From 76ac752ad4dfda8e1815e775765d6d6977ab4e14 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 18:50:42 +0200 Subject: [PATCH 001/174] refactor(stack): remove legacy stack runtimes --- .oxlintrc.json | 2 - AGENTS.md | 5 +- CONTRIBUTING.md | 4 +- apps/cli/AGENTS.md | 2 +- apps/cli/package.json | 1 - .../deploy/deploy.integration.test.ts | 2 +- .../download/download.integration.test.ts | 2 +- .../gen/types/types.integration.test.ts | 2 +- .../download/download.integration.test.ts | 2 +- apps/cli/src/next/main.ts | 12 +- .../functions/functions-docker.unit.test.ts | 2 +- .../error-actionability-coverage.unit.test.ts | 7 +- .../shared/telemetry/error-actionability.ts | 12 +- .../tests/helpers/child-process-spawner.ts | 0 apps/cli/tests/helpers/running-stack.ts | 3 +- ...26-08-28-stack-runtime-rewrite-progress.md | 111 + .../plans/2026-08-28-stack-runtime-rewrite.md | 728 +++++ ...2026-08-28-stack-runtime-rewrite-design.md | 959 +++++++ knip.json | 6 - package.json | 4 +- packages/process-compose/AGENTS.md | 23 - packages/process-compose/CLAUDE.md | 1 - packages/process-compose/README.md | 47 - packages/process-compose/docs/architecture.md | 314 -- packages/process-compose/package.json | 29 - .../process-compose/src/DependencyGraph.ts | 153 - .../src/DependencyGraph.unit.test.ts | 220 -- packages/process-compose/src/HealthProbe.ts | 112 - .../src/HealthProbe.unit.test.ts | 491 ---- packages/process-compose/src/LogBuffer.ts | 105 - .../src/LogBuffer.unit.test.ts | 178 -- .../src/Orchestrator.integration.test.ts | 565 ---- packages/process-compose/src/Orchestrator.ts | 982 ------- .../src/Orchestrator.unit.test.ts | 2550 ----------------- .../process-compose/src/RestartClosure.ts | 37 - .../src/RestartClosure.unit.test.ts | 72 - .../process-compose/src/RestartDecision.ts | 53 - .../src/RestartDecision.unit.test.ts | 72 - packages/process-compose/src/ServiceDef.ts | 121 - packages/process-compose/src/ServiceState.ts | 38 - .../src/ServiceState.unit.test.ts | 35 - .../process-compose/src/ServiceTransition.ts | 186 -- .../src/ServiceTransition.unit.test.ts | 508 ---- packages/process-compose/src/Supervisor.ts | 46 - .../src/SupervisorRuntime.unit.test.ts | 431 --- packages/process-compose/src/errors.ts | 25 - .../process-compose/src/errors.unit.test.ts | 37 - packages/process-compose/src/index.ts | 47 - .../src/supervisor-protocol.ts | 56 - .../process-compose/src/supervisor-runtime.ts | 428 --- packages/process-compose/tsconfig.json | 7 - packages/process-compose/vitest.config.ts | 29 - packages/stack/README.md | 93 +- packages/stack/docs/architecture.md | 426 --- packages/stack/docs/effect-platform-gaps.md | 154 - .../stack/docs/resource-leak-mitigations.md | 111 - packages/stack/docs/service-versioning.md | 181 -- packages/stack/package.json | 33 +- packages/stack/scripts/migrate-fast.sh | 90 - .../scripts/sync-versions-from-dockerfile.ts | 131 - packages/stack/src/ApiProxy.ts | 429 --- packages/stack/src/ApiProxy.unit.test.ts | 563 ---- .../src/BinaryResolver.integration.test.ts | 844 ------ packages/stack/src/BinaryResolver.ts | 914 ------ .../stack/src/BinaryResolver.unit.test.ts | 48 - packages/stack/src/CleanupTargets.ts | 3 - .../src/ContainerRuntime.integration.test.ts | 106 - packages/stack/src/ContainerRuntime.ts | 94 - packages/stack/src/ControlHttpReader.ts | 198 -- packages/stack/src/ControlStopClient.ts | 76 - packages/stack/src/DaemonProtocol.ts | 77 - .../HttpTransportClient.integration.test.ts | 142 - packages/stack/src/HttpTransportClient.ts | 150 - packages/stack/src/JwtGenerator.ts | 40 - packages/stack/src/JwtGenerator.unit.test.ts | 48 - packages/stack/src/LocalStack.ts | 1181 -------- packages/stack/src/Platform.ts | 50 - packages/stack/src/Platform.unit.test.ts | 76 - .../src/PortAllocator.integration.test.ts | 250 -- packages/stack/src/PortAllocator.ts | 760 ----- packages/stack/src/PortCatalog.ts | 229 -- .../RemoteStack.rpc.bun.integration.test.ts | 88 - .../src/RemoteStack.rpc.integration.test.ts | 1349 --------- packages/stack/src/RemoteStack.ts | 408 --- packages/stack/src/ServiceActivation.ts | 105 - .../stack/src/ServiceActivation.unit.test.ts | 72 - packages/stack/src/ServiceCatalog.ts | 345 --- packages/stack/src/ServiceExclusions.ts | 34 - packages/stack/src/ServiceName.ts | 15 - packages/stack/src/ServicePorts.ts | 36 - packages/stack/src/Stack.ts | 239 -- packages/stack/src/Stack.unit.test.ts | 1987 ------------- packages/stack/src/StackBuilder.ts | 684 ----- packages/stack/src/StackBuilder.unit.test.ts | 564 ---- packages/stack/src/StackConfig.ts | 345 --- packages/stack/src/StackConfig.unit.test.ts | 95 - .../StackConfigResolver.policy.unit.test.ts | 127 - packages/stack/src/StackConfigResolver.ts | 822 ------ packages/stack/src/StackIdentity.ts | 42 - packages/stack/src/StackIdentity.unit.test.ts | 12 - packages/stack/src/StackPreparation.ts | 396 --- .../stack/src/StackRpc.integration.test.ts | 32 - packages/stack/src/StackRpc.ts | 268 -- .../src/StackRpcHandlers.integration.test.ts | 396 --- packages/stack/src/StackRpcHandlers.ts | 138 - packages/stack/src/StackServiceState.ts | 41 - .../stack/src/StackServiceState.unit.test.ts | 27 - packages/stack/src/StackStateProjection.ts | 85 - .../src/StackStateProjection.unit.test.ts | 86 - ...upervisorControlServer.integration.test.ts | 41 - packages/stack/src/SupervisorControlServer.ts | 90 - packages/stack/src/SupervisorProtocol.ts | 63 - .../src/SupervisorSession.integration.test.ts | 315 -- packages/stack/src/SupervisorSession.ts | 330 --- ...pervisorUpgradeRestart.integration.test.ts | 447 --- .../stack/src/SupervisorUpgradeRestart.ts | 427 --- packages/stack/src/bun.ts | 62 - packages/stack/src/cleanup.ts | 108 - packages/stack/src/cleanup.unit.test.ts | 72 - .../compiled-supervisor.integration.test.ts | 397 --- .../stack/src/createStack.integration.test.ts | 264 -- packages/stack/src/createStack.ts | 292 -- packages/stack/src/createStack.unit.test.ts | 398 --- packages/stack/src/daemon-bun.ts | 27 - packages/stack/src/daemon-node.ts | 35 - packages/stack/src/discovery.ts | 228 -- packages/stack/src/effect-bun.ts | 93 - .../src/effect-delete.integration.test.ts | 56 - packages/stack/src/effect-node.ts | 93 - packages/stack/src/effect.ts | 168 +- packages/stack/src/error-code.ts | 16 - packages/stack/src/error-code.unit.test.ts | 20 - packages/stack/src/errors.ts | 206 -- packages/stack/src/functions.ts | 253 -- packages/stack/src/functions.unit.test.ts | 355 --- packages/stack/src/index.ts | 38 +- packages/stack/src/layers.ts | 287 -- packages/stack/src/managed-bun.ts | 29 - .../src/managed-control.integration.test.ts | 1042 ------- .../managed-environment.integration.test.ts | 181 -- ...aged-manager-lifecycle.integration.test.ts | 797 ------ .../managed-manager-ports.integration.test.ts | 514 ---- ...naged-manager-projects.integration.test.ts | 279 -- ...naged-manager-recovery.integration.test.ts | 427 --- ...aged-manager-worktrees.integration.test.ts | 68 - packages/stack/src/managed-node.ts | 37 - packages/stack/src/managed-paths.unit.test.ts | 204 -- .../src/managed-store.integration.test.ts | 271 -- packages/stack/src/managed.ts | 81 - .../managed/atomic-claim.integration.test.ts | 126 - packages/stack/src/managed/atomic-claim.ts | 144 - packages/stack/src/managed/control.ts | 805 ------ packages/stack/src/managed/document.ts | 163 -- packages/stack/src/managed/environment.ts | 325 --- packages/stack/src/managed/failure.ts | 32 - packages/stack/src/managed/git-identity.ts | 45 - .../stack/src/managed/git.integration.test.ts | 240 -- packages/stack/src/managed/git.ts | 1058 ------- packages/stack/src/managed/identity.ts | 402 --- packages/stack/src/managed/ids.ts | 19 - packages/stack/src/managed/lifecycle.ts | 314 -- packages/stack/src/managed/manager.ts | 1050 ------- .../stack/src/managed/manager.unit.test.ts | 48 - packages/stack/src/managed/model.ts | 232 -- packages/stack/src/managed/paths.ts | 194 -- packages/stack/src/managed/port-intent.ts | 50 - .../src/managed/port-intent.unit.test.ts | 30 - packages/stack/src/managed/port-plan.ts | 139 - .../stack/src/managed/port-plan.unit.test.ts | 116 - packages/stack/src/managed/store.ts | 236 -- .../src/node-entrypoint.integration.test.ts | 17 - packages/stack/src/node.ts | 69 - packages/stack/src/paths.ts | 11 - .../src/platform-bun.integration.test.ts | 377 --- packages/stack/src/platform-bun.ts | 247 -- .../src/platform-node.integration.test.ts | 318 -- packages/stack/src/platform-node.ts | 195 -- packages/stack/src/prefetch.ts | 38 - packages/stack/src/prefetch.unit.test.ts | 516 ---- packages/stack/src/public/index.ts | 1 + packages/stack/src/services/analytics.ts | 87 - packages/stack/src/services/auth.ts | 98 - packages/stack/src/services/docker-cleanup.ts | 40 - .../stack/src/services/edge-runtime-main.ts | 259 -- packages/stack/src/services/edge-runtime.ts | 127 - packages/stack/src/services/health-budgets.ts | 137 - .../src/services/health-budgets.unit.test.ts | 139 - packages/stack/src/services/imgproxy.ts | 49 - packages/stack/src/services/mailpit.ts | 42 - packages/stack/src/services/nofile-limit.ts | 59 - .../src/services/nofile-limit.unit.test.ts | 74 - packages/stack/src/services/pgmeta.ts | 49 - packages/stack/src/services/pooler.ts | 120 - packages/stack/src/services/postgres-init.ts | 235 -- packages/stack/src/services/postgres.ts | 185 -- packages/stack/src/services/postgrest.ts | 82 - packages/stack/src/services/realtime.ts | 74 - packages/stack/src/services/service-utils.ts | 120 - .../stack/src/services/services.unit.test.ts | 664 ----- packages/stack/src/services/storage.ts | 88 - packages/stack/src/services/studio.ts | 76 - packages/stack/src/services/vector.ts | 131 - .../stack/src/services/vector.unit.test.ts | 122 - packages/stack/src/stackHandle.ts | 55 - .../stack/src/supervisor.integration.test.ts | 2190 -------------- packages/stack/src/supervisor.ts | 1069 ------- packages/stack/src/terminateChild.ts | 67 - .../stack/src/terminateChild.unit.test.ts | 113 - packages/stack/src/testing.ts | 55 +- packages/stack/src/version-plan.ts | 96 - packages/stack/src/version-plan.unit.test.ts | 93 - packages/stack/src/versions.ts | 113 - packages/stack/src/versions.unit.test.ts | 197 -- .../tests/createStack-docker.e2e.test.ts | 257 -- .../tests/createStack-native.e2e.test.ts | 106 - packages/stack/tests/createStack.e2e.test.ts | 167 -- packages/stack/tests/global-setup.ts | 7 - .../tests/helpers/SupervisorSessionFixture.ts | 59 - .../helpers/compiled-supervisor-parent.ts | 76 - packages/stack/tests/helpers/e2e.ts | 84 - packages/stack/tests/helpers/file-watch.ts | 39 - packages/stack/tests/helpers/git-workspace.ts | 85 - .../stack/tests/helpers/managed-manager.ts | 204 -- packages/stack/tests/helpers/mocks.ts | 72 - .../stack/tests/helpers/port-lease-child.ts | 18 - packages/stack/tests/helpers/stack-ports.ts | 69 - .../stack/tests/helpers/supervisor-child.ts | 371 --- .../tests/helpers/supervisor-error-child.ts | 6 - .../helpers/supervisor-non-ready-child.ts | 33 - packages/stack/tests/helpers/warmup.ts | 49 - .../stack/tests/helpers/warmup.unit.test.ts | 115 - packages/stack/tests/warmup-e2e.ts | 3 - packages/stack/vitest.config.ts | 17 - pnpm-lock.yaml | 116 - turbo.json | 1 - 235 files changed, 1830 insertions(+), 50096 deletions(-) rename packages/process-compose/tests/helpers/mocks.ts => apps/cli/tests/helpers/child-process-spawner.ts (100%) create mode 100644 docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md create mode 100644 docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md create mode 100644 docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md delete mode 100644 packages/process-compose/AGENTS.md delete mode 120000 packages/process-compose/CLAUDE.md delete mode 100644 packages/process-compose/README.md delete mode 100644 packages/process-compose/docs/architecture.md delete mode 100644 packages/process-compose/package.json delete mode 100644 packages/process-compose/src/DependencyGraph.ts delete mode 100644 packages/process-compose/src/DependencyGraph.unit.test.ts delete mode 100644 packages/process-compose/src/HealthProbe.ts delete mode 100644 packages/process-compose/src/HealthProbe.unit.test.ts delete mode 100644 packages/process-compose/src/LogBuffer.ts delete mode 100644 packages/process-compose/src/LogBuffer.unit.test.ts delete mode 100644 packages/process-compose/src/Orchestrator.integration.test.ts delete mode 100644 packages/process-compose/src/Orchestrator.ts delete mode 100644 packages/process-compose/src/Orchestrator.unit.test.ts delete mode 100644 packages/process-compose/src/RestartClosure.ts delete mode 100644 packages/process-compose/src/RestartClosure.unit.test.ts delete mode 100644 packages/process-compose/src/RestartDecision.ts delete mode 100644 packages/process-compose/src/RestartDecision.unit.test.ts delete mode 100644 packages/process-compose/src/ServiceDef.ts delete mode 100644 packages/process-compose/src/ServiceState.ts delete mode 100644 packages/process-compose/src/ServiceState.unit.test.ts delete mode 100644 packages/process-compose/src/ServiceTransition.ts delete mode 100644 packages/process-compose/src/ServiceTransition.unit.test.ts delete mode 100644 packages/process-compose/src/Supervisor.ts delete mode 100644 packages/process-compose/src/SupervisorRuntime.unit.test.ts delete mode 100644 packages/process-compose/src/errors.ts delete mode 100644 packages/process-compose/src/errors.unit.test.ts delete mode 100644 packages/process-compose/src/index.ts delete mode 100644 packages/process-compose/src/supervisor-protocol.ts delete mode 100644 packages/process-compose/src/supervisor-runtime.ts delete mode 100644 packages/process-compose/tsconfig.json delete mode 100644 packages/process-compose/vitest.config.ts delete mode 100644 packages/stack/docs/architecture.md delete mode 100644 packages/stack/docs/effect-platform-gaps.md delete mode 100644 packages/stack/docs/resource-leak-mitigations.md delete mode 100644 packages/stack/docs/service-versioning.md delete mode 100755 packages/stack/scripts/migrate-fast.sh delete mode 100644 packages/stack/scripts/sync-versions-from-dockerfile.ts delete mode 100644 packages/stack/src/ApiProxy.ts delete mode 100644 packages/stack/src/ApiProxy.unit.test.ts delete mode 100644 packages/stack/src/BinaryResolver.integration.test.ts delete mode 100644 packages/stack/src/BinaryResolver.ts delete mode 100644 packages/stack/src/BinaryResolver.unit.test.ts delete mode 100644 packages/stack/src/CleanupTargets.ts delete mode 100644 packages/stack/src/ContainerRuntime.integration.test.ts delete mode 100644 packages/stack/src/ContainerRuntime.ts delete mode 100644 packages/stack/src/ControlHttpReader.ts delete mode 100644 packages/stack/src/ControlStopClient.ts delete mode 100644 packages/stack/src/DaemonProtocol.ts delete mode 100644 packages/stack/src/HttpTransportClient.integration.test.ts delete mode 100644 packages/stack/src/HttpTransportClient.ts delete mode 100644 packages/stack/src/JwtGenerator.ts delete mode 100644 packages/stack/src/JwtGenerator.unit.test.ts delete mode 100644 packages/stack/src/LocalStack.ts delete mode 100644 packages/stack/src/Platform.ts delete mode 100644 packages/stack/src/Platform.unit.test.ts delete mode 100644 packages/stack/src/PortAllocator.integration.test.ts delete mode 100644 packages/stack/src/PortAllocator.ts delete mode 100644 packages/stack/src/PortCatalog.ts delete mode 100644 packages/stack/src/RemoteStack.rpc.bun.integration.test.ts delete mode 100644 packages/stack/src/RemoteStack.rpc.integration.test.ts delete mode 100644 packages/stack/src/RemoteStack.ts delete mode 100644 packages/stack/src/ServiceActivation.ts delete mode 100644 packages/stack/src/ServiceActivation.unit.test.ts delete mode 100644 packages/stack/src/ServiceCatalog.ts delete mode 100644 packages/stack/src/ServiceExclusions.ts delete mode 100644 packages/stack/src/ServiceName.ts delete mode 100644 packages/stack/src/ServicePorts.ts delete mode 100644 packages/stack/src/Stack.ts delete mode 100644 packages/stack/src/Stack.unit.test.ts delete mode 100644 packages/stack/src/StackBuilder.ts delete mode 100644 packages/stack/src/StackBuilder.unit.test.ts delete mode 100644 packages/stack/src/StackConfig.ts delete mode 100644 packages/stack/src/StackConfig.unit.test.ts delete mode 100644 packages/stack/src/StackConfigResolver.policy.unit.test.ts delete mode 100644 packages/stack/src/StackConfigResolver.ts delete mode 100644 packages/stack/src/StackIdentity.ts delete mode 100644 packages/stack/src/StackIdentity.unit.test.ts delete mode 100644 packages/stack/src/StackPreparation.ts delete mode 100644 packages/stack/src/StackRpc.integration.test.ts delete mode 100644 packages/stack/src/StackRpc.ts delete mode 100644 packages/stack/src/StackRpcHandlers.integration.test.ts delete mode 100644 packages/stack/src/StackRpcHandlers.ts delete mode 100644 packages/stack/src/StackServiceState.ts delete mode 100644 packages/stack/src/StackServiceState.unit.test.ts delete mode 100644 packages/stack/src/StackStateProjection.ts delete mode 100644 packages/stack/src/StackStateProjection.unit.test.ts delete mode 100644 packages/stack/src/SupervisorControlServer.integration.test.ts delete mode 100644 packages/stack/src/SupervisorControlServer.ts delete mode 100644 packages/stack/src/SupervisorProtocol.ts delete mode 100644 packages/stack/src/SupervisorSession.integration.test.ts delete mode 100644 packages/stack/src/SupervisorSession.ts delete mode 100644 packages/stack/src/SupervisorUpgradeRestart.integration.test.ts delete mode 100644 packages/stack/src/SupervisorUpgradeRestart.ts delete mode 100644 packages/stack/src/bun.ts delete mode 100644 packages/stack/src/cleanup.ts delete mode 100644 packages/stack/src/cleanup.unit.test.ts delete mode 100644 packages/stack/src/compiled-supervisor.integration.test.ts delete mode 100644 packages/stack/src/createStack.integration.test.ts delete mode 100644 packages/stack/src/createStack.ts delete mode 100644 packages/stack/src/createStack.unit.test.ts delete mode 100644 packages/stack/src/daemon-bun.ts delete mode 100644 packages/stack/src/daemon-node.ts delete mode 100644 packages/stack/src/discovery.ts delete mode 100644 packages/stack/src/effect-bun.ts delete mode 100644 packages/stack/src/effect-delete.integration.test.ts delete mode 100644 packages/stack/src/effect-node.ts delete mode 100644 packages/stack/src/error-code.ts delete mode 100644 packages/stack/src/error-code.unit.test.ts delete mode 100644 packages/stack/src/errors.ts delete mode 100644 packages/stack/src/functions.ts delete mode 100644 packages/stack/src/functions.unit.test.ts delete mode 100644 packages/stack/src/layers.ts delete mode 100644 packages/stack/src/managed-bun.ts delete mode 100644 packages/stack/src/managed-control.integration.test.ts delete mode 100644 packages/stack/src/managed-environment.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-lifecycle.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-ports.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-projects.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-recovery.integration.test.ts delete mode 100644 packages/stack/src/managed-manager-worktrees.integration.test.ts delete mode 100644 packages/stack/src/managed-node.ts delete mode 100644 packages/stack/src/managed-paths.unit.test.ts delete mode 100644 packages/stack/src/managed-store.integration.test.ts delete mode 100644 packages/stack/src/managed.ts delete mode 100644 packages/stack/src/managed/atomic-claim.integration.test.ts delete mode 100644 packages/stack/src/managed/atomic-claim.ts delete mode 100644 packages/stack/src/managed/control.ts delete mode 100644 packages/stack/src/managed/document.ts delete mode 100644 packages/stack/src/managed/environment.ts delete mode 100644 packages/stack/src/managed/failure.ts delete mode 100644 packages/stack/src/managed/git-identity.ts delete mode 100644 packages/stack/src/managed/git.integration.test.ts delete mode 100644 packages/stack/src/managed/git.ts delete mode 100644 packages/stack/src/managed/identity.ts delete mode 100644 packages/stack/src/managed/ids.ts delete mode 100644 packages/stack/src/managed/lifecycle.ts delete mode 100644 packages/stack/src/managed/manager.ts delete mode 100644 packages/stack/src/managed/manager.unit.test.ts delete mode 100644 packages/stack/src/managed/model.ts delete mode 100644 packages/stack/src/managed/paths.ts delete mode 100644 packages/stack/src/managed/port-intent.ts delete mode 100644 packages/stack/src/managed/port-intent.unit.test.ts delete mode 100644 packages/stack/src/managed/port-plan.ts delete mode 100644 packages/stack/src/managed/port-plan.unit.test.ts delete mode 100644 packages/stack/src/managed/store.ts delete mode 100644 packages/stack/src/node-entrypoint.integration.test.ts delete mode 100644 packages/stack/src/node.ts delete mode 100644 packages/stack/src/paths.ts delete mode 100644 packages/stack/src/platform-bun.integration.test.ts delete mode 100644 packages/stack/src/platform-bun.ts delete mode 100644 packages/stack/src/platform-node.integration.test.ts delete mode 100644 packages/stack/src/platform-node.ts delete mode 100644 packages/stack/src/prefetch.ts delete mode 100644 packages/stack/src/prefetch.unit.test.ts create mode 100644 packages/stack/src/public/index.ts delete mode 100644 packages/stack/src/services/analytics.ts delete mode 100644 packages/stack/src/services/auth.ts delete mode 100644 packages/stack/src/services/docker-cleanup.ts delete mode 100644 packages/stack/src/services/edge-runtime-main.ts delete mode 100644 packages/stack/src/services/edge-runtime.ts delete mode 100644 packages/stack/src/services/health-budgets.ts delete mode 100644 packages/stack/src/services/health-budgets.unit.test.ts delete mode 100644 packages/stack/src/services/imgproxy.ts delete mode 100644 packages/stack/src/services/mailpit.ts delete mode 100644 packages/stack/src/services/nofile-limit.ts delete mode 100644 packages/stack/src/services/nofile-limit.unit.test.ts delete mode 100644 packages/stack/src/services/pgmeta.ts delete mode 100644 packages/stack/src/services/pooler.ts delete mode 100644 packages/stack/src/services/postgres-init.ts delete mode 100644 packages/stack/src/services/postgres.ts delete mode 100644 packages/stack/src/services/postgrest.ts delete mode 100644 packages/stack/src/services/realtime.ts delete mode 100644 packages/stack/src/services/service-utils.ts delete mode 100644 packages/stack/src/services/services.unit.test.ts delete mode 100644 packages/stack/src/services/storage.ts delete mode 100644 packages/stack/src/services/studio.ts delete mode 100644 packages/stack/src/services/vector.ts delete mode 100644 packages/stack/src/services/vector.unit.test.ts delete mode 100644 packages/stack/src/stackHandle.ts delete mode 100644 packages/stack/src/supervisor.integration.test.ts delete mode 100644 packages/stack/src/supervisor.ts delete mode 100644 packages/stack/src/terminateChild.ts delete mode 100644 packages/stack/src/terminateChild.unit.test.ts delete mode 100644 packages/stack/src/version-plan.ts delete mode 100644 packages/stack/src/version-plan.unit.test.ts delete mode 100644 packages/stack/src/versions.ts delete mode 100644 packages/stack/src/versions.unit.test.ts delete mode 100644 packages/stack/tests/createStack-docker.e2e.test.ts delete mode 100644 packages/stack/tests/createStack-native.e2e.test.ts delete mode 100644 packages/stack/tests/createStack.e2e.test.ts delete mode 100644 packages/stack/tests/global-setup.ts delete mode 100644 packages/stack/tests/helpers/SupervisorSessionFixture.ts delete mode 100644 packages/stack/tests/helpers/compiled-supervisor-parent.ts delete mode 100644 packages/stack/tests/helpers/e2e.ts delete mode 100644 packages/stack/tests/helpers/file-watch.ts delete mode 100644 packages/stack/tests/helpers/git-workspace.ts delete mode 100644 packages/stack/tests/helpers/managed-manager.ts delete mode 100644 packages/stack/tests/helpers/mocks.ts delete mode 100644 packages/stack/tests/helpers/port-lease-child.ts delete mode 100644 packages/stack/tests/helpers/stack-ports.ts delete mode 100644 packages/stack/tests/helpers/supervisor-child.ts delete mode 100644 packages/stack/tests/helpers/supervisor-error-child.ts delete mode 100644 packages/stack/tests/helpers/supervisor-non-ready-child.ts delete mode 100644 packages/stack/tests/helpers/warmup.ts delete mode 100644 packages/stack/tests/helpers/warmup.unit.test.ts delete mode 100644 packages/stack/tests/warmup-e2e.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 1c65f5b241..f7cabe9f44 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -8,8 +8,6 @@ ".repos", "apps/cli-go", "apps/cli-e2e/fixtures", - "packages/stack", - "packages/process-compose", "**/testdata", "**/dist", "**/coverage", diff --git a/AGENTS.md b/AGENTS.md index afc16c3a2a..fc5347cfc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,13 +12,12 @@ Bun monorepo with workspaces under `apps/` and `packages/`. - `apps/docs` — internal Next.js docs site - `packages/api` — typed Supabase Management API client - `packages/config` — config schema and generated types -- `packages/process-compose` — process orchestration library - `packages/stack` — programmatic local Supabase stack runtime - `packages/cli-*` — platform-specific published CLI binary wrappers ## Package Structure -Use `packages/process-compose` as the reference for internal TypeScript/Bun workspaces such as `apps/cli`, `packages/api`, `packages/config`, `packages/process-compose`, and `packages/stack`. +Use the existing internal TypeScript/Bun workspaces as references for package structure and scripts. These workspaces should generally follow this structure: @@ -29,7 +28,7 @@ These workspaces should generally follow this structure: - Standard scripts: `test`, `types:check` - Standard devDependencies: `@tsconfig/bun`, `@types/bun`, `typescript` -Generic linting (`oxlint`), formatting (`oxfmt`), and unused-code analysis (`knip`) are repo-wide, not per-package: the tools are root devDependencies configured by `.oxlintrc.json`, `.oxfmtrc.json`, and `knip.json` at the repo root (knip's config maps each workspace under its `workspaces` key). Effect-specific linting is incrementally scoped to `packages/stack` and `packages/process-compose` through `.oxlintrc.effect.json`; run it with the root `lint:effect:check` or `lint:effect:fix` scripts. The root `check:all`/`fix:all` scripts are the sole repo-wide quality entrypoints and use Turbo to orchestrate the root-owned generic `lint:*`/`fmt:*`/`knip:*` scripts and package `types:check` targets; `fix:all` runs the Effect lint fix after those generic fixes complete. Package-local work can run `pnpm types:check` and the package's test scripts; `pnpm exec oxlint`, `pnpm exec oxfmt`, and `pnpm exec knip-bun` from the repo root also work directly. +Generic linting (`oxlint`), formatting (`oxfmt`), and unused-code analysis (`knip`) are repo-wide, not per-package: the tools are root devDependencies configured by `.oxlintrc.json`, `.oxfmtrc.json`, and `knip.json` at the repo root (knip's config maps each workspace under its `workspaces` key). Effect-specific linting is scoped to `packages/stack` through `.oxlintrc.effect.json`; run it with the root `lint:effect:check` or `lint:effect:fix` scripts. The root `check:all`/`fix:all` scripts are the sole repo-wide quality entrypoints and use Turbo to orchestrate the root-owned generic `lint:*`/`fmt:*`/`knip:*` scripts and package `types:check` targets; `fix:all` runs the Effect lint fix after those generic fixes complete. Package-local work can run `pnpm types:check` and the package's test scripts; `pnpm exec oxlint`, `pnpm exec oxfmt`, and `pnpm exec knip-bun` from the repo root also work directly. Expected exceptions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61a8459a12..7f8a59a1e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,7 +94,6 @@ That pulls `.repos/effect/`, which is the local source of truth for Effect v4 AP |-- packages/ | |-- api/ # Typed Supabase Management API client | |-- config/ # Supabase config schema and generated types -| |-- process-compose/ # Effect-based process orchestration library | |-- stack/ # Programmatic local Supabase stack runtime | `-- cli-*/ # Platform-specific CLI binary packages |-- tools/ # Repository tooling (release scripts, etc.) @@ -117,7 +116,6 @@ That pulls `.repos/effect/`, which is the local source of truth for Effect v4 AP | `packages/api` | Auto-generated TypeScript client for the Supabase Management API. | | `packages/cli-test-helpers` | CLI test harness library — `createHarness`/`exec` API for spawning TS Legacy and TS Next CLI subprocesses in tests. | | `packages/config` | JSON Schema and generated TypeScript types for Supabase configuration. | -| `packages/process-compose` | TypeScript/Bun port of `process-compose` used for multi-service orchestration. | | `packages/stack` | Programmatic local Supabase stack used by the CLI and other tooling. | | `packages/cli-darwin-arm64` | Published native CLI binary wrapper for macOS arm64. | | `packages/cli-darwin-x64` | Published native CLI binary wrapper for macOS x64. | @@ -142,7 +140,7 @@ pnpm run fix:all # run all fixers across every project ### Standard package scripts -Standard TypeScript workspaces (`apps/cli-e2e`, `apps/cli`, `packages/api`, `packages/cli-test-helpers`, `packages/config`, `packages/process-compose`, `packages/stack`) declare their package scripts explicitly. Test suites vary by package: unit tests are standard, while integration and e2e tests exist only where applicable. +Standard TypeScript workspaces (`apps/cli-e2e`, `apps/cli`, `packages/api`, `packages/cli-test-helpers`, `packages/config`, `packages/stack`) declare their package scripts explicitly. Test suites vary by package: unit tests are standard, while integration and e2e tests exist only where applicable. | Script | What it does | | ------------------ | -------------------------------------- | diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 797be14ebd..b0cbcd3766 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -390,7 +390,7 @@ export class LegacyThingMissingError extends Data.TaggedError("LegacyThingMissin - **An instance-dependent getter must stay valid when its fields are absent** — the drift guard evaluates it against a field-less probe. - **A plain `Error` subclass (no `_tag`) also declares its fingerprint identifier**: `static readonly [ErrorActionabilityFingerprintId] = ""`, matching the export name exactly. Tagged errors skip this — their fingerprint comes from the tag. The static identifier is what keeps `error:` fingerprints stable in minified release builds, where `constructor.name` is renamed. -**Errors defined outside `apps/cli/src`** (`@supabase/stack`, `@supabase/config`, `@supabase/process-compose`, `@supabase/api`, `effect`) cannot carry a declaration. Add a structural adapter keyed by `_tag` to `externalActionabilityByTag` in that same module, branching on the producer's typed fields. +**Errors defined outside `apps/cli/src`** (`@supabase/stack`, `@supabase/config`, `@supabase/api`, `effect`) cannot carry a declaration. Add a structural adapter keyed by `_tag` to `externalActionabilityByTag` in that same module, branching on the producer's typed fields. `error-actionability-coverage.unit.test.ts` enforces this. It scans every `TaggedError("Tag")`, every `*Error("Tag")` factory, and every `class X extends Error` under `apps/cli/src`, and fails when a class is unexported, has no own declaration, or is untagged without its matching static fingerprint identifier. A failure there is the guard working: classify the new error rather than loosening the guard, because `unknown` in production telemetry must mean a genuinely unforeseen failure, not one nobody categorized. diff --git a/apps/cli/package.json b/apps/cli/package.json index faa2374194..e3961090e0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -70,7 +70,6 @@ "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.46", "@supabase/pg-topo": "1.0.0-alpha.5", - "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 1b67d04914..1a9a255f9d 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -15,7 +15,7 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; -import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; import { deployFunctions, shouldChmodBundleOutputDirectory, diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 04fa4c2b0f..e3b8fc0997 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -20,7 +20,7 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; -import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { legacyContainerRuntimeNotFoundMessage } from "../../../shared/legacy-container-cli.ts"; import { downloadFunctions } from "../../../../shared/functions/download.ts"; diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index ba5355ed6b..2f5dff947a 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -41,7 +41,7 @@ import { mockLegacyPlatformApiService, mockLegacyTelemetryStateTracked, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; diff --git a/apps/cli/src/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts index 69436f6e91..50c3c2f312 100644 --- a/apps/cli/src/next/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -24,7 +24,7 @@ import { mockProjectLinkState, mockRuntimeInfo, } from "../../../../../tests/helpers/mocks.ts"; -import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; +import { mockChildProcessSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; import type { FunctionsDownloadFlags } from "./download.command.ts"; import { ConflictingFunctionDownloadFlagsError, diff --git a/apps/cli/src/next/main.ts b/apps/cli/src/next/main.ts index ac1caf46da..c4a16fcfe9 100644 --- a/apps/cli/src/next/main.ts +++ b/apps/cli/src/next/main.ts @@ -1,15 +1,5 @@ #!/usr/bin/env bun -import { - enableSupervisorSelfDispatchForCompiledBun, - isSupervisorRuntimeRequested, - runSupervisorRuntimeFromEnv, -} from "@supabase/process-compose"; - -enableSupervisorSelfDispatchForCompiledBun(import.meta.url); - -if (isSupervisorRuntimeRequested()) { - runSupervisorRuntimeFromEnv(); -} else if (process.env.SUPABASE_STACK_RUN_DAEMON === "1") { +if (process.env.SUPABASE_STACK_RUN_DAEMON === "1") { const { runBunDaemon } = await import("@supabase/stack/daemon-bun"); runBunDaemon(); } else { diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 2270a795ad..835c10a4da 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -16,7 +16,7 @@ import { /** * A `ChildProcessSpawner` layer whose handle emits exactly the given raw * `Uint8Array` chunks on stdout/stderr — unlike the shared - * `mockChildProcessSpawner` (`packages/process-compose/tests/helpers/mocks.ts`), + * `mockChildProcessSpawner` (`apps/cli/tests/helpers/child-process-spawner.ts`), * which encodes one full line per chunk, this lets a test place an arbitrary * byte boundary mid-codepoint to exercise `collectByteStream`'s per-stream * `TextDecoder` buffering. diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 36cfc67bbd..8a206ed43b 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -365,12 +365,7 @@ describe("apps/cli error classes declare their actionability", () => { }); describe("workspace package error tags have external adapters", () => { - const packageRoots = [ - "packages/api/src", - "packages/stack/src", - "packages/config/src", - "packages/process-compose/src", - ]; + const packageRoots = ["packages/api/src", "packages/stack/src", "packages/config/src"]; for (const packageRoot of packageRoots) { it(packageRoot, { timeout: 30_000 }, async () => { diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index dbd6bec020..8b222977b4 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -15,7 +15,7 @@ import type { CliError as EffectCliError } from "effect/unstable/cli"; * {@link ErrorActionabilityId} symbol (enforced by * `error-actionability-coverage.unit.test.ts`). Errors originating outside the * CLI workspace (`@supabase/stack`, `@supabase/config`, - * `@supabase/process-compose`, `effect` cli/http) are classified by the + * `effect` cli/http) are classified by the * structural adapters at the bottom of this module, which are themselves * exhaustiveness-checked against those packages' sources. * @@ -1149,16 +1149,6 @@ const externalActionabilityByTag: Record = { } return { ...actionability.stopStack, fingerprint_suffix: "daemon_transport" }; }, - - // @supabase/process-compose — the CLI generates the process graph, so graph - // invariants are internal bugs; runtime service failures are stack-state - // problems the user resolves by restarting the stack. - CyclicDependencyError: () => actionability.impossibleState, - MissingDependencyError: () => actionability.impossibleState, - ServiceNotFoundError: () => actionability.impossibleState, - SpawnError: () => actionability.startStack, - ShutdownTimeoutError: () => actionability.stopStack, - ServiceReadyError: () => actionability.startStack, }; /** diff --git a/packages/process-compose/tests/helpers/mocks.ts b/apps/cli/tests/helpers/child-process-spawner.ts similarity index 100% rename from packages/process-compose/tests/helpers/mocks.ts rename to apps/cli/tests/helpers/child-process-spawner.ts diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index a7dd2ac4aa..23e457f0ae 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -31,7 +31,6 @@ import { import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ServiceNotFoundError } from "@supabase/process-compose"; import { CliSettings } from "../../src/next/config/cli-settings.service.ts"; import { CliProjectHome } from "../../src/next/config/cli-project-home.service.ts"; import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; @@ -77,7 +76,7 @@ const stackService = (info: StackInfo, onStop: Effect.Effect): Stack["Serv getState: (name: string) => { const state = stackStates.find((candidate) => candidate.name === name); return state === undefined - ? Effect.fail(new ServiceNotFoundError({ name })) + ? Effect.fail(new Error(`service not found: ${name}`)) : Effect.succeed(state); }, getAllStates: Effect.succeed(stackStates), diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md new file mode 100644 index 0000000000..36ef17c22c --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -0,0 +1,111 @@ +# Stack Runtime Rewrite Progress + +**Branch:** `feat/stack-runtime-rewrite` +**Base:** `de26a30c7` (`origin/develop` on 2026-08-28) +**Design:** `docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md` +**Plan:** `docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md` + +This tracked ledger records implementation state and rulings that may deserve user review. It is +updated in the same commit as each completed slice. Scratch review packages live outside Git under +`.superpowers/sdd/`. + +## Current state + +- [x] Existing Codex-managed worktree verified as an isolated linked worktree. +- [x] Local branch created directly from `origin/develop`. +- [x] Pinned pnpm dependencies installed with an unchanged lockfile. +- [x] Baseline package type checks passed for `@supabase/process-compose` and `@supabase/stack`. +- [x] Baseline targeted tests passed: process-compose dependency graph (18 tests) and stack identity + (1 test). +- [x] Approved architecture imported as the canonical tracked design. +- [x] Layer-by-layer implementation plan completed and reviewed against the design. +- [x] Legacy process-compose package and stack implementation removed. +- [ ] Greenfield rewrite implemented through the public Effect and Promise surfaces. +- [ ] Targeted test suite green. +- [ ] Completion audit, final review, push, and pull request completed. + +## Slice ledger + +### Task 0 — remove legacy runtimes and establish the empty package boundary + +- Baseline commit: `de26a30c7`. +- Active process-compose references at the deletion baseline (from `git grep -n` at the base) were: + `apps/cli/AGENTS.md:393`, `apps/cli/package.json:73`, + `apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts:18`, + `apps/cli/src/legacy/commands/functions/download/download.integration.test.ts:23`, + `apps/cli/src/legacy/commands/gen/types/types.integration.test.ts:44`, + `apps/cli/src/next/commands/functions/download/download.integration.test.ts:27`, + `apps/cli/src/next/main.ts:6`, `apps/cli/src/shared/functions/functions-docker.unit.test.ts:19`, + `apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts:372`, + `apps/cli/src/shared/telemetry/error-actionability.ts:18,1153`, + `apps/cli/tests/helpers/running-stack.ts:34`, `package.json:19-20`, `.oxlintrc.json:12`, + `knip.json:37`, `turbo.json:64`, `packages/stack/package.json:38`, and process-compose imports + in the deleted package docs/manifest plus throughout the legacy + `packages/stack/src/**` implementation (including `LocalStack.ts`, `Stack.ts`, `StackBuilder.ts`, + `StackRpc.ts`, `StackServiceState.ts`, `StackStateProjection.ts`, `createStack.ts`, `effect.ts`, + `index.ts`, `stackHandle.ts`, `RemoteStack.rpc.integration.test.ts`, + `StackRpcHandlers.integration.test.ts`, `Stack.unit.test.ts`, and `services/**`). +- Deleted the `packages/process-compose` workspace and the legacy stack source, tests, scripts, and + stale package docs. Added the empty `@supabase/stack` public, Effect, and testing barrels. +- Moved `mockChildProcessSpawner` to `apps/cli/tests/helpers/child-process-spawner.ts` and updated + all active CLI imports. +- Removed active process-compose telemetry, manifest, bootstrap, lint, docs, and workspace references; + `.repos/process-compose` and historical ADR/design references remain preserved. + +## Rulings and uncertain decisions + +> **Ruling:** Use `Data.TaggedError` for ordinary domain failures and pinned Effect v4 +> `Schema.TaggedErrorClass` only at schema-serialized boundaries — this follows the repository rule +> while using the actual RC.111 API rather than the older `Schema.TaggedError` name — cost if wrong: +> an error crossing RPC/state may need to be promoted to a schema-backed class later. + +> **Ruling:** The initial deletion commit may temporarily break `apps/cli` type-checking until its +> direct migration task — compatibility stubs would preserve the exact legacy API the greenfield +> rewrite is meant to remove — cost if wrong: intermediate commits cannot be cherry-picked or treated +> as releasable checkpoints. + +> **Ruling:** Define the container activation protocol against a narrow fake host-route resolver +> before Docker/Podman adapters exist — this separates protocol authority from engine mechanics — +> cost if wrong: the engine task may require a small activation interface adjustment. + +When the design does not determine another choice, record it here as: + +> **Ruling:** decision — reason — cost if wrong. + +Implementation continues after recording a ruling; this file is not a question queue. + +## Verification evidence + +### Plan self-review — 2026-08-28 + +- Mapped all design sections to 15 implementation tasks: cleanup, public model, identity, compiler, + durable state/secrets/ports, ownership, reconciliation/observability, native preparation/runtime, + gateway/activation, Functions, container runtime, lifecycle/recovery, facades/CLI, reset session, + and final catalog/audit. +- Removed an ordering conflict: public `StackConfig` is defined with capability Modules before the + durable state schema consumes `StackDefinition`. +- Verified 55 executable steps, 42 balanced Markdown fences, and no placeholder patterns. +- Recorded exact baseline sources for supported settings before their deletion. +- Initialized `.repos/effect` and verified the pinned v4 source. Confirmed + `Schema.TaggedErrorClass`, effectful Schema codecs, services/Layers, scoped acquisition, Deferred, + Semaphore, SubscriptionRef, PubSub, Schedule, callback cancellation, platform filesystem layers, + and Effect RPC against their actual source/tests. + +### Baseline — 2026-08-28 + +- `pnpm install --frozen-lockfile` — passed. +- `pnpm types:check` in `packages/process-compose` — passed. +- `pnpm types:check` in `packages/stack` — passed. +- `pnpm exec vitest run src/DependencyGraph.unit.test.ts` in `packages/process-compose` — 18 passed. +- `pnpm exec vitest run --project unit src/StackIdentity.unit.test.ts` in `packages/stack` — 1 passed. + +### Task 0 — 2026-08-28 + +- `pnpm install --lockfile-only` — passed (pnpm v11.4.0; platform warnings for unsupported binary + wrapper workspaces only). +- `rg -n '@supabase/process-compose|packages/process-compose' package.json packages apps + --glob '!docs/superpowers/**' --glob '!docs/adr/**'` — no matches (exit 1 as expected). +- `pnpm --dir packages/stack types:check` — passed. +- The CLI type-check was not run; the accepted staging ruling allows the old CLI stack call sites to + fail until the direct migration task. The telemetry coverage test was attempted and could not load + the removed `@supabase/stack/managed-model` export; its adapters are part of that migration. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md new file mode 100644 index 0000000000..6da6fc671f --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -0,0 +1,728 @@ +# Stack Runtime Greenfield Rewrite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to +> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `@supabase/process-compose` and the existing `@supabase/stack` implementation with +one minimal Effect v4 managed-stack runtime implementing the approved native/container architecture. + +**Architecture:** Public APIs name stacks and capabilities; private capability Modules compile a +fully materialized definition into workloads. One detached Supervisor owns durable intent, +reconciliation, private runtime resources, the public gateway, and a single control endpoint. The +Effect API is authoritative; Promise and CLI surfaces mechanically adapt it. + +**Tech Stack:** TypeScript 7, Effect v4 RC pinned by the workspace catalog, Effect Platform for Bun +and Node, Effect RPC, Vitest with `@effect/vitest`, pnpm/Turbo, native subprocesses, Docker/Podman. + +**Spec:** `docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md` + +**Progress:** `docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md` + +## Global constraints + +- Greenfield implementation: delete obsolete APIs, tests, adapters, and compatibility scaffolding. +- Managed stacks only. Ordinary handles use explicit `close()`; only `createTestStack` supports + `await using` and destroys its unique identity on disposal. +- Core code is Effect-native. Promises exist only at public non-Effect entrypoints and foreign leaf + adapters; production code must not use TypeScript casts to silence type errors. +- Consult `.repos/effect/` before choosing an Effect construct. Use `Data.TaggedError` by default and + the pinned v4 `Schema.TaggedErrorClass` only when the error itself needs a Schema; use services and + Layers, Scope/acquireRelease, Deferred or shared Fibers for single-flight, Semaphore for lifecycle + serialization, SubscriptionRef for snapshot state, PubSub for retained/live logs, Schedule for + retry, and Effect RPC for the exact-release protocol. +- Never allocate reusable mutable ownership state while constructing an Effect. Build it inside + `Effect.gen`, `Effect.suspend`, or scoped acquisition. +- Native and container workloads never mix in one identity. Every catalog workload has both native + and container artifacts; unsupported platform/runtime mappings fail before durable mutation. +- Persist complete materialized definitions including defaults. Persist secret bytes and concrete + port assignments exactly once in their dedicated state fields. +- Lazy activation is mandatory in both runtimes and has no idle eviction. +- Functions are served only through the stack Edge Runtime. `functionsRoot` is the sole host root and + container mount; reject traversal, absolute, and symlink escapes. +- The caller owns migrations, declarative schemas, and seeds. The durable database reset session is + the final implementation slice. +- Tests follow strict red-green-refactor and exercise consumed behavior. Prefer integration tests; + unit-test only pure compiler/planner/state-machine logic. No arbitrary sleeps, released-port reuse, + global process scans, or broad cleanup. +- Use only targeted tests during implementation. Do not run unrelated e2e suites. +- Every completed task updates the tracked progress ledger and lands as one focused commit. + +## Component map + +```text +packages/stack/src +├── public/ schemas, errors, Effect contract, Promise adapter +├── identity/ project/worktree identity and StackId +├── model/ capability Modules, catalog, materialized compiler, execution graph +├── state/ paths, atomic document, secret slots, ports, ownership +├── control/ stable maintenance framing, exact-release RPC, clients +├── supervisor/ owner session, desired state, reconciler, status/log publication +├── preparation/ verified native/container artifacts +├── runtime/ common contract, native driver, container driver/engine adapters +├── gateway/ HTTP/TCP ingress and container activation server +├── functions/ safe live function discovery +└── entrypoints/ Node/Bun Supervisor and gateway programs +``` + +Each file owns one responsibility. Public modules never export workload ids, execution plans, +artifact keys, state schemas, drivers, or control protocols. + +--- + +### Task 0: Remove the legacy implementations and establish the empty package boundary + +**Files:** + +- Delete: `packages/process-compose/**` +- Delete: legacy `packages/stack/src/**`, `packages/stack/tests/**`, `packages/stack/scripts/**`, and + stale `packages/stack/docs/**` +- Create: `packages/stack/src/public/index.ts`, `packages/stack/src/index.ts`, + `packages/stack/src/effect.ts`, `packages/stack/src/testing.ts` +- Replace: `packages/stack/package.json`, `packages/stack/README.md`, `packages/stack/vitest.config.ts` +- Modify: `package.json`, `.oxlintrc.json`, `knip.json`, `turbo.json`, `apps/cli/package.json`, + `apps/cli/src/next/main.ts`, CLI tests importing the old process mock, telemetry coverage, + `AGENTS.md`, `CONTRIBUTING.md`, `pnpm-lock.yaml` + +**Interfaces:** + +- Produces a private ESM `@supabase/stack` workspace with only `.`, `./effect`, and `./testing` + exports and no runtime behavior yet. +- Moves the generic `mockChildProcessSpawner` test utility to + `apps/cli/tests/helpers/child-process-spawner.ts`; this is test infrastructure, not a runtime API. + +- [ ] **Step 1: Record the deletion baseline** + + Add the base commit and the exact active process-compose references to the tracked progress file. + +- [ ] **Step 2: Delete both legacy implementations before writing replacement runtime code** + + Keep only package configuration, the empty public barrels, and the approved tracked design. Do not + copy orchestration implementation into the new tree. + +- [ ] **Step 3: Remove workspace and CLI process-compose coupling** + + Remove manifest/config/lockfile entries, process-compose self-dispatch in the CLI bootstrap, old + telemetry adapters, and active documentation references. Preserve `.repos/process-compose` and + historical design/ADR references because those are source history, not the deleted workspace. + +- [ ] **Step 4: Regenerate the lockfile and verify the empty boundary** + + ```bash + pnpm install --lockfile-only + rg -n '@supabase/process-compose|packages/process-compose' package.json packages apps \ + --glob '!docs/superpowers/**' --glob '!docs/adr/**' + pnpm --dir packages/stack types:check + ``` + + Expected: install and stack type-check exit 0; the search finds no active reference. + +- [ ] **Step 5: Commit** + + ```bash + git add -A + git commit -m "refactor(stack): remove legacy stack runtimes" + ``` + +### Task 1: Public schemas, tagged failures, and stable status snapshots + +**Files:** + +- Create: `packages/stack/src/public/StackId.ts`, `Runtime.ts`, `Capability.ts`, `Status.ts`, + `Logs.ts`, `Credentials.ts`, `Errors.ts` +- Create: `packages/stack/src/public/public-model.integration.test.ts` +- Modify: `packages/stack/src/public/index.ts`, `packages/stack/src/index.ts` + +**Interfaces:** + +- Produces the common public model from the spec: `StackId`, `StackRuntime`, + `StackRuntimePreference`, ten `CapabilityName` values, `StackStatus`, `LogCursor`, `StackLogEntry`, + descriptors/inspection, credentials, and operation-specific error unions. Task 3 adds the closed + capability configuration after every Module owns its settings schema. +- Promise secret-bearing types contain `string`; Effect secret-bearing types contain + `Redacted.Redacted`. + +- [ ] **Step 1: Write decoding and snapshot-contract integration tests** + + ```ts + it.effect("decodes a complete status snapshot", () => + Schema.decodeUnknownEffect(StackStatusSchema)(STATUS_FIXTURE).pipe( + Effect.map((status) => expect(status.capabilities).toHaveLength(10)), + ), + ); + ``` + + Assert with public predicates or error matching rather than Effect runtime fields. + +- [ ] **Step 2: Run RED** + + ```bash + pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts + ``` + + Expected: failure because the schemas do not exist. + +- [ ] **Step 3: Implement schemas and errors with Effect v4 APIs** + + Use `Data.TaggedError` for ordinary domain failures and pinned v4 `Schema.TaggedErrorClass` only for + failures that cross a schema-serialized boundary. Decode through `Schema.decodeUnknownEffect`; + never throw expected validation failures. + +- [ ] **Step 4: Verify and commit** + + ```bash + pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts + pnpm --dir packages/stack types:check + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): define the greenfield public model" + ``` + +### Task 2: Deterministic identity and safe state paths + +**Files:** + +- Create: `packages/stack/src/identity/Identity.ts`, `GitIdentity.ts`, `FolderIdentity.ts` +- Create: `packages/stack/src/state/Paths.ts` +- Create: `packages/stack/src/identity/identity.integration.test.ts` + +**Interfaces:** + +- Produces `resolveStackIdentity({ projectRoot, name })`, `deriveStackId(identity)`, and + exact identity-scoped paths beneath the user-local stack state root. +- Path construction accepts only validated `StackId` values and cannot address a parent, sibling, or + another identity. + +- [ ] **Step 1: Write real filesystem/worktree and path-safety tests and verify RED** + + Cover repeat identity, named isolation, sibling worktrees, detached checkout, nested project roots, + branch changes, invalid ids, and exact path containment. + + ```bash + pnpm --dir packages/stack exec vitest run --project integration \ + src/identity/identity.integration.test.ts + ``` + +- [ ] **Step 2: Implement identity without a registry daemon** + + Canonicalize the project root, derive the tuple documented by the spec, and hash length-delimited + UTF-8 components into an opaque lowercase digest. Discovery performs no writes. + +- [ ] **Step 3: Implement exact state-path derivation with platform services** + + Keep `FileSystem` and `Path` requirements visible. Validate identity before joining path segments; + expose stack root, state document, data, logs, control metadata, and temporary sibling paths without + performing lifecycle mutation. + +- [ ] **Step 4: Verify and commit** + + Run the targeted identity file and package type-check, update progress, then commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): add identity and safe state paths" + ``` + +### Task 3: Closed capability Modules and the materialized compiler + +**Files:** + +- Create: `packages/stack/src/model/CapabilityModule.ts`, `ExecutionPlan.ts`, `Compiler.ts` +- Create: `packages/stack/src/public/Config.ts` +- Create: `packages/stack/src/model/capabilities/{database,rest,auth,realtime,storage,functions,studio,mail,analytics,pooler}.ts` +- Create: `packages/stack/src/model/compiler.integration.test.ts` +- Create: targeted pure planner tests beside `Compiler.ts` only for fingerprint and graph branches + +**Interfaces:** + +- Produces the exact closed `StackConfig` and listener/security schemas from the design. +- Each Module owns its exhaustive settings Schema, defaults, validation, secret classification, + dependencies, workload definitions, readiness, routes, and both artifact mappings. +- Produces `compileStack(input, previous?)` returning non-secret `StackDefinition`, non-secret + `InputFingerprint`, resolved secret-slot inputs, and private `ExecutionPlan`. +- Workload ids and concrete artifact keys remain private. + +- [ ] **Step 1: Write one public compiler scenario per capability and verify RED** + + Each scenario supplies non-default values accepted by current CLI configuration and asserts they + survive in the materialized definition. Add cross-cutting scenarios for all defaults persisted, + unknown fields rejected, secret bytes absent, exact unsupported versions rejected before output, + both runtime mappings present, and functions path normalization. + +- [ ] **Step 2: Implement the ten direct closed schemas** + + Use direct `Schema.Struct` declarations owned by each Module; do not add a code generator or open + property bags. Port the actual supported fields from `CliConfig` and from the baseline sources at + `de26a30c7:packages/stack/src/StackConfig.ts` and `ServiceCatalog.ts`, updating consumers in the + same task rather than preserving the old shapes. + +- [ ] **Step 3: Implement pure compilation** + + Canonicalize non-secret input for the fingerprint, preserve explicit omission versus selection, + apply every default, convert secret leaves to generated slots, validate dependency closure, and + derive semantic workload-spec hashes. Reuse the persisted definition on identical fingerprints. + +- [ ] **Step 4: Verify and commit** + + Run only `compiler.integration.test.ts`, adjacent pure planner tests, and stack type-check. Commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): compile closed capability definitions" + ``` + +### Task 4: Atomic durable state, sticky secrets, and port coordination + +**Files:** + +- Create: `packages/stack/src/state/StackState.ts`, `StackStateStore.ts`, `SecretStore.ts`, + `PortRegistry.ts`, `PortCoordinator.ts` +- Create: `packages/stack/src/state/state-store.integration.test.ts`, `secrets.integration.test.ts`, + `ports.integration.test.ts` + +**Interfaces:** + +- Produces `StackStateStore`, `resolveSecrets(candidate, persisted, lifecycle)`, and scoped + `PortCoordinator.planAndReserve(stackId, listenerIntents)`. +- `PersistedStackState` has one format identifier, identity, concrete runtime, generation, desired + lifecycle, optional materialized definition/fingerprint, ports, and secret values. Reads are + lock-free; writes atomically replace one complete owner-only document. +- Automatic assignments are globally exclusive while stopped/running; stopped exact assignments may + coexist and are validated only on start. Assignments stay sticky after bind races. + +- [ ] **Step 1: Write state, secret, and port scenarios first** + + Test atomic old-or-new reads, corrupt-state fail-closed behavior, exact-identity cleanup, managed + generation/reuse/mismatch, stopped-only pass-through replacement, no secret bytes in fingerprints/ + log messages, automatic sticky exclusivity, exact stopped coexistence, live exact conflicts, + unchanged assignment reuse, native socket transfer, and container bind-race retention. + +- [ ] **Step 2: Verify RED, then implement atomic state and scoped acquisition** + + Encode/decode through Effect Schema; atomically replace owner-only state through a sibling temporary + file and required fsync operations. Reserve complete candidate sets under the short registry lock + and state generation fence. Own native sockets with `Effect.acquireRelease`; use an engine- + authoritative publish result for containers. Never bind-release-rebind an automatic native port. + The first release reads and writes only `supabase-stack-state-v1`; add no migration framework. + +- [ ] **Step 3: Verify and commit** + + Run the three targeted integration files plus stack type-check and commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): persist state secrets and sticky ports" + ``` + +### Task 5: One owner endpoint, Supervisor launch, and managed handles + +**Files:** + +- Create: `packages/stack/src/state/Ownership.ts` +- Create: `packages/stack/src/control/MaintenanceProtocol.ts`, `StackRpc.ts`, `ControlClient.ts` +- Create: `packages/stack/src/supervisor/Supervisor.ts`, `OwnerSession.ts`, `Launcher.ts` +- Create: `packages/stack/src/entrypoints/supervisor-{node,bun}.ts` +- Create: `packages/stack/src/public/EffectStack.ts` +- Create: `packages/stack/src/supervisor/handles.integration.test.ts` + +**Interfaces:** + +- Produces Effect `createStack`, `openStack`, `findStack`, `listStacks`, and `inspectStack`, plus the + complete `EffectStack` handle contract: status, credentials, prepare, start, restart, stop, destroy, + close, status snapshots, and logs. Later tasks connect those methods to their runtime components. +- One atomic ownership primitive and endpoint carries frozen bounded-JSON maintenance operations plus + exact-release Effect RPC. Ordinary handles are scoped/closeable; owner work outlives handles. + +- [ ] **Step 1: Write multi-process handle scenarios and verify RED** + + Cover create without config/start, same identity joining one owner, open-only behavior, read-only + discovery, closing/caller exit survival, concurrent equivalent create, incompatible owner failure, + and stable maintenance stop. + +- [ ] **Step 2: Implement ownership and protocols** + + Use one endpoint, random owner session, bounded framing, typed decode failures, and Effect RPC for + exact-release operations. Register all callback listeners before starting I/O, resume at most once, + and remove every owned listener/socket in the cancellation Effect. + +- [ ] **Step 3: Implement shared launch/attachment** + + Represent launch with a cached Effect/shared Fiber or `Deferred>`; never a boolean plus + polling. Waiter interruption must not cancel owner launch or cleanup. Supervisor state is allocated + inside its scoped Effect execution. + +- [ ] **Step 4: Verify and commit** + + Run `handles.integration.test.ts`, stack type-check, update progress, and commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): add supervisor ownership and managed handles" + ``` + +### Task 6: Reconciler, status snapshots, and retained/live logs + +**Files:** + +- Create: `packages/stack/src/supervisor/Reconciler.ts`, `DesiredState.ts`, `StatusHub.ts`, `LogStore.ts` +- Create: `packages/stack/src/runtime/RuntimeDriver.ts` +- Create: `packages/stack/src/supervisor/reconciler.integration.test.ts`, `observability.integration.test.ts` +- Create: `packages/stack/src/supervisor/Reconciler.unit.test.ts` for deterministic graph planning + +**Interfaces:** + +- `RuntimeDriver` observes, starts, stops, and removes exact private workloads. +- `Reconciler` compiles a deterministic plan from durable generation + observed snapshot. +- `status`/`watchStatus` publish complete projections; logs have bounded storage, opaque cursors, + exact known-secret redaction, and atomic retained-to-live handoff. + +- [ ] **Step 1: Write failure-isolation and concurrency scenarios** + + Cover dependency ordering, reverse stop, independent branch survival, blocked dependents, readiness + deadlines, bounded restart budgets, caller cancellation, generation fences, one reconciliation at a + time, first watch snapshot, no duplicate/gap log handoff, and capability-only public attribution. + +- [ ] **Step 2: Implement with Effect coordination primitives** + + Use one `Semaphore` for lifecycle serialization, `SubscriptionRef` for current snapshot and change + stream, `PubSub` for live logs, `Deferred` for activation/readiness handoff, and `Schedule` for retry. + Do not recreate waiter arrays, boolean gates, or sleep-based propagation. + +- [ ] **Step 3: Verify and commit** + + Run the two integration files and the focused pure planner test, then commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): reconcile workloads and publish observability" + ``` + +### Task 7: Artifact preparation, native execution, and database bootstrap + +**Files:** + +- Create: `packages/stack/src/preparation/ArtifactStore.ts`, `Integrity.ts` +- Create: `packages/stack/src/runtime/NativeRuntime.ts`, `NativeProcess.ts` +- Create: `packages/stack/src/model/DatabaseBootstrap.ts` +- Create: `packages/stack/src/preparation/artifacts.integration.test.ts` +- Create: `packages/stack/src/runtime/native-runtime.integration.test.ts` + +**Interfaces:** + +- `prepare` verifies and atomically publishes selected native/container artifacts without state, + ports, network, secrets, or workload mutation. +- `NativeRuntime` implements `RuntimeDriver` with exact process-tree ownership and transferred gateway + listeners. Bootstrap is idempotent and records ordered revisions inside PostgreSQL only. + +- [ ] **Step 1: Write preparation/native behavior tests and verify RED** + + Cover cached/downloaded outcomes, integrity failure cleanup, Supervisor-local single-flight, + concurrent Supervisor duplicate safety, cancellation, process readiness/failure, exact process-tree + termination, parent-loss termination, and bootstrap-before-dependent readiness. + +- [ ] **Step 2: Implement leaf foreign boundaries** + + Wrap foreign Promise/callback APIs once with cancellation signals and typed errors. Publish downloads + by atomic rename. Run native subprocesses through Effect Platform; attach output to `LogStore` and + termination to Scope. + +- [ ] **Step 3: Verify and commit** + + Run the two targeted files and stack type-check, then commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): prepare artifacts and run native workloads" + ``` + +### Task 8: Gateway and mandatory lazy activation + +**Files:** + +- Create: `packages/stack/src/gateway/Gateway.ts`, `HttpGateway.ts`, `TcpGateway.ts`, + `ActivationServer.ts`, `ActivationFile.ts` +- Create: `packages/stack/src/gateway/gateway.integration.test.ts` +- Create: `packages/stack/src/gateway/activation.integration.test.ts` + +**Interfaces:** + +- Gateway is the only public ingress and routes HTTP/WebSocket and transparent TCP listeners. +- Native activation calls Supervisor directly; container activation uses an ephemeral exact-release + authenticated TCP server and read-only fenced file with activation-only authority. + +- [ ] **Step 1: Write observable protocol scenarios and verify RED** + + Cover HTTP routes/CORS/forwarding/WebSocket, PostgreSQL/TLS/SMTP/POP3/STARTTLS byte transparency, + backpressure/half-close, 503 activation failure, 502 backend failure, dormant health probes, + dependency-closure activation, generation/session/gateway fences, bounded frames/concurrency/ + deadlines, and no idle eviction. + +- [ ] **Step 2: Implement ingress and activation** + + Use stream/socket platform APIs with scoped cancellation. Capability files contain no labels or + command-line secrets and rotate with each owner/gateway. The activation server exposes only one + activation operation and has no runtime/state/filesystem/log authority. + +- [ ] **Step 3: Verify and commit** + + Run both gateway integration files and stack type-check, then commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): add gateway and lazy activation" + ``` + +### Task 9: Live Functions through the managed Edge Runtime + +**Files:** + +- Create: `packages/stack/src/functions/FunctionsRoot.ts`, `FunctionDiscovery.ts` +- Modify: `packages/stack/src/model/capabilities/functions.ts`, gateway routing, native runtime +- Create: `packages/stack/src/functions/functions.integration.test.ts` + +**Interfaces:** + +- Produces request-time slug discovery under one `functionsRoot` with closed persisted overrides. +- Native and container execution use the stack Edge Runtime; no separate functions-serving workflow. + +- [ ] **Step 1: Write safe live-edit scenarios and verify RED** + + Cover create/edit/delete visible on next request, shared modules, import maps/static files, disabled + override not-found behavior, and rejection of traversal, absolute paths, and symlink escapes. + +- [ ] **Step 2: Implement safe resolution** + + Canonicalize and realpath every resolved entry/import/static/symlink target before opening it; prove + membership beneath `functionsRoot`. Container mode mounts only the whole root read-only at one stable + path. Do not add a watcher or desired-state mutation for code edits. + +- [ ] **Step 3: Verify and commit** + + Run `functions.integration.test.ts` in native fixtures, stack type-check, and commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): serve live functions through edge runtime" + ``` + +### Task 10: Strict container runtime and engine adapters + +**Files:** + +- Create: `packages/stack/src/runtime/ContainerEngine.ts`, `DockerEngine.ts`, `PodmanEngine.ts`, + `ContainerRuntime.ts` +- Modify: gateway activation host routing and Supervisor composition +- Create: `packages/stack/src/runtime/container-runtime.integration.test.ts` + +**Interfaces:** + +- Docker and Podman adapters create exact identity/generation-labeled private networks, workloads, + persistent volumes, gateway publication, read-only activation/config files, and exact cleanup. +- No native process is started for a container identity and no container is created for native. + +- [ ] **Step 1: Write controlled engine integration scenarios and verify RED** + + Cover platform alias selection (`host.docker.internal`, Linux host-gateway, + `host.containers.internal`), unsupported routing preflight, gateway-only host publication, private + backend endpoints, engine bind races, semantic-hash adoption, stale removal, persistent volumes, + strict runtime split, and lazy Functions edits through the mounted root. + +- [ ] **Step 2: Implement the smallest engine contract** + + Model only inspect/pull/create/start/stop/remove/network/volume operations actually used by the + execution plan. Parse exact structured engine output and map failures at the leaf boundary. Do not + build a general container SDK. + +- [ ] **Step 3: Verify and commit** + + Run `container-runtime.integration.test.ts` against controlled Docker and Podman adapters, stack + type-check, and commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): run strict container stacks" + ``` + +### Task 11: Start/stop/restart/destroy and crash recovery + +**Files:** + +- Modify: public Effect stack, Supervisor, Reconciler, owner/control, state, both runtime drivers +- Create: `packages/stack/src/supervisor/lifecycle.integration.test.ts` +- Create: `packages/stack/src/supervisor/recovery.integration.test.ts` + +**Interfaces:** + +- `start` applies config only at unconfigured/stopped boundaries and returns current status for an + identical running input. Changed running input fails and suggests explicit restart. +- `restart` preflights, quiesces through stable maintenance, may apply allowed stopped-time changes, + and loses to concurrent stop. Destroy removes exact data; stop retains state/data/logs/artifacts. + +- [ ] **Step 1: Write the complete lifecycle matrix and verify RED** + + Cover identical/changed fingerprints, pass-through-only changes, version rules, initialized database + immutability, concurrent calls, stop winning restart, owner replacement failure, reboot no-autostart, + native owner loss, container fail-closed, exact adoption fences, corrupt state, and explicit exact + destructive cleanup. + +- [ ] **Step 2: Implement durable transitions** + + Commit validated complete intent before reconciliation. Use stable maintenance only for stop/quiesce; + protocol mismatch is an upgrade signal. Keep replacement interruptible outside the narrow + acquisition-to-registration mask and preserve unrecognized Causes. + +- [ ] **Step 3: Verify and commit** + + Run both targeted lifecycle files and stack type-check, then commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): complete managed lifecycle and recovery" + ``` + +### Task 12: Promise facade, test resource, and CLI migration + +**Files:** + +- Create: `packages/stack/src/public/PromiseStack.ts`, `packages/stack/src/public/Testing.ts` +- Modify: root/effect/testing exports and Node/Bun platform entrypoints +- Modify: all `apps/cli` imports/callers of the deleted stack API, start/stop/status/functions commands, + CLI bootstrap, telemetry adapters, and relevant integration helpers/tests +- Create: `packages/stack/src/public/promise.integration.test.ts` +- Create: `packages/stack/src/public/testing.integration.test.ts` + +**Interfaces:** + +- Root Promise facade exposes create/open/find/list/inspect with plain-string secrets and + AsyncIterable status/log streams. Handles have explicit `close()` and no async-dispose symbol. +- `createTestStack` is an `AsyncDisposable` wrapper that creates, starts, waits, and destroys only its + unique temporary identity. +- CLI translates `CliConfig` to `StackConfig`; `supabase functions serve` starts/opens the managed + stack Functions capability and streams its logs through Edge Runtime. + +- [ ] **Step 1: Write facade ownership tests and CLI handler integration tests first** + + Cover plain-string credentials/config, `Option` to `undefined`, stream cancellation, explicit handle + close survival, test-wrapper disposal cleanup/startup failure cleanup, CLI start/restart guidance, + complete status rendering, stop/destroy, and managed Functions serving/live edits. + +- [ ] **Step 2: Implement mechanical adapters** + + Give each Promise handle one private Scope closed by `close()`. Unwrap `Redacted` only at the outer + boundary and preserve tagged errors. Implement the test wrapper as the sole `AsyncDisposable`. + +- [ ] **Step 3: Migrate CLI consumers to the new API** + + Update call sites directly; do not recreate `daemonLayer`, `foregroundLayer`, managed manager, + launch metadata, old port/version helpers, or compatibility subpaths. Move configuration translation + into the CLI and use new descriptors/status projections. + +- [ ] **Step 4: Verify and commit** + + Run both stack facade integration files plus only changed CLI handler integration files, then stack + and CLI type-checks. Commit: + + ```bash + git add packages/stack apps/cli docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): expose facades and migrate the CLI" + ``` + +### Task 13: Deferred final database reset session + +**Files:** + +- Create: `packages/stack/src/supervisor/DatabaseReset.ts` +- Modify: public Effect/Promise contracts, durable state, Supervisor/Reconciler/status +- Create: `packages/stack/src/supervisor/database-reset.integration.test.ts` + +**Interfaces:** + +- Produces `beginDatabaseReset` returning a fenced durable session with database credentials and + explicit `complete`/`fail`; no all-in-one migration/seed/reset method. +- While active, observable lifecycle is `resetting-database`, desired lifecycle remains running, and + database dependents stay stopped across caller loss. + +- [ ] **Step 1: Freeze the smallest session schema from observed needs** + + Record the final field/API choice as a ruling in progress before implementation. The persisted + session contains only session identity, generation fence, phase, and timestamps needed for recovery; + migration history stays in PostgreSQL. + +- [ ] **Step 2: Write session integration scenarios and verify RED** + + Cover exact data recreation, bootstrap ordering, dependent fencing, explicit complete/fail, caller + loss, later resumption/recovery, stale session rejection, and mechanical Promise adaptation. + +- [ ] **Step 3: Implement the durable session** + + Database recreation and bootstrap are Stack-owned. The session exposes the ready database to the + caller, which owns migrations/declarative schemas/seeds and reports completion or failure. + +- [ ] **Step 4: Verify and commit** + + Run only `database-reset.integration.test.ts`, lifecycle tests affected by the new state, facade + adaptation tests, and stack type-check. Commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): add caller-driven database reset sessions" + ``` + +### Task 14: Final catalog, documentation, and completion audit + +**Files:** + +- Modify: every capability Module/catalog mapping, package README and architecture docs, tracked + progress, CLI docs/help affected by the new lifecycle +- Create: a small set of stack/CLI e2e smoke files only where real compiled subprocess/container + boundaries are not covered by integration tests + +**Interfaces:** + +- Delivers all ten capabilities with full settings and real native/container artifacts, public exports + limited to `.`, `./effect`, and `./testing`, and no process-compose or legacy stack surface. + +- [ ] **Step 1: Audit every spec requirement against authoritative evidence** + + Build a requirement-to-test/source table in the tracked progress document. Missing or indirect + evidence is incomplete work, not a pass. + +- [ ] **Step 2: Fill catalog/platform gaps test-first** + + For every capability, exercise at least one native and one container preparation/execution mapping; + run only the newly relevant platform files. Add the smallest compiled CLI smoke for start/status/ + stop and managed Functions serving. + +- [ ] **Step 3: Run final targeted verification** + + ```bash + pnpm --dir packages/stack types:check + pnpm --dir packages/stack exec vitest run --project unit + pnpm --dir packages/stack exec vitest run --project integration + pnpm --dir apps/cli types:check + pnpm exec oxlint --config .oxlintrc.json packages/stack apps/cli/src/next apps/cli/src/shared/telemetry + pnpm exec oxlint --config .oxlintrc.effect.json packages/stack + pnpm exec oxfmt --config .oxfmtrc.json --check packages/stack \ + docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md \ + docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md \ + docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + pnpm exec knip-bun --workspace packages/stack + ``` + + Run only the named new e2e smoke files, not an unrelated e2e suite. + +- [ ] **Step 4: Whole-branch review and one fix wave** + + Review the full merge-base diff against the design and progress rulings. Fix every load-bearing + finding, re-run affected targeted checks, and record any adjudicated non-blocking ruling. + +- [ ] **Step 5: Commit, push, and open the PR against develop** + + ```bash + git add -A + git commit -m "docs(stack): finalize the managed runtime rewrite" + git push -u origin feat/stack-runtime-rewrite + gh pr create --base develop --title "feat(stack): rewrite the managed stack runtime" \ + --body $'## Summary\n\nReplaces the legacy stack and process-compose implementations with the managed Effect v4 stack runtime described in the tracked architecture.' + ``` + + The PR description explains what and why, includes no test-plan section, and links/supersedes an + issue or PR only when authoritative repository evidence identifies one. diff --git a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md new file mode 100644 index 0000000000..9b269c4962 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md @@ -0,0 +1,959 @@ +# Supabase Local Stack Runtime Architecture + +Status: final greenfield architecture +Last updated: 2026-08-28 + +## Goals and scope + +`@supabase/stack` defines, prepares, runs, observes, stops, restarts, and destroys one local +Supabase stack. It supports a strict native or container runtime split behind one public contract. +The runtime choice is made when an identity is created, is immutable for that identity, and all +workloads use the same runtime. Native and container execution never mix workloads. +The package supports managed stacks only: callers receive handles to a Supervisor-owned identity, +not ownership of an independently launched process or container. + +The package must let callers: + +- run isolated stacks for projects, branches, worktrees, and named instances; +- keep a running stack alive after every creating process exits; +- stop and explicitly restart without changing identity, data, runtime, versions, secrets, or + sticky automatic ports unless the restart explicitly supplies an allowed stopped-time change; +- change resolved settings, capability membership, activation policy, listeners, and exact ports + only at a stop boundary; +- prepare artifacts without creating runtime resources or starting services; +- use eager or mandatory-lazy capabilities through one gateway; +- edit and invoke Edge Functions from the host while the stack remains managed; +- discover stacks without claiming ownership or starting work; and +- recover or replace a compatible Supervisor without silently replacing a healthy owner. + +The package does not parse `config.toml`, run project migrations or seeds, expose Compose, or act as +a general process orchestrator. The CLI resolves its configuration and calls the package API. + +The design follows the object-level separation used by Docker without copying its CLI: + +```text +CLI-resolved StackConfig + │ + ▼ +StackCompiler ──► materialized StackDefinition + runtime ExecutionPlan + │ │ + ▼ ▼ + durable state/fingerprint per-stack Supervisor + │ + StackReconciler + │ │ + ▼ ▼ + ArtifactStore RuntimeDriver + native | container + │ + ▼ + StackGateway + HTTP and transparent TCP +``` + +## Core invariants + +- One `StackId` has at most one live Supervisor and one owned control endpoint. +- A running Supervisor and its workloads outlive every caller handle. Closing a handle only + releases that handle, its subscriptions, and its facade-owned scope. +- `createStack` and `openStack` return ordinary managed-stack handles. Promise handles are explicitly + closed and are not `AsyncDisposable`; `await using` is reserved for the isolated testing helper. + Closing a handle never stops or destroys the managed stack. +- Every accepted desired-state mutation has one durable `desiredGeneration`; equivalent concurrent + callers join the same owner operation. +- Only the Supervisor mutates one stack and it executes at most one reconciliation plan at a time. +- Canceling a caller wait never cancels Supervisor-owned start, stop, restart, preparation, + activation, or destruction work. +- A stopped stack has no live processes, service containers, gateway, private network, or published + listeners. It retains its state, data, logs, artifacts, secrets, and logical port records. +- Runtime kind and engine, materialized definition, input fingerprint, exact capability versions, + managed secrets, and selected automatic ports are sticky until an allowed stop-time change. +- Public listeners belong only to `StackGateway`; backend workloads use private endpoints. +- Runtime adoption requires `StackId`, `desiredGeneration`, and a semantic workload-spec hash that + matches the current compiler output. Compiler changes cannot adopt incompatible old resources. +- Destroy removes the complete stack and all of its data. Cleanup is exact-identity-only. +- Lazy activation is one-way per generation: once a capability is activated, it remains desired + active and its workloads use catalog restart policy until stop or explicit restart. There is no + idle eviction. + +## Public model and API + +`@supabase/stack/effect` is Effect-native end to end. The package root is a thin Promise and +`AsyncIterable` facade; no internal helper returns a Promise. + +```ts +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; +import type * as Redacted from "effect/Redacted"; +import type * as Scope from "effect/Scope"; +import type * as Stream from "effect/Stream"; + +declare const StackIdTypeId: unique symbol; +type StackId = string & { readonly [StackIdTypeId]: "StackId" }; + +type StackRuntime = + | { readonly kind: "native" } + | { readonly kind: "container"; readonly engine: "docker" | "podman" }; + +type StackRuntimePreference = + | { readonly kind: "native" } + | { readonly kind: "container"; readonly engine?: "docker" | "podman" | "auto" }; + +type CapabilityName = + | "database" + | "rest" + | "auth" + | "realtime" + | "storage" + | "functions" + | "studio" + | "mail" + | "analytics" + | "pooler"; + +type CapabilityState = + | "disabled" + | "dormant" + | "preparing" + | "starting" + | "ready" + | "stopping" + | "stopped" + | "failed"; + +type StackLifecycle = + | "unconfigured" + | "stopped" + | "starting" + | "running" + | "resetting-database" + | "stopping" + | "destroying"; + +type DesiredStackLifecycle = "unconfigured" | "stopped" | "running" | "destroying"; +type ActivationMode = "eager" | "lazy"; +type PortField = keyof StackListenersConfig; + +interface StackEndpoint { + readonly protocol: "http" | "tcp"; + readonly address: string; + readonly port: number; + readonly url: string; +} + +interface CapabilityStatus { + readonly name: CapabilityName; + readonly activation: ActivationMode; + readonly state: CapabilityState; + readonly error?: string; +} + +/** Complete point-in-time projection used by both info and watch. */ +interface StackStatus { + readonly id: StackId; + readonly lifecycle: StackLifecycle; + readonly desiredLifecycle: DesiredStackLifecycle; + readonly runtime: StackRuntime; + readonly desiredGeneration?: number; + readonly endpoints: Readonly>>; + readonly versions: Readonly>>; + readonly capabilities: ReadonlyArray; +} + +interface LogCursor { + readonly opaque: string; +} + +interface LogOptions { + readonly capabilities?: ReadonlyArray; + readonly follow?: boolean; + readonly cursor?: LogCursor; +} + +interface StackLogEntry { + readonly cursor: LogCursor; + readonly timestamp: string; + readonly source: CapabilityName | "supervisor" | "gateway"; + readonly stream: "stdout" | "stderr" | "internal"; + readonly message: string; +} + +interface StackDescriptor { + readonly id: StackId; + readonly projectRoot: string; + readonly name: string; + readonly branchContext: string; + readonly runtime: StackRuntime; + readonly desiredLifecycle: DesiredStackLifecycle; +} + +interface StackInspection { + readonly descriptor: StackDescriptor; + readonly owner: "running" | "absent" | "unreachable" | "incompatible"; + readonly status?: StackStatus; +} +``` + +`StackStatus` is a complete projection, not an event delta. `status()` returns one projection and +`watchStatus()` is a stream whose every subscription begins with the current complete projection and +then publishes complete meaningful snapshots. There is no sequence-number event union and no +duplicate projection or delta API. Health is derived from capability states, not a separate lifecycle +value. Logs remain a separate retained-to-live stream with an opaque cursor. + +### Closed capability settings + +`StackConfig` contains a closed `StackCapabilitiesConfig`, `StackListenersConfig`, and security +policy. Every setting accepted by CLI configuration is represented in a schema-derived type; a +capability's schema is exhaustive, not an open property map or a partial provider example. + +```ts +interface StackConfig { + readonly capabilities?: StackCapabilitiesConfig; + readonly listeners?: StackListenersConfig; + readonly security?: StackSecurityConfig; +} + +interface StackCapabilitiesConfig { + readonly database?: DatabaseCapabilityConfig; + readonly rest?: OptionalCapabilityConfig; + readonly auth?: OptionalCapabilityConfig; + readonly realtime?: OptionalCapabilityConfig; + readonly storage?: OptionalCapabilityConfig; + readonly functions?: OptionalCapabilityConfig; + readonly studio?: OptionalCapabilityConfig; + readonly mail?: OptionalCapabilityConfig; + readonly analytics?: OptionalCapabilityConfig; + readonly pooler?: OptionalCapabilityConfig; +} + +type OptionalCapabilityConfig = + | { readonly enabled: false } + | { + readonly enabled?: true; + readonly activation?: ActivationMode; + readonly version?: string; + readonly settings?: Settings; + }; + +interface DatabaseCapabilityConfig { + readonly version?: string; + readonly settings?: DatabaseSettings; +} + +interface StackSecurityConfig { + readonly jwt?: JwtSecurityConfig; +} + +interface JwtSecurityConfig { + readonly issuer?: string; + readonly signing?: JwtSigningConfig; +} + +type JwtSigningConfig = + | { readonly kind: "symmetric"; readonly secret: Redacted.Redacted } + | { readonly kind: "jwks-file"; readonly path: string }; + +// These aliases stand for generated closed schemas. Each generated declaration +// contains every supported field, defaults, validation, secret classification, +// and native/container artifact mapping for that capability. +type RestSettings = GeneratedSettingsSchema<"rest">; +type AuthSettings = GeneratedSettingsSchema<"auth">; +type RealtimeSettings = GeneratedSettingsSchema<"realtime">; +type StorageSettings = GeneratedSettingsSchema<"storage">; +type FunctionsSettings = GeneratedSettingsSchema<"functions">; +type StudioSettings = GeneratedSettingsSchema<"studio">; +type MailSettings = GeneratedSettingsSchema<"mail">; +type AnalyticsSettings = GeneratedSettingsSchema<"analytics">; +type PoolerSettings = GeneratedSettingsSchema<"pooler">; +type DatabaseSettings = GeneratedSettingsSchema<"database">; +``` + +The code comment above is notation for schema-derived declarations, not a literal suggestion to +erase fields. Each capability Module owns its full settings schema, defaulting, validation, secret +classification, native artifact mapping, container artifact mapping, and workload compilation. +Auth providers, mail modes, storage options, and every other catalog-supported field are described +by those modules rather than by incomplete architecture examples. + +Listeners use the closed shape below. Omitted ports request an automatic assignment; an explicit +port is an exact assignment. + +```ts +interface StackListenersConfig { + readonly api?: ListenerConfig; + readonly database?: ListenerConfig; + readonly pooler?: ListenerConfig; + readonly studio?: ListenerConfig; + readonly mailUi?: ListenerConfig; + readonly smtp?: ListenerConfig; + readonly pop3?: ListenerConfig; + readonly functionsInspector?: ListenerConfig; +} + +type ListenerConfig = + | { readonly enabled: false } + | { readonly enabled?: true; readonly address?: string; readonly port?: number }; +``` + +### Effect and Promise entry points + +```ts +interface EffectStackCredentials { + readonly database: { readonly url: Redacted.Redacted; readonly password: Redacted.Redacted }; + readonly api: { + readonly publishableKey: string; + readonly secretKey: Redacted.Redacted; + readonly anonJwt: string; + readonly serviceRoleJwt: Redacted.Redacted; + }; + readonly storage?: { + readonly endpoint: string; + readonly region: string; + readonly accessKeyId: string; + readonly secretAccessKey: Redacted.Redacted; + }; +} + +interface PromiseStackCredentials { + readonly database: { readonly url: string; readonly password: string }; + readonly api: { + readonly publishableKey: string; + readonly secretKey: string; + readonly anonJwt: string; + readonly serviceRoleJwt: string; + }; + readonly storage?: { + readonly endpoint: string; + readonly region: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; + }; +} + +interface StartStackOptions { readonly config?: StackConfig } +interface PrepareStackOptions { readonly config?: StackConfig; readonly capabilities?: ReadonlyArray } +interface CreateStackOptions { readonly projectRoot: string; readonly name?: string; readonly runtime?: StackRuntimePreference } +interface FindStackOptions { readonly projectRoot: string; readonly name?: string } +interface ListStacksOptions { readonly projectRoot?: string } + +/** Generated Promise counterpart: every Redacted leaf is a plain string. */ +type PromiseStackConfig = GeneratedPromiseStackConfig; +type PromiseStartStackOptions = { readonly config?: PromiseStackConfig }; +type PromisePrepareStackOptions = { + readonly config?: PromiseStackConfig; + readonly capabilities?: ReadonlyArray; +}; + +interface PreparedCapability { readonly capability: CapabilityName; readonly version: string; readonly outcome: "cached" | "downloaded" | "pulled" } +interface PrepareStackResult { readonly capabilities: ReadonlyArray } + +interface EffectStack { + readonly id: StackId; + readonly status: () => Effect.Effect; + readonly credentials: () => Effect.Effect; + readonly prepare: (options?: PrepareStackOptions) => Effect.Effect; + readonly start: (options?: StartStackOptions) => Effect.Effect; + readonly restart: (options?: StartStackOptions) => Effect.Effect; + readonly stop: () => Effect.Effect; + readonly destroy: () => Effect.Effect; + readonly close: () => Effect.Effect; + readonly watchStatus: () => Stream.Stream; + readonly logs: (options?: LogOptions) => Stream.Stream; +} + +function createStack(options: CreateStackOptions): Effect.Effect; +function openStack(id: StackId): Effect.Effect; +function findStack(options: FindStackOptions): Effect.Effect, StackDiscoveryError>; +function listStacks(options?: ListStacksOptions): Effect.Effect, StackDiscoveryError>; +function inspectStack(id: StackId): Effect.Effect; + +declare namespace PromiseApi { + interface Stack { + readonly id: StackId; + readonly status: () => Promise; + readonly credentials: () => Promise; + readonly prepare: (options?: PromisePrepareStackOptions) => Promise; + readonly start: (options?: PromiseStartStackOptions) => Promise; + readonly restart: (options?: PromiseStartStackOptions) => Promise; + readonly stop: () => Promise; + readonly destroy: () => Promise; + readonly close: () => Promise; + readonly watchStatus: () => AsyncIterable; + readonly logs: (options?: LogOptions) => AsyncIterable; + } + + function createStack(options: CreateStackOptions): Promise; + function openStack(id: StackId): Promise; + function findStack(options: FindStackOptions): Promise; + function listStacks(options?: ListStacksOptions): Promise>; + function inspectStack(id: StackId): Promise; +} +``` + +The Promise facade mechanically runs the Effect contract in one private scope per handle, maps +`Option` to `undefined`, adapts `Stream` to `AsyncIterable`, and translates `Redacted` values to +plain strings at the outer boundary. `GeneratedPromiseStackConfig` is a generated closed counterpart +of `StackConfig` with every secret leaf represented as `string`, including all capability settings; +it is not an open property map. Secret-bearing types are therefore intentionally different; +the facade does not claim that every shared type is identical. Ordinary Promise handles require an +explicit idempotent `close()` when the caller wants to release handle-local resources. They do not +implement `AsyncDisposable`, because `await using` is reserved for the test helper that owns and +destroys a unique test identity. + +`createStack` canonicalizes the project root, resolves the stable identity, persists a concrete +runtime for a new identity, starts or attaches to its Supervisor, and returns a handle. It does not +read configuration, allocate ports, prepare artifacts, or start workloads. `openStack` attaches only +to an existing opaque id and never creates an identity. `close` is idempotent and never stops a +managed stack. + +`findStack`, `listStacks`, and `inspectStack` are read-only. Inspection may include the durable +descriptor and a compatible observed `StackStatus` when an owner is reachable; it never claims +ownership, starts work, or recovers resources. A new identity that has never accepted a definition +has desired lifecycle `unconfigured`. + +### Testing helper + +`@supabase/stack/testing` exposes a high-level `createTestStack` helper. It creates a unique isolated +managed identity in a temporary root, starts it, waits for readiness, and returns an +`AsyncDisposable` test resource wrapper (distinct from an ordinary `createStack`/`openStack` handle) +whose disposal destroys, rather than merely closes, that identity. The helper records the exact +identity it created, never attaches to existing user state, and destroys only that identity. If +startup fails, it cleans every partial resource and state record it owns before rejecting. This +helper is the sanctioned test setup for public integration scenarios. + +```ts +await using stack = await createTestStack({ config }); +// The stack is ready here. Leaving the scope destroys this exact test identity. +``` + +## Identity + +Identity is resolved before runtime ownership: + +```text +workspaceId + checkoutId + branchContext + localProjectKey + stackName → StackId +``` + +`workspaceId` identifies the repository or ordinary workspace, `checkoutId` the concrete checkout +or worktree, `branchContext` the full branch ref (or `detached`/`ordinary-workspace`), +`localProjectKey` the normalized project path relative to checkout root, and `stackName` an explicit +instance selector defaulting to `default`. `StackId` is an opaque digest of this tuple. A branch +rename creates a new identity; switching branches never stops the previous stack. + +The user-local registry enumerates state documents and serializes identity and port transactions +with one short cross-process lock. It is not a daemon. State, data, logs, runtime names, labels, +control endpoint, and logical ports are all namespaced by exact `StackId`. + +## Materialized configuration and lifecycle + +### Compilation and durable definition + +The CLI resolves `config.toml`, flags, environment interpolation, and secret redaction into +`StackConfig`. At `start` or `prepare`, an Effectful leaf snapshots referenced non-secret auxiliary +files. Live Function code and secret files are not snapshotted. + +```ts +interface StackDefinition { + /** Every capability is present with enabled, activation, version, and defaulted settings. */ + readonly capabilities: { + readonly database: MaterializedCapability>; + readonly rest: MaterializedCapability>; + readonly auth: MaterializedCapability>; + readonly realtime: MaterializedCapability>; + readonly storage: MaterializedCapability>; + readonly functions: MaterializedCapability>; + readonly studio: MaterializedCapability>; + readonly mail: MaterializedCapability>; + readonly analytics: MaterializedCapability>; + readonly pooler: MaterializedCapability>; + }; + readonly listeners: MaterializedListenersConfig; + readonly security: MaterializedSecurityConfig; +} + +interface MaterializedCapability { + readonly enabled: boolean; + readonly activation: ActivationMode; + readonly version: string; + readonly settings: Settings; +} + +/** Generated closed settings with every default applied and each secret leaf replaced by a slot. */ +type MaterializedSettings = + GeneratedMaterializedSettingsSchema; +type MaterializedListenersConfig = GeneratedMaterializedListenersSchema; +type MaterializedSecurityConfig = GeneratedMaterializedSecuritySchema; + +type InputFingerprint = string & { readonly __inputFingerprint: unique symbol }; +``` + +The pure compiler produces a fully resolved non-secret `StackDefinition`, a canonical non-secret +`inputFingerprint`, resolved secret inputs keyed by generated slots, and an in-memory runtime-specific +execution plan. The definition contains every logical value, including defaults, explicit capability +settings, activation, listener policy and port intent, security policy, versions, and secret-slot +references. Actual port assignments and secret bytes are persisted once in their dedicated state +fields. Together these fields are the complete materialized state; defaults are never reconstructed +during recovery. The fingerprint preserves explicit choices, omissions, normalized paths, +auxiliary-source digests, and secret shape/presence without any secret bytes. + +The private execution plan derives dependencies, reverse-stop ordering, routes, mounts, probes, +runtime ids, workload ids, semantic workload-spec hash, and concrete artifact keys. These are private +runtime state and are never durable public configuration. Every compiled workload release maps to +both a native artifact and a container artifact; the concrete key is selected only by the immutable +runtime and platform. + +Fingerprint rules are strict: + +1. An identical fingerprint reuses the exact persisted materialized definition even if package + catalog defaults or compiler code have changed. +2. A changed fingerprint is rejected while the stack is running with + `StackMustBeStoppedError`, even when current defaults would resolve the candidate to the same + definition. The running owner is never replaced implicitly. +3. While stopped, a changed fingerprint resolves a complete new definition against current + defaults and persists definition and fingerprint atomically after full validation. +4. A changed stopped definition retains only explicitly sticky values permitted by the state model: + runtime, exact capability versions from the previous definition, managed secrets, and unchanged + port assignments. + +Secret comparison is independent of the non-secret fingerprint. Managed secret omissions reuse the +persisted value and conflicting supplied values fail in every lifecycle state. Pass-through secret +declarations may change only while stopped, including when the non-secret fingerprint is unchanged. + +The compiler and all capability Modules validate unsupported exact versions and artifact mappings +before mutation. Artifact availability is not compatibility. Public version identifiers are +runtime-neutral release names; private artifact keys include runtime and platform details. + +### Start, stop, and explicit restart + +`start({ config })` and explicit `restart({ config })` are the only operations that apply +configuration. On an unconfigured identity, `start()` uses current defaults, materializes a +definition, and requires no caller secret. A configured identity with omitted config reuses its +persisted fingerprint and definition. A valid stopped start commits the complete definition, +fingerprint, ports, secrets, and a new running `desiredGeneration` atomically before reconciliation. + +On a compatible running stack, `start` first compares any supplied config and secrets. An identical +request is idempotent and returns the current `StackStatus`; any change fails with +`StackMustBeStoppedError`. If the owner or RPC release is incompatible, `start` fails with +`StackUpgradeRequiredError` and tells the CLI caller to run `supabase restart`; `start`, `openStack`, +`status`, and `logs` never replace an owner. + +`restart(options?)` is explicit and requires desired lifecycle `running`. It preflights and decodes +durable state, session-fences and quiesces the old owner through the stable maintenance protocol, +waits for ownership release, then launches the current Supervisor. It may apply newly resolved +stopped-time configuration from `options` only after the old owner has stopped at the fenced point. +Identity, data, runtime, managed secrets, and unchanged sticky records are preserved. Allowed +stopped-time options may change settings, capability versions other than an initialized database, +pass-through secrets, and exact port requests. An explicitly concurrent `stop` wins; +it fences the generation and prevents a delayed restart from relaunching the stack. + +`stop()` accepts a stopped `desiredGeneration`, rejects new activation, drains gateway traffic, +stops dependents in reverse dependency order, terminates exact native process trees or removes exact +containers, removes gateway/network resources, closes live listeners, and atomically retains logs. +It retains state, data, definitions, fingerprints, secrets, versions, ports, and cached artifacts. +Caller exit never rolls back durable running intent; the Supervisor continues reconciliation. + +`destroy()` fences destroying, completes or supersedes owner work at a safe boundary, removes exact +runtime resources and all data/logs, and deletes the state document under the registry lock. There +is no retain-data variant. + +### Versions and database immutability + +The first accepted definition persists an exact version for every enabled capability. Omitted +versions reuse existing pins; a newly enabled capability selects the current catalog release. A +stopped edit may change a non-database pin. Database version becomes immutable after database data +initialization. An exact unsupported version fails before state or data mutation; an available +artifact is not evidence of compatibility. Changing an initialized database version requires +destroy/recreate or a future explicit upgrade workflow. + +Internal database bootstrap is idempotent. If ordered migration tracking is required, it is stored +inside the database, never in `state.json`. Bootstrap creates internal roles, passwords, extensions, +schemas, and grants for the pinned version and is complete before dependents become ready. + +## Durable state, secrets, and logs + +Each identity has one authoritative owner-only document: + +```text +//state.json +``` + +Its public shape is conceptually: + +```ts +interface PersistedStackState { + readonly format: "supabase-stack-state-v1"; + readonly identity: PersistedStackIdentity; + readonly runtime: StackRuntime; + readonly desiredGeneration: number; + readonly desiredLifecycle: DesiredStackLifecycle; + readonly definition?: StackDefinition; + readonly inputFingerprint?: InputFingerprint; + readonly ports: ReadonlyArray; + readonly secrets: PersistedSecretValues; +} + +/** Generated closed schema for all managed and pass-through secret leaves. */ +type PersistedSecretValues = GeneratedPersistedSecretSchema; +``` + +The durable definition is fully materialized and contains no PIDs, timestamps, runtime ids, +workload graph, routes, mounts, probes, concrete artifact keys, or secret bytes. Runtime-specific +execution plans are rebuilt on recovery. Exact capability versions have one durable representation: +the materialized definition. Concrete port assignments and managed/pass-through secret bytes live +only in their dedicated fields. + +Every mutation validates the expected generation, builds one complete next value, writes an owner-only +temporary sibling, fsyncs as required by the platform, and atomically replaces `state.json`. A crash +therefore exposes either the complete old or complete new value. Missing or corrupt state beside +exact identity-owned data, logs, or runtime resources fails closed with `StackStateInvalidError`. +Ordinary create/open/start/destroy paths never overwrite or guess replacement state. A separate, +explicitly destructive recovery/cleanup operation may remove exact `StackId`-owned remnants after +deliberate authorization; it is outside ordinary lifecycle operations. + +Stack-managed secrets include database and internal-role passwords, JWT signing material, encryption +keys, and generated API credentials. Omission generates each required value once; a different later +value fails with `StackSecretMismatchError` in every lifecycle state. Caller-owned pass-through +secrets (SMTP, OAuth, and Function secret environment) are complete declarations and can be added, +replaced, or removed only while stopped. Effect APIs use `Redacted`; the Promise facade unwraps to +plain strings only at its public boundary. + +Known exact managed and pass-through secret strings are redacted before persistence or streaming +logs. The implementation never logs full environment maps, secret files, or secret arguments. Logs +remain potentially sensitive because transformed or derived values cannot be guaranteed to match +every secret. Log files are owner-only, bounded, and rotated. A `LogCursor` identifies retained +position; the retained-to-live handoff is atomic so a follower sees each entry at most once without +a gap. + +### State format evolution + +The first release reads and writes only `supabase-stack-state-v1`; it has no readers for prior +development formats. Once a second concrete format ships, that release must decode the still- +supported earlier format and atomically migrate the complete document before reconciliation. Use one +explicit decoder/migration per released format; do not add a generic migration framework or downgrade +guarantee. An unsupported or semantically invalid format fails closed, while the stable maintenance +stop remains available to stop a live owner. + +## Capabilities and private workloads + +The public surface names only the ten `CapabilityName` values and aggregates each capability into a +`CapabilityState`. Internally, a capability Module compiles one or more private workloads. Examples +include storage plus imgproxy, studio plus pgmeta, analytics plus vector, and database bootstrap. +Workload ids drive labels, artifact selection, exact cleanup, readiness, internal log routing, +dependency edges, and restart policy. Public log entries are attributed to their capability rather +than exposing workload ids. Capability state is an aggregation of its workloads; an individual +failed workload does not erase unrelated capability state. + +Each workload release has both native and container mappings. A strict runtime split means a +StackId is entirely native or entirely container and no workload can be switched across runtimes in +place. Unsupported runtime/platform artifact mappings fail before mutation. + +## Ownership, control, and recovery + +The Supervisor proves ownership with one atomic primitive (an exact private endpoint bind or an +advisory lock) and records a random `ownerSessionId`. The same owned endpoint carries two +versioned protocols: + +- `OwnerMaintenanceControl` is a frozen bounded-JSON protocol for probe, stable stop, and quiesce/ + replacement. Stable maintenance stop is the sole stop mechanism that must work across releases. +- Exact-release `StackControlRpc` is Effect RPC for handles, preparation, status/watch, credentials, + logs, start, restart, and destroy. Its release is pinned and version-gated. It deliberately does + not expose stop; stop always goes through the stable maintenance operation. + +Both controls share the one owned endpoint; no parallel maintenance channel exists. RPC handlers +submit work to the Supervisor and never become lifecycle owners. Protocol mismatch is an upgrade +signal, not an implicit replacement: +the client decodes and validates state, asks the old owner to quiesce only after preflight, waits for +release, launches the current Supervisor, and reconciles the accepted generation. If preflight +fails, old owner and runtime remain untouched. Errors distinguish unsupported state format from +replacement failure. + +Recovery after attachment or reboot is explicit. The new owner decodes complete state and recompiles +the persisted `StackDefinition` before inspecting only exact native/container resources. It removes +leftover ephemeral resources for stopped state, and for running state adopts resources carrying +matching `StackId`, `desiredGeneration`, and semantic workload-spec hash. Native work starts clean; +containers with stale hashes are removed and recreated. +Persistent volumes are identity-scoped and survive stop/start. A machine reboot does not auto-start +a stack. Native owner loss terminates exact process trees through a parent-death primitive or private +launcher pipe; container ingress fails closed until a current fenced session is established. + +## Reconciliation and runtime execution + +`StackReconciler` compares the accepted durable generation and materialized definition with an +observed workload snapshot, produces a deterministic plan from the in-memory execution graph, and +executes it through one runtime driver. + +- Start follows dependency order; stop and destroy use reverse dependency order. +- A workload failure marks its capability failed and consumes its restart budget, while independent + workloads continue. A dependency failure blocks dependent workloads and reports the dependency + cause without stopping unrelated branches. +- Every readiness probe has a deadline. Catalog restart policy uses bounded exponential backoff, + per-workload budgets, and a generation fence; exhausted workloads remain failed until explicit + stop/restart. +- Durable running intent remains after caller failure and the Supervisor continues reconciliation. +- Process-tree termination and container removal target exact workload identity and generation. +- Lazy activation can prepare the dependency closure and start workloads once per generation; an + activated capability remains active until stop/restart and is never evicted for idleness. + +### Artifact preparation + +`ArtifactStore` verifies native downloads and container images, publishes them atomically, and uses +Supervisor-local single-flight for identical transfers. Concurrent Supervisors may duplicate transfer +work safely; the design makes no cross-Supervisor or cross-identity deduplication promise. `prepare` +uses persisted pins or a validated prospective config, never reads or generates secrets, allocates +ports, creates networks, starts workloads, or changes durable state. + +### Ports + +```ts +type PortIntent = + | { readonly kind: "automatic" } + | { readonly kind: "exact"; readonly port: number }; + +interface HostPortAssignment { + readonly field: PortField; + readonly port: number; + readonly intent: "automatic" | "exact"; +} +``` + +Automatic assignments are globally/logically exclusive while stacks are stopped or running. The +assignment is selected once and remains sticky. Exact assignments are persisted and sticky but do +not globally exclude another stopped stack; they conflict only with a live stack or host listener +when that stack starts. Under the registry lock, `PortCoordinator` retains unchanged assignments, +selects missing automatic ports, and validates exact requests. It commits the complete state before +releasing the lock and never performs downloads or runtime work in the transaction. + +Native listeners remain bound and transfer directly to the in-process gateway. Container mode closes +its transient test socket before engine publication; the engine bind is authoritative. A host race +returns `PortUnavailableError` and retains the assignment without silently selecting another port. +Stop releases live sockets and publications but not logical records; destroy releases all records. + +### Gateway and mandatory lazy activation + +`StackGateway` is the only public ingress. HTTP owns routing, CORS, WebSockets, forwarding headers, +and error mapping. TCP copies bytes without interpreting PostgreSQL, TLS, SMTP, POP3, or STARTTLS. +Backend workloads use private network endpoints; only the gateway publishes host listeners. + +Lazy activation is mandatory for both native and container runtimes. A native gateway invokes the +Supervisor activation capability in process. A container gateway uses a dedicated ephemeral exact- +release TCP `GatewayActivationServer` owned by the Supervisor, distinct from stable maintenance. + +Before creating the gateway container, the Supervisor binds the activation server and writes an +owner-readable ephemeral file containing endpoint, random capability, `StackId`, desiredGeneration, +`gatewayInstanceId`, and `ownerSessionId`. The file is mounted read-only into the gateway; values are +never placed in labels or command arguments. The selected `ContainerEngine` adapter resolves the +host address/alias reachable from its gateway container: Docker Desktop uses +`host.docker.internal`, Linux Docker uses a host-gateway mapping, and Podman uses +`host.containers.internal` or its concrete equivalent. Unsupported engine/platform routing fails +before gateway creation. + +The server validates every fence, bounds frame size, concurrent requests, and activation deadlines, +and grants activation only. It has no engine, artifact, filesystem, stop, destroy, configuration, +credential, or log authority. Capability files and server sockets rotate or are destroyed with the +owner and gateway. A request can activate only an enabled listener for the matching generation, +gateway instance, and owner session. Activation is one-way per generation; activated workloads use +catalog restart policy until stop/restart and are never idle-evicted. HTTP activation failure maps +to 503, post-activation backend failure to 502, and TCP activation failure closes the connection. + +### Functions capability + +Functions remain one public capability. Filesystem membership and content are live inputs; requests +are oneshot and no Supervisor watcher or desired-state mutation is involved. `functionsRoot` is the +sole allowed filesystem root and the sole container bind mount. It may resolve from `projectRoot`, +but every entrypoint, import map, static file, shared module, and symlink target must remain inside +that root. Per-function entrypoints are relative to their function directory; shared code belongs +inside the root (for example `_shared`). Traversal, absolute escapes, and symlink escapes are +rejected. Container mode mounts the whole root read-only at one stable private path. + +On each request the Functions runtime resolves the slug, applies the persisted closed override, and +loads current entrypoint and module contents. Creating, editing, or deleting a function is visible on +the next request without a CLI call. A disabled override returns not found. + +`supabase functions serve` always uses the managed stack Functions capability and Edge Runtime. It +creates or opens and starts the managed stack as needed, waits for Functions readiness, prints the +gateway URL, streams logs, and observes live edits. Incompatible persisted Functions settings fail +with an upgrade-required error and require explicit `supabase restart`. Caller exit closes only its +handle; the managed stack remains until an explicit stop or destroy. + +## CLI boundaries + +The CLI reloads `config.toml` and flags for each `supabase start`, translates `CliConfig` to +`StackConfig`, and calls: + +```text +createStack({ projectRoot, name, runtime }) + │ + ├── optional stack.prepare({ config }) + └── stack.start({ config }) +``` + +Editing `config.toml` alone has no effect. A changed running config is rejected; after `supabase +stop`, the next `supabase start` resolves and atomically persists the complete new definition. +`supabase restart` is the explicit owner replacement operation and may apply newly resolved config at +its fenced stop point. `supabase status` and watch output consume complete `StackStatus` snapshots. + +The only workspace is `@supabase/stack`: + +```text +packages/stack +├── public/ Effect API, Promise facade, public schemas +├── identity/ root, worktree, branch, and StackId resolution +├── model/ StackDefinition, compiler, capability modules, catalog +├── state/ atomic state, registry, ownership, ports, secret policy +├── supervisor/ Supervisor and StackReconciler +├── preparation/ verified ArtifactStore +├── runtime/ native and container drivers plus engine adapters +├── gateway/ HTTP, TCP, and activation server +├── control/ one owner endpoint, maintenance framing, exact-release RPC +└── entrypoints/ Supervisor and gateway executables +``` + +Only `@supabase/stack/effect`, the package-root Promise facade, testing helper, and shared public +types are exported. State, catalog, drivers, workload graph, control protocols, and gateway +internals remain private. + +Expected operational failures are finite `Data.TaggedError` unions. Principal tags include: + +- `InvalidStackIdentityError`, `InvalidProjectRootError`, `InvalidStackConfigError`; +- `StackNotFoundError`, `StackOwnershipConflictError`, `StackRuntimeMismatchError`; +- `StackDefinitionRequiredError`, `StackNotRunningError`, `StackMustBeStoppedError`, + `StackLifecycleConflictError`; +- `StackStateInvalidError`, `StackStateFormatUnsupportedError`, `StackUpgradeRequiredError`, + `StackUpgradeReplacementError`; +- `StackSecretMismatchError`, `InvalidJwtSigningMaterialError`; +- `PortAllocationError`, `PortUnavailableError`; +- `GatewayAuthenticationError`, `GatewayStaleGenerationError`, `GatewayActivationError`; +- `StackPreparationError`, `ArtifactIntegrityError`, `ContainerPullError`; +- `StackReconciliationError`, `ServiceStartError`, `ServiceReadinessError`, + `ContainerEngineError`, `StackDestructionError`. + +Operation-specific aliases expose only applicable tags. The Promise facade rejects with the same +tagged values and never collapses expected failures, interruption, or defects into a generic error. + +## Construction sequence + +Build observable vertical slices in this order: + +1. Identity, durable materialized definition/fingerprint, one-owner endpoint, maintenance control, + Supervisor launch, discovery, handle scopes, and caller-exit survival. +2. Native lifecycle: capability Modules, catalog/version validation, secrets, one representative + workload graph, reconciliation, start, stop, explicit restart, destroy, status/watch, logs, + credentials, and idempotent database bootstrap. +3. Ingress and preparation: sticky ports, native listener transfer, HTTP/TCP gateway, + `GatewayActivationServer`, ArtifactStore, mandatory lazy activation, and live Functions. +4. Container execution and recovery: engine adapters, private networks, gateway-only publication, + host-gateway aliases, activation-file fencing, workload-spec-hash adoption, and owner recovery. +5. Complete catalog: every capability's native/container mappings, platform/engine activation + integration, port and secret matrices, and a small e2e smoke suite. +6. Promise facade and release: mechanical adaptation of the authoritative Effect contract, + including plain-string credentials, explicit handle closure, `Option`, streams, and tagged + failures. +7. Deferred final database-reset slice described below, after the runtime/catalog/Functions work is + stable. + +## Deferred final database-reset slice + +The rewrite removes the single all-in-one database reset operation from the primary API. The caller +owns migrations, declarative schemas, seeds, progress, and their errors. Stack owns lifecycle +fencing, stopping database dependents, exact database-data recreation, internal idempotent bootstrap, +and preventing premature activation. + +The eventual durable interface is a reset session, for example `beginDatabaseReset`, returning a +session with a database connection and explicit `complete`/`fail` operations. This API and its +persisted session shape are intentionally not frozen until this final slice. While a session exists, +the observable lifecycle is `resetting-database`, the ultimate desired lifecycle remains `running`, +and separate durable reset-session metadata fences dependents. Caller loss never silently resumes +dependents; a later caller may resume or initiate recovery. Migration history remains in the +database, not stack state. + +This is the final behavioral slice of the rewrite, after the main runtime, catalog, and Functions +work. Its integration tests cover session fencing, exact data recreation, bootstrap ordering, +explicit completion/failure, caller loss, later resumption, and mechanical Promise adaptation. + +## Testing strategy + +Public integration scenarios are authoritative. Targeted unit tests are allowed for complex pure +configuration normalization, graph planning, transitions, restart decisions, and port planning. +Real subprocess, process-tree, ownership, filesystem, socket, and transport seams remain in +integration tests. Container lifecycle and lazy activation use controlled Docker/Podman engine +integration. Direct tests of a private boundary are added only when a concrete observability gap +cannot be covered through the public surface. A small e2e suite covers only critical CLI workflows. + +### Public integration scenarios + +Identity and handles: + +- `createStack` does not read config or start workloads; `createTestStack` creates and cleans only + its unique managed identity. +- Repeated calls, branches, detached worktrees, nested roots, and names produce documented ids. +- Discovery never creates or recovers a stack; two callers share one Supervisor. +- Closing an ordinary handle never stops the managed stack; only the unique test wrapper supports + asynchronous disposal, and it destroys only its own test identity. +- Caller process exit leaves a running stack available to a second process. +- Runtime kind and engine are immutable; conflicting preferences fail. + +Materialized configuration and lifecycle: + +- First start persists a complete defaulted non-secret `StackDefinition`, `inputFingerprint`, and + separately generated secret values without requiring a caller-supplied secret. +- Identical fingerprints reuse the exact definition across catalog/default changes. +- Any changed running fingerprint fails, even when the candidate definition would compare equal. +- Stopped changes resolve current defaults and persist definition/fingerprint atomically while + retaining sticky versions, managed secrets, runtime, and unchanged ports; pass-through-only secret + changes are detected independently of the non-secret fingerprint. +- A compatible identical running `start` returns the current status; changed input fails and + incompatible ownership requires explicit restart. +- Restart fences/quiesces the old owner, preserves identity/data/runtime and unchanged sticky + values, applies only allowed explicit stopped-time changes, and loses to an explicitly concurrent + stop. +- Stop removes ephemeral resources but retains state/data/logs/artifacts; destroy removes exact data. +- Complete status snapshots start every watch subscription; logs use opaque cursors and atomic + retained-to-live handoff. + +Capabilities, workloads, and versions: + +- Every one of the ten capability Modules validates its exhaustive settings schema and compiles + native/container workload mappings. +- Independent workload failure does not stop unrelated branches; dependency failure blocks only + dependents; readiness deadlines and restart budgets are observable. +- Exact unsupported versions fail before mutation; database version is immutable after initialization. +- Artifact preparation is atomic and Supervisor-local single-flight without a cross-Supervisor + deduplication promise. +- Native and container stacks never mix workloads; concrete artifact keys remain private. + +Secrets, logs, and ports: + +- Managed secrets are unique, sticky, and mismatch-protected; pass-through secrets change only while + stopped. Effect uses `Redacted`; Promise credentials are plain strings. +- Logs redact known exact secret strings, never emit full env/secret files or arguments, remain + owner-only and bounded, and expose cursor continuity. +- Automatic port assignments remain globally/logically exclusive while stopped and running. Stopped + exact assignments may coexist and conflict only with a live stack or host listener at start. +- Native listener transfer avoids a rebind window; container bind races preserve the sticky claim. + +Gateway, activation, and Functions: + +- HTTP/TCP routing, WebSockets, TLS, STARTTLS, backpressure, half-close, and 503/502 mapping work + through the gateway. +- The ephemeral `GatewayActivationServer` uses the read-only fenced file, engine-specific host alias, + bounded frames/concurrency/deadlines, exact-release protocol, and activation-only authority. +- Lazy activation is one-way per generation, honors dependency readiness and restart policy, and has + no idle eviction. Health/status probes never activate a dormant capability. +- Function root traversal and symlink escapes are rejected; live create/edit/delete and shared + modules are visible on the next oneshot request in native and container modes. +- `supabase functions serve` uses the managed stack, waits for readiness, streams logs, observes + edits, requires explicit restart for incompatible persisted settings, and closes only its handle. + +Recovery and deferred reset (final slice, after the initial suite): + +- Corrupt state fails closed beside exact remnants; destructive recovery is explicit and identity- + scoped. +- Native owner loss terminates exact process trees; containers fail closed until recovery. Adoption + requires identity, generation, and workload-spec hash; stale/foreign resources are untouched. +- Machine reboot does not auto-start stacks; caller failure leaves durable running intent. +- Final reset-session tests are added only after the main runtime/catalog/Functions suite and cover + dependent fencing, exact data recreation, bootstrap, explicit completion/failure, caller loss, + later resumption, and Promise adaptation. + +The Effect API is the behavioral test surface. Promise tests cover only mechanical adaptation and +handle ownership. Tests synchronize on readiness, events, control connections, files, or process +exit; they do not use arbitrary sleeps, released-port reuse, global process scans, or broad cleanup. diff --git a/knip.json b/knip.json index 0dd065b891..9d3023f06d 100644 --- a/knip.json +++ b/knip.json @@ -34,12 +34,6 @@ "entry": ["src/**/*.test.ts"], "ignoreDependencies": ["undici"] }, - "packages/process-compose": { - "entry": ["src/**/*.test.ts", "tests/**/*.ts"] - }, - "packages/stack": { - "entry": ["scripts/**/*.ts", "src/**/*.test.ts", "src/daemon-node.ts", "tests/**/*.ts"] - }, "packages/cli-test-helpers": { "entry": ["src/**/*.test.ts"] } diff --git a/package.json b/package.json index ce5c67305e..82b002626f 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "fix:all": "pnpm exec turbo run lint:fix fmt:fix knip:fix && pnpm run lint:effect:fix", "lint:check": "oxlint --config .oxlintrc.json", "lint:fix": "oxlint --config .oxlintrc.json --fix", - "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack packages/process-compose", - "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack packages/process-compose", + "lint:effect:check": "oxlint --config .oxlintrc.effect.json packages/stack", + "lint:effect:fix": "oxlint --config .oxlintrc.effect.json --fix --fix-suggestions packages/stack", "fmt:check": "oxfmt --config .oxfmtrc.json --check", "fmt:fix": "oxfmt --config .oxfmtrc.json", "knip:check": "knip-bun", diff --git a/packages/process-compose/AGENTS.md b/packages/process-compose/AGENTS.md deleted file mode 100644 index 274da23005..0000000000 --- a/packages/process-compose/AGENTS.md +++ /dev/null @@ -1,23 +0,0 @@ -# Process Compose - -Effect V4 service orchestrator — manages a dependency graph of services with health checks, log streaming, and lifecycle management. - -## Architecture - -See [`docs/architecture.md`](docs/architecture.md) for the full architecture document with diagrams. - -- `ServiceDef.ts` — Pure data types for service definitions -- `ServiceState.ts` — Runtime state machine -- `DependencyGraph.ts` — Topological ordering using `effect/Graph` -- `HealthProbe.ts` — Health check runner (HTTP/exec/TCP probes) via `ChildProcessSpawner` -- `LogBuffer.ts` — Per-service log capture + streaming via `PubSub` -- `Orchestrator.ts` — Core coordinator using `FiberMap` + `Deferred` + `SubscriptionRef` - -## Testing - -Use `bun run test` (not `bun test`) to run tests — we use vitest. - -Uses `@effect/vitest` with `it.effect` / `it.live`. Mock factories in `tests/helpers/mocks.ts`. - -Always run the relevant unit and integration tests for the workspace you changed. -Do not automatically run unrelated e2e tests. Only run targeted e2e coverage when the user asks, or when the specific command or surface you touched needs it. diff --git a/packages/process-compose/CLAUDE.md b/packages/process-compose/CLAUDE.md deleted file mode 120000 index 47dc3e3d86..0000000000 --- a/packages/process-compose/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/packages/process-compose/README.md b/packages/process-compose/README.md deleted file mode 100644 index cc40625bb1..0000000000 --- a/packages/process-compose/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# `@supabase/process-compose` - -An Effect-based TypeScript library for supervising dependency-ordered process graphs under Bun or -Node.js. - -The package accepts in-memory `ServiceDef` values and provides lifecycle control, readiness, -health probes, restart policies, state streams, log streams, and optional orphan supervision. It -does not expose a CLI, parse YAML, or run an HTTP server. - -```ts -import { buildGraph, Orchestrator } from "@supabase/process-compose"; -import { Effect } from "effect"; - -const graph = await Effect.runPromise( - buildGraph([ - { name: "database", command: "postgres" }, - { - name: "api", - command: "api-server", - dependencies: [{ service: "database", condition: "healthy" }], - }, - ]), -); - -// Orchestrator.layer(graph) requires a LogBuffer layer and the caller's -// ChildProcessSpawner Adapter. -const orchestratorLayer = Orchestrator.layer(graph); -``` - -See [the architecture guide](./docs/architecture.md) for the lifecycle, supervision, and compiled -runtime contracts. - -## Development - -Repo-wide quality checks run from the repository root: - -```sh -pnpm check:all -pnpm fix:all -``` - -Package-local checks and tests run from `packages/process-compose`: - -```sh -pnpm types:check -pnpm run test:unit && pnpm run test:integration -``` diff --git a/packages/process-compose/docs/architecture.md b/packages/process-compose/docs/architecture.md deleted file mode 100644 index e265b17ad6..0000000000 --- a/packages/process-compose/docs/architecture.md +++ /dev/null @@ -1,314 +0,0 @@ -# Architecture of `@supabase/process-compose` - -`@supabase/process-compose` supervises an in-memory dependency graph of operating-system -processes. It is a generic Effect Module: it has no Supabase service knowledge, project-file -parser, CLI, or management HTTP server. - -## Why the process manager is TypeScript - -The local stack needs one lifecycle model for native executables and Docker-backed processes, plus -in-process access to typed state streams, log streams, lifecycle hooks, and scoped cleanup. Keeping -that model in TypeScript lets `@supabase/stack` compose it directly with Effect resources without -maintaining a second configuration and transport protocol to a Go process-manager binary. Docker -Compose would make Docker the orchestration model and would prevent the native-first and -sandbox-friendly runtime. The package borrows useful process-compose concepts—dependency -conditions, health checks, and restart policies—but deliberately implements only the library -capabilities needed by current callers. - -## Module map - -```mermaid -flowchart LR - Def["ServiceDef values"] --> Graph["DependencyGraph"] - Graph --> Orch["Orchestrator"] - Orch --> Spawn["ChildProcessSpawner Adapter"] - Orch --> State["ServiceState streams"] - Orch --> Logs["LogBuffer"] - Orch --> Probe["HealthProbe"] - Orch --> Supervisor["Optional supervisor runtime"] - Probe --> Transition["ServiceTransition"] - Orch --> Transition -``` - -The public package export is `src/index.ts`. The principal Interface is `Orchestrator`; callers -construct its layer from a validated `ResolvedGraph`, a `ChildProcessSpawner` Adapter, and a shared -`LogBuffer`. - -## Service definitions and dependency graph - -`ServiceDef` describes one process: - -- executable, arguments, environment, and working directory; -- dependencies and a dependency wait timeout; -- optional HTTP, TCP, or exec health check with separate startup and liveness failure thresholds; -- shutdown signal and grace period; -- restart policy and restart budget; -- `started` and `healthy` lifecycle hooks; -- in-process cleanup and optional external orphan supervision. - -`buildGraph()` removes definitions with `enabled: false`, validates every referenced dependency, -rejects cycles, and returns topological start and reverse-topological stop orders. Dependencies use -one of three conditions: - -| Condition | Satisfied when | -| ----------- | -------------------------------------------------------------------------------------------------- | -| `started` | The dependency is desired to run and has spawned, is healthy/unhealthy, or completed successfully. | -| `healthy` | The dependency is desired to run and currently `Healthy`. | -| `completed` | The dependency reached `Stopped` with exit code `0`; a non-zero completion fails the dependent. | - -`startService(name)` starts the requested definition and its transitive dependencies. -`stopService(name)` and `restartService(name)` also include active dependents so a caller cannot -leave an already-running dependent attached to a restarted dependency. The pure restart-closure -calculation preserves inactive connector services on a dependency path to an active descendant; -otherwise restarting the descendant without its connector would violate graph order. - -`updateServiceDefinition(name, replacement)` validates a graph with the replacement and then swaps -the graph used for subsequent starts and restarts. It does not mutate a process generation that is -already running. The replacement name is normalized to the selected name. - -## Desired state and observed state - -Each definition owns one stable `SubscriptionRef` for the lifetime of the -orchestrator. State contains both observed lifecycle status and caller-owned intent: - -```ts -type ServiceDesiredState = "inactive" | "running" | "stopped"; - -interface ServiceState { - readonly name: string; - readonly status: - | "Pending" - | "Starting" - | "Running" - | "Healthy" - | "Unhealthy" - | "Stopping" - | "Stopped" - | "Failed" - | "Restarting"; - readonly pid: number | null; - readonly exitCode: number | null; - readonly restartCount: number; - readonly startedAt: number | null; - readonly error: string | null; - readonly desired: ServiceDesiredState; -} -``` - -`inactive` means the definition has not been requested, `running` means the caller wants the -service maintained, and `stopped` records an explicit stop. Desired state is independent of -intermediate observed states and is what prevents an explicit stop from being undone by restart -policy. - -`pid` identifies only a currently live process. Process exit, supervisory termination, hook -failure, restart, and forced shutdown clear it. A health-triggered termination does not fabricate -an `exitCode`; the code remains `null` unless the process itself reports an exit. - -`ServiceState` extends `Data.Class`, so Effect's `Equal.equals` can compare values structurally. It -does not make two separately allocated objects equal under JavaScript `===`, and -`SubscriptionRef.set` is not itself a distinct-update filter. Callers that want to suppress -redundant publications must compare before writing. - -`ServiceTransition` is the only normal path for observed-status changes. `applyEvent()` rejects -illegal `(status, event)` pairs by returning `null`; `transition()` applies a legal event atomically -through `SubscriptionRef.modifyEffect`. Races such as a late health callback during shutdown are -therefore ignored without corrupting state. The transition classification is keyed by every event -tag, so adding an event fails type checking until its legal source statuses are defined. - -## Lifecycle of one process generation - -For each requested definition, `Orchestrator` runs this sequence: - -1. Set desired state to `running` and install one lifecycle fiber in a `FiberMap`. -2. Call `beforeStart`, if supplied. It runs once for initial startup and again before each restart - backoff, allowing a caller to reserve external resources. -3. Wait for dependencies, bounded by `dependencyTimeoutSeconds` (default: 120 seconds). -4. Transition to `Starting`. -5. Call `beforeSpawn` immediately before every spawn. A caller can use it to release a reserved port - as late as possible. For a supervised child there is still a spawn-to-bind window because no - supervisor/child bind handshake exists. -6. Spawn either the configured process or its optional supervisor. -7. Register a scoped finalizer before exposing `Running`. -8. Run `started` hooks sequentially. Only successful hooks allow `ProcessSpawned` to publish - `Running`. -9. Stream stdout and stderr into `LogBuffer`. -10. Run the health loop, or run `healthy` hooks immediately when no health check exists, then - publish `Healthy`. -11. Race process exit against unhealthy or hook failure, finalize the child, and apply restart - policy to process-exit and unhealthy causes. - -The service keeps the same state stream across restart generations. Restart backoff is -`min(30 seconds, 2^(restartCount - 1))`. - -All public lifecycle mutations (`start`, `startService`, `stop`, `stopService`, `restartService`, -and `updateServiceDefinition`) share one semaphore. The semaphore serializes changes to the graph, -desired-state refs, and `FiberMap`; state reads and lifecycle fibers remain concurrent. This keeps -commands composable without allowing a concurrent stop or graph update to observe a half-applied -mutation. - -A no-health-check, `restart: "no"` process is treated as one-shot work. A small isolated poll of -`ChildProcessHandle.isRunning` compensates for adapters that can report process completion before -their `exitCode` Effect becomes observable; it is not part of the general lifecycle loop. - -### Lifecycle hooks - -Hooks are caller-supplied Effects triggered on `started` or `healthy`. Hooks for one trigger run in -declaration order. Each hook has a timeout (default: 30 seconds) and either: - -- `fail`: publish `HookFailed` and stop running later hooks for that trigger; -- `ignore`: append a diagnostic log and continue. - -The supplied logger writes tagged stdout/stderr lines into the service's normal log stream. A -failing hook finalizes its process generation before publishing terminal `Failed` state. - -## Health and readiness - -`HealthProbe` supports: - -- HTTP: successful for a 2xx response; -- TCP: successful when a connection opens; -- exec: successful for exit code `0`. - -After `initialDelaySeconds`, probes repeat every `periodSeconds`. Each attempt is bounded by -`timeoutSeconds`; consecutive success and failure counters reset each other. Before a process -generation has ever become healthy, `startupFailureThreshold` controls when it becomes -`Unhealthy`. It defaults to `failureThreshold` for compatibility. After the first healthy result, -all later failures use `failureThreshold`, including after an unhealthy-to-healthy recovery. -Initial probe failures are therefore observable rather than leaving the service indefinitely in -`Running`. - -HTTP probes pass the fiber cancellation signal to `fetch` and apply the configured timeout through -Effect interruption. TCP probes use an Effect callback with socket cleanup, so cancellation closes -the socket instead of leaving an in-flight connection behind. - -An unhealthy process uses the same pure restart-budget decision as a process exit. When restart is -enabled and the budget is exhausted, the supervisor terminates the child and publishes `Failed` -with `pid: null`, `exitCode: null`, and a stable health-exhaustion error. With restart policy `no`, -the live process remains observable as `Unhealthy`. - -`waitReady(name)` is intentionally unbounded. For long-running definitions it succeeds at -`Healthy` and fails at a non-restarting terminal state. A `restart: "no"` definition without a -health check is treated as one-shot work, where successful completion is readiness; a -health-checked definition remains subject to health readiness even when restart is disabled. -`waitAllReady()` considers only definitions whose desired state is `running`, so intentionally -inactive definitions do not block lazy callers. Higher-level Modules own any finite user-facing -deadline. - -## Restart policies - -| Policy | Exit behavior | Unhealthy behavior | -| ---------------- | ---------------------------------------------------- | ------------------------------------------------- | -| `no` | Never restart. | Leave the live process observable as `Unhealthy`. | -| `on-failure` | Restart non-zero exits. | Restart. | -| `always` | Restart every exit. | Restart. | -| `unless-stopped` | Restart every exit while desired state is `running`. | Restart. | - -`maxRestarts: 0` means unlimited restarts when the selected policy allows one. Manual stop first -sets desired state to `stopped`, so it does not re-enter the restart loop. - -## Shutdown and cleanup ownership - -Removing a service from the `FiberMap`, clearing the map, or closing the orchestrator scope -interrupts its lifecycle fiber. The generation finalizer: - -1. sends the configured shutdown signal (default `SIGTERM`); -2. waits for the configured per-process timeout (default 10 seconds); -3. falls back to `SIGKILL`; -4. runs the in-process `cleanup` Effect. - -Whole-graph stop sets desired state first and then stops dependents before dependencies. The global -shutdown budget defaults to 60 seconds. If that budget expires, the orchestrator logs the timeout -and force-terminates active children before waiting for teardown to finish. Services that were -running reach terminal `Stopped` state with `pid: null` and exit code `143`; stop does not fail with -`ShutdownTimeoutError`. - -In-process cleanup and orphan supervision solve different failure modes: - -- `cleanup` runs while the owner Effect runtime is alive and can use its dependencies. -- `supervision.orphanCleanup` is serializable work executed by a detached supervisor when the - owner's stdin closes, its PID disappears, the supervisor receives a termination signal, or the - managed child exits while cleanup is configured. - -`ExternalCleanupAction` supports a shell-free `RunCommand` with an executable, argument array, and -optional timeout (default 5 seconds), plus `RemovePath` for filesystem cleanup. Cleanup commands -receive the managed child's sanitized environment, without the supervisor self-dispatch protocol -variables. The supervisor rejects a malformed decoded cleanup contract before spawning the child, -while individual execution failures remain best-effort. Callers must choose idempotent commands -because owner-loss signals can race. `RunCommand` actions execute serially, so their aggregate -duration can approach the sum of their individual timeouts. Each command runs in its own process -group on Unix, while Windows uses `taskkill /T`; a timeout therefore terminates the command tree -rather than only its root. Filesystem retry delays may overlap command execution. - -## Supervisor runtime - -A definition opts into supervision by setting `supervision`. Instead of spawning the configured -command directly, `Supervisor.makeSupervisedCommand()` launches `supervisor-runtime.ts`. The -supervisor: - -- starts the actual command in its own process group on Unix; -- pipes child stdout/stderr back to the orchestrator; -- watches both owner stdin and owner PID; -- kills the entire child tree on owner loss or a shutdown signal; -- applies graceful-then-forceful termination; -- runs serializable orphan cleanup before it exits. - -Its coordination core is one scoped Effect program: child exit and owner-loss signals complete -`Deferred`s, owner liveness is checked by a supervised fiber on a `Schedule`, and cleanup command -timeouts use Effect interruption. The source-file and compiled self-dispatch functions are the -only outer process adapters that call `Effect.runPromise`. - -This extra process is required for abrupt owner death: an Effect finalizer cannot run after its -own process has already disappeared. Ordinary definitions avoid the extra process. - -## Compiled Bun self-dispatch - -Development runtimes can execute `supervisor-runtime.ts` by file path. A Bun single-file executable -cannot: `process.execPath` points back to the compiled CLI, and source URLs may live under Bun's -virtual filesystem. The package therefore uses three internal environment variables: - -| Variable | Role | -| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH` | Enabled by the compiled parent when this package is loaded from `file:///$bunfs/`; tells later spawns to re-enter the same executable. | -| `PROCESS_COMPOSE_RUN_SUPERVISOR` | Selects the supervisor runtime when the executable re-enters its main entrypoint. | -| `PROCESS_COMPOSE_SUPERVISOR_CONFIG` | Carries the base64url-encoded supervisor configuration for that runtime. | - -The CLI entrypoint calls `enableSupervisorSelfDispatchForCompiledBun()` before normal command -dispatch, checks `isSupervisorRuntimeRequested()`, and invokes `runSupervisorRuntimeFromEnv()`. -The supervisor removes all three variables before starting the managed command, preventing the -protocol from leaking recursively into a service. Contract tests run the same encoded supervisor -configuration through both the source-file argument path and environment-based self-dispatch path. - -The stack daemon has a separate Supabase-owned marker, `SUPABASE_STACK_RUN_DAEMON`; it is documented -in [the compiled Bun re-entry section of the stack architecture guide](../../stack/docs/architecture.md#platform-re-entry-compiled-bun). - -These contracts exist because compiled-child behavior caused two user-visible incidents: - -- [CLI-1452](https://linear.app/supabase/issue/CLI-1452/compiled-bun-compile-next-binary-cant-run-supabase-functions-dev): compiled runtime paths and native watcher bindings were unsafe for functions development. -- [CLI-1453](https://linear.app/supabase/issue/CLI-1453/compiled-bun-compile-next-binary-start-detach-daemon-fork-ignores): detached startup re-entered the CLI instead of the daemon. - -## Logs - -`LogBuffer` holds a bounded per-service history and a bounded merged history (10,000 entries each), -plus live per-service and merged sliding `PubSub` streams. `historyAll` can filter by service names. -Streams contain new entries only; callers explicitly request history when they need replay. Live -streams intentionally drop the oldest queued entries for a slow subscriber so process stdout and -stderr draining cannot block; the bounded history refs remain the source of truth for recent -output. Per-service stream and history initialization is serialized with a semaphore to make -first-use concurrent appends deterministic. - -When a process exits unexpectedly or becomes unhealthy, the orchestrator appends recent buffered -output to its diagnostics. - -## Error model - -Graph construction can fail with `MissingDependencyError` or `CyclicDependencyError`. Lifecycle -lookup uses `ServiceNotFoundError`; spawn preparation uses `SpawnError`; readiness uses -`ServiceReadyError`. Global shutdown timeout is logged and force-cleared. - -## Testing through Interfaces - -- Pure unit tests cover graph construction, state transitions, health counters, and log buffering. -- Orchestrator integration tests provide a stateful `ChildProcessSpawner` Adapter and assert on - public states, desired state, logs, hooks, dependency ordering, restart, and cleanup. -- Supervisor runtime tests use real subprocesses because owner-loss and process-tree behavior cross - the process Seam. diff --git a/packages/process-compose/package.json b/packages/process-compose/package.json deleted file mode 100644 index ac6374eab5..0000000000 --- a/packages/process-compose/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@supabase/process-compose", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "test": "pnpm run test:unit && pnpm run test:integration", - "test:unit": "pnpm exec turbo run @supabase/process-compose#test:unit:run --", - "test:unit:run": "bun --bun vitest run --project unit --coverage.reportsDirectory=coverage/unit", - "test:integration": "pnpm exec turbo run @supabase/process-compose#test:integration:run --", - "test:integration:run": "bun --bun vitest run --project integration --coverage.reportsDirectory=coverage/integration", - "types:check": "tsc --noEmit" - }, - "dependencies": { - "effect": "catalog:" - }, - "devDependencies": { - "@effect/platform-bun": "catalog:", - "@effect/vitest": "catalog:", - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@vitest/coverage-istanbul": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/packages/process-compose/src/DependencyGraph.ts b/packages/process-compose/src/DependencyGraph.ts deleted file mode 100644 index d36ac7de63..0000000000 --- a/packages/process-compose/src/DependencyGraph.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Effect, Graph } from "effect"; -import type { DependencyCondition, ServiceDef } from "./ServiceDef.ts"; -import { CyclicDependencyError, MissingDependencyError } from "./errors.ts"; - -export interface ResolvedGraph { - readonly startOrder: ReadonlyArray; - readonly stopOrder: ReadonlyArray; - readonly startOrderFor: (name: string) => ReadonlyArray; - readonly dependenciesOf: ( - name: string, - ) => ReadonlyArray<{ def: ServiceDef; condition: DependencyCondition }>; - readonly dependentsOf: (name: string) => ReadonlyArray; -} - -/** - * Builds a resolved dependency graph from a list of service definitions. - * - * Filters out disabled services, validates all dependency references exist, - * detects cycles, and computes start/stop ordering via topological sort. - * - * Fails with: - * - `MissingDependencyError` if a dependency references a non-existent service - * - `CyclicDependencyError` if the graph contains a cycle - */ -export const buildGraph = ( - services: ReadonlyArray, -): Effect.Effect => - Effect.gen(function* () { - // Filter out disabled services - const enabled = services.filter((s) => s.enabled !== false); - - // Build the directed graph - // Edge direction: FROM dependency TO dependent - // This ensures topo sort yields dependencies before their dependents - const nodeByName = new Map(); - const serviceByNode = new Map(); - - let missingDepError: MissingDependencyError | undefined; - - const graph = Graph.directed((mutable) => { - // Add all enabled services as nodes - for (const svc of enabled) { - const idx = Graph.addNode(mutable, svc); - nodeByName.set(svc.name, idx); - serviceByNode.set(idx, svc); - } - - // Add edges: dependency -> dependent - for (const svc of enabled) { - const deps = svc.dependencies ?? []; - for (const dep of deps) { - const depIdx = nodeByName.get(dep.service); - if (depIdx === undefined) { - missingDepError = new MissingDependencyError({ - service: svc.name, - dependency: dep.service, - }); - return; - } - const svcIdx = nodeByName.get(svc.name)!; - // Edge: depIdx (dep) -> svcIdx (dependent) - Graph.addEdge(mutable, depIdx, svcIdx, dep.condition); - } - } - }); - - if (missingDepError !== undefined) { - return yield* missingDepError; - } - - // Check for cycles before calling topo (which would throw a generic GraphError) - if (!Graph.isAcyclic(graph)) { - // Find nodes involved in cycle for the error message - const cycleNodes: Array = []; - for (const [, svc] of Graph.nodes(graph)) { - cycleNodes.push(svc.name); - } - return yield* new CyclicDependencyError({ cycle: cycleNodes.join(" -> ") }); - } - - // Compute start order via topological sort (yields dependencies first) - const startOrder: Array = Array.from(Graph.values(Graph.topo(graph))); - - // Stop order is reverse of start order - const stopOrder: Array = [...startOrder].reverse(); - - // Map from name to NodeIndex for quick lookup - const getNodeIndex = (name: string): Graph.NodeIndex | undefined => nodeByName.get(name); - - const startOrderFor = (name: string): ReadonlyArray => { - const nodeIdx = getNodeIndex(name); - if (nodeIdx === undefined) return []; - - // Collect all transitive dependencies by following "incoming" edges - // (edges point FROM dep TO dependent, so "incoming" from a node finds its deps) - const reachable = new Set(); - - // DFS traversal following incoming edges to find all transitive deps - const stack = [nodeIdx]; - while (stack.length > 0) { - const current = stack.pop()!; - if (reachable.has(current)) continue; - reachable.add(current); - - // Edges point from dependency to dependent, so predecessors are dependencies. - stack.push(...Graph.predecessors(graph, current)); - } - - // Return the nodes in the reachable set, in start order - return startOrder.filter((svc) => { - const idx = getNodeIndex(svc.name); - return idx !== undefined && reachable.has(idx); - }); - }; - - const dependenciesOf = ( - name: string, - ): ReadonlyArray<{ def: ServiceDef; condition: DependencyCondition }> => { - const nodeIdx = getNodeIndex(name); - if (nodeIdx === undefined) return []; - - // Direct dependencies: follow incoming edges from this node - const result: Array<{ def: ServiceDef; condition: DependencyCondition }> = []; - for (const [, edge] of Graph.edges(graph)) { - if (edge.target !== nodeIdx) continue; - const depDef = serviceByNode.get(edge.source); - if (depDef !== undefined) { - result.push({ def: depDef, condition: edge.data }); - } - } - - return result; - }; - - const dependentsOf = (name: string): ReadonlyArray => { - const nodeIdx = getNodeIndex(name); - if (nodeIdx === undefined) return []; - - // Direct dependents: follow outgoing edges from this node - // Edges point FROM dependency TO dependent. - const result: Array = []; - for (const dependentIdx of Graph.successors(graph, nodeIdx)) { - const dependent = serviceByNode.get(dependentIdx); - if (dependent !== undefined) { - result.push(dependent); - } - } - - return result; - }; - - return { startOrder, stopOrder, startOrderFor, dependenciesOf, dependentsOf }; - }); diff --git a/packages/process-compose/src/DependencyGraph.unit.test.ts b/packages/process-compose/src/DependencyGraph.unit.test.ts deleted file mode 100644 index e8defcf233..0000000000 --- a/packages/process-compose/src/DependencyGraph.unit.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; -import { buildGraph } from "./DependencyGraph.ts"; -import { CyclicDependencyError, MissingDependencyError } from "./errors.ts"; -import type { ServiceDef } from "./ServiceDef.ts"; - -const svc = ( - name: string, - deps?: Array<{ service: string; condition: "started" | "healthy" | "completed" }>, -): ServiceDef => ({ - name, - command: `run-${name}`, - dependencies: deps ?? [], -}); - -const runGraph = (services: ReadonlyArray) => Effect.runSync(buildGraph(services)); - -describe("DependencyGraph", () => { - it("empty graph: no services -> empty start/stop order", () => { - const graph = runGraph([]); - expect(graph.startOrder).toEqual([]); - expect(graph.stopOrder).toEqual([]); - }); - - it("single service with no deps -> startOrder contains that service", () => { - const a = svc("a"); - const graph = runGraph([a]); - expect(graph.startOrder).toHaveLength(1); - expect(graph.startOrder[0]).toBe(a); - }); - - it("linear chain: A depends on B, B depends on C -> startOrder is [C, B, A]", () => { - const c = svc("c"); - const b = svc("b", [{ service: "c", condition: "started" }]); - const a = svc("a", [{ service: "b", condition: "started" }]); - - const graph = runGraph([a, b, c]); - const names = graph.startOrder.map((s) => s.name); - expect(names).toEqual(["c", "b", "a"]); - }); - - it("diamond dependency: A->B, A->C, B->D, C->D -> D comes first, A comes last", () => { - const d = svc("d"); - const b = svc("b", [{ service: "d", condition: "started" }]); - const c = svc("c", [{ service: "d", condition: "started" }]); - const a = svc("a", [ - { service: "b", condition: "started" }, - { service: "c", condition: "started" }, - ]); - - const graph = runGraph([a, b, c, d]); - const names = graph.startOrder.map((s) => s.name); - - // D must come first, A must come last - expect(names[0]).toBe("d"); - expect(names[names.length - 1]).toBe("a"); - // B and C must come before A - expect(names.indexOf("b")).toBeLessThan(names.indexOf("a")); - expect(names.indexOf("c")).toBeLessThan(names.indexOf("a")); - // D must come before B and C - expect(names.indexOf("d")).toBeLessThan(names.indexOf("b")); - expect(names.indexOf("d")).toBeLessThan(names.indexOf("c")); - }); - - it("cycle detection: A->B, B->A -> fails with CyclicDependencyError", () => { - const a = svc("a", [{ service: "b", condition: "started" }]); - const b = svc("b", [{ service: "a", condition: "started" }]); - - expect(() => runGraph([a, b])).toThrow(CyclicDependencyError); - }); - - it("missing dependency: A depends on nonexistent -> fails with MissingDependencyError", () => { - const a = svc("a", [{ service: "nonexistent", condition: "started" }]); - - expect(() => runGraph([a])).toThrow(MissingDependencyError); - }); - - it("startOrderFor single service returns transitive deps + the service itself in topo order", () => { - const c = svc("c"); - const b = svc("b", [{ service: "c", condition: "started" }]); - const a = svc("a", [{ service: "b", condition: "started" }]); - - const graph = runGraph([a, b, c]); - const order = graph.startOrderFor("a"); - const names = order.map((s) => s.name); - - expect(names).toEqual(["c", "b", "a"]); - }); - - it("startOrderFor service with no deps returns just that service", () => { - const a = svc("a"); - const b = svc("b"); - - const graph = runGraph([a, b]); - const order = graph.startOrderFor("a"); - - expect(order).toHaveLength(1); - expect(order[0]).toBe(a); - }); - - it("startOrderFor returns only the reachable subgraph, not unrelated services", () => { - const c = svc("c"); - const b = svc("b", [{ service: "c", condition: "started" }]); - const a = svc("a", [{ service: "b", condition: "started" }]); - const x = svc("x"); // unrelated - - const graph = runGraph([a, b, c, x]); - const order = graph.startOrderFor("b"); - const names = order.map((s) => s.name); - - // Only b and its dep c, not a or x - expect(names).toContain("b"); - expect(names).toContain("c"); - expect(names).not.toContain("a"); - expect(names).not.toContain("x"); - expect(names.indexOf("c")).toBeLessThan(names.indexOf("b")); - }); - - it("stopOrder is reverse of startOrder", () => { - const c = svc("c"); - const b = svc("b", [{ service: "c", condition: "started" }]); - const a = svc("a", [{ service: "b", condition: "started" }]); - - const graph = runGraph([a, b, c]); - expect(graph.stopOrder).toEqual([...graph.startOrder].reverse()); - }); - - it("dependenciesOf returns correct direct dependencies with conditions", () => { - const b = svc("b"); - const c = svc("c"); - const a = svc("a", [ - { service: "b", condition: "started" }, - { service: "c", condition: "healthy" }, - ]); - - const graph = runGraph([a, b, c]); - const deps = graph.dependenciesOf("a"); - - expect(deps).toHaveLength(2); - - const bDep = deps.find((d) => d.def.name === "b"); - const cDep = deps.find((d) => d.def.name === "c"); - - expect(bDep).toBeDefined(); - expect(bDep?.condition).toBe("started"); - expect(cDep).toBeDefined(); - expect(cDep?.condition).toBe("healthy"); - }); - - it("dependenciesOf returns empty array for a service with no deps", () => { - const a = svc("a"); - const graph = runGraph([a]); - expect(graph.dependenciesOf("a")).toEqual([]); - }); - - it("disabled services are filtered out", () => { - const a = svc("a"); - const disabled: ServiceDef = { ...svc("b"), enabled: false }; - - const graph = runGraph([a, disabled]); - const names = graph.startOrder.map((s) => s.name); - - expect(names).toContain("a"); - expect(names).not.toContain("b"); - }); - - it("disabled dependency is excluded and dependent referencing it is still valid when dep is disabled", () => { - const b: ServiceDef = { ...svc("b"), enabled: false }; - // a depends on b, but b is disabled — b won't be in the graph - // This means a's dep on b is orphaned, but since b is disabled - // the dep reference should be ignored - // Actually: the instructions say "filter out disabled services" — if b is disabled, - // it's not in the graph. If a still lists b as a dep, that would cause MissingDependencyError. - // This test verifies disabled service and its dependents: if b is disabled, a - // that only depends on b is still valid (b's dep is just missing = filtered). - // Let's test a simple case: disabled service is simply not in start order. - const a = svc("a"); - const graph = runGraph([a, b]); - expect(graph.startOrder.map((s) => s.name)).toEqual(["a"]); - }); - - it("multiple independent services: services with no deps both appear in startOrder", () => { - const a = svc("a"); - const b = svc("b"); - - const graph = runGraph([a, b]); - const names = graph.startOrder.map((s) => s.name); - - expect(names).toContain("a"); - expect(names).toContain("b"); - expect(names).toHaveLength(2); - }); - - it("dependentsOf returns direct dependents", () => { - const c = svc("c"); - const b = svc("b", [{ service: "c", condition: "started" }]); - const a = svc("a", [{ service: "c", condition: "healthy" }]); - - const graph = runGraph([a, b, c]); - const dependents = graph.dependentsOf("c"); - const names = dependents.map((d) => d.name).sort(); - - expect(names).toEqual(["a", "b"]); - }); - - it("dependentsOf returns empty array for leaf nodes", () => { - const c = svc("c"); - const b = svc("b", [{ service: "c", condition: "started" }]); - const a = svc("a", [{ service: "b", condition: "started" }]); - - const graph = runGraph([a, b, c]); - expect(graph.dependentsOf("a")).toEqual([]); - }); - - it("dependentsOf returns empty for unknown service", () => { - const a = svc("a"); - const graph = runGraph([a]); - expect(graph.dependentsOf("unknown")).toEqual([]); - }); -}); diff --git a/packages/process-compose/src/HealthProbe.ts b/packages/process-compose/src/HealthProbe.ts deleted file mode 100644 index 3cfcef817d..0000000000 --- a/packages/process-compose/src/HealthProbe.ts +++ /dev/null @@ -1,112 +0,0 @@ -import * as Net from "node:net"; -import { Duration, Effect, Match, Ref, Schedule } from "effect"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { defaults, type HealthCheckConfig, type ProbeConfig } from "./ServiceDef.ts"; - -const executeProbe = ( - probe: ProbeConfig, - timeoutSeconds: number, -): Effect.Effect< - boolean, - never, - ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient -> => { - return Match.valueTags(probe, { - Http: (probe) => - HttpClient.get(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`).pipe( - Effect.timeout(Duration.seconds(timeoutSeconds)), - Effect.map((res) => res.status >= 200 && res.status < 300), - Effect.orElseSucceed(() => false), - ), - Exec: (probe) => { - const cmd = ChildProcess.make(probe.command, probe.args, { - env: probe.env, - extendEnv: true, - }); - return ChildProcessSpawner.ChildProcessSpawner.use((spawner) => - spawner.exitCode(cmd).pipe( - Effect.map((code) => code === 0), - Effect.timeout(Duration.seconds(timeoutSeconds)), - Effect.map((opt) => opt ?? false), - ), - ).pipe(Effect.orElseSucceed(() => false)); - }, - Tcp: (probe) => - Effect.callback((resume) => { - const socket = Net.createConnection({ host: probe.host, port: probe.port }); - socket.once("connect", () => { - socket.destroy(); - resume(Effect.succeed(true)); - }); - socket.once("error", () => { - socket.destroy(); - resume(Effect.succeed(false)); - }); - return Effect.sync(() => socket.destroy()); - }).pipe( - Effect.timeout(Duration.seconds(timeoutSeconds)), - Effect.map((opt) => opt ?? false), - Effect.orElseSucceed(() => false), - ), - }); -}; - -export interface HealthProbeCallbacks { - readonly onHealthy: Effect.Effect; - readonly onUnhealthy: Effect.Effect; -} - -export const runHealthProbe = (config: { - readonly name: string; - readonly healthCheck: HealthCheckConfig; - readonly callbacks: HealthProbeCallbacks; -}): Effect.Effect => - Effect.gen(function* () { - const hc = config.healthCheck; - const initialDelay = hc.initialDelaySeconds ?? defaults.healthCheck.initialDelaySeconds; - const period = hc.periodSeconds ?? defaults.healthCheck.periodSeconds; - const timeout = hc.timeoutSeconds ?? defaults.healthCheck.timeoutSeconds; - const successThreshold = hc.successThreshold ?? defaults.healthCheck.successThreshold; - const failureThreshold = hc.failureThreshold ?? defaults.healthCheck.failureThreshold; - const startupFailureThreshold = hc.startupFailureThreshold ?? failureThreshold; - - if (initialDelay > 0) { - yield* Effect.sleep(Duration.seconds(initialDelay)); - } - - const counters = yield* Ref.make({ successes: 0, failures: 0 }); - let phase: "Starting" | "Healthy" | "Unhealthy" = "Starting"; - let hasEverBeenHealthy = false; - - yield* Effect.repeat( - Effect.gen(function* () { - const success = yield* executeProbe(hc.probe, timeout); - - if (success) { - const { successes } = yield* Ref.getAndUpdate(counters, (c) => ({ - successes: c.successes + 1, - failures: 0, - })); - if (phase !== "Healthy" && successes + 1 >= successThreshold) { - phase = "Healthy"; - hasEverBeenHealthy = true; - yield* config.callbacks.onHealthy; - } - } else { - const { failures } = yield* Ref.getAndUpdate(counters, (c) => ({ - successes: 0, - failures: c.failures + 1, - })); - const activeFailureThreshold = hasEverBeenHealthy - ? failureThreshold - : startupFailureThreshold; - if (phase !== "Unhealthy" && failures + 1 >= activeFailureThreshold) { - phase = "Unhealthy"; - yield* config.callbacks.onUnhealthy; - } - } - }), - Schedule.spaced(Duration.seconds(period)), - ); - }).pipe(Effect.asVoid); diff --git a/packages/process-compose/src/HealthProbe.unit.test.ts b/packages/process-compose/src/HealthProbe.unit.test.ts deleted file mode 100644 index fbc6f96d36..0000000000 --- a/packages/process-compose/src/HealthProbe.unit.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- This test uses a temporary filesystem fixture at the native boundary. -import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- This test uses native path handling for its temporary fixture. -import { join } from "node:path"; -import * as Net from "node:net"; -import { describe, expect, it } from "@effect/vitest"; -import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner"; -import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem"; -import { layer as BunPathLayer } from "@effect/platform-bun/BunPath"; -import { Deferred, Duration, Effect, Exit, Fiber, Layer, Predicate, Sink, Stream } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { runHealthProbe } from "./HealthProbe.ts"; -import type { HealthCheckConfig, ProbeConfig } from "./ServiceDef.ts"; - -const platformLayer = Layer.mergeAll( - BunChildProcessSpawnerLayer.pipe(Layer.provide(Layer.mergeAll(BunFileSystemLayer, BunPathLayer))), - BunFileSystemLayer, - BunPathLayer, - FetchHttpClient.layer, -); - -const sequenceProbeLayer = (results: ReadonlyArray) => { - let calls = 0; - return { - layer: Layer.mergeAll( - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - const result = results[calls] ?? results.at(-1) ?? false; - calls++; - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(2000 + calls), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result ? 0 : 1)), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - FetchHttpClient.layer, - ), - get calls() { - return calls; - }, - }; -}; - -const setupProbe = (probe: ProbeConfig, overrides?: Partial) => - Effect.gen(function* () { - let healthy = false; - const healthySignal = yield* Deferred.make(); - const unhealthySignal = yield* Deferred.make(); - const config = { - name: "test", - healthCheck: { - probe, - initialDelaySeconds: 0, - periodSeconds: 0.01, - timeoutSeconds: 1, - successThreshold: 1, - failureThreshold: 2, - ...overrides, - }, - callbacks: { - onHealthy: Effect.gen(function* () { - healthy = true; - yield* Deferred.succeed(healthySignal, void 0); - }), - onUnhealthy: Effect.gen(function* () { - healthy = false; - yield* Deferred.succeed(unhealthySignal, void 0); - }), - }, - }; - return { healthySignal, unhealthySignal, config, isHealthy: () => healthy }; - }); - -describe("HealthProbe", () => { - it.live("aborts an in-flight HTTP probe when its fiber is interrupted", () => { - const originalFetch = globalThis.fetch; - return Effect.gen(function* () { - const started = yield* Deferred.make(); - let aborted = false; - globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { - init?.signal?.addEventListener("abort", () => { - aborted = true; - }); - // oxlint-disable-next-line effecttsgo/run-effect-inside-effect -- The test fetch stub signals its start synchronously before hanging. - Effect.runSync(Deferred.succeed(started, void 0)); - // oxlint-disable-next-line effecttsgo/new-promise -- The test fetch stub must remain pending until interruption. - return new Promise(() => undefined); - }) as typeof fetch; - - const { config } = yield* setupProbe({ - _tag: "Http", - scheme: "http", - host: "127.0.0.1", - port: 80, - path: "/health", - }); - const fiber = yield* Effect.forkChild(runHealthProbe(config), { startImmediately: true }); - yield* Deferred.await(started); - yield* Fiber.interrupt(fiber); - - expect(aborted).toBe(true); - }).pipe( - Effect.ensuring(Effect.sync(() => (globalThis.fetch = originalFetch))), - Effect.provide(platformLayer), - ); - }); - - it.live("Exec probes require explicit args", () => - Effect.sync(() => { - // @ts-expect-error Exec probes must declare args explicitly. - const _probe: ProbeConfig = { - _tag: "Exec", - command: "true", - }; - - expect(true).toBe(true); - }), - ); - - it.live("transitions to Healthy with successful exec probe", () => - Effect.gen(function* () { - const { healthySignal, config, isHealthy } = yield* setupProbe({ - _tag: "Exec", - command: "true", - args: [], - }); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - expect(isHealthy()).toBe(true); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("transitions to Healthy with structured exec probe args", () => - Effect.gen(function* () { - const { healthySignal, config, isHealthy } = yield* setupProbe({ - _tag: "Exec", - command: process.execPath, - args: ["-e", "process.exit(0)"], - }); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - expect(isHealthy()).toBe(true); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("runs exec probes directly without shell indirection", () => - Effect.sync(() => { - const spawned: Array<{ - readonly command: string; - readonly args: ReadonlyArray; - }> = []; - const layer = Layer.mergeAll( - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.sync(() => { - if (Predicate.isTagged(command, "StandardCommand")) { - spawned.push({ - command: command.command, - args: command.args, - }); - } - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1234), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - FetchHttpClient.layer, - ); - - return Effect.gen(function* () { - const { healthySignal, config } = yield* setupProbe({ - _tag: "Exec", - command: "true", - args: [], - }); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - expect(spawned).toEqual([ - { - command: "true", - args: [], - }, - ]); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.flatten), - ); - - it.live("passes env to structured exec probes", () => - Effect.gen(function* () { - const { healthySignal, config, isHealthy } = yield* setupProbe({ - _tag: "Exec", - command: process.execPath, - args: ["-e", "process.exit(process.env.SUPA_HEALTH_CHECK === 'ok' ? 0 : 1)"], - env: { SUPA_HEALTH_CHECK: "ok" }, - }); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - expect(isHealthy()).toBe(true); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("completes healthySignal Deferred on success", () => - Effect.gen(function* () { - const { healthySignal, config } = yield* setupProbe({ - _tag: "Exec", - command: "true", - args: [], - }); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - const done = yield* Deferred.isDone(healthySignal); - expect(done).toBe(true); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("never transitions to Healthy with always-failing exec probe", () => - Effect.gen(function* () { - const { healthySignal, config, isHealthy } = yield* setupProbe({ - _tag: "Exec", - command: "false", - args: [], - }); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - - const exit = yield* Deferred.await(healthySignal).pipe( - Effect.timeout(Duration.millis(300)), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(isHealthy()).toBe(false); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("uses failureThreshold for startup when no startup threshold is configured", () => { - const probe = sequenceProbeLayer([false]); - return Effect.gen(function* () { - const { unhealthySignal, config } = yield* setupProbe( - { _tag: "Exec", command: "check", args: [] }, - { failureThreshold: 2 }, - ); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(unhealthySignal).pipe(Effect.timeout(Duration.seconds(1))); - expect(probe.calls).toBe(2); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(probe.layer)); - }); - - it.live("allows a larger startup threshold than the liveness threshold", () => { - const probe = sequenceProbeLayer([false]); - return Effect.gen(function* () { - const { unhealthySignal, config } = yield* setupProbe( - { _tag: "Exec", command: "check", args: [] }, - { startupFailureThreshold: 4, failureThreshold: 2 }, - ); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(unhealthySignal).pipe(Effect.timeout(Duration.seconds(1))); - expect(probe.calls).toBe(4); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(probe.layer)); - }); - - it.live("recovers on the final startup probe without becoming unhealthy", () => { - const probe = sequenceProbeLayer([false, false, true]); - return Effect.gen(function* () { - const { healthySignal, unhealthySignal, config } = yield* setupProbe( - { _tag: "Exec", command: "check", args: [] }, - { startupFailureThreshold: 3, failureThreshold: 1 }, - ); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(1))); - expect(probe.calls).toBe(3); - expect(yield* Deferred.isDone(unhealthySignal)).toBe(false); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(probe.layer)); - }); - - it.live("uses the liveness threshold after the first healthy transition", () => { - const probe = sequenceProbeLayer([true, false, false]); - return Effect.gen(function* () { - const { healthySignal, unhealthySignal, config } = yield* setupProbe( - { _tag: "Exec", command: "check", args: [] }, - { startupFailureThreshold: 5, failureThreshold: 2 }, - ); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(1))); - yield* Deferred.await(unhealthySignal).pipe(Effect.timeout(Duration.seconds(1))); - expect(probe.calls).toBe(3); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(probe.layer)); - }); - - it.live("does not re-enable startup tolerance after an unhealthy recovery", () => { - const probe = sequenceProbeLayer([true, false, false, true, false, false]); - return Effect.gen(function* () { - let healthyTransitions = 0; - let unhealthyTransitions = 0; - const secondUnhealthy = yield* Deferred.make(); - const fiber = yield* Effect.forkChild( - runHealthProbe({ - name: "test", - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.01, - startupFailureThreshold: 5, - failureThreshold: 2, - }, - callbacks: { - onHealthy: Effect.sync(() => { - healthyTransitions++; - }), - onUnhealthy: Effect.gen(function* () { - unhealthyTransitions++; - if (unhealthyTransitions === 2) { - yield* Deferred.succeed(secondUnhealthy, void 0); - } - }), - }, - }), - ); - - yield* Deferred.await(secondUnhealthy).pipe(Effect.timeout(Duration.seconds(1))); - expect(probe.calls).toBe(6); - expect(healthyTransitions).toBe(2); - expect(unhealthyTransitions).toBe(2); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(probe.layer)); - }); - - it.live("respects initialDelaySeconds before first probe", () => - Effect.gen(function* () { - const { healthySignal, config } = yield* setupProbe( - { _tag: "Exec", command: "true", args: [] }, - { initialDelaySeconds: 0.2, periodSeconds: 0.01 }, - ); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - - // Signal should NOT be complete within 100ms (less than the 200ms initial delay) - const earlyExit = yield* Deferred.await(healthySignal).pipe( - Effect.timeout(Duration.millis(100)), - Effect.exit, - ); - expect(Exit.isFailure(earlyExit)).toBe(true); - - // After enough time, the signal should complete - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - const done = yield* Deferred.isDone(healthySignal); - expect(done).toBe(true); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("respects successThreshold before marking Healthy", () => - Effect.gen(function* () { - const { healthySignal, config, isHealthy } = yield* setupProbe( - { _tag: "Exec", command: "true", args: [] }, - { successThreshold: 3, periodSeconds: 0.01 }, - ); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - expect(isHealthy()).toBe(true); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("transitions to Healthy with successful TCP probe", () => - Effect.gen(function* () { - // Start a real TCP server on a random port - const server = Net.createServer(); - const port = yield* Effect.callback((resume) => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as Net.AddressInfo; - resume(Effect.succeed(addr.port)); - }); - }); - - const { healthySignal, config, isHealthy } = yield* setupProbe({ - _tag: "Tcp", - host: "127.0.0.1", - port, - }); - - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - expect(isHealthy()).toBe(true); - yield* Fiber.interrupt(fiber); - - // Close the server - yield* Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - }); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("never transitions to Healthy with closed TCP port", () => - Effect.gen(function* () { - // Bind a server to get a random port, then close it so the port is not listening - const port = yield* Effect.callback((resume) => { - const server = Net.createServer(); - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as Net.AddressInfo; - const p = addr.port; - server.close(() => resume(Effect.succeed(p))); - }); - }); - - const { healthySignal, config, isHealthy } = yield* setupProbe({ - _tag: "Tcp", - host: "127.0.0.1", - port, - }); - - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - - const exit = yield* Deferred.await(healthySignal).pipe( - Effect.timeout(Duration.millis(300)), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(isHealthy()).toBe(false); - yield* Fiber.interrupt(fiber); - }).pipe(Effect.provide(platformLayer)), - ); - - it.live("transitions to Unhealthy after failureThreshold failures following Healthy", () => - Effect.gen(function* () { - const tempDir = mkdtempSync(join(tmpdir(), "health-probe-test-")); - const flagFile = join(tempDir, "healthy"); - - // Create the flag file so probe succeeds initially - writeFileSync(flagFile, ""); - - const { healthySignal, unhealthySignal, config, isHealthy } = yield* setupProbe( - { _tag: "Exec", command: "test", args: ["-f", flagFile] }, - { periodSeconds: 0.01, successThreshold: 1, failureThreshold: 2 }, - ); - const fiber = yield* Effect.forkChild(runHealthProbe(config)); - - // Wait until healthy - yield* Deferred.await(healthySignal).pipe(Effect.timeout(Duration.seconds(5))); - expect(isHealthy()).toBe(true); - - // Remove the flag file so probe starts failing - // oxlint-disable-next-line effecttsgo/try-catch-in-effect-gen -- Native unlink cleanup is best-effort test fixture teardown. - try { - unlinkSync(flagFile); - } catch { - /* ignore */ - } - - yield* Deferred.await(unhealthySignal).pipe(Effect.timeout(Duration.seconds(5))); - - expect(isHealthy()).toBe(false); - yield* Fiber.interrupt(fiber); - rmSync(tempDir, { recursive: true, force: true }); - }).pipe(Effect.provide(platformLayer)), - ); -}); diff --git a/packages/process-compose/src/LogBuffer.ts b/packages/process-compose/src/LogBuffer.ts deleted file mode 100644 index 3992f0d10e..0000000000 --- a/packages/process-compose/src/LogBuffer.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Clock, Context, Effect, Layer, PubSub, Ref, Semaphore, Stream } from "effect"; - -export interface LogEntry { - readonly timestamp: number; - readonly service: string; - readonly stream: "stdout" | "stderr"; - readonly line: string; -} - -const MAX_BUFFER_SIZE = 10_000; - -export class LogBuffer extends Context.Service< - LogBuffer, - { - readonly append: ( - service: string, - stream: "stdout" | "stderr", - line: string, - ) => Effect.Effect; - readonly subscribe: (service: string) => Stream.Stream; - readonly subscribeAll: Stream.Stream; - readonly history: (service: string, limit?: number) => Effect.Effect>; - readonly historyAll: ( - limit?: number, - services?: ReadonlyArray, - ) => Effect.Effect>; - } ->()("process-compose/LogBuffer") { - static layer = Layer.effect( - this, - Effect.gen(function* () { - const servicePubSubs = new Map>(); - const serviceBuffers = new Map>>(); - // Log delivery must never block child stdout/stderr draining. The bounded history refs - // below remain authoritative; live subscribers receive the newest entries when slow. - const globalPubSub = yield* PubSub.sliding(4096); - const globalBuffer = yield* Ref.make>([]); - const serviceInitialization = Semaphore.makeUnsafe(1); - - const getOrCreate = (service: string) => - Effect.gen(function* () { - if (!servicePubSubs.has(service)) { - const ps = yield* PubSub.sliding(1024); - servicePubSubs.set(service, ps); - serviceBuffers.set(service, Ref.makeUnsafe>([])); - } - return { - pubsub: servicePubSubs.get(service)!, - buffer: serviceBuffers.get(service)!, - }; - }).pipe(serviceInitialization.withPermit); - - return { - append: (service, stream, line) => - Effect.gen(function* () { - const entry: LogEntry = { - timestamp: yield* Clock.currentTimeMillis, - service, - stream, - line, - }; - const { pubsub, buffer } = yield* getOrCreate(service); - yield* PubSub.publish(pubsub, entry); - yield* PubSub.publish(globalPubSub, entry); - yield* Ref.update(globalBuffer, (buf) => { - const next = buf.concat(entry); - return next.length > MAX_BUFFER_SIZE ? next.slice(-MAX_BUFFER_SIZE) : next; - }); - yield* Ref.update(buffer, (buf) => { - const next = buf.concat(entry); - return next.length > MAX_BUFFER_SIZE ? next.slice(-MAX_BUFFER_SIZE) : next; - }); - }), - - subscribe: (service) => - Stream.unwrap( - Effect.gen(function* () { - const { pubsub } = yield* getOrCreate(service); - return Stream.fromPubSub(pubsub); - }), - ), - - subscribeAll: Stream.fromPubSub(globalPubSub), - - history: (service, limit = 100) => - Effect.gen(function* () { - const { buffer } = yield* getOrCreate(service); - const all = Ref.getUnsafe(buffer); - return all.slice(-limit); - }), - - historyAll: (limit = 100, services) => - Effect.sync(() => { - const all = Ref.getUnsafe(globalBuffer); - const filtered = - services === undefined || services.length === 0 - ? all - : all.filter((entry) => services.includes(entry.service)); - - return filtered.slice(-limit); - }), - }; - }), - ); -} diff --git a/packages/process-compose/src/LogBuffer.unit.test.ts b/packages/process-compose/src/LogBuffer.unit.test.ts deleted file mode 100644 index dee6de6547..0000000000 --- a/packages/process-compose/src/LogBuffer.unit.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Duration, Effect, Fiber, Option, Stream } from "effect"; -import { LogBuffer } from "./LogBuffer.ts"; - -const layer = LogBuffer.layer; - -describe("LogBuffer", () => { - it.live("appends and retrieves history", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - yield* log.append("svc", "stdout", "line1"); - yield* log.append("svc", "stdout", "line2"); - yield* log.append("svc", "stderr", "line3"); - const entries = yield* log.history("svc"); - expect(entries).toHaveLength(3); - expect(entries[0]?.line).toBe("line1"); - expect(entries[1]?.line).toBe("line2"); - expect(entries[2]?.line).toBe("line3"); - }).pipe(Effect.provide(layer)), - ); - - it.live("history respects limit", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - for (let i = 0; i < 10; i++) { - yield* log.append("svc", "stdout", `line${i}`); - } - const entries = yield* log.history("svc", 3); - expect(entries).toHaveLength(3); - expect(entries[0]?.line).toBe("line7"); - expect(entries[1]?.line).toBe("line8"); - expect(entries[2]?.line).toBe("line9"); - }).pipe(Effect.provide(layer)), - ); - - it.live("subscribe receives live entries", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - - // Start collecting 1 entry from the subscription in background - const collectEffect = log.subscribe("svc").pipe(Stream.take(1), Stream.runCollect); - const fiber = yield* Effect.forkChild(collectEffect, { startImmediately: true }); - - yield* log.append("svc", "stdout", "hello"); - - const entries = yield* Fiber.join(fiber); - expect(entries).toHaveLength(1); - expect(entries[0]?.line).toBe("hello"); - expect(entries[0]?.service).toBe("svc"); - expect(entries[0]?.stream).toBe("stdout"); - }).pipe(Effect.provide(layer)), - ); - - it.live("ring buffer eviction keeps only MAX_BUFFER_SIZE entries", () => { - const MAX_BUFFER_SIZE = 10_000; - return Effect.gen(function* () { - const log = yield* LogBuffer; - const total = MAX_BUFFER_SIZE + 100; - for (let i = 0; i < total; i++) { - yield* log.append("svc", "stdout", `line${i}`); - } - const entries = yield* log.history("svc", MAX_BUFFER_SIZE + 100); - expect(entries).toHaveLength(MAX_BUFFER_SIZE); - // First entry should be line100 (earliest 100 entries were evicted) - expect(entries[0]?.line).toBe("line100"); - }).pipe(Effect.provide(layer)); - }); - - it.live("subscribeAll receives entries from all services", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - - // Collect 3 entries from the global subscription - const collectEffect = log.subscribeAll.pipe(Stream.take(3), Stream.runCollect); - const fiber = yield* Effect.forkChild(collectEffect, { startImmediately: true }); - - yield* log.append("svcA", "stdout", "from-a"); - yield* log.append("svcB", "stderr", "from-b"); - yield* log.append("svcA", "stdout", "from-a-again"); - - const entries = yield* Fiber.join(fiber); - expect(entries).toHaveLength(3); - expect(entries[0]?.service).toBe("svcA"); - expect(entries[1]?.service).toBe("svcB"); - expect(entries[2]?.service).toBe("svcA"); - }).pipe(Effect.provide(layer)), - ); - - it.live("multiple services are independent", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - yield* log.append("a", "stdout", "line-a1"); - yield* log.append("b", "stdout", "line-b1"); - yield* log.append("a", "stderr", "line-a2"); - yield* log.append("b", "stderr", "line-b2"); - - const entriesA = yield* log.history("a", 100); - const entriesB = yield* log.history("b", 100); - - expect(entriesA).toHaveLength(2); - expect(entriesA.every((e) => e.service === "a")).toBe(true); - expect(entriesA[0]?.line).toBe("line-a1"); - expect(entriesA[1]?.line).toBe("line-a2"); - - expect(entriesB).toHaveLength(2); - expect(entriesB.every((e) => e.service === "b")).toBe(true); - expect(entriesB[0]?.line).toBe("line-b1"); - expect(entriesB[1]?.line).toBe("line-b2"); - }).pipe(Effect.provide(layer)), - ); - - it.live("does not block appends when a live subscriber is slow", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - const firstEntry = yield* Deferred.make(); - const releaseSubscriber = yield* Deferred.make(); - let first = true; - const subscriber = yield* Effect.forkChild( - log.subscribe("svc").pipe( - Stream.tap(() => - first - ? Effect.sync(() => { - first = false; - }).pipe( - Effect.andThen(Deferred.succeed(firstEntry, void 0)), - Effect.andThen(Deferred.await(releaseSubscriber)), - ) - : Effect.void, - ), - Stream.runDrain, - ), - { startImmediately: true }, - ); - yield* log.append("svc", "stdout", "first"); - yield* Deferred.await(firstEntry); - const result = yield* Effect.forEach( - Array.from({ length: 1_100 }, (_, index) => index + 1), - (index) => log.append("svc", "stdout", `line${index}`), - { concurrency: "unbounded", discard: true }, - ).pipe(Effect.timeoutOption(Duration.seconds(2))); - - expect(Option.isSome(result)).toBe(true); - yield* Deferred.succeed(releaseSubscriber, void 0); - yield* Fiber.interrupt(subscriber); - }).pipe(Effect.provide(layer)), - ); - - it.live("preserves entries when several writers initialize one service concurrently", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - yield* Effect.forEach( - Array.from({ length: 32 }, (_, index) => index), - (index) => log.append("svc", "stdout", `line${index}`), - { concurrency: "unbounded", discard: true }, - ); - const entries = yield* log.history("svc", 32); - expect(entries).toHaveLength(32); - expect(new Set(entries.map((entry) => entry.line)).size).toBe(32); - }).pipe(Effect.provide(layer)), - ); - - it.live("historyAll returns merged entries in timestamp order and respects filters", () => - Effect.gen(function* () { - const log = yield* LogBuffer; - yield* log.append("a", "stdout", "line-a1"); - yield* log.append("b", "stderr", "line-b1"); - yield* log.append("a", "stdout", "line-a2"); - - const merged = yield* log.historyAll(10); - expect(merged.map((entry) => entry.line)).toEqual(["line-a1", "line-b1", "line-a2"]); - - const filtered = yield* log.historyAll(10, ["b"]); - expect(filtered).toHaveLength(1); - expect(filtered[0]?.service).toBe("b"); - expect(filtered[0]?.line).toBe("line-b1"); - }).pipe(Effect.provide(layer)), - ); -}); diff --git a/packages/process-compose/src/Orchestrator.integration.test.ts b/packages/process-compose/src/Orchestrator.integration.test.ts deleted file mode 100644 index d9e7d5d30c..0000000000 --- a/packages/process-compose/src/Orchestrator.integration.test.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { describe, expect, it } from "@effect/vitest"; -import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner"; -import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem"; -import { layer as BunPathLayer } from "@effect/platform-bun/BunPath"; -import { - Clock, - Deferred, - Duration, - Effect, - Fiber, - FileSystem, - Layer, - Option, - Stream, -} from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { buildGraph } from "./DependencyGraph.ts"; -import { LogBuffer } from "./LogBuffer.ts"; -import { Orchestrator } from "./Orchestrator.ts"; -import type { ServiceState } from "./ServiceState.ts"; -import type { ProbeConfig, ServiceDef } from "./ServiceDef.ts"; - -const spawnerLayer = BunChildProcessSpawnerLayer.pipe( - Layer.provide(Layer.mergeAll(BunFileSystemLayer, BunPathLayer)), -); - -const deps = Layer.mergeAll(spawnerLayer, LogBuffer.layer, FetchHttpClient.layer); - -function setupReal(defs: ReadonlyArray) { - const graph = Effect.runSync(buildGraph(defs)); - const layer = Orchestrator.layer(graph).pipe(Layer.provide(deps)); - return { graph, layer }; -} - -const isUp = (status: string) => status === "Running" || status === "Healthy"; - -const fileExistsProbe = (path: string) => - ({ - _tag: "Exec" as const, - command: "test", - args: ["-f", path], - }) satisfies ProbeConfig; - -type StateReader = { - readonly getAllStates: Effect.Effect>; - readonly allStateChanges: Stream.Stream; -}; - -const waitForStatuses = ( - orc: StateReader, - predicates: ReadonlyArray<{ - readonly name: string; - readonly predicate: (state: ServiceState) => boolean; - }>, -): Effect.Effect => - Effect.gen(function* () { - const current = yield* orc.getAllStates; - const matches = (states: ReadonlyArray) => - predicates.every(({ name, predicate }) => { - const state = states.find((candidate) => candidate.name === name); - return state !== undefined && predicate(state); - }); - if (matches(current)) return; - - yield* orc.allStateChanges.pipe( - Stream.scan(new Map(current.map((state) => [state.name, state])), (states, state) => - new Map(states).set(state.name, state), - ), - Stream.filter((states) => matches([...states.values()])), - Stream.take(1), - Stream.runDrain, - ); - }); - -describe("Orchestrator integration", () => { - it.live( - "serializes a concurrent start and stop lifecycle command", - () => { - const defs: ServiceDef[] = [ - { - name: "serialized", - command: "sh", - args: ["-c", "trap '' TERM; sleep 60"], - shutdown: { signal: "SIGTERM", timeoutSeconds: 0.5 }, - }, - ]; - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - const changes = yield* orc.stateChanges("serialized"); - const running = yield* changes.pipe( - Stream.filter((state) => isUp(state.status)), - Stream.take(1), - Stream.runHead, - Effect.forkChild, - ); - yield* orc.start(); - yield* Fiber.join(running); - - const stopping = yield* changes.pipe( - Stream.filter((state) => state.status === "Stopping"), - Stream.take(1), - Stream.runHead, - Effect.forkChild, - ); - const events: Array = []; - const startEntered = yield* Deferred.make(); - const stop = orc.stop.pipe( - Effect.tap(() => - Effect.sync(() => { - events.push("stop"); - }), - ), - ); - const stopFiber = yield* Effect.forkChild(stop, { startImmediately: true }); - yield* Fiber.join(stopping); - - const startFiber = yield* Effect.forkChild( - orc.startService("serialized", { - beforeStart: () => - Effect.sync(() => events.push("start")).pipe( - Effect.andThen(Deferred.succeed(startEntered, void 0)), - ), - }), - { startImmediately: true }, - ); - yield* Fiber.join(stopFiber); - yield* Fiber.join(startFiber); - const startResult = yield* Deferred.await(startEntered).pipe( - Effect.timeoutOption(Duration.seconds(2)), - ); - expect(Option.isSome(startResult)).toBe(true); - - expect(events).toEqual(["stop", "start"]); - yield* orc.stop; - }).pipe(Effect.provide(layer), Effect.scoped); - }, - { timeout: 15000 }, - ); - - it.live( - "starts services in dependency order (A before B)", - () => { - const defs: ServiceDef[] = [ - { - name: "service-a", - command: "sh", - args: ["-c", "echo service-a-started && sleep 60"], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - { - name: "service-b", - command: "sh", - args: ["-c", "echo service-b-started && sleep 60"], - dependencies: [{ service: "service-a", condition: "started" }], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - ]; - - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "service-a", predicate: (state) => isUp(state.status) }, - { name: "service-b", predicate: (state) => isUp(state.status) }, - ]); - - const stateA = yield* orc.getState("service-a"); - const stateB = yield* orc.getState("service-b"); - - expect(stateA.pid).toBeGreaterThan(0); - expect(stateB.pid).toBeGreaterThan(0); - expect(stateA.startedAt!).toBeLessThanOrEqual(stateB.startedAt!); - - yield* orc.stop; - }).pipe(Effect.provide(layer), Effect.scoped); - }, - { timeout: 15000 }, - ); - - it.live( - "health check transitions to Healthy with exec probe", - () => { - const flagFile = `/tmp/pc-e2e-flag-${randomUUID()}`; - - const defs: ServiceDef[] = [ - { - name: "flag-service", - command: "sh", - args: ["-c", `touch ${flagFile} && sleep 60`], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - healthCheck: { - probe: fileExistsProbe(flagFile), - initialDelaySeconds: 0, - periodSeconds: 0.1, - timeoutSeconds: 2, - successThreshold: 1, - failureThreshold: 3, - }, - }, - ]; - - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "flag-service", predicate: (state) => state.status === "Healthy" }, - ]); - - const state = yield* orc.getState("flag-service"); - expect(state.status).toBe("Healthy"); - yield* orc.stop; - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(flagFile, { force: true }); - }).pipe(Effect.provide(BunFileSystemLayer), Effect.ignore), - ), - Effect.provide(layer), - Effect.scoped, - ); - }, - { timeout: 15000 }, - ); - - it.live( - "stop() terminates all running services", - () => { - const defs: ServiceDef[] = [ - { name: "long-a", command: "sleep", args: ["30"] }, - { name: "long-b", command: "sleep", args: ["30"] }, - ]; - - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "long-a", predicate: (state) => isUp(state.status) }, - { name: "long-b", predicate: (state) => isUp(state.status) }, - ]); - - const a = yield* orc.getState("long-a"); - const b = yield* orc.getState("long-b"); - expect(a.pid).toBeGreaterThan(0); - expect(b.pid).toBeGreaterThan(0); - - yield* orc.stop; - }).pipe(Effect.provide(layer), Effect.scoped); - }, - { timeout: 15000 }, - ); - - it.live( - "stop() shuts down independent services in parallel", - () => { - const defs: ServiceDef[] = [ - { name: "sleep-a", command: "sleep", args: ["60"], shutdown: { timeoutSeconds: 2 } }, - { name: "sleep-b", command: "sleep", args: ["60"], shutdown: { timeoutSeconds: 2 } }, - { name: "sleep-c", command: "sleep", args: ["60"], shutdown: { timeoutSeconds: 2 } }, - ]; - - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "sleep-a", predicate: (state) => isUp(state.status) }, - { name: "sleep-b", predicate: (state) => isUp(state.status) }, - { name: "sleep-c", predicate: (state) => isUp(state.status) }, - ]); - - const before = yield* Clock.currentTimeMillis; - yield* orc.stop; - const elapsed = (yield* Clock.currentTimeMillis) - before; - - // 3 services * 2s timeout each = 6s sequential. - // sleep responds to SIGTERM quickly, so parallel should be < 2s. - expect(elapsed).toBeLessThan(4000); - }).pipe(Effect.provide(layer), Effect.scoped); - }, - { timeout: 15000 }, - ); - - it.live( - "captures stdout lines in LogBuffer", - () => { - const defs: ServiceDef[] = [ - { - name: "echo-svc", - command: "sh", - args: ["-c", "echo line-one && echo line-two && echo line-three && sleep 60"], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - ]; - - const graph = Effect.runSync(buildGraph(defs)); - const layer = Orchestrator.layer(graph).pipe(Layer.provideMerge(deps)); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - const logBuffer = yield* LogBuffer; - const linesReady = yield* Effect.forkChild( - logBuffer.subscribe("echo-svc").pipe(Stream.take(3), Stream.runCollect), - { startImmediately: true }, - ); - - yield* orc.start(); - const entries = yield* Fiber.join(linesReady); - const lines = entries.map((e) => e.line); - expect(lines).toContain("line-one"); - expect(lines).toContain("line-two"); - expect(lines).toContain("line-three"); - - yield* orc.stop; - }).pipe(Effect.provide(layer), Effect.scoped); - }, - { timeout: 15000 }, - ); -}); - -const isPidAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; - -describe("resource cleanup", () => { - it.live( - "stop() kills all child process PIDs", - () => { - const defs: ServiceDef[] = [ - { - name: "svc-a", - command: "sleep", - args: ["60"], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - { - name: "svc-b", - command: "sleep", - args: ["60"], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - ]; - - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "svc-a", predicate: (state) => isUp(state.status) }, - { name: "svc-b", predicate: (state) => isUp(state.status) }, - ]); - - const pidA = (yield* orc.getState("svc-a")).pid!; - const pidB = (yield* orc.getState("svc-b")).pid!; - expect(pidA).toBeGreaterThan(0); - expect(pidB).toBeGreaterThan(0); - expect(isPidAlive(pidA)).toBe(true); - expect(isPidAlive(pidB)).toBe(true); - - yield* orc.stop; - - expect(isPidAlive(pidA)).toBe(false); - expect(isPidAlive(pidB)).toBe(false); - }).pipe(Effect.provide(layer), Effect.scoped); - }, - { timeout: 15000 }, - ); - - it.live( - "stopService() kills only the targeted process", - () => { - const defs: ServiceDef[] = [ - { - name: "target", - command: "sleep", - args: ["60"], - restart: "no", - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - { - name: "bystander", - command: "sleep", - args: ["60"], - restart: "no", - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - ]; - - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "target", predicate: (state) => isUp(state.status) }, - { name: "bystander", predicate: (state) => isUp(state.status) }, - ]); - - const pidTarget = (yield* orc.getState("target")).pid!; - const pidBystander = (yield* orc.getState("bystander")).pid!; - - yield* orc.stopService("target"); - - expect(isPidAlive(pidTarget)).toBe(false); - expect(isPidAlive(pidBystander)).toBe(true); - - yield* orc.stop; - }).pipe(Effect.provide(layer), Effect.scoped); - }, - { timeout: 15000 }, - ); - - it.live( - "unless-stopped service stays dead after explicit stop", - () => { - const defs: ServiceDef[] = [ - { - name: "restartable", - command: "sleep", - args: ["60"], - restart: "unless-stopped", - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - ]; - - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "restartable", predicate: (state) => isUp(state.status) }, - ]); - - const originalPid = (yield* orc.getState("restartable")).pid!; - yield* orc.stopService("restartable"); - - expect(isPidAlive(originalPid)).toBe(false); - const state = yield* orc.getState("restartable"); - expect(state.status).toBe("Stopped"); - - yield* orc.stop; - }).pipe(Effect.provide(layer), Effect.scoped); - }, - { timeout: 15000 }, - ); - - it.live( - "exec health probe processes cleaned up on stop", - () => { - const flagFile = `/tmp/pc-cleanup-flag-${randomUUID()}`; - const defs: ServiceDef[] = [ - { - name: "probed", - command: "sh", - args: ["-c", `touch ${flagFile} && sleep 60`], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - healthCheck: { - probe: fileExistsProbe(flagFile), - initialDelaySeconds: 0, - periodSeconds: 0.2, - timeoutSeconds: 2, - successThreshold: 1, - failureThreshold: 3, - }, - }, - ]; - - const { layer } = setupReal(defs); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "probed", predicate: (state) => state.status === "Healthy" }, - ]); - - const pid = (yield* orc.getState("probed")).pid!; - yield* orc.stop; - - expect(isPidAlive(pid)).toBe(false); - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(flagFile, { force: true }); - }).pipe(Effect.provide(BunFileSystemLayer), Effect.ignore), - ), - Effect.provide(layer), - Effect.scoped, - ); - }, - { timeout: 15000 }, - ); - - it.live( - "scope closure kills children without explicit stop", - () => { - const defs: ServiceDef[] = [ - { - name: "scoped-a", - command: "sleep", - args: ["60"], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - { - name: "scoped-b", - command: "sleep", - args: ["60"], - shutdown: { signal: "SIGTERM", timeoutSeconds: 1 }, - }, - ]; - - const { layer } = setupReal(defs); - let capturedPidA = 0; - let capturedPidB = 0; - - return Effect.gen(function* () { - yield* Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - yield* waitForStatuses(orc, [ - { name: "scoped-a", predicate: (state) => isUp(state.status) }, - { name: "scoped-b", predicate: (state) => isUp(state.status) }, - ]); - - capturedPidA = (yield* orc.getState("scoped-a")).pid!; - capturedPidB = (yield* orc.getState("scoped-b")).pid!; - expect(capturedPidA).toBeGreaterThan(0); - expect(capturedPidB).toBeGreaterThan(0); - }).pipe(Effect.provide(layer), Effect.scoped); - - // Scope closure owns the child finalizers, so they complete before this assertion. - expect(isPidAlive(capturedPidA)).toBe(false); - expect(isPidAlive(capturedPidB)).toBe(false); - }); - }, - { timeout: 15000 }, - ); -}); diff --git a/packages/process-compose/src/Orchestrator.ts b/packages/process-compose/src/Orchestrator.ts deleted file mode 100644 index bff9e6e90f..0000000000 --- a/packages/process-compose/src/Orchestrator.ts +++ /dev/null @@ -1,982 +0,0 @@ -import { - Cause, - Clock, - Deferred, - DateTime, - Duration, - Effect, - Exit, - Fiber, - FiberMap, - Layer, - Match, - Context, - Option, - Predicate, - PlatformError, - Semaphore, - Stream, - SubscriptionRef, -} from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { HttpClient } from "effect/unstable/http"; -import { buildGraph, type ResolvedGraph } from "./DependencyGraph.ts"; -import { type HealthProbeCallbacks, runHealthProbe } from "./HealthProbe.ts"; -import { LogBuffer } from "./LogBuffer.ts"; -import { restartClosureFor } from "./RestartClosure.ts"; -import { decideRestart, UNHEALTHY_RESTART_EXHAUSTED_ERROR } from "./RestartDecision.ts"; -import type { - HookTrigger, - OrchestratorConfig, - ServiceDef, - ServiceStartOptions, -} from "./ServiceDef.ts"; -import { defaults } from "./ServiceDef.ts"; -import { initial, ServiceState, type ServiceDesiredState } from "./ServiceState.ts"; -import { makeSupervisedCommand, usesSupervisor } from "./Supervisor.ts"; -import { - CyclicDependencyError, - MissingDependencyError, - ServiceNotFoundError, - ServiceReadyError, - SpawnError, -} from "./errors.ts"; -import { type ServiceEvent, transition } from "./ServiceTransition.ts"; - -const DIAGNOSTIC_LOG_LINES = 20; - -const willRestartAfterExit = (def: ServiceDef, state: ServiceState): boolean => { - if (state.exitCode === null) return false; - return Predicate.isTagged( - decideRestart({ - cause: { _tag: "ProcessExit", exitCode: state.exitCode }, - policy: def.restart ?? defaults.restart, - restartCount: state.restartCount, - maxRestarts: def.maxRestarts ?? defaults.maxRestarts, - desired: state.desired, - }), - "Restart", - ); -}; - -// Some one-shot adapters report `isRunning: false` before their exit-code Effect is observable. -// Keep the compensating poll isolated here so the ordinary process-exit path remains event-driven. -const waitForProcessToStop = (handle: { - readonly isRunning: Effect.Effect; -}): Effect.Effect => - Effect.gen(function* () { - let running = true; - yield* Effect.whileLoop({ - while: () => running, - body: () => - handle.isRunning.pipe( - Effect.orElseSucceed(() => false), - Effect.tap((next) => Effect.sync(() => (running = next))), - Effect.andThen(Effect.sleep(Duration.millis(100))), - ), - step: () => undefined, - }); - }); - -export class Orchestrator extends Context.Service< - Orchestrator, - { - readonly start: (options?: ServiceStartOptions) => Effect.Effect; - readonly startService: ( - name: string, - options?: ServiceStartOptions, - ) => Effect.Effect; - readonly stop: Effect.Effect; - readonly stopService: (name: string) => Effect.Effect; - readonly restartService: ( - name: string, - options?: ServiceStartOptions, - ) => Effect.Effect; - readonly updateServiceDefinition: ( - name: string, - def: ServiceDef, - ) => Effect.Effect; - readonly getState: (name: string) => Effect.Effect; - readonly getAllStates: Effect.Effect>; - readonly stateChanges: ( - name: string, - ) => Effect.Effect, ServiceNotFoundError>; - readonly allStateChanges: Stream.Stream; - readonly waitReady: ( - name: string, - ) => Effect.Effect; - readonly waitAllReady: Effect.Effect; - } ->()("process-compose/Orchestrator") { - static layer = ( - initialGraph: ResolvedGraph, - config?: OrchestratorConfig, - ): Layer.Layer< - Orchestrator, - never, - ChildProcessSpawner.ChildProcessSpawner | LogBuffer | HttpClient.HttpClient - > => - Layer.effect( - this, - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const logBuffer = yield* LogBuffer; - const httpClient = yield* HttpClient.HttpClient; - let graph = initialGraph; - - const appendRecentServiceLogs = ( - name: string, - header: string, - emptyMessage: string, - ): Effect.Effect => - Effect.gen(function* () { - const recentLogs = yield* logBuffer.history(name, DIAGNOSTIC_LOG_LINES); - yield* logBuffer.append(name, "stderr", header); - - if (recentLogs.length === 0) { - yield* logBuffer.append(name, "stderr", emptyMessage); - return; - } - - for (const entry of recentLogs) { - const ts = DateTime.formatIso(DateTime.makeUnsafe(entry.timestamp)); - yield* logBuffer.append(name, "stderr", ` | ${ts} ${entry.stream}: ${entry.line}`); - } - }); - - interface ServiceRuntime { - readonly state: SubscriptionRef.SubscriptionRef; - effectiveDef: ServiceDef; - } - const services = new Map(); - - // Each service has one stable state stream for its entire lifetime. - for (const def of graph.startOrder) { - const stateRef = yield* SubscriptionRef.make(initial(def.name)); - services.set(def.name, { - state: stateRef, - effectiveDef: def, - }); - } - - // FiberMap to track running service fibers — auto-interrupted on scope close - const fibers = yield* FiberMap.make(); - const forceStops = new Map>(); - // Serialize every public lifecycle mutation so graph, desired state, and FiberMap - // updates form one command boundary. Read-only state queries remain concurrent. - const lifecycleLock = Semaphore.makeUnsafe(1); - - // Helper: send a validated FSM event — only does the state transition - const sendEvent = ( - name: string, - event: ServiceEvent, - ): Effect.Effect => { - const svc = services.get(name); - if (svc === undefined) return Effect.succeed(null); - return transition(svc.state, event); - }; - - const setDesired = (name: string, desired: ServiceDesiredState): Effect.Effect => { - const svc = services.get(name); - if (svc === undefined) return Effect.void; - return SubscriptionRef.update(svc.state, (state) => - state.desired === desired ? state : new ServiceState({ ...state, desired }), - ); - }; - - const waitForState = ( - service: ServiceRuntime, - predicate: (state: ServiceState) => boolean, - ): Effect.Effect => - SubscriptionRef.changes(service.state).pipe( - Stream.filter(predicate), - Stream.take(1), - Stream.runHead, - Effect.map(Option.getOrThrow), - ); - - // Helper: run all hooks for a given trigger in sequence - const runHooks = (def: ServiceDef, trigger: HookTrigger): Effect.Effect => - Effect.gen(function* () { - const hooks = (def.hooks ?? []).filter((h) => h.on === trigger); - for (const hook of hooks) { - const timeout = hook.timeoutSeconds ?? defaults.hookTimeoutSeconds; - const log = (stream: "stdout" | "stderr", line: string) => - logBuffer.append(def.name, stream, line); - const result = yield* hook - .run(log) - .pipe(Effect.timeout(Duration.seconds(timeout)), Effect.exit); - if (Exit.isFailure(result) && (hook.failurePolicy ?? "fail") === "fail") { - return `Hook (on:${trigger}) failed: ${Cause.pretty(result.cause)}`; - } - if (Exit.isFailure(result)) { - yield* logBuffer.append( - def.name, - "stderr", - `[hook-ignored] on:${trigger} hook failed: ${Cause.pretty(result.cause)}`, - ); - } - } - return null; - }); - - type SpawnResult = - | { readonly _tag: "ProcessExit"; readonly exitCode: number } - | { readonly _tag: "Unhealthy" } - | { readonly _tag: "HookFailed"; readonly error: string }; - - // The full lifecycle loop for a single service - const runService = ( - def: ServiceDef, - options?: ServiceStartOptions, - ): Effect.Effect => - Effect.gen(function* () { - let restartCount = 0; - const maxRestarts = def.maxRestarts ?? defaults.maxRestarts; - const restartPolicy = def.restart ?? defaults.restart; - const prepareStart = () => - options - ?.beforeStart?.(def.name) - .pipe( - Effect.mapError((cause) => new SpawnError({ service: def.command, cause })), - ) ?? Effect.void; - - yield* prepareStart(); - - // Wait for all dependencies to reach their required conditions - const timeoutSeconds = - def.dependencyTimeoutSeconds ?? defaults.dependencyTimeoutSeconds; - const awaitDependenciesCore = Effect.gen(function* () { - const deps = graph.dependenciesOf(def.name); - for (const { def: depDef, condition } of deps) { - const dependency = services.get(depDef.name); - if (dependency === undefined) continue; - if (condition === "started") { - yield* waitForState( - dependency, - (state) => - state.desired === "running" && - (state.status === "Running" || - state.status === "Healthy" || - state.status === "Unhealthy" || - (state.status === "Stopped" && state.exitCode === 0)), - ); - } else if (condition === "healthy") { - const ready = yield* waitForState( - dependency, - (state) => - state.desired === "running" && - (state.status === "Healthy" || - ((state.status === "Failed" || state.status === "Stopped") && - !willRestartAfterExit(dependency.effectiveDef, state))), - ); - if (ready.status !== "Healthy") { - yield* sendEvent(def.name, { - _tag: "DependencyFailed", - error: `Dependency ${depDef.name} failed: ${ - ready.error ?? - (ready.exitCode === null - ? "stopped before becoming healthy" - : `exited with code ${ready.exitCode} before becoming healthy`) - }`, - }); - return; - } - } else if (condition === "completed") { - const completed = yield* waitForState( - dependency, - (state) => - (state.status === "Failed" || - (state.status === "Stopped" && state.exitCode !== null)) && - !willRestartAfterExit(dependency.effectiveDef, state), - ); - if (completed.status === "Failed") { - yield* sendEvent(def.name, { - _tag: "DependencyFailed", - error: `Dependency ${depDef.name} failed: ${completed.error ?? "unknown failure"}`, - }); - return; - } - if (completed.exitCode !== 0) { - yield* sendEvent(def.name, { - _tag: "DependencyFailed", - error: `Dependency ${depDef.name} exited with code ${completed.exitCode}`, - }); - return; - } - } - } - }); - const awaitDependencies = awaitDependenciesCore.pipe( - Effect.timeout(Duration.seconds(timeoutSeconds)), - Effect.catch(() => - sendEvent(def.name, { - _tag: "DependencyFailed", - error: `Timed out after ${timeoutSeconds}s waiting for dependencies`, - }).pipe(Effect.asVoid), - ), - ); - - // Run a single spawn-and-wait cycle; returns exit code or unhealthy restart signal. - // Caller must transition to Starting before calling this. - const spawnOnce = (): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const generationResult = Deferred.makeUnsafe(); - const supervised = usesSupervisor(def); - - // Build command - const cmd = supervised - ? makeSupervisedCommand(def) - : ChildProcess.make(def.command, def.args ?? [], { - cwd: def.cwd, - env: def.env, - extendEnv: true, - stdin: "ignore", - }); - - // Release external resources such as port reservations only - // once dependencies are satisfied and spawning is imminent. - // For supervised external processes, this can still leave a wider - // spawn-to-bind window because the supervisor starts before its - // child binds published ports. Closing that gap would require an - // explicit supervisor/child handshake. - yield* options?.beforeSpawn?.(def.name) ?? Effect.void; - - // Spawn the process - const handle = yield* spawner - .spawn(cmd) - .pipe( - Effect.mapError((cause) => new SpawnError({ service: def.command, cause })), - ); - - const waitForHandleExit = handle.exitCode.pipe(Effect.asVoid, Effect.ignore); - - const sendSignal = (signal: ChildProcess.Signal): Effect.Effect => - handle - .kill({ killSignal: signal }) - .pipe(Effect.asVoid, Effect.ignore, Effect.andThen(waitForHandleExit)); - - forceStops.set(def.name, sendSignal("SIGKILL")); - - const runCleanup = () => - def.cleanup == null - ? Effect.void - : def.cleanup.pipe( - Effect.catchCause((cause) => - logBuffer.append( - def.name, - "stderr", - `[cleanup-failed] ${Cause.pretty(cause)}`, - ), - ), - ); - - // Register finalizer: graceful shutdown then SIGKILL fallback. - // Tree teardown for supervised services is owned by the supervisor. - yield* Effect.addFinalizer(() => - sendSignal(def.shutdown?.signal ?? defaults.shutdown.signal).pipe( - Effect.timeout( - Duration.seconds( - def.shutdown?.timeoutSeconds ?? defaults.shutdown.timeoutSeconds, - ), - ), - Effect.catch(() => - sendSignal("SIGKILL").pipe( - Effect.andThen( - logBuffer.append( - def.name, - "stderr", - "Shutdown timed out, sent SIGKILL", - ), - ), - ), - ), - Effect.ignore, - Effect.andThen(runCleanup()), - Effect.ensuring(Effect.sync(() => forceStops.delete(def.name))), - ), - ); - - // Keep the service in Starting until its started hooks pass, - // so Running is the stable dependency signal. - const startedHookError = yield* runHooks(def, "started"); - if (startedHookError !== null) { - return { _tag: "HookFailed", error: startedHookError }; - } - yield* sendEvent(def.name, { - _tag: "ProcessSpawned", - pid: handle.pid, - startedAt: yield* Clock.currentTimeMillis, - }); - - // Fork log streaming (stdout + stderr) — decode binary to text lines - yield* handle.stdout.pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runForEach((line) => logBuffer.append(def.name, "stdout", line)), - Effect.ignore, - Effect.forkChild, - ); - - yield* handle.stderr.pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runForEach((line) => logBuffer.append(def.name, "stderr", line)), - Effect.ignore, - Effect.forkChild, - ); - - // Health checking - if (def.healthCheck) { - const callbacks: HealthProbeCallbacks = { - onHealthy: Effect.gen(function* () { - const service = services.get(def.name); - if (service === undefined) return; - const current = SubscriptionRef.getUnsafe(service.state); - if (current.status === "Running" || current.status === "Unhealthy") { - const healthyHookError = yield* runHooks(def, "healthy"); - if (healthyHookError !== null) { - yield* Deferred.succeed(generationResult, { - _tag: "HookFailed", - error: healthyHookError, - }); - return; - } - } - yield* sendEvent(def.name, { _tag: "HealthCheckPassed" }); - }).pipe(Effect.asVoid), - onUnhealthy: Effect.gen(function* () { - yield* sendEvent(def.name, { _tag: "HealthCheckFailed" }); - yield* appendRecentServiceLogs( - def.name, - `[health-check-failed] Service "${def.name}" became unhealthy. Recent output:`, - `[health-check-failed] Service "${def.name}" became unhealthy (no recent log output).`, - ); - if (restartPolicy !== "no") { - yield* Deferred.succeed(generationResult, { _tag: "Unhealthy" }); - } - }), - }; - yield* runHealthProbe({ - name: def.name, - healthCheck: def.healthCheck, - callbacks, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.forkChild, - ); - } else { - const healthyHookError = yield* runHooks(def, "healthy"); - if (healthyHookError !== null) { - return { _tag: "HookFailed", error: healthyHookError }; - } - yield* sendEvent(def.name, { _tag: "HealthCheckPassed" }); - } - - // Race process exit against unhealthy restart signal. - // handle.exitCode fails when the process is killed by a signal - // (code is null, only signal is set), so we catch and treat it - // as exit code 143 (128 + SIGTERM). - const waitForExit = handle.exitCode.pipe( - Effect.map((code): SpawnResult => ({ - _tag: "ProcessExit", - exitCode: Number(code), - })), - Effect.orElseSucceed((): SpawnResult => ({ - _tag: "ProcessExit", - exitCode: 143, - })), - ); - const waitForObservedOneShotExit = - restartPolicy === "no" && def.healthCheck == null - ? waitForProcessToStop(handle).pipe( - Effect.andThen( - waitForExit.pipe( - Effect.timeout(Duration.millis(100)), - Effect.orElseSucceed((): SpawnResult => ({ - _tag: "ProcessExit", - exitCode: 0, - })), - ), - ), - ) - : Effect.never; - - return yield* Effect.raceAll([ - waitForExit, - waitForObservedOneShotExit, - Deferred.await(generationResult), - ]); - }), - ); - - // Main lifecycle: await deps, then run with optional restart loop - yield* awaitDependencies; - - // Check if we should even start (dependency might have set us Failed) - const currentState = SubscriptionRef.getUnsafe(services.get(def.name)!.state); - if (currentState.status === "Failed" || currentState.desired !== "running") return; - - // Transition Pending → Starting - yield* sendEvent(def.name, { _tag: "DependenciesSatisfied" }); - - let result = yield* spawnOnce(); - - // Handle spawn result - const handleResult = (r: SpawnResult) => - Match.valueTags(r, { - ProcessExit: (result) => - Effect.gen(function* () { - if (result.exitCode !== 0 && result.exitCode !== 143) { - yield* appendRecentServiceLogs( - def.name, - `[process-exited] Service "${def.name}" exited with code ${result.exitCode}. Recent output:`, - `[process-exited] Service "${def.name}" exited with code ${result.exitCode} (no recent log output).`, - ); - } - yield* sendEvent(def.name, { - _tag: "ProcessExited", - exitCode: result.exitCode, - }); - }), - HookFailed: (result) => - sendEvent(def.name, { _tag: "HookFailed", error: result.error }), - Unhealthy: () => Effect.void, - }); - yield* handleResult(result); - - const restartDecision = (r: SpawnResult) => { - const svc = services.get(def.name); - const desired = - svc === undefined ? "inactive" : SubscriptionRef.getUnsafe(svc.state).desired; - return Match.valueTags(r, { - HookFailed: () => ({ _tag: "Terminate", reason: "PolicyDisabled" }) as const, - ProcessExit: (result) => - decideRestart({ - cause: { _tag: "ProcessExit", exitCode: result.exitCode }, - policy: restartPolicy, - restartCount, - maxRestarts, - desired, - }), - Unhealthy: () => - decideRestart({ - cause: { _tag: "Unhealthy" }, - policy: restartPolicy, - restartCount, - maxRestarts, - desired, - }), - }); - }; - - let decision = restartDecision(result); - while (Predicate.isTagged(decision, "Restart")) { - restartCount = decision.restartCount; - - yield* sendEvent(def.name, { _tag: "RestartTriggered", restartCount }); - - // The previous process scope has closed, so external resources can - // be reserved safely for the duration of this restart's backoff. - yield* prepareStart(); - - if (Predicate.isTagged(result, "Unhealthy")) { - yield* appendRecentServiceLogs( - def.name, - `[restart] Service "${def.name}" is restarting after an unhealthy health check. Recent output:`, - `[restart] Service "${def.name}" is restarting after an unhealthy health check (no recent log output).`, - ); - } - - // Exponential-ish backoff: min(30s, 2^(n-1) seconds) - const backoffSeconds = Math.min(30, Math.pow(2, restartCount - 1)); - yield* Effect.sleep(Duration.seconds(backoffSeconds)); - - // Transition Restarting → Starting. State subscribers remain - // attached across every restart generation. - yield* sendEvent(def.name, { _tag: "BackoffElapsed" }); - - result = yield* spawnOnce(); - yield* handleResult(result); - decision = restartDecision(result); - } - - if ( - Predicate.isTagged(result, "Unhealthy") && - Predicate.isTagged(decision, "Terminate") && - decision.reason === "BudgetExhausted" - ) { - yield* sendEvent(def.name, { - _tag: "UnhealthyRestartExhausted", - error: UNHEALTHY_RESTART_EXHAUSTED_ERROR, - }); - } - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); - - const runServiceSafe = (def: ServiceDef, options?: ServiceStartOptions) => - Effect.sync(() => { - const service = services.get(def.name); - if (service !== undefined) { - service.effectiveDef = def; - } - }).pipe( - Effect.andThen(runService(def, options)), - Effect.catch((error) => - sendEvent(def.name, { - _tag: "SpawnFailed", - error: `Spawn failed: ${error.service} - ${String(error.cause)}`, - }).pipe(Effect.asVoid), - ), - ); - - const lookupDef = (name: string): ServiceDef | undefined => - graph.startOrder.find((d) => d.name === name); - - const resetService = (name: string): Effect.Effect => - Effect.gen(function* () { - const svc = services.get(name); - if (svc) { - const desired = SubscriptionRef.getUnsafe(svc.state).desired; - yield* SubscriptionRef.set(svc.state, initial(name, desired)); - } - }); - - const stopForRestart = (name: string): Effect.Effect => - Effect.gen(function* () { - yield* sendEvent(name, { _tag: "StopRequested" }); - yield* FiberMap.remove(fibers, name); - yield* sendEvent(name, { _tag: "ProcessExited", exitCode: 143 }); - }); - - const restartClosure = (name: string): ReadonlyArray => { - const activeServices = new Set( - graph.startOrder - .filter((def) => { - const service = services.get(def.name); - return ( - FiberMap.hasUnsafe(fibers, def.name) || - (service !== undefined && - SubscriptionRef.getUnsafe(service.state).desired === "running") - ); - }) - .map((def) => def.name), - ); - const closure = new Set( - restartClosureFor( - { - order: graph.startOrder.map((def) => def.name), - dependentsOf: (service) => graph.dependentsOf(service).map((def) => def.name), - }, - name, - activeServices, - ), - ); - return graph.startOrder.filter((def) => closure.has(def.name)); - }; - - const waitReadySingle = (def: ServiceDef): Effect.Effect => - Effect.suspend(() => { - const svc = services.get(def.name); - if (!svc) return Effect.void; - const current = SubscriptionRef.getUnsafe(svc.state); - const effectiveDef = svc.effectiveDef; - if (current.desired !== "running") { - return Effect.fail( - new ServiceReadyError({ - name: def.name, - reason: - current.desired === "inactive" - ? "Service has not been started" - : "Service was explicitly stopped", - }), - ); - } - if (current.status === "Failed" && !willRestartAfterExit(effectiveDef, current)) { - return Effect.fail( - new ServiceReadyError({ - name: def.name, - reason: current.error ?? "Service entered Failed state", - }), - ); - } - - if ( - (effectiveDef.restart ?? defaults.restart) === "no" && - effectiveDef.healthCheck == null - ) { - return waitForState( - svc, - (state) => state.status === "Failed" || state.status === "Stopped", - ).pipe( - Effect.flatMap((terminal) => - terminal.status === "Stopped" && terminal.exitCode === 0 - ? Effect.void - : Effect.fail( - new ServiceReadyError({ - name: def.name, - reason: - terminal.exitCode === null - ? (terminal.error ?? "Service entered Failed state") - : `One-shot service exited with code ${terminal.exitCode}`, - ...(terminal.exitCode === null ? {} : { exitCode: terminal.exitCode }), - }), - ), - ), - ); - } - - return waitForState( - svc, - (state) => - state.status === "Healthy" || - ((state.status === "Failed" || state.status === "Stopped") && - !willRestartAfterExit(effectiveDef, state)), - ).pipe( - Effect.flatMap((ready) => { - if (ready.status === "Healthy") return Effect.void; - return Effect.fail( - new ServiceReadyError({ - name: def.name, - reason: - ready.status === "Stopped" - ? "Service stopped before becoming ready" - : (ready.error ?? "Service entered Failed state"), - }), - ); - }), - ); - }); - - return { - start: (options) => - Effect.gen(function* () { - for (const def of graph.startOrder) { - yield* setDesired(def.name, "running"); - yield* FiberMap.run(fibers, def.name, runServiceSafe(def, options)); - } - }).pipe(lifecycleLock.withPermit), - - startService: (name: string, options) => - Effect.gen(function* () { - const def = lookupDef(name); - if (def === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - const order = graph.startOrderFor(name); - for (const d of order) { - const service = services.get(d.name); - const state = service?.state; - const status = - state === undefined ? undefined : SubscriptionRef.getUnsafe(state).status; - const restartPolicy = d.restart ?? defaults.restart; - - // A successful one-shot remains a satisfied dependency after its - // process exits. Naturally stopped long-running services can be - // started again even when their restart policy did not relaunch them. - if ( - status === "Stopped" && - d.name !== name && - service !== undefined && - SubscriptionRef.getUnsafe(service.state).desired !== "stopped" && - restartPolicy === "no" && - d.healthCheck == null - ) { - continue; - } - - if (status === "Failed") { - // The failed fiber may still be unwinding its process scope. - // Remove it before resetting the state used by the retry. - yield* FiberMap.remove(fibers, d.name); - yield* resetService(d.name); - } else if (status === "Stopped") { - yield* FiberMap.remove(fibers, d.name); - yield* resetService(d.name); - } - yield* setDesired(d.name, "running"); - yield* FiberMap.run(fibers, d.name, runServiceSafe(d, options), { - onlyIfMissing: true, - }); - } - }).pipe(lifecycleLock.withPermit), - - stop: Effect.gen(function* () { - const timeoutSecs = config?.shutdownTimeoutSeconds ?? defaults.shutdownTimeoutSeconds; - const desiredBeforeStop = new Map( - graph.startOrder.map((def) => { - const svc = services.get(def.name); - return [ - def.name, - svc === undefined ? "inactive" : SubscriptionRef.getUnsafe(svc.state).desired, - ] as const; - }), - ); - - yield* Effect.forEach( - graph.startOrder.filter((def) => desiredBeforeStop.get(def.name) === "running"), - (def) => setDesired(def.name, "stopped"), - { discard: true }, - ); - - const stopAll = Effect.gen(function* () { - const waitUntilStopped = (name: string) => { - const service = services.get(name); - return service === undefined - ? Effect.void - : waitForState( - service, - (state) => state.desired === "inactive" || state.status === "Stopped", - ).pipe(Effect.asVoid); - }; - const stopOne = (def: ServiceDef) => - Effect.gen(function* () { - if (desiredBeforeStop.get(def.name) === "inactive") { - return; - } - // Wait for all dependents to be stopped first - const dependents = graph.dependentsOf(def.name); - for (const dep of dependents) { - yield* waitUntilStopped(dep.name); - } - - // Now safe to stop this service - yield* sendEvent(def.name, { _tag: "StopRequested" }); - yield* FiberMap.remove(fibers, def.name); - // Force Stopped if still in Stopping (fiber was interrupted before ProcessExited) - yield* sendEvent(def.name, { _tag: "ProcessExited", exitCode: 143 }); - }); - - // Fork all stop effects in parallel - yield* Effect.all( - graph.startOrder.map((def) => stopOne(def)), - { concurrency: "unbounded" }, - ); - }); - - const stopFiber = yield* Effect.forkChild(stopAll); - const stoppedInTime = yield* Effect.race( - Fiber.await(stopFiber).pipe(Effect.as(true)), - Effect.sleep(Duration.seconds(timeoutSecs)).pipe(Effect.as(false)), - ); - - if (!stoppedInTime) { - for (const def of graph.startOrder) { - yield* logBuffer.append( - def.name, - "stderr", - `[shutdown-timeout] Global shutdown timed out after ${timeoutSecs}s, force-interrupting`, - ); - } - yield* Effect.all(forceStops.values(), { concurrency: "unbounded" }); - yield* Fiber.await(stopFiber); - } - }).pipe(lifecycleLock.withPermit), - - stopService: (name: string) => - Effect.gen(function* () { - if (lookupDef(name) === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - const affected = restartClosure(name); - for (const affectedDef of [...affected].reverse()) { - yield* setDesired(affectedDef.name, "stopped"); - yield* sendEvent(affectedDef.name, { _tag: "StopRequested" }); - yield* FiberMap.remove(fibers, affectedDef.name); - yield* sendEvent(affectedDef.name, { _tag: "ProcessExited", exitCode: 143 }); - } - }).pipe(lifecycleLock.withPermit), - - restartService: (name: string, options) => - Effect.gen(function* () { - const def = lookupDef(name); - if (def === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - const affected = restartClosure(name); - - for (const affectedDef of [...affected].reverse()) { - yield* stopForRestart(affectedDef.name); - } - for (const affectedDef of affected) { - yield* resetService(affectedDef.name); - yield* setDesired(affectedDef.name, "running"); - } - for (const affectedDef of affected) { - yield* FiberMap.run(fibers, affectedDef.name, runServiceSafe(affectedDef, options)); - } - }).pipe(lifecycleLock.withPermit), - - updateServiceDefinition: (name: string, def: ServiceDef) => - Effect.gen(function* () { - const existing = lookupDef(name); - if (existing === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - - const replacement = def.name === name ? def : { ...def, name }; - const nextGraph = yield* buildGraph( - graph.startOrder.map((current) => (current.name === name ? replacement : current)), - ); - graph = nextGraph; - }).pipe(lifecycleLock.withPermit), - - getState: (name: string) => - Effect.gen(function* () { - const svc = services.get(name); - if (svc === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - return SubscriptionRef.getUnsafe(svc.state); - }), - - getAllStates: Effect.sync(() => - graph.startOrder.map((def) => { - const svc = services.get(def.name); - return svc ? SubscriptionRef.getUnsafe(svc.state) : initial(def.name); - }), - ), - - stateChanges: (name: string) => - Effect.gen(function* () { - const svc = services.get(name); - if (svc === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - return SubscriptionRef.changes(svc.state); - }), - - allStateChanges: (() => { - const streams = graph.startOrder.map((def) => { - const svc = services.get(def.name); - return svc ? SubscriptionRef.changes(svc.state) : Stream.empty; - }); - return Stream.mergeAll(streams, { concurrency: "unbounded" }); - })(), - - waitReady: (name: string) => - Effect.gen(function* () { - const def = lookupDef(name); - if (def === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - yield* waitReadySingle(def); - }), - - waitAllReady: Effect.suspend(() => - Effect.all( - graph.startOrder - .filter((def) => { - const svc = services.get(def.name); - return ( - svc !== undefined && SubscriptionRef.getUnsafe(svc.state).desired === "running" - ); - }) - .map(waitReadySingle), - { concurrency: "unbounded" }, - ).pipe(Effect.asVoid), - ), - }; - }), - ); -} diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts deleted file mode 100644 index f07fa0d54e..0000000000 --- a/packages/process-compose/src/Orchestrator.unit.test.ts +++ /dev/null @@ -1,2550 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { - Clock, - Deferred, - Data, - Duration, - Effect, - Exit, - Fiber, - Layer, - Option, - Predicate, - Sink, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { FetchHttpClient } from "effect/unstable/http"; -import { buildGraph } from "./DependencyGraph.ts"; -import { LogBuffer } from "./LogBuffer.ts"; -import { Orchestrator } from "./Orchestrator.ts"; -import type { OrchestratorConfig, ServiceDef } from "./ServiceDef.ts"; -import type { ServiceState } from "./ServiceState.ts"; - -class TestFailure extends Data.TaggedError("TestFailure")<{ - readonly message: string; -}> {} - -// --- Mock factories --- - -const encoder = new TextEncoder(); - -function mockLogBuffer() { - const entries: Array<{ service: string; stream: string; line: string }> = []; - const entryEvents = createWaitList(); - return { - layer: Layer.succeed(LogBuffer, { - append: (service: string, stream: "stdout" | "stderr", line: string) => - Effect.sync(() => { - entries.push({ service, stream, line }); - entryEvents.notify(); - }), - subscribe: (_service: string) => Stream.empty, - subscribeAll: Stream.empty, - history: (service: string, limit = 100) => - Effect.gen(function* () { - const timestamp = yield* Clock.currentTimeMillis; - const matching = entries.filter((e) => e.service === service); - const sliced = matching.slice(-limit); - return sliced.map((e) => ({ - timestamp, - service: e.service, - stream: e.stream as "stdout" | "stderr", - line: e.line, - })); - }), - historyAll: (limit = 100, services?: ReadonlyArray) => - Effect.gen(function* () { - const timestamp = yield* Clock.currentTimeMillis; - const filtered = - services === undefined || services.length === 0 - ? entries - : entries.filter((entry) => services.includes(entry.service)); - const sliced = filtered.slice(-limit); - return sliced.map((entry) => ({ - timestamp, - service: entry.service, - stream: entry.stream as "stdout" | "stderr", - line: entry.line, - })); - }), - }), - get entries() { - return entries; - }, - waitForEntry( - predicate: (entry: { service: string; stream: string; line: string }) => boolean, - description: string, - ) { - return entryEvents.waitUntil(() => entries.some(predicate), description); - }, - }; -} - -interface SpawnRecord { - command: string; - args: ReadonlyArray; -} - -interface KillRecord { - command: string; - signal: string; -} - -interface SpawnOpts { - exitCode?: number; - getExitCode?: () => number; - stdout?: string[]; - exitDelay?: Duration.Input; - getExitDelay?: () => Duration.Input; -} - -function createWaitList() { - interface Waiter { - readonly ready: () => boolean; - readonly signal: Deferred.Deferred; - } - - const waiters = new Set(); - - const notify = () => { - for (const waiter of waiters) { - if (waiter.ready()) { - waiters.delete(waiter); - Effect.runSync(Deferred.succeed(waiter.signal, void 0)); - } - } - }; - - const waitUntil = (ready: () => boolean, description: string, timeoutMs = 30_000) => - Effect.gen(function* () { - if (ready()) return; - const signal = yield* Deferred.make(); - const waiter: Waiter = { ready, signal }; - waiters.add(waiter); - const result = yield* Deferred.await(signal).pipe( - Effect.timeoutOption(Duration.millis(timeoutMs)), - Effect.ensuring(Effect.sync(() => waiters.delete(waiter))), - ); - if (Option.isNone(result)) { - return yield* new TestFailure({ message: `Timed out waiting for ${description}` }); - } - }); - - return { notify, waitUntil }; -} - -const waitForState = ( - orc: Orchestrator["Service"], - name: string, - predicate: (state: ServiceState) => boolean, - description: string, -) => - Effect.gen(function* () { - const current = yield* orc.getState(name); - if (predicate(current)) return current; - - const stream = yield* orc.stateChanges(name); - const result = yield* stream.pipe( - Stream.filter(predicate), - Stream.runHead, - Effect.timeout(Duration.seconds(3)), - ); - return Option.getOrThrowWith( - result, - () => new TestFailure({ message: `Timed out waiting for ${name} to become ${description}` }), - ); - }); - -const waitForHealthy = (orc: Orchestrator["Service"], name: string) => - waitForState(orc, name, (state) => state.status === "Healthy", "Healthy"); - -const waitForFailed = (orc: Orchestrator["Service"], name: string) => - waitForState(orc, name, (state) => state.status === "Failed", "Failed"); - -const waitForStopped = (orc: Orchestrator["Service"], name: string) => - waitForState(orc, name, (state) => state.status === "Stopped", "Stopped"); - -function mockChildProcessSpawner( - opts: SpawnOpts & { - perService?: Record; - onSpawn?: (record: SpawnRecord) => void; - } = {}, -) { - const spawned: SpawnRecord[] = []; - const killed: KillRecord[] = []; - const spawnEvents = createWaitList(); - const killEvents = createWaitList(); - - return { - layer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const standardCommand = Predicate.isTagged(command, "StandardCommand"); - const cmd = standardCommand ? command.command : ""; - const args = standardCommand ? command.args : []; - const record: SpawnRecord = { command: cmd, args }; - spawned.push(record); - opts.onSpawn?.(record); - spawnEvents.notify(); - - // Per-service overrides - const svcOpts = opts.perService?.[cmd] ?? opts; - const exitDeferred = yield* Deferred.make(); - - const resolvedExitCode = svcOpts.getExitCode?.() ?? svcOpts.exitCode ?? 0; - yield* Effect.forkDetach( - Effect.andThen( - Effect.sleep(svcOpts.getExitDelay?.() ?? svcOpts.exitDelay ?? "10 millis"), - Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(resolvedExitCode)), - ), - ); - - const stdoutBytes = (svcOpts.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1000 + spawned.length), - stdout: Stream.fromIterable(stdoutBytes), - stderr: Stream.empty, - all: Stream.empty, - exitCode: Deferred.await(exitDeferred), - isRunning: Effect.succeed(true), - stdin: Sink.drain, - kill: (killOpts) => - Effect.gen(function* () { - killed.push({ command: cmd, signal: killOpts?.killSignal ?? "SIGTERM" }); - killEvents.notify(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(143)); - }), - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - get spawned() { - return spawned; - }, - get killed() { - return killed; - }, - waitForSpawnCount(count: number) { - return spawnEvents.waitUntil( - () => spawned.length >= count, - `${count} spawned processes; saw ${spawned.length}`, - ); - }, - waitForSpawn(command: string, count = 1) { - return spawnEvents.waitUntil( - () => spawned.filter((record) => record.command === command).length >= count, - `${count} spawned ${command} processes`, - ); - }, - waitForKillCount(count: number) { - return killEvents.waitUntil( - () => killed.length >= count, - `${count} killed processes; saw ${killed.length}`, - ); - }, - }; -} - -function setupOrchestrator( - defs: ReadonlyArray, - runnerOpts: Parameters[0] = {}, - config?: OrchestratorConfig, -) { - const graph = Effect.runSync(buildGraph(defs)); - const proc = mockChildProcessSpawner(runnerOpts); - const log = mockLogBuffer(); - const layer = Orchestrator.layer(graph, config).pipe( - Layer.provide(Layer.mergeAll(proc.layer, log.layer, FetchHttpClient.layer)), - ); - return { graph, proc, log, layer }; -} - -function mockStuckChildProcessSpawner() { - const spawned: SpawnRecord[] = []; - const killed: string[] = []; - - return { - layer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const standardCommand = Predicate.isTagged(command, "StandardCommand"); - const cmd = standardCommand ? command.command : ""; - const args = standardCommand ? command.args : []; - spawned.push({ command: cmd, args }); - - // exitCode Deferred that is NEVER resolved — simulates stuck process - const exitDeferred = yield* Deferred.make(); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1000 + spawned.length), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Deferred.await(exitDeferred), - isRunning: Effect.succeed(true), - stdin: Sink.drain, - kill: (killOpts) => - Effect.gen(function* () { - const signal = killOpts?.killSignal ?? "SIGTERM"; - killed.push(signal); - // SIGKILL always succeeds — resolve exit deferred - if (signal === "SIGKILL") { - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(137)); - } - // Await exit like the real spawner — blocks until process exits - yield* Deferred.await(exitDeferred); - }).pipe(Effect.asVoid), - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - get spawned() { - return spawned; - }, - get killed() { - return killed; - }, - }; -} - -function setupOrchestratorWithStuckKill( - defs: ReadonlyArray, - config?: OrchestratorConfig, -) { - const graph = Effect.runSync(buildGraph(defs)); - const proc = mockStuckChildProcessSpawner(); - const log = mockLogBuffer(); - const layer = Orchestrator.layer(graph, config).pipe( - Layer.provide(Layer.mergeAll(proc.layer, log.layer, FetchHttpClient.layer)), - ); - return { graph, proc, log, layer }; -} - -// --- Helpers --- - -const svc = (name: string, overrides?: Partial): ServiceDef => ({ - name, - command: name, - ...overrides, -}); - -// --- Tests --- - -describe("Orchestrator", () => { - it.live("start() spawns all services", () => { - const { layer, proc } = setupOrchestrator([svc("a"), svc("b"), svc("c")]); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawnCount(3); - expect(proc.spawned.length).toBe(3); - const names = proc.spawned.map((s) => s.command).sort(); - expect(names).toEqual(["a", "b", "c"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("start() respects dependency order via started condition", () => { - const spawnOrder: string[] = []; - const { layer } = setupOrchestrator( - [ - svc("db"), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { - exitDelay: "500 millis", - onSpawn: (o) => spawnOrder.push(o.command), - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "api"); - expect(spawnOrder[0]).toBe("db"); - expect(spawnOrder[1]).toBe("api"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("a stopping dependency does not release services waiting for it to start", () => { - const startedHook = Deferred.makeUnsafe(); - const { layer, proc } = setupOrchestrator( - [ - svc("db", { - hooks: [{ on: "started", run: () => Deferred.await(startedHook) }], - }), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { exitDelay: "5 seconds" }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawn("db"); - yield* orc.stopService("db"); - yield* Effect.sleep("50 millis"); - - expect(proc.spawned.some((spawn) => spawn.command === "api")).toBe(false); - yield* orc.stopService("api"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("getState returns current state for a service", () => { - const { layer } = setupOrchestrator([svc("a")], { - exitDelay: "500 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - const state = yield* orc.getState("a"); - // Should be Running or Healthy (no health check = immediate Healthy) - expect(["Running", "Healthy"]).toContain(state.status); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("getState returns ServiceNotFoundError for unknown service", () => { - const { layer } = setupOrchestrator([svc("a")]); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - const exit = yield* orc.getState("nonexistent").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("getAllStates returns state for every service", () => { - const { layer } = setupOrchestrator([svc("a"), svc("b")]); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - const states = yield* orc.getAllStates; - expect(states.length).toBe(2); - const names = states.map((s) => s.name).sort(); - expect(names).toEqual(["a", "b"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("stopService sets state to Stopped", () => { - const { layer } = setupOrchestrator([svc("a")], { - exitDelay: "5 seconds", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - yield* orc.stopService("a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Stopped"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("stop() interrupts all service fibers", () => { - const { layer, proc } = setupOrchestrator([svc("a"), svc("b")], { - exitDelay: "5 seconds", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawnCount(2); - expect(proc.spawned.length).toBe(2); - yield* orc.stop; - yield* proc.waitForKillCount(2); - // Kill should have been called for each service (via finalizer) - expect(proc.killed.length).toBeGreaterThanOrEqual(2); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("supervised services spawn the supervisor runtime", () => { - const { layer, proc } = setupOrchestrator([ - svc("database", { - command: "container-runtime", - args: ["run", "database"], - supervision: { - orphanCleanup: [ - { - _tag: "RunCommand", - executable: "container-runtime", - args: ["remove", "database-test"], - }, - ], - }, - }), - ]); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawnCount(1); - expect(proc.spawned).toHaveLength(1); - expect(proc.spawned[0]?.command).toBe(process.execPath); - expect(proc.spawned[0]?.args[0]).toContain("supervisor-runtime.ts"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("stopping a supervisor during its spawn handshake cleans it up", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("database", { - command: "container-runtime", - args: ["run", "database"], - supervision: { - orphanCleanup: [ - { - _tag: "RunCommand", - executable: "container-runtime", - args: ["remove", "database-test"], - }, - ], - }, - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("database", { beforeSpawn: () => Effect.void }); - yield* proc.waitForSpawnCount(1); - - yield* orc.stopService("database"); - yield* proc.waitForKillCount(1); - - expect(proc.killed[0]?.command).toBe(process.execPath); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("ServiceDef shutdown does not expose killMode", () => { - const service: ServiceDef = { - name: "a", - command: "a", - shutdown: { - signal: "SIGTERM", - // @ts-expect-error killMode was removed; supervision owns tree teardown. - killMode: "group", - }, - }; - - return Effect.sync(() => { - expect(service.shutdown?.signal).toBe("SIGTERM"); - }); - }); - - it.live("stop() waits for service cleanup finalizers", () => { - let cleanedUp = false; - const { layer, proc } = setupOrchestrator( - [ - svc("postgres", { - cleanup: Effect.sleep(Duration.millis(150)).pipe( - Effect.andThen( - Effect.sync(() => { - cleanedUp = true; - }), - ), - ), - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawnCount(1); - yield* orc.stop; - expect(cleanedUp).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService starts transitive dependencies", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("db"), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - svc("web", { - dependencies: [{ service: "api", condition: "started" }], - }), - svc("unrelated"), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("web"); - yield* proc.waitForSpawnCount(3); - const names = proc.spawned.map((s) => s.command).sort(); - // Should start db, api, web — but NOT unrelated - expect(names).toEqual(["api", "db", "web"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("runs beforeSpawn for each service at its spawn boundary", () => { - const events: string[] = []; - const { layer, proc } = setupOrchestrator( - [ - svc("db"), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { - exitDelay: "500 millis", - onSpawn: ({ command }) => events.push(`spawn:${command}`), - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api", { - beforeSpawn: (name) => Effect.sync(() => events.push(`release:${name}`)), - }); - yield* proc.waitForSpawnCount(2); - - expect(events).toEqual(["release:db", "spawn:db", "release:api", "spawn:api"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService restarts an explicitly requested dependency closure", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("db"), - svc("web", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("web"); - yield* proc.waitForSpawnCount(2); - yield* orc.stopService("db"); - yield* waitForStopped(orc, "db"); - - yield* orc.startService("web"); - yield* proc.waitForSpawnCount(4); - - expect(proc.spawned.map((record) => record.command)).toEqual(["db", "web", "db", "web"]); - expect((yield* orc.getState("db")).status).not.toBe("Stopped"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService preserves successful one-shot dependencies", () => { - const { layer, proc } = setupOrchestrator([ - svc("setup", { restart: "no" }), - svc("api", { - dependencies: [{ service: "setup", condition: "completed" }], - }), - svc("worker", { - dependencies: [{ service: "setup", condition: "completed" }], - }), - ]); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api"); - yield* proc.waitForSpawnCount(2); - yield* waitForStopped(orc, "setup"); - - yield* orc.startService("worker"); - yield* proc.waitForSpawnCount(3); - - expect(proc.spawned.map((record) => record.command)).toEqual(["setup", "api", "worker"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService restarts a stopped health-checked dependency", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("db", { - restart: "no", - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.01, - }, - }), - svc("api", { - dependencies: [{ service: "db", condition: "healthy" }], - }), - svc("worker", { - dependencies: [{ service: "db", condition: "healthy" }], - dependencyTimeoutSeconds: 1, - }), - ], - { - exitDelay: "5 seconds", - perService: { - db: { exitCode: 0, exitDelay: "50 millis" }, - check: { exitCode: 0, exitDelay: "1 millis" }, - }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api"); - yield* proc.waitForSpawn("api"); - yield* waitForStopped(orc, "db"); - - yield* orc.startService("worker"); - yield* proc.waitForSpawn("db", 2); - yield* proc.waitForSpawn("worker"); - - expect(proc.spawned.filter((spawn) => spawn.command === "db")).toHaveLength(2); - expect((yield* orc.getState("worker")).status).not.toBe("Failed"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService reruns a directly requested successful one-shot", () => { - const { layer, proc } = setupOrchestrator([svc("setup", { restart: "no" })]); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("setup"); - yield* proc.waitForSpawn("setup"); - yield* waitForStopped(orc, "setup"); - - yield* orc.startService("setup"); - yield* proc.waitForSpawn("setup", 2); - - expect(proc.spawned.map((record) => record.command)).toEqual(["setup", "setup"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService restarts a naturally stopped long-running service", () => { - const { layer, proc } = setupOrchestrator([svc("api", { restart: "on-failure" })], { - exitCode: 0, - exitDelay: "25 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api"); - yield* proc.waitForSpawn("api"); - yield* waitForStopped(orc, "api"); - - yield* orc.startService("api"); - yield* proc.waitForSpawn("api", 2); - - expect(proc.spawned.map((record) => record.command)).toEqual(["api", "api"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService replaces a failed fiber before retrying", () => { - let attempts = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("api", { - restart: "no", - hooks: [ - { - on: "started", - run: () => - Effect.suspend(() => { - attempts++; - return attempts === 1 - ? Effect.fail(new TestFailure({ message: "first attempt failed" })) - : Effect.void; - }), - }, - ], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api"); - yield* waitForFailed(orc, "api"); - - yield* orc.startService("api"); - yield* proc.waitForSpawn("api", 2); - yield* Effect.sleep(Duration.millis(25)); - - expect(attempts).toBe(2); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService serializes concurrent retries of the same failed service", () => { - let attempts = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("api", { - restart: "no", - hooks: [ - { - on: "started", - run: () => - Effect.suspend(() => { - attempts++; - return attempts === 1 - ? Effect.fail(new TestFailure({ message: "first attempt failed" })) - : Effect.void; - }), - }, - ], - }), - ], - { exitDelay: "50 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api"); - yield* waitForFailed(orc, "api"); - - yield* Effect.all([orc.startService("api"), orc.startService("api")], { - concurrency: "unbounded", - }); - yield* orc.waitReady("api"); - - expect(attempts).toBe(2); - expect(proc.spawned.map((record) => record.command)).toEqual(["api", "api"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("dependents observe a retried service without being relaunched", () => { - let attempts = 0; - const beforeSpawn: string[] = []; - const { layer, proc } = setupOrchestrator( - [ - svc("db", { - restart: "no", - hooks: [ - { - on: "started", - run: () => - Effect.suspend(() => { - attempts++; - return attempts === 1 - ? Effect.fail(new TestFailure({ message: "first attempt failed" })) - : Effect.void; - }), - }, - ], - }), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api", { - beforeSpawn: (name) => Effect.sync(() => beforeSpawn.push(name)), - }); - yield* waitForFailed(orc, "db"); - const ready = yield* orc.waitReady("api").pipe(Effect.forkScoped); - - yield* orc.startService("db", { - beforeSpawn: (name) => Effect.sync(() => beforeSpawn.push(name)), - }); - yield* proc.waitForSpawn("api"); - yield* Fiber.join(ready); - - expect(proc.spawned.map((record) => record.command)).toEqual(["db", "db", "api"]); - expect(beforeSpawn).toEqual(["db", "db", "api"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("one-shot readiness waiters survive a dependency retry", () => { - let attempts = 0; - const { layer } = setupOrchestrator( - [ - svc("db", { - restart: "no", - hooks: [ - { - on: "started", - run: () => - Effect.suspend(() => { - attempts++; - return attempts === 1 - ? Effect.fail(new TestFailure({ message: "first attempt failed" })) - : Effect.void; - }), - }, - ], - }), - svc("setup", { - restart: "no", - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { exitDelay: "20 millis" }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("setup"); - yield* waitForFailed(orc, "db"); - const ready = yield* orc.waitReady("setup").pipe(Effect.forkScoped); - - yield* orc.startService("db"); - yield* Fiber.join(ready); - - expect(attempts).toBe(2); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("startService leaves never-requested dependents pending when retrying a service", () => { - let attempts = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("db", { - restart: "no", - hooks: [ - { - on: "started", - run: () => - Effect.suspend(() => { - attempts++; - return attempts === 1 - ? Effect.fail(new TestFailure({ message: "first attempt failed" })) - : Effect.void; - }), - }, - ], - }), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("db"); - yield* waitForFailed(orc, "db"); - - yield* orc.startService("db"); - yield* proc.waitForSpawn("db", 2); - yield* Effect.sleep(Duration.millis(25)); - - expect(proc.spawned.map((record) => record.command)).toEqual(["db", "db"]); - expect((yield* orc.getState("api")).status).toBe("Pending"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("restartService stops and restarts a service", () => { - const { layer, proc } = setupOrchestrator([svc("a")], { - exitDelay: "5 seconds", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawnCount(1); - expect(proc.spawned.length).toBe(1); - yield* orc.restartService("a"); - yield* proc.waitForSpawnCount(2); - // Should have spawned twice - expect(proc.spawned.length).toBe(2); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("restartService restarts transitive dependents and preserves unrelated services", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("db"), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - svc("web", { - dependencies: [{ service: "api", condition: "started" }], - }), - svc("unrelated"), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* Effect.sleep(Duration.millis(100)); - - yield* orc.restartService("db"); - yield* Effect.sleep(Duration.millis(100)); - - const spawnCounts = proc.spawned.reduce>((counts, record) => { - counts[record.command] = (counts[record.command] ?? 0) + 1; - return counts; - }, {}); - - expect(spawnCounts).toEqual({ - api: 2, - db: 2, - unrelated: 1, - web: 2, - }); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("restartService leaves never-started dependents pending", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("db"), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("db"); - yield* proc.waitForSpawn("db"); - - yield* orc.restartService("db"); - yield* proc.waitForSpawn("db", 2); - - expect(proc.spawned.map((record) => record.command)).toEqual(["db", "db"]); - expect((yield* orc.getState("api")).status).toBe("Pending"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("restartService replays completed dependencies of active dependents", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("db"), - svc("setup", { - restart: "no", - dependencies: [{ service: "db", condition: "started" }], - }), - svc("api", { - dependencies: [{ service: "setup", condition: "completed" }], - }), - ], - { - perService: { - db: { exitDelay: "5 seconds" }, - setup: { exitDelay: "10 millis" }, - api: { exitDelay: "5 seconds" }, - }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api"); - yield* proc.waitForSpawnCount(3); - yield* waitForStopped(orc, "setup"); - - yield* orc.restartService("db"); - yield* proc.waitForSpawnCount(6); - - expect(proc.spawned.map((record) => record.command)).toEqual([ - "db", - "setup", - "api", - "db", - "setup", - "api", - ]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("updateServiceDefinition restarts with the updated definition", () => { - const { layer, proc } = setupOrchestrator([svc("a")], { - exitDelay: "5 seconds", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* Effect.sleep(Duration.millis(50)); - yield* orc.updateServiceDefinition("a", svc("a", { command: "a-next", args: ["--next"] })); - yield* orc.restartService("a"); - yield* Effect.sleep(Duration.millis(50)); - - expect(proc.spawned.map((record) => record.command)).toEqual(["a", "a-next"]); - expect(proc.spawned.at(-1)?.args).toEqual(["--next"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("stateChanges returns a stream of state transitions", () => { - const { layer } = setupOrchestrator([svc("a")], { - exitDelay: "200 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - const stream = yield* orc.stateChanges("a"); - // Collect state changes with a timeout - const fiber = yield* stream.pipe( - Stream.takeUntil((s) => s.status === "Stopped"), - Stream.runCollect, - Effect.timeout(Duration.seconds(2)), - Effect.forkChild, - ); - yield* orc.start(); - const result = yield* Fiber.join(fiber); - // result is Option> due to timeout - // The stream should have collected at least Pending, Starting, etc. - expect(result).toBeDefined(); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("disabled services are not started", () => { - const { layer, proc } = setupOrchestrator([svc("a"), svc("b", { enabled: false })]); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawnCount(1); - const names = proc.spawned.map((s) => s.command); - expect(names).toEqual(["a"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("services without health check are marked Healthy immediately", () => { - const { layer } = setupOrchestrator([svc("a")], { - exitDelay: "500 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Healthy"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("logs are captured via LogBuffer", () => { - const { layer, log } = setupOrchestrator([svc("a")], { - stdout: ["hello world"], - exitDelay: "200 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* log.waitForEntry( - (entry) => entry.service === "a" && entry.line === "hello world", - "service stdout log", - ); - const matching = log.entries.filter((e) => e.service === "a" && e.line === "hello world"); - expect(matching.length).toBe(1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("process exit with code 0 sets Stopped", () => { - const { layer } = setupOrchestrator([svc("a", { restart: "no" })], { - exitCode: 0, - exitDelay: "50 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForStopped(orc, "a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Stopped"); - expect(state.exitCode).toBe(0); - expect(state.pid).toBeNull(); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("process exit with non-zero code sets Failed", () => { - const { layer, log } = setupOrchestrator([svc("a", { restart: "no" })], { - exitCode: 1, - exitDelay: "50 millis", - stdout: ["about to fail"], - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForFailed(orc, "a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Failed"); - expect(state.exitCode).toBe(1); - expect(state.pid).toBeNull(); - expect(log.entries.some((e) => e.line.includes("[process-exited]"))).toBe(true); - expect(log.entries.some((e) => e.line.includes("about to fail"))).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - describe("dependency timeout", () => { - it.live("transitions to Failed when dependency never becomes healthy", () => { - const { layer } = setupOrchestrator( - [ - svc("db", { - restart: "no", - healthCheck: { - probe: { _tag: "Exec", command: "true", args: [] }, - initialDelaySeconds: 999, - }, - }), - svc("api", { - restart: "no", - dependencies: [{ service: "db", condition: "healthy" }], - dependencyTimeoutSeconds: 0.2, - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForFailed(orc, "api"); - const state = yield* orc.getState("api"); - expect(state.status).toBe("Failed"); - expect(state.error).toContain("Timed out"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("healthy dependency fails immediately when health restarts are exhausted", () => { - const { layer } = setupOrchestrator( - [ - svc("db", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.01, - startupFailureThreshold: 1, - failureThreshold: 1, - }, - }), - svc("api", { - restart: "no", - dependencies: [{ service: "db", condition: "healthy" }], - dependencyTimeoutSeconds: 5, - }), - ], - { - exitDelay: "5 seconds", - perService: { check: { exitCode: 1, exitDelay: "1 millis" } }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - const state = yield* waitForFailed(orc, "api"); - expect(state.error).toContain("Dependency db failed"); - expect(state.error).not.toContain("Timed out"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("healthy dependency waits through a transient failed restart", () => { - let databaseSpawns = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("db", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - initialDelaySeconds: 0.05, - periodSeconds: 0.05, - }, - }), - svc("api", { - restart: "no", - dependencies: [{ service: "db", condition: "healthy" }], - dependencyTimeoutSeconds: 5, - }), - ], - { - perService: { - db: { - exitCode: 1, - getExitDelay: () => (++databaseSpawns === 1 ? "10 millis" : "5 seconds"), - }, - check: { exitCode: 0, exitDelay: "1 millis" }, - }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawn("api"); - - expect(proc.spawned.filter((spawn) => spawn.command === "db")).toHaveLength(2); - expect((yield* orc.getState("api")).status).not.toBe("Failed"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("healthy dependency fails after clean-exit restarts are exhausted", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("db", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - initialDelaySeconds: 5, - }, - }), - svc("api", { - restart: "no", - dependencies: [{ service: "db", condition: "healthy" }], - dependencyTimeoutSeconds: 3, - }), - ], - { - perService: { - db: { exitCode: 0, exitDelay: "10 millis" }, - check: { exitCode: 0, exitDelay: "1 millis" }, - }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - - const state = yield* waitForFailed(orc, "api"); - expect(state.error).toContain("Dependency db failed"); - expect(state.error).not.toContain("Timed out"); - expect(proc.spawned.filter((spawn) => spawn.command === "db")).toHaveLength(2); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("completed dependency waits through a transient failed restart", () => { - let setupSpawns = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("setup", { - restart: "on-failure", - maxRestarts: 1, - }), - svc("app", { - restart: "no", - dependencies: [{ service: "setup", condition: "completed" }], - dependencyTimeoutSeconds: 5, - }), - ], - { - perService: { - setup: { - getExitCode: () => (++setupSpawns === 1 ? 1 : 0), - exitDelay: "10 millis", - }, - app: { exitDelay: "5 seconds" }, - }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawn("app"); - - expect(proc.spawned.filter((spawn) => spawn.command === "setup")).toHaveLength(2); - expect((yield* orc.getState("app")).status).not.toBe("Failed"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("dependency waits use the running generation restart policy", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("setup", { restart: "no" }), - svc("app", { - restart: "no", - dependencies: [{ service: "setup", condition: "completed" }], - dependencyTimeoutSeconds: 1, - }), - ], - { - perService: { - setup: { exitCode: 1, exitDelay: "200 millis" }, - app: { exitDelay: "5 seconds" }, - }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawn("setup"); - - // The replacement applies on the next explicit restart. The running - // generation still uses restart:no and must remain authoritative. - yield* orc.updateServiceDefinition("setup", svc("setup", { restart: "always" })); - - const state = yield* waitForFailed(orc, "app"); - expect(state.error).toContain("Dependency setup failed"); - expect(state.error).not.toContain("Timed out"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("no timeout when dependency resolves before deadline", () => { - const { layer } = setupOrchestrator( - [ - svc("db"), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - dependencyTimeoutSeconds: 5, - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForState( - orc, - "api", - (state) => state.status === "Running" || state.status === "Healthy", - "Running or Healthy", - ); - const state = yield* orc.getState("api"); - expect(["Running", "Healthy"]).toContain(state.status); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("timeout with completed condition", () => { - const { layer } = setupOrchestrator( - [ - svc("setup", { restart: "no" }), - svc("app", { - restart: "no", - dependencies: [{ service: "setup", condition: "completed" }], - dependencyTimeoutSeconds: 0.1, - }), - ], - { - exitDelay: "5 seconds", - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForFailed(orc, "app"); - const state = yield* orc.getState("app"); - expect(state.status).toBe("Failed"); - expect(state.error).toContain("Timed out"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("completed dependency fails immediately when health restarts are exhausted", () => { - const { layer } = setupOrchestrator( - [ - svc("setup", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.01, - startupFailureThreshold: 1, - failureThreshold: 1, - }, - }), - svc("app", { - restart: "no", - dependencies: [{ service: "setup", condition: "completed" }], - dependencyTimeoutSeconds: 5, - }), - ], - { - exitDelay: "5 seconds", - perService: { check: { exitCode: 1, exitDelay: "1 millis" } }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - const state = yield* waitForFailed(orc, "app"); - expect(state.error).toContain("Dependency setup failed"); - expect(state.error).not.toContain("Timed out"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - }); - - describe("failure diagnostics", () => { - it.live("logs diagnostic output when service becomes unhealthy", () => { - let checkCalls = 0; - const { layer, log } = setupOrchestrator( - [ - svc("a", { - restart: "no", - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.05, - successThreshold: 1, - failureThreshold: 2, - }, - }), - ], - { - exitDelay: "5 seconds", - stdout: ["line1", "line2", "error: something broke"], - perService: { - check: { - exitDelay: "1 millis", - getExitCode: () => { - checkCalls++; - return checkCalls <= 1 ? 0 : 1; - }, - }, - }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* log.waitForEntry( - (entry) => entry.service === "a" && entry.line.includes("[health-check-failed]"), - "health-check failure diagnostic", - ); - const diagnosticEntries = log.entries.filter( - (e) => e.service === "a" && e.line.includes("[health-check-failed]"), - ); - expect(diagnosticEntries.length).toBeGreaterThanOrEqual(1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - }); - - describe("lifecycle hooks", () => { - it.live("runs on:started hook after process spawns", () => { - let hookRan = false; - const { layer } = setupOrchestrator( - [ - svc("a", { - hooks: [ - { - on: "started", - run: (_log) => - Effect.sync(() => { - hookRan = true; - }), - }, - ], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - expect(hookRan).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("runs on:healthy hook after health check passes", () => { - let hookRan = false; - const { layer } = setupOrchestrator( - [ - svc("a", { - hooks: [ - { - on: "healthy", - run: (_log) => - Effect.sync(() => { - hookRan = true; - }), - }, - ], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - expect(hookRan).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("runs on:healthy hook when an unhealthy service recovers", () => { - let checkCalls = 0; - let healthyHookRuns = 0; - const { layer } = setupOrchestrator( - [ - svc("a", { - restart: "no", - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.01, - startupFailureThreshold: 1, - successThreshold: 1, - failureThreshold: 1, - }, - hooks: [ - { - on: "healthy", - run: () => - Effect.sync(() => { - healthyHookRuns++; - }), - }, - ], - }), - ], - { - exitDelay: "5 seconds", - perService: { - check: { - exitDelay: "1 millis", - getExitCode: () => (++checkCalls === 1 ? 1 : 0), - }, - }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForState(orc, "a", (state) => state.status === "Unhealthy", "Unhealthy"); - yield* waitForHealthy(orc, "a"); - expect(healthyHookRuns).toBe(1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("publishes a failed recovery hook as a terminal failure", () => { - let checkCalls = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - restart: "no", - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.01, - startupFailureThreshold: 1, - successThreshold: 1, - failureThreshold: 1, - }, - hooks: [ - { - on: "healthy", - run: () => Effect.fail(new TestFailure({ message: "recovery failed" })), - }, - ], - }), - ], - { - exitDelay: "5 seconds", - perService: { - check: { - exitDelay: "1 millis", - getExitCode: () => (++checkCalls === 1 ? 1 : 0), - }, - }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForState(orc, "a", (state) => state.status === "Unhealthy", "Unhealthy"); - const state = yield* waitForFailed(orc, "a"); - expect(proc.killed.some((record) => record.command === "a")).toBe(true); - expect(state.pid).toBeNull(); - expect(state.error).toContain("on:healthy"); - expect(state.error).toContain("recovery failed"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("dependent waits for on:started hook to complete before starting", () => { - const order: string[] = []; - const { layer } = setupOrchestrator( - [ - svc("db", { - hooks: [ - { - on: "started", - run: (_log) => - Effect.gen(function* () { - yield* Effect.sleep(Duration.millis(100)); - order.push("db-hook-done"); - }), - }, - ], - }), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { - exitDelay: "5 seconds", - onSpawn: (record) => { - if (record.command === "api") order.push("api-spawned"); - }, - }, - ); - return Effect.gen(function* () { - const orchestrator = yield* Orchestrator; - yield* orchestrator.start(); - yield* waitForHealthy(orchestrator, "api"); - expect(order).toEqual(["db-hook-done", "api-spawned"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("dependent waits for on:healthy hook to complete before starting", () => { - const order: string[] = []; - const { layer } = setupOrchestrator( - [ - svc("db", { - hooks: [ - { - on: "healthy", - run: (_log) => - Effect.gen(function* () { - yield* Effect.sleep(Duration.millis(100)); - order.push("db-hook-done"); - }), - }, - ], - }), - svc("api", { - dependencies: [{ service: "db", condition: "healthy" }], - }), - ], - { - exitDelay: "5 seconds", - onSpawn: (r) => { - if (r.command === "api") order.push("api-spawned"); - }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "api"); - expect(order).toEqual(["db-hook-done", "api-spawned"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("hook failure with policy:fail transitions to Failed", () => { - const { layer } = setupOrchestrator( - [ - svc("a", { - restart: "no", - hooks: [ - { - on: "started", - run: (_log) => Effect.fail(new TestFailure({ message: "migration failed" })), - }, - ], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForFailed(orc, "a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Failed"); - expect(state.error).toContain("migration failed"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("hook failure with policy:ignore continues normally", () => { - const { layer } = setupOrchestrator( - [ - svc("a", { - hooks: [ - { - on: "started", - run: (_log) => Effect.fail(new TestFailure({ message: "optional hook failed" })), - failurePolicy: "ignore", - }, - ], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Healthy"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("hook timeout transitions to Failed", () => { - const { layer } = setupOrchestrator( - [ - svc("a", { - restart: "no", - hooks: [ - { - on: "started", - run: (_log) => Effect.sleep(Duration.seconds(60)), - timeoutSeconds: 0.1, - }, - ], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForFailed(orc, "a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Failed"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("hooks re-run on service restart", () => { - let hookCount = 0; - const { layer } = setupOrchestrator( - [ - svc("a", { - hooks: [ - { - on: "started", - run: (_log) => - Effect.sync(() => { - hookCount++; - }), - }, - ], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - expect(hookCount).toBe(1); - yield* orc.restartService("a"); - yield* waitForHealthy(orc, "a"); - expect(hookCount).toBe(2); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("multiple hooks on same trigger run in order", () => { - const order: number[] = []; - const { layer } = setupOrchestrator( - [ - svc("a", { - hooks: [ - { on: "started", run: (_log) => Effect.sync(() => order.push(1)) }, - { on: "started", run: (_log) => Effect.sync(() => order.push(2)) }, - { on: "started", run: (_log) => Effect.sync(() => order.push(3)) }, - ], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - expect(order).toEqual([1, 2, 3]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("hook can log to service log buffer via log callback", () => { - const { layer, log } = setupOrchestrator( - [ - svc("a", { - hooks: [ - { - on: "started", - run: (log) => - Effect.gen(function* () { - yield* log("stdout", "migration starting"); - yield* log("stdout", "migration complete"); - }), - }, - ], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* log.waitForEntry( - (entry) => entry.service === "a" && entry.line === "migration complete", - "hook completion log", - ); - const hookLogs = log.entries.filter( - (e) => e.service === "a" && e.line === "migration complete", - ); - expect(hookLogs.length).toBe(1); - expect(hookLogs[0]?.stream).toBe("stdout"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("hook log callback is scoped to the correct service", () => { - const { layer, log } = setupOrchestrator( - [ - svc("db", { - hooks: [{ on: "started", run: (log) => log("stdout", "db-hook-log") }], - }), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - hooks: [{ on: "started", run: (log) => log("stdout", "api-hook-log") }], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "api"); - const dbLogs = log.entries.filter((e) => e.service === "db" && e.line === "db-hook-log"); - const apiLogs = log.entries.filter((e) => e.service === "api" && e.line === "api-hook-log"); - expect(dbLogs.length).toBe(1); - expect(apiLogs.length).toBe(1); - // No cross-contamination - const cross = log.entries.filter((e) => e.service === "db" && e.line === "api-hook-log"); - expect(cross.length).toBe(0); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("failed hook with ignore policy still captures log output", () => { - const { layer, log } = setupOrchestrator( - [ - svc("a", { - hooks: [ - { - on: "started", - run: (log) => - Effect.gen(function* () { - yield* log("stderr", "attempting migration..."); - return yield* new TestFailure({ message: "migration failed" }); - }), - failurePolicy: "ignore", - }, - ], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Healthy"); - const hookLogs = log.entries.filter( - (e) => e.service === "a" && e.line === "attempting migration...", - ); - expect(hookLogs.length).toBe(1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - }); - - describe("parallel shutdown", () => { - it.live("stop() stops all independent services", () => { - const { layer, proc } = setupOrchestrator([svc("a"), svc("b"), svc("c")], { - exitDelay: "5 seconds", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawnCount(3); - yield* orc.stop; - const states = yield* orc.getAllStates; - for (const s of states) { - expect(s.status).toBe("Stopped"); - } - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("stop() respects dependency order: dependent stops before dependency", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("db"), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawn("api"); - yield* orc.stop; - - // api must stop before db (dependent before dependency) - const killOrder = proc.killed.map((record) => record.command); - expect(killOrder.indexOf("api")).toBeLessThan(killOrder.indexOf("db")); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("stop() handles diamond dependencies", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("a"), - svc("b", { dependencies: [{ service: "a", condition: "started" }] }), - svc("c", { dependencies: [{ service: "a", condition: "started" }] }), - svc("d", { - dependencies: [ - { service: "b", condition: "started" }, - { service: "c", condition: "started" }, - ], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawnCount(4); - yield* orc.stop; - const states = yield* orc.getAllStates; - for (const s of states) { - expect(s.status).toBe("Stopped"); - } - }).pipe(Effect.provide(layer), Effect.scoped); - }); - }); - - describe("global shutdown timeout", () => { - it.live("stop() completes within timeout under normal conditions", () => { - const { layer } = setupOrchestrator( - [svc("a"), svc("b")], - { exitDelay: "5 seconds" }, - { shutdownTimeoutSeconds: 5 }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForHealthy(orc, "a"); - yield* orc.stop; - const states = yield* orc.getAllStates; - for (const s of states) { - expect(s.status).toBe("Stopped"); - } - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("stop() force-interrupts when global timeout expires", () => { - const { layer, proc } = setupOrchestratorWithStuckKill( - [svc("stuck", { shutdown: { timeoutSeconds: 999 } })], - { shutdownTimeoutSeconds: 0.5 }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForState(orc, "stuck", (state) => state.status === "Healthy", "Healthy"); - const before = yield* Clock.currentTimeMillis; - yield* orc.stop; - const elapsed = (yield* Clock.currentTimeMillis) - before; - expect(elapsed).toBeLessThan(3000); - const state = yield* orc.getState("stuck"); - expect(state.status).toBe("Stopped"); - expect(state.pid).toBeNull(); - expect(state.exitCode).toBe(143); - expect(proc.killed).toEqual(["SIGTERM", "SIGKILL"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("stop() logs warning when global timeout fires", () => { - const { layer, log } = setupOrchestratorWithStuckKill( - [svc("stuck", { shutdown: { timeoutSeconds: 999 } })], - { shutdownTimeoutSeconds: 0.3 }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForState(orc, "stuck", (state) => state.status === "Healthy", "Healthy"); - yield* orc.stop; - const timeoutEntries = log.entries.filter((e) => e.line.includes("[shutdown-timeout]")); - expect(timeoutEntries.length).toBeGreaterThanOrEqual(1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - }); - - it.live("fails readiness when a pre-start hook fails", () => { - const { layer } = setupOrchestrator([svc("api")], { exitDelay: "5 seconds" }); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.startService("api", { - beforeStart: () => Effect.fail(new TestFailure({ message: "port reservation failed" })), - }); - - const error = yield* orc.waitReady("api").pipe(Effect.flip); - expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true); - if (Predicate.isTagged(error, "ServiceReadyError")) { - expect(error.reason).toContain("port reservation failed"); - } - expect((yield* orc.getState("api")).status).toBe("Failed"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - describe("unhealthy restart", () => { - it.live("restarts service when it becomes unhealthy and restart policy allows", () => { - let checkCalls = 0; - const prepared: string[] = []; - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.05, - successThreshold: 1, - failureThreshold: 2, - }, - }), - ], - { - exitDelay: "5 seconds", - perService: { - check: { - exitDelay: "1 millis", - getExitCode: () => { - checkCalls++; - // First call succeeds (→ Healthy), rest fail (→ Unhealthy) - return checkCalls <= 1 ? 0 : 1; - }, - }, - }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start({ - beforeStart: (name) => Effect.sync(() => prepared.push(name)), - }); - yield* proc.waitForSpawn("a", 2); - // Should have spawned the main service twice (original + 1 restart) - const mainSpawns = proc.spawned.filter((s) => s.command === "a"); - expect(mainSpawns.length).toBe(2); - expect(prepared).toEqual(["a", "a"]); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("fails readiness when unhealthy restart preparation fails", () => { - let checkCalls = 0; - let prepareCalls = 0; - const { layer } = setupOrchestrator( - [ - svc("a", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.05, - successThreshold: 1, - failureThreshold: 2, - }, - }), - ], - { - exitDelay: "5 seconds", - perService: { - check: { - exitDelay: "1 millis", - getExitCode: () => { - checkCalls++; - return checkCalls <= 1 ? 0 : 1; - }, - }, - }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start({ - beforeStart: () => - Effect.suspend(() => { - prepareCalls++; - return prepareCalls === 1 - ? Effect.void - : Effect.fail(new TestFailure({ message: "port reservation failed" })); - }), - }); - - yield* waitForState(orc, "a", (state) => state.status === "Failed", "Failed"); - const error = yield* orc.waitReady("a").pipe(Effect.flip); - expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true); - if (Predicate.isTagged(error, "ServiceReadyError")) { - expect(error.reason).toContain("port reservation failed"); - } - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("does not restart unhealthy service when restart policy is no", () => { - let checkCalls = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - restart: "no", - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.05, - successThreshold: 1, - failureThreshold: 2, - }, - }), - ], - { - exitDelay: "5 seconds", - perService: { - check: { - exitDelay: "1 millis", - getExitCode: () => { - checkCalls++; - return checkCalls <= 1 ? 0 : 1; - }, - }, - }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForState(orc, "a", (state) => state.status === "Unhealthy", "Unhealthy"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Unhealthy"); - const mainSpawns = proc.spawned.filter((s) => s.command === "a"); - expect(mainSpawns.length).toBe(1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("unhealthy restart respects maxRestarts", () => { - let checkCalls = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.05, - successThreshold: 1, - failureThreshold: 2, - }, - }), - ], - { - exitDelay: "5 seconds", - perService: { - check: { - exitDelay: "1 millis", - // Each cycle: 1 success (→ Healthy) then 2 failures (→ Unhealthy) - getExitCode: () => (++checkCalls % 3 === 1 ? 0 : 1), - }, - }, - }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawn("a", 2); - const state = yield* waitForState( - orc, - "a", - (candidate) => candidate.status === "Failed", - "Failed", - ); - // maxRestarts=1 means original spawn + 1 restart = 2 total - const mainSpawns = proc.spawned.filter((s) => s.command === "a"); - expect(mainSpawns.length).toBe(2); - expect(state.pid).toBeNull(); - expect(state.exitCode).toBeNull(); - expect(state.error).toBe("Health check failed and restart budget was exhausted"); - - const readyError = yield* orc.waitReady("a").pipe(Effect.flip); - expect(Predicate.isTagged(readyError, "ServiceReadyError")).toBe(true); - if (Predicate.isTagged(readyError, "ServiceReadyError")) { - expect(readyError.reason).toBe("Health check failed and restart budget was exhausted"); - } - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("applies startup failures before a service has ever been healthy", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.01, - startupFailureThreshold: 2, - failureThreshold: 1, - }, - }), - ], - { - exitDelay: "5 seconds", - perService: { check: { exitCode: 1, exitDelay: "1 millis" } }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - const state = yield* waitForFailed(orc, "a"); - expect(proc.spawned.filter((spawn) => spawn.command === "a")).toHaveLength(2); - expect(state.pid).toBeNull(); - expect(state.exitCode).toBeNull(); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("keeps an initially unhealthy no-restart service alive", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - restart: "no", - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - periodSeconds: 0.01, - failureThreshold: 1, - }, - }), - ], - { - exitDelay: "5 seconds", - perService: { check: { exitCode: 1, exitDelay: "1 millis" } }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - const state = yield* waitForState( - orc, - "a", - (candidate) => candidate.status === "Unhealthy", - "Unhealthy", - ); - expect(state.pid).not.toBeNull(); - expect(state.exitCode).toBeNull(); - expect(proc.spawned.filter((spawn) => spawn.command === "a")).toHaveLength(1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - }); - - it.live("finalizes the child before publishing a healthy-hook failure", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - hooks: [ - { - on: "healthy", - run: () => Effect.fail(new TestFailure({ message: "warmup failed" })), - }, - ], - }), - ], - { exitDelay: "5 seconds" }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - const state = yield* waitForFailed(orc, "a"); - expect(proc.killed.some((record) => record.command === "a")).toBe(true); - expect(state.pid).toBeNull(); - expect(state.exitCode).toBeNull(); - expect(state.error).toContain("on:healthy"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - describe("readiness", () => { - it.live("waitReady resolves when long-running service becomes healthy", () => { - const { layer } = setupOrchestrator([svc("a")], { - exitDelay: "500 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* orc.waitReady("a"); - const state = yield* orc.getState("a"); - expect(state.status).toBe("Healthy"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitReady follows a configured restart instead of failing on its exit state", () => { - let serviceSpawns = 0; - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - restart: "always", - maxRestarts: 1, - healthCheck: { - probe: { _tag: "Exec", command: "check", args: [] }, - initialDelaySeconds: 0.05, - periodSeconds: 0.05, - }, - }), - ], - { - perService: { - a: { - exitCode: 1, - getExitDelay: () => (++serviceSpawns === 1 ? "10 millis" : "5 seconds"), - }, - check: { exitCode: 0, exitDelay: "1 millis" }, - }, - }, - ); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* orc.waitReady("a"); - - expect(proc.spawned.filter((spawn) => spawn.command === "a")).toHaveLength(2); - expect((yield* orc.getState("a")).status).toBe("Healthy"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitReady uses the running generation restart policy", () => { - const { layer, proc } = setupOrchestrator([svc("a", { restart: "no" })], { - exitCode: 1, - exitDelay: "200 millis", - }); - - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawn("a"); - - // The replacement applies to the next generation; this one still - // terminates under restart:no and readiness must fail with it. - yield* orc.updateServiceDefinition("a", svc("a", { restart: "always" })); - - const exit = yield* orc.waitReady("a").pipe(Effect.exit, Effect.timeout("1 second")); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitReady resolves when one-shot service completes successfully", () => { - const { layer } = setupOrchestrator([svc("a", { restart: "no" })], { - exitCode: 0, - exitDelay: "50 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* orc.waitReady("a"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitReady resolves after a one-shot service has already completed", () => { - const { layer } = setupOrchestrator([svc("a", { restart: "no" })], { - exitCode: 0, - exitDelay: "10 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* waitForStopped(orc, "a"); - yield* orc.waitReady("a"); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitReady fails when one-shot exits with non-zero code", () => { - const { layer } = setupOrchestrator([svc("a", { restart: "no" })], { - exitCode: 1, - exitDelay: "50 millis", - }); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - const exit = yield* orc.waitReady("a").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitReady fails fast when service enters Failed state", () => { - const { layer } = setupOrchestrator( - [ - svc("a", { - restart: "no", - hooks: [ - { - on: "started", - run: (_log) => Effect.fail(new TestFailure({ message: "startup failed" })), - }, - ], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - const exit = yield* orc.waitReady("a").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitReady fails when a starting service is stopped", () => { - const { layer, proc } = setupOrchestrator( - [ - svc("a", { - healthCheck: { - probe: { _tag: "Exec", command: "true", args: [] }, - initialDelaySeconds: 999, - }, - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* proc.waitForSpawn("a"); - const ready = yield* orc.waitReady("a").pipe(Effect.forkScoped); - - yield* orc.stopService("a"); - - const exit = yield* Fiber.await(ready); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitAllReady resolves when all services ready", () => { - const { layer } = setupOrchestrator( - [ - svc("db"), - svc("api", { - dependencies: [{ service: "db", condition: "started" }], - }), - ], - { exitDelay: "500 millis" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - yield* orc.waitAllReady; - const states = yield* orc.getAllStates; - for (const s of states) { - expect(s.status).toBe("Healthy"); - } - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitAllReady fails fast if any service fails", () => { - const { layer } = setupOrchestrator( - [ - svc("db"), - svc("api", { - restart: "no", - hooks: [ - { - on: "started", - run: (_log) => Effect.fail(new TestFailure({ message: "crash" })), - }, - ], - }), - ], - { exitDelay: "5 seconds" }, - ); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - yield* orc.start(); - const exit = yield* orc.waitAllReady.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - - it.live("waitReady returns ServiceNotFoundError for unknown service", () => { - const { layer } = setupOrchestrator([svc("a")]); - return Effect.gen(function* () { - const orc = yield* Orchestrator; - const exit = yield* orc.waitReady("nonexistent").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped); - }); - }); -}); diff --git a/packages/process-compose/src/RestartClosure.ts b/packages/process-compose/src/RestartClosure.ts deleted file mode 100644 index 2871e38fed..0000000000 --- a/packages/process-compose/src/RestartClosure.ts +++ /dev/null @@ -1,37 +0,0 @@ -export interface RestartClosureGraph { - readonly order: ReadonlyArray; - readonly dependentsOf: (name: string) => ReadonlyArray; -} - -/** - * Returns the requested service and every connector on a path to an active dependent. - * The result preserves dependency start order so callers can stop it in reverse safely. - */ -export const restartClosureFor = ( - graph: RestartClosureGraph, - name: string, - activeServices: ReadonlySet, -): ReadonlyArray => { - const closure = new Set([name]); - const visited = new Set(); - - const collectDependents = (current: string): boolean => { - if (visited.has(current)) { - return closure.has(current); - } - visited.add(current); - - let connectsToActiveDependent = false; - for (const dependent of graph.dependentsOf(current)) { - const descendantConnectsToActive = collectDependents(dependent); - if (activeServices.has(dependent) || descendantConnectsToActive) { - closure.add(dependent); - connectsToActiveDependent = true; - } - } - return connectsToActiveDependent; - }; - - collectDependents(name); - return graph.order.filter((service) => closure.has(service)); -}; diff --git a/packages/process-compose/src/RestartClosure.unit.test.ts b/packages/process-compose/src/RestartClosure.unit.test.ts deleted file mode 100644 index 306d926c9b..0000000000 --- a/packages/process-compose/src/RestartClosure.unit.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { restartClosureFor, type RestartClosureGraph } from "./RestartClosure.ts"; - -const graph = ( - order: ReadonlyArray, - dependents: Readonly>>, -): RestartClosureGraph => ({ - order, - dependentsOf: (name) => dependents[name] ?? [], -}); - -describe("restartClosureFor", () => { - const cases: ReadonlyArray<{ - readonly name: string; - readonly graph: RestartClosureGraph; - readonly root: string; - readonly active: ReadonlyArray; - readonly expected: ReadonlyArray; - }> = [ - { - name: "keeps only the requested service when no dependent is active", - graph: graph(["db", "api", "web"], { db: ["api"], api: ["web"] }), - root: "db", - active: [], - expected: ["db"], - }, - { - name: "includes active direct dependents in dependency order", - graph: graph(["db", "api", "worker"], { db: ["api", "worker"] }), - root: "db", - active: ["worker", "api"], - expected: ["db", "api", "worker"], - }, - { - name: "preserves inactive connectors leading to an active descendant", - graph: graph(["db", "gateway", "api", "web"], { - db: ["gateway"], - gateway: ["api"], - api: ["web"], - }), - root: "db", - active: ["web"], - expected: ["db", "gateway", "api", "web"], - }, - { - name: "continues through active dependents to include active descendants", - graph: graph(["db", "api", "web"], { db: ["api"], api: ["web"] }), - root: "db", - active: ["api", "web"], - expected: ["db", "api", "web"], - }, - { - name: "handles shared connectors without duplicating diamond nodes", - graph: graph(["db", "api", "worker", "web"], { - db: ["api", "worker"], - api: ["web"], - worker: ["web"], - }), - root: "db", - active: ["web"], - expected: ["db", "api", "worker", "web"], - }, - ]; - - for (const testCase of cases) { - it(testCase.name, () => { - expect(restartClosureFor(testCase.graph, testCase.root, new Set(testCase.active))).toEqual( - testCase.expected, - ); - }); - } -}); diff --git a/packages/process-compose/src/RestartDecision.ts b/packages/process-compose/src/RestartDecision.ts deleted file mode 100644 index 651fb17a11..0000000000 --- a/packages/process-compose/src/RestartDecision.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Predicate } from "effect"; -import type { RestartPolicy } from "./ServiceDef.ts"; -import type { ServiceDesiredState } from "./ServiceState.ts"; - -export type LifecycleCause = - | { readonly _tag: "ProcessExit"; readonly exitCode: number } - | { readonly _tag: "Unhealthy" }; - -export type RestartDecision = - | { readonly _tag: "Restart"; readonly restartCount: number } - | { - readonly _tag: "Terminate"; - readonly reason: "NotDesired" | "PolicyDisabled" | "BudgetExhausted"; - } - | { readonly _tag: "KeepRunningUnhealthy" }; - -export const UNHEALTHY_RESTART_EXHAUSTED_ERROR = - "Health check failed and restart budget was exhausted"; - -export function decideRestart(options: { - readonly cause: LifecycleCause; - readonly policy: RestartPolicy; - readonly restartCount: number; - readonly maxRestarts: number; - readonly desired: ServiceDesiredState; -}): RestartDecision { - const causeIsUnhealthy = Predicate.isTagged(options.cause, "Unhealthy"); - const processExit = Predicate.isTagged(options.cause, "ProcessExit") ? options.cause : undefined; - - if (options.desired !== "running") { - return { _tag: "Terminate", reason: "NotDesired" }; - } - - if (causeIsUnhealthy && options.policy === "no") { - return { _tag: "KeepRunningUnhealthy" }; - } - - const policyAllowsRestart = - options.policy === "always" || - options.policy === "unless-stopped" || - (options.policy === "on-failure" && - (causeIsUnhealthy || (processExit !== undefined && processExit.exitCode !== 0))); - - if (!policyAllowsRestart) { - return { _tag: "Terminate", reason: "PolicyDisabled" }; - } - - if (options.maxRestarts !== 0 && options.restartCount >= options.maxRestarts) { - return { _tag: "Terminate", reason: "BudgetExhausted" }; - } - - return { _tag: "Restart", restartCount: options.restartCount + 1 }; -} diff --git a/packages/process-compose/src/RestartDecision.unit.test.ts b/packages/process-compose/src/RestartDecision.unit.test.ts deleted file mode 100644 index 8e7aa62fa5..0000000000 --- a/packages/process-compose/src/RestartDecision.unit.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Predicate } from "effect"; -import { decideRestart, type LifecycleCause } from "./RestartDecision.ts"; -import type { RestartPolicy } from "./ServiceDef.ts"; - -const exit = (exitCode: number): LifecycleCause => ({ _tag: "ProcessExit", exitCode }); -const unhealthy: LifecycleCause = { _tag: "Unhealthy" }; - -describe("decideRestart", () => { - it.each([ - ["no", exit(0), "Terminate"], - ["no", exit(1), "Terminate"], - ["no", unhealthy, "KeepRunningUnhealthy"], - ["on-failure", exit(0), "Terminate"], - ["on-failure", exit(1), "Restart"], - ["on-failure", unhealthy, "Restart"], - ["always", exit(0), "Restart"], - ["always", exit(1), "Restart"], - ["always", unhealthy, "Restart"], - ["unless-stopped", exit(0), "Restart"], - ["unless-stopped", exit(1), "Restart"], - ["unless-stopped", unhealthy, "Restart"], - ] as const)("applies %s to %o", (policy, cause, expected) => { - const decision = decideRestart({ - policy, - cause, - restartCount: 0, - maxRestarts: 1, - desired: "running", - }); - expect(Predicate.isTagged(decision, expected)).toBe(true); - }); - - it.each(["no", "on-failure", "always", "unless-stopped"] as const)( - "never restarts %s after stop was requested", - (policy: RestartPolicy) => { - expect( - decideRestart({ - policy, - cause: exit(1), - restartCount: 0, - maxRestarts: 0, - desired: "stopped", - }), - ).toEqual({ _tag: "Terminate", reason: "NotDesired" }); - }, - ); - - it("treats zero maxRestarts as unlimited", () => { - expect( - decideRestart({ - policy: "always", - cause: unhealthy, - restartCount: 1_000, - maxRestarts: 0, - desired: "running", - }), - ).toEqual({ _tag: "Restart", restartCount: 1_001 }); - }); - - it("terminates when the restart budget is exhausted", () => { - expect( - decideRestart({ - policy: "always", - cause: unhealthy, - restartCount: 2, - maxRestarts: 2, - desired: "running", - }), - ).toEqual({ _tag: "Terminate", reason: "BudgetExhausted" }); - }); -}); diff --git a/packages/process-compose/src/ServiceDef.ts b/packages/process-compose/src/ServiceDef.ts deleted file mode 100644 index 97d012b895..0000000000 --- a/packages/process-compose/src/ServiceDef.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { Effect } from "effect"; -import type { ChildProcess } from "effect/unstable/process"; - -export type DependencyCondition = "started" | "healthy" | "completed"; - -export interface Dependency { - readonly service: string; - readonly condition: DependencyCondition; -} - -export type ProbeConfig = - | { - readonly _tag: "Http"; - readonly host: string; - readonly port: number; - readonly path: string; - readonly scheme: "http" | "https"; - } - | { - readonly _tag: "Exec"; - readonly command: string; - readonly args: ReadonlyArray; - readonly env?: Record; - } - | { readonly _tag: "Tcp"; readonly host: string; readonly port: number }; - -export interface HealthCheckConfig { - readonly probe: ProbeConfig; - readonly initialDelaySeconds?: number; - readonly periodSeconds?: number; - readonly timeoutSeconds?: number; - readonly successThreshold?: number; - /** Consecutive failures allowed before the process has ever become healthy. */ - readonly startupFailureThreshold?: number; - readonly failureThreshold?: number; -} - -export interface ShutdownConfig { - readonly signal?: ChildProcess.Signal; - readonly timeoutSeconds?: number; -} - -export type RestartPolicy = "no" | "on-failure" | "always" | "unless-stopped"; - -export type HookTrigger = "started" | "healthy"; - -export type HookLog = (stream: "stdout" | "stderr", line: string) => Effect.Effect; - -export interface LifecycleHook { - readonly on: HookTrigger; - readonly run: (log: HookLog) => Effect.Effect; - readonly timeoutSeconds?: number; - readonly failurePolicy?: "fail" | "ignore"; -} - -export type ExternalCleanupAction = - | { - readonly _tag: "RunCommand"; - readonly executable: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - } - | { - readonly _tag: "RemovePath"; - readonly path: string; - readonly recursive?: boolean; - readonly force?: boolean; - }; - -export interface SupervisionConfig { - readonly orphanCleanup?: ReadonlyArray; -} - -export interface ServiceDef { - readonly name: string; - readonly command: string; - readonly args?: ReadonlyArray; - readonly env?: Record; - readonly cwd?: string; - readonly dependencies?: ReadonlyArray; - readonly dependencyTimeoutSeconds?: number; - readonly healthCheck?: HealthCheckConfig; - readonly shutdown?: ShutdownConfig; - readonly restart?: RestartPolicy; - readonly maxRestarts?: number; - readonly cleanup?: Effect.Effect; - readonly supervision?: SupervisionConfig; - readonly hooks?: ReadonlyArray; - readonly enabled?: boolean; -} - -export interface OrchestratorConfig { - readonly shutdownTimeoutSeconds?: number; -} - -export interface ServiceStartOptions { - /** Runs when a service lifecycle starts and again after each process exit before backoff. */ - readonly beforeStart?: (name: string) => Effect.Effect; - /** Runs after dependencies are satisfied and immediately before each spawn. */ - readonly beforeSpawn?: (name: string) => Effect.Effect; -} - -export const defaults = { - healthCheck: { - initialDelaySeconds: 0, - periodSeconds: 10, - timeoutSeconds: 2, - successThreshold: 1, - failureThreshold: 3, - }, - shutdown: { - signal: "SIGTERM" as const, - timeoutSeconds: 10, - }, - dependencyTimeoutSeconds: 120, - hookTimeoutSeconds: 30, - shutdownTimeoutSeconds: 60, - restart: "unless-stopped" as const, - maxRestarts: 0, - enabled: true, -} as const; diff --git a/packages/process-compose/src/ServiceState.ts b/packages/process-compose/src/ServiceState.ts deleted file mode 100644 index fbd19f777c..0000000000 --- a/packages/process-compose/src/ServiceState.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Data } from "effect"; - -export type ServiceStatus = - | "Pending" - | "Starting" - | "Running" - | "Healthy" - | "Unhealthy" - | "Stopping" - | "Stopped" - | "Failed" - | "Restarting"; - -export type ServiceDesiredState = "inactive" | "running" | "stopped"; - -export class ServiceState extends Data.Class<{ - readonly name: string; - readonly status: ServiceStatus; - readonly pid: number | null; - readonly exitCode: number | null; - readonly restartCount: number; - readonly startedAt: number | null; - readonly error: string | null; - /** Caller-owned intent, independent of the current process transition. */ - readonly desired: ServiceDesiredState; -}> {} - -export const initial = (name: string, desired: ServiceDesiredState = "inactive"): ServiceState => - new ServiceState({ - name, - status: "Pending", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - desired, - }); diff --git a/packages/process-compose/src/ServiceState.unit.test.ts b/packages/process-compose/src/ServiceState.unit.test.ts deleted file mode 100644 index c571719811..0000000000 --- a/packages/process-compose/src/ServiceState.unit.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ServiceState, initial } from "./ServiceState.ts"; - -describe("ServiceState", () => { - it("creates initial state with Pending status", () => { - const state = initial("postgres"); - expect(state.name).toBe("postgres"); - expect(state.status).toBe("Pending"); - expect(state.pid).toBeNull(); - expect(state.exitCode).toBeNull(); - expect(state.restartCount).toBe(0); - expect(state.startedAt).toBeNull(); - expect(state.error).toBeNull(); - expect(state.desired).toBe("inactive"); - }); - - it("supports structural equality", () => { - const a = initial("postgres"); - const b = initial("postgres"); - expect(a).toEqual(b); - }); - - it("can transition via Data.Class copy", () => { - const state = initial("postgres"); - const running = new ServiceState({ - ...state, - status: "Running", - pid: 1234, - startedAt: 1_700_000_000_000, - }); - expect(running.status).toBe("Running"); - expect(running.pid).toBe(1234); - expect(running.name).toBe("postgres"); - }); -}); diff --git a/packages/process-compose/src/ServiceTransition.ts b/packages/process-compose/src/ServiceTransition.ts deleted file mode 100644 index 4cb61da328..0000000000 --- a/packages/process-compose/src/ServiceTransition.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { Effect, Match, SubscriptionRef } from "effect"; -import { ServiceState, type ServiceStatus } from "./ServiceState.ts"; - -// --------------------------------------------------------------------------- -// Events -// --------------------------------------------------------------------------- - -export type ServiceEvent = - | { readonly _tag: "DependenciesSatisfied" } - | { readonly _tag: "DependencyFailed"; readonly error: string } - | { readonly _tag: "SpawnFailed"; readonly error: string } - | { - readonly _tag: "ProcessSpawned"; - readonly pid: number; - readonly startedAt: number; - } - | { readonly _tag: "HealthCheckPassed" } - | { readonly _tag: "HealthCheckFailed" } - | { readonly _tag: "ProcessTerminated" } - | { readonly _tag: "UnhealthyRestartExhausted"; readonly error: string } - | { readonly _tag: "ProcessExited"; readonly exitCode: number } - | { readonly _tag: "StopRequested" } - | { - readonly _tag: "RestartTriggered"; - readonly restartCount: number; - } - | { readonly _tag: "BackoffElapsed" } - | { readonly _tag: "HookFailed"; readonly error: string }; - -// --------------------------------------------------------------------------- -// Transition table — every event must classify its legal source statuses -// --------------------------------------------------------------------------- - -const transitionSets = { - dependenciesSatisfied: new Set(["Pending"]), - spawnFailed: new Set(["Pending", "Starting", "Restarting"]), - processSpawned: new Set(["Starting"]), - healthCheckPassed: new Set(["Running", "Healthy", "Unhealthy"]), - healthCheckFailed: new Set(["Running", "Healthy"]), - unhealthy: new Set(["Unhealthy"]), - processExited: new Set(["Running", "Healthy", "Unhealthy", "Stopping", "Failed"]), - stopRequested: new Set([ - "Pending", - "Starting", - "Running", - "Healthy", - "Unhealthy", - "Restarting", - "Failed", - ]), - restartTriggered: new Set(["Stopped", "Failed", "Unhealthy"]), - backoffElapsed: new Set(["Restarting"]), - hookFailed: new Set(["Starting", "Running", "Healthy", "Unhealthy"]), -} as const; - -const transitionStatuses = Match.type().pipe( - Match.tag( - "DependenciesSatisfied", - "DependencyFailed", - () => transitionSets.dependenciesSatisfied, - ), - Match.tag("SpawnFailed", () => transitionSets.spawnFailed), - Match.tag("ProcessSpawned", () => transitionSets.processSpawned), - Match.tag("HealthCheckPassed", () => transitionSets.healthCheckPassed), - Match.tag("HealthCheckFailed", () => transitionSets.healthCheckFailed), - Match.tag("ProcessTerminated", "UnhealthyRestartExhausted", () => transitionSets.unhealthy), - Match.tag("ProcessExited", () => transitionSets.processExited), - Match.tag("StopRequested", () => transitionSets.stopRequested), - Match.tag("RestartTriggered", () => transitionSets.restartTriggered), - Match.tag("BackoffElapsed", () => transitionSets.backoffElapsed), - Match.tag("HookFailed", () => transitionSets.hookFailed), - Match.exhaustive, -); - -const applyTransition = Match.type().pipe( - Match.tag( - "DependenciesSatisfied", - () => (state: ServiceState) => new ServiceState({ ...state, status: "Starting" }), - ), - Match.tag( - "DependencyFailed", - "SpawnFailed", - (event) => (state: ServiceState) => - new ServiceState({ - ...state, - status: "Failed", - pid: null, - exitCode: null, - error: event.error, - }), - ), - Match.tag( - "ProcessSpawned", - (event) => (state: ServiceState) => - new ServiceState({ - ...state, - status: "Running", - pid: event.pid, - startedAt: event.startedAt, - }), - ), - Match.tag( - "HealthCheckPassed", - () => (state: ServiceState) => new ServiceState({ ...state, status: "Healthy" }), - ), - Match.tag( - "HealthCheckFailed", - () => (state: ServiceState) => new ServiceState({ ...state, status: "Unhealthy" }), - ), - Match.tag( - "ProcessTerminated", - () => (state: ServiceState) => new ServiceState({ ...state, pid: null }), - ), - Match.tag( - "UnhealthyRestartExhausted", - (event) => (state: ServiceState) => - new ServiceState({ - ...state, - status: "Failed", - pid: null, - exitCode: null, - error: event.error, - }), - ), - Match.tag("ProcessExited", (event) => (state: ServiceState) => { - const status: ServiceStatus = - state.status === "Stopping" ? "Stopped" : event.exitCode === 0 ? "Stopped" : "Failed"; - return new ServiceState({ ...state, status, pid: null, exitCode: event.exitCode }); - }), - Match.tag("StopRequested", () => (state: ServiceState) => { - const stopStatus = - state.status === "Pending" || state.status === "Restarting" ? "Stopped" : "Stopping"; - return new ServiceState({ ...state, status: stopStatus }); - }), - Match.tag( - "RestartTriggered", - (event) => (state: ServiceState) => - new ServiceState({ - ...state, - status: "Restarting", - pid: null, - restartCount: event.restartCount, - }), - ), - Match.tag( - "BackoffElapsed", - () => (state: ServiceState) => - new ServiceState({ - ...state, - status: "Starting", - pid: null, - exitCode: null, - startedAt: null, - error: null, - }), - ), - Match.tag( - "HookFailed", - (event) => (state: ServiceState) => - new ServiceState({ ...state, status: "Failed", pid: null, error: event.error }), - ), - Match.exhaustive, -); - -// --------------------------------------------------------------------------- -// applyEvent — pure function, returns new ServiceState or null if invalid -// --------------------------------------------------------------------------- - -export const applyEvent = (state: ServiceState, event: ServiceEvent): ServiceState | null => { - if (!transitionStatuses(event).has(state.status)) return null; - return applyTransition(event)(state); -}; - -// --------------------------------------------------------------------------- -// transition — effectful, atomic validate-and-apply via SubscriptionRef -// --------------------------------------------------------------------------- - -export const transition = ( - ref: SubscriptionRef.SubscriptionRef, - event: ServiceEvent, -): Effect.Effect => - SubscriptionRef.modifyEffect(ref, (current) => { - const next = applyEvent(current, event); - if (next === null) return Effect.succeed([null, current] as const); - return Effect.succeed([next, next] as const); - }); diff --git a/packages/process-compose/src/ServiceTransition.unit.test.ts b/packages/process-compose/src/ServiceTransition.unit.test.ts deleted file mode 100644 index 12b175f1a4..0000000000 --- a/packages/process-compose/src/ServiceTransition.unit.test.ts +++ /dev/null @@ -1,508 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Match } from "effect"; -import { applyEvent, type ServiceEvent } from "./ServiceTransition.ts"; -import { ServiceState, initial, type ServiceStatus } from "./ServiceState.ts"; - -const make = ( - name: string, - overrides: Partial<{ - status: ServiceState["status"]; - pid: number | null; - exitCode: number | null; - restartCount: number; - startedAt: number | null; - error: string | null; - }> = {}, -): ServiceState => - new ServiceState({ - ...initial(name), - ...overrides, - }); - -describe("ServiceTransition", () => { - it("classifies every event against every service status", () => { - const statuses: ReadonlyArray = [ - "Pending", - "Starting", - "Running", - "Healthy", - "Unhealthy", - "Stopping", - "Stopped", - "Failed", - "Restarting", - ]; - const events: { readonly [Tag in ServiceEvent["_tag"]]: Extract } = - { - DependenciesSatisfied: { _tag: "DependenciesSatisfied" }, - DependencyFailed: { _tag: "DependencyFailed", error: "dependency failed" }, - SpawnFailed: { _tag: "SpawnFailed", error: "spawn failed" }, - ProcessSpawned: { _tag: "ProcessSpawned", pid: 1234, startedAt: 1000 }, - HealthCheckPassed: { _tag: "HealthCheckPassed" }, - HealthCheckFailed: { _tag: "HealthCheckFailed" }, - ProcessTerminated: { _tag: "ProcessTerminated" }, - UnhealthyRestartExhausted: { - _tag: "UnhealthyRestartExhausted", - error: "restart exhausted", - }, - ProcessExited: { _tag: "ProcessExited", exitCode: 1 }, - StopRequested: { _tag: "StopRequested" }, - RestartTriggered: { _tag: "RestartTriggered", restartCount: 1 }, - BackoffElapsed: { _tag: "BackoffElapsed" }, - HookFailed: { _tag: "HookFailed", error: "hook failed" }, - }; - const legalStatuses: { - readonly [Tag in ServiceEvent["_tag"]]: ReadonlyArray; - } = { - DependenciesSatisfied: ["Pending"], - DependencyFailed: ["Pending"], - SpawnFailed: ["Pending", "Starting", "Restarting"], - ProcessSpawned: ["Starting"], - HealthCheckPassed: ["Running", "Healthy", "Unhealthy"], - HealthCheckFailed: ["Running", "Healthy"], - ProcessTerminated: ["Unhealthy"], - UnhealthyRestartExhausted: ["Unhealthy"], - ProcessExited: ["Running", "Healthy", "Unhealthy", "Stopping", "Failed"], - StopRequested: [ - "Pending", - "Starting", - "Running", - "Healthy", - "Unhealthy", - "Failed", - "Restarting", - ], - RestartTriggered: ["Unhealthy", "Stopped", "Failed"], - BackoffElapsed: ["Restarting"], - HookFailed: ["Starting", "Running", "Healthy", "Unhealthy"], - }; - const legalStatusesFor = Match.type().pipe( - Match.tagsExhaustive({ - DependenciesSatisfied: () => legalStatuses.DependenciesSatisfied, - DependencyFailed: () => legalStatuses.DependencyFailed, - SpawnFailed: () => legalStatuses.SpawnFailed, - ProcessSpawned: () => legalStatuses.ProcessSpawned, - HealthCheckPassed: () => legalStatuses.HealthCheckPassed, - HealthCheckFailed: () => legalStatuses.HealthCheckFailed, - ProcessTerminated: () => legalStatuses.ProcessTerminated, - UnhealthyRestartExhausted: () => legalStatuses.UnhealthyRestartExhausted, - ProcessExited: () => legalStatuses.ProcessExited, - StopRequested: () => legalStatuses.StopRequested, - RestartTriggered: () => legalStatuses.RestartTriggered, - BackoffElapsed: () => legalStatuses.BackoffElapsed, - HookFailed: () => legalStatuses.HookFailed, - }), - ); - - for (const event of Object.values(events)) { - for (const status of statuses) { - const state = make("service", { status, pid: 1234 }); - expect(applyEvent(state, event) !== null, `${status}`).toBe( - legalStatusesFor(event).includes(status), - ); - } - } - }); - - describe("valid transitions", () => { - it("Pending + DependenciesSatisfied → Starting", () => { - const state = make("db"); - const next = applyEvent(state, { _tag: "DependenciesSatisfied" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Starting"); - }); - - it("Pending + DependencyFailed → Failed with error", () => { - const state = make("api", { pid: 1234, exitCode: 1 }); - const next = applyEvent(state, { - _tag: "DependencyFailed", - error: "db exited with code 1", - }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Failed"); - expect(next!.pid).toBeNull(); - expect(next!.exitCode).toBeNull(); - expect(next!.error).toBe("db exited with code 1"); - }); - - it("Starting + ProcessSpawned → Running with pid and startedAt", () => { - const state = make("db", { status: "Starting" }); - const next = applyEvent(state, { - _tag: "ProcessSpawned", - pid: 1234, - startedAt: 1000, - }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Running"); - expect(next!.pid).toBe(1234); - expect(next!.startedAt).toBe(1000); - }); - - it("Starting + SpawnFailed → Failed with error", () => { - const result = applyEvent(make("db", { status: "Starting", pid: 1234, exitCode: 1 }), { - _tag: "SpawnFailed", - error: "spawn gate failed", - }); - expect(result?.status).toBe("Failed"); - expect(result?.pid).toBeNull(); - expect(result?.exitCode).toBeNull(); - expect(result?.error).toBe("spawn gate failed"); - }); - - it("Pending + SpawnFailed → Failed with error", () => { - const result = applyEvent(make("db"), { - _tag: "SpawnFailed", - error: "pre-start failed", - }); - expect(result?.status).toBe("Failed"); - expect(result?.error).toBe("pre-start failed"); - }); - - it("Restarting + SpawnFailed → Failed with error", () => { - const result = applyEvent(make("db", { status: "Restarting" }), { - _tag: "SpawnFailed", - error: "restart preparation failed", - }); - expect(result?.status).toBe("Failed"); - expect(result?.error).toBe("restart preparation failed"); - }); - - it("Running + HealthCheckPassed → Healthy", () => { - const state = make("db", { status: "Running", pid: 1234 }); - const next = applyEvent(state, { _tag: "HealthCheckPassed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Healthy"); - }); - - it("Running + ProcessExited(0) → Stopped", () => { - const state = make("db", { status: "Running", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessExited", exitCode: 0 }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopped"); - expect(next!.exitCode).toBe(0); - expect(next!.pid).toBeNull(); - }); - - it("Running + ProcessExited(1) → Failed", () => { - const state = make("db", { status: "Running", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessExited", exitCode: 1 }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Failed"); - expect(next!.exitCode).toBe(1); - expect(next!.pid).toBeNull(); - }); - - it("Running + StopRequested → Stopping", () => { - const state = make("db", { status: "Running", pid: 1234 }); - const next = applyEvent(state, { _tag: "StopRequested" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopping"); - }); - - it("Running + HealthCheckFailed → Unhealthy", () => { - const state = make("db", { status: "Running", pid: 1234 }); - const next = applyEvent(state, { _tag: "HealthCheckFailed" }); - expect(next?.status).toBe("Unhealthy"); - expect(next?.pid).toBe(1234); - }); - - it("Healthy + HealthCheckFailed → Unhealthy", () => { - const state = make("db", { status: "Healthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "HealthCheckFailed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Unhealthy"); - }); - - it("Healthy + HealthCheckPassed → Healthy (same structural value)", () => { - const state = make("db", { status: "Healthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "HealthCheckPassed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Healthy"); - // Structural equality — SubscriptionRef won't broadcast - expect(next).toEqual(state); - }); - - it("Healthy + ProcessExited(0) → Stopped", () => { - const state = make("db", { status: "Healthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessExited", exitCode: 0 }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopped"); - }); - - it("Healthy + ProcessExited(1) → Failed", () => { - const state = make("db", { status: "Healthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessExited", exitCode: 1 }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Failed"); - }); - - it("Healthy + StopRequested → Stopping", () => { - const state = make("db", { status: "Healthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "StopRequested" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopping"); - }); - - it("Unhealthy + HealthCheckPassed → Healthy", () => { - const state = make("db", { status: "Unhealthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "HealthCheckPassed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Healthy"); - }); - - it("Unhealthy + ProcessExited(0) → Stopped", () => { - const state = make("db", { status: "Unhealthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessExited", exitCode: 0 }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopped"); - }); - - it("Unhealthy + ProcessExited(1) → Failed", () => { - const state = make("db", { status: "Unhealthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessExited", exitCode: 1 }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Failed"); - }); - - it("Unhealthy + StopRequested → Stopping", () => { - const state = make("db", { status: "Unhealthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "StopRequested" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopping"); - }); - - it("Stopping + ProcessExited → Stopped (any exit code)", () => { - const state = make("db", { status: "Stopping", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessExited", exitCode: 137 }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopped"); - expect(next!.exitCode).toBe(137); - }); - - it("Stopping + ProcessExited(0) → Stopped", () => { - const state = make("db", { status: "Stopping", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessExited", exitCode: 0 }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopped"); - }); - - it("Stopped + RestartTriggered → Restarting", () => { - const state = make("db", { status: "Stopped", exitCode: 0 }); - const next = applyEvent(state, { - _tag: "RestartTriggered", - restartCount: 1, - }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Restarting"); - expect(next!.restartCount).toBe(1); - }); - - it("Failed + RestartTriggered → Restarting", () => { - const state = make("db", { status: "Failed", exitCode: 1 }); - const next = applyEvent(state, { - _tag: "RestartTriggered", - restartCount: 2, - }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Restarting"); - expect(next!.restartCount).toBe(2); - }); - - it("Unhealthy + RestartTriggered → Restarting", () => { - const state = make("db", { status: "Unhealthy", pid: 1234 }); - const next = applyEvent(state, { - _tag: "RestartTriggered", - restartCount: 1, - }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Restarting"); - expect(next!.restartCount).toBe(1); - expect(next!.pid).toBeNull(); - }); - - it("Unhealthy + UnhealthyRestartExhausted → terminal Failed", () => { - const state = make("db", { status: "Unhealthy", pid: 1234, exitCode: null }); - const next = applyEvent(state, { - _tag: "UnhealthyRestartExhausted", - error: "Health check failed and restart budget was exhausted", - }); - expect(next?.status).toBe("Failed"); - expect(next?.pid).toBeNull(); - expect(next?.exitCode).toBeNull(); - expect(next?.error).toBe("Health check failed and restart budget was exhausted"); - }); - - it("Unhealthy + ProcessTerminated clears its no-longer-live pid", () => { - const state = make("db", { status: "Unhealthy", pid: 1234 }); - const next = applyEvent(state, { _tag: "ProcessTerminated" }); - expect(next?.status).toBe("Unhealthy"); - expect(next?.pid).toBeNull(); - expect(next?.exitCode).toBeNull(); - }); - - it("Pending + StopRequested → Stopped (no process to kill)", () => { - const state = make("db"); - const next = applyEvent(state, { _tag: "StopRequested" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopped"); - }); - - it("Starting + StopRequested → Stopping", () => { - const state = make("db", { status: "Starting" }); - const next = applyEvent(state, { _tag: "StopRequested" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopping"); - }); - - it("Restarting + StopRequested → Stopped (no process to kill)", () => { - const state = make("db", { status: "Restarting", restartCount: 1 }); - const next = applyEvent(state, { _tag: "StopRequested" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Stopped"); - }); - - it("Restarting + BackoffElapsed → Starting", () => { - const state = make("db", { status: "Restarting", restartCount: 1 }); - const next = applyEvent(state, { _tag: "BackoffElapsed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Starting"); - // pid/exitCode/startedAt/error should be reset for new cycle - expect(next!.pid).toBeNull(); - expect(next!.exitCode).toBeNull(); - expect(next!.startedAt).toBeNull(); - expect(next!.error).toBeNull(); - }); - }); - - describe("invalid transitions return null", () => { - it("Pending + HealthCheckPassed → null", () => { - const state = make("db"); - expect(applyEvent(state, { _tag: "HealthCheckPassed" })).toBeNull(); - }); - - it("Pending + ProcessExited → null", () => { - const state = make("db"); - expect(applyEvent(state, { _tag: "ProcessExited", exitCode: 0 })).toBeNull(); - }); - - it("Starting + HealthCheckPassed → null", () => { - const state = make("db", { status: "Starting" }); - expect(applyEvent(state, { _tag: "HealthCheckPassed" })).toBeNull(); - }); - - it("Running + DependenciesSatisfied → null", () => { - const state = make("db", { status: "Running", pid: 1234 }); - expect(applyEvent(state, { _tag: "DependenciesSatisfied" })).toBeNull(); - }); - - it("Running + RestartTriggered → null", () => { - const state = make("db", { status: "Running", pid: 1234 }); - expect(applyEvent(state, { _tag: "RestartTriggered", restartCount: 1 })).toBeNull(); - }); - - it("Stopped + ProcessExited → null", () => { - const state = make("db", { status: "Stopped", exitCode: 0 }); - expect(applyEvent(state, { _tag: "ProcessExited", exitCode: 0 })).toBeNull(); - }); - - it("Stopped + HealthCheckPassed → null", () => { - const state = make("db", { status: "Stopped", exitCode: 0 }); - expect(applyEvent(state, { _tag: "HealthCheckPassed" })).toBeNull(); - }); - - it("Failed + DependenciesSatisfied → null", () => { - const state = make("db", { status: "Failed", exitCode: 1 }); - expect(applyEvent(state, { _tag: "DependenciesSatisfied" })).toBeNull(); - }); - - it("Stopping + HealthCheckPassed → null (health probe races shutdown)", () => { - const state = make("db", { status: "Stopping", pid: 1234 }); - expect(applyEvent(state, { _tag: "HealthCheckPassed" })).toBeNull(); - }); - - it("Stopping + HealthCheckFailed → null", () => { - const state = make("db", { status: "Stopping", pid: 1234 }); - expect(applyEvent(state, { _tag: "HealthCheckFailed" })).toBeNull(); - }); - - it("Stopping + StopRequested → null (already stopping)", () => { - const state = make("db", { status: "Stopping", pid: 1234 }); - expect(applyEvent(state, { _tag: "StopRequested" })).toBeNull(); - }); - - it("Restarting + ProcessExited → null", () => { - const state = make("db", { status: "Restarting", restartCount: 1 }); - expect(applyEvent(state, { _tag: "ProcessExited", exitCode: 0 })).toBeNull(); - }); - }); - - describe("HookFailed event", () => { - it("Running + HookFailed → Failed with error", () => { - const state = make("db", { status: "Running", pid: 1234, startedAt: 1000 }); - const next = applyEvent(state, { _tag: "HookFailed", error: "migration failed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Failed"); - expect(next!.error).toBe("migration failed"); - expect(next!.pid).toBeNull(); - }); - - it("Healthy + HookFailed → Failed with error", () => { - const state = make("db", { status: "Healthy", pid: 1234, startedAt: 1000 }); - const next = applyEvent(state, { _tag: "HookFailed", error: "seed failed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Failed"); - expect(next!.error).toBe("seed failed"); - }); - - it("Unhealthy + HookFailed → Failed with error", () => { - const state = make("db", { status: "Unhealthy", pid: 1234, startedAt: 1000 }); - const next = applyEvent(state, { _tag: "HookFailed", error: "recovery failed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Failed"); - expect(next!.error).toBe("recovery failed"); - expect(next!.pid).toBeNull(); - }); - - it("Starting + HookFailed → Failed with error", () => { - const state = make("db", { status: "Starting" }); - const next = applyEvent(state, { _tag: "HookFailed", error: "startup failed" }); - expect(next).not.toBeNull(); - expect(next!.status).toBe("Failed"); - expect(next!.error).toBe("startup failed"); - }); - - it("Pending + HookFailed → null (ignored)", () => { - const state = make("db"); - expect(applyEvent(state, { _tag: "HookFailed", error: "x" })).toBeNull(); - }); - }); - - describe("field preservation", () => { - it("preserves name across transitions", () => { - const state = make("postgres"); - const next = applyEvent(state, { _tag: "DependenciesSatisfied" }); - expect(next!.name).toBe("postgres"); - }); - - it("preserves restartCount through non-restart transitions", () => { - const state = make("db", { - status: "Starting", - restartCount: 3, - }); - const next = applyEvent(state, { - _tag: "ProcessSpawned", - pid: 5678, - startedAt: 2000, - }); - expect(next!.restartCount).toBe(3); - }); - - it("preserves pid through health transitions", () => { - const state = make("db", { status: "Running", pid: 1234 }); - const healthy = applyEvent(state, { _tag: "HealthCheckPassed" }); - expect(healthy!.pid).toBe(1234); - const unhealthy = applyEvent(healthy!, { _tag: "HealthCheckFailed" }); - expect(unhealthy!.pid).toBe(1234); - }); - }); -}); diff --git a/packages/process-compose/src/Supervisor.ts b/packages/process-compose/src/Supervisor.ts deleted file mode 100644 index b936e9d02f..0000000000 --- a/packages/process-compose/src/Supervisor.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { ChildProcess } from "effect/unstable/process"; -import type { ExternalCleanupAction, ServiceDef } from "./ServiceDef.ts"; -import { defaults } from "./ServiceDef.ts"; -import { - isSupervisorSelfDispatchEnabled, - makeSupervisorRuntimeEnv, -} from "./supervisor-protocol.ts"; - -interface SupervisorRuntimeConfig { - readonly command: string; - readonly args: ReadonlyArray; - readonly ownerPid: number; - readonly shutdownSignal: ChildProcess.Signal; - readonly shutdownTimeoutMs: number; - readonly cleanup: ReadonlyArray; -} - -export const supervisorRuntimePath = fileURLToPath( - new URL("./supervisor-runtime.ts", import.meta.url), -); - -export const usesSupervisor = (def: ServiceDef): boolean => def.supervision != null; - -export const makeSupervisedCommand = (def: ServiceDef) => { - const runtimeConfig: SupervisorRuntimeConfig = { - command: def.command, - args: def.args ?? [], - ownerPid: process.pid, - shutdownSignal: def.shutdown?.signal ?? defaults.shutdown.signal, - shutdownTimeoutMs: (def.shutdown?.timeoutSeconds ?? defaults.shutdown.timeoutSeconds) * 1000, - cleanup: def.supervision?.orphanCleanup ?? [], - }; - const encoded = Buffer.from(JSON.stringify(runtimeConfig)).toString("base64url"); - const selfDispatch = isSupervisorSelfDispatchEnabled(import.meta.url); - - return ChildProcess.make(process.execPath, selfDispatch ? [] : [supervisorRuntimePath, encoded], { - cwd: def.cwd, - env: selfDispatch ? makeSupervisorRuntimeEnv(encoded, def.env) : def.env, - extendEnv: true, - stdin: "pipe", - // Detach the supervisor from the Bun parent so it can survive abrupt owner - // death long enough to observe stdin/ownerPid changes and run cleanup. - detached: true, - }); -}; diff --git a/packages/process-compose/src/SupervisorRuntime.unit.test.ts b/packages/process-compose/src/SupervisorRuntime.unit.test.ts deleted file mode 100644 index 5f6d9d5132..0000000000 --- a/packages/process-compose/src/SupervisorRuntime.unit.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import -- These integration-style tests intentionally exercise the native subprocess, filesystem, and timer boundary. -import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { describe, expect, test } from "vitest"; -import { makeSupervisorRuntimeEnv, withoutSupervisorRuntimeEnv } from "./supervisor-protocol.ts"; - -const supervisorRuntimePath = fileURLToPath(new URL("./supervisor-runtime.ts", import.meta.url)); -const supervisorProtocolPath = fileURLToPath(new URL("./supervisor-protocol.ts", import.meta.url)); - -type SupervisorEntry = "source path" | "compiled self-dispatch"; - -const spawnSupervisor = (entry: SupervisorEntry, encodedConfig: string) => { - if (entry === "source path") { - return spawn(process.execPath, [supervisorRuntimePath, encodedConfig], { - stdio: ["pipe", "ignore", "ignore"], - }); - } - - const runtimeUrl = pathToFileURL(supervisorRuntimePath).href; - const protocolUrl = pathToFileURL(supervisorProtocolPath).href; - const dispatch = [ - `import { runSupervisorRuntimeFromEnv } from ${JSON.stringify(runtimeUrl)};`, - `import { isSupervisorRuntimeRequested } from ${JSON.stringify(protocolUrl)};`, - `if (!isSupervisorRuntimeRequested()) throw new Error("supervisor dispatch not requested");`, - `runSupervisorRuntimeFromEnv();`, - ].join("\n"); - return spawn(process.execPath, ["--eval", dispatch], { - env: makeSupervisorRuntimeEnv(encodedConfig, { - ...process.env, - PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH: "1", - }), - stdio: ["pipe", "ignore", "ignore"], - }); -}; - -const waitFor = async ( - predicate: () => boolean, - opts: { - readonly timeoutMs?: number; - readonly intervalMs?: number; - } = {}, -): Promise => { - const timeoutMs = opts.timeoutMs ?? 5_000; - const intervalMs = opts.intervalMs ?? 50; - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (predicate()) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - throw new Error("Timed out waiting for condition"); -}; - -const isPidAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; - -describe("supervisor-runtime", () => { - test.each(["source path", "compiled self-dispatch"])( - "%s kills the child tree and runs validated orphan cleanup when parent stdin closes", - { timeout: 15_000 }, - async (entry) => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const cleanupMarker = path.join(tempDir, "cleanup-command-ran"); - const cleanupEnvironmentMarker = path.join(tempDir, "cleanup-environment.json"); - const childPidFile = path.join(tempDir, "child.pid"); - const grandchildPidFile = path.join(tempDir, "grandchild.pid"); - const readyFile = path.join(tempDir, "ready"); - const childScriptPath = path.join(tempDir, "child.mjs"); - - mkdirSync(cleanupDir); - writeFileSync( - childScriptPath, - [ - `import { spawn } from "node:child_process";`, - `import { writeFileSync } from "node:fs";`, - `writeFileSync(${JSON.stringify(childPidFile)}, String(process.pid));`, - `const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, - `if (grandchild.pid != null) writeFileSync(${JSON.stringify(grandchildPidFile)}, String(grandchild.pid));`, - `writeFileSync(${JSON.stringify(readyFile)}, "ready");`, - `process.on("SIGTERM", () => {});`, - `process.on("SIGINT", () => {});`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ); - - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - shutdownSignal: "SIGTERM", - shutdownTimeoutMs: 100, - cleanup: [ - { _tag: "RemovePath", path: cleanupDir, recursive: true }, - { - _tag: "RunCommand", - executable: process.execPath, - args: [ - "-e", - [ - `const { writeFileSync } = require("node:fs");`, - `writeFileSync(process.argv[1], process.argv[2]);`, - `writeFileSync(process.argv[3], JSON.stringify({`, - ` run: process.env.PROCESS_COMPOSE_RUN_SUPERVISOR,`, - ` config: process.env.PROCESS_COMPOSE_SUPERVISOR_CONFIG,`, - ` dispatch: process.env.PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH,`, - `}));`, - ].join("\n"), - cleanupMarker, - "literal; $(not-run) & value", - cleanupEnvironmentMarker, - ], - }, - ], - }), - ).toString("base64url"); - - const supervisor = spawnSupervisor(entry, encodedConfig); - - try { - await waitFor(() => existsSync(readyFile)); - - const childPid = Number.parseInt(readFileSync(childPidFile, "utf8"), 10); - const grandchildPid = Number.parseInt(readFileSync(grandchildPidFile, "utf8"), 10); - - supervisor.stdin.end(); - - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - await waitFor(() => !existsSync(cleanupDir), { timeoutMs: 10_000 }); - await waitFor(() => existsSync(cleanupMarker), { timeoutMs: 10_000 }); - expect(readFileSync(cleanupMarker, "utf8")).toBe("literal; $(not-run) & value"); - expect(JSON.parse(readFileSync(cleanupEnvironmentMarker, "utf8"))).toEqual({}); - await waitFor(() => !isPidAlive(childPid), { timeoutMs: 10_000 }); - await waitFor(() => !isPidAlive(grandchildPid), { timeoutMs: 10_000 }); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }, - ); - - test( - "runs cleanup exactly once when graceful shutdown races with child exit", - { timeout: 15_000 }, - async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-race-")); - const cleanupMarker = path.join(tempDir, "cleanup-runs"); - const readyFile = path.join(tempDir, "ready"); - const childScriptPath = path.join(tempDir, "child.mjs"); - - writeFileSync( - childScriptPath, - [ - `import { writeFileSync } from "node:fs";`, - `writeFileSync(${JSON.stringify(readyFile)}, "ready");`, - `process.on("SIGTERM", () => setTimeout(() => process.exit(0), 25));`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ); - - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - shutdownSignal: "SIGTERM", - shutdownTimeoutMs: 1_000, - cleanup: [ - { - _tag: "RunCommand", - executable: process.execPath, - args: [ - "-e", - [ - `const { appendFileSync } = require("node:fs");`, - `appendFileSync(${JSON.stringify(cleanupMarker)}, "cleanup\\n");`, - `setTimeout(() => process.exit(0), 250);`, - ].join("\n"), - ], - }, - ], - }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => existsSync(readyFile)); - supervisor.stdin.end(); - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - - expect(readFileSync(cleanupMarker, "utf8")).toBe("cleanup\n"); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }, - ); - - test( - "exits successfully when graceful shutdown races with cleanup-less child exit", - { timeout: 15_000 }, - async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-exit-race-")); - const readyFile = path.join(tempDir, "ready"); - const childScriptPath = path.join(tempDir, "child.mjs"); - - writeFileSync( - childScriptPath, - [ - `import { writeFileSync } from "node:fs";`, - `writeFileSync(${JSON.stringify(readyFile)}, "ready");`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ); - - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - shutdownSignal: "SIGTERM", - shutdownTimeoutMs: 1_000, - }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => existsSync(readyFile)); - supervisor.stdin.end(); - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - - expect(supervisor.exitCode).toBe(0); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }, - ); - - test.each([ - [ - "non-string command argument", - { _tag: "RunCommand", executable: process.execPath, args: [42] }, - ], - ["empty executable", { _tag: "RunCommand", executable: "", args: [] }], - [ - "non-positive timeout", - { _tag: "RunCommand", executable: process.execPath, args: [], timeoutMs: 0 }, - ], - ["invalid path option", { _tag: "RemovePath", path: "/tmp/example", recursive: "yes" }], - ])("rejects a malformed cleanup contract with %s before spawning", async (_name, cleanup) => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-invalid-")); - const childMarker = path.join(tempDir, "child-started"); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(childMarker)}, "started")`], - cleanup: [cleanup], - }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => supervisor.exitCode != null); - expect(supervisor.exitCode).not.toBe(0); - expect(existsSync(childMarker)).toBe(false); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test("removes supervisor protocol variables from the managed child environment", () => { - const childEnv = withoutSupervisorRuntimeEnv({ - KEEP_ME: "value", - PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH: "1", - PROCESS_COMPOSE_RUN_SUPERVISOR: "1", - PROCESS_COMPOSE_SUPERVISOR_CONFIG: "encoded", - }); - - expect(childEnv).toEqual({ KEEP_ME: "value" }); - }); - - test( - "bounds a cleanup command tree by its timeout and continues remaining cleanup", - { timeout: 12_000 }, - async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const cleanupWorkerPidFile = path.join(tempDir, "cleanup-worker.pid"); - const childScriptPath = path.join(tempDir, "child.mjs"); - mkdirSync(cleanupDir); - writeFileSync(childScriptPath, "process.exit(0);\n"); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - cleanup: [ - { - _tag: "RunCommand", - executable: process.execPath, - args: [ - "-e", - [ - `const { spawn } = require("node:child_process");`, - `const { writeFileSync } = require("node:fs");`, - `const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, - `writeFileSync(${JSON.stringify(cleanupWorkerPidFile)}, String(worker.pid));`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ], - // Budget starts at spawn, so it must outlast node booting, spawning - // the worker, and writing the pid file. 100ms lost that race on CI. - timeoutMs: 2_000, - }, - { _tag: "RemovePath", path: cleanupDir, recursive: true }, - ], - }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - expect(supervisor.exitCode).toBe(0); - expect(existsSync(cleanupDir)).toBe(false); - const cleanupWorkerPid = Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10); - expect(Number.isSafeInteger(cleanupWorkerPid)).toBe(true); - await waitFor(() => !isPidAlive(cleanupWorkerPid), { timeoutMs: 10_000 }); - } finally { - supervisor.kill("SIGKILL"); - if (existsSync(cleanupWorkerPidFile)) { - try { - process.kill( - Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10), - "SIGKILL", - ); - } catch {} - } - rmSync(tempDir, { recursive: true, force: true }); - } - }, - ); - - test("bounds a cleanup command when no timeout is configured", { timeout: 12_000 }, async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const cleanupPidFile = path.join(tempDir, "cleanup.pid"); - const childScriptPath = path.join(tempDir, "child.mjs"); - mkdirSync(cleanupDir); - writeFileSync(childScriptPath, "process.exit(0);\n"); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - cleanup: [ - { - _tag: "RunCommand", - executable: process.execPath, - args: [ - "-e", - `require("node:fs").writeFileSync(${JSON.stringify(cleanupPidFile)}, String(process.pid)); setInterval(() => {}, 1000)`, - ], - }, - { _tag: "RemovePath", path: cleanupDir, recursive: true }, - ], - }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 8_000 }); - expect(supervisor.exitCode).toBe(0); - expect(existsSync(cleanupDir)).toBe(false); - } finally { - supervisor.kill("SIGKILL"); - if (existsSync(cleanupPidFile)) { - try { - process.kill(Number.parseInt(readFileSync(cleanupPidFile, "utf8"), 10), "SIGKILL"); - } catch {} - } - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test( - "runs orphan cleanup when the configured owner pid is already gone", - { timeout: 15_000 }, - async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const childScriptPath = path.join(tempDir, "child.mjs"); - - mkdirSync(cleanupDir); - writeFileSync(childScriptPath, `setInterval(() => {}, 1000);\n`); - - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - ownerPid: 999_999_999, - shutdownSignal: "SIGTERM", - shutdownTimeoutMs: 100, - cleanup: [{ _tag: "RemovePath", path: cleanupDir, recursive: true }], - }), - ).toString("base64url"); - - const supervisor = spawn(process.execPath, [supervisorRuntimePath, encodedConfig], { - stdio: ["pipe", "ignore", "ignore"], - }); - - try { - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - await waitFor(() => !existsSync(cleanupDir), { timeoutMs: 10_000 }); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }, - ); -}); diff --git a/packages/process-compose/src/errors.ts b/packages/process-compose/src/errors.ts deleted file mode 100644 index 65dc4ea66f..0000000000 --- a/packages/process-compose/src/errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Data } from "effect"; - -export class CyclicDependencyError extends Data.TaggedError("CyclicDependencyError")<{ - readonly cycle: string; -}> {} - -export class MissingDependencyError extends Data.TaggedError("MissingDependencyError")<{ - readonly service: string; - readonly dependency: string; -}> {} - -export class ServiceNotFoundError extends Data.TaggedError("ServiceNotFoundError")<{ - readonly name: string; -}> {} - -export class SpawnError extends Data.TaggedError("SpawnError")<{ - readonly service: string; - readonly cause: unknown; -}> {} - -export class ServiceReadyError extends Data.TaggedError("ServiceReadyError")<{ - readonly name: string; - readonly reason: string; - readonly exitCode?: number; -}> {} diff --git a/packages/process-compose/src/errors.unit.test.ts b/packages/process-compose/src/errors.unit.test.ts deleted file mode 100644 index b593159070..0000000000 --- a/packages/process-compose/src/errors.unit.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Predicate } from "effect"; -import { - CyclicDependencyError, - MissingDependencyError, - ServiceNotFoundError, - SpawnError, -} from "./errors.ts"; - -describe("errors", () => { - it("CyclicDependencyError has correct tag and data", () => { - const err = new CyclicDependencyError({ cycle: "a -> b -> a" }); - expect(Predicate.isTagged(err, "CyclicDependencyError")).toBe(true); - expect(err.cycle).toBe("a -> b -> a"); - }); - - it("MissingDependencyError has correct tag and data", () => { - const err = new MissingDependencyError({ service: "app", dependency: "db" }); - expect(Predicate.isTagged(err, "MissingDependencyError")).toBe(true); - expect(err.service).toBe("app"); - expect(err.dependency).toBe("db"); - }); - - it("ServiceNotFoundError has correct tag and data", () => { - const err = new ServiceNotFoundError({ name: "unknown" }); - expect(Predicate.isTagged(err, "ServiceNotFoundError")).toBe(true); - expect(err.name).toBe("unknown"); - }); - - it("SpawnError has correct tag and data", () => { - const cause = new Error("ENOENT"); - const err = new SpawnError({ service: "postgres", cause }); - expect(Predicate.isTagged(err, "SpawnError")).toBe(true); - expect(err.service).toBe("postgres"); - expect(err.cause).toBe(cause); - }); -}); diff --git a/packages/process-compose/src/index.ts b/packages/process-compose/src/index.ts deleted file mode 100644 index 421763613f..0000000000 --- a/packages/process-compose/src/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -export type { - DependencyCondition, - Dependency, - ExternalCleanupAction, - ProbeConfig, - HealthCheckConfig, - ShutdownConfig, - RestartPolicy, - SupervisionConfig, - HookTrigger, - HookLog, - LifecycleHook, - OrchestratorConfig, - ServiceStartOptions, - ServiceDef, -} from "./ServiceDef.ts"; -export { defaults } from "./ServiceDef.ts"; - -export type { ServiceDesiredState, ServiceStatus } from "./ServiceState.ts"; -export { ServiceState, initial } from "./ServiceState.ts"; - -export { - CyclicDependencyError, - MissingDependencyError, - ServiceNotFoundError, - ServiceReadyError, - SpawnError, -} from "./errors.ts"; - -export type { LogEntry } from "./LogBuffer.ts"; -export { LogBuffer } from "./LogBuffer.ts"; - -export type { ResolvedGraph } from "./DependencyGraph.ts"; -export { buildGraph } from "./DependencyGraph.ts"; - -export type { HealthProbeCallbacks } from "./HealthProbe.ts"; -export { makeSupervisedCommand, supervisorRuntimePath, usesSupervisor } from "./Supervisor.ts"; -export { - enableSupervisorSelfDispatchForCompiledBun, - isSupervisorRuntimeRequested, -} from "./supervisor-protocol.ts"; -export { runSupervisorRuntime, runSupervisorRuntimeFromEnv } from "./supervisor-runtime.ts"; - -export type { ServiceEvent } from "./ServiceTransition.ts"; -export { applyEvent, transition } from "./ServiceTransition.ts"; - -export { Orchestrator } from "./Orchestrator.ts"; diff --git a/packages/process-compose/src/supervisor-protocol.ts b/packages/process-compose/src/supervisor-protocol.ts deleted file mode 100644 index cfa05b3cce..0000000000 --- a/packages/process-compose/src/supervisor-protocol.ts +++ /dev/null @@ -1,56 +0,0 @@ -const supervisorEnv = { - selfDispatch: "PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH", - runSupervisor: "PROCESS_COMPOSE_RUN_SUPERVISOR", - config: "PROCESS_COMPOSE_SUPERVISOR_CONFIG", -}; - -type SupervisorEnv = Record; - -function isBunVirtualFileSystemUrl(importMetaUrl: string): boolean { - return importMetaUrl.startsWith("file:///$bunfs/"); -} - -export function enableSupervisorSelfDispatchForCompiledBun( - importMetaUrl: string, - env: SupervisorEnv = process.env, -): void { - if (isBunVirtualFileSystemUrl(importMetaUrl)) { - env[supervisorEnv.selfDispatch] = "1"; - } -} - -export function isSupervisorSelfDispatchEnabled( - importMetaUrl: string, - env: SupervisorEnv = process.env, -): boolean { - return isBunVirtualFileSystemUrl(importMetaUrl) && env[supervisorEnv.selfDispatch] === "1"; -} - -export function makeSupervisorRuntimeEnv( - encodedConfig: string, - env: SupervisorEnv = {}, -): SupervisorEnv { - return { - ...env, - [supervisorEnv.runSupervisor]: "1", - [supervisorEnv.config]: encodedConfig, - }; -} - -export function isSupervisorRuntimeRequested(env: SupervisorEnv = process.env): boolean { - return env[supervisorEnv.runSupervisor] === "1"; -} - -export function supervisorRuntimeConfigFromEnv( - env: SupervisorEnv = process.env, -): string | undefined { - return env[supervisorEnv.config]; -} - -export function withoutSupervisorRuntimeEnv(env: SupervisorEnv = process.env): SupervisorEnv { - const next = { ...env }; - delete next[supervisorEnv.selfDispatch]; - delete next[supervisorEnv.runSupervisor]; - delete next[supervisorEnv.config]; - return next; -} diff --git a/packages/process-compose/src/supervisor-runtime.ts b/packages/process-compose/src/supervisor-runtime.ts deleted file mode 100644 index 3ac0933fbc..0000000000 --- a/packages/process-compose/src/supervisor-runtime.ts +++ /dev/null @@ -1,428 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Standalone supervisor entrypoint uses Node process APIs. -import { execFileSync, spawn } from "node:child_process"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Standalone supervisor entrypoint uses Node filesystem APIs. -import { realpathSync, rmSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { - Data, - Deferred, - Duration, - Effect, - Fiber, - Match, - Option, - Predicate, - Schedule, -} from "effect"; -import type { ChildProcess } from "effect/unstable/process"; -import type { ExternalCleanupAction } from "./ServiceDef.ts"; -import { - supervisorRuntimeConfigFromEnv, - withoutSupervisorRuntimeEnv, -} from "./supervisor-protocol.ts"; - -type RemovePathAction = Extract; -type RunCommandAction = Extract; - -class SupervisorCleanupError extends Data.TaggedError("SupervisorCleanupError")<{ - readonly cause: unknown; -}> {} - -interface SupervisorRuntimeConfig { - readonly command: string; - readonly args?: ReadonlyArray; - readonly ownerPid?: number; - readonly shutdownSignal?: ChildProcess.Signal; - readonly shutdownTimeoutMs?: number; - readonly cleanup?: ReadonlyArray; -} - -interface ChildExit { - readonly code: number | null; - readonly signal: NodeJS.Signals | null; -} - -type SupervisorOutcome = - | { readonly _tag: "ShutdownRequested"; readonly signal: ChildProcess.Signal } - | { readonly _tag: "ChildExited"; readonly exit: ChildExit }; - -const DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS = 5_000; - -const isMain = (() => { - if (process.argv[1] == null) { - return false; - } - - const runtimePath = fileURLToPath(import.meta.url); - if (!runtimePath.endsWith("supervisor-runtime.ts")) { - return false; - } - - try { - return realpathSync(process.argv[1]) === realpathSync(runtimePath); - } catch { - return process.argv[1] === runtimePath; - } -})(); - -const getField = (value: object, key: string): unknown => Reflect.get(value, key); - -const isObject = (value: unknown): value is object => typeof value === "object" && value !== null; - -const stringArrayFrom = (value: unknown): ReadonlyArray | undefined => { - if (!Array.isArray(value)) { - return undefined; - } - - return value.every((item) => typeof item === "string") ? value : undefined; -}; - -const signalFrom = (value: unknown): ChildProcess.Signal | undefined => { - switch (value) { - case "SIGABRT": - case "SIGALRM": - case "SIGBUS": - case "SIGCHLD": - case "SIGCONT": - case "SIGFPE": - case "SIGHUP": - case "SIGILL": - case "SIGINT": - case "SIGIO": - case "SIGIOT": - case "SIGKILL": - case "SIGPIPE": - case "SIGPOLL": - case "SIGPROF": - case "SIGPWR": - case "SIGQUIT": - case "SIGSEGV": - case "SIGSTKFLT": - case "SIGSTOP": - case "SIGSYS": - case "SIGTERM": - case "SIGTRAP": - case "SIGTSTP": - case "SIGTTIN": - case "SIGTTOU": - case "SIGUNUSED": - case "SIGURG": - case "SIGUSR1": - case "SIGUSR2": - case "SIGVTALRM": - case "SIGWINCH": - case "SIGXCPU": - case "SIGXFSZ": - case "SIGBREAK": - case "SIGLOST": - case "SIGINFO": - return value; - default: - return undefined; - } -}; - -const cleanupActionFrom = (value: unknown): ExternalCleanupAction | undefined => { - if (!isObject(value)) { - return undefined; - } - - if (Predicate.isTagged(value, "RunCommand")) { - const executable = getField(value, "executable"); - const args = stringArrayFrom(getField(value, "args")); - const timeoutMs = getField(value, "timeoutMs"); - if ( - typeof executable !== "string" || - executable.length === 0 || - args === undefined || - (timeoutMs !== undefined && - (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) - ) { - return undefined; - } - return { - _tag: "RunCommand", - executable, - args, - timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined, - }; - } - - if (Predicate.isTagged(value, "RemovePath")) { - const path = getField(value, "path"); - const recursive = getField(value, "recursive"); - const force = getField(value, "force"); - return typeof path === "string" && - path.length > 0 && - (recursive === undefined || typeof recursive === "boolean") && - (force === undefined || typeof force === "boolean") - ? { - _tag: "RemovePath", - path, - recursive: typeof recursive === "boolean" ? recursive : undefined, - force: typeof force === "boolean" ? force : undefined, - } - : undefined; - } - - return undefined; -}; - -const cleanupActionsFrom = (value: unknown): ReadonlyArray | undefined => { - if (!Array.isArray(value)) { - return undefined; - } - - const actions = value.map(cleanupActionFrom); - return actions.every((action) => action != null) ? actions : undefined; -}; - -const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeConfig => { - const value: unknown = JSON.parse(Buffer.from(encodedConfig, "base64url").toString("utf8")); - if (!isObject(value)) { - throw new Error("Invalid supervisor config"); - } - - const command = getField(value, "command"); - if (typeof command !== "string") { - throw new Error("Invalid supervisor command"); - } - - const ownerPid = getField(value, "ownerPid"); - const shutdownTimeoutMs = getField(value, "shutdownTimeoutMs"); - const cleanupValue = getField(value, "cleanup"); - const cleanup = cleanupValue === undefined ? undefined : cleanupActionsFrom(cleanupValue); - if (cleanupValue !== undefined && cleanup === undefined) { - throw new Error("Invalid supervisor cleanup"); - } - - return { - command, - args: stringArrayFrom(getField(value, "args")), - ownerPid: typeof ownerPid === "number" ? ownerPid : undefined, - shutdownSignal: signalFrom(getField(value, "shutdownSignal")), - shutdownTimeoutMs: typeof shutdownTimeoutMs === "number" ? shutdownTimeoutMs : undefined, - cleanup, - }; -}; - -const killProcessTree = (pid: number, signal: ChildProcess.Signal): void => { - if (isWindows) { - try { - execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { - stdio: "ignore", - timeout: 5_000, - }); - } catch {} - - return; - } - - try { - process.kill(-pid, signal); - return; - } catch {} - - try { - process.kill(pid, signal); - } catch {} -}; - -const isWindows = process.platform === "win32"; - -const waitForExit = ( - childExit: Deferred.Deferred, - timeoutMs: number, -): Effect.Effect => - Deferred.await(childExit).pipe( - Effect.timeoutOption(Duration.millis(timeoutMs)), - Effect.map(Option.isSome), - ); - -const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const childEnv = withoutSupervisorRuntimeEnv(); - const child = yield* Effect.sync(() => - spawn(config.command, config.args ?? [], { - cwd: process.cwd(), - env: childEnv, - stdio: ["ignore", "pipe", "pipe"], - detached: !isWindows, - }), - ); - if (child.stdout != null) child.stdout.pipe(process.stdout); - if (child.stderr != null) child.stderr.pipe(process.stderr); - - const childExit = yield* Deferred.make(); - const shutdownRequest = yield* Deferred.make(); - const onChildExit = (code: number | null, signal: NodeJS.Signals | null) => { - Deferred.doneUnsafe(childExit, Effect.succeed({ code, signal })); - }; - child.once("exit", onChildExit); - - const ownerPid = typeof config.ownerPid === "number" ? config.ownerPid : undefined; - const ownerAlive = () => { - if (ownerPid == null) return true; - try { - process.kill(ownerPid, 0); - return true; - } catch { - return false; - } - }; - const requestShutdown = (signal: ChildProcess.Signal) => { - Deferred.doneUnsafe(shutdownRequest, Effect.succeed(signal)); - }; - - process.stdin.resume(); - const onStdinEnd = () => requestShutdown(config.shutdownSignal ?? "SIGTERM"); - const onStdinClose = () => requestShutdown(config.shutdownSignal ?? "SIGTERM"); - const onSigInt = () => requestShutdown("SIGINT"); - const onSigTerm = () => requestShutdown("SIGTERM"); - process.stdin.on("end", onStdinEnd); - process.stdin.on("close", onStdinClose); - process.on("SIGINT", onSigInt); - process.on("SIGTERM", onSigTerm); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - child.removeListener("exit", onChildExit); - process.stdin.removeListener("end", onStdinEnd); - process.stdin.removeListener("close", onStdinClose); - process.removeListener("SIGINT", onSigInt); - process.removeListener("SIGTERM", onSigTerm); - if (child.pid != null) killProcessTree(child.pid, "SIGKILL"); - }), - ); - - const ownerWatcher = yield* Effect.forkChild( - Effect.repeat( - Effect.gen(function* () { - if (!ownerAlive()) { - yield* Deferred.succeed(shutdownRequest, config.shutdownSignal ?? "SIGTERM"); - } - }), - Schedule.spaced(Duration.millis(500)), - ), - ); - - const killChildTree = (signal: ChildProcess.Signal) => - Effect.sync(() => { - if (child.pid != null) killProcessTree(child.pid, signal); - }); - - const runCleanupCommand = (action: RunCommandAction): Effect.Effect => - Effect.callback((resume) => { - const cleanupChild = spawn(action.executable, action.args, { - detached: !isWindows, - env: childEnv, - stdio: "ignore", - }); - const finish = () => resume(Effect.void); - cleanupChild.once("error", finish); - cleanupChild.once("exit", finish); - return Effect.sync(() => { - cleanupChild.removeListener("error", finish); - cleanupChild.removeListener("exit", finish); - if (cleanupChild.pid != null) killProcessTree(cleanupChild.pid, "SIGKILL"); - }); - }).pipe( - Effect.timeoutOption( - Duration.millis(action.timeoutMs ?? DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS), - ), - Effect.asVoid, - ); - - const runCleanup = Effect.gen(function* () { - const removePathWithRetry = (action: RemovePathAction) => - Effect.try({ - try: () => { - rmSync(action.path, { - recursive: action.recursive ?? true, - force: action.force ?? true, - }); - }, - catch: (cause) => new SupervisorCleanupError({ cause }), - }).pipe( - Effect.retry(Schedule.spaced(Duration.millis(250)).pipe(Schedule.upTo({ times: 19 }))), - Effect.ignore, - ); - yield* Effect.all( - [ - Effect.forEach( - config.cleanup ?? [], - (action) => - Predicate.isTagged(action, "RunCommand") ? runCleanupCommand(action) : Effect.void, - { concurrency: 1, discard: true }, - ), - Effect.forEach( - config.cleanup ?? [], - (action) => - Predicate.isTagged(action, "RemovePath") - ? removePathWithRetry(action) - : Effect.void, - { concurrency: "unbounded", discard: true }, - ), - ], - { discard: true }, - ); - }); - - const shutdown = (signal: ChildProcess.Signal) => - Effect.gen(function* () { - yield* killChildTree(signal); - const exitedGracefully = yield* waitForExit( - childExit, - config.shutdownTimeoutMs ?? 10_000, - ); - if (!exitedGracefully) { - yield* killChildTree("SIGKILL"); - yield* waitForExit(childExit, 2_000); - } - }); - - const outcome = yield* Effect.race( - Deferred.await(shutdownRequest).pipe( - Effect.map((signal): SupervisorOutcome => ({ _tag: "ShutdownRequested", signal })), - ), - Deferred.await(childExit).pipe( - Effect.map((exit): SupervisorOutcome => ({ _tag: "ChildExited", exit })), - ), - ); - - yield* Fiber.interrupt(ownerWatcher); - return yield* Match.valueTags(outcome, { - ShutdownRequested: ({ signal }) => - Effect.gen(function* () { - yield* shutdown(signal); - yield* runCleanup; - return yield* Effect.sync(() => process.exit(0)); - }), - ChildExited: ({ exit: { code, signal } }) => - Effect.gen(function* () { - if (!ownerAlive() || (config.cleanup?.length ?? 0) > 0) { - yield* runCleanup; - return yield* Effect.sync(() => process.exit(0)); - } else if (signal != null) { - return yield* Effect.sync(() => process.exit(1)); - } else { - return yield* Effect.sync(() => process.exit(code ?? 0)); - } - }), - }); - }), - ); - -export function runSupervisorRuntime(encodedConfig = process.argv[2]): void { - if (encodedConfig == null) throw new Error("Missing supervisor config"); - const config = parseSupervisorRuntimeConfig(encodedConfig); - void Effect.runPromise(runSupervisorRuntimeEffect(config)).catch(() => process.exit(1)); -} - -if (isMain) { - runSupervisorRuntime(); -} - -export function runSupervisorRuntimeFromEnv(env = process.env): void { - runSupervisorRuntime(supervisorRuntimeConfigFromEnv(env)); -} diff --git a/packages/process-compose/tsconfig.json b/packages/process-compose/tsconfig.json deleted file mode 100644 index eef2f2a863..0000000000 --- a/packages/process-compose/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "@tsconfig/bun/tsconfig.json", - "compilerOptions": { - "lib": ["ESNext", "DOM"], - "types": ["bun"] - } -} diff --git a/packages/process-compose/vitest.config.ts b/packages/process-compose/vitest.config.ts deleted file mode 100644 index e10a870951..0000000000 --- a/packages/process-compose/vitest.config.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - passWithNoTests: true, - coverage: { - enabled: false, - provider: "istanbul", - clean: false, - include: ["src/**/*.ts"], - reporter: ["text", "lcov"], - reportsDirectory: "coverage", - }, - projects: [ - { - test: { - name: "unit", - include: ["**/*.unit.test.ts"], - }, - }, - { - test: { - name: "integration", - include: ["**/*.integration.test.ts"], - }, - }, - ], - }, -}); diff --git a/packages/stack/README.md b/packages/stack/README.md index 20b0a0fd97..dc648fe9dc 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -1,90 +1,11 @@ # `@supabase/stack` -Local Supabase stack runtime for Node and Bun. The package offers a direct -in-process API and a managed supervisor API for detached CLI workflows. +The local Supabase stack runtime. Its public API is being rebuilt as a +greenfield Effect-native managed runtime; implementation modules are private +to the package. -## Direct stack +The supported entrypoints are: -```ts -import { createStack } from "@supabase/stack"; - -await using stack = await createStack({ projectDir: process.cwd() }); -await stack.start(); -console.log((await stack.getInfo()).url); -``` - -`createStack` resolves configuration, reserves ports, and builds a scoped -handle. `stack.start()` starts services; disposing the handle stops them and -releases its lease. When `mode` is omitted, creation uses Docker mode with a -usable Docker or Podman service and otherwise selects native mode. An explicit -mode never falls back to the other one. - -## Managed stack - -Managed callers use `@supabase/stack/managed` (or the conditional managed Bun -and Node entrypoint) and provide a state root to the manager layer: - -```ts -import { managedStackManagerLayer } from "@supabase/stack/managed"; - -const layer = managedStackManagerLayer({ stateRoot: "/absolute/managed" }); -``` - -The manager persists one document per deterministic stack id under -`/stacks//stack.json`. It owns identity discovery, sticky-port -intent, lifecycle transitions, and control ownership. The lifecycle facade -provides `connectManagedStack`, `updateManagedLaunch`, `stopManagedStack`, and -`deleteManagedStack`, so consumers do not manipulate documents or control -routes directly. - -The CLI normally uses `@supabase/stack/effect`: - -```ts -import { daemonLayer, connectLayer, stopDaemon } from "@supabase/stack/effect"; - -const runtime = - yield * - daemonLayer({ - cacheRoot: cliSettings.supabaseHome, - projectDir: projectRoot, - name: "default", - portIntents, - launch: { mode: "docker", versions: {}, excludedServices: [] }, - }); -``` - -`daemonLayer` starts the managed supervisor and returns a remote `Stack` layer; -`restartManagedStackForUpgrade` is the explicit stop/start operation used by -`supabase start` when the owner was started by another CLI version; -`connectLayer` reattaches through the deterministic control endpoint; -`stopDaemon` and the discovery helpers delegate to the managed lifecycle -facade. No CLI metadata file or PID polling is involved. - -Managed ownership is exposed by one deterministic loopback HTTP listener. The -stable cross-build control protocol is `GET /owner` plus session-fenced -`POST /stop`; runtime operations use same-version Effect RPC over framed NDJSON -at `POST /rpc`. The complete application is installed before the listener -binds, and runtime RPC is available only after the supervisor publishes a -running lifecycle state. Owner discovery distinguishes a versioned supervisor -from an unversioned maintenance lease. Stop requests carry either explicit-user -or upgrade-replacement intent so a user stop cannot be undone by a delayed -replacement child. - -The CLI version must exactly match the daemon CLI version before a remote -runtime client is constructed. Released and preview CLI versions are immutable -and unique, so the version is the compatibility identity. An incompatible owner is never spoken to -over RPC: connect-only commands report an actionable upgrade requirement, and -only an explicit `supabase start` may preflight, stop the exact old owner -session, and start the current version. Upgrade restart preserves the managed -identity and launch metadata, data roots, runtime mode, pinned service -versions, exclusions, and sticky port assignments; it never deletes the -managed stack. Existing connections briefly disconnect during this normal -stop/start upgrade restart. - -After a managed supervisor claims a stack, its persisted Docker, Podman, or -native selection remains pinned even if startup later fails. Retry after -restoring or starting that runtime; delete and recreate the stack to choose a -different execution mode. Deletion removes the stack's managed data. - -For the end-to-end lifecycle, identity, ports, service execution, transport, -compiled-Bun re-entry, and testing boundary, see [How `@supabase/stack` works](docs/architecture.md). +- `@supabase/stack` — Promise facade +- `@supabase/stack/effect` — Effect-native API +- `@supabase/stack/testing` — test helpers diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md deleted file mode 100644 index 8b4c16f24b..0000000000 --- a/packages/stack/docs/architecture.md +++ /dev/null @@ -1,426 +0,0 @@ -# How `@supabase/stack` works - -`@supabase/stack` is the local Supabase runtime for Node and Bun. It has two -runtime modes that share the same service graph, port allocator, and HTTP -proxy: - -- **Direct** (`createStack`) keeps the stack in the caller's process and lets - the caller own its lifecycle. -- **Managed** (`daemonLayer` and the managed lifecycle facade) runs one - supervisor child per deterministic stack identity. The CLI uses this mode - when a stack must outlive one command, be reattached by another command, or - be isolated from a sibling worktree. - -Choose direct mode for an application, test, or script that can keep a handle -open and dispose it. Choose managed mode for a CLI workflow or any caller that -needs detached ownership, durable status, sticky ports, logs, or reattachment. -Managed mode is a coordination layer around the direct runtime; it does not -introduce a second service registry, repository contract, or SQLite adapter. - -## Managed startup at a glance - -The parent resolves the workspace identity before forking. The child creates a -`SupervisorSession` actor and the complete control application before attempting -the deterministic loopback bind. Ownership is claimed before expensive -workspace reconciliation, while `/owner` and the session-fenced `/stop` route -remain available throughout startup. Runtime RPC is gated until the runtime is -published as running. - -```mermaid -sequenceDiagram - participant CLI - participant Parent as supervisor parent - participant Child as managed child - participant Manager as ManagedStackManager - participant Control as loopback control endpoint - participant Runtime as direct Stack runtime - - CLI->>Parent: daemonLayer(config, port intents, launch) - Parent->>Child: fork + start message (resolved stack id) - Child->>Control: assemble /owner, /stop, and /rpc application - Child->>Control: acquire ownership + bind deterministic endpoint - Child->>Manager: ensure workspace + verify stack id - Child->>Manager: resolve document, allocate/reuse ports - Child->>Manager: write starting - Child->>Runtime: build Stack and ApiProxy - Child->>Manager: write running + runtime endpoint - Child-->>Parent: started(endpoint) - Parent-->>CLI: RemoteStack layer - CLI->>Control: same-version Stack RPC at /rpc -``` - -`running` in the managed document is recorded only after -`SupervisorSession` has published a ready runtime. The actor is the one -atomic state projection: control is available during `starting`, while RPC -handlers read `runtimeStack` and fail fast with typed `StackUnavailableError` -until `running` (and again during shutdown). - -## Public entrypoints - -The package exposes the same platform-neutral contracts through conditional -Node and Bun bindings: - -- `@supabase/stack` is the Promise-based direct API (`createStack`, `prefetch`, - and the stack types). -- `@supabase/stack/effect` provides the platform-bound Effect layers. It - includes `foregroundLayer` for direct use, `daemonLayer` to launch a managed - supervisor, `connectLayer` to reattach, and lifecycle/discovery helpers such - as `stopDaemon` and `updateManagedLaunch`. -- `@supabase/stack/managed` exposes managed identity, control, document, - manager, and lifecycle operations for consumers that need those boundaries - directly. -- `@supabase/stack/testing` exposes only runtime seams used to build integration - tests; it is not a production repository or fixture contract. - -`@supabase/stack/daemon-bun` is an internal compiled-Bun re-entry target. The -Node and Bun entrypoints provide filesystem, path, process, HTTP, and control -transport services to the shared implementation. - -## Direct runtime - -The internal `createStack` Effect validates allocation-free port intents, acquires -one authoritative lease for every active field, then resolves configuration once -with those selected ports and builds a scoped runtime and handle. Temporary roots -created during resolution are tracked and removed on failure. Node and Bun adapt -that handle to the Promise/`AsyncIterable` facade at the package edge; there is no -Promise resolver that can expose a placeholder port set. The lease owns each -socket until its exact runtime consumer takes over the port, and retains every -remaining reservation until disposal. Automatic API-port handoff may retry with -an OS-selected port only before the first successful start, while explicit ports -remain sticky. -The direct runtime does **not** start service processes. Asset preparation and -process-compose graph construction happen when the handle is first started or -a service is activated. - -`stack.start()` starts services according to the configured startup mode and -waits for the selected readiness policy. The handle also exposes status, logs, -per-service operations, and graceful `stop()`/`dispose()` methods. Its scope -owns service processes and releases the lease when disposed. A direct stack -never reads or writes managed documents and never coordinates with a sibling -stack. - -### Concurrency and cleanup - -`SupervisorSession` owns one command `Queue`, one actor fiber, the startup -fiber, and a child runtime scope. `/stop`, signals, startup completion, and -unexpected runtime disposal submit commands to that actor instead of mutating -lifecycle state independently. Shutdown publishes `stopping`, interrupts and -joins startup, stops and disposes any constructed runtime, closes its scope, -persists the terminal document state, and releases the control listener last. -Concurrent stop callers join the same actor-owned cleanup transaction, which is -also the session scope's idempotent finalizer. Explicit stops complete after all -teardown attempts and write cleanup anomalies to the managed stack's -`logs/supervisor.log`; startup and runtime failures preserve their primary -cause. Unary calls and streams observe the same terminal signal, including its -typed `stopping` or `failed` reason, before listener shutdown. For HTTP stop, -Node and Bun close the listener gracefully after flushing a successful `202`; -the stable client consumes that bounded response and polls the exact session -fence until the owner disappears. - -`StackPreparation` resolves independent services with a concurrency cap of four. -Its closure includes the resources for every public graph dependency a requested -service can start, so Docker never auto-pulls outside the preparation pipeline. -Each service resolves one canonical GHCR image. Pulls retry only transient -registry and network failures on a one-second exponential `Schedule`, capped at -five retries; non-retryable failures surface immediately with deterministic -details. Auto-managed roots are removed through the Effect `FileSystem` with a -bounded retry schedule. Each removal pass is uninterruptible, while the delay -between attempts remains interruptible; cleanup stays scoped to the exact paths -owned by that stack. Stale binary staging directories use the same small -concurrency cap during reconciliation. - -## Managed lifecycle - -### One document and one owner - -Each managed stack has one durable document under: - -```text -/managed/stacks//stack.json -``` - -The same directory contains `data/` for managed service data, `logs/` for -runtime logs, and `runtime/` for supervisor-owned runtime files. The -`ManagedStackManager` is the only component that writes `stack.json`. - -Control ownership is the liveness and mutation authority. `acquireControl` -returns `Owned` for the process that bound the deterministic endpoint or -`Attached` for a live owner. An attached caller uses the owner's endpoint for -runtime requests; it never edits the document directly. - -### Start and attach - -1. `daemonLayer` discovers the workspace and derives the stack id before the - parent forks a supervisor child. Managed-only port intents and launch - metadata stay separate from the generic daemon configuration. -2. The child constructs the session actor and the complete static application, - then claims the deterministic endpoint. A bind failure never leaves a - partially installed runtime server. -3. The owner re-checks workspace identity, re-reads the document, selects or - validates its concrete runtime, and reconciles stale named resources. The - manager allocates or reuses ports and records `starting`. -4. The child builds the direct runtime, publishes it through - `SupervisorSession`, records `running`, and sends the verified owner - descriptor to the parent. -5. The parent returns a `RemoteStack` layer. The CLI invokes `StackRpc` over - `POST /rpc` for runtime operations. - -`connectManagedStack` reads the document, probes the deterministic endpoint -without binding it, and returns a `RemoteStack` only when the owner reports a -ready running state. Read-only status and discovery therefore do not claim an -endpoint; mutating operations acquire control ownership. - -### Update, stop, and delete - -- `updateManagedLaunch` is owner-gated. An attached client posts the validated - launch payload to the same-version `UpdateLaunch` RPC; the owner invokes - `ManagedStackManager.updateLaunch`, and the caller re-reads the document. -- `stopManagedStack` asks an attached owner to perform a graceful - `RemoteStack.stop()`, waits for the document to become `stopped`, and lets - the owner close the runtime before releasing control. If the old owner is - gone, the facade acquires control, removes containers named for the - stack id, records `stopped`, and does not inspect PIDs or scan processes. -- `deleteManagedStack` requires owned control, reconciles any owned running or - failed runtime resources, and then removes the document and its managed data - root. The explicit destructive path can also remove an invalid document - after ownership is acquired; ordinary status and start operations report - corruption instead of guessing. - -### Failure and recovery - -The owner records `starting`, `running`, `stopped`, `failed`, and `deleting` -transitions. A startup error records `failed` and releases the port lease. A -graceful stop closes the direct runtime before recording `stopped`. - -If a supervisor crashes, its document and possible runtime artifacts remain. -The next managed start acquires control, reconciles named container resources by -stack id, and reuses sticky ports according to their persisted `exact` or -`automatic` intent. No PID file, process scan, second metadata file, or registry -surgery is needed for recovery. - -Every document contains a concrete launch selection. Native documents record -`mode: "native"`; container documents record `mode: "docker"` together with -the selected `docker` or `podman` executable. Inputs may omit a mode to request -selection, but unresolved launch state is never persisted. Resolved direct -runtime configuration uses the same correlated native-or-container union. - -## Identity and state - -The stack id is a deterministic hash of workspace lineage, checkout lineage, -branch or detached context, the canonical local project key, and the managed -stack name. `workspaceId` identifies a local repository or ordinary-folder -lineage; it is not a remote Supabase project id. - -For a Git checkout, `localProjectKey` is the canonical project-root path -relative to the enclosing checkout root, normalized with `/` and `.` for the -root. Sibling nested projects therefore get different identities and ports -even when they use the default name. Renaming a local project directory -intentionally creates a new identity; migration is not attempted. - -Git metadata supplies checkout and branch context. An ordinary folder receives -an explicit private marker under `.supabase/`. Git checkout markers and -ordinary-folder markers are private, unreleased storage owned by the current -build; there is no migration layer or parallel document format. - -Consuming applications resolve and pass the canonical local Supabase project -root to the managed environment APIs. `workspacePath` is an identity input, not -an arbitrary current working directory: the stack does not inspect -`supabase/config.toml`, remote-link metadata, or CLI-specific project layout -conventions. - -Moved checkouts can be repaired with `repairWorkspace`, preserving the identity -and ports. Duplicate checkout adoption is intentionally unsupported and -requires an explicit ownership decision. Read-only project discovery treats -unsupported or unreadable Git metadata as no managed stack; mutating operations -fail loudly rather than creating state against ambiguous identity evidence. - -## Ports and control ownership - -Managed port assignments preserve both a port and its intent: - -- `exact` means the caller requested that number and a live conflicting owner - is an error. -- `automatic` means the assignment is sticky for the document but may be - allocated independently for a sibling worktree or nested project. - -Startup derives active and disabled fields from the enabled-service -configuration and preserves the raw project document so omitted values remain -automatic. Automatic managed service allocation excludes the entire loopback -control range (`10000..32767`). Explicit service ports may use that range, but -they still conflict with the incoming stack's deterministic endpoint, a known -persisted endpoint, or another stack's reservation under the normal exact-port -rules. A persisted automatic assignment in the control range is invalid and -fails loudly rather than being silently migrated. - -A deterministic sequence of eight control endpoint candidates is derived from -the stack id and served on loopback. Acquisition scans for an existing matching -owner, then binds the first available candidate; read-only probes scan the same -sequence without claiming it. A hash collision or unrelated listener consumes -that candidate, and acquisition fails with a typed conflict only when the -sequence cannot yield an unambiguous owner or free endpoint. The manager -reserves every known candidate against service allocation, and the document -records the endpoint the owner actually bound. An exact service port can still -equal a future candidate of an identity that has never started, so that -candidate is skipped when ownership is acquired. Start fails only when the -sequence cannot distinguish an owner from an ambiguous transport failure or -find a free endpoint; explicit ports are not forbidden across the whole -reserved range. - -This is deliberately a small single-user localhost mechanism. The stable -control protocol has no token authentication; ownership, endpoint identity, -protocol-version checks, and the owner session fence provide the lifecycle -boundary. The one static application exposes only: - -- `GET /owner` for the current supervisor lifecycle and CLI version, or the - current maintenance operation; -- `POST /stop` for an idempotent shutdown request containing the ownership id, - exact owner session id, and explicit-user or upgrade-replacement intent; and -- `POST /rpc` for same-version Effect RPC over framed NDJSON, fenced to the - expected ownership id and owner session before dispatch. - -`RemoteStack` is the thin typed RPC adapter. It never maintains a handwritten -runtime route table or stream parser. Remote stop uses the stable `/stop` route -and waits for the targeted owner session to end. - -The `/owner` payload is an exhaustive union. A `supervisor` owner publishes the -deterministic ownership id, random `ownerSessionId`, control protocol/version, -lifecycle state, readiness, and daemon CLI version. A `maintenance` owner -publishes its operation (`delete`, `stop`, `update`, or `repair`) and has no -daemon version or RPC surface. `/stop` requires ownership id, session id, and -intent, returns `409` for a different owner session, `423` while maintenance -owns the endpoint, and `202` only after the supervisor has accepted the -one-shot shutdown request. The caller then observes the captured session under -one deadline; disappearance completes as ended, while another valid owner is a -replacement. The protocol is session-fenced from its first supported release; -there is no legacy runtime compatibility window or second-server handoff. - -### CLI version identity and upgrade restart - -The owner response includes the daemon CLI version. Released and preview -versions are immutable and unique, so that version is the compatibility -identity. A `RemoteStack` RPC client is constructed only when the client CLI -version equals the owner CLI version. A mismatch is a typed -`DaemonUpgradeRequired`; it never becomes an attempted RPC request. Every RPC -request repeats the same owner/session fence so a client that outlives its -captured listener session is rejected before a handler runs. - -Direct source execution uses the visible `0.0.0-dev` sentinel. It is a -development mode, not a cross-checkout compatibility promise: after changing -runtime or RPC code, the developer restarts the managed stack before testing. - -Only an explicit `supabase start` may authorize an upgrade restart. It -preflights the managed document and persisted launch selection while the old -owner is live, sends a session-fenced replacement `/stop`, waits for that exact -session to end, then starts the current version. The upgrade restart preserves the -managed stack identity and creation metadata, data roots, runtime mode, pinned -service versions, exclusions, and sticky port intents. It never invokes the -destructive delete path or silently changes launch metadata. - -The public `@supabase/stack/effect` entry exposes this authorization as -`restartManagedStackForUpgrade`. Ordinary `daemonLayer` calls cannot authorize a -restart: an incompatible owner fails with `DaemonUpgradeRequired` and remains running. - -An upgrade restart is one parent-owned transaction. After preflight, the CLI -emits its restart notice, then the parent uses the shared stable `ControlClient` -with the captured ownership and session ids and waits for that exact session to -end. It re-reads and preflights the persisted launch before spawning an ordinary -child marked as the authorized replacement. The child only starts or attaches; -it never decides to stop an incompatible owner. Persisted -exclusions are applied to effective runtime service policies before preflight, -active-port calculation, allocation, configuration resolution, and startup; -copying them only into `stack.json` is insufficient. Once the new runtime is -up, its managed summary is authoritative for subsequent launch updates. - -Connect-only commands fail with `DaemonUpgradeRequired`: status renders a degraded -owner/document summary with an instruction to run `supabase start`, while logs, -service operations, and other runtime commands return the actionable upgrade -error. No read-only command restarts a live stack. A stop request always uses -the stable control protocol, regardless of CLI version. - -The upgrade restart is a stop/start transaction rather than a supervisor handoff. -Preflight failure leaves the old owner running; stop timeout never binds a new -owner; startup failure preserves the document and data for retry. Concurrent -ordinary starts never restart an incompatible stack, and a delayed stop -containing the old session id receives `409` from the new owner. An explicit -user stop is persisted before the old endpoint closes and prevents an attached -or delayed replacement child from restarting the stack; a replacement stop -does not set that user intent. - -### Static application and lifecycle ownership - -`SupervisorSession` owns lifecycle state, the owner session, runtime -publication, and the serialized shutdown state machine. All shutdown sources -submit to its `Queue`. The accepted `202` stop response is flushed by the -listener's graceful close; the stable control client consumes the bounded -response body and polls the exact fenced session until it disappears. The actor -finishes startup cancellation and runtime-scope finalizers before terminal -persistence and ownership/listener close. Node, Bun, and compiled Bun children -use the same pre-bind application and session composition. - -## Service execution and `ApiProxy` - -`StackPreparation` resolves each enabled service to a verified native binary or -a Docker image. Explicit `mode: "native"` uses the supported native services -and rejects Docker-only services; explicit `mode: "docker"` requires a usable -Docker or Podman runtime and resolves every service to an image. When mode is -omitted, selection prefers a usable Docker or Podman runtime and otherwise uses -native mode only on a host for which native artifacts are published; that -automatic fallback disables Docker-only services before ports or managed launch -state are acquired. The selected runtime is then fixed for the stack. Service -versions are normalized to their catalog form before either binary resolution -or Docker image resolution, and `StackBuilder` turns the results into one -process-compose graph. Docker resources are namespaced with the managed stack -id. - -`ApiProxy` listens on the configured public `apiPort` and routes Supabase API -paths (`/auth`, `/rest`, `/functions`, `/realtime`, `/storage`, `/pg`, -`/analytics`, and related endpoints) to the service ports. The database URL -and direct service endpoints remain available from `Stack.getInfo`. The -loopback control endpoint is management traffic and is never the user-facing -API URL. - -## Platform re-entry: compiled Bun - -In source mode, Node and Bun supervisors fork the adjacent `daemon-node.ts` or -`daemon-bun.ts` entrypoint. A Bun single-file executable cannot fork a source -URL from Bun's virtual filesystem, so the child re-enters the compiled CLI -instead. The parent sets `SUPABASE_STACK_RUN_DAEMON=1`; the CLI entrypoint -handles that marker before normal command dispatch and invokes the same -`runBunDaemon()` supervisor entrypoint. This is the only stack-specific -self-dispatch path; the daemon branch does not run normal CLI command dispatch. - -## Component map - -| Concern | Owner | -| ------------------------------------------------- | --------------------------------------------------------------------------------------- | -| Public Promise and Effect entrypoints | `src/{node,bun,effect-node,effect-bun}.ts` | -| Identity discovery and stack id | `managed/environment.ts`, `managed/identity.ts`, `managed/git.ts` | -| Document paths, schema, and atomic persistence | `managed/paths.ts`, `managed/document.ts`, `managed/store.ts` | -| Managed reads, writes, ports, and lifecycle state | `managed/manager.ts`, `managed/lifecycle.ts`, `discovery.ts` | -| Ownership and deterministic endpoint | `managed/control.ts` | -| Detached child protocol and startup | `supervisor.ts`, `SupervisorUpgradeRestart.ts`, `daemon-node.ts`, `daemon-bun.ts` | -| Runtime control RPC and client | `SupervisorControlServer.ts`, `StackRpc.ts`, `RemoteStack.ts`, `HttpTransportClient.ts` | -| Direct runtime construction and service lifecycle | `createStack.ts`, `layers.ts`, `LocalStack.ts`, `Stack.ts` | -| Asset resolution and native/Docker graph | `StackPreparation.ts`, `StackBuilder.ts`, `ServiceCatalog.ts` | -| Public API routing | `ApiProxy.ts` | -| Platform listeners and process services | `platform-node.ts`, `platform-bun.ts` | - -## Testing boundary - -Integration tests exercise the surfaces a consumer uses: manager identity and -documents, sibling worktrees and nested projects, detached start/reattach, -launch updates through RPC, status and logs, graceful session-fenced stop, -stale-owner recovery, and deletion. They also cover control before runtime -construction, real HTTP/NDJSON unary and stream calls, stream cancellation, -CLI version mismatch, upgrade restart and preservation (including actual -excluded-service behavior and sticky-port reuse), concurrent lifecycle -requests, cleanup after cancellation or failure, and response flush before -close. Node and Bun control adapters share conflict classification, and a small -number of end-to-end tests cover Node, Bun, and compiled-Bun subprocess -boundaries. - -Unit tests are reserved for pure identity, port, document, projection, and -platform algorithms or for branches unreachable through the public runtime -surface. The testing entrypoint exposes only the static control application and transport -seams needed to build those journeys; it does not recreate a repository, -SQLite adapter, or contract-fixture implementation. diff --git a/packages/stack/docs/effect-platform-gaps.md b/packages/stack/docs/effect-platform-gaps.md deleted file mode 100644 index 299a84fd6d..0000000000 --- a/packages/stack/docs/effect-platform-gaps.md +++ /dev/null @@ -1,154 +0,0 @@ -# Effect V4 Platform API Gaps — Supabase CLI Audit - -After moving `apps/cli` behind a local platform boundary, the remaining upstream gaps are narrower than they first appeared. - -## Already Covered by Effect Platform - -These are available today and do not need local stopgaps beyond normal wiring: - -- `Stdio` and `Terminal` cover argv and interactive stdio access. -- `Config` and `ConfigProvider.fromEnv` cover environment variable injection. -- `effect/unstable/process` covers child-process spawning for normal subprocess use. -- `effect/unstable/socket/SocketServer` covers raw TCP server binding, including bind-to-port-0 flows. - -The old “raw TCP server for port probing” gap is no longer current. - -## 1. No Current Process Service - -**Current CLI use cases** - -- subscribe to `SIGINT` / `SIGTERM` -- react to stdin shutdown for foreground cleanup -- set `process.exitCode` -- call `process.exit(code)` - -**Why this still matters** - -Effect has process spawning, but not a reusable abstraction for the currently running process. Signal handling and exit behavior still require direct runtime access. - -**Suggested API** - -```ts -const currentProcess = yield * CurrentProcess.CurrentProcess; - -yield * currentProcess.awaitSignal(["SIGINT", "SIGTERM"]); -yield * currentProcess.setExitCode(1); -yield * currentProcess.exit(1); -``` - -Useful extras: - -- `signals: Stream` -- `stdinClosed: Effect` -- `pid: Effect` - -## 2. No Runtime Info Service - -**Current CLI use cases** - -- `platform` -- `arch` -- `homedir` -- `execPath` -- `pid` - -We also still have stack-level use cases elsewhere for values like `hostname` and `userInfo`. - -**Why this still matters** - -These values are stable runtime facts, but today they come from `node:process` / `node:os` instead of an injectable service. - -**Suggested API** - -```ts -const runtime = yield * RuntimeInfo.RuntimeInfo; - -const platform = runtime.platform; -const arch = runtime.arch; -const homeDir = runtime.homeDir; -const execPath = runtime.execPath; -const pid = runtime.pid; -``` - -Possible expansion: - -- `hostname` -- `userInfo` -- `tmpdir` - -## 3. No TTY Metadata Service - -**Current CLI use cases** - -- `stdin.isTTY` -- `stdout.isTTY` - -**Why this still matters** - -Effect exposes stdio streams and terminal operations, but not simple injectable TTY capability metadata. CLI code often needs this before deciding whether prompts or rich output are allowed. - -**Suggested API** - -```ts -const tty = yield * Tty.Tty; - -if (tty.stdoutIsTty) { - // interactive formatter -} -``` - -## 4. No DI-Safe Current Working Directory - -**Current CLI use cases** - -- resolve the current project root -- connect the local stack to the caller's working directory - -**Why this still matters** - -`cwd` is runtime state that affects behavior and tests. We can wrap it locally, but Effect does not currently expose a first-class service for it. Relying on implicit `process.cwd()` keeps project resolution outside the platform abstraction. - -**Suggested API** - -```ts -const workingDirectory = yield * WorkingDirectory.WorkingDirectory; -const cwd = workingDirectory.current; -``` - -## 5. ChildProcess IPC Is Still Missing - -**Current stack use case** - -We still have daemon-style parent/child coordination that needs a structured message channel, not just stdio pipes. - -**What Effect provides today** - -`ChildProcessSpawner` and `ChildProcess.make` with normal stdio and FD support. - -**What is still missing** - -- `fork()`-style IPC channel -- message send/receive primitives -- ergonomic `unref()` support for daemon lifecycles - -## 6. No Crypto Primitives Service - -**Current stack use cases** - -- SHA-256 checksums -- HMAC signing -- token decryption - -**Why this still matters** - -Effect has randomness and encodings, but not a platform crypto service for hashing, MACs, or encryption/decryption. - -## 7. No Sync URL-to-Path Utility for Module-Level Use - -**Current stack use case** - -Module-level resolution of daemon entrypoints from `import.meta.url`. - -**Why this still matters** - -`Path.fromFileUrl` is effectful, which is appropriate in-context, but some entrypoint constants need a synchronous conversion at module scope. diff --git a/packages/stack/docs/resource-leak-mitigations.md b/packages/stack/docs/resource-leak-mitigations.md deleted file mode 100644 index 452a9dac76..0000000000 --- a/packages/stack/docs/resource-leak-mitigations.md +++ /dev/null @@ -1,111 +0,0 @@ -# Resource leak mitigations - -The local stack must release processes, Docker containers, port leases, data -directories, and managed runtime files when startup fails, a foreground command -is interrupted, or a detached owner is stopped or crashes. - -## Shared disposal - -`Stack.dispose()` uses the shared cleanup path in `src/cleanup.ts`: - -- run `stack.stop()` uninterruptibly; -- force-remove the exact Docker container names derived from the managed stack - id; and -- retry removal of auto-managed Postgres directories. - -Foreground and detached runtimes use this path. Managed lifecycle state is -recorded by `ManagedStackManager` in the single stack document; cleanup does not -write a second StateManager metadata file. - -## Detached owner cleanup - -The managed supervisor owns the port lease, service processes, and one complete -loopback HTTP application. It records `starting`, `running`, `failed`, and -`stopped` in `stack.json`. `SupervisorSession` owns one command queue and actor -fiber; stop requests from HTTP, signals, startup failure, and explicit disposal -all join that serialized state machine. - -The application is assembled before the deterministic listener binds and has -only three routes: - -- `GET /owner` projects either a supervisor lifecycle/version or a maintenance - operation; -- `POST /stop` accepts an ownership id, exact owner session id, and explicit or - replacement intent, returns a flushed `202` for a supervisor and `423` for a - maintenance lease, and lets the caller wait for that session to end; and -- `POST /rpc` serves same-version Effect RPC over framed NDJSON when - `SupervisorSession.runtimeStack` has published the runtime. Requests carry - the expected ownership id and owner session; a stale session fence is - rejected before a handler runs. Before runtime publication, handlers - fail fast with typed `StackUnavailableError`. - -Graceful remote stop therefore uses the stable session-fenced control route, -waits for the targeted owner session and document transition, then lets the -owner dispose the runtime before releasing control. A stale delayed stop gets -`409` from the new owner and cannot tear it down. - -Every shutdown source submits to one session actor. The first accepted intent -wins. Once accepted, the actor -publishes `stopping`, interrupts and joins startup, attempts runtime stop and -disposal, closes the runtime scope, persists terminal state, and closes the -ownership listener last, even when an earlier step fails. The same idempotent -cleanup transaction is also registered as the session-scope finalizer, so scope -closure remains a liveness backstop if the actor fiber exits unexpectedly. -Explicit stop succeeds after teardown and logs every non-interruption cleanup -failure; startup and runtime failures retain their original `Cause` after the -same teardown completes. Active RPC streams observe the actor's stop-accepted -gate and finish before the listener closes. Node and Bun close listeners -gracefully after flushing the -accepted `202`; the stable client drains that response and then polls the exact -session fence, so listener shutdown cannot be stranded by an unread body. -Explicit stop intent is persisted before listener release; replacement intent -keeps the stopped document eligible for the authorized upgrade start. - -If the owner is gone, the next lifecycle operation acquires control for the -stack id, force-removes deterministic Docker containers, reconciles persisted -assignments, and records `stopped`. It does not probe PIDs or trust stale -runtime artifacts. The control endpoint is deterministic from the stack id and -is validated before an attached client connects. - -The endpoint is runtime coordination state, not the public API proxy URL. Node, -Bun, and compiled-Bun children use the same static application and lifecycle -composition; the same server owns every lifecycle phase. - -## Process supervision - -`@supabase/process-compose` service definitions use declarative supervision: - -- a supervisor owns the child process tree; -- stdout and stderr are forwarded to the orchestrator; -- abrupt parent death closes the supervisor's owner pipe; and -- platform-specific termination and cleanup actions tear down descendants. - -Docker-backed service definitions retain a normal cleanup hook (`docker rm -f`) -as a second line of defense after signal-based shutdown. Structured exec probes -use explicit commands and arguments rather than shell strings. - -## Foreground and signals - -Foreground start installs a command-level `SIGINT`/`SIGTERM` cleanup effect and -waits for disposal to begin before interrupting the main Effect. Direct -`createStack()` callers receive the same finalizer through the scoped handle. - -## Regression coverage - -Integration tests cover manager port/document cleanup, detached supervisor -startup/reattach/launch-update over RPC, stop during every startup phase, -session-fenced stop, upgrade restart with actual -excluded-service and sticky-port preservation, cancellation, failed-step -cleanup, and delete. The process-compose and stack suites cover supervised -child trees, Docker cleanup hooks, one-shot exit observation, and -Node/Bun/compiled-Bun re-entry. Node and Bun control adapters exercise the -same conflict classification. Leak helpers compare managed document and -runtime roots, temporary Postgres paths, processes, and containers before and -after each journey. - -## Platform notes - -Unix and Linux use process-group supervision where available; Windows uses the -platform tree-termination backend. Docker services and structured probes do not -depend on Bash wrappers. The control transport is provided by the Node/Bun -platform adapter, while managed identity and cleanup remain platform-neutral. diff --git a/packages/stack/docs/service-versioning.md b/packages/stack/docs/service-versioning.md deleted file mode 100644 index bb53509b63..0000000000 --- a/packages/stack/docs/service-versioning.md +++ /dev/null @@ -1,181 +0,0 @@ -# Service Versioning in the Supabase CLI - -The TypeScript CLI resolves service versions from a small set of explicit inputs and records the -launch selection in the managed stack document. Runtime ownership and liveness are separate from -version resolution: a stack is live only while its managed control endpoint has an owner. - -## Resolution flow - -```text -DEFAULT_VERSIONS - | - v -linked project cache + checkout-local overrides - | - v -managed launch metadata (versions + exclusions) - | - v -supervisor resolves and starts the stack -``` - -The important separation is: - -- `project.json` caches linked remote service versions for the current checkout. -- `local-versions.json` contains optional checkout-local overrides. -- `--service-version service=version` applies a one-run override. -- `launch` in the managed document is the persisted baseline and records exclusions and update - notification state for one managed stack. - -The managed document is stored under the global CLI home: - -```text -/managed/stacks//stack.json -``` - -It contains the stack identity, assigned ports and intents, lifecycle, runtime control endpoint, -and launch metadata. There is no second state or metadata file. Start, status, logs, update, -services, and stop all go through the managed lifecycle facade. The stable cross-build control -protocol is `GET /owner` plus session-fenced `POST /stop`; runtime operations use same-version -Effect RPC over HTTP/NDJSON at `POST /rpc`. A running document without an owned control endpoint -is stale and can be reclaimed by the next lifecycle operation. `/owner` distinguishes versioned -supervisor ownership from unversioned maintenance ownership, and `/stop` distinguishes an explicit -user stop from an authorized upgrade replacement. - -### Stable control protocol evolution - -Once a control wire shape has shipped, any incompatible change to `/owner` or `/stop` requires a -`CONTROL_PROTOCOL_VERSION` bump. Same-build `/rpc` compatibility is identified by the immutable -CLI version and is not a substitute for versioning the stable control endpoints. - -## Built-in defaults and remote versions - -`ServiceCatalog.ts` defines `DEFAULT_VERSIONS` for the services in a CLI release. These defaults -are used for unlinked projects, services without a remote probe, and new stacks before a user -selects a different version. - -When a project is linked, the CLI refreshes `.supabase/project.json` with the service versions it -can probe. The currently supported remote probes are: - -| Service | Source | -| ----------- | --------------------------------------- | -| `postgres` | Management API project database version | -| `postgrest` | Tenant REST probe | -| `auth` | Tenant health probe | -| `storage` | Tenant storage version probe | - -Other local services remain on the catalog defaults unless a launch override is provided. The -artifact provider (Docker or a supported native release) is selected by the service catalog and is -independent of stack lifecycle and control ownership. - -## Checkout-local inputs - -The linked cache remains checkout-local and gitignored: - -```text -/.supabase/project.json -/.supabase/local-versions.json -``` - -`project.json` is refreshed by `supabase link` and by `supabase stack update`. The optional -`local-versions.json` file overrides the candidate baseline for that checkout. These files do not -replace managed launch metadata and do not describe whether a stack is running. - -For ports, raw values and their origins are read from `supabase/config.toml`. Omitted sticky ports -remain automatic; explicitly configured values are persisted as exact intents in each managed -document. Sibling worktrees have independent stack identities: an explicit request asks for that -exact port and conflicts with a live sibling using it, while automatic allocations are selected -independently. Runtime-only service ports are allocated by the managed supervisor for that run and -are not written to `stack.json`. - -## Commands - -### `supabase start` - -Start resolves the candidate versions, applies local and command-line overrides, and records the -resulting launch selection in the managed document. Starting an existing stack reuses its persisted -launch baseline unless an explicit update or override changes it. Port intent is read from the raw -project config before defaults are applied so automatic and exact values remain distinguishable. -After startup, the managed summary is authoritative for launch updates: the caller must not -overwrite persisted mode, pinned versions, exclusions, or sticky port assignments with defaults from -the new CLI build. - -### `supabase stack status` - -Status reads the managed document and probes `/owner` before reporting a running stack. When the -owner CLI version matches, it may use the runtime RPC projection for detailed service state. A mismatched -owner is reported as a degraded owner/document summary with an instruction to run `supabase start`; -status never restarts a live stack and does not attempt runtime RPC against the mismatched version. It -compares the persisted launch baseline with the current candidate versions and reports when -`supabase stack update` can adopt newer linked or default versions. - -### `supabase stack update` - -Update refreshes the linked cache when the project is linked, computes the candidate baseline, and -updates `launch.versions` through the same-version `UpdateLaunch` RPC when the stack is running. A -stopped stack is updated directly through the manager. It does not maintain a project-level copy of -pinned versions and does not restart the runtime. If the owner CLI version differs, update fails with an -upgrade-required diagnostic rather than restarting the stack. - -### `supabase stop` - -Stop asks the managed control owner to stop the runtime, waits for the document to record -`stopped`, and waits for control ownership to be released. Normal stop keeps the managed document -for the next start. `--no-backup` then removes the document and its managed runtime artifacts after -deterministic cleanup. - -## User stories - -### Fresh start - -An unlinked project with no overrides starts from `DEFAULT_VERSIONS` and persists that launch -selection in its managed document. - -### Linked project - -`supabase link` and `supabase stack update` refresh the checkout-local linked cache. Existing managed -stacks keep their persisted launch baseline until the user explicitly runs update. - -### Checkout-local experimentation - -Values in `.supabase/local-versions.json` override the candidate baseline for that checkout. A -`--service-version` flag has the highest precedence for that invocation only. - -### CLI upgrades - -New stacks can adopt newer catalog defaults immediately. Existing stacks remain pinned until update -changes their managed launch metadata. When `supabase start` encounters an incompatible live owner, -it performs an explicit stop/start upgrade restart after preflight. The restart is authorized only by that -explicit operation: it preflights while the old owner is live, stops the exact captured session through -the stable `ControlClient` with replacement intent, waits for that session to release ownership, and -launches an ordinary child. A concurrent explicit stop wins and prevents that child from restarting the -stack. -Persisted exclusions are reapplied to effective runtime -service policies before preflight, active-port calculation, allocation, configuration resolution, and -startup—not merely copied into `stack.json`. The upgrade restart preserves durable stack identity and -creation metadata, data roots, runtime mode and container runtime, pinned service versions, -exclusions, and sticky port assignments. It never invokes destructive deletion. Connect-only commands -never restart the stack; they report the upgrade requirement instead. - -### Team collaboration - -Linked caches and local overrides are checkout-local and gitignored. Sibling worktrees have separate -managed documents and control identities, while the global managed home provides deterministic -lookup and conflict checks without a project-local second state format. - -## Service inventory - -The local catalog currently represents: - -- `postgres`, `postgrest`, `auth`, `realtime`, `storage` -- `imgproxy`, `mailpit`, `pgmeta`, `studio`, `analytics`, `vector`, `pooler` - -Only the services with remote probes listed above are copied into `project.json`; all services can -still be selected through launch metadata when a supported artifact is available. - -## Future work - -The main missing hosted-version improvement is a Management API route that exposes all service -versions directly. Adding that route should only change the linked-cache adapter and candidate -resolution; managed lifecycle, control ownership, and the single-document launch model remain the -same. diff --git a/packages/stack/package.json b/packages/stack/package.json index ef7152183c..995faf2f70 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -4,46 +4,25 @@ "private": true, "type": "module", "exports": { - ".": { - "bun": "./src/bun.ts", - "default": "./src/node.ts" - }, - "./effect": { - "bun": "./src/effect-bun.ts", - "default": "./src/effect-node.ts" - }, - "./managed": { - "bun": "./src/managed-bun.ts", - "default": "./src/managed-node.ts" - }, - "./managed-model": "./src/managed/model.ts", - "./testing": "./src/testing.ts", - "./daemon-bun": "./src/daemon-bun.ts" + ".": "./src/index.ts", + "./effect": "./src/effect.ts", + "./testing": "./src/testing.ts" }, "scripts": { - "test": "pnpm run test:unit && pnpm run test:integration && pnpm run test:e2e", + "test": "pnpm run test:unit && pnpm run test:integration", "test:unit": "pnpm exec turbo run @supabase/stack#test:unit:run --", - "test:unit:run": "bun --bun vitest run --project unit --coverage.reportsDirectory=coverage/unit", + "test:unit:run": "bun --bun vitest run --project unit", "test:integration": "pnpm exec turbo run @supabase/stack#test:integration:run --", - "test:integration:run": "bun --bun vitest run --project integration --coverage.reportsDirectory=coverage/integration", - "test:e2e": "pnpm exec turbo run @supabase/stack#test:e2e:run --", - "test:e2e:run": "bun --bun vitest run --project e2e", - "test:e2e:warmup": "bun run tests/warmup-e2e.ts", - "sync:versions": "bun run scripts/sync-versions-from-dockerfile.ts", + "test:integration:run": "bun --bun vitest run --project integration", "types:check": "tsc --noEmit" }, "dependencies": { - "@effect/platform-bun": "catalog:", - "@effect/platform-node": "catalog:", - "@supabase/process-compose": "workspace:*", "effect": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", - "@supabase/supabase-js": "^2.112.3", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@vitest/coverage-istanbul": "catalog:", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/stack/scripts/migrate-fast.sh b/packages/stack/scripts/migrate-fast.sh deleted file mode 100755 index b698f6aa32..0000000000 --- a/packages/stack/scripts/migrate-fast.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/bin/sh -set -eu - -####################################### -# Fast replacement for migrate.sh — chains multiple -f flags per psql -# invocation so all SQL files run in a single connection (~2 sessions -# instead of ~57 separate psql processes). -# -# Drop-in replacement: same env vars, same directory layout, same behavior. -# -# Env vars: -# POSTGRES_DB defaults to postgres -# POSTGRES_HOST defaults to localhost -# POSTGRES_PORT defaults to 5432 -# POSTGRES_PASSWORD defaults to "" -# USE_DBMATE defaults to "" -# Exit code: -# 0 if migration succeeds, non-zero on error. -####################################### - -export PGDATABASE="${POSTGRES_DB:-postgres}" -export PGHOST="${POSTGRES_HOST:-localhost}" -export PGPORT="${POSTGRES_PORT:-5432}" -export PGPASSWORD="${POSTGRES_PASSWORD:-}" - -PSQL_OPTS="-v ON_ERROR_STOP=1 --no-password --no-psqlrc" - -# if args are supplied, simply forward to dbmate -connect="$PGPASSWORD@$PGHOST:$PGPORT/$PGDATABASE?sslmode=disable" -if [ "$#" -ne 0 ]; then - export DATABASE_URL="${DATABASE_URL:-postgres://supabase_admin:$connect}" - exec dbmate "$@" - exit 0 -fi - -db=$( cd -- "$( dirname -- "$0" )" > /dev/null 2>&1 && pwd ) -if [ -z "${USE_DBMATE:-}" ]; then - # Create postgres role if missing (as supabase_admin) - psql $PSQL_OPTS -U supabase_admin <<'EOSQL' -do $$ -begin - if not exists (select from pg_roles where rolname = 'postgres') then - create role postgres superuser login password 'postgres'; - alter database postgres owner to postgres; - end if; -end $$ -EOSQL - - # Build -f flags for init-scripts, then run in a single psql session (as postgres) - init_flags="" - for sql in "$db"/init-scripts/*.sql; do - [ -f "$sql" ] && init_flags="$init_flags -f $sql" - done - if [ -n "$init_flags" ]; then - echo "$0: running init-scripts (batched)" - psql $PSQL_OPTS -U postgres $init_flags - fi - - psql $PSQL_OPTS -U postgres -c "ALTER USER supabase_admin WITH PASSWORD '$PGPASSWORD'" - - # Build -f flags for migrations, then run in a single psql session (as supabase_admin) - migrate_flags="" - for sql in "$db"/migrations/*.sql; do - [ -f "$sql" ] && migrate_flags="$migrate_flags -f $sql" - done - if [ -n "$migrate_flags" ]; then - echo "$0: running migrations (batched)" - psql $PSQL_OPTS -U supabase_admin $migrate_flags - fi -else - psql $PSQL_OPTS -U supabase_admin <([ - ["pg", "postgres"], - ["postgrest", "postgrest"], - ["gotrue", "auth"], - ["edgeruntime", "edge-runtime"], - ["realtime", "realtime"], - ["storage", "storage"], - ["imgproxy", "imgproxy"], - ["mailpit", "mailpit"], - ["pgmeta", "pgmeta"], - ["studio", "studio"], - ["logflare", "analytics"], - ["vector", "vector"], - ["supavisor", "pooler"], -]); - -const ignoredAliases = new Set(["kong", "differ", "migra", "pgprove"]); - -function assertFullManifest( - versions: Partial>, -): asserts versions is VersionManifest { - const missing = SERVICE_NAMES.filter((service) => versions[service] === undefined); - if (missing.length > 0) { - throw new Error(`Missing Dockerfile versions for: ${missing.join(", ")}`); - } -} - -export function readVersionManifestFromDockerfile(dockerfile: string): VersionManifest { - const versions: Partial> = {}; - - for (const rawLine of dockerfile.split("\n")) { - const line = rawLine.trim(); - const match = fromLinePattern.exec(line); - if (match === null) { - continue; - } - - const [, , tag, alias] = match; - if (tag === undefined || alias === undefined) { - continue; - } - - if (ignoredAliases.has(alias)) { - continue; - } - - const service = dockerfileAliases.get(alias); - if (service === undefined) { - throw new Error(`Unknown Dockerfile image alias '${alias}'.`); - } - if (versions[service] !== undefined) { - throw new Error(`Duplicate Dockerfile version for '${service}'.`); - } - - versions[service] = normalizeServiceVersion(service, tag); - } - - assertFullManifest(versions); - return versions; -} - -export function syncDefaultVersionsSource(source: string, versions: VersionManifest): string { - let updated = source; - for (const service of SERVICE_NAMES) { - const nameMarker = ` name: ${JSON.stringify(service)},`; - const entryStart = updated.indexOf(nameMarker); - if (entryStart === -1) { - throw new Error(`Could not find catalog entry for '${service}'.`); - } - - const versionMarker = " defaultVersion: "; - const versionStart = updated.indexOf(versionMarker, entryStart + nameMarker.length); - if (versionStart === -1 || versionStart - entryStart > 300) { - throw new Error(`Could not find defaultVersion for '${service}'.`); - } - const versionEnd = updated.indexOf("\n", versionStart); - if (versionEnd === -1) { - throw new Error(`Could not find defaultVersion line end for '${service}'.`); - } - - updated = `${updated.slice(0, versionStart)}${versionMarker}${JSON.stringify( - versions[service], - )},${updated.slice(versionEnd)}`; - } - return updated; -} - -async function main() { - const checkOnly = process.argv.includes("--check"); - const dockerfile = await readFile(dockerfilePath, "utf8"); - const catalogSource = await readFile(catalogPath, "utf8"); - const versions = readVersionManifestFromDockerfile(dockerfile); - const syncedSource = syncDefaultVersionsSource(catalogSource, versions); - - if (syncedSource === catalogSource) { - console.log("DEFAULT_VERSIONS is already synced with the Dockerfile manifest."); - return; - } - - if (checkOnly) { - console.error("DEFAULT_VERSIONS is out of sync with the Dockerfile manifest."); - process.exitCode = 1; - return; - } - - await Bun.write(catalogPath, syncedSource); - console.log("Synced DEFAULT_VERSIONS with the Dockerfile manifest."); -} - -if (import.meta.main) { - await main(); -} diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts deleted file mode 100644 index 08e49149cc..0000000000 --- a/packages/stack/src/ApiProxy.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { Deferred, Effect, Layer, Option, Context, Predicate, Schedule, Result } from "effect"; -import { - Headers, - HttpBody, - HttpClient, - HttpClientRequest, - HttpRouter, - HttpServer, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http"; -import { StackServiceActivator } from "./ServiceActivation.ts"; -import type { ServiceName } from "./ServiceName.ts"; - -export interface ProxyConfig { - readonly listenPort: number; - readonly gotruePort: number; - readonly postgrestPort: number; - readonly postgrestAdminPort: number; - readonly edgeRuntimePort: number; - readonly realtimePort: number; - readonly storagePort: number; - readonly pgmetaPort: number; - readonly analyticsPort: number; - readonly poolerPort: number; - readonly studioPort: number; - readonly publishableKey: string; - readonly secretKey: string; - readonly anonJwt: string; - readonly serviceRoleJwt: string; -} - -function transformAuthorization( - headers: Headers.Headers, - config: ProxyConfig, - useCustomHeader = false, -): Headers.Headers { - const auth = headers["authorization"]; - const apikey = headers["apikey"]; - - const transformHeaderName = useCustomHeader ? "sb-api-key" : "authorization"; - const transformPrefix = useCustomHeader ? "" : "Bearer "; - - if (auth !== undefined && !auth.startsWith("Bearer sb_")) { - return headers; - } - - if (apikey === config.publishableKey) { - return Headers.set(headers, transformHeaderName, transformPrefix + config.anonJwt); - } - if (apikey === config.secretKey) { - return Headers.set(headers, transformHeaderName, transformPrefix + config.serviceRoleJwt); - } - if (apikey !== undefined && apikey !== "") { - return Headers.set(headers, transformHeaderName, apikey); - } - - return headers; -} - -function addProxyHeaders( - headers: Headers.Headers, - remoteAddress: string | undefined, -): Headers.Headers { - const clientIp = remoteAddress ?? "127.0.0.1"; - const prior = headers["x-forwarded-for"]; - const xForwardedFor = prior !== undefined ? `${prior}, ${clientIp}` : clientIp; - - return Headers.set( - Headers.set(Headers.set(headers, "x-real-ip", clientIp), "x-forwarded-for", xForwardedFor), - "x-forwarded-proto", - "http", - ); -} - -const STRIP_PROXY_RESPONSE_HEADERS = new Set([ - "content-encoding", - "content-length", - "date", - "transfer-encoding", -]); - -function sanitizeProxyResponseHeaders(headers: Headers.Headers): Headers.Headers { - return Headers.fromInput( - Object.fromEntries( - Object.entries(headers).filter( - ([name]) => !STRIP_PROXY_RESPONSE_HEADERS.has(name.toLowerCase()), - ), - ), - ); -} - -const CORS_HEADERS: ReadonlyArray = [ - ["access-control-allow-origin", "*"], - ["access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"], - ["access-control-allow-headers", "Authorization, Content-Type, apikey, X-Client-Info"], - ["access-control-expose-headers", "Content-Range, Range, sb-error-code"], - ["access-control-max-age", "86400"], -]; - -function addCorsHeaders( - response: HttpServerResponse.HttpServerResponse, -): HttpServerResponse.HttpServerResponse { - return CORS_HEADERS.reduce( - (res, [name, value]) => HttpServerResponse.setHeader(res, name, value), - response, - ); -} - -// Edge Functions cold-boot lazily: the first request to a function makes the -// edge-runtime spin up a user worker, and it can drop the connection while it -// does so. Its `/_internal/health` probe answers immediately, so "Healthy" -// status does not mean a function is servable yet. Briefly retry transport -// failures on that route so a user's first call doesn't surface as a 502. -const COLD_START_RETRY_SCHEDULE = Schedule.spaced("250 millis").pipe(Schedule.upTo({ times: 8 })); -interface ProxyHandlerOptions { - readonly service: ServiceName; - readonly backendPort: number; - readonly stripPrefix?: string; - readonly backendPath?: string; - readonly transformAuth?: boolean; - readonly transformAuthCustomHeader?: boolean; - readonly extraHeaders?: Record; - // Retry transient transport failures, for backends (edge-runtime) that may - // refuse/reset connections while cold-starting. Buffers the request body so - // it can be re-sent across attempts. - readonly retryColdStart?: boolean; -} - -function makeProxyHandler( - client: HttpClient.HttpClient, - config: ProxyConfig, - activator: StackServiceActivator["Service"], - signalTerminalFailure: Effect.Effect, - opts: ProxyHandlerOptions, -) { - return (req: HttpServerRequest.HttpServerRequest) => - Effect.gen(function* () { - const activation = yield* activator.activate(opts.service).pipe( - Effect.tapErrorTag("StackReadinessError", () => signalTerminalFailure), - Effect.result, - ); - if (Result.isFailure(activation)) { - return HttpServerResponse.text("Service unavailable", { - status: 503, - headers: { "retry-after": "1" }, - }); - } - - let backendPath = opts.backendPath; - - if (backendPath === undefined) { - backendPath = req.url.startsWith(opts.stripPrefix ?? "") - ? req.url.slice((opts.stripPrefix ?? "").length) - : req.url; - if (backendPath === "") { - backendPath = "/"; - } - } - - let outHeaders = req.headers; - if (opts.transformAuth === true) { - outHeaders = transformAuthorization(outHeaders, config, opts.transformAuthCustomHeader); - } - outHeaders = addProxyHeaders(outHeaders, Option.getOrUndefined(req.remoteAddress)); - - for (const [name, value] of Object.entries(opts.extraHeaders ?? {})) { - outHeaders = Headers.set(outHeaders, name, value); - } - - const backendUrl = `http://127.0.0.1:${opts.backendPort}${backendPath}`; - const noBodyMethods = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); - const contentType = Option.getOrUndefined(Headers.get(req.headers, "content-type")); - - let body: HttpBody.HttpBody; - if (noBodyMethods.has(req.method)) { - body = HttpBody.empty; - } else if (opts.retryColdStart === true) { - // Buffer the body so the request can be safely re-sent if we retry. - const buffered = yield* Effect.result(req.arrayBuffer); - if (Result.isFailure(buffered)) { - return HttpServerResponse.text("Bad gateway: unable to read request body", { - status: 502, - }); - } - body = HttpBody.uint8Array(new Uint8Array(buffered.success), contentType); - } else { - body = HttpBody.stream(req.stream, contentType); - } - - const outReq = HttpClientRequest.make(req.method)(backendUrl, { - headers: outHeaders, - body, - }); - - const request = client.execute(outReq); - const outRes = yield* opts.retryColdStart === true - ? Effect.retry(request, { - while: (error) => Predicate.isTagged(error.reason, "TransportError"), - schedule: COLD_START_RETRY_SCHEDULE, - }) - : request; - const responseHeaders = sanitizeProxyResponseHeaders(outRes.headers); - return HttpServerResponse.stream(outRes.stream, { - status: outRes.status, - headers: responseHeaders, - }); - }).pipe( - Effect.catchTag("HttpClientError", (error) => - Effect.succeed( - HttpServerResponse.text(`Bad gateway: ${error.message}`, { - status: 502, - }), - ), - ), - ); -} - -export class ApiProxy extends Context.Service< - ApiProxy, - { - readonly address: HttpServer.Address; - /** Completes when terminal lazy-activation failure requires daemon teardown. */ - readonly awaitTerminalFailure: Effect.Effect; - } ->()("local/ApiProxy") { - static layer = ( - config: ProxyConfig, - ): Layer.Layer< - ApiProxy, - never, - HttpServer.HttpServer | HttpClient.HttpClient | StackServiceActivator - > => - Layer.effect(ApiProxy)( - Effect.gen(function* () { - const server = yield* HttpServer.HttpServer; - const client = yield* HttpClient.HttpClient; - const activator = yield* StackServiceActivator; - const terminalFailure = yield* Deferred.make(); - const signalTerminalFailure = Deferred.succeed(terminalFailure, void 0).pipe(Effect.asVoid); - - const routes = [ - HttpRouter.route("*", "/health", HttpServerResponse.text("OK", { status: 200 })), - HttpRouter.route( - "*", - "/.well-known/oauth-authorization-server", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "auth", - backendPort: config.gotruePort, - backendPath: "/.well-known/oauth-authorization-server", - }), - ), - HttpRouter.route( - "*", - "/auth/v1/verify", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "auth", - backendPort: config.gotruePort, - stripPrefix: "/auth/v1", - }), - ), - HttpRouter.route( - "*", - "/auth/v1/callback", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "auth", - backendPort: config.gotruePort, - stripPrefix: "/auth/v1", - }), - ), - HttpRouter.route( - "*", - "/auth/v1/authorize", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "auth", - backendPort: config.gotruePort, - stripPrefix: "/auth/v1", - }), - ), - HttpRouter.route( - "*", - "/auth/v1/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "auth", - backendPort: config.gotruePort, - stripPrefix: "/auth/v1", - transformAuth: true, - }), - ), - HttpRouter.route( - "*", - "/rest/v1/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "postgrest", - backendPort: config.postgrestPort, - stripPrefix: "/rest/v1", - transformAuth: true, - }), - ), - HttpRouter.route( - "*", - "/rest-admin/v1/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "postgrest", - backendPort: config.postgrestAdminPort, - stripPrefix: "/rest-admin/v1", - }), - ), - HttpRouter.route( - "*", - "/graphql/v1", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "postgrest", - backendPort: config.postgrestPort, - backendPath: "/rpc/graphql", - transformAuth: true, - extraHeaders: { "content-profile": "graphql_public" }, - }), - ), - HttpRouter.route( - "*", - "/functions/v1/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "edge-runtime", - backendPort: config.edgeRuntimePort, - stripPrefix: "/functions/v1", - transformAuth: true, - transformAuthCustomHeader: true, - retryColdStart: true, - }), - ), - HttpRouter.route( - "*", - "/realtime/v1/api/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "realtime", - backendPort: config.realtimePort, - stripPrefix: "/realtime/v1", - transformAuth: true, - }), - ), - HttpRouter.route( - "*", - "/realtime/v1/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "realtime", - backendPort: config.realtimePort, - stripPrefix: "/realtime/v1", - }), - ), - HttpRouter.route( - "*", - "/storage/v1/s3/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "storage", - backendPort: config.storagePort, - stripPrefix: "/storage/v1", - }), - ), - HttpRouter.route( - "*", - "/storage/v1/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "storage", - backendPort: config.storagePort, - stripPrefix: "/storage/v1", - transformAuth: true, - }), - ), - HttpRouter.route( - "*", - "/pg/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "pgmeta", - backendPort: config.pgmetaPort, - stripPrefix: "/pg", - }), - ), - HttpRouter.route( - "*", - "/analytics/v1/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "analytics", - backendPort: config.analyticsPort, - stripPrefix: "/analytics/v1", - }), - ), - HttpRouter.route( - "*", - "/pooler/v2/*", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "pooler", - backendPort: config.poolerPort, - stripPrefix: "/pooler", - }), - ), - HttpRouter.route( - "*", - "/mcp", - makeProxyHandler(client, config, activator, signalTerminalFailure, { - service: "studio", - backendPort: config.studioPort, - backendPath: "/api/mcp", - }), - ), - ]; - - const httpEffect = yield* HttpRouter.toHttpEffect(HttpRouter.addAll(routes)); - - const appEffect = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest; - - if (req.method === "OPTIONS") { - return addCorsHeaders(HttpServerResponse.empty({ status: 204 })); - } - - const response = yield* httpEffect; - return addCorsHeaders(response); - }); - - yield* Effect.forkScoped(server.serve(appEffect)); - - return { - address: server.address, - awaitTerminalFailure: Deferred.await(terminalFailure), - }; - }), - ); -} diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts deleted file mode 100644 index cf031ba0cd..0000000000 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ /dev/null @@ -1,563 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Unit tests exercise the public Promise facade and native HTTP, gzip, and timestamp boundaries. -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as http from "node:http"; -import { gzipSync } from "node:zlib"; -import { Effect, Layer, ManagedRuntime, Predicate } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { ApiProxy, type ProxyConfig } from "./ApiProxy.ts"; -import { StackNotRunningError, StackReadinessError } from "./errors.ts"; -import { StackServiceActivator } from "./ServiceActivation.ts"; -import type { ServiceName } from "./versions.ts"; - -interface EchoServer { - readonly port: number; - readonly stop: () => Promise; -} - -// Echo backend — returns request details as JSON so tests can assert on what -// the proxy forwarded. -function startEchoBackend(): Promise { - return new Promise((resolve, reject) => { - const server = http.createServer((req, incomingRes) => { - const url = new URL(req.url ?? "/", `http://127.0.0.1`); - if (url.pathname === "/encoded") { - const body = gzipSync(JSON.stringify({ ok: true })); - incomingRes.writeHead(200, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - "Content-Encoding": "gzip", - Date: new Date(0).toUTCString(), - }); - incomingRes.end(body); - return; - } - - const body = JSON.stringify({ - path: url.pathname + url.search, - method: req.method, - headers: req.headers, - }); - incomingRes.writeHead(200, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }); - incomingRes.end(body); - }); - - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Unexpected server address")); - return; - } - resolve({ - port: addr.port, - stop: () => - new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), - }); - }); - - server.on("error", reject); - }); -} - -interface FlakyServer { - readonly port: number; - readonly attempts: () => number; - readonly stop: () => Promise; -} - -// Backend that resets the connection (transport failure) for the first -// `failFirst` requests, then responds 200 with `body`. Mirrors an edge-runtime -// dropping connections while it cold-boots a user worker on first request. -function startFlakyBackend(opts: { failFirst: number; body: string }): Promise { - return new Promise((resolve, reject) => { - let attempts = 0; - const server = http.createServer((req, incomingRes) => { - attempts += 1; - if (attempts <= opts.failFirst) { - req.socket.destroy(); - return; - } - incomingRes.writeHead(200, { - "Content-Type": "text/plain", - "Content-Length": Buffer.byteLength(opts.body), - }); - incomingRes.end(opts.body); - }); - - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Unexpected server address")); - return; - } - resolve({ - port: addr.port, - attempts: () => attempts, - stop: () => - new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), - }); - }); - - server.on("error", reject); - }); -} - -// Builds the full proxy layer backed by a Node HTTP server. -function buildProxyLayer( - config: ProxyConfig, - activatorLayer: Layer.Layer = StackServiceActivator.noop, -): Layer.Layer { - return ApiProxy.layer(config).pipe( - Layer.provide(NodeHttpServer.layer(() => http.createServer(), { port: 0 }).pipe(Layer.orDie)), - Layer.provide(FetchHttpClient.layer), - Layer.provide(activatorLayer), - ) as Layer.Layer; -} - -// Spins up a proxy for an ad-hoc config and returns its URL plus a disposer. -async function startProxy( - config: ProxyConfig, - activatorLayer?: Layer.Layer, -): Promise<{ - url: string; - dispose: () => Promise; - awaitTerminalFailure: () => Promise; -}> { - const proxyRuntime = ManagedRuntime.make(buildProxyLayer(config, activatorLayer)); - const proxy = await proxyRuntime.runPromise(ApiProxy); - const addr = proxy.address; - let url = ""; - if (Predicate.isTagged(addr, "TcpAddress")) { - const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; - url = `http://${host}:${addr.port}`; - } - return { - url, - dispose: () => proxyRuntime.dispose(), - awaitTerminalFailure: () => proxyRuntime.runPromise(proxy.awaitTerminalFailure), - }; -} - -describe("ApiProxy", () => { - let echoServer: EchoServer; - let proxyUrl: string; - let runtime: ManagedRuntime.ManagedRuntime; - - const PUBLISHABLE_KEY = "sb_publishable_testkey"; - const SECRET_KEY = "sb_secret_testkey"; - const ANON_JWT = "test-anon-jwt-token"; - const SERVICE_ROLE_JWT = "test-service-role-jwt-token"; - - beforeAll(async () => { - echoServer = await startEchoBackend(); - const echoPort = echoServer.port; - - const config: ProxyConfig = { - listenPort: 0, - gotruePort: echoPort, - postgrestPort: echoPort, - postgrestAdminPort: echoPort, - edgeRuntimePort: echoPort, - realtimePort: echoPort, - storagePort: echoPort, - pgmetaPort: echoPort, - analyticsPort: echoPort, - poolerPort: echoPort, - studioPort: echoPort, - publishableKey: PUBLISHABLE_KEY, - secretKey: SECRET_KEY, - anonJwt: ANON_JWT, - serviceRoleJwt: SERVICE_ROLE_JWT, - }; - - runtime = ManagedRuntime.make(buildProxyLayer(config)); - - const proxy = await runtime.runPromise(ApiProxy); - const addr = proxy.address; - if (Predicate.isTagged(addr, "TcpAddress")) { - const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname; - proxyUrl = `http://${host}:${addr.port}`; - } - }); - - afterAll(async () => { - await runtime.dispose(); - await echoServer.stop(); - }); - - // --------------------------------------------------------------------------- - // Health endpoint - // --------------------------------------------------------------------------- - - test("GET /health returns 200 OK", async () => { - const res = await fetch(`${proxyUrl}/health`); - expect(res.status).toBe(200); - expect(await res.text()).toBe("OK"); - }); - - test("POST /health returns 200 OK (any method)", async () => { - const res = await fetch(`${proxyUrl}/health`, { method: "POST" }); - expect(res.status).toBe(200); - }); - - // --------------------------------------------------------------------------- - // CORS - // --------------------------------------------------------------------------- - - test("OPTIONS returns 204 with CORS headers", async () => { - const res = await fetch(`${proxyUrl}/rest/v1/users`, { method: "OPTIONS" }); - expect(res.status).toBe(204); - expect(res.headers.get("access-control-allow-origin")).toBe("*"); - expect(res.headers.get("access-control-allow-methods")).toContain("GET"); - expect(res.headers.get("access-control-allow-headers")).toContain("apikey"); - expect(res.headers.get("access-control-expose-headers")).toContain("Content-Range"); - expect(res.headers.get("access-control-expose-headers")).toContain("sb-error-code"); - expect(res.headers.get("access-control-max-age")).toBe("86400"); - }); - - test("non-OPTIONS responses include CORS headers", async () => { - const res = await fetch(`${proxyUrl}/health`); - expect(res.headers.get("access-control-allow-origin")).toBe("*"); - }); - - test("activates the routed service before forwarding", async () => { - const activated: ServiceName[] = []; - const activatorLayer = Layer.succeed(StackServiceActivator, { - activate: (service) => - Effect.sync(() => { - activated.push(service); - }), - }); - const proxy = await startProxy(configForPort(echoServer.port), activatorLayer); - try { - const res = await fetch(`${proxy.url}/rest/v1/users`); - expect(res.status).toBe(200); - expect(activated).toEqual(["postgrest"]); - } finally { - await proxy.dispose(); - } - }); - - test("returns 503 when the stack cannot activate a service", async () => { - const activatorLayer = Layer.succeed(StackServiceActivator, { - activate: () => Effect.fail(new StackNotRunningError({ phase: "idle" })), - }); - const proxy = await startProxy(configForPort(echoServer.port), activatorLayer); - try { - const res = await fetch(`${proxy.url}/rest/v1/users`); - expect(res.status).toBe(503); - expect(res.headers.get("retry-after")).toBe("1"); - } finally { - await proxy.dispose(); - } - }); - - test("signals daemon teardown after a terminal activation failure", async () => { - const activatorLayer = Layer.succeed(StackServiceActivator, { - activate: () => - Effect.fail( - new StackReadinessError({ - target: "postgrest", - timeoutMs: 30_000, - detail: "PostgREST did not become ready", - }), - ), - }); - const proxy = await startProxy(configForPort(echoServer.port), activatorLayer); - try { - const res = await fetch(`${proxy.url}/rest/v1/users`); - expect(res.status).toBe(503); - await expect(proxy.awaitTerminalFailure()).resolves.toBeUndefined(); - } finally { - await proxy.dispose(); - } - }); - - // --------------------------------------------------------------------------- - // Auth transformation — publishableKey → anonJwt - // --------------------------------------------------------------------------- - - test("publishableKey in apikey header maps to anonJwt", async () => { - const res = await fetch(`${proxyUrl}/rest/v1/users`, { - headers: { apikey: PUBLISHABLE_KEY }, - }); - const body = (await res.json()) as { headers: Record }; - expect(body.headers["authorization"]).toBe(`Bearer ${ANON_JWT}`); - }); - - // --------------------------------------------------------------------------- - // Auth transformation — secretKey → serviceRoleJwt - // --------------------------------------------------------------------------- - - test("secretKey in apikey header maps to serviceRoleJwt", async () => { - const res = await fetch(`${proxyUrl}/rest/v1/users`, { - headers: { apikey: SECRET_KEY }, - }); - const body = (await res.json()) as { headers: Record }; - expect(body.headers["authorization"]).toBe(`Bearer ${SERVICE_ROLE_JWT}`); - }); - - // --------------------------------------------------------------------------- - // Auth transformation — real JWT is preserved - // --------------------------------------------------------------------------- - - test("real Authorization header is preserved", async () => { - const realJwt = "Bearer eyJhbGciOiJIUzI1NiJ9.test"; - const res = await fetch(`${proxyUrl}/rest/v1/users`, { - headers: { authorization: realJwt, apikey: PUBLISHABLE_KEY }, - }); - const body = (await res.json()) as { headers: Record }; - expect(body.headers["authorization"]).toBe(realJwt); - }); - - // --------------------------------------------------------------------------- - // Auth transformation — legacy Bearer sb_* is replaced by apikey mapping - // --------------------------------------------------------------------------- - - test("legacy Bearer sb_* is replaced by apikey mapping", async () => { - const res = await fetch(`${proxyUrl}/rest/v1/users`, { - headers: { - authorization: "Bearer sb_old_key", - apikey: PUBLISHABLE_KEY, - }, - }); - const body = (await res.json()) as { headers: Record }; - expect(body.headers["authorization"]).toBe(`Bearer ${ANON_JWT}`); - }); - - // --------------------------------------------------------------------------- - // Path stripping — auth routes - // --------------------------------------------------------------------------- - - test("/auth/v1/token strips prefix", async () => { - const res = await fetch(`${proxyUrl}/auth/v1/token`, { - headers: { apikey: PUBLISHABLE_KEY }, - }); - const body = (await res.json()) as { path: string }; - expect(body.path).toBe("/token"); - }); - - // --------------------------------------------------------------------------- - // Path stripping — REST routes - // --------------------------------------------------------------------------- - - test("/rest/v1/users strips prefix", async () => { - const res = await fetch(`${proxyUrl}/rest/v1/users`, { - headers: { apikey: PUBLISHABLE_KEY }, - }); - const body = (await res.json()) as { path: string }; - expect(body.path).toBe("/users"); - }); - - describe("/functions/v1/ test strips prefix and transforms auth", () => { - test("transforms to custom header", async () => { - const res = await fetch(`${proxyUrl}/functions/v1/test`, { - headers: { apikey: SECRET_KEY }, - }); - const body = (await res.json()) as { path: string; headers: Record }; - expect(body.path).toBe("/test"); - expect(body.headers["sb-api-key"]).toBe(SERVICE_ROLE_JWT); - }); - - test("transforms to custom header without replacing original auth", async () => { - const res = await fetch(`${proxyUrl}/functions/v1/test`, { - headers: { - apikey: SECRET_KEY, - authorization: `Bearer ${SECRET_KEY}`, - }, - }); - const body = (await res.json()) as { path: string; headers: Record }; - expect(body.path).toBe("/test"); - expect(body.headers["authorization"]).toBe(`Bearer ${SECRET_KEY}`); - expect(body.headers["sb-api-key"]).toBe(SERVICE_ROLE_JWT); - }); - }); - - test("strips upstream content-encoding metadata from proxied function responses", async () => { - const res = await fetch(`${proxyUrl}/functions/v1/encoded`); - expect(res.status).toBe(200); - expect(res.headers.get("content-encoding")).toBeNull(); - expect(res.headers.get("date")).not.toBe(new Date(0).toUTCString()); - expect(await res.json()).toEqual({ ok: true }); - }); - - // --------------------------------------------------------------------------- - // Auth open endpoints — no auth transformation - // --------------------------------------------------------------------------- - - test("/auth/v1/verify does not transform auth", async () => { - const res = await fetch(`${proxyUrl}/auth/v1/verify`, { - headers: { apikey: PUBLISHABLE_KEY }, - }); - const body = (await res.json()) as { headers: Record }; - // Open endpoints skip auth transformation; no Authorization header injected. - expect(body.headers["authorization"]).toBeUndefined(); - }); - - // --------------------------------------------------------------------------- - // Proxy headers - // --------------------------------------------------------------------------- - - test("adds X-Forwarded-Proto header", async () => { - const res = await fetch(`${proxyUrl}/rest/v1/users`); - const body = (await res.json()) as { headers: Record }; - expect(body.headers["x-forwarded-proto"]).toBe("http"); - }); - - // --------------------------------------------------------------------------- - // 502 Bad Gateway when backend is unreachable - // --------------------------------------------------------------------------- - - test("returns 502 when backend is unreachable", async () => { - // Keep ownership of the backend endpoint and deterministically refuse every - // connection. A released port could be reclaimed by another process. - const deadServer = await new Promise((resolve) => { - const s = http.createServer((request) => request.socket.destroy()); - s.listen(0, "127.0.0.1", () => resolve(s)); - }); - const deadAddr = deadServer.address() as { port: number }; - const deadPort = deadAddr.port; - const deadConfig: ProxyConfig = { - listenPort: 0, - gotruePort: deadPort, - postgrestPort: deadPort, - postgrestAdminPort: deadPort, - edgeRuntimePort: deadPort, - realtimePort: deadPort, - storagePort: deadPort, - pgmetaPort: deadPort, - analyticsPort: deadPort, - poolerPort: deadPort, - studioPort: deadPort, - publishableKey: PUBLISHABLE_KEY, - secretKey: SECRET_KEY, - anonJwt: ANON_JWT, - serviceRoleJwt: SERVICE_ROLE_JWT, - }; - - const deadRuntime = ManagedRuntime.make(buildProxyLayer(deadConfig)); - try { - const deadProxy = await deadRuntime.runPromise(ApiProxy); - const deadAddr2 = deadProxy.address; - let deadProxyUrl = ""; - if (Predicate.isTagged(deadAddr2, "TcpAddress")) { - const host = deadAddr2.hostname === "0.0.0.0" ? "127.0.0.1" : deadAddr2.hostname; - deadProxyUrl = `http://${host}:${deadAddr2.port}`; - } - - const res = await fetch(`${deadProxyUrl}/rest/v1/users`); - expect(res.status).toBe(502); - } finally { - await deadRuntime.dispose(); - await new Promise((resolve, reject) => - deadServer.close((error) => (error === undefined ? resolve() : reject(error))), - ); - } - }); - - // --------------------------------------------------------------------------- - // Edge-function cold-start: retry transient connection failures - // --------------------------------------------------------------------------- - - function configForPort(port: number): ProxyConfig { - return { - listenPort: 0, - gotruePort: port, - postgrestPort: port, - postgrestAdminPort: port, - edgeRuntimePort: port, - realtimePort: port, - storagePort: port, - pgmetaPort: port, - analyticsPort: port, - poolerPort: port, - studioPort: port, - publishableKey: PUBLISHABLE_KEY, - secretKey: SECRET_KEY, - anonJwt: ANON_JWT, - serviceRoleJwt: SERVICE_ROLE_JWT, - }; - } - - test("retries transient connection failures on the functions route until it is servable", async () => { - const backend = await startFlakyBackend({ failFirst: 1, body: "hello" }); - const proxy = await startProxy(configForPort(backend.port)); - try { - const res = await fetch(`${proxy.url}/functions/v1/hello`); - expect(res.status).toBe(200); - expect(await res.text()).toBe("hello"); - expect(backend.attempts()).toBeGreaterThanOrEqual(2); - } finally { - await proxy.dispose(); - await backend.stop(); - } - }); - - test("does not retry non-functions routes on a connection failure", async () => { - const backend = await startFlakyBackend({ failFirst: 1, body: "ok" }); - const proxy = await startProxy(configForPort(backend.port)); - try { - const res = await fetch(`${proxy.url}/rest/v1/users`); - expect(res.status).toBe(502); - expect(backend.attempts()).toBe(1); - } finally { - await proxy.dispose(); - await backend.stop(); - } - }); - - test("replays the request body when retrying the functions route", async () => { - let attempts = 0; - // First request: reset the connection. Second: echo the received body back, - // so the assertion fails unless the buffered body was re-sent on retry. - const echoBody = await new Promise((resolve, reject) => { - const server = http.createServer((req, incomingRes) => { - attempts += 1; - if (attempts === 1) { - req.socket.destroy(); - return; - } - let data = ""; - req.on("data", (chunk) => (data += chunk)); - req.on("end", () => { - incomingRes.writeHead(200, { - "Content-Type": "text/plain", - "Content-Length": Buffer.byteLength(data), - }); - incomingRes.end(data); - }); - }); - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Unexpected server address")); - return; - } - resolve({ - port: addr.port, - attempts: () => attempts, - stop: () => - new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), - }); - }); - server.on("error", reject); - }); - - const proxy = await startProxy(configForPort(echoBody.port)); - try { - const res = await fetch(`${proxy.url}/functions/v1/hello`, { - method: "POST", - body: "payload", - }); - expect(res.status).toBe(200); - expect(await res.text()).toBe("payload"); - expect(echoBody.attempts()).toBeGreaterThanOrEqual(2); - } finally { - await proxy.dispose(); - await echoBody.stop(); - } - }); -}); diff --git a/packages/stack/src/BinaryResolver.integration.test.ts b/packages/stack/src/BinaryResolver.integration.test.ts deleted file mode 100644 index 357faaca01..0000000000 --- a/packages/stack/src/BinaryResolver.integration.test.ts +++ /dev/null @@ -1,844 +0,0 @@ -// oxlint-disable effecttsgo/global-date-in-effect, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Integration tests inspect native binary metadata, timestamps, and JSON manifests at the filesystem boundary. - -import { createHash } from "node:crypto"; -import { execFileSync } from "node:child_process"; -import { zstdCompressSync } from "node:zlib"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - utimesSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, FileSystem, Layer, Predicate } from "effect"; -import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { BinaryResolver } from "./BinaryResolver.ts"; -import { - BinaryHostCompatibilityError, - BinaryManifestError, - BinaryRuntimeError, - ChecksumMismatchError, - DownloadError, -} from "./errors.ts"; -import { detectPlatform } from "./Platform.ts"; -import { nativeReleaseForService } from "./ServiceCatalog.ts"; -import { DEFAULT_VERSIONS } from "./versions.ts"; - -const makeRoot = (): string => mkdtempSync(join(tmpdir(), "stack-slim-services-")); - -const makeFixture = ( - root: string, - manifestOverride: Record = {}, - includePostgrest = true, - paddingBytes = 0, -) => { - const source = join(root, "source"); - const tar = join(root, "postgrest.tar"); - const archive = join(root, "postgrest.tar.zst"); - rmSync(source, { recursive: true, force: true }); - mkdirSync(join(source, "bin"), { recursive: true }); - if (includePostgrest) { - writeFileSync(join(source, "bin", "postgrest"), "#!/bin/sh\necho postgrest\n"); - } - if (paddingBytes > 0) { - writeFileSync(join(source, "bin", "padding"), Buffer.alloc(paddingBytes)); - } - execFileSync("tar", ["-cf", tar, "-C", source, "."]); - writeFileSync(archive, zstdCompressSync(readFileSync(tar))); - return { archive: readFileSync(archive), manifestOverride }; -}; - -const writeTarOctal = (header: Buffer, offset: number, length: number, value: number): void => { - const encoded = `${value.toString(8).padStart(length - 1, "0")}\0`; - header.write(encoded, offset, length, "ascii"); -}; - -const makeTarArchive = (member: string, contents: string): Buffer => { - const payload = Buffer.from(contents); - const header = Buffer.alloc(512); - header.write(member, 0, 100, "utf8"); - writeTarOctal(header, 100, 8, 0o644); - writeTarOctal(header, 108, 8, 0); - writeTarOctal(header, 116, 8, 0); - writeTarOctal(header, 124, 12, payload.length); - writeTarOctal(header, 136, 12, 0); - header[156] = "0".charCodeAt(0); - header.write("ustar\0", 257, 6, "ascii"); - header.write("00", 263, 2, "ascii"); - header.fill(0x20, 148, 156); - const checksum = header.reduce((sum, byte) => sum + byte, 0); - header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); - const padding = Buffer.alloc((512 - (payload.length % 512)) % 512); - return Buffer.concat([header, payload, padding, Buffer.alloc(1024)]); -}; - -const makeTraversalFixture = (root: string) => { - const archive = join(root, "postgrest-traversal.tar.zst"); - writeFileSync(join(root, "outside.txt"), "must not extract\n"); - writeFileSync(archive, zstdCompressSync(makeTarArchive("../outside.txt", "must not extract\n"))); - return { archive: readFileSync(archive), manifestOverride: {} }; -}; - -const makeEscapingSymlinkFixture = (root: string) => { - const source = join(root, "symlink-source"); - const tar = join(root, "postgrest-symlink.tar"); - const archive = join(root, "postgrest-symlink.tar.zst"); - rmSync(source, { recursive: true, force: true }); - mkdirSync(join(source, "bin"), { recursive: true }); - symlinkSync("/bin/sh", join(source, "bin/postgrest")); - execFileSync("tar", ["-cf", tar, "-C", source, "."]); - writeFileSync(archive, zstdCompressSync(readFileSync(tar))); - return { archive: readFileSync(archive), manifestOverride: {} }; -}; - -const makeResolverLayer = ( - cacheRoot: string, - fixture: ReturnType, - options: { - readonly checksum?: string; - readonly checksumText?: string; - readonly spawnedCommands?: Array<{ command: string; args: ReadonlyArray }>; - readonly onChecksumRead?: () => void; - readonly exitCodeForCommand?: (command: string) => number | undefined; - readonly transformFileSystem?: (fileSystem: FileSystem.FileSystem) => FileSystem.FileSystem; - } = {}, -) => { - const client = HttpClient.make((request) => - Effect.sync(() => { - if (request.url.endsWith(".manifest.json")) { - return HttpClientResponse.fromWeb( - request, - new Response( - JSON.stringify({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - target: process.platform === "darwin" ? "darwin-arm64" : "linux-amd64", - entrypoint: [], - cmd: ["/bin/postgrest"], - runtime_requires: null, - libc: process.platform === "linux" ? "glibc" : null, - os_floor: - process.platform === "linux" - ? { kind: "glibc", floor: null, scanned: 1 } - : { kind: "macos", floor: null, scanned: 1 }, - ...fixture.manifestOverride, - }), - { status: 200 }, - ), - ); - } - if (request.url.endsWith("SHA256SUMS")) { - const hash = options.checksum ?? createHash("sha256").update(fixture.archive).digest("hex"); - const checksumText = - options.checksumText ?? - `${hash} postgrest-${DEFAULT_VERSIONS.postgrest}-${process.platform === "darwin" ? "darwin-arm64" : "linux-amd64"}.tar.zst\n`; - const response = HttpClientResponse.fromWeb( - request, - new Response(checksumText, { status: 200 }), - ); - if (options.onChecksumRead !== undefined) { - Object.defineProperty(response, "text", { - value: Effect.sync(() => { - options.onChecksumRead?.(); - return checksumText; - }), - }); - } - return response; - } - return HttpClientResponse.fromWeb(request, new Response(fixture.archive, { status: 200 })); - }), - ); - const spawnerLayer = - options.spawnedCommands === undefined && options.exitCodeForCommand === undefined - ? NodeServices.layer - : Layer.effect( - ChildProcessSpawner.ChildProcessSpawner, - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const record = (command: ChildProcess.Command) => { - if (Predicate.isTagged(command, "StandardCommand")) { - options.spawnedCommands?.push({ - command: command.command, - args: command.args, - }); - } - }; - const spawner = ChildProcessSpawner.make((command) => { - record(command); - return delegate.spawn(command); - }); - return ChildProcessSpawner.ChildProcessSpawner.of({ - ...spawner, - exitCode: (command) => { - if (!Predicate.isTagged(command, "StandardCommand")) { - return spawner.exitCode(command); - } - const exitCode = options.exitCodeForCommand?.(command.command); - if (exitCode === undefined) return spawner.exitCode(command); - record(command); - return Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)); - }, - }); - }), - ).pipe(Layer.provide(NodeServices.layer)); - const fileSystemLayer = - options.transformFileSystem === undefined - ? NodeFileSystem.layer - : Layer.effect( - FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, options.transformFileSystem), - ).pipe(Layer.provide(NodeFileSystem.layer)); - return BinaryResolver.make(cacheRoot).pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(HttpClient.HttpClient, client), - spawnerLayer, - fileSystemLayer, - NodePath.layer, - ), - ), - ); -}; - -describe("BinaryResolver slim-services installer", () => { - it.live("keeps the Effect runtime responsive while decompressing an archive", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - let eventLoopAdvanced = false; - let advancedBeforeArchiveWrite = false; - const resolverLayer = makeResolverLayer( - root, - makeFixture(root, {}, true, 32 * 1024 * 1024), - { - onChecksumRead: () => { - setImmediate(() => { - eventLoopAdvanced = true; - }); - }, - transformFileSystem: (fileSystem) => - FileSystem.FileSystem.of({ - ...fileSystem, - writeFile: (requestedPath, data, options) => { - if (requestedPath.endsWith("_download.tar")) { - advancedBeforeArchiveWrite = eventLoopAdvanced; - } - return fileSystem.writeFile(requestedPath, data, options); - }, - }), - }, - ); - - yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - - expect(advancedBeforeArchiveWrite).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("rejects failed binary post-processing before publishing the cache", () => - Effect.gen(function* () { - const commands = [ - ...(process.platform === "win32" ? [] : ["chmod"]), - ...(process.platform === "darwin" ? ["find"] : []), - ]; - for (const failedCommand of commands) { - const root = makeRoot(); - try { - const resolverLayer = makeResolverLayer(root, makeFixture(root), { - exitCodeForCommand: (command) => (command === failedCommand ? 73 : undefined), - }); - const failure = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer), Effect.flip); - - expect(failure).toBeInstanceOf(BinaryRuntimeError); - if (failure instanceof BinaryRuntimeError) { - expect(failure.detail).toContain("73"); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } - } - }), - ); - - it.live("installs a tar.zst archive into an empty cache and reuses it", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root); - const resolverLayer = makeResolverLayer(root, fixture); - const release = nativeReleaseForService( - "postgrest", - DEFAULT_VERSIONS.postgrest, - yield* detectPlatform, - ); - if (release === undefined) return; - const result = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const first = yield* resolver.resolveWithMetadata({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - const second = yield* resolver.resolveWithMetadata({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - return { first, second }; - }).pipe(Effect.provide(resolverLayer)); - expect(result.first.downloaded).toBe(true); - expect(result.second.downloaded).toBe(false); - expect(readFileSync(join(result.first.path, "bin/postgrest"), "utf8")).toContain( - "postgrest", - ); - expect(existsSync(join(result.first.path, ".complete"))).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("rejects a complete cache prepared for an incompatible host", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const resolverLayer = makeResolverLayer(root, makeFixture(root)); - const installed = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - - const markerPath = join(installed, ".complete"); - const marker = JSON.parse(readFileSync(markerPath, "utf8")); - marker.hostCompatibility = - process.platform === "darwin" - ? { - runtimeRequires: null, - libc: null, - osFloor: { kind: "macos", floor: "999.0" }, - } - : { - runtimeRequires: "glibc", - libc: "glibc", - osFloor: { kind: "glibc", floor: "999.0" }, - }; - writeFileSync(markerPath, JSON.stringify(marker)); - - const failure = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer), Effect.flip); - - expect(failure).toBeInstanceOf(BinaryHostCompatibilityError); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("fails closed when the manifest host floor is malformed", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root, { - os_floor: - process.platform === "darwin" - ? { kind: "macos", floor: "not-a-version", scanned: 1 } - : { kind: "glibc", floor: "not-a-version", scanned: 1 }, - }); - const resolverLayer = makeResolverLayer(root, fixture); - const failure = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer), Effect.flip); - - expect(failure).toBeInstanceOf(BinaryHostCompatibilityError); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("installs slim archives without requiring an external zstd executable", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const spawnedCommands: Array<{ command: string; args: ReadonlyArray }> = []; - const resolverLayer = makeResolverLayer(root, makeFixture(root), { spawnedCommands }); - yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - - expect(spawnedCommands.some(({ args }) => args.includes("--zstd"))).toBe(false); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("accepts the Mailpit-style glibc runtime requirement", () => - Effect.gen(function* () { - if (process.platform !== "linux") return; - const root = makeRoot(); - try { - const fixture = makeFixture(root, { runtime_requires: "glibc" }); - const resolverLayer = makeResolverLayer(root, fixture); - const result = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - expect(existsSync(join(result, "bin/postgrest"))).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.effect("reclaims interrupted staging while preserving complete cache entries", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root); - const resolverLayer = makeResolverLayer(root, fixture); - const release = nativeReleaseForService( - "postgrest", - DEFAULT_VERSIONS.postgrest, - yield* detectPlatform, - ); - if (release === undefined) return; - const cacheDir = BinaryResolver.cachePath(join(root, "bin"), { - service: "postgrest", - releaseSet: "slim-services", - version: DEFAULT_VERSIONS.postgrest, - runtime: "native", - target: release.target, - }); - const stale = join(dirname(cacheDir), `.${release.assetName}.partial-interrupted`); - mkdirSync(stale, { recursive: true }); - const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000); - utimesSync(stale, old, old); - // Native filesystem mtimes must be compared with wall-clock time; - // pinning Effect's virtual clock exposes accidental epoch comparisons. - yield* TestClock.setTime(0); - - const resolved = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - - expect(existsSync(stale)).toBe(false); - expect(existsSync(join(resolved, ".complete"))).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("limits stale staging reconciliation to four filesystem operations", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root); - const release = nativeReleaseForService( - "postgrest", - DEFAULT_VERSIONS.postgrest, - yield* detectPlatform, - ); - if (release === undefined) return; - const cacheDir = BinaryResolver.cachePath(join(root, "bin"), { - service: "postgrest", - releaseSet: "slim-services", - version: DEFAULT_VERSIONS.postgrest, - runtime: "native", - target: release.target, - }); - const stalePrefix = join(dirname(cacheDir), `.${release.assetName}.partial-cap-`); - const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000); - for (let index = 0; index < 5; index += 1) { - const stalePath = `${stalePrefix}${index}`; - mkdirSync(stalePath, { recursive: true }); - utimesSync(stalePath, old, old); - } - - const saturated = yield* Deferred.make(); - const releaseStats = yield* Deferred.make(); - let active = 0; - let maxActive = 0; - const resolverLayer = makeResolverLayer(root, fixture, { - transformFileSystem: (fileSystem) => - FileSystem.FileSystem.of({ - ...fileSystem, - stat: (requestedPath) => { - if (!requestedPath.startsWith(stalePrefix)) return fileSystem.stat(requestedPath); - return Effect.acquireUseRelease( - Effect.sync(() => { - active += 1; - maxActive = Math.max(maxActive, active); - return active; - }).pipe( - Effect.tap((current) => - current === 4 ? Deferred.succeed(saturated, undefined) : Effect.void, - ), - ), - () => - Deferred.await(releaseStats).pipe( - Effect.andThen(fileSystem.stat(requestedPath)), - ), - () => - Effect.sync(() => { - active -= 1; - }), - ); - }, - }), - }); - const resolving = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer), Effect.forkChild({ startImmediately: true })); - - yield* Deferred.await(saturated); - yield* Effect.yieldNow; - expect(maxActive).toBe(4); - - yield* Deferred.succeed(releaseStats, undefined); - yield* Fiber.join(resolving); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("replaces caches with invalid identity markers or missing required paths", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root); - const resolverLayer = makeResolverLayer(root, fixture); - const result = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolveWithMetadata({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - expect(result.downloaded).toBe(true); - - const markerPath = join(result.path, ".complete"); - writeFileSync(markerPath, JSON.stringify({ service: "postgrest" })); - const replacedMarker = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolveWithMetadata({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - expect(replacedMarker.downloaded).toBe(true); - expect(JSON.parse(readFileSync(markerPath, "utf8"))).toMatchObject({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - runtime: "native", - }); - - rmSync(join(replacedMarker.path, "bin/postgrest")); - const restoredPath = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolveWithMetadata({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - expect(restoredPath.downloaded).toBe(true); - expect(existsSync(join(restoredPath.path, "bin/postgrest"))).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("preserves a cache published while another resolver repairs stale state", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root); - const resolverLayer = makeResolverLayer(root, fixture); - const installed = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolveWithMetadata({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - const markerPath = join(installed.path, ".complete"); - writeFileSync(markerPath, JSON.stringify({ service: "postgrest" })); - - const staleMarkerRead = yield* Deferred.make(); - const releaseStaleReader = yield* Deferred.make(); - let markerReads = 0; - const staleReaderLayer = makeResolverLayer(root, fixture, { - transformFileSystem: (fileSystem) => - FileSystem.FileSystem.of({ - ...fileSystem, - readFileString: (requestedPath, options) => - Effect.gen(function* () { - const contents = yield* fileSystem.readFileString(requestedPath, options); - if (requestedPath === markerPath) { - markerReads += 1; - if (markerReads === 2) { - yield* Deferred.succeed(staleMarkerRead, undefined); - yield* Deferred.await(releaseStaleReader); - } - } - return contents; - }), - }), - }); - const staleReader = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolveWithMetadata({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(staleReaderLayer), Effect.forkChild); - yield* Deferred.await(staleMarkerRead); - - const publisher = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolveWithMetadata({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(makeResolverLayer(root, fixture))); - yield* Deferred.succeed(releaseStaleReader, undefined); - const repaired = yield* Fiber.join(staleReader); - - expect(publisher.downloaded).toBe(true); - expect(repaired.downloaded).toBe(false); - expect(repaired.path).toBe(installed.path); - expect(readFileSync(join(installed.path, "bin/postgrest"), "utf8")).toContain("postgrest"); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("rejects archive members that escape the private staging directory", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeTraversalFixture(root); - const resolverLayer = makeResolverLayer(root, fixture); - const failure = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer), Effect.flip); - expect(failure).toBeInstanceOf(DownloadError); - const release = nativeReleaseForService( - "postgrest", - DEFAULT_VERSIONS.postgrest, - yield* detectPlatform, - ); - if (release !== undefined) { - const cache = BinaryResolver.cachePath(join(root, "bin"), { - service: "postgrest", - releaseSet: "slim-services", - version: DEFAULT_VERSIONS.postgrest, - runtime: "native", - target: release.target, - }); - expect(existsSync(join(dirname(cache), "outside.txt"))).toBe(false); - expect(existsSync(join(cache, ".complete"))).toBe(false); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("rejects archive symlinks that resolve outside private staging", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const resolverLayer = makeResolverLayer(root, makeEscapingSymlinkFixture(root)); - const failure = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer), Effect.flip); - expect(failure).toBeInstanceOf(BinaryRuntimeError); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("rejects checksum and manifest/runtime validation failures", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root); - const resolverLayer = makeResolverLayer(root, fixture, { checksum: "0".repeat(64) }); - const checksum = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer), Effect.flip); - expect(checksum).toBeInstanceOf(ChecksumMismatchError); - - const manifestLayer = makeResolverLayer( - root, - makeFixture(root, { - target: process.platform === "darwin" ? "linux-amd64" : "darwin-arm64", - }), - ); - const manifest = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(manifestLayer), Effect.flip); - expect(manifest).toBeInstanceOf(BinaryManifestError); - expect(manifest).not.toBeInstanceOf(BinaryRuntimeError); - - const unsafeCommandLayer = makeResolverLayer( - root, - makeFixture(root, { cmd: ["../bin/postgrest"] }), - ); - const unsafeCommand = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(unsafeCommandLayer), Effect.flip); - expect(unsafeCommand).toBeInstanceOf(BinaryManifestError); - - const runtimeLayer = makeResolverLayer(root, makeFixture(root, {}, false)); - const runtime = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(runtimeLayer), Effect.flip); - expect(runtime).toBeInstanceOf(BinaryRuntimeError); - const release = nativeReleaseForService( - "postgrest", - DEFAULT_VERSIONS.postgrest, - yield* detectPlatform, - ); - if (release !== undefined) { - const cache = BinaryResolver.cachePath(join(root, "bin"), { - service: "postgrest", - releaseSet: "slim-services", - version: DEFAULT_VERSIONS.postgrest, - runtime: "native", - target: release.target, - }); - expect(existsSync(join(cache, ".complete"))).toBe(false); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("fails closed when SHA256SUMS has no entry for the requested archive", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root); - const unrelated = createHash("sha256").update(fixture.archive).digest("hex"); - const resolverLayer = makeResolverLayer(root, fixture, { - checksumText: `${unrelated} unrelated.sbom.spdx.json\n`, - }); - const failure = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer), Effect.flip); - - expect(failure).toBeInstanceOf(BinaryManifestError); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); - - it.live("accepts uppercase SHA256SUMS digests", () => - Effect.gen(function* () { - const root = makeRoot(); - try { - const fixture = makeFixture(root); - const hash = createHash("sha256").update(fixture.archive).digest("hex").toUpperCase(); - const resolverLayer = makeResolverLayer(root, fixture, { checksum: hash }); - const resolved = yield* Effect.gen(function* () { - const resolver = yield* BinaryResolver; - return yield* resolver.resolve({ - service: "postgrest", - version: DEFAULT_VERSIONS.postgrest, - }); - }).pipe(Effect.provide(resolverLayer)); - - expect(existsSync(join(resolved, "bin/postgrest"))).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }), - ); -}); diff --git a/packages/stack/src/BinaryResolver.ts b/packages/stack/src/BinaryResolver.ts deleted file mode 100644 index ef03aafd2c..0000000000 --- a/packages/stack/src/BinaryResolver.ts +++ /dev/null @@ -1,914 +0,0 @@ -import { createHash } from "node:crypto"; -import { zstdDecompress } from "node:zlib"; -import { - Context, - Duration, - Effect, - FileSystem, - Layer, - Option, - Path, - Predicate, - PlatformError, - Result, - Schedule, - Schema, -} from "effect"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - BinaryHostCompatibilityError, - BinaryManifestError, - BinaryNotFoundError, - BinaryRuntimeError, - ChecksumMismatchError, - DownloadError, -} from "./errors.ts"; -import { detectPlatform, type NativeTarget } from "./Platform.ts"; -import { nativeReleaseForService, type NativeReleaseArtifact } from "./ServiceCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; - -export interface BinarySpec { - readonly service: ServiceName; - readonly version: string; - readonly cacheDir?: string; -} - -interface ResolveBinaryResult { - readonly path: string; - readonly downloaded: boolean; -} - -interface SlimServiceManifest { - readonly service: string; - readonly version: string; - readonly target: string; - readonly entrypoint: ReadonlyArray; - readonly cmd: ReadonlyArray; - readonly runtime_requires?: null | "glibc"; - readonly libc: null | "glibc"; - readonly os_floor: null | { - readonly kind: string; - readonly floor: string | null; - readonly offender?: string | null; - readonly scanned: number; - readonly bundled_glibc?: boolean; - }; -} - -export interface ResolveBinaryOptions { - readonly onDownloadStart?: Effect.Effect; -} - -interface AssetInfo { - readonly service: ServiceName; - readonly releaseSet: "slim-services"; - readonly version: string; - readonly runtime: "native"; - readonly target: NativeTarget; -} - -interface HostCompatibilityRequirement { - readonly runtimeRequires: null | "glibc"; - readonly libc: null | "glibc"; - readonly osFloor: null | { - readonly kind: "glibc" | "macos"; - readonly floor: string | null; - }; -} - -interface CacheCompleteMarker { - readonly provider: string; - readonly service: string; - readonly version: string; - readonly asset: string; - readonly url: string; - readonly target: NativeTarget; - readonly releaseSet: "slim-services"; - readonly runtime: "native"; - readonly hostCompatibility: HostCompatibilityRequirement; -} - -const unknownJsonSchema = Schema.fromJsonString(Schema.Unknown); - -const cachePath = (baseDir: string, info: AssetInfo): string => - `${baseDir}/${info.releaseSet}/${info.service}/${info.version}/${info.runtime}/${info.target}`; - -const CACHE_COMPLETE_MARKER = ".complete"; -const STALE_PREPARATION_ENTRY_AGE_MS = 24 * 60 * 60 * 1_000; - -const hasTraversalSegment = (value: string): boolean => - value.split(/[\\/]/).some((segment) => segment === ".."); - -const isUnsafeArchiveMember = (member: string): boolean => { - const normalized = member.trim(); - if (normalized.length === 0) return false; - if (normalized.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalized)) return true; - let depth = 0; - for (const segment of normalized.split(/[\\/]/)) { - if (segment.length === 0 || segment === ".") continue; - if (segment === "..") { - if (depth === 0) return true; - depth -= 1; - } else { - depth += 1; - } - } - return false; -}; - -const verifyChecksum = ( - data: ArrayBuffer, - expected: string, - url: string, -): Effect.Effect => - Effect.sync(() => { - const actual = createHash("sha256").update(new Uint8Array(data)).digest("hex"); - // The .sha256 file typically contains "hex filename" or just "hex" - const expectedHex = (expected.trim().split(/\s+/)[0] ?? "").toLowerCase(); - return { actual, expectedHex }; - }).pipe( - Effect.flatMap(({ actual, expectedHex }) => { - if (actual !== expectedHex) { - return Effect.fail(new ChecksumMismatchError({ url, expected: expectedHex, actual })); - } - return Effect.void; - }), - ); - -const decompressArchive = ( - data: ArrayBuffer, - url: string, -): Effect.Effect => - Effect.callback((resume) => { - let completed = false; - const complete = (effect: Effect.Effect) => { - if (completed) return; - completed = true; - resume(effect); - }; - try { - zstdDecompress(new Uint8Array(data), (cause, archive) => - complete( - cause === null ? Effect.succeed(archive) : Effect.fail(new DownloadError({ url, cause })), - ), - ); - } catch (cause) { - complete(Effect.fail(new DownloadError({ url, cause }))); - } - return Effect.void; - }); - -const checksumForArchive = (contents: string, archiveName: string): string | undefined => { - for (const line of contents.split(/\r?\n/)) { - const match = line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/i); - if (match?.[2] === archiveName || match?.[2]?.endsWith(`/${archiveName}`)) { - return match[1]?.toLowerCase(); - } - } - return undefined; -}; - -const manifestError = (url: string, detail: string): BinaryManifestError => - new BinaryManifestError({ url, detail }); - -const isSlimServiceManifest = (value: unknown): value is SlimServiceManifest => { - if (typeof value !== "object" || value === null) return false; - if (!("service" in value) || typeof value.service !== "string") return false; - if (!("version" in value) || typeof value.version !== "string") return false; - if (!("target" in value) || typeof value.target !== "string") return false; - if (!("entrypoint" in value) || !Array.isArray(value.entrypoint)) return false; - if (!("cmd" in value) || !Array.isArray(value.cmd)) return false; - if ( - "runtime_requires" in value && - value.runtime_requires !== null && - value.runtime_requires !== "glibc" - ) - return false; - if (!("libc" in value) || (value.libc !== null && value.libc !== "glibc")) return false; - if (!("os_floor" in value)) return false; - if (value.os_floor !== null) { - if (typeof value.os_floor !== "object") return false; - if (!("kind" in value.os_floor) || typeof value.os_floor.kind !== "string") return false; - if (!("floor" in value.os_floor)) return false; - if (value.os_floor.floor !== null && typeof value.os_floor.floor !== "string") return false; - if (!("scanned" in value.os_floor) || typeof value.os_floor.scanned !== "number") return false; - if ( - "offender" in value.os_floor && - value.os_floor.offender !== null && - typeof value.os_floor.offender !== "string" - ) - return false; - if ("bundled_glibc" in value.os_floor && typeof value.os_floor.bundled_glibc !== "boolean") - return false; - } - return true; -}; - -const validateManifest = ( - release: NativeReleaseArtifact, - raw: unknown, - platform: { readonly os: string; readonly arch: string }, - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], -): Effect.Effect< - HostCompatibilityRequirement, - BinaryManifestError | BinaryHostCompatibilityError -> => - Effect.gen(function* () { - if (typeof raw !== "object" || raw === null) { - return yield* manifestError(release.manifestUrl, "Manifest must be an object"); - } - if (!isSlimServiceManifest(raw)) { - return yield* manifestError(release.manifestUrl, "Manifest schema is invalid"); - } - const manifest = raw; - if ( - manifest.service !== release.service || - manifest.version !== release.version || - manifest.target !== release.target - ) { - return yield* manifestError( - release.manifestUrl, - "Manifest service/version/target does not match release", - ); - } - if ( - !Array.isArray(manifest.entrypoint) || - !manifest.entrypoint.every((value) => typeof value === "string") || - !Array.isArray(manifest.cmd) || - !manifest.cmd.every((value) => typeof value === "string") - ) { - return yield* manifestError( - release.manifestUrl, - "Manifest entrypoint/cmd must be string arrays", - ); - } - if (manifest.entrypoint.length === 0 && manifest.cmd.length === 0) { - return yield* manifestError(release.manifestUrl, "Manifest has no command"); - } - const runtimeRequires = manifest.runtime_requires ?? null; - const commandPaths = [...manifest.entrypoint, ...manifest.cmd].filter( - (entry) => - entry.startsWith("/") || - entry.includes("/") || - entry.includes("\\") || - entry === "." || - entry === "..", - ); - if (commandPaths.some((entry) => hasTraversalSegment(entry))) { - return yield* manifestError(release.manifestUrl, "Manifest command path is unsafe"); - } - const osFloor = manifest.os_floor; - if (osFloor !== null && typeof osFloor !== "object") { - return yield* manifestError(release.manifestUrl, "Manifest os_floor is invalid"); - } - if (osFloor !== null && osFloor.kind !== "macos" && osFloor.kind !== "glibc") { - return yield* new BinaryHostCompatibilityError({ - target: release.target, - detail: `Unsupported manifest host kind ${osFloor.kind}`, - }); - } - const hostCompatibility: HostCompatibilityRequirement = { - runtimeRequires, - libc: manifest.libc, - osFloor: - osFloor === null - ? null - : osFloor.kind === "macos" - ? { kind: "macos", floor: osFloor.floor } - : { kind: "glibc", floor: osFloor.floor }, - }; - yield* validateHostCompatibility(release.target, hostCompatibility, platform, spawner); - return hostCompatibility; - }); - -const validateHostCompatibility = ( - target: NativeTarget, - requirement: HostCompatibilityRequirement, - platform: { readonly os: string; readonly arch: string }, - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], -): Effect.Effect => - Effect.gen(function* () { - const requiresGlibc = - requirement.libc === "glibc" || - requirement.runtimeRequires === "glibc" || - requirement.osFloor?.kind === "glibc"; - if (requirement.osFloor?.kind === "macos" && platform.os !== "darwin") { - return yield* new BinaryHostCompatibilityError({ - target, - detail: "Manifest requires macOS", - }); - } - if (requiresGlibc && platform.os !== "linux") { - return yield* new BinaryHostCompatibilityError({ - target, - detail: "Manifest requires Linux/glibc", - }); - } - const floor = requirement.osFloor?.floor; - if (requiresGlibc) { - const host = yield* Effect.sync(() => { - try { - const report = process.report?.getReport?.(); - if (typeof report !== "object" || report === null || !("header" in report)) { - return undefined; - } - const header = report.header; - if ( - typeof header !== "object" || - header === null || - !("glibcVersionRuntime" in header) || - typeof header.glibcVersionRuntime !== "string" - ) { - return undefined; - } - return header.glibcVersionRuntime; - } catch { - return undefined; - } - }); - if (typeof host !== "string" || host.trim().length === 0) { - return yield* new BinaryHostCompatibilityError({ - target, - detail: "Unable to determine host glibc version", - }); - } - if (floor !== null && floor !== undefined) { - const comparison = compareVersions(host, floor); - if (comparison === undefined) { - return yield* new BinaryHostCompatibilityError({ - target, - detail: `Host glibc ${host} or manifest floor ${floor} is not a dotted numeric version`, - }); - } - if (comparison < 0) { - return yield* new BinaryHostCompatibilityError({ - target, - detail: `Host glibc ${host} is below manifest floor ${floor}`, - }); - } - } - } - if ( - platform.os === "darwin" && - requirement.osFloor?.kind === "macos" && - floor !== null && - floor !== undefined - ) { - const host = yield* spawner.string(ChildProcess.make("sw_vers", ["-productVersion"])).pipe( - Effect.mapError( - (cause) => - new BinaryHostCompatibilityError({ - target, - detail: `Unable to determine macOS version: ${String(cause)}`, - }), - ), - ); - const hostVersion = host.trim().split(/\s+/)[0] ?? ""; - if (hostVersion.length === 0) { - return yield* new BinaryHostCompatibilityError({ - target, - detail: "Unable to determine macOS version", - }); - } - const comparison = compareVersions(hostVersion, floor); - if (comparison === undefined) { - return yield* new BinaryHostCompatibilityError({ - target, - detail: `Host macOS ${hostVersion} or manifest floor ${floor} is not a dotted numeric version`, - }); - } - if (comparison < 0) { - return yield* new BinaryHostCompatibilityError({ - target, - detail: `Host macOS ${hostVersion} is below manifest floor ${floor}`, - }); - } - } - }); - -const parseVersion = (value: string): ReadonlyArray | undefined => { - const parts = value.split("."); - if (parts.length === 0 || parts.some((part) => !/^\d+$/.test(part))) return undefined; - const parsed = parts.map(Number); - return parsed.every(Number.isSafeInteger) ? parsed : undefined; -}; - -const compareVersions = (left: string, right: string): number | undefined => { - const a = parseVersion(left); - const b = parseVersion(right); - if (a === undefined || b === undefined) return undefined; - for (let index = 0; index < Math.max(a.length, b.length); index += 1) { - const diff = (a[index] ?? 0) - (b[index] ?? 0); - if (diff !== 0) return diff; - } - return 0; -}; - -export class BinaryResolver extends Context.Service< - BinaryResolver, - { - /** Computes the immutable cache identity without inspecting or changing the filesystem. */ - readonly plan: (spec: BinarySpec) => Effect.Effect; - readonly resolveWithMetadata: ( - spec: BinarySpec, - options?: ResolveBinaryOptions, - ) => Effect.Effect< - ResolveBinaryResult, - | BinaryNotFoundError - | DownloadError - | ChecksumMismatchError - | BinaryManifestError - | BinaryRuntimeError - | BinaryHostCompatibilityError - >; - readonly resolve: ( - spec: BinarySpec, - ) => Effect.Effect< - string, - | BinaryNotFoundError - | DownloadError - | ChecksumMismatchError - | BinaryManifestError - | BinaryRuntimeError - | BinaryHostCompatibilityError - >; - } ->()("local/BinaryResolver") { - // Static pure functions — tested in unit tests - static cachePath = cachePath; - - static make( - cacheRoot: string, - ): Layer.Layer< - BinaryResolver, - never, - | FileSystem.FileSystem - | Path.Path - | HttpClient.HttpClient - | ChildProcessSpawner.ChildProcessSpawner - > { - return Layer.effect( - this, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = path.join(cacheRoot, "bin"); - const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const isCacheCompleteMarker = (value: unknown): value is CacheCompleteMarker => { - if (typeof value !== "object" || value === null) return false; - if (!("provider" in value) || typeof value.provider !== "string") return false; - if (!("service" in value) || typeof value.service !== "string") return false; - if (!("version" in value) || typeof value.version !== "string") return false; - if (!("asset" in value) || typeof value.asset !== "string") return false; - if (!("url" in value) || typeof value.url !== "string") return false; - if ( - !("target" in value) || - (value.target !== "darwin-arm64" && - value.target !== "linux-amd64" && - value.target !== "linux-arm64") - ) - return false; - if (!("releaseSet" in value) || value.releaseSet !== "slim-services") return false; - if (!("runtime" in value) || value.runtime !== "native") return false; - if (!("hostCompatibility" in value)) return false; - const host = value.hostCompatibility; - if (typeof host !== "object" || host === null) return false; - if ( - !("runtimeRequires" in host) || - (host.runtimeRequires !== null && host.runtimeRequires !== "glibc") - ) - return false; - if (!("libc" in host) || (host.libc !== null && host.libc !== "glibc")) return false; - if (!("osFloor" in host)) return false; - if (host.osFloor !== null) { - if (typeof host.osFloor !== "object") return false; - if ( - !("kind" in host.osFloor) || - (host.osFloor.kind !== "glibc" && host.osFloor.kind !== "macos") - ) - return false; - if ( - !("floor" in host.osFloor) || - (host.osFloor.floor !== null && typeof host.osFloor.floor !== "string") - ) - return false; - } - return true; - }; - - const isCompleteCache = ( - directory: string, - release: NativeReleaseArtifact, - info: AssetInfo, - platform: { readonly os: string; readonly arch: string }, - ) => - Effect.gen(function* () { - const marker = yield* fs - .readFileString(path.join(directory, CACHE_COMPLETE_MARKER)) - .pipe(Effect.option); - if (Option.isNone(marker)) return false; - const parsed = yield* Schema.decodeEffect(unknownJsonSchema)(marker.value).pipe( - Effect.orElseSucceed(() => undefined), - ); - if (!isCacheCompleteMarker(parsed)) return false; - if ( - parsed.provider !== release.provider || - parsed.service !== info.service || - parsed.version !== info.version || - parsed.asset !== release.assetName || - parsed.url !== release.downloadUrl || - parsed.target !== info.target || - parsed.releaseSet !== info.releaseSet || - parsed.runtime !== info.runtime - ) { - return false; - } - const requiredPaths = [...new Set(release.requiredRuntimePaths)]; - const present = yield* Effect.forEach(requiredPaths, (entry) => - fs.exists(path.join(directory, entry)), - ); - if (!present.every(Boolean)) return false; - yield* validateHostCompatibility( - release.target, - parsed.hostCompatibility, - platform, - spawner, - ); - return true; - }).pipe(Effect.catchTag("PlatformError", () => Effect.succeed(false))); - - const resolveRelease = (spec: BinarySpec) => - Effect.gen(function* () { - const platform = yield* detectPlatform; - const release = nativeReleaseForService(spec.service, spec.version, platform); - if (release === undefined) { - return yield* new BinaryNotFoundError({ - service: spec.service, - platform: `${platform.os}-${platform.arch}`, - }); - } - const info: AssetInfo = { - service: spec.service, - releaseSet: "slim-services", - version: spec.version, - runtime: "native", - target: release.target, - }; - return { platform, release, info }; - }); - - const plan = (spec: BinarySpec): Effect.Effect => - Effect.gen(function* () { - const { info } = yield* resolveRelease(spec); - return cachePath(spec.cacheDir ?? binDir, info); - }); - - const cleanupStaleEntries = (directory: string, prefix: string) => - fs.readDirectory(directory).pipe( - Effect.flatMap((entries) => - Effect.forEach( - entries.filter((entry) => entry.startsWith(prefix)), - (entry) => { - const stagingPath = path.join(directory, entry); - return fs.stat(stagingPath).pipe( - Effect.flatMap((info) => - Option.match(info.mtime, { - onNone: () => Effect.void, - onSome: (modifiedAt) => - Effect.gen(function* () { - // Preparation mtimes are native filesystem wall-clock - // values, so compare them with the same clock source. - // oxlint-disable-next-line effecttsgo/global-date-in-effect -- Native filesystem mtime staleness requires wall-clock time at this leaf boundary. - const now = Date.now(); - return now - modifiedAt.getTime() >= STALE_PREPARATION_ENTRY_AGE_MS - ? yield* fs.remove(stagingPath, { recursive: true, force: true }) - : undefined; - }), - }), - ), - Effect.ignore, - ); - }, - { concurrency: 4 }, - ), - ), - Effect.ignore, - ); - - const validateExtractedTree = (directory: string) => - Effect.gen(function* () { - const root = yield* fs.realPath(directory); - const entries = yield* fs.readDirectory(directory, { recursive: true }); - for (const entry of entries) { - const candidate = path.join(directory, entry); - const resolved = yield* fs.realPath(candidate).pipe( - Effect.mapError( - () => - new BinaryRuntimeError({ - path: candidate, - detail: "Extracted path cannot be resolved inside private staging", - }), - ), - ); - const relative = path.relative(root, resolved); - if ( - path.isAbsolute(relative) || - relative === ".." || - relative.startsWith(`..${path.sep}`) - ) { - return yield* new BinaryRuntimeError({ - path: candidate, - detail: `Extracted path resolves outside private staging: ${entry}`, - }); - } - } - }); - - const extractRelease = ( - release: NativeReleaseArtifact, - destination: string, - platform: { readonly os: string; readonly arch: string }, - ) => - Effect.gen(function* () { - const manifestResponse = yield* httpClient - .get(release.manifestUrl) - .pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: release.manifestUrl, cause })), - ), - ); - const manifestText = yield* manifestResponse.text.pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: release.manifestUrl, cause })), - ), - ); - const parsed = yield* Schema.decodeEffect(unknownJsonSchema)(manifestText).pipe( - Effect.mapError((cause) => - manifestError(release.manifestUrl, `Invalid JSON: ${String(cause)}`), - ), - ); - const hostCompatibility = yield* validateManifest(release, parsed, platform, spawner); - - const tarballResponse = yield* httpClient - .get(release.downloadUrl) - .pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: release.downloadUrl, cause })), - ), - ); - const tarball = yield* tarballResponse.arrayBuffer.pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: release.downloadUrl, cause })), - ), - ); - - const checksumResponse = yield* httpClient - .get(release.checksumUrl) - .pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: release.checksumUrl, cause })), - ), - ); - const checksumText = yield* checksumResponse.text.pipe( - Effect.catchTag("HttpClientError", (cause) => - Effect.fail(new DownloadError({ url: release.checksumUrl, cause })), - ), - ); - const expected = checksumForArchive(checksumText, `${release.assetName}.tar.zst`); - if (expected === undefined) { - return yield* manifestError( - release.checksumUrl, - "SHA256SUMS has no entry for the archive", - ); - } - yield* verifyChecksum(tarball, expected, release.checksumUrl); - - const archivePath = path.join(destination, "_download.tar"); - const archive = yield* decompressArchive(tarball, release.downloadUrl); - yield* fs.writeFile(archivePath, archive); - - const members = yield* spawner - .string(ChildProcess.make("tar", ["-tf", archivePath])) - .pipe( - Effect.mapError( - (cause) => - new DownloadError({ - url: release.downloadUrl, - cause, - }), - ), - ); - const unsafeMember = members - .split(/\r?\n/) - .map((member) => member.trim()) - .find(isUnsafeArchiveMember); - if (unsafeMember !== undefined) { - return yield* new DownloadError({ - url: release.downloadUrl, - cause: new Error(`archive member is unsafe: ${unsafeMember}`), - }); - } - - const exitCode = yield* spawner - .exitCode(ChildProcess.make("tar", ["-xf", archivePath, "-C", destination])) - .pipe( - Effect.catchTag("PlatformError", (cause) => - Effect.fail(new DownloadError({ url: release.downloadUrl, cause })), - ), - ); - if (exitCode !== 0) { - return yield* new DownloadError({ - url: release.downloadUrl, - cause: new Error(`extraction exited with code ${exitCode}`), - }); - } - - yield* validateExtractedTree(destination); - - yield* fs.remove(archivePath).pipe(Effect.ignore); - - const requirePostProcess = (name: string, command: ChildProcess.Command) => - Effect.gen(function* () { - const exitCode = yield* spawner.exitCode(command).pipe( - Effect.mapError( - (cause) => - new BinaryRuntimeError({ - path: destination, - detail: `${name} could not run: ${String(cause)}`, - }), - ), - ); - if (exitCode !== 0) { - return yield* new BinaryRuntimeError({ - path: destination, - detail: `${name} exited with code ${exitCode}`, - }); - } - }); - - if (platform.os !== "win32") { - yield* requirePostProcess( - "chmod", - ChildProcess.make("chmod", ["-R", "u+x", destination]), - ); - } - - if (platform.os === "darwin") { - yield* requirePostProcess( - "codesign", - ChildProcess.make("find", [ - destination, - "-type", - "f", - "(", - "-perm", - "+111", - "-o", - "-name", - "*.dylib", - ")", - "-exec", - "codesign", - "-f", - "-s", - "-", - "{}", - "+", - ]), - ); - } - - const requiredPaths = [...new Set(release.requiredRuntimePaths)]; - const missing = yield* Effect.forEach(requiredPaths, (entry) => - fs.exists(path.join(destination, entry)), - ).pipe(Effect.map((exists) => requiredPaths.filter((_entry, index) => !exists[index]))); - if (missing.length > 0) { - return yield* new BinaryRuntimeError({ - path: destination, - detail: `Manifest runtime paths are missing: ${missing.join(", ")}`, - }); - } - return hostCompatibility; - }); - - const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => { - const core = Effect.gen(function* () { - const { platform, release, info } = yield* resolveRelease(spec); - const baseDir = spec.cacheDir ?? binDir; - const cacheDir = cachePath(baseDir, info); - const parentDir = path.dirname(cacheDir); - const stagingPrefix = `.${release.assetName}.partial-`; - const publicationLock = path.join(parentDir, `.${release.assetName}.publication-lock`); - yield* cleanupStaleEntries(parentDir, stagingPrefix); - yield* cleanupStaleEntries(parentDir, path.basename(publicationLock)); - if (yield* isCompleteCache(cacheDir, release, info, platform)) { - return { - path: cacheDir, - downloaded: false, - } satisfies ResolveBinaryResult; - } - yield* fs.makeDirectory(parentDir, { recursive: true }); - yield* options?.onDownloadStart ?? Effect.void; - - const stagingDir = yield* fs.makeTempDirectory({ - directory: parentDir, - prefix: stagingPrefix, - }); - return yield* Effect.gen(function* () { - const hostCompatibility = yield* extractRelease(release, stagingDir, platform); - yield* fs.writeFile( - path.join(stagingDir, CACHE_COMPLETE_MARKER), - new TextEncoder().encode( - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Complete markers use a stable human-readable JSON format at the native filesystem boundary. - JSON.stringify({ - provider: release.provider, - service: spec.service, - version: spec.version, - asset: release.assetName, - url: release.downloadUrl, - target: info.target, - releaseSet: info.releaseSet, - runtime: info.runtime, - hostCompatibility, - }), - ), - ); - - // Each contender publishes from a private staging directory. The - // first atomic rename wins; later publishers reuse that complete - // destination instead of coordinating through process identity. - const publication = yield* fs.rename(stagingDir, cacheDir).pipe(Effect.result); - if (Result.isSuccess(publication)) { - return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult; - } - if (yield* isCompleteCache(cacheDir, release, info, platform)) { - return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; - } - - return yield* Effect.scoped( - Effect.gen(function* () { - const acquirePublicationLock: Effect.Effect = - fs.makeDirectory(publicationLock).pipe( - Effect.retry({ - while: (error) => Predicate.isTagged(error.reason, "AlreadyExists"), - schedule: Schedule.recurs(1_200).pipe( - Schedule.addDelay(() => Effect.succeed(Duration.millis(25))), - ), - }), - ); - yield* Effect.acquireRelease(acquirePublicationLock, () => - fs - .remove(publicationLock, { recursive: true, force: true }) - .pipe(Effect.ignore), - ); - - // The destination may have changed while this resolver was - // waiting to repair it. Revalidate under the publication - // claim before removing anything shared. - if (yield* isCompleteCache(cacheDir, release, info, platform)) { - return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; - } - - yield* fs.remove(cacheDir, { recursive: true, force: true }); - const retry = yield* fs.rename(stagingDir, cacheDir).pipe(Effect.result); - if (Result.isSuccess(retry)) { - return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult; - } - const retryFailure = retry.failure; - if (yield* isCompleteCache(cacheDir, release, info, platform)) { - return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; - } - return yield* retryFailure; - }), - ); - }).pipe( - Effect.ensuring( - fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore), - ), - ); - }); - - // Absorb PlatformError (from FileSystem ops) into DownloadError - return core.pipe( - Effect.catchTag("PlatformError", (e) => - Effect.fail( - new DownloadError({ url: `filesystem error for ${spec.service}`, cause: e }), - ), - ), - ); - }; - - return { - plan, - resolveWithMetadata, - resolve: (spec: BinarySpec) => { - return Effect.map(resolveWithMetadata(spec), ({ path }) => path); - }, - }; - }), - ); - } -} diff --git a/packages/stack/src/BinaryResolver.unit.test.ts b/packages/stack/src/BinaryResolver.unit.test.ts deleted file mode 100644 index 011d30733b..0000000000 --- a/packages/stack/src/BinaryResolver.unit.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { BinaryResolver } from "./BinaryResolver.ts"; -import { nativeReleaseForService } from "./ServiceCatalog.ts"; -import { DEFAULT_VERSIONS } from "./versions.ts"; - -describe("slim native release descriptors", () => { - it("formats the slim-services archive, manifest, and checksum names", () => { - const release = nativeReleaseForService("postgrest", "v16.1", { - os: "darwin", - arch: "arm64", - }); - expect(release).toMatchObject({ - releaseTag: "postgrest-v16.1", - target: "darwin-arm64", - archive: "tar.zst", - downloadUrl: - "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/postgrest-v16.1-darwin-arm64.tar.zst", - manifestUrl: - "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/postgrest-v16.1-darwin-arm64.manifest.json", - checksumUrl: - "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/SHA256SUMS", - }); - }); - - it("only exposes the three supported native targets", () => { - expect( - nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, { os: "win32", arch: "x64" }), - ).toBeUndefined(); - expect( - nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, { os: "linux", arch: "x64" })?.target, - ).toBe("linux-amd64"); - }); -}); - -describe("BinaryResolver.cachePath", () => { - it("includes service, release provider, version, and target identity", () => { - const path = BinaryResolver.cachePath("/home/user/.supabase/bin", { - service: "postgres", - releaseSet: "slim-services", - version: DEFAULT_VERSIONS.postgres, - runtime: "native", - target: "linux-amd64", - }); - expect(path).toBe( - `/home/user/.supabase/bin/slim-services/postgres/${DEFAULT_VERSIONS.postgres}/native/linux-amd64`, - ); - }); -}); diff --git a/packages/stack/src/CleanupTargets.ts b/packages/stack/src/CleanupTargets.ts deleted file mode 100644 index 202eabf8d6..0000000000 --- a/packages/stack/src/CleanupTargets.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface CleanupTargets { - readonly dockerContainerNames: ReadonlyArray; -} diff --git a/packages/stack/src/ContainerRuntime.integration.test.ts b/packages/stack/src/ContainerRuntime.integration.test.ts deleted file mode 100644 index 15cf536848..0000000000 --- a/packages/stack/src/ContainerRuntime.integration.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Layer, Predicate, Sink, Stream } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { selectStackRuntime, selectStackRuntimeForPlatform } from "./ContainerRuntime.ts"; - -const runtimeSpawner = (availability: Readonly>) => { - const commands: string[] = []; - return { - commands, - layer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const executable = Predicate.isTagged(command, "StandardCommand") ? command.command : ""; - commands.push(executable); - const exitCode = yield* Deferred.make(); - yield* Deferred.succeed( - exitCode, - ChildProcessSpawner.ExitCode(availability[executable] === true ? 0 : 1), - ); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Deferred.await(exitCode), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - }; -}; - -describe("stack runtime selection", () => { - it.effect("uses Docker mode when the Docker daemon is usable", () => { - const spawner = runtimeSpawner({ docker: true }); - return Effect.gen(function* () { - expect(yield* selectStackRuntime()).toEqual({ - mode: "docker", - containerRuntime: "docker", - }); - }).pipe(Effect.provide(spawner.layer)); - }); - - it.effect("uses Podman for Docker mode when Docker is unavailable", () => { - const spawner = runtimeSpawner({ podman: true }); - return Effect.gen(function* () { - expect(yield* selectStackRuntime()).toEqual({ - mode: "docker", - containerRuntime: "podman", - }); - }).pipe(Effect.provide(spawner.layer)); - }); - - it.effect("uses native mode when no container runtime is usable", () => { - const spawner = runtimeSpawner({}); - return Effect.gen(function* () { - expect(yield* selectStackRuntimeForPlatform({ os: "linux", arch: "x64" })).toEqual({ - mode: "native", - containerRuntime: null, - }); - }).pipe(Effect.provide(spawner.layer)); - }); - - it.effect("does not probe container runtimes when native mode is explicit", () => { - const spawner = runtimeSpawner({ docker: true }); - return Effect.gen(function* () { - expect(yield* selectStackRuntimeForPlatform({ os: "linux", arch: "x64" }, "native")).toEqual({ - mode: "native", - containerRuntime: null, - }); - expect(spawner.commands).toEqual([]); - }).pipe(Effect.provide(spawner.layer)); - }); - - it.effect("rejects explicit Docker mode when neither runtime is usable", () => { - const spawner = runtimeSpawner({}); - return Effect.gen(function* () { - const error = yield* selectStackRuntime("docker").pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "StackBuildError", - reason: "docker_not_running", - }); - }).pipe(Effect.provide(spawner.layer)); - }); - - it.effect("rejects automatic fallback when the host has no native artifacts", () => { - const spawner = runtimeSpawner({}); - return Effect.gen(function* () { - const error = yield* selectStackRuntimeForPlatform({ os: "win32", arch: "x64" }).pipe( - Effect.flip, - ); - expect(error).toMatchObject({ - _tag: "StackBuildError", - reason: "docker_not_running", - }); - expect(error.detail).toContain("win32-x64"); - }).pipe(Effect.provide(spawner.layer)); - }); -}); diff --git a/packages/stack/src/ContainerRuntime.ts b/packages/stack/src/ContainerRuntime.ts deleted file mode 100644 index 5382caec0f..0000000000 --- a/packages/stack/src/ContainerRuntime.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Effect, Exit } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { StackBuildError } from "./errors.ts"; -import { detectPlatform, nativeTargetForPlatform, type PlatformInfo } from "./Platform.ts"; -import type { StackMode } from "./StackConfig.ts"; - -export type ContainerRuntime = "docker" | "podman"; - -export type StackRuntimeSelection = - | { readonly mode: "native"; readonly containerRuntime: null } - | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime }; - -const probeContainerRuntime = ( - runtime: ContainerRuntime, -): Effect.Effect => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const result = yield* Effect.exit( - spawner.exitCode(ChildProcess.make(runtime, ["info"])).pipe(Effect.timeout("30 seconds")), - ); - return Exit.isSuccess(result) && result.value === 0; - }); - -export const validateStackRuntime = ( - selection: StackRuntimeSelection, -): Effect.Effect< - StackRuntimeSelection, - StackBuildError, - ChildProcessSpawner.ChildProcessSpawner -> => - selection.mode === "native" - ? Effect.succeed(selection) - : probeContainerRuntime(selection.containerRuntime).pipe( - Effect.flatMap((usable) => - usable - ? Effect.succeed(selection) - : Effect.fail( - new StackBuildError({ - detail: `Docker mode requires a usable ${selection.containerRuntime} runtime. Restore or start the persisted ${selection.containerRuntime} runtime and retry, or delete and recreate the stack (removing its managed data) to choose another execution mode.`, - reason: "docker_not_running", - }), - ), - ), - ); - -export const selectStackRuntimeForPlatform = ( - platform: PlatformInfo, - requestedMode?: StackMode, -): Effect.Effect => - Effect.gen(function* () { - if (requestedMode === "native") { - if (nativeTargetForPlatform(platform) !== undefined) { - return { mode: "native", containerRuntime: null }; - } - return yield* new StackBuildError({ - detail: `Native mode is unavailable on ${platform.os}-${platform.arch}. Use a supported Linux or Apple silicon macOS host, or install and start Docker or Podman.`, - reason: "invalid_config", - }); - } - - const runtimes = ["docker", "podman"] as const satisfies ReadonlyArray; - const probes = yield* Effect.all( - runtimes.map((runtime) => - probeContainerRuntime(runtime).pipe(Effect.map((usable) => [runtime, usable] as const)), - ), - { concurrency: "unbounded" }, - ); - const selected = probes.find(([, usable]) => usable)?.[0]; - if (selected !== undefined) { - return { mode: "docker", containerRuntime: selected }; - } - - if (requestedMode === "docker") { - return yield* new StackBuildError({ - detail: "Docker mode requires a usable Docker or Podman runtime", - reason: "docker_not_running", - }); - } - - if (nativeTargetForPlatform(platform) !== undefined) { - return { mode: "native", containerRuntime: null }; - } - return yield* new StackBuildError({ - detail: `No usable Docker or Podman runtime was found, and native mode is unavailable on ${platform.os}-${platform.arch}. Install and start Docker or Podman.`, - reason: "docker_not_running", - }); - }); - -export const selectStackRuntime = ( - requestedMode?: StackMode, -): Effect.Effect => - detectPlatform.pipe( - Effect.flatMap((platform) => selectStackRuntimeForPlatform(platform, requestedMode)), - ); diff --git a/packages/stack/src/ControlHttpReader.ts b/packages/stack/src/ControlHttpReader.ts deleted file mode 100644 index 12cb8f31a9..0000000000 --- a/packages/stack/src/ControlHttpReader.ts +++ /dev/null @@ -1,198 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- The owner probe uses the native Node HTTP stream boundary to support both platform transports. -import * as Http from "node:http"; -import { Data, Effect } from "effect"; -import { - CONTROL_STATUS_PATH, - ControlProtocolError, - ControlTransportError, - type ControlEndpoint, - type ControlOwnerReader, -} from "./managed/control.ts"; -import { errorCode } from "./error-code.ts"; - -const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024; - -class ControlReaderError extends Data.TaggedError("ControlReaderError")<{ - readonly message: string; - readonly reason: "protocol" | "transport"; - readonly cause?: unknown; -}> {} - -const readError = ( - endpoint: ControlEndpoint, - cause: unknown, -): ControlTransportError | ControlProtocolError => { - const code = errorCode(cause); - if ( - cause instanceof SyntaxError || - code?.startsWith("HPE_") === true || - (cause instanceof ControlReaderError && cause.reason === "protocol") - ) { - return new ControlProtocolError({ endpoint, cause }); - } - return new ControlTransportError({ - endpoint, - reason: code === "ECONNREFUSED" ? "unreachable" : "transport", - cause, - }); -}; - -/** Protocol-aware owner reader shared by the Node and Bun control transports. */ -export const readControlOwner: ControlOwnerReader = (endpoint) => - Effect.callback((resume) => { - let response: Http.IncomingMessage | undefined; - let onData: ((chunk: string) => void) | undefined; - let onEnd: (() => void) | undefined; - let onResponseError: ((cause: Error) => void) | undefined; - let onResponseAborted: (() => void) | undefined; - let onResponseClose: (() => void) | undefined; - let settled = false; - let cleanup = () => {}; - let dispose = () => {}; - const finish = (effect: Effect.Effect, shouldDispose = false) => { - if (settled) return; - settled = true; - cleanup(); - if (shouldDispose) dispose(); - resume(effect); - }; - const onRequestError = (cause: Error) => - finish( - Effect.fail(new ControlReaderError({ message: cause.message, reason: "transport", cause })), - true, - ); - const request = Http.request( - { - host: endpoint.hostname, - port: endpoint.port, - path: CONTROL_STATUS_PATH, - method: "GET", - // One-shot connection: a pooled keep-alive connection would let a - // closed listener keep answering status probes while the probes - // themselves keep the connection alive. - agent: false, - }, - (incoming) => { - response = incoming; - let body = ""; - let bodyBytes = 0; - let ended = false; - let responseAborted = false; - onData = (chunk) => { - bodyBytes += Buffer.byteLength(chunk, "utf8"); - if (bodyBytes > MAX_CONTROL_RESPONSE_BYTES) { - finish( - Effect.fail( - new ControlReaderError({ - message: `Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`, - reason: "protocol", - }), - ), - true, - ); - return; - } - body += chunk; - }; - onEnd = () => { - ended = true; - if ((incoming.statusCode ?? 500) < 200 || (incoming.statusCode ?? 500) >= 300) { - finish( - Effect.fail( - new ControlReaderError({ - message: `Control status request returned ${incoming.statusCode ?? 500}`, - reason: "protocol", - }), - ), - true, - ); - return; - } - try { - finish(Effect.succeed(JSON.parse(body))); - } catch (cause) { - finish( - Effect.fail( - new ControlReaderError({ - message: cause instanceof Error ? cause.message : String(cause), - reason: "protocol", - cause, - }), - ), - true, - ); - } - }; - onResponseError = (cause) => - finish( - Effect.fail( - new ControlReaderError({ message: cause.message, reason: "transport", cause }), - ), - true, - ); - onResponseAborted = () => { - responseAborted = true; - }; - onResponseClose = () => { - if (responseAborted || !ended) { - finish( - Effect.fail( - new ControlReaderError({ - message: "Control status response closed before end", - reason: "transport", - }), - ), - true, - ); - } - }; - incoming.setEncoding("utf8"); - incoming.on("data", onData); - incoming.once("end", onEnd); - incoming.once("error", onResponseError); - incoming.once("aborted", onResponseAborted); - incoming.once("close", onResponseClose); - }, - ); - dispose = () => { - response?.destroy(); - request.destroy(); - }; - cleanup = () => { - request.removeListener("error", onRequestError); - if (response !== undefined) { - if (onData !== undefined) response.removeListener("data", onData); - if (onEnd !== undefined) response.removeListener("end", onEnd); - if (onResponseError !== undefined) response.removeListener("error", onResponseError); - if (onResponseAborted !== undefined) response.removeListener("aborted", onResponseAborted); - if (onResponseClose !== undefined) response.removeListener("close", onResponseClose); - } - }; - request.once("error", onRequestError); - request.end(); - return Effect.callback((resumeCancellation) => { - const onClose = () => { - cleanup(); - resumeCancellation(Effect.void); - }; - settled = true; - request.once("close", onClose); - dispose(); - return Effect.sync(() => { - request.removeListener("close", onClose); - cleanup(); - }); - }); - }).pipe( - Effect.timeoutOrElse({ - duration: 500, - orElse: () => - Effect.fail( - new ControlReaderError({ - message: "Control status request timed out", - reason: "transport", - }), - ), - }), - Effect.mapError((cause) => readError(endpoint, cause)), - ); diff --git a/packages/stack/src/ControlStopClient.ts b/packages/stack/src/ControlStopClient.ts deleted file mode 100644 index 2f85f82c4c..0000000000 --- a/packages/stack/src/ControlStopClient.ts +++ /dev/null @@ -1,76 +0,0 @@ -// oxlint-disable effecttsgo/global-fetch-in-effect, effecttsgo/prefer-schema-over-json -- -// The stop client is the native HTTP/JSON control-protocol leaf; this boundary -// forwards the already validated request to the platform fetch implementation. -import { Effect } from "effect"; -import { errorCode } from "./error-code.ts"; -import { - CONTROL_STOP_PATH, - ControlMaintenanceBusyError, - ControlStopConflictError, - ControlTransportError, - type ControlEndpoint, - type ControlStopRequest, -} from "./managed/control.ts"; - -const isDefinitivelyUnreachable = (cause: unknown): boolean => { - const code = errorCode(cause); - return code === "ECONNREFUSED" || code === "ConnectionRefused"; -}; - -const consumeResponse = ( - endpoint: ControlEndpoint, - response: Response, -): Effect.Effect => - Effect.tryPromise({ - try: () => - (response.body === null ? Promise.resolve() : response.arrayBuffer()).then(() => response), - catch: (cause) => new ControlTransportError({ endpoint, reason: "transport", cause }), - }); - -export const requestControlStop = ( - endpoint: ControlEndpoint, - request: ControlStopRequest, -): Effect.Effect< - void, - ControlTransportError | ControlStopConflictError | ControlMaintenanceBusyError -> => - Effect.tryPromise({ - try: (signal) => - fetch(`http://${endpoint.hostname}:${endpoint.port}${CONTROL_STOP_PATH}`, { - method: "POST", - signal: AbortSignal.any([signal, AbortSignal.timeout(500)]), - headers: { - connection: "close", - "content-type": "application/json", - }, - body: JSON.stringify(request), - }), - catch: (cause) => - new ControlTransportError({ - endpoint, - reason: isDefinitivelyUnreachable(cause) ? "unreachable" : "transport", - cause, - }), - }).pipe( - Effect.flatMap((response) => consumeResponse(endpoint, response)), - Effect.flatMap( - ( - response, - ): Effect.Effect< - void, - ControlTransportError | ControlStopConflictError | ControlMaintenanceBusyError - > => { - if (response.ok) return Effect.void; - if (response.status === 409) return Effect.fail(new ControlStopConflictError({ endpoint })); - if (response.status === 423) - return Effect.fail(new ControlMaintenanceBusyError({ endpoint })); - return Effect.fail( - new ControlTransportError({ - endpoint, - reason: "transport", - cause: new Error(`Control stop request returned ${response.status}`), - }), - ); - }, - ), - ); diff --git a/packages/stack/src/DaemonProtocol.ts b/packages/stack/src/DaemonProtocol.ts deleted file mode 100644 index f78f1c6fd4..0000000000 --- a/packages/stack/src/DaemonProtocol.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Schema } from "effect"; - -export const ControlOwnerStateSchema = Schema.Literals([ - "starting", - "running", - "stopping", - "deleting", - "failed", -]); - -export const CONTROL_PROTOCOL = "supabase-stack-control" as const; -export const CONTROL_PROTOCOL_VERSION = 1 as const; - -export type ControlOwnerState = typeof ControlOwnerStateSchema.Type; - -const ControlOwnerIdentitySchema = Schema.Struct({ - controlProtocol: Schema.Literal(CONTROL_PROTOCOL), - controlProtocolVersion: Schema.Literal(CONTROL_PROTOCOL_VERSION), - ownershipId: Schema.String, - ownerSessionId: Schema.String, -}); - -export const ControlSupervisorDescriptorSchema = Schema.Struct({ - ...ControlOwnerIdentitySchema.fields, - kind: Schema.Literal("supervisor"), - daemonCliVersion: Schema.String, -}); - -const ControlMaintenanceDescriptorSchema = Schema.Struct({ - ...ControlOwnerIdentitySchema.fields, - kind: Schema.Literal("maintenance"), - operation: Schema.Literals(["delete", "stop", "update", "repair"]), -}); - -const ControlSupervisorStatusSchema = Schema.Struct({ - ...ControlSupervisorDescriptorSchema.fields, - state: ControlOwnerStateSchema, - ready: Schema.Boolean, -}); - -const ControlMaintenanceStatusSchema = Schema.Struct({ - ...ControlMaintenanceDescriptorSchema.fields, -}); - -export const ControlOwnerStatusSchema = Schema.Union([ - ControlSupervisorStatusSchema, - ControlMaintenanceStatusSchema, -]); - -export type ControlOwnerStatus = typeof ControlOwnerStatusSchema.Type; -export type ControlSupervisorStatus = typeof ControlSupervisorStatusSchema.Type; -export type ControlMaintenanceOperation = - (typeof ControlMaintenanceDescriptorSchema.Type)["operation"]; - -export const isControlSupervisorStatus = ( - status: ControlOwnerStatus, -): status is ControlSupervisorStatus => status.kind === "supervisor"; - -export const ControlStopRequestSchema = Schema.Struct({ - ownershipId: Schema.String, - ownerSessionId: Schema.String, - intent: Schema.Literals(["explicit", "replacement"]), -}); - -export type ControlStopRequest = typeof ControlStopRequestSchema.Type; -export type ControlStopIntent = ControlStopRequest["intent"]; - -export interface ControlSessionFence { - readonly ownershipId: string; - readonly ownerSessionId: string; -} - -export const matchesControlSession = ( - actual: ControlSessionFence, - expected: ControlSessionFence, -): boolean => - actual.ownershipId === expected.ownershipId && actual.ownerSessionId === expected.ownerSessionId; diff --git a/packages/stack/src/HttpTransportClient.integration.test.ts b/packages/stack/src/HttpTransportClient.integration.test.ts deleted file mode 100644 index 1810c8ca24..0000000000 --- a/packages/stack/src/HttpTransportClient.integration.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Transport tests coordinate native HTTP sockets and readiness callbacks through Vitest's Promise boundary. -import { Effect, Fiber, ManagedRuntime, Predicate, Result } from "effect"; -import type { Socket } from "node:net"; -import { createServer, type Server } from "node:http"; -import { afterEach, describe, expect, test } from "vitest"; -import { - HttpTransportClient, - httpTransportClientLayer, - makeHttpControlClient, -} from "./HttpTransportClient.ts"; -import type { ControlEndpoint } from "./managed/control.ts"; - -const endpointFor = (server: Server): ControlEndpoint => { - const address = server.address(); - if (address === null || typeof address === "string") throw new Error("Expected TCP address"); - return { - hostname: "127.0.0.1", - port: address.port, - url: `http://127.0.0.1:${address.port}`, - }; -}; - -const listen = (server: Server): Promise => - new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - -const close = (server: Server): Promise => - new Promise((resolve, reject) => { - if (!server.listening) { - resolve(); - return; - } - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); - -const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); - }), - ]); - } finally { - if (timeout !== undefined) clearTimeout(timeout); - } -}; - -describe("HttpTransportClient", () => { - let server: Server | undefined; - let activeSocket: Socket | undefined; - - afterEach(async () => { - activeSocket?.destroy(); - if (server !== undefined) await close(server); - activeSocket = undefined; - server = undefined; - }); - - test("closes an unanswered request when its fiber is interrupted", async () => { - let requestArrived!: () => void; - const requestReady = new Promise((resolve) => { - requestArrived = resolve; - }); - let closed = false; - let connectionClosed!: () => void; - const connectionClosedPromise = new Promise((resolve) => { - connectionClosed = () => { - closed = true; - resolve(); - }; - }); - - server = createServer((_request, response) => { - const socket = response.socket; - if (socket === null) throw new Error("Expected request socket"); - activeSocket = socket; - requestArrived(); - socket.once("close", connectionClosed); - }); - await listen(server); - - const runtime = ManagedRuntime.make(httpTransportClientLayer); - try { - const fiber = runtime.runFork( - Effect.gen(function* () { - const client = yield* HttpTransportClient; - yield* client.request(endpointFor(server!), "/never"); - }), - ); - await requestReady; - await runtime.runPromise(Fiber.interrupt(fiber)); - await withTimeout(connectionClosedPromise, 5_000); - expect(closed).toBe(true); - } finally { - await runtime.dispose(); - } - }); - - test("reports the owner protocol version mismatch from an HTTP response", async () => { - server = createServer((_request, response) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 2, - ownershipId: "a".repeat(64), - ownerSessionId: "owner-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }), - ); - }); - await listen(server); - - const runtime = ManagedRuntime.make(httpTransportClientLayer); - try { - const result = await runtime.runPromise( - Effect.gen(function* () { - const transport = yield* HttpTransportClient; - const client = makeHttpControlClient(transport); - return yield* client.readOwner(endpointFor(server!), "a".repeat(64)).pipe(Effect.result); - }), - ); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(Predicate.isTagged(result.failure, "ControlProtocolMismatchError")).toBe(true); - if (Predicate.isTagged(result.failure, "ControlProtocolMismatchError")) { - expect(result.failure.expectedVersion).toBe(1); - expect(result.failure.observedVersion).toBe(2); - } - } - } finally { - await runtime.dispose(); - } - }); -}); diff --git a/packages/stack/src/HttpTransportClient.ts b/packages/stack/src/HttpTransportClient.ts deleted file mode 100644 index 35d616e9e4..0000000000 --- a/packages/stack/src/HttpTransportClient.ts +++ /dev/null @@ -1,150 +0,0 @@ -// oxlint-disable effecttsgo/global-fetch-in-effect, effecttsgo/prefer-schema-over-json -- -// This service is the native HTTP transport leaf; request bodies are encoded -// for the control protocol and fetch is supplied by the host runtime. -import { Context, Data, Effect, Layer } from "effect"; -import { - CONTROL_STATUS_PATH, - CONTROL_STOP_PATH, - ControlProtocolError, - ControlStopConflictError, - ControlMaintenanceBusyError, - ControlTransportError, - makeControlClient, - type ControlClientShape, - type ControlClientTransport, - type ControlEndpoint, -} from "./managed/control.ts"; -import { errorCode } from "./error-code.ts"; - -export class HttpTransportClientError extends Data.TaggedError("HttpTransportClientError")<{ - readonly endpoint: ControlEndpoint; - readonly path: string; - readonly cause: unknown; - readonly reason: "transport" | "status" | "protocol"; -}> {} - -export class HttpTransportClient extends Context.Service< - HttpTransportClient, - { - readonly request: ( - endpoint: ControlEndpoint, - path: string, - init?: RequestInit, - ) => Effect.Effect; - } ->()("stack/HttpTransportClient") {} - -export const httpTransportClientLayer = Layer.succeed(HttpTransportClient, { - request: (endpoint, path, init) => - Effect.tryPromise({ - try: (signal) => - fetch(`${endpoint.url}${path}`, { - ...init, - signal: - init?.signal === undefined || init.signal === null - ? signal - : AbortSignal.any([signal, init.signal]), - }), - catch: (cause) => - new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }), - }), -}); - -const CONTROL_REQUEST_TIMEOUT_MS = 500; - -const controlTransportError = ( - endpoint: ControlEndpoint, - cause: HttpTransportClientError, -): ControlTransportError => - new ControlTransportError({ - endpoint, - reason: - errorCode(cause) === "ECONNREFUSED" || errorCode(cause) === "ConnectionRefused" - ? "unreachable" - : "transport", - cause, - }); - -const consumeControlResponse = ( - endpoint: ControlEndpoint, - response: Response, -): Effect.Effect => - Effect.tryPromise({ - try: () => - (response.body === null ? Promise.resolve() : response.arrayBuffer()).then(() => undefined), - catch: (cause) => new ControlTransportError({ endpoint, reason: "transport", cause }), - }); - -const makeHttpControlTransport = ( - transport: HttpTransportClient["Service"], -): ControlClientTransport => ({ - read: (endpoint) => - Effect.suspend(() => - transport.request(endpoint, CONTROL_STATUS_PATH, { - method: "GET", - headers: { connection: "close" }, - signal: AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), - }), - ).pipe( - Effect.mapError((cause) => controlTransportError(endpoint, cause)), - Effect.flatMap((response) => - response.ok - ? Effect.tryPromise({ - try: () => response.json(), - catch: (cause) => new ControlProtocolError({ endpoint, cause }), - }) - : Effect.fail(new ControlProtocolError({ endpoint, cause: response.status })), - ), - ), - requestStop: (endpoint, request) => - Effect.suspend(() => - transport.request(endpoint, CONTROL_STOP_PATH, { - method: "POST", - body: JSON.stringify(request), - headers: { "content-type": "application/json", connection: "close" }, - signal: AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), - }), - ).pipe( - Effect.mapError((cause) => controlTransportError(endpoint, cause)), - Effect.flatMap((response) => - consumeControlResponse(endpoint, response).pipe(Effect.as(response)), - ), - Effect.flatMap( - ( - response, - ): Effect.Effect< - void, - | ControlTransportError - | ControlProtocolError - | ControlStopConflictError - | ControlMaintenanceBusyError - > => { - if (response.ok) return Effect.void; - if (response.status === 409) - return Effect.fail(new ControlStopConflictError({ endpoint })); - if (response.status === 423) - return Effect.fail(new ControlMaintenanceBusyError({ endpoint })); - // A stop response can be lost after the daemon accepts the request - // (for example while it closes its listener). Treat every non-409 - // HTTP status as ambiguous transport, matching the platform - // transports so callers can observe the fenced owner session. - return Effect.fail( - controlTransportError( - endpoint, - new HttpTransportClientError({ - endpoint, - path: CONTROL_STOP_PATH, - cause: response.status, - reason: "status", - }), - ), - ); - }, - ), - ), -}); - -/** Stable control client backed by the shared HTTP transport service. */ -export const makeHttpControlClient = ( - transport: HttpTransportClient["Service"], -): ControlClientShape => makeControlClient(makeHttpControlTransport(transport)); diff --git a/packages/stack/src/JwtGenerator.ts b/packages/stack/src/JwtGenerator.ts deleted file mode 100644 index 1e4ef1d34b..0000000000 --- a/packages/stack/src/JwtGenerator.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createHmac } from "node:crypto"; - -// Hardcoded opaque key defaults matching Go CLI (pkg/config/apikeys.go:19-20). -// These are client-facing keys for local dev — SDKs use these, not JWTs directly. -export const defaultPublishableKey = "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"; -export const defaultSecretKey = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"; - -/** Well-known dev JWT secret. NOT for production use. */ -export const defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; - -/** - * Pure synchronous JWT generation used while resolving stack configuration. - */ -export function generateJwt(secret: string, role: string): string { - const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); - // oxlint-disable-next-line effecttsgo/global-date -- This synchronous signer must stamp JWT claims at generation time. - const issuedAt = Math.floor(Date.now() / 1000); - const payload = Buffer.from( - JSON.stringify({ - role, - iss: "supabase", - iat: issuedAt, - exp: issuedAt + 60 * 60 * 24 * 365 * 10, - }), - ).toString("base64url"); - const data = `${header}.${payload}`; - const signature = createHmac("sha256", secret).update(data).digest("base64url"); - return `${data}.${signature}`; -} - -export function generateJwks(secret: string): string { - return JSON.stringify({ - keys: [ - { - kty: "oct", - k: Buffer.from(secret).toString("base64url"), - }, - ], - }); -} diff --git a/packages/stack/src/JwtGenerator.unit.test.ts b/packages/stack/src/JwtGenerator.unit.test.ts deleted file mode 100644 index 1a16746206..0000000000 --- a/packages/stack/src/JwtGenerator.unit.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// oxlint-disable effecttsgo/global-date -- JWT assertions compare generated claims against the wall clock used by the native signer. - -import { createHmac } from "node:crypto"; -import { describe, expect, it } from "vitest"; -import { defaultJwtSecret, generateJwks, generateJwt } from "./JwtGenerator.ts"; - -describe("JwtGenerator", () => { - it("generates HS256 JWTs", () => { - const before = Math.floor(Date.now() / 1000); - const role = "anon"; - const jwt = generateJwt(defaultJwtSecret, role); - const after = Math.floor(Date.now() / 1000); - const [header, payload, signature] = jwt.split("."); - - expect(JSON.parse(Buffer.from(header!, "base64url").toString("utf8"))).toEqual({ - alg: "HS256", - typ: "JWT", - }); - expect(JSON.parse(Buffer.from(payload!, "base64url").toString("utf8"))).toEqual({ - role, - iss: "supabase", - iat: expect.any(Number), - exp: expect.any(Number), - }); - - const decodedPayload = JSON.parse(Buffer.from(payload!, "base64url").toString("utf8")) as { - readonly iat: number; - readonly exp: number; - }; - expect(decodedPayload.iat).toBeGreaterThanOrEqual(before); - expect(decodedPayload.iat).toBeLessThanOrEqual(after); - expect(decodedPayload.exp).toBe(decodedPayload.iat + 60 * 60 * 24 * 365 * 10); - expect(signature).toBe( - createHmac("sha256", defaultJwtSecret).update(`${header}.${payload}`).digest("base64url"), - ); - }); - - it("generates an oct JWKS from the local JWT secret", () => { - expect(JSON.parse(generateJwks(defaultJwtSecret))).toEqual({ - keys: [ - { - kty: "oct", - k: Buffer.from(defaultJwtSecret).toString("base64url"), - }, - ], - }); - }); -}); diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts deleted file mode 100644 index a4c4509b3e..0000000000 --- a/packages/stack/src/LocalStack.ts +++ /dev/null @@ -1,1181 +0,0 @@ -import { LogBuffer, Orchestrator } from "@supabase/process-compose"; -import { ServiceNotFoundError } from "@supabase/process-compose"; -import type { - LogEntry, - ResolvedGraph, - ServiceReadyError, - ServiceState, -} from "@supabase/process-compose"; -import { - Context, - Deferred, - Duration, - Effect, - Equal, - Exit, - FileSystem, - Layer, - Match, - Path, - Ref, - Schema, - Scope, - Semaphore, - Stream, - SubscriptionRef, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { FetchHttpClient } from "effect/unstable/http"; -import type { CleanupTargets } from "./CleanupTargets.ts"; -import { cleanupLocalStackResources } from "./cleanup.ts"; -import { - DockerPullError, - StackBuildError, - StackNotRunningError, - StackReadinessError, -} from "./errors.ts"; -import { - clearFunctionsRuntimeConfig, - configureFunctionsRuntime, - resolvedFunctionsBundleSchemaForProject, - type ResolvedFunctionsBundle, -} from "./functions.ts"; -import { detectPlatform, dockerHostAddress } from "./Platform.ts"; -import type { PortLease } from "./PortAllocator.ts"; -import { - activationReadinessPolicy, - activationTargetsForService, - eagerServices, - lifecycleTargetsForService, - StackServiceActivator, -} from "./ServiceActivation.ts"; -import { portFieldsForService } from "./ServicePorts.ts"; -import { preparationClosure, StackPreparation } from "./StackPreparation.ts"; -import type { PreparedStackArtifacts, StackPreparationInput } from "./StackPreparation.ts"; -import { - enabledServicesForConfig, - StackBuilder, - validateResolvedConfig, - versionsForConfig, -} from "./StackBuilder.ts"; -import { resolveReadinessPolicy } from "./StackConfig.ts"; -import type { ReadinessPolicy, ReadyOptions, ResolvedStackConfig } from "./StackConfig.ts"; -import { projectStackStates, type StackServiceProjectionCatalog } from "./StackStateProjection.ts"; -import { StackServiceState } from "./StackServiceState.ts"; -import { Stack } from "./Stack.ts"; -import type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; -import { SERVICE_NAMES, type ServiceName } from "./versions.ts"; - -type LifecyclePhase = "idle" | "starting" | "running" | "stopping" | "stopped" | "disposed"; - -type StackService = typeof Stack.Service; - -const READINESS_DIAGNOSTIC_LOG_LIMIT = 20; -const READINESS_DIAGNOSTIC_LINE_LIMIT = 512; - -/** @internal Enriches a readiness timeout without changing diagnostic failure semantics. */ -export const attachReadinessDiagnostics = ( - error: StackReadinessError, - states: Effect.Effect>, - logs: Effect.Effect>, -): Effect.Effect => - Effect.gen(function* () { - const [serviceStates, entries] = yield* Effect.all([states, logs]); - const nonReadyStates = serviceStates.filter( - (state) => - state.status !== "Healthy" && !(state.status === "Stopped" && state.exitCode === 0), - ); - const stateDetail = - nonReadyStates.length === 0 - ? "none" - : nonReadyStates - .map((state) => { - const errorDetail = - state.error === null - ? "" - : `, error=${state.error.slice(0, READINESS_DIAGNOSTIC_LINE_LIMIT)}`; - return `${state.name}: ${state.status} (desired=${state.desired}, restarts=${state.restartCount}${errorDetail})`; - }) - .join("; "); - const logDetail = - entries.length === 0 - ? "none" - : entries - .map( - (entry) => - `[${entry.service}/${entry.stream}] ${entry.line.slice(0, READINESS_DIAGNOSTIC_LINE_LIMIT)}`, - ) - .join("\n"); - - return new StackReadinessError({ - target: error.target, - timeoutMs: error.timeoutMs, - detail: `${error.detail}\nNon-ready services: ${stateDetail}\nRecent logs:\n${logDetail}`, - }); - }); - -/** Private signal used by the Promise adapter to close its enclosing managed runtime. */ -export class LocalStackLifecycle extends Context.Service< - LocalStackLifecycle, - { - readonly awaitDisposed: Effect.Effect; - readonly isDisposed: Effect.Effect; - } ->()("stack/LocalStackLifecycle") {} - -interface RuntimeState { - readonly orchestrator: Orchestrator["Service"]; - readonly graph: ResolvedGraph; - readonly serviceProjection: StackServiceProjectionCatalog; -} - -const initialPublicStates = (config: ResolvedStackConfig): ReadonlyArray => - enabledServicesForConfig(config).map( - (name) => - new StackServiceState({ - name, - status: "Pending", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - -const stackInfoFor = (config: ResolvedStackConfig): StackInfo => { - const apiUrl = `http://127.0.0.1:${config.apiPort}`; - return { - url: apiUrl, - dbUrl: `postgresql://postgres:postgres@127.0.0.1:${config.dbPort}/postgres`, - publishableKey: config.publishableKey, - secretKey: config.secretKey, - anonJwt: config.anonJwt, - serviceRoleJwt: config.serviceRoleJwt, - serviceEndpoints: { - ...(config.auth === false ? {} : { auth: `${apiUrl}/auth/v1` }), - ...(config.postgrest === false ? {} : { postgrest: `${apiUrl}/rest/v1` }), - ...(config.edgeRuntime === false - ? {} - : { - functions: `${apiUrl}/functions/v1`, - edge_runtime: `${apiUrl}/functions/v1`, - }), - ...(config.realtime === false ? {} : { realtime: `${apiUrl}/realtime/v1` }), - ...(config.storage === false - ? {} - : { - storage: `${apiUrl}/storage/v1`, - storage_s3: `${apiUrl}/storage/v1/s3`, - }), - ...(config.imgproxy === false || config.servicePolicies.imgproxy !== "eager" - ? {} - : { imgproxy: `http://127.0.0.1:${config.imgproxy.port}` }), - ...(config.mailpit === false - ? {} - : { - mailpit: `http://127.0.0.1:${config.mailpit.port}`, - mailpit_smtp: `smtp://127.0.0.1:${config.mailpit.smtpPort}`, - mailpit_pop3: `pop3://127.0.0.1:${config.mailpit.pop3Port}`, - }), - ...(config.pgmeta === false ? {} : { pgmeta: `${apiUrl}/pg` }), - ...(config.studio === false ? {} : { studio: `http://127.0.0.1:${config.studio.port}` }), - ...(config.analytics === false ? {} : { analytics: `${apiUrl}/analytics/v1` }), - ...(config.pooler === false - ? {} - : { - pooler: `postgresql://postgres:postgres@127.0.0.1:${config.pooler.port}/postgres`, - pooler_admin: `http://127.0.0.1:${config.pooler.apiPort}`, - }), - }, - }; -}; - -const changedStatesBetween = ( - previous: ReadonlyArray | undefined, - current: ReadonlyArray, -): ReadonlyArray => { - if (previous === undefined) { - return current; - } - - const previousByName = new Map(previous.map((state) => [state.name, state] as const)); - return current.filter((state) => !Equal.equals(previousByName.get(state.name), state)); -}; - -/** - * The private in-process Stack implementation. Its scoped construction owns - * lifecycle state once and publishes both public seams from that same state. - */ -export const localStackLayer = ( - config: ResolvedStackConfig, - portLease: PortLease, -): Layer.Layer< - Stack | StackServiceActivator | LocalStackLifecycle, - StackBuildError, - | StackBuilder - | StackPreparation - | ChildProcessSpawner.ChildProcessSpawner - | FileSystem.FileSystem - | Path.Path -> => - Layer.effectContext( - Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const scope = yield* Effect.scope; - const preparationScope = yield* Scope.fork(scope, "parallel"); - - const info = stackInfoFor(config); - const enabledServices = enabledServicesForConfig(config); - const stateRef = yield* SubscriptionRef.make(initialPublicStates(config)); - const phaseRef = yield* Ref.make("idle"); - const functionsBundleRef = yield* Ref.make( - config.functions === false ? undefined : config.functions, - ); - const edgeRuntimeConfigRef = yield* Ref.make(config.edgeRuntime); - const disposedSignal = yield* Deferred.make(); - const lifecycleLock = Semaphore.makeUnsafe(1); - const projectionLock = Semaphore.makeUnsafe(1); - - const logBufferServices = yield* Layer.buildWithScope(LogBuffer.layer, scope); - const logBuffer = Context.get(logBufferServices, LogBuffer); - - const updateState = (nextState: StackServiceState) => - SubscriptionRef.update(stateRef, (current) => { - const previous = current.find((entry) => entry.name === nextState.name); - if (Equal.equals(previous, nextState)) { - return current; - } - return current.some((entry) => entry.name === nextState.name) - ? current.map((entry) => (entry.name === nextState.name ? nextState : entry)) - : [...current, nextState]; - }); - - const markDownloading = (service: ServiceName) => - SubscriptionRef.update(stateRef, (current) => { - const index = current.findIndex((entry) => entry.name === service); - if (index === -1 || current[index]?.status === "Downloading") return current; - return current.map((entry, entryIndex) => - entryIndex === index - ? new StackServiceState({ ...entry, status: "Downloading" }) - : entry, - ); - }); - - const restoreStateIfDownloading = ( - service: ServiceName, - previous: StackServiceState | undefined, - ) => - previous === undefined - ? Effect.void - : SubscriptionRef.update(stateRef, (current) => { - const index = current.findIndex((entry) => entry.name === service); - if (index === -1 || current[index]?.status !== "Downloading") return current; - return current.map((entry, entryIndex) => (entryIndex === index ? previous : entry)); - }); - - const syncProjectedStates = ( - orchestrator: Orchestrator["Service"], - serviceProjection: StackServiceProjectionCatalog, - ) => - Effect.gen(function* () { - const rawStates = yield* orchestrator.getAllStates; - yield* Effect.forEach(projectStackStates(rawStates, serviceProjection), updateState, { - discard: true, - }); - }).pipe(projectionLock.withPermit); - - const requireKnownService = (name: string) => - Effect.gen(function* () { - const currentStates = SubscriptionRef.getUnsafe(stateRef); - const match = currentStates.find((state) => state.name === name); - if (match === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - return match; - }); - const requireKnownServiceName = ( - name: string, - ): Effect.Effect => - Effect.gen(function* () { - yield* requireKnownService(name); - const service = SERVICE_NAMES.find((candidate) => candidate === name); - if (service === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - return service; - }); - - let plannedArtifacts: PreparedStackArtifacts | undefined; - let planDeferred: Deferred.Deferred | undefined; - const preparedResolutions: Partial = {}; - const preparationInFlight = new Map< - string, - Deferred.Deferred - >(); - let runtimeState: RuntimeState | undefined; - let runtimeDeferred: Deferred.Deferred | undefined; - let exactCleanupTargets: CleanupTargets | undefined; - - const preparationInput = ( - services: ReadonlyArray, - ): Effect.Effect => { - const shared = { - services, - enabledServices, - versions: versionsForConfig(config), - }; - return config.runtime.mode === "native" - ? Effect.succeed({ ...shared, mode: "native" }) - : Effect.succeed({ - ...shared, - mode: "docker", - containerRuntime: config.runtime.containerRuntime, - }); - }; - - const ensurePlanned = Effect.uninterruptibleMask((restore) => - Effect.suspend(() => { - if (disposed || disposing) { - return Effect.fail( - new StackBuildError({ - detail: "Cannot plan stack assets after stack disposal has begun", - }), - ); - } - if (plannedArtifacts !== undefined) return Effect.succeed(plannedArtifacts); - if (planDeferred !== undefined) return restore(Deferred.await(planDeferred)); - const deferred = Deferred.makeUnsafe(); - planDeferred = deferred; - const effect = Effect.gen(function* () { - yield* validateResolvedConfig(config); - const input = yield* preparationInput(enabledServicesForConfig(config)); - return yield* preparation.plan(input).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to plan stack assets", - cause, - reason: "asset_preparation", - }), - ), - ); - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - plannedArtifacts = value; - }), - ), - Effect.ensuring(Effect.sync(() => (planDeferred = undefined))), - ); - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); - return yield* restore(Deferred.await(deferred)); - }); - }), - ); - - const prepareServices = (services: ReadonlyArray) => - Effect.uninterruptibleMask((restore) => - Effect.suspend(() => { - if (disposed || disposing) { - return Effect.fail( - new StackBuildError({ - detail: "Cannot prepare stack assets after disposal has begun", - }), - ); - } - const targets = [ - ...new Set( - services.flatMap((service) => - activationTargetsForService(enabledServices, service), - ), - ), - ]; - const preparationTargets = preparationClosure(targets, enabledServices); - const pending = preparationTargets.filter( - (service) => preparedResolutions[service] === undefined, - ); - if (pending.length === 0) { - return Effect.succeed({ - resolutions: preparedResolutions, - } satisfies PreparedStackArtifacts); - } - const key = pending.toSorted().join(","); - const existing = preparationInFlight.get(key); - if (existing !== undefined) return restore(Deferred.await(existing)); - const previousStates = new Map( - preparationTargets.flatMap((service) => { - const state = SubscriptionRef.getUnsafe(stateRef).find( - (entry) => entry.name === service, - ); - return state === undefined || state.status === "Downloading" - ? [] - : [[service, state] as const]; - }), - ); - const deferred = Deferred.makeUnsafe(); - preparationInFlight.set(key, deferred); - const effect = preparationInput(pending).pipe( - Effect.flatMap((input) => - Stream.runFoldEffect( - preparation.prepareEvents(input), - () => ({ resolutions: {} }) satisfies PreparedStackArtifacts, - (current, event) => - Match.valueTags(event, { - ServiceDownloadStarted: (download) => - markDownloading(download.service).pipe(Effect.as(current)), - ServiceDownloadFinished: (download) => - restoreStateIfDownloading( - download.service, - previousStates.get(download.service), - ).pipe(Effect.as(current)), - PreparationCompleted: (completed) => Effect.succeed(completed.artifacts), - }), - ), - ), - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to prepare stack assets", - cause, - reason: - cause instanceof DockerPullError && cause.daemonDown - ? "docker_not_running" - : "asset_preparation", - }), - ), - Effect.tapError(() => - Effect.forEach( - preparationTargets, - (service) => { - return restoreStateIfDownloading(service, previousStates.get(service)); - }, - { discard: true, concurrency: "unbounded" }, - ), - ), - Effect.tap((value) => - Effect.sync(() => Object.assign(preparedResolutions, value.resolutions)), - ), - Effect.ensuring(Effect.sync(() => preparationInFlight.delete(key))), - ); - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); - return yield* restore(Deferred.await(deferred)); - }); - }), - ); - - const ensureRuntime = Effect.uninterruptibleMask((restore) => - Effect.suspend(() => { - if (disposed || disposing) { - return Effect.fail( - new StackBuildError({ - detail: "Cannot ensure stack runtime after stack disposal has begun", - }), - ); - } - if (runtimeState !== undefined) { - return Effect.succeed(runtimeState); - } - if (runtimeDeferred !== undefined) return restore(Deferred.await(runtimeDeferred)); - - const deferred = Deferred.makeUnsafe(); - runtimeDeferred = deferred; - - const effect = Effect.gen(function* () { - const prepared = yield* ensurePlanned; - const { graph, serviceProjection, cleanupTargets } = yield* builder - .build(config, prepared) - .pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Scope.Scope, scope), - ); - const graphServices = new Set(graph.startOrder.map((definition) => definition.name)); - const missingEnabledService = enabledServices.find( - (service) => !graphServices.has(service), - ); - if (missingEnabledService !== undefined) { - return yield* new StackBuildError({ - detail: `Prepared graph does not contain enabled service ${missingEnabledService}`, - }); - } - exactCleanupTargets = cleanupTargets; - - const orchLayer = Orchestrator.layer(graph).pipe( - Layer.provide(Layer.succeed(LogBuffer, logBuffer)), - Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), - Layer.provide(FetchHttpClient.layer), - ); - const orchServices = yield* Layer.buildWithScope(orchLayer, scope); - const orchestrator = Context.get(orchServices, Orchestrator); - - yield* syncProjectedStates(orchestrator, serviceProjection); - yield* orchestrator.allStateChanges.pipe( - Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), - Effect.ignore, - Effect.forkIn(scope), - ); - - return { - orchestrator, - graph, - serviceProjection, - } satisfies RuntimeState; - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - runtimeState = value; - }), - ), - Effect.ensuring( - Effect.sync(() => { - runtimeDeferred = undefined; - }), - ), - ); - - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); - return yield* restore(Deferred.await(deferred)); - }); - }), - ); - - let disposed = false; - let disposing = false; - const runtimeHost = Effect.gen(function* () { - const prepared = yield* ensurePlanned; - const platform = yield* detectPlatform; - const edgeRuntimeResolution = prepared.resolutions["edge-runtime"]; - return { - hostname: - edgeRuntimeResolution?.type === "docker" ? dockerHostAddress(platform.os) : "127.0.0.1", - }; - }); - const providePlatform = ( - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); - const decodeFunctionsBundle = (bundle: unknown) => - Schema.decodeUnknownEffect(resolvedFunctionsBundleSchemaForProject(config.projectDir))( - bundle, - ).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Invalid Edge Functions bundle", - cause, - reason: "invalid_config", - }), - ), - ); - const configureFunctions = ( - nextConfig: ResolvedStackConfig, - bundle: ResolvedFunctionsBundle | undefined, - ): Effect.Effect => - Effect.gen(function* () { - yield* providePlatform(configureFunctionsRuntime(nextConfig, yield* runtimeHost, bundle)); - }).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to configure Edge Functions", - cause, - }), - ), - ); - const configWithEdgeRuntimeOptions = ( - opts: EdgeRuntimeReloadConfig, - ): Effect.Effect => - Effect.gen(function* () { - const currentEdgeRuntime = yield* Ref.get(edgeRuntimeConfigRef); - if (currentEdgeRuntime === false || opts.edgeRuntime.enabled === false) { - return yield* new ServiceNotFoundError({ name: "edge-runtime" }); - } - - return { - ...config, - edgeRuntime: { - ...currentEdgeRuntime, - enabled: opts.edgeRuntime.enabled ?? currentEdgeRuntime.enabled, - inspectorPort: opts.edgeRuntime.inspectorPort ?? currentEdgeRuntime.inspectorPort, - policy: opts.edgeRuntime.policy ?? currentEdgeRuntime.policy, - env: opts.edgeRuntime.env ?? currentEdgeRuntime.env, - }, - }; - }); - const publicAllStateChanges = () => - SubscriptionRef.changes(stateRef).pipe( - Stream.mapAccum< - ReadonlyArray | undefined, - ReadonlyArray, - StackServiceState - >( - () => undefined, - (previous, current) => [current, changedStatesBetween(previous, current)], - ), - ); - const withLifecycleLock = lifecycleLock.withPermit; - const syncRuntimeProjectedStates = (runtime: RuntimeState) => - syncProjectedStates(runtime.orchestrator, runtime.serviceProjection); - const serviceStartOptions = { - // Reservation may yield while disposal flips the lifecycle state. - beforeStart: (name: string) => - portLease - .reserve(portFieldsForService(name)) - .pipe(Effect.andThen(requireMutable(`start service ${name}`))), - beforeSpawn: (name: string) => portLease.release(portFieldsForService(name)), - }; - const knownServiceError = (service: string, cause: ServiceNotFoundError) => - new StackBuildError({ - detail: `Prepared graph does not contain enabled service ${service}`, - cause, - }); - const beginStartTargets = ( - root: ServiceName, - allowExplicitlyStopped: ReadonlySet, - ) => - Effect.gen(function* () { - const runtime = yield* ensureRuntime; - const targets = activationTargetsForService(enabledServices, root); - const targetClosure = new Set( - targets.flatMap((target) => - runtime.graph.startOrderFor(target).map((definition) => definition.name), - ), - ); - - for (const dependency of targetClosure) { - const state = yield* runtime.orchestrator - .getState(dependency) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError(dependency, cause)), - ), - ); - const publicDependency = SERVICE_NAMES.find((candidate) => candidate === dependency); - if ( - state.desired === "stopped" && - publicDependency !== undefined && - !allowExplicitlyStopped.has(publicDependency) - ) { - return yield* new StackBuildError({ - detail: `Cannot activate ${root} because dependency ${dependency} was explicitly stopped`, - }); - } - } - - for (const target of targets) { - yield* runtime.orchestrator - .startService(target, serviceStartOptions) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError(target, cause)), - ), - ); - } - return { runtime, targets }; - }); - const waitForTargets = ({ - runtime, - targets, - }: { - readonly runtime: RuntimeState; - readonly targets: ReadonlyArray; - }) => - Effect.gen(function* () { - yield* Effect.forEach( - targets, - (target) => - runtime.orchestrator - .waitReady(target) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError(target, cause)), - ), - ), - { concurrency: "unbounded", discard: true }, - ); - yield* syncRuntimeProjectedStates(runtime); - }); - const inspectStartedTargets = (root: ServiceName) => - Effect.gen(function* () { - const runtime = yield* ensureRuntime; - const targets = activationTargetsForService(enabledServices, root); - const states = yield* Effect.forEach(targets, (target) => - runtime.orchestrator - .getState(target) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError(target, cause)), - ), - ), - ); - if (states.some((state) => state.desired !== "running")) { - return undefined; - } - return { - runtime, - targets, - ready: states.every( - (state) => - state.status === "Healthy" || (state.status === "Stopped" && state.exitCode === 0), - ), - }; - }); - const requireRunningPhase = Effect.gen(function* () { - const phase = yield* Ref.get(phaseRef); - if (phase !== "running") { - return yield* new StackNotRunningError({ phase }); - } - }); - const requireMutable = (operation: string) => - Effect.suspend(() => - disposed || disposing - ? Effect.fail( - new StackBuildError({ - detail: `Cannot ${operation} after stack disposal has begun`, - }), - ) - : Effect.void, - ); - const disposeOnce = () => - Effect.suspend(() => { - disposing = true; - const preparationError = new StackBuildError({ - detail: "Stack disposed during asset preparation", - }); - const failInFlight: Effect.Effect = Effect.gen(function* () { - if (planDeferred !== undefined) { - yield* Deferred.fail(planDeferred, preparationError); - } - for (const deferred of preparationInFlight.values()) { - yield* Deferred.fail(deferred, preparationError); - } - if (runtimeDeferred !== undefined) { - yield* Deferred.fail(runtimeDeferred, preparationError); - } - }); - const cleanup: Effect.Effect = Effect.gen(function* () { - if (disposed) { - return; - } - disposed = true; - yield* Ref.set(phaseRef, "stopping"); - yield* Scope.close(preparationScope, Exit.void); - yield* cleanupLocalStackResources({ - stop: () => - runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop, - cleanupTargets: exactCleanupTargets ?? { dockerContainerNames: [] }, - config, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.ensuring(providePlatform(clearFunctionsRuntimeConfig(config.runtimeRoot))), - Effect.ensuring(portLease.releaseAll), - Effect.ensuring(Ref.set(phaseRef, "disposed")), - ); - }).pipe(withLifecycleLock); - return failInFlight.pipe(Effect.andThen(cleanup)); - }).pipe( - Effect.ensuring(Deferred.succeed(disposedSignal, undefined).pipe(Effect.asVoid)), - Effect.uninterruptible, - ); - - const withReadinessPolicy = ( - effect: Effect.Effect, - target: string, - readyOptions?: ReadyOptions, - ): Effect.Effect => { - const policy: ReadinessPolicy = resolveReadinessPolicy({ - readyOptions, - stackPolicy: config.readiness, - }); - if (policy.mode === "infinite") { - return effect; - } - return effect.pipe( - Effect.timeoutOrElse({ - duration: Duration.millis(policy.timeoutMs), - orElse: () => - Effect.fail( - new StackReadinessError({ - target, - timeoutMs: policy.timeoutMs, - detail: `Timed out waiting for ${target} readiness after ${policy.timeoutMs}ms`, - }), - ), - }), - ); - }; - const readinessErrorWithDiagnostics = ( - error: StackReadinessError, - ): Effect.Effect => - runtimeState === undefined - ? Effect.succeed(error) - : attachReadinessDiagnostics( - error, - runtimeState.orchestrator.getAllStates, - logBuffer.historyAll(READINESS_DIAGNOSTIC_LOG_LIMIT), - ); - const cleanupOnReadinessFailure = ( - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe( - Effect.catchIf( - (error): error is StackReadinessError => error instanceof StackReadinessError, - (error) => - readinessErrorWithDiagnostics(error).pipe( - Effect.flatMap(Effect.fail), - Effect.ensuring(disposeOnce()), - ), - ), - ); - yield* Effect.addFinalizer(disposeOnce); - - const activateService = (name: ServiceName) => - Effect.gen(function* () { - yield* requireMutable(`activate service ${name}`); - yield* requireRunningPhase; - const service = yield* requireKnownServiceName(name); - const existing = yield* inspectStartedTargets(service); - if (existing?.ready === true) { - // Close the race with a concurrent stack stop before taking - // the lock-free healthy-request fast path. - yield* requireRunningPhase; - return; - } - if (existing !== undefined) { - yield* waitForTargets(existing).pipe((effect) => - withReadinessPolicy( - effect, - name, - activationReadinessPolicy(service, config.readiness, config.readinessSource), - ), - ); - return; - } - yield* prepareServices([service]); - const started = yield* Effect.gen(function* () { - yield* requireRunningPhase; - const concurrentlyStarted = yield* inspectStartedTargets(service); - if (concurrentlyStarted !== undefined) return concurrentlyStarted; - return yield* beginStartTargets(service, new Set()); - }).pipe(withLifecycleLock); - yield* waitForTargets(started).pipe((effect) => - withReadinessPolicy( - effect, - name, - activationReadinessPolicy(service, config.readiness, config.readinessSource), - ), - ); - }).pipe(cleanupOnReadinessFailure); - - const stack = { - getInfo: Effect.succeed(info), - start: Effect.suspend(() => { - let serviceStartupBegan = false; - return Effect.gen(function* () { - yield* requireMutable("start"); - yield* Ref.set(phaseRef, "starting"); - const runtime = yield* ensureRuntime; - yield* configureFunctions(config, yield* Ref.get(functionsBundleRef)); - - const eager = eagerServices(enabledServices, config.servicePolicies); - const allServicesEager = eager.length === enabledServices.length; - // The all-eager case can use one topological whole-graph start; - // mixed policies need the more general per-service preparation path. - if (!allServicesEager) { - const readiness: Array> = []; - yield* prepareServices(["postgres", ...eager]); - yield* requireMutable("start"); - if ( - runtime.graph.startOrder.some((definition) => definition.name === "postgres-init") - ) { - serviceStartupBegan = true; - yield* runtime.orchestrator - .startService("postgres-init", serviceStartOptions) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError("postgres-init", cause)), - ), - ); - readiness.push( - runtime.orchestrator - .waitReady("postgres-init") - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError("postgres-init", cause)), - ), - ), - ); - } - for (const service of eager) { - yield* requireMutable("start"); - serviceStartupBegan = true; - const started = yield* beginStartTargets( - service, - // Whole-stack startup owns every enabled service, including - // lazy transitive dependencies of eager services (for - // example Studio -> pgmeta). Explicit startService calls - // keep their narrower activation allowlist below. - new Set(enabledServices), - ); - readiness.push(waitForTargets(started)); - } - yield* Effect.all(readiness, { concurrency: "unbounded", discard: true }).pipe( - (effect) => withReadinessPolicy(effect, "stack"), - ); - yield* syncRuntimeProjectedStates(runtime); - } else { - yield* prepareServices(enabledServices); - yield* requireMutable("start"); - serviceStartupBegan = true; - yield* runtime.orchestrator.start(serviceStartOptions); - yield* runtime.orchestrator.waitAllReady.pipe((effect) => - withReadinessPolicy(effect, "stack"), - ); - yield* syncRuntimeProjectedStates(runtime); - } - yield* requireMutable("start"); - yield* Ref.set(phaseRef, "running"); - }).pipe( - Effect.onError(() => Ref.set(phaseRef, "stopped")), - withLifecycleLock, - cleanupOnReadinessFailure, - Effect.onError(() => (serviceStartupBegan ? disposeOnce() : Effect.void)), - ); - }), - stop: Effect.gen(function* () { - if (disposed) { - return; - } - if (runtimeState === undefined) { - yield* Ref.set(phaseRef, "stopped"); - return; - } - yield* Ref.set(phaseRef, "stopping"); - yield* runtimeState.orchestrator.stop; - yield* Ref.set(phaseRef, "stopped"); - }).pipe(withLifecycleLock), - dispose: disposeOnce(), - startService: (name) => - Effect.gen(function* () { - yield* requireMutable(`start service ${name}`); - yield* requireRunningPhase; - const service = yield* requireKnownServiceName(name); - yield* prepareServices([service]); - const started = yield* Effect.gen(function* () { - yield* requireMutable(`start service ${name}`); - yield* requireRunningPhase; - return yield* beginStartTargets( - service, - new Set(lifecycleTargetsForService(enabledServices, service)), - ); - }).pipe(withLifecycleLock); - yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); - }).pipe(cleanupOnReadinessFailure), - stopService: (name) => - Effect.gen(function* () { - yield* requireMutable(`stop service ${name}`); - yield* requireRunningPhase; - const service = yield* requireKnownServiceName(name); - const runtime = yield* ensureRuntime; - for (const target of lifecycleTargetsForService( - enabledServices, - service, - ).toReversed()) { - yield* runtime.orchestrator.stopService(target); - } - // Settle the public projection before returning so callers observe - // the stop immediately, matching the start/restart/waitReady paths. - yield* syncRuntimeProjectedStates(runtime); - }).pipe(withLifecycleLock), - restartService: (name) => - Effect.gen(function* () { - yield* requireMutable(`restart service ${name}`); - yield* requireRunningPhase; - const service = yield* requireKnownServiceName(name); - yield* prepareServices([service]); - const started = yield* Effect.gen(function* () { - yield* requireMutable(`restart service ${name}`); - yield* requireRunningPhase; - const runtime = yield* ensureRuntime; - yield* runtime.orchestrator.restartService(service, serviceStartOptions); - return { runtime, targets: [service] }; - }).pipe(withLifecycleLock); - yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); - }).pipe(cleanupOnReadinessFailure), - reloadFunctions: (opts) => - Effect.gen(function* () { - yield* requireMutable("reload functions"); - yield* requireRunningPhase; - yield* requireKnownService("edge-runtime"); - const requestedBundle = - opts?.functions === undefined - ? undefined - : yield* decodeFunctionsBundle(opts.functions); - yield* prepareServices(["edge-runtime"]); - const started = yield* Effect.gen(function* () { - yield* requireMutable("reload functions"); - yield* requireRunningPhase; - const currentBundle = yield* Ref.get(functionsBundleRef); - const nextBundle = requestedBundle ?? currentBundle; - yield* configureFunctions(config, nextBundle); - yield* Ref.set(functionsBundleRef, nextBundle); - const runtime = yield* ensureRuntime; - const state = yield* runtime.orchestrator.getState("edge-runtime"); - if (state.desired !== "running") { - return yield* beginStartTargets("edge-runtime", new Set(["edge-runtime"])); - } - yield* runtime.orchestrator.restartService("edge-runtime", serviceStartOptions); - return { runtime, targets: ["edge-runtime"] as const }; - }).pipe(withLifecycleLock); - yield* waitForTargets(started).pipe((effect) => - withReadinessPolicy(effect, "edge-runtime"), - ); - }).pipe(cleanupOnReadinessFailure), - reloadEdgeRuntime: (opts) => - Effect.gen(function* () { - yield* requireMutable("reload Edge Runtime"); - yield* requireRunningPhase; - yield* requireKnownService("edge-runtime"); - if (opts.edgeRuntime.enabled === false) { - return yield* new ServiceNotFoundError({ name: "edge-runtime" }); - } - const requestedBundle = - opts.functions === undefined - ? undefined - : yield* decodeFunctionsBundle(opts.functions); - yield* prepareServices(["edge-runtime"]); - const started = yield* Effect.gen(function* () { - yield* requireMutable("reload Edge Runtime"); - yield* requireRunningPhase; - const nextConfig = yield* configWithEdgeRuntimeOptions(opts); - const currentBundle = yield* Ref.get(functionsBundleRef); - const nextBundle = requestedBundle ?? currentBundle; - const prepared = yield* ensurePlanned; - const runtime = yield* ensureRuntime; - const buildResult = yield* builder - .build(nextConfig, prepared) - .pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Scope.Scope, scope), - ); - const edgeRuntimeDef = buildResult.graph.startOrder.find( - (def) => def.name === "edge-runtime", - ); - - if (edgeRuntimeDef === undefined) { - return yield* new ServiceNotFoundError({ name: "edge-runtime" }); - } - - yield* configureFunctions(nextConfig, nextBundle); - yield* Ref.set(functionsBundleRef, nextBundle); - yield* runtime.orchestrator - .updateServiceDefinition("edge-runtime", edgeRuntimeDef) - .pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to update edge-runtime service definition", - cause, - }), - ), - ); - yield* Ref.set(edgeRuntimeConfigRef, nextConfig.edgeRuntime); - const state = yield* runtime.orchestrator.getState("edge-runtime"); - if (state.desired !== "running") { - return yield* beginStartTargets("edge-runtime", new Set(["edge-runtime"])); - } - yield* runtime.orchestrator.restartService("edge-runtime", serviceStartOptions); - return { runtime, targets: ["edge-runtime"] as const }; - }).pipe(withLifecycleLock); - yield* waitForTargets(started).pipe((effect) => - withReadinessPolicy(effect, "edge-runtime"), - ); - }).pipe(cleanupOnReadinessFailure), - getState: (name) => - Effect.gen(function* () { - const currentStates = SubscriptionRef.getUnsafe(stateRef); - const match = currentStates.find((state) => state.name === name); - if (match === undefined) { - return yield* new ServiceNotFoundError({ name }); - } - return match; - }), - getAllStates: Effect.sync(() => SubscriptionRef.getUnsafe(stateRef)), - stateChanges: (name) => - Effect.gen(function* () { - yield* requireKnownService(name); - return Stream.filter(publicAllStateChanges(), (state) => state.name === name); - }), - allStateChanges: publicAllStateChanges(), - waitReady: (name, opts) => - Effect.gen(function* () { - const phase = yield* Ref.get(phaseRef); - if (phase !== "running") { - return yield* new StackBuildError({ - detail: `Cannot wait for service ${name} while the stack is ${phase}`, - }); - } - yield* requireKnownServiceName(name); - const runtime = yield* ensureRuntime; - yield* runtime.orchestrator - .waitReady(name) - .pipe((effect) => withReadinessPolicy(effect, name, opts)); - yield* syncRuntimeProjectedStates(runtime); - }).pipe(cleanupOnReadinessFailure), - waitAllReady: (opts) => - Effect.gen(function* () { - const phase = yield* Ref.get(phaseRef); - if (phase !== "running") { - return yield* new StackBuildError({ - detail: `Cannot wait for stack readiness while the stack is ${phase}`, - }); - } - const runtime = yield* ensureRuntime; - yield* runtime.orchestrator.waitAllReady.pipe((effect) => - withReadinessPolicy(effect, "stack", opts), - ); - yield* syncRuntimeProjectedStates(runtime); - }).pipe(cleanupOnReadinessFailure), - subscribeLogs: (name) => - Stream.unwrap(requireKnownService(name).pipe(Effect.as(logBuffer.subscribe(name)))), - subscribeAllLogs: (services) => - services === undefined || services.length === 0 - ? logBuffer.subscribeAll - : Stream.unwrap( - Effect.forEach(services, requireKnownService, { discard: true }).pipe( - Effect.as( - logBuffer.subscribeAll.pipe( - Stream.filter((entry) => services.includes(entry.service)), - ), - ), - ), - ), - logHistory: (name, limit) => - requireKnownService(name).pipe(Effect.andThen(logBuffer.history(name, limit))), - logHistoryAll: (limit, services) => - services === undefined || services.length === 0 - ? logBuffer.historyAll(limit) - : Effect.forEach(services, requireKnownService, { discard: true }).pipe( - Effect.andThen(logBuffer.historyAll(limit, services)), - ), - } satisfies StackService; - - return Context.make(Stack, stack).pipe( - Context.add(StackServiceActivator, { activate: activateService }), - Context.add(LocalStackLifecycle, { - awaitDisposed: Deferred.await(disposedSignal), - isDisposed: Effect.sync(() => disposed), - }), - ); - }), - ); diff --git a/packages/stack/src/Platform.ts b/packages/stack/src/Platform.ts deleted file mode 100644 index cf819bf33c..0000000000 --- a/packages/stack/src/Platform.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Effect } from "effect"; - -export interface PlatformInfo { - readonly os: string; - readonly arch: string; -} - -/** Native slim-service release targets. The release set intentionally has no - * windows or x64 macOS artifacts. */ -export type NativeTarget = "darwin-arm64" | "linux-amd64" | "linux-arm64"; - -export const nativeTargetForPlatform = (platform: PlatformInfo): NativeTarget | undefined => { - if (platform.os === "darwin" && platform.arch === "arm64") return "darwin-arm64"; - if (platform.os === "linux" && platform.arch === "x64") return "linux-amd64"; - if (platform.os === "linux" && platform.arch === "arm64") return "linux-arm64"; - return undefined; -}; - -export const detectPlatform: Effect.Effect = Effect.sync(() => ({ - os: process.platform, - arch: process.arch, -})); - -/** Host address that Docker containers should use to reach services on the host machine. */ -export const dockerHostAddress = (_os: string): string => "host.docker.internal"; - -const dockerHostGatewayArgs = (os: string): readonly string[] => - os === "linux" ? ["--add-host", "host.docker.internal:host-gateway"] : []; - -/** - * Docker networking args. We publish ports on every platform so container ports stay fixed - * and host ports can be randomized consistently. Linux needs an explicit host-gateway alias - * for host.docker.internal; Docker Desktop provides that name on macOS/Windows. - */ -export const dockerNetworkArgs = (os: string, ports: readonly number[]): readonly string[] => - dockerPortMapArgs( - os, - ports.map((port) => ({ host: port, container: port })), - ); - -export const dockerPortMapArgs = ( - os: string, - mappings: ReadonlyArray<{ - readonly host: number; - readonly container: number; - }>, -): readonly string[] => [ - ...dockerHostGatewayArgs(os), - ...mappings.flatMap(({ host, container }) => ["-p", `${host}:${container}`]), -]; diff --git a/packages/stack/src/Platform.unit.test.ts b/packages/stack/src/Platform.unit.test.ts deleted file mode 100644 index 67a8095557..0000000000 --- a/packages/stack/src/Platform.unit.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { - detectPlatform, - dockerHostAddress, - dockerNetworkArgs, - nativeTargetForPlatform, -} from "./Platform.ts"; - -describe("detectPlatform", () => { - it.effect("returns current platform info", () => - Effect.gen(function* () { - const info = yield* detectPlatform; - expect(info.os).toBeDefined(); - expect(info.arch).toBeDefined(); - expect(["darwin", "linux"]).toContain(info.os); - expect(["arm64", "x64"]).toContain(info.arch); - }), - ); -}); - -describe("nativeTargetForPlatform", () => { - it("maps darwin-arm64", () => { - expect(nativeTargetForPlatform({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64"); - }); - - it("maps linux-x64", () => { - expect(nativeTargetForPlatform({ os: "linux", arch: "x64" })).toBe("linux-amd64"); - }); - - it("maps linux-arm64", () => { - expect(nativeTargetForPlatform({ os: "linux", arch: "arm64" })).toBe("linux-arm64"); - }); - - it("returns null for unsupported", () => { - expect(nativeTargetForPlatform({ os: "win32", arch: "x64" })).toBeUndefined(); - }); -}); - -describe("dockerHostAddress", () => { - it("returns host.docker.internal on linux", () => { - expect(dockerHostAddress("linux")).toBe("host.docker.internal"); - }); - - it("returns host.docker.internal on darwin", () => { - expect(dockerHostAddress("darwin")).toBe("host.docker.internal"); - }); - - it("returns host.docker.internal on win32", () => { - expect(dockerHostAddress("win32")).toBe("host.docker.internal"); - }); -}); - -describe("dockerNetworkArgs", () => { - it("returns host gateway and port mapping on linux", () => { - expect(dockerNetworkArgs("linux", [5432])).toEqual([ - "--add-host", - "host.docker.internal:host-gateway", - "-p", - "5432:5432", - ]); - }); - - it("returns port mapping on darwin", () => { - expect(dockerNetworkArgs("darwin", [9999])).toEqual(["-p", "9999:9999"]); - }); - - it("maps multiple ports on non-linux", () => { - expect(dockerNetworkArgs("darwin", [5432, 9999])).toEqual([ - "-p", - "5432:5432", - "-p", - "9999:9999", - ]); - }); -}); diff --git a/packages/stack/src/PortAllocator.integration.test.ts b/packages/stack/src/PortAllocator.integration.test.ts deleted file mode 100644 index 733917ed14..0000000000 --- a/packages/stack/src/PortAllocator.integration.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-error-in-effect-failure, effecttsgo/global-timers-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Port allocation tests drive native TCP listeners and child processes, including intentionally pending callbacks and process errors. - -import { spawn } from "node:child_process"; -import { once } from "node:events"; -import { createServer, type Server } from "node:net"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { NodeFileSystem } from "@effect/platform-node"; -import { Cause, Effect, Exit, FileSystem } from "effect"; -import { reservePortSet, type PortReservationRequest } from "./PortAllocator.ts"; - -const PORT_LEASE_CHILD = resolve(import.meta.dirname, "../tests/helpers/port-lease-child.ts"); -const STACK_PACKAGE_ROOT = resolve(import.meta.dirname, ".."); - -const INTERRUPTED_ALLOCATION_SCRIPT = ` -import { NodeFileSystem } from "@effect/platform-node"; -import { Effect, Fiber } from "effect"; -import { Server } from "node:net"; - -let markBound; -const bound = new Promise((resolve) => { - markBound = resolve; -}); -const originalListen = Server.prototype.listen; -Server.prototype.listen = function (...args) { - if (typeof args.at(-1) === "function") args.pop(); - return originalListen.call(this, ...args, () => { - Server.prototype.listen = originalListen; - markBound(); - }); -}; - -const { reservePortSet } = await import("./src/PortAllocator.ts"); - -const fiber = Effect.runFork( - reservePortSet([{ field: "apiPort", selection: { kind: "automatic" } }]).pipe( - Effect.provide(NodeFileSystem.layer), - ), -); -await bound; -await Effect.runPromise(Fiber.interrupt(fiber)); -`; - -const interruptedAllocationExits = (runtime: "node" | "bun"): Effect.Effect => - Effect.callback((resume) => { - const args = - runtime === "node" - ? ["--input-type=module", "-e", INTERRUPTED_ALLOCATION_SCRIPT] - : ["-e", INTERRUPTED_ALLOCATION_SCRIPT]; - const child = spawn(runtime, args, { - cwd: STACK_PACKAGE_ROOT, - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - let settled = false; - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - const finish = (effect: Effect.Effect) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - resume(effect); - }; - const timeout = setTimeout(() => { - child.kill("SIGKILL"); - finish( - Effect.fail( - new Error(`${runtime} retained a listener after interrupted allocation: ${stderr}`), - ), - ); - }, 20_000); - child.once("error", (error) => finish(Effect.fail(error))); - child.once("close", (code, signal) => - finish( - code === 0 - ? Effect.void - : Effect.fail( - new Error(`${runtime} allocator probe exited with ${code ?? signal}: ${stderr}`), - ), - ), - ); - return Effect.sync(() => { - clearTimeout(timeout); - if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); - }); - }); - -const automatic = (field: PortReservationRequest["field"]): PortReservationRequest => ({ - field, - selection: { kind: "automatic" }, -}); - -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer))); - -const occupyFreePort = () => - Effect.acquireRelease( - Effect.callback((resume) => { - const server = createServer(); - server.once("error", (error) => resume(Effect.fail(error))); - server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); - return Effect.sync(() => server.close()); - }).pipe( - Effect.map((server) => { - const address = server.address(); - if (address === null || typeof address === "string") throw new Error("Expected address"); - return { port: address.port, server }; - }), - ), - ({ server }) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - return Effect.void; - }), - ); - -const startChildLease = () => { - const child = spawn("bun", ["run", PORT_LEASE_CHILD], { - stdio: ["pipe", "pipe", "pipe"], - }); - const ready = new Promise<{ readonly apiPort: number; readonly dbPort: number }>( - (resolveReady, rejectReady) => { - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - const newline = stdout.indexOf("\n"); - if (newline === -1) return; - try { - resolveReady(JSON.parse(stdout.slice(0, newline))); - } catch (error) { - rejectReady(new Error(`Invalid child response: ${stdout}`, { cause: error })); - } - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.once("error", rejectReady); - child.once("close", (code) => { - rejectReady(new Error(`Child exited before readiness (${code}): ${stderr}`)); - }); - }, - ); - return { child, ready }; -}; - -describe("reservePortSet", () => { - it.each(["node", "bun"] as const)( - "closes a pending listener when allocation is interrupted under %s", - async (runtime) => { - await Effect.runPromise(interruptedAllocationExits(runtime)); - }, - 30_000, - ); - - it("fails an occupied exact port with field and port attribution", async () => { - let occupiedPort = 0; - const exit = await run( - Effect.scoped( - Effect.gen(function* () { - const occupied = yield* occupyFreePort(); - occupiedPort = occupied.port; - return yield* reservePortSet([ - { field: "apiPort", selection: { kind: "exact", port: occupied.port } }, - ]).pipe(Effect.exit); - }), - ), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toMatchObject({ field: "apiPort", port: occupiedPort }); - } - }); - - it("reserves multiple automatic fields and re-reserves selected fields", async () => { - const lease = await run(reservePortSet([automatic("apiPort"), automatic("dbPort")])); - try { - expect(lease.ports.apiPort).toBeGreaterThan(0); - expect(lease.ports.dbPort).toBeGreaterThan(0); - await run(lease.release(["dbPort"])); - await run(lease.reserve(["dbPort"])); - } finally { - await run(lease.releaseAll); - } - }); - - it("retains claims after TCP release until releaseAll", async () => { - const lease = await run(reservePortSet([automatic("apiPort")])); - const port = lease.ports.apiPort; - if (port === undefined) throw new Error("Expected API port"); - try { - await run(lease.release(["apiPort"])); - const blocked = await run( - reservePortSet([{ field: "apiPort", selection: { kind: "exact", port } }]).pipe( - Effect.exit, - ), - ); - expect(Exit.isFailure(blocked)).toBe(true); - } finally { - await run(lease.releaseAll); - } - }); - - it("keeps automatic ports disjoint across child processes", async () => { - const first = startChildLease(); - const second = startChildLease(); - try { - const [left, right] = await Promise.all([first.ready, second.ready]); - const ports = [left.apiPort, left.dbPort, right.apiPort, right.dbPort]; - expect(new Set(ports).size).toBe(ports.length); - } finally { - for (const child of [first.child, second.child]) { - if (child.exitCode === null) { - child.stdin.end("release\n"); - await once(child, "close"); - } - } - } - }, 30_000); - - it("recovers a stale claim left by an unclean child exit", async () => { - const child = startChildLease(); - const ports = await child.ready; - child.child.kill("SIGKILL"); - await once(child.child, "close"); - const lease = await run( - reservePortSet([{ field: "apiPort", selection: { kind: "exact", port: ports.apiPort } }]), - ); - await run(lease.releaseAll); - }, 30_000); - - it("rolls back earlier fields when a later exact field is unavailable", async () => { - const failed = await run( - Effect.scoped( - Effect.gen(function* () { - const occupied = yield* occupyFreePort(); - return yield* reservePortSet([ - automatic("apiPort"), - { field: "dbPort", selection: { kind: "exact", port: occupied.port } }, - ]).pipe(Effect.exit); - }), - ), - ); - expect(Exit.isFailure(failed)).toBe(true); - const retry = await run(reservePortSet([automatic("apiPort")])); - await run(retry.releaseAll); - }); -}); diff --git a/packages/stack/src/PortAllocator.ts b/packages/stack/src/PortAllocator.ts deleted file mode 100644 index 09ea2ce5ee..0000000000 --- a/packages/stack/src/PortAllocator.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { createServer, type Server } from "node:net"; -import { tmpdir } from "node:os"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Port claim paths are native temporary filesystem boundary values. -import { join } from "node:path"; -import { - Cause, - Data, - Effect, - Exit, - FileSystem, - Option, - Predicate, - Schema, - Semaphore, -} from "effect"; -import { PlatformError } from "effect/PlatformError"; -import { PortSetSchema, type PortField, type PortSet } from "./PortCatalog.ts"; - -export class PortAllocationError extends Data.TaggedError("PortAllocationError")<{ - readonly detail: string; - readonly cause?: unknown; - readonly field?: PortField; - readonly port?: number; - readonly reason?: "unavailable" | "failed"; -}> { - override get message(): string { - return this.detail; - } -} - -class PortClaimCollisionError extends Data.TaggedError("PortClaimCollisionError")<{ - readonly port: number; -}> {} - -const MAX_CLAIM_ATTEMPTS = 32; - -export type PortSelection = - | { readonly kind: "exact"; readonly port: number } - | { - readonly kind: "automatic"; - readonly preferred?: number; - /** Additional exclusions used by managed allocation (for example control ports). */ - readonly excluded?: ReadonlySet; - }; - -export interface PortReservationRequest { - readonly field: PortField; - readonly selection: PortSelection; -} - -export interface PortSelectionOptions { - readonly reserved?: ReadonlySet; -} - -const closeServer = (server: Server): Effect.Effect => - Effect.callback((resume) => { - server.close((cause) => - resume( - cause === undefined || - (typeof cause === "object" && - cause !== null && - "code" in cause && - Reflect.get(cause, "code") === "ERR_SERVER_NOT_RUNNING") - ? Effect.void - : Effect.die(cause), - ), - ); - return Effect.void; - }); - -interface BoundPort { - readonly port: number; - readonly server: Server; -} - -interface PortClaim { - readonly path: string; - readonly port: number; - readonly token: string; -} - -interface ClaimRecord { - readonly pid: number; - readonly token: string; -} - -interface ClaimSnapshot { - readonly contents: string; - readonly record: ClaimRecord | undefined; - readonly info: FileSystem.File.Info; -} - -const claimNamespace = (): string => { - const uid = process.getuid?.(); - if (uid !== undefined) return `uid-${uid}`; - // oxlint-disable-next-line effecttsgo/process-env -- Claim namespace fallback is computed before an Effect runtime exists. - const username = process.env.USER ?? process.env.USERNAME ?? "unknown"; - const safeUsername = username.replace(/[^a-zA-Z0-9._-]/g, "_") || "unknown"; - return `user-${safeUsername}`; -}; - -const CLAIM_ROOT = join(tmpdir(), `supabase-stack-port-claims-${claimNamespace()}`); -const CLAIM_STALE_AFTER_MS = 30_000; - -const claimPath = (port: number, root = CLAIM_ROOT): string => join(root, `port-${port}`); - -const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (cause) { - return typeof cause === "object" && cause !== null && "code" in cause - ? Reflect.get(cause, "code") !== "ESRCH" - : false; - } -}; - -const isNotFound = (error: PlatformError): boolean => Predicate.isTagged(error.reason, "NotFound"); -const isAlreadyExists = (error: PlatformError): boolean => - Predicate.isTagged(error.reason, "AlreadyExists"); - -const parseClaimRecord = (contents: string): ClaimRecord | undefined => { - try { - const value: unknown = JSON.parse(contents); - if (typeof value !== "object" || value === null) return undefined; - const pid = Reflect.get(value, "pid"); - const token = Reflect.get(value, "token"); - return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && typeof token === "string" - ? { pid, token } - : undefined; - } catch { - return undefined; - } -}; - -const readClaimSnapshot = ( - path: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const contents = yield* fs - .readFileString(path) - .pipe( - Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.void : Effect.fail(error), - ), - ); - if (contents === undefined) return undefined; - const info = yield* fs - .stat(path) - .pipe( - Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.void : Effect.fail(error), - ), - ); - if (info === undefined) return undefined; - return { contents, record: parseClaimRecord(contents), info }; - }); - -const claimIsStale = (snapshot: ClaimSnapshot, now: number): boolean => { - if (snapshot.info.type !== "File") return false; - if (snapshot.record !== undefined) return !isProcessAlive(snapshot.record.pid); - return ( - Option.isSome(snapshot.info.mtime) && - now - snapshot.info.mtime.value.getTime() > CLAIM_STALE_AFTER_MS - ); -}; - -const inspectClaim = ( - path: string, -): Effect.Effect< - { readonly snapshot: ClaimSnapshot; readonly stale: boolean } | undefined, - PlatformError, - FileSystem.FileSystem -> => - Effect.gen(function* () { - const snapshot = yield* readClaimSnapshot(path); - if (snapshot === undefined) return undefined; - // Claim mtimes come from the native filesystem wall clock; compare them - // against the same wall-clock source rather than Effect's virtual Clock. - // oxlint-disable-next-line effecttsgo/global-date-in-effect -- Native filesystem mtime staleness requires wall-clock time at this leaf boundary. - return { snapshot, stale: claimIsStale(snapshot, Date.now()) }; - }); - -const claimIdentityMatches = (expected: ClaimSnapshot, current: ClaimSnapshot): boolean => { - if (expected.record !== undefined || current.record !== undefined) { - return ( - expected.record !== undefined && - current.record !== undefined && - expected.record.pid === current.record.pid && - expected.record.token === current.record.token - ); - } - if (expected.contents !== current.contents) return false; - if (Option.isSome(expected.info.ino) && Option.isSome(current.info.ino)) { - return expected.info.ino.value === current.info.ino.value; - } - return false; -}; - -const removeCreatedClaim = ( - path: string, - contents: string, - openedInfo: FileSystem.File.Info | undefined, - opened: boolean, - fs: FileSystem.FileSystem, -): Effect.Effect => - Effect.gen(function* () { - if (!opened) return; - const current = yield* readClaimSnapshot(path).pipe(Effect.orElseSucceed(() => undefined)); - if (current === undefined) return; - if (openedInfo === undefined) { - if (current.contents.length !== 0) return; - } else if (Option.isSome(openedInfo.ino) && Option.isSome(current.info.ino)) { - if (openedInfo.ino.value !== current.info.ino.value) return; - } else { - if (current.contents !== contents) return; - if ( - !Option.isSome(openedInfo.mtime) || - !Option.isSome(current.info.mtime) || - openedInfo.mtime.value.getTime() !== current.info.mtime.value.getTime() - ) { - return; - } - } - yield* fs.remove(path, { force: true }).pipe(Effect.ignore); - }).pipe(Effect.provideService(FileSystem.FileSystem, fs)); - -const readClaimRecord = ( - path: string, -): Effect.Effect => - readClaimSnapshot(path).pipe(Effect.map((snapshot) => snapshot?.record)); - -const removeStaleClaim = ( - path: string, - expected: ClaimSnapshot, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const current = yield* readClaimSnapshot(path).pipe(Effect.orElseSucceed(() => undefined)); - if (current === undefined || !claimIdentityMatches(expected, current)) return false; - yield* fs - .remove(path, { force: true }) - .pipe( - Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.void : Effect.fail(error), - ), - ); - return true; - }); - -const acquirePortClaimInternal = ( - port: number, - root = CLAIM_ROOT, -): Effect.Effect< - PortClaim, - PlatformError | PortAllocationError | PortClaimCollisionError, - FileSystem.FileSystem -> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(root, { recursive: true }); - const path = claimPath(port, root); - const token = randomUUID(); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Atomic claim records are a tiny native filesystem protocol payload. - const contents = JSON.stringify({ pid: process.pid, token }); - - for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) { - let openedInfo: FileSystem.File.Info | undefined; - let created = false; - const openedExit = yield* Effect.exit( - Effect.scoped( - fs.open(path, { flag: "wx", mode: 0o600 }).pipe( - Effect.flatMap((handle) => { - created = true; - return handle.stat.pipe( - Effect.tap((info) => Effect.sync(() => (openedInfo = info))), - Effect.andThen(handle.writeAll(new TextEncoder().encode(contents))), - ); - }), - ), - ), - ); - if (Exit.isSuccess(openedExit)) return { path, port, token }; - yield* Effect.uninterruptible(removeCreatedClaim(path, contents, openedInfo, created, fs)); - const failure = Cause.findErrorOption(openedExit.cause); - if (Option.isNone(failure)) return yield* Effect.failCause(openedExit.cause); - if (!isAlreadyExists(failure.value)) { - return yield* failure.value; - } - const inspection = yield* inspectClaim(path); - if (inspection === undefined) continue; - if (!inspection.stale) { - return yield* new PortClaimCollisionError({ port }); - } - if (!(yield* removeStaleClaim(path, inspection.snapshot))) continue; - } - return yield* new PortAllocationError({ - detail: `Failed to claim port ${port} after ${MAX_CLAIM_ATTEMPTS} attempts`, - port, - reason: "failed", - }); - }); - -const portAllocationFromCause = (port: number, cause: unknown): PortAllocationError => - cause instanceof PortAllocationError - ? cause - : new PortAllocationError({ - detail: `Failed to claim port ${port}`, - cause, - port, - reason: "failed", - }); - -const acquirePortClaim = ( - port: number, - root = CLAIM_ROOT, -): Effect.Effect => - acquirePortClaimInternal(port, root).pipe( - Effect.mapError((cause) => - cause instanceof PortClaimCollisionError ? cause : portAllocationFromCause(port, cause), - ), - ); - -const releasePortClaim = (claim: PortClaim, fs: FileSystem.FileSystem): Effect.Effect => - Effect.gen(function* () { - const record = yield* readClaimRecord(claim.path).pipe( - Effect.orElseSucceed(() => undefined), - Effect.provideService(FileSystem.FileSystem, fs), - ); - if (record?.token !== claim.token || record.pid !== process.pid) return; - yield* fs.remove(claim.path, { force: true }).pipe(Effect.ignore); - }); - -const bindPort = (port: number): Effect.Effect => - Effect.callback((resume, signal) => { - const server = createServer((socket) => socket.destroy()); - const onError = (cause: unknown) => { - resume( - Effect.fail( - new PortAllocationError({ - detail: - port === 0 ? "Failed to reserve a random port" : `Port ${port} is not available`, - cause, - reason: port === 0 ? "failed" : "unavailable", - ...(port === 0 ? {} : { port }), - }), - ), - ); - }; - server.once("error", onError); - server.listen({ port, host: "127.0.0.1", signal }, () => { - server.off("error", onError); - const address = server.address(); - if (address === null || typeof address === "string") { - resume( - closeServer(server).pipe( - Effect.andThen( - Effect.fail( - new PortAllocationError({ detail: "Reserved TCP port has no numeric address" }), - ), - ), - ), - ); - return; - } - resume(Effect.succeed({ port: address.port, server })); - }); - return Effect.sync(() => server.off("error", onError)).pipe( - Effect.andThen(closeServer(server)), - ); - }); - -export interface PortLease { - readonly ports: PortSet; - readonly reserve: (fields: ReadonlyArray) => Effect.Effect; - /** Releases TCP reservations while retaining ownership claims for this lease. */ - readonly release: (fields: ReadonlyArray) => Effect.Effect; - /** Releases all TCP reservations and ends ownership of every selected port. */ - readonly releaseAll: Effect.Effect; -} - -const uniqueFields = ( - requests: ReadonlyArray, -): ReadonlyArray => { - const seen = new Set(); - return requests.filter((request) => { - if (seen.has(request.field)) return false; - seen.add(request.field); - return true; - }); -}; - -const uniquePortFields = (fields: ReadonlyArray): ReadonlyArray => { - const seen = new Set(); - return fields.filter((field) => { - if (seen.has(field)) return false; - seen.add(field); - return true; - }); -}; - -const releaseReservations = ( - reservations: Map, - fields: ReadonlyArray, -): Effect.Effect => - // Cleanup must finish the ownership handoff even if the caller is - // interrupted between releasing individual fields. - Effect.uninterruptible( - Effect.forEach( - uniquePortFields(fields), - (field) => { - const server = reservations.get(field); - if (server === undefined) return Effect.void; - return closeServer(server).pipe( - Effect.tap(() => Effect.sync(() => reservations.delete(field))), - ); - }, - { discard: true }, - ), - ); - -const releaseClaims = ( - claims: Map, - fields: ReadonlyArray, - fs: FileSystem.FileSystem, -): Effect.Effect => - Effect.uninterruptible( - Effect.forEach( - uniquePortFields(fields), - (field) => { - const claim = claims.get(field); - if (claim === undefined) return Effect.void; - return releasePortClaim(claim, fs).pipe( - Effect.tap(() => Effect.sync(() => claims.delete(field))), - ); - }, - { discard: true }, - ), - ); - -const claimAndBind = ( - field: PortField, - port: number, - claims: Map, - fs: FileSystem.FileSystem, - root: string, -): Effect.Effect => { - const existingClaim = claims.get(field); - return Effect.gen(function* () { - const bound = yield* Effect.interruptible(bindPort(port)); - if (existingClaim !== undefined) { - claims.set(field, existingClaim); - return bound; - } - - const claimExit = yield* Effect.exit( - Effect.interruptible( - acquirePortClaim(port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), - ), - ); - if (Exit.isFailure(claimExit)) { - yield* closeServer(bound.server); - return yield* Effect.failCause(claimExit.cause); - } - yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value))); - return bound; - }); -}; - -const reserveReservations = ( - ports: PortSet, - reservations: Map, - claims: Map, - fields: ReadonlyArray, - fs: FileSystem.FileSystem, - root: string, -): Effect.Effect => - Effect.suspend(() => { - const acquired: Array = []; - const acquiredClaims: Array = []; - return Effect.forEach( - uniquePortFields(fields), - (field) => { - if (reservations.has(field)) return Effect.void; - const port = ports[field]; - if (port === undefined) { - return Effect.fail( - new PortAllocationError({ - detail: `Port field ${field} was not allocated`, - field, - }), - ); - } - const existingClaim = claims.has(field); - return Effect.uninterruptibleMask(() => - claimAndBind(field, port, claims, fs, root).pipe( - Effect.mapError((error) => - error instanceof PortClaimCollisionError - ? new PortAllocationError({ - detail: `Port ${error.port} is not available`, - field, - port: error.port, - reason: "unavailable", - }) - : withPortField(field, error), - ), - Effect.tap(({ server }) => - Effect.sync(() => { - reservations.set(field, server); - acquired.push(field); - if (!existingClaim) acquiredClaims.push(field); - }), - ), - ), - ); - }, - { discard: true }, - ).pipe( - Effect.onError(() => - Effect.all( - [releaseReservations(reservations, acquired), releaseClaims(claims, acquiredClaims, fs)], - { discard: true }, - ), - ), - ); - }); - -const makePortLease = ( - ports: PortSet, - reservations: Map, - claims: Map, - fs: FileSystem.FileSystem, - root: string, -): PortLease => { - const lock = Semaphore.makeUnsafe(1); - return { - ports, - reserve: (fields) => - lock.withPermit(reserveReservations(ports, reservations, claims, fields, fs, root)), - release: (fields) => lock.withPermit(releaseReservations(reservations, fields)), - releaseAll: lock.withPermit( - Effect.suspend(() => - Effect.all( - [ - releaseReservations(reservations, [...reservations.keys()]), - releaseClaims(claims, [...claims.keys()], fs), - ], - { discard: true }, - ), - ), - ), - }; -}; - -const reserveRandomPort = ( - exclude: ReadonlySet, - field: PortField, - claims: Map, - fs: FileSystem.FileSystem, - root: string, - attempt = 0, -): Effect.Effect< - BoundPort, - PortAllocationError | PortClaimCollisionError, - FileSystem.FileSystem -> => { - if (attempt >= MAX_CLAIM_ATTEMPTS) { - return Effect.fail( - new PortAllocationError({ - detail: `Failed to reserve a random port after ${MAX_CLAIM_ATTEMPTS} claim collisions`, - reason: "failed", - }), - ); - } - return Effect.gen(function* () { - const bound = yield* Effect.interruptible(bindPort(0)); - if (exclude.has(bound.port)) { - yield* closeServer(bound.server); - return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); - } - - const claimExit = yield* Effect.exit( - Effect.interruptible( - acquirePortClaim(bound.port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)), - ), - ); - if (Exit.isSuccess(claimExit)) { - yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value))); - return bound; - } - - yield* closeServer(bound.server); - const failure = Cause.findErrorOption(claimExit.cause); - if (Option.isSome(failure) && failure.value instanceof PortClaimCollisionError) { - return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); - } - if (Option.isSome(failure) && failure.value instanceof PlatformError) { - return yield* portAllocationFromCause(bound.port, failure.value); - } - if (Option.isSome(failure) && failure.value instanceof PortAllocationError) { - return yield* failure.value; - } - return yield* Effect.failCause( - Cause.map(claimExit.cause, (error) => - error instanceof PortClaimCollisionError || error instanceof PortAllocationError - ? error - : portAllocationFromCause(bound.port, error), - ), - ); - }); -}; - -const withPortField = (field: PortField, error: PortAllocationError): PortAllocationError => - new PortAllocationError({ - detail: error.detail, - cause: error.cause, - field, - reason: error.reason, - ...(error.port === undefined ? {} : { port: error.port }), - }); - -const decodePortSet = ( - partial: Partial>, -): Effect.Effect => - Schema.decodeEffect(PortSetSchema)(partial).pipe( - Effect.mapError( - (cause) => - new PortAllocationError({ - detail: "Allocated ports did not match the port catalog", - cause, - }), - ), - ); - -export const reservePortSet = ( - requests: ReadonlyArray, - options: PortSelectionOptions = {}, -): Effect.Effect => - Effect.suspend(() => { - const reservations = new Map(); - const claims = new Map(); - const root = CLAIM_ROOT; - const reserve = (fs: FileSystem.FileSystem) => - Effect.gen(function* () { - const reserved = options.reserved ?? new Set(); - const allocated = new Set(); - const partial: Partial> = {}; - - const bindAndRegister = ( - field: PortField, - acquisition: Effect.Effect, - ) => - Effect.uninterruptibleMask(() => - Effect.gen(function* () { - const result = yield* acquisition.pipe( - Effect.mapError((error) => - error instanceof PortClaimCollisionError - ? new PortAllocationError({ - detail: `Port ${error.port} is not available`, - field, - port: error.port, - reason: "unavailable", - }) - : withPortField(field, error), - ), - ); - reservations.set(field, result.server); - return result; - }), - ); - - for (const request of uniqueFields(requests)) { - const selection = request.selection; - const exclude = new Set([ - ...reserved, - ...allocated, - ...(selection.kind === "automatic" ? (selection.excluded ?? []) : []), - ]); - let bound: BoundPort; - - if (selection.kind === "exact") { - if ( - !Number.isInteger(selection.port) || - selection.port < 1 || - selection.port > 65_535 - ) { - return yield* new PortAllocationError({ - detail: `Invalid exact port ${selection.port}`, - field: request.field, - port: selection.port, - }); - } - if (exclude.has(selection.port)) { - return yield* new PortAllocationError({ - detail: `Port ${selection.port} is not available`, - field: request.field, - port: selection.port, - }); - } - bound = yield* bindAndRegister( - request.field, - claimAndBind(request.field, selection.port, claims, fs, root), - ); - } else if ( - selection.preferred !== undefined && - selection.preferred > 0 && - !exclude.has(selection.preferred) - ) { - const preferred = selection.preferred; - bound = yield* bindAndRegister( - request.field, - claimAndBind(request.field, preferred, claims, fs, root).pipe( - Effect.catchTag("PortClaimCollisionError", () => - reserveRandomPort(exclude, request.field, claims, fs, root).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ), - ), - Effect.catchTag("PortAllocationError", (error) => - error.reason === "unavailable" - ? reserveRandomPort(exclude, request.field, claims, fs, root).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ) - : Effect.fail(error), - ), - ), - ); - } else { - bound = yield* bindAndRegister( - request.field, - reserveRandomPort(exclude, request.field, claims, fs, root).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ), - ); - } - - allocated.add(bound.port); - partial[request.field] = bound.port; - } - - const ports = yield* decodePortSet(partial); - return makePortLease(ports, reservations, claims, fs, root); - }); - - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* reserve(fs).pipe( - Effect.onError(() => - Effect.all( - [ - releaseReservations(reservations, [...reservations.keys()]), - releaseClaims(claims, [...claims.keys()], fs), - ], - { discard: true }, - ), - ), - ); - }); - }); diff --git a/packages/stack/src/PortCatalog.ts b/packages/stack/src/PortCatalog.ts deleted file mode 100644 index fe879a414e..0000000000 --- a/packages/stack/src/PortCatalog.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { Schema } from "effect"; -import type { ServiceName } from "./ServiceName.ts"; - -export interface AllocatedPorts { - readonly apiPort: number; - readonly dbPort: number; - readonly authPort: number; - readonly postgrestPort: number; - readonly postgrestAdminPort: number; - readonly edgeRuntimePort: number; - readonly edgeRuntimeInspectorPort: number; - readonly realtimePort: number; - readonly storagePort: number; - readonly imgproxyPort: number; - readonly mailpitPort: number; - readonly mailpitSmtpPort: number; - readonly mailpitPop3Port: number; - readonly pgmetaPort: number; - readonly studioPort: number; - readonly analyticsPort: number; - readonly poolerPort: number; - readonly poolerApiPort: number; -} - -/** A selected allocation may contain any subset of the catalog fields. */ -export type PortSet = Readonly>; - -/** Every runnable or persisted stack has the two core listeners. */ -export type ResolvedPorts = Readonly>; - -export type PortField = keyof AllocatedPorts; - -export type ConfigPortKey = - | "api.port" - | "db.port" - | "edge_runtime.inspector_port" - | "local_smtp.port" - | "local_smtp.smtp_port" - | "local_smtp.pop3_port" - | "studio.port" - | "analytics.port" - | "db.pooler.port"; - -export interface PortCatalogEntry { - readonly field: PortField; - readonly configKey?: ConfigPortKey; - readonly preferred?: number; - readonly service?: ServiceName; - readonly persistence: "runtime" | "sticky"; -} - -const PORT_CATALOG_ENTRIES: { - readonly [Field in PortField]: PortCatalogEntry & { readonly field: Field }; -} = { - apiPort: { field: "apiPort", configKey: "api.port", preferred: 54321, persistence: "sticky" }, - dbPort: { - field: "dbPort", - configKey: "db.port", - preferred: 54322, - service: "postgres", - persistence: "sticky", - }, - authPort: { field: "authPort", service: "auth", persistence: "runtime" }, - postgrestPort: { field: "postgrestPort", service: "postgrest", persistence: "runtime" }, - postgrestAdminPort: { - field: "postgrestAdminPort", - service: "postgrest", - persistence: "runtime", - }, - edgeRuntimePort: { - field: "edgeRuntimePort", - preferred: 54341, - service: "edge-runtime", - persistence: "runtime", - }, - edgeRuntimeInspectorPort: { - field: "edgeRuntimeInspectorPort", - configKey: "edge_runtime.inspector_port", - preferred: 54342, - service: "edge-runtime", - persistence: "sticky", - }, - realtimePort: { field: "realtimePort", service: "realtime", persistence: "runtime" }, - storagePort: { field: "storagePort", service: "storage", persistence: "runtime" }, - imgproxyPort: { field: "imgproxyPort", service: "imgproxy", persistence: "runtime" }, - mailpitPort: { - field: "mailpitPort", - configKey: "local_smtp.port", - preferred: 54324, - service: "mailpit", - persistence: "sticky", - }, - mailpitSmtpPort: { - field: "mailpitSmtpPort", - configKey: "local_smtp.smtp_port", - preferred: 54325, - service: "mailpit", - persistence: "sticky", - }, - mailpitPop3Port: { - field: "mailpitPop3Port", - configKey: "local_smtp.pop3_port", - preferred: 54326, - service: "mailpit", - persistence: "sticky", - }, - pgmetaPort: { field: "pgmetaPort", service: "pgmeta", persistence: "runtime" }, - studioPort: { - field: "studioPort", - configKey: "studio.port", - preferred: 54323, - service: "studio", - persistence: "sticky", - }, - analyticsPort: { - field: "analyticsPort", - configKey: "analytics.port", - preferred: 54327, - service: "analytics", - persistence: "sticky", - }, - poolerPort: { - field: "poolerPort", - configKey: "db.pooler.port", - preferred: 54329, - service: "pooler", - persistence: "sticky", - }, - poolerApiPort: { field: "poolerApiPort", service: "pooler", persistence: "runtime" }, -}; - -export const PORT_CATALOG = PORT_CATALOG_ENTRIES; -export const PORT_FIELDS = [ - "apiPort", - "dbPort", - "authPort", - "postgrestPort", - "postgrestAdminPort", - "edgeRuntimePort", - "edgeRuntimeInspectorPort", - "realtimePort", - "storagePort", - "imgproxyPort", - "mailpitPort", - "mailpitSmtpPort", - "mailpitPop3Port", - "pgmetaPort", - "studioPort", - "analyticsPort", - "poolerPort", - "poolerApiPort", -] as const satisfies ReadonlyArray; -export const stickyPortFields: ReadonlyArray = PORT_FIELDS.filter( - (field) => PORT_CATALOG[field].persistence === "sticky", -); -export const runtimeOnlyPortFields: ReadonlyArray = PORT_FIELDS.filter( - (field) => PORT_CATALOG[field].persistence === "runtime", -); - -const preferredPort = (field: PortField): number => { - const preferred = PORT_CATALOG[field].preferred; - if (preferred === undefined) { - throw new Error(`Port catalog field ${field} has no preferred port`); - } - return preferred; -}; - -export const DEFAULT_API_PORT = preferredPort("apiPort"); -export const DEFAULT_DB_PORT = preferredPort("dbPort"); -export const DEFAULT_PORTS: PortSet = { - apiPort: preferredPort("apiPort"), - dbPort: preferredPort("dbPort"), - edgeRuntimePort: preferredPort("edgeRuntimePort"), - edgeRuntimeInspectorPort: preferredPort("edgeRuntimeInspectorPort"), - mailpitPort: preferredPort("mailpitPort"), - mailpitSmtpPort: preferredPort("mailpitSmtpPort"), - mailpitPop3Port: preferredPort("mailpitPop3Port"), - studioPort: preferredPort("studioPort"), - analyticsPort: preferredPort("analyticsPort"), - poolerPort: preferredPort("poolerPort"), -}; - -export const AllocatedPortsSchema = Schema.Struct({ - apiPort: Schema.Finite, - dbPort: Schema.Finite, - authPort: Schema.Finite, - postgrestPort: Schema.Finite, - postgrestAdminPort: Schema.Finite, - edgeRuntimePort: Schema.Finite, - edgeRuntimeInspectorPort: Schema.Finite, - realtimePort: Schema.Finite, - storagePort: Schema.Finite, - imgproxyPort: Schema.Finite, - mailpitPort: Schema.Finite, - mailpitSmtpPort: Schema.Finite, - mailpitPop3Port: Schema.Finite, - pgmetaPort: Schema.Finite, - studioPort: Schema.Finite, - analyticsPort: Schema.Finite, - poolerPort: Schema.Finite, - poolerApiPort: Schema.Finite, -}); - -export const PortSetSchema = Schema.Struct({ - apiPort: Schema.optionalKey(Schema.Finite), - dbPort: Schema.optionalKey(Schema.Finite), - authPort: Schema.optionalKey(Schema.Finite), - postgrestPort: Schema.optionalKey(Schema.Finite), - postgrestAdminPort: Schema.optionalKey(Schema.Finite), - edgeRuntimePort: Schema.optionalKey(Schema.Finite), - edgeRuntimeInspectorPort: Schema.optionalKey(Schema.Finite), - realtimePort: Schema.optionalKey(Schema.Finite), - storagePort: Schema.optionalKey(Schema.Finite), - imgproxyPort: Schema.optionalKey(Schema.Finite), - mailpitPort: Schema.optionalKey(Schema.Finite), - mailpitSmtpPort: Schema.optionalKey(Schema.Finite), - mailpitPop3Port: Schema.optionalKey(Schema.Finite), - pgmetaPort: Schema.optionalKey(Schema.Finite), - studioPort: Schema.optionalKey(Schema.Finite), - analyticsPort: Schema.optionalKey(Schema.Finite), - poolerPort: Schema.optionalKey(Schema.Finite), - poolerApiPort: Schema.optionalKey(Schema.Finite), -}); - -export const ResolvedPortsSchema = Schema.Struct({ - ...PortSetSchema.fields, - apiPort: Schema.Finite, - dbPort: Schema.Finite, -}); diff --git a/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts b/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts deleted file mode 100644 index e5db253667..0000000000 --- a/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Bun RPC tests invoke the native Promise-based integration harness from Vitest callbacks. -import { Effect, Exit, Layer, Predicate, Scope } from "effect"; -import { describe, expect, test } from "vitest"; -import { ControlTransport } from "./managed/control.ts"; -import { httpTransportClientLayer } from "./HttpTransportClient.ts"; -import { RemoteStack } from "./RemoteStack.ts"; -import { Stack } from "./Stack.ts"; -import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -import { makeSupervisorSessionFixture } from "../tests/helpers/SupervisorSessionFixture.ts"; -import { makeTestStack } from "./testing.ts"; - -const isBun = typeof Bun !== "undefined"; -const ownerId = "c".repeat(64); - -describe("Bun runtime RPC", () => { - (isBun ? test : test.skip)("serves a same-version runtime RPC request over Bun TCP", async () => { - const { controlTransportLayer } = await import("./platform-bun.ts"); - const scope = Scope.makeUnsafe(); - const lifecycle = await Effect.runPromise( - makeSupervisorSessionFixture({ - ownershipId: ownerId, - ownerSessionId: "bun-rpc-session", - daemonCliVersion: "test", - }).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))), - ); - await Effect.runPromise(lifecycle.publishStack(makeTestStack())); - const application = { - app: await Effect.runPromise( - makeSupervisorControlApplication(lifecycle).pipe( - Effect.provide(Layer.succeed(Scope.Scope, scope)), - ), - ), - }; - const listener = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - transport.bind( - { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, - () => ({ - controlProtocol: "supabase-stack-control" as const, - controlProtocolVersion: 1 as const, - ownershipId: ownerId, - ownerSessionId: "bun-rpc-session", - kind: "supervisor" as const, - state: "running" as const, - ready: true, - daemonCliVersion: "test", - }), - () => "accepted" as const, - application, - ), - ).pipe( - Effect.provide(Layer.mergeAll(Layer.succeed(Scope.Scope, scope), controlTransportLayer)), - ), - ); - try { - const address = listener.server.address; - expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); - if (!Predicate.isTagged(address, "TcpAddress")) return; - const endpoint = { - hostname: "127.0.0.1", - port: address.port, - url: `http://127.0.0.1:${address.port}`, - }; - const layer = RemoteStack.layer(endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "bun-rpc-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - const exit = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - return yield* remote.getInfo; - }).pipe(Effect.provide(layer), Effect.exit), - ), - ); - expect(Exit.isSuccess(exit)).toBe(true); - if (Exit.isSuccess(exit)) expect(exit.value.url).toContain("127.0.0.1"); - } finally { - await Effect.runPromise(listener.close); - await Effect.runPromise(Scope.close(scope, Exit.void)); - } - }); -}); diff --git a/packages/stack/src/RemoteStack.rpc.integration.test.ts b/packages/stack/src/RemoteStack.rpc.integration.test.ts deleted file mode 100644 index f262cd66fd..0000000000 --- a/packages/stack/src/RemoteStack.rpc.integration.test.ts +++ /dev/null @@ -1,1349 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- RPC tests intentionally exercise native HTTP handlers at the transport boundary. -import { it } from "@effect/vitest"; -import { - Cause, - Context, - Deferred, - Effect, - Exit, - Fiber, - Layer, - Option, - Predicate, - Result, - Stream, -} from "effect"; -import * as TestClock from "effect/testing/TestClock"; -import { ServiceNotFoundError, ServiceReadyError } from "@supabase/process-compose"; -import { createServer, type RequestListener, type Server } from "node:http"; -import { expect } from "vitest"; -import { Stack, type StackInfo } from "./Stack.ts"; -import { StackServiceState } from "./StackServiceState.ts"; -import { - HttpTransportClient, - HttpTransportClientError, - httpTransportClientLayer, -} from "./HttpTransportClient.ts"; -import { RemoteStack } from "./RemoteStack.ts"; -import { StackRpcProtocolError } from "./errors.ts"; -import { - StackBuildError, - StackNotRunningError, - StackReadinessError, - StackUnavailableError, -} from "./errors.ts"; -import { - acquireControl, - ControlMaintenanceBusyError, - ControlTransport, - isControlOwnership, -} from "./managed/control.ts"; -import { isControlSupervisorStatus, type ControlOwnerStatus } from "./DaemonProtocol.ts"; -import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -import { makeSupervisorSessionFixture } from "../tests/helpers/SupervisorSessionFixture.ts"; -import { makeTestStack } from "./testing.ts"; - -const ownerId = "b".repeat(64); - -const supervisorStatus = (status: ControlOwnerStatus) => { - if (!isControlSupervisorStatus(status)) throw new Error("expected supervisor status"); - return status; -}; - -const controlTransportLayer = - typeof Bun === "undefined" - ? (await import("./platform-node.ts")).controlTransportLayer - : (await import("./platform-bun.ts")).controlTransportLayer; - -const remoteOwner = (ownerSessionId: string) => ({ - controlProtocol: "supabase-stack-control" as const, - controlProtocolVersion: 1 as const, - ownershipId: ownerId, - ownerSessionId, - kind: "supervisor" as const, - state: "running" as const, - ready: true, - daemonCliVersion: "test", -}); - -const remoteLayer = ( - endpoint: { readonly hostname: string; readonly port: number; readonly url: string }, - ownerSessionId: string, - transport: HttpTransportClient["Service"], -) => - RemoteStack.layer(endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId, - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(Layer.succeed(HttpTransportClient, transport))); - -const live = (effect: Effect.Effect) => - effect.pipe(Effect.provide(controlTransportLayer)); - -const startStubServer = (handler: RequestListener) => - Effect.acquireRelease( - Effect.callback< - { - readonly server: Server; - readonly endpoint: { - readonly hostname: string; - readonly port: number; - readonly url: string; - }; - }, - Error - >((resume) => { - const server = createServer(handler); - const onError = (error: Error) => resume(Effect.fail(error)); - server.once("error", onError); - server.listen(0, "127.0.0.1", () => { - server.off("error", onError); - const address = server.address(); - if (address === null || typeof address === "string") { - resume(Effect.die("test server did not expose a TCP address")); - return; - } - resume( - Effect.succeed({ - server, - endpoint: { - hostname: "127.0.0.1", - port: address.port, - url: `http://127.0.0.1:${address.port}`, - }, - }), - ); - }); - return Effect.sync(() => { - server.off("error", onError); - if (server.listening) server.close(); - else server.once("listening", () => server.close()); - }); - }), - ({ server }) => - Effect.callback((resume) => { - if (!server.listening) { - resume(Effect.void); - return Effect.void; - } - server.close(() => resume(Effect.void)); - return Effect.void; - }), - ); - -const startMalformedServer = (frame: string) => - startStubServer((request, response) => { - if (request.url === "/owner") { - response.writeHead(200, { "content-type": "application/json", connection: "close" }); - response.end( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: ownerId, - ownerSessionId: "malformed-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }), - ); - return; - } - if (request.url === "/rpc") { - response.writeHead(200, { "content-type": "application/x-ndjson", connection: "close" }); - response.end(frame); - return; - } - response.writeHead(404, { connection: "close" }); - response.end(); - }); - -const startBusyServer = startStubServer((request, response) => { - if (request.url === "/owner") { - response.writeHead(200, { "content-type": "application/json", connection: "close" }); - response.end(JSON.stringify(remoteOwner("busy-session"))); - return; - } - if (request.url === "/stop") { - response.writeHead(423, { "content-type": "application/json", connection: "close" }); - response.end(JSON.stringify({ error: "busy" })); - return; - } - response.writeHead(404, { connection: "close" }); - response.end(); -}); - -const startDisconnectServer = ( - requestStarted: Deferred.Deferred, - requestClosed: Deferred.Deferred, -) => - startStubServer((request, response) => { - if (request.url === "/owner") { - response.writeHead(200, { "content-type": "application/json", connection: "close" }); - response.end( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: ownerId, - ownerSessionId: "disconnect-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }), - ); - return; - } - if (request.url === "/rpc") { - Deferred.doneUnsafe(requestStarted, Effect.void); - request.once("close", () => { - Deferred.doneUnsafe(requestClosed, Effect.void); - response.destroy(); - }); - return; - } - response.writeHead(404, { connection: "close" }); - response.end(); - }); - -it.live("executes every Stack operation over the same-version RPC endpoint", () => - live( - Effect.scoped( - Effect.gen(function* () { - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: ownerId, - ownerSessionId: "rpc-session", - daemonCliVersion: "test", - }); - let calls = 0; - const readyCalls: Array<{ readonly name?: string; readonly options?: unknown }> = []; - const logReleased = Deferred.makeUnsafe(); - const activeLogStarted = Deferred.makeUnsafe(); - const activeLogReleased = Deferred.makeUnsafe(); - let logSubscriptions = 0; - const serviceState = new StackServiceState({ - name: "auth", - status: "Running", - pid: 1, - exitCode: null, - restartCount: 0, - startedAt: 1, - error: null, - }); - const info: StackInfo = { - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://localhost/postgres", - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "role", - serviceEndpoints: {}, - }; - const logs = [{ timestamp: 1, service: "auth", stream: "stdout" as const, line: "ready" }]; - const counted = () => - Effect.sync(() => { - calls += 1; - }); - const stack: Stack["Service"] = makeTestStack({ - getInfo: Effect.succeed(info), - start: counted(), - stop: counted(), - dispose: counted(), - startService: (name) => { - switch (name) { - case "unavailable": - return Effect.fail( - new StackUnavailableError({ phase: "stopping", detail: "stack is stopping" }), - ); - case "missing": - return Effect.fail(new ServiceNotFoundError({ name })); - case "error": - return Effect.fail( - new ServiceReadyError({ name, reason: "did not become ready", exitCode: 17 }), - ); - case "build": - return Effect.fail( - new StackBuildError({ detail: "docker failed", reason: "docker_not_running" }), - ); - case "not-running": - return Effect.fail(new StackNotRunningError({ phase: "stopped" })); - case "readiness": - return Effect.fail( - new StackReadinessError({ target: "auth", timeoutMs: 1234, detail: "timed out" }), - ); - default: - return counted(); - } - }, - stopService: counted, - restartService: counted, - reloadFunctions: counted, - reloadEdgeRuntime: counted, - getState: (name) => - name === "missing" - ? Effect.fail(new ServiceNotFoundError({ name })) - : Effect.succeed(serviceState), - getAllStates: Effect.succeed([serviceState]), - stateChanges: () => Effect.succeed(Stream.fromIterable([serviceState])), - allStateChanges: Stream.fromIterable([serviceState]), - waitReady: (name, options) => - Effect.sync(() => { - calls += 1; - readyCalls.push({ name, options }); - }), - waitAllReady: (options) => - Effect.sync(() => { - calls += 1; - readyCalls.push({ options }); - }), - subscribeLogs: () => { - const active = logSubscriptions++ > 0; - const entries = active - ? Stream.fromIterable(logs).pipe( - Stream.tap(() => - Deferred.succeed(activeLogStarted, undefined).pipe(Effect.asVoid), - ), - ) - : Stream.fromIterable(logs); - return Stream.concat(entries, Stream.never).pipe( - Stream.ensuring( - Deferred.succeed(active ? activeLogReleased : logReleased, undefined), - ), - ); - }, - subscribeAllLogs: () => Stream.fromIterable(logs), - logHistory: () => Effect.succeed(logs), - logHistoryAll: () => Effect.succeed(logs), - }); - yield* lifecycle.publishStack(stack); - const application = { - app: yield* makeSupervisorControlApplication(lifecycle), - }; - const owner = yield* acquireControl({ - stackId: ownerId, - initialStatus: yield* lifecycle.currentStatus, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); - yield* lifecycle.setClose(owner.close); - const ownerStatus = supervisorStatus(yield* lifecycle.currentStatus); - const rpcPaths: Array = []; - const recordingTransportLayer = Layer.effect( - HttpTransportClient, - Effect.gen(function* () { - const base = yield* HttpTransportClient; - return { - request: ( - endpoint: Parameters[0], - path: string, - init?: RequestInit, - ) => - Effect.sync(() => { - rpcPaths.push(path); - }).pipe(Effect.flatMap(() => base.request(endpoint, path, init))), - }; - }), - ).pipe(Layer.provide(httpTransportClientLayer)); - const mismatchLayer = RemoteStack.layer(owner.endpoint, { - cliVersion: "different-version", - owner: { - ownershipId: owner.ownershipId, - ownerSessionId: ownerStatus.ownerSessionId, - controlProtocolVersion: ownerStatus.controlProtocolVersion, - daemonCliVersion: ownerStatus.daemonCliVersion, - }, - }).pipe(Layer.provide(recordingTransportLayer)); - const mismatchExit = yield* Effect.exit( - Effect.scoped(Stack).pipe(Effect.provide(mismatchLayer)), - ); - expect(Exit.isFailure(mismatchExit)).toBe(true); - expect(rpcPaths).toEqual(["/owner"]); - const remoteLayer = RemoteStack.layer(owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: owner.ownershipId, - ownerSessionId: ownerStatus.ownerSessionId, - controlProtocolVersion: ownerStatus.controlProtocolVersion, - daemonCliVersion: ownerStatus.daemonCliVersion, - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - yield* Effect.gen(function* () { - const remote = yield* Stack; - expect(yield* remote.getInfo).toEqual(info); - yield* remote.start; - yield* remote.startService("auth"); - const readyError = yield* Effect.flip(remote.startService("error")); - expect(Predicate.isTagged(readyError, "ServiceReadyError")).toBe(true); - if (Predicate.isTagged(readyError, "ServiceReadyError")) { - expect(readyError).toMatchObject({ - name: "error", - reason: "did not become ready", - exitCode: 17, - }); - } - const unavailableError = yield* Effect.flip(remote.startService("unavailable")); - expect(Predicate.isTagged(unavailableError, "StackUnavailableError")).toBe(true); - if (Predicate.isTagged(unavailableError, "StackUnavailableError")) { - expect(unavailableError).toMatchObject({ - phase: "stopping", - detail: "stack is stopping", - }); - } - const missingError = yield* Effect.flip(remote.startService("missing")); - expect(Predicate.isTagged(missingError, "ServiceNotFoundError")).toBe(true); - if (Predicate.isTagged(missingError, "ServiceNotFoundError")) { - expect(missingError.name).toBe("missing"); - } - const buildError = yield* Effect.flip(remote.startService("build")); - expect(Predicate.isTagged(buildError, "StackBuildError")).toBe(true); - if (Predicate.isTagged(buildError, "StackBuildError")) { - expect(buildError).toMatchObject({ - detail: "docker failed", - reason: "docker_not_running", - }); - } - const notRunningError = yield* Effect.flip(remote.startService("not-running")); - expect(Predicate.isTagged(notRunningError, "StackNotRunningError")).toBe(true); - if (Predicate.isTagged(notRunningError, "StackNotRunningError")) { - expect(notRunningError.phase).toBe("stopped"); - } - const readinessError = yield* Effect.flip(remote.startService("readiness")); - expect(Predicate.isTagged(readinessError, "StackReadinessError")).toBe(true); - if (Predicate.isTagged(readinessError, "StackReadinessError")) { - expect(readinessError).toMatchObject({ - target: "auth", - timeoutMs: 1234, - detail: "timed out", - }); - } - yield* remote.stopService("auth"); - yield* remote.restartService("auth"); - yield* remote.reloadFunctions(); - yield* remote.reloadEdgeRuntime({ edgeRuntime: { enabled: true } }); - expect(yield* remote.getState("auth")).toEqual(serviceState); - expect(yield* remote.getAllStates).toEqual([serviceState]); - const authChanges = yield* remote.stateChanges("auth"); - expect(yield* Stream.runCollect(authChanges)).toEqual([serviceState]); - const missingChanges = yield* Effect.exit(remote.stateChanges("missing")); - expect(Exit.isFailure(missingChanges)).toBe(true); - if (Exit.isFailure(missingChanges)) { - const failure = Cause.findErrorOption(missingChanges.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(Predicate.isTagged(failure.value, "ServiceNotFoundError")).toBe(true); - if (Predicate.isTagged(failure.value, "ServiceNotFoundError")) { - expect(failure.value.name).toBe("missing"); - } - } - } - expect(yield* Stream.runCollect(remote.allStateChanges)).toEqual([serviceState]); - yield* remote.waitReady("auth"); - yield* remote.waitAllReady(); - const finiteReady = { mode: "finite" as const, timeoutMs: 1234 }; - yield* remote.waitReady("auth", finiteReady); - yield* remote.waitAllReady(finiteReady); - expect(readyCalls).toEqual([ - { name: "auth", options: { mode: "inherit" } }, - { options: { mode: "inherit" } }, - { name: "auth", options: finiteReady }, - { options: finiteReady }, - ]); - expect(yield* remote.logHistory("auth")).toEqual(logs); - expect(yield* remote.logHistoryAll()).toEqual(logs); - expect( - yield* Effect.scoped(Stream.runCollect(Stream.take(remote.subscribeLogs("auth"), 1))), - ).toEqual([logs[0]]); - yield* Deferred.await(logReleased); - expect(yield* Stream.runCollect(remote.subscribeAllLogs(["auth"]))).toEqual(logs); - expect(calls).toBeGreaterThan(0); - const activeLogs = yield* Effect.forkChild(Stream.runDrain(remote.subscribeLogs("auth"))); - yield* Deferred.await(activeLogStarted); - yield* remote.stop; - yield* Deferred.await(activeLogReleased); - yield* Fiber.await(activeLogs); - }).pipe(Effect.provide(remoteLayer)); - }), - ), - ), -); - -it.live("preserves a maintenance-busy stop as a typed RemoteStack failure", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* startBusyServer; - const layer = RemoteStack.layer(server.endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "busy-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - - const exit = yield* Effect.exit( - Effect.gen(function* () { - const remote = yield* Stack; - return yield* remote.stop; - }).pipe(Effect.provide(layer)), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(Predicate.isTagged(failure.value, "ControlMaintenanceBusyError")).toBe(true); - expect(failure.value).toBeInstanceOf(ControlMaintenanceBusyError); - } - } - }), - ), -); - -it.live("fences stale RPC clients after deterministic endpoint replacement", () => - live( - Effect.scoped( - Effect.gen(function* () { - const stackId = "d".repeat(64); - const sessionA = "rpc-fence-session-a"; - const sessionB = "rpc-fence-session-b"; - const info: StackInfo = { - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://localhost/postgres", - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "role", - serviceEndpoints: {}, - }; - let handlerCalls = 0; - const makeOwner = (ownerSessionId: string, daemonCliVersion: string) => - Effect.gen(function* () { - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: stackId, - ownerSessionId, - daemonCliVersion, - }); - yield* lifecycle.publishStack({ - ...makeTestStack(), - getInfo: Effect.sync(() => { - handlerCalls += 1; - return info; - }), - }); - const owner = yield* acquireControl({ - stackId, - initialStatus: yield* lifecycle.currentStatus, - application: { app: yield* makeSupervisorControlApplication(lifecycle) }, - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - yield* lifecycle.setClose(owner.close); - return { lifecycle, owner }; - }); - - const first = yield* makeOwner(sessionA, "test"); - const firstStatus = supervisorStatus(yield* first.lifecycle.currentStatus); - const staleLayer = RemoteStack.layer(first.owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: stackId, - ownerSessionId: firstStatus.ownerSessionId, - controlProtocolVersion: firstStatus.controlProtocolVersion, - daemonCliVersion: firstStatus.daemonCliVersion, - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - const staleContext = yield* Layer.build(staleLayer); - const staleRemote = Context.get(staleContext, Stack); - expect((yield* staleRemote.getInfo).url).toBe(info.url); - expect(handlerCalls).toBe(1); - - yield* first.owner.close; - const replacement = yield* makeOwner(sessionB, "test"); - const staleResult = yield* staleRemote.getInfo.pipe(Effect.result); - expect(Result.isFailure(staleResult)).toBe(true); - if (Result.isFailure(staleResult)) { - expect(staleResult.failure).toBeInstanceOf(StackRpcProtocolError); - } - expect(handlerCalls).toBe(1); - - const replacementStatus = supervisorStatus(yield* replacement.lifecycle.currentStatus); - const replacementLayer = RemoteStack.layer(replacement.owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: stackId, - ownerSessionId: replacementStatus.ownerSessionId, - controlProtocolVersion: replacementStatus.controlProtocolVersion, - daemonCliVersion: replacementStatus.daemonCliVersion, - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - const replacementContext = yield* Layer.build(replacementLayer); - const replacementRemote = Context.get(replacementContext, Stack); - expect((yield* replacementRemote.getInfo).url).toBe(info.url); - expect(handlerCalls).toBe(2); - - yield* replacement.owner.close; - }), - ), - ), -); - -it.live.each([ - ["malformed NDJSON", "not-json\n"], - ["incomplete NDJSON", '{"_tag":"RpcResponse","success":'], -] as const)("preserves endpoint and procedure for %s", ([_label, frame]) => - Effect.scoped( - Effect.gen(function* () { - const server = yield* startMalformedServer(frame); - const layer = RemoteStack.layer(server.endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "malformed-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - const exit = yield* Effect.exit( - Effect.gen(function* () { - const remote = yield* Stack; - yield* remote.getInfo; - }).pipe(Effect.provide(layer)), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(StackRpcProtocolError); - expect(Predicate.isTagged(failure.value, "StackRpcProtocolError")).toBe(true); - expect(failure.value).toMatchObject({ - endpoint: server.endpoint.url, - procedure: "GetInfo", - }); - } - } - }), - ).pipe(Effect.provide(controlTransportLayer)), -); - -it.effect("reports the HTTP status when the owner probe is non-successful", () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12348, url: "http://127.0.0.1:12348" }; - const layer = RemoteStack.layer(endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "owner-probe-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe( - Layer.provide( - Layer.succeed(HttpTransportClient, { - request: (_endpoint, path) => - path === "/owner" - ? Effect.succeed( - new Response(JSON.stringify({ error: "internal details must not leak" }), { - status: 503, - headers: { "content-type": "application/json" }, - }), - ) - : Effect.die(`unexpected request ${path}`), - }), - ), - ); - const exit = yield* Effect.exit(Effect.scoped(Stack).pipe(Effect.provide(layer))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(Predicate.isTagged(failure.value, "StackRpcProtocolError")).toBe(true); - if (Predicate.isTagged(failure.value, "StackRpcProtocolError")) { - expect(failure.value).toMatchObject({ - endpoint: endpoint.url, - procedure: "owner", - detail: "Owner probe returned HTTP 503", - }); - expect(failure.value.detail).not.toContain("internal details"); - } - } - } - }), -); - -it.live("interrupts an owned server RPC request when the client disconnects", () => - Effect.scoped( - Effect.gen(function* () { - const requestStarted = Deferred.makeUnsafe(); - const requestClosed = Deferred.makeUnsafe(); - const server = yield* startDisconnectServer(requestStarted, requestClosed); - const layer = RemoteStack.layer(server.endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "disconnect-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - yield* Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - const request = yield* Effect.forkChild(remote.getInfo); - yield* Deferred.await(requestStarted); - yield* Fiber.interrupt(request); - yield* Deferred.await(requestClosed); - }).pipe(Effect.provide(layer)), - ); - }), - ).pipe(Effect.provide(controlTransportLayer)), -); - -it.effect("interrupts a remote stop after the cleanup handoff", () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12353, url: "http://127.0.0.1:12353" }; - const stopStarted = yield* Deferred.make(); - const transport = Layer.succeed(HttpTransportClient, { - request: (_requestEndpoint, path) => { - if (path === "/owner") return Effect.succeed(Response.json(remoteOwner("stop-session"))); - if (path === "/stop") { - return Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Effect.never)); - } - return Effect.die(`unexpected request ${path}`); - }, - }); - const layer = RemoteStack.layer(endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "stop-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(transport)); - const request = yield* Effect.forkChild( - Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - yield* remote.stop; - }).pipe(Effect.provide(layer)), - ), - ); - yield* Deferred.await(stopStarted); - yield* Fiber.interrupt(request); - expect(request.pollUnsafe()).not.toBeUndefined(); - }), -); - -it.live("closes an owner while another client still consumes an RPC stream", () => - live( - Effect.scoped( - Effect.gen(function* () { - const ownerSessionId = "active-stream-stop-session"; - const streamStarted = Deferred.makeUnsafe(); - const streamReleased = Deferred.makeUnsafe(); - const log = { - timestamp: 1, - service: "auth", - stream: "stdout" as const, - line: "ready", - }; - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: ownerId, - ownerSessionId, - daemonCliVersion: "test", - }); - yield* lifecycle.publishStack({ - ...makeTestStack(), - subscribeLogs: () => - Stream.concat( - Stream.succeed(log).pipe( - Stream.tap(() => Deferred.succeed(streamStarted, undefined).pipe(Effect.asVoid)), - ), - Stream.never, - ).pipe( - Stream.ensuring(Deferred.succeed(streamReleased, undefined).pipe(Effect.asVoid)), - ), - }); - const owner = yield* acquireControl({ - stackId: ownerId, - initialStatus: yield* lifecycle.currentStatus, - application: { app: yield* makeSupervisorControlApplication(lifecycle) }, - }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); - yield* lifecycle.setClose(owner.close); - const layer = RemoteStack.layer(owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: owner.ownershipId, - ownerSessionId, - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - const shutdownExit = yield* Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - yield* Effect.forkChild(Stream.runDrain(remote.subscribeLogs("auth")), { - startImmediately: true, - }); - yield* Deferred.await(streamStarted); - const transport = yield* ControlTransport; - yield* transport.requestStop(owner.endpoint, { - ownershipId: owner.ownershipId, - ownerSessionId, - intent: "explicit", - }); - return yield* lifecycle.awaitShutdown.pipe(Effect.timeout("2 seconds"), Effect.exit); - }).pipe(Effect.provide(layer)), - ); - expect(Exit.isSuccess(shutdownExit)).toBe(true); - yield* Deferred.await(streamReleased); - }), - ), - ), -); - -it.live("terminates an active stream with the stopping reason", () => - live( - Effect.scoped( - Effect.gen(function* () { - const ownerSessionId = "active-stream-stop-accepted-session"; - const streamStarted = Deferred.makeUnsafe(); - const streamReleased = Deferred.makeUnsafe(); - const stopRelease = Deferred.makeUnsafe(); - const log = { - timestamp: 1, - service: "auth", - stream: "stdout" as const, - line: "ready", - }; - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: ownerId, - ownerSessionId, - daemonCliVersion: "test", - }); - yield* lifecycle.publishStack({ - ...makeTestStack(), - stop: Deferred.await(stopRelease), - subscribeLogs: () => - Stream.concat( - Stream.succeed(log).pipe( - Stream.tap(() => Deferred.succeed(streamStarted, undefined).pipe(Effect.asVoid)), - ), - Stream.never, - ).pipe( - Stream.ensuring(Deferred.succeed(streamReleased, undefined).pipe(Effect.asVoid)), - ), - }); - const owner = yield* acquireControl({ - stackId: ownerId, - initialStatus: yield* lifecycle.currentStatus, - application: { app: yield* makeSupervisorControlApplication(lifecycle) }, - }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); - yield* lifecycle.setClose(owner.close); - const layer = RemoteStack.layer(owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: owner.ownershipId, - ownerSessionId, - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - yield* Effect.addFinalizer(() => - Deferred.succeed(stopRelease, undefined).pipe(Effect.asVoid), - ); - const outcome = yield* Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - const activeLogs = yield* Effect.forkChild( - Stream.runDrain(remote.subscribeLogs("auth")), - { startImmediately: true }, - ); - yield* Deferred.await(streamStarted); - const transport = yield* ControlTransport; - yield* transport.requestStop(owner.endpoint, { - ownershipId: owner.ownershipId, - ownerSessionId, - intent: "explicit", - }); - const streamExit = yield* Fiber.join(activeLogs).pipe( - Effect.timeout("1 second"), - Effect.exit, - ); - yield* Deferred.succeed(stopRelease, undefined); - yield* lifecycle.awaitShutdown; - yield* Deferred.await(streamReleased); - return streamExit; - }).pipe(Effect.provide(layer)), - ); - expect(Exit.isFailure(outcome)).toBe(true); - if (Exit.isFailure(outcome)) { - const error = Cause.squash(outcome.cause); - expect(Predicate.isTagged(error, "StackUnavailableError")).toBe(true); - if (Predicate.isTagged(error, "StackUnavailableError")) { - expect(error).toMatchObject({ phase: "stopping" }); - } - } - }), - ), - ), -); - -it.effect("times out a hung fast unary RPC with endpoint and procedure context", () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12345, url: "http://127.0.0.1:12345" }; - const rpcStarted = yield* Deferred.make(); - const transportLayer = Layer.succeed(HttpTransportClient, { - request: (_requestEndpoint, path) => - path === "/owner" - ? Effect.succeed( - new Response( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: ownerId, - ownerSessionId: "hung-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - : Deferred.succeed(rpcStarted, undefined).pipe(Effect.andThen(Effect.never)), - }); - const layer = RemoteStack.layer(endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "hung-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(transportLayer)); - const request = yield* Effect.forkChild( - Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - yield* remote.getInfo; - }).pipe(Effect.provide(layer), Effect.exit), - ), - ); - yield* Deferred.await(rpcStarted); - yield* TestClock.adjust("30 seconds"); - yield* Effect.yieldNow; - const result = yield* Fiber.join(request); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const failure = Cause.findErrorOption(result.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(Predicate.isTagged(failure.value, "StackRpcTransportError")).toBe(true); - if (Predicate.isTagged(failure.value, "StackRpcTransportError")) { - expect(failure.value).toMatchObject({ - endpoint: endpoint.url, - procedure: "GetInfo", - }); - } - } - } - }), -); - -it.effect("does not apply the fast timeout to a long-running StartStack RPC", () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12347, url: "http://127.0.0.1:12347" }; - const rpcStarted = yield* Deferred.make(); - const transportLayer = Layer.succeed(HttpTransportClient, { - request: (_requestEndpoint, path) => - path === "/owner" - ? Effect.succeed( - new Response( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: ownerId, - ownerSessionId: "long-start-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - : Deferred.succeed(rpcStarted, undefined).pipe(Effect.andThen(Effect.never)), - }); - const layer = RemoteStack.layer(endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "long-start-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(transportLayer)); - const request = yield* Effect.forkChild( - Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - yield* remote.start; - }).pipe(Effect.provide(layer)), - ), - ); - yield* Deferred.await(rpcStarted); - yield* TestClock.adjust("30 seconds"); - yield* Effect.yieldNow; - expect(request.pollUnsafe()).toBeUndefined(); - yield* Fiber.interrupt(request); - }), -); - -it.effect("does not apply the fast timeout to a long-running StopService RPC", () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12349, url: "http://127.0.0.1:12349" }; - const rpcStarted = yield* Deferred.make(); - const transportLayer = Layer.succeed(HttpTransportClient, { - request: (_requestEndpoint, path) => - path === "/owner" - ? Effect.succeed( - new Response( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: ownerId, - ownerSessionId: "long-stop-service-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - : Deferred.succeed(rpcStarted, undefined).pipe(Effect.andThen(Effect.never)), - }); - const layer = RemoteStack.layer(endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: "long-stop-service-session", - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(transportLayer)); - const request = yield* Effect.forkChild( - Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - yield* remote.stopService("postgres"); - }).pipe(Effect.provide(layer)), - ), - ); - yield* Deferred.await(rpcStarted); - yield* TestClock.adjust("30 seconds"); - yield* Effect.yieldNow; - expect(request.pollUnsafe()).toBeUndefined(); - yield* Fiber.interrupt(request); - }), -); - -it.effect("observes the captured session after the stop was accepted and its response resets", () => - Effect.forEach(["ECONNREFUSED", "ConnectionRefused"] as const, (refusedCode) => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12350, url: "http://127.0.0.1:12350" }; - const ownerSessionId = "accepted-reset-session"; - let ownerReads = 0; - const transport: HttpTransportClient["Service"] = { - request: (requestEndpoint, path) => { - if (path === "/owner") { - ownerReads += 1; - return ownerReads === 1 - ? Effect.succeed(Response.json(remoteOwner(ownerSessionId))) - : Effect.fail( - new HttpTransportClientError({ - endpoint: requestEndpoint, - path, - reason: "transport", - cause: { code: refusedCode }, - }), - ); - } - if (path === "/stop") - return Effect.fail( - new HttpTransportClientError({ - endpoint: requestEndpoint, - path, - reason: "transport", - cause: new Error("connection reset after the supervisor accepted the stop"), - }), - ); - return Effect.die(`unexpected request ${path}`); - }, - }; - - const result = yield* Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - return yield* remote.stop.pipe(Effect.result); - }).pipe(Effect.provide(remoteLayer(endpoint, ownerSessionId, transport))), - ); - - expect(Result.isSuccess(result)).toBe(true); - expect(ownerReads).toBe(2); - }), - ).pipe(Effect.asVoid), -); - -it.effect("observes the captured session after an ambiguous HTTP stop status", () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12353, url: "http://127.0.0.1:12353" }; - const ownerSessionId = "accepted-http-status-session"; - let ownerReads = 0; - const transport: HttpTransportClient["Service"] = { - request: (requestEndpoint, path) => { - if (path === "/owner") { - ownerReads += 1; - return ownerReads === 1 - ? Effect.succeed(Response.json(remoteOwner(ownerSessionId))) - : Effect.fail( - new HttpTransportClientError({ - endpoint: requestEndpoint, - path, - reason: "transport", - cause: { code: "ECONNREFUSED" }, - }), - ); - } - if (path === "/stop") return Effect.succeed(new Response(null, { status: 503 })); - return Effect.die(`unexpected request ${path}`); - }, - }; - - const result = yield* Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - return yield* remote.stop.pipe(Effect.result); - }).pipe(Effect.provide(remoteLayer(endpoint, ownerSessionId, transport))), - ); - - expect(Result.isSuccess(result)).toBe(true); - expect(ownerReads).toBe(2); - }), -); - -it.effect( - "keeps observing when a transient owner read fails while the target session is alive", - () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12351, url: "http://127.0.0.1:12351" }; - const ownerSessionId = "transient-read-session"; - const transientRead = yield* Deferred.make(); - const targetObserved = yield* Deferred.make(); - let ownerReads = 0; - const transport: HttpTransportClient["Service"] = { - request: (requestEndpoint, path) => { - if (path === "/owner") { - ownerReads += 1; - if (ownerReads === 1) return Effect.succeed(Response.json(remoteOwner(ownerSessionId))); - if (ownerReads === 2) - return Deferred.succeed(transientRead, undefined).pipe( - Effect.andThen( - Effect.fail( - new HttpTransportClientError({ - endpoint: requestEndpoint, - path, - reason: "transport", - cause: { code: "ETIMEDOUT" }, - }), - ), - ), - ); - if (ownerReads === 3) - return Deferred.succeed(targetObserved, undefined).pipe( - Effect.as(Response.json(remoteOwner(ownerSessionId))), - ); - return Effect.succeed(Response.json(remoteOwner("replacement-session"))); - } - if (path === "/stop") return Effect.succeed(new Response(null, { status: 202 })); - return Effect.die(`unexpected request ${path}`); - }, - }; - const stop = yield* Effect.forkChild( - Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - yield* remote.stop; - }).pipe(Effect.provide(remoteLayer(endpoint, ownerSessionId, transport))), - ), - ); - - yield* Deferred.await(transientRead); - yield* Effect.yieldNow; - expect(stop.pollUnsafe()).toBeUndefined(); - yield* TestClock.adjust("25 millis"); - yield* Deferred.await(targetObserved); - expect(stop.pollUnsafe()).toBeUndefined(); - yield* TestClock.adjust("25 millis"); - yield* Fiber.join(stop); - expect(ownerReads).toBe(4); - }), -); - -it.effect("finishes the captured stop when a replacement session answers with conflict", () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12352, url: "http://127.0.0.1:12352" }; - const ownerSessionId = "replaced-session"; - let ownerReads = 0; - let stopRequests = 0; - const transport: HttpTransportClient["Service"] = { - request: (_requestEndpoint, path) => { - if (path === "/owner") { - ownerReads += 1; - return Effect.succeed( - Response.json(remoteOwner(ownerReads === 1 ? ownerSessionId : "replacement-session")), - ); - } - if (path === "/stop") { - stopRequests += 1; - return Effect.succeed(new Response(null, { status: 409 })); - } - return Effect.die(`unexpected request ${path}`); - }, - }; - - const result = yield* Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - return yield* remote.stop.pipe(Effect.result); - }).pipe(Effect.provide(remoteLayer(endpoint, ownerSessionId, transport))), - ); - - expect(Result.isSuccess(result)).toBe(true); - expect(stopRequests).toBe(1); - expect(ownerReads).toBe(2); - }), -); - -it.effect("finishes a fenced stop when another stack rebinds the endpoint", () => - Effect.gen(function* () { - const endpoint = { hostname: "127.0.0.1", port: 12346, url: "http://127.0.0.1:12346" }; - let ownerReads = 0; - const response = (status: unknown) => - new Response(JSON.stringify(status), { - status: 200, - headers: { "content-type": "application/json" }, - }); - const initialOwner = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: ownerId, - ownerSessionId: "stop-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - } as const; - const transportLayer = Layer.succeed(HttpTransportClient, { - request: (_requestEndpoint, path) => { - if (path === "/owner") { - ownerReads += 1; - return Effect.succeed( - response( - ownerReads === 1 ? initialOwner : { ...initialOwner, ownershipId: "f".repeat(64) }, - ), - ); - } - if (path === "/stop") return Effect.succeed(new Response(null, { status: 202 })); - return Effect.die(`unexpected request ${path}`); - }, - }); - const layer = RemoteStack.layer(endpoint, { - cliVersion: "test", - owner: { - ownershipId: ownerId, - ownerSessionId: initialOwner.ownerSessionId, - controlProtocolVersion: 1, - daemonCliVersion: "test", - }, - }).pipe(Layer.provide(transportLayer)); - const result = yield* Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - return yield* remote.stop.pipe(Effect.result); - }).pipe(Effect.provide(layer)), - ); - expect(Result.isSuccess(result)).toBe(true); - expect(ownerReads).toBe(2); - }), -); - -it.live("interrupts the real RPC handler fiber when the client request is canceled", () => - live( - Effect.scoped( - Effect.gen(function* () { - const started = Deferred.makeUnsafe(); - const finalized = Deferred.makeUnsafe(); - const info: StackInfo = { - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://localhost/postgres", - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "role", - serviceEndpoints: {}, - }; - const stack: Stack["Service"] = { - ...makeTestStack(), - getInfo: Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(finalized, undefined)), - Effect.as(info), - ), - }; - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: ownerId, - ownerSessionId: "rpc-cancel-session", - daemonCliVersion: "test", - }); - yield* lifecycle.publishStack(stack); - const application = { - app: yield* makeSupervisorControlApplication(lifecycle), - }; - const owner = yield* acquireControl({ - stackId: ownerId, - initialStatus: yield* lifecycle.currentStatus, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); - yield* lifecycle.setClose(owner.close); - const ownerStatus = supervisorStatus(yield* lifecycle.currentStatus); - const layer = RemoteStack.layer(owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: owner.ownershipId, - ownerSessionId: ownerStatus.ownerSessionId, - controlProtocolVersion: ownerStatus.controlProtocolVersion, - daemonCliVersion: ownerStatus.daemonCliVersion, - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - yield* Effect.gen(function* () { - const remote = yield* Stack; - const request = yield* Effect.forkChild(remote.getInfo); - yield* Deferred.await(started); - yield* Fiber.interrupt(request); - yield* Deferred.await(finalized); - }).pipe(Effect.provide(layer)); - }), - ), - ), -); diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts deleted file mode 100644 index 48d533ac7e..0000000000 --- a/packages/stack/src/RemoteStack.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { Data, Effect, Exit, Fiber, Layer, Match, Scope, Schema, Stream } from "effect"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as HttpClientError from "effect/unstable/http/HttpClientError"; -import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import * as HttpBody from "effect/unstable/http/HttpBody"; -import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import * as RpcClient from "effect/unstable/rpc/RpcClient"; -import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; -import type * as Rpc from "effect/unstable/rpc/Rpc"; -import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; -import type { Scope as ScopeType } from "effect/Scope"; -import { - DaemonUpgradeRequired, - StackBuildError, - StackRpcProtocolError, - StackRpcTransportError, - StackUnavailableError, -} from "./errors.ts"; -import { HttpTransportClient, makeHttpControlClient } from "./HttpTransportClient.ts"; -import { - ControlAddressConflictError, - ControlProtocolError, - ControlProtocolMismatchError, - ControlTransportError, - type ControlEndpoint, -} from "./managed/control.ts"; -import { Stack } from "./Stack.ts"; -import { inheritReadyOptions } from "./StackConfig.ts"; -import { - StackRpc, - STACK_RPC_PATH, - stackRpcFenceHeaders, - type StackLaunchUpdateRpc, - type StackRpcFence, -} from "./StackRpc.ts"; -import { StackServiceState } from "./StackServiceState.ts"; -import { - CONTROL_PROTOCOL_VERSION, - isControlSupervisorStatus, - type ControlSupervisorStatus, -} from "./DaemonProtocol.ts"; - -class HttpBodyStreamError extends Data.TaggedError("HttpBodyStreamError")<{ - readonly cause: unknown; -}> {} - -interface RemoteOwnerDescriptor { - readonly ownershipId: string; - readonly ownerSessionId: string; - readonly endpoint: ControlEndpoint; - readonly controlProtocolVersion: typeof CONTROL_PROTOCOL_VERSION; - readonly daemonCliVersion: string; -} - -export interface RemoteStackOptions { - readonly owner: Omit; - readonly cliVersion: string; - readonly stackId?: string; -} - -const protocolError = ( - endpoint: ControlEndpoint, - procedure: string, - detail: string, - cause?: unknown, -) => - new StackRpcProtocolError({ - endpoint: endpoint.url, - procedure, - detail, - ...(cause === undefined ? {} : { cause }), - }); -const transportError = (endpoint: ControlEndpoint, procedure: string, cause: unknown) => - new StackRpcTransportError({ endpoint: endpoint.url, procedure, cause }); - -const controlErrorToRpc = ( - endpoint: ControlEndpoint, - procedure: string, - error: - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError, -): StackRpcTransportError | StackRpcProtocolError => { - if (error instanceof ControlTransportError) return transportError(endpoint, procedure, error); - const detail = - error instanceof ControlProtocolMismatchError || error instanceof ControlAddressConflictError - ? error.message - : procedure === "owner" && typeof error.cause === "number" - ? `Owner probe returned HTTP ${error.cause}` - : `Invalid ${procedure} response`; - return protocolError(endpoint, procedure, detail, error); -}; - -const translateRpcClientFailure = ( - error: RpcClientError.RpcClientError, - endpoint: ControlEndpoint, - procedure: string, -): StackRpcTransportError | StackRpcProtocolError => { - const reason = error.reason; - if (Schema.is(RpcClientError.RpcClientDefect)(reason)) - return protocolError(endpoint, procedure, reason.message, reason.cause); - if (Schema.is(HttpClientError.HttpClientErrorSchema)(reason)) - return reason.kind === "TransportError" - ? transportError(endpoint, procedure, reason.cause ?? reason) - : protocolError(endpoint, procedure, error.message, reason); - return transportError(endpoint, procedure, reason); -}; - -const bodyForRequest = ( - body: HttpBody.HttpBody, -): Effect.Effect => { - return Match.valueTags(body, { - Empty: () => Effect.void.pipe(Effect.as(undefined)), - FormData: () => Effect.void.pipe(Effect.as(undefined)), - Uint8Array: (value) => Effect.succeed(value.body), - Raw: (value) => Effect.succeed(typeof value.body === "string" ? value.body : undefined), - Stream: (value) => - // HttpBody streams originate in the foreign RPC transport and expose - // their producer's unknown failure type; map it immediately below. - // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context - Stream.runCollect(value.stream).pipe( - Effect.mapError((cause) => new HttpBodyStreamError({ cause })), - Effect.map((chunks) => { - const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - const result = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.byteLength; - } - return result; - }), - ), - }); -}; - -const makeHttpClient = ( - endpoint: ControlEndpoint, - transport: HttpTransportClient["Service"], - fence: StackRpcFence, -): HttpClient.HttpClient => - HttpClient.make((request, url, signal) => { - const rawPath = `${url.pathname}${url.search}`; - const path = rawPath === `${STACK_RPC_PATH}/` ? STACK_RPC_PATH : rawPath; - return bodyForRequest(request.body).pipe( - Effect.flatMap((body) => - transport.request(endpoint, path, { - method: request.method, - headers: { ...request.headers, ...stackRpcFenceHeaders(fence) }, - signal, - ...(body === undefined ? {} : { body }), - }), - ), - Effect.map((response) => HttpClientResponse.fromWeb(request, response)), - Effect.mapError( - (cause) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request, cause }), - }), - ), - ); - }); - -type GeneratedRpcClient = RpcClient.RpcClient< - RpcGroup.Rpcs, - RpcClientError.RpcClientError ->; -const makeRemoteRpcClient = ( - endpoint: ControlEndpoint, - options: RemoteStackOptions, -): Effect.Effect< - { readonly client: GeneratedRpcClient; readonly owner: ControlSupervisorStatus }, - DaemonUpgradeRequired | StackRpcTransportError | StackRpcProtocolError, - HttpTransportClient | ScopeType -> => - Effect.gen(function* () { - const transport = yield* HttpTransportClient; - const control = makeHttpControlClient(transport); - const expectedOwner = options.owner; - const ownerStatus = yield* control - .readOwner(endpoint, expectedOwner.ownershipId) - .pipe(Effect.mapError((error) => controlErrorToRpc(endpoint, "owner", error))); - if (!isControlSupervisorStatus(ownerStatus)) { - return yield* protocolError( - endpoint, - "owner", - `Managed stack is busy with ${ownerStatus.operation}`, - ); - } - if (options.cliVersion !== ownerStatus.daemonCliVersion) - return yield* new DaemonUpgradeRequired({ - stackId: options.stackId ?? expectedOwner.ownershipId, - oldCliVersion: ownerStatus.daemonCliVersion, - newCliVersion: options.cliVersion, - state: ownerStatus.state, - ready: ownerStatus.ready, - }); - if ( - ownerStatus.ownershipId !== expectedOwner.ownershipId || - ownerStatus.ownerSessionId !== expectedOwner.ownerSessionId || - ownerStatus.controlProtocolVersion !== expectedOwner.controlProtocolVersion || - ownerStatus.daemonCliVersion !== expectedOwner.daemonCliVersion - ) - return yield* protocolError( - endpoint, - "owner", - "Remote supervisor owner descriptor changed before RPC construction", - ); - const rpcHttpClient = HttpClient.mapRequest( - makeHttpClient(endpoint, transport, { - ownershipId: expectedOwner.ownershipId, - ownerSessionId: expectedOwner.ownerSessionId, - }), - HttpClientRequest.prependUrl(`${endpoint.url}${STACK_RPC_PATH}`), - ); - const protocol = yield* RpcClient.makeProtocolHttp(rpcHttpClient).pipe( - Effect.provide(RpcSerialization.layerNdjson), - ); - const client = yield* RpcClient.make(StackRpc).pipe( - Effect.provideService(RpcClient.Protocol, protocol), - ); - return { client, owner: ownerStatus }; - }); - -type StackRpcDomainError = Rpc.Error>; -type StackRpcFailure = StackRpcDomainError | RpcClientError.RpcClientError; - -const isRpcClientFailure = ( - error: E, -): error is Extract => - Schema.is(RpcClientError.RpcClientError)(error); - -const callRpc = ( - endpoint: ControlEndpoint, - procedure: string, - effect: Effect.Effect, -) => - effect.pipe( - Effect.catchIf(isRpcClientFailure, (error) => - Effect.fail(translateRpcClientFailure(error, endpoint, procedure)), - ), - ); - -const fastCall = ( - endpoint: ControlEndpoint, - procedure: string, - effect: Effect.Effect, -) => - callRpc(endpoint, procedure, effect).pipe( - Effect.timeout("30 seconds"), - Effect.catchTag("TimeoutError", (cause) => - Effect.fail(transportError(endpoint, procedure, cause)), - ), - ); - -const streamRpc = ( - endpoint: ControlEndpoint, - procedure: string, - stream: Stream.Stream, -) => - stream.pipe( - Stream.catchIf(isRpcClientFailure, (error) => - Stream.fail(translateRpcClientFailure(error, endpoint, procedure)), - ), - ); -export const RemoteStack = { - layer: ( - endpoint: ControlEndpoint, - options: RemoteStackOptions, - ): Layer.Layer< - Stack, - DaemonUpgradeRequired | StackRpcTransportError | StackRpcProtocolError, - HttpTransportClient - > => - Layer.effect( - Stack, - Effect.gen(function* () { - const parentScope = yield* Effect.scope; - const rpcScope = yield* Scope.fork(parentScope); - const transport = yield* HttpTransportClient; - const control = makeHttpControlClient(transport); - const { client } = yield* makeRemoteRpcClient(endpoint, options).pipe( - Scope.provide(rpcScope), - ); - // Cache only creation of a detached cleanup fiber, never the cleanup - // completion itself. Scope.close marks the scope closed before its - // finalizers complete, and callers must be able to interrupt their - // wait without interrupting shared cleanup. The detached fiber is - // intentionally owned by the process-wide scope; every later - // stop/dispose caller observes its completion by joining the same - // cached fiber. - const closeRpcScopeFiber = yield* Effect.cached( - Effect.uninterruptible( - Effect.forkDetach(Scope.close(rpcScope, Exit.void), { uninterruptible: true }), - ), - ); - const scopedRpcStream = (stream: Stream.Stream) => - stream.pipe(Stream.provideService(Scope.Scope, rpcScope)); - const call = ( - procedure: string, - effect: Effect.Effect, - ) => callRpc(endpoint, procedure, effect); - const requestStop = () => { - const owner = options.owner; - // Observe foreign finalizer failures so they cannot suppress the - // fenced control-plane stop. The network request remains - // interruptible and bounded by the control client. - return Effect.exit(closeRpcScopeFiber.pipe(Effect.flatMap(Fiber.join))).pipe( - Effect.andThen(control.stopSession(endpoint, owner.ownershipId, owner.ownerSessionId)), - ); - }; - return { - getInfo: fastCall(endpoint, "GetInfo", client.GetInfo(undefined)), - start: call("StartStack", client.StartStack(undefined)), - stop: requestStop(), - dispose: requestStop(), - startService: (name: string) => call("StartService", client.StartService({ name })), - stopService: (name: string) => call("StopService", client.StopService({ name })), - restartService: (name: string) => call("RestartService", client.RestartService({ name })), - reloadFunctions: (opts) => - call( - "ReloadFunctions", - client.ReloadFunctions(opts === undefined ? {} : { options: opts }), - ), - reloadEdgeRuntime: (opts) => call("ReloadEdgeRuntime", client.ReloadEdgeRuntime(opts)), - getState: (name: string) => - fastCall(endpoint, "GetServiceState", client.GetServiceState({ name })).pipe( - Effect.map((state) => new StackServiceState(state)), - ), - getAllStates: fastCall( - endpoint, - "GetAllServiceStates", - client.GetAllServiceStates(undefined), - ).pipe(Effect.map((states) => states.map((state) => new StackServiceState(state)))), - stateChanges: (name: string) => - fastCall(endpoint, "GetServiceState", client.GetServiceState({ name })).pipe( - Effect.as( - scopedRpcStream( - streamRpc(endpoint, "WatchServiceStates", client.WatchServiceStates({ name })), - ).pipe(Stream.map((state) => new StackServiceState(state))), - ), - ), - allStateChanges: scopedRpcStream( - streamRpc(endpoint, "WatchServiceStates", client.WatchServiceStates({})), - ).pipe( - Stream.catchTag("ServiceNotFoundError", Stream.die), - Stream.map((state) => new StackServiceState(state)), - ), - waitReady: (name: string, opts) => - call( - "WaitServiceReady", - client.WaitServiceReady({ name, options: opts ?? inheritReadyOptions }), - ), - waitAllReady: (opts) => - call("WaitStackReady", client.WaitStackReady({ options: opts ?? inheritReadyOptions })), - subscribeLogs: (name: string) => - scopedRpcStream(streamRpc(endpoint, "WatchLogs", client.WatchLogs({ name }))), - subscribeAllLogs: (services) => - scopedRpcStream( - streamRpc( - endpoint, - "WatchLogs", - client.WatchLogs(services === undefined ? {} : { services }), - ), - ), - logHistory: (name: string, limit?: number) => - fastCall( - endpoint, - "GetLogHistory", - client.GetLogHistory(limit === undefined ? { name } : { name, limit }), - ), - logHistoryAll: (limit?: number, services?: ReadonlyArray) => - fastCall( - endpoint, - "GetLogHistory", - client.GetLogHistory({ - ...(limit === undefined ? {} : { limit }), - ...(services === undefined ? {} : { services }), - }), - ), - }; - }), - ), -}; - -export const updateRemoteLaunch = ( - endpoint: ControlEndpoint, - options: RemoteStackOptions, - stackId: string, - launch: StackLaunchUpdateRpc, -): Effect.Effect< - void, - | DaemonUpgradeRequired - | StackBuildError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError, - HttpTransportClient -> => - Effect.scoped( - makeRemoteRpcClient(endpoint, options).pipe( - Effect.flatMap(({ client }) => - fastCall(endpoint, "UpdateLaunch", client.UpdateLaunch({ stackId, launch })), - ), - ), - ); diff --git a/packages/stack/src/ServiceActivation.ts b/packages/stack/src/ServiceActivation.ts deleted file mode 100644 index 78b45c0a2f..0000000000 --- a/packages/stack/src/ServiceActivation.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ServiceNotFoundError } from "@supabase/process-compose"; -import type { ServiceReadyError } from "@supabase/process-compose"; -import { Context, Effect, Layer } from "effect"; -import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; -import { stackServiceStartupBudgetSeconds } from "./services/health-budgets.ts"; -import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; -import type { ReadinessPolicy } from "./StackConfig.ts"; -import type { ServicePolicyManifest } from "./StackConfig.ts"; - -export const eagerServices = ( - enabled: ReadonlyArray, - policies: ServicePolicyManifest, -): ReadonlyArray => enabled.filter((service) => policies[service] === "eager"); - -export const activationTargetsForService = ( - enabledServices: ReadonlyArray, - service: ServiceName, -): ReadonlyArray => { - const enabled = new Set(enabledServices); - const targets = new Set(); - const addWithCompanions = (target: ServiceName): void => { - if (!enabled.has(target) || targets.has(target)) return; - for (const activated of serviceMetadata(target).activation.activates) { - addWithCompanions(activated); - } - targets.add(target); - }; - addWithCompanions(service); - - return [...targets]; -}; - -const DEFAULT_ACTIVATION_TIMEOUT_FLOOR_SECONDS = 180; -const ACTIVATION_COORDINATION_MARGIN_SECONDS = 5; - -/** - * Bounds request-triggered lazy activation by the complete companion closure. - * The floor preserves the existing tolerance for services with shorter probe - * budgets, while longer transitive closures expand the timeout automatically. - */ -export const activationTimeoutSecondsForService = (service: ServiceName): number => { - const startupBudget = activationTargetsForService(SERVICE_NAMES, service).reduce( - (total, target) => total + stackServiceStartupBudgetSeconds[target], - 0, - ); - return Math.max( - DEFAULT_ACTIVATION_TIMEOUT_FLOOR_SECONDS, - startupBudget + ACTIVATION_COORDINATION_MARGIN_SECONDS, - ); -}; - -/** - * The package default expands for services whose transitive startup budget is - * longer than three minutes. An explicit stack policy remains authoritative. - */ -export const activationReadinessPolicy = ( - service: ServiceName, - stackPolicy: ReadinessPolicy, - source: "default" | "configured", -): ReadinessPolicy => - source === "default" - ? { mode: "finite", timeoutMs: activationTimeoutSecondsForService(service) * 1000 } - : stackPolicy; - -/** Services exclusively owned by a public service for stop/restart operations. */ -export const lifecycleTargetsForService = ( - enabledServices: ReadonlyArray, - service: ServiceName, -): ReadonlyArray => { - const enabled = new Set(enabledServices); - const targets: ServiceName[] = []; - const add = (target: ServiceName): void => { - if (enabled.has(target)) targets.push(target); - }; - - const addWithOwnedCompanions = (target: ServiceName): void => { - add(target); - for (const owned of serviceMetadata(target).activation.owns) { - addWithOwnedCompanions(owned); - } - }; - addWithOwnedCompanions(service); - return targets; -}; - -export class StackServiceActivator extends Context.Service< - StackServiceActivator, - { - readonly activate: ( - service: ServiceName, - ) => Effect.Effect< - void, - | ServiceNotFoundError - | ServiceReadyError - | StackBuildError - | StackNotRunningError - | StackReadinessError - >; - } ->()("stack/StackServiceActivator") { - static noop = Layer.succeed(this, { - activate: () => Effect.void, - }); -} diff --git a/packages/stack/src/ServiceActivation.unit.test.ts b/packages/stack/src/ServiceActivation.unit.test.ts deleted file mode 100644 index 531e155d26..0000000000 --- a/packages/stack/src/ServiceActivation.unit.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - activationReadinessPolicy, - activationTimeoutSecondsForService, - activationTargetsForService, - eagerServices, - lifecycleTargetsForService, -} from "./ServiceActivation.ts"; -import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; -import { DEFAULT_SERVICE_POLICIES } from "./ServiceCatalog.ts"; - -describe("service activation", () => { - it("defines an access policy for every stack service", () => { - expect(Object.keys(SERVICE_CATALOG).sort()).toEqual([...SERVICE_NAMES].sort()); - }); - - it("starts direct endpoints eagerly", () => { - expect(eagerServices(SERVICE_NAMES, DEFAULT_SERVICE_POLICIES)).toEqual([ - "postgres", - "realtime", - "mailpit", - "studio", - "pooler", - ]); - }); - - it("activates service companions transitively", () => { - expect(activationTargetsForService(SERVICE_NAMES, "storage")).toEqual(["imgproxy", "storage"]); - expect(activationTargetsForService(SERVICE_NAMES, "analytics")).toEqual([ - "vector", - "analytics", - ]); - expect(activationTargetsForService(SERVICE_NAMES, "studio")).toEqual([ - "vector", - "analytics", - "studio", - ]); - }); - - it("omits disabled companions", () => { - const enabled = SERVICE_NAMES.filter( - (service) => service !== "imgproxy" && service !== "vector", - ); - expect(activationTargetsForService(enabled, "storage")).toEqual(["storage"]); - expect(activationTargetsForService(enabled, "analytics")).toEqual(["analytics"]); - expect(activationTargetsForService(enabled, "studio")).toEqual(["analytics", "studio"]); - }); - - it("derives request activation timeouts from the transitive companion closure", () => { - expect(activationTimeoutSecondsForService("auth")).toBe(180); - expect(activationTimeoutSecondsForService("analytics")).toBe(554); - expect(activationTimeoutSecondsForService("studio")).toBe(825); - }); - - it("expands only the package-default activation deadline", () => { - expect( - activationReadinessPolicy("analytics", { mode: "finite", timeoutMs: 180_000 }, "default"), - ).toEqual({ mode: "finite", timeoutMs: 554_000 }); - expect( - activationReadinessPolicy("analytics", { mode: "finite", timeoutMs: 10_000 }, "configured"), - ).toEqual({ mode: "finite", timeoutMs: 10_000 }); - expect(activationReadinessPolicy("analytics", { mode: "infinite" }, "configured")).toEqual({ - mode: "infinite", - }); - }); - - it("does not assign shared public dependencies to their consumers", () => { - expect(lifecycleTargetsForService(SERVICE_NAMES, "storage")).toEqual(["storage", "imgproxy"]); - expect(lifecycleTargetsForService(SERVICE_NAMES, "analytics")).toEqual(["analytics", "vector"]); - expect(lifecycleTargetsForService(SERVICE_NAMES, "studio")).toEqual(["studio"]); - }); -}); diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts deleted file mode 100644 index d04d709971..0000000000 --- a/packages/stack/src/ServiceCatalog.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { Record } from "effect"; -import { nativeTargetForPlatform, type NativeTarget, type PlatformInfo } from "./Platform.ts"; -import type { PortField } from "./PortCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; - -type ServiceRuntimeSupport = "native-preferred" | "docker-only"; -type ArchiveFormat = "tar.zst"; -export type ServicePreparationPolicy = "off" | "lazy" | "eager"; - -export interface NativeReleaseArtifact { - readonly service: ServiceName; - readonly version: string; - readonly provider: string; - readonly assetName: string; - readonly releaseTag: string; - readonly target: NativeTarget; - readonly archive: ArchiveFormat; - readonly downloadUrl: string; - readonly manifestUrl: string; - readonly checksumUrl: string; - readonly requiredRuntimePaths: ReadonlyArray; -} - -interface NativeReleaseSource { - readonly provider: string; - readonly resolve: (version: string, platform: PlatformInfo) => NativeReleaseArtifact | undefined; -} - -interface DockerImageSource { - readonly registry?: string; - readonly repository: string; - readonly tagPrefix?: string; -} - -interface ServiceArtifactDefinition { - readonly docker: DockerImageSource; - readonly native?: NativeReleaseSource; -} - -interface ServiceActivationPolicy { - /** Other public services required when this service is activated. */ - readonly activates: ReadonlyArray; - /** Private companions whose lifecycle is exclusively owned by this service. */ - readonly owns: ReadonlyArray; -} - -export interface ServicePreparationMetadata { - /** Policies supported by the service's runtime/resource implementation. */ - readonly supported: ReadonlyArray>; - readonly default: Exclude; - /** Services whose resources must be materialized before this service can start. */ - readonly dependencies: ReadonlyArray; -} - -type ServiceConfigKey = - | "postgres" - | "postgrest" - | "auth" - | "edgeRuntime" - | "realtime" - | "storage" - | "imgproxy" - | "mailpit" - | "pgmeta" - | "studio" - | "analytics" - | "vector" - | "pooler"; - -export interface ServiceCatalogEntry { - readonly name: Name; - readonly configKey: ServiceConfigKey; - readonly defaultVersion: string; - readonly runtimeSupport: ServiceRuntimeSupport; - readonly artifact: ServiceArtifactDefinition; - readonly activation: ServiceActivationPolicy; - readonly preparation: ServicePreparationMetadata; - readonly portFields: ReadonlyArray; -} - -const SUPABASE_GHCR_REGISTRY = "ghcr.io/supabase"; -const SUPABASE_CLI_GHCR_REGISTRY = `${SUPABASE_GHCR_REGISTRY}/cli`; -const SLIM_RELEASE_BASE = "https://github.com/supabase/slim-services/releases/download"; - -const nativeRelease = ( - service: ServiceName, - version: string, - platform: PlatformInfo, - options: { - readonly requiredRuntimePaths: ReadonlyArray; - }, -): NativeReleaseArtifact | undefined => { - const target = nativeTargetForPlatform(platform); - if (target === undefined) return undefined; - const releaseTag = `${service}-${version}`; - const base = `${SLIM_RELEASE_BASE}/${releaseTag}`; - const assetName = `${releaseTag}-${target}`; - return { - service, - version, - provider: "github.com/supabase/slim-services", - assetName, - releaseTag, - target, - archive: "tar.zst", - downloadUrl: `${base}/${assetName}.tar.zst`, - manifestUrl: `${base}/${assetName}.manifest.json`, - checksumUrl: `${base}/SHA256SUMS`, - requiredRuntimePaths: options.requiredRuntimePaths, - }; -}; - -const preparation = ( - supported: ReadonlyArray>, - defaultPolicy: Exclude, - dependencies: ReadonlyArray = [], -): ServicePreparationMetadata => ({ - supported, - default: defaultPolicy, - dependencies, -}); - -/** - * Exhaustive static identity and capability metadata for public stack services. - * Cross-service topology and process definitions deliberately remain in StackBuilder. - */ -export const SERVICE_CATALOG = { - postgres: { - name: "postgres", - configKey: "postgres", - defaultVersion: "17.6.1.165", - runtimeSupport: "native-preferred", - artifact: { - docker: { repository: "postgres" }, - native: { - provider: "github.com/supabase/slim-services", - resolve: (version, platform) => - nativeRelease("postgres", version, platform, { - requiredRuntimePaths: [ - "bin/postgres", - "bin/pg_isready", - "bin/psql", - "share/supabase-cli/bin/supabase-postgres-init.sh", - "share/supabase-cli/config/pgsodium_getkey.sh", - "share/supabase-cli/migrations", - "lib", - ], - }), - }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["eager"], "eager"), - portFields: ["dbPort"], - }, - postgrest: { - name: "postgrest", - configKey: "postgrest", - defaultVersion: "v16.2", - runtimeSupport: "native-preferred", - artifact: { - docker: { repository: "postgrest" }, - native: { - provider: "github.com/supabase/slim-services", - resolve: (version, platform) => - nativeRelease("postgrest", version, platform, { - requiredRuntimePaths: ["bin/postgrest"], - }), - }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), - portFields: ["postgrestPort", "postgrestAdminPort"], - }, - auth: { - name: "auth", - configKey: "auth", - defaultVersion: "v2.196.0", - runtimeSupport: "native-preferred", - artifact: { - docker: { repository: "auth" }, - native: { - provider: "github.com/supabase/slim-services", - resolve: (version, platform) => - nativeRelease("auth", version, platform, { - requiredRuntimePaths: ["bin/auth"], - }), - }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), - portFields: ["authPort"], - }, - "edge-runtime": { - name: "edge-runtime", - configKey: "edgeRuntime", - defaultVersion: "v1.74.3", - runtimeSupport: "docker-only", - artifact: { - docker: { repository: "edge-runtime" }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), - portFields: ["edgeRuntimePort", "edgeRuntimeInspectorPort"], - }, - realtime: { - name: "realtime", - configKey: "realtime", - defaultVersion: "v2.129.9", - runtimeSupport: "docker-only", - artifact: { - docker: { repository: "realtime" }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["eager"], "eager", ["postgres"]), - portFields: ["realtimePort"], - }, - storage: { - name: "storage", - configKey: "storage", - defaultVersion: "v1.71.0", - runtimeSupport: "docker-only", - artifact: { - docker: { repository: "storage" }, - }, - activation: { activates: ["imgproxy"], owns: ["imgproxy"] }, - preparation: preparation(["lazy", "eager"], "lazy", ["postgres", "imgproxy"]), - portFields: ["storagePort"], - }, - imgproxy: { - name: "imgproxy", - configKey: "imgproxy", - defaultVersion: "v3.8.0", - runtimeSupport: "docker-only", - artifact: { - docker: { repository: "imgproxy" }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["lazy", "eager"], "lazy", ["storage"]), - portFields: ["imgproxyPort"], - }, - mailpit: { - name: "mailpit", - configKey: "mailpit", - defaultVersion: "v1.30.2", - runtimeSupport: "docker-only", - artifact: { - docker: { repository: "mailpit" }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["eager"], "eager"), - portFields: ["mailpitPort", "mailpitSmtpPort", "mailpitPop3Port"], - }, - pgmeta: { - name: "pgmeta", - configKey: "pgmeta", - defaultVersion: "0.98.0", - runtimeSupport: "docker-only", - artifact: { - docker: { repository: "pgmeta", tagPrefix: "v" }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["lazy", "eager"], "lazy", ["postgres"]), - portFields: ["pgmetaPort"], - }, - studio: { - name: "studio", - configKey: "studio", - defaultVersion: "2026.08.24-sha-8ec45b2", - runtimeSupport: "docker-only", - artifact: { - docker: { repository: "studio" }, - }, - activation: { activates: ["analytics"], owns: [] }, - preparation: preparation(["eager"], "eager", ["pgmeta", "analytics"]), - portFields: ["studioPort"], - }, - analytics: { - name: "analytics", - configKey: "analytics", - defaultVersion: "v1.50.6", - runtimeSupport: "docker-only", - artifact: { - docker: { repository: "analytics" }, - }, - activation: { activates: ["vector"], owns: ["vector"] }, - preparation: preparation(["lazy", "eager"], "lazy", ["postgres", "vector"]), - portFields: ["analyticsPort"], - }, - vector: { - name: "vector", - configKey: "vector", - defaultVersion: "0.53.0-alpine", - runtimeSupport: "docker-only", - artifact: { - docker: { registry: SUPABASE_GHCR_REGISTRY, repository: "vector" }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["lazy", "eager"], "lazy", ["analytics"]), - portFields: [], - }, - pooler: { - name: "pooler", - configKey: "pooler", - defaultVersion: "2.9.7", - runtimeSupport: "docker-only", - artifact: { - docker: { registry: SUPABASE_GHCR_REGISTRY, repository: "supavisor" }, - }, - activation: { activates: [], owns: [] }, - preparation: preparation(["eager"], "eager", ["postgres"]), - portFields: ["poolerPort", "poolerApiPort"], - }, -} satisfies { readonly [Name in ServiceName]: ServiceCatalogEntry }; - -export const SERVICE_NAMES: ReadonlyArray = Record.keys(SERVICE_CATALOG); - -export const DEFAULT_VERSIONS: Readonly> = Record.map( - SERVICE_CATALOG, - (metadata) => metadata.defaultVersion, -); - -export const serviceMetadata = (service: ServiceName): ServiceCatalogEntry => - SERVICE_CATALOG[service]; - -export const nativeReleaseForService = ( - service: ServiceName, - version: string, - platform: PlatformInfo, -): NativeReleaseArtifact | undefined => - serviceMetadata(service).artifact.native?.resolve(version, platform); - -export const isDockerOnlyService = (service: ServiceName): boolean => - SERVICE_CATALOG[service].runtimeSupport === "docker-only"; - -export const DEFAULT_SERVICE_POLICIES: Readonly< - Record> -> = Record.map(SERVICE_CATALOG, (metadata) => metadata.preparation.default); - -export const requiredPreparationDependencies = (service: ServiceName): ReadonlyArray => - serviceMetadata(service).preparation.dependencies; - -export const dockerImageForArtifact = (service: ServiceName, version: string): string => { - const source = serviceMetadata(service).artifact.docker; - return `${source.registry ?? SUPABASE_CLI_GHCR_REGISTRY}/${source.repository}:${source.tagPrefix ?? ""}${version}`; -}; diff --git a/packages/stack/src/ServiceExclusions.ts b/packages/stack/src/ServiceExclusions.ts deleted file mode 100644 index 33b9def78b..0000000000 --- a/packages/stack/src/ServiceExclusions.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { SERVICE_NAMES } from "./ServiceCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; - -const excludedCompanions: Readonly>> = { - storage: ["imgproxy"], - pgmeta: ["studio"], - analytics: ["vector"], - postgres: [], - postgrest: [], - auth: [], - "edge-runtime": [], - realtime: [], - imgproxy: [], - mailpit: [], - studio: [], - vector: [], - pooler: [], -}; - -const isServiceName = (value: string): value is ServiceName => - SERVICE_NAMES.some((service) => service === value); - -/** Expands public exclusions to include services whose graph requires them. */ -export const expandExcludedServices = ( - services: ReadonlyArray, -): ReadonlySet => { - const expanded = new Set(); - for (const service of services) { - if (!isServiceName(service)) continue; - expanded.add(service); - for (const companion of excludedCompanions[service]) expanded.add(companion); - } - return expanded; -}; diff --git a/packages/stack/src/ServiceName.ts b/packages/stack/src/ServiceName.ts deleted file mode 100644 index f9524cacd2..0000000000 --- a/packages/stack/src/ServiceName.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Public Supabase services represented in a local stack. */ -export type ServiceName = - | "postgres" - | "postgrest" - | "auth" - | "edge-runtime" - | "realtime" - | "storage" - | "imgproxy" - | "mailpit" - | "pgmeta" - | "studio" - | "analytics" - | "vector" - | "pooler"; diff --git a/packages/stack/src/ServicePorts.ts b/packages/stack/src/ServicePorts.ts deleted file mode 100644 index 5674f87ce9..0000000000 --- a/packages/stack/src/ServicePorts.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { PORT_CATALOG, PORT_FIELDS, type PortField } from "./PortCatalog.ts"; -import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; -import type { StackConfig } from "./StackConfig.ts"; - -const serviceEnabledForConfig = (config: StackConfig, service: keyof typeof SERVICE_CATALOG) => { - if (config.servicePolicies?.[service] === "off") return false; - if (service === "postgres" || service === "postgrest" || service === "auth") { - return config[service === "postgres" ? "postgres" : service] !== false; - } - if (service === "edge-runtime") { - const mode = config.mode ?? "native"; - return ( - !(mode === "native" && config.edgeRuntime === undefined) && - config.edgeRuntime !== false && - (config.edgeRuntime?.enabled ?? true) !== false - ); - } - const configKey = SERVICE_CATALOG[service].configKey; - if (configKey === "vector") return config.vector !== undefined && config.vector !== false; - return config[configKey] !== undefined && config[configKey] !== false; -}; - -/** Classifies active services before any ports are resolved. */ -export const portFieldsForConfigInput = (config: StackConfig = {}): ReadonlyArray => - PORT_FIELDS.filter( - (field) => - field === "apiPort" || - field === "dbPort" || - (PORT_CATALOG[field].service !== undefined && - serviceEnabledForConfig(config, PORT_CATALOG[field].service)), - ); - -export const portFieldsForService = (name: string): ReadonlyArray => { - const service = SERVICE_NAMES.find((candidate) => candidate === name); - return service === undefined ? [] : SERVICE_CATALOG[service].portFields; -}; diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts deleted file mode 100644 index 8fda48f34a..0000000000 --- a/packages/stack/src/Stack.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { ServiceNotFoundError } from "@supabase/process-compose"; -import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; -import { Context, Effect, Schema, Stream } from "effect"; -import { - StackBuildError, - StackNotRunningError, - StackReadinessError, - StackRpcProtocolError, - StackRpcTransportError, - StackUnavailableError, -} from "./errors.ts"; -import { - ResolvedFunctionsBundleSchema, - type FunctionsReloadConfig, - type ResolvedFunctionsBundle, -} from "./functions.ts"; -import type { EdgeRuntimeConfig, ReadyOptions } from "./StackConfig.ts"; -import { StackServiceState } from "./StackServiceState.ts"; -import type { - ControlAddressConflictError, - ControlMaintenanceBusyError, - ControlProtocolError, - ControlProtocolMismatchError, - ControlTransportError, -} from "./managed/control.ts"; -import type { StopTimeout } from "./errors.ts"; - -export interface StackInfo { - readonly url: string; - readonly dbUrl: string; - readonly publishableKey: string; - readonly secretKey: string; - readonly anonJwt: string; - readonly serviceRoleJwt: string; - readonly serviceEndpoints: Readonly>; -} - -export const StackInfoSchema = Schema.Struct({ - url: Schema.String, - dbUrl: Schema.String, - publishableKey: Schema.String, - secretKey: Schema.String, - anonJwt: Schema.String, - serviceRoleJwt: Schema.String, - serviceEndpoints: Schema.Record(Schema.String, Schema.String), -}); - -const EdgeRuntimeConfigSchema = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - inspectorPort: Schema.optionalKey(Schema.Finite), - policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"])), - env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), -}); - -export const EdgeRuntimeReloadConfigSchema = Schema.Struct({ - edgeRuntime: EdgeRuntimeConfigSchema, - functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), -}); - -export interface EdgeRuntimeReloadConfig { - readonly edgeRuntime: EdgeRuntimeConfig; - readonly functions?: ResolvedFunctionsBundle; -} - -export class Stack extends Context.Service< - Stack, - { - readonly getInfo: Effect.Effect< - StackInfo, - StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - readonly start: Effect.Effect< - void, - | ServiceReadyError - | StackBuildError - | StackReadinessError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >; - readonly stop: Effect.Effect< - void, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - | ControlMaintenanceBusyError - | StopTimeout - >; - readonly dispose: Effect.Effect< - void, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - | ControlMaintenanceBusyError - | StopTimeout - >; - readonly startService: ( - name: string, - ) => Effect.Effect< - void, - | ServiceNotFoundError - | ServiceReadyError - | StackBuildError - | StackNotRunningError - | StackReadinessError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >; - readonly stopService: ( - name: string, - ) => Effect.Effect< - void, - | ServiceNotFoundError - | StackBuildError - | StackNotRunningError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >; - readonly restartService: ( - name: string, - ) => Effect.Effect< - void, - | ServiceNotFoundError - | ServiceReadyError - | StackBuildError - | StackNotRunningError - | StackReadinessError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >; - readonly reloadFunctions: ( - opts?: FunctionsReloadConfig, - ) => Effect.Effect< - void, - | ServiceNotFoundError - | ServiceReadyError - | StackBuildError - | StackNotRunningError - | StackReadinessError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >; - readonly reloadEdgeRuntime: ( - opts: EdgeRuntimeReloadConfig, - ) => Effect.Effect< - void, - | ServiceNotFoundError - | ServiceReadyError - | StackBuildError - | StackNotRunningError - | StackReadinessError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >; - readonly getState: ( - name: string, - ) => Effect.Effect< - StackServiceState, - ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - readonly getAllStates: Effect.Effect< - ReadonlyArray, - StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - readonly stateChanges: ( - name: string, - ) => Effect.Effect< - Stream.Stream< - StackServiceState, - | ServiceNotFoundError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >, - ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - readonly allStateChanges: Stream.Stream< - StackServiceState, - StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - readonly waitReady: ( - name: string, - opts?: ReadyOptions, - ) => Effect.Effect< - void, - | ServiceNotFoundError - | ServiceReadyError - | StackBuildError - | StackReadinessError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >; - readonly waitAllReady: ( - opts?: ReadyOptions, - ) => Effect.Effect< - void, - | ServiceReadyError - | StackBuildError - | StackReadinessError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError - >; - readonly subscribeLogs: ( - name: string, - ) => Stream.Stream< - LogEntry, - ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - readonly subscribeAllLogs: ( - services?: ReadonlyArray, - ) => Stream.Stream< - LogEntry, - ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - readonly logHistory: ( - name: string, - limit?: number, - ) => Effect.Effect< - ReadonlyArray, - ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - readonly logHistoryAll: ( - limit?: number, - services?: ReadonlyArray, - ) => Effect.Effect< - ReadonlyArray, - ServiceNotFoundError | StackUnavailableError | StackRpcTransportError | StackRpcProtocolError - >; - } ->()("stack/Stack") {} diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts deleted file mode 100644 index e638d17336..0000000000 --- a/packages/stack/src/Stack.unit.test.ts +++ /dev/null @@ -1,1987 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-date-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json, effecttsgo/run-effect-inside-effect -- Stack tests exercise native HTTP/filesystem fixtures and use direct runtime evaluation for synchronous lifecycle assertions. - -import { describe, expect, it } from "@effect/vitest"; -import { NodeServices } from "@effect/platform-node"; -import { buildGraph, ServiceNotFoundError } from "@supabase/process-compose"; -import { createHmac } from "node:crypto"; -import { mkdtempSync } from "node:fs"; -import { readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createServer, type Server } from "node:http"; -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Predicate, Stream } from "effect"; -import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; -import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; -import { StackBuildError, StackReadinessError } from "./errors.ts"; -import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; -import { functionsRuntimeConfigPath, type ResolvedFunctionsBundle } from "./functions.ts"; -import type { AllocatedPorts, PortField, ResolvedPorts } from "./PortCatalog.ts"; -import type { PortLease } from "./PortAllocator.ts"; -import { StackServiceActivator } from "./ServiceActivation.ts"; -import { Stack } from "./Stack.ts"; -import { attachReadinessDiagnostics, localStackLayer } from "./LocalStack.ts"; -import { StackPreparation } from "./StackPreparation.ts"; -import { StackBuilder } from "./StackBuilder.ts"; -import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; -import { DEFAULT_VERSIONS } from "./versions.ts"; - -const testJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; - -const defaultPorts: AllocatedPorts = { - apiPort: 54321, - dbPort: 54322, - authPort: 9999, - postgrestPort: 54323, - postgrestAdminPort: 54324, - edgeRuntimePort: 54325, - edgeRuntimeInspectorPort: 54326, - realtimePort: 54330, - storagePort: 54331, - imgproxyPort: 54332, - mailpitPort: 54333, - mailpitSmtpPort: 54334, - mailpitPop3Port: 54335, - pgmetaPort: 54336, - studioPort: 54337, - analyticsPort: 54338, - poolerPort: 54339, - poolerApiPort: 54340, -}; - -const defaultConfig: ResolvedStackConfig = { - cacheRoot: "/tmp/supabase-cache", - stackRoot: "/tmp/supabase-stack", - runtimeRoot: "/tmp/supabase-runtime", - projectDir: "/tmp/supabase-project", - runtime: { mode: "native", containerRuntime: null }, - servicePolicies: { - postgres: "eager", - postgrest: "eager", - auth: "eager", - "edge-runtime": "off", - realtime: "off", - storage: "off", - imgproxy: "off", - mailpit: "off", - pgmeta: "off", - studio: "off", - analytics: "off", - vector: "off", - pooler: "off", - }, - readiness: DEFAULT_STACK_READINESS_POLICY, - readinessSource: "default", - jwtSecret: testJwtSecret, - ports: defaultPorts, - apiPort: 54321, - dbPort: 54322, - publishableKey: defaultPublishableKey, - secretKey: defaultSecretKey, - functions: false, - autoManagedPaths: [], - anonJwt: generateJwt(testJwtSecret, "anon"), - serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), - postgres: { - port: 54322, - dataDir: "/tmp/supabase/data", - version: DEFAULT_VERSIONS.postgres, - autoExposeNewTables: true, - }, - postgrest: { - port: 54323, - adminPort: 54324, - schemas: ["public", "storage", "graphql_public"], - extraSearchPath: ["public", "extensions"], - maxRows: 1000, - version: DEFAULT_VERSIONS.postgrest, - }, - auth: { - port: 9999, - siteUrl: "http://localhost:3000", - jwtExpiry: 3600, - externalUrl: "http://127.0.0.1:54321", - version: DEFAULT_VERSIONS.auth, - }, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, -}; - -const edgeRuntimeConfig: ResolvedStackConfig = { - ...defaultConfig, - runtime: { mode: "docker", containerRuntime: "docker" }, - servicePolicies: { ...defaultConfig.servicePolicies, "edge-runtime": "eager" }, - edgeRuntime: { - enabled: true, - port: defaultPorts.edgeRuntimePort, - inspectorPort: defaultPorts.edgeRuntimeInspectorPort, - policy: "per_worker", - version: DEFAULT_VERSIONS["edge-runtime"], - env: {}, - }, -}; - -const functionsBundle = (root: string, value: string): ResolvedFunctionsBundle => ({ - env: { SHARED: value }, - functions: [ - { - name: "hello", - verifyJWT: false, - entrypointPath: join(root, "hello", "index.ts"), - importMapPath: null, - staticFiles: [], - env: { FUNCTION_VALUE: value }, - }, - ], -}); - -const noopPortLease = (ports: ResolvedPorts): PortLease => ({ - ports, - reserve: () => Effect.void, - release: () => Effect.void, - releaseAll: Effect.void, -}); - -function setupLayer( - config: ResolvedStackConfig = defaultConfig, - portLease: PortLease = noopPortLease(config.ports), - spawner = mockChildProcessSpawner(), - resolver = mockBinaryResolver(), -) { - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const layer = localStackLayer(config, portLease).pipe( - Layer.provide(StackBuilder.layer), - Layer.provide(stackPreparationLayer), - Layer.provide(spawner.layer), - Layer.provide(NodeServices.layer), - ); - - return { layer, resolver, spawner }; -} - -describe("Stack", () => { - it.effect("preserves defects and interruption while collecting readiness diagnostics", () => - Effect.gen(function* () { - const readinessError = new StackReadinessError({ - target: "stack", - timeoutMs: 10, - detail: "Timed out waiting for stack readiness", - }); - const defect = new Error("diagnostic state collection failed"); - - const defectExit = yield* attachReadinessDiagnostics( - readinessError, - Effect.die(defect), - Effect.succeed([]), - ).pipe(Effect.exit); - const interruptionExit = yield* attachReadinessDiagnostics( - readinessError, - Effect.interrupt, - Effect.succeed([]), - ).pipe(Effect.exit); - - expect(Exit.isFailure(defectExit)).toBe(true); - if (Exit.isFailure(defectExit)) { - expect(Cause.squash(defectExit.cause)).toBe(defect); - } - expect(Exit.isFailure(interruptionExit)).toBe(true); - if (Exit.isFailure(interruptionExit)) { - expect(Cause.hasInterruptsOnly(interruptionExit.cause)).toBe(true); - } - }), - ); - - it.effect("getInfo returns correct URLs based on config", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const info = yield* stack.getInfo; - - expect(info.url).toBe("http://127.0.0.1:54321"); - expect(info.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); - expect(info.serviceEndpoints.auth).toBe("http://127.0.0.1:54321/auth/v1"); - expect(info.serviceEndpoints.postgrest).toBe("http://127.0.0.1:54321/rest/v1"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("getInfo includes functions and edge runtime endpoints when enabled", () => { - const { layer } = setupLayer(edgeRuntimeConfig); - - return Effect.gen(function* () { - const stack = yield* Stack; - const info = yield* stack.getInfo; - - expect(info.serviceEndpoints.functions).toBe("http://127.0.0.1:54321/functions/v1"); - expect(info.serviceEndpoints.edge_runtime).toBe("http://127.0.0.1:54321/functions/v1"); - }).pipe(Effect.provide(layer)); - }); - - it.live("preserves current Functions and Edge Runtime settings across partial reloads", () => { - const runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-functions-reload-")); - const initialBundle = functionsBundle(runtimeRoot, "initial-secret"); - const replacementBundle = functionsBundle(runtimeRoot, "replacement-secret"); - const config = { - ...edgeRuntimeConfig, - projectDir: runtimeRoot, - runtimeRoot, - functions: initialBundle, - postgrest: false, - auth: false, - servicePolicies: { - ...edgeRuntimeConfig.servicePolicies, - postgrest: "off", - auth: "off", - "edge-runtime": "lazy", - }, - } satisfies ResolvedStackConfig; - const graph = Effect.runSync( - buildGraph([ - { - name: "postgres", - command: process.execPath, - restart: "unless-stopped", - }, - { - name: "edge-runtime", - command: process.execPath, - restart: "unless-stopped", - }, - ]), - ); - const builtConfigs: ResolvedStackConfig[] = []; - const builderLayer = Layer.succeed(StackBuilder, { - build: (candidate) => - Effect.sync(() => { - builtConfigs.push(candidate); - return { - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([ - ["postgres", { visibility: "public" as const }], - ["edge-runtime", { visibility: "public" as const }], - ]), - }; - }), - }); - const resolver = mockBinaryResolver(); - const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( - Layer.provide(builderLayer), - Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), - Layer.provide(mockChildProcessSpawner().layer), - Layer.provide(NodeServices.layer), - ); - const readRuntimeConfig = Effect.promise(() => - readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => - JSON.parse(contents), - ), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - expect((yield* stack.getState("edge-runtime")).status).toBe("Dormant"); - - yield* stack.reloadFunctions({ functions: replacementBundle }); - expect((yield* stack.getState("edge-runtime")).status).toBe("Healthy"); - expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); - - yield* stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }); - expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); - - yield* stack.reloadEdgeRuntime({ edgeRuntime: { env: { NEXT: "next-value" } } }); - expect(builtConfigs.at(-1)?.edgeRuntime).toMatchObject({ - policy: "oneshot", - env: { NEXT: "next-value" }, - }); - - yield* stack.reloadFunctions(); - expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); - - const duplicateBundle = { - ...replacementBundle, - functions: [replacementBundle.functions[0]!, replacementBundle.functions[0]!], - }; - expect( - Predicate.isTagged( - yield* stack.reloadFunctions({ functions: duplicateBundle }).pipe(Effect.flip), - "StackBuildError", - ), - ).toBe(true); - expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); - - // Replace the workspace directory with a plain file so the config write - // fails for any user — permission bits alone are bypassed by root. - const runtimeDirectory = join(runtimeRoot, "edge-runtime"); - yield* Effect.promise(async () => { - await rm(runtimeDirectory, { recursive: true, force: true }); - await writeFile(runtimeDirectory, ""); - }); - const failedBundle = functionsBundle(runtimeRoot, "failed-secret"); - const error = yield* stack.reloadFunctions({ functions: failedBundle }).pipe(Effect.flip); - expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); - - yield* Effect.promise(() => rm(runtimeDirectory)); - yield* stack.reloadFunctions(); - expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); - - yield* stack.dispose; - expect( - yield* Effect.promise(() => - readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then( - () => true, - () => false, - ), - ), - ).toBe(false); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.promise(() => rm(runtimeRoot, { recursive: true, force: true }))), - Effect.timeout("5 seconds"), - ); - }); - - it.live("merges overlapping function and Edge Runtime reloads", () => { - const runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-functions-reload-race-")); - const initialBundle = functionsBundle(runtimeRoot, "initial-secret"); - const replacementBundle = functionsBundle(runtimeRoot, "replacement-secret"); - const config = { - ...edgeRuntimeConfig, - projectDir: runtimeRoot, - runtimeRoot, - functions: initialBundle, - postgrest: false, - auth: false, - servicePolicies: { - ...edgeRuntimeConfig.servicePolicies, - postgrest: "off", - auth: "off", - "edge-runtime": "lazy", - }, - } satisfies ResolvedStackConfig; - const graph = Effect.runSync( - buildGraph([ - { name: "postgres", command: process.execPath, restart: "unless-stopped" }, - { name: "edge-runtime", command: process.execPath, restart: "unless-stopped" }, - ]), - ); - const builderLayer = Layer.succeed(StackBuilder, { - build: () => - Effect.sync(() => { - return { - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([ - ["postgres", { visibility: "public" as const }], - ["edge-runtime", { visibility: "public" as const }], - ]), - }; - }), - }); - const preparationStarted = Deferred.makeUnsafe(); - const allowPreparation = Deferred.makeUnsafe(); - let blockNextSpawn = false; - const resolver = mockBinaryResolver(); - const spawner = mockChildProcessSpawner({ - beforeSpawn: () => { - if (!blockNextSpawn) return Effect.void; - blockNextSpawn = false; - return Deferred.succeed(preparationStarted, undefined).pipe( - Effect.andThen(Deferred.await(allowPreparation)), - ); - }, - }); - const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( - Layer.provide(builderLayer), - Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), - Layer.provide(spawner.layer), - Layer.provide(NodeServices.layer), - ); - const readRuntimeConfig = Effect.promise(() => - readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => - JSON.parse(contents), - ), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - blockNextSpawn = true; - - const functionsReload = yield* stack - .reloadFunctions({ functions: replacementBundle }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(preparationStarted); - - // Both requests join the same gated preparation before either can commit. - // The later Edge Runtime commit must preserve the Functions update. - const edgeReload = yield* stack - .reloadEdgeRuntime({ edgeRuntime: { env: { CONCURRENT: "edge-value" } } }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.succeed(allowPreparation, undefined); - yield* Fiber.join(functionsReload); - yield* Fiber.join(edgeReload); - - expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); - expect((yield* stack.getState("edge-runtime")).status).toBe("Healthy"); - yield* stack.dispose; - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.promise(() => rm(runtimeRoot, { recursive: true, force: true }))), - Effect.timeout("5 seconds"), - ); - }); - - it.effect("getInfo returns valid JWT tokens", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const info = yield* stack.getInfo; - - expect(info.anonJwt).toBeDefined(); - expect(info.serviceRoleJwt).toBeDefined(); - - // Verify anon JWT structure - const anonParts = info.anonJwt.split("."); - expect(anonParts).toHaveLength(3); - - const anonHeader = JSON.parse(Buffer.from(anonParts[0]!, "base64url").toString()); - expect(anonHeader.alg).toBe("HS256"); - expect(anonHeader.typ).toBe("JWT"); - - const anonPayload = JSON.parse(Buffer.from(anonParts[1]!, "base64url").toString()); - expect(anonPayload.role).toBe("anon"); - expect(anonPayload.iss).toBe("supabase"); - expect(anonPayload.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)); - - // Verify service_role JWT structure - const serviceRoleParts = info.serviceRoleJwt.split("."); - expect(serviceRoleParts).toHaveLength(3); - - const serviceRolePayload = JSON.parse( - Buffer.from(serviceRoleParts[1]!, "base64url").toString(), - ); - expect(serviceRolePayload.role).toBe("service_role"); - expect(serviceRolePayload.iss).toBe("supabase"); - expect(serviceRolePayload.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)); - }).pipe(Effect.provide(layer)); - }); - - it.effect("JWT tokens use the configured jwtSecret", () => { - const secret = "super-secret-jwt-token-with-at-least-32-characters-long"; - const { layer } = setupLayer({ ...defaultConfig, jwtSecret: secret }); - - return Effect.gen(function* () { - const stack = yield* Stack; - const info = yield* stack.getInfo; - - // Verify that the signature is valid by re-signing with the same secret - const verifyToken = (token: string): boolean => { - const parts = token.split("."); - if (parts.length !== 3) return false; - const data = `${parts[0]}.${parts[1]}`; - const expectedSig = createHmac("sha256", secret).update(data).digest("base64url"); - return parts[2] === expectedSig; - }; - - expect(verifyToken(info.anonJwt)).toBe(true); - expect(verifyToken(info.serviceRoleJwt)).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("getInfo returns consistent info on multiple calls", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const info1 = yield* stack.getInfo; - const info2 = yield* stack.getInfo; - - expect(info1.url).toBe(info2.url); - expect(info1.dbUrl).toBe(info2.dbUrl); - // JWT tokens are generated at construction time so they should be identical - expect(info1.anonJwt).toBe(info2.anonJwt); - expect(info1.serviceRoleJwt).toBe(info2.serviceRoleJwt); - }).pipe(Effect.provide(layer)); - }); - - it.effect("getInfo returns publishableKey and secretKey", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const info = yield* stack.getInfo; - - expect(info.publishableKey).toBeDefined(); - expect(info.secretKey).toBeDefined(); - // Without custom keys in config, should fall back to defaults - expect(info.publishableKey).toBe(defaultPublishableKey); - expect(info.secretKey).toBe(defaultSecretKey); - }).pipe(Effect.provide(layer)); - }); - - it.effect("getInfo returns custom publishableKey and secretKey when provided", () => { - const customConfig: ResolvedStackConfig = { - ...defaultConfig, - publishableKey: "sb_publishable_custom_key", - secretKey: "sb_secret_custom_key", - }; - const { layer } = setupLayer(customConfig); - - return Effect.gen(function* () { - const stack = yield* Stack; - const info = yield* stack.getInfo; - - expect(info.publishableKey).toBe("sb_publishable_custom_key"); - expect(info.secretKey).toBe("sb_secret_custom_key"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("getAllStates returns projected public states", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const states = yield* stack.getAllStates; - - expect(states).toHaveLength(3); - - const names = states.map((s) => s.name); - expect(names).toContain("postgres"); - expect(names).toContain("postgrest"); - expect(names).toContain("auth"); - - const postgres = states.find((state) => state.name === "postgres"); - expect(postgres?.status).toBe("Pending"); - - for (const state of states) { - expect(state.pid).toBeNull(); - expect(state.exitCode).toBeNull(); - expect(state.restartCount).toBe(0); - expect(state.startedAt).toBeNull(); - expect(state.error).toBeNull(); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("getAllStates includes edge-runtime when enabled", () => { - const { layer } = setupLayer(edgeRuntimeConfig); - - return Effect.gen(function* () { - const stack = yield* Stack; - const states = yield* stack.getAllStates; - - expect(states.map((state) => state.name)).toContain("edge-runtime"); - }).pipe(Effect.provide(layer)); - }); - - it.live("starts the readiness deadline after artifact preparation", () => { - const resolver = mockBinaryResolver({ - downloadedServices: ["postgres"], - downloadDelayMs: 300, - }); - const spawner = mockChildProcessSpawner(); - const config = { - ...defaultConfig, - postgrest: false, - auth: false, - readiness: { mode: "finite", timeoutMs: 250 }, - readinessSource: "configured", - } satisfies ResolvedStackConfig; - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( - Layer.provide(StackBuilder.layer), - Layer.provide(stackPreparationLayer), - Layer.provide(spawner.layer), - Layer.provide(NodeServices.layer), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - const startedAt = Date.now(); - const exit = yield* stack.start.pipe(Effect.exit); - - expect(Date.now() - startedAt).toBeGreaterThanOrEqual(250); - expect(Exit.isSuccess(exit)).toBe(true); - }).pipe(Effect.provide(layer), Effect.scoped, Effect.timeout("5 seconds")); - }); - - it.effect("rejects unsupported native services before resource planning", () => { - const config = { - ...edgeRuntimeConfig, - runtime: { mode: "native", containerRuntime: null }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config, noopPortLease(config.ports)); - - return Effect.gen(function* () { - const stack = yield* Stack; - const error = yield* stack.start.pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - if (Predicate.isTagged(error, "StackBuildError")) - expect(error.detail).toContain("edge-runtime"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("getState fails for internal helper services", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const exit = yield* stack.getState("postgres-init").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("logHistory returns empty array initially", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const logs = yield* stack.logHistory("postgres"); - - expect(logs).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("logHistoryAll returns empty array initially", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const logs = yield* stack.logHistoryAll(); - - expect(logs).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects unknown services across every log operation", () => { - const { layer } = setupLayer(); - - return Effect.gen(function* () { - const stack = yield* Stack; - const history = yield* stack.logHistory("missing").pipe(Effect.flip); - expect(history).toBeInstanceOf(ServiceNotFoundError); - - const historyAll = yield* stack.logHistoryAll(undefined, ["missing"]).pipe(Effect.flip); - expect(historyAll).toBeInstanceOf(ServiceNotFoundError); - - const subscription = yield* Stream.runCollect(stack.subscribeLogs("missing")).pipe( - Effect.flip, - ); - expect(subscription).toBeInstanceOf(ServiceNotFoundError); - - const subscriptions = yield* Stream.runCollect(stack.subscribeAllLogs(["missing"])).pipe( - Effect.flip, - ); - expect(subscriptions).toBeInstanceOf(ServiceNotFoundError); - }).pipe(Effect.provide(layer)); - }); - - it.live("startService fails with ServiceNotFoundError for unknown service", () => { - const config = { - ...defaultConfig, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "lazy", - auth: "lazy", - }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config); - - return Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - const exit = yield* stack.startService("nonexistent").pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "ServiceNotFoundError" }); - } - }).pipe(Effect.provide(layer)); - }); - - // Regression guard for https://github.com/supabase/cli/issues/5068: image - // acquisition must complete during preparation, so a failed pull aborts start - // instead of letting containers come up before their images are ready. - // it.live is required because the mock spawner resolves exit codes on the real - // clock; it.effect's TestClock would leave those sleeps pending. - it.live("start fails and never starts containers when a docker image pull fails", () => { - // Fail binary resolution so every service falls back to Docker, then fail - // every spawned docker command (image inspect + pull). - const resolver = mockBinaryResolver({ failServices: ["postgres", "postgrest", "auth"] }); - const spawner = mockChildProcessSpawner({ exitCode: 1 }); - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( - Layer.provide(StackBuilder.layer), - Layer.provide(stackPreparationLayer), - ); - const providedLayer = layer.pipe( - Layer.provide(spawner.layer), - Layer.provide(NodeServices.layer), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - const exit = yield* stack.start.pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - // No container was ever started: only prepare-phase docker commands ran. - const startedContainers = spawner.spawned.filter((record) => record.args[0] === "run"); - expect(startedContainers).toEqual([]); - }).pipe(Effect.provide(providedLayer)); - }); - - it.live("disposal fails a cold eager start with a typed build error", () => { - return Effect.gen(function* () { - const preparationStarted = yield* Deferred.make(); - const resolver = mockBinaryResolver({ - downloadedServices: ["auth"], - beforeResolve: ({ service }) => - service === "auth" - ? Deferred.succeed(preparationStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.void, - }); - const { layer } = setupLayer( - defaultConfig, - noopPortLease(defaultConfig.ports), - undefined, - resolver, - ); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - const starting = yield* stack.start.pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(preparationStarted); - - const disposing = yield* stack.dispose.pipe(Effect.forkChild({ startImmediately: true })); - yield* Fiber.join(disposing); - - const startExit = yield* Fiber.await(starting); - expect(Exit.isFailure(startExit)).toBe(true); - if (Exit.isFailure(startExit)) { - expect(Cause.squash(startExit.cause)).toMatchObject({ _tag: "StackBuildError" }); - } - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")); - }); - - it.live("can retry start after a build failure before services start", () => { - let buildAttempts = 0; - const graph = Effect.runSync( - buildGraph([{ name: "postgres", command: "true", restart: "no" }]), - ); - const builderLayer = Layer.succeed(StackBuilder, { - build: () => - Effect.suspend(() => { - buildAttempts += 1; - return buildAttempts === 1 - ? Effect.fail(new StackBuildError({ detail: "transient build failure" })) - : Effect.succeed({ - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([ - ["postgres", { visibility: "public" }], - ]), - }); - }), - }); - const resolver = mockBinaryResolver(); - const spawner = mockChildProcessSpawner(); - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const config = { - ...defaultConfig, - postgrest: false, - auth: false, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "off", - auth: "off", - }, - } satisfies ResolvedStackConfig; - const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( - Layer.provide(builderLayer), - Layer.provide(stackPreparationLayer), - Layer.provide(spawner.layer), - Layer.provide(NodeServices.layer), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - expect(Exit.isFailure(yield* stack.start.pipe(Effect.exit))).toBe(true); - yield* stack.start; - expect(buildAttempts).toBe(2); - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("rejects an all-eager graph that omits an enabled public service", () => { - const graph = Effect.runSync( - buildGraph([{ name: "postgres", command: "true", restart: "no" }]), - ); - const builderLayer = Layer.succeed(StackBuilder, { - build: () => - Effect.succeed({ - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([["postgres", { visibility: "public" as const }]]), - }), - }); - const resolver = mockBinaryResolver(); - const layer = localStackLayer(defaultConfig, noopPortLease(defaultConfig.ports)).pipe( - Layer.provide(builderLayer), - Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), - Layer.provide(mockChildProcessSpawner().layer), - Layer.provide(NodeServices.layer), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - const exit = yield* stack.start.pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.findErrorOption(exit.cause)).toMatchObject({ - _tag: "Some", - value: { - _tag: "StackBuildError", - detail: "Prepared graph does not contain enabled service postgrest", - }, - }); - } - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("can retry start after asset preparation fails before services start", () => { - const resolver = mockBinaryResolver({ - failOnceServices: ["postgres"], - }); - const config = { - ...defaultConfig, - postgrest: false, - auth: false, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "off", - auth: "off", - }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); - - return Effect.gen(function* () { - const stack = yield* Stack; - expect(Exit.isFailure(yield* stack.start.pipe(Effect.exit))).toBe(true); - yield* stack.start; - expect((yield* stack.getState("postgres")).status).toBe("Healthy"); - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("restarts activated companions after stopping the stack", () => { - const graph = Effect.runSync( - buildGraph([ - { - name: "postgres", - command: process.execPath, - restart: "no", - healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, - }, - { - name: "postgrest", - command: process.execPath, - restart: "no", - healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, - }, - { - name: "pgmeta", - command: process.execPath, - restart: "no", - healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, - }, - { - name: "studio", - command: process.execPath, - restart: "no", - healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, - }, - { - name: "analytics", - command: process.execPath, - restart: "no", - healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, - }, - { - name: "vector", - command: process.execPath, - restart: "no", - healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, - }, - ]), - ); - const builderLayer = Layer.succeed(StackBuilder, { - build: () => - Effect.succeed({ - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([ - ["postgres", { visibility: "public" as const }], - ["postgrest", { visibility: "public" as const }], - ["pgmeta", { visibility: "public" as const }], - ["studio", { visibility: "public" as const }], - ["analytics", { visibility: "public" as const }], - ["vector", { visibility: "public" as const }], - ]), - }), - }); - const config = { - ...defaultConfig, - runtime: { mode: "docker", containerRuntime: "docker" }, - pgmeta: { port: defaultPorts.pgmetaPort, version: DEFAULT_VERSIONS.pgmeta }, - studio: { - port: defaultPorts.studioPort, - apiUrl: "http://127.0.0.1:54321", - version: DEFAULT_VERSIONS.studio, - }, - analytics: { - port: defaultPorts.analyticsPort, - version: DEFAULT_VERSIONS.analytics, - backend: "postgres", - apiKey: "test-api-key", - }, - vector: { version: DEFAULT_VERSIONS.vector }, - servicePolicies: { - ...defaultConfig.servicePolicies, - auth: "off", - postgrest: "lazy", - pgmeta: "eager", - studio: "eager", - analytics: "eager", - vector: "eager", - }, - auth: false, - } satisfies ResolvedStackConfig; - const { resolver, spawner } = setupLayer(config, noopPortLease(config.ports)); - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( - Layer.provide(builderLayer), - Layer.provide(stackPreparationLayer), - Layer.provide(spawner.layer), - Layer.provide(NodeServices.layer), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - yield* stack.stop; - yield* stack.start; - - expect((yield* stack.getState("studio")).status).toBe("Healthy"); - expect((yield* stack.getState("analytics")).status).toBe("Healthy"); - expect((yield* stack.getState("vector")).status).toBe("Healthy"); - }).pipe(Effect.provide(layer), Effect.timeout("10 seconds")); - }); - - it.live("rejects a cached start when disposal begins during startup", () => - Effect.gen(function* () { - const startEntered = yield* Deferred.make(); - const releaseStart = yield* Deferred.make(); - const config = { - ...defaultConfig, - postgrest: false, - auth: false, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "off", - auth: "off", - }, - } satisfies ResolvedStackConfig; - const graph = Effect.runSync( - buildGraph([{ name: "postgres", command: "true", restart: "no" }]), - ); - const builderLayer = Layer.succeed(StackBuilder, { - build: () => - Effect.succeed({ - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([["postgres", { visibility: "public" as const }]]), - }), - }); - let gateNextStart = false; - const portLease: PortLease = { - ports: config.ports, - reserve: () => { - if (!gateNextStart) return Effect.void; - gateNextStart = false; - return Deferred.succeed(startEntered, undefined).pipe( - Effect.andThen(Deferred.await(releaseStart)), - ); - }, - release: () => Effect.void, - releaseAll: Effect.void, - }; - const resolver = mockBinaryResolver(); - const layer = localStackLayer(config, portLease).pipe( - Layer.provide(builderLayer), - Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), - Layer.provide(mockChildProcessSpawner().layer), - Layer.provide(NodeServices.layer), - ); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - yield* stack.stop; - - gateNextStart = true; - const holder = yield* stack.start.pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(startEntered); - const disposing = yield* stack.dispose.pipe(Effect.forkChild({ startImmediately: true })); - - yield* Deferred.succeed(releaseStart, undefined); - const holderExit = yield* Fiber.await(holder); - expect(Exit.isFailure(holderExit)).toBe(true); - if (Exit.isFailure(holderExit)) { - expect(Cause.squash(holderExit.cause)).toMatchObject({ _tag: "StackBuildError" }); - } - yield* Fiber.join(disposing); - - expect((yield* stack.getState("postgres")).status).toBe("Stopped"); - const afterDisposal = yield* stack.start.pipe(Effect.flip); - expect(Predicate.isTagged(afterDisposal, "StackBuildError")).toBe(true); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), - ); - - it.live("a partial startup failure disposes resources from services already started", () => { - let cleaned = false; - const spawner = mockChildProcessSpawner({ - beforeSpawn: (record) => - record.command === "fail" ? Effect.die("simulated spawn failure") : Effect.void, - }); - const graph = Effect.runSync( - buildGraph([ - { - name: "postgres", - command: process.execPath, - restart: "no", - cleanup: Effect.sync(() => { - cleaned = true; - }), - }, - { - name: "postgrest", - command: "fail", - dependencies: [{ service: "postgres", condition: "started" }], - restart: "no", - }, - ]), - ); - const builderLayer = Layer.succeed(StackBuilder, { - build: () => - Effect.succeed({ - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([ - ["postgres", { visibility: "public" }], - ["postgrest", { visibility: "public" }], - ]), - }), - }); - const resolver = mockBinaryResolver(); - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(resolver.layer)); - const layer = localStackLayer( - { - ...defaultConfig, - auth: false, - servicePolicies: { ...defaultConfig.servicePolicies, auth: "off" }, - readiness: { mode: "finite", timeoutMs: 100 }, - readinessSource: "configured", - }, - noopPortLease(defaultConfig.ports), - ).pipe( - Layer.provide(builderLayer), - Layer.provide(stackPreparationLayer), - Layer.provide(spawner.layer), - Layer.provide(NodeServices.layer), - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - const exit = yield* stack.start.pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(cleaned).toBe(true); - expect(spawner.killed).toContain("SIGTERM"); - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("lazy startup starts direct services without starting HTTP backends", () => { - const { layer } = setupLayer({ - ...defaultConfig, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "lazy", - auth: "lazy", - storage: "lazy", - imgproxy: "lazy", - }, - }); - - return Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - yield* stack.waitAllReady(); - - expect((yield* stack.getState("postgres")).status).toBe("Healthy"); - expect((yield* stack.getState("auth")).status).toBe("Dormant"); - expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); - - yield* stack.stop; - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("prepares a dormant service before restarting it", () => - Effect.gen(function* () { - const resolver = mockBinaryResolver({ downloadedServices: ["postgrest"] }); - const config = { - ...defaultConfig, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "lazy", - auth: "lazy", - }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - const stateChanges = yield* stack.stateChanges("postgrest"); - const downloading = yield* stateChanges.pipe( - Stream.filter((state) => state.status === "Downloading"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - const running = yield* stateChanges.pipe( - Stream.filter((state) => state.status === "Running"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - const restarting = yield* stack - .restartService("postgrest") - .pipe(Effect.forkChild({ startImmediately: true })); - - expect(Option.isSome(yield* Fiber.join(downloading))).toBe(true); - expect(Option.isSome(yield* Fiber.join(running))).toBe(true); - - yield* Fiber.interrupt(restarting); - yield* stack.stop; - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), - ); - - it.live("does not restart a service after the stack stops during preparation", () => - Effect.gen(function* () { - const allowPreparation = yield* Deferred.make(); - const resolver = mockBinaryResolver({ - downloadedServices: ["postgrest"], - beforeResolve: ({ service }) => - service === "postgrest" ? Deferred.await(allowPreparation) : Effect.void, - }); - const config = { - ...defaultConfig, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "lazy", - auth: "lazy", - }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - const downloading = yield* (yield* stack.stateChanges("postgrest")).pipe( - Stream.filter((state) => state.status === "Downloading"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - const restarting = yield* stack - .restartService("postgrest") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Fiber.join(downloading); - const running = yield* (yield* stack.stateChanges("postgrest")).pipe( - Stream.filter((state) => state.status === "Running"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - - yield* stack.stop; - yield* Deferred.succeed(allowPreparation, undefined); - const outcome = yield* Effect.race( - Fiber.join(restarting).pipe( - Effect.exit, - Effect.map((exit) => ({ type: "restart" as const, exit })), - ), - Fiber.join(running).pipe(Effect.as({ type: "resurrected" as const })), - ); - - expect(outcome.type).toBe("restart"); - if (outcome.type === "restart") expect(Exit.isFailure(outcome.exit)).toBe(true); - expect((yield* stack.getState("postgrest")).status).not.toBe("Running"); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), - ); - - it.live("lazy activation restores dormant state after a stopped transitive dependency", () => { - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const resolver = mockBinaryResolver({ downloadedServices: ["postgrest"] }); - const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); - - return Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - yield* stack.start; - yield* stack.stopService("postgres"); - - const downloading = yield* (yield* stack.stateChanges("postgrest")).pipe( - Stream.filter((state) => state.status === "Downloading"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - const error = yield* activator.activate("postgrest").pipe(Effect.flip); - - expect(Option.isSome(yield* Fiber.join(downloading))).toBe(true); - expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); - if (Predicate.isTagged(error, "StackBuildError")) { - expect(error.detail).toContain("postgres was explicitly stopped"); - } - expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); - yield* stack.stop; - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("preserves an explicit stop during in-flight lazy activation", () => { - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const allowDownload = Deferred.makeUnsafe(); - const resolver = mockBinaryResolver({ - downloadedServices: ["auth"], - beforeResolve: ({ service }) => - service === "auth" ? Deferred.await(allowDownload) : Effect.void, - }); - const { layer } = setupLayer(config, noopPortLease(config.ports), undefined, resolver); - - return Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - yield* stack.start; - - const authChanges = yield* stack.stateChanges("auth"); - const downloading = yield* authChanges.pipe( - Stream.filter((state) => state.status === "Downloading"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - const stopped = yield* authChanges.pipe( - Stream.filter((state) => state.status === "Stopped"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - const activation = yield* activator - .activate("auth") - .pipe(Effect.forkChild({ startImmediately: true })); - - expect(Option.isSome(yield* Fiber.join(downloading))).toBe(true); - yield* stack.stopService("auth"); - expect(Option.isSome(yield* Fiber.join(stopped))).toBe(true); - yield* Deferred.succeed(allowDownload, undefined); - - const activationExit = yield* Fiber.await(activation); - expect(Exit.isFailure(activationExit)).toBe(true); - if (Exit.isFailure(activationExit)) { - const error = Cause.squash(activationExit.cause); - expect(error).toMatchObject({ _tag: "StackBuildError" }); - if (error instanceof StackBuildError) { - expect(error.detail).toContain("auth was explicitly stopped"); - } - } - expect((yield* stack.getState("auth")).status).toBe("Stopped"); - yield* stack.stop; - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("rejects stopping a service before start without affecting a later start", () => { - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config); - - return Effect.gen(function* () { - const stack = yield* Stack; - const error = yield* stack.stopService("auth").pipe(Effect.flip); - - expect(Predicate.isTagged(error, "StackNotRunningError")).toBe(true); - if (Predicate.isTagged(error, "StackNotRunningError")) expect(error.phase).toBe("idle"); - - yield* stack.start; - expect((yield* stack.getState("auth")).status).toBe("Dormant"); - yield* stack.stop; - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("lazy readiness includes an activation that is still starting", () => - Effect.gen(function* () { - const spawnStarted = yield* Deferred.make(); - const spawner = mockChildProcessSpawner({ - beforeSpawn: (record) => - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), - ) - ? Deferred.succeed(spawnStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.void, - }); - const config = { - ...defaultConfig, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "lazy", - auth: "lazy", - mailpit: "eager", - }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - yield* stack.start; - expect((yield* stack.getState("auth")).status).toBe("Dormant"); - const activeStateFiber = yield* stack.allStateChanges.pipe( - Stream.filter((state) => state.name === "auth" && state.status !== "Dormant"), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - const activationFiber = yield* activator - .activate("auth") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(spawnStarted); - expect(Option.isSome(yield* Fiber.join(activeStateFiber))).toBe(true); - expect((yield* stack.getState("auth")).status).not.toBe("Dormant"); - - const readyFiber = yield* stack - .waitAllReady() - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - expect(readyFiber.pollUnsafe()).toBeUndefined(); - - yield* stack.stop.pipe(Effect.timeout("1 second")); - yield* Fiber.interrupt(readyFiber); - yield* Fiber.interrupt(activationFiber); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), - ); - - it.live( - "healthy activation stays available while a manual service start waits for readiness", - () => - Effect.gen(function* () { - const authSpawnStarted = yield* Deferred.make(); - const spawner = mockChildProcessSpawner({ - beforeSpawn: (record) => - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), - ) - ? Deferred.succeed(authSpawnStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.void, - }); - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - yield* stack.start; - const manualStart = yield* stack - .startService("auth") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(authSpawnStarted); - - yield* activator.activate("postgres"); - - yield* Fiber.interrupt(manualStart); - yield* stack.stop; - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), - ); - - it.live("begins independent eager services before waiting for their readiness", () => - Effect.gen(function* () { - const postgresReleaseStarted = yield* Deferred.make(); - const allowPostgresRelease = yield* Deferred.make(); - const mailpitReleaseStarted = yield* Deferred.make(); - const config = { - ...defaultConfig, - runtime: { mode: "docker", containerRuntime: "docker" }, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "off", - auth: "off", - mailpit: "eager", - }, - postgrest: false, - auth: false, - mailpit: { - port: defaultPorts.mailpitPort, - smtpPort: defaultPorts.mailpitSmtpPort, - pop3Port: defaultPorts.mailpitPop3Port, - version: DEFAULT_VERSIONS.mailpit, - adminEmail: "admin@example.com", - senderName: "Admin", - }, - } satisfies ResolvedStackConfig; - const lease: PortLease = { - ports: config.ports, - reserve: () => Effect.void, - release: (fields) => - fields.includes("dbPort") - ? Deferred.succeed(postgresReleaseStarted, undefined).pipe( - Effect.andThen(Deferred.await(allowPostgresRelease)), - ) - : fields.includes("mailpitPort") - ? Deferred.succeed(mailpitReleaseStarted, undefined).pipe(Effect.asVoid) - : Effect.void, - releaseAll: Effect.void, - }; - const graph = Effect.runSync( - buildGraph([ - { - name: "postgres", - command: process.execPath, - restart: "unless-stopped", - healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, - }, - { - name: "mailpit", - command: process.execPath, - restart: "unless-stopped", - healthCheck: { probe: { _tag: "Exec", command: "true", args: [] } }, - }, - ]), - ); - const builderLayer = Layer.succeed(StackBuilder, { - build: () => - Effect.succeed({ - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([ - ["postgres", { visibility: "public" as const }], - ["mailpit", { visibility: "public" as const }], - ]), - }), - }); - const resolver = mockBinaryResolver(); - const layer = localStackLayer(config, lease).pipe( - Layer.provide(builderLayer), - Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), - Layer.provide(mockChildProcessSpawner().layer), - Layer.provide(NodeServices.layer), - ); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - const starting = yield* stack.start.pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(postgresReleaseStarted); - - yield* Deferred.await(mailpitReleaseStarted); - yield* Deferred.succeed(allowPostgresRelease, undefined); - yield* Fiber.join(starting); - - expect((yield* stack.getState("postgres")).status).toBe("Healthy"); - expect((yield* stack.getState("mailpit")).status).toBe("Healthy"); - - yield* stack.stop; - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), - ); - - it.live("dispose cancels an in-flight lazy activation", () => - Effect.gen(function* () { - const spawnStarted = yield* Deferred.make(); - const allowSpawn = yield* Deferred.make(); - const spawner = mockChildProcessSpawner({ - beforeSpawn: (record) => - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), - ) - ? Deferred.succeed(spawnStarted, undefined).pipe( - Effect.andThen(Deferred.await(allowSpawn)), - ) - : Effect.void, - }); - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config, noopPortLease(config.ports), spawner); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - yield* stack.start; - const activationFiber = yield* activator - .activate("auth") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(spawnStarted); - - const disposeFiber = yield* stack.dispose.pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Fiber.join(disposeFiber); - yield* Deferred.succeed(allowSpawn, undefined); - yield* Fiber.interrupt(activationFiber); - - expect(spawner.spawned.some((record) => record.command.endsWith("/auth"))).toBe(false); - - const error = yield* activator.activate("auth").pipe(Effect.flip); - expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); - if (Predicate.isTagged(error, "StackBuildError")) { - expect(error.detail).toContain("disposal has begun"); - } - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), - ); - - it.live("dispose cancels in-flight lazy preparation", () => - Effect.gen(function* () { - const preparationStarted = yield* Deferred.make(); - const disposed = yield* Deferred.make(); - const resolver = mockBinaryResolver({ - downloadedServices: ["auth"], - beforeResolve: ({ service }) => - service === "auth" - ? Deferred.succeed(preparationStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.void, - }); - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const lease = { - ...noopPortLease(config.ports), - releaseAll: Deferred.succeed(disposed, undefined).pipe(Effect.asVoid), - } satisfies PortLease; - const { layer } = setupLayer(config, lease, mockChildProcessSpawner(), resolver); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - const activation = yield* stack - .startService("auth") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(preparationStarted); - const secondActivation = yield* stack - .startService("auth") - .pipe(Effect.forkChild({ startImmediately: true })); - - const disposing = yield* stack.dispose.pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(disposed); - yield* Fiber.join(disposing); - - const activationExit = yield* Fiber.await(activation); - const secondActivationExit = yield* Fiber.await(secondActivation); - expect(Exit.isFailure(activationExit)).toBe(true); - expect(Exit.isFailure(secondActivationExit)).toBe(true); - if (Exit.isFailure(activationExit)) { - expect(Cause.squash(activationExit.cause)).toMatchObject({ - _tag: "StackBuildError", - detail: "Stack disposed during asset preparation", - }); - } - if (Exit.isFailure(secondActivationExit)) { - expect(Cause.squash(secondActivationExit.cause)).toMatchObject({ - _tag: "StackBuildError", - detail: "Stack disposed during asset preparation", - }); - } - expect((yield* stack.getState("auth")).status).not.toBe("Downloading"); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped), - ); - - it.live("allows a finite wait override against an infinite stack policy", () => - Effect.gen(function* () { - const spawnStarted = yield* Deferred.make(); - const spawner = mockChildProcessSpawner({ - beforeSpawn: (record) => - record.command === "/cache/auth" - ? Deferred.succeed(spawnStarted, undefined) - : Effect.void, - }); - let releasedAll = false; - const config = { - ...defaultConfig, - postgrest: false, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "off", auth: "lazy" }, - readiness: { mode: "infinite" }, - readinessSource: "configured", - } satisfies ResolvedStackConfig; - const graph = Effect.runSync( - buildGraph([ - { - name: "postgres", - command: "true", - restart: "no", - }, - { - name: "auth", - command: "/cache/auth", - dependencies: [{ service: "postgres", condition: "started" }], - restart: "unless-stopped", - healthCheck: { - probe: { - _tag: "Http", - host: "127.0.0.1", - port: 1, - path: "/health", - scheme: "http", - }, - periodSeconds: 10, - }, - hooks: [{ on: "started", run: (log) => log("stderr", "auth startup failed") }], - }, - ]), - ); - const builderLayer = Layer.succeed(StackBuilder, { - build: () => - Effect.succeed({ - graph, - cleanupTargets: { dockerContainerNames: [] }, - serviceProjection: new Map([ - ["postgres", { visibility: "public" as const }], - ["auth", { visibility: "public" as const }], - ]), - }), - }); - const lease: PortLease = { - ...noopPortLease(config.ports), - releaseAll: Effect.sync(() => { - releasedAll = true; - }), - }; - const resolver = mockBinaryResolver(); - const layer = localStackLayer(config, lease).pipe( - Layer.provide(builderLayer), - Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), - Layer.provide(spawner.layer), - Layer.provide(NodeServices.layer), - ); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - yield* stack.start; - const authLog = yield* stack - .subscribeLogs("auth") - .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); - const activation = yield* activator - .activate("auth") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(spawnStarted); - const authLogEntry = yield* Fiber.join(authLog); - expect(authLogEntry).toMatchObject({ - _tag: "Some", - value: { line: "auth startup failed", service: "auth" }, - }); - - const error = yield* stack - .waitAllReady({ mode: "finite", timeoutMs: 25 }) - .pipe(Effect.flip); - - expect(Predicate.isTagged(error, "StackReadinessError")).toBe(true); - if (Predicate.isTagged(error, "StackReadinessError")) { - expect(error.target).toBe("stack"); - expect(error.timeoutMs).toBe(25); - expect(error.detail).toContain("Non-ready services: auth:"); - expect(error.detail).toContain("Recent logs"); - expect(error.detail).toContain("auth startup failed"); - } - expect(releasedAll).toBe(true); - yield* Fiber.interrupt(activation); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")), - ); - - it.live("does not revive stopped lazy dependents when restarting a dependency", () => { - return Effect.gen(function* () { - const authHealthServer = yield* Effect.acquireRelease( - Effect.tryPromise( - () => - new Promise((resolve, reject) => { - const server = createServer((_request, response) => { - response.writeHead(200, { "content-type": "text/plain" }); - response.end("ok"); - }); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve(server)); - }), - ), - (server) => - Effect.tryPromise(() => new Promise((resolve) => server.close(() => resolve()))), - ); - const address = authHealthServer.address(); - const authPort = typeof address === "object" && address !== null ? address.port : undefined; - if (authPort === undefined) { - throw new Error("Expected the auth health test server to bind a TCP port"); - } - const authConfig = defaultConfig.auth; - if (authConfig === false) { - throw new Error("Expected auth to be enabled in the default test config"); - } - const { layer, spawner } = setupLayer({ - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - ports: { ...defaultPorts, authPort }, - auth: { ...authConfig, port: authPort }, - }); - yield* Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - const isAuthStart = (record: { readonly args: ReadonlyArray }) => - record.args.some((arg) => - Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'), - ); - yield* stack.start; - yield* activator.activate("auth"); - const initialAuthStarts = spawner.spawned.filter(isAuthStart).length; - expect(initialAuthStarts).toBeGreaterThan(0); - - yield* stack.stopService("postgres"); - yield* stack.restartService("postgres"); - yield* stack.waitAllReady(); - - expect(spawner.spawned.filter(isAuthStart)).toHaveLength(initialAuthStarts); - expect((yield* stack.getState("auth")).status).toBe("Stopped"); - yield* stack.stop; - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")); - }); - - it.live("lazy readiness fails fast before a service is activated", () => { - const { layer } = setupLayer({ - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - }); - - return Effect.gen(function* () { - const stack = yield* Stack; - const beforeStart = yield* stack.waitAllReady().pipe(Effect.flip); - expect(Predicate.isTagged(beforeStart, "StackBuildError")).toBe(true); - - yield* stack.start; - const authNotActivated = yield* stack.waitReady("auth").pipe(Effect.flip); - expect(Predicate.isTagged(authNotActivated, "ServiceReadyError")).toBe(true); - - yield* stack.stop; - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live( - "restores dormant lazy services after preparation failure and retries successfully", - () => { - return Effect.gen(function* () { - const healthServer = yield* Effect.acquireRelease( - Effect.tryPromise( - () => - new Promise((resolve, reject) => { - const server = createServer((_request, response) => { - response.writeHead(200, { "content-type": "text/plain" }); - response.end("ok"); - }); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve(server)); - }), - ), - (server) => - Effect.tryPromise(() => new Promise((resolve) => server.close(() => resolve()))), - ); - const address = healthServer.address(); - const postgrestPort = typeof address === "object" && address !== null ? address.port : 0; - if (postgrestPort === 0) throw new Error("Expected a PostgREST health port"); - const basePostgrest = defaultConfig.postgrest; - if (basePostgrest === false) throw new Error("Expected PostgREST in the default config"); - const config = { - ...defaultConfig, - servicePolicies: { - ...defaultConfig.servicePolicies, - postgrest: "lazy", - auth: "off", - }, - auth: false, - ports: { - ...defaultConfig.ports, - postgrestPort, - postgrestAdminPort: postgrestPort + 1, - }, - postgrest: { - ...basePostgrest, - port: postgrestPort, - adminPort: postgrestPort + 1, - }, - } satisfies ResolvedStackConfig; - const failingResolver = mockBinaryResolver({ failOnceServices: ["postgrest"] }); - const stackPreparationLayer = StackPreparation.layer.pipe( - Layer.provide(failingResolver.layer), - ); - const testLayer = localStackLayer(config, noopPortLease(config.ports)).pipe( - Layer.provide(StackBuilder.layer), - Layer.provide(stackPreparationLayer), - Layer.provide(mockChildProcessSpawner().layer), - Layer.provide(NodeServices.layer), - ); - - yield* Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - yield* stack.start; - expect(["Running", "Healthy", "Initializing"]).toContain( - (yield* stack.getState("postgres")).status, - ); - expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); - const first = yield* activator.activate("postgrest").pipe(Effect.flip); - expect(Predicate.isTagged(first, "StackBuildError")).toBe(true); - expect(["Running", "Healthy", "Initializing"]).toContain( - (yield* stack.getState("postgres")).status, - ); - expect((yield* stack.getState("postgrest")).status).toBe("Dormant"); - - yield* activator.activate("postgrest"); - expect(["Running", "Healthy"]).toContain((yield* stack.getState("postgrest")).status); - yield* stack.stop; - }).pipe(Effect.provide(testLayer)); - }).pipe(Effect.scoped, Effect.timeout("5 seconds")); - }, - ); - - it.live("keeps unactivated services dormant after a stop and start cycle", () => { - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config); - - return Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - expect((yield* stack.getState("auth")).status).toBe("Dormant"); - - yield* stack.stop; - yield* stack.start; - - expect((yield* stack.getState("auth")).status).toBe("Dormant"); - yield* stack.stop; - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("rejects a cached activation after the stack has stopped", () => { - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config); - - return Effect.gen(function* () { - const stack = yield* Stack; - const activator = yield* StackServiceActivator; - yield* stack.start; - yield* stack.stop; - - const error = yield* activator.activate("postgres").pipe(Effect.flip); - expect(Predicate.isTagged(error, "StackNotRunningError")).toBe(true); - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("preserves an explicitly stopped service across a stack restart", () => { - const config = { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - } satisfies ResolvedStackConfig; - const { layer } = setupLayer(config); - - return Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - // `stopService` settles the public projection before returning, so the - // stopped state is observable immediately without stream coordination. - yield* stack.stopService("auth"); - expect((yield* stack.getState("auth")).status).toBe("Stopped"); - - yield* stack.stop; - yield* stack.start; - - expect((yield* stack.getState("auth")).status).toBe("Stopped"); - yield* stack.stop; - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); - - it.live("releases only the ports in a lazy service dependency closure", () => { - const released = new Set(); - const authReleaseStarted = Deferred.makeUnsafe(); - const lease: PortLease = { - ports: defaultPorts, - reserve: () => Effect.void, - release: (fields) => - Effect.gen(function* () { - for (const field of fields) { - released.add(field); - if (field === "authPort") yield* Deferred.succeed(authReleaseStarted, undefined); - } - }), - releaseAll: Effect.void, - }; - const { layer } = setupLayer( - { - ...defaultConfig, - servicePolicies: { ...defaultConfig.servicePolicies, postgrest: "lazy", auth: "lazy" }, - }, - lease, - ); - - return Effect.gen(function* () { - const stack = yield* Stack; - yield* stack.start; - - expect(released.has("dbPort")).toBe(true); - expect(released.has("authPort")).toBe(false); - expect(released.has("postgrestPort")).toBe(false); - - const startFiber = yield* stack - .startService("auth") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(authReleaseStarted); - expect(released.has("authPort")).toBe(true); - expect(released.has("postgrestPort")).toBe(false); - - yield* Fiber.interrupt(startFiber); - yield* stack.stop; - }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); - }); -}); diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts deleted file mode 100644 index 30de853c92..0000000000 --- a/packages/stack/src/StackBuilder.ts +++ /dev/null @@ -1,684 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Native path joining is required for the temporary PostgreSQL alias boundary. -import { join } from "node:path"; -import { buildGraph } from "@supabase/process-compose"; -import type { ResolvedGraph, ServiceDef } from "@supabase/process-compose"; -import { Context, Effect, FileSystem, Layer, Scope } from "effect"; -import type { CleanupTargets } from "./CleanupTargets.ts"; -import { StackBuildError } from "./errors.ts"; -import { generateJwks } from "./JwtGenerator.ts"; -import { detectPlatform, dockerHostAddress } from "./Platform.ts"; -import { shortTempPrefixRoot } from "./paths.ts"; -import { makeAnalyticsServiceDocker } from "./services/analytics.ts"; -import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; -import { - makeEdgeRuntimeServiceDocker, - prepareEdgeRuntimeBootstrap, -} from "./services/edge-runtime.ts"; -import { makeImgproxyServiceDocker } from "./services/imgproxy.ts"; -import { makeMailpitServiceDocker } from "./services/mailpit.ts"; -import { makePgmetaServiceDocker } from "./services/pgmeta.ts"; -import { makePoolerServiceDocker } from "./services/pooler.ts"; -import { - makePostgresInitService, - makePostgresInitServiceDocker, -} from "./services/postgres-init.ts"; -import { makePostgresService, makePostgresServiceDocker } from "./services/postgres.ts"; -import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; -import { makeRealtimeServiceDocker } from "./services/realtime.ts"; -import { type ServiceDependency } from "./services/service-utils.ts"; -import { - LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, - LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, - makeStorageServiceDocker, -} from "./services/storage.ts"; -import { makeStudioServiceDocker } from "./services/studio.ts"; -import { makeVectorServiceDocker } from "./services/vector.ts"; -import { - dependencyTimeoutSecondsForServices, - POSTGRES_INIT_COMPLETION_BUDGET_SECONDS, -} from "./services/health-budgets.ts"; -import type { PreparedStackArtifacts, ServiceResolution } from "./StackPreparation.ts"; -import type { StackServiceProjectionCatalog } from "./StackStateProjection.ts"; -import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; -import { INSTANCE_ID_PATTERN, type ResolvedStackConfig } from "./StackConfig.ts"; -import { dockerContainerName, stackIdentity } from "./StackIdentity.ts"; -import type { VersionManifest } from "./versions.ts"; - -export interface BuildResult { - readonly graph: ResolvedGraph; - readonly cleanupTargets: CleanupTargets; - readonly serviceProjection: StackServiceProjectionCatalog; -} - -const dockerOnlyServices = SERVICE_NAMES.filter( - (service) => serviceMetadata(service).runtimeSupport === "docker-only", -); -const nativeServices = SERVICE_NAMES.filter( - (service) => serviceMetadata(service).runtimeSupport !== "docker-only", -); - -// Serial health-check paths used by dependency waits; keep each path aligned -// with the corresponding service's transitive dependencies. -const postgresStartupPath: ReadonlyArray = ["postgres"]; -const storageStartupPath: ReadonlyArray = ["postgres", "storage"]; -const analyticsStartupPath: ReadonlyArray = ["postgres", "analytics"]; - -const postgresDependencyTimeoutSeconds = dependencyTimeoutSecondsForServices(postgresStartupPath); - -const postgresDependencies: ReadonlyArray = [ - { service: "postgres-init", condition: "completed" }, -]; - -const publicServiceProjection = ( - defs: ReadonlyArray, -): StackServiceProjectionCatalog => { - const serviceProjection: Map< - string, - { - visibility: "public" | "internal"; - owner?: string; - ownerStatusWhileActive?: "Initializing"; - } - > = new Map(defs.map((def) => [def.name, { visibility: "public" as const }] as const)); - - serviceProjection.set("postgres-init", { - visibility: "internal", - owner: "postgres", - ownerStatusWhileActive: "Initializing", - }); - - return serviceProjection; -}; - -const hasAutoManagedPath = (config: ResolvedStackConfig, path: string) => - config.autoManagedPaths.some( - (managedPath) => - path === managedPath || - path.startsWith(`${managedPath}/`) || - path.startsWith(`${managedPath}\\`), - ); - -const resolvedConfigForService = (config: ResolvedStackConfig, service: ServiceName) => - config[serviceMetadata(service).configKey]; - -const prepareNativePostgresAlias = ( - preparedPath: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const aliasRoot = yield* Effect.acquireRelease( - fs.makeTempDirectory({ - directory: shortTempPrefixRoot(), - prefix: "supabase-stack-postgres-", - }), - (path) => fs.remove(path, { recursive: true, force: true }).pipe(Effect.ignore), - ).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to create a private native PostgreSQL binary directory", - cause, - }), - ), - ); - const aliasPath = join(aliasRoot, "bundle"); - if (/\s/.test(aliasPath) || (process.platform !== "darwin" && process.platform !== "linux")) { - yield* fs.remove(aliasRoot, { recursive: true, force: true }).pipe(Effect.ignore); - return yield* new StackBuildError({ - detail: "Native PostgreSQL requires a Unix temporary path without whitespace", - reason: "invalid_config", - }); - } - - yield* fs.symlink(preparedPath, aliasPath).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to publish the native PostgreSQL binary alias", - cause, - }), - ), - ); - return aliasPath; - }); - -export const validateResolvedConfig = ( - config: ResolvedStackConfig, -): Effect.Effect => - Effect.gen(function* () { - if (config.instanceId !== undefined && !INSTANCE_ID_PATTERN.test(config.instanceId)) { - return yield* new StackBuildError({ - detail: `Invalid instanceId: must match ${INSTANCE_ID_PATTERN}`, - reason: "invalid_config", - }); - } - - if (config.runtime.mode === "native") { - const enabledDockerOnly = dockerOnlyServices.filter( - (service) => resolvedConfigForService(config, service) !== false, - ); - if (enabledDockerOnly.length > 0) { - return yield* new StackBuildError({ - detail: `Native mode supports only ${nativeServices.join(", ")}. Disable ${enabledDockerOnly.join(", ")} or select Docker mode with a usable Docker or Podman runtime.`, - reason: "invalid_config", - }); - } - } - - if (config.imgproxy !== false && config.storage === false) { - return yield* new StackBuildError({ - detail: "imgproxy requires storage to be enabled", - reason: "invalid_config", - }); - } - - if (config.vector !== false && config.analytics === false) { - return yield* new StackBuildError({ - detail: "vector requires analytics to be enabled", - reason: "invalid_config", - }); - } - - if (config.studio !== false && config.pgmeta === false) { - return yield* new StackBuildError({ - detail: "studio requires pgmeta to be enabled", - reason: "invalid_config", - }); - } - }); - -export const enabledServicesForConfig = (config: ResolvedStackConfig): ReadonlyArray => - SERVICE_NAMES.filter( - (service) => - config.servicePolicies?.[service] !== "off" && - resolvedConfigForService(config, service) !== false, - ); - -export const versionsForConfig = (config: ResolvedStackConfig): Partial => { - const versions: Partial> = {}; - for (const service of enabledServicesForConfig(config)) { - const serviceConfig = resolvedConfigForService(config, service); - if (serviceConfig !== false) { - versions[service] = serviceConfig.version; - } - } - return versions; -}; - -const requirePreparedResolution = ( - prepared: PreparedStackArtifacts, - service: ServiceName, -): Effect.Effect => { - const resolution = prepared.resolutions[service]; - return resolution !== undefined - ? Effect.succeed(resolution) - : Effect.fail( - new StackBuildError({ - detail: `Missing prepared resolution for ${service}`, - }), - ); -}; - -const requirePreparedDockerImage = ( - prepared: PreparedStackArtifacts, - service: ServiceName, -): Effect.Effect => - requirePreparedResolution(prepared, service).pipe( - Effect.flatMap((resolution) => - resolution.type === "docker" - ? Effect.succeed(resolution.image) - : Effect.fail( - new StackBuildError({ - detail: `Expected a docker image for ${service}`, - }), - ), - ), - ); - -export class StackBuilder extends Context.Service< - StackBuilder, - { - readonly build: ( - config: ResolvedStackConfig, - prepared: PreparedStackArtifacts, - ) => Effect.Effect; - } ->()("local/StackBuilder") { - static layer: Layer.Layer = Layer.succeed(this, { - build: (config: ResolvedStackConfig, prepared: PreparedStackArtifacts) => - Effect.gen(function* () { - yield* validateResolvedConfig(config); - - const requireContainerRuntime = Effect.suspend(() => - config.runtime.mode === "native" - ? Effect.fail( - new StackBuildError({ - detail: "A Docker service requires a selected container runtime", - reason: "invalid_config", - }), - ) - : Effect.succeed(config.runtime.containerRuntime), - ); - - const platform = yield* detectPlatform; - const serviceHost = dockerHostAddress(platform.os); - const projectDir = config.projectDir; - - const postgresResolution = yield* requirePreparedResolution(prepared, "postgres"); - - if (postgresResolution.type === "docker") { - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(config.postgres.dataDir, { recursive: true }).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to prepare the PostgreSQL data directory", - cause, - }), - ), - ); - } - - const authResolution = - config.auth === false ? false : yield* requirePreparedResolution(prepared, "auth"); - - const postgrestResolution = - config.postgrest === false - ? false - : yield* requirePreparedResolution(prepared, "postgrest"); - - const postgresInitCompletionBudgetSeconds = POSTGRES_INIT_COMPLETION_BUDGET_SECONDS; - const postgresConsumerDependencyTimeoutSeconds = - postgresDependencyTimeoutSeconds + postgresInitCompletionBudgetSeconds; - const storageDependencyTimeoutSeconds = - dependencyTimeoutSecondsForServices(storageStartupPath) + - postgresInitCompletionBudgetSeconds; - const analyticsDependencyTimeoutSeconds = - dependencyTimeoutSecondsForServices(analyticsStartupPath) + - postgresInitCompletionBudgetSeconds; - const jwtJwks = generateJwks(config.jwtSecret); - const identity = stackIdentity(config); - - const postgresService = - postgresResolution.type === "binary" - ? makePostgresService({ - binPath: yield* prepareNativePostgresAlias(postgresResolution.path), - dataDir: config.postgres.dataDir, - port: config.dbPort, - cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), - dependencies: [], - }) - : makePostgresServiceDocker({ - runtime: yield* requireContainerRuntime, - image: postgresResolution.image, - dataDir: config.postgres.dataDir, - port: config.dbPort, - platformOs: platform.os, - identity, - cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), - dependencies: [], - }); - - const defs: Array = [ - { - ...postgresService, - enabled: true, - }, - ]; - - defs.push({ - ...(postgresResolution.type === "binary" - ? makePostgresInitService({ - postgresDir: postgresResolution.path, - dbPort: config.dbPort, - jwtSecret: config.jwtSecret, - jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, - autoExposeNewTables: config.postgres.autoExposeNewTables, - dependencies: [{ service: "postgres", condition: "healthy" }], - }) - : makePostgresInitServiceDocker({ - runtime: yield* requireContainerRuntime, - dbPort: config.dbPort, - jwtSecret: config.jwtSecret, - jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, - autoExposeNewTables: config.postgres.autoExposeNewTables, - identity, - dependencies: [{ service: "postgres", condition: "healthy" }], - })), - dependencyTimeoutSeconds: postgresDependencyTimeoutSeconds, - enabled: true, - }); - - if (config.postgrest !== false && postgrestResolution !== false) { - defs.push({ - ...(postgrestResolution.type === "binary" - ? makePostgrestService({ - binPath: postgrestResolution.path, - dbPort: config.dbPort, - port: config.postgrest.port, - schemas: config.postgrest.schemas, - extraSearchPath: config.postgrest.extraSearchPath, - maxRows: config.postgrest.maxRows, - jwtSecret: config.jwtSecret, - dependencies: postgresDependencies, - }) - : makePostgrestServiceDocker({ - runtime: yield* requireContainerRuntime, - image: postgrestResolution.image, - dbHost: serviceHost, - dbPort: config.dbPort, - port: config.postgrest.port, - adminPort: config.postgrest.adminPort, - schemas: config.postgrest.schemas, - extraSearchPath: config.postgrest.extraSearchPath, - maxRows: config.postgrest.maxRows, - jwtSecret: config.jwtSecret, - platformOs: platform.os, - identity, - dependencies: postgresDependencies, - })), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.auth !== false && authResolution !== false) { - defs.push({ - ...(authResolution.type === "binary" - ? makeAuthServiceNative({ - binPath: authResolution.path, - dbPort: config.dbPort, - authPort: config.auth.port, - siteUrl: config.auth.siteUrl, - jwtSecret: config.jwtSecret, - jwtExpiry: config.auth.jwtExpiry, - externalUrl: config.auth.externalUrl, - smtpHost: config.mailpit !== false ? serviceHost : undefined, - smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, - smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, - smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, - dependencies: postgresDependencies, - }) - : makeAuthServiceDocker({ - runtime: yield* requireContainerRuntime, - image: authResolution.image, - dbHost: serviceHost, - dbPort: config.dbPort, - authPort: config.auth.port, - siteUrl: config.auth.siteUrl, - jwtSecret: config.jwtSecret, - jwtExpiry: config.auth.jwtExpiry, - externalUrl: config.auth.externalUrl, - smtpHost: config.mailpit !== false ? serviceHost : undefined, - smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, - smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, - smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, - platformOs: platform.os, - identity, - dependencies: postgresDependencies, - })), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.edgeRuntime !== false) { - const edgeRuntimeImage = yield* requirePreparedDockerImage(prepared, "edge-runtime"); - const edgeRuntimeBootstrapDir = yield* prepareEdgeRuntimeBootstrap(config.runtimeRoot); - defs.push({ - ...makeEdgeRuntimeServiceDocker({ - runtime: yield* requireContainerRuntime, - image: edgeRuntimeImage, - identity, - runtimeRoot: config.runtimeRoot, - bootstrapDir: edgeRuntimeBootstrapDir, - projectDir, - port: config.edgeRuntime.port, - inspectorPort: config.edgeRuntime.inspectorPort, - policy: config.edgeRuntime.policy, - env: config.edgeRuntime.env, - platformOs: platform.os, - dependencies: postgresDependencies, - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.mailpit !== false) { - const mailpitImage = yield* requirePreparedDockerImage(prepared, "mailpit"); - defs.push({ - ...makeMailpitServiceDocker({ - runtime: yield* requireContainerRuntime, - image: mailpitImage, - identity, - webPort: config.mailpit.port, - smtpPort: config.mailpit.smtpPort, - pop3Port: config.mailpit.pop3Port, - platformOs: platform.os, - dependencies: [], - }), - enabled: true, - }); - } - - if (config.realtime !== false) { - const realtimeImage = yield* requirePreparedDockerImage(prepared, "realtime"); - defs.push({ - ...makeRealtimeServiceDocker({ - runtime: yield* requireContainerRuntime, - image: realtimeImage, - port: config.realtime.port, - identity, - dbHost: serviceHost, - dbPort: config.dbPort, - jwtSecret: config.jwtSecret, - jwtJwks, - tenantId: config.realtime.tenantId, - encryptionKey: config.realtime.encryptionKey, - secretKeyBase: config.realtime.secretKeyBase, - maxHeaderLength: config.realtime.maxHeaderLength, - platformOs: platform.os, - dependencies: postgresDependencies, - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.storage !== false) { - const storageImage = yield* requirePreparedDockerImage(prepared, "storage"); - defs.push({ - ...makeStorageServiceDocker({ - runtime: yield* requireContainerRuntime, - image: storageImage, - port: config.storage.port, - identity, - dbHost: serviceHost, - dbPort: config.dbPort, - dataDir: config.storage.dataDir, - anonKey: config.publishableKey, - serviceKey: config.secretKey, - jwtSecret: config.jwtSecret, - jwtJwks, - fileSizeLimit: config.storage.fileSizeLimit, - enableImageTransformation: config.imgproxy !== false, - imgproxyUrl: - config.imgproxy !== false ? `http://${serviceHost}:${config.imgproxy.port}` : "", - s3ProtocolEnabled: config.storage.s3ProtocolEnabled, - platformOs: platform.os, - dependencies: postgresDependencies, - cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.imgproxy !== false) { - const storageConfig = config.storage; - const imgproxyImage = yield* requirePreparedDockerImage(prepared, "imgproxy"); - defs.push({ - ...makeImgproxyServiceDocker({ - runtime: yield* requireContainerRuntime, - image: imgproxyImage, - port: config.imgproxy.port, - identity, - dataDir: storageConfig === false ? "" : storageConfig.dataDir, - platformOs: platform.os, - dependencies: [{ service: "storage", condition: "healthy" }], - }), - dependencyTimeoutSeconds: storageDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.pgmeta !== false) { - const pgmetaImage = yield* requirePreparedDockerImage(prepared, "pgmeta"); - defs.push({ - ...makePgmetaServiceDocker({ - runtime: yield* requireContainerRuntime, - image: pgmetaImage, - identity, - port: config.pgmeta.port, - dbHost: serviceHost, - dbPort: config.dbPort, - platformOs: platform.os, - dependencies: postgresDependencies, - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.analytics !== false) { - const analyticsImage = yield* requirePreparedDockerImage(prepared, "analytics"); - defs.push({ - ...makeAnalyticsServiceDocker({ - runtime: yield* requireContainerRuntime, - image: analyticsImage, - identity, - hostPort: config.analytics.port, - platformOs: platform.os, - dbHost: serviceHost, - dbPort: config.dbPort, - apiKey: config.analytics.apiKey, - backend: config.analytics.backend, - dependencies: postgresDependencies, - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.vector !== false) { - const analyticsConfig = config.analytics; - const vectorImage = yield* requirePreparedDockerImage(prepared, "vector"); - defs.push({ - ...makeVectorServiceDocker({ - runtime: yield* requireContainerRuntime, - image: vectorImage, - identity, - serviceHost, - analyticsPort: analyticsConfig === false ? 0 : analyticsConfig.port, - analyticsApiKey: analyticsConfig === false ? "api-key" : analyticsConfig.apiKey, - platformOs: platform.os, - dependencies: [{ service: "analytics", condition: "healthy" }], - }), - dependencyTimeoutSeconds: analyticsDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.pooler !== false) { - const poolerImage = yield* requirePreparedDockerImage(prepared, "pooler"); - defs.push({ - ...makePoolerServiceDocker({ - runtime: yield* requireContainerRuntime, - image: poolerImage, - identity, - hostAdminPort: config.pooler.apiPort, - hostPort: config.pooler.port, - platformOs: platform.os, - dbHost: serviceHost, - dbPort: config.dbPort, - poolMode: config.pooler.mode, - defaultPoolSize: config.pooler.defaultPoolSize, - maxClientConn: config.pooler.maxClientConn, - jwtSecret: config.jwtSecret, - tenantId: config.pooler.tenantId, - encryptionKey: config.pooler.encryptionKey, - secretKeyBase: config.pooler.secretKeyBase, - dependencies: postgresDependencies, - }), - dependencyTimeoutSeconds: postgresConsumerDependencyTimeoutSeconds, - enabled: true, - }); - } - - if (config.studio !== false) { - const pgmetaConfig = config.pgmeta; - const studioImage = yield* requirePreparedDockerImage(prepared, "studio"); - defs.push({ - ...makeStudioServiceDocker({ - runtime: yield* requireContainerRuntime, - image: studioImage, - identity, - port: config.studio.port, - apiUrl: config.studio.apiUrl, - publicApiUrl: `http://127.0.0.1:${config.apiPort}`, - pgmetaUrl: pgmetaConfig === false ? "" : `http://${serviceHost}:${pgmetaConfig.port}`, - publishableKey: config.publishableKey, - secretKey: config.secretKey, - s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, - s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, - jwtSecret: config.jwtSecret, - analyticsEnabled: config.analytics !== false, - analyticsBackend: config.analytics !== false ? config.analytics.backend : "postgres", - analyticsUrl: - config.analytics !== false ? `http://${serviceHost}:${config.analytics.port}` : "", - analyticsApiKey: config.analytics !== false ? config.analytics.apiKey : "api-key", - platformOs: platform.os, - dependencies: - config.analytics === false - ? [{ service: "pgmeta", condition: "healthy" }] - : [ - { service: "pgmeta", condition: "healthy" }, - { service: "analytics", condition: "healthy" }, - ], - }), - dependencyTimeoutSeconds: analyticsDependencyTimeoutSeconds, - enabled: true, - }); - } - - const dockerContainerNames = SERVICE_NAMES.filter((service) => - defs.some( - (def) => - def.name === service && - config.runtime.mode === "docker" && - def.command === config.runtime.containerRuntime, - ), - ).map((service) => dockerContainerName(service, identity.key)); - - const graph = yield* buildGraph(defs).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to build dependency graph", - cause, - }), - ), - ); - - return { - graph, - cleanupTargets: { - dockerContainerNames, - }, - serviceProjection: publicServiceProjection(defs), - }; - }), - }); -} diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts deleted file mode 100644 index b041ec5431..0000000000 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ /dev/null @@ -1,564 +0,0 @@ -// oxlint-disable effecttsgo/any-unknown-in-error-context, effecttsgo/node-builtin-import -- Builder tests assert dynamic startup failures while using native filesystem/path fixtures. - -import { describe, expect, it } from "@effect/vitest"; -import { NodeFileSystem } from "@effect/platform-node"; -import { Deferred, Effect, FileSystem, Layer, Predicate, Scope, Sink, Stream } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; -import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; -import { candidateCleanupTargets } from "./cleanup.ts"; -import { activationTargetsForService } from "./ServiceActivation.ts"; -import { SERVICE_NAMES } from "./ServiceCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; -import { StackBuilder, validateResolvedConfig } from "./StackBuilder.ts"; -import type { BuildResult } from "./StackBuilder.ts"; -import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; -import { STACK_ID_LABEL } from "./StackIdentity.ts"; -import { enabledServicesForConfig, versionsForConfig } from "./StackBuilder.ts"; -import type { AllocatedPorts } from "./PortCatalog.ts"; -import { preparationClosure, StackPreparation } from "./StackPreparation.ts"; -import type { StackPreparationInput } from "./StackPreparation.ts"; -import { resolveConfig } from "./StackConfigResolver.ts"; -import { - dependencyTimeoutSecondsForServices, - POSTGRES_INIT_COMPLETION_BUDGET_SECONDS, -} from "./services/health-budgets.ts"; -import { DEFAULT_VERSIONS } from "./versions.ts"; - -const testJwtSecret = "super-secret-jwt-token-with-at-least-32-characters"; - -const basePorts: AllocatedPorts = { - apiPort: 3000, - dbPort: 5432, - authPort: 9999, - postgrestPort: 3001, - postgrestAdminPort: 3002, - edgeRuntimePort: 3003, - edgeRuntimeInspectorPort: 3004, - realtimePort: 3010, - storagePort: 3011, - imgproxyPort: 3012, - mailpitPort: 3013, - mailpitSmtpPort: 3014, - mailpitPop3Port: 3015, - pgmetaPort: 3016, - studioPort: 3017, - analyticsPort: 3018, - poolerPort: 3019, - poolerApiPort: 3020, -}; - -const baseConfig: ResolvedStackConfig = { - cacheRoot: "/tmp/supabase-cache", - stackRoot: "/tmp/supabase-stack", - runtimeRoot: "/tmp/supabase-runtime", - projectDir: "/tmp/supabase-project", - runtime: { mode: "native", containerRuntime: null }, - servicePolicies: { - postgres: "eager", - postgrest: "eager", - auth: "eager", - "edge-runtime": "off", - realtime: "off", - storage: "off", - imgproxy: "off", - mailpit: "off", - pgmeta: "off", - studio: "off", - analytics: "off", - vector: "off", - pooler: "off", - }, - readiness: DEFAULT_STACK_READINESS_POLICY, - readinessSource: "default", - jwtSecret: testJwtSecret, - ports: basePorts, - apiPort: 3000, - dbPort: 5432, - publishableKey: defaultPublishableKey, - secretKey: defaultSecretKey, - functions: false, - autoManagedPaths: [], - anonJwt: generateJwt(testJwtSecret, "anon"), - serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), - postgres: { - port: 5432, - dataDir: "/tmp/pg-data", - version: DEFAULT_VERSIONS.postgres, - autoExposeNewTables: true, - }, - postgrest: { - port: 3001, - adminPort: 3002, - schemas: ["public", "extensions"], - extraSearchPath: ["public"], - maxRows: 1000, - version: DEFAULT_VERSIONS.postgrest, - }, - auth: { - port: 9999, - siteUrl: "http://localhost:3000", - jwtExpiry: 3600, - externalUrl: "http://localhost:9999", - version: DEFAULT_VERSIONS.auth, - }, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, -}; - -const dockerConfig: ResolvedStackConfig = { - ...baseConfig, - runtime: { mode: "docker", containerRuntime: "docker" }, -}; - -/** - * Two stacks that were handed their own identities while sharing every port, - * which is what sibling worktrees of one project look like when a crashed stack - * left its containers behind and its ports free. - */ -const firstManagedId = "3f1a2b4c-5d6e-4f70-8192-a3b4c5d6e7f8"; -const secondManagedId = "9e8d7c6b-5a49-4382-9170-f6e5d4c3b2a1"; - -const managedConfig: ResolvedStackConfig = { - ...dockerConfig, - instanceId: firstManagedId, -}; - -const siblingManagedConfig: ResolvedStackConfig = { - ...dockerConfig, - instanceId: secondManagedId, -}; - -const edgeRuntimeConfig: ResolvedStackConfig = { - ...baseConfig, - runtime: { mode: "docker", containerRuntime: "docker" }, - servicePolicies: { ...baseConfig.servicePolicies, "edge-runtime": "eager" }, - edgeRuntime: { - enabled: true, - port: basePorts.edgeRuntimePort, - inspectorPort: basePorts.edgeRuntimeInspectorPort, - policy: "per_worker", - version: DEFAULT_VERSIONS["edge-runtime"], - env: {}, - }, -}; - -const encoder = new TextEncoder(); - -function mockSequenceSpawner( - results: ReadonlyArray<{ readonly exitCode: number; readonly stderr?: string[] }>, -) { - let index = 0; - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((_command) => - Effect.gen(function* () { - const result = results[index] ?? { exitCode: 0 }; - index += 1; - const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(3000 + index), - stdout: Stream.empty, - stderr: Stream.fromIterable( - (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)), - ), - all: Stream.empty, - exitCode: Deferred.await(exitDeferred), - isRunning: Effect.succeed(true), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ); -} - -function builderLayer( - resolver: ReturnType, - spawnerLayer = mockSequenceSpawner([{ exitCode: 0 }]), -) { - return Layer.mergeAll( - StackBuilder.layer, - NodeFileSystem.layer, - StackPreparation.layer.pipe(Layer.provide(resolver.layer), Layer.provide(spawnerLayer)), - ); -} - -const prepareAndBuild = ( - builder: typeof StackBuilder.Service, - preparation: typeof StackPreparation.Service, - config: ResolvedStackConfig, -): Effect.Effect => - Effect.gen(function* () { - const shared = { - services: enabledServicesForConfig(config), - versions: versionsForConfig(config), - }; - const input: StackPreparationInput = - config.runtime.mode === "native" - ? { ...shared, mode: "native" } - : { ...shared, mode: "docker", containerRuntime: config.runtime.containerRuntime }; - const prepared = yield* preparation.prepare(input); - const fs = yield* FileSystem.FileSystem; - const scope = yield* Effect.scope; - return yield* builder - .build(config, prepared) - .pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Scope.Scope, scope), - ); - }); - -describe("StackBuilder", () => { - it.effect("builds graph with all native binaries", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph, cleanupTargets, serviceProjection } = yield* prepareAndBuild( - builder, - preparation, - baseConfig, - ); - - expect(graph.startOrder.length).toBe(4); - expect(cleanupTargets.dockerContainerNames).toEqual([]); - expect(candidateCleanupTargets(baseConfig).dockerContainerNames).toEqual([ - `supabase-postgres-${baseConfig.apiPort}`, - `supabase-postgrest-${baseConfig.apiPort}`, - `supabase-auth-${baseConfig.apiPort}`, - ]); - - const names = graph.startOrder.map((s) => s.name); - expect(names).toContain("postgres"); - expect(names).toContain("postgres-init"); - expect(names).toContain("postgrest"); - expect(names).toContain("auth"); - - // Ordering: postgres → postgres-init → [postgrest, auth] - expect(names.indexOf("postgres")).toBeLessThan(names.indexOf("postgres-init")); - expect(names.indexOf("postgres-init")).toBeLessThan(names.indexOf("postgrest")); - expect(names.indexOf("postgres-init")).toBeLessThan(names.indexOf("auth")); - - const postgresDependencyTimeout = dependencyTimeoutSecondsForServices(["postgres"]); - expect( - graph.startOrder.find((service) => service.name === "postgres-init") - ?.dependencyTimeoutSeconds, - ).toBe(postgresDependencyTimeout); - for (const name of ["postgrest", "auth"]) { - expect( - graph.startOrder.find((service) => service.name === name)?.dependencyTimeoutSeconds, - ).toBe(postgresDependencyTimeout + POSTGRES_INIT_COMPLETION_BUDGET_SECONDS); - } - - expect(serviceProjection.get("postgres")).toEqual({ visibility: "public" }); - expect(serviceProjection.get("postgres-init")).toEqual({ - visibility: "internal", - owner: "postgres", - ownerStatusWhileActive: "Initializing", - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("excludes disabled services", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, { - ...baseConfig, - auth: false, - }); - - // postgres + postgres-init + postgrest (no auth) - expect(graph.startOrder.length).toBe(3); - const names = graph.startOrder.map((s) => s.name); - expect(names).toContain("postgres"); - expect(names).toContain("postgres-init"); - expect(names).toContain("postgrest"); - expect(names).not.toContain("auth"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("Docker mode consistently uses the selected container runtime", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const config = { - ...dockerConfig, - runtime: { mode: "docker", containerRuntime: "podman" }, - } satisfies ResolvedStackConfig; - const { graph, cleanupTargets } = yield* prepareAndBuild(builder, preparation, config); - - expect(graph.startOrder.length).toBe(4); - - const names = graph.startOrder.map((s) => s.name); - expect(names).toContain("postgres"); - expect(names).toContain("postgres-init"); - expect(names).toContain("postgrest"); - expect(names).toContain("auth"); - - // All Docker-backed services launch directly and rely on process-compose - // supervision for abrupt parent-exit cleanup. - for (const name of ["postgres", "postgrest", "auth"]) { - const def = graph.startOrder.find((s) => s.name === name); - expect(def).toBeDefined(); - expect(def?.command).toBe("podman"); - expect(def?.supervision).toBeDefined(); - expect(def?.supervision?.orphanCleanup).toContainEqual( - expect.objectContaining({ executable: "podman" }), - ); - } - - // Docker container names are collected for cleanup - expect(cleanupTargets.dockerContainerNames).toEqual([ - `supabase-postgres-${config.apiPort}`, - `supabase-postgrest-${config.apiPort}`, - `supabase-auth-${config.apiPort}`, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("prepares nested PostgreSQL bind-mount parents before Docker launch", () => { - const resolver = mockBinaryResolver(); - const root = mkdtempSync(join(tmpdir(), "sup-stack-postgres-data-")); - const dataDir = join(root, "data", "postgres"); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - yield* prepareAndBuild(builder, preparation, { - ...dockerConfig, - postgres: { ...dockerConfig.postgres, dataDir }, - }); - expect(existsSync(dataDir)).toBe(true); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), - ); - }); - - it.effect("prepares every public service in each startable graph closure", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const config = yield* resolveConfig( - { - realtime: {}, - storage: {}, - imgproxy: {}, - mailpit: {}, - pgmeta: {}, - studio: {}, - analytics: {}, - vector: {}, - pooler: {}, - }, - { - ports: basePorts, - stackRoot: "/tmp/supabase-stack", - runtimeRoot: "/tmp/supabase-runtime", - runtime: { mode: "docker", containerRuntime: "docker" }, - }, - ); - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, config); - - for (const service of SERVICE_NAMES) { - const prepared = new Set( - preparationClosure(activationTargetsForService(SERVICE_NAMES, service), SERVICE_NAMES), - ); - const publicGraphServices = graph - .startOrderFor(service) - .map((definition) => definition.name) - .filter((name): name is ServiceName => - SERVICE_NAMES.some((candidate) => candidate === name), - ); - expect( - publicGraphServices.length, - `${service} must exercise a non-empty public graph closure`, - ).toBeGreaterThan(0); - const missing = publicGraphServices.filter((name) => !prepared.has(name)); - expect( - missing, - `${service} starts public services outside its preparation closure`, - ).toEqual([]); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("names and labels a stack's containers by the identity it was given", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph, cleanupTargets } = yield* prepareAndBuild(builder, preparation, managedConfig); - - expect(cleanupTargets.dockerContainerNames).toEqual([ - `supabase-postgres-id-${firstManagedId}`, - `supabase-postgrest-id-${firstManagedId}`, - `supabase-auth-id-${firstManagedId}`, - ]); - expect(candidateCleanupTargets(managedConfig).dockerContainerNames).toEqual( - cleanupTargets.dockerContainerNames, - ); - // Nothing about the stack's Docker resources is keyed by a port any more, - // so a stack that reuses this one's ports cannot collide with it. - for (const name of cleanupTargets.dockerContainerNames) { - expect(name).not.toContain(String(managedConfig.apiPort)); - } - - for (const def of graph.startOrder.filter((service) => service.args?.[0] === "run")) { - expect(def.args).toContain(`supabase-${def.name}-id-${firstManagedId}`); - // The label carries the whole identity, so the containers stay findable - // by it even if the names are ever built differently. - expect(def.args?.join(" ")).toContain(`--label ${STACK_ID_LABEL}=${firstManagedId}`); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects an invalid instanceId supplied to the builder directly", () => { - return Effect.gen(function* () { - const error = yield* validateResolvedConfig({ - ...dockerConfig, - instanceId: "../bad:id", - }).pipe(Effect.flip); - - expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); - expect(error.reason).toBe("invalid_config"); - }); - }); - - it.effect("keeps sibling stacks sharing every port on separate containers", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const first = yield* prepareAndBuild(builder, preparation, managedConfig); - const sibling = yield* prepareAndBuild(builder, preparation, siblingManagedConfig); - - expect(siblingManagedConfig.apiPort).toBe(managedConfig.apiPort); - const shared = first.cleanupTargets.dockerContainerNames.filter((name) => - sibling.cleanupTargets.dockerContainerNames.includes(name), - ); - expect(shared).toEqual([]); - expect(sibling.cleanupTargets.dockerContainerNames).toEqual([ - `supabase-postgres-id-${secondManagedId}`, - `supabase-postgrest-id-${secondManagedId}`, - `supabase-auth-id-${secondManagedId}`, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("leaves a stack without an identity on its port-derived names", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph, cleanupTargets } = yield* prepareAndBuild(builder, preparation, dockerConfig); - - expect(cleanupTargets.dockerContainerNames).toEqual([ - `supabase-postgres-${dockerConfig.apiPort}`, - `supabase-postgrest-${dockerConfig.apiPort}`, - `supabase-auth-${dockerConfig.apiPort}`, - ]); - for (const def of graph.startOrder.filter((service) => service.args?.[0] === "run")) { - expect(def.args).toContain(`supabase-${def.name}-${dockerConfig.apiPort}`); - expect(def.args?.join(" ")).not.toContain(STACK_ID_LABEL); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("docker consumers wait for database initialization", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, dockerConfig); - - const authDef = graph.startOrder.find((s) => s.name === "auth"); - expect(authDef?.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - expect(authDef?.dependencyTimeoutSeconds).toBe( - dependencyTimeoutSecondsForServices(["postgres"]) + POSTGRES_INIT_COMPLETION_BUDGET_SECONDS, - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("docker mode wires dependencies correctly", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph } = yield* prepareAndBuild(builder, preparation, dockerConfig); - - const authDef = graph.startOrder.find((s) => s.name === "auth"); - expect(authDef?.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - - const postgrestDef = graph.startOrder.find((s) => s.name === "postgrest"); - expect(postgrestDef?.dependencies).toEqual([ - { service: "postgres-init", condition: "completed" }, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("uses Docker for edge-runtime and its dependencies in Docker mode", () => { - const resolver = mockBinaryResolver(); - const layer = builderLayer(resolver); - - return Effect.gen(function* () { - const builder = yield* StackBuilder; - const preparation = yield* StackPreparation; - const { graph, cleanupTargets } = yield* prepareAndBuild( - builder, - preparation, - edgeRuntimeConfig, - ); - - const edgeRuntimeDef = graph.startOrder.find((service) => service.name === "edge-runtime"); - expect(edgeRuntimeDef).toBeDefined(); - expect(edgeRuntimeDef?.command).toBe("docker"); - expect(edgeRuntimeDef?.dependencies).toEqual([ - { service: "postgres-init", condition: "completed" }, - ]); - expect(cleanupTargets.dockerContainerNames).toContain( - `supabase-edge-runtime-${edgeRuntimeConfig.apiPort}`, - ); - }).pipe(Effect.provide(layer)); - }); -}); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts deleted file mode 100644 index 0255dbc560..0000000000 --- a/packages/stack/src/StackConfig.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { Schema } from "effect"; -import type { ResolvedFunctionsBundle } from "./functions.ts"; -import type { ResolvedPorts } from "./PortCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; - -import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; - -export type StackMode = "native" | "docker"; -export type ServicePolicy = "off" | "lazy" | "eager"; -export type ServicePolicyManifest = Readonly>; - -export type ReadinessPolicy = - | { readonly mode: "finite"; readonly timeoutMs: number } - | { readonly mode: "infinite" }; - -export type ReadyOptions = { readonly mode: "inherit" } | ReadinessPolicy; - -const ReadinessPolicySchema = Schema.Union([ - Schema.Struct({ - mode: Schema.Literal("finite"), - timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), - }), - Schema.Struct({ mode: Schema.Literal("infinite") }), -]); - -/** The single wire representation accepted by Effect, Promise, and daemon Adapters. */ -export const ReadyOptionsSchema = Schema.Union([ - Schema.Struct({ mode: Schema.Literal("inherit") }), - ReadinessPolicySchema, -]); - -export const inheritReadyOptions: ReadyOptions = { mode: "inherit" }; - -/** - * What a Docker container name segment tolerates: this is used in the - * namespaced `supabase--id-` form, so anything Docker - * itself rejects there (path separators, colons, whitespace, …) must be - * rejected here first. A managed caller's stack UUID always matches; a - * hand-supplied `instanceId` must be shaped the same way. - */ -export const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/i; - -/** Validates {@link StackConfig.instanceId} against {@link INSTANCE_ID_PATTERN}. */ -export const InstanceIdSchema = Schema.String.check(Schema.isPattern(INSTANCE_ID_PATTERN)); - -/** Default readiness deadline; lazy activation expands it for longer transitive startup budgets. */ -export const DEFAULT_STACK_READINESS_POLICY: ReadinessPolicy = { - mode: "finite", - timeoutMs: 180_000, -}; - -/** Resolve readiness with per-call policy taking precedence over stack policy and package default. */ -export const resolveReadinessPolicy = (input: { - readonly readyOptions?: ReadyOptions; - readonly stackPolicy?: ReadinessPolicy; -}): ReadinessPolicy => - input.readyOptions === undefined || input.readyOptions.mode === "inherit" - ? (input.stackPolicy ?? DEFAULT_STACK_READINESS_POLICY) - : input.readyOptions; - -export interface PostgresConfig { - readonly port?: number; - readonly dataDir?: string; - readonly version?: string; - /** - * When true (default), the bundled initial schema GRANTs that expose new tables, views, - * sequences, and functions in `public` to the Data API roles (`anon`, `authenticated`, - * `service_role`) are kept in place, matching the cloud default. When false, those default - * privileges are revoked so new entities require explicit GRANTs to surface through the Data - * API, matching a cloud project with the "Default privileges for new entities" toggle turned - * off. - */ - readonly autoExposeNewTables?: boolean; -} - -export interface PostgrestConfig { - readonly schemas?: ReadonlyArray; - readonly extraSearchPath?: ReadonlyArray; - readonly maxRows?: number; - readonly version?: string; -} - -export interface AuthConfig { - readonly port?: number; - readonly siteUrl?: string; - readonly jwtExpiry?: number; - readonly externalUrl?: string; - readonly version?: string; -} - -export interface RealtimeConfig { - readonly port?: number; - readonly version?: string; - readonly tenantId?: string; - readonly encryptionKey?: string; - readonly secretKeyBase?: string; - readonly maxHeaderLength?: number; -} - -export interface EdgeRuntimeConfig { - readonly enabled?: boolean; - readonly port?: number; - readonly inspectorPort?: number; - readonly policy?: "oneshot" | "per_worker"; - readonly version?: string; - readonly env?: Readonly>; -} - -export interface StorageConfig { - readonly port?: number; - readonly dataDir?: string; - readonly fileSizeLimit?: string; - readonly s3ProtocolEnabled?: boolean; - readonly version?: string; -} - -export interface ImgproxyConfig { - readonly port?: number; - readonly version?: string; -} - -export interface MailpitConfig { - readonly port?: number; - readonly smtpPort?: number; - readonly pop3Port?: number; - readonly version?: string; - readonly adminEmail?: string; - readonly senderName?: string; -} - -export interface PgmetaConfig { - readonly port?: number; - readonly version?: string; -} - -export interface StudioConfig { - readonly port?: number; - readonly apiUrl?: string; - readonly version?: string; -} - -export interface AnalyticsConfig { - readonly port?: number; - readonly version?: string; - readonly backend?: "postgres" | "bigquery"; - readonly apiKey?: string; -} - -export interface VectorConfig { - readonly version?: string; -} - -export interface PoolerConfig { - readonly port?: number; - readonly apiPort?: number; - readonly mode?: "transaction" | "session"; - readonly version?: string; - readonly tenantId?: string; - readonly encryptionKey?: string; - readonly secretKeyBase?: string; - readonly defaultPoolSize?: number; - readonly maxClientConn?: number; -} - -export interface StackConfig { - /** - * An opaque identity for this stack, namespaced in the names and preserved - * verbatim in the labels of the Docker resources it owns so two stacks never - * collide on them — which is exactly why it must itself be a Docker-name-safe - * token: see {@link INSTANCE_ID_PATTERN}. - * - * The runtime never interprets its structure beyond that — a managed caller - * passes its stack id (a UUID, which already matches), and a caller that - * passes nothing keeps the port-derived names it always had. - */ - readonly instanceId?: string; - readonly cacheRoot?: string; - readonly stackRoot?: string; - readonly runtimeRoot?: string; - readonly projectDir?: string; - readonly mode?: StackMode; - /** Per-service resource policy. `off` excludes a service from the graph. */ - readonly servicePolicies?: Partial>; - /** Stack-wide readiness policy. Per-call ReadyOptions take precedence. */ - readonly readiness?: ReadinessPolicy; - readonly jwtSecret?: string; - readonly port?: number; - readonly publishableKey?: string; - readonly secretKey?: string; - readonly functions?: ResolvedFunctionsBundle | false; - readonly postgres?: PostgresConfig; - readonly postgrest?: PostgrestConfig | false; - readonly auth?: AuthConfig | false; - readonly edgeRuntime?: EdgeRuntimeConfig | false; - readonly realtime?: RealtimeConfig | false; - readonly storage?: StorageConfig | false; - readonly imgproxy?: ImgproxyConfig | false; - readonly mailpit?: MailpitConfig | false; - readonly pgmeta?: PgmetaConfig | false; - readonly studio?: StudioConfig | false; - readonly analytics?: AnalyticsConfig | false; - readonly vector?: VectorConfig | false; - readonly pooler?: PoolerConfig | false; -} - -export interface ResolvedPostgresConfig { - readonly port: number; - readonly dataDir: string; - readonly version: string; - readonly autoExposeNewTables: boolean; -} - -export interface ResolvedPostgrestConfig { - readonly port: number; - readonly adminPort: number; - readonly schemas: ReadonlyArray; - readonly extraSearchPath: ReadonlyArray; - readonly maxRows: number; - readonly version: string; -} - -export interface ResolvedAuthConfig { - readonly port: number; - readonly siteUrl: string; - readonly jwtExpiry: number; - readonly externalUrl: string; - readonly version: string; -} - -export interface ResolvedRealtimeConfig { - readonly port: number; - readonly version: string; - readonly tenantId: string; - readonly encryptionKey: string; - readonly secretKeyBase: string; - readonly maxHeaderLength: number; -} - -export interface ResolvedEdgeRuntimeConfig { - readonly enabled: boolean; - readonly port: number; - readonly inspectorPort: number; - readonly policy: "oneshot" | "per_worker"; - readonly version: string; - readonly env: Readonly>; -} - -export interface ResolvedStorageConfig { - readonly port: number; - readonly version: string; - readonly dataDir: string; - readonly fileSizeLimit: string; - readonly s3ProtocolEnabled: boolean; -} - -export interface ResolvedImgproxyConfig { - readonly port: number; - readonly version: string; -} - -export interface ResolvedMailpitConfig { - readonly port: number; - readonly smtpPort: number; - readonly pop3Port: number; - readonly version: string; - readonly adminEmail: string; - readonly senderName: string; -} - -export interface ResolvedPgmetaConfig { - readonly port: number; - readonly version: string; -} - -export interface ResolvedStudioConfig { - readonly port: number; - readonly version: string; - readonly apiUrl: string; -} - -export interface ResolvedAnalyticsConfig { - readonly port: number; - readonly version: string; - readonly backend: "postgres" | "bigquery"; - readonly apiKey: string; -} - -export interface ResolvedVectorConfig { - readonly version: string; -} - -export interface ResolvedPoolerConfig { - readonly port: number; - readonly apiPort: number; - readonly mode: "transaction" | "session"; - readonly version: string; - readonly tenantId: string; - readonly encryptionKey: string; - readonly secretKeyBase: string; - readonly defaultPoolSize: number; - readonly maxClientConn: number; -} - -export interface ResolvedStackConfig { - /** The opaque identity this stack's Docker resources are keyed by, if any. */ - readonly instanceId?: string; - readonly cacheRoot: string; - readonly stackRoot: string; - readonly runtimeRoot: string; - readonly projectDir: string; - /** Concrete execution mode and, for containers, the selected executable. */ - readonly runtime: StackRuntimeSelection; - readonly servicePolicies: ServicePolicyManifest; - readonly readiness: ReadinessPolicy; - /** Whether readiness came from the package default or an explicit stack policy. */ - readonly readinessSource: "default" | "configured"; - readonly jwtSecret: string; - readonly ports: ResolvedPorts; - readonly apiPort: number; - readonly dbPort: number; - readonly publishableKey: string; - readonly secretKey: string; - readonly functions: ResolvedFunctionsBundle | false; - readonly autoManagedPaths: ReadonlyArray; - readonly anonJwt: string; - readonly serviceRoleJwt: string; - readonly postgres: ResolvedPostgresConfig; - readonly postgrest: ResolvedPostgrestConfig | false; - readonly auth: ResolvedAuthConfig | false; - readonly edgeRuntime: ResolvedEdgeRuntimeConfig | false; - readonly realtime: ResolvedRealtimeConfig | false; - readonly storage: ResolvedStorageConfig | false; - readonly imgproxy: ResolvedImgproxyConfig | false; - readonly mailpit: ResolvedMailpitConfig | false; - readonly pgmeta: ResolvedPgmetaConfig | false; - readonly studio: ResolvedStudioConfig | false; - readonly analytics: ResolvedAnalyticsConfig | false; - readonly vector: ResolvedVectorConfig | false; - readonly pooler: ResolvedPoolerConfig | false; -} - -export interface ResolvedDaemonConfig extends ResolvedStackConfig { - readonly name: string; - readonly projectDir: string; -} diff --git a/packages/stack/src/StackConfig.unit.test.ts b/packages/stack/src/StackConfig.unit.test.ts deleted file mode 100644 index da53bd95db..0000000000 --- a/packages/stack/src/StackConfig.unit.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; -import { - DEFAULT_STACK_READINESS_POLICY, - InstanceIdSchema, - ReadyOptionsSchema, - resolveReadinessPolicy, - type ReadinessPolicy, -} from "./StackConfig.ts"; - -const finite = (timeoutMs: number): ReadinessPolicy => ({ mode: "finite", timeoutMs }); - -describe("resolveReadinessPolicy", () => { - it("uses the finite package default when neither level chooses a policy", () => { - expect(resolveReadinessPolicy({})).toEqual(DEFAULT_STACK_READINESS_POLICY); - }); - - it("inherits finite and infinite stack policies", () => { - expect( - resolveReadinessPolicy({ - readyOptions: { mode: "inherit" }, - stackPolicy: finite(180_000), - }), - ).toEqual(finite(180_000)); - expect( - resolveReadinessPolicy({ - readyOptions: { mode: "inherit" }, - stackPolicy: { mode: "infinite" }, - }), - ).toEqual({ mode: "infinite" }); - }); - - it("allows shorter and longer per-call finite policies", () => { - expect( - resolveReadinessPolicy({ - readyOptions: finite(5_000), - stackPolicy: finite(180_000), - }), - ).toEqual(finite(5_000)); - expect( - resolveReadinessPolicy({ - readyOptions: finite(300_000), - stackPolicy: finite(180_000), - }), - ).toEqual(finite(300_000)); - }); - - it("allows either level to opt into or override infinite waiting", () => { - expect( - resolveReadinessPolicy({ - readyOptions: { mode: "infinite" }, - stackPolicy: finite(180_000), - }), - ).toEqual({ mode: "infinite" }); - expect( - resolveReadinessPolicy({ - readyOptions: finite(30_000), - stackPolicy: { mode: "infinite" }, - }), - ).toEqual(finite(30_000)); - }); -}); - -describe("ReadyOptionsSchema", () => { - const decode = Schema.decodeUnknownSync(ReadyOptionsSchema); - - it("accepts the three readiness override modes", () => { - expect(decode({ mode: "inherit" })).toEqual({ mode: "inherit" }); - expect(decode({ mode: "infinite" })).toEqual({ mode: "infinite" }); - expect(decode({ mode: "finite", timeoutMs: 25 })).toEqual({ - mode: "finite", - timeoutMs: 25, - }); - }); - - it("rejects malformed and non-positive finite deadlines", () => { - expect(() => decode({ mode: "finite", timeoutMs: 0 })).toThrow(); - expect(() => decode({ mode: "finite", timeoutMs: -1 })).toThrow(); - expect(() => decode({ mode: "forever" })).toThrow(); - }); -}); - -describe("InstanceIdSchema", () => { - const decode = Schema.decodeUnknownSync(InstanceIdSchema); - - it("accepts a Docker-name-safe token, such as a managed stack's UUID", () => { - expect(decode("0f9d2b3c-4a5e-4c7d-8e9f-1a2b3c4d5e6f")).toBe( - "0f9d2b3c-4a5e-4c7d-8e9f-1a2b3c4d5e6f", - ); - }); - - it("rejects a value that would break Docker container name interpolation", () => { - expect(() => decode("../bad:id")).toThrow(); - }); -}); diff --git a/packages/stack/src/StackConfigResolver.policy.unit.test.ts b/packages/stack/src/StackConfigResolver.policy.unit.test.ts deleted file mode 100644 index 696e34c320..0000000000 --- a/packages/stack/src/StackConfigResolver.policy.unit.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- These tests exercise the public Promise facade of config resolution through Vitest callbacks. - -import { describe, expect, it } from "vitest"; -import { NodeFileSystem } from "@effect/platform-node"; -import { Cause, Effect, Exit, FileSystem } from "effect"; -import { systemError } from "effect/PlatformError"; -import { - resolveConfig as resolveConfigEffect, - type ResolveConfigOptions, -} from "./StackConfigResolver.ts"; -import { StackBuildError } from "./errors.ts"; -import type { PortSet } from "./PortCatalog.ts"; - -const testPorts: PortSet = { - apiPort: 40_000, - dbPort: 40_001, - authPort: 40_002, - postgrestPort: 40_003, - postgrestAdminPort: 40_004, - realtimePort: 40_005, - storagePort: 40_006, - imgproxyPort: 40_007, - mailpitPort: 40_008, - mailpitSmtpPort: 40_009, - mailpitPop3Port: 40_010, - pgmetaPort: 40_011, - studioPort: 40_012, - analyticsPort: 40_013, - poolerPort: 40_014, - poolerApiPort: 40_015, -}; - -const resolveConfig = ( - config?: Parameters[0], - options?: Partial, -) => - Effect.runPromise( - resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts }).pipe( - Effect.provide(NodeFileSystem.layer), - ), - ); - -describe("resolved service preparation policies", () => { - it("maps temporary-root filesystem failures to StackBuildError", async () => { - const exit = await Effect.runPromise( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const failingFs = { - ...fs, - makeTempDirectory: () => - Effect.fail( - systemError({ - _tag: "PermissionDenied", - module: "test", - method: "makeTempDirectory", - }), - ), - }; - return yield* resolveConfigEffect(undefined, { ports: testPorts }).pipe( - Effect.provideService(FileSystem.FileSystem, failingFs), - Effect.exit, - ); - }).pipe(Effect.provide(NodeFileSystem.layer)), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.findErrorOption(exit.cause)).toMatchObject({ - _tag: "Some", - value: { _tag: "StackBuildError" }, - }); - } - }); - - it("applies explicit policies and catalog defaults while keeping Postgres eager", async () => { - const config = await resolveConfig({ - servicePolicies: { postgrest: "eager", mailpit: "eager" }, - mailpit: {}, - stackRoot: "/tmp/stack-policy-test", - runtimeRoot: "/tmp/runtime-policy-test", - }); - - expect(config.servicePolicies.postgres).toBe("eager"); - expect(config.servicePolicies.postgrest).toBe("eager"); - expect(config.servicePolicies.auth).toBe("lazy"); - expect(config.servicePolicies.mailpit).toBe("eager"); - }); - - it("rejects an unsupported lazy policy before port allocation", async () => { - await expect(resolveConfig({ servicePolicies: { postgres: "lazy" } })).rejects.toBeInstanceOf( - StackBuildError, - ); - }); - - it("rejects disabling postgres through the service policy manifest", async () => { - await expect(resolveConfig({ servicePolicies: { postgres: "off" } })).rejects.toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - }); - - it("resolves explicitly disabled core services to false without reserving ports", async () => { - const config = await resolveConfig({ servicePolicies: { postgrest: "off" } }); - expect(config.postgrest).toBe(false); - expect(config.servicePolicies.postgrest).toBe("off"); - }); - - it("rejects a preparation policy for a service that is not configured", async () => { - await expect(resolveConfig({ servicePolicies: { realtime: "eager" } })).rejects.toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - }); - - it("rejects an eager service whose required public dependency is lazy before allocating ports", async () => { - await expect( - resolveConfig({ - analytics: {}, - vector: {}, - servicePolicies: { analytics: "lazy", vector: "eager" }, - }), - ).rejects.toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - }); -}); diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts deleted file mode 100644 index 4c2a80b364..0000000000 --- a/packages/stack/src/StackConfigResolver.ts +++ /dev/null @@ -1,822 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Config path resolution is a pure boundary helper and intentionally does not require the Path service. -import { join } from "node:path"; -import { Effect, Exit, FileSystem, Record, Schema } from "effect"; -import type { PlatformError } from "effect/PlatformError"; -import { StackBuildError } from "./errors.ts"; -import { - resolvedFunctionsBundleSchemaForProject, - type ResolvedFunctionsBundle, -} from "./functions.ts"; -import { - defaultJwtSecret, - defaultPublishableKey, - defaultSecretKey, - generateJwt, -} from "./JwtGenerator.ts"; -import { defaultCacheRoot, shortTempPrefixRoot } from "./paths.ts"; -import { type PortReservationRequest } from "./PortAllocator.ts"; -import { PORT_CATALOG, type PortField, type PortSet, type ResolvedPorts } from "./PortCatalog.ts"; -import { portFieldsForConfigInput } from "./ServicePorts.ts"; -import { INSTANCE_ID_PATTERN, InstanceIdSchema, resolveReadinessPolicy } from "./StackConfig.ts"; -import type { - AnalyticsConfig, - AuthConfig, - EdgeRuntimeConfig, - ImgproxyConfig, - MailpitConfig, - PgmetaConfig, - PoolerConfig, - PostgrestConfig, - RealtimeConfig, - ResolvedAnalyticsConfig, - ResolvedAuthConfig, - ResolvedEdgeRuntimeConfig, - ResolvedImgproxyConfig, - ResolvedMailpitConfig, - ResolvedPgmetaConfig, - ResolvedPoolerConfig, - ResolvedPostgrestConfig, - ResolvedRealtimeConfig, - ResolvedStackConfig, - ResolvedStorageConfig, - ResolvedStudioConfig, - ResolvedVectorConfig, - ServicePolicy, - ServicePolicyManifest, - StackConfig, - StorageConfig, - StudioConfig, - VectorConfig, -} from "./StackConfig.ts"; -import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; -import { - DEFAULT_SERVICE_POLICIES, - DEFAULT_VERSIONS, - SERVICE_CATALOG, - SERVICE_NAMES, - serviceMetadata, -} from "./ServiceCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; - -export interface ResolveConfigOptions { - /** Ports selected by the caller-owned lease. Resolution never allocates ports. */ - readonly ports: PortSet; - readonly stackRoot?: string; - readonly runtimeRoot?: string; - readonly runtime?: StackRuntimeSelection; -} - -interface ResolvedRoots { - readonly cacheRoot: string; - readonly stackRoot: string; - readonly runtimeRoot: string; - readonly autoManagedPaths: ReadonlyArray; -} - -const cleanupAutoManagedPaths = ( - paths: ReadonlyArray, -): Effect.Effect => - Effect.uninterruptible( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* Effect.forEach( - paths, - (path) => fs.remove(path, { recursive: true, force: true }).pipe(Effect.ignoreCause), - { discard: true }, - ); - }), - ); - -const tempRootError = (prefix: string, cause: PlatformError): StackBuildError => - new StackBuildError({ - detail: `Failed to create temporary ${prefix} directory`, - cause, - }); - -const makeTempRoot = ( - prefix: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs - .makeTempDirectory({ directory: shortTempPrefixRoot(), prefix }) - .pipe(Effect.mapError((cause) => tempRootError(prefix, cause))); - }); - -const resolveRoots = ( - config: StackConfig, - opts: ResolveConfigOptions, -): Effect.Effect => - Effect.gen(function* () { - const cacheRoot = config.cacheRoot ?? defaultCacheRoot(); - const autoManagedPaths: string[] = []; - const roots = yield* Effect.gen(function* () { - const makeTrackedTempRoot = (prefix: string) => - Effect.uninterruptibleMask((restore) => - restore(makeTempRoot(prefix)).pipe( - Effect.tap((dir) => Effect.sync(() => autoManagedPaths.push(dir))), - ), - ); - const stackRoot = - opts.stackRoot ?? config.stackRoot ?? (yield* makeTrackedTempRoot("sb-stack-")); - const runtimeRoot = - opts.runtimeRoot ?? config.runtimeRoot ?? (yield* makeTrackedTempRoot("sb-run-")); - - return { - cacheRoot, - stackRoot, - runtimeRoot, - autoManagedPaths, - }; - }).pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) ? Effect.void : cleanupAutoManagedPaths(autoManagedPaths), - ), - ); - return roots; - }); - -const resolveDataDir = ( - explicitDir: string | undefined, - stackRoot: string, - suffix: string, -): string => explicitDir ?? join(stackRoot, "data", suffix); - -const requiredPort = (ports: PortSet, field: PortField): Effect.Effect => { - const port = ports[field]; - if (port === undefined) { - return Effect.fail( - new StackBuildError({ - detail: `Missing resolved port for active field ${field}`, - reason: "invalid_config", - }), - ); - } - return Effect.succeed(port); -}; - -function resolvePostgrestConfig( - input: PostgrestConfig | undefined, - raw: PostgrestConfig | false | undefined, - ports: PortSet, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return Effect.all({ - port: requiredPort(ports, "postgrestPort"), - adminPort: requiredPort(ports, "postgrestAdminPort"), - }).pipe( - Effect.map(({ port, adminPort }) => ({ - port, - adminPort, - schemas: cfg.schemas ?? ["public", "graphql_public"], - extraSearchPath: cfg.extraSearchPath ?? ["public", "extensions"], - maxRows: cfg.maxRows ?? 1000, - version: cfg.version ?? DEFAULT_VERSIONS.postgrest, - })), - ); -} - -function resolveAuthConfig( - input: AuthConfig | undefined, - raw: AuthConfig | false | undefined, - ports: PortSet, - apiPort: number, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return requiredPort(ports, "authPort").pipe( - Effect.map((port) => ({ - port, - siteUrl: cfg.siteUrl ?? "http://localhost:3000", - jwtExpiry: cfg.jwtExpiry ?? 3600, - externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, - version: cfg.version ?? DEFAULT_VERSIONS.auth, - })), - ); -} - -function resolveRealtimeConfig( - input: RealtimeConfig | undefined, - raw: RealtimeConfig | false | undefined, - ports: PortSet, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return requiredPort(ports, "realtimePort").pipe( - Effect.map((port) => ({ - port, - version: cfg.version ?? DEFAULT_VERSIONS.realtime, - tenantId: cfg.tenantId ?? "realtime-dev", - encryptionKey: cfg.encryptionKey ?? "supabaserealtime", - secretKeyBase: - cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - maxHeaderLength: cfg.maxHeaderLength ?? 4096, - })), - ); -} - -function resolveEdgeRuntimeConfig( - input: EdgeRuntimeConfig | undefined, - raw: EdgeRuntimeConfig | false | undefined, - ports: PortSet, -): Effect.Effect { - if (raw === false || raw?.enabled === false) return Effect.succeed(false); - const cfg = input ?? {}; - return Effect.all({ - port: requiredPort(ports, "edgeRuntimePort"), - inspectorPort: requiredPort(ports, "edgeRuntimeInspectorPort"), - }).pipe( - Effect.map(({ port, inspectorPort }) => ({ - enabled: cfg.enabled ?? true, - port, - inspectorPort, - policy: cfg.policy ?? "per_worker", - version: cfg.version ?? DEFAULT_VERSIONS["edge-runtime"], - env: cfg.env ?? {}, - })), - ); -} - -function resolveFunctionsConfig( - config: StackConfig, - projectDir: string, -): Effect.Effect { - if (config.functions === undefined || config.functions === false) { - return Effect.succeed(false); - } - return Schema.decodeEffect(resolvedFunctionsBundleSchemaForProject(projectDir))( - config.functions, - ).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Invalid Edge Functions bundle", - cause, - reason: "invalid_config", - }), - ), - ); -} - -const resolveInstanceId = ( - instanceId: string | undefined, -): Effect.Effect => - instanceId === undefined - ? Effect.void.pipe(Effect.as(undefined)) - : Schema.decodeEffect(InstanceIdSchema)(instanceId).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: `Invalid instanceId: must match ${INSTANCE_ID_PATTERN}`, - cause, - reason: "invalid_config", - }), - ), - ); - -function resolveStorageConfig( - input: StorageConfig | undefined, - raw: StorageConfig | false | undefined, - ports: PortSet, - opts: ResolveConfigOptions, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return requiredPort(ports, "storagePort").pipe( - Effect.map((port) => ({ - port, - version: cfg.version ?? DEFAULT_VERSIONS.storage, - dataDir: resolveDataDir(cfg.dataDir, opts.stackRoot!, "storage"), - fileSizeLimit: cfg.fileSizeLimit ?? "50MiB", - s3ProtocolEnabled: cfg.s3ProtocolEnabled ?? true, - })), - ); -} - -function resolveImgproxyConfig( - input: ImgproxyConfig | undefined, - raw: ImgproxyConfig | false | undefined, - ports: PortSet, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return requiredPort(ports, "imgproxyPort").pipe( - Effect.map((port) => ({ - port, - version: cfg.version ?? DEFAULT_VERSIONS.imgproxy, - })), - ); -} - -function resolveMailpitConfig( - input: MailpitConfig | undefined, - raw: MailpitConfig | false | undefined, - ports: PortSet, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return Effect.all({ - port: requiredPort(ports, "mailpitPort"), - smtpPort: requiredPort(ports, "mailpitSmtpPort"), - pop3Port: requiredPort(ports, "mailpitPop3Port"), - }).pipe( - Effect.map(({ port, smtpPort, pop3Port }) => ({ - port, - smtpPort, - pop3Port, - version: cfg.version ?? DEFAULT_VERSIONS.mailpit, - adminEmail: cfg.adminEmail ?? "admin@email.com", - senderName: cfg.senderName ?? "Admin", - })), - ); -} - -function resolvePgmetaConfig( - input: PgmetaConfig | undefined, - raw: PgmetaConfig | false | undefined, - ports: PortSet, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return requiredPort(ports, "pgmetaPort").pipe( - Effect.map((port) => ({ - port, - version: cfg.version ?? DEFAULT_VERSIONS.pgmeta, - })), - ); -} - -function resolveStudioConfig( - input: StudioConfig | undefined, - raw: StudioConfig | false | undefined, - ports: PortSet, - apiPort: number, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return requiredPort(ports, "studioPort").pipe( - Effect.map((port) => ({ - port, - version: cfg.version ?? DEFAULT_VERSIONS.studio, - apiUrl: cfg.apiUrl ?? `http://127.0.0.1:${apiPort}`, - })), - ); -} - -function resolveAnalyticsConfig( - input: AnalyticsConfig | undefined, - raw: AnalyticsConfig | false | undefined, - ports: PortSet, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return requiredPort(ports, "analyticsPort").pipe( - Effect.map((port) => ({ - port, - version: cfg.version ?? DEFAULT_VERSIONS.analytics, - backend: cfg.backend ?? "postgres", - apiKey: cfg.apiKey ?? "api-key", - })), - ); -} - -function resolveVectorConfig( - input: VectorConfig | undefined, - raw: VectorConfig | false | undefined, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return Effect.succeed({ - version: cfg.version ?? DEFAULT_VERSIONS.vector, - }); -} - -function resolvePoolerConfig( - input: PoolerConfig | undefined, - raw: PoolerConfig | false | undefined, - ports: PortSet, -): Effect.Effect { - if (raw === false) return Effect.succeed(false); - const cfg = input ?? {}; - return Effect.all({ - port: requiredPort(ports, "poolerPort"), - apiPort: requiredPort(ports, "poolerApiPort"), - }).pipe( - Effect.map(({ port, apiPort }) => ({ - port, - apiPort, - mode: cfg.mode ?? "transaction", - version: cfg.version ?? DEFAULT_VERSIONS.pooler, - tenantId: cfg.tenantId ?? "pooler-dev", - encryptionKey: cfg.encryptionKey ?? "12345678901234567890123456789032", - secretKeyBase: - cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - defaultPoolSize: cfg.defaultPoolSize ?? 20, - maxClientConn: cfg.maxClientConn ?? 100, - })), - ); -} - -const enabledServiceConfig = ( - enabled: boolean, - config: Config | false | undefined, -): Config | undefined => (enabled && config !== false ? config : undefined); - -export const rawServiceEnabled = (config: StackConfig, service: ServiceName): boolean => { - switch (service) { - case "postgres": - return true; - case "postgrest": - return config.postgrest !== false; - case "auth": - return config.auth !== false; - case "edge-runtime": - return ( - ((config.mode ?? "native") !== "native" || config.edgeRuntime !== undefined) && - config.edgeRuntime !== false && - (config.edgeRuntime?.enabled ?? true) !== false - ); - case "realtime": - return config.realtime !== undefined && config.realtime !== false; - case "storage": - return config.storage !== undefined && config.storage !== false; - case "imgproxy": - return config.imgproxy !== undefined && config.imgproxy !== false; - case "mailpit": - return config.mailpit !== undefined && config.mailpit !== false; - case "pgmeta": - return config.pgmeta !== undefined && config.pgmeta !== false; - case "studio": - return config.studio !== undefined && config.studio !== false; - case "analytics": - return config.analytics !== undefined && config.analytics !== false; - case "vector": - return config.vector !== undefined && config.vector !== false; - case "pooler": - return config.pooler !== undefined && config.pooler !== false; - } -}; - -const preparationPolicyRank: Readonly> = { - off: 0, - lazy: 1, - eager: 2, -}; - -/** - * Resolve policy declarations before roots, ports, or config-dependent effects - * are acquired. This keeps unsupported policies a pure user/configuration error. - */ -const resolveServicePolicies = ( - config: StackConfig, -): Effect.Effect => - Effect.gen(function* () { - const policies: Record = Record.map(SERVICE_CATALOG, () => "off"); - const requestedPolicies = config.servicePolicies ?? {}; - for (const service of SERVICE_NAMES) { - const requested = requestedPolicies[service]; - if (service === "postgres" && requested !== undefined && requested !== "eager") { - return yield* new StackBuildError({ - detail: "postgres supports only the eager service preparation policy", - reason: "invalid_config", - }); - } - - const enabled = rawServiceEnabled(config, service); - if (!enabled && requested !== undefined && requested !== "off") { - return yield* new StackBuildError({ - detail: `${service} cannot use the ${requested} service preparation policy because the service is not configured`, - reason: "invalid_config", - }); - } - if (!enabled || requested === "off") { - policies[service] = "off"; - continue; - } - - const policy: Exclude = - requested === undefined ? DEFAULT_SERVICE_POLICIES[service] : requested; - if (!serviceMetadata(service).preparation.supported.includes(policy)) { - return yield* new StackBuildError({ - detail: `${service} does not support the ${policy} service preparation policy`, - reason: "invalid_config", - }); - } - policies[service] = policy; - } - - let promoted = true; - while (promoted) { - promoted = false; - for (const service of SERVICE_NAMES) { - const policy = policies[service]; - if (policy === "off") continue; - for (const dependency of serviceMetadata(service).activation.activates) { - const dependencyPolicy = policies[dependency]; - if ( - dependencyPolicy === "off" || - preparationPolicyRank[dependencyPolicy] <= preparationPolicyRank[policy] - ) { - continue; - } - if (requestedPolicies[service] !== undefined) { - return yield* new StackBuildError({ - detail: `${dependency} uses the ${dependencyPolicy} preparation policy but requires ${service} to be at least ${dependencyPolicy}`, - reason: "invalid_config", - }); - } - policies[service] = dependencyPolicy; - promoted = true; - } - } - } - return policies; - }); - -export interface PortRequestOptions { - readonly preferredPorts?: PortSet; - readonly runtime?: StackRuntimeSelection; -} - -/** - * Validate the allocation-relevant parts of a stack configuration and return - * exact requests before automatic requests. The helper is allocation-free; - * callers reserve the returned requests and pass the resulting ports into - * `resolveConfig`. - */ -export const portRequestsForConfig = ( - input: StackConfig = {}, - options: PortRequestOptions = {}, -): Effect.Effect, StackBuildError> => - Effect.gen(function* () { - if ( - input.mode !== undefined && - options.runtime !== undefined && - input.mode !== options.runtime.mode - ) { - return yield* new StackBuildError({ - detail: `Selected ${options.runtime.mode} runtime does not match requested ${input.mode} mode`, - reason: "invalid_config", - }); - } - const mode = options.runtime?.mode ?? input.mode ?? "native"; - const config: StackConfig = { ...input, mode }; - if (mode === "docker" && options.runtime?.containerRuntime == null) { - return yield* new StackBuildError({ - detail: "Docker mode requires a selected Docker or Podman runtime", - reason: "invalid_config", - }); - } - - // Deliberately first: unsupported policies and invalid explicit ports must - // fail before a caller acquires any OS resource. - yield* resolveServicePolicies(config); - const postgresInput = config.postgres ?? {}; - const authInput = config.auth !== false ? (config.auth ?? undefined) : undefined; - const edgeRuntimeInput = config.edgeRuntime !== false ? config.edgeRuntime : undefined; - const realtimeInput = config.realtime !== false ? (config.realtime ?? undefined) : undefined; - const storageInput = config.storage !== false ? (config.storage ?? undefined) : undefined; - const imgproxyInput = config.imgproxy !== false ? (config.imgproxy ?? undefined) : undefined; - const mailpitInput = config.mailpit !== false ? (config.mailpit ?? undefined) : undefined; - const pgmetaInput = config.pgmeta !== false ? (config.pgmeta ?? undefined) : undefined; - const studioInput = config.studio !== false ? (config.studio ?? undefined) : undefined; - const analyticsInput = config.analytics !== false ? (config.analytics ?? undefined) : undefined; - const poolerInput = config.pooler !== false ? (config.pooler ?? undefined) : undefined; - const explicitPortForField = (field: PortField): number | undefined => { - switch (field) { - case "apiPort": - return config.port; - case "dbPort": - return postgresInput.port; - case "authPort": - return authInput?.port; - case "edgeRuntimePort": - return edgeRuntimeInput?.port; - case "edgeRuntimeInspectorPort": - return edgeRuntimeInput?.inspectorPort; - case "realtimePort": - return realtimeInput?.port; - case "storagePort": - return storageInput?.port; - case "imgproxyPort": - return imgproxyInput?.port; - case "mailpitPort": - return mailpitInput?.port; - case "mailpitSmtpPort": - return mailpitInput?.smtpPort; - case "mailpitPop3Port": - return mailpitInput?.pop3Port; - case "pgmetaPort": - return pgmetaInput?.port; - case "studioPort": - return studioInput?.port; - case "analyticsPort": - return analyticsInput?.port; - case "poolerPort": - return poolerInput?.port; - case "poolerApiPort": - return poolerInput?.apiPort; - case "postgrestPort": - case "postgrestAdminPort": - return undefined; - } - }; - const activeFields = portFieldsForConfigInput(config); - for (const field of activeFields) { - const explicit = explicitPortForField(field); - if ( - explicit !== undefined && - (!Number.isInteger(explicit) || explicit < 1 || explicit > 65_535) - ) { - return yield* new StackBuildError({ - detail: `Invalid port for ${field}: expected an integer between 1 and 65535`, - reason: "invalid_config", - }); - } - } - const unorderedRequests = activeFields.map((field) => { - const explicit = explicitPortForField(field); - if (explicit !== undefined) { - return { field, selection: { kind: "exact", port: explicit } } as const; - } - const preferred = options.preferredPorts?.[field] ?? PORT_CATALOG[field].preferred; - return preferred === undefined - ? ({ field, selection: { kind: "automatic" } } as const) - : ({ field, selection: { kind: "automatic", preferred } } as const); - }); - return [ - ...unorderedRequests.filter((request) => request.selection.kind === "exact"), - ...unorderedRequests.filter((request) => request.selection.kind === "automatic"), - ]; - }); - -export function resolveConfig( - input: StackConfig | undefined, - opts: ResolveConfigOptions, -): Effect.Effect { - return Effect.suspend(() => { - let roots: ResolvedRoots | undefined; - const cleanup = () => - roots === undefined ? Effect.void : cleanupAutoManagedPaths(roots.autoManagedPaths); - - return Effect.gen(function* () { - const inputConfig = input ?? {}; - const runtime: StackRuntimeSelection = opts.runtime ?? { - mode: "native", - containerRuntime: null, - }; - const config: StackConfig = { ...inputConfig, mode: runtime.mode }; - // Deliberately first: unsupported policies must not create roots or reserve ports. - const servicePolicies = yield* resolveServicePolicies(config); - for (const field of portFieldsForConfigInput(config)) { - if (opts.ports[field] === undefined) { - return yield* new StackBuildError({ - detail: `Missing resolved port for active field ${field}`, - reason: "invalid_config", - }); - } - } - const projectDir = config.projectDir ?? process.cwd(); - const instanceId = yield* resolveInstanceId(config.instanceId); - const functions = yield* resolveFunctionsConfig(config, projectDir); - const edgeRuntimeEnabled = servicePolicies["edge-runtime"] !== "off"; - if (functions !== false && !edgeRuntimeEnabled) { - return yield* new StackBuildError({ - detail: "Edge Functions require Edge Runtime to be enabled", - reason: "invalid_config", - }); - } - roots = yield* resolveRoots(config, opts); - const postgresInput = config.postgres ?? {}; - const postgrestInput = - servicePolicies.postgrest !== "off" && config.postgrest !== false - ? (config.postgrest ?? undefined) - : undefined; - const authInput = - servicePolicies.auth !== "off" && config.auth !== false - ? (config.auth ?? undefined) - : undefined; - const realtimeEnabled = servicePolicies.realtime !== "off"; - const storageEnabled = servicePolicies.storage !== "off"; - const imgproxyEnabled = servicePolicies.imgproxy !== "off"; - const mailpitEnabled = servicePolicies.mailpit !== "off"; - const pgmetaEnabled = servicePolicies.pgmeta !== "off"; - const studioEnabled = servicePolicies.studio !== "off"; - const analyticsEnabled = servicePolicies.analytics !== "off"; - const vectorEnabled = servicePolicies.vector !== "off"; - const poolerEnabled = servicePolicies.pooler !== "off"; - const edgeRuntimeInput = enabledServiceConfig(edgeRuntimeEnabled, config.edgeRuntime); - const realtimeInput = enabledServiceConfig(realtimeEnabled, config.realtime); - const storageInput = enabledServiceConfig(storageEnabled, config.storage); - const imgproxyInput = enabledServiceConfig(imgproxyEnabled, config.imgproxy); - const mailpitInput = enabledServiceConfig(mailpitEnabled, config.mailpit); - const pgmetaInput = enabledServiceConfig(pgmetaEnabled, config.pgmeta); - const studioInput = enabledServiceConfig(studioEnabled, config.studio); - const analyticsInput = enabledServiceConfig(analyticsEnabled, config.analytics); - const vectorInput = enabledServiceConfig(vectorEnabled, config.vector); - const poolerInput = enabledServiceConfig(poolerEnabled, config.pooler); - - const postgresDataDir = resolveDataDir(postgresInput.dataDir, roots.stackRoot, "postgres"); - - // Port selection is owned by the caller. Resolve the provided lease result only. - const ports = opts.ports; - - const jwtSecret = config.jwtSecret ?? defaultJwtSecret; - const anonJwt = generateJwt(jwtSecret, "anon"); - const serviceRoleJwt = generateJwt(jwtSecret, "service_role"); - const apiPort = yield* requiredPort(ports, "apiPort"); - const dbPort = yield* requiredPort(ports, "dbPort"); - const resolvedPorts: ResolvedPorts = { ...ports, apiPort, dbPort }; - - return { - instanceId, - cacheRoot: roots.cacheRoot, - stackRoot: roots.stackRoot, - runtimeRoot: roots.runtimeRoot, - projectDir, - runtime, - servicePolicies, - readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), - readinessSource: - config.readiness === undefined ? ("default" as const) : ("configured" as const), - jwtSecret, - ports: resolvedPorts, - apiPort, - dbPort, - publishableKey: config.publishableKey ?? defaultPublishableKey, - secretKey: config.secretKey ?? defaultSecretKey, - functions, - autoManagedPaths: roots.autoManagedPaths, - anonJwt, - serviceRoleJwt, - postgres: { - port: dbPort, - dataDir: postgresDataDir, - version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, - autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, - }, - postgrest: yield* resolvePostgrestConfig( - postgrestInput, - servicePolicies.postgrest === "off" ? false : config.postgrest, - ports, - ), - auth: yield* resolveAuthConfig( - authInput, - servicePolicies.auth === "off" ? false : config.auth, - ports, - apiPort, - ), - edgeRuntime: edgeRuntimeEnabled - ? yield* resolveEdgeRuntimeConfig(edgeRuntimeInput, config.edgeRuntime, ports) - : false, - realtime: realtimeEnabled - ? yield* resolveRealtimeConfig(realtimeInput, config.realtime, ports) - : false, - storage: storageEnabled - ? yield* resolveStorageConfig(storageInput, config.storage, ports, { - ...opts, - stackRoot: roots.stackRoot, - }) - : false, - imgproxy: imgproxyEnabled - ? yield* resolveImgproxyConfig(imgproxyInput, config.imgproxy, ports) - : false, - mailpit: mailpitEnabled - ? yield* resolveMailpitConfig(mailpitInput, config.mailpit, ports) - : false, - pgmeta: pgmetaEnabled - ? yield* resolvePgmetaConfig(pgmetaInput, config.pgmeta, ports) - : false, - studio: studioEnabled - ? yield* resolveStudioConfig(studioInput, config.studio, ports, apiPort) - : false, - analytics: analyticsEnabled - ? yield* resolveAnalyticsConfig(analyticsInput, config.analytics, ports) - : false, - vector: vectorEnabled ? yield* resolveVectorConfig(vectorInput, config.vector) : false, - pooler: poolerEnabled - ? yield* resolvePoolerConfig(poolerInput, config.pooler, ports) - : false, - }; - }).pipe( - Effect.catchDefect((cause) => - cause instanceof StackBuildError ? Effect.fail(cause) : Effect.die(cause), - ), - Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : cleanup())), - ); - }); -} - -export type DaemonConfigInput = Omit & { - readonly cwd: string; - readonly name?: string; - readonly projectDir?: string; -}; - -export function sanitizeDaemonConfigInput( - input: DaemonConfigInput & { readonly functions?: unknown }, -): DaemonConfigInput { - const { functions: _functions, ...config } = input; - return config; -} diff --git a/packages/stack/src/StackIdentity.ts b/packages/stack/src/StackIdentity.ts deleted file mode 100644 index 89e37330df..0000000000 --- a/packages/stack/src/StackIdentity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ServiceName } from "./ServiceName.ts"; -import type { ResolvedStackConfig } from "./StackConfig.ts"; - -/** - * What tells one running stack's Docker resources apart from every other one's. - * - * A stack that was given an identity is keyed by a namespaced form of it; one - * that was not falls back to its api port, which is what names were always - * built from. The fallback is what keeps the direct ephemeral API unchanged, - * and the identity is what stops two stacks that happen to share a port — a - * crashed one's leftovers and the sibling that reused its port — from - * colliding on the same container names. - */ -export interface StackIdentity { - /** What container names are built from, in a disjoint namespace per identity kind. */ - readonly key: string; - /** The identity the stack was given, when it was given one. */ - readonly stackId: string | undefined; -} - -/** - * The Docker label attached when a caller supplies an identified stack. A - * managed CLI caller can use it as a stable cleanup key when that integration - * passes its UUID through; current cleanup remains name-based. - */ -export const STACK_ID_LABEL = "com.supabase.stack-id"; - -const EXPLICIT_IDENTITY_PREFIX = "id-"; - -/** The one place a stack's identity is derived from its config. */ -export const stackIdentity = ( - config: Pick, -): StackIdentity => ({ - key: - config.instanceId === undefined - ? String(config.apiPort) - : `${EXPLICIT_IDENTITY_PREFIX}${config.instanceId}`, - stackId: config.instanceId, -}); - -export const dockerContainerName = (service: ServiceName, instanceKey: string): string => - `supabase-${service}-${instanceKey}`; diff --git a/packages/stack/src/StackIdentity.unit.test.ts b/packages/stack/src/StackIdentity.unit.test.ts deleted file mode 100644 index daa1670da2..0000000000 --- a/packages/stack/src/StackIdentity.unit.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { stackIdentity } from "./StackIdentity.ts"; - -describe("stackIdentity", () => { - it("keeps explicit identities separate from port-derived names", () => { - const portIdentity = stackIdentity({ apiPort: 54_321 }); - const explicitIdentity = stackIdentity({ apiPort: 12_345, instanceId: "54321" }); - - expect(explicitIdentity).toEqual({ key: "id-54321", stackId: "54321" }); - expect(portIdentity).toEqual({ key: "54321", stackId: undefined }); - }); -}); diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts deleted file mode 100644 index 18da3672e5..0000000000 --- a/packages/stack/src/StackPreparation.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { Context, Data, Deferred, Duration, Effect, Layer, Queue, Schedule, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { BinaryResolver } from "./BinaryResolver.ts"; -import type { ContainerRuntime } from "./ContainerRuntime.ts"; -import type { - BinaryHostCompatibilityError, - BinaryManifestError, - BinaryRuntimeError, - ChecksumMismatchError, - DownloadError, -} from "./errors.ts"; -import { BinaryNotFoundError, DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; -import { isDockerOnlyService, requiredPreparationDependencies } from "./ServiceCatalog.ts"; -import { - DEFAULT_VERSIONS, - SERVICE_NAMES, - dockerImageForService, - normalizeServiceVersions, - type ServiceName, - type VersionManifest, -} from "./versions.ts"; - -export interface PreparedStackArtifacts { - readonly resolutions: Partial>; -} - -export type ServiceResolution = - | { readonly type: "binary"; readonly path: string } - | { readonly type: "docker"; readonly image: string }; - -interface StackPreparationOptions { - readonly versions?: Partial; - readonly services?: ReadonlyArray; - readonly enabledServices?: ReadonlyArray; -} - -export type StackPreparationInput = StackPreparationOptions & - ( - | { readonly mode: "native"; readonly containerRuntime?: never } - | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime } - ); - -export type StackPreparationError = - | BinaryNotFoundError - | DownloadError - | ChecksumMismatchError - | BinaryManifestError - | BinaryRuntimeError - | BinaryHostCompatibilityError - | DockerPullError; - -export class ServiceDownloadStarted extends Data.TaggedClass("ServiceDownloadStarted")<{ - readonly service: ServiceName; -}> {} - -export class ServiceDownloadFinished extends Data.TaggedClass("ServiceDownloadFinished")<{ - readonly service: ServiceName; -}> {} - -export class PreparationCompleted extends Data.TaggedClass("PreparationCompleted")<{ - readonly artifacts: PreparedStackArtifacts; -}> {} - -export type StackPreparationEvent = - | ServiceDownloadStarted - | ServiceDownloadFinished - | PreparationCompleted; - -const RETRYABLE_PULL_PATTERNS = [ - /toomanyrequests/i, - /rate exceeded/i, - /429\b/i, - /timeout/i, - /temporarily unavailable/i, - /temporary failure/i, - /connection reset/i, - /tls handshake timeout/i, - /i\/o timeout/i, -] as const; - -class PullAttemptError extends Data.TaggedError("PullAttemptError")<{ - readonly detail: string; - readonly daemonDown: boolean; -}> {} - -const pullRetrySchedule = Schedule.exponential(Duration.seconds(1)).pipe( - Schedule.upTo({ times: 5 }), -); - -const resolveDockerImageForService = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - runtime: ContainerRuntime, - service: ServiceName, - version: string, - callbacks?: { - readonly onDownloadStart?: Effect.Effect; - }, -): Effect.Effect => - pullImage(spawner, runtime, dockerImageForService(service, version), callbacks); - -export const preparationClosure = ( - services: ReadonlyArray, - enabledServices?: ReadonlyArray, -): ReadonlyArray => { - const enabled = enabledServices === undefined ? undefined : new Set(enabledServices); - const closure = new Set(); - const add = (service: ServiceName): void => { - if (enabled !== undefined && !enabled.has(service)) return; - if (closure.has(service)) return; - closure.add(service); - for (const dependency of requiredPreparationDependencies(service)) add(dependency); - }; - for (const service of services) add(service); - return [...closure]; -}; - -const selectedServices = (input: StackPreparationInput): ReadonlyArray => { - const defaults = - input.mode === "docker" - ? SERVICE_NAMES - : SERVICE_NAMES.filter((service) => !isDockerOnlyService(service)); - return preparationClosure(input.services ?? defaults, input.enabledServices); -}; - -const versionsForInput = (input: StackPreparationInput): VersionManifest => ({ - ...DEFAULT_VERSIONS, - ...normalizeServiceVersions(input.versions ?? {}), -}); - -const plannedResolution = ( - resolver: BinaryResolver["Service"], - service: ServiceName, - version: string, - mode: "native" | "docker", -): Effect.Effect => { - if (mode === "docker") { - return Effect.succeed({ - type: "docker", - image: dockerImageForService(service, version), - }); - } - if (isDockerOnlyService(service)) { - return Effect.fail(new BinaryNotFoundError({ service, platform: "native" })); - } - return resolver - .plan({ service, version }) - .pipe(Effect.map((path): ServiceResolution => ({ type: "binary", path }))); -}; - -const planAssetsWithDependencies = ( - resolver: BinaryResolver["Service"], - input: StackPreparationInput, - versions = versionsForInput(input), -): Effect.Effect => - Effect.gen(function* () { - const services = selectedServices(input); - const results = yield* Effect.all( - services.map((service) => - plannedResolution(resolver, service, versions[service], input.mode).pipe( - Effect.map((resolution) => [service, resolution] as const), - ), - ), - { concurrency: "unbounded" }, - ); - return { - resolutions: Object.fromEntries(results), - } satisfies PreparedStackArtifacts; - }); - -export class StackPreparation extends Context.Service< - StackPreparation, - { - readonly plan: ( - input: StackPreparationInput, - ) => Effect.Effect; - readonly prepare: ( - input: StackPreparationInput, - ) => Effect.Effect; - readonly prepareEvents: ( - input: StackPreparationInput, - ) => Stream.Stream; - } ->()("stack/StackPreparation") { - static layer: Layer.Layer< - StackPreparation, - never, - BinaryResolver | ChildProcessSpawner.ChildProcessSpawner - > = Layer.effect( - this, - Effect.gen(function* () { - const resolver = yield* BinaryResolver; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const scope = yield* Effect.scope; - const inFlight = new Map< - string, - Deferred.Deferred - >(); - - const materialize = ( - service: ServiceName, - resolution: ServiceResolution, - version: string, - input: StackPreparationInput, - publishEvent?: (event: StackPreparationEvent) => Effect.Effect, - ): Effect.Effect => - Effect.uninterruptibleMask((restore) => - Effect.suspend(() => { - let downloadStarted = false; - const markDownloadStart = () => - Effect.sync(() => { - downloadStarted = true; - }).pipe( - Effect.andThen( - publishEvent?.(new ServiceDownloadStarted({ service })) ?? Effect.void, - ), - ); - const markDownloadFinished = () => - Effect.suspend(() => - downloadStarted - ? (publishEvent?.(new ServiceDownloadFinished({ service })) ?? Effect.void) - : Effect.void, - ); - const key = [ - service, - resolution.type, - resolution.type === "docker" ? resolution.image : resolution.path, - input.mode === "docker" ? input.containerRuntime : "native", - ].join("\u0000"); - const existing = inFlight.get(key); - if (existing !== undefined) return restore(Deferred.await(existing)); - const deferred = Deferred.makeUnsafe(); - inFlight.set(key, deferred); - const effect: Effect.Effect = - resolution.type === "docker" - ? input.mode === "docker" - ? resolveDockerImageForService( - spawner, - input.containerRuntime, - service, - version, - { - onDownloadStart: markDownloadStart(), - }, - ).pipe(Effect.map((image): ServiceResolution => ({ type: "docker", image }))) - : Effect.die("Native preparation planned a Docker resolution") - : resolver - .resolveWithMetadata( - { service, version }, - { - onDownloadStart: markDownloadStart(), - }, - ) - .pipe(Effect.map(({ path }): ServiceResolution => ({ type: "binary", path }))); - const coordinated = effect.pipe( - Effect.matchCauseEffect({ - onSuccess: (value) => - Effect.andThen(markDownloadFinished(), Deferred.succeed(deferred, value)), - onFailure: (cause) => Deferred.failCause(deferred, cause), - }), - Effect.ensuring(Effect.sync(() => inFlight.delete(key))), - ); - return Effect.gen(function* () { - yield* Effect.forkIn(coordinated, scope, { startImmediately: true }); - return yield* restore(Deferred.await(deferred)); - }); - }), - ); - - const prepareWithEvents = ( - input: StackPreparationInput, - publishEvent?: (event: StackPreparationEvent) => Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - const versions = versionsForInput(input); - const planned = yield* planAssetsWithDependencies(resolver, input, versions); - const entries = yield* Effect.all( - selectedServices(input).map((service) => { - const resolution = planned.resolutions[service]; - if (resolution === undefined) return Effect.die(`Missing plan for ${service}`); - return materialize(service, resolution, versions[service], input, publishEvent).pipe( - Effect.map((resolved) => [service, resolved] as const), - ); - }), - { concurrency: 4 }, - ); - const artifacts = { - resolutions: Object.fromEntries(entries), - } satisfies PreparedStackArtifacts; - yield* publishEvent?.(new PreparationCompleted({ artifacts })) ?? Effect.void; - return artifacts; - }); - - return { - plan: (input: StackPreparationInput) => planAssetsWithDependencies(resolver, input), - prepare: (input: StackPreparationInput) => prepareWithEvents(input), - prepareEvents: (input: StackPreparationInput) => - Stream.callback((queue) => - prepareWithEvents(input, (event) => Queue.offer(queue, event)).pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => Queue.end(queue), - }), - Effect.forkScoped, - ), - ), - }; - }), - ); -} - -const pullImage = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - runtime: ContainerRuntime, - image: string, - callbacks?: { - readonly onDownloadStart?: Effect.Effect; - }, -): Effect.Effect => - Effect.gen(function* () { - if (yield* hasLocalDockerImage(spawner, runtime, image)) { - return image; - } - - yield* callbacks?.onDownloadStart ?? Effect.void; - - return yield* runPullCommand(spawner, runtime, image).pipe( - Effect.retry({ - while: (error) => shouldRetryPull(error.detail), - schedule: pullRetrySchedule, - }), - Effect.as(image), - Effect.mapError( - (failure) => - new DockerPullError({ - image, - detail: `Failed to pull canonical Docker image. ${failure.detail}`, - cause: new Error(failure.detail), - daemonDown: failure.daemonDown, - }), - ), - ); - }); - -const runPullCommand = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - runtime: ContainerRuntime, - image: string, -): Effect.Effect<{ readonly exitCode: number; readonly stderr: string }, PullAttemptError> => - Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(runtime, ["pull", image])); - const [stderr, exitCode] = yield* Effect.all( - [collectStreamAsString(child.stderr), child.exitCode.pipe(Effect.map(Number))], - { concurrency: "unbounded" }, - ); - const result = { - exitCode, - stderr: stderr.trim(), - }; - if (result.exitCode !== 0) { - const detail = - result.stderr.length > 0 - ? result.stderr - : `${runtime} pull exited with code ${result.exitCode}`; - return yield* new PullAttemptError({ - detail, - daemonDown: isDockerDaemonDownMessage(detail), - }); - } - return result; - }).pipe( - Effect.scoped, - Effect.catchTag("PlatformError", (error) => - Effect.fail(new PullAttemptError({ detail: String(error), daemonDown: true })), - ), - ); - -const hasLocalDockerImage = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - runtime: ContainerRuntime, - image: string, -): Effect.Effect => - spawner.exitCode(ChildProcess.make(runtime, ["image", "inspect", image])).pipe( - Effect.map((exitCode) => exitCode === 0), - Effect.catchTag("PlatformError", () => Effect.succeed(false)), - ); - -const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => - Stream.runFold( - stream, - () => "", - (acc, chunk) => acc + new TextDecoder().decode(chunk), - ); - -function shouldRetryPull(message: string): boolean { - return RETRYABLE_PULL_PATTERNS.some((pattern) => pattern.test(message)); -} diff --git a/packages/stack/src/StackRpc.integration.test.ts b/packages/stack/src/StackRpc.integration.test.ts deleted file mode 100644 index a62c3ed071..0000000000 --- a/packages/stack/src/StackRpc.integration.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { matchesStackRpcFence, StackRpc, STACK_RPC_PATH } from "./StackRpc.ts"; - -describe("Stack RPC contract", () => { - it("defines one shared runtime contract at the stable rpc endpoint", () => { - expect(STACK_RPC_PATH).toBe("/rpc"); - expect([...StackRpc.requests.keys()]).toEqual([ - "GetInfo", - "StartStack", - "StartService", - "StopService", - "RestartService", - "WaitStackReady", - "WaitServiceReady", - "ReloadFunctions", - "ReloadEdgeRuntime", - "UpdateLaunch", - "GetServiceState", - "GetAllServiceStates", - "WatchServiceStates", - "GetLogHistory", - "WatchLogs", - ]); - }); - - it("rejects incomplete fences even when expected values are empty", () => { - const expected = { ownershipId: "", ownerSessionId: "" }; - - expect(matchesStackRpcFence({ "x-supabase-stack-ownership-id": "" }, expected)).toBe(false); - expect(matchesStackRpcFence({ "x-supabase-stack-owner-session-id": "" }, expected)).toBe(false); - }); -}); diff --git a/packages/stack/src/StackRpc.ts b/packages/stack/src/StackRpc.ts deleted file mode 100644 index 8d7d58530a..0000000000 --- a/packages/stack/src/StackRpc.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { Schema, SchemaTransformation } from "effect"; -import { Rpc, RpcGroup } from "effect/unstable/rpc"; -import { ServiceNotFoundError, ServiceReadyError } from "@supabase/process-compose"; -import { - StackBuildError, - StackNotRunningError, - StackReadinessError, - StackUnavailableError, -} from "./errors.ts"; -import { StackInfoSchema } from "./Stack.ts"; -import { StackServiceStatusSchema } from "./StackServiceState.ts"; -import { ResolvedFunctionsBundleSchema } from "./functions.ts"; -import { ReadyOptionsSchema } from "./StackConfig.ts"; -import { matchesControlSession, type ControlSessionFence } from "./DaemonProtocol.ts"; - -/** Headers that fence same-version RPC calls to one observed supervisor session. */ -const STACK_RPC_FENCE_HEADERS = { - ownershipId: "x-supabase-stack-ownership-id", - ownerSessionId: "x-supabase-stack-owner-session-id", -} as const; - -export type StackRpcFence = ControlSessionFence; - -export const stackRpcFenceHeaders = (fence: StackRpcFence): Readonly> => ({ - [STACK_RPC_FENCE_HEADERS.ownershipId]: fence.ownershipId, - [STACK_RPC_FENCE_HEADERS.ownerSessionId]: fence.ownerSessionId, -}); - -export const matchesStackRpcFence = ( - headers: Readonly>, - expected: StackRpcFence, -): boolean => { - const ownershipId = headers[STACK_RPC_FENCE_HEADERS.ownershipId]; - const ownerSessionId = headers[STACK_RPC_FENCE_HEADERS.ownerSessionId]; - if (ownershipId === undefined || ownerSessionId === undefined) return false; - return matchesControlSession({ ownershipId, ownerSessionId }, expected); -}; - -const StackUnavailableErrorSchema = Schema.TaggedStruct("StackUnavailableError", { - phase: Schema.Literals(["starting", "stopping", "failed"]), - detail: Schema.optionalKey(Schema.String), -}).pipe( - Schema.decodeTo( - Schema.instanceOf(StackUnavailableError), - SchemaTransformation.transform({ - decode: (value) => new StackUnavailableError(value), - encode: (value) => value, - }), - ), -); - -const ServiceNotFoundErrorSchema = Schema.TaggedStruct("ServiceNotFoundError", { - name: Schema.String, -}).pipe( - Schema.decodeTo( - Schema.instanceOf(ServiceNotFoundError), - SchemaTransformation.transform({ - decode: (value) => new ServiceNotFoundError(value), - encode: (value) => value, - }), - ), -); - -const ServiceReadyErrorSchema = Schema.TaggedStruct("ServiceReadyError", { - name: Schema.String, - reason: Schema.String, - exitCode: Schema.optionalKey(Schema.Finite), -}).pipe( - Schema.decodeTo( - Schema.instanceOf(ServiceReadyError), - SchemaTransformation.transform({ - decode: (value) => new ServiceReadyError(value), - encode: (value) => value, - }), - ), -); - -const StackBuildErrorSchema = Schema.TaggedStruct("StackBuildError", { - detail: Schema.String, - reason: Schema.optionalKey( - Schema.Literals(["invalid_config", "docker_not_running", "asset_preparation"]), - ), -}).pipe( - Schema.decodeTo( - Schema.instanceOf(StackBuildError), - SchemaTransformation.transform({ - decode: (value) => new StackBuildError(value), - encode: (value) => value, - }), - ), -); - -const StackNotRunningErrorSchema = Schema.TaggedStruct("StackNotRunningError", { - phase: Schema.String, -}).pipe( - Schema.decodeTo( - Schema.instanceOf(StackNotRunningError), - SchemaTransformation.transform({ - decode: (value) => new StackNotRunningError(value), - encode: (value) => value, - }), - ), -); - -const StackReadinessErrorSchema = Schema.TaggedStruct("StackReadinessError", { - target: Schema.String, - timeoutMs: Schema.Finite, - detail: Schema.String, -}).pipe( - Schema.decodeTo( - Schema.instanceOf(StackReadinessError), - SchemaTransformation.transform({ - decode: (value) => new StackReadinessError(value), - encode: (value) => value, - }), - ), -); - -const buildReadyErrors = Schema.Union([ - StackUnavailableErrorSchema, - ServiceReadyErrorSchema, - StackBuildErrorSchema, - StackReadinessErrorSchema, -]); -const serviceReadyErrors = Schema.Union([ - StackUnavailableErrorSchema, - ServiceNotFoundErrorSchema, - ServiceReadyErrorSchema, - StackBuildErrorSchema, - StackReadinessErrorSchema, -]); -const serviceMutatingErrors = Schema.Union([ - StackUnavailableErrorSchema, - ServiceNotFoundErrorSchema, - ServiceReadyErrorSchema, - StackBuildErrorSchema, - StackNotRunningErrorSchema, - StackReadinessErrorSchema, -]); -const stopServiceErrors = Schema.Union([ - StackUnavailableErrorSchema, - ServiceNotFoundErrorSchema, - StackBuildErrorSchema, - StackNotRunningErrorSchema, -]); -const serviceStateErrors = Schema.Union([StackUnavailableErrorSchema, ServiceNotFoundErrorSchema]); -const updateLaunchErrors = Schema.Union([StackUnavailableErrorSchema, StackBuildErrorSchema]); - -const StackServiceStateSchema = Schema.Struct({ - name: Schema.String, - status: StackServiceStatusSchema, - pid: Schema.NullOr(Schema.Finite), - exitCode: Schema.NullOr(Schema.Finite), - restartCount: Schema.Finite, - startedAt: Schema.NullOr(Schema.Finite), - error: Schema.NullOr(Schema.String), -}); - -const StackLogEntrySchema = Schema.Struct({ - timestamp: Schema.Finite, - service: Schema.String, - stream: Schema.Union([Schema.Literal("stdout"), Schema.Literal("stderr")]), - line: Schema.String, -}); - -const ReadyOptionsRpcSchema = ReadyOptionsSchema; - -const EdgeRuntimeReloadRpcSchema = Schema.Struct({ - edgeRuntime: Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - inspectorPort: Schema.optionalKey(Schema.Finite), - policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"])), - env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), - }), - functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), -}); - -const StackLaunchUpdateRpcSchema = Schema.Struct({ - versions: Schema.Record(Schema.String, Schema.String), - excludedServices: Schema.optionalKey(Schema.Array(Schema.String)), - lastNotifiedUpdateFingerprint: Schema.optionalKey(Schema.String), -}); -export type StackLaunchUpdateRpc = typeof StackLaunchUpdateRpcSchema.Type; - -/** One same-version RPC contract for every runtime operation. */ -export const StackRpc = RpcGroup.make( - Rpc.make("GetInfo", { success: StackInfoSchema, error: StackUnavailableErrorSchema }), - Rpc.make("StartStack", { success: Schema.Void, error: buildReadyErrors }), - Rpc.make("StartService", { - payload: { name: Schema.String }, - success: Schema.Void, - error: serviceMutatingErrors, - }), - Rpc.make("StopService", { - payload: { name: Schema.String }, - success: Schema.Void, - error: stopServiceErrors, - }), - Rpc.make("RestartService", { - payload: { name: Schema.String }, - success: Schema.Void, - error: serviceMutatingErrors, - }), - Rpc.make("WaitStackReady", { - payload: { options: Schema.optionalKey(ReadyOptionsRpcSchema) }, - success: Schema.Void, - error: buildReadyErrors, - }), - Rpc.make("WaitServiceReady", { - payload: { name: Schema.String, options: Schema.optionalKey(ReadyOptionsRpcSchema) }, - success: Schema.Void, - error: serviceReadyErrors, - }), - Rpc.make("ReloadFunctions", { - payload: { - options: Schema.optionalKey( - Schema.Struct({ functions: Schema.optionalKey(ResolvedFunctionsBundleSchema) }), - ), - }, - success: Schema.Void, - error: serviceMutatingErrors, - }), - Rpc.make("ReloadEdgeRuntime", { - payload: EdgeRuntimeReloadRpcSchema, - success: Schema.Void, - error: serviceMutatingErrors, - }), - Rpc.make("UpdateLaunch", { - payload: { stackId: Schema.String, launch: StackLaunchUpdateRpcSchema }, - success: Schema.Void, - error: updateLaunchErrors, - }), - Rpc.make("GetServiceState", { - payload: { name: Schema.String }, - success: StackServiceStateSchema, - error: serviceStateErrors, - }), - Rpc.make("GetAllServiceStates", { - success: Schema.Array(StackServiceStateSchema), - error: StackUnavailableErrorSchema, - }), - Rpc.make("WatchServiceStates", { - payload: { name: Schema.optionalKey(Schema.String) }, - success: StackServiceStateSchema, - error: serviceStateErrors, - stream: true, - }), - Rpc.make("GetLogHistory", { - payload: { - name: Schema.optionalKey(Schema.String), - limit: Schema.optionalKey(Schema.Finite), - services: Schema.optionalKey(Schema.Array(Schema.String)), - }, - success: Schema.Array(StackLogEntrySchema), - error: serviceStateErrors, - }), - Rpc.make("WatchLogs", { - payload: { - name: Schema.optionalKey(Schema.String), - services: Schema.optionalKey(Schema.Array(Schema.String)), - }, - success: StackLogEntrySchema, - error: serviceStateErrors, - stream: true, - }), -); - -export const STACK_RPC_PATH = "/rpc" as const; diff --git a/packages/stack/src/StackRpcHandlers.integration.test.ts b/packages/stack/src/StackRpcHandlers.integration.test.ts deleted file mode 100644 index 60fd064222..0000000000 --- a/packages/stack/src/StackRpcHandlers.integration.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -// oxlint-disable effecttsgo/global-fetch-in-effect, effecttsgo/prefer-schema-over-json -- Handler integration tests drive raw HTTP payloads to validate the RPC protocol boundary. -import { ServiceNotFoundError } from "@supabase/process-compose"; -import { it } from "@effect/vitest"; -import { - Context, - Cause, - Deferred, - Effect, - Exit, - Fiber, - Layer, - Predicate, - Semaphore, - Stream, -} from "effect"; -import { expect } from "vitest"; -import { httpTransportClientLayer } from "./HttpTransportClient.ts"; -import { RemoteStack, updateRemoteLaunch } from "./RemoteStack.ts"; -import { Stack } from "./Stack.ts"; -import { StackBuildError, StackUnavailableError } from "./errors.ts"; -import { acquireControl, isControlOwnership } from "./managed/control.ts"; -import { controlTransportLayer } from "./platform-node.ts"; -import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -import { makeSupervisorSessionFixture } from "../tests/helpers/SupervisorSessionFixture.ts"; -import { StackServiceState } from "./StackServiceState.ts"; -import { stackRpcFenceHeaders } from "./StackRpc.ts"; -import { isControlSupervisorStatus, type ControlOwnerStatus } from "./DaemonProtocol.ts"; -import { makeTestStack } from "./testing.ts"; - -const OWNER_ID = "e".repeat(64); - -const supervisorStatus = (status: ControlOwnerStatus) => { - if (!isControlSupervisorStatus(status)) throw new Error("expected supervisor status"); - return status; -}; - -const serviceState = (name: string) => - new StackServiceState({ - name, - status: "Running", - pid: 1, - exitCode: null, - restartCount: 0, - startedAt: 1, - error: null, - }); - -const logs = [ - { timestamp: 1, service: "postgres", stream: "stdout" as const, line: "postgres starting" }, - { timestamp: 2, service: "auth", stream: "stdout" as const, line: "auth starting" }, - { timestamp: 3, service: "postgres", stream: "stdout" as const, line: "postgres ready" }, - { timestamp: 4, service: "auth", stream: "stdout" as const, line: "auth ready" }, - { timestamp: 5, service: "auth", stream: "stdout" as const, line: "auth accepting" }, - { timestamp: 6, service: "storage", stream: "stdout" as const, line: "storage ready" }, -]; - -const stack: Stack["Service"] = makeTestStack({ - getInfo: Effect.succeed({ - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://localhost/postgres", - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "role", - serviceEndpoints: {}, - }), - reloadFunctions: () => - Effect.fail( - new StackBuildError({ - detail: "Invalid Edge Functions reload payload", - reason: "invalid_config", - }), - ), - getState: (name) => - name === "postgres" || name === "auth" - ? Effect.succeed(serviceState(name)) - : Effect.fail(new ServiceNotFoundError({ name })), - getAllStates: Effect.succeed([serviceState("postgres"), serviceState("auth")]), - stateChanges: (name) => Effect.succeed(Stream.fromIterable([serviceState(name)])), - allStateChanges: Stream.fromIterable([serviceState("postgres"), serviceState("auth")]), - subscribeLogs: (name) => Stream.fromIterable(logs.filter((entry) => entry.service === name)), - subscribeAllLogs: (services) => - Stream.fromIterable( - services === undefined || services.length === 0 - ? logs - : logs.filter((entry) => services.includes(entry.service)), - ), - logHistory: (name, limit) => - Effect.succeed(logs.filter((entry) => entry.service === name).slice(-(limit ?? 100))), - logHistoryAll: (limit, services) => - Effect.succeed( - (services === undefined || services.length === 0 - ? logs - : logs.filter((entry) => services.includes(entry.service)) - ).slice(-(limit ?? 100)), - ), -}); - -it.live("serves handler behavior over the RPC boundary", () => - Effect.scoped( - Effect.gen(function* () { - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: OWNER_ID, - ownerSessionId: "handler-session", - daemonCliVersion: "test", - }); - const application = { - app: yield* makeSupervisorControlApplication(lifecycle), - }; - const owner = yield* acquireControl({ - stackId: OWNER_ID, - initialStatus: yield* lifecycle.currentStatus, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - yield* lifecycle.setClose(owner.close); - const status = supervisorStatus(yield* lifecycle.currentStatus); - const layer = RemoteStack.layer(owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: OWNER_ID, - ownerSessionId: status.ownerSessionId, - controlProtocolVersion: status.controlProtocolVersion, - daemonCliVersion: status.daemonCliVersion, - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - const remote = yield* Layer.build(layer).pipe( - Effect.map((context) => Context.get(context, Stack)), - ); - - const unavailable = yield* Effect.flip(remote.getInfo); - expect(Predicate.isTagged(unavailable, "StackUnavailableError")).toBe(true); - if (Predicate.isTagged(unavailable, "StackUnavailableError")) { - expect(unavailable.phase).toBe("starting"); - } - yield* lifecycle.publishStack(stack); - expect((yield* remote.getInfo).url).toBe("http://127.0.0.1:54321"); - - const history = yield* remote.logHistoryAll(3, ["postgres", "auth"]); - expect(history.map((entry) => entry.line)).toEqual([ - "postgres ready", - "auth ready", - "auth accepting", - ]); - - const rawReload = yield* Effect.promise(() => - fetch(`${owner.endpoint.url}/rpc`, { - method: "POST", - headers: { - "content-type": "application/ndjson", - ...stackRpcFenceHeaders({ - ownershipId: status.ownershipId, - ownerSessionId: status.ownerSessionId, - }), - }, - body: `${JSON.stringify({ - _tag: "Request", - id: "redaction-test", - tag: "ReloadFunctions", - payload: { - options: { - functions: { - env: { SECRET: "must-not-appear-in-errors" }, - functions: [ - { - name: "hello", - verifyJWT: false, - entrypointPath: "relative/index.ts", - importMapPath: null, - staticFiles: [], - env: {}, - }, - ], - }, - }, - }, - headers: [], - })}\n`, - }), - ); - const rawBody = yield* Effect.promise(() => rawReload.text()); - expect(rawReload.status).toBe(200); - expect(rawBody.length).toBeGreaterThan(0); - expect(rawBody).toContain("entrypointPath"); - expect(rawBody).not.toContain("must-not-appear-in-errors"); - expect(rawBody).not.toContain("relative/index.ts"); - }).pipe(Effect.provide(controlTransportLayer)), - ), -); - -it.live("rejects launch updates after supervisor shutdown begins", () => - Effect.scoped( - Effect.gen(function* () { - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: OWNER_ID, - ownerSessionId: "launch-update-session", - daemonCliVersion: "test", - }); - const updates: Array = []; - const application = { - app: yield* makeSupervisorControlApplication(lifecycle, { - update: (stackId) => - Effect.sync(() => { - updates.push(stackId); - }), - }), - }; - const owner = yield* acquireControl({ - stackId: OWNER_ID, - initialStatus: yield* lifecycle.currentStatus, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - yield* lifecycle.setClose(owner.close); - const status = supervisorStatus(yield* lifecycle.currentStatus); - const stopStarted = yield* Deferred.make(); - const releaseStop = yield* Deferred.make(); - yield* lifecycle.publishStack({ - ...stack, - stop: Deferred.succeed(stopStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseStop)), - ), - }); - - yield* Effect.gen(function* () { - yield* lifecycle.submitShutdownWithIntent("explicit"); - yield* Deferred.await(stopStarted); - const unavailable = yield* Effect.flip( - updateRemoteLaunch( - owner.endpoint, - { - cliVersion: "test", - owner: { - ownershipId: status.ownershipId, - ownerSessionId: status.ownerSessionId, - controlProtocolVersion: status.controlProtocolVersion, - daemonCliVersion: status.daemonCliVersion, - }, - }, - OWNER_ID, - { versions: { postgres: "17.6.1.076" } }, - ), - ); - expect(Predicate.isTagged(unavailable, "StackUnavailableError")).toBe(true); - if (Predicate.isTagged(unavailable, "StackUnavailableError")) { - expect(unavailable.phase).toBe("stopping"); - } - expect(updates).toEqual([]); - }).pipe(Effect.ensuring(Deferred.succeed(releaseStop, undefined).pipe(Effect.asVoid))); - yield* lifecycle.awaitShutdown; - }).pipe(Effect.provide(Layer.mergeAll(controlTransportLayer, httpTransportClientLayer))), - ), -); - -it.live("propagates a failure terminal reason to an active state stream", () => - Effect.scoped( - Effect.gen(function* () { - const streamStarted = Deferred.makeUnsafe(); - const releaseStop = Deferred.makeUnsafe(); - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: OWNER_ID, - ownerSessionId: "failure-stream-session", - daemonCliVersion: "test", - }); - yield* lifecycle.publishStack({ - ...stack, - stop: Deferred.await(releaseStop), - allStateChanges: Stream.concat( - Stream.succeed(serviceState("auth")).pipe( - Stream.tap(() => Deferred.succeed(streamStarted, undefined).pipe(Effect.asVoid)), - ), - Stream.never, - ), - }); - const application = { app: yield* makeSupervisorControlApplication(lifecycle) }; - const owner = yield* acquireControl({ - stackId: OWNER_ID, - initialStatus: yield* lifecycle.currentStatus, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - yield* lifecycle.setClose(owner.close); - const status = supervisorStatus(yield* lifecycle.currentStatus); - const layer = RemoteStack.layer(owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: OWNER_ID, - ownerSessionId: status.ownerSessionId, - controlProtocolVersion: status.controlProtocolVersion, - daemonCliVersion: status.daemonCliVersion, - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - - const streamExit = yield* Effect.scoped( - Effect.gen(function* () { - const remote = yield* Stack; - const active = yield* Effect.forkChild(Stream.runDrain(remote.allStateChanges), { - startImmediately: true, - }); - yield* Deferred.await(streamStarted); - yield* lifecycle.disposeRuntime; - const exit = yield* Fiber.await(active); - yield* Deferred.succeed(releaseStop, undefined); - yield* lifecycle.awaitShutdown.pipe(Effect.exit); - return exit; - }).pipe(Effect.provide(layer)), - ); - - expect(Exit.isFailure(streamExit)).toBe(true); - if (Exit.isFailure(streamExit)) { - const error = Cause.squash(streamExit.cause); - expect(Predicate.isTagged(error, "StackUnavailableError")).toBe(true); - if (Predicate.isTagged(error, "StackUnavailableError")) { - expect(error).toMatchObject({ - phase: "failed", - detail: "Local stack disposed unexpectedly", - } satisfies Pick); - } - } - }).pipe(Effect.provide(controlTransportLayer)), - ), -); - -it.live("interrupts an in-flight runtime mutation before stopping the stack", () => - Effect.scoped( - Effect.gen(function* () { - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: OWNER_ID, - ownerSessionId: "mutation-stop-session", - daemonCliVersion: "test", - }); - const operationLock = Semaphore.makeUnsafe(1); - const mutationStarted = Deferred.makeUnsafe(); - const mutationReleased = Deferred.makeUnsafe(); - const stopStarted = Deferred.makeUnsafe(); - yield* lifecycle.publishStack({ - ...stack, - reloadEdgeRuntime: () => - Deferred.succeed(mutationStarted, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(mutationReleased, undefined)), - operationLock.withPermit, - ), - stop: Deferred.succeed(stopStarted, undefined).pipe( - Effect.asVoid, - operationLock.withPermit, - ), - }); - const application = { - app: yield* makeSupervisorControlApplication(lifecycle), - }; - const owner = yield* acquireControl({ - stackId: OWNER_ID, - initialStatus: yield* lifecycle.currentStatus, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - yield* lifecycle.setClose(owner.close); - const status = supervisorStatus(yield* lifecycle.currentStatus); - const layer = RemoteStack.layer(owner.endpoint, { - cliVersion: "test", - owner: { - ownershipId: OWNER_ID, - ownerSessionId: status.ownerSessionId, - controlProtocolVersion: status.controlProtocolVersion, - daemonCliVersion: status.daemonCliVersion, - }, - }).pipe(Layer.provide(httpTransportClientLayer)); - const remote = yield* Layer.build(layer).pipe( - Effect.map((context) => Context.get(context, Stack)), - ); - - const mutation = yield* remote - .reloadEdgeRuntime({ edgeRuntime: { enabled: true } }) - .pipe(Effect.forkChild); - yield* Deferred.await(mutationStarted); - const response = yield* Effect.promise(() => - fetch(`${owner.endpoint.url}/stop`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ownershipId: status.ownershipId, - ownerSessionId: status.ownerSessionId, - intent: "explicit", - }), - }), - ); - - expect(response.status).toBe(202); - yield* Deferred.await(mutationReleased); - yield* Deferred.await(stopStarted); - expect(Exit.isFailure(yield* Fiber.await(mutation))).toBe(true); - yield* lifecycle.awaitShutdown; - }).pipe(Effect.provide(controlTransportLayer)), - ), -); diff --git a/packages/stack/src/StackRpcHandlers.ts b/packages/stack/src/StackRpcHandlers.ts deleted file mode 100644 index 09f23d1ee6..0000000000 --- a/packages/stack/src/StackRpcHandlers.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Context, Effect, Predicate, Stream } from "effect"; -import { - StackBuildError, - type StackRpcProtocolError, - type StackRpcTransportError, - type StackUnavailableError, -} from "./errors.ts"; -import { inheritReadyOptions } from "./StackConfig.ts"; -import { StackRpc } from "./StackRpc.ts"; -import type { Stack } from "./Stack.ts"; -import type { StackLaunchUpdateRpc } from "./StackRpc.ts"; -import { SupervisorSession } from "./SupervisorSession.ts"; - -type StackService = Stack["Service"]; - -const isRpcBoundaryError = ( - error: unknown, -): error is StackRpcTransportError | StackRpcProtocolError => - Predicate.isTagged(error, "StackRpcTransportError") || - Predicate.isTagged(error, "StackRpcProtocolError"); - -const local = ( - session: SupervisorSession["Service"], - operation: ( - stack: StackService, - ) => Effect.Effect, -): Effect.Effect => - session.runtimeStack.pipe( - Effect.flatMap((stack) => session.interruptWhenStopping(operation(stack))), - Effect.catchIf(isRpcBoundaryError, (error) => Effect.die(error)), - ); - -const localStream = ( - session: SupervisorSession["Service"], - operation: ( - stack: StackService, - ) => Effect.Effect< - Stream.Stream, - E | StackRpcTransportError | StackRpcProtocolError - >, -): Stream.Stream => - Stream.unwrap( - session - .interruptWhenStopping(session.runtimeStack.pipe(Effect.flatMap(operation))) - .pipe(Effect.map(session.interruptStreamWhenStopping)), - ).pipe(Stream.catchIf(isRpcBoundaryError, (error) => Stream.die(error))); - -export interface StackLaunchUpdater { - readonly update: ( - stackId: string, - launch: StackLaunchUpdateRpc, - ) => Effect.Effect; -} - -export const StackLaunchUpdater = Context.Reference( - "stack/StackLaunchUpdater", - { - defaultValue: () => ({ - update: () => - Effect.fail( - new StackBuildError({ detail: "Managed launch updates require a supervisor owner" }), - ), - }), - }, -); - -/** Runtime-backed implementations for the shared StackRpc contract. */ -export const StackRpcHandlers = StackRpc.toLayer( - Effect.gen(function* () { - const session = yield* SupervisorSession; - const launchUpdater = yield* StackLaunchUpdater; - return { - GetInfo: () => local(session, (stack) => stack.getInfo), - StartStack: () => local(session, (stack) => stack.start), - StartService: ({ name }: { readonly name: string }) => - local(session, (stack) => stack.startService(name)), - StopService: ({ name }: { readonly name: string }) => - local(session, (stack) => stack.stopService(name)), - RestartService: ({ name }: { readonly name: string }) => - local(session, (stack) => stack.restartService(name)), - WaitStackReady: ({ - options, - }: { - readonly options?: Parameters[0]; - }) => local(session, (stack) => stack.waitAllReady(options ?? inheritReadyOptions)), - WaitServiceReady: ({ - name, - options, - }: { - readonly name: string; - readonly options?: Parameters[1]; - }) => local(session, (stack) => stack.waitReady(name, options ?? inheritReadyOptions)), - ReloadFunctions: ({ - options, - }: { - readonly options?: Parameters[0]; - }) => local(session, (stack) => stack.reloadFunctions(options)), - ReloadEdgeRuntime: (options: Parameters[0]) => - local(session, (stack) => stack.reloadEdgeRuntime(options)), - UpdateLaunch: ({ - stackId, - launch, - }: { - readonly stackId: string; - readonly launch: StackLaunchUpdateRpc; - }) => local(session, () => launchUpdater.update(stackId, launch)), - GetServiceState: ({ name }: { readonly name: string }) => - local(session, (stack) => stack.getState(name)), - GetAllServiceStates: () => local(session, (stack) => stack.getAllStates), - WatchServiceStates: ({ name }: { readonly name?: string }) => - name === undefined - ? localStream(session, (stack) => Effect.succeed(stack.allStateChanges)) - : localStream(session, (stack) => stack.stateChanges(name)), - GetLogHistory: ({ - name, - limit, - services, - }: { - readonly name?: string; - readonly limit?: number; - readonly services?: ReadonlyArray; - }) => - name === undefined - ? local(session, (stack) => stack.logHistoryAll(limit, services)) - : local(session, (stack) => stack.logHistory(name, limit)), - WatchLogs: ({ - name, - services, - }: { - readonly name?: string; - readonly services?: ReadonlyArray; - }) => - name === undefined - ? localStream(session, (stack) => Effect.succeed(stack.subscribeAllLogs(services))) - : localStream(session, (stack) => Effect.succeed(stack.subscribeLogs(name))), - }; - }), -); diff --git a/packages/stack/src/StackServiceState.ts b/packages/stack/src/StackServiceState.ts deleted file mode 100644 index ea7030fefc..0000000000 --- a/packages/stack/src/StackServiceState.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Data, Schema } from "effect"; -import type { ServiceState as RawServiceState } from "@supabase/process-compose"; - -export const StackServiceStatusSchema = Schema.Union([ - Schema.Literal("Pending"), - Schema.Literal("Dormant"), - Schema.Literal("Downloading"), - Schema.Literal("Starting"), - Schema.Literal("Running"), - Schema.Literal("Healthy"), - Schema.Literal("Unhealthy"), - Schema.Literal("Stopping"), - Schema.Literal("Stopped"), - Schema.Literal("Failed"), - Schema.Literal("Restarting"), - Schema.Literal("Initializing"), -]); - -export type StackServiceStatus = typeof StackServiceStatusSchema.Type; - -export class StackServiceState extends Data.Class<{ - readonly name: string; - readonly status: StackServiceStatus; - readonly pid: number | null; - readonly exitCode: number | null; - readonly restartCount: number; - readonly startedAt: number | null; - readonly error: string | null; -}> {} - -export function fromRawServiceState(raw: RawServiceState): StackServiceState { - return new StackServiceState({ - name: raw.name, - status: raw.status, - pid: raw.pid, - exitCode: raw.exitCode, - restartCount: raw.restartCount, - startedAt: raw.startedAt, - error: raw.error, - }); -} diff --git a/packages/stack/src/StackServiceState.unit.test.ts b/packages/stack/src/StackServiceState.unit.test.ts deleted file mode 100644 index 20f3b99b81..0000000000 --- a/packages/stack/src/StackServiceState.unit.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ServiceState } from "@supabase/process-compose"; -import { fromRawServiceState } from "./StackServiceState.ts"; - -describe("fromRawServiceState", () => { - it("preserves terminal health-failure state semantics", () => { - const projected = fromRawServiceState( - new ServiceState({ - name: "edge-runtime", - status: "Failed", - pid: null, - exitCode: null, - restartCount: 2, - startedAt: 1_000, - error: "Health check failed and restart budget was exhausted", - desired: "running", - }), - ); - - expect(projected).toMatchObject({ - status: "Failed", - pid: null, - exitCode: null, - error: "Health check failed and restart budget was exhausted", - }); - }); -}); diff --git a/packages/stack/src/StackStateProjection.ts b/packages/stack/src/StackStateProjection.ts deleted file mode 100644 index 51b25512e8..0000000000 --- a/packages/stack/src/StackStateProjection.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { ServiceState as RawServiceState } from "@supabase/process-compose"; -import { - StackServiceState, - type StackServiceStatus, - fromRawServiceState, -} from "./StackServiceState.ts"; - -interface StackServiceProjectionSpec { - readonly visibility: "public" | "internal"; - readonly owner?: string; - readonly ownerStatusWhileActive?: StackServiceStatus; -} - -export type StackServiceProjectionCatalog = ReadonlyMap; - -function isHelperActive(state: RawServiceState): boolean { - return state.status !== "Stopped" && state.status !== "Failed"; -} - -function projectPublicState( - raw: RawServiceState, - rawByName: ReadonlyMap, - catalog: StackServiceProjectionCatalog, -): StackServiceState { - if (raw.desired === "inactive" && (raw.status === "Pending" || raw.status === "Stopped")) { - return new StackServiceState({ ...fromRawServiceState(raw), status: "Dormant" }); - } - - const ownerHelpers = [...rawByName.values()].filter((candidate) => { - const spec = catalog.get(candidate.name); - return spec?.visibility === "internal" && spec.owner === raw.name; - }); - - const failedHelper = ownerHelpers.find((helper) => helper.status === "Failed"); - if (failedHelper !== undefined) { - return new StackServiceState({ - name: raw.name, - status: "Failed", - pid: raw.pid, - exitCode: raw.exitCode, - restartCount: raw.restartCount, - startedAt: raw.startedAt, - error: failedHelper.error ?? raw.error, - }); - } - - if (raw.status === "Failed") { - return fromRawServiceState(raw); - } - - const activeHelper = ownerHelpers.find(isHelperActive); - if (activeHelper !== undefined) { - const helperSpec = catalog.get(activeHelper.name); - return new StackServiceState({ - name: raw.name, - status: helperSpec?.ownerStatusWhileActive ?? raw.status, - pid: raw.pid, - exitCode: raw.exitCode, - restartCount: raw.restartCount, - startedAt: raw.startedAt, - error: raw.error, - }); - } - - return fromRawServiceState(raw); -} - -export function projectStackStates( - rawStates: ReadonlyArray, - catalog: StackServiceProjectionCatalog, -): ReadonlyArray { - const rawByName = new Map(rawStates.map((state) => [state.name, state] as const)); - - return rawStates - .filter((state) => (catalog.get(state.name)?.visibility ?? "public") === "public") - .map((state) => projectPublicState(state, rawByName, catalog)); -} - -export function projectStackState( - name: string, - rawStates: ReadonlyArray, - catalog: StackServiceProjectionCatalog, -): StackServiceState | undefined { - return projectStackStates(rawStates, catalog).find((state) => state.name === name); -} diff --git a/packages/stack/src/StackStateProjection.unit.test.ts b/packages/stack/src/StackStateProjection.unit.test.ts deleted file mode 100644 index 7275ba764e..0000000000 --- a/packages/stack/src/StackStateProjection.unit.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { ServiceState } from "@supabase/process-compose"; -import { - projectStackState, - projectStackStates, - type StackServiceProjectionCatalog, -} from "./StackStateProjection.ts"; - -function rawState(name: string, status: ServiceState["status"], error: string | null = null) { - return new ServiceState({ - name, - status, - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error, - desired: "running", - }); -} - -const projectionCatalog: StackServiceProjectionCatalog = new Map([ - ["postgres", { visibility: "public" }], - [ - "postgres-init", - { - visibility: "internal", - owner: "postgres", - ownerStatusWhileActive: "Initializing", - }, - ], - ["auth", { visibility: "public" }], -]); - -describe("projectStackStates", () => { - test("omits internal helper services from public output", () => { - const projected = projectStackStates( - [ - rawState("postgres", "Healthy"), - rawState("postgres-init", "Stopped"), - rawState("auth", "Healthy"), - ], - projectionCatalog, - ); - - expect(projected.map((state) => state.name)).toEqual(["postgres", "auth"]); - }); - - test("shows owner as Initializing while helper is active", () => { - const projected = projectStackStates( - [ - rawState("postgres", "Starting"), - rawState("postgres-init", "Running"), - rawState("auth", "Pending"), - ], - projectionCatalog, - ); - - expect(projected.find((state) => state.name === "postgres")?.status).toBe("Initializing"); - }); - - test("propagates helper failure to owner", () => { - const projected = projectStackStates( - [ - rawState("postgres", "Healthy"), - rawState("postgres-init", "Failed", "init failed"), - rawState("auth", "Healthy"), - ], - projectionCatalog, - ); - - const postgres = projected.find((state) => state.name === "postgres"); - expect(postgres?.status).toBe("Failed"); - expect(postgres?.error).toBe("init failed"); - }); - - test("falls back to the owner raw state after helper completion", () => { - const projected = projectStackState( - "postgres", - [rawState("postgres", "Healthy"), rawState("postgres-init", "Stopped")], - projectionCatalog, - ); - - expect(projected?.status).toBe("Healthy"); - }); -}); diff --git a/packages/stack/src/SupervisorControlServer.integration.test.ts b/packages/stack/src/SupervisorControlServer.integration.test.ts deleted file mode 100644 index 32e9931da1..0000000000 --- a/packages/stack/src/SupervisorControlServer.integration.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch-in-effect, effecttsgo/node-builtin-import -- Control-server tests call the native HTTP client from Vitest callbacks to exercise the wire boundary. -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { Effect, Layer, ManagedRuntime, Predicate } from "effect"; -import { HttpServer } from "effect/unstable/http"; -import { createServer } from "node:http"; -import { describe, expect, it } from "vitest"; -import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -import { makeSupervisorSessionFixture } from "../tests/helpers/SupervisorSessionFixture.ts"; - -describe("SupervisorControlServer", () => { - it("returns 404 for an unknown control route", async () => { - const serverLayer = NodeHttpServer.layer(() => createServer(), { port: 0 }).pipe(Layer.orDie); - const runtime = ManagedRuntime.make(serverLayer); - try { - const result = await runtime.runPromise( - Effect.scoped( - Effect.gen(function* () { - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: "stack", - ownerSessionId: "session", - daemonCliVersion: "test", - close: Effect.void, - }); - const application = yield* makeSupervisorControlApplication(lifecycle); - const server = yield* HttpServer.HttpServer; - yield* server.serve(application); - const address = server.address; - if (!Predicate.isTagged(address, "TcpAddress")) throw new Error("expected tcp address"); - const response = yield* Effect.tryPromise(() => - fetch(`http://127.0.0.1:${address.port}/unknown`), - ); - return response.status; - }), - ), - ); - expect(result).toBe(404); - } finally { - await runtime.dispose(); - } - }); -}); diff --git a/packages/stack/src/SupervisorControlServer.ts b/packages/stack/src/SupervisorControlServer.ts deleted file mode 100644 index 2a54f2ccbe..0000000000 --- a/packages/stack/src/SupervisorControlServer.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Effect, Layer } from "effect"; -import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; -import * as RpcServer from "effect/unstable/rpc/RpcServer"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { ControlStopRequestSchema, matchesControlSession } from "./DaemonProtocol.ts"; -import { matchesStackRpcFence, StackRpc } from "./StackRpc.ts"; -import { - StackLaunchUpdater, - StackRpcHandlers, - type StackLaunchUpdater as StackLaunchUpdaterService, -} from "./StackRpcHandlers.ts"; -import { SupervisorSession } from "./SupervisorSession.ts"; - -/** Builds the complete static supervisor application before listener binding. */ -export const makeSupervisorControlApplication = ( - session: SupervisorSession["Service"], - launchUpdater?: StackLaunchUpdaterService, -): Effect.Effect< - Effect.Effect< - HttpServerResponse.HttpServerResponse, - never, - HttpServerRequest.HttpServerRequest | import("effect/Scope").Scope - >, - never, - import("effect/Scope").Scope -> => - Effect.gen(function* () { - const handlers = - launchUpdater === undefined - ? StackRpcHandlers - : StackRpcHandlers.pipe(Layer.provide(Layer.succeed(StackLaunchUpdater, launchUpdater))); - const rpc = yield* RpcServer.toHttpEffect(StackRpc).pipe( - Effect.provide( - Layer.mergeAll( - handlers.pipe(Layer.provide(Layer.succeed(SupervisorSession, session))), - RpcSerialization.layerNdjson, - ), - ), - ); - const fencedRpc = Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const status = yield* session.currentStatus; - if ( - !matchesStackRpcFence(request.headers, { - ownershipId: status.ownershipId, - ownerSessionId: status.ownerSessionId, - }) - ) { - // These headers fence a client to the observed owner; they are not an - // authentication mechanism and carry no secret material. - return HttpServerResponse.jsonUnsafe({ error: "rpc-fence-mismatch" }, { status: 409 }); - } - return yield* rpc; - }); - const routes = [ - HttpRouter.route( - "GET", - "/owner", - session.currentStatus.pipe(Effect.map(HttpServerResponse.jsonUnsafe)), - ), - HttpRouter.route( - "POST", - "/stop", - Effect.gen(function* () { - const request = yield* HttpServerRequest.schemaBodyJson(ControlStopRequestSchema); - const status = yield* session.currentStatus; - if (!matchesControlSession(request, status)) { - return HttpServerResponse.jsonUnsafe({ error: "conflict" }, { status: 409 }); - } - // Submit ownership of the stop transaction before returning 202. The - // listener closes gracefully after the response is flushed. - yield* session.submitShutdownWithIntent(request.intent); - return HttpServerResponse.jsonUnsafe({ ok: true }, { status: 202 }); - }).pipe( - Effect.catchTags({ - SchemaError: () => - Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "invalid" }, { status: 400 })), - HttpServerError: () => - Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "invalid" }, { status: 400 })), - }), - ), - ), - HttpRouter.route("POST", "/rpc", fencedRpc), - ]; - const application = yield* HttpRouter.toHttpEffect(HttpRouter.addAll(routes)); - // The router returns a request-scoped Effect that the platform listener - // evaluates later, after it provides HttpServerRequest and Scope. - // oxlint-disable-next-line effecttsgo/return-effect-in-gen - return application.pipe(Effect.orDie); - }); diff --git a/packages/stack/src/SupervisorProtocol.ts b/packages/stack/src/SupervisorProtocol.ts deleted file mode 100644 index 00047ad891..0000000000 --- a/packages/stack/src/SupervisorProtocol.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Schema } from "effect"; -import { ControlSupervisorDescriptorSchema, ControlOwnerStateSchema } from "./DaemonProtocol.ts"; -import { managedStackLaunchInputSchema } from "./managed/document.ts"; -import { PORT_FIELDS } from "./PortCatalog.ts"; - -const portIntentSchema = Schema.Struct({ - activeFields: Schema.Array(Schema.Literals(PORT_FIELDS)), - disabledFields: Schema.optionalKey(Schema.Array(Schema.Literals(PORT_FIELDS))), - document: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), -}); - -export const SupervisorStartCommandSchema = Schema.Struct({ - type: Schema.Literal("start"), - replacement: Schema.optionalKey(Schema.Boolean), - cliVersion: Schema.String, - stackId: Schema.String, - workspacePath: Schema.String, - stackName: Schema.String, - stateRoot: Schema.String, - config: Schema.Record(Schema.String, Schema.Unknown), - portIntents: portIntentSchema, - launch: Schema.optionalKey(managedStackLaunchInputSchema), -}); -export type SupervisorStartMessage = Schema.Schema.Type; - -const SupervisorOwnerDescriptorSchema = Schema.Struct({ - kind: Schema.Literal("supervisor"), - ownershipId: ControlSupervisorDescriptorSchema.fields.ownershipId, - ownerSessionId: ControlSupervisorDescriptorSchema.fields.ownerSessionId, - controlProtocolVersion: ControlSupervisorDescriptorSchema.fields.controlProtocolVersion, - daemonCliVersion: ControlSupervisorDescriptorSchema.fields.daemonCliVersion, - state: ControlOwnerStateSchema, - ready: Schema.Boolean, -}); - -export const SupervisorStartedEventSchema = Schema.Struct({ - type: Schema.Literal("started"), - endpoint: Schema.Struct({ - hostname: Schema.String, - port: Schema.Finite, - url: Schema.String, - }), - owner: SupervisorOwnerDescriptorSchema, - attached: Schema.optionalKey(Schema.Boolean), -}); -export type SupervisorStartedMessage = Schema.Schema.Type; - -export const SupervisorErrorEventSchema = Schema.Struct({ - type: Schema.Literal("error"), - message: Schema.String, - errorCode: Schema.optionalKey(Schema.Literal("DAEMON_UPGRADE_REQUIRED")), - stackId: Schema.optionalKey(Schema.String), - oldCliVersion: Schema.optionalKey(Schema.String), - newCliVersion: Schema.optionalKey(Schema.String), - state: Schema.optionalKey(ControlOwnerStateSchema), - ready: Schema.optionalKey(Schema.Boolean), -}); -export type SupervisorErrorMessage = Schema.Schema.Type; - -export const SupervisorEventSchema = Schema.Union([ - SupervisorStartedEventSchema, - SupervisorErrorEventSchema, -]); diff --git a/packages/stack/src/SupervisorSession.integration.test.ts b/packages/stack/src/SupervisorSession.integration.test.ts deleted file mode 100644 index d86aea8e0d..0000000000 --- a/packages/stack/src/SupervisorSession.integration.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Session integration tests use Vitest's Promise callback boundary. -import { Cause, Data, Deferred, Effect, Exit, Fiber, Predicate, Scope, Stream } from "effect"; -import { describe, expect, it } from "vitest"; -import type { Stack } from "./Stack.ts"; -import { StackServiceState } from "./StackServiceState.ts"; -import { SupervisorSession } from "./SupervisorSession.ts"; -import { makeTestStack } from "./testing.ts"; - -class PublishFailedError extends Data.TaggedError("PublishFailedError")<{}> {} - -const state = new StackServiceState({ - name: "auth", - status: "Running", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, -}); - -const makeStack = (events: Array): Stack["Service"] => - makeTestStack({ - getInfo: Effect.die("unused"), - stop: Effect.sync(() => events.push("stop")), - dispose: Effect.sync(() => events.push("dispose")), - getState: () => Effect.succeed(state), - getAllStates: Effect.succeed([state]), - }); - -const withSession = ( - use: (session: Awaited>) => Promise, -): Promise => - Effect.runPromise( - Effect.acquireUseRelease( - Effect.gen(function* () { - const scope = yield* Scope.make(); - const controller = yield* SupervisorSession.make({ - ownershipId: "stack", - ownerSessionId: "session", - daemonCliVersion: "test", - }).pipe(Effect.provideService(Scope.Scope, scope)); - return { scope, controller }; - }), - (session) => Effect.tryPromise(() => use(session)), - ({ scope }) => Scope.close(scope, Exit.void), - ), - ); - -const makeSession = async () => { - const scope = Scope.makeUnsafe(); - const controller = await Effect.runPromise( - SupervisorSession.make({ - ownershipId: "stack", - ownerSessionId: "session", - daemonCliVersion: "test", - }).pipe(Effect.provideService(Scope.Scope, scope)), - ); - return { scope, controller }; -}; - -describe("SupervisorSession", () => { - it("runs explicit cleanup when the session is externally interrupted during startup", () => - withSession(async ({ controller }) => { - const events: Array = []; - const startupEntered = Deferred.makeUnsafe(); - const run = Effect.runFork( - controller.run({ - startup: () => - Deferred.succeed(startupEntered, undefined).pipe(Effect.andThen(Effect.never)), - stack: (runtime: Stack["Service"]) => runtime, - awaitDisposed: () => Effect.never, - onRunning: () => Effect.void, - onStopped: (intent) => Effect.sync(() => events.push(`stopped:${intent}`)), - onFailure: () => Effect.sync(() => events.push("failed")), - closeOwner: Effect.sync(() => events.push("close-owner")), - errorDetail: () => "failed", - }), - ); - - await Effect.runPromise(Deferred.await(startupEntered)); - await Effect.runPromise(Fiber.interrupt(run)); - expect(events).toEqual(["stopped:explicit", "close-owner"]); - })); - - it("runs explicit cleanup when the session is externally interrupted while running", () => - withSession(async ({ controller }) => { - const events: Array = []; - const running = Deferred.makeUnsafe(); - const stack = makeStack(events); - const run = Effect.runFork( - controller.run({ - startup: () => Effect.succeed(stack), - stack: (runtime) => runtime, - awaitDisposed: () => Effect.never, - onRunning: () => Deferred.succeed(running, undefined), - onStopped: (intent) => Effect.sync(() => events.push(`stopped:${intent}`)), - onFailure: () => Effect.sync(() => events.push("failed")), - closeOwner: Effect.sync(() => events.push("close-owner")), - errorDetail: () => "failed", - }), - ); - - await Effect.runPromise(Deferred.await(running)); - await Effect.runPromise(Fiber.interrupt(run)); - expect(events).toEqual(["stop", "dispose", "stopped:explicit", "close-owner"]); - })); - - it("acknowledges stopping before waiting for startup finalizers and closes ownership last", () => - withSession(async ({ controller }) => { - const events: Array = []; - const startupEntered = Deferred.makeUnsafe(); - const finalizerEntered = Deferred.makeUnsafe(); - const releaseFinalizer = Deferred.makeUnsafe(); - const startup = Effect.acquireUseRelease( - Deferred.succeed(startupEntered, undefined), - () => Effect.never, - () => - Effect.sync(() => events.push("startup-finalizer")).pipe( - Effect.andThen(Deferred.succeed(finalizerEntered, undefined)), - Effect.andThen(Deferred.await(releaseFinalizer)), - ), - ); - const run = Effect.runFork( - controller.run({ - startup: () => startup, - stack: (stack: Stack["Service"]) => stack, - awaitDisposed: () => Effect.never, - onRunning: () => Effect.void, - onStopped: () => Effect.sync(() => events.push("persist-stopped")), - onFailure: () => Effect.void, - closeOwner: Effect.sync(() => events.push("close-owner")), - errorDetail: () => "failed", - }), - ); - - await Effect.runPromise(Deferred.await(startupEntered)); - await Effect.runPromise(controller.service.submitShutdownWithIntent("explicit")); - await Effect.runPromise(Deferred.await(finalizerEntered)); - expect(events).toEqual(["startup-finalizer"]); - expect(await Effect.runPromise(controller.service.currentStatus)).toMatchObject({ - state: "stopping", - ready: false, - }); - await Effect.runPromise(Deferred.succeed(releaseFinalizer, undefined)); - await Effect.runPromise(Fiber.join(run)); - expect(events).toEqual(["startup-finalizer", "persist-stopped", "close-owner"]); - })); - - it("stops a constructed runtime when readiness publication fails", () => - withSession(async ({ controller }) => { - const events: Array = []; - const stack = makeStack(events); - const run = await Effect.runPromise( - controller - .run({ - startup: () => - Effect.addFinalizer(() => Effect.sync(() => events.push("close-runtime-scope"))).pipe( - Effect.as(stack), - ), - stack: (runtime) => runtime, - awaitDisposed: () => Effect.never, - onRunning: () => Effect.fail(new PublishFailedError()), - onStopped: () => Effect.void, - onFailure: () => Effect.sync(() => events.push("persist-failed")), - closeOwner: Effect.sync(() => events.push("close-owner")), - errorDetail: (cause) => String(Cause.squash(cause)), - }) - .pipe(Effect.exit), - ); - expect(Exit.isFailure(run)).toBe(true); - expect(events).toEqual([ - "stop", - "dispose", - "close-runtime-scope", - "persist-failed", - "close-owner", - ]); - })); - - it("logs runtime finalizer defects while completing an explicit stop", () => - withSession(async ({ controller }) => { - const events: Array = []; - const running = Deferred.makeUnsafe(); - const stack = makeStack(events); - const run = Effect.runFork( - controller.run({ - startup: () => - Effect.addFinalizer(() => Effect.die("runtime finalizer failed")).pipe( - Effect.as(stack), - ), - stack: (runtime) => runtime, - awaitDisposed: () => Effect.never, - onRunning: () => Deferred.succeed(running, undefined).pipe(Effect.asVoid), - onStopped: () => Effect.sync(() => events.push("persist-stopped")), - onFailure: () => Effect.void, - closeOwner: Effect.sync(() => events.push("close-owner")), - errorDetail: () => "failed", - }), - ); - - await Effect.runPromise(Deferred.await(running)); - await Effect.runPromise(controller.service.submitShutdownWithIntent("explicit")); - const exit = await Effect.runPromise(Fiber.await(run)); - expect(Exit.isSuccess(exit)).toBe(true); - expect(events).toEqual(["stop", "dispose", "persist-stopped", "close-owner"]); - })); - - it("preserves the startup failure when cleanup also defects", () => - withSession(async ({ controller }) => { - const events: Array = []; - const startupFailure = new Error("startup failed"); - const exit = await Effect.runPromise( - controller - .run({ - startup: () => - Effect.addFinalizer(() => Effect.die("runtime finalizer failed")).pipe( - Effect.andThen(Effect.fail(startupFailure)), - ), - stack: (runtime: Stack["Service"]) => runtime, - awaitDisposed: () => Effect.never, - onRunning: () => Effect.void, - onStopped: () => Effect.void, - onFailure: () => Effect.sync(() => events.push("persist-failed")), - closeOwner: Effect.sync(() => events.push("close-owner")), - errorDetail: () => startupFailure.message, - }) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(startupFailure); - expect(events).toEqual(["persist-failed", "close-owner"]); - })); - - it("acknowledges a stop submitted while terminal cleanup is already running", () => - withSession(async ({ controller }) => { - const terminalEntered = Deferred.makeUnsafe(); - const releaseTerminal = Deferred.makeUnsafe(); - const run = Effect.runFork( - controller.run({ - startup: () => Effect.succeed(makeStack([])), - stack: (runtime) => runtime, - awaitDisposed: () => Effect.never, - onRunning: () => Effect.fail(new PublishFailedError()), - onStopped: () => Effect.void, - onFailure: () => - Deferred.succeed(terminalEntered, undefined).pipe( - Effect.andThen(Deferred.await(releaseTerminal)), - ), - closeOwner: Effect.void, - errorDetail: () => "publish failed", - }), - ); - - await Effect.runPromise(Deferred.await(terminalEntered)); - const stopAccepted = Deferred.makeUnsafe(); - Effect.runFork( - controller.service - .submitShutdownWithIntent("explicit") - .pipe(Effect.andThen(Deferred.succeed(stopAccepted, undefined))), - ); - await Effect.runPromise(Effect.yieldNow); - expect(await Effect.runPromise(Deferred.isDone(stopAccepted))).toBe(true); - await Effect.runPromise(Deferred.succeed(releaseTerminal, undefined)); - expect(Exit.isFailure(await Effect.runPromise(Fiber.await(run)))).toBe(true); - })); - - it("reports unexpected runtime disposal as a tagged stack failure", () => - withSession(async ({ controller }) => { - const running = Deferred.makeUnsafe(); - const disposed = Deferred.makeUnsafe(); - const run = Effect.runFork( - controller.run({ - startup: () => Effect.succeed(makeStack([])), - stack: (runtime) => runtime, - awaitDisposed: () => Deferred.await(disposed), - onRunning: () => Deferred.succeed(running, undefined).pipe(Effect.asVoid), - onStopped: () => Effect.void, - onFailure: () => Effect.void, - closeOwner: Effect.void, - errorDetail: () => "failed", - }), - ); - - await Effect.runPromise(Deferred.await(running)); - await Effect.runPromise(Deferred.succeed(disposed, undefined)); - const exit = await Effect.runPromise(Fiber.await(run)); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Predicate.isTagged(Cause.squash(exit.cause), "StackUnavailableError")).toBe(true); - } - const unavailable = await Effect.runPromise(Effect.flip(controller.service.runtimeStack)); - expect(Predicate.isTagged(unavailable, "StackUnavailableError")).toBe(true); - if (Predicate.isTagged(unavailable, "StackUnavailableError")) { - expect(unavailable).toMatchObject({ - phase: "failed", - detail: "Local stack disposed unexpectedly", - }); - } - const streamExit = await Effect.runPromise( - controller.service - .interruptStreamWhenStopping(Stream.never) - .pipe(Stream.runDrain, Effect.exit), - ); - expect(Exit.isFailure(streamExit)).toBe(true); - if (Exit.isFailure(streamExit)) { - const error = Cause.squash(streamExit.cause); - expect(Predicate.isTagged(error, "StackUnavailableError")).toBe(true); - if (Predicate.isTagged(error, "StackUnavailableError")) { - expect(error).toMatchObject({ phase: "failed" }); - } - } - })); -}); diff --git a/packages/stack/src/SupervisorSession.ts b/packages/stack/src/SupervisorSession.ts deleted file mode 100644 index 9d6cb7cdec..0000000000 --- a/packages/stack/src/SupervisorSession.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { - Cause, - Context, - Deferred, - Effect, - Exit, - Fiber, - Match, - Queue, - Ref, - Scope, - Stream, -} from "effect"; -import { - CONTROL_PROTOCOL, - CONTROL_PROTOCOL_VERSION, - type ControlSupervisorStatus, - type ControlStopIntent, -} from "./DaemonProtocol.ts"; -import type { Stack } from "./Stack.ts"; -import { StackUnavailableError } from "./errors.ts"; - -type SupervisorSessionState = - | { readonly phase: "starting" } - | { readonly phase: "running"; readonly stack: Stack["Service"] } - | { readonly phase: "stopping" } - | { readonly phase: "failed"; readonly detail: string } - | { readonly phase: "closed" }; - -type SessionCommand = - | { readonly _tag: "StartupFinished" } - | { readonly _tag: "StopRequested"; readonly intent: ControlStopIntent } - | { readonly _tag: "RuntimeDisposed" }; - -interface SupervisorSessionRunInput { - readonly startup: (runtimeScope: Scope.Scope) => Effect.Effect; - readonly stack: (runtime: A) => Stack["Service"]; - readonly awaitDisposed: (runtime: A) => Effect.Effect; - readonly onRunning: (runtime: A) => Effect.Effect; - readonly onStopped: (intent: ControlStopIntent) => Effect.Effect; - readonly onFailure: (detail: string) => Effect.Effect; - readonly closeOwner: Effect.Effect; - readonly errorDetail: (cause: Cause.Cause) => string; -} - -export interface SupervisorSessionController { - readonly service: SupervisorSession["Service"]; - readonly run: ( - input: SupervisorSessionRunInput, - ) => Effect.Effect< - { readonly started: boolean }, - E | F | StackUnavailableError, - Exclude - >; -} - -const cleanupFailures = ( - exits: ReadonlyArray>, -): ReadonlyArray> => { - const failures: Array> = []; - for (const exit of exits) { - if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) failures.push(exit.cause); - } - return failures; -}; - -export class SupervisorSession extends Context.Service< - SupervisorSession, - { - readonly currentStatus: Effect.Effect; - readonly runtimeStack: Effect.Effect; - readonly interruptWhenStopping: ( - effect: Effect.Effect, - ) => Effect.Effect; - readonly interruptStreamWhenStopping: ( - stream: Stream.Stream, - ) => Stream.Stream; - readonly submitShutdownWithIntent: (intent: ControlStopIntent) => Effect.Effect; - } ->()("stack/SupervisorSession") { - static make(input: { - readonly ownershipId: string; - readonly ownerSessionId: string; - readonly daemonCliVersion: string; - }): Effect.Effect { - return Effect.gen(function* () { - const sessionScope = yield* Effect.scope; - const stateRef = Ref.makeUnsafe({ phase: "starting" }); - const commands = yield* Queue.unbounded(); - // The terminal signal is completed before teardown begins. Its value is - // retained for every later caller, including streams subscribed after - // the session has already closed, so they observe the actual terminal - // reason instead of a generic "stopping" state. - const terminalSignal = Deferred.makeUnsafe(); - const status = (state: SupervisorSessionState): ControlSupervisorStatus => ({ - controlProtocol: CONTROL_PROTOCOL, - controlProtocolVersion: CONTROL_PROTOCOL_VERSION, - ownershipId: input.ownershipId, - ownerSessionId: input.ownerSessionId, - kind: "supervisor", - state: state.phase === "closed" ? "stopping" : state.phase, - ready: state.phase === "running", - daemonCliVersion: input.daemonCliVersion, - }); - const service: SupervisorSession["Service"] = { - currentStatus: Ref.get(stateRef).pipe(Effect.map(status)), - runtimeStack: Ref.get(stateRef).pipe( - Effect.flatMap((state) => - state.phase === "running" - ? Effect.succeed(state.stack) - : state.phase === "closed" - ? Deferred.await(terminalSignal).pipe(Effect.flatMap((error) => Effect.fail(error))) - : Effect.fail( - new StackUnavailableError({ - phase: state.phase, - ...(state.phase === "failed" ? { detail: state.detail } : {}), - }), - ), - ), - ), - interruptWhenStopping: (effect) => - Effect.raceFirst( - effect, - Deferred.await(terminalSignal).pipe(Effect.flatMap((error) => Effect.fail(error))), - ), - interruptStreamWhenStopping: (stream) => - stream.pipe( - Stream.interruptWhen( - Deferred.await(terminalSignal).pipe(Effect.flatMap((error) => Effect.fail(error))), - ), - ), - submitShutdownWithIntent: (intent) => - Queue.offer(commands, { _tag: "StopRequested", intent }).pipe( - Effect.andThen(Deferred.await(terminalSignal).pipe(Effect.asVoid)), - ), - }; - const run = ( - runInput: SupervisorSessionRunInput, - ): Effect.Effect< - { readonly started: boolean }, - E | F | StackUnavailableError, - Exclude - > => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const runtimeScope = yield* Scope.fork(sessionScope); - const startupResult = Deferred.makeUnsafe>(); - const startupFiber = yield* Effect.uninterruptibleMask((restore) => - restore(runInput.startup(runtimeScope)).pipe( - Scope.provide(runtimeScope), - Effect.exit, - Effect.flatMap((exit) => - Deferred.succeed(startupResult, exit).pipe( - Effect.andThen(Queue.offer(commands, { _tag: "StartupFinished" })), - ), - ), - ), - ).pipe(Effect.forkChild({ startImmediately: true })); - let runtime: A | undefined; - let started = false; - - type CleanupRequest = { - readonly terminal: Effect.Effect; - readonly reason: StackUnavailableError; - }; - const cleanupResult = Deferred.makeUnsafe>(); - let cleanupStarted = false; - let cleanupRequest: CleanupRequest | undefined; - const awaitCleanup = Deferred.await(cleanupResult).pipe( - Effect.flatMap((exit) => - Exit.isSuccess(exit) ? Effect.void : Effect.failCause(exit.cause), - ), - ); - - const cleanup = (request: CleanupRequest) => - Effect.uninterruptible( - Effect.suspend(() => { - if (cleanupStarted) { - return awaitCleanup; - } - cleanupStarted = true; - cleanupRequest = request; - return Effect.gen(function* () { - // Publish the terminal reason before interrupting startup - // or disposing the runtime so all in-flight and future - // RPC calls are fenced to the same outcome. - yield* Deferred.succeed(terminalSignal, request.reason); - // The startup fiber may have built a runtime without yet - // publishing StartupFinished. Interrupt and join it before - // deriving the stack so every cleanup path owns the value. - yield* Fiber.interrupt(startupFiber); - const startupExit = yield* Deferred.await(startupResult); - if (runtime === undefined && Exit.isSuccess(startupExit)) { - runtime = startupExit.value; - } - const stack = runtime === undefined ? undefined : runInput.stack(runtime); - const stopExit = - stack === undefined ? Exit.void : yield* Effect.exit(stack.stop); - const disposeExit = - stack === undefined ? Exit.void : yield* Effect.exit(stack.dispose); - const scopeExit = yield* Effect.exit(Scope.close(runtimeScope, Exit.void)); - const terminalExit = yield* Effect.exit(request.terminal); - const closeExit = yield* Effect.exit(runInput.closeOwner); - yield* Ref.set(stateRef, { phase: "closed" }); - yield* Queue.shutdown(commands); - const failures = cleanupFailures([ - stopExit, - disposeExit, - scopeExit, - terminalExit, - closeExit, - ]); - yield* Effect.forEach(failures, (failure) => - Effect.logError("Supervisor cleanup failed", Cause.pretty(failure)), - ); - }).pipe( - Effect.exit, - Effect.flatMap((exit) => - Deferred.succeed(cleanupResult, exit).pipe( - Effect.andThen( - Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.void, - ), - ), - ), - ); - }), - ); - - yield* Scope.addFinalizer( - sessionScope, - Effect.suspend(() => - cleanup( - cleanupRequest ?? { - terminal: runInput.onStopped("explicit"), - reason: new StackUnavailableError({ phase: "stopping" }), - }, - ), - ), - ); - - const runLoop = Effect.gen(function* () { - while (true) { - const command = yield* Queue.take(commands); - const outcome = yield* Match.valueTags(command, { - StartupFinished: () => - Effect.gen(function* () { - const exit = yield* Deferred.await(startupResult); - if (Exit.isFailure(exit)) { - const detail = runInput.errorDetail(exit.cause); - yield* Ref.set(stateRef, { phase: "failed", detail }); - yield* cleanup({ - terminal: runInput.onFailure(detail), - reason: new StackUnavailableError({ phase: "failed", detail }), - }); - return yield* Effect.failCause(exit.cause); - } - runtime = exit.value; - yield* Ref.set(stateRef, { - phase: "running", - stack: runInput.stack(runtime), - }); - const runningExit = yield* Effect.exit(runInput.onRunning(runtime)); - if (Exit.isFailure(runningExit)) { - const detail = runInput.errorDetail(runningExit.cause); - yield* Ref.set(stateRef, { phase: "failed", detail }); - yield* cleanup({ - terminal: runInput.onFailure(detail), - reason: new StackUnavailableError({ phase: "failed", detail }), - }); - return yield* Effect.failCause(runningExit.cause); - } - started = true; - yield* runInput - .awaitDisposed(runtime) - .pipe( - Effect.exit, - Effect.andThen(Queue.offer(commands, { _tag: "RuntimeDisposed" })), - Effect.forkIn(sessionScope), - ); - }), - StopRequested: (command) => - Effect.gen(function* () { - yield* Ref.set(stateRef, { phase: "stopping" }); - yield* cleanup({ - terminal: runInput.onStopped(command.intent), - reason: new StackUnavailableError({ phase: "stopping" }), - }); - return { started }; - }), - RuntimeDisposed: () => - Effect.gen(function* () { - const detail = "Local stack disposed unexpectedly"; - yield* Ref.set(stateRef, { phase: "failed", detail }); - yield* cleanup({ - terminal: runInput.onFailure(detail), - reason: new StackUnavailableError({ phase: "failed", detail }), - }); - return yield* new StackUnavailableError({ phase: "failed", detail }); - }), - }); - if (outcome !== undefined) return outcome; - } - }); - - return yield* restore(runLoop).pipe( - Effect.onExit((exit) => { - if (Exit.isSuccess(exit)) return Effect.void; - const activeCleanup = cleanupRequest; - if (activeCleanup !== undefined) return cleanup(activeCleanup); - const request: CleanupRequest = Cause.hasInterruptsOnly(exit.cause) - ? { - terminal: runInput.onStopped("explicit"), - reason: new StackUnavailableError({ phase: "stopping" }), - } - : (() => { - const detail = runInput.errorDetail(exit.cause); - return { - terminal: runInput.onFailure(detail), - reason: new StackUnavailableError({ phase: "failed", detail }), - }; - })(); - return cleanup(request); - }), - ); - }), - ); - return { service, run }; - }); - } -} diff --git a/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts b/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts deleted file mode 100644 index 74497cb00d..0000000000 --- a/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- Upgrade tests exercise native supervisor process services. -import { NodeServices } from "@effect/platform-node"; -import { it } from "@effect/vitest"; -import { Cause, Effect, Exit, Fiber, Option } from "effect"; -import * as TestClock from "effect/testing/TestClock"; -import { afterEach, describe, expect } from "vitest"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - ControlTransportError, - type ControlSupervisorStatus, - type ControlTransportShape, -} from "./managed/control.ts"; -import type { ManagedStack, ManagedStackManagerShape } from "./managed/manager.ts"; -import type { SupervisorStartMessage } from "./SupervisorProtocol.ts"; -import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; -import { reservePortSet } from "./PortAllocator.ts"; -import { prepareUpgradeReplacement } from "./SupervisorUpgradeRestart.ts"; -import { StopTimeout, UpgradePreflightError, UpgradeRestartError } from "./errors.ts"; -import type { DaemonConfigInput } from "./StackConfigResolver.ts"; -import { fillServiceVersionManifest } from "./versions.ts"; - -const roots: Array = []; - -const allPersistedVersions = fillServiceVersionManifest({}); - -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); -}); - -const setup = (persistedVersions: Partial> = { auth: "v-old" }) => { - const root = mkdtempSync(join(tmpdir(), "supervisor-upgrade-restart-")); - roots.push(root); - const workspacePath = join(root, "workspace"); - const stateRoot = join(root, "state"); - mkdirSync(workspacePath); - mkdirSync(stateRoot); - const stackId = "a".repeat(64); - const endpoint = { - hostname: "127.0.0.1", - port: 54321, - url: "http://127.0.0.1:54321", - } as const; - const status: ControlSupervisorStatus = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: stackId, - ownerSessionId: "old-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "old", - }; - const oldOwner = { - endpoint, - status, - }; - const document: ManagedStack = { - format: "supabase-stack", - formatVersion: 1, - id: stackId, - identity: { - workspaceId: "workspace", - checkoutId: "checkout", - contextId: "context", - localProjectKey: ".", - name: "default", - }, - workspace: { - kind: "folder", - checkoutKind: "folder", - path: workspacePath, - branch: "main", - }, - ports: [], - lifecycle: "running", - launch: { - mode: "native", - versions: persistedVersions, - excludedServices: [], - }, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; - const unused = () => Effect.die("unused manager operation"); - const manager: ManagedStackManagerShape = { - stateRoot, - discoverWorkspace: unused, - ensureWorkspace: unused, - acquireControl: unused, - probeControl: unused, - readStack: unused, - startStack: unused, - inspectStack: () => Effect.succeed(document), - listStacks: Effect.die("unused manager operation"), - allocateManagedPorts: unused, - validateManagedPortReservations: () => Effect.void, - recordLifecycle: unused, - updateLaunch: unused, - repairWorkspace: unused, - deleteStack: unused, - }; - const stopState = { requested: false }; - const stopRequest = { intent: undefined as "explicit" | "replacement" | undefined }; - const transport: ControlTransportShape = { - bind: () => Effect.die("unused bind"), - read: (readEndpoint) => - stopState.requested - ? Effect.fail( - new ControlTransportError({ - endpoint: readEndpoint, - reason: "unreachable", - cause: new Error("old owner ended"), - }), - ) - : Effect.succeed(status), - requestStop: (_endpoint, request) => - Effect.sync(() => { - stopState.requested = true; - stopRequest.intent = request.intent; - }), - }; - const configInput: DaemonConfigInput = { - cwd: workspacePath, - projectDir: workspacePath, - mode: "native", - auth: false, - postgrest: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }; - const input: SupervisorStartMessage = { - type: "start", - cliVersion: "new", - stackId, - workspacePath, - stackName: "default", - stateRoot, - config: configInput, - portIntents: { activeFields: ["apiPort", "dbPort"], document: {} }, - launch: { - mode: "native", - versions: { auth: "v-new" }, - excludedServices: ["auth"], - }, - }; - return { - configInput, - endpoint, - input, - manager, - oldCliVersion: "old", - oldOwner, - stackId, - stopState, - stopRequest, - transport, - }; -}; - -describe("incompatible supervisor upgrade restart", () => { - it.effect("bounds preflight before stopping the old owner", () => { - const context = setup(); - return Effect.gen(function* () { - const pending = yield* prepareUpgradeReplacement({ - ...context, - configInput: context.configInput, - manager: { ...context.manager, inspectStack: () => Effect.never }, - controlTransport: context.transport, - resolutionTimeout: "30 seconds", - }).pipe( - Effect.provide(NodeServices.layer), - Effect.scoped, - Effect.exit, - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.yieldNow; - yield* TestClock.adjust("30 seconds"); - yield* Effect.yieldNow; - const exit = yield* Fiber.join(pending); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(UpgradePreflightError); - expect(error.value).toMatchObject({ - detail: "Timed out preflighting upgrade restart", - }); - } - } - }); - }); - - it.live("uses the persisted launch instead of the restart invocation", () => { - const context = setup(); - return prepareUpgradeReplacement({ - ...context, - configInput: context.configInput, - controlTransport: context.transport, - }).pipe( - Effect.provide(NodeServices.layer), - Effect.scoped, - Effect.tap((result) => - Effect.sync(() => { - expect(result.effectiveConfigInput.auth).toEqual({ version: "v-old" }); - expect(result.effectiveConfigInput.servicePolicies?.auth).not.toBe("off"); - }), - ), - Effect.asVoid, - ); - }); - - it.live("fences upgrade replacement with replacement stop intent", () => { - const context = setup(); - return prepareUpgradeReplacement({ - ...context, - configInput: context.configInput, - controlTransport: context.transport, - }).pipe( - Effect.provide(NodeServices.layer), - Effect.scoped, - Effect.tap(() => - Effect.sync(() => { - expect(context.stopRequest.intent).toBe("replacement"); - }), - ), - Effect.asVoid, - ); - }); - - it.live("refreshes a launch update committed during the initial preflight", () => { - const context = setup(); - let inspections = 0; - return Effect.gen(function* () { - const initial = yield* context.manager.inspectStack(context.stackId); - if (initial === undefined) return yield* Effect.die("expected managed stack document"); - const refreshed: ManagedStack = { - ...initial, - launch: { - ...initial.launch, - versions: { ...initial.launch.versions, auth: "v-refreshed" }, - }, - }; - const result = yield* prepareUpgradeReplacement({ - ...context, - manager: { - ...context.manager, - inspectStack: () => Effect.sync(() => (inspections++ === 0 ? initial : refreshed)), - }, - controlTransport: context.transport, - }); - expect(result.effectiveConfigInput.auth).toEqual({ version: "v-refreshed" }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); - }); - - it.live("reports a restart failure when the refreshed launch cannot be preflighted", () => { - const context = setup(); - let inspections = 0; - return Effect.gen(function* () { - const exit = yield* prepareUpgradeReplacement({ - ...context, - manager: { - ...context.manager, - inspectStack: () => - inspections++ === 0 - ? context.manager.inspectStack(context.stackId) - : Effect.void.pipe(Effect.as(undefined)), - }, - controlTransport: context.transport, - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(UpgradeRestartError); - expect(error.value).toMatchObject({ - stackId: context.stackId, - newCliVersion: context.input.cliVersion, - detail: "Managed stack document is missing", - }); - } - } - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); - }); - - it.live("pins enabled services to persisted launch versions", () => { - const context = setup(); - const configInput = { ...context.configInput, auth: { version: "v-new" } }; - const launch = context.input.launch ?? { versions: {} }; - const input = { - ...context.input, - launch: { ...launch, excludedServices: [] }, - }; - return prepareUpgradeReplacement({ - ...context, - input, - configInput, - controlTransport: context.transport, - }).pipe( - Effect.provide(NodeServices.layer), - Effect.scoped, - Effect.tap((result) => - Effect.sync(() => { - expect(result.effectiveConfigInput.auth).toEqual({ version: "v-old" }); - }), - ), - Effect.asVoid, - ); - }); - - it.live("persists a concrete version for an enabled service introduced after the old CLI", () => { - const context = setup({ postgres: "pg-old" }); - const configInput = { ...context.configInput, auth: { version: "auth-current" } }; - return prepareUpgradeReplacement({ - ...context, - configInput, - controlTransport: context.transport, - }).pipe( - Effect.provide(NodeServices.layer), - Effect.scoped, - Effect.tap((result) => - Effect.sync(() => { - expect(result.launch.versions).toMatchObject({ - auth: "auth-current", - postgres: "pg-old", - }); - }), - ), - Effect.asVoid, - ); - }); - - it.live( - "rejects an occupied exact port for a newly activated sticky service before stopping", - () => { - const context = setup(); - return Effect.scoped( - Effect.gen(function* () { - const blocker = yield* reservePortSet([ - { field: "studioPort", selection: { kind: "automatic" } }, - ]); - yield* Effect.addFinalizer(() => blocker.releaseAll); - const occupiedPort = blocker.ports.studioPort; - if (occupiedPort === undefined) return yield* Effect.die("expected an occupied port"); - - const exit = yield* prepareUpgradeReplacement({ - ...context, - configInput: { - ...context.configInput, - edgeRuntime: { inspectorPort: occupiedPort, version: "edge-current" }, - }, - controlTransport: context.transport, - }).pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) expect(error.value).toBeInstanceOf(UpgradePreflightError); - } - expect(context.stopState.requested).toBe(false); - }), - ).pipe(Effect.provide(NodeServices.layer)); - }, - ); - - it.live("keeps restart-request exclusions from enabling native Docker-only services", () => { - const context = setup(allPersistedVersions); - const launch = context.input.launch ?? { versions: {} }; - const input = { - ...context.input, - launch: { ...launch, excludedServices: ["studio", "analytics"] }, - }; - return prepareUpgradeReplacement({ - ...context, - input, - configInput: context.configInput, - controlTransport: context.transport, - }).pipe( - Effect.provide(NodeServices.layer), - Effect.scoped, - Effect.tap((result) => - Effect.sync(() => { - for (const service of SERVICE_NAMES) { - if (SERVICE_CATALOG[service].runtimeSupport === "docker-only") { - const configKey = SERVICE_CATALOG[service].configKey; - expect(result.effectiveConfigInput[configKey]).toEqual( - context.configInput[configKey], - ); - } - } - }), - ), - Effect.asVoid, - ); - }); - - it.effect("reports the refreshed owner state when stop times out", () => { - const context = setup(); - const stoppingStatus: ControlSupervisorStatus = { - ...context.oldOwner.status, - state: "stopping", - ready: false, - }; - const transport: ControlTransportShape = { - ...context.transport, - read: () => Effect.succeed(stoppingStatus), - requestStop: () => Effect.never, - }; - return Effect.gen(function* () { - const pending = yield* prepareUpgradeReplacement({ - ...context, - controlTransport: transport, - resolutionTimeout: "30 seconds", - }).pipe( - Effect.provide(NodeServices.layer), - Effect.scoped, - Effect.exit, - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.yieldNow; - yield* TestClock.adjust("30 seconds"); - yield* Effect.yieldNow; - const exit = yield* Fiber.join(pending); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StopTimeout); - expect(error.value).toMatchObject({ lastState: "stopping" }); - } - } - }); - }); -}); diff --git a/packages/stack/src/SupervisorUpgradeRestart.ts b/packages/stack/src/SupervisorUpgradeRestart.ts deleted file mode 100644 index 95523b1c9b..0000000000 --- a/packages/stack/src/SupervisorUpgradeRestart.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { Duration, Effect, FileSystem, Match, Path } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { validateStackRuntime, type StackRuntimeSelection } from "./ContainerRuntime.ts"; -import type { SupervisorStartMessage } from "./SupervisorProtocol.ts"; -import { - observeControlStopForSession, - type ControlProbe, - type ControlTransportShape, -} from "./managed/control.ts"; -import type { ManagedStackManagerShape } from "./managed/manager.ts"; -import { managedStackPathsEffect } from "./managed/paths.ts"; -import type { ManagedStackLaunch } from "./managed/document.ts"; -import { PORT_CATALOG, PORT_FIELDS, type PortField } from "./PortCatalog.ts"; -import { reservePortSet } from "./PortAllocator.ts"; -import { portFieldsForConfigInput } from "./ServicePorts.ts"; -import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts"; -import { expandExcludedServices } from "./ServiceExclusions.ts"; -import { - portRequestsForConfig, - rawServiceEnabled, - resolveConfig, - type DaemonConfigInput, -} from "./StackConfigResolver.ts"; -import { versionsForConfig } from "./StackBuilder.ts"; -import { StopTimeout, UpgradePreflightError, UpgradeRestartError } from "./errors.ts"; -import { isControlSupervisorStatus } from "./DaemonProtocol.ts"; - -interface UpgradeRestartContext { - readonly stackId: string; - readonly oldCliVersion: string; - readonly oldOwner?: ControlProbe; - readonly input: SupervisorStartMessage; - readonly configInput: DaemonConfigInput; - readonly manager: ManagedStackManagerShape; - readonly controlTransport: ControlTransportShape; - readonly resolutionTimeout?: Duration.Input; -} - -export interface UpgradeRestartResult { - readonly effectiveConfigInput: DaemonConfigInput; - readonly launch: ManagedStackLaunch; -} - -const UPGRADE_RESTART_PHASE_TIMEOUT = Duration.seconds(30); - -const runtimeSelectionForLaunch = (launch: ManagedStackLaunch): StackRuntimeSelection => - launch.mode === "native" - ? { mode: "native", containerRuntime: null } - : { mode: "docker", containerRuntime: launch.containerRuntime }; - -const persistedPortField = (key: string): PortField | undefined => { - switch (key) { - case "api.port": - return "apiPort"; - case "db.port": - return "dbPort"; - case "edge_runtime.inspector_port": - return "edgeRuntimeInspectorPort"; - case "local_smtp.port": - return "mailpitPort"; - case "local_smtp.smtp_port": - return "mailpitSmtpPort"; - case "local_smtp.pop3_port": - return "mailpitPop3Port"; - case "studio.port": - return "studioPort"; - case "analytics.port": - return "analyticsPort"; - case "db.pooler.port": - return "poolerPort"; - default: - return undefined; - } -}; - -const isCatalogDefaultServiceConfig = (value: unknown): boolean => { - if (value === undefined) return true; - if (typeof value !== "object" || value === null) return false; - return Object.keys(value).every((key) => key === "version"); -}; - -export const applyNativeDefaults = (config: DaemonConfigInput): DaemonConfigInput => { - const servicePolicies = { ...config.servicePolicies }; - for (const service of SERVICE_NAMES) { - const metadata = SERVICE_CATALOG[service]; - if ( - metadata.runtimeSupport === "docker-only" && - servicePolicies[service] === undefined && - isCatalogDefaultServiceConfig(config[metadata.configKey]) - ) { - servicePolicies[service] = "off"; - } - } - return { ...config, servicePolicies }; -}; - -const enableService = ( - config: DaemonConfigInput, - service: (typeof SERVICE_NAMES)[number], - version: string | undefined, -): DaemonConfigInput => { - const versionField = version === undefined ? {} : { version }; - switch (service) { - case "postgres": - return { ...config, postgres: { ...config.postgres, ...versionField } }; - case "postgrest": - return { - ...config, - postgrest: { ...(config.postgrest === false ? {} : config.postgrest), ...versionField }, - }; - case "auth": - return { - ...config, - auth: { ...(config.auth === false ? {} : config.auth), ...versionField }, - }; - case "edge-runtime": - return { - ...config, - edgeRuntime: { - ...(config.edgeRuntime === false ? {} : config.edgeRuntime), - ...versionField, - }, - }; - case "realtime": - return { - ...config, - realtime: { ...(config.realtime === false ? {} : config.realtime), ...versionField }, - }; - case "storage": - return { - ...config, - storage: { ...(config.storage === false ? {} : config.storage), ...versionField }, - }; - case "imgproxy": - return { - ...config, - imgproxy: { ...(config.imgproxy === false ? {} : config.imgproxy), ...versionField }, - }; - case "mailpit": - return { - ...config, - mailpit: { ...(config.mailpit === false ? {} : config.mailpit), ...versionField }, - }; - case "pgmeta": - return { - ...config, - pgmeta: { ...(config.pgmeta === false ? {} : config.pgmeta), ...versionField }, - }; - case "studio": - return { - ...config, - studio: { ...(config.studio === false ? {} : config.studio), ...versionField }, - }; - case "analytics": - return { - ...config, - analytics: { ...(config.analytics === false ? {} : config.analytics), ...versionField }, - }; - case "vector": - return { - ...config, - vector: { ...(config.vector === false ? {} : config.vector), ...versionField }, - }; - case "pooler": - return { - ...config, - pooler: { ...(config.pooler === false ? {} : config.pooler), ...versionField }, - }; - } -}; - -/** Persisted exclusions are authoritative; restored services keep their pinned version. */ -const applyPersistedLaunch = ( - config: DaemonConfigInput, - persisted: ManagedStackLaunch, - requested: SupervisorStartMessage["launch"], -): DaemonConfigInput => { - let effective = config; - const restartRequestExclusions = expandExcludedServices(requested?.excludedServices ?? []); - const persistedExclusions = expandExcludedServices(persisted.excludedServices ?? []); - const servicesToEnable = new Set<(typeof SERVICE_NAMES)[number]>(); - for (const service of restartRequestExclusions) { - if (persisted.mode !== "native" || SERVICE_CATALOG[service].runtimeSupport !== "docker-only") { - servicesToEnable.add(service); - } - } - for (const service of SERVICE_NAMES) { - if ( - !persistedExclusions.has(service) && - persisted.versions[service] !== undefined && - rawServiceEnabled(config, service) - ) { - servicesToEnable.add(service); - } - } - for (const service of servicesToEnable) { - if (!persistedExclusions.has(service)) { - effective = enableService(effective, service, persisted.versions[service]); - } - } - const servicePolicies = { ...effective.servicePolicies }; - for (const service of servicesToEnable) { - if (!persistedExclusions.has(service) && servicePolicies[service] === "off") { - delete servicePolicies[service]; - } - } - for (const excluded of persistedExclusions) { - servicePolicies[excluded] = "off"; - } - return { ...effective, servicePolicies }; -}; - -const preflightError = (context: UpgradeRestartContext, detail: string): UpgradePreflightError => - new UpgradePreflightError({ - stackId: context.stackId, - oldCliVersion: context.oldCliVersion, - newCliVersion: context.input.cliVersion, - detail, - }); - -const causeMessage = (cause: unknown): string => { - if ( - typeof cause === "object" && - cause !== null && - "detail" in cause && - typeof cause.detail === "string" - ) { - return cause.detail; - } - if ( - typeof cause === "object" && - cause !== null && - "cause" in cause && - cause.cause !== undefined && - cause.cause !== cause - ) { - return causeMessage(cause.cause); - } - if (cause instanceof Error && cause.message.length > 0) return cause.message; - return typeof cause === "string" ? cause : String(cause); -}; - -const preflight = ( - context: UpgradeRestartContext, -): Effect.Effect< - UpgradeRestartResult, - UpgradePreflightError, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner -> => - Effect.gen(function* () { - const existing = yield* context.manager - .inspectStack(context.stackId) - .pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); - if (existing === undefined) - return yield* preflightError(context, "Managed stack document is missing"); - - const persistedRuntime = runtimeSelectionForLaunch(existing.launch); - yield* validateStackRuntime(persistedRuntime).pipe( - Effect.mapError((cause) => preflightError(context, causeMessage(cause))), - ); - - const withExclusions = applyPersistedLaunch( - context.configInput, - existing.launch, - context.input.launch, - ); - const effectiveConfigInput = - persistedRuntime.mode === "native" && context.configInput.mode === undefined - ? applyNativeDefaults(withExclusions) - : withExclusions; - const portRequests = yield* portRequestsForConfig(effectiveConfigInput, { - runtime: persistedRuntime, - }).pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); - const activeFields = portFieldsForConfigInput({ - ...effectiveConfigInput, - mode: persistedRuntime.mode, - }); - const activeFieldSet = new Set(activeFields); - yield* context.manager - .validateManagedPortReservations({ - stackId: context.stackId, - portDocument: { - ...context.input.portIntents, - activeFields, - disabledFields: PORT_FIELDS.filter( - (field) => PORT_CATALOG[field].persistence === "sticky" && !activeFieldSet.has(field), - ), - }, - persisted: existing.ports, - preservePersisted: true, - }) - .pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); - - const persistedFields = new Set( - existing.ports.flatMap((assignment) => { - const field = persistedPortField(assignment.key); - return field === undefined ? [] : [field]; - }), - ); - const newlyActivatedExactPorts = portRequests.filter( - (request) => - request.selection.kind === "exact" && - PORT_CATALOG[request.field].persistence === "sticky" && - !persistedFields.has(request.field), - ); - if (newlyActivatedExactPorts.length > 0) { - yield* Effect.acquireUseRelease( - reservePortSet(newlyActivatedExactPorts), - () => Effect.void, - (lease) => lease.releaseAll, - ).pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); - } - - const paths = yield* managedStackPathsEffect(context.input.stateRoot, existing.id).pipe( - Effect.mapError((cause) => preflightError(context, causeMessage(cause))), - ); - const syntheticPorts: Partial> = {}; - for (const assignment of existing.ports) { - const field = persistedPortField(assignment.key); - if (field !== undefined) syntheticPorts[field] = assignment.port; - } - for (const [index, field] of activeFields.entries()) { - if (syntheticPorts[field] === undefined) - syntheticPorts[field] = PORT_CATALOG[field].preferred ?? 60_000 + index; - } - const resolvedConfig = yield* resolveConfig( - { - ...effectiveConfigInput, - projectDir: effectiveConfigInput.projectDir ?? context.input.workspacePath, - mode: persistedRuntime.mode, - }, - { - runtime: persistedRuntime, - stackRoot: paths.root, - runtimeRoot: paths.runtime, - ports: syntheticPorts, - }, - ).pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); - return { - effectiveConfigInput, - launch: { - ...existing.launch, - versions: { - ...versionsForConfig(resolvedConfig), - ...existing.launch.versions, - }, - }, - }; - }); - -/** - * Parent-side replacement transaction. It proves the persisted launch is - * startable before asking the exact incompatible session to stop, then reads - * and validates the launch again before a fresh child is spawned. - */ -export const prepareUpgradeReplacement = ( - context: UpgradeRestartContext, -): Effect.Effect< - UpgradeRestartResult, - UpgradePreflightError | StopTimeout | UpgradeRestartError, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner -> => - Effect.gen(function* () { - const phaseTimeout = context.resolutionTimeout ?? UPGRADE_RESTART_PHASE_TIMEOUT; - const preflightCurrentLaunch = () => - preflight(context).pipe( - Effect.timeout(phaseTimeout), - Effect.catchTag("TimeoutError", () => - Effect.fail(preflightError(context, "Timed out preflighting upgrade restart")), - ), - ); - const initial = yield* preflightCurrentLaunch(); - const oldOwner = context.oldOwner; - if (oldOwner === undefined) return initial; - if (!isControlSupervisorStatus(oldOwner.status)) { - return yield* new UpgradeRestartError({ - stackId: context.stackId, - newCliVersion: context.input.cliVersion, - detail: `Managed stack is busy with ${oldOwner.status.operation} maintenance`, - }); - } - const oldStatus = oldOwner.status; - yield* observeControlStopForSession( - oldOwner.endpoint, - oldStatus.ownershipId, - oldStatus.ownerSessionId, - context.controlTransport, - "replacement", - phaseTimeout, - ).pipe( - Effect.flatMap((result) => - Match.valueTags(result, { - ended: () => Effect.void, - replaced: () => Effect.void, - "still-live": ({ lastState }) => - Effect.fail( - new StopTimeout({ - endpoint: oldOwner.endpoint.url, - ownerSessionId: oldStatus.ownerSessionId, - lastState, - }), - ), - }), - ), - Effect.mapError((error) => - error instanceof StopTimeout - ? error - : new UpgradeRestartError({ - stackId: context.stackId, - newCliVersion: context.input.cliVersion, - detail: causeMessage(error), - }), - ), - ); - return yield* preflightCurrentLaunch().pipe( - Effect.mapError( - (error) => - new UpgradeRestartError({ - stackId: context.stackId, - newCliVersion: context.input.cliVersion, - detail: causeMessage(error), - }), - ), - ); - }); - -export { runtimeSelectionForLaunch }; diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts deleted file mode 100644 index 821af22053..0000000000 --- a/packages/stack/src/bun.ts +++ /dev/null @@ -1,62 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/multiple-effect-provide -- Public Bun Promise facades intentionally bridge host async calls; platform and transport layers are staged to preserve dependency and scope ordering. - -import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { BinaryResolver } from "./BinaryResolver.ts"; -import { selectStackRuntime } from "./ContainerRuntime.ts"; -import { createStack as createStackCore, type ResolveConfigEffect } from "./createStack.ts"; -import { toStackHandle, type StackHandle } from "./stackHandle.ts"; -import { toStackError } from "./errors.ts"; -import { - prefetch as prefetchEffect, - type PrefetchEffectOptions, - type PrefetchOptions, - type PrefetchResult, -} from "./prefetch.ts"; -import { defaultCacheRoot } from "./paths.ts"; -import { platformFactory } from "./platform-bun.ts"; -import { StackPreparation } from "./StackPreparation.ts"; -import type { StackConfig } from "./StackConfig.ts"; -import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; - -const resolveConfigEffectForPlatform: ResolveConfigEffect = (config, options) => - resolveConfigEffect(config, options); - -export async function createStack(config?: StackConfig): Promise { - const runtime = await Effect.runPromise( - selectStackRuntime(config?.mode).pipe(Effect.provide(BunServices.layer)), - ).catch((error: unknown) => { - throw toStackError(error); - }); - const handle = await Effect.runPromise( - createStackCore(config, platformFactory, runtime, resolveConfigEffectForPlatform).pipe( - Effect.provide(BunServices.layer), - ), - ); - return toStackHandle(handle); -} - -export async function prefetch(options?: PrefetchOptions): Promise { - const runtime = await Effect.runPromise( - selectStackRuntime(options?.mode).pipe(Effect.provide(BunServices.layer)), - ).catch((error: unknown) => { - throw toStackError(error); - }); - const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( - Layer.provide(FetchHttpClient.layer), - ); - const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); - const resolvedOptions: PrefetchEffectOptions = - runtime.mode === "native" - ? { ...options, mode: "native" } - : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; - return Effect.runPromise( - prefetchEffect(resolvedOptions).pipe( - Effect.provide(preparationLayer), - Effect.provide(BunServices.layer), - ), - ); -} - -export * from "./index.ts"; diff --git a/packages/stack/src/cleanup.ts b/packages/stack/src/cleanup.ts deleted file mode 100644 index c83a8c55e9..0000000000 --- a/packages/stack/src/cleanup.ts +++ /dev/null @@ -1,108 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Cleanup invokes the native child-process CLI as a best-effort leaf boundary. -import { execFile } from "node:child_process"; -import { Data, Duration, Effect, FileSystem, Schedule } from "effect"; -import type { ContainerRuntime } from "./ContainerRuntime.ts"; -import type { CleanupTargets } from "./CleanupTargets.ts"; -import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts"; -import type { ResolvedStackConfig } from "./StackConfig.ts"; -import { dockerContainerName, stackIdentity } from "./StackIdentity.ts"; - -export const candidateCleanupTargets = (config: ResolvedStackConfig): CleanupTargets => { - const identity = stackIdentity(config); - return { - dockerContainerNames: SERVICE_NAMES.filter((service) => { - const serviceConfig = config[serviceMetadata(service).configKey]; - return service === "postgres" || serviceConfig !== false; - }).map((service) => dockerContainerName(service, identity.key)), - }; -}; - -/** - * Force-remove Docker containers by name. Best-effort safety net — - * silently ignores containers that don't exist or are already removed. - */ -export const dockerForceRemove = ( - runtime: ContainerRuntime, - containerNames: ReadonlyArray, -): Effect.Effect => - Effect.forEach( - containerNames, - (name) => - Effect.callback((resume) => { - const child = execFile(runtime, ["rm", "-f", name], { timeout: 5_000 }, () => - resume(Effect.void), - ); - return Effect.sync(() => child.kill()); - }), - { concurrency: 4, discard: true }, - ); - -class CleanupPending extends Data.TaggedError("CleanupPending")<{}> {} - -const cleanupAutoManagedPathsWithRetry = ( - paths: ReadonlyArray, -): Effect.Effect => - Effect.gen(function* () { - if (paths.length === 0) { - return; - } - - const fs = yield* FileSystem.FileSystem; - const attempt = Effect.gen(function* () { - yield* Effect.forEach( - paths, - (path) => fs.remove(path, { recursive: true, force: true }).pipe(Effect.ignore), - { concurrency: 4, discard: true }, - ); - const remaining = yield* Effect.forEach( - paths, - (path) => - fs.exists(path).pipe(Effect.catchTag("PlatformError", () => Effect.succeed(true))), - { concurrency: 4 }, - ); - if (remaining.some(Boolean)) return yield* new CleanupPending(); - }).pipe(Effect.uninterruptible); - const retries = Effect.sleep(Duration.millis(250)).pipe( - Effect.andThen( - attempt.pipe( - Effect.retry( - Schedule.recurs(78).pipe(Schedule.addDelay(() => Effect.succeed(Duration.millis(250)))), - ), - ), - ), - // Cleanup callers mask their surrounding teardown transaction. Re-enable - // interruption only for the bounded waits after the guaranteed first pass. - Effect.interruptible, - ); - yield* attempt.pipe( - Effect.catchTag("CleanupPending", () => retries), - Effect.catchTag("CleanupPending", () => Effect.void), - ); - }); - -export const cleanupAutoManagedPaths = ( - paths: ReadonlyArray, -): Effect.Effect => cleanupAutoManagedPathsWithRetry(paths); - -export const cleanupLocalStackResources = (opts: { - readonly stop: () => Effect.Effect; - readonly cleanupTargets: CleanupTargets; - readonly config: ResolvedStackConfig; -}): Effect.Effect => - Effect.gen(function* () { - // The Stack stop contract is infallible and owns graceful service - // shutdown. Keep it uninterruptible so SIGTERM-driven scope closure does - // not abandon the transaction mid-shutdown and leak child processes. - yield* Effect.uninterruptible(opts.stop()); - - // Safety net: force-remove any Docker containers that survived - // signal-based shutdown. On macOS, killing the `docker run` client - // may not stop the container. - if (opts.config.runtime.mode === "docker") { - yield* dockerForceRemove( - opts.config.runtime.containerRuntime, - opts.cleanupTargets.dockerContainerNames, - ); - } - yield* cleanupAutoManagedPathsWithRetry(opts.config.autoManagedPaths); - }); diff --git a/packages/stack/src/cleanup.unit.test.ts b/packages/stack/src/cleanup.unit.test.ts deleted file mode 100644 index 0828e5ad21..0000000000 --- a/packages/stack/src/cleanup.unit.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, FileSystem } from "effect"; -import { systemError } from "effect/PlatformError"; -import * as TestClock from "effect/testing/TestClock"; -import { cleanupAutoManagedPaths } from "./cleanup.ts"; - -describe("automatic managed-path cleanup", () => { - it.effect("can be interrupted while masked teardown waits to retry a busy path", () => - Effect.gen(function* () { - const attempted = yield* Deferred.make(); - const finished = yield* Deferred.make(); - const fs = FileSystem.makeNoop({ - remove: () => Effect.void, - exists: () => Deferred.succeed(attempted, undefined).pipe(Effect.as(true)), - }); - const cleanup = cleanupAutoManagedPaths(["/owned"]).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - // Production teardown masks the surrounding cleanup transaction. The - // bounded retry wait must still remain interruptible within that mask. - Effect.uninterruptible, - Effect.ensuring(Deferred.succeed(finished, undefined)), - ); - const fiber = yield* cleanup.pipe(Effect.forkChild({ startImmediately: true })); - - yield* Deferred.await(attempted); - const interruption = yield* Fiber.interrupt(fiber).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.yieldNow; - const interruptedBeforeNextRetry = yield* Deferred.isDone(finished); - - yield* TestClock.adjust("20 seconds"); - yield* Fiber.join(interruption); - expect(interruptedBeforeNextRetry).toBe(true); - }), - ); - - it.effect("retries when path existence cannot be determined", () => - Effect.gen(function* () { - const checked = yield* Deferred.make(); - let removalAttempts = 0; - const fs = FileSystem.makeNoop({ - remove: () => - Effect.sync(() => { - removalAttempts += 1; - }), - exists: () => - Deferred.succeed(checked, undefined).pipe( - Effect.andThen( - Effect.fail( - systemError({ - _tag: "PermissionDenied", - module: "test", - method: "exists", - }), - ), - ), - ), - }); - const fiber = yield* cleanupAutoManagedPaths(["/owned"]).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.forkChild({ startImmediately: true }), - ); - - yield* Deferred.await(checked); - yield* TestClock.adjust("250 millis"); - yield* Effect.yieldNow; - expect(removalAttempts).toBe(2); - yield* Fiber.interrupt(fiber); - }), - ); -}); diff --git a/packages/stack/src/compiled-supervisor.integration.test.ts b/packages/stack/src/compiled-supervisor.integration.test.ts deleted file mode 100644 index ea25c4af8b..0000000000 --- a/packages/stack/src/compiled-supervisor.integration.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/crypto-random-uuid, effecttsgo/global-fetch, effecttsgo/global-fetch-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json, effecttsgo/process-env -- Compiled-supervisor tests cross native child-process, filesystem, HTTP, environment, and serialization boundaries from Vitest's Promise harness. -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { fork, type ChildProcess } from "node:child_process"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { Data, Effect, Schedule, Schema } from "effect"; -import { describe, expect, test, beforeAll, afterAll } from "vitest"; -import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; -import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; -import { managedStackDocumentPathEffect } from "./managed/paths.ts"; -import { managedStackLaunchInputSchema } from "./managed/document.ts"; -import { - CompiledSupervisorParentEventSchema, - type CompiledSupervisorStartMessage, -} from "../tests/helpers/compiled-supervisor-parent.ts"; -import { SupervisorStartCommandSchema } from "./SupervisorProtocol.ts"; - -const execFileAsync = promisify(execFile); -const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; -const parentEntryPoint = fileURLToPath( - new URL("../tests/helpers/compiled-supervisor-parent.ts", import.meta.url), -); - -interface TestRoots { - readonly root: string; - readonly stateRoot: string; - readonly stackId: string; -} - -interface CompiledParent { - readonly child: ChildProcess; - readonly ready: Promise; - readonly exited: Promise; -} - -let artifactRoot: string; -let compiledParentPath: string; - -const makeWorkspace = (): TestRoots => { - const root = mkdtempSync(join(tmpdir(), "sup-stack-compiled-workspace-")); - const stateRoot = mkdtempSync(join(tmpdir(), "sup-stack-compiled-state-")); - const identity: EnvironmentIdentity = { - workspaceId: crypto.randomUUID(), - checkoutId: crypto.randomUUID(), - contextId: crypto.randomUUID(), - localProjectKey: ".", - }; - mkdirSync(join(root, ".supabase"), { recursive: true }); - writeFileSync( - join(root, ".supabase", "identity.json"), - `${JSON.stringify({ version: 1, ...identity }, null, 2)}\n`, - ); - return { root, stateRoot, stackId: deriveStackId(identity, "default") }; -}; - -const messageFor = ( - roots: TestRoots, - overrides: Partial = {}, -): CompiledSupervisorStartMessage => ({ - type: "start", - cliVersion: "2.61.0", - stackId: roots.stackId, - workspacePath: roots.root, - stackName: "default", - stateRoot: roots.stateRoot, - config: { - cwd: roots.root, - projectDir: roots.root, - mode: "native", - auth: false, - postgrest: false, - realtime: false, - storage: false, - imgproxy: false, - localSmtp: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }, - portIntents: { activeFields: ["apiPort", "dbPort"], document: {} }, - launch: { - mode: "native", - versions: { postgres: "pinned-postgres" }, - excludedServices: ["analytics"], - }, - ...overrides, -}); - -const waitForExit = (child: ChildProcess): Promise => - new Promise((resolve) => { - if (child.exitCode !== null) { - resolve(); - return; - } - child.once("exit", () => resolve()); - }); - -const spawnCompiledParent = ( - input: CompiledSupervisorStartMessage, - environment: Readonly> = {}, -): CompiledParent => { - const child = fork(parentEntryPoint, [], { - execPath: compiledParentPath, - detached: false, - stdio: ["ignore", "pipe", "pipe", "ipc"], - env: { - ...process.env, - SUPABASE_STACK_TEST_PLATFORM: "bun", - ...environment, - }, - }); - let stderr = ""; - child.stderr?.on("data", (chunk: Uint8Array) => { - stderr += new TextDecoder().decode(chunk); - }); - const ready = new Promise((resolve, reject) => { - const onMessage = (raw: unknown) => { - let event: Schema.Schema.Type; - try { - event = Schema.decodeUnknownSync(CompiledSupervisorParentEventSchema)(raw); - } catch { - return; - } - if (event.type === "ready") { - cleanup(); - resolve(); - } else { - cleanup(); - reject(new Error(`${event.message}\n${stderr}`)); - } - }; - const onError = (cause: Error) => { - cleanup(); - reject(cause); - }; - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - reject( - new Error( - `compiled supervisor parent exited (${String(code)}, ${String(signal)})\n${stderr}`, - ), - ); - }; - const cleanup = () => { - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - child.on("message", onMessage); - child.once("error", onError); - child.once("exit", onExit); - }); - const encoded = Schema.encodeSync(SupervisorStartCommandSchema)(input); - child.send(encoded); - return { child, ready, exited: waitForExit(child) }; -}; - -const owner = async (endpoint: ControlEndpoint) => { - const response = await fetch(`${endpoint.url}/owner`); - expect(response.status).toBe(200); - return (await response.json()) as { - readonly ownershipId: string; - readonly ownerSessionId: string; - readonly daemonCliVersion: string; - readonly state: string; - readonly ready: boolean; - }; -}; - -const stop = async ( - endpoint: ControlEndpoint, - ownershipId: string, - ownerSessionId: string, - intent: "explicit" | "replacement" = "explicit", -): Promise => - fetch(`${endpoint.url}/stop`, { - method: "POST", - headers: { "content-type": "application/json", connection: "close" }, - body: JSON.stringify({ ownershipId, ownerSessionId, intent }), - }); - -const waitForProcessExit = (pid: number): Promise => { - const attempt = Effect.try({ - try: () => { - process.kill(pid, 0); - return false; - }, - catch: () => undefined, - }).pipe(Effect.orElseSucceed(() => true)); - const probe = attempt.pipe( - Effect.flatMap((exited) => - exited ? Effect.succeed(true) : Effect.fail(new EndpointStillAliveError()), - ), - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), - Effect.asVoid, - ); - return Effect.runPromise(probe); -}; - -const documentFor = (roots: TestRoots) => { - const path = Effect.runSync(managedStackDocumentPathEffect(roots.stateRoot, roots.stackId)); - return { path, value: JSON.parse(readFileSync(path, "utf8")) as Record }; -}; - -const waitForDocumentLifecycle = (roots: TestRoots, lifecycle: string): Promise => { - const path = Effect.runSync(managedStackDocumentPathEffect(roots.stateRoot, roots.stackId)); - const probe = Effect.try({ - try: () => { - const value = JSON.parse(readFileSync(path, "utf8")) as { readonly lifecycle?: string }; - if (value.lifecycle !== lifecycle) throw new Error("document lifecycle has not settled"); - }, - catch: (cause) => new DocumentNotReadyError({ cause: String(cause) }), - }).pipe( - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), - Effect.asVoid, - ); - return Effect.runPromise(probe); -}; - -class EndpointStillAliveError extends Data.TaggedError("EndpointStillAliveError")<{}> {} - -class DocumentNotReadyError extends Data.TaggedError("DocumentNotReadyError")<{ - readonly cause: string; -}> {} - -class EndpointUnavailableError extends Data.TaggedError("EndpointUnavailableError")<{ - readonly cause: string; -}> {} - -const waitForEndpointUnavailable = (endpoint: ControlEndpoint): Promise => { - const attempt = Effect.tryPromise({ - try: async () => { - const response = await fetch(`${endpoint.url}/owner`); - if (response.ok) throw new EndpointStillAliveError(); - }, - catch: (cause) => - cause instanceof EndpointStillAliveError - ? cause - : new EndpointUnavailableError({ cause: String(cause) }), - }).pipe( - Effect.catchTags({ - EndpointStillAliveError: (cause) => Effect.fail(cause), - EndpointUnavailableError: () => Effect.void, - }), - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), - Effect.asVoid, - ); - return Effect.runPromise(attempt); -}; - -const endpointFor = (roots: TestRoots): Promise => - Effect.runPromise(controlEndpoint(roots.stackId)); - -const cleanup = (roots: TestRoots): void => { - rmSync(roots.root, { recursive: true, force: true }); - rmSync(roots.stateRoot, { recursive: true, force: true }); -}; - -const killPid = (pid: number): void => { - try { - process.kill(pid, "SIGKILL"); - } catch {} -}; - -describe("compiled Bun detached supervisor", () => { - beforeAll(async () => { - artifactRoot = mkdtempSync(join(tmpdir(), "sup-stack-compiled-artifact-")); - compiledParentPath = join(artifactRoot, "compiled-supervisor-parent"); - await execFileAsync(bunExecutable, [ - "build", - parentEntryPoint, - "--compile", - `--outfile=${compiledParentPath}`, - ]); - }, 120_000); - - afterAll(() => { - rmSync(artifactRoot, { recursive: true, force: true }); - }); - - test("starts, attaches, session-stops, and replaces through compiled child re-entry", async () => { - const roots = makeWorkspace(); - let first: CompiledParent | undefined; - let attached: CompiledParent | undefined; - let upgradeRestart: CompiledParent | undefined; - const runtimePids = new Set(); - try { - const endpoint = await endpointFor(roots); - first = spawnCompiledParent(messageFor(roots)); - await first.ready; - const firstOwner = await owner(endpoint); - expect(firstOwner).toMatchObject({ - ownershipId: roots.stackId, - daemonCliVersion: "2.61.0", - state: "running", - ready: true, - }); - const before = documentFor(roots).value; - const runtime = before["runtime"] as { readonly pid: number }; - runtimePids.add(runtime.pid); - const pathsRoot = dirname(documentFor(roots).path); - const sentinel = join(pathsRoot, "data", "compiled-preservation.txt"); - mkdirSync(dirname(sentinel), { recursive: true }); - writeFileSync(sentinel, "compiled-preserve"); - - attached = spawnCompiledParent(messageFor(roots)); - await attached.ready; - expect(await owner(endpoint)).toMatchObject({ - ownerSessionId: firstOwner.ownerSessionId, - daemonCliVersion: firstOwner.daemonCliVersion, - state: "running", - }); - await attached.exited; - - const stopped = await stop(endpoint, firstOwner.ownershipId, firstOwner.ownerSessionId); - expect(stopped.status).toBe(202); - await waitForProcessExit(runtime.pid); - await first.exited; - await waitForDocumentLifecycle(roots, "stopped"); - await waitForEndpointUnavailable(endpoint); - expect(documentFor(roots).value["lifecycle"] as string).toBe("stopped"); - - first = spawnCompiledParent( - messageFor(roots, { - cliVersion: "2.60.0", - launch: { - mode: "native", - versions: { postgres: "old-pinned" }, - excludedServices: ["analytics"], - }, - }), - ); - await first.ready; - const oldOwner = await owner(endpoint); - const oldDocument = documentFor(roots).value; - const oldRuntime = oldDocument["runtime"] as { readonly pid: number }; - runtimePids.add(oldRuntime.pid); - const oldLaunch = Schema.decodeUnknownSync(managedStackLaunchInputSchema)( - oldDocument["launch"], - ); - const oldPorts = oldDocument["ports"]; - writeFileSync(sentinel, "upgrade-restart-preserve"); - - expect( - await stop(endpoint, oldOwner.ownershipId, oldOwner.ownerSessionId, "replacement"), - ).toMatchObject({ status: 202 }); - await waitForProcessExit(oldRuntime.pid); - await first.exited; - await waitForEndpointUnavailable(endpoint); - - upgradeRestart = spawnCompiledParent( - messageFor(roots, { - replacement: true, - cliVersion: "2.61.0", - launch: oldLaunch, - }), - ); - await upgradeRestart.ready; - const currentOwner = await owner(endpoint); - expect(currentOwner).toMatchObject({ - daemonCliVersion: "2.61.0", - state: "running", - ready: true, - }); - expect(currentOwner.ownerSessionId).not.toBe(oldOwner.ownerSessionId); - const staleStop = await stop(endpoint, oldOwner.ownershipId, oldOwner.ownerSessionId); - expect(staleStop.status).toBe(409); - const after = documentFor(roots).value; - expect(after["id"]).toBe(oldDocument["id"]); - expect(after["createdAt"]).toBe(oldDocument["createdAt"]); - expect(after["launch"]).toEqual(oldLaunch); - expect(after["ports"]).toEqual(oldPorts); - expect(readFileSync(sentinel, "utf8")).toBe("upgrade-restart-preserve"); - - const restartedRuntime = after["runtime"] as { readonly pid: number }; - runtimePids.add(restartedRuntime.pid); - expect( - await stop(endpoint, currentOwner.ownershipId, currentOwner.ownerSessionId), - ).toMatchObject({ - status: 202, - }); - await waitForProcessExit(restartedRuntime.pid); - await upgradeRestart.exited; - } finally { - for (const pid of runtimePids) killPid(pid); - for (const handle of [first, attached, upgradeRestart]) { - if (handle?.child.exitCode === null) handle.child.kill("SIGKILL"); - } - cleanup(roots); - } - }, 120_000); -}); diff --git a/packages/stack/src/createStack.integration.test.ts b/packages/stack/src/createStack.integration.test.ts deleted file mode 100644 index 26113c6a9e..0000000000 --- a/packages/stack/src/createStack.integration.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Integration tests await the public stack facade and coordinate native process fixtures. - -import { createServer } from "node:net"; -import { existsSync } from "node:fs"; -import { afterEach, describe, expect, it } from "vitest"; -import { NodeFileSystem, NodeServices } from "@effect/platform-node"; -import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, Option } from "effect"; -import { systemError } from "effect/PlatformError"; -import { - createStack, - type ForegroundStackHandle, - type PlatformFactory, - type ResolveConfigEffect, -} from "./createStack.ts"; -import { platformFactory } from "./platform-node.ts"; -import { resolveConfig, resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; -import type { PortSet } from "./PortCatalog.ts"; -import { toStackHandle } from "./stackHandle.ts"; - -const handles: ForegroundStackHandle[] = []; -const testPorts: PortSet = { - apiPort: 40_000, - dbPort: 40_001, - authPort: 40_002, - postgrestPort: 40_003, - postgrestAdminPort: 40_004, - edgeRuntimePort: 40_005, - edgeRuntimeInspectorPort: 40_006, - realtimePort: 40_007, - storagePort: 40_008, - imgproxyPort: 40_009, - mailpitPort: 40_010, - mailpitSmtpPort: 40_011, - mailpitPop3Port: 40_012, - pgmetaPort: 40_013, - studioPort: 40_014, - analyticsPort: 40_015, - poolerPort: 40_016, - poolerApiPort: 40_017, -}; - -afterEach(() => { - const owned = handles.splice(0); - return Effect.runPromise(Effect.forEach(owned, (handle) => handle.dispose, { discard: true })); -}); - -describe("direct createStack port ownership", () => { - it("retries an automatic API port when the platform bind reports EADDRINUSE as a defect", async () => { - let attempts = 0; - const bindError = Object.assign(new Error("address already in use"), { code: "EADDRINUSE" }); - const collidingPlatformFactory: PlatformFactory = (options) => { - attempts += 1; - const platform = platformFactory(options); - return attempts < 3 - ? Layer.mergeAll(platform, Layer.effectDiscard(Effect.die(bindError))) - : platform; - }; - - const stack = await Effect.runPromise( - createStack( - { mode: "native", postgrest: false, auth: false }, - collidingPlatformFactory, - { mode: "native", containerRuntime: null }, - resolveConfig, - ).pipe(Effect.provide(NodeServices.layer)), - ); - handles.push(stack); - - expect(attempts).toBe(3); - }); - - it("allocates only active service fields without managed state", async () => { - const stack = await Effect.runPromise( - createStack( - { - mode: "native", - postgrest: false, - auth: false, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }, - platformFactory, - { mode: "native", containerRuntime: null }, - resolveConfig, - ).pipe(Effect.provide(NodeServices.layer)), - ); - handles.push(stack); - - expect(stack.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); - expect(stack.dbUrl).toMatch(/127\.0\.0\.1:\d+/); - const activeServices = new Set( - (await Effect.runPromise(stack.getStatus)).map((state) => state.name), - ); - expect(activeServices).not.toContain("studio"); - expect(activeServices).not.toContain("analytics"); - expect(activeServices).not.toContain("pooler"); - }); - - it("isolates resolver-owned roots across repeated evaluations", async () => { - const createdPaths: string[] = []; - let failNextRoot = false; - const result = await Effect.runPromise( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const trackingFs = { - ...fs, - makeTempDirectory: (options: Parameters[0]) => - Effect.suspend(() => - failNextRoot - ? Effect.fail( - systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "makeTempDirectory", - description: "injected failure", - }), - ) - : fs - .makeTempDirectory(options) - .pipe(Effect.tap((path) => Effect.sync(() => createdPaths.push(path)))), - ), - }; - const resolver = resolveConfigEffect({ mode: "native" }, { ports: testPorts }); - const firstConfig = yield* resolver.pipe( - Effect.provideService(FileSystem.FileSystem, trackingFs), - ); - const firstPaths = [...firstConfig.autoManagedPaths]; - failNextRoot = true; - const secondExit = yield* resolver.pipe( - Effect.provideService(FileSystem.FileSystem, trackingFs), - Effect.exit, - ); - const firstPathsExist = firstPaths.map((path) => existsSync(path)); - yield* Effect.forEach( - firstPaths, - (path) => fs.remove(path, { recursive: true, force: true }), - { discard: true }, - ); - return { firstPaths, firstPathsExist, secondExit }; - }).pipe(Effect.provide(NodeFileSystem.layer)), - ); - - expect(result.firstPaths).toHaveLength(2); - expect(createdPaths).toEqual(result.firstPaths); - expect(result.firstPathsExist).toEqual([true, true]); - expect(Exit.isFailure(result.secondExit)).toBe(true); - if (Exit.isFailure(result.secondExit)) { - expect(Cause.findErrorOption(result.secondExit.cause)).toMatchObject({ - _tag: "Some", - value: { _tag: "StackBuildError" }, - }); - } - expect(result.firstPaths.every((path) => !existsSync(path))).toBe(true); - }); - - it("releases a resolver lease when creation is interrupted", async () => { - const leasedPort = Deferred.makeUnsafe(); - const resolveBlocked: ResolveConfigEffect = (_config, options) => - Effect.gen(function* () { - yield* Deferred.succeed(leasedPort, options?.ports.apiPort ?? 0); - return yield* Effect.never; - }); - - const fiber = Effect.runFork( - createStack( - { mode: "native" }, - platformFactory, - { mode: "native", containerRuntime: null }, - resolveBlocked, - ).pipe(Effect.provide(NodeServices.layer)), - ); - const port = await Effect.runPromise(Deferred.await(leasedPort)); - await Effect.runPromise(Fiber.interrupt(fiber)); - - const server = createServer(); - let bound = false; - try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, "127.0.0.1", () => { - bound = true; - resolve(); - }); - }); - } finally { - if (bound) { - await new Promise((resolve, reject) => { - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); - } - } - expect(bound).toBe(true); - }); - - it("shares foreground disposal completion across concurrent callers", async () => { - const finalizerStarted = Deferred.makeUnsafe(); - const releaseFinalizer = Deferred.makeUnsafe(); - const gatedPlatformFactory: PlatformFactory = (options) => - Layer.mergeAll( - platformFactory(options), - Layer.effectDiscard( - Effect.addFinalizer(() => - Deferred.succeed(finalizerStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseFinalizer)), - Effect.asVoid, - ), - ), - ), - ); - - const stack = await Effect.runPromise( - createStack( - { - mode: "native", - postgrest: false, - auth: false, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }, - gatedPlatformFactory, - { mode: "native", containerRuntime: null }, - resolveConfig, - ).pipe(Effect.provide(NodeServices.layer)), - ); - handles.push(stack); - const publicStack = toStackHandle(stack); - - const secondInvoked = Deferred.makeUnsafe(); - const secondDone = Deferred.makeUnsafe(); - const firstDisposal = Effect.runFork(Effect.promise(() => publicStack.dispose())); - await Effect.runPromise(Deferred.await(finalizerStarted)); - const secondDisposal = Effect.runFork( - Effect.promise(() => { - Effect.runSync(Deferred.succeed(secondInvoked, undefined)); - return publicStack.dispose(); - }).pipe(Effect.andThen(Deferred.succeed(secondDone, undefined)), Effect.asVoid), - ); - - await Effect.runPromise(Deferred.await(secondInvoked)); - expect(Option.isNone(await Effect.runPromise(Deferred.poll(secondDone)))).toBe(true); - await Effect.runPromise(Deferred.succeed(releaseFinalizer, undefined)); - const [firstExit, secondExit] = await Effect.runPromise( - Effect.all([Fiber.await(firstDisposal), Fiber.await(secondDisposal)]), - ); - expect(Exit.isSuccess(firstExit)).toBe(true); - expect(Exit.isSuccess(secondExit)).toBe(true); - }); -}); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts deleted file mode 100644 index 2d4755d462..0000000000 --- a/packages/stack/src/createStack.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { LogEntry } from "@supabase/process-compose"; -import { - Cause, - Context, - Deferred, - Effect, - Exit, - FileSystem, - type Layer, - ManagedRuntime, - Option, - Path, - Result, - Stream, -} from "effect"; -import { HttpServer } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { ApiProxy } from "./ApiProxy.ts"; -import type { StackRuntimeSelection } from "./ContainerRuntime.ts"; -import { candidateCleanupTargets, cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; -import { - StackBuildError, - StackRpcProtocolError, - StackRpcTransportError, - StackUnavailableError, - toStackError, - type StackError, -} from "./errors.ts"; -import type { FunctionsReloadConfig } from "./functions.ts"; -import { foregroundLayer } from "./layers.ts"; -import { LocalStackLifecycle } from "./LocalStack.ts"; -import { PortAllocationError, reservePortSet, type PortLease } from "./PortAllocator.ts"; -import { Stack } from "./Stack.ts"; -import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; -import type { ReadyOptions, ResolvedStackConfig, StackConfig } from "./StackConfig.ts"; -import { portRequestsForConfig, type ResolveConfigOptions } from "./StackConfigResolver.ts"; -import type { StackServiceState } from "./StackServiceState.ts"; - -type PlatformServices = - | FileSystem.FileSystem - | Path.Path - | ChildProcessSpawner.ChildProcessSpawner - | HttpServer.HttpServer; - -type PlatformLayer = Layer.Layer; - -/** Supplies the platform HTTP server used by the stack and HTTP proxy. */ -interface PlatformFactoryOptions { - readonly apiPort: number; - readonly releaseApiPort: Effect.Effect; -} - -export type PlatformFactory = (options: PlatformFactoryOptions) => PlatformLayer; - -export type ResolveConfigEffect = ( - input: StackConfig | undefined, - options: ResolveConfigOptions, -) => Effect.Effect; - -type CreateStackAttemptError = - | StackBuildError - | PortAllocationError - | StackUnavailableError - | StackRpcTransportError - | StackRpcProtocolError; - -/** The internal foreground handle; public adapters live at the package edge. */ -export interface ForegroundStackHandle { - readonly url: string; - readonly dbUrl: string; - readonly publishableKey: string; - readonly secretKey: string; - readonly start: Effect.Effect; - readonly stop: Effect.Effect; - readonly dispose: Effect.Effect; - startService(name: string): Effect.Effect; - stopService(name: string): Effect.Effect; - restartService(name: string): Effect.Effect; - reloadFunctions(opts?: FunctionsReloadConfig): Effect.Effect; - reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Effect.Effect; - ready(opts?: ReadyOptions): Effect.Effect; - serviceReady(name: string, opts?: ReadyOptions): Effect.Effect; - readonly getStatus: Effect.Effect, StackError>; - getServiceStatus(name: string): Effect.Effect; - readonly statusChanges: Stream.Stream; - readonly logs: Stream.Stream; - serviceLogs(name: string): Stream.Stream; - logHistory(name: string, limit?: number): Effect.Effect, StackError>; -} - -/** @internal Converts operation failures and closes a terminal foreground runtime. */ -export function runForegroundOperation( - operation: Effect.Effect, - isDisposed: Effect.Effect, - dispose: Effect.Effect, -): Effect.Effect { - return operation.pipe( - Effect.catch((error) => - Effect.uninterruptible( - Effect.gen(function* () { - if (yield* isDisposed) { - yield* dispose; - } - return yield* toStackError(error); - }), - ), - ), - ); -} - -const MAX_AUTOMATIC_API_PORT_HANDOFF_ATTEMPTS = 3; - -/** - * The port lease is intentionally released just before the HTTP server binds. - * Another process can claim that port in the small handoff window, so a new - * foreground stack may retry its automatic API-port allocation. Explicit API - * ports never enter this retry path. - */ -const isAddressInUse = (error: unknown, depth = 0): boolean => { - if (depth > 8 || typeof error !== "object" || error === null) return false; - if ("code" in error && Reflect.get(error, "code") === "EADDRINUSE") return true; - if ("cause" in error) return isAddressInUse(Reflect.get(error, "cause"), depth + 1); - return false; -}; - -const causeIsAddressInUse = (cause: Cause.Cause): boolean => { - const failure = Cause.findErrorOption(cause); - if (Option.isSome(failure)) return isAddressInUse(failure.value); - const defect = Cause.findDefect(cause); - return Result.isSuccess(defect) && isAddressInUse(defect.success); -}; - -const createStackAttempt = ( - config: StackConfig | undefined, - platformFactory: PlatformFactory, - runtimeSelection: StackRuntimeSelection, - resolveConfig: ResolveConfigEffect, - preferredApiPort?: number, -): Effect.Effect => - Effect.gen(function* () { - let portLease: PortLease | undefined; - let resolved: ResolvedStackConfig | undefined; - let disposeRuntime: Effect.Effect | undefined; - - const cleanup = Effect.uninterruptible( - Effect.gen(function* () { - if (disposeRuntime !== undefined) { - yield* disposeRuntime.pipe(Effect.ignore); - } - if (portLease !== undefined) { - yield* portLease.releaseAll.pipe(Effect.ignore); - } - if (resolved === undefined) { - return; - } - if (resolved.runtime.mode === "docker") { - yield* dockerForceRemove( - resolved.runtime.containerRuntime, - candidateCleanupTargets(resolved).dockerContainerNames, - ).pipe(Effect.ignore); - } - yield* cleanupAutoManagedPaths(resolved!.autoManagedPaths); - }), - ); - - const attempt = Effect.gen(function* () { - const requests = yield* portRequestsForConfig(config, { - runtime: runtimeSelection, - ...(preferredApiPort === undefined - ? {} - : { preferredPorts: { apiPort: preferredApiPort } }), - }); - const lease = yield* reservePortSet(requests); - portLease = lease; - resolved = yield* resolveConfig(config, { - runtime: runtimeSelection, - ports: lease.ports, - }); - - const fullLayer = foregroundLayer(resolved, platformFactory, lease); - const managedRuntime = ManagedRuntime.make(fullLayer); - disposeRuntime = managedRuntime.disposeEffect; - return yield* Effect.gen(function* () { - const services = yield* managedRuntime.contextEffect; - const localStack = Context.get(services, Stack); - const apiProxy = Context.get(services, ApiProxy); - const lifecycle = Context.get(services, LocalStackLifecycle); - const info = yield* Effect.provideContext(localStack.getInfo, services); - - const disposalCompletion = Deferred.makeUnsafe>(); - let disposalStarted = false; - const awaitDisposal = Deferred.await(disposalCompletion).pipe( - Effect.flatMap((exit) => - Exit.isSuccess(exit) ? Effect.void : Effect.failCause(exit.cause), - ), - ); - const dispose = Effect.uninterruptibleMask((restore) => - Effect.suspend(() => { - if (disposalStarted) { - return restore(awaitDisposal); - } - disposalStarted = true; - return Effect.forkDetach( - managedRuntime.disposeEffect.pipe( - Effect.uninterruptible, - Effect.exit, - Effect.flatMap((exit) => Deferred.succeed(disposalCompletion, exit)), - Effect.asVoid, - ), - { startImmediately: true }, - ).pipe(Effect.asVoid, Effect.andThen(restore(awaitDisposal))); - }), - ); - const run = (effect: Effect.Effect) => - runForegroundOperation( - Effect.provideContext(effect, services), - Effect.provideContext(lifecycle.isDisposed, services), - dispose, - ); - - // The HTTP module has no response-flushed hook. Give the proxy's final - // 503 response a brief opportunity to leave the socket before closing - // the runtime after terminal lazy activation. - managedRuntime.runFork( - apiProxy.awaitTerminalFailure.pipe( - Effect.andThen(Effect.sleep("25 millis")), - Effect.andThen(dispose), - Effect.ignoreCause, - ), - ); - - return { - url: info.url, - dbUrl: info.dbUrl, - publishableKey: info.publishableKey, - secretKey: info.secretKey, - start: run(localStack.start), - stop: run(localStack.stop), - dispose, - startService: (name: string) => run(localStack.startService(name)), - stopService: (name: string) => run(localStack.stopService(name)), - restartService: (name: string) => run(localStack.restartService(name)), - reloadFunctions: (opts?: FunctionsReloadConfig) => run(localStack.reloadFunctions(opts)), - reloadEdgeRuntime: (opts: EdgeRuntimeReloadConfig) => - run(localStack.reloadEdgeRuntime(opts)), - ready: (opts?: ReadyOptions) => run(localStack.waitAllReady(opts)), - serviceReady: (name: string, opts?: ReadyOptions) => - run(localStack.waitReady(name, opts)), - getStatus: run(localStack.getAllStates), - getServiceStatus: (name: string) => run(localStack.getState(name)), - statusChanges: localStack.allStateChanges.pipe(Stream.mapError(toStackError)), - logs: localStack.subscribeAllLogs().pipe(Stream.mapError(toStackError)), - serviceLogs: (name: string) => - localStack.subscribeLogs(name).pipe(Stream.mapError(toStackError)), - logHistory: (name: string, limit?: number) => run(localStack.logHistory(name, limit)), - } satisfies ForegroundStackHandle; - }); - }); - - return yield* attempt.pipe( - Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : cleanup)), - ); - }); - -export function createStack( - config: StackConfig | undefined, - platformFactory: PlatformFactory, - runtime: StackRuntimeSelection, - resolveConfig: ResolveConfigEffect, -): Effect.Effect { - const automaticApiPort = config?.port === undefined; - const loop = ( - attempt: number, - ): Effect.Effect => - createStackAttempt( - config, - platformFactory, - runtime, - resolveConfig, - attempt === 0 ? undefined : 0, - ).pipe( - Effect.catchCause((cause) => - automaticApiPort && - causeIsAddressInUse(cause) && - attempt + 1 < MAX_AUTOMATIC_API_PORT_HANDOFF_ATTEMPTS - ? Effect.suspend(() => loop(attempt + 1)) - : Effect.failCause(cause), - ), - ); - - return loop(0).pipe(Effect.mapError(toStackError)); -} diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts deleted file mode 100644 index 9b21f0e9cc..0000000000 --- a/packages/stack/src/createStack.unit.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-error-in-effect-failure, effecttsgo/node-builtin-import -- Lifecycle tests await the public Promise facade and exercise native path fixtures; Error values model unknown startup failures. - -import { describe, expect, it } from "vitest"; -import { NodeFileSystem } from "@effect/platform-node"; -import { Cause, Effect, Exit, Result } from "effect"; -import { existsSync } from "node:fs"; -import { basename, dirname } from "node:path"; -import { candidateCleanupTargets, cleanupAutoManagedPaths } from "./cleanup.ts"; -import { dockerContainerName } from "./StackIdentity.ts"; -import { runForegroundOperation } from "./createStack.ts"; -import { ChecksumMismatchError, StackReadinessError } from "./errors.ts"; -import { shortTempPrefixRoot } from "./paths.ts"; -import { - portRequestsForConfig, - resolveConfig as resolveConfigEffect, - type ResolveConfigOptions, - sanitizeDaemonConfigInput, -} from "./StackConfigResolver.ts"; -import { DEFAULT_VERSIONS } from "./versions.ts"; -import type { PortField, PortSet } from "./PortCatalog.ts"; - -const testPorts = (config?: Parameters[0]): PortSet => { - const ports = { - apiPort: 40_000, - dbPort: 40_001, - authPort: 40_002, - postgrestPort: 40_003, - postgrestAdminPort: 40_004, - edgeRuntimePort: 40_005, - edgeRuntimeInspectorPort: 40_006, - realtimePort: 40_007, - storagePort: 40_008, - imgproxyPort: 40_009, - mailpitPort: 40_010, - mailpitSmtpPort: 40_011, - mailpitPop3Port: 40_012, - pgmetaPort: 40_013, - studioPort: 40_014, - analyticsPort: 40_015, - poolerPort: 40_016, - poolerApiPort: 40_017, - } satisfies Record; - return { ...ports, apiPort: config?.port ?? ports.apiPort }; -}; - -const resolveConfig = ( - config?: Parameters[0], - options?: Partial, -) => - Effect.runPromise( - resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts(config) }).pipe( - Effect.provide(NodeFileSystem.layer), - ), - ); - -describe("foreground operation lifecycle", () => { - it("preserves checksum mismatch classification for non-Effect consumers", async () => { - const mismatch = new ChecksumMismatchError({ - url: "https://example.com/archive.tar.gz", - expected: "expected", - actual: "actual", - }); - - await expect( - Effect.runPromise( - runForegroundOperation(Effect.fail(mismatch), Effect.succeed(false), Effect.void), - ), - ).rejects.toMatchObject({ - code: "CHECKSUM_MISMATCH", - cause: mismatch, - }); - }); - - it("disposes the foreground runtime after a direct readiness timeout", async () => { - let disposeCount = 0; - const operation = Effect.fail( - new StackReadinessError({ - target: "stack", - timeoutMs: 10, - detail: "Timed out waiting for stack readiness", - }), - ); - - await expect( - Effect.runPromise( - runForegroundOperation( - operation, - Effect.succeed(true), - Effect.sync(() => { - disposeCount += 1; - }), - ), - ), - ).rejects.toMatchObject({ code: "STACK_READINESS_TIMEOUT" }); - expect(disposeCount).toBe(1); - }); - - it("disposes the foreground runtime after another terminal start failure", async () => { - let disposeCount = 0; - - await expect( - Effect.runPromise( - runForegroundOperation( - Effect.fail(new Error("service startup failed")), - Effect.succeed(true), - Effect.sync(() => { - disposeCount += 1; - }), - ), - ), - ).rejects.toMatchObject({ code: "UNKNOWN" }); - expect(disposeCount).toBe(1); - }); - - it("keeps the foreground runtime open after a non-terminal operation failure", async () => { - let disposeCount = 0; - - await expect( - Effect.runPromise( - runForegroundOperation( - Effect.fail(new Error("failed")), - Effect.succeed(false), - Effect.sync(() => { - disposeCount += 1; - }), - ), - ), - ).rejects.toMatchObject({ code: "UNKNOWN" }); - expect(disposeCount).toBe(0); - }); - - it("preserves defects without converting them into expected stack failures", async () => { - const defect = new Error("unexpected runtime defect"); - let disposeCount = 0; - - const exit = await Effect.runPromiseExit( - runForegroundOperation( - Effect.die(defect), - Effect.succeed(true), - Effect.sync(() => { - disposeCount += 1; - }), - ), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const found = Cause.findDefect(exit.cause); - expect(Result.isSuccess(found)).toBe(true); - if (Result.isSuccess(found)) { - expect(found.success).toBe(defect); - } - } - expect(disposeCount).toBe(0); - }); -}); - -describe("StackConfigResolver", () => { - it("strips function bundles from daemon configuration at runtime", () => { - const input = { - cwd: "/project", - functions: { environment: { SECRET: "must-not-cross-ipc" } }, - }; - - expect(sanitizeDaemonConfigInput(input)).toEqual({ cwd: "/project" }); - }); -}); - -describe("resolveConfig edge runtime defaults", () => { - it("disables edge runtime when omitted in native mode", async () => { - const config = await resolveConfig({ mode: "native" }); - - expect(config.runtime).toEqual({ mode: "native", containerRuntime: null }); - expect(config.edgeRuntime).toBe(false); - }); - - it("enables edge runtime when omitted in Docker mode", async () => { - const config = await resolveConfig( - { mode: "docker" }, - { - runtime: { mode: "docker", containerRuntime: "docker" }, - }, - ); - - expect(config.runtime).toEqual({ mode: "docker", containerRuntime: "docker" }); - expect(config.edgeRuntime).toEqual( - expect.objectContaining({ - enabled: true, - version: DEFAULT_VERSIONS["edge-runtime"], - }), - ); - }); - - it("requires Effect consumers to provide the selected Docker runtime", async () => { - await expect( - Effect.runPromise(portRequestsForConfig({ mode: "docker" })), - ).rejects.toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - }); - - it("applies the detected Docker mode before resolving services and ports", async () => { - const config = await resolveConfig(undefined, { - runtime: { mode: "docker", containerRuntime: "podman" }, - }); - - expect(config.runtime).toEqual({ mode: "docker", containerRuntime: "podman" }); - expect(config.edgeRuntime).toEqual( - expect.objectContaining({ - enabled: true, - version: DEFAULT_VERSIONS["edge-runtime"], - }), - ); - }); - - it("preserves explicit edge runtime opt-in in native mode for builder validation", async () => { - const config = await resolveConfig({ mode: "native", edgeRuntime: {} }); - - expect(config.runtime).toEqual({ mode: "native", containerRuntime: null }); - expect(config.edgeRuntime).toEqual( - expect.objectContaining({ - enabled: true, - version: DEFAULT_VERSIONS["edge-runtime"], - }), - ); - }); -}); - -describe("portRequestsForConfig explicit ports", () => { - it("rejects an explicit zero port before invoking allocation", async () => { - await expect( - Effect.runPromise(portRequestsForConfig({ mode: "native", port: 0 })), - ).rejects.toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - }); - - it.each([1.5, -1, 65_536])("rejects an explicit invalid port %s", async (port) => { - await expect( - Effect.runPromise(portRequestsForConfig({ mode: "native", port })), - ).rejects.toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - }); - - it("preserves an explicit pooler api port", async () => { - const config = await resolveConfig( - { - mode: "docker", - edgeRuntime: false, - postgrest: false, - auth: false, - pooler: { port: 42423, apiPort: 42424 }, - }, - { - runtime: { mode: "docker", containerRuntime: "docker" }, - ports: { ...testPorts(), poolerPort: 42423, poolerApiPort: 42424 }, - }, - ); - - expect(config.ports.poolerPort).toBe(42423); - expect(config.ports.poolerApiPort).toBe(42424); - }); - - it("orders explicit ports before automatic requests", async () => { - const sharedCandidate = 61_234; - const requests = await Effect.runPromise( - portRequestsForConfig( - { - mode: "native", - edgeRuntime: false, - postgrest: false, - auth: false, - analytics: { port: sharedCandidate }, - }, - { preferredPorts: { dbPort: sharedCandidate } }, - ), - ); - expect(requests[0]).toEqual({ - field: "analyticsPort", - selection: { kind: "exact", port: sharedCandidate }, - }); - expect(requests[1]?.field).toBe("apiPort"); - }); -}); - -describe("candidateCleanupTargets", () => { - it("derives fallback Docker identities from enabled catalog services", async () => { - const config = await resolveConfig( - { - mode: "docker", - auth: false, - edgeRuntime: false, - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }, - { runtime: { mode: "docker", containerRuntime: "docker" } }, - ); - - expect(candidateCleanupTargets(config)).toEqual({ - dockerContainerNames: [ - dockerContainerName("postgres", String(config.apiPort)), - dockerContainerName("postgrest", String(config.apiPort)), - ], - }); - }); - - it("keys fallback Docker identities by the stack's own identity when it has one", async () => { - const instanceId = "0f9d2b3c-4a5e-4c7d-8e9f-1a2b3c4d5e6f"; - const config = await resolveConfig( - { mode: "docker", instanceId }, - { runtime: { mode: "docker", containerRuntime: "docker" } }, - ); - - expect(config.instanceId).toBe(instanceId); - const { dockerContainerNames } = candidateCleanupTargets(config); - expect(dockerContainerNames).toContain(dockerContainerName("postgres", `id-${instanceId}`)); - for (const name of dockerContainerNames) { - expect(name).not.toContain(String(config.apiPort)); - } - }); -}); - -describe("resolveConfig instanceId validation", () => { - it("rejects an instanceId that is not Docker-name-safe", async () => { - await expect(resolveConfig({ instanceId: "../bad:id" })).rejects.toMatchObject({ - _tag: "StackBuildError", - reason: "invalid_config", - }); - }); - - it("accepts a managed stack's UUID instanceId", async () => { - const instanceId = "0f9d2b3c-4a5e-4c7d-8e9f-1a2b3c4d5e6f"; - const config = await resolveConfig({ instanceId }); - expect(config.instanceId).toBe(instanceId); - }); - - it("leaves instanceId undefined when omitted", async () => { - const config = await resolveConfig(); - expect(config.instanceId).toBeUndefined(); - }); -}); - -describe("resolveConfig service policies", () => { - it("uses catalog defaults and resolves explicit policies", async () => { - const config = await resolveConfig({ servicePolicies: { postgrest: "eager" } }); - expect(config.servicePolicies.postgres).toBe("eager"); - expect(config.servicePolicies.postgrest).toBe("eager"); - expect(config.servicePolicies.auth).toBe("lazy"); - }); -}); - -describe("resolveConfig state roots", () => { - it("uses disposable temporary roots when direct callers omit them", async () => { - const config = await resolveConfig(); - - try { - expect(config.autoManagedPaths).toEqual([config.stackRoot, config.runtimeRoot]); - expect(dirname(config.stackRoot)).toBe(shortTempPrefixRoot()); - expect(dirname(config.runtimeRoot)).toBe(shortTempPrefixRoot()); - expect(basename(config.stackRoot)).toMatch(/^sb-stack-/); - expect(basename(config.runtimeRoot)).toMatch(/^sb-run-/); - expect(existsSync(config.stackRoot)).toBe(true); - expect(existsSync(config.runtimeRoot)).toBe(true); - } finally { - await Effect.runPromise( - cleanupAutoManagedPaths(config.autoManagedPaths).pipe(Effect.provide(NodeFileSystem.layer)), - ); - } - - expect(existsSync(config.stackRoot)).toBe(false); - expect(existsSync(config.runtimeRoot)).toBe(false); - }); -}); - -describe("resolveConfig readiness policy", () => { - it("uses a finite package default", async () => { - const config = await resolveConfig(); - expect(config.readiness).toEqual({ mode: "finite", timeoutMs: 180_000 }); - expect(config.readinessSource).toBe("default"); - }); - - it("preserves an explicit infinite policy", async () => { - const config = await resolveConfig({ readiness: { mode: "infinite" } }); - expect(config.readiness).toEqual({ mode: "infinite" }); - expect(config.readinessSource).toBe("configured"); - }); -}); diff --git a/packages/stack/src/daemon-bun.ts b/packages/stack/src/daemon-bun.ts deleted file mode 100644 index 6f6ad880a2..0000000000 --- a/packages/stack/src/daemon-bun.ts +++ /dev/null @@ -1,27 +0,0 @@ -// oxlint-disable effecttsgo/multiple-effect-provide -- Daemon startup layers are provided in dependency order so managed transport and platform scopes remain distinct. - -import { BunFileSystem, BunServices } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; -import { runSupervisor } from "./supervisor.ts"; -import { managedStackManagerLayer as makeManagerLayer } from "./managed/manager.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { controlTransportLayer, platformFactory } from "./platform-bun.ts"; - -const managerLayer = (stateRoot: string) => - makeManagerLayer({ stateRoot }).pipe( - Layer.provide(Layer.mergeAll(BunFileSystem.layer, gitConfigStoreLayer, controlTransportLayer)), - ); - -/** Thin Bun child entrypoint shared by managed and ordinary detached starts. */ -export const runBunDaemon = (): void => { - void Effect.runPromise( - runSupervisor({ platformFactory, managerLayer }).pipe( - Effect.provide(BunServices.layer), - Effect.provide(BunFileSystem.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ), - ); -}; - -if (import.meta.main) runBunDaemon(); diff --git a/packages/stack/src/daemon-node.ts b/packages/stack/src/daemon-node.ts deleted file mode 100644 index 4a1f15f9b8..0000000000 --- a/packages/stack/src/daemon-node.ts +++ /dev/null @@ -1,35 +0,0 @@ -// oxlint-disable effecttsgo/multiple-effect-provide -- Daemon startup layers are provided in dependency order so managed transport and platform scopes remain distinct. - -import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; -import { Effect, Layer } from "effect"; -import { runSupervisor } from "./supervisor.ts"; -import { managedStackManagerLayer as makeManagerLayer } from "./managed/manager.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { controlTransportLayer, platformFactory } from "./platform-node.ts"; - -const managerLayer = (stateRoot: string) => - makeManagerLayer({ stateRoot }).pipe( - Layer.provide( - Layer.mergeAll( - NodeFileSystem.layer, - NodePath.layer, - gitConfigStoreLayer, - controlTransportLayer, - ), - ), - ); - -/** Thin Node child entrypoint shared by managed and ordinary detached starts. */ -export const runNodeSupervisor = (): void => { - void Effect.runPromise( - runSupervisor({ platformFactory, managerLayer }).pipe( - Effect.provide(NodeServices.layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ), - ); -}; - -if (import.meta.main) runNodeSupervisor(); diff --git a/packages/stack/src/discovery.ts b/packages/stack/src/discovery.ts deleted file mode 100644 index adc749cf7c..0000000000 --- a/packages/stack/src/discovery.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { Effect } from "effect"; -import type { ManagedStackManagerError, ManagedStackManagerShape } from "./managed/manager.ts"; -import { ManagedStackManager } from "./managed/manager.ts"; -import { InvalidManagedStackDocumentError, type ManagedStackDocument } from "./managed/document.ts"; -import { - connectManagedStack, - deleteManagedStack, - resolveManagedDocument, - stopManagedStack, -} from "./managed/lifecycle.ts"; -import { PORT_CATALOG, PORT_FIELDS, type ResolvedPorts } from "./PortCatalog.ts"; -import type { PartialVersionManifest } from "./versions.ts"; -import { NoRunningStackError } from "./managed/model.ts"; -import type { ManagedPortDrift, ManagedPortIntentDocument } from "./managed/model.ts"; -import { managedStackDocumentPathEffect } from "./managed/paths.ts"; -import { HttpTransportClient } from "./HttpTransportClient.ts"; -import type { ControlTransport } from "./managed/control.ts"; -import { isControlSupervisorStatus } from "./DaemonProtocol.ts"; -import { - DaemonUpgradeRequired, - StackRpcProtocolError, - StackRpcTransportError, - StopTimeout, -} from "./errors.ts"; -import type { Stack } from "./Stack.ts"; - -export interface StackSummary { - readonly name: string; - readonly running: boolean; - readonly ports: ResolvedPorts; - readonly versions: PartialVersionManifest; - readonly pid?: number; - readonly url?: string; - readonly dbUrl?: string; - readonly startedAt?: string; - readonly lastNotifiedUpdateFingerprint?: string; - readonly launch: ManagedStackDocument["launch"]; - readonly drift?: ReadonlyArray; -} - -const portFieldByKey: Readonly> = Object.fromEntries( - PORT_FIELDS.flatMap((field) => { - const key = PORT_CATALOG[field].configKey; - return key === undefined ? [] : [[key, field]]; - }), -); - -const summaryForDocument = ( - document: ManagedStackDocument & { readonly drift?: ReadonlyArray }, - path: string, - running?: boolean, -): Effect.Effect => { - const ports: Record = {}; - for (const assignment of document.ports) { - const field = portFieldByKey[assignment.key]; - if (field !== undefined) ports[field] = assignment.port; - } - if (ports.apiPort === undefined || ports.dbPort === undefined) { - return Effect.fail(new InvalidManagedStackDocumentError({ path })); - } - const { apiPort, dbPort } = ports; - return Effect.succeed({ - name: document.identity.name, - running: running ?? (document.lifecycle === "running" && document.runtime !== undefined), - ports: { - ...ports, - apiPort, - dbPort, - }, - versions: document.launch.versions, - launch: document.launch, - ...(document.drift === undefined ? {} : { drift: document.drift }), - ...(document.launch.lastNotifiedUpdateFingerprint === undefined - ? {} - : { lastNotifiedUpdateFingerprint: document.launch.lastNotifiedUpdateFingerprint }), - ...(document.runtime === undefined ? {} : { pid: document.runtime.pid }), - startedAt: document.updatedAt, - }); -}; - -const liveStatus = ( - manager: ManagedStackManagerShape, - document: ManagedStackDocument, -): Effect.Effect => - manager - .probeControl(document.id) - .pipe( - Effect.map( - (probe) => - probe !== undefined && - isControlSupervisorStatus(probe.status) && - probe.status.state === "running" && - probe.status.ready, - ), - ); - -export const listStacks = (opts: { - readonly cacheRoot: string; - readonly projectDir?: string; -}): Effect.Effect, ManagedStackManagerError, ManagedStackManager> => - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const listings = yield* manager.listStacks; - const projectPath = - opts.projectDir === undefined - ? undefined - : yield* manager.discoverWorkspace(opts.projectDir).pipe( - Effect.map((workspace) => workspace.path), - Effect.catchTag("UnsupportedGitWorkspaceError", () => Effect.succeed(null)), - ); - if (projectPath === null) return []; - const summaries = yield* Effect.forEach(listings, (listing) => - Effect.gen(function* () { - if ( - listing.status !== "healthy" || - (projectPath !== undefined && listing.document.workspace.path !== projectPath) - ) { - return undefined; - } - const running = yield* liveStatus(manager, listing.document); - return yield* summaryForDocument( - listing.document, - yield* managedStackDocumentPathEffect(manager.stateRoot, listing.document.id), - running, - ); - }), - ); - return summaries - .filter((summary): summary is StackSummary => summary !== undefined) - .sort((left, right) => left.name.localeCompare(right.name)); - }); - -export const resolveStackSummary = (opts: { - readonly cacheRoot: string; - readonly projectDir?: string; - readonly cwd?: string; - readonly name: string; - readonly portDocument?: ManagedPortIntentDocument; -}): Effect.Effect< - StackSummary, - NoRunningStackError | ManagedStackManagerError, - ManagedStackManager -> => - Effect.gen(function* () { - const document = yield* resolveManagedDocument({ - workspacePath: opts.projectDir ?? opts.cwd ?? process.cwd(), - stackName: opts.name, - cwd: opts.cwd, - ...(opts.portDocument === undefined ? {} : { portDocument: opts.portDocument }), - }); - const manager = yield* ManagedStackManager; - return yield* summaryForDocument( - document, - yield* managedStackDocumentPathEffect(manager.stateRoot, document.id), - yield* liveStatus(manager, document), - ); - }); - -export const stopDaemon = (opts: { - readonly name?: string; - readonly cwd?: string; - readonly cacheRoot: string; - readonly projectDir?: string; -}): Effect.Effect< - void, - NoRunningStackError | ManagedStackManagerError | StopTimeout, - ManagedStackManager | HttpTransportClient -> => - stopManagedStack({ - workspacePath: opts.projectDir ?? opts.cwd ?? process.cwd(), - ...(opts.name === undefined ? {} : { stackName: opts.name }), - cwd: opts.cwd, - }); - -export const deleteManagedStackPersistence = (opts: { - readonly name?: string; - readonly cwd?: string; - readonly cacheRoot: string; - readonly projectDir?: string; -}): Effect.Effect< - void, - NoRunningStackError | ManagedStackManagerError, - ManagedStackManager | ControlTransport -> => - deleteManagedStack({ - workspacePath: opts.projectDir ?? opts.cwd ?? process.cwd(), - ...(opts.name === undefined ? {} : { stackName: opts.name }), - cwd: opts.cwd, - }); - -export type ManagedStack = ManagedStackDocument; - -export const resolveManagedStack = (opts: { - readonly cacheRoot: string; - readonly name?: string; - readonly cwd?: string; - readonly projectDir?: string; -}): Effect.Effect< - ManagedStack, - NoRunningStackError | ManagedStackManagerError, - ManagedStackManager -> => - resolveManagedDocument({ - workspacePath: opts.projectDir ?? opts.cwd ?? process.cwd(), - ...(opts.name === undefined ? {} : { stackName: opts.name }), - cwd: opts.cwd, - }); - -export const connectManagedLayer = (opts: { - readonly name?: string; - readonly cwd?: string; - readonly cacheRoot: string; - readonly projectDir?: string; - readonly cliVersion: string; -}): Effect.Effect< - import("effect").Layer.Layer< - Stack, - DaemonUpgradeRequired | StackRpcProtocolError | StackRpcTransportError - >, - NoRunningStackError | ManagedStackManagerError | DaemonUpgradeRequired, - ManagedStackManager | HttpTransportClient -> => - connectManagedStack({ - workspacePath: opts.projectDir ?? opts.cwd ?? process.cwd(), - ...(opts.name === undefined ? {} : { stackName: opts.name }), - cwd: opts.cwd, - cliVersion: opts.cliVersion, - }); diff --git a/packages/stack/src/effect-bun.ts b/packages/stack/src/effect-bun.ts deleted file mode 100644 index 717b6263f9..0000000000 --- a/packages/stack/src/effect-bun.ts +++ /dev/null @@ -1,93 +0,0 @@ -// @supabase/stack/effect — Bun-bound Effect interfaces and consumer layers. -// oxlint-disable effecttsgo/multiple-effect-provide -- Consumer layers are staged in dependency order; merging them would alter managed scope ownership. - -export * from "./effect.ts"; - -import { Effect, type Layer } from "effect"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Bun effect entrypoint uses native path semantics for managed state roots. -import { join } from "node:path"; -import type { PortLease } from "./PortAllocator.ts"; -import type { Stack } from "./Stack.ts"; -import type { ResolvedStackConfig } from "./StackConfig.ts"; -import type { ManagedDaemonConfigInput } from "./layers.ts"; -import type { DaemonUpgradeRequired } from "./errors.ts"; -import { defaultCacheRoot } from "./paths.ts"; -import { - daemonLayer as daemonLayerForPlatform, - restartManagedStackForUpgrade as restartManagedStackForUpgradeForPlatform, - foregroundLayer as foregroundLayerForPlatform, -} from "./layers.ts"; -import { controlTransportLayer, daemonEntryPoint, platformFactory } from "./platform-bun.ts"; -import { httpTransportClientLayer } from "./HttpTransportClient.ts"; -import { - connectManagedLayer, - deleteManagedStackPersistence as deleteManagedStackPersistenceCore, - listStacks as listStacksCore, - resolveManagedStack as resolveManagedStackCore, - resolveStackSummary as resolveStackSummaryCore, - stopDaemon as stopDaemonCore, -} from "./discovery.ts"; -import { - resolveManagedDocument as resolveManagedDocumentCore, - updateManagedLaunch as updateManagedLaunchCore, -} from "./managed/lifecycle.ts"; -import { managedStackManagerLayer } from "./managed-bun.ts"; - -export { httpTransportClientLayer }; - -export const foregroundLayer = ( - config: ResolvedStackConfig, - portLease: PortLease, -): Layer.Layer => foregroundLayerForPlatform(config, platformFactory, portLease); - -export const daemonLayer = (input: ManagedDaemonConfigInput) => - daemonLayerForPlatform(input, daemonEntryPoint); - -export const restartManagedStackForUpgrade = ( - input: ManagedDaemonConfigInput, - mismatch: DaemonUpgradeRequired, -) => - restartManagedStackForUpgradeForPlatform(input, mismatch, daemonEntryPoint).pipe( - Effect.provide(managedLayer(input.cacheRoot ?? defaultCacheRoot())), - Effect.provide(controlTransportLayer), - ); - -const managedLayer = (cacheRoot: string) => - managedStackManagerLayer({ stateRoot: join(cacheRoot, "managed") }); - -export const connectLayer = (opts: Parameters[0]) => - connectManagedLayer(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const resolveManagedStack = (opts: Parameters[0]) => - resolveManagedStackCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const listStacks = (opts: Parameters[0]) => - listStacksCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const resolveStackSummary = (opts: Parameters[0]) => - resolveStackSummaryCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const stopDaemon = (opts: Parameters[0]) => - stopDaemonCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const deleteManagedStackPersistence = ( - opts: Parameters[0], -) => - deleteManagedStackPersistenceCore(opts).pipe( - Effect.provide(managedLayer(opts.cacheRoot)), - Effect.provide(controlTransportLayer), - ); - -export const resolveManagedDocument = (opts: { - readonly workspacePath: string; - readonly stackName?: string; - readonly cwd?: string; - readonly cacheRoot: string; -}) => resolveManagedDocumentCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const updateManagedLaunch = (opts: { - readonly workspacePath: string; - readonly stackName?: string; - readonly cwd?: string; - readonly cacheRoot: string; - readonly cliVersion: string; - readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; -}) => - updateManagedLaunchCore(opts).pipe( - Effect.provide(managedLayer(opts.cacheRoot)), - Effect.provide(httpTransportClientLayer), - ); diff --git a/packages/stack/src/effect-delete.integration.test.ts b/packages/stack/src/effect-delete.integration.test.ts deleted file mode 100644 index 7c5cb7f09e..0000000000 --- a/packages/stack/src/effect-delete.integration.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// oxlint-disable effecttsgo/global-error-in-effect-catch, effecttsgo/global-error-in-effect-failure, effecttsgo/node-builtin-import -- Deletion tests intentionally inject native Error failures and use temporary filesystem paths to verify cleanup classification. - -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { it } from "@effect/vitest"; -import { Effect } from "effect"; -import { expect } from "vitest"; -import { deleteManagedStackPersistence as deleteWithNode } from "./effect-node.ts"; -import { NoRunningStackError } from "./managed/model.ts"; - -const makeFixture = Effect.acquireRelease( - Effect.try({ - try: () => { - const root = mkdtempSync(join(tmpdir(), "stack-effect-delete-")); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - return { root, workspace, cacheRoot: join(root, "cache") }; - }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ({ root }) => - Effect.try({ - try: () => rmSync(root, { recursive: true, force: true }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe(Effect.ignore), -); - -const assertDeleteWithoutStack = (deleteManagedStackPersistence: typeof deleteWithNode) => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* makeFixture; - const error = yield* Effect.flip( - deleteManagedStackPersistence({ - projectDir: fixture.workspace, - cacheRoot: fixture.cacheRoot, - }), - ); - expect(error).toBeInstanceOf(NoRunningStackError); - }), - ); - -it.live("Node Effect entrypoint owns the complete delete transport", () => - assertDeleteWithoutStack(deleteWithNode), -); - -it.live.skipIf(typeof Bun === "undefined")( - "Bun Effect entrypoint owns the complete delete transport", - () => - Effect.gen(function* () { - const { deleteManagedStackPersistence } = yield* Effect.promise( - () => import("./effect-bun.ts"), - ); - yield* assertDeleteWithoutStack(deleteManagedStackPersistence); - }), -); diff --git a/packages/stack/src/effect-node.ts b/packages/stack/src/effect-node.ts deleted file mode 100644 index 665c0e908f..0000000000 --- a/packages/stack/src/effect-node.ts +++ /dev/null @@ -1,93 +0,0 @@ -// @supabase/stack/effect — Node-bound Effect interfaces and consumer layers. -// oxlint-disable effecttsgo/multiple-effect-provide -- Consumer layers are staged in dependency order; merging them would alter managed scope ownership. - -export * from "./effect.ts"; - -import { Effect, type Layer } from "effect"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Node effect entrypoint uses native path semantics for managed state roots. -import { join } from "node:path"; -import type { PortLease } from "./PortAllocator.ts"; -import type { Stack } from "./Stack.ts"; -import type { ResolvedStackConfig } from "./StackConfig.ts"; -import type { ManagedDaemonConfigInput } from "./layers.ts"; -import type { DaemonUpgradeRequired } from "./errors.ts"; -import { defaultCacheRoot } from "./paths.ts"; -import { - daemonLayer as daemonLayerForPlatform, - restartManagedStackForUpgrade as restartManagedStackForUpgradeForPlatform, - foregroundLayer as foregroundLayerForPlatform, -} from "./layers.ts"; -import { controlTransportLayer, daemonEntryPoint, platformFactory } from "./platform-node.ts"; -import { httpTransportClientLayer } from "./HttpTransportClient.ts"; -import { - connectManagedLayer, - deleteManagedStackPersistence as deleteManagedStackPersistenceCore, - listStacks as listStacksCore, - resolveManagedStack as resolveManagedStackCore, - resolveStackSummary as resolveStackSummaryCore, - stopDaemon as stopDaemonCore, -} from "./discovery.ts"; -import { - resolveManagedDocument as resolveManagedDocumentCore, - updateManagedLaunch as updateManagedLaunchCore, -} from "./managed/lifecycle.ts"; -import { managedStackManagerLayer } from "./managed-node.ts"; - -export { httpTransportClientLayer }; - -export const foregroundLayer = ( - config: ResolvedStackConfig, - portLease: PortLease, -): Layer.Layer => foregroundLayerForPlatform(config, platformFactory, portLease); - -export const daemonLayer = (input: ManagedDaemonConfigInput) => - daemonLayerForPlatform(input, daemonEntryPoint); - -export const restartManagedStackForUpgrade = ( - input: ManagedDaemonConfigInput, - mismatch: DaemonUpgradeRequired, -) => - restartManagedStackForUpgradeForPlatform(input, mismatch, daemonEntryPoint).pipe( - Effect.provide(managedLayer(input.cacheRoot ?? defaultCacheRoot())), - Effect.provide(controlTransportLayer), - ); - -const managedLayer = (cacheRoot: string) => - managedStackManagerLayer({ stateRoot: join(cacheRoot, "managed") }); - -export const connectLayer = (opts: Parameters[0]) => - connectManagedLayer(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const resolveManagedStack = (opts: Parameters[0]) => - resolveManagedStackCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const listStacks = (opts: Parameters[0]) => - listStacksCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const resolveStackSummary = (opts: Parameters[0]) => - resolveStackSummaryCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const stopDaemon = (opts: Parameters[0]) => - stopDaemonCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const deleteManagedStackPersistence = ( - opts: Parameters[0], -) => - deleteManagedStackPersistenceCore(opts).pipe( - Effect.provide(managedLayer(opts.cacheRoot)), - Effect.provide(controlTransportLayer), - ); - -export const resolveManagedDocument = (opts: { - readonly workspacePath: string; - readonly stackName?: string; - readonly cwd?: string; - readonly cacheRoot: string; -}) => resolveManagedDocumentCore(opts).pipe(Effect.provide(managedLayer(opts.cacheRoot))); -export const updateManagedLaunch = (opts: { - readonly workspacePath: string; - readonly stackName?: string; - readonly cwd?: string; - readonly cacheRoot: string; - readonly cliVersion: string; - readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; -}) => - updateManagedLaunchCore(opts).pipe( - Effect.provide(managedLayer(opts.cacheRoot)), - Effect.provide(httpTransportClientLayer), - ); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index b860961ad0..3c8709143e 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -1,167 +1 @@ -// Platform-agnostic Effect contracts re-exported by the conditional @supabase/stack/effect entry. - -export type { LogEntry } from "@supabase/process-compose"; -export type { StackServiceStatus } from "./StackServiceState.ts"; -export { StackServiceState, fromRawServiceState } from "./StackServiceState.ts"; - -export { - BinaryHostCompatibilityError, - BinaryManifestError, - BinaryNotFoundError, - BinaryRuntimeError, - ChecksumMismatchError, - DaemonUpgradeRequired, - DockerPullError, - DownloadError, - isDockerDaemonDownMessage, - PortConflictError, - StackBuildError, - StackError, - StackNotRunningError, - StackReadinessError, - StackRpcProtocolError, - StackRpcTransportError, - StackUnavailableError, - StopTimeout, - UpgradePreflightError, - UpgradeRestartError, - toStackError, -} from "./errors.ts"; - -export type { NativeTarget, PlatformInfo } from "./Platform.ts"; -export { detectPlatform, nativeTargetForPlatform } from "./Platform.ts"; - -export { expandExcludedServices } from "./ServiceExclusions.ts"; - -export type { ContainerRuntime, StackRuntimeSelection } from "./ContainerRuntime.ts"; -export { selectStackRuntime, validateStackRuntime } from "./ContainerRuntime.ts"; - -export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts"; - -export type { PrefetchEffectOptions, PrefetchOptions, PrefetchResult } from "./prefetch.ts"; -export { prefetch } from "./prefetch.ts"; - -export { - defaultJwtSecret, - defaultPublishableKey, - defaultSecretKey, - generateJwt, -} from "./JwtGenerator.ts"; - -export { edgeRuntimeNofileUlimit } from "./services/nofile-limit.ts"; - -export type { - AllocatedPorts, - ConfigPortKey, - PortCatalogEntry, - PortField, - PortSet, - ResolvedPorts, -} from "./PortCatalog.ts"; -export type { - PortLease, - PortReservationRequest, - PortSelection, - PortSelectionOptions, -} from "./PortAllocator.ts"; -export { PortAllocationError, reservePortSet } from "./PortAllocator.ts"; -export { - AllocatedPortsSchema, - DEFAULT_API_PORT, - DEFAULT_DB_PORT, - DEFAULT_PORTS, - PORT_CATALOG, - PORT_FIELDS, - PortSetSchema, - ResolvedPortsSchema, - runtimeOnlyPortFields, - stickyPortFields, -} from "./PortCatalog.ts"; -export { portFieldsForConfigInput, portFieldsForService } from "./ServicePorts.ts"; - -export type { - AnalyticsConfig, - AuthConfig, - EdgeRuntimeConfig, - ImgproxyConfig, - MailpitConfig, - PgmetaConfig, - PoolerConfig, - PostgresConfig, - PostgrestConfig, - RealtimeConfig, - ResolvedAnalyticsConfig, - ResolvedAuthConfig, - ResolvedEdgeRuntimeConfig, - ResolvedImgproxyConfig, - ResolvedMailpitConfig, - ResolvedPgmetaConfig, - ResolvedPoolerConfig, - ResolvedPostgresConfig, - ResolvedPostgrestConfig, - ResolvedRealtimeConfig, - ResolvedStackConfig, - ResolvedStorageConfig, - ResolvedStudioConfig, - ResolvedVectorConfig, - ReadinessPolicy, - ReadyOptions, - ServicePolicy, - ServicePolicyManifest, - StackMode, - StackConfig, - StorageConfig, - StudioConfig, - VectorConfig, -} from "./StackConfig.ts"; -export { DEFAULT_STACK_READINESS_POLICY, resolveReadinessPolicy } from "./StackConfig.ts"; - -export type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; -export { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; -export type { - FunctionsReloadConfig, - FunctionsRuntimeConfig, - ResolvedFunction, - ResolvedFunctionsBundle, -} from "./functions.ts"; -export { - clearFunctionsRuntimeConfig, - configureFunctionsRuntime, - FunctionsReloadConfigSchema, - functionsRuntimeConfigFileName, - functionsRuntimeConfigPath, - ResolvedFunctionSchema, - ResolvedFunctionsBundleSchema, - resolveFunctionsRuntimeConfig, -} from "./functions.ts"; - -export type { AvailableServiceVersionUpdate, ServiceName, VersionManifest } from "./versions.ts"; -export { - DEFAULT_VERSIONS, - diffPinnedAndAvailableVersions, - dockerImageForService, - fillServiceVersionManifest, - fullVersionManifest, - normalizeServiceVersion, - normalizeServiceVersions, - SERVICE_NAMES, -} from "./versions.ts"; -export type { - StackVersionOverride, - StackVersionPlan, - StackVersionPlanInput, -} from "./version-plan.ts"; -export { planStackVersions } from "./version-plan.ts"; - -export { DEFAULT_MANAGED_STACK_NAME } from "./paths.ts"; - -export { NoRunningStackError } from "./managed/model.ts"; - -export type { PartialVersionManifest } from "./versions.ts"; -export { PartialVersionManifestSchema } from "./versions.ts"; -export { portRequestsForConfig, resolveConfig } from "./StackConfigResolver.ts"; -export type { PortRequestOptions, ResolveConfigOptions } from "./StackConfigResolver.ts"; - -export { DaemonStartError } from "./layers.ts"; -export type { ManagedDaemonConfigInput } from "./layers.ts"; -export type { StackSummary } from "./discovery.ts"; +export * from "./public/index.ts"; diff --git a/packages/stack/src/error-code.ts b/packages/stack/src/error-code.ts deleted file mode 100644 index de8e667c3e..0000000000 --- a/packages/stack/src/error-code.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Reads a nested transport error code without recursing through a malformed - * cyclic cause chain. - */ -export const errorCode = (cause: unknown): string | undefined => { - const maxCauseDepth = 8; - const seen = new Set(); - let current: unknown = cause; - for (let depth = 0; depth < maxCauseDepth; depth += 1) { - if (typeof current !== "object" || current === null || seen.has(current)) return undefined; - seen.add(current); - if ("code" in current && typeof current.code === "string") return current.code; - current = "cause" in current ? current.cause : undefined; - } - return undefined; -}; diff --git a/packages/stack/src/error-code.unit.test.ts b/packages/stack/src/error-code.unit.test.ts deleted file mode 100644 index 10984ecf69..0000000000 --- a/packages/stack/src/error-code.unit.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { errorCode } from "./error-code.ts"; - -describe("errorCode", () => { - it("reads codes only within the bounded cause chain", () => { - const withinLimit = { cause: { cause: { code: "ECONNREFUSED" } } }; - let beyondLimit: unknown = { code: "ETIMEDOUT" }; - for (let depth = 0; depth < 8; depth += 1) beyondLimit = { cause: beyondLimit }; - - expect(errorCode(withinLimit)).toBe("ECONNREFUSED"); - expect(errorCode(beyondLimit)).toBeUndefined(); - }); - - it("terminates cyclic cause chains", () => { - const cyclic: { cause?: unknown } = {}; - cyclic.cause = cyclic; - - expect(errorCode(cyclic)).toBeUndefined(); - }); -}); diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts deleted file mode 100644 index 737b3ad045..0000000000 --- a/packages/stack/src/errors.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Data, Predicate } from "effect"; -import type { ControlOwnerState } from "./DaemonProtocol.ts"; - -export class BinaryNotFoundError extends Data.TaggedError("BinaryNotFoundError")<{ - readonly service: string; - readonly platform: string; -}> {} - -export class DownloadError extends Data.TaggedError("DownloadError")<{ - readonly url: string; - readonly cause: unknown; -}> {} - -export class ChecksumMismatchError extends Data.TaggedError("ChecksumMismatchError")<{ - readonly url: string; - readonly expected: string; - readonly actual: string; -}> {} - -export class BinaryManifestError extends Data.TaggedError("BinaryManifestError")<{ - readonly url: string; - readonly detail: string; -}> {} - -export class BinaryRuntimeError extends Data.TaggedError("BinaryRuntimeError")<{ - readonly path: string; - readonly detail: string; -}> {} - -export class BinaryHostCompatibilityError extends Data.TaggedError("BinaryHostCompatibilityError")<{ - readonly target: string; - readonly detail: string; -}> {} - -export class DockerPullError extends Data.TaggedError("DockerPullError")<{ - readonly image: string; - readonly detail: string; - readonly cause: unknown; - /** - * Whether the pull failed because the container runtime itself is unusable - * locally — the daemon is unreachable (detected from the runtime's output - * at the boundary where it is produced) or the docker binary could not be - * spawned at all. Consumers must branch on this instead of sniffing - * `detail` text. - */ - readonly daemonDown: boolean; -}> {} - -/** - * Whether a container runtime's output indicates the daemon itself is not - * running. This is the boundary vocabulary for `DockerPullError.daemonDown` - * and shared with the CLI's legacy docker-run layer so both paths agree on - * what "daemon down" looks like. - */ -export const isDockerDaemonDownMessage = (message: string): boolean => { - const normalized = message.toLowerCase(); - return ( - normalized.includes("cannot connect to the docker daemon") || - normalized.includes("docker daemon is not running") || - normalized.includes("docker desktop is not running") || - normalized.includes("is the docker daemon running") || - normalized.includes("cannot connect to podman") || - normalized.includes("error during connect") || - // Spawn succeeds but the socket is not accessible (e.g. a Linux user - // missing docker group membership) — a local setup problem, not a - // registry failure. - normalized.includes("permission denied while trying to connect to the docker daemon") - ); -}; - -export class StackBuildError extends Data.TaggedError("StackBuildError")<{ - readonly detail: string; - readonly cause?: unknown; - /** - * Structured discriminant for consumers that need to distinguish failure - * classes without parsing `detail`: `invalid_config` for user-fixable - * configuration problems, `docker_not_running` for an unavailable local - * runtime, and `asset_preparation` for other download/registry failures. - * Absent for internal invariant violations. - */ - readonly reason?: "invalid_config" | "docker_not_running" | "asset_preparation"; -}> {} - -/** Runtime RPC is unavailable until the supervisor publishes a running stack. */ -export class StackUnavailableError extends Data.TaggedError("StackUnavailableError")<{ - readonly phase: "starting" | "stopping" | "failed"; - readonly detail?: string; -}> {} - -/** A remote RPC request could not reach the owner endpoint. */ -export class StackRpcTransportError extends Data.TaggedError("StackRpcTransportError")<{ - readonly endpoint: string; - readonly procedure: string; - readonly cause: unknown; -}> {} - -/** A same-version RPC response violated the framed/schema protocol. */ -export class StackRpcProtocolError extends Data.TaggedError("StackRpcProtocolError")<{ - readonly endpoint: string; - readonly procedure: string; - readonly detail: string; - readonly cause?: unknown; -}> {} - -export class StackNotRunningError extends Data.TaggedError("StackNotRunningError")<{ - readonly phase: string; -}> {} - -export class StackReadinessError extends Data.TaggedError("StackReadinessError")<{ - readonly target: string; - readonly timeoutMs: number; - readonly detail: string; -}> {} - -/** The owner is healthy but belongs to another immutable CLI version. */ -export class DaemonUpgradeRequired extends Data.TaggedError("DaemonUpgradeRequired")<{ - readonly stackId: string; - readonly oldCliVersion: string; - readonly newCliVersion: string; - readonly state: ControlOwnerState; - readonly ready: boolean; -}> {} - -export class SupervisorStartError extends Data.TaggedError("SupervisorStartError")<{ - readonly message: string; -}> {} - -export class UpgradePreflightError extends Data.TaggedError("UpgradePreflightError")<{ - readonly stackId: string; - readonly oldCliVersion: string; - readonly newCliVersion: string; - readonly detail: string; -}> {} - -export class UpgradeRestartError extends Data.TaggedError("UpgradeRestartError")<{ - readonly stackId: string; - readonly newCliVersion: string; - readonly detail: string; -}> {} - -export class StopTimeout extends Data.TaggedError("StopTimeout")<{ - readonly endpoint: string; - readonly ownerSessionId: string; - readonly lastState?: string; -}> {} - -export class PortConflictError extends Data.TaggedError("PortConflictError")<{ - readonly port: number; - readonly service: string; -}> {} - -export class StackError extends Data.TaggedError("StackError")<{ - readonly code: string; - readonly message: string; - readonly cause?: unknown; -}> {} - -const taggedStackErrorCodes = [ - ["ServiceNotFoundError", "SERVICE_NOT_FOUND"], - ["StackBuildError", "BUILD_ERROR"], - ["StackNotRunningError", "STACK_NOT_RUNNING"], - ["StackReadinessError", "STACK_READINESS_TIMEOUT"], - ["StackUnavailableError", "STACK_UNAVAILABLE"], - ["StackRpcTransportError", "STACK_RPC_TRANSPORT"], - ["StackRpcProtocolError", "STACK_RPC_PROTOCOL"], - ["DaemonUpgradeRequired", "DAEMON_UPGRADE_REQUIRED"], - ["UpgradePreflightError", "UPGRADE_PREFLIGHT"], - ["UpgradeRestartError", "UPGRADE_RESTART"], - ["StopTimeout", "STOP_TIMEOUT"], - ["BinaryNotFoundError", "BINARY_NOT_FOUND"], - ["ChecksumMismatchError", "CHECKSUM_MISMATCH"], - ["BinaryManifestError", "BINARY_MANIFEST"], - ["BinaryRuntimeError", "BINARY_RUNTIME"], - ["BinaryHostCompatibilityError", "BINARY_HOST"], - ["DownloadError", "DOWNLOAD_ERROR"], - ["DockerPullError", "DOCKER_PULL_ERROR"], - ["PortConflictError", "PORT_CONFLICT"], - ["PortAllocationError", "PORT_ALLOCATION"], - ["ServiceReadyError", "SERVICE_NOT_READY"], -] as const; - -const messageForUnknownError = (error: unknown): string => { - if (error instanceof Error && error.message.length > 0) return error.message; - if (error !== null && typeof error === "object" && "detail" in error) { - const detail = error.detail; - if (typeof detail === "string" && detail.length > 0) return detail; - } - return String(error); -}; - -export function toStackError(err: unknown): StackError { - if (err instanceof StackError) return err; - for (const [tag, code] of taggedStackErrorCodes) { - if (Predicate.isTagged(err, tag)) { - return new StackError({ - code, - message: messageForUnknownError(err), - cause: err, - }); - } - } - if (err instanceof Error) { - return new StackError({ code: "UNKNOWN", message: err.message, cause: err }); - } - return new StackError({ code: "UNKNOWN", message: String(err) }); -} diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts deleted file mode 100644 index 9b29c7051f..0000000000 --- a/packages/stack/src/functions.ts +++ /dev/null @@ -1,253 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Synchronous path checks validate project-owned files before entering Effect filesystem operations. -import { existsSync, realpathSync } from "node:fs"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Pure path normalization supports the project-boundary validation helper. -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { Effect, FileSystem, Path, Schema } from "effect"; -import type { ResolvedStackConfig } from "./StackConfig.ts"; - -const absolutePath = Schema.String.check( - Schema.makeFilter((value) => - isAbsolute(value) ? undefined : { path: [], issue: "Expected an absolute path" }, - ), -); - -const environment = Schema.Record(Schema.String, Schema.String); - -export const ResolvedFunctionSchema = Schema.Struct({ - name: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/)), - verifyJWT: Schema.Boolean, - entrypointPath: absolutePath, - importMapPath: Schema.NullOr(absolutePath), - staticFiles: Schema.Array(absolutePath), - env: environment, -}); - -export interface ResolvedFunction extends Schema.Schema.Type {} - -/** - * Project-owned Edge Functions input. Every path and environment reference is - * resolved before the bundle crosses into the stack package. - * - * `env` contains values shared by every function. A function's own `env` - * overrides matching shared values when its worker is created. - */ -export const ResolvedFunctionsBundleSchema = Schema.Struct({ - env: environment, - functions: Schema.Array(ResolvedFunctionSchema), -}).check( - Schema.makeFilter((bundle) => { - const names = new Set(); - for (let index = 0; index < bundle.functions.length; index += 1) { - const name = bundle.functions[index]?.name; - if (name !== undefined && names.has(name)) { - return { - path: ["functions", index, "name"], - issue: `Duplicate function name: ${name}`, - }; - } - if (name !== undefined) { - names.add(name); - } - } - return undefined; - }), -); - -export interface ResolvedFunctionsBundle extends Schema.Schema.Type< - typeof ResolvedFunctionsBundleSchema -> {} - -function isWithinPath(root: string, candidate: string): boolean { - const relativePath = relative(root, candidate); - return ( - relativePath === "" || - (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)) - ); -} - -function nearestExistingAncestor(candidate: string): string | undefined { - let current = resolve(candidate); - while (!existsSync(current)) { - const parent = dirname(current); - if (parent === current) return undefined; - current = parent; - } - return current; -} - -function isWithinProjectDir(projectDir: string, candidate: string): boolean { - const resolvedProjectDir = resolve(projectDir); - if (!isWithinPath(resolvedProjectDir, candidate)) return false; - - const existingCandidate = nearestExistingAncestor(candidate); - if (existingCandidate === undefined) return false; - try { - return isWithinPath(realpathSync(resolvedProjectDir), realpathSync(existingCandidate)); - } catch { - return false; - } -} - -/** - * Docker mounts `projectDir` at the same absolute path as the host. Keeping all - * referenced files below that root gives native and Docker runtimes the same - * bundle contract, including after a reload. - */ -export const resolvedFunctionsBundleSchemaForProject = (projectDir: string) => - ResolvedFunctionsBundleSchema.check( - Schema.makeFilter((bundle) => { - for (let index = 0; index < bundle.functions.length; index += 1) { - const fn = bundle.functions[index]; - if (fn === undefined) continue; - - const paths = [ - { field: "entrypointPath", value: fn.entrypointPath }, - ...(fn.importMapPath === null - ? [] - : [{ field: "importMapPath", value: fn.importMapPath }]), - ...fn.staticFiles.map((value, staticIndex) => ({ - field: `staticFiles.${staticIndex}`, - value, - })), - ]; - const outsideProject = paths.find(({ value }) => !isWithinProjectDir(projectDir, value)); - if (outsideProject !== undefined) { - return { - path: ["functions", index, outsideProject.field], - issue: "Function paths must be within projectDir", - }; - } - } - return undefined; - }), - ); - -export const FunctionsReloadConfigSchema = Schema.Struct({ - functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), -}); - -export interface FunctionsReloadConfig extends Schema.Schema.Type< - typeof FunctionsReloadConfigSchema -> {} - -export interface FunctionsRuntimeConfig { - readonly functionsUrl: string; - readonly supabaseUrl: string; - readonly dbUrl: string; - readonly publishableKey: string; - readonly secretKey: string; - readonly jwtSecret: string; - readonly env: Readonly>; - readonly functions: Readonly< - Record< - string, - { - readonly verifyJWT: boolean; - readonly entrypointPath: string; - readonly importMapPath: string | null; - readonly staticFiles: ReadonlyArray; - readonly env: Readonly>; - } - > - >; -} - -interface FunctionsRuntimeHost { - readonly hostname: string; -} - -export const functionsRuntimeConfigFileName = "functions-runtime-config.json"; - -function edgeRuntimeWorkspaceDir(runtimeRoot: string): string { - return join(runtimeRoot, "edge-runtime"); -} - -export function functionsRuntimeConfigPath(runtimeRoot: string): string { - return join(edgeRuntimeWorkspaceDir(runtimeRoot), functionsRuntimeConfigFileName); -} - -export function resolveFunctionsRuntimeConfig( - stackConfig: ResolvedStackConfig, - runtimeHost: FunctionsRuntimeHost, - bundle: ResolvedFunctionsBundle | undefined, -): FunctionsRuntimeConfig | undefined { - if (bundle === undefined || bundle.functions.length === 0 || stackConfig.edgeRuntime === false) { - return undefined; - } - - return { - functionsUrl: `http://127.0.0.1:${stackConfig.apiPort}/functions/v1`, - supabaseUrl: `http://${runtimeHost.hostname}:${stackConfig.apiPort}`, - dbUrl: `postgresql://postgres:postgres@${runtimeHost.hostname}:${stackConfig.dbPort}/postgres`, - publishableKey: stackConfig.publishableKey, - secretKey: stackConfig.secretKey, - jwtSecret: stackConfig.jwtSecret, - env: bundle.env, - functions: Object.fromEntries( - bundle.functions.map((fn) => [ - fn.name, - { - verifyJWT: fn.verifyJWT, - entrypointPath: fn.entrypointPath, - importMapPath: fn.importMapPath, - staticFiles: fn.staticFiles, - env: fn.env, - }, - ]), - ), - }; -} - -const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( - runtimeRoot: string, - config: FunctionsRuntimeConfig, -) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const filePath = functionsRuntimeConfigPath(runtimeRoot); - const directory = path.dirname(filePath); - // A unique suffix prevents concurrent writers from sharing the atomic temp file. - // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect -- This native filename boundary does not represent domain randomness. - const temporaryPath = `${filePath}.tmp-${crypto.randomUUID()}`; - - yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 }); - yield* Effect.gen(function* () { - // The on-disk runtime config is an explicitly JSON-defined host boundary. - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Runtime bootstrap consumes this stable JSON wire format. - yield* fs.writeFileString(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { - flag: "wx", - mode: 0o600, - }); - yield* fs.chmod(temporaryPath, 0o600); - yield* fs.rename(temporaryPath, filePath); - }).pipe(Effect.ensuring(fs.remove(temporaryPath).pipe(Effect.ignore))); -}); - -export const clearFunctionsRuntimeConfig = Effect.fnUntraced(function* (runtimeRoot: string) { - const fs = yield* FileSystem.FileSystem; - const filePath = functionsRuntimeConfigPath(runtimeRoot); - const directory = yield* Path.Path.pipe(Effect.map((path) => path.dirname(filePath))); - - yield* fs.remove(filePath).pipe(Effect.ignore); - - const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); - yield* Effect.forEach( - entries.filter((entry) => entry.startsWith(`${functionsRuntimeConfigFileName}.tmp-`)), - (entry) => fs.remove(join(directory, entry)).pipe(Effect.ignore), - { discard: true }, - ); -}); - -export const configureFunctionsRuntime = Effect.fnUntraced(function* ( - stackConfig: ResolvedStackConfig, - runtimeHost: FunctionsRuntimeHost, - bundle: ResolvedFunctionsBundle | undefined, -) { - const runtimeConfig = resolveFunctionsRuntimeConfig(stackConfig, runtimeHost, bundle); - if (runtimeConfig === undefined) { - yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); - } else { - yield* writeFunctionsRuntimeConfig(stackConfig.runtimeRoot, runtimeConfig); - } - return runtimeConfig; -}); diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts deleted file mode 100644 index d28c0259da..0000000000 --- a/packages/stack/src/functions.unit.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Functions tests exercise native filesystem fixtures and JSON/JWT protocol payloads through the public Promise facade. - -import { describe, expect, it } from "@effect/vitest"; -import { NodeServices } from "@effect/platform-node"; -import { mkdtempSync, symlinkSync } from "node:fs"; -import { readFile, readdir, rm, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Predicate, Schema } from "effect"; -import { - resolveConfig as resolveConfigEffect, - type ResolveConfigOptions, -} from "./StackConfigResolver.ts"; -import type { PortSet } from "./PortCatalog.ts"; -import { defaultJwtSecret, generateJwt } from "./JwtGenerator.ts"; -import { - clearFunctionsRuntimeConfig, - configureFunctionsRuntime, - functionsRuntimeConfigPath, - ResolvedFunctionsBundleSchema, - resolveFunctionsRuntimeConfig, - type ResolvedFunctionsBundle, -} from "./functions.ts"; -import { verifyRequest } from "./services/edge-runtime-main.ts"; - -const testPorts: PortSet = { - apiPort: 40_000, - dbPort: 40_001, - authPort: 40_002, - postgrestPort: 40_003, - postgrestAdminPort: 40_004, - edgeRuntimePort: 40_005, - edgeRuntimeInspectorPort: 40_006, - realtimePort: 40_007, - storagePort: 40_008, - imgproxyPort: 40_009, - mailpitPort: 40_010, - mailpitSmtpPort: 40_011, - mailpitPop3Port: 40_012, - pgmetaPort: 40_013, - studioPort: 40_014, - analyticsPort: 40_015, - poolerPort: 40_016, - poolerApiPort: 40_017, -}; - -const resolveConfig = ( - config?: Parameters[0], - options?: Partial, -) => - Effect.runPromise( - resolveConfigEffect(config, { ...options, ports: options?.ports ?? testPorts }).pipe( - Effect.provide(NodeServices.layer), - ), - ); - -function makeTempProject(): string { - return mkdtempSync(join(tmpdir(), "supabase-stack-functions-")); -} - -function makeBundle(root: string): ResolvedFunctionsBundle { - return { - env: { SHARED: "shared-value", BUNDLE_ONLY: "bundle-value" }, - functions: [ - { - name: "hello-world", - verifyJWT: true, - entrypointPath: join(root, "functions", "hello-world", "index.ts"), - importMapPath: null, - staticFiles: [join(root, "functions", "hello-world", "assets", "*")], - env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, - }, - ], - }; -} - -function jwtWithInvalidSignature(algorithm?: string): string { - const header = Buffer.from(JSON.stringify({ alg: algorithm, typ: "JWT" })).toString("base64url"); - const payload = Buffer.from(JSON.stringify({ sub: "test-user" })).toString("base64url"); - return `${header}.${payload}.invalid`; -} - -const authFailureCases = [ - { - name: "returns the missing authorization error", - code: "UNAUTHORIZED_NO_AUTH_HEADER", - message: "Missing authorization header", - }, - { - name: "returns the invalid JWT format error", - authorization: "Bearer not-a-jwt", - code: "UNAUTHORIZED_INVALID_JWT_FORMAT", - message: "Invalid JWT format", - }, - { - name: "returns the invalid JWT format error when the algorithm is missing", - authorization: `Bearer ${jwtWithInvalidSignature()}`, - code: "UNAUTHORIZED_INVALID_JWT_FORMAT", - message: "Invalid JWT format", - }, - { - name: "returns the legacy JWT error", - authorization: `Bearer ${jwtWithInvalidSignature("HS256")}`, - code: "UNAUTHORIZED_LEGACY_JWT", - message: "Invalid JWT", - }, - { - name: "returns the asymmetric JWT error", - authorization: `Bearer ${jwtWithInvalidSignature("ES256")}`, - code: "UNAUTHORIZED_ASYMMETRIC_JWT", - message: "Invalid JWT", - }, - { - name: "returns the unsupported algorithm error", - authorization: `Bearer ${jwtWithInvalidSignature("none")}`, - code: "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", - message: "Unsupported JWT algorithm none", - }, -]; - -describe("stack Functions runtime config", () => { - it("projects an explicit bundle without project discovery", async () => { - const root = makeTempProject(); - const stackConfig = await resolveConfig( - { - mode: "docker", - projectDir: root, - functions: makeBundle(root), - }, - { runtime: { mode: "docker", containerRuntime: "docker" } }, - ); - const config = resolveFunctionsRuntimeConfig( - stackConfig, - { hostname: "127.0.0.1" }, - makeBundle(root), - ); - - expect(config?.env).toEqual({ SHARED: "shared-value", BUNDLE_ONLY: "bundle-value" }); - expect(config?.functions["hello-world"]).toEqual({ - verifyJWT: true, - entrypointPath: join(root, "functions", "hello-world", "index.ts"), - importMapPath: null, - staticFiles: [join(root, "functions", "hello-world", "assets", "*")], - env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, - }); - - await rm(root, { recursive: true, force: true }); - }); - - it("rejects a function bundle when Edge Runtime is disabled", async () => { - const root = makeTempProject(); - - const error = await resolveConfig({ - mode: "native", - projectDir: root, - functions: makeBundle(root), - }).then( - () => undefined, - (cause: unknown) => cause, - ); - - expect(Predicate.isTagged(error, "StackBuildError")).toBe(true); - if (Predicate.isTagged(error, "StackBuildError")) { - expect(error).toMatchObject({ - reason: "invalid_config", - detail: "Edge Functions require Edge Runtime to be enabled", - }); - } - - await rm(root, { recursive: true, force: true }); - }); - - it("validates paths, import maps, and unique function names", async () => { - const decode = Schema.decodeUnknownSync(ResolvedFunctionsBundleSchema); - const root = makeTempProject(); - const bundle = makeBundle(root); - - expect(decode(bundle).functions[0]?.importMapPath).toBeNull(); - expect(() => - decode({ - ...bundle, - functions: [{ ...bundle.functions[0], entrypointPath: "./index.ts" }], - }), - ).toThrow("Expected an absolute path"); - expect(() => - decode({ - ...bundle, - functions: [bundle.functions[0], bundle.functions[0]], - }), - ).toThrow("Duplicate function name: hello-world"); - - await rm(root, { recursive: true, force: true }); - }); - - it("validates explicit bundles at config resolution", async () => { - const root = makeTempProject(); - const bundle = makeBundle(root); - - await expect( - resolveConfig({ - projectDir: root, - functions: { - ...bundle, - functions: [bundle.functions[0]!, bundle.functions[0]!], - }, - }), - ).rejects.toMatchObject({ - _tag: "StackBuildError", - detail: "Invalid Edge Functions bundle", - }); - await expect( - resolveConfig({ - projectDir: join(root, "project"), - functions: bundle, - }), - ).rejects.toMatchObject({ - _tag: "StackBuildError", - detail: "Invalid Edge Functions bundle", - }); - - await rm(root, { recursive: true, force: true }); - }); - - it("rejects bundle paths that escape projectDir through a symlink", async () => { - const root = makeTempProject(); - const outside = makeTempProject(); - const bundle = makeBundle(root); - symlinkSync( - outside, - join(root, "linked-outside"), - process.platform === "win32" ? "junction" : "dir", - ); - - await expect( - resolveConfig({ - projectDir: root, - functions: { - ...bundle, - functions: [ - { - ...bundle.functions[0]!, - entrypointPath: join(root, "linked-outside", "index.ts"), - }, - ], - }, - }), - ).rejects.toMatchObject({ - _tag: "StackBuildError", - detail: "Invalid Edge Functions bundle", - }); - - await Promise.all([ - rm(root, { recursive: true, force: true }), - rm(outside, { recursive: true, force: true }), - ]); - }); - - it("keeps placeholder mode when no functions are supplied", async () => { - const stackConfig = await resolveConfig({ functions: false }); - - expect( - resolveFunctionsRuntimeConfig(stackConfig, { hostname: "127.0.0.1" }, undefined), - ).toBeUndefined(); - expect( - resolveFunctionsRuntimeConfig( - stackConfig, - { hostname: "127.0.0.1" }, - { - env: {}, - functions: [], - }, - ), - ).toBeUndefined(); - }); - - it.live("atomically writes restrictive ephemeral config and removes it", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - const bundle = makeBundle(cwd); - const stackConfig = yield* Effect.promise(() => - resolveConfig( - { mode: "docker", projectDir: cwd, runtimeRoot: cwd, functions: bundle }, - { runtime: { mode: "docker", containerRuntime: "docker" } }, - ), - ); - yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, bundle); - const filePath = functionsRuntimeConfigPath(stackConfig.runtimeRoot); - const written = JSON.parse(yield* Effect.promise(() => readFile(filePath, "utf8"))) as { - functions: Record; - }; - - expect(Object.keys(written.functions)).toEqual(["hello-world"]); - expect((yield* Effect.promise(() => stat(filePath))).mode & 0o777).toBe(0o600); - expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([ - "functions-runtime-config.json", - ]); - - yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); - expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([]); - }).pipe( - Effect.provide(NodeServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), - ); - }); -}); - -describe("stack Functions runtime auth", () => { - for (const { name, authorization, code, message } of authFailureCases) { - it(name, async () => { - const response = await verifyRequest( - new Request("http://127.0.0.1/functions/v1/test", { - headers: authorization === undefined ? undefined : { authorization }, - }), - { jwtSecret: defaultJwtSecret }, - { verifyJWT: true }, - ); - - expect(response).not.toBeNull(); - if (response === null) return; - - expect(response.status).toBe(401); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(response.headers.get("sb-error-code")).toBe(code); - expect(response.headers.get("access-control-expose-headers")).toBe("sb-error-code"); - expect(await response.json()).toEqual({ code, message, msg: message }); - }); - } - - it("accepts a valid legacy JWT", async () => { - const token = generateJwt(defaultJwtSecret, "anon"); - const response = await verifyRequest( - new Request("http://127.0.0.1/functions/v1/test", { - headers: { authorization: `Bearer ${token}` }, - }), - { jwtSecret: defaultJwtSecret }, - { verifyJWT: true }, - ); - - expect(response).toBeNull(); - }); - - it("accepts a lowercase bearer scheme", async () => { - const token = generateJwt(defaultJwtSecret, "anon"); - const response = await verifyRequest( - new Request("http://127.0.0.1/functions/v1/test", { - headers: { authorization: `bearer ${token}` }, - }), - { jwtSecret: defaultJwtSecret }, - { verifyJWT: true }, - ); - - expect(response).toBeNull(); - }); -}); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index a482ef0b0a..3c8709143e 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1,37 +1 @@ -// @supabase/stack — shared runtime-agnostic types for conditional root entry points - -export type { LogEntry } from "@supabase/process-compose"; -export type { StackServiceStatus } from "./StackServiceState.ts"; - -export type { - AnalyticsConfig, - AuthConfig, - EdgeRuntimeConfig, - ImgproxyConfig, - MailpitConfig, - PgmetaConfig, - PoolerConfig, - PostgresConfig, - PostgrestConfig, - RealtimeConfig, - ReadinessPolicy, - ReadyOptions, - ServicePolicy, - ServicePolicyManifest, - StackMode, - StackConfig, - StorageConfig, - StudioConfig, - VectorConfig, -} from "./StackConfig.ts"; - -export type { ServiceName, VersionManifest } from "./versions.ts"; -export type { ServiceResolution, StackPreparationError } from "./StackPreparation.ts"; -export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; -export type { StackHandle } from "./stackHandle.ts"; -export type { - FunctionsReloadConfig, - FunctionsRuntimeConfig, - ResolvedFunction, - ResolvedFunctionsBundle, -} from "./functions.ts"; +export * from "./public/index.ts"; diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts deleted file mode 100644 index 3455f4e457..0000000000 --- a/packages/stack/src/layers.ts +++ /dev/null @@ -1,287 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Docker bootstrap paths use native path semantics at a synchronous layer-construction boundary. -import { join } from "node:path"; -import { Data, Effect, Layer } from "effect"; -import { FileSystem, Path } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { ApiProxy, type ProxyConfig } from "./ApiProxy.ts"; -import { BinaryResolver } from "./BinaryResolver.ts"; -import type { PlatformFactory } from "./createStack.ts"; -import { supervisorLayer, type SupervisorStartMessage } from "./supervisor.ts"; -import type { PortLease } from "./PortAllocator.ts"; -import { Stack } from "./Stack.ts"; -import { LocalStackLifecycle, localStackLayer } from "./LocalStack.ts"; -import { StackPreparation } from "./StackPreparation.ts"; -import { StackBuilder } from "./StackBuilder.ts"; -import type { ResolvedStackConfig } from "./StackConfig.ts"; -import { sanitizeDaemonConfigInput, type DaemonConfigInput } from "./StackConfigResolver.ts"; -import { HttpTransportClient } from "./HttpTransportClient.ts"; -import { DEFAULT_MANAGED_STACK_NAME, defaultCacheRoot } from "./paths.ts"; -import type { ManagedStackLaunchInput } from "./managed/document.ts"; -import type { ManagedPortIntentDocument } from "./managed/model.ts"; -import { ManagedStackManager } from "./managed/manager.ts"; -import { ControlTransport } from "./managed/control.ts"; -import { isControlSupervisorStatus } from "./DaemonProtocol.ts"; -import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { prepareUpgradeReplacement } from "./SupervisorUpgradeRestart.ts"; -import { - DaemonUpgradeRequired, - StackRpcProtocolError, - StackRpcTransportError, - StopTimeout, - UpgradePreflightError, - UpgradeRestartError, -} from "./errors.ts"; - -/** - * Inputs owned by the process that will boot the runtime. The lease is passed - * through unchanged: runtime service activation releases fields from this - * exact lease rather than probing or allocating a second port set. - */ -export interface RuntimeBootInput { - readonly config: ResolvedStackConfig; - readonly lease: PortLease; -} - -export interface RuntimeBootOptions { - readonly platform?: ReturnType; -} - -/** - * Build a foreground layer that runs the stack in-process. - * - * Wires: BinaryResolver → StackBuilder → Stack + ApiProxy + platform. - * Returns a fully self-contained layer with no remaining requirements. - */ -const runtimeBootLayer = ( - input: RuntimeBootInput, - platformFactory: PlatformFactory, - options: RuntimeBootOptions = {}, -): Layer.Layer => { - const config = input.config; - const portLease = input.lease; - const platform = - options.platform ?? - platformFactory({ - apiPort: config.apiPort, - releaseApiPort: portLease.release(["apiPort"]), - }); - - const binaryResolverLayer = BinaryResolver.make(config.cacheRoot).pipe( - Layer.provide(FetchHttpClient.layer), - ); - const stackPreparationLayer = StackPreparation.layer.pipe(Layer.provide(binaryResolverLayer)); - const stackLayer = localStackLayer(config, portLease).pipe( - Layer.provide(StackBuilder.layer), - Layer.provide(stackPreparationLayer), - ); - - const proxyConfig: ProxyConfig = { - listenPort: config.apiPort, - gotruePort: config.auth !== false ? config.auth.port : 0, - postgrestPort: config.postgrest !== false ? config.postgrest.port : 0, - postgrestAdminPort: config.postgrest !== false ? config.postgrest.adminPort : 0, - edgeRuntimePort: config.edgeRuntime !== false ? config.edgeRuntime.port : 0, - realtimePort: config.realtime !== false ? config.realtime.port : 0, - storagePort: config.storage !== false ? config.storage.port : 0, - pgmetaPort: config.pgmeta !== false ? config.pgmeta.port : 0, - analyticsPort: config.analytics !== false ? config.analytics.port : 0, - poolerPort: config.pooler !== false ? config.pooler.apiPort : 0, - studioPort: config.studio !== false ? config.studio.port : 0, - publishableKey: config.publishableKey, - secretKey: config.secretKey, - anonJwt: config.anonJwt, - serviceRoleJwt: config.serviceRoleJwt, - }; - const apiProxyLayer = ApiProxy.layer(proxyConfig).pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(stackLayer), - ); - - return Layer.mergeAll(stackLayer, apiProxyLayer).pipe(Layer.provide(platform), Layer.orDie); -}; - -/** Build an in-process stack runtime with an already selected lease. */ -export const foregroundLayer = ( - config: ResolvedStackConfig, - platformFactory: PlatformFactory, - portLease: PortLease, -): Layer.Layer => - runtimeBootLayer({ config, lease: portLease }, platformFactory); - -// --------------------------------------------------------------------------- -// Detached mode errors -// --------------------------------------------------------------------------- - -export class DaemonStartError extends Data.TaggedError("DaemonStartError")<{ - readonly message: string; -}> {} - -/** Managed-only additions kept outside the generic daemon config resolver. */ -export type ManagedDaemonConfigInput = DaemonConfigInput & { - readonly cliVersion: string; - readonly portIntents: ManagedPortIntentDocument; - readonly launch?: ManagedStackLaunchInput; -}; - -// --------------------------------------------------------------------------- -// Daemon-backed mode -// --------------------------------------------------------------------------- - -interface ManagedSupervisorStart { - readonly message: SupervisorStartMessage; - readonly configInput: DaemonConfigInput; -} - -const managedSupervisorStartMessage = ( - input: ManagedDaemonConfigInput, -): Effect.Effect => - Effect.gen(function* () { - // Keep managed coordination metadata out of the generic daemon config. - const { portIntents, launch, ...daemonConfigInput } = input; - const daemonInput = sanitizeDaemonConfigInput(daemonConfigInput); - if (daemonInput.stackRoot !== undefined || daemonInput.runtimeRoot !== undefined) { - return yield* new DaemonStartError({ - message: "Managed daemon stacks derive stackRoot and runtimeRoot automatically", - }); - } - const projectDir = daemonInput.projectDir ?? daemonInput.cwd; - const name = daemonInput.name ?? DEFAULT_MANAGED_STACK_NAME; - const cacheRoot = daemonInput.cacheRoot ?? defaultCacheRoot(); - const stateRoot = join(cacheRoot, "managed"); - const config: DaemonConfigInput = { - ...daemonInput, - cacheRoot, - projectDir, - name, - }; - const discovery = yield* ensureEnvironment(projectDir).pipe( - Effect.provide(gitConfigStoreLayer), - Effect.mapError((error) => new DaemonStartError({ message: error.message })), - ); - return { - configInput: config, - message: { - type: "start", - cliVersion: input.cliVersion, - stackId: deriveStackId(discovery.identity, name), - workspacePath: projectDir, - stackName: name, - stateRoot, - config, - portIntents, - ...(launch === undefined ? {} : { launch }), - }, - }; - }); - -const launchManagedSupervisor = ( - startMsg: SupervisorStartMessage, - daemonEntryPoint: string, -): Effect.Effect< - Layer.Layer, - | DaemonStartError - | DaemonUpgradeRequired - | UpgradePreflightError - | UpgradeRestartError - | StopTimeout, - HttpTransportClient -> => - Effect.gen(function* () { - const httpTransportClient = yield* HttpTransportClient; - return yield* supervisorLayer(startMsg, daemonEntryPoint).pipe( - Effect.provideService(HttpTransportClient, httpTransportClient), - Effect.mapError((error) => - error instanceof DaemonUpgradeRequired || - error instanceof UpgradePreflightError || - error instanceof UpgradeRestartError || - error instanceof StopTimeout - ? error - : new DaemonStartError({ message: error.message }), - ), - ); - }); - -/** Fork the unified supervisor and return a RemoteStack layer connected to it. */ -export const daemonLayer = (input: ManagedDaemonConfigInput, daemonEntryPoint: string) => - managedSupervisorStartMessage(input).pipe( - Effect.flatMap(({ message }) => launchManagedSupervisor(message, daemonEntryPoint)), - ); - -/** Explicitly authorize a full stop/start when the current owner is incompatible. */ -export const restartManagedStackForUpgrade = ( - input: ManagedDaemonConfigInput, - mismatch: DaemonUpgradeRequired, - daemonEntryPoint: string, -): Effect.Effect< - Layer.Layer, - | DaemonStartError - | DaemonUpgradeRequired - | UpgradePreflightError - | UpgradeRestartError - | StopTimeout, - | FileSystem.FileSystem - | Path.Path - | HttpTransportClient - | ManagedStackManager - | ControlTransport - | import("effect/unstable/process").ChildProcessSpawner.ChildProcessSpawner -> => - Effect.gen(function* () { - const { message: startMsg, configInput } = yield* managedSupervisorStartMessage(input); - const manager = yield* ManagedStackManager; - const controlTransport = yield* ControlTransport; - const probe = yield* manager.probeControl(startMsg.stackId).pipe( - Effect.mapError( - (error) => - new UpgradeRestartError({ - stackId: startMsg.stackId, - newCliVersion: startMsg.cliVersion, - detail: error.message, - }), - ), - ); - const owner = probe?.status; - if (owner !== undefined && !isControlSupervisorStatus(owner)) { - return yield* new UpgradeRestartError({ - stackId: startMsg.stackId, - newCliVersion: startMsg.cliVersion, - detail: `Managed stack is busy with ${owner.operation} maintenance`, - }); - } - if (owner?.daemonCliVersion === startMsg.cliVersion) { - return yield* launchManagedSupervisor(startMsg, daemonEntryPoint); - } - const oldCliVersion = owner?.daemonCliVersion ?? mismatch.oldCliVersion; - const prepared = yield* prepareUpgradeReplacement({ - stackId: startMsg.stackId, - oldCliVersion, - ...(probe === undefined ? {} : { oldOwner: probe }), - input: startMsg, - configInput, - manager, - controlTransport, - }); - return yield* launchManagedSupervisor( - { - ...startMsg, - replacement: true, - config: prepared.effectiveConfigInput, - launch: prepared.launch, - }, - daemonEntryPoint, - ).pipe( - Effect.mapError((error) => - error instanceof DaemonUpgradeRequired || - error instanceof UpgradePreflightError || - error instanceof UpgradeRestartError || - error instanceof StopTimeout - ? error - : new UpgradeRestartError({ - stackId: startMsg.stackId, - newCliVersion: startMsg.cliVersion, - detail: error.message, - }), - ), - ); - }); diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts deleted file mode 100644 index e98108ad45..0000000000 --- a/packages/stack/src/managed-bun.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer as EffectLayer } from "effect"; -import { - managedDaemonLayer as managedDaemonLayerForPlatform, - type ManagedDaemonStartInput, -} from "./supervisor.ts"; -import { daemonEntryPoint as managedDaemonEntryPoint } from "./platform-bun.ts"; -import { managedStackManagerLayer as managedStackManagerLayerCore } from "./managed/manager.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { controlTransportLayer } from "./platform-bun.ts"; - -export * from "./managed.ts"; -export { controlTransportLayer }; -export { managedDaemonEntryPoint }; -export type { ManagedDaemonStartInput } from "./supervisor.ts"; - -export const managedStackManagerLayer = (options: { readonly stateRoot: string }) => - managedStackManagerLayerCore(options).pipe( - EffectLayer.provide( - EffectLayer.mergeAll(BunServices.layer, gitConfigStoreLayer, controlTransportLayer), - ), - ); - -export const managedDaemonLayer = ( - input: ManagedDaemonStartInput, -): ReturnType => - managedDaemonLayerForPlatform(input, managedDaemonEntryPoint).pipe( - Effect.provide(BunServices.layer), - ); diff --git a/packages/stack/src/managed-control.integration.test.ts b/packages/stack/src/managed-control.integration.test.ts deleted file mode 100644 index 6659f4b41f..0000000000 --- a/packages/stack/src/managed-control.integration.test.ts +++ /dev/null @@ -1,1042 +0,0 @@ -// oxlint-disable effecttsgo/global-fetch-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Managed-control tests use native HTTP/JSON protocol payloads at the integration boundary. -import { it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Predicate, Result, Stream } from "effect"; -import * as TestClock from "effect/testing/TestClock"; -import { randomUUID } from "node:crypto"; -import { spawn } from "node:child_process"; -import { createServer, type Server } from "node:http"; -import { createServer as createTcpServer, type Server as TcpServer, type Socket } from "node:net"; -import { describe, expect } from "vitest"; -import { - acquireControl, - CONTROL_CANDIDATE_COUNT, - controlEndpoint, - controlEndpointCandidates, - ControlBindError, - type ControlOwnerStatus, - ControlTransport, - type ControlTransportShape, - ControlTransportError, - isControlAttached, - isControlOwnership, - probeControl, - readControlOwnerStatus, - requestControlStopForSession, -} from "./managed/control.ts"; -import { controlTransportLayer } from "./platform-node.ts"; -import { Stack } from "./Stack.ts"; -import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -import { makeSupervisorSessionFixture } from "../tests/helpers/SupervisorSessionFixture.ts"; - -const STACK_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const COLLIDING_STACK_ID = `${STACK_ID.slice(0, 10)}${"f".repeat(54)}`; - -const live = (effect: Effect.Effect) => - effect.pipe(Effect.provide(controlTransportLayer)); - -const makeStack = (started: { value: boolean }): Stack["Service"] => ({ - getInfo: Effect.succeed({ - url: "http://127.0.0.1", - dbUrl: "postgres://127.0.0.1", - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "service", - serviceEndpoints: {}, - }), - start: Effect.sync(() => void (started.value = true)), - stop: Effect.void, - dispose: Effect.void, - startService: () => Effect.void, - stopService: () => Effect.void, - restartService: () => Effect.void, - reloadFunctions: () => Effect.void, - reloadEdgeRuntime: () => Effect.void, - getState: () => Effect.die("unused"), - getAllStates: Effect.succeed([]), - stateChanges: () => Effect.succeed(Stream.empty), - allStateChanges: Stream.empty, - waitReady: () => Effect.void, - waitAllReady: () => Effect.void, - subscribeLogs: () => Stream.empty, - subscribeAllLogs: () => Stream.empty, - logHistory: () => Effect.succeed([]), - logHistoryAll: () => Effect.succeed([]), -}); - -const makeStaticOwner = (stackId: string, stack: Stack["Service"]) => - Effect.gen(function* () { - const ownerSessionId = `static-owner-${randomUUID()}`; - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: stackId, - ownerSessionId, - daemonCliVersion: "test", - close: Effect.void, - }); - const application = { - app: yield* makeSupervisorControlApplication(lifecycle), - }; - const owner = yield* acquireControl({ - stackId, - initialStatus: { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: stackId, - ownerSessionId, - kind: "supervisor", - state: "starting", - ready: false, - daemonCliVersion: "test", - }, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - yield* lifecycle.setClose(owner.close); - yield* lifecycle.publishStack(stack); - return { lifecycle, owner }; - }); - -const listenRawResponse = (port: number, body: string): Promise => - new Promise((resolve, reject) => { - const server = createServer((_request, response) => { - response.writeHead(200, { "content-type": "text/plain" }); - response.end(body); - }); - server.once("error", reject); - server.listen(port, "127.0.0.1", () => resolve(server)); - }); - -const listenRaw = (port: number): Promise => listenRawResponse(port, "not-supabase"); - -const listenNonHttp = ( - port: number, -): Promise<{ readonly server: TcpServer; readonly close: () => Promise }> => - new Promise((resolve, reject) => { - const sockets = new Set(); - const server = createTcpServer((socket) => { - sockets.add(socket); - socket.once("close", () => sockets.delete(socket)); - socket.end("not-http\r\n"); - }); - server.once("error", reject); - server.listen(port, "127.0.0.1", () => - resolve({ - server, - close: () => - new Promise((resolveClose, rejectClose) => { - for (const socket of sockets) socket.destroy(); - server.close((error) => (error === undefined ? resolveClose() : rejectClose(error))); - }), - }), - ); - }); - -const closeRaw = (server: Server): Promise => - new Promise((resolve, reject) => { - if (!server.listening) { - resolve(); - return; - } - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); - -it.effect("canonical owner reads retain foreign-owner conflict diagnostics", () => - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const result = yield* readControlOwnerStatus(endpoint, STACK_ID, () => - Effect.succeed({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: "f".repeat(64), - ownerSessionId: "foreign-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "foreign", - }), - ).pipe(Effect.result); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(Predicate.isTagged(result.failure, "ControlAddressConflictError")).toBe(true); - } - }), -); - -it.effect("decodes maintenance ownership without a daemon version identity", () => - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const status = yield* readControlOwnerStatus(endpoint, STACK_ID, () => - Effect.succeed({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: STACK_ID, - ownerSessionId: "maintenance-session", - kind: "maintenance", - operation: "delete", - }), - ); - expect(status).toMatchObject({ - kind: "maintenance", - operation: "delete", - }); - expect("daemonCliVersion" in status).toBe(false); - }), -); - -const spawnBoundChild = (port: number) => { - const child = spawn( - process.execPath, - [ - "-e", - 'const http=require("node:http"); const server=http.createServer((_req,res)=>res.end("child")); server.listen(Number(process.argv[1]), "127.0.0.1", () => process.send?.("ready"));', - String(port), - ], - { stdio: ["ignore", "ignore", "pipe", "ipc"] }, - ); - let stderr = ""; - child.stderr?.on("data", (chunk: Uint8Array) => { - stderr += new TextDecoder().decode(chunk); - }); - const ready = new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code) => - reject(new Error(`child exited before ready: ${code}\n${stderr || "(no stderr)"}`)), - ); - child.on("message", (message) => { - if (message === "ready") resolve(); - }); - }); - const exited = new Promise((resolve) => child.once("exit", () => resolve())); - return { child, ready, exited }; -}; - -describe("managed control endpoint", () => { - it.live("derives deterministic loopback candidates from the ownership id", () => { - return Effect.sync(() => { - const endpoint = Effect.runSync(controlEndpoint(STACK_ID)); - expect(endpoint.url).toBe("http://127.0.0.1:13737"); - const candidates = Effect.runSync(controlEndpointCandidates(STACK_ID)); - expect(candidates).toHaveLength(CONTROL_CANDIDATE_COUNT); - expect(candidates.map(({ port }) => port)).toEqual( - Array.from({ length: CONTROL_CANDIDATE_COUNT }, (_, offset) => 13737 + offset), - ); - }); - }); - - it.live("serves the static supervisor application on the owned listener", () => - Effect.scoped( - live( - Effect.gen(function* () { - const started = { value: false }; - const stack = makeStack(started); - const { owner } = yield* makeStaticOwner(STACK_ID, stack); - expect(started.value).toBe(false); - const response = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); - expect(response.status).toBe(200); - expect(yield* Effect.promise(() => response.json())).toMatchObject({ - ownershipId: STACK_ID, - state: "running", - ready: true, - }); - }), - ), - ), - ); - - it.live( - "publishes owner status before and after runtime publication on the static listener", - () => - Effect.scoped( - live( - Effect.gen(function* () { - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: STACK_ID, - ownerSessionId: `static-listener-${randomUUID()}`, - daemonCliVersion: "test", - close: Effect.void, - }); - const application = { - app: yield* makeSupervisorControlApplication(lifecycle), - }; - const owner = yield* acquireControl({ - stackId: STACK_ID, - initialStatus: yield* lifecycle.currentStatus, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - yield* lifecycle.setClose(owner.close); - const before = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); - expect(before.status).toBe(200); - expect(yield* Effect.promise(() => before.json())).toMatchObject({ state: "starting" }); - yield* lifecycle.publishStack(makeStack({ value: false })); - const after = yield* Effect.promise(() => fetch(`${owner.endpoint.url}/owner`)); - expect(yield* Effect.promise(() => after.json())).toMatchObject({ - state: "running", - ready: true, - }); - yield* owner.close; - }), - ), - ), - ); - - it.live("hands a fenced stop request to the supervisor shutdown transaction exactly once", () => - Effect.scoped( - live( - Effect.gen(function* () { - const stopCalls = { value: 0 }; - const stack = { - ...makeStack({ value: false }), - stop: Effect.sync(() => { - stopCalls.value += 1; - }), - } satisfies Stack["Service"]; - const { owner, lifecycle } = yield* makeStaticOwner(STACK_ID, stack); - const ownerStatus = yield* lifecycle.currentStatus; - - const response = yield* Effect.promise(() => - fetch(`${owner.endpoint.url}/stop`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ownershipId: STACK_ID, - ownerSessionId: ownerStatus.ownerSessionId, - intent: "explicit", - }), - }), - ); - expect(response.status).toBe(202); - expect(yield* Effect.promise(() => response.json())).toEqual({ ok: true }); - yield* lifecycle.awaitShutdown; - expect(stopCalls.value).toBe(1); - }), - ), - ), - ); - - it.live("attaches a concurrent caller to the live owner", () => - Effect.scoped( - live( - Effect.gen(function* () { - yield* makeStaticOwner(STACK_ID, makeStack({ value: false })); - const contender = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - if (!isControlAttached(contender)) throw new Error("expected attached control"); - expect(yield* contender.ownerStatus).toMatchObject({ - controlProtocolVersion: 1, - state: "running", - ready: true, - }); - }), - ), - ), - ); - - it.live("claims the next candidate when another stack owns the first", () => - Effect.scoped( - live( - Effect.gen(function* () { - const firstEndpoint = yield* controlEndpoint(STACK_ID); - const secondEndpoint = yield* controlEndpoint(COLLIDING_STACK_ID); - expect(secondEndpoint.port).toBe(firstEndpoint.port); - const owner = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - const contender = yield* acquireControl({ - stackId: COLLIDING_STACK_ID, - maintenanceOperation: "update", - }); - if (!isControlOwnership(contender)) throw new Error("expected contender ownership"); - expect(contender.endpoint.port).not.toBe(owner.endpoint.port); - - // Readers locate each owner at its actual candidate. - const ownerProbe = yield* probeControl(STACK_ID); - expect(ownerProbe?.endpoint.port).toBe(owner.endpoint.port); - const contenderProbe = yield* probeControl(COLLIDING_STACK_ID); - expect(contenderProbe?.endpoint.port).toBe(contender.endpoint.port); - - // A second caller for the collided stack attaches to its owner. - const attached = yield* acquireControl({ - stackId: COLLIDING_STACK_ID, - maintenanceOperation: "update", - }); - expect(isControlAttached(attached)).toBe(true); - expect(attached.endpoint.port).toBe(contender.endpoint.port); - }), - ), - ), - ); - - it.live("binds again after the owner scope releases the address", () => - live( - Effect.gen(function* () { - yield* Effect.scoped( - Effect.gen(function* () { - const owner = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - expect(isControlOwnership(owner)).toBe(true); - }), - ); - const next = yield* Effect.scoped( - acquireControl({ stackId: STACK_ID, maintenanceOperation: "update" }), - ); - expect(isControlOwnership(next)).toBe(true); - }), - ), - ); - - it.live("claims the next candidate without taking over an unrelated listener", () => - live( - Effect.scoped( - Effect.gen(function* () { - const candidates = yield* controlEndpointCandidates(STACK_ID); - const unrelated = yield* Effect.acquireRelease( - Effect.promise(() => listenRaw(candidates[0]!.port)), - (server) => Effect.promise(() => closeRaw(server)), - ); - const owner = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - expect(owner.endpoint.port).toBe(candidates[1]!.port); - expect(unrelated.listening).toBe(true); - const probe = yield* probeControl(STACK_ID); - expect(probe?.endpoint.port).toBe(candidates[1]!.port); - }), - ), - ), - ); - - it.live("starts on the next candidate when another stack service is not HTTP", () => - live( - Effect.scoped( - Effect.gen(function* () { - const candidates = yield* controlEndpointCandidates(STACK_ID); - const unrelated = yield* Effect.acquireRelease( - Effect.promise(() => listenNonHttp(candidates[0]!.port)), - (listener) => Effect.promise(() => listener.close()), - ); - const owner = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - expect(owner.endpoint.port).toBe(candidates[1]!.port); - expect(unrelated.server.listening).toBe(true); - }), - ), - ), - ); - - it.live("fails once every candidate is occupied by unrelated listeners", () => - live( - Effect.scoped( - Effect.gen(function* () { - const candidates = yield* controlEndpointCandidates(STACK_ID); - yield* Effect.forEach(candidates, (candidate) => - Effect.acquireRelease( - Effect.promise(() => listenRaw(candidate.port)), - (server) => Effect.promise(() => closeRaw(server)), - ), - ); - const result = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }).pipe(Effect.result); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(Predicate.isTagged(result.failure, "ControlAddressConflictError")).toBe(true); - } - }), - ), - ), - ); - - it.live("reports an unavailable control endpoint before the parent handshake expires", () => - Effect.scoped( - Effect.gen(function* () { - const unavailable = Layer.succeed(ControlTransport, { - bind: (endpoint) => - Effect.fail( - new ControlBindError({ endpoint, reason: "in-use" as const, cause: "occupied" }), - ), - read: (endpoint) => - Effect.fail( - new ControlTransportError({ - endpoint, - reason: "unreachable" as const, - cause: "unavailable", - }), - ), - requestStop: () => Effect.void, - }); - const exit = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }).pipe(Effect.timeout("10 seconds"), Effect.exit, Effect.provide(unavailable)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toMatchObject({ - _tag: "ControlAddressConflictError", - }); - } - }), - ), - ); - - it.effect("retries an unreachable owner candidate before considering later candidates", () => - Effect.scoped( - Effect.gen(function* () { - const candidates = yield* controlEndpointCandidates(STACK_ID); - const ownerEndpoint = candidates[0]!; - const ownerStatus: ControlOwnerStatus = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: STACK_ID, - ownerSessionId: "owner-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "old", - }; - const attachUnavailable = yield* Deferred.make(); - let ownerReads = 0; - const attemptedBinds: Array = []; - const transport = Layer.succeed(ControlTransport, { - bind: (endpoint) => { - attemptedBinds.push(endpoint.port); - return Effect.fail( - new ControlBindError({ - endpoint, - reason: endpoint.port === ownerEndpoint.port ? "in-use" : "failed", - cause: new Error( - endpoint.port === ownerEndpoint.port - ? "owner is listening" - : "must not bind a later candidate while the owner is unreachable", - ), - }), - ); - }, - read: (endpoint) => { - if (endpoint.port !== ownerEndpoint.port) { - return Effect.fail( - new ControlTransportError({ - endpoint, - reason: "unreachable", - cause: new Error("candidate is free"), - }), - ); - } - ownerReads += 1; - if (ownerReads > 2) return Effect.succeed(ownerStatus); - return ( - ownerReads === 2 ? Deferred.succeed(attachUnavailable, undefined) : Effect.void - ).pipe( - Effect.andThen( - Effect.fail( - new ControlTransportError({ - endpoint, - reason: "unreachable", - cause: new Error("owner handshake is temporarily unavailable"), - }), - ), - ), - ); - }, - requestStop: () => Effect.void, - }); - const pending = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }).pipe(Effect.provide(transport), Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(attachUnavailable); - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - const result = yield* Fiber.join(pending).pipe(Effect.result); - - expect(Result.isSuccess(result)).toBe(true); - if (Result.isSuccess(result)) { - expect(isControlAttached(result.success)).toBe(true); - expect(result.success.endpoint).toEqual(ownerEndpoint); - } - expect(attemptedBinds).toEqual([ownerEndpoint.port]); - }), - ), - ); - - it.live("fails closed when an owner probe encounters ambiguous transport", () => - Effect.scoped( - Effect.gen(function* () { - let reads = 0; - const transport = Layer.succeed(ControlTransport, { - bind: (endpoint) => - Effect.fail( - new ControlBindError({ endpoint, reason: "in-use" as const, cause: "occupied" }), - ), - read: (endpoint) => - Effect.fail( - new ControlTransportError({ - endpoint, - reason: reads++ === 0 ? ("transport" as const) : ("unreachable" as const), - cause: "probe failed", - }), - ), - requestStop: () => Effect.void, - }); - const exit = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }).pipe(Effect.result, Effect.provide(transport)); - expect(Result.isFailure(exit)).toBe(true); - if (Result.isFailure(exit)) { - expect(exit.failure).toBeInstanceOf(ControlTransportError); - if (exit.failure instanceof ControlTransportError) { - expect(exit.failure.reason).toBe("transport"); - } - } - }), - ), - ); - - it.effect("observes the original session after an ambiguous stop delivery", () => - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const ownerSessionId = "owner-session"; - const status: ControlOwnerStatus = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: STACK_ID, - ownerSessionId, - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }; - let requestCalls = 0; - let reads = 0; - const transport: ControlTransportShape = { - bind: () => Effect.die("unused"), - read: () => - Effect.sync(() => { - reads += 1; - return status; - }), - requestStop: (requestEndpoint) => - Effect.sync(() => { - requestCalls += 1; - }).pipe( - Effect.andThen( - Effect.fail( - new ControlTransportError({ - endpoint: requestEndpoint, - reason: "transport", - cause: new Error("simulated connection reset after POST delivery"), - }), - ), - ), - ), - }; - const pending = yield* requestControlStopForSession( - endpoint, - STACK_ID, - ownerSessionId, - transport, - ).pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - yield* TestClock.adjust("30 seconds"); - yield* Effect.yieldNow; - - const result = yield* Fiber.join(pending).pipe(Effect.result); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect( - Predicate.isTagged(result.failure, "StopTimeout"), - `expected StopTimeout, received ${String(result.failure)}`, - ).toBe(true); - if (Predicate.isTagged(result.failure, "StopTimeout")) { - expect(result.failure.lastState).toBe("running"); - } - } - expect(requestCalls).toBe(1); - expect(reads).toBeGreaterThan(0); - }), - ); - - it.effect("completes when the captured session is replaced at the stop deadline", () => - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const replacement: ControlOwnerStatus = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: STACK_ID, - ownerSessionId: "replacement-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "new", - }; - let reads = 0; - const transport: ControlTransportShape = { - bind: () => Effect.die("unused"), - read: () => - Effect.sync(() => { - reads += 1; - return replacement; - }), - requestStop: () => Effect.never, - }; - const pending = yield* requestControlStopForSession( - endpoint, - STACK_ID, - "old-session", - transport, - "replacement", - "1 second", - ).pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - yield* TestClock.adjust("1 second"); - yield* Fiber.join(pending); - expect(reads).toBe(1); - }), - ); - - it.effect("retries an ambiguous observation until the exact session changes", () => - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const ownerSessionId = "owner-session"; - const readStarted = yield* Deferred.make(); - const status: ControlOwnerStatus = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: STACK_ID, - ownerSessionId, - kind: "supervisor", - state: "stopping", - ready: false, - daemonCliVersion: "test", - }; - const replacementStatus = { ...status, ownerSessionId: "replacement-session" }; - let reads = 0; - const secondRead = yield* Deferred.make(); - const transport: ControlTransportShape = { - bind: () => Effect.die("unused"), - read: (readEndpoint) => { - return Effect.sync(() => { - reads += 1; - return reads; - }).pipe( - Effect.flatMap((attempt) => - attempt === 1 - ? Deferred.succeed(readStarted, void 0).pipe( - Effect.andThen( - Effect.fail( - new ControlTransportError({ - endpoint: readEndpoint, - reason: "transport", - cause: new Error("simulated observation reset"), - }), - ), - ), - ) - : Deferred.succeed(secondRead, void 0).pipe(Effect.as(replacementStatus)), - ), - ); - }, - requestStop: () => Effect.void, - }; - const pending = yield* requestControlStopForSession( - endpoint, - STACK_ID, - ownerSessionId, - transport, - ).pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(readStarted); - yield* Effect.yieldNow; - yield* TestClock.adjust("25 millis"); - yield* Deferred.await(secondRead); - const result = yield* Fiber.join(pending).pipe(Effect.result); - expect(Result.isSuccess(result)).toBe(true); - expect(reads).toBe(2); - }), - ); - - it.effect("completes when another stack rebinds the stopped owner's endpoint", () => - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const ownerSessionId = "owner-session"; - const foreignStatus: ControlOwnerStatus = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: "f".repeat(64), - ownerSessionId: "foreign-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }; - const transport: ControlTransportShape = { - bind: () => Effect.die("unused"), - read: () => Effect.succeed(foreignStatus), - requestStop: () => Effect.void, - }; - - const result = yield* requestControlStopForSession( - endpoint, - STACK_ID, - ownerSessionId, - transport, - ).pipe(Effect.result); - - expect(Result.isSuccess(result)).toBe(true); - }), - ); - - it.live("treats a post-stop non-control response as proof that the captured session ended", () => - Effect.forEach(["malformed", "protocol-mismatch"] as const, (replacementKind) => - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const ownerSessionId = "owner-session"; - const oldListenerClosed = yield* Deferred.make(); - const replacementBound = yield* Deferred.make(); - let stopRequests = 0; - let reads = 0; - const transport: ControlTransportShape = { - bind: () => Effect.die("unused"), - requestStop: () => - Effect.sync(() => { - stopRequests += 1; - }).pipe( - // Model the supervisor's ordered teardown and the unrelated - // listener rebinding before the first post-stop read. - Effect.andThen(Deferred.succeed(oldListenerClosed, undefined)), - Effect.andThen(Deferred.succeed(replacementBound, undefined)), - ), - read: () => - Effect.gen(function* () { - yield* Deferred.await(oldListenerClosed); - yield* Deferred.await(replacementBound); - reads += 1; - if (replacementKind === "malformed") { - return "not-supabase"; - } - return { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 2, - ownershipId: STACK_ID, - ownerSessionId: "replacement-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "foreign", - }; - }), - }; - - const result = yield* requestControlStopForSession( - endpoint, - STACK_ID, - ownerSessionId, - transport, - ).pipe(Effect.result); - expect(Result.isSuccess(result)).toBe(true); - expect(stopRequests).toBe(1); - expect(reads).toBe(1); - }), - ).pipe(Effect.asVoid), - ); - - it.effect("retains the verified attach status when a later live read is unreachable", () => - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const status: ControlOwnerStatus = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: STACK_ID, - ownerSessionId: "owner-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }; - let reads = 0; - const transport: ControlTransportShape = { - bind: () => Effect.die("unused"), - read: (readEndpoint) => - Effect.suspend(() => { - reads += 1; - return reads === 1 - ? Effect.succeed(status) - : Effect.fail( - new ControlTransportError({ - endpoint: readEndpoint, - reason: "unreachable", - cause: new Error("owner closed after attach handshake"), - }), - ); - }), - requestStop: () => Effect.void, - }; - const attached = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }).pipe(Effect.provideService(ControlTransport, transport)); - expect(isControlAttached(attached)).toBe(true); - if (!isControlAttached(attached)) return; - expect(attached.observedStatus).toEqual(status); - const liveStatus = yield* attached.ownerStatus.pipe(Effect.result); - expect(Result.isFailure(liveStatus)).toBe(true); - expect(reads).toBe(2); - expect(endpoint.port).toBe(attached.endpoint.port); - }), - ); - - it.effect("stops only the owner session verified by the attach handshake", () => - Effect.gen(function* () { - const attachedStatus: ControlOwnerStatus = { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: STACK_ID, - ownerSessionId: "attached-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "old", - }; - const replacementStatus: ControlOwnerStatus = { - ...attachedStatus, - ownerSessionId: "replacement-session", - daemonCliVersion: "new", - }; - let reads = 0; - let requestedSession: string | undefined; - const transport: ControlTransportShape = { - bind: () => Effect.die("unused"), - read: () => Effect.sync(() => (reads++ === 0 ? attachedStatus : replacementStatus)), - requestStop: (_requestEndpoint, request) => - Effect.sync(() => { - requestedSession = request.ownerSessionId; - expect(request).toMatchObject({ intent: "explicit" }); - }), - }; - const attached = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }).pipe(Effect.provideService(ControlTransport, transport)); - expect(isControlAttached(attached)).toBe(true); - if (!isControlAttached(attached)) return; - - yield* attached.requestStop; - - expect(requestedSession).toBe("attached-session"); - }), - ); - - it.live("preserves an explicit owner protocol mismatch", () => - live( - Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const unrelated = yield* Effect.acquireRelease( - Effect.promise(() => - listenRawResponse( - endpoint.port, - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 2, - ownershipId: STACK_ID, - ownerSessionId: "foreign", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "foreign", - }), - ), - ), - (server) => Effect.promise(() => closeRaw(server)), - ); - const result = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }).pipe(Effect.result); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(Predicate.isTagged(result.failure, "ControlProtocolMismatchError")).toBe(true); - } - expect(unrelated.listening).toBe(true); - }), - ), - ), - ); - - it.live("attaches before close and rebinds after close", () => - live( - Effect.scoped( - Effect.gen(function* () { - const owner = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - if (!isControlOwnership(owner)) throw new Error("expected control ownership"); - const attached = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - expect(isControlAttached(attached)).toBe(true); - yield* owner.close; - const next = yield* acquireControl({ stackId: STACK_ID, maintenanceOperation: "update" }); - expect(isControlOwnership(next)).toBe(true); - }), - ), - ), - ); - - it.live("reclaims the endpoint after the owning process dies", () => - live( - Effect.scoped( - Effect.gen(function* () { - const endpoint = yield* controlEndpoint(STACK_ID); - const child = spawnBoundChild(endpoint.port); - yield* Effect.promise(() => child.ready); - child.child.kill("SIGKILL"); - yield* Effect.promise(() => child.exited); - const owner = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - expect(isControlOwnership(owner)).toBe(true); - }), - ), - ), - ); - - it.live("rejects a fenced stop while a maintenance owner holds the endpoint", () => - Effect.scoped( - live( - Effect.gen(function* () { - const maintenance = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "delete", - }); - if (!isControlOwnership(maintenance)) throw new Error("expected maintenance ownership"); - const attached = yield* acquireControl({ - stackId: STACK_ID, - maintenanceOperation: "update", - }); - if (!isControlAttached(attached)) throw new Error("expected maintenance attachment"); - const result = yield* attached.requestStop.pipe(Effect.result); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(Predicate.isTagged(result.failure, "ControlMaintenanceBusyError")).toBe(true); - } - }), - ), - ), - ); -}); diff --git a/packages/stack/src/managed-environment.integration.test.ts b/packages/stack/src/managed-environment.integration.test.ts deleted file mode 100644 index 95ec1a8eb0..0000000000 --- a/packages/stack/src/managed-environment.integration.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -// oxlint-disable effecttsgo/any-unknown-in-error-context, effecttsgo/node-builtin-import -- Managed environment tests validate dynamic process environment failures against native filesystem fixtures. - -import { NodeFileSystem } from "@effect/platform-node"; -import { it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; -import { afterEach, describe, expect } from "vitest"; -import { cpSync, renameSync } from "node:fs"; -import { join } from "node:path"; -import { - git, - makeBareRepository, - makeRepository, - storedConfigValue, - temporaryRoots, -} from "../tests/helpers/git-workspace.ts"; -import { - discoverEnvironment, - deriveStackId, - ensureEnvironment, - validateEnvironmentRepair, -} from "./managed/environment.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; - -const { makeRoot, removeAll } = temporaryRoots("managed-environment-test-"); -const gitLayer = Layer.mergeAll(NodeFileSystem.layer, gitConfigStoreLayer); - -interface GitWorkspace { - readonly root: string; - readonly primary: string; - readonly checkoutNewBranch: (branch: string) => void; - readonly checkout: (branch: string) => void; - readonly addWorktree: ( - name: string, - branch: string, - options?: { readonly force?: boolean }, - ) => string; -} - -const withGitWorkspace = (run: (workspace: GitWorkspace) => Effect.Effect) => { - const root = makeRoot(); - const primary = makeRepository(root); - const workspace: GitWorkspace = { - root, - primary, - checkoutNewBranch: (branch) => git(primary, "checkout", "-q", "-b", branch), - checkout: (branch) => git(primary, "checkout", "-q", branch), - addWorktree: (name, branch, options = {}) => { - const path = `${root}/${name}`; - const args = ["worktree", "add", "-q"]; - if (options.force === true) args.push("-f", "--checkout"); - args.push(path, branch); - git(primary, ...args); - return path; - }, - }; - return Effect.ensuring(run(workspace), Effect.sync(removeAll)); -}; - -afterEach(removeAll); - -describe("managed environment identity", () => { - it.live("returns to the same stack after leaving and revisiting a branch", () => - withGitWorkspace((workspace) => - Effect.gen(function* () { - const main = yield* ensureEnvironment(workspace.primary); - workspace.checkoutNewBranch("feature"); - const feature = yield* ensureEnvironment(workspace.primary); - workspace.checkout("main"); - const mainAgain = yield* discoverEnvironment(workspace.primary); - expect(deriveStackId(main.identity, "default")).toBe( - deriveStackId(mainAgain.identity, "default"), - ); - expect(feature.identity.contextId).not.toBe(main.identity.contextId); - }), - ).pipe(Effect.provide(gitLayer)), - ); - - it.live("isolates the same branch in two worktrees by checkout identity", () => - withGitWorkspace((workspace) => - Effect.gen(function* () { - const sibling = workspace.addWorktree("sibling", "main", { force: true }); - const first = yield* ensureEnvironment(workspace.primary); - const second = yield* ensureEnvironment(sibling); - expect(first.identity.contextId).toBe(second.identity.contextId); - expect(first.identity.checkoutId).not.toBe(second.identity.checkoutId); - expect(deriveStackId(first.identity, "default")).not.toBe( - deriveStackId(second.identity, "default"), - ); - }), - ).pipe(Effect.provide(gitLayer)), - ); - - it.live("keeps bare-clone worktree identity stable across repeated discovery", () => - withGitWorkspace((workspace) => - Effect.gen(function* () { - const bare = makeBareRepository(workspace.root, workspace.primary); - const firstPath = join(workspace.root, "bare-first"); - const secondPath = join(workspace.root, "bare-second"); - git(bare, "worktree", "add", "-q", firstPath, "-b", "feature/first", "main"); - git(bare, "worktree", "add", "-q", secondPath, "-b", "feature/second", "main"); - - // Git enables worktree-scoped config itself and relocates core.bare when - // sparse-checkout is initialized; the fixture must exercise that path. - git(firstPath, "sparse-checkout", "set", "."); - expect(git(bare, "config", "--get", "extensions.worktreeConfig").trim()).toBe("true"); - expect(storedConfigValue(join(bare, "config"), "core.bare")).toBeUndefined(); - expect(storedConfigValue(join(bare, "config.worktree"), "core.bare")).toBe("true"); - - const first = yield* ensureEnvironment(firstPath); - const second = yield* ensureEnvironment(secondPath); - const firstAgain = yield* discoverEnvironment(firstPath); - - expect(first.workspace.checkoutKind).toBe("bare"); - expect(second.workspace.checkoutKind).toBe("bare"); - expect(first.identity.workspaceId).toBe(second.identity.workspaceId); - expect(first.identity.checkoutId).not.toBe(second.identity.checkoutId); - expect(first.identity.contextId).not.toBe(second.identity.contextId); - expect(firstAgain).toEqual(first); - }), - ).pipe(Effect.provide(gitLayer)), - ); - - it.live("requires explicit repair when a checkout identity appears at a new path", () => - withGitWorkspace((workspace) => - Effect.gen(function* () { - const original = workspace.primary; - const registered = yield* ensureEnvironment(original); - const movedPath = `${workspace.root}/moved`; - renameSync(original, movedPath); - const report = yield* discoverEnvironment(movedPath); - expect(report.state).toBe("needsRepair"); - if (report.state !== "needsRepair") throw new Error("expected moved checkout"); - expect(report.reason).toBe("moved"); - const repair = yield* validateEnvironmentRepair(report.repair); - expect(repair).toEqual(report.repair); - const after = yield* discoverEnvironment(movedPath); - expect(after.state).toBe("needsRepair"); - expect(after.identity).toEqual(registered.identity); - }), - ).pipe(Effect.provide(gitLayer)), - ); - - it.live("rejects a forged nested update in a moved repair plan", () => - withGitWorkspace((workspace) => - Effect.gen(function* () { - const original = workspace.primary; - yield* ensureEnvironment(original); - const movedPath = `${workspace.root}/moved-forged`; - renameSync(original, movedPath); - const report = yield* discoverEnvironment(movedPath); - if (report.state !== "needsRepair") throw new Error("expected moved checkout"); - const update = report.repair.updates[0]; - if (update === undefined) throw new Error("expected one moved repair update"); - const forged = { - ...report.repair, - updates: [{ ...update, to: update.from }], - }; - const result = yield* validateEnvironmentRepair(forged).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - }), - ).pipe(Effect.provide(gitLayer)), - ); - - it.live("classifies a duplicated checkout as repair-required without rewriting it", () => - withGitWorkspace((workspace) => - Effect.gen(function* () { - yield* ensureEnvironment(workspace.primary); - const duplicatePath = `${workspace.root}/duplicate`; - cpSync(workspace.primary, duplicatePath, { recursive: true }); - const report = yield* discoverEnvironment(duplicatePath); - expect(report.state).toBe("needsRepair"); - if (report.state !== "needsRepair") throw new Error("expected duplicate checkout"); - expect(report.reason).toBe("duplicate"); - const repair = yield* validateEnvironmentRepair(report.repair).pipe(Effect.exit); - expect(Exit.isFailure(repair)).toBe(true); - const after = yield* discoverEnvironment(duplicatePath); - expect(after.state).toBe("needsRepair"); - }), - ).pipe(Effect.provide(gitLayer)), - ); -}); diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts deleted file mode 100644 index 5285be3f64..0000000000 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ /dev/null @@ -1,797 +0,0 @@ -// oxlint-disable effecttsgo/global-fetch-in-effect, effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Lifecycle tests coordinate native manager control and intentionally dynamic JSON protocol fixtures; manager dependencies are staged in order because several scenario layers consume earlier services. -import { it } from "@effect/vitest"; -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer } from "effect"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { afterEach, describe, expect } from "vitest"; -import { ManagedStackManager, managedStackManagerLayer } from "./managed/manager.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl, ControlTransport, isControlOwnership } from "./managed/control.ts"; -import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; -import { controlTransportLayer } from "./platform-node.ts"; -import { httpTransportClientLayer } from "./HttpTransportClient.ts"; -import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; -import { Stack } from "./Stack.ts"; -import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -import { makeSupervisorSessionFixture } from "../tests/helpers/SupervisorSessionFixture.ts"; -import { - connectManagedStack, - deleteManagedStack, - stopManagedStack, - updateManagedLaunch, -} from "./managed/lifecycle.ts"; -import { DaemonUpgradeRequired } from "./errors.ts"; -import { - automaticDocument, - cleanupRoots, - controlStack, - exactCoreDocument, - freePorts, - releaseLease, - setupManagedManager, - startManagedStack, -} from "../tests/helpers/managed-manager.ts"; - -const roots: Array = []; -afterEach(() => cleanupRoots(roots)); -const setup = () => setupManagedManager(roots); - -describe("managed stack lifecycle journeys", () => { - it.live("updates launch metadata and recovers a stale owner through the lifecycle facade", () => { - const { layer, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const [apiPort, dbPort] = yield* freePorts(2); - if (apiPort === undefined || dbPort === undefined) { - throw new Error("expected free managed stack ports"); - } - const portDocument = exactCoreDocument(apiPort, dbPort); - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const owner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(owner)) throw new Error("expected stack control ownership"); - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - stackName: "default", - portDocument, - ownership: owner, - lifecycle: "running", - launch: { mode: "native", versions: {} }, - }); - yield* releaseLease(started); - yield* owner.close; - - const input = { - workspacePath: workspace, - stackName: "default", - cliVersion: "test", - launch: { - versions: { postgres: "17.6.1" }, - excludedServices: [], - }, - }; - const updated = yield* updateManagedLaunch(input); - expect(updated.launch).toEqual({ mode: "native", ...input.launch }); - - yield* stopManagedStack(input); - const stopped = yield* manager.inspectStack(stackId); - expect(stopped?.lifecycle).toBe("stopped"); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - Effect.provide(httpTransportClientLayer), - ); - }); - - it.live("preserves launch mode while updating metadata without a runtime owner", () => { - const { layer, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const [apiPort, dbPort] = yield* freePorts(2); - if (apiPort === undefined || dbPort === undefined) { - throw new Error("expected free managed stack ports"); - } - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const owner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(owner)) throw new Error("expected stack control ownership"); - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - stackName: "default", - portDocument: exactCoreDocument(apiPort, dbPort), - ownership: owner, - lifecycle: "running", - }); - yield* releaseLease(started); - yield* owner.close; - - const updated = yield* updateManagedLaunch({ - workspacePath: workspace, - stackName: "default", - cliVersion: "test", - launch: { - versions: { postgres: "17.6.1" }, - excludedServices: ["studio"], - lastNotifiedUpdateFingerprint: "fingerprint", - }, - }); - - expect(updated.launch).toEqual({ - mode: "native", - versions: { postgres: "17.6.1" }, - excludedServices: ["studio"], - lastNotifiedUpdateFingerprint: "fingerprint", - }); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - Effect.provide(httpTransportClientLayer), - ); - }); - - it.live("stops an owner whose document is still starting", () => { - const { layer, workspace } = setup(); - const ownerSessionId = "starting-owner-session"; - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const stopped = { value: false }; - const localStack = { - ...controlStack(), - stop: Effect.sync(() => void (stopped.value = true)), - } satisfies Stack["Service"]; - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: stackId, - ownerSessionId, - daemonCliVersion: "test", - close: Effect.void, - }); - const application = { - app: yield* makeSupervisorControlApplication(lifecycle), - }; - const owner = yield* acquireControl({ - stackId, - initialStatus: { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: stackId, - ownerSessionId, - kind: "supervisor", - state: "starting", - ready: false, - daemonCliVersion: "test", - }, - application, - }); - if (!isControlOwnership(owner)) throw new Error("expected static owner"); - yield* lifecycle.setClose( - manager - .recordLifecycle(owner, { stackId, lifecycle: "stopped" }) - .pipe(Effect.asVoid, Effect.andThen(owner.close)), - ); - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: owner, - lifecycle: "starting", - }); - yield* releaseLease(started); - yield* lifecycle.publishStack(localStack); - - const stopFiber = yield* Effect.forkScoped(stopManagedStack({ workspacePath: workspace })); - yield* Fiber.join(stopFiber); - expect(stopped.value).toBe(true); - expect((yield* manager.inspectStack(stackId))?.lifecycle).toBe("stopped"); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - Effect.provide(httpTransportClientLayer), - ); - }); - - it.live("does not rewrite an in-progress delete during stop cleanup", () => { - const { layer, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const owner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(owner)) throw new Error("expected stack control ownership"); - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - stackName: "default", - portDocument: automaticDocument(), - ownership: owner, - lifecycle: "running", - }); - yield* releaseLease(started); - yield* manager.recordLifecycle(owner, { stackId, lifecycle: "deleting" }); - yield* owner.close; - yield* stopManagedStack({ workspacePath: workspace }); - expect((yield* manager.inspectStack(stackId))?.lifecycle).toBe("deleting"); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - Effect.provide(httpTransportClientLayer), - ); - }); - - it.live("reports a CLI mismatch before rejecting an incompatible starting owner", () => { - const { layer, workspace } = setup(); - const ownerSessionId = "mismatch-owner-session"; - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const lifecycle = yield* makeSupervisorSessionFixture({ - ownershipId: stackId, - ownerSessionId, - daemonCliVersion: "old-cli", - }); - const owner = yield* acquireControl({ - stackId, - initialStatus: yield* lifecycle.currentStatus, - application: { app: yield* makeSupervisorControlApplication(lifecycle) }, - }); - if (!isControlOwnership(owner)) throw new Error("expected static owner"); - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: owner, - lifecycle: "starting", - }); - yield* releaseLease(started); - yield* lifecycle.setClose(owner.close); - - const result = yield* connectManagedStack({ - workspacePath: workspace, - cliVersion: "new-cli", - }).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Cause.squash(result.cause); - expect(error).toBeInstanceOf(DaemonUpgradeRequired); - if (error instanceof DaemonUpgradeRequired) { - expect(error).toMatchObject({ state: "starting", ready: false }); - } - } - yield* lifecycle.requestShutdown("dispose").pipe(Effect.ignore); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - Effect.provide(httpTransportClientLayer), - ); - }); - - it.live("preserves launch metadata when lifecycle stop races its document update", () => { - const { stateRoot, workspace } = setup(); - const gate = { enabled: false, reads: 0 }; - let firstRead!: Deferred.Deferred; - let secondRead!: Deferred.Deferred; - let releaseFirst!: Deferred.Deferred; - let releaseSecond!: Deferred.Deferred; - const gatedFileSystemLayer = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const base = yield* FileSystem.FileSystem; - return { - ...base, - readFileString: (path: string, options?: Parameters[1]) => { - if (!gate.enabled || !path.endsWith("stack.json")) { - return base.readFileString(path, options); - } - return Effect.gen(function* () { - const contents = yield* base.readFileString(path, options); - gate.reads += 1; - const first = gate.reads === 1; - const signal = first ? firstRead : secondRead; - yield* Deferred.succeed(signal, void 0); - yield* Deferred.await(first ? releaseFirst : releaseSecond); - return contents; - }); - }, - } satisfies FileSystem.FileSystem; - }), - ).pipe(Layer.provide(NodeFileSystem.layer)); - const managerLayer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe( - Layer.provide(gatedFileSystemLayer), - Layer.provide(NodePath.layer), - Layer.provide(gitConfigStoreLayer), - Layer.provide(controlTransportLayer), - ); - return Effect.scoped( - Effect.gen(function* () { - firstRead = yield* Deferred.make(); - secondRead = yield* Deferred.make(); - releaseFirst = yield* Deferred.make(); - releaseSecond = yield* Deferred.make(); - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const owner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); - const initial = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: owner, - lifecycle: "running", - launch: { mode: "native", versions: {} }, - }); - yield* releaseLease(initial); - const launch = { - versions: { postgres: "17.6.1" }, - }; - gate.enabled = true; - const launchFiber = yield* manager - .updateLaunch(owner, { stackId, launch }) - .pipe(Effect.forkScoped({ startImmediately: true })); - yield* Deferred.await(firstRead); - const stopFiber = yield* manager - .recordLifecycle(owner, { stackId, lifecycle: "stopped" }) - .pipe(Effect.forkScoped({ startImmediately: true })); - const secondAlreadyRead = yield* Deferred.isDone(secondRead); - yield* Deferred.succeed(releaseFirst, void 0); - if (secondAlreadyRead) { - yield* Fiber.join(launchFiber); - } else { - yield* Deferred.await(secondRead); - } - yield* Deferred.succeed(releaseSecond, void 0); - gate.enabled = false; - yield* Fiber.join(launchFiber); - yield* Fiber.join(stopFiber); - const final = yield* manager.inspectStack(stackId); - expect(final?.lifecycle).toBe("stopped"); - expect(final?.launch).toEqual({ mode: "native", ...launch }); - }), - ).pipe( - Effect.provide(managerLayer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("reclaims a stale starting document after its owner closes", () => { - const { layer, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const previousOwner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(previousOwner)) throw new Error("expected ownership"); - const starting = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: previousOwner, - lifecycle: "starting", - }); - yield* releaseLease(starting); - yield* previousOwner.close; - const nextOwner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(nextOwner)) throw new Error("expected reattached ownership"); - const recovered = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: { - activeFields: [], - disabledFields: ["apiPort", "dbPort"], - document: {}, - }, - ownership: nextOwner, - lifecycle: "stopped", - }); - expect(recovered.stack.lifecycle).toBe("stopped"); - expect(recovered.stack.ports).toEqual(starting.stack.ports); - yield* releaseLease(recovered); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("deletes a stale running document after its owner closes", () => { - const { layer, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const owner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); - const running = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: owner, - lifecycle: "running", - runtime: { pid: 1, controlEndpoint: owner.endpoint.url, protocolVersion: 1 }, - }); - yield* releaseLease(running); - yield* owner.close; - - yield* deleteManagedStack({ workspacePath: workspace }); - expect(yield* manager.inspectStack(stackId)).toBeUndefined(); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("keeps delete ownership bound until destructive cleanup finishes", () => { - const { layer, stateRoot, workspace } = setup(); - let armed = false; - let documentPath: string | undefined; - let entered!: Deferred.Deferred; - let release!: Deferred.Deferred; - const gatedFileSystemLayer = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const base = yield* FileSystem.FileSystem; - return { - ...base, - remove: (path: string, options?: Parameters[1]) => { - if (armed && documentPath === path) { - armed = false; - return Effect.gen(function* () { - yield* Deferred.succeed(entered, void 0); - yield* Deferred.await(release); - return yield* base.remove(path, options); - }); - } - return base.remove(path, options); - }, - } satisfies FileSystem.FileSystem; - }), - ).pipe(Layer.provide(NodeFileSystem.layer)); - const managerLayer = layer.pipe(Layer.provide(gatedFileSystemLayer)); - return Effect.scoped( - Effect.gen(function* () { - entered = yield* Deferred.make(); - release = yield* Deferred.make(); - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - documentPath = yield* managedStackDocumentPathEffect(stateRoot, stackId); - const owner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: owner, - lifecycle: "stopped", - }); - yield* releaseLease(started); - yield* owner.close; - - armed = true; - const deleting = yield* Effect.forkScoped(deleteManagedStack({ workspacePath: workspace })); - yield* Deferred.await(entered); - - const probe = yield* manager.probeControl(stackId); - if (probe === undefined) throw new Error("expected deleting owner"); - const response = yield* Effect.tryPromise(() => - fetch(`${probe.endpoint.url}/stop`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ownershipId: stackId, - ownerSessionId: probe.status.ownerSessionId, - intent: "explicit", - }), - }), - ); - expect(response.status).toBe(423); - yield* Effect.promise(() => response.arrayBuffer()); - - const replacement = yield* manager.acquireControl(stackId, "update"); - expect(isControlOwnership(replacement)).toBe(false); - - yield* Deferred.succeed(release, void 0); - yield* Fiber.join(deleting); - expect(yield* manager.inspectStack(stackId)).toBeUndefined(); - }), - ).pipe( - Effect.provide(managerLayer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live( - "keeps a stack document when its identity changes before delete ownership settles", - () => { - const { layer, workspace } = setup(); - const copied = join(workspace, "..", "delete-race-copy"); - let armed = false; - let markerSwapped!: Deferred.Deferred; - const gatedTransport = Layer.effect( - ControlTransport, - Effect.gen(function* () { - const base = yield* ControlTransport; - return { - ...base, - read: (endpoint: Parameters[0]) => - Effect.gen(function* () { - if (armed) { - armed = false; - writeFileSync( - join(copied, ".supabase", "identity.json"), - readFileSync(join(workspace, ".supabase", "identity.json")), - ); - yield* Deferred.succeed(markerSwapped, void 0); - } - return yield* base.read(endpoint); - }), - } satisfies typeof base; - }), - ).pipe(Layer.provide(controlTransportLayer)); - const managerLayer = layer.pipe(Layer.provide(gatedTransport)); - return Effect.scoped( - Effect.gen(function* () { - markerSwapped = yield* Deferred.make(); - const manager = yield* ManagedStackManager; - const originalEnvironment = yield* ensureEnvironment(workspace); - const originalStackId = deriveStackId(originalEnvironment.identity, "default"); - const originalOwner = yield* acquireControl({ - stackId: originalStackId, - maintenanceOperation: "update", - }); - if (!isControlOwnership(originalOwner)) throw new Error("expected original ownership"); - const original = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: originalOwner, - lifecycle: "stopped", - }); - yield* releaseLease(original); - yield* originalOwner.close; - - mkdirSync(copied); - const copiedEnvironment = yield* ensureEnvironment(copied); - const copiedStackId = deriveStackId(copiedEnvironment.identity, "default"); - const copiedOwner = yield* acquireControl({ - stackId: copiedStackId, - maintenanceOperation: "update", - }); - if (!isControlOwnership(copiedOwner)) throw new Error("expected copied ownership"); - const copiedStack = yield* startManagedStack(manager, { - workspacePath: copied, - portDocument: automaticDocument(), - ownership: copiedOwner, - lifecycle: "stopped", - }); - yield* releaseLease(copiedStack); - - armed = true; - const deleting = yield* Effect.forkScoped(deleteManagedStack({ workspacePath: copied })); - yield* Deferred.await(markerSwapped); - yield* copiedOwner.close; - const result = yield* Fiber.join(deleting).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - expect(Cause.squash(result.cause)).toMatchObject({ - _tag: "InvalidManagedIdentityError", - }); - } - expect(yield* manager.inspectStack(copiedStackId)).toBeDefined(); - }), - ).pipe( - Effect.provide(managerLayer), - Effect.provide(gatedTransport), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - ); - }, - ); - - it.live("rejects stop when workspace identity changes while control ownership settles", () => { - const { layer, workspace } = setup(); - const copied = join(workspace, "..", "stop-race-copy"); - let armed = false; - let markerSwapped!: Deferred.Deferred; - const gatedTransport = Layer.effect( - ControlTransport, - Effect.gen(function* () { - const base = yield* ControlTransport; - return { - ...base, - read: (endpoint: Parameters[0]) => - Effect.gen(function* () { - if (armed) { - armed = false; - writeFileSync( - join(copied, ".supabase", "identity.json"), - readFileSync(join(workspace, ".supabase", "identity.json")), - ); - yield* Deferred.succeed(markerSwapped, void 0); - } - return yield* base.read(endpoint); - }), - } satisfies typeof base; - }), - ).pipe(Layer.provide(controlTransportLayer)); - const managerLayer = layer.pipe(Layer.provide(gatedTransport)); - return Effect.scoped( - Effect.gen(function* () { - markerSwapped = yield* Deferred.make(); - const manager = yield* ManagedStackManager; - const originalEnvironment = yield* ensureEnvironment(workspace); - const originalStackId = deriveStackId(originalEnvironment.identity, "default"); - const originalOwner = yield* acquireControl({ - stackId: originalStackId, - maintenanceOperation: "update", - }); - if (!isControlOwnership(originalOwner)) throw new Error("expected original ownership"); - const original = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: originalOwner, - lifecycle: "stopped", - }); - yield* releaseLease(original); - yield* originalOwner.close; - - mkdirSync(copied); - const copiedEnvironment = yield* ensureEnvironment(copied); - const copiedStackId = deriveStackId(copiedEnvironment.identity, "default"); - const copiedOwner = yield* acquireControl({ - stackId: copiedStackId, - maintenanceOperation: "update", - }); - if (!isControlOwnership(copiedOwner)) throw new Error("expected copied ownership"); - const copiedStack = yield* startManagedStack(manager, { - workspacePath: copied, - portDocument: automaticDocument(), - ownership: copiedOwner, - lifecycle: "starting", - }); - yield* releaseLease(copiedStack); - - armed = true; - const stopping = yield* Effect.forkScoped(stopManagedStack({ workspacePath: copied })); - yield* Deferred.await(markerSwapped); - yield* copiedOwner.close; - const result = yield* Fiber.join(stopping).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - expect(Cause.squash(result.cause)).toMatchObject({ - _tag: "InvalidManagedIdentityError", - }); - } - expect((yield* manager.inspectStack(copiedStackId))?.lifecycle).toBe("starting"); - }), - ).pipe( - Effect.provide(managerLayer), - Effect.provide(gatedTransport), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(httpTransportClientLayer), - ); - }); - - it.live("deletes an owned stack when its document path is a directory", () => { - const { layer, stateRoot, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const owner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(owner)) throw new Error("expected ownership"); - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership: owner, - lifecycle: "stopped", - }); - yield* started.lease.releaseAll; - - const documentPath = yield* managedStackDocumentPathEffect(stateRoot, stackId); - rmSync(documentPath); - mkdirSync(documentPath); - mkdirSync(join(documentPath, "nested")); - writeFileSync(join(documentPath, "nested", "orphaned"), "corrupt"); - - expect(yield* manager.deleteStack(stackId, owner)).toEqual({ - outcome: "removed", - stackId, - }); - const stackRoot = (yield* managedStackPathsEffect(stateRoot, stackId)).root; - expect(existsSync(stackRoot)).toBe(false); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("finishes an interrupted delete before the next start", () => { - const { layer, stateRoot, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const [apiPort, dbPort] = yield* freePorts(2); - if (apiPort === undefined || dbPort === undefined) { - throw new Error("expected interrupted-delete ports"); - } - const portDocument = exactCoreDocument(apiPort, dbPort); - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const previousOwner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(previousOwner)) throw new Error("expected ownership"); - const previous = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument, - ownership: previousOwner, - }); - yield* releaseLease(previous); - const stackPaths = yield* managedStackPathsEffect(stateRoot, stackId); - const dataPath = join(stackPaths.data, "orphaned"); - mkdirSync(stackPaths.data, { recursive: true }); - writeFileSync(dataPath, "stale"); - yield* manager.recordLifecycle(previousOwner, { stackId, lifecycle: "deleting" }); - yield* previousOwner.close; - - const nextOwner = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(nextOwner)) throw new Error("expected recovered ownership"); - const restarted = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument, - ownership: nextOwner, - }); - expect(restarted.stack.lifecycle).toBe("stopped"); - expect(existsSync(dataPath)).toBe(false); - yield* releaseLease(restarted); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); -}); diff --git a/packages/stack/src/managed-manager-ports.integration.test.ts b/packages/stack/src/managed-manager-ports.integration.test.ts deleted file mode 100644 index 2e900c517e..0000000000 --- a/packages/stack/src/managed-manager-ports.integration.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -// oxlint-disable effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Port-manager tests compose platform path fixtures around the native control boundary; manager dependencies are provided sequentially to preserve the service order expected by scenario layers. -import { it } from "@effect/vitest"; -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Cause, Effect, Exit } from "effect"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect } from "vitest"; -import { ManagedStackManager } from "./managed/manager.ts"; -import { ManagedExactPortOccupiedError } from "./managed/model.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { - acquireControl, - CONTROL_PORT_RANGE, - controlEndpoint, - isControlOwnership, -} from "./managed/control.ts"; -import { controlTransportLayer } from "./platform-node.ts"; -import { reservePortSet } from "./PortAllocator.ts"; -import { - acquireWorkspaceControl, - automaticDocument, - automaticRuntimeDocument, - cleanupRoots, - closeExternal, - exactCoreDocument, - exactDocument, - freePort, - freePorts, - listenExternal, - releaseLease, - setupManagedManager, - startManagedStack, -} from "../tests/helpers/managed-manager.ts"; - -const roots: Array = []; -afterEach(() => cleanupRoots(roots)); -const setup = () => setupManagedManager(roots); - -describe("managed stack ports journeys", () => { - it.live("retains the same automatic ports while stopped", () => { - const { layer, workspace: base } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); - const first = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: { activeFields: ["apiPort", "dbPort"], document: {} }, - ownership, - }); - yield* releaseLease(first); - const second = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: { - activeFields: [], - disabledFields: ["apiPort", "dbPort"], - document: {}, - }, - ownership, - }); - if (first === undefined || second === undefined) throw new Error("expected stack"); - expect(second.stack.id).toBe(first.stack.id); - expect(second.stack.ports).toEqual(first.stack.ports); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("can preserve sticky ports while a replacement request changes its exact intent", () => { - const { layer, workspace: base } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); - const first = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership, - lifecycle: "stopped", - }); - const api = first.stack.ports.find((assignment) => assignment.key === "api.port"); - if (api === undefined) throw new Error("expected API assignment"); - yield* releaseLease(first); - const second = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: exactDocument("apiPort", api.port === 65_000 ? 65_001 : 65_000), - ownership, - lifecycle: "stopped", - preservePersistedPorts: true, - }); - expect(second.stack.ports).toContainEqual(api); - yield* releaseLease(second); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("reserves exact durable and automatic runtime ports through one lease", () => { - const { layer, workspace: base } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const apiPort = yield* freePort; - const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (!isControlOwnership(ownership)) throw new Error("expected ownership"); - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: { - activeFields: ["apiPort", "dbPort", "authPort"], - document: { api: { port: apiPort } }, - }, - ownership, - }); - const fields = ["apiPort", "dbPort", "authPort"] as const; - yield* started.lease.release(fields); - yield* started.lease.reserve(fields); - yield* releaseLease(started); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("allows stopped exact siblings and rejects a live owner", () => { - const { layer, workspace: base } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const port = yield* freePort; - const firstOwner = yield* acquireWorkspaceControl(base, "first"); - if (!isControlOwnership(firstOwner.ownership)) throw new Error("expected first ownership"); - const first = yield* startManagedStack(manager, { - workspacePath: firstOwner.workspace, - portDocument: exactDocument("apiPort", port), - ownership: firstOwner.ownership, - }); - yield* releaseLease(first); - const secondOwner = yield* acquireWorkspaceControl(base, "second"); - if (!isControlOwnership(secondOwner.ownership)) - throw new Error("expected second ownership"); - const second = yield* startManagedStack(manager, { - workspacePath: secondOwner.workspace, - portDocument: exactDocument("apiPort", port), - ownership: secondOwner.ownership, - }); - yield* releaseLease(second); - const liveOwner = yield* acquireWorkspaceControl(base, "live"); - if (!isControlOwnership(liveOwner.ownership)) throw new Error("expected live ownership"); - const live = yield* startManagedStack(manager, { - workspacePath: liveOwner.workspace, - portDocument: exactDocument("apiPort", port), - ownership: liveOwner.ownership, - lifecycle: "running", - }); - const rejectedOwner = yield* acquireWorkspaceControl(base, "rejected"); - if (!isControlOwnership(rejectedOwner.ownership)) - throw new Error("expected rejected ownership"); - const rejected = yield* startManagedStack(manager, { - workspacePath: rejectedOwner.workspace, - portDocument: exactDocument("apiPort", port), - ownership: rejectedOwner.ownership, - }).pipe(Effect.exit); - expect(Exit.isFailure(rejected)).toBe(true); - if (Exit.isFailure(rejected)) { - expect(Cause.squash(rejected.cause)).toBeInstanceOf(ManagedExactPortOccupiedError); - } - yield* releaseLease(live); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("allows an unused exact API port inside the control range", () => { - const { layer, workspace: base } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (!isControlOwnership(ownership)) throw new Error("expected stack ownership"); - const port = ownership.endpoint.port === 15_432 ? 15_433 : 15_432; - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: exactDocument("apiPort", port), - ownership, - }); - expect(started.stack.ports).toEqual( - expect.arrayContaining([{ key: "api.port", port, intent: "exact" }]), - ); - yield* releaseLease(started); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("rejects an exact API port matching a persisted stack control endpoint", () => { - const { layer, workspace: base } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const { - workspace: otherWorkspace, - stackId: otherStackId, - ownership: otherOwnership, - } = yield* acquireWorkspaceControl(base, "other"); - if (!isControlOwnership(otherOwnership)) throw new Error("expected other stack ownership"); - const other = yield* startManagedStack(manager, { - workspacePath: otherWorkspace, - portDocument: automaticDocument(), - ownership: otherOwnership, - lifecycle: "stopped", - }); - yield* releaseLease(other); - yield* otherOwnership.close; - const otherEndpoint = yield* controlEndpoint(otherStackId); - - const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (!isControlOwnership(ownership)) throw new Error("expected stack ownership"); - const rejected = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: exactDocument("apiPort", otherEndpoint.port), - ownership, - }).pipe(Effect.exit); - - expect(Exit.isFailure(rejected)).toBe(true); - if (Exit.isFailure(rejected)) { - expect(Cause.squash(rejected.cause)).toMatchObject({ - _tag: "ManagedExactPortOccupiedError", - key: "api.port", - port: otherEndpoint.port, - }); - } - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("attributes an exact API port collision with an external listener", () => { - const { layer, workspace: base } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const port = yield* freePort; - const external = yield* Effect.acquireRelease( - Effect.promise(() => listenExternal(port)), - (server) => Effect.promise(() => closeExternal(server)), - ); - expect(external.listening).toBe(true); - - const { workspace, ownership } = yield* acquireWorkspaceControl(base); - if (!isControlOwnership(ownership)) throw new Error("expected stack ownership"); - const rejected = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: exactDocument("apiPort", port), - ownership, - }).pipe(Effect.exit); - - expect(Exit.isFailure(rejected)).toBe(true); - if (Exit.isFailure(rejected)) { - expect(Cause.squash(rejected.cause)).toMatchObject({ - _tag: "ManagedExactPortOccupiedError", - key: "api.port", - port, - }); - } - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("keeps automatic ports exclusive while stopped", () => { - const { layer } = setup(); - const root = mkdtempSync(join(tmpdir(), "managed-auto-test-")); - roots.push(root); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const [apiPort, dbPort] = yield* freePorts(2); - if (apiPort === undefined || dbPort === undefined) { - throw new Error("expected isolated automatic ports"); - } - const { - workspace: firstWorkspace, - stackId, - ownership, - } = yield* acquireWorkspaceControl(root, "first"); - if (!isControlOwnership(ownership)) throw new Error("expected ownership"); - const exact = yield* startManagedStack(manager, { - workspacePath: firstWorkspace, - portDocument: exactCoreDocument(apiPort, dbPort), - ownership, - }); - yield* releaseLease(exact); - const first = yield* startManagedStack(manager, { - workspacePath: firstWorkspace, - portDocument: automaticRuntimeDocument(), - ownership, - }); - yield* releaseLease(first); - expect( - first.stack.ports.every( - ({ port }) => port < CONTROL_PORT_RANGE.min || port > CONTROL_PORT_RANGE.max, - ), - ).toBe(true); - const stalePort = ownership.endpoint.port === 15_432 ? 15_433 : 15_432; - const stale = yield* manager - .allocateManagedPorts(ownership, { - stackId, - portDocument: automaticRuntimeDocument(), - persisted: [{ key: "api.port", port: stalePort, intent: "automatic" }], - }) - .pipe(Effect.exit); - if (Exit.isSuccess(stale)) yield* stale.value.lease.releaseAll; - expect(Exit.isFailure(stale)).toBe(true); - if (Exit.isFailure(stale)) { - expect(Cause.squash(stale.cause)).toMatchObject({ - _tag: "ManagedPortAllocationError", - fields: ["apiPort"], - }); - } - const inactiveStale = yield* manager - .allocateManagedPorts(ownership, { - stackId, - portDocument: { - activeFields: ["dbPort"], - disabledFields: ["apiPort"], - document: {}, - }, - persisted: [{ key: "api.port", port: stalePort, intent: "automatic" }], - }) - .pipe(Effect.exit); - if (Exit.isSuccess(inactiveStale)) yield* inactiveStale.value.lease.releaseAll; - expect(Exit.isFailure(inactiveStale)).toBe(true); - if (Exit.isFailure(inactiveStale)) { - expect(Cause.squash(inactiveStale.cause)).toMatchObject({ - _tag: "ManagedPortAllocationError", - fields: ["apiPort"], - }); - } - const firstPort = first.stack.ports[0]?.port; - if (firstPort === undefined) throw new Error("expected automatic port"); - const external = yield* reservePortSet([ - { field: "apiPort", selection: { kind: "exact", port: firstPort } }, - ]); - const restart = yield* startManagedStack(manager, { - workspacePath: firstWorkspace, - portDocument: automaticRuntimeDocument(), - ownership, - }).pipe(Effect.exit); - expect(Exit.isFailure(restart)).toBe(true); - if (Exit.isFailure(restart)) { - expect(Cause.squash(restart.cause)).toMatchObject({ - _tag: "ManagedPortAllocationError", - }); - } - yield* external.releaseAll; - const { workspace: secondWorkspace, ownership: secondOwnership } = - yield* acquireWorkspaceControl(root, "second"); - if (!isControlOwnership(secondOwnership)) throw new Error("expected second ownership"); - const second = yield* startManagedStack(manager, { - workspacePath: secondWorkspace, - portDocument: automaticRuntimeDocument(), - ownership: secondOwnership, - }); - expect(second.stack.ports[0]?.port).not.toBe(first.stack.ports[0]?.port); - yield* releaseLease(second); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("reports running drift and applies it after stop", () => { - const { layer } = setup(); - const root = mkdtempSync(join(tmpdir(), "managed-drift-test-")); - roots.push(root); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const [original, dbPort, changed] = yield* freePorts(3); - if (original === undefined || dbPort === undefined || changed === undefined) { - throw new Error("expected drift ports"); - } - const { - workspace, - stackId, - ownership: initialOwnership, - } = yield* acquireWorkspaceControl(root); - if (!isControlOwnership(initialOwnership)) throw new Error("expected ownership"); - const running = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: exactCoreDocument(original, dbPort), - ownership: initialOwnership, - lifecycle: "running", - }); - const status = yield* manager.readStack({ - workspacePath: workspace, - portDocument: exactCoreDocument(changed, dbPort), - }); - expect(status?.drift).toHaveLength(1); - const missingAssignment = yield* manager.readStack({ - workspacePath: workspace, - portDocument: automaticDocument("studioPort"), - }); - const missingDrift = missingAssignment?.drift?.find((drift) => drift.key === "studio.port"); - expect(missingDrift).toBeDefined(); - expect(missingDrift?.actualPort).toBeUndefined(); - yield* releaseLease(running); - yield* manager.recordLifecycle(initialOwnership, { stackId, lifecycle: "stopped" }); - yield* initialOwnership.close; - const ownership = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(ownership)) throw new Error("expected ownership"); - const stopped = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: exactCoreDocument(changed, dbPort), - ownership, - lifecycle: "stopped", - }); - expect(stopped.stack.ports[0]?.port).toBe(changed); - yield* releaseLease(stopped); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("preserves removed-key and disabled-service intent", () => { - const { layer } = setup(); - const root = mkdtempSync(join(tmpdir(), "managed-retired-test-")); - roots.push(root); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const port = yield* freePort; - const { workspace, ownership } = yield* acquireWorkspaceControl(root); - if (!isControlOwnership(ownership)) throw new Error("expected ownership"); - const first = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: exactDocument("studioPort", port), - ownership, - }); - yield* releaseLease(first); - const disabled = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: { activeFields: [], disabledFields: ["studioPort"], document: {} }, - ownership, - }); - expect( - disabled.stack.ports.filter((assignment) => assignment.key === "studio.port"), - ).toEqual([{ key: "studio.port", port, intent: "exact" }]); - yield* releaseLease(disabled); - const removed = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: { activeFields: [], document: {} }, - ownership, - }); - expect( - removed.stack.ports.filter((assignment) => assignment.key === "studio.port"), - ).toEqual([{ key: "studio.port", port, intent: "automatic" }]); - yield* releaseLease(removed); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); -}); diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts deleted file mode 100644 index 720740373e..0000000000 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -// oxlint-disable effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Project-manager tests use native temporary paths for filesystem-backed integration fixtures; manager dependencies are staged to satisfy dependent transport layers. -import { it } from "@effect/vitest"; -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"; -import { - cpSync, - mkdirSync, - mkdtempSync, - realpathSync, - renameSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect } from "vitest"; -import { ManagedStackManager } from "./managed/manager.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl, ControlTransport, isControlOwnership } from "./managed/control.ts"; -import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; -import { controlTransportLayer } from "./platform-node.ts"; -import { listStacks as listStackSummaries, resolveStackSummary } from "./discovery.ts"; -import { git, makeRepository } from "../tests/helpers/git-workspace.ts"; -import { - automaticDocument, - cleanupRoots, - setupManagedManager, - startManagedStack, - startWithOwner, -} from "../tests/helpers/managed-manager.ts"; - -const roots: Array = []; -afterEach(() => cleanupRoots(roots)); -const setup = () => setupManagedManager(roots); - -describe("managed stack projects journeys", () => { - it.live("rejects copied ordinary workspace identity until the original path is moved", () => { - const { layer, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); - const initial = yield* startManagedStack(manager, { - workspacePath: workspace, - stackName: "default", - portDocument: automaticDocument(), - ownership, - lifecycle: "stopped", - }); - yield* initial.lease.releaseAll; - - const copied = join(workspace, "..", "workspace-copy"); - cpSync(workspace, copied, { recursive: true }); - const copiedDiscovery = yield* manager.discoverWorkspace(copied).pipe(Effect.exit); - expect(Exit.isFailure(copiedDiscovery)).toBe(true); - if (Exit.isFailure(copiedDiscovery)) { - const error = Cause.squash(copiedDiscovery.cause); - expect(error).toMatchObject({ _tag: "InvalidManagedIdentityError" }); - if (error instanceof Error) { - expect(error.message).toContain("Delete"); - expect(error.message).toContain(".supabase/identity.json"); - } - } - const copiedEnsure = yield* manager.ensureWorkspace(copied).pipe(Effect.exit); - expect(Exit.isFailure(copiedEnsure)).toBe(true); - if (Exit.isFailure(copiedEnsure)) { - expect(Cause.squash(copiedEnsure.cause)).toMatchObject({ - _tag: "InvalidManagedIdentityError", - }); - } - - const copiedStart = yield* startManagedStack(manager, { - workspacePath: copied, - stackName: "default", - portDocument: automaticDocument(), - ownership, - lifecycle: "stopped", - }).pipe(Effect.exit); - expect(Exit.isFailure(copiedStart)).toBe(true); - if (Exit.isFailure(copiedStart)) { - const error = Cause.squash(copiedStart.cause); - expect(error).toMatchObject({ _tag: "InvalidManagedIdentityError" }); - if (error instanceof Error) { - expect(error.message).toContain("Delete"); - expect(error.message).toContain(".supabase/identity.json"); - } - } - - git(workspace, "init", "-q", "-b", "main"); - const copiedAfterOriginalGit = yield* manager.discoverWorkspace(copied); - expect(copiedAfterOriginalGit.state).toBe("ready"); - rmSync(join(workspace, ".git"), { recursive: true, force: true }); - - rmSync(copied, { recursive: true, force: true }); - renameSync(workspace, copied); - const movedDiscovery = yield* manager.discoverWorkspace(copied); - expect(movedDiscovery.state).toBe("ready"); - writeFileSync(workspace, "stale workspace path"); - const moved = yield* startManagedStack(manager, { - workspacePath: copied, - stackName: "default", - portDocument: automaticDocument(), - ownership, - lifecycle: "stopped", - }); - expect(moved.stack.id).toBe(stackId); - expect(moved.stack.workspace.path).toBe(realpathSync(copied)); - expect((yield* manager.inspectStack(stackId))?.workspace.path).toBe(realpathSync(copied)); - yield* moved.lease.releaseAll; - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("rejects empty and ASCII-control stack names before resolving a stack", () => { - const { layer, workspace } = setup(); - return Effect.gen(function* () { - const manager = yield* ManagedStackManager; - for (const stackName of ["", "bad\nname"]) { - const exit = yield* manager - .readStack({ workspacePath: workspace, stackName, portDocument: automaticDocument() }) - .pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toMatchObject({ - _tag: "InvalidManagedStackNameError", - }); - } - } - }).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("lets a concurrent start own the endpoint while status checks liveness", () => { - const { layer, stateRoot, workspace } = setup(); - return Effect.gen(function* () { - const baseTransport = yield* ControlTransport; - const readStarted = yield* Deferred.make(); - const continueRead = yield* Deferred.make(); - // Hold only the status probe's first read in flight. Later reads (the - // concurrent acquire scans its endpoint candidates before binding) must - // pass through, mirroring the real transport's bounded read timeout. - let gateReads = false; - let gatedRead = false; - const gatedTransport = Layer.succeed(ControlTransport, { - ...baseTransport, - read: (endpoint) => - Effect.suspend(() => { - if (!gateReads || gatedRead) return baseTransport.read(endpoint); - gatedRead = true; - return Effect.gen(function* () { - yield* Deferred.succeed(readStarted, void 0); - yield* Deferred.await(continueRead); - return yield* baseTransport.read(endpoint); - }); - }), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const initial = yield* Effect.scoped( - startWithOwner(manager, workspace, automaticDocument()), - ); - const stackId = initial.stack.id; - gateReads = true; - - const summaryFiber = yield* Effect.forkScoped( - resolveStackSummary({ - cacheRoot: stateRoot, - projectDir: workspace, - name: "default", - }), - ); - yield* Deferred.await(readStarted).pipe(Effect.timeout("1 second")); - - const owner = yield* acquireControl({ - stackId, - initialStatus: { - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId: stackId, - ownerSessionId: "projects-test-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }, - }); - if (!isControlOwnership(owner)) throw new Error("status probe took control ownership"); - yield* Deferred.succeed(continueRead, void 0); - const summary = yield* Fiber.join(summaryFiber); - expect(summary.running).toBe(true); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(gatedTransport), - ); - }).pipe(Effect.provide(controlTransportLayer)); - }); - - it.live("isolates default stacks in sibling nested projects", () => { - const { layer, stateRoot } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const root = mkdtempSync(join(tmpdir(), "managed-nested-projects-test-")); - roots.push(root); - const repository = makeRepository(root); - const firstProject = join(repository, "apps", "first"); - const secondProject = join(repository, "apps", "second"); - mkdirSync(firstProject, { recursive: true }); - mkdirSync(secondProject, { recursive: true }); - mkdirSync(join(firstProject, "supabase")); - mkdirSync(join(secondProject, "supabase")); - writeFileSync(join(firstProject, "supabase", "config.toml"), 'project_id = "remote"\n'); - writeFileSync(join(secondProject, "supabase", "config.toml"), 'project_id = "remote"\n'); - const firstAlias = join(root, "first-project-link"); - symlinkSync(firstProject, firstAlias, "dir"); - const manager = yield* ManagedStackManager; - const startAndClose = (project: string) => - Effect.scoped( - startWithOwner(manager, project, automaticDocument()).pipe( - Effect.map((result) => result.stack), - ), - ); - const first = yield* startAndClose(firstProject); - const second = yield* startAndClose(secondProject); - if (first === undefined || second === undefined) { - throw new Error("expected both nested project stacks"); - } - const firstPort = first.ports.find((assignment) => assignment.key === "api.port")?.port; - const secondPort = second.ports.find((assignment) => assignment.key === "api.port")?.port; - expect(first.id).not.toBe(second.id); - expect(firstPort).toBeDefined(); - expect(secondPort).toBeDefined(); - expect(firstPort).not.toBe(secondPort); - expect(first.identity.localProjectKey).toBe("apps/first"); - expect(second.identity.localProjectKey).toBe("apps/second"); - expect(first.workspace.path).toBe(realpathSync(firstProject)); - expect(second.workspace.path).toBe(realpathSync(secondProject)); - const firstListing = yield* listStackSummaries({ - cacheRoot: stateRoot, - projectDir: firstAlias, - }); - const secondListing = yield* listStackSummaries({ - cacheRoot: stateRoot, - projectDir: secondProject, - }); - expect(firstListing).toHaveLength(1); - expect(secondListing).toHaveLength(1); - expect(firstListing[0]?.name).toBe("default"); - expect(secondListing[0]?.name).toBe("default"); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); -}); diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts deleted file mode 100644 index cc8d0ed118..0000000000 --- a/packages/stack/src/managed-manager-recovery.integration.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -// oxlint-disable effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Recovery tests inject native process/filesystem failures and compose scenario-specific platform layers; dependencies are staged to preserve the gated layer ordering. -import { it } from "@effect/vitest"; -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, PlatformError } from "effect"; -import { randomBytes } from "node:crypto"; -import { cpSync, mkdirSync, mkdtempSync, realpathSync, renameSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect } from "vitest"; -import { - deriveRepairOwnershipId, - ManagedStackManager, - type ManagedStackManagerShape, - managedStackManagerLayer, -} from "./managed/manager.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { acquireControl, ControlTransport, isControlOwnership } from "./managed/control.ts"; -import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; -import { controlTransportLayer } from "./platform-node.ts"; -import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; -import { makeRepository } from "../tests/helpers/git-workspace.ts"; -import { deleteManagedStack } from "./managed/lifecycle.ts"; -import { listStacks as listStackSummaries } from "./discovery.ts"; -import { - automaticDocument, - cleanupRoots, - releaseLease, - setupManagedManager, - startManagedStack, - startWithOwner, -} from "../tests/helpers/managed-manager.ts"; - -const roots: Array = []; -afterEach(() => cleanupRoots(roots)); -const setup = () => setupManagedManager(roots); - -const acquireIsolatedCollisionOwner = () => - Effect.gen(function* () { - for (let attempt = 0; attempt < 32; attempt += 1) { - const stackId = randomBytes(32).toString("hex"); - const collidingStackId = `${stackId.slice(0, 10)}${randomBytes(27).toString("hex")}`; - const acquisition = yield* acquireControl({ stackId, maintenanceOperation: "update" }).pipe( - Effect.timeout("5 seconds"), - Effect.exit, - ); - if (Exit.isSuccess(acquisition) && isControlOwnership(acquisition.value)) { - return { collidingStackId, ownership: acquisition.value }; - } - } - return yield* Effect.die(new Error("failed to acquire an isolated collision endpoint")); - }); - -const acquireIsolatedStackOwner = (workspacePath: string) => - Effect.gen(function* () { - const environment = yield* ensureEnvironment(workspacePath); - for (let attempt = 0; attempt < 32; attempt += 1) { - const stackName = `test-${randomBytes(8).toString("hex")}`; - const stackId = deriveStackId(environment.identity, stackName); - const acquisition = yield* acquireControl({ stackId, maintenanceOperation: "update" }).pipe( - Effect.timeout("5 seconds"), - Effect.exit, - ); - if (Exit.isSuccess(acquisition) && isControlOwnership(acquisition.value)) { - return { stackName, ownership: acquisition.value }; - } - } - return yield* Effect.die(new Error("failed to acquire an isolated stack endpoint")); - }); - -const startWithIsolatedOwner = ( - manager: ManagedStackManagerShape, - workspacePath: string, - portDocument: ReturnType, - lifecycle: "stopped" | "running" = "stopped", -) => - Effect.scoped( - Effect.gen(function* () { - const { stackName, ownership } = yield* acquireIsolatedStackOwner(workspacePath); - const result = yield* startManagedStack(manager, { - workspacePath, - stackName, - portDocument, - ownership, - lifecycle, - }); - return { ...result, stackName }; - }), - ); - -describe("managed stack recovery journeys", () => { - it.live("revalidates a copied ordinary workspace after a concurrent first start", () => { - const root = mkdtempSync(join(tmpdir(), "managed-manager-race-test-")); - roots.push(root); - const workspace = join(root, "workspace"); - const copied = join(root, "workspace-copy"); - const stateRoot = join(root, "state"); - mkdirSync(workspace); - const gate = { enabled: true, blocked: false }; - const gatedFileSystemLayer = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const base = yield* FileSystem.FileSystem; - return { - ...base, - readFileString: (path: string, options?: Parameters[1]) => { - if (!gate.enabled || gate.blocked || !path.endsWith("stack.json")) { - return base.readFileString(path, options); - } - gate.blocked = true; - return Effect.gen(function* () { - yield* Deferred.succeed(readStarted, void 0); - yield* Deferred.await(releaseRead); - return yield* base.readFileString(path, options); - }); - }, - } satisfies FileSystem.FileSystem; - }), - ).pipe(Layer.provide(NodeFileSystem.layer)); - const managerLayer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe( - Layer.provide(gatedFileSystemLayer), - Layer.provide(NodePath.layer), - Layer.provide(gitConfigStoreLayer), - Layer.provide(controlTransportLayer), - ); - let readStarted!: Deferred.Deferred; - let releaseRead!: Deferred.Deferred; - return Effect.scoped( - Effect.gen(function* () { - readStarted = yield* Deferred.make(); - releaseRead = yield* Deferred.make(); - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - cpSync(workspace, copied, { recursive: true }); - const stackId = deriveStackId(environment.identity, "default"); - const ownership = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); - - const readFiber = yield* Effect.forkScoped( - manager.readStack({ workspacePath: copied, portDocument: automaticDocument() }), - ); - yield* Deferred.await(readStarted); - const startFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const started = yield* startManagedStack(manager, { - workspacePath: workspace, - portDocument: automaticDocument(), - ownership, - lifecycle: "stopped", - }); - yield* started.lease.releaseAll; - }), - ); - yield* Fiber.join(startFiber); - gate.enabled = false; - yield* Deferred.succeed(releaseRead, void 0); - const read = yield* Fiber.join(readFiber).pipe(Effect.exit); - expect(Exit.isFailure(read)).toBe(true); - if (Exit.isFailure(read)) { - expect(Cause.squash(read.cause)).toMatchObject({ - _tag: "InvalidManagedIdentityError", - }); - } - }), - ).pipe( - Effect.provide(managerLayer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("fences start publication while checkout repair is owned", () => { - const { layer, workspace } = setup(); - return Effect.gen(function* () { - const baseTransport = yield* ControlTransport; - const repairRead = yield* Deferred.make(); - let repairEndpointUrl: string | undefined; - let repairId: string | undefined; - const observedTransport = Layer.succeed(ControlTransport, { - ...baseTransport, - read: (endpoint) => { - const ownerId = repairId; - if (endpoint.url !== repairEndpointUrl || ownerId === undefined) { - return baseTransport.read(endpoint); - } - return Deferred.succeed(repairRead, void 0).pipe( - Effect.andThen( - Effect.succeed({ - controlProtocol: "supabase-stack-control" as const, - controlProtocolVersion: 1 as const, - ownershipId: ownerId, - ownerSessionId: "repair-session", - kind: "supervisor" as const, - state: "running" as const, - ready: true, - daemonCliVersion: "test", - }), - ), - ); - }, - }); - - return yield* Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* ensureEnvironment(workspace); - repairId = deriveRepairOwnershipId(environment.identity); - const repairOwner = yield* acquireControl({ - stackId: repairId, - maintenanceOperation: "repair", - }); - if (!isControlOwnership(repairOwner)) throw new Error("expected repair ownership"); - repairEndpointUrl = repairOwner.endpoint.url; - const stackOwner = yield* acquireIsolatedStackOwner(workspace); - const stackId = deriveStackId(environment.identity, stackOwner.stackName); - const startFiber = yield* startManagedStack(manager, { - workspacePath: workspace, - stackName: stackOwner.stackName, - portDocument: automaticDocument(), - ownership: stackOwner.ownership, - }).pipe(Effect.forkScoped); - yield* Deferred.await(repairRead).pipe(Effect.timeout("30 seconds")); - expect(yield* manager.inspectStack(stackId)).toBeUndefined(); - yield* repairOwner.close; - repairEndpointUrl = undefined; - const started = yield* Fiber.join(startFiber).pipe(Effect.timeout("60 seconds")); - expect(started.stack.id).toBe(stackId); - yield* releaseLease(started); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(observedTransport), - ); - }).pipe(Effect.provide(controlTransportLayer)); - }); - - it.live("rejects a colliding control capability for another stack id", () => { - const { layer } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const { collidingStackId, ownership } = yield* acquireIsolatedCollisionOwner(); - const rejected = yield* manager - .allocateManagedPorts(ownership, { - stackId: collidingStackId, - portDocument: automaticDocument(), - }) - .pipe(Effect.exit); - expect(Exit.isFailure(rejected)).toBe(true); - if (Exit.isFailure(rejected)) { - expect(Cause.squash(rejected.cause)).toMatchObject({ - _tag: "ManagedStackControlRequiredError", - }); - } - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("repairs a moved workspace without changing stack id or ports", () => { - const { layer, stateRoot } = setup(); - // Permission bits cannot block writes when tests run as root, so gate the - // FileSystem seam instead to force the partial-repair failure. - const blockedWrites = { root: undefined as string | undefined }; - const blockingFileSystemLayer = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const base = yield* FileSystem.FileSystem; - return { - ...base, - writeFileString: ( - path: string, - data: string, - options?: Parameters[2], - ) => - blockedWrites.root !== undefined && path.startsWith(blockedWrites.root) - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "writeFileString", - pathOrDescriptor: path, - }), - ) - : base.writeFileString(path, data, options), - } satisfies FileSystem.FileSystem; - }), - ).pipe(Layer.provide(NodeFileSystem.layer)); - return Effect.scoped( - Effect.gen(function* () { - const root = mkdtempSync(join(tmpdir(), "managed-repair-test-")); - roots.push(root); - const repository = makeRepository(root); - const firstProject = join(repository, "apps", "first"); - const secondProject = join(repository, "apps", "second"); - mkdirSync(firstProject, { recursive: true }); - mkdirSync(secondProject, { recursive: true }); - const manager = yield* ManagedStackManager; - const original = yield* Effect.scoped( - startWithIsolatedOwner(manager, firstProject, automaticDocument(), "running"), - ); - const originalStackName = original.stackName; - const secondary = yield* Effect.scoped( - startWithIsolatedOwner(manager, secondProject, automaticDocument(), "stopped"), - ); - const originalId = original.stack.id; - const originalPort = original.stack.ports[0]?.port; - const secondaryId = secondary.stack.id; - const moved = join(root, "moved"); - renameSync(repository, moved); - const movedFirstProject = realpathSync(join(moved, "apps", "first")); - const movedSecondProject = realpathSync(join(moved, "apps", "second")); - const discovery = yield* manager.discoverWorkspace(movedFirstProject); - if (discovery.state !== "needsRepair") throw new Error("expected repair"); - const blockedRead = yield* manager - .readStack({ - workspacePath: movedFirstProject, - stackName: originalStackName, - portDocument: automaticDocument(), - }) - .pipe(Effect.exit); - expect(Exit.isFailure(blockedRead)).toBe(true); - if (Exit.isFailure(blockedRead)) { - const blockedError = Cause.squash(blockedRead.cause); - expect(blockedError).toMatchObject({ - _tag: "ManagedWorkspaceRepairConflictError", - repairReason: "moved", - }); - if (blockedError instanceof Error) { - expect(blockedError.message).toContain("repairWorkspace"); - } - } - const deleteBeforeRepair = yield* deleteManagedStack({ - workspacePath: movedFirstProject, - stackName: originalStackName, - }).pipe(Effect.exit); - expect(Exit.isFailure(deleteBeforeRepair)).toBe(true); - const blockedId = [originalId, secondaryId].sort().at(-1); - if (blockedId === undefined) throw new Error("expected affected stack"); - const blockedRoot = (yield* managedStackPathsEffect(stateRoot, blockedId)).root; - blockedWrites.root = blockedRoot; - const failed = yield* manager.repairWorkspace(discovery.repair).pipe(Effect.exit); - blockedWrites.root = undefined; - expect(Exit.isFailure(failed)).toBe(true); - const firstUpdatedId = [originalId, secondaryId].sort().at(0); - if (firstUpdatedId === undefined) throw new Error("expected affected stack"); - const partial = yield* manager.inspectStack(firstUpdatedId); - const partialExpectedPath = - partial?.identity.localProjectKey === "apps/first" - ? movedFirstProject - : movedSecondProject; - expect(partial?.workspace.path).toBe(partialExpectedPath); - const stillNeedsRepair = yield* manager.discoverWorkspace(movedFirstProject); - expect(stillNeedsRepair.state).toBe("needsRepair"); - const repaired = yield* manager.repairWorkspace(discovery.repair); - expect(repaired.state).toBe("ready"); - const stack = yield* manager.inspectStack(originalId); - expect(stack?.id).toBe(originalId); - expect(stack?.lifecycle).toBe("failed"); - expect(stack?.runtime).toBeUndefined(); - expect(stack?.ports[0]?.port).toBe(originalPort); - expect(stack?.workspace.path).toBe(movedFirstProject); - const repairedSecondary = yield* manager.inspectStack(secondaryId); - expect(repairedSecondary?.workspace.path).toBe(movedSecondProject); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(blockingFileSystemLayer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("lists corrupt and healthy stacks together", () => { - const { layer, stateRoot, workspace } = setup(); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const stack = yield* startWithOwner(manager, workspace, automaticDocument()); - yield* releaseLease(stack); - const corruptId = "f".repeat(64); - const corruptPaths = yield* managedStackPathsEffect(stateRoot, corruptId); - const corruptDocumentPath = yield* managedStackDocumentPathEffect(stateRoot, corruptId); - yield* Effect.sync(() => { - mkdirSync(corruptPaths.root, { recursive: true }); - writeFileSync(corruptDocumentPath, "not-json"); - }); - const listings = yield* manager.listStacks; - expect(listings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stack.stack.id, status: "healthy" }), - expect.objectContaining({ id: corruptId, status: "corrupt" }), - ]), - ); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); - - it.live("treats unsupported Git metadata as no managed stacks on read-only listing", () => { - const { layer, stateRoot, workspace } = setup(); - mkdirSync(join(workspace, ".git")); - return listStackSummaries({ cacheRoot: stateRoot, projectDir: workspace }).pipe( - Effect.tap((summaries) => Effect.sync(() => expect(summaries).toEqual([]))), - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); -}); diff --git a/packages/stack/src/managed-manager-worktrees.integration.test.ts b/packages/stack/src/managed-manager-worktrees.integration.test.ts deleted file mode 100644 index d314c0905a..0000000000 --- a/packages/stack/src/managed-manager-worktrees.integration.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// oxlint-disable effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Worktree-manager tests use native path fixtures to model isolated workspaces; manager dependencies are intentionally provided in dependency order. -import { it } from "@effect/vitest"; -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Effect } from "effect"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect } from "vitest"; -import { ManagedStackManager } from "./managed/manager.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { controlTransportLayer } from "./platform-node.ts"; -import { git, makeRepository } from "../tests/helpers/git-workspace.ts"; -import { - automaticDocument, - cleanupRoots, - releaseLease, - setupManagedManager, - startWithOwner, -} from "../tests/helpers/managed-manager.ts"; - -const roots: Array = []; -afterEach(() => cleanupRoots(roots)); -const setup = () => setupManagedManager(roots); - -describe("managed stack worktrees journeys", () => { - it.live("starts sibling worktrees concurrently with independent automatic ports", () => { - const { layer } = setup(); - const siblingRoot = mkdtempSync(join(tmpdir(), "managed-sibling-test-")); - roots.push(siblingRoot); - const repository = makeRepository(siblingRoot); - const firstWorkspace = join(siblingRoot, "first"); - const secondWorkspace = join(siblingRoot, "second"); - git(repository, "worktree", "add", "-q", firstWorkspace, "-b", "first", "main"); - git(repository, "worktree", "add", "-q", secondWorkspace, "-b", "second", "main"); - return Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const [first, second] = yield* Effect.all( - [ - startWithOwner(manager, firstWorkspace, automaticDocument()), - startWithOwner(manager, secondWorkspace, automaticDocument()), - ], - { concurrency: "unbounded" }, - ); - expect(first.stack.id).not.toBe(second.stack.id); - const firstPorts = new Set(first.stack.ports.map((assignment) => assignment.port)); - expect(second.stack.ports.some((assignment) => firstPorts.has(assignment.port))).toBe( - false, - ); - const listings = yield* manager.listStacks; - expect(listings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: first.stack.id, status: "healthy" }), - expect.objectContaining({ id: second.stack.id, status: "healthy" }), - ]), - ); - yield* releaseLease(first); - yield* releaseLease(second); - }), - ).pipe( - Effect.provide(layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), - ); - }); -}); diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts deleted file mode 100644 index c95b9cf75e..0000000000 --- a/packages/stack/src/managed-node.ts +++ /dev/null @@ -1,37 +0,0 @@ -// oxlint-disable effecttsgo/multiple-effect-provide -- Managed daemon layers are staged to satisfy transport and filesystem dependencies without changing lifecycle ownership. - -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Effect, Layer } from "effect"; -import { - managedDaemonLayer as managedDaemonLayerForPlatform, - type ManagedDaemonStartInput, -} from "./supervisor.ts"; -import { daemonEntryPoint as managedDaemonEntryPoint } from "./platform-node.ts"; -import { managedStackManagerLayer as managedStackManagerLayerCore } from "./managed/manager.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { controlTransportLayer } from "./platform-node.ts"; - -export * from "./managed.ts"; -export { controlTransportLayer }; -export { managedDaemonEntryPoint }; -export type { ManagedDaemonStartInput } from "./supervisor.ts"; - -export const managedStackManagerLayer = (options: { readonly stateRoot: string }) => - managedStackManagerLayerCore(options).pipe( - Layer.provide( - Layer.mergeAll( - NodeFileSystem.layer, - NodePath.layer, - gitConfigStoreLayer, - controlTransportLayer, - ), - ), - ); - -export const managedDaemonLayer = ( - input: ManagedDaemonStartInput, -): ReturnType => - managedDaemonLayerForPlatform(input, managedDaemonEntryPoint).pipe( - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - ); diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts deleted file mode 100644 index 733d89b5e9..0000000000 --- a/packages/stack/src/managed-paths.unit.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- Path tests use the native path module to assert platform-specific path normalization. - -import { Effect } from "effect"; -import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { validateManagedUuid } from "./managed/ids.ts"; -import { InvalidManagedIdentityError, UnsafeManagedStackPathError } from "./managed/model.ts"; -import * as managedPathsModule from "./managed/paths.ts"; -import { - assertManagedStackRootEffect, - managedStackPathsEffect, - resolveManagedStateRootEffect, -} from "./managed/paths.ts"; - -const run = (effect: Effect.Effect): A => Effect.runSync(effect); -const failureOf = (effect: Effect.Effect): E => Effect.runSync(Effect.flip(effect)); - -describe("managed paths", () => { - it("does not expose the removed SQLite registry path", () => { - expect(managedPathsModule).not.toHaveProperty("managedRegistryPath"); - }); - - it.each([ - ["empty", ""], - ["wrong-length", "018f8b4e-8e5c-7e32-a956-6f297fd05a2"], - ["non-hex", "018f8b4g-8e5c-7e32-a956-6f297fd05a2d"], - ["unsupported version", "018f8b4e-8e5c-0e32-a956-6f297fd05a2d"], - ["invalid variant", "018f8b4e-8e5c-7e32-7956-6f297fd05a2d"], - ])("rejects %s managed UUIDs", (_case, value) => { - expect(failureOf(validateManagedUuid(value, "test id"))).toBeInstanceOf( - InvalidManagedIdentityError, - ); - }); - - it("isolates managed records beneath SUPABASE_HOME", () => { - expect( - run( - resolveManagedStateRootEffect({ - env: { SUPABASE_HOME: "/configured/supabase" }, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBe("/configured/supabase/managed"); - }); - - it("trims surrounding whitespace from a configured SUPABASE_HOME", () => { - expect( - run( - resolveManagedStateRootEffect({ - env: { SUPABASE_HOME: " /configured/supabase " }, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBe("/configured/supabase/managed"); - }); - - it("treats whitespace-only state-root environment values as unset", () => { - expect( - run( - resolveManagedStateRootEffect({ - env: { SUPABASE_HOME: " " }, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBe("/home/user/.local/state/supabase/managed"); - expect( - run( - resolveManagedStateRootEffect({ - env: { XDG_STATE_HOME: "\t" }, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBe("/home/user/.local/state/supabase/managed"); - expect( - run( - resolveManagedStateRootEffect({ - env: { LOCALAPPDATA: " " }, - homeDir: "C:\\Users\\user", - platform: "win32", - }), - ), - ).toBe("C:\\Users\\user/AppData/Local/Supabase/managed"); - }); - - it("uses platform application-state directories by default", () => { - expect( - run(resolveManagedStateRootEffect({ env: {}, homeDir: "/home/user", platform: "linux" })), - ).toBe("/home/user/.local/state/supabase/managed"); - expect( - run(resolveManagedStateRootEffect({ env: {}, homeDir: "/Users/user", platform: "darwin" })), - ).toBe("/Users/user/Library/Application Support/supabase/managed"); - expect( - run( - resolveManagedStateRootEffect({ - env: { XDG_STATE_HOME: "" }, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBe("/home/user/.local/state/supabase/managed"); - expect( - run( - resolveManagedStateRootEffect({ - env: { LOCALAPPDATA: "" }, - homeDir: "C:\\Users\\user", - platform: "win32", - }), - ), - ).toBe("C:\\Users\\user/AppData/Local/Supabase/managed"); - }); - - it("anchors caller- and environment-supplied state roots to an absolute path", () => { - expect(run(resolveManagedStateRootEffect({ stateRoot: "relative/managed" }))).toBe( - resolve("relative/managed"), - ); - expect(run(resolveManagedStateRootEffect({ stateRoot: "/absolute/managed" }))).toBe( - "/absolute/managed", - ); - expect( - run( - resolveManagedStateRootEffect({ - env: { SUPABASE_HOME: "relative/supabase" }, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBe(join(resolve("relative/supabase"), "managed")); - expect( - run( - resolveManagedStateRootEffect({ - env: { XDG_STATE_HOME: "relative/state" }, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBe(join(resolve("relative/state"), "supabase", "managed")); - }); - - it("refuses a blank explicit state root instead of falling back", () => { - for (const stateRoot of ["", " ", "\t"]) { - expect( - failureOf( - resolveManagedStateRootEffect({ - stateRoot, - env: {}, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBeInstanceOf(UnsafeManagedStackPathError); - } - expect( - failureOf( - resolveManagedStateRootEffect({ - stateRoot: "", - env: { SUPABASE_HOME: "/configured/supabase" }, - homeDir: "/home/user", - platform: "linux", - }), - ), - ).toBeInstanceOf(UnsafeManagedStackPathError); - }); - - it("names the blank root it refused instead of an empty message tail", () => { - expect(failureOf(resolveManagedStateRootEffect({ stateRoot: "\t" }))).toMatchObject({ - path: "\t", - message: 'Refusing a blank managed state root: "\\t"', - }); - }); - - it("trims surrounding whitespace from an explicit state root", () => { - expect(run(resolveManagedStateRootEffect({ stateRoot: " /absolute/managed " }))).toBe( - "/absolute/managed", - ); - }); - - it("keys every mutable stack path by opaque stack ID", () => { - expect(run(managedStackPathsEffect("/state", "018f8b4e-8e5c-7e32-a956-6f297fd05a2d"))).toEqual({ - root: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d", - data: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/data", - logs: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/logs", - runtime: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/runtime", - }); - }); - - it("rejects non-UUID IDs and stack paths that do not match the derived root", () => { - expect(failureOf(managedStackPathsEffect("/state", "../../tmp/escaped"))).toBeInstanceOf( - InvalidManagedIdentityError, - ); - expect( - failureOf( - assertManagedStackRootEffect( - "/state", - "018f8b4e-8e5c-7e32-a956-6f297fd05a2d", - "/tmp/escaped", - ), - ), - ).toBeInstanceOf(UnsafeManagedStackPathError); - }); -}); diff --git a/packages/stack/src/managed-store.integration.test.ts b/packages/stack/src/managed-store.integration.test.ts deleted file mode 100644 index 23cac5c86f..0000000000 --- a/packages/stack/src/managed-store.integration.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Store integration tests use native temporary paths and inspect persisted JSON at the storage boundary. - -import { NodeFileSystem, NodePath } from "@effect/platform-node"; -import { it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, PlatformError, Predicate } from "effect"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect } from "vitest"; -import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; -import { makeStackStore } from "./managed/store.ts"; -import type { ManagedStackDocument } from "./managed/document.ts"; - -const STACK_ID = "11111111-1111-4111-8111-111111111111"; -const HEALTHY_ID = "33333333-3333-4333-8333-333333333333"; -const CORRUPT_ID = "22222222-2222-4222-8222-222222222222"; - -const filesystemLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer); -const temporaryRoots: Array = []; -let resetDisappearance: (() => void) | undefined; - -const failingWriteFileSystemLayer = Layer.effect( - FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, (fs) => ({ - ...fs, - writeFileString: ( - _path: string, - _data: string, - _options?: Parameters[2], - ) => - Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "test", - method: "writeFileString", - description: "injected write failure", - }), - ), - })), -).pipe(Layer.provide(NodeFileSystem.layer)); - -const disappearingDocumentFileSystemLayer = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const base = yield* FileSystem.FileSystem; - let disappearNext = true; - resetDisappearance = () => { - disappearNext = true; - }; - return { - ...base, - readFileString: (path: string, options?: Parameters[1]) => { - if (path.endsWith("stack.json") && disappearNext) { - disappearNext = false; - return Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "test", - method: "readFileString", - description: "injected disappearance", - }), - ); - } - return base.readFileString(path, options); - }, - exists: (path: string) => - path.endsWith("stack.json") && disappearNext ? Effect.succeed(true) : base.exists(path), - } satisfies FileSystem.FileSystem; - }), -).pipe(Layer.provide(NodeFileSystem.layer)); - -afterEach(() => { - for (const root of temporaryRoots.splice(0)) { - rmSync(root, { force: true, recursive: true }); - } -}); - -const makeRoot = (): string => { - const root = mkdtempSync(join(tmpdir(), "managed-store-test-")); - temporaryRoots.push(root); - return root; -}; - -const document = (overrides: Partial = {}): ManagedStackDocument => ({ - format: "supabase-stack", - formatVersion: 1, - id: STACK_ID, - identity: { - workspaceId: "workspace-id", - checkoutId: "checkout-id", - contextId: "context-id", - localProjectKey: ".", - name: "default", - }, - workspace: { - kind: "folder", - checkoutKind: "folder", - path: "/tmp/project", - }, - ports: [ - { key: "api.port", port: 54321, intent: "automatic" }, - { key: "db.port", port: 54322, intent: "automatic" }, - ], - lifecycle: "stopped", - launch: { mode: "native", versions: {} }, - createdAt: "2026-08-16T00:00:00.000Z", - updatedAt: "2026-08-16T00:00:00.000Z", - ...overrides, -}); - -const makeTempStackStore = (stateRoot = makeRoot()) => makeStackStore(stateRoot); - -const writeRawStackDocument = (stateRoot: string, stackId: string, content: string): void => { - const stackRoot = Effect.runSync(managedStackPathsEffect(stateRoot, stackId)).root; - mkdirSync(stackRoot, { recursive: true }); - writeFileSync(Effect.runSync(managedStackDocumentPathEffect(stateRoot, stackId)), content); -}; - -describe("managed stack document store", () => { - it.live("persists and replaces one complete stack document atomically", () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - yield* store.write(document({ lifecycle: "starting" })); - yield* store.write(document({ lifecycle: "running" })); - const invalid = yield* store - .write(document({ ports: [{ key: "api.port", port: 54321, intent: "automatic" }] })) - .pipe(Effect.exit); - expect(Exit.isFailure(invalid)).toBe(true); - if (Exit.isFailure(invalid)) { - expect(Cause.squash(invalid.cause)).toMatchObject({ - _tag: "InvalidManagedStackDocumentError", - }); - } - expect(yield* store.read(STACK_ID)).toMatchObject({ lifecycle: "running" }); - }).pipe(Effect.provide(filesystemLayer)), - ); - - it.live("persists launch selections needed for detached CLI reattachment", () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - yield* store.write( - document({ - launch: { - mode: "docker", - containerRuntime: "docker", - versions: { postgres: "17.6.1" }, - excludedServices: ["studio", "analytics"], - lastNotifiedUpdateFingerprint: "fingerprint", - }, - }), - ); - expect((yield* store.read(STACK_ID))?.launch).toEqual({ - mode: "docker", - containerRuntime: "docker", - versions: { postgres: "17.6.1" }, - excludedServices: ["studio", "analytics"], - lastNotifiedUpdateFingerprint: "fingerprint", - }); - }).pipe(Effect.provide(filesystemLayer)), - ); - - it.live("rejects unknown launch modes as invalid managed documents", () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - writeRawStackDocument( - store.stateRoot, - STACK_ID, - JSON.stringify({ ...document(), launch: { mode: "auto", versions: {} } }), - ); - - const exit = yield* store.read(STACK_ID).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect( - Predicate.isTagged(Cause.squash(exit.cause), "InvalidManagedStackDocumentError"), - ).toBe(true); - } - }).pipe(Effect.provide(filesystemLayer)), - ); - - it.live("rejects managed documents without a concrete launch selection", () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - const { launch: _launch, ...withoutLaunch } = document(); - writeRawStackDocument(store.stateRoot, STACK_ID, JSON.stringify(withoutLaunch)); - - const exit = yield* store.read(STACK_ID).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect( - Predicate.isTagged(Cause.squash(exit.cause), "InvalidManagedStackDocumentError"), - ).toBe(true); - } - }).pipe(Effect.provide(filesystemLayer)), - ); - - it.live("rejects an incomplete Docker launch as an invalid managed document", () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - writeRawStackDocument( - store.stateRoot, - STACK_ID, - JSON.stringify({ ...document(), launch: { mode: "docker", versions: {} } }), - ); - - const exit = yield* store.read(STACK_ID).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect( - Predicate.isTagged(Cause.squash(exit.cause), "InvalidManagedStackDocumentError"), - ).toBe(true); - } - }).pipe(Effect.provide(filesystemLayer)), - ); - - it.live("lists a corrupt stack beside healthy stacks", () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - yield* store.write(document({ id: HEALTHY_ID })); - writeRawStackDocument(store.stateRoot, CORRUPT_ID, "not-json"); - expect(yield* store.list).toEqual([ - expect.objectContaining({ id: CORRUPT_ID, status: "corrupt" }), - expect.objectContaining({ id: HEALTHY_ID, status: "healthy" }), - ]); - }).pipe(Effect.provide(filesystemLayer)), - ); - - it.live("lists an unreadable stack as corrupt without hiding healthy stacks", () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - yield* store.write(document({ id: HEALTHY_ID })); - const corruptPath = yield* managedStackDocumentPathEffect(store.stateRoot, CORRUPT_ID); - yield* Effect.sync(() => mkdirSync(corruptPath, { recursive: true })); - - const listings = yield* store.list; - expect(listings).toEqual([ - expect.objectContaining({ - id: CORRUPT_ID, - status: "corrupt", - cause: expect.any(PlatformError.PlatformError), - }), - expect.objectContaining({ id: HEALTHY_ID, status: "healthy" }), - ]); - }).pipe(Effect.provide(filesystemLayer)), - ); - - it.live("keeps a store filesystem failure in the typed error channel", () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - const exit = yield* store.write(document()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.hasDies(exit.cause)).toBe(false); - expect(Cause.squash(exit.cause)).toBeInstanceOf(PlatformError.PlatformError); - } - }).pipe(Effect.provide(Layer.mergeAll(failingWriteFileSystemLayer, NodePath.layer))), - ); - - it.live( - "treats a document that disappears after exists as absent and skips it from listings", - () => - Effect.gen(function* () { - const store = yield* makeTempStackStore(); - yield* store.write(document()); - expect(yield* store.read(STACK_ID)).toBeUndefined(); - if (resetDisappearance === undefined) throw new Error("expected injected filesystem"); - resetDisappearance(); - expect(yield* store.list).toEqual([]); - }).pipe(Effect.provide(Layer.mergeAll(disappearingDocumentFileSystemLayer, NodePath.layer))), - ); -}); diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts deleted file mode 100644 index d71a1f7ace..0000000000 --- a/packages/stack/src/managed.ts +++ /dev/null @@ -1,81 +0,0 @@ -export { - GIT_WORKSPACE_ID_KEY, - GitConfigStore, - ensureBranchContextId, - ensureGitCheckoutIdentity, - gitBranchContextIdKey, - inspectWorkspace, - readBranchContextId, - gitConfigStoreLayer, -} from "./managed/git.ts"; -export type { - GitCheckoutIdentityState, - GitCheckoutInspection, - GitCheckoutKind, - GitConfigStoreShape, - GitHead, - OrdinaryFolderInspection, - WorkspaceInspection, - EnsureGitCheckoutIdentityResult, -} from "./managed/git.ts"; - -export { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; -export type { EnsureOrdinaryWorkspaceIdentityResult } from "./managed/identity.ts"; -export * from "./managed/ids.ts"; -export * from "./managed/environment.ts"; -export { - acquireControl, - controlEndpoint, - CONTROL_PORT_RANGE, - ControlAddressConflictError, - ControlBindError, - ControlProtocolError, - ControlProtocolMismatchError, - ControlTransportError, - InvalidControlOwnershipIdError, - isControlOwnership, -} from "./managed/control.ts"; -export type { - ControlAcquisition, - ControlAttached, - ControlEndpoint, - ControlOwnerState, - ControlOwnerStatus, - ControlOwnership, - ControlOwnershipInput, -} from "./managed/control.ts"; -export * from "./managed/model.ts"; -export * from "./managed/paths.ts"; - -export { - ManagedStackManager, - managedStackManagerLayer, - makeManagedStackManager, - deriveRepairOwnershipId, - ManagedStackControlRequiredError, - ManagedStackAttachedError, - ManagedWorkspaceRepairConflictError, -} from "./managed/manager.ts"; -export type { - ManagedStack, - ManagedStackManagerShape, - ManagedStackManagerError, - ManagedStackStartResult, - ManagedStackLifecycleUpdate, - ManagedPortLease, - ManagedPortAllocation, - ManagedDeleteResult, - AllocateManagedPortsRequest, - ReadStackRequest, - StartStackRequest, - ManagedStackLaunchUpdateRequest, -} from "./managed/manager.ts"; -export type { ManagedStackLaunchUpdate } from "./managed/document.ts"; -export { - connectManagedStack, - deleteManagedStack, - resolveManagedDocument, - stopManagedStack, - updateManagedLaunch, -} from "./managed/lifecycle.ts"; -export type { ManagedLifecycleInput } from "./managed/lifecycle.ts"; diff --git a/packages/stack/src/managed/atomic-claim.integration.test.ts b/packages/stack/src/managed/atomic-claim.integration.test.ts deleted file mode 100644 index b04b46e0b7..0000000000 --- a/packages/stack/src/managed/atomic-claim.integration.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- Atomic claim integration tests use native filesystem fixtures to verify cross-process claims. - -import { NodeFileSystem } from "@effect/platform-node"; -import { it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, FileSystem, Layer, PlatformError } from "effect"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect } from "vitest"; -import { AtomicClaimUnsupportedError, claimFileAtomically } from "./atomic-claim.ts"; - -const roots: Array = []; - -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); -}); - -const makeRoot = (): string => { - const root = mkdtempSync(join(tmpdir(), "managed-claim-test-")); - roots.push(root); - return root; -}; - -const interruptibleLinkLayer = ( - started: Deferred.Deferred, - release: Deferred.Deferred, -) => - Layer.effect( - FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, (fs) => { - let blockNextLink = true; - return { - ...fs, - link: (fromPath: string, toPath: string) => { - if (!blockNextLink) return fs.link(fromPath, toPath); - blockNextLink = false; - return Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.andThen(fs.link(fromPath, toPath)), - ); - }, - }; - }), - ).pipe(Layer.provide(NodeFileSystem.layer)); - -const unsupportedLinkLayer = (code: string) => - Layer.effect( - FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, (fs) => ({ - ...fs, - link: (_fromPath: string, _toPath: string) => - Effect.fail( - PlatformError.systemError({ - _tag: "Unknown", - module: "test", - method: "link", - description: "hard links are unavailable", - cause: { code }, - }), - ), - })), - ).pipe(Layer.provide(NodeFileSystem.layer)); - -describe("managed atomic claims", () => { - it.live("concurrent claimants publish exactly one complete marker", () => { - const target = join(makeRoot(), "identity.json"); - return Effect.gen(function* () { - const outcomes = yield* Effect.all( - Array.from({ length: 8 }, (_, index) => - claimFileAtomically(target, `winner-${index}\n`, { mode: 0o600 }), - ), - { concurrency: "unbounded" }, - ); - const fs = yield* FileSystem.FileSystem; - const content = yield* fs.readFileString(target); - expect(outcomes.filter((outcome) => outcome === "claimed")).toHaveLength(1); - expect(content).toMatch(/^winner-[0-7]\n$/); - }).pipe(Effect.provide(NodeFileSystem.layer)); - }); - - it.live("reports unsupported hard-link publication errors as typed failures", () => { - return Effect.gen(function* () { - for (const code of ["ENOTSUP", "ENOSYS", "EXDEV"] as const) { - const root = makeRoot(); - const unsupportedLayer = unsupportedLinkLayer(code); - - const failure = yield* Effect.flip( - claimFileAtomically(join(root, "identity.json"), "content\n").pipe( - Effect.provide(unsupportedLayer), - ), - ); - expect(failure).toBeInstanceOf(AtomicClaimUnsupportedError); - const entries = yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.readDirectory(root); - }).pipe(Effect.provide(unsupportedLayer)); - expect(entries).toEqual([]); - } - }); - }); - - it.live("cleans interrupted publication state so a later claim can retry", () => { - const root = makeRoot(); - const target = join(root, "identity.json"); - return Effect.gen(function* () { - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const layer = interruptibleLinkLayer(started, release); - const first = yield* claimFileAtomically(target, "first\n").pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - yield* Deferred.await(started); - yield* Fiber.interrupt(first); - yield* Deferred.succeed(release, undefined); - - const fs = yield* FileSystem.FileSystem; - expect(yield* fs.exists(target)).toBe(false); - expect(yield* fs.readDirectory(root)).toEqual([]); - - const retried = yield* claimFileAtomically(target, "second\n").pipe(Effect.provide(layer)); - expect(retried).toBe("claimed"); - expect(yield* fs.readFileString(target)).toBe("second\n"); - }).pipe(Effect.provide(NodeFileSystem.layer)); - }); -}); diff --git a/packages/stack/src/managed/atomic-claim.ts b/packages/stack/src/managed/atomic-claim.ts deleted file mode 100644 index 93e9eab558..0000000000 --- a/packages/stack/src/managed/atomic-claim.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { Cause, Data, Effect, Exit, FileSystem, Option, PlatformError, Predicate } from "effect"; - -export type FileClaimOutcome = "claimed" | "already-exists"; - -/** The hard-link publication protocol is unavailable on this filesystem. */ -export class AtomicClaimUnsupportedError extends Data.TaggedError("AtomicClaimUnsupportedError")<{ - readonly targetPath: string; - readonly message: string; -}> {} - -export interface FileClaimOptions { - /** Mode for the published file; defaults to the process umask. */ - readonly mode?: number; -} - -const isAlreadyExists = (error: PlatformError.PlatformError): boolean => - Predicate.isTagged(error.reason, "AlreadyExists"); - -const errorCode = (error: PlatformError.PlatformError): string | undefined => { - const cause = error.reason.cause; - if (cause !== null && typeof cause === "object" && "code" in cause) { - const code = cause.code; - return typeof code === "string" ? code : undefined; - } - return undefined; -}; - -const isHardLinkUnsupported = (error: PlatformError.PlatformError): boolean => { - const code = errorCode(error); - return ( - code === "EPERM" || - code === "ENOTSUP" || - code === "EOPNOTSUPP" || - code === "ENOSYS" || - code === "EXDEV" - ); -}; - -const unsupportedHardLink = ( - targetPath: string, - error: PlatformError.PlatformError, -): Effect.Effect => - Effect.fail( - new AtomicClaimUnsupportedError({ - targetPath, - message: `Filesystem cannot atomically publish a managed claim (${error.message})`, - }), - ); - -const removeOwnedFile = (fs: FileSystem.FileSystem, path: string): Effect.Effect => - fs.remove(path, { force: true }).pipe(Effect.ignore); - -/** - * Writes an owned file with an interruption-safe open handoff. Exclusive open - * and ownership recording stay masked as one region; only the subsequent write - * is restored, so a created pathname can never escape without cleanup ownership. - */ -const writeOwnedFile = ( - fs: FileSystem.FileSystem, - path: string, - content: string, - mode: number | undefined, -): Effect.Effect => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - let attempted = false; - let owned = false; - const result = yield* Effect.exit( - Effect.scoped( - Effect.gen(function* () { - // The surrounding mask covers open and this handoff assignment. - attempted = true; - const file = yield* fs.open(path, { flag: "wx", mode }); - owned = true; - yield* restore(file.writeAll(new TextEncoder().encode(content))); - }), - ), - ); - if (Exit.isSuccess(result)) return; - const error = Cause.findErrorOption(result.cause); - const interruptedBeforeOwnership = - attempted && - Cause.hasInterrupts(result.cause) && - (Option.isNone(error) || !isAlreadyExists(error.value)); - if (owned || interruptedBeforeOwnership) { - yield* removeOwnedFile(fs, path); - } - return yield* Effect.failCause(result.cause); - }), - ); - -const publish = ( - fs: FileSystem.FileSystem, - temporaryPath: string, - targetPath: string, -): Effect.Effect => - Effect.gen(function* () { - // A hard link publishes the complete, closed temp atomically and refuses an - // existing target. Unsupported links fail as typed PlatformError rather than - // falling back to a direct target write or an unprovable sidecar protocol. - const linked = yield* Effect.exit(fs.link(temporaryPath, targetPath)); - if (Exit.isSuccess(linked)) return "claimed" as const; - const linkError = Cause.findErrorOption(linked.cause); - if (Option.isNone(linkError)) return yield* Effect.failCause(linked.cause); - if (isAlreadyExists(linkError.value)) { - const readable = yield* Effect.exit(fs.readFile(targetPath)); - if (Exit.isSuccess(readable)) return "already-exists" as const; - return yield* Effect.failCause(readable.cause); - } - if (isHardLinkUnsupported(linkError.value)) { - return yield* unsupportedHardLink(targetPath, linkError.value); - } - return yield* linkError.value; - }); - -/** - * Publishes `content` at `targetPath` unless a claimant got there first. - * - * Every claimant writes a unique sibling completely before publication. The - * hard-link publication is the sole publication primitive: it exposes only a - * complete temp and never overwrites a winner. Filesystems that reject hard - * links fail as a typed PlatformError after exact temporary cleanup. - */ -export const claimFileAtomically = ( - targetPath: string, - content: string, - options: FileClaimOptions = {}, -): Effect.Effect< - FileClaimOutcome, - PlatformError.PlatformError | AtomicClaimUnsupportedError, - FileSystem.FileSystem -> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const temporaryPath = `${targetPath}.tmp.${randomUUID()}`; - - // Register exact temp cleanup before entering interruptible publication. - return yield* Effect.acquireUseRelease( - writeOwnedFile(fs, temporaryPath, content, options.mode).pipe(Effect.as(temporaryPath)), - () => publish(fs, temporaryPath, targetPath), - (ownedPath) => removeOwnedFile(fs, ownedPath), - ); - }); diff --git a/packages/stack/src/managed/control.ts b/packages/stack/src/managed/control.ts deleted file mode 100644 index b876fa5f2b..0000000000 --- a/packages/stack/src/managed/control.ts +++ /dev/null @@ -1,805 +0,0 @@ -import { - Data, - Duration, - Effect, - Context, - Match, - Predicate, - Ref, - Result, - Schedule, - Schema, -} from "effect"; -import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { - CONTROL_PROTOCOL, - CONTROL_PROTOCOL_VERSION, - ControlOwnerStatusSchema, - type ControlMaintenanceOperation, - type ControlOwnerStatus, - type ControlOwnerState, - type ControlSupervisorStatus, - type ControlStopIntent, - type ControlStopRequest, - isControlSupervisorStatus, - matchesControlSession, -} from "../DaemonProtocol.ts"; -import { StopTimeout } from "../errors.ts"; - -export type { - ControlMaintenanceOperation, - ControlOwnerState, - ControlOwnerStatus, - ControlSupervisorStatus, - ControlStopRequest, - ControlStopIntent, -} from "../DaemonProtocol.ts"; -export { ControlStopRequestSchema } from "../DaemonProtocol.ts"; - -/** The owner status path exposed once the daemon routes are installed. */ -export const CONTROL_STATUS_PATH = "/owner"; -/** The early shutdown path exposed by the deterministic control listener. */ -export const CONTROL_STOP_PATH = "/stop"; - -const CONTROL_ID_PATTERN = /^[0-9a-f]{64}$/; - -/** Reserved loopback TCP range for deterministic managed control endpoints. */ -export const CONTROL_PORT_RANGE = { min: 10_000, max: 32_767 } as const; - -export interface ControlEndpoint { - readonly hostname: string; - readonly port: number; - readonly url: string; -} - -export interface ControlApplication { - readonly app: Effect.Effect< - HttpServerResponse.HttpServerResponse, - never, - HttpServerRequest.HttpServerRequest | import("effect/Scope").Scope - >; -} - -const controlOwnershipBrand: unique symbol = Symbol("stack/ControlOwnership"); - -export class InvalidControlOwnershipIdError extends Data.TaggedError( - "InvalidControlOwnershipIdError", -)<{ - readonly ownershipId: string; -}> { - override get message(): string { - return "Control ownership ids must be 64 lowercase hexadecimal characters"; - } -} - -export class ControlBindError extends Data.TaggedError("ControlBindError")<{ - readonly endpoint: ControlEndpoint; - readonly reason: "in-use" | "failed"; - readonly cause: unknown; -}> {} - -export class ControlTransportError extends Data.TaggedError("ControlTransportError")<{ - readonly endpoint: ControlEndpoint; - readonly reason: "unreachable" | "transport"; - readonly cause: unknown; -}> {} - -export class ControlProtocolError extends Data.TaggedError("ControlProtocolError")<{ - readonly endpoint: ControlEndpoint; - readonly cause: unknown; -}> {} - -/** A fenced stop reached an owner other than the captured session. */ -export class ControlStopConflictError extends Data.TaggedError("ControlStopConflictError")<{ - readonly endpoint: ControlEndpoint; -}> {} - -/** A maintenance owner fences concurrent lifecycle mutations. */ -export class ControlMaintenanceBusyError extends Data.TaggedError("ControlMaintenanceBusyError")<{ - readonly endpoint: ControlEndpoint; -}> {} - -export class ControlProtocolMismatchError extends Data.TaggedError("ControlProtocolMismatchError")<{ - readonly endpoint: ControlEndpoint; - readonly expectedVersion: 1; - readonly observedVersion: number | undefined; - readonly expectedProtocol: typeof CONTROL_PROTOCOL; - readonly observedProtocol: string | undefined; -}> { - override get message(): string { - return `Control protocol mismatch: expected ${this.expectedProtocol}/${this.expectedVersion}, observed ${String(this.observedProtocol)}/${String(this.observedVersion)}`; - } -} - -export class ControlAddressConflictError extends Data.TaggedError("ControlAddressConflictError")<{ - readonly endpoint: ControlEndpoint; - readonly cause: unknown; -}> { - override get message(): string { - return `Control endpoint ${this.endpoint.url} is occupied by another listener`; - } -} - -class ControlUnavailableError extends Data.TaggedError("ControlUnavailableError")<{ - readonly endpoint: ControlEndpoint; - readonly cause: unknown; -}> {} - -class ControlStopPending extends Data.TaggedError("ControlStopPending")<{ - readonly state: ControlOwnerState; -}> {} - -interface ControlListener { - readonly server: HttpServer.HttpServer["Service"]; - readonly close: Effect.Effect; -} - -export interface ControlClientTransport { - readonly read: ( - endpoint: ControlEndpoint, - ) => Effect.Effect; - readonly requestStop: ( - endpoint: ControlEndpoint, - request: ControlStopRequest, - ) => Effect.Effect< - void, - | ControlTransportError - | ControlProtocolError - | ControlStopConflictError - | ControlMaintenanceBusyError - >; -} - -type ControlStopDecision = "accepted" | "conflict" | "busy" | "invalid"; - -export interface ControlTransportShape extends ControlClientTransport { - readonly bind: ( - endpoint: ControlEndpoint, - ownerStatus: () => ControlOwnerStatus, - onStop: (request: ControlStopRequest) => ControlStopDecision, - application?: ControlApplication, - ) => Effect.Effect; -} - -/** Runtime-specific loopback bind/connect operations supplied by Node or Bun. */ -export class ControlTransport extends Context.Service()( - "stack/ControlTransport", -) {} - -export type ControlOwnershipInput = - | { - readonly stackId: string; - readonly initialStatus: ControlSupervisorStatus; - readonly application?: ControlApplication; - } - | { - readonly stackId: string; - readonly maintenanceOperation: ControlMaintenanceOperation; - }; - -export interface ControlOwnership { - readonly _tag: "Owned"; - readonly [controlOwnershipBrand]: true; - readonly ownershipId: string; - readonly endpoint: ControlEndpoint; - readonly close: Effect.Effect; -} - -export interface ControlAttached { - readonly _tag: "Attached"; - readonly ownershipId: string; - readonly endpoint: ControlEndpoint; - /** Status decoded during the ownership handshake before the result escaped. */ - readonly observedStatus: ControlOwnerStatus; - readonly ownerStatus: Effect.Effect< - ControlOwnerStatus, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - >; - readonly requestStop: Effect.Effect< - void, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - | ControlMaintenanceBusyError - | StopTimeout - >; -} - -export type ControlAcquisition = ControlOwnership | ControlAttached; - -export const isControlOwnership = ( - acquisition: ControlAcquisition, -): acquisition is ControlOwnership => Predicate.isTagged(acquisition, "Owned"); - -export const isControlAttached = ( - acquisition: ControlAcquisition, -): acquisition is ControlAttached => Predicate.isTagged(acquisition, "Attached"); - -const invalidId = (ownershipId: string): Effect.Effect => - Effect.fail(new InvalidControlOwnershipIdError({ ownershipId })); - -const ownershipBytes = (ownershipId: string): ReadonlyArray => { - const bytes: Array = []; - for (let index = 0; index < ownershipId.length; index += 2) { - bytes.push(Number.parseInt(ownershipId.slice(index, index + 2), 16)); - } - return bytes; -}; - -/** - * Number of deterministic endpoints derived per ownership id. Two ids can - * hash to the same primary port, so owners fall through to the next - * candidate and readers scan the same sequence, matching on `ownershipId`. - */ -export const CONTROL_CANDIDATE_COUNT = 8; - -const CONTROL_RANGE_SIZE = CONTROL_PORT_RANGE.max - CONTROL_PORT_RANGE.min + 1; - -const endpointForValue = (value: number): ControlEndpoint => { - const port = CONTROL_PORT_RANGE.min + (value % CONTROL_RANGE_SIZE); - const host = "127.0.0.1"; - return { hostname: host, port, url: `http://${host}:${port}` }; -}; - -/** Derives the deterministic loopback endpoint candidates for a stack id. */ -export const controlEndpointCandidates = ( - ownershipId: string, -): Effect.Effect, InvalidControlOwnershipIdError> => { - if (!CONTROL_ID_PATTERN.test(ownershipId)) return invalidId(ownershipId); - const bytes = ownershipBytes(ownershipId); - const value = (bytes[3]! << 8) | bytes[4]!; - return Effect.succeed( - Array.from({ length: CONTROL_CANDIDATE_COUNT }, (_, offset) => - endpointForValue(value + offset), - ), - ); -}; - -/** Derives the primary deterministic endpoint (first candidate) for a stack id. */ -export const controlEndpoint = ( - ownershipId: string, -): Effect.Effect => - Effect.map(controlEndpointCandidates(ownershipId), (candidates) => candidates[0]!); - -const CONTROL_STOP_TIMEOUT = Duration.seconds(30); - -/** Waits until the exact owner session disappears after an accepted stop. */ -const waitForControlSessionEnd = ( - endpoint: ControlEndpoint, - ownershipId: string, - ownerSessionId: string, - stop: Effect.Effect< - void, - | ControlTransportError - | ControlProtocolError - | ControlStopConflictError - | ControlMaintenanceBusyError - >, - read: Effect.Effect< - ControlOwnerStatus, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - >, - timeout: Duration.Input, -): Effect.Effect< - ControlSessionEnd, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - | ControlMaintenanceBusyError -> => - Effect.gen(function* () { - const lastState = yield* Ref.make(undefined); - const observe = read.pipe( - Effect.flatMap((current) => { - if (current.ownershipId !== ownershipId || current.ownerSessionId !== ownerSessionId) { - return Effect.succeed({ _tag: "replaced" } as const); - } - if (!isControlSupervisorStatus(current)) { - return Effect.succeed({ _tag: "replaced" } as const); - } - return Ref.set(lastState, current.state).pipe( - Effect.andThen(Effect.fail(new ControlStopPending({ state: current.state }))), - ); - }), - Effect.catchTags({ - ControlTransportError: (error) => - error.reason === "unreachable" - ? Effect.succeed({ _tag: "ended" } as const) - : Ref.get(lastState).pipe( - Effect.flatMap((state) => - Effect.fail(new ControlStopPending({ state: state ?? "stopping" })), - ), - ), - // A valid owner for another identity can claim this candidate after the - // captured session releases it. That proves the captured session ended. - ControlAddressConflictError: () => Effect.succeed({ _tag: "replaced" } as const), - // Once the captured listener has closed, an unrelated listener may bind - // the same endpoint before this observer runs. A malformed response or - // a different control protocol therefore proves that the old session is - // gone just like a foreign owner response does. - ControlProtocolError: () => Effect.succeed({ _tag: "replaced" } as const), - ControlProtocolMismatchError: () => Effect.succeed({ _tag: "replaced" } as const), - }), - ); - const retry = observe.pipe( - Effect.retry({ - schedule: Schedule.spaced("25 millis"), - while: (error) => Predicate.isTagged(error, "ControlStopPending"), - }), - ); - const transaction = stop.pipe( - Effect.catchTags({ - ControlTransportError: () => Effect.void, - ControlStopConflictError: () => Effect.void, - }), - Effect.andThen(retry), - ); - return yield* transaction.pipe( - Effect.timeoutOrElse({ - duration: timeout, - orElse: () => - observe.pipe( - Effect.catchTag("ControlStopPending", ({ state }) => - Effect.succeed({ _tag: "still-live", lastState: state } as const), - ), - ), - }), - Effect.catchTag("ControlStopPending", ({ state }) => - Effect.succeed({ _tag: "still-live", lastState: state } as const), - ), - ); - }); - -export type ControlSessionEnd = - | { readonly _tag: "ended" } - | { readonly _tag: "replaced" } - | { readonly _tag: "still-live"; readonly lastState: ControlOwnerState }; - -/** - * Sends a fenced stop to one already-verified owner session and waits for that - * exact session to disappear. Callers that have only an ownership id should - * re-probe before invoking this helper; the session fence must never be - * refreshed after the stop request is accepted. - */ -export const observeControlStopForSession = ( - endpoint: ControlEndpoint, - ownershipId: string, - ownerSessionId: string, - transport: ControlClientTransport, - intent: ControlStopIntent = "explicit", - timeout: Duration.Input = CONTROL_STOP_TIMEOUT, -): Effect.Effect< - ControlSessionEnd, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - | ControlMaintenanceBusyError -> => - waitForControlSessionEnd( - endpoint, - ownershipId, - ownerSessionId, - transport.requestStop(endpoint, { ownershipId, ownerSessionId, intent }), - Effect.suspend(() => readControlOwnerStatus(endpoint, ownershipId, transport.read)), - timeout, - ); - -export const requestControlStopForSession = ( - endpoint: ControlEndpoint, - ownershipId: string, - ownerSessionId: string, - transport: ControlClientTransport, - intent: ControlStopIntent = "explicit", - timeout: Duration.Input = CONTROL_STOP_TIMEOUT, -): Effect.Effect< - void, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - | ControlMaintenanceBusyError - | StopTimeout -> => - observeControlStopForSession( - endpoint, - ownershipId, - ownerSessionId, - transport, - intent, - timeout, - ).pipe( - Effect.flatMap((result) => - Match.valueTags(result, { - ended: () => Effect.void, - replaced: () => Effect.void, - "still-live": ({ lastState }) => - Effect.fail(new StopTimeout({ endpoint: endpoint.url, ownerSessionId, lastState })), - }), - ), - ); - -const decodeOwnerStatus = ( - endpoint: ControlEndpoint, - value: unknown, -): Effect.Effect => { - if (typeof value === "object" && value !== null) { - const observedVersion = - "controlProtocolVersion" in value && typeof value.controlProtocolVersion === "number" - ? value.controlProtocolVersion - : undefined; - const observedProtocol = - "controlProtocol" in value && typeof value.controlProtocol === "string" - ? value.controlProtocol - : undefined; - const hasVersion = "controlProtocolVersion" in value; - const hasProtocol = "controlProtocol" in value; - if ( - (hasVersion && observedVersion !== CONTROL_PROTOCOL_VERSION) || - (hasProtocol && observedProtocol !== CONTROL_PROTOCOL) - ) { - return Effect.fail( - new ControlProtocolMismatchError({ - endpoint, - expectedVersion: CONTROL_PROTOCOL_VERSION, - observedVersion, - expectedProtocol: CONTROL_PROTOCOL, - observedProtocol, - }), - ); - } - } - return Schema.decodeUnknownEffect(ControlOwnerStatusSchema)(value).pipe( - Effect.mapError(() => new ControlProtocolError({ endpoint, cause: value })), - ); -}; - -const defaultStatus = ( - ownershipId: string, - status: ControlSupervisorStatus, -): ControlSupervisorStatus => ({ - ...status, - controlProtocol: CONTROL_PROTOCOL, - controlProtocolVersion: CONTROL_PROTOCOL_VERSION, - ownershipId, -}); - -const maintenanceStatus = ( - ownershipId: string, - operation: ControlMaintenanceOperation, -): ControlOwnerStatus => ({ - controlProtocol: CONTROL_PROTOCOL, - controlProtocolVersion: CONTROL_PROTOCOL_VERSION, - ownershipId, - // Maintenance status is a native protocol descriptor; its session ID is - // generated before the owning Effect is constructed. - // oxlint-disable-next-line effecttsgo/crypto-random-uuid - ownerSessionId: crypto.randomUUID(), - kind: "maintenance", - operation, -}); - -const unavailable = (endpoint: ControlEndpoint, cause: unknown): ControlUnavailableError => - new ControlUnavailableError({ endpoint, cause }); - -export type ControlOwnerReader = ( - endpoint: ControlEndpoint, -) => Effect.Effect; - -/** Reads and verifies one exact owner through a supplied control transport. */ -export const readControlOwnerStatus = ( - endpoint: ControlEndpoint, - ownershipId: string, - read: ControlOwnerReader, -): Effect.Effect< - ControlOwnerStatus, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError -> => - read(endpoint).pipe( - Effect.flatMap((value) => decodeOwnerStatus(endpoint, value)), - Effect.flatMap((status) => - status.ownershipId === ownershipId - ? Effect.succeed(status) - : Effect.fail( - new ControlAddressConflictError({ - endpoint, - cause: new Error( - `Control endpoint is owned by ${status.ownershipId}, not ${ownershipId}`, - ), - }), - ), - ), - ); - -export interface ControlClientShape { - readonly readOwner: ( - endpoint: ControlEndpoint, - ownershipId: string, - ) => Effect.Effect< - ControlOwnerStatus, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - >; - readonly stopSession: ( - endpoint: ControlEndpoint, - ownershipId: string, - ownerSessionId: string, - intent?: ControlStopIntent, - ) => Effect.Effect< - void, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - | ControlMaintenanceBusyError - | StopTimeout - >; -} - -/** Stable owner/session client shared by platform and remote HTTP transports. */ -export const makeControlClient = (transport: ControlClientTransport): ControlClientShape => ({ - readOwner: (endpoint, ownershipId) => - readControlOwnerStatus(endpoint, ownershipId, transport.read), - stopSession: (endpoint, ownershipId, ownerSessionId, intent = "explicit") => - requestControlStopForSession(endpoint, ownershipId, ownerSessionId, transport, intent), -}); - -/** A located owner: its published status and the candidate it bound. */ -export interface ControlProbe { - readonly status: ControlOwnerStatus; - readonly endpoint: ControlEndpoint; -} - -/** Reads an existing owner wherever it bound, without claiming an endpoint. */ -export const probeControl = ( - ownershipId: string, -): Effect.Effect => - Effect.gen(function* () { - const candidates = yield* controlEndpointCandidates(ownershipId); - const transport = yield* ControlTransport; - for (const endpoint of candidates) { - const status = yield* readControlOwnerStatus(endpoint, ownershipId, transport.read).pipe( - Effect.orElseSucceed(() => undefined), - ); - if (status !== undefined) return { status, endpoint }; - } - return undefined; - }); - -const makeAttached = ( - endpoint: ControlEndpoint, - ownershipId: string, - transport: ControlTransportShape, - observedStatus: ControlOwnerStatus, -): ControlAttached => { - const client = makeControlClient(transport); - const ownerStatus = client.readOwner(endpoint, ownershipId); - const requestStop = client.stopSession(endpoint, ownershipId, observedStatus.ownerSessionId); - return { - _tag: "Attached", - ownershipId, - endpoint, - observedStatus, - ownerStatus, - requestStop, - }; -}; - -const attach = ( - endpoint: ControlEndpoint, - ownershipId: string, - transport: ControlTransportShape, -): Effect.Effect< - ControlAttached, - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError -> => - readControlOwnerStatus(endpoint, ownershipId, transport.read).pipe( - Effect.map((status) => makeAttached(endpoint, ownershipId, transport, status)), - ); - -const makeOwned = ( - endpoint: ControlEndpoint, - ownershipId: string, - listener: ControlListener, -): Effect.Effect => { - let closed = false; - const close = Effect.suspend(() => { - if (closed) return Effect.void; - closed = true; - return listener.close; - }); - return Effect.succeed({ - _tag: "Owned", - [controlOwnershipBrand]: true, - ownershipId, - endpoint, - close, - }); -}; - -/** - * Finds the candidate a live owner of `ownershipId` bound, if any. Foreign - * owners, non-Supabase listeners, and free ports are skipped; a protocol - * mismatch fails closed because a newer owner of this very stack may be - * publishing there, and claiming another candidate beside it would split - * ownership across versions. - */ -const scanForOwner = ( - candidates: ReadonlyArray, - ownershipId: string, - transport: ControlTransportShape, -): Effect.Effect => - Effect.gen(function* () { - for (const endpoint of candidates) { - const status = yield* readControlOwnerStatus(endpoint, ownershipId, transport.read).pipe( - Effect.map((status) => status), - Effect.catchTags({ - ControlTransportError: (cause) => - cause.reason === "unreachable" ? Effect.void : Effect.fail(cause), - ControlProtocolError: () => Effect.void, - ControlAddressConflictError: () => Effect.void, - }), - ); - if (status !== undefined) return { endpoint, status }; - } - return undefined; - }); - -const acquireAtCandidates = ( - candidates: ReadonlyArray, - ownershipId: string, - status: ControlOwnerStatus, - transport: ControlTransportShape, - application?: ControlApplication, -): Effect.Effect< - ControlAcquisition, - | ControlBindError - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError, - import("effect/Scope").Scope -> => { - const attempt: Effect.Effect< - ControlAcquisition, - | ControlBindError - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError - | ControlUnavailableError, - import("effect/Scope").Scope - > = Effect.gen(function* () { - // An existing owner may hold any candidate: an earlier occupant can have - // freed a lower port since the owner bound. Attach before claiming one so - // a stack never ends up with two owners on different candidates. The scan - // read doubles as the attach handshake, so an owner is read exactly once. - const ownerEndpoint = yield* scanForOwner(candidates, ownershipId, transport); - if (ownerEndpoint !== undefined) { - return makeAttached(ownerEndpoint.endpoint, ownershipId, transport, ownerEndpoint.status); - } - let pending: ControlUnavailableError | undefined; - let conflict: ControlAddressConflictError | undefined; - for (const endpoint of candidates) { - const bound = yield* transport - .bind( - endpoint, - () => status, - (request) => { - if (request === undefined) return "invalid"; - if (!matchesControlSession(request, status)) { - return "conflict"; - } - if (!isControlSupervisorStatus(status)) return "busy"; - return "accepted"; - }, - application, - ) - .pipe(Effect.result); - if (Result.isSuccess(bound)) { - const owned = yield* makeOwned(endpoint, ownershipId, bound.success); - yield* Effect.addFinalizer(() => owned.close); - return owned; - } - const error = bound.failure; - if (error.reason !== "in-use") return yield* error; - // The address was taken between the scan and the bind: attach if the - // occupant is our owner, retry the walk if it is not serving yet, and - // move to the next candidate if it belongs to someone else. - const attached: ControlAcquisition | undefined = yield* attach( - endpoint, - ownershipId, - transport, - ).pipe( - Effect.map((acquisition): ControlAcquisition | undefined => acquisition), - Effect.catchTags({ - ControlAddressConflictError: (cause) => - Effect.sync(() => { - conflict = cause; - return undefined; - }), - ControlProtocolError: (cause) => - Effect.sync(() => { - conflict = new ControlAddressConflictError({ endpoint, cause }); - return undefined; - }), - ControlTransportError: (cause) => - cause.reason === "unreachable" - ? Effect.sync(() => { - pending = unavailable(endpoint, cause); - return undefined; - }) - : Effect.fail(cause), - }), - ); - if (attached !== undefined) return attached; - if (pending !== undefined) break; - } - if (pending !== undefined) return yield* pending; - return yield* ( - conflict ?? - new ControlAddressConflictError({ - endpoint: candidates[0]!, - cause: new Error("Every control endpoint candidate is occupied"), - }) - ); - }); - - return attempt.pipe( - Effect.retry({ - // Bound by duration, not attempts: an attempt's own reads can each - // consume the 500 ms transport timeout, and a count-based budget would - // stretch a single acquire far past the parent's startup handshake. - schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "1500 millis" })), - while: (error) => Predicate.isTagged(error, "ControlUnavailableError"), - }), - Effect.catchTag("ControlUnavailableError", (error) => - Effect.fail( - new ControlAddressConflictError({ endpoint: error.endpoint, cause: error.cause }), - ), - ), - ); -}; - -/** Acquires a deterministic loopback listener or attaches to its owner. */ -export const acquireControl = ( - input: ControlOwnershipInput, -): Effect.Effect< - ControlAcquisition, - | InvalidControlOwnershipIdError - | ControlBindError - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | ControlAddressConflictError, - ControlTransport | import("effect/Scope").Scope -> => - Effect.gen(function* () { - const candidates = yield* controlEndpointCandidates(input.stackId); - const transport = yield* ControlTransport; - return yield* acquireAtCandidates( - candidates, - input.stackId, - "initialStatus" in input - ? defaultStatus(input.stackId, input.initialStatus) - : maintenanceStatus(input.stackId, input.maintenanceOperation), - transport, - "application" in input ? input.application : undefined, - ); - }); diff --git a/packages/stack/src/managed/document.ts b/packages/stack/src/managed/document.ts deleted file mode 100644 index a0d7997cf2..0000000000 --- a/packages/stack/src/managed/document.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Data, Effect, Schema } from "effect"; -import type { ManagedPortAssignment } from "./model.ts"; -import { PartialVersionManifestSchema } from "../versions.ts"; - -export type ManagedStackDocumentLifecycle = - | "stopped" - | "starting" - | "running" - | "deleting" - | "failed"; - -const managedStackLaunchFields = { - versions: PartialVersionManifestSchema, - excludedServices: Schema.optionalKey(Schema.Array(Schema.String)), - lastNotifiedUpdateFingerprint: Schema.optionalKey(Schema.String), -} as const; - -export const managedStackLaunchUpdateSchema = Schema.Struct(managedStackLaunchFields); -export type ManagedStackLaunchUpdate = Schema.Schema.Type; - -const managedStackLaunchSchema = Schema.Union([ - Schema.Struct({ - mode: Schema.Literal("native"), - ...managedStackLaunchFields, - }), - Schema.Struct({ - mode: Schema.Literal("docker"), - containerRuntime: Schema.Literals(["docker", "podman"] as const), - ...managedStackLaunchFields, - }), -]); - -export type ManagedStackLaunch = Schema.Schema.Type; - -export interface ManagedStackDocument { - readonly format: "supabase-stack"; - readonly formatVersion: 1; - readonly id: string; - readonly identity: { - readonly workspaceId: string; - readonly checkoutId: string; - readonly contextId: string; - readonly localProjectKey: string; - readonly name: string; - }; - readonly workspace: { - readonly kind: "git" | "folder"; - readonly checkoutKind: "primary" | "worktree" | "bare" | "folder"; - readonly path: string; - readonly branch?: string; - }; - readonly ports: ReadonlyArray; - readonly lifecycle: ManagedStackDocumentLifecycle; - /** A durable fence written by the identity-scoped public stop operation. */ - readonly stopIntent?: "explicit"; - readonly runtime?: { - readonly pid: number; - readonly controlEndpoint: string; - readonly protocolVersion: 1; - }; - readonly launch: ManagedStackLaunch; - readonly createdAt: string; - readonly updatedAt: string; -} - -/** Launch request before the supervisor selects a concrete execution mode. */ -export const managedStackLaunchInputSchema = Schema.Struct({ - mode: Schema.optionalKey(Schema.Literals(["native", "docker"] as const)), - ...managedStackLaunchFields, -}); - -export type ManagedStackLaunchInput = Schema.Schema.Type; - -const managedPortAssignmentSchema = Schema.Struct({ - key: Schema.Literals([ - "api.port", - "db.port", - "edge_runtime.inspector_port", - "local_smtp.port", - "local_smtp.smtp_port", - "local_smtp.pop3_port", - "studio.port", - "analytics.port", - "db.pooler.port", - ]), - port: Schema.Finite, - intent: Schema.Literals(["automatic", "exact"]), -}); - -const managedStackDocumentSchema = Schema.Struct({ - format: Schema.Literal("supabase-stack"), - formatVersion: Schema.Literal(1), - id: Schema.String, - identity: Schema.Struct({ - workspaceId: Schema.String, - checkoutId: Schema.String, - contextId: Schema.String, - localProjectKey: Schema.String, - name: Schema.String, - }), - workspace: Schema.Struct({ - kind: Schema.Literals(["git", "folder"]), - checkoutKind: Schema.Literals(["primary", "worktree", "bare", "folder"]), - path: Schema.String, - branch: Schema.optionalKey(Schema.String), - }), - ports: Schema.Array(managedPortAssignmentSchema), - lifecycle: Schema.Literals(["stopped", "starting", "running", "deleting", "failed"]), - stopIntent: Schema.optionalKey(Schema.Literal("explicit")), - runtime: Schema.optionalKey( - Schema.Struct({ - pid: Schema.Finite, - controlEndpoint: Schema.String, - protocolVersion: Schema.Literal(1), - }), - ), - launch: managedStackLaunchSchema, - createdAt: Schema.String, - updatedAt: Schema.String, -}); - -const ManagedStackDocumentSchema = Schema.fromJsonString(managedStackDocumentSchema); - -const hasCorePortAssignments = (document: Pick): boolean => - document.ports.some((assignment) => assignment.key === "api.port") && - document.ports.some((assignment) => assignment.key === "db.port"); - -export class InvalidManagedStackDocumentError extends Data.TaggedError( - "InvalidManagedStackDocumentError", -)<{ - readonly path: string; -}> { - override get message(): string { - return `Managed stack document is invalid: ${this.path}`; - } -} - -export const decodeManagedStackDocument = ( - path: string, - content: string, -): Effect.Effect => - Schema.decodeEffect(ManagedStackDocumentSchema)(content).pipe( - Effect.mapError(() => new InvalidManagedStackDocumentError({ path })), - Effect.flatMap((document) => - hasCorePortAssignments(document) - ? Effect.succeed(document) - : Effect.fail(new InvalidManagedStackDocumentError({ path })), - ), - ); - -export const encodeManagedStackDocument = ( - path: string, - document: ManagedStackDocument, -): Effect.Effect => - Effect.gen(function* () { - if (!hasCorePortAssignments(document)) { - return yield* new InvalidManagedStackDocumentError({ path }); - } - const encoded = yield* Schema.encodeEffect(ManagedStackDocumentSchema)(document).pipe( - Effect.mapError(() => new InvalidManagedStackDocumentError({ path })), - ); - return `${encoded}\n`; - }); diff --git a/packages/stack/src/managed/environment.ts b/packages/stack/src/managed/environment.ts deleted file mode 100644 index b6f506a8d7..0000000000 --- a/packages/stack/src/managed/environment.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { createHash } from "node:crypto"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Git/workspace identity canonicalization needs platform-native path semantics before Effect filesystem access. -import { isAbsolute, relative, sep } from "node:path"; -import { Effect, FileSystem } from "effect"; -import { - canonicalizeManagedWorkspacePathWithFileSystem, - ensureDetachedContextIdentity, - ensureGitCheckoutLocation, - ensureOrdinaryWorkspaceIdentity, - readDetachedContextIdentity, - readGitCheckoutLocation, - readOrdinaryWorkspaceIdentityWithFileSystem, -} from "./identity.ts"; -import { - ensureBranchContextId, - ensureGitCheckoutIdentity, - GitConfigStore, - inspectWorkspace, - readBranchContextId, - readGitCheckoutIdentityWithFileSystem, - type WorkspaceInspection, -} from "./git.ts"; -import { InvalidManagedIdentityError, UnsupportedGitWorkspaceError } from "./model.ts"; - -export interface EnvironmentIdentity { - readonly workspaceId: string; - readonly checkoutId: string; - readonly contextId: string; - readonly localProjectKey: string; -} - -export type RepairReason = "moved" | "duplicate"; - -export interface WorkspaceDescriptor { - readonly kind: "git" | "folder"; - readonly checkoutKind: "primary" | "worktree" | "bare" | "folder"; - /** Canonical Supabase project root used to initiate discovery. */ - readonly path: string; - readonly branch?: string; -} - -export interface RepairUpdate { - readonly kind: "checkout-location"; - readonly from: string; - readonly to: string; -} - -export interface RepairRequest { - readonly reason: RepairReason; - readonly path: string; - readonly expectedPath: string; - readonly identity: EnvironmentIdentity; - readonly updates: ReadonlyArray; -} - -interface WorkspaceDiscoveryBase { - readonly path: string; - readonly workspace: WorkspaceDescriptor; - /** The complete identity, including deterministic values for all workspace claims. */ - readonly identity: EnvironmentIdentity; -} - -type PartialEnvironmentIdentity = { - workspaceId?: string; - checkoutId?: string; - contextId?: string; -}; - -export interface ReadyWorkspaceDiscovery extends WorkspaceDiscoveryBase { - readonly state: "ready"; -} - -export interface RepairWorkspaceDiscovery extends WorkspaceDiscoveryBase { - readonly state: "needsRepair"; - readonly reason: RepairReason; - readonly repair: RepairRequest; -} - -export type WorkspaceDiscovery = ReadyWorkspaceDiscovery | RepairWorkspaceDiscovery; - -type EnvironmentError = InvalidManagedIdentityError | UnsupportedGitWorkspaceError; - -const digest = (value: string): string => createHash("sha256").update(value, "utf8").digest("hex"); - -const deterministicUuid = (seed: string): string => { - const hex = digest(seed); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${( - 8 + - (Number.parseInt(hex.slice(16, 17), 16) % 4) - ).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`; -}; - -const identityFactory = (seed: string): (() => string) => { - let temporary = 0; - return () => deterministicUuid(`${seed}:${temporary++}`); -}; - -const lengthPrefixed = (value: string): Buffer => { - const bytes = Buffer.from(value, "utf8"); - const prefix = Buffer.allocUnsafe(4); - prefix.writeUInt32BE(bytes.byteLength, 0); - return Buffer.concat([prefix, bytes]); -}; - -/** Derive the stack identity from five UTF-8 length-prefixed values. */ -export const deriveStackId = (identity: EnvironmentIdentity, name: string): string => - createHash("sha256") - .update( - Buffer.concat([ - lengthPrefixed(identity.workspaceId), - lengthPrefixed(identity.checkoutId), - lengthPrefixed(identity.contextId), - lengthPrefixed(identity.localProjectKey), - lengthPrefixed(name), - ]), - ) - .digest("hex"); - -const descriptor = (inspection: WorkspaceInspection): WorkspaceDescriptor => { - if (inspection.kind === "ordinary-folder") { - return { kind: "folder", checkoutKind: "folder", path: inspection.canonicalPath }; - } - return { - kind: "git", - checkoutKind: - inspection.checkoutKind === "primary" - ? "primary" - : inspection.checkoutKind === "bare-worktree" - ? "bare" - : "worktree", - path: inspection.canonicalPath, - ...(inspection.head.kind === "detached" ? {} : { branch: inspection.head.branch }), - }; -}; - -const partialIdentity = ( - inspection: WorkspaceInspection, - values: PartialEnvironmentIdentity, - localProjectKey: string, -): EnvironmentIdentity => { - if (inspection.kind === "ordinary-folder") { - const seed = `folder:${inspection.canonicalPath}`; - return { - workspaceId: values.workspaceId ?? deterministicUuid(`${seed}:workspaceId`), - checkoutId: values.checkoutId ?? deterministicUuid(`${seed}:checkoutId`), - contextId: values.contextId ?? deterministicUuid(`${seed}:contextId`), - localProjectKey, - }; - } - return { - workspaceId: - values.workspaceId ?? deterministicUuid(`git:workspace:${inspection.commonDirectory}`), - checkoutId: values.checkoutId ?? deterministicUuid(`git:checkout:${inspection.gitDirectory}`), - contextId: - values.contextId ?? - deterministicUuid( - inspection.head.kind === "detached" - ? `git:detached:${inspection.gitDirectory}` - : `git:branch:${inspection.commonDirectory}:${inspection.head.branch}`, - ), - localProjectKey, - }; -}; - -const localProjectKeyFor = ( - inspection: WorkspaceInspection, - canonicalPath: string, -): Effect.Effect => { - if (inspection.kind === "ordinary-folder") return Effect.succeed("."); - const key = relative(inspection.workspaceRoot, canonicalPath).split(sep).join("/"); - if (isAbsolute(key) || key === ".." || key.startsWith("../")) { - return Effect.fail( - new InvalidManagedIdentityError({ - message: `Supabase project path escapes its Git checkout: ${canonicalPath}`, - }), - ); - } - return Effect.succeed(key.length === 0 ? "." : key); -}; - -const makeRepairDiscovery = ( - path: string, - workspace: WorkspaceDescriptor, - identity: EnvironmentIdentity, - reason: RepairReason, - repair: RepairRequest, -): RepairWorkspaceDiscovery => ({ - state: "needsRepair", - reason, - repair, - path, - workspace, - identity, -}); - -const makeRegistrationDiscovery = ( - path: string, - workspace: WorkspaceDescriptor, - identity: EnvironmentIdentity, -): ReadyWorkspaceDiscovery => ({ state: "ready", path, workspace, identity }); - -const discoverInternal = ( - workspacePath: string, -): Effect.Effect => - Effect.gen(function* () { - const canonicalPath = yield* canonicalizeManagedWorkspacePathWithFileSystem(workspacePath); - const inspection = yield* inspectWorkspace(canonicalPath); - const localProjectKey = yield* localProjectKeyFor(inspection, canonicalPath); - const values: PartialEnvironmentIdentity = {}; - let location: string | undefined; - if (inspection.kind === "ordinary-folder") { - const marker = yield* readOrdinaryWorkspaceIdentityWithFileSystem(canonicalPath); - if (marker !== undefined) { - values.workspaceId = marker.workspaceId; - values.checkoutId = marker.checkoutId; - values.contextId = marker.contextId; - } - } else { - const stored = yield* readGitCheckoutIdentityWithFileSystem(inspection); - values.workspaceId = stored.workspaceId; - values.checkoutId = stored.checkoutId; - if (inspection.head.kind === "detached") { - values.contextId = yield* readDetachedContextIdentity(inspection.gitDirectory); - } else { - values.contextId = yield* readBranchContextId(inspection, inspection.head.branch); - } - location = yield* readGitCheckoutLocation(inspection.gitDirectory); - } - const identity = partialIdentity(inspection, values, localProjectKey); - const workspace = descriptor(inspection); - if ( - inspection.kind === "git-checkout" && - location !== undefined && - location !== inspection.workspaceRoot - ) { - const fs = yield* FileSystem.FileSystem; - const duplicate = yield* fs.exists(location).pipe( - Effect.catchTag("PlatformError", (error) => - Effect.fail( - new UnsupportedGitWorkspaceError({ - path: location, - reason: `Checkout location is inaccessible (${error.message})`, - workspaceCause: "metadata-inaccessible", - }), - ), - ), - ); - const reason: RepairReason = duplicate ? "duplicate" : "moved"; - const repair: RepairRequest = { - reason, - path: inspection.workspaceRoot, - expectedPath: location, - identity, - updates: [{ kind: "checkout-location", from: location, to: inspection.workspaceRoot }], - }; - return makeRepairDiscovery(canonicalPath, workspace, identity, reason, repair); - } - return makeRegistrationDiscovery(canonicalPath, workspace, identity); - }); - -export const discoverEnvironment = (workspacePath: string) => discoverInternal(workspacePath); - -export const ensureEnvironment = ( - workspacePath: string, -): Effect.Effect => - Effect.gen(function* () { - const before = yield* discoverInternal(workspacePath); - if (before.state === "needsRepair") return before; - const inspection = yield* inspectWorkspace(before.path); - if (inspection.kind === "ordinary-folder") { - const seed = `folder:${inspection.canonicalPath}`; - const factory = identityFactory(seed); - yield* ensureOrdinaryWorkspaceIdentity(inspection.canonicalPath, factory); - } else { - const seed = `git:${inspection.commonDirectory}:${inspection.gitDirectory}`; - const factory = identityFactory(seed); - yield* ensureGitCheckoutIdentity(inspection, factory); - if (inspection.head.kind === "detached") { - yield* ensureDetachedContextIdentity(inspection.gitDirectory, factory); - } else { - yield* ensureBranchContextId(inspection, inspection.head.branch, factory); - } - yield* ensureGitCheckoutLocation(inspection.gitDirectory, inspection.workspaceRoot); - } - return yield* discoverInternal(before.path); - }); - -export const validateEnvironmentRepair = ( - request: RepairRequest, -): Effect.Effect => - Effect.gen(function* () { - const current = yield* discoverInternal(request.path); - if (request.reason === "duplicate") { - return yield* new InvalidManagedIdentityError({ - message: "Duplicate checkout evidence requires an explicit ownership decision", - }); - } - const currentUpdates = current.state === "needsRepair" ? current.repair.updates : []; - const requestedUpdates = request.updates; - const currentUpdate = currentUpdates[0]; - const requestedUpdate = requestedUpdates[0]; - const updatesMatch = - currentUpdates.length === 1 && - requestedUpdates.length === 1 && - currentUpdate !== undefined && - requestedUpdate !== undefined && - requestedUpdate.kind === currentUpdate.kind && - requestedUpdate.from === currentUpdate.from && - requestedUpdate.to === currentUpdate.to; - if ( - current.state !== "needsRepair" || - current.reason !== request.reason || - current.repair.path !== request.path || - current.repair.expectedPath !== request.expectedPath || - current.identity.workspaceId !== request.identity.workspaceId || - current.identity.checkoutId !== request.identity.checkoutId || - current.identity.contextId !== request.identity.contextId || - !updatesMatch - ) { - return yield* new InvalidManagedIdentityError({ - message: "Workspace identity changed before repair", - }); - } - return current.repair; - }); diff --git a/packages/stack/src/managed/failure.ts b/packages/stack/src/managed/failure.ts deleted file mode 100644 index 073b427cc2..0000000000 --- a/packages/stack/src/managed/failure.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Effect } from "effect"; - -/** A stable human-readable rendering for failures crossing process boundaries. */ -export const causeMessage = (cause: unknown): string => { - if (cause instanceof Error && cause.message.length > 0) return cause.message; - try { - const serialized = JSON.stringify(cause); - return serialized === undefined ? String(cause) : serialized; - } catch { - return String(cause); - } -}; - -/** - * Narrows an effect's error channel to the one failure class a protocol reports, - * turning everything else into a defect. - * - * This is the asynchronous counterpart of {@link failsWith}. A protocol's own - * refusals are the only ones its callers can act on; the filesystem errors - * around them — an unreadable workspace, a full disk — are defects, and - * inventing a protocol failure for them would hide what actually went wrong. - * - * The sorting happens after the effect fails rather than inside a `tryPromise` - * `catch` handler: `Effect.try` turns a throwing handler into a defect. Effects - * that need this recovery classify their own failures before reaching it. - */ -export const failsOnlyWith = - (failure: abstract new (...args: never[]) => E) => - (effect: Effect.Effect): Effect.Effect => - Effect.catch(effect, (error) => - error instanceof failure ? Effect.fail(error) : Effect.die(error), - ); diff --git a/packages/stack/src/managed/git-identity.ts b/packages/stack/src/managed/git-identity.ts deleted file mode 100644 index 22082ca6e5..0000000000 --- a/packages/stack/src/managed/git-identity.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Effect, Schema } from "effect"; -import { validateManagedUuid } from "./ids.ts"; -import { - GIT_CHECKOUT_IDENTITY_VERSION, - InvalidManagedIdentityError, - type GitCheckoutIdentity, -} from "./model.ts"; - -const gitCheckoutIdentitySchema = Schema.fromJsonString( - Schema.Struct({ - version: Schema.Literal(GIT_CHECKOUT_IDENTITY_VERSION), - checkoutId: Schema.String, - }), -); - -/** Internal versioned encoder shared by every Git checkout marker write path. */ -export const encodeGitCheckoutIdentity = ( - identity: GitCheckoutIdentity, -): Effect.Effect => - Schema.encodeEffect(gitCheckoutIdentitySchema)(identity).pipe( - Effect.mapError( - (error) => - new InvalidManagedIdentityError({ - message: `The git checkout identity is invalid: ${String(error)}`, - }), - ), - ); - -/** Internal versioned decoder shared by every Git checkout marker read path. */ -export const decodeGitCheckoutIdentity = ( - content: string, -): Effect.Effect => - Schema.decodeEffect(gitCheckoutIdentitySchema)(content).pipe( - Effect.mapError( - (error) => - new InvalidManagedIdentityError({ - message: `The git checkout identity is invalid: ${String(error)}`, - }), - ), - Effect.flatMap(({ version, checkoutId }) => - validateManagedUuid(checkoutId, "checkoutId").pipe( - Effect.map((validated) => ({ version, checkoutId: validated })), - ), - ), - ); diff --git a/packages/stack/src/managed/git.integration.test.ts b/packages/stack/src/managed/git.integration.test.ts deleted file mode 100644 index 0ed9942a14..0000000000 --- a/packages/stack/src/managed/git.integration.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- Git integration tests invoke native repository commands. - -import { BunFileSystem } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; -import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { afterEach } from "vitest"; -import { - git, - makeBareRepository, - makeDirectory, - makeRepository, - temporaryRoots, -} from "../../tests/helpers/git-workspace.ts"; -import { - ensureGitCheckoutIdentity, - gitConfigStoreLayer, - inspectWorkspace, - type GitCheckoutInspection, -} from "./git.ts"; -import { ensureOrdinaryWorkspaceIdentity } from "./identity.ts"; -import { InvalidManagedIdentityError, UnsupportedGitWorkspaceError } from "./model.ts"; - -const { makeRoot, removeAll } = temporaryRoots("managed-git-test-"); -const gitLayer = Layer.mergeAll(BunFileSystem.layer, gitConfigStoreLayer); - -afterEach(removeAll); - -const inspectCheckout = ( - path: string, -): Effect.Effect => - Effect.flatMap(inspectWorkspace(path), (inspection) => - inspection.kind === "git-checkout" - ? Effect.succeed(inspection) - : Effect.die(new Error(`${path} was classified as ${inspection.kind}`)), - ); - -describe("managed Git workspace identity", () => { - it.live("distinguishes an ordinary folder from a nested primary checkout", () => - Effect.gen(function* () { - const root = makeRoot(); - const folder = makeDirectory(root, "folder"); - const repository = makeRepository(root); - const nested = makeDirectory(repository, join("src", "nested")); - - expect(yield* inspectWorkspace(folder)).toMatchObject({ kind: "ordinary-folder" }); - const checkout = yield* inspectCheckout(nested); - expect(checkout).toMatchObject({ - checkoutKind: "primary", - workspaceRoot: repository, - head: { kind: "branch", branch: "main" }, - }); - }).pipe(Effect.provide(gitLayer)), - ); - - it.live("reports detached HEAD without inventing a branch context", () => - Effect.gen(function* () { - const repository = makeRepository(makeRoot()); - const commit = git(repository, "rev-parse", "HEAD").trim(); - git(repository, "checkout", "-q", commit); - - expect((yield* inspectCheckout(repository)).head).toEqual({ kind: "detached", commit }); - }).pipe(Effect.provide(gitLayer)), - ); - - it.live("fails loudly for dangling metadata and Git-internal paths", () => - Effect.gen(function* () { - const root = makeRoot(); - const dangling = makeDirectory(root, "dangling"); - writeFileSync(join(dangling, ".git"), `gitdir: ${join(root, "missing")}\n`); - const repository = makeRepository(root); - makeBareRepository(root, repository); - - for (const path of [dangling, join(repository, ".git"), join(root, "bare.git")]) { - const failure = yield* Effect.flip(inspectWorkspace(path)); - expect(failure).toBeInstanceOf(UnsupportedGitWorkspaceError); - } - }).pipe(Effect.provide(gitLayer)), - ); - - it.live("rejects a repository configured for reftable refs", () => - Effect.gen(function* () { - const repository = makeRepository(makeRoot()); - git(repository, "config", "extensions.refStorage", "reftable"); - - const failure = yield* Effect.flip(inspectWorkspace(repository)); - expect(failure).toBeInstanceOf(UnsupportedGitWorkspaceError); - expect(failure).toMatchObject({ workspaceCause: "reftable" }); - }).pipe(Effect.provide(gitLayer)), - ); - - it.live("parses quoted plain config values and ignores subsections", () => - Effect.gen(function* () { - const repository = makeRepository(makeRoot()); - const configPath = join(repository, ".git", "config"); - writeFileSync( - configPath, - `${readFileSync(configPath, "utf8")} -[extensions "unrelated"] - refStorage = reftable -[extensions] - refStorage = "files" -[core "unrelated"] - bare = true -[core] - bare = "false" -`, - ); - const checkout = yield* inspectCheckout(repository); - expect(checkout.checkoutKind).toBe("primary"); - }).pipe(Effect.provide(gitLayer)), - ); - - it.live("gives sibling worktrees one project and independent checkout identities", () => - Effect.gen(function* () { - const root = makeRoot(); - const repository = makeRepository(root); - const firstPath = join(root, "first"); - const secondPath = join(root, "second"); - git(repository, "worktree", "add", "-q", firstPath, "-b", "feature/first"); - git(repository, "worktree", "add", "-q", secondPath, "-b", "feature/second"); - - const [primary, first, second] = yield* Effect.all([ - inspectCheckout(repository), - inspectCheckout(firstPath), - inspectCheckout(secondPath), - ]); - const identities = yield* Effect.all( - [primary, first, second].map((checkout) => ensureGitCheckoutIdentity(checkout)), - { concurrency: "unbounded" }, - ); - - expect(new Set(identities.map((identity) => identity.workspaceId)).size).toBe(1); - expect(new Set(identities.map((identity) => identity.checkoutId)).size).toBe(3); - expect(first.checkoutKind).toBe("linked-worktree"); - expect(second.checkoutKind).toBe("linked-worktree"); - const thirdPath = join(root, "third"); - git(repository, "worktree", "add", "-q", thirdPath, "-b", "feature/third"); - const third = yield* inspectCheckout(thirdPath); - const raced = yield* Effect.all( - [ensureGitCheckoutIdentity(third), ensureGitCheckoutIdentity(third)], - { concurrency: "unbounded" }, - ); - expect(new Set(raced.map((identity) => identity.checkoutId)).size).toBe(1); - expect(raced.filter((identity) => identity.checkoutIdentityCreated)).toHaveLength(1); - }).pipe(Effect.provide(gitLayer)), - ); - - it.live("cleans an ordinary identity temp after interruption races exclusive open", () => - Effect.gen(function* () { - const root = makeRoot(); - const workspace = makeDirectory(root, "workspace"); - const markerPath = join(workspace, ".supabase", "identity.json"); - let tempOpenBlocked = false; - const layer = Layer.effect( - FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, (fs) => ({ - ...fs, - open: (path, options) => { - if ( - !tempOpenBlocked && - path.startsWith(`${markerPath}.tmp.`) && - options?.flag === "wx" - ) { - tempOpenBlocked = true; - return fs - .open(path, options) - .pipe(Effect.flatMap((file) => Effect.interrupt.pipe(Effect.as(file)))); - } - return fs.open(path, options); - }, - })), - ).pipe(Layer.provide(BunFileSystem.layer)); - - const interrupted = yield* ensureOrdinaryWorkspaceIdentity(workspace).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(interrupted)).toBe(true); - - const fs = yield* FileSystem.FileSystem.pipe(Effect.provide(layer)); - expect(yield* fs.exists(markerPath)).toBe(false); - expect(yield* fs.readDirectory(join(workspace, ".supabase"))).toEqual([]); - - const retry = yield* ensureOrdinaryWorkspaceIdentity(workspace).pipe(Effect.provide(layer)); - expect(retry.created).toBe(true); - }), - ); - - it.live("fails with a typed error when hard-link publication is unsupported", () => - Effect.gen(function* () { - const root = makeRoot(); - const repository = makeRepository(root); - const checkout = yield* inspectCheckout(repository).pipe(Effect.provide(gitLayer)); - const markerPath = join(checkout.gitDirectory, "supabase-checkout.json"); - const layer = Layer.effect( - FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, (fs) => ({ - ...fs, - link: () => - Effect.fail( - PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "link", - cause: Object.assign(new Error("hard links unavailable"), { code: "EPERM" }), - }), - ), - })), - ); - const providedLayer = Layer.mergeAll( - layer.pipe(Layer.provide(BunFileSystem.layer)), - gitConfigStoreLayer, - ); - - const failure = yield* Effect.flip( - ensureGitCheckoutIdentity(checkout).pipe(Effect.provide(providedLayer)), - ); - expect(failure).toBeInstanceOf(UnsupportedGitWorkspaceError); - const fs = yield* FileSystem.FileSystem.pipe(Effect.provide(providedLayer)); - expect(yield* fs.exists(markerPath)).toBe(false); - expect( - (yield* fs.readDirectory(checkout.gitDirectory)).filter((entry) => - entry.startsWith("supabase-checkout.json.tmp."), - ), - ).toEqual([]); - - const workspace = makeDirectory(root, "ordinary"); - const ordinaryFailure = yield* Effect.flip( - ensureOrdinaryWorkspaceIdentity(workspace).pipe(Effect.provide(providedLayer)), - ); - expect(ordinaryFailure).toBeInstanceOf(InvalidManagedIdentityError); - expect(ordinaryFailure.message).toContain("hard links"); - const ordinaryMetadata = join(workspace, ".supabase"); - expect(yield* fs.exists(join(ordinaryMetadata, "identity.json"))).toBe(false); - expect(yield* fs.readDirectory(ordinaryMetadata)).toEqual([]); - }), - ); -}); diff --git a/packages/stack/src/managed/git.ts b/packages/stack/src/managed/git.ts deleted file mode 100644 index 91cd8e4c73..0000000000 --- a/packages/stack/src/managed/git.ts +++ /dev/null @@ -1,1058 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Git config requires the native child-process callback and platform path topology. -import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Git topology resolution uses platform-native path semantics. -import { dirname, isAbsolute, join, resolve } from "node:path"; -import { - Context, - Duration, - Effect, - FileSystem, - Layer, - Predicate, - Schedule, - type PlatformError, -} from "effect"; -import { claimFileAtomically } from "./atomic-claim.ts"; -import { failsOnlyWith } from "./failure.ts"; -import { createManagedUuidEffect, validateManagedUuid } from "./ids.ts"; -import { ensureGitCheckoutLocation, readGitCheckoutLocation } from "./identity.ts"; -import { decodeGitCheckoutIdentity, encodeGitCheckoutIdentity } from "./git-identity.ts"; -import { - GIT_CHECKOUT_IDENTITY_VERSION, - InvalidManagedIdentityError, - UnsupportedGitWorkspaceError, - type GitCheckoutIdentity, - type UnsupportedGitWorkspaceCause, -} from "./model.ts"; -import { gitCheckoutIdentityPath, gitConfigPath, gitWorktreeConfigPath } from "./paths.ts"; - -const BRANCH_REF_PREFIX = "refs/heads/"; -const COMMON_DIRECTORY_FILE = "commondir"; -const GIT_ENTRY = ".git"; -const GIT_DIRECTORY_LINK_PREFIX = "gitdir:"; -const HEAD_FILE = "HEAD"; -const PACKED_REFS_FILE = "packed-refs"; -const SYMBOLIC_REF_PREFIX = "ref: "; - -/** SHA-1 and SHA-256 object ids, the two hashes a `HEAD` can be detached at. */ -const OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; - -/** - * Entries every git directory has. A workspace that has them at its own top - * level *is* a git directory — a bare repository, or a `.git` somebody changed - * into — rather than a checkout that contains one. - */ -const GIT_DIRECTORY_ENTRIES = [HEAD_FILE, "objects", "refs"] as const; - -/** The local workspace identity, shared by every checkout of the same repository. */ -export const GIT_WORKSPACE_ID_KEY = "supabase.workspaceId"; - -/** - * The branch context identity, keyed by branch so that git owns its lifecycle: - * `git branch -m` renames the whole `branch.` section and deleting a - * branch deletes it, so a context needs no rename or reaper of its own. - * - * Subsection names are case-sensitive and may contain `/` and `.`; git splits a - * key at its first and last dot, so a branch name never has to be escaped. - */ -export const gitBranchContextIdKey = (branch: string): string => - `branch.${branch}.supabaseContextId`; - -export type GitCheckoutKind = "bare-worktree" | "linked-worktree" | "primary"; - -export type GitHead = - | { readonly kind: "branch"; readonly branch: string } - | { readonly kind: "detached"; readonly commit: string } - | { readonly kind: "unborn"; readonly branch: string }; - -export interface OrdinaryFolderInspection { - readonly kind: "ordinary-folder"; - readonly canonicalPath: string; -} - -export interface GitCheckoutInspection { - readonly kind: "git-checkout"; - readonly checkoutKind: GitCheckoutKind; - readonly canonicalPath: string; - /** The checkout's top-level working directory: the one holding its `.git`. */ - readonly workspaceRoot: string; - /** This checkout's own git directory, where its checkout identity lives. */ - readonly gitDirectory: string; - /** The repository directory shared with every linked worktree. */ - readonly commonDirectory: string; - readonly head: GitHead; -} - -export type WorkspaceInspection = GitCheckoutInspection | OrdinaryFolderInspection; - -const failsWithGitWorkspace = failsOnlyWith(UnsupportedGitWorkspaceError); -const failsWithIdentity = ( - effect: Effect.Effect, -): Effect.Effect => - Effect.catch(effect, (error) => { - if ( - error instanceof InvalidManagedIdentityError || - error instanceof UnsupportedGitWorkspaceError - ) { - return Effect.fail(error); - } - return Effect.die(error); - }); - -const unsupported = ( - path: string, - reason: string, - workspaceCause: UnsupportedGitWorkspaceCause, -): Effect.Effect => - Effect.fail(new UnsupportedGitWorkspaceError({ path, reason, workspaceCause })); - -const platformErrorPath = (error: PlatformError.PlatformError): string | undefined => - Predicate.isTagged(error.reason, "BadArgument") || - typeof error.reason.pathOrDescriptor !== "string" - ? undefined - : error.reason.pathOrDescriptor; - -const inaccessiblePlatformError = ( - fallbackPath: string, - error: PlatformError.PlatformError, -): Effect.Effect => { - const detail = error.reason.description === undefined ? error.message : error.reason.description; - return unsupported( - platformErrorPath(error) ?? fallbackPath, - `Git metadata is inaccessible: ${detail}`, - "metadata-inaccessible", - ); -}; - -/** - * A file git may or may not have written. Absence is an answer throughout git's - * metadata — an unborn branch has no ref file, a primary checkout has no - * `commondir` — so it is reported as `undefined` rather than as a failure. - */ -const readOptionalFile = ( - fs: FileSystem.FileSystem, - path: string, -): Effect.Effect => - Effect.catch(fs.readFileString(path), (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.void.pipe(Effect.as(undefined)) - : Effect.fail(error), - ); - -const realPathOrMalformed = ( - fs: FileSystem.FileSystem, - path: string, - reason: string, -): Effect.Effect => - Effect.catch( - fs.realPath(path), - (error): Effect.Effect => - Predicate.isTagged(error.reason, "NotFound") - ? unsupported(path, reason, "malformed-metadata") - : Effect.fail(error), - ); - -const firstLine = (content: string): string | undefined => - content - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0); - -/** - * The git directory a `.git` file points at. Its target may be absolute or - * relative to the directory holding the `.git` file. - */ -const linkedGitDirectory = (workspaceRoot: string, content: string): string | undefined => { - const line = firstLine(content); - if (line === undefined || !line.startsWith(GIT_DIRECTORY_LINK_PREFIX)) { - return undefined; - } - const target = line.slice(GIT_DIRECTORY_LINK_PREFIX.length).trim(); - if (target.length === 0) { - return undefined; - } - return isAbsolute(target) ? resolve(target) : resolve(workspaceRoot, target); -}; - -const looksLikeGitDirectory = ( - fs: FileSystem.FileSystem, - directory: string, -): Effect.Effect => - Effect.map( - Effect.all(GIT_DIRECTORY_ENTRIES.map((entry) => fs.exists(join(directory, entry)))), - (found) => found.every((present) => present), - ); - -interface GitCheckoutRoot { - readonly workspaceRoot: string; - readonly gitDirectory: string; -} - -/** - * Walks up from `canonicalPath` to the checkout that encloses it, or reports - * that nothing does. - * - * The walk is deliberate rather than delegated to `git rev-parse`: discovery - * happens before anything is claimed and must be able to run without the git - * binary, and reading the metadata is the whole of it. - */ -const locateGitCheckoutRoot = ( - fs: FileSystem.FileSystem, - canonicalPath: string, -): Effect.Effect< - GitCheckoutRoot | undefined, - PlatformError.PlatformError | UnsupportedGitWorkspaceError -> => - Effect.gen(function* () { - let directory = canonicalPath; - for (;;) { - const entry = join(directory, GIT_ENTRY); - if (yield* fs.exists(entry)) { - const info = yield* fs.stat(entry); - if (info.type === "Directory") { - return { workspaceRoot: directory, gitDirectory: entry }; - } - const linked = linkedGitDirectory(directory, yield* fs.readFileString(entry)); - if (linked === undefined) { - return yield* unsupported( - entry, - "Cannot read the git directory this .git file points at", - "malformed-metadata", - ); - } - return { workspaceRoot: directory, gitDirectory: linked }; - } - if (yield* looksLikeGitDirectory(fs, directory)) { - return yield* unsupported( - directory, - "Refusing to inspect a git directory as a workspace", - "inside-git-directory", - ); - } - const parent = dirname(directory); - if (parent === directory) { - return undefined; - } - directory = parent; - } - }); - -const branchRefExists = ( - fs: FileSystem.FileSystem, - commonDirectory: string, - ref: string, -): Effect.Effect => - Effect.gen(function* () { - if (yield* fs.exists(join(commonDirectory, ...ref.split("/")))) { - return true; - } - const packed = yield* readOptionalFile(fs, join(commonDirectory, PACKED_REFS_FILE)); - return packed === undefined - ? false - : packed.split("\n").some((line) => line.trim().split(" ")[1] === ref); - }); - -/** - * A reftable repository's refs live in `reftable/`, and the `HEAD` it keeps for - * compatibility is a stub naming this branch — a name git's own ref format - * forbids, so no real branch can collide with it. - * - * Such a repository is refused rather than read: reporting it as an unborn branch - * would give every branch and every detached `HEAD` in it one bogus - * `branch..invalid` context, and reading the actual refs means implementing - * another format. - */ -const REFTABLE_HEAD_STUB_BRANCH = ".invalid"; -const REFTABLE_UNSUPPORTED_REASON = - "Refusing a repository whose refs are stored in a reftable, which is not supported yet"; - -const resolveHead = ( - fs: FileSystem.FileSystem, - gitDirectory: string, - commonDirectory: string, -): Effect.Effect => - Effect.gen(function* () { - const head = (yield* readOptionalFile(fs, join(gitDirectory, HEAD_FILE)))?.trim(); - if (head === undefined || head.length === 0) { - return yield* unsupported( - gitDirectory, - "The checkout has no readable HEAD", - "malformed-metadata", - ); - } - if (head.startsWith(SYMBOLIC_REF_PREFIX)) { - const ref = head.slice(SYMBOLIC_REF_PREFIX.length).trim(); - // Only the prefix is stripped: a branch name may itself contain `/`. - const branch = ref.startsWith(BRANCH_REF_PREFIX) - ? ref.slice(BRANCH_REF_PREFIX.length) - : undefined; - if (branch === undefined || branch.length === 0) { - return yield* unsupported( - gitDirectory, - `HEAD does not name a branch under ${ref}`, - "malformed-metadata", - ); - } - // The compat stub of a reftable repository, which is refused by its - // `extensions.refStorage` before this runs. Checked again here because the - // stub is the tripwire that cannot be missed: a repository declaring the - // extension some other way must still never reach a `.invalid` context. - if (branch === REFTABLE_HEAD_STUB_BRANCH) { - return yield* unsupported(gitDirectory, REFTABLE_UNSUPPORTED_REASON, "reftable"); - } - if ( - branch.includes("\\") || - branch - .split("/") - .some((segment) => segment.length === 0 || segment === "." || segment === "..") - ) { - return yield* unsupported( - gitDirectory, - "HEAD names a branch with an invalid path segment", - "malformed-metadata", - ); - } - // A branch whose ref does not exist yet is the state a fresh repository - // starts in, so it names a context even though it has no commit. - return (yield* branchRefExists(fs, commonDirectory, ref)) - ? { kind: "branch", branch } - : { kind: "unborn", branch }; - } - if (OBJECT_ID_PATTERN.test(head)) { - return { kind: "detached", commit: head }; - } - return yield* unsupported( - gitDirectory, - "HEAD is neither a branch nor an object id", - "malformed-metadata", - ); - }); - -const CONFIG_SECTION_PATTERN = /^\s*\[\s*([\w.-]+)\s*(\]?)/; -const CORE_BARE_PATTERN = /^\s*bare\s*(?:=(.*))?\s*(?:[;#].*)?$/i; -const REF_STORAGE_PATTERN = /^\s*refStorage\s*(?:=(.*))?\s*(?:[;#].*)?$/i; -const WORKTREE_CONFIG_PATTERN = /^\s*worktreeConfig\s*(?:=(.*))?\s*(?:[;#].*)?$/i; -const TRUTHY_CONFIG_VALUES: ReadonlySet = new Set(["1", "on", "true", "yes"]); -const REFTABLE_REF_STORAGE = "reftable"; - -/** - * Strips a value's surrounding double quotes, git config's only quoting form for - * the keys read here, with a minimal unescape of `\"` and `\\` inside — not a - * full parser, just enough for the bare-word-or-fully-quoted values these keys - * actually take. - */ -const unquoteConfigValue = (value: string): string => - value.length >= 2 && value.startsWith('"') && value.endsWith('"') - ? value.slice(1, -1).replace(/\\(["\\])/g, "$1") - : value; - -/** - * The value `key` holds in `[section]`, or `undefined` when the section does not - * set it. - * - * These are the only config reads on the inspection path, so they stay a line - * scan: a full config parser would be a liability, and reaching for the git - * binary here would make read-only discovery depend on it. Every key read this - * way is one git normally writes itself, as a bare word on its own line — but a - * hand-edited config may quote the value (`bare = "true"`), which git's own - * reader still honors, so this scan unquotes a fully-quoted value before - * comparing it. Comment-stripping still runs first, ahead of unquoting: a quoted - * value containing `;` or `#` would be mishandled, which is pathological for - * every key read here since git itself never writes one that way. - * - * The two rules that decide which line answers are git's own: `[section "sub"]` - * is a different section, whose keys say nothing about the plain one, and a key - * a repository sets more than once is worth whatever it was set to last. - */ -const configValue = (content: string, section: string, key: RegExp): string | undefined => { - let inSection = false; - let value: string | undefined; - for (const line of content.split("\n")) { - const heading = CONFIG_SECTION_PATTERN.exec(line); - if (heading !== null) { - // Section names are case-insensitive, and only a heading that closes right - // after the name is the plain section: anything else — a subsection, or - // another section entirely — ends the one being read. - inSection = heading[2] === "]" && heading[1]?.toLowerCase() === section; - continue; - } - const match = inSection ? key.exec(line) : null; - if (match !== null) { - // A variable with no equals sign is `true` in git's config syntax. An - // explicit empty assignment (`bare =`, including a trailing comment) is - // different and means false for boolean keys. - const raw = match[1]; - value = raw === undefined ? "true" : unquoteConfigValue(raw.split(/[;#]/)[0]?.trim() ?? ""); - } - } - return value; -}; - -const isTruthyConfigValue = (value: string | undefined): boolean => - value !== undefined && TRUTHY_CONFIG_VALUES.has(value.toLowerCase()); - -/** What the shared repository's config says about the repository as a whole. */ -interface RepositoryConfig { - /** - * Whether the repository is bare, which is what separates a worktree of a bare - * repository from a worktree of an ordinary one. - */ - readonly bare: boolean; - /** Whether its refs are stored in a reftable rather than as ref files. */ - readonly reftable: boolean; -} - -const readRepositoryConfig = ( - fs: FileSystem.FileSystem, - commonDirectory: string, -): Effect.Effect => - Effect.gen(function* () { - const shared = (yield* readOptionalFile(fs, gitConfigPath(commonDirectory))) ?? ""; - const reftable = - configValue(shared, "extensions", REF_STORAGE_PATTERN)?.toLowerCase() === - REFTABLE_REF_STORAGE; - const declaredBare = configValue(shared, "core", CORE_BARE_PATTERN); - if (declaredBare !== undefined) { - return { bare: isTruthyConfigValue(declaredBare), reftable }; - } - // A repository with `extensions.worktreeConfig` enabled keeps `core.bare` in - // the worktree config instead — git moves it there itself, and enables the - // extension on its own for `git sparse-checkout set` — so an absent - // `core.bare` here is not an answer yet. The extension has to be enabled for - // that file to mean anything, and the one beside the shared config is the - // repository's own worktree. - if (!isTruthyConfigValue(configValue(shared, "extensions", WORKTREE_CONFIG_PATTERN))) { - return { bare: false, reftable }; - } - const worktree = yield* readOptionalFile(fs, gitWorktreeConfigPath(commonDirectory)); - return { - bare: - worktree !== undefined && - isTruthyConfigValue(configValue(worktree, "core", CORE_BARE_PATTERN)), - reftable, - }; - }); - -/** - * Classifies `workspacePath` and resolves the git topology around it, writing - * nothing. - * - * Every mutating operation starts here, and a `status`-style read must reach the - * same answer without claiming anything, so this must stay free of side effects: - * identities are ensured separately, by the operations that are allowed to. - */ -export const inspectWorkspace = ( - workspacePath: string, -): Effect.Effect => - failsWithGitWorkspace( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const canonicalPath = yield* fs.realPath(workspacePath); - const checkout = yield* locateGitCheckoutRoot(fs, canonicalPath); - if (checkout === undefined) { - const ordinary: WorkspaceInspection = { kind: "ordinary-folder", canonicalPath }; - return ordinary; - } - - const gitDirectory = yield* realPathOrMalformed( - fs, - checkout.gitDirectory, - "Cannot read the git directory this .git file points at", - ); - // A linked worktree's git directory names the repository it belongs to, - // relative to itself; a primary checkout's git directory *is* that - // repository and has no `commondir` at all. - const commonLink = (yield* readOptionalFile( - fs, - join(gitDirectory, COMMON_DIRECTORY_FILE), - ))?.trim(); - let commonDirectory: string; - if (commonLink === undefined) { - commonDirectory = gitDirectory; - } else if (commonLink.length === 0) { - return yield* unsupported( - join(gitDirectory, COMMON_DIRECTORY_FILE), - "The linked worktree has an empty commondir target", - "malformed-metadata", - ); - } else { - commonDirectory = yield* realPathOrMalformed( - fs, - isAbsolute(commonLink) ? commonLink : resolve(gitDirectory, commonLink), - "Cannot read the common git directory this commondir file points at", - ); - } - - const repositoryConfig = yield* readRepositoryConfig(fs, commonDirectory); - if (repositoryConfig.reftable) { - return yield* unsupported(commonDirectory, REFTABLE_UNSUPPORTED_REASON, "reftable"); - } - - const checkoutKind: GitCheckoutKind = - gitDirectory === commonDirectory - ? "primary" - : repositoryConfig.bare - ? "bare-worktree" - : "linked-worktree"; - - const inspection: WorkspaceInspection = { - kind: "git-checkout", - checkoutKind, - canonicalPath, - workspaceRoot: checkout.workspaceRoot, - gitDirectory, - commonDirectory, - head: yield* resolveHead(fs, gitDirectory, commonDirectory), - }; - return inspection; - }).pipe( - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "BadArgument") - ? Effect.die(error) - : inaccessiblePlatformError(workspacePath, error), - ), - ), - ); - -export interface GitConfigStoreShape { - /** Every value stored at `key`, in file order; empty when it is not set. */ - readonly getAll: ( - file: string, - key: string, - ) => Effect.Effect, UnsupportedGitWorkspaceError, FileSystem.FileSystem>; - /** Read-only regexp query returning matching keys and values in file order. */ - readonly getRegexp: ( - file: string, - regexp: string, - ) => Effect.Effect< - ReadonlyArray<{ readonly key: string; readonly value: string }>, - UnsupportedGitWorkspaceError, - FileSystem.FileSystem - >; - /** Appends a value to `key`, replacing nothing. */ - readonly add: ( - file: string, - key: string, - value: string, - ) => Effect.Effect; - /** Collapses `key` to exactly `value`. */ - readonly replace: ( - file: string, - key: string, - value: string, - ) => Effect.Effect; -} - -/** - * @effect-expect-leaking FileSystem - * - * The one reader and writer of git config in the managed layer. - * - * Writes go through the git binary rather than through this package's own - * serializer: a hand-written config risks corrupting a user's repository, and - * git's lock file around the whole read-modify-write is what makes two processes - * claiming at once safe. Reads go through it too, because a value may be quoted - * or continued across lines and only git decides what it means. - */ -export class GitConfigStore extends Context.Service()( - "stack/managed/GitConfigStore", -) {} - -/** `git config --get-all` exits 1 for a key that is not set, which is an answer. */ -const MISSING_KEY_EXIT_CODE = 1; - -type GitConfigResult = - | { readonly kind: "answered"; readonly stdout: string } - | { readonly kind: "unset" } - | { - readonly kind: "failed"; - readonly detail: string; - readonly status?: number; - readonly lockFilePresent: boolean; - }; - -const runGitConfig = ( - args: ReadonlyArray, - tolerateUnset: boolean, - file: string, -): Effect.Effect => - Effect.gen(function* () { - const result = yield* Effect.callback((resume) => { - let settled = false; - let child: ReturnType | undefined; - const settle = (value: GitConfigResult) => { - if (settled) return; - settled = true; - resume(Effect.succeed(value)); - }; - const onComplete = ( - error: (Error & { readonly code?: number | string }) | null, - stdout: string, - stderr: string, - ) => { - if (error === null) { - settle({ kind: "answered", stdout }); - return; - } - const exitCode = error.code; - if (tolerateUnset && exitCode === MISSING_KEY_EXIT_CODE && stderr.trim().length === 0) { - settle({ kind: "unset" }); - return; - } - const detail = - stderr.trim().length > 0 - ? stderr.trim() - : typeof exitCode === "number" - ? `git config exited with status ${exitCode}` - : `git config could not be spawned (${String(exitCode)})`; - settle({ - kind: "failed", - detail, - status: typeof exitCode === "number" ? exitCode : undefined, - lockFilePresent: false, - }); - }; - try { - child = execFile("git", ["config", ...args], { encoding: "utf8" }, onComplete); - } catch (cause) { - settle({ - kind: "failed", - detail: `git config could not be spawned (${String(cause)})`, - lockFilePresent: false, - }); - } - return Effect.sync(() => { - if (!settled) { - settled = true; - try { - child?.kill("SIGTERM"); - } catch { - // The process may have exited between interruption and cleanup. - } - } - }); - }); - if (result.kind !== "failed" || result.status === undefined) return result; - const fs = yield* FileSystem.FileSystem; - return { - ...result, - lockFilePresent: yield* fs - .exists(`${file}.lock`) - .pipe(Effect.catchTag("PlatformError", () => Effect.succeed(false))), - }; - }); - -const gitLockRetrySchedule = () => - Schedule.exponential(Duration.millis(10)).pipe(Schedule.upTo({ duration: Duration.millis(400) })); - -/** - * `git config` does not wait for another process' config lock — it refuses - * immediately — so waiting is this store's job. Every claim in a repository with - * sibling worktrees contends for that lock, so a refusal to wait would make - * concurrent starts fail rather than serialize. Retry is bounded and only - * triggered by concrete lock evidence or the two ambiguous git statuses above. - */ -const gitConfig = ( - args: ReadonlyArray, - tolerateUnset: boolean, - file: string, -): Effect.Effect => - Effect.gen(function* () { - const attempt = Effect.flatMap(runGitConfig(args, tolerateUnset, file), (value) => - value.kind === "failed" && - value.status !== undefined && - (value.lockFilePresent || value.status === 4 || value.status === 255) - ? Effect.fail(value) - : Effect.succeed(value), - ); - const result = yield* Effect.retry(attempt, { - while: () => true, - schedule: gitLockRetrySchedule(), - }).pipe( - Effect.catch((error: GitConfigResult) => - unsupported( - file, - `Git config is inaccessible (${error.kind === "failed" ? error.detail : "retry exhausted"})`, - "metadata-inaccessible", - ), - ), - ); - if (result.kind === "answered") return result.stdout; - if (result.kind === "unset") return undefined; - if (result.status === 128) { - return yield* unsupported( - file, - `Git config is malformed (${result.detail})`, - "malformed-metadata", - ); - } - return yield* unsupported( - file, - `Git config is inaccessible (${result.detail})`, - "metadata-inaccessible", - ); - }); - -/** A write refuses rather than tolerating an exit status of its own. */ -const gitConfigWrite = ( - args: ReadonlyArray, - file: string, -): Effect.Effect => - Effect.asVoid(gitConfig(args, false, file)); - -export const gitConfigStoreLayer: Layer.Layer = Layer.succeed(GitConfigStore, { - getAll: (file, key) => - Effect.map(gitConfig(["--file", file, "--get-all", key], true, file), (stdout) => - (stdout ?? "") - .split("\n") - .map((value) => value.trim()) - .filter((value) => value.length > 0), - ), - getRegexp: (file, regexp) => - Effect.map( - gitConfig(["--file", file, "--null", "--get-regexp", regexp], true, file), - (stdout) => - (stdout ?? "").split("\0").flatMap((record) => { - const separator = record.indexOf("\n"); - if (separator <= 0) return []; - return [{ key: record.slice(0, separator), value: record.slice(separator + 1) }]; - }), - ), - add: (file, key, value) => gitConfigWrite(["--file", file, "--add", key, value], file), - replace: (file, key, value) => - gitConfigWrite(["--file", file, "--replace-all", key, value], file), -}); - -const requireUuid = ( - value: string, - label: string, -): Effect.Effect => validateManagedUuid(value, label); - -const mintUuid = ( - idFactory: () => string, - label: string, -): Effect.Effect => createManagedUuidEffect(idFactory, label); - -/** - * The value a config-stored identity has settled on. - * - * The first value wins, because that is the only choice that cannot change: git - * appends, so a later claim lands after the first one and never displaces it. - * Reading and claiming therefore agree even while a raced key is still - * multi-valued. - */ -const settledValue = (values: ReadonlyArray): string | undefined => values[0]; - -const readConfigId = ( - file: string, - key: string, - label: string, -): Effect.Effect< - string | undefined, - InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore | FileSystem.FileSystem -> => - Effect.gen(function* () { - const store = yield* GitConfigStore; - const value = settledValue(yield* store.getAll(file, key)); - return value === undefined ? undefined : yield* requireUuid(value, label); - }); - -/** - * Claims a config-stored identity, adopting whichever value the repository - * settled on. - * - * Git config has no compare-and-swap, so a claimant cannot publish - * conditionally, and overwriting would lose a competing claim that a sibling - * worktree has already reported. It appends instead — git's config lock - * serializes appends and drops none of them — and then re-reads and adopts the - * first value, which every claimant that appended sees. Two worktrees starting at - * the same moment therefore agree on one project rather than splitting into two. - * - * A raced key is left holding more than one value, and whoever observes that - * collapses it to the value everybody already agreed on. The collapse is - * idempotent and preserves the first value, so it is safe to run against a - * repository another claimant is still appending to, and a claimant that died - * mid-race leaves nothing worse than a key the next one tidies up. It is - * therefore hygiene rather than part of the claim, and a failed collapse does not - * fail the claim it was tidying up after. - */ -const ensureConfigId = ( - file: string, - key: string, - label: string, - idFactory: () => string, -): Effect.Effect< - string, - InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore | FileSystem.FileSystem -> => - Effect.gen(function* () { - const store = yield* GitConfigStore; - const existing = yield* store.getAll(file, key); - const values = yield* Effect.gen(function* () { - if (existing.length > 0) { - return existing; - } - yield* store.add(file, key, yield* mintUuid(idFactory, label)); - return yield* store.getAll(file, key); - }); - - const settled = settledValue(values); - if (settled === undefined) { - return yield* new InvalidManagedIdentityError({ - message: `${key} was claimed but is not set`, - }); - } - const id = yield* requireUuid(settled, label); - if (values.length > 1) { - yield* Effect.catchDefect( - store.replace(file, key, id).pipe(Effect.ignore), - () => Effect.void, - ); - } - return id; - }); - -interface CheckoutIdentityClaim { - readonly checkoutId: string; - /** Whether this call published the marker, rather than adopting a winner's. */ - readonly created: boolean; -} - -const readCheckoutIdentity = ( - gitDirectory: string, -): Effect.Effect< - GitCheckoutIdentity | undefined, - InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - FileSystem.FileSystem -> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const content = yield* fs.readFileString(gitCheckoutIdentityPath(gitDirectory)).pipe( - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.void.pipe(Effect.as(undefined)) - : Effect.fail( - new UnsupportedGitWorkspaceError({ - path: gitCheckoutIdentityPath(gitDirectory), - reason: `Git checkout identity is inaccessible (${error.message})`, - workspaceCause: "metadata-inaccessible", - }), - ), - ), - ); - return content === undefined ? undefined : yield* decodeGitCheckoutIdentity(content); - }); - -const ensureCheckoutIdentity = ( - gitDirectory: string, - idFactory: () => string, -): Effect.Effect< - CheckoutIdentityClaim, - InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - FileSystem.FileSystem -> => - Effect.gen(function* () { - const existing = yield* readCheckoutIdentity(gitDirectory); - if (existing !== undefined) return { checkoutId: existing.checkoutId, created: false }; - const markerPath = gitCheckoutIdentityPath(gitDirectory); - - const identity: GitCheckoutIdentity = { - version: GIT_CHECKOUT_IDENTITY_VERSION, - checkoutId: yield* mintUuid(idFactory, "checkoutId"), - }; - const content = yield* encodeGitCheckoutIdentity(identity); - const outcome = yield* claimFileAtomically(markerPath, `${content}\n`, { mode: 0o600 }).pipe( - Effect.catchTags({ - AtomicClaimUnsupportedError: (error) => - Effect.fail( - new UnsupportedGitWorkspaceError({ - path: markerPath, - reason: error.message, - workspaceCause: "metadata-inaccessible", - }), - ), - PlatformError: (error) => - Effect.fail( - new UnsupportedGitWorkspaceError({ - path: gitCheckoutIdentityPath(gitDirectory), - reason: `Git checkout identity is inaccessible (${error.message})`, - workspaceCause: "metadata-inaccessible", - }), - ), - }), - ); - if (outcome === "claimed") { - return { checkoutId: identity.checkoutId, created: true }; - } - - const winner = yield* readCheckoutIdentity(gitDirectory); - if (winner === undefined) { - return yield* new InvalidManagedIdentityError({ - message: "Checkout identity publication raced without a winning marker", - }); - } - return { checkoutId: winner.checkoutId, created: false }; - }); - -export interface EnsureGitCheckoutIdentityResult { - readonly workspaceId: string; - readonly checkoutId: string; - /** The common git directory, whose config holds the workspace identity. */ - readonly workspaceIdentityLocation: string; - /** This checkout's git directory, which holds its checkout identity. */ - readonly checkoutIdentityLocation: string; - /** - * Whether this call published the checkout identity marker, as opposed to - * adopting one a racing sibling had already claimed. Exactly one caller - * racing on the same git directory sees `true`. - */ - readonly checkoutIdentityCreated: boolean; -} - -export interface GitCheckoutIdentityState { - readonly workspaceId: string | undefined; - readonly checkoutId: string | undefined; - /** Last canonical workspace path recorded for explicit move detection. */ - readonly workspacePath: string | undefined; - readonly workspaceIdentityLocation: string; - readonly checkoutIdentityLocation: string; -} - -/** - * The workspace and checkout identities of a git checkout, minting whichever is - * missing. - * - * The two are stored apart because git's own rules are what keep them correct. - * The workspace identity is repository-local config, so every linked worktree — - * including a bare repository's — reads the same one, and `git clone` copies none - * of it, which is what makes a fresh clone a new local workspace. The checkout identity - * is a file in the checkout's own git directory, which is per-worktree by - * construction. - */ -export const ensureGitCheckoutIdentity = ( - inspection: GitCheckoutInspection, - idFactory: () => string = randomUUID, -): Effect.Effect< - EnsureGitCheckoutIdentityResult, - InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore | FileSystem.FileSystem -> => - failsWithIdentity( - Effect.gen(function* () { - const workspaceId = yield* ensureConfigId( - gitConfigPath(inspection.commonDirectory), - GIT_WORKSPACE_ID_KEY, - "workspaceId", - idFactory, - ); - const checkoutClaim = yield* ensureCheckoutIdentity(inspection.gitDirectory, idFactory); - yield* ensureGitCheckoutLocation(inspection.gitDirectory, inspection.workspaceRoot); - return { - workspaceId, - checkoutId: checkoutClaim.checkoutId, - workspaceIdentityLocation: inspection.commonDirectory, - checkoutIdentityLocation: inspection.gitDirectory, - checkoutIdentityCreated: checkoutClaim.created, - }; - }), - ); - -/** Read-only checkout marker probe through Effect FileSystem. */ -export const readGitCheckoutIdentityWithFileSystem = ( - inspection: GitCheckoutInspection, -): Effect.Effect< - GitCheckoutIdentityState, - InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore | FileSystem.FileSystem -> => - failsWithIdentity( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workspaceId = yield* readConfigId( - gitConfigPath(inspection.commonDirectory), - GIT_WORKSPACE_ID_KEY, - "workspaceId", - ); - const content = yield* fs - .readFileString(gitCheckoutIdentityPath(inspection.gitDirectory)) - .pipe( - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.void.pipe(Effect.as(undefined)) - : Effect.fail( - new UnsupportedGitWorkspaceError({ - path: gitCheckoutIdentityPath(inspection.gitDirectory), - reason: `Git checkout identity is inaccessible (${error.message})`, - workspaceCause: "metadata-inaccessible", - }), - ), - ), - ); - const identity = - content === undefined ? undefined : yield* decodeGitCheckoutIdentity(content); - const workspacePath = yield* readGitCheckoutLocation(inspection.gitDirectory); - return { - workspaceId, - checkoutId: identity?.checkoutId, - workspacePath, - workspaceIdentityLocation: inspection.commonDirectory, - checkoutIdentityLocation: inspection.gitDirectory, - }; - }), - ); - -const requireBranch = (branch: string): Effect.Effect => - branch.trim().length === 0 - ? Effect.fail( - new InvalidManagedIdentityError({ message: "A branch context requires a branch name" }), - ) - : Effect.succeed(branch); - -/** - * The context identity every checkout on `branch` shares, minting it if the - * branch does not have one yet. - * - * It is stored in the shared repository config, so two worktrees on the same - * branch resolve the same context while keeping their own checkout identities. - */ -export const ensureBranchContextId = ( - inspection: GitCheckoutInspection, - branch: string, - idFactory: () => string = randomUUID, -): Effect.Effect< - string, - InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore | FileSystem.FileSystem -> => - failsWithIdentity( - Effect.flatMap(requireBranch(branch), (name) => - ensureConfigId( - gitConfigPath(inspection.commonDirectory), - gitBranchContextIdKey(name), - "contextId", - idFactory, - ), - ), - ); - -/** {@link ensureBranchContextId} without the claim: absent stays absent. */ -export const readBranchContextId = ( - inspection: GitCheckoutInspection, - branch: string, -): Effect.Effect< - string | undefined, - InvalidManagedIdentityError | UnsupportedGitWorkspaceError, - GitConfigStore | FileSystem.FileSystem -> => - failsWithIdentity( - Effect.flatMap(requireBranch(branch), (name) => - readConfigId( - gitConfigPath(inspection.commonDirectory), - gitBranchContextIdKey(name), - "contextId", - ), - ), - ); diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts deleted file mode 100644 index f3837a1a28..0000000000 --- a/packages/stack/src/managed/identity.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { randomUUID } from "node:crypto"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Marker parent paths are derived synchronously before native filesystem publication. -import { dirname } from "node:path"; -import { Effect, FileSystem, PlatformError, Predicate, Schema } from "effect"; -import { claimFileAtomically, type FileClaimOutcome } from "./atomic-claim.ts"; -import { - InvalidManagedIdentityError, - ORDINARY_WORKSPACE_IDENTITY_VERSION, - type OrdinaryWorkspaceIdentity, -} from "./model.ts"; -import { createManagedUuidEffect, validateManagedUuid } from "./ids.ts"; -import { failsOnlyWith } from "./failure.ts"; -import { - gitCheckoutLocationPath, - gitDetachedContextIdentityPath, - ordinaryWorkspaceIdentityPath, -} from "./paths.ts"; -import type { ControlOwnership } from "./control.ts"; - -const failsWithIdentity = failsOnlyWith(InvalidManagedIdentityError); - -const ordinaryWorkspaceIdentitySchema = Schema.fromJsonString( - Schema.Struct({ - version: Schema.Literal(ORDINARY_WORKSPACE_IDENTITY_VERSION), - workspaceId: Schema.String, - checkoutId: Schema.String, - contextId: Schema.String, - }), -); - -const encodeOrdinaryWorkspaceIdentity = ( - identity: OrdinaryWorkspaceIdentity, -): Effect.Effect => - Schema.encodeEffect(ordinaryWorkspaceIdentitySchema)(identity).pipe( - Effect.mapError( - (error) => - new InvalidManagedIdentityError({ - message: `The ordinary workspace identity is invalid: ${String(error)}`, - }), - ), - ); - -const decodeIdentity = ( - content: string, -): Effect.Effect => - Schema.decodeEffect(ordinaryWorkspaceIdentitySchema)(content).pipe( - Effect.mapError( - (error) => - new InvalidManagedIdentityError({ - message: `The ordinary workspace identity is invalid: ${String(error)}`, - }), - ), - Effect.flatMap(({ version, workspaceId, checkoutId, contextId }) => - Effect.all({ - workspaceId: validateManagedUuid(workspaceId, "workspaceId"), - checkoutId: validateManagedUuid(checkoutId, "checkoutId"), - contextId: validateManagedUuid(contextId, "contextId"), - }).pipe(Effect.map((identity) => ({ version, ...identity }))), - ), - ); - -const inaccessibleIdentity = ( - label: string, - error: PlatformError.PlatformError, -): InvalidManagedIdentityError => - new InvalidManagedIdentityError({ message: `${label} is inaccessible (${error.message})` }); - -const claimIdentityFile = ( - path: string, - content: string, - label: string, - mode?: number, -): Effect.Effect => - claimFileAtomically(path, content, { mode }).pipe( - Effect.catchTags({ - AtomicClaimUnsupportedError: (error) => - Effect.fail( - new InvalidManagedIdentityError({ - message: `${label} could not be published at ${path}: ${error.message}. The filesystem must support hard links for managed identity publication.`, - }), - ), - PlatformError: (error) => - Effect.fail( - new InvalidManagedIdentityError({ - message: `${label} could not be published at ${path}: ${error.message}`, - }), - ), - }), - ); - -/** Effect FileSystem variant used by managed discovery. */ -export const canonicalizeManagedWorkspacePathWithFileSystem = ( - workspacePath: string, -): Effect.Effect => - failsWithIdentity( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const info = yield* fs.stat(workspacePath); - if (info.type !== "Directory") { - return yield* new InvalidManagedIdentityError({ - message: `${workspacePath} is not a directory`, - }); - } - return yield* fs.realPath(workspacePath); - }).pipe( - Effect.catchTag("PlatformError", (error: PlatformError.PlatformError) => - Effect.fail( - new InvalidManagedIdentityError({ - message: `Cannot canonicalize ${workspacePath}: ${error.message}`, - }), - ), - ), - ), - ); - -const readIdentity = ( - workspacePath: string, -): Effect.Effect< - OrdinaryWorkspaceIdentity | undefined, - InvalidManagedIdentityError, - FileSystem.FileSystem -> => - failsWithIdentity( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.readFileString(ordinaryWorkspaceIdentityPath(workspacePath)).pipe( - Effect.flatMap((content) => decodeIdentity(content)), - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.void.pipe(Effect.as(undefined)) - : Effect.fail(inaccessibleIdentity("Ordinary workspace identity", error)), - ), - ); - }), - ); - -/** Read-only marker probe through Effect FileSystem; absence remains undefined. */ -export const readOrdinaryWorkspaceIdentityWithFileSystem = readIdentity; - -export interface EnsureOrdinaryWorkspaceIdentityResult { - readonly identity: OrdinaryWorkspaceIdentity; - readonly created: boolean; - readonly markerPath: string; -} - -export const ensureOrdinaryWorkspaceIdentity = ( - workspacePath: string, - idFactory: () => string = randomUUID, -): Effect.Effect< - EnsureOrdinaryWorkspaceIdentityResult, - InvalidManagedIdentityError, - FileSystem.FileSystem -> => - failsWithIdentity( - Effect.gen(function* () { - const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); - const existing = yield* readIdentity(workspacePath); - if (existing !== undefined) return { identity: existing, created: false, markerPath }; - - const identity: OrdinaryWorkspaceIdentity = { - version: ORDINARY_WORKSPACE_IDENTITY_VERSION, - workspaceId: yield* createManagedUuidEffect(idFactory, "workspaceId"), - checkoutId: yield* createManagedUuidEffect(idFactory, "checkoutId"), - contextId: yield* createManagedUuidEffect(idFactory, "contextId"), - }; - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(dirname(markerPath), { recursive: true }); - const content = yield* encodeOrdinaryWorkspaceIdentity(identity); - const outcome = yield* claimIdentityFile( - markerPath, - `${content}\n`, - "Ordinary workspace identity", - 0o600, - ); - if (outcome === "claimed") return { identity, created: true, markerPath }; - - const winner = yield* readIdentity(workspacePath); - if (winner === undefined) { - return yield* new InvalidManagedIdentityError({ - message: "Identity publication raced without a winning marker", - }); - } - return { identity: winner, created: false, markerPath }; - }), - ); - -const DETACHED_CONTEXT_VERSION = 1; - -const detachedContextIdentitySchema = Schema.fromJsonString( - Schema.Struct({ - version: Schema.Literal(DETACHED_CONTEXT_VERSION), - contextId: Schema.String, - }), -); - -const encodeDetachedContextIdentity = (identity: { - readonly version: typeof DETACHED_CONTEXT_VERSION; - readonly contextId: string; -}): Effect.Effect => - Schema.encodeEffect(detachedContextIdentitySchema)(identity).pipe( - Effect.mapError( - (error) => - new InvalidManagedIdentityError({ - message: `The detached context identity is invalid: ${String(error)}`, - }), - ), - ); - -const decodeDetachedContextId = ( - content: string, -): Effect.Effect => - Schema.decodeEffect(detachedContextIdentitySchema)(content).pipe( - Effect.mapError( - (error) => - new InvalidManagedIdentityError({ - message: `The detached context identity is invalid: ${String(error)}`, - }), - ), - Effect.flatMap(({ contextId }) => validateManagedUuid(contextId, "contextId")), - ); - -const readDetachedContextId = ( - gitDirectory: string, -): Effect.Effect => - failsWithIdentity( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.readFileString(gitDetachedContextIdentityPath(gitDirectory)).pipe( - Effect.flatMap((content) => decodeDetachedContextId(content)), - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.void.pipe(Effect.as(undefined)) - : Effect.fail(inaccessibleIdentity("Detached context identity", error)), - ), - ); - }), - ); - -export const readDetachedContextIdentity = readDetachedContextId; - -export const ensureDetachedContextIdentity = ( - gitDirectory: string, - idFactory: () => string = randomUUID, -): Effect.Effect< - { readonly contextId: string; readonly created: boolean }, - InvalidManagedIdentityError, - FileSystem.FileSystem -> => - failsWithIdentity( - Effect.gen(function* () { - const existing = yield* readDetachedContextId(gitDirectory); - if (existing !== undefined) return { contextId: existing, created: false }; - const contextId = yield* createManagedUuidEffect(idFactory, "contextId"); - const markerPath = gitDetachedContextIdentityPath(gitDirectory); - const content = yield* encodeDetachedContextIdentity({ - version: DETACHED_CONTEXT_VERSION, - contextId, - }); - const outcome = yield* claimIdentityFile( - markerPath, - `${content}\n`, - "Detached context identity", - 0o600, - ); - if (outcome === "claimed") return { contextId, created: true }; - const winner = yield* readDetachedContextId(gitDirectory); - if (winner === undefined) { - return yield* new InvalidManagedIdentityError({ - message: "Detached context publication raced without a winning marker", - }); - } - return { contextId: winner, created: false }; - }), - ); - -const checkoutLocationSchema = Schema.fromJsonString( - Schema.Struct({ - version: Schema.Literal(1), - workspacePath: Schema.String, - }), -); - -const encodeCheckoutLocation = (location: { - readonly version: 1; - readonly workspacePath: string; -}): Effect.Effect => - Schema.encodeEffect(checkoutLocationSchema)(location).pipe( - Effect.mapError( - (error) => - new InvalidManagedIdentityError({ - message: `The git checkout location is invalid: ${String(error)}`, - }), - ), - ); - -const decodeLocation = (content: string): Effect.Effect => - Schema.decodeEffect(checkoutLocationSchema)(content).pipe( - Effect.mapError( - (error) => - new InvalidManagedIdentityError({ - message: `The git checkout location is invalid: ${String(error)}`, - }), - ), - Effect.map(({ workspacePath }) => workspacePath), - ); - -export const readGitCheckoutLocation = ( - gitDirectory: string, -): Effect.Effect => - failsWithIdentity( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.readFileString(gitCheckoutLocationPath(gitDirectory)).pipe( - Effect.flatMap((content) => decodeLocation(content)), - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.void.pipe(Effect.as(undefined)) - : Effect.fail(inaccessibleIdentity("Git checkout location", error)), - ), - ); - }), - ); - -const removeTemporary = (fs: FileSystem.FileSystem, path: string): Effect.Effect => - fs.remove(path, { force: true }).pipe(Effect.ignore); - -const writeTemporary = ( - fs: FileSystem.FileSystem, - path: string, - content: string, -): Effect.Effect => - Effect.scoped( - fs - .open(path, { flag: "wx", mode: 0o600 }) - .pipe(Effect.flatMap((file) => file.writeAll(new TextEncoder().encode(content)))), - ); - -export const ensureGitCheckoutLocation = ( - gitDirectory: string, - workspacePath: string, -): Effect.Effect< - { readonly workspacePath: string; readonly created: boolean }, - InvalidManagedIdentityError, - FileSystem.FileSystem -> => - failsWithIdentity( - Effect.gen(function* () { - const existing = yield* readGitCheckoutLocation(gitDirectory); - if (existing !== undefined) return { workspacePath: existing, created: false }; - const markerPath = gitCheckoutLocationPath(gitDirectory); - const content = yield* encodeCheckoutLocation({ version: 1, workspacePath }); - const outcome = yield* claimIdentityFile( - markerPath, - `${content}\n`, - "Git checkout location", - 0o600, - ); - if (outcome === "claimed") return { workspacePath, created: true }; - const winner = yield* readGitCheckoutLocation(gitDirectory); - if (winner === undefined) { - return yield* new InvalidManagedIdentityError({ - message: "Checkout location publication raced without a winning marker", - }); - } - return { workspacePath: winner, created: false }; - }), - ); - -/** Replace a moved checkout marker while holding the opaque repair authority. */ -export const updateGitCheckoutLocationOwned = ( - gitDirectory: string, - expectedPath: string, - workspacePath: string, - ownership: ControlOwnership, -): Effect.Effect< - { readonly workspacePath: string }, - InvalidManagedIdentityError, - FileSystem.FileSystem -> => - failsWithIdentity( - Effect.gen(function* () { - void ownership; - const fs = yield* FileSystem.FileSystem; - const markerPath = gitCheckoutLocationPath(gitDirectory); - const current = yield* readGitCheckoutLocation(gitDirectory); - if (current === undefined || current !== expectedPath) { - return yield* new InvalidManagedIdentityError({ - message: "Git checkout location changed before repair publication", - }); - } - const temporaryPath = `${markerPath}.tmp.${randomUUID()}`; - const content = yield* encodeCheckoutLocation({ version: 1, workspacePath }); - const publication = writeTemporary(fs, temporaryPath, `${content}\n`).pipe( - Effect.andThen(fs.rename(temporaryPath, markerPath)), - ); - yield* Effect.ensuring( - publication, - Effect.uninterruptible(removeTemporary(fs, temporaryPath)), - ); - return { workspacePath }; - }), - ); diff --git a/packages/stack/src/managed/ids.ts b/packages/stack/src/managed/ids.ts deleted file mode 100644 index a4056fa47e..0000000000 --- a/packages/stack/src/managed/ids.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Effect } from "effect"; -import { InvalidManagedIdentityError } from "./model.ts"; - -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -/** Validate an identity without throwing from an Effect program. */ -export const validateManagedUuid = ( - value: unknown, - label: string, -): Effect.Effect => - typeof value === "string" && UUID_PATTERN.test(value) - ? Effect.succeed(value) - : Effect.fail(new InvalidManagedIdentityError({ message: `${label} must be an opaque UUID` })); - -export const createManagedUuidEffect = ( - idFactory: () => string, - label: string, -): Effect.Effect => - Effect.sync(idFactory).pipe(Effect.flatMap((value) => validateManagedUuid(value, label))); diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts deleted file mode 100644 index 1eb4265583..0000000000 --- a/packages/stack/src/managed/lifecycle.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { Data, Effect, Layer, Schedule } from "effect"; -import { NoRunningStackError } from "./model.ts"; -import { RemoteStack, updateRemoteLaunch } from "../RemoteStack.ts"; -import { Stack } from "../Stack.ts"; -import { dockerForceRemove } from "../cleanup.ts"; -import { dockerContainerName } from "../StackIdentity.ts"; -import { SERVICE_NAMES } from "../ServiceCatalog.ts"; -import { HttpTransportClient, HttpTransportClientError } from "../HttpTransportClient.ts"; -import type { ManagedStackDocument, ManagedStackLaunchUpdate } from "./document.ts"; -import { - ManagedStackAttachedError, - ManagedStackManager, - ManagedWorkspaceRepairConflictError, - workspaceRepairConflict, - type ManagedStackManagerError, - type ManagedStackLaunchUpdateRequest, -} from "./manager.ts"; -import { acquireControl, ControlTransport, isControlOwnership } from "./control.ts"; -import { isControlSupervisorStatus } from "../DaemonProtocol.ts"; -import { - DaemonUpgradeRequired, - StackBuildError, - StackRpcProtocolError, - StackRpcTransportError, - StackUnavailableError, -} from "../errors.ts"; -import { - ManagedStackNotStoppedError, - type ManagedPortIntentDocument, - validateManagedStackName, -} from "./model.ts"; -import { deriveStackId } from "./environment.ts"; - -/** Inputs shared by all managed lifecycle operations. */ -export interface ManagedLifecycleInput { - readonly workspacePath: string; - readonly stackName?: string; - readonly cwd?: string; - readonly portDocument?: ManagedPortIntentDocument; - readonly cliVersion?: string; -} - -const emptyPortDocument = (): ManagedPortIntentDocument => ({ - activeFields: [], - document: {}, -}); - -const noRunningStack = (input: ManagedLifecycleInput): NoRunningStackError => - new NoRunningStackError({ cwd: input.cwd ?? input.workspacePath }); - -const stackIdForInput = ( - manager: import("./manager.ts").ManagedStackManagerShape, - input: ManagedLifecycleInput, -): Effect.Effect => - Effect.gen(function* () { - const stackName = yield* validateManagedStackName(input.stackName ?? "default"); - const discovery = yield* manager.discoverWorkspace(input.workspacePath); - if (discovery.state === "needsRepair") { - return yield* workspaceRepairConflict(discovery.reason); - } - return deriveStackId(discovery.identity, stackName); - }); - -/** Resolve one document through the managed manager and Git workspace identity. */ -export const resolveManagedDocument = ( - input: ManagedLifecycleInput, -): Effect.Effect< - ManagedStackDocument, - NoRunningStackError | ManagedStackManagerError, - ManagedStackManager -> => - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const document = yield* manager.readStack({ - workspacePath: input.workspacePath, - ...(input.stackName === undefined ? {} : { stackName: input.stackName }), - portDocument: input.portDocument ?? emptyPortDocument(), - }); - return document === undefined ? yield* noRunningStack(input) : document; - }); - -class ManagedStopPending extends Data.TaggedError("ManagedStopPending")<{}> {} -class ManagedDeletePending extends Data.TaggedError("ManagedDeletePending")<{}> {} - -/** Connect to the control endpoint the managed supervisor actually bound. */ -export const connectManagedStack = ( - input: ManagedLifecycleInput & { readonly cliVersion: string }, -): Effect.Effect< - Layer.Layer, - NoRunningStackError | ManagedStackManagerError | DaemonUpgradeRequired, - ManagedStackManager | HttpTransportClient -> => - Effect.gen(function* () { - const document = yield* resolveManagedDocument(input); - if ( - (document.lifecycle !== "running" && document.lifecycle !== "starting") || - (document.lifecycle === "running" && document.runtime?.controlEndpoint === undefined) - ) { - return yield* noRunningStack(input); - } - const manager = yield* ManagedStackManager; - const probe = yield* manager.probeControl(document.id); - if (probe === undefined) { - return yield* noRunningStack(input); - } - if (!isControlSupervisorStatus(probe.status)) { - return yield* new ManagedStackAttachedError({ stackId: document.id }); - } - if (probe.status.daemonCliVersion !== input.cliVersion) { - return yield* new DaemonUpgradeRequired({ - stackId: document.id, - oldCliVersion: probe.status.daemonCliVersion, - newCliVersion: input.cliVersion, - state: probe.status.state, - ready: probe.status.ready, - }); - } - if (probe.status.state !== "running" || !probe.status.ready) { - return yield* noRunningStack(input); - } - const client = yield* HttpTransportClient; - return RemoteStack.layer(probe.endpoint, { - cliVersion: input.cliVersion, - owner: { - ownershipId: probe.status.ownershipId, - ownerSessionId: probe.status.ownerSessionId, - controlProtocolVersion: probe.status.controlProtocolVersion, - daemonCliVersion: probe.status.daemonCliVersion, - }, - }).pipe(Layer.provide(Layer.succeed(HttpTransportClient, client))); - }); - -/** Ask the owner to stop; the supervisor clears runtime state before exiting. */ -export const stopManagedStack = ( - input: ManagedLifecycleInput, -): Effect.Effect< - void, - NoRunningStackError | ManagedStackManagerError | import("../errors.ts").StopTimeout, - ManagedStackManager | HttpTransportClient -> => - Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const document = yield* resolveManagedDocument(input); - const stackId = document.id; - const revalidatedStackId = yield* stackIdForInput(manager, input); - if (revalidatedStackId !== stackId) { - return yield* new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed before stop", - }); - } - const cleanupOwned = (owned: import("./control.ts").ControlOwnership) => - Effect.ensuring( - Effect.gen(function* () { - const current = yield* manager.inspectStack(stackId); - const containerRuntime = - current?.launch.mode === "docker" ? current.launch.containerRuntime : null; - if (containerRuntime !== null) { - yield* dockerForceRemove( - containerRuntime, - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); - } - if ( - current !== undefined && - current.lifecycle !== "deleting" && - (current.lifecycle !== "stopped" || current.stopIntent !== "explicit") - ) { - yield* manager.recordLifecycle(owned, { - stackId, - lifecycle: "stopped", - stopIntent: "explicit", - }); - } - }), - owned.close, - ); - - /** - * Stop the exact session currently observed, then probe again. A new - * supervisor can bind immediately after the old session disappears; a - * public identity-scoped stop must follow and fence that successor - * rather than returning with a running document. - */ - const stopCurrentOwner = Effect.gen(function* () { - const current = yield* manager.inspectStack(stackId); - if (current === undefined) return; - const acquisition = yield* manager.acquireControl(stackId, "stop"); - const currentStackId = yield* stackIdForInput(manager, input); - if (currentStackId !== stackId) { - return yield* new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed while stopping", - }); - } - if (isControlOwnership(acquisition)) { - yield* cleanupOwned(acquisition); - return; - } - yield* acquisition.requestStop.pipe( - Effect.catchTag("ControlMaintenanceBusyError", () => - Effect.fail(new ManagedStackAttachedError({ stackId })), - ), - ); - return yield* new ManagedStopPending(); - }).pipe( - Effect.retry({ - schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" })), - while: (error) => error instanceof ManagedStopPending, - }), - Effect.catchTag("ManagedStopPending", () => - Effect.fail(new ManagedStackNotStoppedError({ stackId })), - ), - ); - yield* stopCurrentOwner; - }), - ); - -/** Remove a stopped document while holding its deterministic control owner. */ -export const deleteManagedStack = ( - input: ManagedLifecycleInput, -): Effect.Effect< - void, - NoRunningStackError | ManagedStackManagerError, - ManagedStackManager | ControlTransport -> => - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const stackId = yield* stackIdForInput(manager, input); - yield* Effect.scoped( - Effect.gen(function* () { - const acquisition = yield* acquireControl({ - stackId, - maintenanceOperation: "delete", - }).pipe( - Effect.flatMap((candidate) => - isControlOwnership(candidate) - ? Effect.succeed(candidate) - : Effect.fail(new ManagedDeletePending()), - ), - Effect.retry( - Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" })), - ), - Effect.mapError(() => new ManagedStackAttachedError({ stackId })), - ); - const result = yield* Effect.gen(function* () { - const revalidatedStackId = yield* stackIdForInput(manager, input); - if (revalidatedStackId !== stackId) { - return yield* new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed before delete", - }); - } - return yield* manager.deleteStack(stackId, acquisition); - }).pipe(Effect.ensuring(acquisition.close)); - if (result.outcome === "already-absent") return yield* noRunningStack(input); - }), - ); - }); - -/** Persist launch selections in the managed document, owner-gated. */ -export const updateManagedLaunch = ( - input: ManagedLifecycleInput & { - readonly launch: ManagedStackLaunchUpdate; - readonly cliVersion: string; - }, -): Effect.Effect< - ManagedStackDocument, - | NoRunningStackError - | ManagedStackManagerError - | HttpTransportClientError - | DaemonUpgradeRequired - | StackBuildError - | StackUnavailableError - | StackRpcProtocolError - | StackRpcTransportError, - ManagedStackManager | HttpTransportClient -> => - Effect.scoped( - Effect.gen(function* () { - const document = yield* resolveManagedDocument(input); - const manager = yield* ManagedStackManager; - const acquisition = yield* manager.acquireControl(document.id, "update"); - if (!isControlOwnership(acquisition)) { - if (document.lifecycle !== "running" || document.runtime?.controlEndpoint === undefined) { - return yield* new ManagedStackAttachedError({ stackId: document.id }); - } - const status = yield* acquisition.ownerStatus; - if (!isControlSupervisorStatus(status)) { - return yield* new ManagedStackAttachedError({ stackId: document.id }); - } - yield* updateRemoteLaunch( - acquisition.endpoint, - { - cliVersion: input.cliVersion, - owner: { - ownershipId: status.ownershipId, - ownerSessionId: status.ownerSessionId, - controlProtocolVersion: status.controlProtocolVersion, - daemonCliVersion: status.daemonCliVersion, - }, - }, - document.id, - input.launch, - ); - const next = yield* manager.inspectStack(document.id); - if (next === undefined) return yield* noRunningStack(input); - return next; - } - const update: ManagedStackLaunchUpdateRequest = { - stackId: document.id, - launch: input.launch, - }; - return yield* Effect.ensuring(manager.updateLaunch(acquisition, update), acquisition.close); - }), - ); diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts deleted file mode 100644 index 280ea68921..0000000000 --- a/packages/stack/src/managed/manager.ts +++ /dev/null @@ -1,1050 +0,0 @@ -import { createHash } from "node:crypto"; -import { - Context, - Data, - DateTime, - Effect, - Exit, - FileSystem, - Layer, - Path, - PlatformError, - Predicate, - Schedule, - Semaphore, - Scope, -} from "effect"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Managed path validation relies on native path semantics at the filesystem boundary. -import { isAbsolute, relative, resolve } from "node:path"; -import { PORT_CATALOG, type PortSet } from "../PortCatalog.ts"; -import { reservePortSet, type PortLease, type PortReservationRequest } from "../PortAllocator.ts"; -import { - acquireControl, - CONTROL_PORT_RANGE, - controlEndpointCandidates, - ControlTransport, - isControlOwnership, - probeControl, - type ControlAcquisition, - type ControlMaintenanceOperation, - type ControlOwnership, - type ControlProbe, -} from "./control.ts"; -import { - discoverEnvironment, - deriveStackId, - ensureEnvironment, - validateEnvironmentRepair, - type EnvironmentIdentity, - type RepairReason, - type RepairRequest, - type WorkspaceDiscovery, -} from "./environment.ts"; -import { GitConfigStore, inspectWorkspace } from "./git.ts"; -import { - canonicalizeManagedWorkspacePathWithFileSystem, - readOrdinaryWorkspaceIdentityWithFileSystem, - updateGitCheckoutLocationOwned, -} from "./identity.ts"; -import { - ManagedExactPortOccupiedError, - InvalidManagedStackNameError, - InvalidManagedIdentityError, - ManagedPortAllocationError, - ManagedStackNotFoundError, - ManagedStackNotStoppedError, - UnsafeManagedStackPathError, - type ManagedPortAssignment, - type ManagedPortDrift, - type ManagedPortIntentDocument, - validateManagedStackName, -} from "./model.ts"; -import { - managedPortReservationsConflict, - planManagedPorts, - type ManagedPortPlan, -} from "./port-plan.ts"; -import { resolvePortIntents } from "./port-intent.ts"; -import { makeStackStore, type ManagedStackListing } from "./store.ts"; -import type { ManagedStackDocument, ManagedStackLaunchUpdate } from "./document.ts"; -import { dockerForceRemove } from "../cleanup.ts"; -import { SERVICE_NAMES } from "../ServiceCatalog.ts"; -import { dockerContainerName } from "../StackIdentity.ts"; - -/** A document joined with a transient drift report for read-only callers. */ -export interface ManagedStack extends ManagedStackDocument { - readonly drift?: ReadonlyArray; -} - -export interface ReadStackRequest { - readonly workspacePath: string; - readonly stackName?: string; - readonly portDocument: ManagedPortIntentDocument; -} - -export interface StartStackRequest { - readonly workspacePath: string; - readonly stackName?: string; - readonly portDocument: ManagedPortIntentDocument; - /** The supervisor-owned lifecycle capability for this deterministic stack. */ - readonly ownership: ControlOwnership; - readonly lifecycle?: ManagedStackDocument["lifecycle"]; - readonly runtime?: ManagedStackDocument["runtime"]; - readonly launch: ManagedStackDocument["launch"]; - /** Incompatible replacement must retain the target's sticky assignments. */ - readonly preservePersistedPorts?: boolean; -} - -export interface AllocateManagedPortsRequest { - readonly stackId: string; - readonly portDocument: ManagedPortIntentDocument; - readonly persisted?: ReadonlyArray; - /** During an upgrade restart, keep the target's sticky assignments. */ - readonly preservePersisted?: boolean; -} - -export interface ManagedPortAllocation { - readonly assignments: ReadonlyArray; - readonly lease: PortLease; -} - -interface ManagedStackStartResultBase { - readonly stack: ManagedStack; -} - -export type ManagedStackStartResult = ManagedStackStartResultBase & { - /** The lease remains live until the caller's Effect scope closes. */ - readonly lease: PortLease; -}; - -export interface ManagedStackLifecycleUpdate { - readonly stackId: string; - readonly lifecycle: ManagedStackDocument["lifecycle"]; - readonly stopIntent?: "explicit"; - /** A running runtime descriptor, or `null` to clear stale runtime state. */ - readonly runtime?: ManagedStackDocument["runtime"] | null; -} - -export interface ManagedStackLaunchUpdateRequest { - readonly stackId: string; - readonly launch: ManagedStackLaunchUpdate; -} - -export type ManagedPortLease = PortLease; - -export type ManagedDeleteResult = - | { readonly outcome: "removed"; readonly stackId: string } - | { readonly outcome: "already-absent"; readonly stackId: string }; - -export class ManagedStackControlRequiredError extends Data.TaggedError( - "ManagedStackControlRequiredError", -)<{ - readonly stackId: string; -}> { - override get message(): string { - return `Managed stack ${this.stackId} requires its owned control endpoint`; - } -} - -export class ManagedStackAttachedError extends Data.TaggedError("ManagedStackAttachedError")<{ - readonly stackId: string; -}> { - override get message(): string { - return `Managed stack ${this.stackId} is owned by another process`; - } -} - -export class ManagedWorkspaceRepairConflictError extends Data.TaggedError( - "ManagedWorkspaceRepairConflictError", -)<{ - readonly stackId?: string; - readonly repairReason?: RepairReason; - readonly reason: string; -}> { - override get message(): string { - return this.reason; - } -} - -export const workspaceRepairConflict = ( - repairReason: RepairReason, -): ManagedWorkspaceRepairConflictError => - new ManagedWorkspaceRepairConflictError({ - repairReason, - reason: - repairReason === "moved" - ? "Moved checkout requires repair; call repairWorkspace with the discovery repair request" - : "Duplicate checkout requires an explicit ownership decision; repairWorkspace cannot adopt it", - }); - -export type ManagedStackManagerError = - | ManagedStackControlRequiredError - | ManagedStackAttachedError - | ManagedWorkspaceRepairConflictError - | ManagedStackNotFoundError - | ManagedStackNotStoppedError - | ManagedExactPortOccupiedError - | ManagedPortAllocationError - | PlatformError.PlatformError - | import("./document.ts").InvalidManagedStackDocumentError - | import("./model.ts").InvalidManagedIdentityError - | UnsafeManagedStackPathError - | InvalidManagedStackNameError - | import("./model.ts").UnsupportedGitWorkspaceError - | import("./control.ts").InvalidControlOwnershipIdError - | import("./control.ts").ControlBindError - | import("./control.ts").ControlTransportError - | import("./control.ts").ControlProtocolError - | import("./control.ts").ControlProtocolMismatchError - | import("./control.ts").ControlAddressConflictError - | import("./control.ts").ControlMaintenanceBusyError; - -export interface ManagedStackManagerShape { - readonly stateRoot: string; - readonly discoverWorkspace: ( - path: string, - ) => Effect.Effect; - readonly ensureWorkspace: ( - path: string, - ) => Effect.Effect; - readonly acquireControl: ( - stackId: string, - operation: ControlMaintenanceOperation, - ) => Effect.Effect; - readonly probeControl: ( - stackId: string, - ) => Effect.Effect; - readonly readStack: ( - request: ReadStackRequest, - ) => Effect.Effect; - readonly startStack: ( - request: StartStackRequest, - ) => Effect.Effect< - ManagedStackStartResult, - ManagedStackManagerError, - import("effect/Scope").Scope - >; - readonly inspectStack: ( - stackId: string, - ) => Effect.Effect; - readonly listStacks: Effect.Effect< - ReadonlyArray, - ManagedStackManagerError, - never - >; - readonly allocateManagedPorts: ( - ownership: ControlOwnership, - request: AllocateManagedPortsRequest, - ) => Effect.Effect; - /** Validate durable managed-port conflicts without acquiring or releasing host ports. */ - readonly validateManagedPortReservations: ( - request: AllocateManagedPortsRequest, - ) => Effect.Effect; - /** Persist one owner-gated lifecycle transition for the supervisor. */ - readonly recordLifecycle: ( - ownership: ControlOwnership, - update: ManagedStackLifecycleUpdate, - ) => Effect.Effect; - /** Persist launch selections under the stack's control ownership. */ - readonly updateLaunch: ( - ownership: ControlOwnership, - update: ManagedStackLaunchUpdateRequest, - ) => Effect.Effect; - readonly repairWorkspace: ( - request: RepairRequest, - ) => Effect.Effect; - readonly deleteStack: ( - stackId: string, - ownership: ControlOwnership, - ) => Effect.Effect; -} - -type ManagerRequirements = FileSystem.FileSystem | Path.Path | GitConfigStore | ControlTransport; -export type ManagedStackManagerConstructionError = - | InvalidManagedIdentityError - | UnsafeManagedStackPathError; - -export class ManagedStackManager extends Context.Service< - ManagedStackManager, - ManagedStackManagerShape ->()("stack/managed/ManagedStackManager") {} - -const now = (): string => DateTime.formatIso(DateTime.nowUnsafe()); - -const lengthPrefixed = (value: string): Uint8Array => { - const bytes = new TextEncoder().encode(value); - const result = new Uint8Array(4 + bytes.byteLength); - new DataView(result.buffer).setUint32(0, bytes.byteLength); - result.set(bytes, 4); - return result; -}; - -/** Deterministic ownership id reserved for one moved-checkout repair. */ -export const deriveRepairOwnershipId = (identity: EnvironmentIdentity): string => { - const input = new Uint8Array([ - ...lengthPrefixed("supabase-stack-repair"), - ...lengthPrefixed(identity.workspaceId), - ...lengthPrefixed(identity.checkoutId), - ]); - return createHash("sha256").update(input).digest("hex"); -}; - -const isOwned = (acquisition: ControlAcquisition): acquisition is ControlOwnership => - isControlOwnership(acquisition); - -const isHealthyDocument = ( - listing: ManagedStackListing, -): listing is Extract => - listing.status === "healthy"; - -const workspaceDocument = (discovery: WorkspaceDiscovery): ManagedStackDocument["workspace"] => - discovery.workspace; - -const stackDrift = ( - document: ManagedStackDocument, - portDocument: ManagedPortIntentDocument, -): ReadonlyArray => { - const current = new Map(document.ports.map((assignment) => [assignment.key, assignment])); - return resolvePortIntents(portDocument).flatMap((request) => { - const actual = current.get(request.key); - if (actual === undefined) { - const missing: ManagedPortDrift = { - key: request.key, - actualIntent: "automatic", - configuredIntent: request.intent, - ...(request.intent === "exact" ? { configuredPort: request.port } : {}), - }; - return [missing]; - } - const configuredPort = request.intent === "exact" ? request.port : actual.port; - if (actual.intent === request.intent && actual.port === configuredPort) return []; - const mismatch: ManagedPortDrift = { - key: request.key, - actualIntent: actual.intent, - actualPort: actual.port, - configuredIntent: request.intent, - ...(request.intent === "exact" ? { configuredPort: request.port } : {}), - }; - return [mismatch]; - }); -}; - -const portRequests = ( - plan: ManagedPortPlan, - automaticExcluded: ReadonlySet = new Set(), -): ReadonlyArray => - [...plan.durable] - .sort( - (left, right) => - Number(right.selection.kind === "exact") - Number(left.selection.kind === "exact"), - ) - .map(({ field, selection }) => ({ - field, - selection: - selection.kind === "automatic" ? { ...selection, excluded: automaticExcluded } : selection, - })) - .concat( - plan.runtimeOnly.map(({ field, selection }) => ({ - field, - selection: - selection.kind === "automatic" - ? { ...selection, excluded: automaticExcluded } - : selection, - })), - ); - -const managedAssignments = ( - plan: ManagedPortPlan, - ports: PortSet, -): Effect.Effect, ManagedPortAllocationError> => - Effect.gen(function* () { - const durable = yield* Effect.forEach(plan.durable, (entry) => { - const port = entry.selection.kind === "exact" ? entry.selection.port : ports[entry.field]; - return port === undefined - ? Effect.fail( - new ManagedPortAllocationError({ fields: [entry.field], cause: "missing lease port" }), - ) - : Effect.succeed({ - key: entry.key, - port, - intent: entry.intent, - } satisfies ManagedPortAssignment); - }); - return [...durable, ...plan.inactiveAssignments].sort((left, right) => - left.key < right.key ? -1 : left.key > right.key ? 1 : 0, - ); - }); - -/** - * Acquires a managed port lease, owns it before using it, and releases it on - * every non-successful exit. The scope finalizer is registered immediately - * after acquisition so a successful lease remains owned until its caller's - * scope closes. - */ -export const withManagedPortLease = ( - acquire: Effect.Effect, - use: (lease: PortLease) => Effect.Effect, -): Effect.Effect<{ readonly value: A; readonly lease: PortLease }, E, R | Scope.Scope> => - Effect.gen(function* () { - const parentScope = yield* Effect.scope; - const leaseScope = yield* Scope.fork(parentScope); - const attempt = Effect.acquireRelease(acquire, (lease) => lease.releaseAll).pipe( - Scope.provide(leaseScope), - Effect.flatMap((lease) => use(lease).pipe(Effect.map((value) => ({ value, lease })))), - ); - return yield* attempt.pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) ? Effect.void : Effect.uninterruptible(Scope.close(leaseScope, exit)), - ), - ); - }); - -const requireOwnedForStack = ( - ownership: ControlOwnership, - stackId: string, -): Effect.Effect => - ownership.ownershipId === stackId - ? Effect.void - : Effect.fail(new ManagedStackControlRequiredError({ stackId })); - -const conflictError = ( - stackId: string, - assignment: ManagedPortAssignment, - owner: ManagedStackDocument, -): ManagedExactPortOccupiedError => - new ManagedExactPortOccupiedError({ - key: assignment.key, - port: assignment.port, - stackId, - ownerStackId: owner.id, - ownerStackName: owner.identity.name, - ownerKey: owner.ports.find((candidate) => candidate.port === assignment.port)?.key, - }); - -interface ManagedStackManagerOptions { - readonly stateRoot: string; - /** Test seam: disable well-known default ports for automatic allocation. */ - readonly preferCatalogDefaults?: boolean; -} - -const makeManager = ( - options: ManagedStackManagerOptions, -): Effect.Effect< - ManagedStackManagerShape, - ManagedStackManagerConstructionError, - ManagerRequirements -> => - Effect.gen(function* () { - const { stateRoot, preferCatalogDefaults = true } = options; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const gitConfig = yield* GitConfigStore; - const controlTransport = yield* ControlTransport; - const provideDependencies = (effect: Effect.Effect) => - effect.pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - Effect.provideService(GitConfigStore, gitConfig), - Effect.provideService(ControlTransport, controlTransport), - ); - const store = yield* makeStackStore(stateRoot); - const lifecycleLock = Semaphore.makeUnsafe(1); - - const validateOrdinaryWorkspaceIdentity = ( - discovery: WorkspaceDiscovery, - ): Effect.Effect => - Effect.gen(function* () { - if (discovery.workspace.kind !== "folder") return; - const listings = yield* store.list; - const matching = listings - .filter(isHealthyDocument) - .map((listing) => listing.document) - .filter( - (document) => - document.identity.workspaceId === discovery.identity.workspaceId && - document.identity.checkoutId === discovery.identity.checkoutId && - document.identity.contextId === discovery.identity.contextId && - document.identity.localProjectKey === discovery.identity.localProjectKey, - ); - for (const document of matching) { - const persistedPath = document.workspace.path; - const persistedInfo = yield* fileSystem - .stat(persistedPath) - .pipe( - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") ? Effect.void : Effect.fail(error), - ), - ); - if (persistedInfo === undefined || persistedInfo.type !== "Directory") continue; - const canonicalPersistedPath = yield* provideDependencies( - canonicalizeManagedWorkspacePathWithFileSystem(persistedPath), - ); - if (canonicalPersistedPath === discovery.workspace.path) continue; - const persistedInspection = yield* provideDependencies( - inspectWorkspace(canonicalPersistedPath), - ); - if (persistedInspection.kind !== "ordinary-folder") continue; - const marker = yield* provideDependencies( - readOrdinaryWorkspaceIdentityWithFileSystem(canonicalPersistedPath), - ); - if ( - marker !== undefined && - marker.workspaceId === discovery.identity.workspaceId && - marker.checkoutId === discovery.identity.checkoutId && - marker.contextId === discovery.identity.contextId - ) { - return yield* new InvalidManagedIdentityError({ - message: - "This ordinary workspace identity is already in use at another folder. Delete the current copied folder's .supabase/identity.json so a new identity can be generated.", - }); - } - } - }); - - const inspectManagedPortReservations = (request: AllocateManagedPortsRequest) => - Effect.gen(function* () { - const persisted = request.persisted ?? []; - const listings = yield* store.list; - const plan = planManagedPorts({ - activeFields: request.portDocument.activeFields, - disabledFields: request.portDocument.disabledFields, - intents: resolvePortIntents(request.portDocument), - persisted, - preferCatalogDefaults, - preservePersisted: request.preservePersisted, - }); - const invalidPersistedAutomatic = plan.durable.find( - (entry) => - entry.intent === "automatic" && - entry.selection.kind === "exact" && - entry.selection.port >= CONTROL_PORT_RANGE.min && - entry.selection.port <= CONTROL_PORT_RANGE.max, - ); - const invalidInactiveAutomatic = plan.inactiveAssignments.find( - (assignment) => - assignment.intent === "automatic" && - assignment.port >= CONTROL_PORT_RANGE.min && - assignment.port <= CONTROL_PORT_RANGE.max, - ); - const invalidPersistedPort = - invalidPersistedAutomatic?.selection.kind === "exact" - ? invalidPersistedAutomatic.selection.port - : invalidInactiveAutomatic?.port; - const invalidPersistedField = - invalidPersistedAutomatic?.field ?? - Object.values(PORT_CATALOG).find( - (entry) => entry.configKey === invalidInactiveAutomatic?.key, - )?.field; - if (invalidPersistedField !== undefined && invalidPersistedPort !== undefined) { - return yield* new ManagedPortAllocationError({ - fields: [invalidPersistedField], - cause: `Persisted automatic port ${invalidPersistedPort} is reserved for managed control endpoints`, - }); - } - const strictReserved = new Set(); - const exactReserved = new Set( - (yield* controlEndpointCandidates(request.stackId)).map(({ port }) => port), - ); - const owners = new Map< - number, - ReadonlyArray<{ - readonly document: ManagedStackDocument; - readonly assignment: ManagedPortAssignment; - }> - >(); - for (const listing of listings.filter(isHealthyDocument)) { - for (const candidate of yield* controlEndpointCandidates(listing.document.id)) { - exactReserved.add(candidate.port); - } - if (listing.document.id === request.stackId) continue; - for (const assignment of listing.document.ports) { - const liveExact = - assignment.intent === "exact" && - (listing.document.lifecycle === "running" || - listing.document.lifecycle === "starting"); - owners.set(assignment.port, [ - ...(owners.get(assignment.port) ?? []), - { document: listing.document, assignment }, - ]); - if (assignment.intent === "automatic" || liveExact) { - strictReserved.add(assignment.port); - } - } - } - for (const assignment of plan.inactiveAssignments) { - strictReserved.add(assignment.port); - } - const requestedAssignments = plan.durable.flatMap((entry) => - entry.selection.kind === "exact" - ? [ - { - key: entry.key, - port: entry.selection.port, - intent: entry.intent, - } satisfies ManagedPortAssignment, - ] - : [], - ); - for (const assignment of requestedAssignments) { - if (!exactReserved.has(assignment.port)) continue; - return yield* new ManagedExactPortOccupiedError({ - key: assignment.key, - port: assignment.port, - stackId: request.stackId, - }); - } - for (const assignment of requestedAssignments) { - const owner = (owners.get(assignment.port) ?? []).find((candidate) => { - const lifecycle = - candidate.document.lifecycle === "deleting" - ? "running" - : candidate.document.lifecycle; - return managedPortReservationsConflict(request.stackId, assignment, { - stackId: candidate.document.id, - stackName: candidate.document.identity.name, - lifecycle, - assignment: candidate.assignment, - }); - }); - if (owner !== undefined) { - return yield* conflictError(request.stackId, assignment, owner.document); - } - const inactiveOwner = plan.inactiveAssignments.find( - (candidate) => candidate.port === assignment.port, - ); - if (inactiveOwner !== undefined && assignment.intent === "exact") { - return yield* new ManagedExactPortOccupiedError({ - key: assignment.key, - port: assignment.port, - stackId: request.stackId, - ownerStackId: request.stackId, - ownerKey: inactiveOwner.key, - }); - } - } - return { exactReserved, owners, plan, strictReserved }; - }); - - const validateManagedPortReservations = ( - request: AllocateManagedPortsRequest, - ): Effect.Effect => - inspectManagedPortReservations(request).pipe(Effect.asVoid); - - const allocateManagedPorts = ( - ownership: ControlOwnership, - request: AllocateManagedPortsRequest, - ): Effect.Effect< - ManagedPortAllocation, - ManagedStackManagerError, - import("effect/Scope").Scope - > => - Effect.gen(function* () { - yield* requireOwnedForStack(ownership, request.stackId); - const attempt = Effect.gen(function* () { - const { exactReserved, owners, plan, strictReserved } = - yield* inspectManagedPortReservations(request); - const automaticExcluded = new Set(); - for (let port = CONTROL_PORT_RANGE.min; port <= CONTROL_PORT_RANGE.max; port += 1) { - automaticExcluded.add(port); - } - for (const port of strictReserved) automaticExcluded.add(port); - for (const [port] of owners) automaticExcluded.add(port); - for (const assignment of plan.inactiveAssignments) { - automaticExcluded.add(assignment.port); - } - for (const port of exactReserved) automaticExcluded.add(port); - - const requests = portRequests(plan, automaticExcluded); - const allocation = yield* withManagedPortLease( - reservePortSet(requests, { reserved: exactReserved }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError((cause) => { - const entry = - cause.field === undefined - ? undefined - : plan.durable.find((candidate) => candidate.field === cause.field); - const key = - entry?.intent === "exact" ? PORT_CATALOG[entry.field].configKey : undefined; - return key !== undefined && cause.port !== undefined - ? new ManagedExactPortOccupiedError({ - key, - port: cause.port, - stackId: request.stackId, - }) - : new ManagedPortAllocationError({ - fields: requests.map((item) => item.field), - cause, - }); - }), - ), - (lease) => managedAssignments(plan, lease.ports), - ); - return { assignments: allocation.value, lease: allocation.lease }; - }); - const allocation = yield* attempt.pipe( - Effect.retry({ - schedule: Schedule.spaced("5 millis").pipe(Schedule.upTo({ times: 2 })), - while: (error) => Predicate.isTagged(error, "ManagedPortAllocationError"), - }), - ); - return allocation; - }); - - const readStack = ( - request: ReadStackRequest, - ): Effect.Effect => - Effect.gen(function* () { - const stackName = yield* validateManagedStackName(request.stackName ?? "default"); - const discovery = yield* provideDependencies(discoverEnvironment(request.workspacePath)); - yield* validateOrdinaryWorkspaceIdentity(discovery); - if (discovery.state === "needsRepair") { - return yield* workspaceRepairConflict(discovery.reason); - } - const stackId = deriveStackId(discovery.identity, stackName); - const existing = yield* store.read(stackId); - if (existing === undefined) return undefined; - yield* validateOrdinaryWorkspaceIdentity(discovery); - const drift = stackDrift(existing, request.portDocument); - return drift.length === 0 ? existing : { ...existing, drift }; - }); - - const startStack = ( - request: StartStackRequest, - ): Effect.Effect< - ManagedStackStartResult, - ManagedStackManagerError, - import("effect/Scope").Scope - > => - Effect.gen(function* () { - const stackName = yield* validateManagedStackName(request.stackName ?? "default"); - const discovery = yield* provideDependencies(ensureEnvironment(request.workspacePath)); - yield* validateOrdinaryWorkspaceIdentity(discovery); - if (discovery.state === "needsRepair") { - return yield* workspaceRepairConflict(discovery.reason); - } - const stackId = deriveStackId(discovery.identity, stackName); - const repairId = deriveRepairOwnershipId(discovery.identity); - const repairAcquisition = yield* provideDependencies( - acquireControl({ stackId: repairId, maintenanceOperation: "repair" }).pipe( - Effect.flatMap((acquisition) => - isOwned(acquisition) - ? Effect.succeed(acquisition) - : Effect.fail( - new ManagedWorkspaceRepairConflictError({ - stackId: repairId, - reason: "Workspace repair is already owned", - }), - ), - ), - Effect.retry({ - // A held repair fence means another process is actively - // repairing this workspace; wait out a realistic repair - // (Git operations included) instead of failing after ~5s. - schedule: Schedule.spaced("20 millis").pipe( - Schedule.upTo({ duration: "30 seconds" }), - ), - while: (error) => error instanceof ManagedWorkspaceRepairConflictError, - }), - ), - ); - return yield* Effect.gen(function* () { - const refreshed = yield* provideDependencies(ensureEnvironment(request.workspacePath)); - yield* validateOrdinaryWorkspaceIdentity(refreshed); - if (refreshed.state === "needsRepair") { - return yield* workspaceRepairConflict(refreshed.reason); - } - const refreshedStackId = deriveStackId(refreshed.identity, stackName); - if (refreshedStackId !== stackId) { - return yield* new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed while resolving the stack", - }); - } - yield* requireOwnedForStack(request.ownership, refreshedStackId); - const existing = yield* store.read(refreshedStackId); - if (existing?.lifecycle === "deleting") { - yield* store.remove(refreshedStackId); - } - let current = existing?.lifecycle === "deleting" ? undefined : existing; - if ( - current !== undefined && - (current.lifecycle === "running" || current.lifecycle === "starting") - ) { - current = { ...current, lifecycle: "failed", updatedAt: now() }; - yield* store.write(current); - } - const allocation = yield* allocateManagedPorts(request.ownership, { - stackId: refreshedStackId, - portDocument: request.portDocument, - persisted: current?.ports, - preservePersisted: request.preservePersistedPorts, - }); - const timestamp = now(); - const document: ManagedStackDocument = { - format: "supabase-stack", - formatVersion: 1, - id: refreshedStackId, - identity: { ...refreshed.identity, name: stackName }, - workspace: workspaceDocument(refreshed), - ports: allocation.assignments, - lifecycle: request.lifecycle ?? "stopped", - ...(request.runtime && { runtime: request.runtime }), - launch: request.launch, - createdAt: current?.createdAt ?? timestamp, - updatedAt: timestamp, - }; - yield* store.write(document); - return { stack: document, lease: allocation.lease } satisfies ManagedStackStartResult; - }).pipe(Effect.ensuring(repairAcquisition.close)); - }); - - const inspectStack = ( - stackId: string, - ): Effect.Effect => store.read(stackId); - - const listStacks: Effect.Effect< - ReadonlyArray, - ManagedStackManagerError - > = store.list; - - const recordLifecycle = ( - ownership: ControlOwnership, - update: ManagedStackLifecycleUpdate, - ): Effect.Effect => - lifecycleLock.withPermit( - Effect.gen(function* () { - yield* requireOwnedForStack(ownership, update.stackId); - const current = yield* store.read(update.stackId); - if (current === undefined) { - return yield* new ManagedStackNotFoundError({ stackId: update.stackId }); - } - let next: ManagedStackDocument = { - ...current, - lifecycle: update.lifecycle, - updatedAt: now(), - }; - if (update.stopIntent === "explicit") { - next = { ...next, stopIntent: "explicit" }; - } else if (update.lifecycle !== "stopped") { - const { stopIntent: _stopIntent, ...withoutStopIntent } = next; - next = withoutStopIntent; - } - if ( - update.runtime !== undefined || - update.lifecycle === "stopped" || - update.lifecycle === "failed" - ) { - const { runtime: _runtime, ...withoutRuntime } = next; - next = withoutRuntime; - } - if (update.runtime !== undefined && update.runtime !== null) { - next = { ...next, runtime: update.runtime }; - } - yield* store.write(next); - return next; - }), - ); - - const updateLaunch = ( - ownership: ControlOwnership, - update: ManagedStackLaunchUpdateRequest, - ): Effect.Effect => - lifecycleLock.withPermit( - Effect.gen(function* () { - yield* requireOwnedForStack(ownership, update.stackId); - const current = yield* store.read(update.stackId); - if (current === undefined) { - return yield* new ManagedStackNotFoundError({ stackId: update.stackId }); - } - const metadata = { - versions: update.launch.versions, - ...(update.launch.excludedServices === undefined - ? {} - : { excludedServices: update.launch.excludedServices }), - ...(update.launch.lastNotifiedUpdateFingerprint === undefined - ? {} - : { - lastNotifiedUpdateFingerprint: update.launch.lastNotifiedUpdateFingerprint, - }), - }; - const launch: ManagedStackDocument["launch"] = - current.launch.mode === "native" - ? { ...metadata, mode: "native" } - : { - ...metadata, - mode: "docker", - containerRuntime: current.launch.containerRuntime, - }; - const next: ManagedStackDocument = { - ...current, - launch, - updatedAt: now(), - }; - yield* store.write(next); - return next; - }), - ); - - const repairWorkspace = ( - request: RepairRequest, - ): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - if (request.reason === "duplicate") { - return yield* workspaceRepairConflict("duplicate"); - } - const repairId = deriveRepairOwnershipId(request.identity); - const repairAcquisition = yield* provideDependencies( - acquireControl({ stackId: repairId, maintenanceOperation: "repair" }), - ); - if (!isOwned(repairAcquisition)) { - return yield* new ManagedWorkspaceRepairConflictError({ - reason: "Workspace repair is already owned", - }); - } - yield* provideDependencies(validateEnvironmentRepair(request)); - const listings = yield* store.list; - const affected = listings - .filter(isHealthyDocument) - .map((listing) => listing.document) - .filter( - (document) => - document.identity.workspaceId === request.identity.workspaceId && - document.identity.checkoutId === request.identity.checkoutId, - ) - .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)); - const stackOwners: Array = []; - for (const document of affected) { - const acquisition = yield* provideDependencies( - acquireControl({ stackId: document.id, maintenanceOperation: "repair" }), - ); - if (!isOwned(acquisition)) { - return yield* new ManagedWorkspaceRepairConflictError({ - stackId: document.id, - reason: `Managed stack ${document.id} is attached to a live owner`, - }); - } - stackOwners.push(acquisition); - } - const revalidated = yield* provideDependencies(validateEnvironmentRepair(request)); - const inspection = yield* provideDependencies(inspectWorkspace(revalidated.path)); - if (inspection.kind !== "git-checkout") { - return yield* new ManagedWorkspaceRepairConflictError({ - reason: "Repair target is not a Git checkout", - }); - } - const updatedAt = now(); - const checkoutRoot = revalidated.path; - const updates = affected.map((document) => { - const projectPath = - document.identity.localProjectKey === "." - ? checkoutRoot - : resolve(checkoutRoot, ...document.identity.localProjectKey.split("/")); - return { document, projectPath, escaped: relative(checkoutRoot, projectPath) }; - }); - for (const { document, escaped } of updates) { - if (isAbsolute(escaped) || escaped === ".." || escaped.startsWith("../")) { - return yield* new ManagedWorkspaceRepairConflictError({ - stackId: document.id, - reason: `Managed stack ${document.id} has an invalid local project key`, - }); - } - } - for (const { document, projectPath } of updates) { - const stale = document.lifecycle === "running" || document.lifecycle === "starting"; - const { runtime: _runtime, ...withoutRuntime } = document; - yield* store.write({ - ...withoutRuntime, - ...(stale ? { lifecycle: "failed" as const } : {}), - workspace: { ...document.workspace, path: projectPath }, - updatedAt, - }); - } - yield* provideDependencies( - updateGitCheckoutLocationOwned( - inspection.gitDirectory, - revalidated.expectedPath, - revalidated.path, - repairAcquisition, - ), - ); - return yield* provideDependencies(discoverEnvironment(revalidated.path)); - }), - ); - - const deleteStack = ( - stackId: string, - ownedBy: ControlOwnership, - ): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const acquisition = ownedBy; - yield* requireOwnedForStack(acquisition, stackId); - const current = yield* store.read(stackId).pipe( - Effect.catchTag("InvalidManagedStackDocumentError", () => - store.remove(stackId).pipe(Effect.as({ outcome: "removed" as const, stackId })), - ), - Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.void - : store.remove(stackId).pipe(Effect.as({ outcome: "removed" as const, stackId })), - ), - ); - if (current === undefined) return { outcome: "already-absent", stackId }; - if ("outcome" in current) return current; - if (current.launch.mode === "docker") { - yield* dockerForceRemove( - current.launch.containerRuntime, - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); - } - const { runtime: _runtime, ...withoutRuntime } = current; - const deleting = { ...withoutRuntime, lifecycle: "deleting" as const, updatedAt: now() }; - yield* store.write(deleting); - yield* store.remove(stackId); - return { outcome: "removed", stackId }; - }), - ); - - return { - stateRoot: store.stateRoot, - discoverWorkspace: (path) => - Effect.gen(function* () { - const discovery = yield* provideDependencies(discoverEnvironment(path)); - yield* validateOrdinaryWorkspaceIdentity(discovery); - return discovery; - }), - ensureWorkspace: (path) => - Effect.gen(function* () { - const discovery = yield* provideDependencies(ensureEnvironment(path)); - yield* validateOrdinaryWorkspaceIdentity(discovery); - return discovery; - }), - acquireControl: (stackId, operation) => - provideDependencies(acquireControl({ stackId, maintenanceOperation: operation })), - probeControl: (stackId) => provideDependencies(probeControl(stackId)), - readStack, - startStack, - inspectStack, - listStacks, - allocateManagedPorts, - validateManagedPortReservations, - recordLifecycle, - updateLaunch, - repairWorkspace, - deleteStack, - } satisfies ManagedStackManagerShape; - }); - -/** Internal manager layer. Platform layers provide filesystem, Git, and control transport. */ -export const managedStackManagerLayer = ( - options: ManagedStackManagerOptions, -): Layer.Layer => - Layer.effect(ManagedStackManager, makeManager(options)); - -export const makeManagedStackManager = ( - stateRoot: string, -): Effect.Effect< - ManagedStackManagerShape, - ManagedStackManagerConstructionError, - ManagerRequirements -> => makeManager({ stateRoot }); diff --git a/packages/stack/src/managed/manager.unit.test.ts b/packages/stack/src/managed/manager.unit.test.ts deleted file mode 100644 index d99cf16f84..0000000000 --- a/packages/stack/src/managed/manager.unit.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Fiber } from "effect"; -import { describe, expect } from "vitest"; -import { withManagedPortLease } from "./manager.ts"; -import type { PortLease } from "../PortAllocator.ts"; - -describe("managed port lease ownership", () => { - it.effect("releases an acquired lease when use is interrupted", () => - Effect.gen(function* () { - const acquired = yield* Deferred.make(); - const hold = yield* Deferred.make(); - let releases = 0; - const lease: PortLease = { - ports: { apiPort: 54321 }, - reserve: () => Effect.void, - release: () => Effect.void, - releaseAll: Effect.sync(() => { - releases += 1; - }), - }; - - const fiber = yield* withManagedPortLease( - Deferred.succeed(acquired, undefined).pipe(Effect.andThen(Effect.succeed(lease))), - () => Deferred.await(hold), - ).pipe(Effect.forkChild({ startImmediately: true })); - - yield* Deferred.await(acquired); - yield* Fiber.interrupt(fiber); - expect(releases).toBe(1); - - let failedReleases = 0; - const failedLease: PortLease = { - ports: { apiPort: 54322 }, - reserve: () => Effect.void, - release: () => Effect.void, - releaseAll: Effect.sync(() => { - failedReleases += 1; - }), - }; - const failed = yield* withManagedPortLease(Effect.succeed(failedLease), () => - Effect.fail("use failed"), - ).pipe(Effect.scoped, Effect.exit); - - expect(Exit.isFailure(failed)).toBe(true); - expect(failedReleases).toBe(1); - }), - ); -}); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts deleted file mode 100644 index dc792558ac..0000000000 --- a/packages/stack/src/managed/model.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { Data, Effect, Predicate } from "effect"; -import type { ConfigPortKey, PortField } from "../PortCatalog.ts"; -import { causeMessage } from "./failure.ts"; - -export const ORDINARY_WORKSPACE_IDENTITY_VERSION = 1; -export const GIT_CHECKOUT_IDENTITY_VERSION = 1; - -export type ManagedPortIntent = "automatic" | "exact"; - -export interface OrdinaryWorkspaceIdentity { - readonly version: typeof ORDINARY_WORKSPACE_IDENTITY_VERSION; - readonly workspaceId: string; - readonly checkoutId: string; - readonly contextId: string; -} - -export interface GitCheckoutIdentity { - readonly version: typeof GIT_CHECKOUT_IDENTITY_VERSION; - readonly checkoutId: string; -} - -export interface ManagedStackPaths { - readonly root: string; - readonly data: string; - readonly logs: string; - readonly runtime: string; -} - -export interface ManagedPortAssignment { - readonly key: ConfigPortKey; - readonly port: number; - readonly intent: ManagedPortIntent; -} - -export interface ManagedPortIntentDocument { - readonly activeFields: ReadonlyArray; - /** Services omitted from activeFields but intentionally retaining prior intent. */ - readonly disabledFields?: ReadonlyArray; - readonly document?: Readonly>; -} - -export type ManagedPortRequest = - | { - readonly field: PortField; - readonly key: ConfigPortKey; - readonly intent: "exact"; - readonly port: number; - } - | { - readonly field: PortField; - readonly key: ConfigPortKey; - readonly intent: "automatic"; - }; - -export interface ManagedPortDrift { - readonly key: ConfigPortKey; - readonly actualIntent: ManagedPortIntent; - readonly actualPort?: number; - readonly configuredIntent: ManagedPortIntent; - readonly configuredPort?: number; -} - -export class InvalidManagedIdentityError extends Data.TaggedError("InvalidManagedIdentityError")<{ - readonly message: string; -}> { - readonly code = "INVALID_MANAGED_IDENTITY" as const; -} - -export class InvalidManagedStackNameError extends Data.TaggedError("InvalidManagedStackNameError")<{ - readonly name: string; - readonly reason: "empty" | "control-character"; - readonly characterCode?: number; -}> { - readonly code = "INVALID_MANAGED_STACK_NAME" as const; - - override get message(): string { - if (this.reason === "empty") return "Managed stack name must not be empty"; - const code = - this.characterCode === undefined - ? "an" - : `U+${this.characterCode.toString(16).toUpperCase().padStart(4, "0")}`; - return `Managed stack name contains ASCII control character ${code}`; - } -} - -export const validateManagedStackName = ( - name: string, -): Effect.Effect => { - if (name.length === 0) { - return Effect.fail(new InvalidManagedStackNameError({ name, reason: "empty" })); - } - const character = [...name].find((value) => { - const code = value.codePointAt(0); - return code !== undefined && (code <= 0x1f || code === 0x7f); - }); - const characterCode = character?.codePointAt(0); - if (characterCode !== undefined && (characterCode <= 0x1f || characterCode === 0x7f)) { - return Effect.fail( - new InvalidManagedStackNameError({ name, reason: "control-character", characterCode }), - ); - } - return Effect.succeed(name); -}; - -export class NoRunningStackError extends Data.TaggedError("NoRunningStackError")<{ - readonly cwd: string; -}> {} - -export type UnsupportedGitWorkspaceCause = - | "inside-git-directory" - | "malformed-metadata" - | "metadata-inaccessible" - | "reftable"; - -export class UnsupportedGitWorkspaceError extends Data.TaggedError("UnsupportedGitWorkspaceError")<{ - readonly path: string; - readonly reason: string; - readonly workspaceCause: UnsupportedGitWorkspaceCause; -}> { - readonly code = "UNSUPPORTED_GIT_WORKSPACE" as const; - - override get message(): string { - return `${this.reason}: ${JSON.stringify(this.path)}`; - } -} - -export class ManagedExactPortOccupiedError extends Data.TaggedError( - "ManagedExactPortOccupiedError", -)<{ - readonly key: ConfigPortKey; - readonly port: number; - readonly stackId: string; - readonly ownerStackId?: string; - readonly ownerStackName?: string; - readonly ownerKey?: ConfigPortKey; -}> { - readonly code = "MANAGED_EXACT_PORT_OCCUPIED" as const; - - override get message(): string { - const owner = this.ownerStackId === undefined ? "another process" : this.ownerStackId; - return `Port ${this.port} configured by ${this.key} is occupied by ${owner}`; - } -} - -export class ManagedPortAllocationError extends Data.TaggedError("ManagedPortAllocationError")<{ - readonly fields: ReadonlyArray; - readonly cause: unknown; -}> { - readonly code = "MANAGED_PORT_ALLOCATION_FAILED" as const; - - override get message(): string { - return `Failed to allocate managed ports ${this.fields.join(", ")}: ${causeMessage(this.cause)}`; - } -} - -export class ManagedStackNotFoundError extends Data.TaggedError("ManagedStackNotFoundError")<{ - readonly stackId: string; -}> { - readonly code = "MANAGED_STACK_NOT_FOUND" as const; - - override get message(): string { - return `Managed stack ${this.stackId} was not found`; - } -} - -export class ManagedStackNotStoppedError extends Data.TaggedError("ManagedStackNotStoppedError")<{ - readonly stackId: string; -}> { - readonly code = "MANAGED_STACK_NOT_STOPPED" as const; - - override get message(): string { - return `Managed stack ${this.stackId} must be safely stopped before deletion`; - } -} - -export class UnsafeManagedStackPathError extends Data.TaggedError("UnsafeManagedStackPathError")<{ - readonly path: string; - readonly reason?: string; -}> { - readonly code = "UNSAFE_MANAGED_STACK_PATH" as const; - - override get message(): string { - return `${this.reason ?? "Refusing to remove an unsafe managed stack path"}: ${JSON.stringify(this.path)}`; - } -} - -export type ManagedStackError = - | InvalidManagedIdentityError - | InvalidManagedStackNameError - | UnsupportedGitWorkspaceError - | ManagedExactPortOccupiedError - | ManagedPortAllocationError - | ManagedStackNotFoundError - | ManagedStackNotStoppedError - | UnsafeManagedStackPathError; - -export type ManagedErrorCode = ManagedStackError["code"]; -export type ManagedErrorTag = ManagedStackError["_tag"]; - -function exhaustiveArrayOf() { - return >(array: U & ([T] extends [U[number]] ? unknown : never)): U => - array; -} - -export const MANAGED_ERROR_CODES = exhaustiveArrayOf()([ - "INVALID_MANAGED_IDENTITY", - "INVALID_MANAGED_STACK_NAME", - "UNSUPPORTED_GIT_WORKSPACE", - "MANAGED_EXACT_PORT_OCCUPIED", - "MANAGED_PORT_ALLOCATION_FAILED", - "MANAGED_STACK_NOT_FOUND", - "MANAGED_STACK_NOT_STOPPED", - "UNSAFE_MANAGED_STACK_PATH", -] as const); - -export const MANAGED_ERROR_TAG_BY_CODE = { - INVALID_MANAGED_IDENTITY: "InvalidManagedIdentityError", - INVALID_MANAGED_STACK_NAME: "InvalidManagedStackNameError", - UNSUPPORTED_GIT_WORKSPACE: "UnsupportedGitWorkspaceError", - MANAGED_EXACT_PORT_OCCUPIED: "ManagedExactPortOccupiedError", - MANAGED_PORT_ALLOCATION_FAILED: "ManagedPortAllocationError", - MANAGED_STACK_NOT_FOUND: "ManagedStackNotFoundError", - MANAGED_STACK_NOT_STOPPED: "ManagedStackNotStoppedError", - UNSAFE_MANAGED_STACK_PATH: "UnsafeManagedStackPathError", -} as const satisfies Record; - -const MANAGED_ERROR_TAGS: ReadonlySet = new Set(Object.values(MANAGED_ERROR_TAG_BY_CODE)); - -export function isManagedStackError(error: unknown): error is ManagedStackError { - if (!(error instanceof Error)) return false; - return [...MANAGED_ERROR_TAGS].some((tag) => Predicate.isTagged(error, tag)); -} diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts deleted file mode 100644 index 7e291ff874..0000000000 --- a/packages/stack/src/managed/paths.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { homedir } from "node:os"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- These exported path helpers are pure platform-aware boundary functions. -import { join, resolve } from "node:path"; -import { Effect } from "effect"; -import { validateManagedUuid } from "./ids.ts"; -import { - InvalidManagedIdentityError, - UnsafeManagedStackPathError, - type ManagedStackPaths, -} from "./model.ts"; - -export interface ManagedStateRootOptions { - readonly stateRoot?: string; - readonly env?: Readonly>; - readonly homeDir?: string; - readonly platform?: NodeJS.Platform; -} - -const nonEmpty = (value: string | undefined): string | undefined => { - const trimmed = value?.trim(); - return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; -}; - -const requireManagedStateRootEffect = ( - stateRoot: string, -): Effect.Effect => { - const trimmed = nonEmpty(stateRoot); - return trimmed === undefined - ? Effect.fail( - new UnsafeManagedStackPathError({ - path: stateRoot, - reason: "Refusing a blank managed state root", - }), - ) - : Effect.succeed(resolve(trimmed)); -}; - -/** - * Every caller- or environment-supplied root is anchored to the working - * directory once, here. A relative root would otherwise be reinterpreted - * against whatever the process' cwd happens to be at each later use, so a - * chdir would split persisted stack state across directories and make - * {@link assertManagedStackRootEffect} accept a same-shaped path under the new cwd. - * `homedir()` is absolute by definition and needs no anchoring. - * - * An explicit root is a decision, so a blank one is a caller bug and fails - * rather than falling back: `resolve("")` silently yields the process' working - * directory, which would scatter managed state across whatever directory a - * caller happened to start in. Environment values are configuration that may - * legitimately be present but empty, so a blank one is treated as unset and - * falls through to the next source. - */ -export const resolveManagedStateRootEffect = ( - options: ManagedStateRootOptions = {}, -): Effect.Effect => { - if (options.stateRoot !== undefined) { - return requireManagedStateRootEffect(options.stateRoot); - } - const env = options.env ?? process.env; - const configuredHome = nonEmpty(env["SUPABASE_HOME"]); - if (configuredHome !== undefined) { - return Effect.succeed(join(resolve(configuredHome), "managed")); - } - const platform = options.platform ?? process.platform; - const userHome = options.homeDir ?? homedir(); - if (platform === "darwin") { - return Effect.succeed(join(userHome, "Library", "Application Support", "supabase", "managed")); - } - if (platform === "win32") { - const localAppData = nonEmpty(env["LOCALAPPDATA"]); - return Effect.succeed( - join( - localAppData === undefined ? join(userHome, "AppData", "Local") : resolve(localAppData), - "Supabase", - "managed", - ), - ); - } - const stateHome = nonEmpty(env["XDG_STATE_HOME"]); - return Effect.succeed( - join( - stateHome === undefined ? join(userHome, ".local", "state") : resolve(stateHome), - "supabase", - "managed", - ), - ); -}; - -/** - * The state root a managed stack service must be started with. - * - * `stateRoot` is required wherever a service is built, but a caller bypassing - * the type system (or a plain-JS caller) could still pass `undefined`, which - * would make {@link resolveManagedStateRootEffect} silently fall back to - * `SUPABASE_HOME` or the user's home directory instead of failing loudly. A root - * is a decision the caller owes the service, so a missing one is refused here - * rather than guessed. - */ -export const requireExplicitManagedStateRootEffect = ( - stateRoot: string | undefined, -): Effect.Effect => - stateRoot === undefined - ? Effect.fail( - new UnsafeManagedStackPathError({ - path: String(stateRoot), - reason: "Refusing to start a managed stack service without an explicit state root", - }), - ) - : resolveManagedStateRootEffect({ stateRoot }); - -export const managedStacksRoot = (stateRoot: string): string => join(stateRoot, "stacks"); - -const SHA256_STACK_ID_PATTERN = /^[0-9a-f]{64}$/i; - -const validateManagedStackId = ( - stackId: string, -): Effect.Effect => - SHA256_STACK_ID_PATTERN.test(stackId) - ? Effect.succeed(stackId) - : validateManagedUuid(stackId, "stackId"); - -export const managedStackPathsEffect = ( - stateRoot: string, - stackId: string, -): Effect.Effect => - validateManagedStackId(stackId).pipe( - Effect.map((id) => { - const root = join(managedStacksRoot(stateRoot), id); - return { - root, - data: join(root, "data"), - logs: join(root, "logs"), - runtime: join(root, "runtime"), - }; - }), - ); - -export const managedStackDocumentPathEffect = ( - stateRoot: string, - stackId: string, -): Effect.Effect => - managedStackPathsEffect(stateRoot, stackId).pipe( - Effect.map(({ root }) => join(root, "stack.json")), - ); - -export const assertManagedStackRootEffect = ( - stateRoot: string, - stackId: string, - stackRoot: string, -): Effect.Effect => - Effect.gen(function* () { - const expected = resolve((yield* managedStackPathsEffect(stateRoot, stackId)).root); - const actual = resolve(stackRoot); - return actual === expected - ? actual - : yield* new UnsafeManagedStackPathError({ path: stackRoot }); - }); - -export const ordinaryWorkspaceIdentityPath = (workspacePath: string): string => - join(workspacePath, ".supabase", "identity.json"); - -/** - * The config file of the repository a checkout shares with its linked - * worktrees — the common git directory's own `config`, which is where git keeps - * repository-local configuration and which `git clone` never copies. - */ -export const gitConfigPath = (commonDirectory: string): string => join(commonDirectory, "config"); - -/** - * The worktree-scoped config beside it, which only applies once - * `extensions.worktreeConfig` is enabled. Git enables that extension by itself — - * `git sparse-checkout set` does — and moves `core.bare` out of the shared config - * into this file, so a reader of `core.bare` has to know about it. The one in the - * common directory belongs to the repository's own worktree, bare or not. - */ -export const gitWorktreeConfigPath = (commonDirectory: string): string => - join(commonDirectory, "config.worktree"); - -/** - * A checkout's own identity marker, inside its own git directory: the common - * directory for a primary checkout, `/worktrees/` for a linked - * one. Git ignores files it does not know about there, so the marker needs no - * exclusion rule and can never reach the index. - */ -export const gitCheckoutIdentityPath = (gitDirectory: string): string => - join(gitDirectory, "supabase-checkout.json"); - -/** A checkout-scoped detached-context identity beside its checkout marker. */ -export const gitDetachedContextIdentityPath = (gitDirectory: string): string => - join(gitDirectory, "supabase-detached-context.json"); - -/** The last canonical workspace path observed for a checkout identity. */ -export const gitCheckoutLocationPath = (gitDirectory: string): string => - join(gitDirectory, "supabase-checkout-location.json"); diff --git a/packages/stack/src/managed/port-intent.ts b/packages/stack/src/managed/port-intent.ts deleted file mode 100644 index 7883ab7738..0000000000 --- a/packages/stack/src/managed/port-intent.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { PORT_CATALOG } from "../PortCatalog.ts"; -import type { ManagedPortIntentDocument, ManagedPortRequest } from "./model.ts"; - -export type { ManagedPortIntentDocument, ManagedPortRequest } from "./model.ts"; - -const hasOwn = (value: object, key: string): boolean => - Object.prototype.hasOwnProperty.call(value, key); - -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null; - -const valueAt = ( - document: Readonly> | undefined, - path: ReadonlyArray, -): unknown => { - let current: unknown = document; - for (const segment of path) { - if (!isRecord(current) || !hasOwn(current, segment)) { - return undefined; - } - current = current[segment]; - } - return current; -}; - -/** Resolve configured sticky port keys from the pre-default effective document. */ -export const resolvePortIntents = ( - document: ManagedPortIntentDocument, -): ReadonlyArray => - document.activeFields.flatMap((field): ReadonlyArray => { - const entry = PORT_CATALOG[field]; - if (entry.persistence !== "sticky" || entry.configKey === undefined) { - return []; - } - - const path = entry.configKey.split("."); - const value = valueAt(document.document, path); - if (typeof value === "number") { - return [ - { - field, - key: entry.configKey, - intent: "exact", - port: value, - }, - ]; - } - - return [{ field, key: entry.configKey, intent: "automatic" }]; - }); diff --git a/packages/stack/src/managed/port-intent.unit.test.ts b/packages/stack/src/managed/port-intent.unit.test.ts deleted file mode 100644 index 94fb4917b3..0000000000 --- a/packages/stack/src/managed/port-intent.unit.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolvePortIntents } from "./port-intent.ts"; - -describe("resolvePortIntents", () => { - it("treats an explicitly configured template default as exact", () => { - expect( - resolvePortIntents({ - activeFields: ["apiPort", "dbPort"], - document: { api: { port: 54321 } }, - }), - ).toEqual([ - { - field: "apiPort", - key: "api.port", - intent: "exact", - port: 54321, - }, - { field: "dbPort", key: "db.port", intent: "automatic" }, - ]); - }); - - it("ignores inactive sticky fields even when the document contains them", () => { - expect( - resolvePortIntents({ - activeFields: ["apiPort"], - document: { api: { port: 55434 }, studio: { port: 55435 } }, - }), - ).toEqual([{ field: "apiPort", key: "api.port", intent: "exact", port: 55434 }]); - }); -}); diff --git a/packages/stack/src/managed/port-plan.ts b/packages/stack/src/managed/port-plan.ts deleted file mode 100644 index 5609fbdfca..0000000000 --- a/packages/stack/src/managed/port-plan.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { PORT_CATALOG } from "../PortCatalog.ts"; -import type { ConfigPortKey, PortField } from "../PortCatalog.ts"; -import type { PortReservationRequest, PortSelection } from "../PortAllocator.ts"; -import type { ManagedPortAssignment, ManagedPortRequest, ManagedPortIntent } from "./model.ts"; -import type { ManagedStackDocumentLifecycle } from "./document.ts"; - -/** A persisted assignment used when applying the managed port conflict matrix. */ -export interface ManagedPortReservation { - readonly stackId: string; - readonly stackName?: string; - readonly lifecycle: ManagedStackDocumentLifecycle; - readonly assignment: ManagedPortAssignment; -} - -/** - * Automatic assignments are exclusive for every persisted stack. Exact - * assignments may coexist while the owner is stopped or failed, but never - * while the owner occupies its ports. - */ -export const managedPortReservationsConflict = ( - incomingStackId: string, - incoming: ManagedPortAssignment, - owner: ManagedPortReservation, -): boolean => - incomingStackId !== owner.stackId && - incoming.port === owner.assignment.port && - (incoming.intent === "automatic" || - owner.assignment.intent === "automatic" || - owner.lifecycle === "running" || - owner.lifecycle === "starting" || - owner.lifecycle === "deleting"); - -export interface ManagedDurablePortPlanEntry { - readonly field: PortField; - readonly key: ConfigPortKey; - readonly intent: ManagedPortIntent; - readonly selection: PortSelection; - readonly newlyAllocatedAutomatic: boolean; -} - -export interface ManagedPortPlan { - readonly durable: ReadonlyArray; - readonly runtimeOnly: ReadonlyArray; - readonly inactiveAssignments: ReadonlyArray; -} - -export interface ManagedPortPlanInput { - readonly activeFields: ReadonlyArray; - readonly disabledFields?: ReadonlyArray; - readonly intents: ReadonlyArray; - readonly persisted?: ReadonlyArray; - /** - * Seed automatic selections with the catalog's well-known default ports. - * Tests disable this so parallel suites do not contend on the same - * defaults, which sticky reuse later re-reserves exactly. - */ - readonly preferCatalogDefaults?: boolean; - /** Replacements keep the target stack's existing sticky assignments. */ - readonly preservePersisted?: boolean; -} - -const automaticSelection = (preferred: number | undefined): PortSelection => - preferred === undefined ? { kind: "automatic" } : { kind: "automatic", preferred }; - -/** Build sticky durable selections and runtime-only requests from resolved intent. */ -export const planManagedPorts = (input: ManagedPortPlanInput): ManagedPortPlan => { - const persisted = input.persisted ?? []; - const preferredFor = (entry: (typeof PORT_CATALOG)[PortField]): number | undefined => - (input.preferCatalogDefaults ?? true) ? entry.preferred : undefined; - const persistedByKey = new Map(persisted.map((assignment) => [assignment.key, assignment])); - const intentsByField = new Map(input.intents.map((request) => [request.field, request])); - const activeKeys = new Set(); - const durable: Array = []; - const runtimeOnly: Array = []; - - for (const field of input.activeFields) { - const entry = PORT_CATALOG[field]; - if (entry.persistence === "sticky" && entry.configKey !== undefined) { - activeKeys.add(entry.configKey); - const configured = intentsByField.get(field); - const persistedAssignment = persistedByKey.get(entry.configKey); - const intent = configured?.intent ?? "automatic"; - if (input.preservePersisted && persistedAssignment !== undefined) { - durable.push({ - field, - key: entry.configKey, - intent: persistedAssignment.intent, - selection: { kind: "exact", port: persistedAssignment.port }, - newlyAllocatedAutomatic: false, - }); - } else if (configured?.intent === "exact") { - durable.push({ - field, - key: entry.configKey, - intent: configured.intent, - selection: { kind: "exact", port: configured.port }, - newlyAllocatedAutomatic: false, - }); - } else if (persistedAssignment !== undefined) { - durable.push({ - field, - key: entry.configKey, - intent, - selection: { kind: "exact", port: persistedAssignment.port }, - newlyAllocatedAutomatic: false, - }); - } else { - durable.push({ - field, - key: entry.configKey, - intent, - selection: automaticSelection(preferredFor(entry)), - newlyAllocatedAutomatic: true, - }); - } - continue; - } - - if (entry.persistence === "runtime") { - runtimeOnly.push({ field, selection: automaticSelection(preferredFor(entry)) }); - } - } - - const disabledKeys = new Set( - (input.disabledFields ?? []).flatMap((field) => { - const key = PORT_CATALOG[field].configKey; - return key === undefined ? [] : [key]; - }), - ); - return { - durable, - runtimeOnly, - inactiveAssignments: persisted - .filter((assignment) => !activeKeys.has(assignment.key)) - .map((assignment) => - disabledKeys.has(assignment.key) ? assignment : { ...assignment, intent: "automatic" }, - ), - }; -}; diff --git a/packages/stack/src/managed/port-plan.unit.test.ts b/packages/stack/src/managed/port-plan.unit.test.ts deleted file mode 100644 index ea277419b2..0000000000 --- a/packages/stack/src/managed/port-plan.unit.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolvePortIntents } from "./port-intent.ts"; -import { planManagedPorts, type ManagedDurablePortPlanEntry } from "./port-plan.ts"; - -describe("planManagedPorts", () => { - it("pins a persisted automatic durable value", () => { - const activeFields = ["apiPort"] as const; - const intents = resolvePortIntents({ activeFields, document: {} }); - - expect( - planManagedPorts({ - activeFields, - intents, - persisted: [{ key: "api.port", port: 55001, intent: "automatic" }], - }), - ).toEqual({ - durable: [ - { - field: "apiPort", - key: "api.port", - intent: "automatic", - selection: { kind: "exact", port: 55001 }, - newlyAllocatedAutomatic: false, - }, - ], - runtimeOnly: [], - inactiveAssignments: [], - }); - }); - - it("changes an exact assignment to automatic while retaining its number when omitted", () => { - const activeFields = ["dbPort"] as const; - const intents = resolvePortIntents({ activeFields, document: {} }); - - expect( - planManagedPorts({ - activeFields, - intents, - persisted: [{ key: "db.port", port: 55002, intent: "exact" }], - }), - ).toEqual({ - durable: [ - { - field: "dbPort", - key: "db.port", - intent: "automatic", - selection: { kind: "exact", port: 55002 }, - newlyAllocatedAutomatic: false, - }, - ], - runtimeOnly: [], - inactiveAssignments: [], - }); - }); - - it("preserves inactive durable assignments without requesting sockets", () => { - const activeFields = [] as const; - const intents = resolvePortIntents({ activeFields, document: {} }); - - expect( - planManagedPorts({ - activeFields, - intents, - persisted: [{ key: "studio.port", port: 55003, intent: "exact" }], - }), - ).toEqual({ - durable: [], - runtimeOnly: [], - inactiveAssignments: [{ key: "studio.port", port: 55003, intent: "automatic" }], - }); - }); - - it("creates runtime-only automatic requests for active keyless fields", () => { - const activeFields = ["authPort"] as const; - const intents = resolvePortIntents({ activeFields, document: {} }); - - expect(planManagedPorts({ activeFields, intents, persisted: [] })).toEqual({ - durable: [], - runtimeOnly: [{ field: "authPort", selection: { kind: "automatic" } }], - inactiveAssignments: [], - }); - }); - - it("keeps exact requests exact instead of adding an automatic fallback", () => { - const activeFields = ["apiPort"] as const; - const intents = resolvePortIntents({ - activeFields, - document: { api: { port: 55004 } }, - }); - - const [entry] = planManagedPorts({ activeFields, intents, persisted: [] }).durable; - const durable: ManagedDurablePortPlanEntry | undefined = entry; - expect(durable).toEqual({ - field: "apiPort", - key: "api.port", - intent: "exact", - selection: { kind: "exact", port: 55004 }, - newlyAllocatedAutomatic: false, - }); - }); - - it("prefers the catalog candidate for a new automatic durable field", () => { - const activeFields = ["apiPort"] as const; - const intents = resolvePortIntents({ activeFields, document: {} }); - - expect(planManagedPorts({ activeFields, intents, persisted: [] }).durable).toEqual([ - { - field: "apiPort", - key: "api.port", - intent: "automatic", - selection: { kind: "automatic", preferred: 54321 }, - newlyAllocatedAutomatic: true, - }, - ]); - }); -}); diff --git a/packages/stack/src/managed/store.ts b/packages/stack/src/managed/store.ts deleted file mode 100644 index fd8f7315b6..0000000000 --- a/packages/stack/src/managed/store.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { Effect, FileSystem, Path, PlatformError, Predicate } from "effect"; -import { - decodeManagedStackDocument, - encodeManagedStackDocument, - InvalidManagedStackDocumentError, - type ManagedStackDocument, -} from "./document.ts"; -import { - assertManagedStackRootEffect, - managedStackDocumentPathEffect, - managedStackPathsEffect, - managedStacksRoot, - requireExplicitManagedStateRootEffect, -} from "./paths.ts"; -import { InvalidManagedIdentityError, UnsafeManagedStackPathError } from "./model.ts"; - -export type ManagedStackListing = - | { - readonly id: string; - readonly status: "healthy"; - readonly document: ManagedStackDocument; - } - | { - readonly id: string; - readonly status: "corrupt"; - readonly path: string; - readonly cause: InvalidManagedStackDocumentError | PlatformError.PlatformError; - }; - -export interface StackStore { - readonly stateRoot: string; - readonly read: ( - stackId: string, - ) => Effect.Effect< - ManagedStackDocument | undefined, - InvalidManagedStackDocumentError | InvalidManagedIdentityError | PlatformError.PlatformError - >; - readonly list: Effect.Effect< - ReadonlyArray, - PlatformError.PlatformError | InvalidManagedIdentityError - >; - readonly write: ( - document: ManagedStackDocument, - ) => Effect.Effect< - void, - InvalidManagedStackDocumentError | InvalidManagedIdentityError | PlatformError.PlatformError - >; - readonly remove: ( - stackId: string, - ) => Effect.Effect< - void, - InvalidManagedIdentityError | UnsafeManagedStackPathError | PlatformError.PlatformError - >; -} - -const writeDocumentAtomically = ( - fs: FileSystem.FileSystem, - path: Path.Path, - documentPath: string, - stackRoot: string, - document: ManagedStackDocument, -): Effect.Effect => - Effect.gen(function* () { - const content = yield* encodeManagedStackDocument(documentPath, document); - yield* fs.makeDirectory(stackRoot, { recursive: true, mode: 0o700 }); - const temporaryPath = path.join(stackRoot, `stack.json.tmp.${randomUUID()}`); - yield* Effect.ensuring( - Effect.gen(function* () { - yield* fs.writeFileString(temporaryPath, content, { mode: 0o600 }); - yield* fs.rename(temporaryPath, documentPath); - }), - fs.remove(temporaryPath, { force: true }).pipe(Effect.catch(() => Effect.void)), - ); - }); - -const decodeAtPath = ( - fs: FileSystem.FileSystem, - documentPath: string, - stackId: string, -): Effect.Effect< - ManagedStackDocument, - InvalidManagedStackDocumentError | PlatformError.PlatformError -> => - Effect.gen(function* () { - const content = yield* fs.readFileString(documentPath); - const document = yield* decodeManagedStackDocument(documentPath, content); - if (document.id !== stackId) { - return yield* new InvalidManagedStackDocumentError({ path: documentPath }); - } - return document; - }); - -const isNotFound = (error: PlatformError.PlatformError): boolean => - Predicate.isTagged(error.reason, "NotFound"); - -const makeListEntry = ( - fs: FileSystem.FileSystem, - stateRoot: string, - stackId: string, -): Effect.Effect< - ManagedStackListing | undefined, - PlatformError.PlatformError | InvalidManagedIdentityError -> => - Effect.gen(function* () { - const documentPath = yield* managedStackDocumentPathEffect(stateRoot, stackId); - return yield* decodeAtPath(fs, documentPath, stackId).pipe( - Effect.map((document): ManagedStackListing => ({ id: stackId, status: "healthy", document })), - Effect.catchTags({ - InvalidManagedStackDocumentError: (cause) => - Effect.succeed({ - id: stackId, - status: "corrupt", - path: documentPath, - cause, - }), - PlatformError: (error) => - isNotFound(error) - ? Effect.void.pipe(Effect.as(undefined)) - : Effect.succeed({ - id: stackId, - status: "corrupt", - path: documentPath, - cause: error, - }), - }), - ); - }); - -export const makeStackStore = ( - stateRoot: string, -): Effect.Effect< - StackStore, - InvalidManagedIdentityError | UnsafeManagedStackPathError, - FileSystem.FileSystem | Path.Path -> => - Effect.gen(function* () { - const resolvedStateRoot = yield* requireExplicitManagedStateRootEffect(stateRoot); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const read = ( - stackId: string, - ): Effect.Effect< - ManagedStackDocument | undefined, - InvalidManagedStackDocumentError | InvalidManagedIdentityError | PlatformError.PlatformError - > => { - return Effect.gen(function* () { - const documentPath = yield* managedStackDocumentPathEffect(resolvedStateRoot, stackId); - return yield* decodeAtPath(fs, documentPath, stackId).pipe( - Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.void.pipe(Effect.as(undefined)) : Effect.fail(error), - ), - ); - }); - }; - - const list: Effect.Effect< - ReadonlyArray, - PlatformError.PlatformError | InvalidManagedIdentityError - > = Effect.gen(function* () { - const stacksRoot = managedStacksRoot(resolvedStateRoot); - if (!(yield* fs.exists(stacksRoot))) { - return []; - } - const names = yield* fs - .readDirectory(stacksRoot) - .pipe( - Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.succeed>([]) : Effect.fail(error), - ), - ); - const validNames = yield* Effect.all( - names.map((name) => - managedStackPathsEffect(resolvedStateRoot, name).pipe( - Effect.map(() => name), - Effect.catchTag("InvalidManagedIdentityError", () => - Effect.void.pipe(Effect.as(undefined)), - ), - ), - ), - ); - const sortedNames = validNames - .filter((name): name is string => name !== undefined) - .sort((left, right) => left.localeCompare(right)); - const entries = yield* Effect.all( - sortedNames.map((stackId) => makeListEntry(fs, resolvedStateRoot, stackId)), - ); - return entries.filter((entry): entry is ManagedStackListing => entry !== undefined); - }); - - const write = ( - document: ManagedStackDocument, - ): Effect.Effect< - void, - InvalidManagedStackDocumentError | InvalidManagedIdentityError | PlatformError.PlatformError - > => - Effect.gen(function* () { - const paths = yield* managedStackPathsEffect(resolvedStateRoot, document.id); - yield* writeDocumentAtomically( - fs, - path, - path.join(paths.root, "stack.json"), - paths.root, - document, - ); - }); - - const remove = ( - stackId: string, - ): Effect.Effect< - void, - InvalidManagedIdentityError | UnsafeManagedStackPathError | PlatformError.PlatformError - > => - Effect.gen(function* () { - const paths = yield* managedStackPathsEffect(resolvedStateRoot, stackId); - const safeRoot = yield* assertManagedStackRootEffect( - resolvedStateRoot, - stackId, - paths.root, - ); - if (!(yield* fs.exists(safeRoot))) return; - const entries = yield* fs.readDirectory(safeRoot); - for (const entry of entries) { - if (entry === "stack.json") continue; - yield* fs.remove(path.join(safeRoot, entry), { recursive: true, force: true }); - } - yield* fs.remove(path.join(safeRoot, "stack.json"), { - recursive: true, - force: true, - }); - yield* fs.remove(safeRoot, { recursive: true, force: true }); - }); - - return { stateRoot: resolvedStateRoot, read, list, write, remove }; - }); diff --git a/packages/stack/src/node-entrypoint.integration.test.ts b/packages/stack/src/node-entrypoint.integration.test.ts deleted file mode 100644 index 81f112c9d4..0000000000 --- a/packages/stack/src/node-entrypoint.integration.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- Entrypoint integration tests invoke the native process boundary directly. - -import { execFileSync } from "node:child_process"; -import { expect, test } from "vitest"; - -test("loads the managed entrypoint in Node without a TypeScript transform", () => { - const entrypoint = new URL("./managed-node.ts", import.meta.url).href; - expect(() => - execFileSync( - "node", - ["--input-type=module", "--eval", `await import(${JSON.stringify(entrypoint)})`], - { - stdio: "pipe", - }, - ), - ).not.toThrow(); -}); diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts deleted file mode 100644 index 3a5aa6ba6e..0000000000 --- a/packages/stack/src/node.ts +++ /dev/null @@ -1,69 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/multiple-effect-provide -- Public Node Promise facades intentionally bridge host async calls; platform and transport layers are staged to preserve dependency and scope ordering. - -import { NodeServices } from "@effect/platform-node"; -import { Effect, Layer } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { BinaryResolver } from "./BinaryResolver.ts"; -import { selectStackRuntime } from "./ContainerRuntime.ts"; -import { createStack as createStackCore, type ResolveConfigEffect } from "./createStack.ts"; -import { toStackHandle, type StackHandle } from "./stackHandle.ts"; -import { toStackError } from "./errors.ts"; -import { - prefetch as prefetchEffect, - type PrefetchEffectOptions, - type PrefetchOptions, - type PrefetchResult, -} from "./prefetch.ts"; -import { defaultCacheRoot } from "./paths.ts"; -import { platformFactory } from "./platform-node.ts"; -import { StackPreparation } from "./StackPreparation.ts"; -import type { StackConfig } from "./StackConfig.ts"; -import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; - -const resolveConfigEffectForPlatform: ResolveConfigEffect = (config, options) => - resolveConfigEffect(config, options); - -/** - * The Node daemon bootstrap is deliberately not exported from the package. The conditional Effect - * entry resolves `daemon-node.ts` by file URL through the internal platform adapter. Keep - * `src/daemon-node.ts` in the `packages/stack` workspace entry in the root `knip.json`: static - * imports cannot see that fork target. - */ - -export async function createStack(config?: StackConfig): Promise { - const runtime = await Effect.runPromise( - selectStackRuntime(config?.mode).pipe(Effect.provide(NodeServices.layer)), - ).catch((error: unknown) => { - throw toStackError(error); - }); - const handle = await Effect.runPromise( - createStackCore(config, platformFactory, runtime, resolveConfigEffectForPlatform).pipe( - Effect.provide(NodeServices.layer), - ), - ); - return toStackHandle(handle); -} - -export async function prefetch(options?: PrefetchOptions): Promise { - const runtime = await Effect.runPromise( - selectStackRuntime(options?.mode).pipe(Effect.provide(NodeServices.layer)), - ).catch((error: unknown) => { - throw toStackError(error); - }); - const resolverLayer = BinaryResolver.make(defaultCacheRoot()).pipe( - Layer.provide(FetchHttpClient.layer), - ); - const preparationLayer = StackPreparation.layer.pipe(Layer.provide(resolverLayer)); - const resolvedOptions: PrefetchEffectOptions = - runtime.mode === "native" - ? { ...options, mode: "native" } - : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; - return Effect.runPromise( - prefetchEffect(resolvedOptions).pipe( - Effect.provide(preparationLayer), - Effect.provide(NodeServices.layer), - ), - ); -} - -export * from "./index.ts"; diff --git a/packages/stack/src/paths.ts b/packages/stack/src/paths.ts deleted file mode 100644 index 2a6e361f32..0000000000 --- a/packages/stack/src/paths.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { homedir, tmpdir } from "node:os"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Exported path helpers are synchronous platform boundary values used before Effect runtime setup. -import { join } from "node:path"; - -const shortTempRoot = () => (process.platform === "win32" ? tmpdir() : "/tmp"); - -export const defaultCacheRoot = (): string => join(homedir(), ".supabase"); - -export const DEFAULT_MANAGED_STACK_NAME = "default"; - -export const shortTempPrefixRoot = (): string => shortTempRoot(); diff --git a/packages/stack/src/platform-bun.integration.test.ts b/packages/stack/src/platform-bun.integration.test.ts deleted file mode 100644 index fc868a1ef0..0000000000 --- a/packages/stack/src/platform-bun.integration.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch -- Bun platform tests call native fetch and Promise callbacks to validate the transport implementation. -import { Cause, Deferred, Effect, Exit, Layer, Predicate, Scope } from "effect"; -import { describe, expect, test } from "vitest"; -import { - ControlStopConflictError, - ControlProtocolError, - ControlTransport, - ControlTransportError, - makeControlClient, -} from "./managed/control.ts"; -import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -import { makeSupervisorSessionFixture } from "../tests/helpers/SupervisorSessionFixture.ts"; -import { makeTestStack } from "./testing.ts"; - -const isBun = typeof Bun !== "undefined"; - -describe("Bun control transport", () => { - (isBun ? test : test.skip)( - "classifies a fenced stop conflict distinctly from transport failure", - async () => { - const { controlTransportLayer } = await import("./platform-bun.ts"); - const server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch: () => Response.json({ error: "conflict" }, { status: 409 }), - }); - try { - const port = server.port; - expect(port).toBeTypeOf("number"); - if (port === undefined) return; - const endpoint = { - hostname: "127.0.0.1", - port, - url: `http://127.0.0.1:${port}`, - }; - const exit = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - transport.requestStop(endpoint, { - ownershipId: "0".repeat(64), - ownerSessionId: "captured-session", - intent: "explicit", - }), - ).pipe(Effect.provide(controlTransportLayer), Effect.exit), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(ControlStopConflictError); - } - } finally { - await server.stop(true); - } - }, - ); - - (isBun ? test : test.skip)("classifies an owner status timeout as transport", async () => { - const { controlTransportLayer } = await import("./platform-bun.ts"); - const server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - fetch: () => - new Response(new ReadableStream({ start() {} }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - }); - try { - const port = server.port; - expect(port).toBeTypeOf("number"); - if (port === undefined) return; - const endpoint = { - hostname: "127.0.0.1", - port, - url: `http://127.0.0.1:${port}`, - }; - const exit = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => transport.read(endpoint)).pipe( - Effect.provide(controlTransportLayer), - Effect.exit, - ), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.squash(exit.cause); - expect(failure).toBeInstanceOf(ControlTransportError); - if (failure instanceof ControlTransportError) expect(failure.reason).toBe("transport"); - } - } finally { - await server.stop(true); - } - }); - - (isBun ? test : test.skip)("classifies a non-HTTP owner response as protocol", async () => { - const { controlTransportLayer } = await import("./platform-bun.ts"); - const server = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - socket: { - data(socket) { - socket.end("not-http\r\n"); - }, - }, - }); - try { - const endpoint = { - hostname: "127.0.0.1", - port: server.port, - url: `http://127.0.0.1:${server.port}`, - }; - const exit = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => transport.read(endpoint)).pipe( - Effect.provide(controlTransportLayer), - Effect.exit, - ), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(ControlProtocolError); - } - } finally { - server.stop(true); - } - }); - - (isBun ? test : test.skip)("installs the complete owner app before bind returns", async () => { - const scope = Scope.makeUnsafe(); - const lifecycle = await Effect.runPromise( - makeSupervisorSessionFixture({ - ownershipId: "a".repeat(64), - ownerSessionId: "session", - daemonCliVersion: "test", - close: Effect.void, - }).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))), - ); - const application = { - app: await Effect.runPromise( - makeSupervisorControlApplication(lifecycle).pipe( - Effect.provide(Layer.succeed(Scope.Scope, scope)), - ), - ), - }; - const listener = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - transport.bind( - { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, - () => ({ - controlProtocol: "supabase-stack-control" as const, - controlProtocolVersion: 1 as const, - ownershipId: "a".repeat(64), - ownerSessionId: "session", - state: "starting" as const, - kind: "supervisor" as const, - ready: false, - daemonCliVersion: "test", - }), - () => "accepted" as const, - application, - ), - ).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(Scope.Scope, scope), - (await import("./platform-bun.ts")).controlTransportLayer, - ), - ), - ), - ); - try { - const address = listener.server.address; - expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); - if (!Predicate.isTagged(address, "TcpAddress")) return; - const response = await fetch(`http://127.0.0.1:${address.port}/owner`); - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ daemonCliVersion: "test" }); - } finally { - await Effect.runPromise(listener.close); - await Effect.runPromise(Scope.close(scope, Exit.void)); - } - }); - - (isBun ? test : test.skip)("returns a JSON error for malformed /stop requests", async () => { - const scope = Scope.makeUnsafe(); - const endpoint = { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }; - const listener = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - transport.bind( - endpoint, - () => ({ - controlProtocol: "supabase-stack-control" as const, - controlProtocolVersion: 1 as const, - ownershipId: "b".repeat(64), - ownerSessionId: "session", - state: "running" as const, - kind: "supervisor" as const, - ready: true, - daemonCliVersion: "test", - }), - () => "accepted" as const, - ), - ).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(Scope.Scope, scope), - (await import("./platform-bun.ts")).controlTransportLayer, - ), - ), - ), - ); - try { - const address = listener.server.address; - expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); - if (!Predicate.isTagged(address, "TcpAddress")) return; - const response = await fetch(`http://127.0.0.1:${address.port}/stop`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{", - }); - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ error: "Invalid stop request" }); - } finally { - await Effect.runPromise(listener.close); - await Effect.runPromise(Scope.close(scope, Exit.void)); - } - }); - - (isBun ? test : test.skip)("flushes /stop before graceful Bun close", async () => { - const scope = Scope.makeUnsafe(); - const lifecycle = await Effect.runPromise( - makeSupervisorSessionFixture({ - ownershipId: "c".repeat(64), - ownerSessionId: "session", - daemonCliVersion: "test", - close: Effect.void, - }).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))), - ); - const started = Deferred.makeUnsafe(); - const release = Deferred.makeUnsafe(); - await Effect.runPromise( - lifecycle.publishStack( - makeTestStack({ - stop: Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), - }), - ), - ); - const application = { - app: await Effect.runPromise( - makeSupervisorControlApplication(lifecycle).pipe( - Effect.provide(Layer.succeed(Scope.Scope, scope)), - ), - ), - }; - const listener = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - transport.bind( - { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, - () => ({ - controlProtocol: "supabase-stack-control" as const, - controlProtocolVersion: 1 as const, - ownershipId: "c".repeat(64), - ownerSessionId: "session", - state: "running" as const, - kind: "supervisor" as const, - ready: true, - daemonCliVersion: "test", - }), - () => "accepted" as const, - application, - ), - ).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(Scope.Scope, scope), - (await import("./platform-bun.ts")).controlTransportLayer, - ), - ), - ), - ); - try { - await Effect.runPromise(lifecycle.setClose(listener.close)); - const address = listener.server.address; - expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); - if (!Predicate.isTagged(address, "TcpAddress")) return; - const response = await fetch(`http://127.0.0.1:${address.port}/stop`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ownershipId: "c".repeat(64), - ownerSessionId: "session", - intent: "explicit", - }), - }); - const body = await response.text(); - await Effect.runPromise(Deferred.await(started)); - expect(response.status).toBe(202); - expect(body).toBe(JSON.stringify({ ok: true })); - await Effect.runPromise(Deferred.succeed(release, undefined)); - await Effect.runPromise(lifecycle.awaitShutdown); - await expect(fetch(`http://127.0.0.1:${address.port}/owner`)).rejects.toThrow(); - } finally { - await Effect.runPromise(listener.close); - await Effect.runPromise(Scope.close(scope, Exit.void)); - } - }); - - (isBun ? test : test.skip)( - "completes an immediate fenced stop while the response body is consumed", - async () => { - const scope = Scope.makeUnsafe(); - const ownershipId = "d".repeat(64); - const ownerSessionId = "immediate-stop-session"; - const lifecycle = await Effect.runPromise( - makeSupervisorSessionFixture({ - ownershipId, - ownerSessionId, - daemonCliVersion: "test", - }).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))), - ); - await Effect.runPromise(lifecycle.publishStack(makeTestStack())); - const application = { - app: await Effect.runPromise( - makeSupervisorControlApplication(lifecycle).pipe( - Effect.provide(Layer.succeed(Scope.Scope, scope)), - ), - ), - }; - const listener = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - transport.bind( - { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, - () => ({ - controlProtocol: "supabase-stack-control" as const, - controlProtocolVersion: 1 as const, - ownershipId, - ownerSessionId, - state: "running" as const, - kind: "supervisor" as const, - ready: true, - daemonCliVersion: "test", - }), - () => "accepted" as const, - application, - ), - ).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(Scope.Scope, scope), - (await import("./platform-bun.ts")).controlTransportLayer, - ), - ), - ), - ); - try { - await Effect.runPromise(lifecycle.setClose(listener.close)); - const address = listener.server.address; - expect(Predicate.isTagged(address, "TcpAddress")).toBe(true); - if (!Predicate.isTagged(address, "TcpAddress")) return; - const endpoint = { - hostname: "127.0.0.1", - port: address.port, - url: `http://127.0.0.1:${address.port}`, - }; - const { controlTransportLayer } = await import("./platform-bun.ts"); - const stopExit = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - makeControlClient(transport).stopSession(endpoint, ownershipId, ownerSessionId), - ).pipe(Effect.provide(controlTransportLayer), Effect.exit), - ); - expect(Exit.isSuccess(stopExit)).toBe(true); - await Effect.runPromise(lifecycle.awaitShutdown); - await expect(fetch(`http://127.0.0.1:${address.port}/owner`)).rejects.toThrow(); - } finally { - await Effect.runPromise(listener.close); - await Effect.runPromise(Scope.close(scope, Exit.void)); - } - }, - ); -}); diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts deleted file mode 100644 index efc1be56b2..0000000000 --- a/packages/stack/src/platform-bun.ts +++ /dev/null @@ -1,247 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Bun's native fetch and ReadableStream callbacks are Promise-based host boundaries. -import * as BunServices from "@effect/platform-bun/BunServices"; -import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -import { fileURLToPath } from "node:url"; -import { Data, Effect, Exit, Layer, Scope } from "effect"; -import { - HttpEffect, - HttpServer, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http"; -import type { PlatformFactory } from "./createStack.ts"; -import { readControlOwner } from "./ControlHttpReader.ts"; -import { requestControlStop } from "./ControlStopClient.ts"; -import { errorCode } from "./error-code.ts"; -import { STACK_RPC_PATH } from "./StackRpc.ts"; -import { - CONTROL_STATUS_PATH, - CONTROL_STOP_PATH, - ControlStopRequestSchema, - ControlBindError, - ControlTransport, - type ControlOwnerStatus, - type ControlStopRequest, - type ControlEndpoint, - type ControlApplication, -} from "./managed/control.ts"; - -class BunServerStopError extends Data.TaggedError("BunServerStopError")<{ - readonly cause: unknown; -}> {} -const controlTransport: ControlTransport["Service"] = { - bind: ( - endpoint: ControlEndpoint, - ownerStatus: () => ControlOwnerStatus, - onStop: (request: ControlStopRequest) => "accepted" | "conflict" | "busy" | "invalid", - application?: ControlApplication, - ) => - // Bun.serve starts synchronously inside BunHttpServer.make, before that - // constructor yields to register its scope finalizer. Keep only this - // acquisition window uninterruptible; request handling and listener close - // remain governed by the normal scope and interruption semantics. - Effect.uninterruptibleMask(() => - Effect.gen(function* () { - const parentScope = yield* Effect.scope; - if (application !== undefined) { - const webHandler = HttpEffect.toWebHandler(application.app); - const activeRpcRequests = new Set<{ readonly interrupt: () => void }>(); - const handler = async (request: Request): Promise => { - const path = new URL(request.url).pathname; - if (path !== STACK_RPC_PATH && path !== `${STACK_RPC_PATH}/`) { - return webHandler(request); - } - const controller = new AbortController(); - let cancelBody: (() => Promise) | undefined; - const onClientAbort = () => controller.abort(request.signal.reason); - const active = { - interrupt: () => { - controller.abort(); - void cancelBody?.(); - }, - }; - const release = () => { - request.signal.removeEventListener("abort", onClientAbort); - activeRpcRequests.delete(active); - }; - activeRpcRequests.add(active); - request.signal.addEventListener("abort", onClientAbort, { once: true }); - if (request.signal.aborted) onClientAbort(); - try { - const response = await webHandler( - new Request(request, { signal: controller.signal }), - ); - if (response.body === null) { - release(); - return response; - } - const reader = response.body.getReader(); - cancelBody = () => reader.cancel(); - const body = new ReadableStream({ - pull: async (streamController) => { - try { - const next = await reader.read(); - if (next.done) { - release(); - streamController.close(); - } else { - streamController.enqueue(next.value); - } - } catch (cause) { - release(); - streamController.error(cause); - } - }, - cancel: async (reason) => { - release(); - await reader.cancel(reason); - }, - }); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } catch (cause) { - release(); - throw cause; - } - }; - const server = yield* Effect.try({ - try: () => - Bun.serve({ - hostname: endpoint.hostname, - port: endpoint.port, - idleTimeout: 0, - fetch: handler, - }), - catch: (cause) => - new ControlBindError({ - endpoint, - reason: errorCode(cause) === "EADDRINUSE" ? "in-use" : "failed", - cause, - }), - }); - const close = yield* Effect.cached( - Effect.tryPromise({ - try: () => { - const stopped = server.stop(false); - for (const request of activeRpcRequests) request.interrupt(); - return stopped; - }, - catch: (cause) => new BunServerStopError({ cause }), - }).pipe(Effect.asVoid, Effect.orDie), - ); - const service = HttpServer.make({ - address: { - _tag: "TcpAddress", - hostname: endpoint.hostname, - port: server.port ?? endpoint.port, - }, - serve: () => Effect.void, - }); - yield* Scope.addFinalizer(parentScope, close); - return { server: service, close }; - } - const serverScope = yield* Scope.fork(parentScope); - const server = yield* BunHttpServer.make({ - hostname: endpoint.hostname, - port: endpoint.port, - // Stack lifecycle requests can legitimately take up to the configured - // readiness deadline. Bun's 10-second default would otherwise close - // the control connection while the stack continues starting. - idleTimeout: 0, - disablePreemptiveShutdown: true, - ...(application === undefined - ? { - routes: { - [CONTROL_STATUS_PATH]: { - GET: () => - new Response(JSON.stringify(ownerStatus()), { - status: 200, - headers: { "content-type": "application/json" }, - }), - }, - }, - } - : {}), - }).pipe( - Scope.provide(serverScope), - Effect.catchDefect((cause) => - Effect.fail( - new ControlBindError({ - endpoint, - reason: errorCode(cause) === "EADDRINUSE" ? "in-use" : "failed", - cause, - }), - ), - ), - ); - yield* ( - application === undefined - ? server.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.url === CONTROL_STOP_PATH && request.method === "POST") { - return yield* HttpServerRequest.schemaBodyJson(ControlStopRequestSchema).pipe( - Effect.map((stopRequest) => { - const decision = onStop(stopRequest); - const status = - decision === "accepted" - ? 202 - : decision === "conflict" - ? 409 - : decision === "busy" - ? 423 - : 400; - return HttpServerResponse.jsonUnsafe( - decision === "accepted" ? { ok: true } : { error: decision }, - { status }, - ); - }), - Effect.catchTags({ - SchemaError: () => - Effect.succeed( - HttpServerResponse.jsonUnsafe( - { error: "Invalid stop request" }, - { status: 400 }, - ), - ), - HttpServerError: () => - Effect.succeed( - HttpServerResponse.jsonUnsafe( - { error: "Invalid stop request" }, - { status: 400 }, - ), - ), - }), - ); - } - return HttpServerResponse.jsonUnsafe( - { error: "Stack supervisor is starting" }, - { status: 503 }, - ); - }), - ) - : Effect.void - ).pipe(Scope.provide(serverScope)); - return { - server, - close: Scope.close(serverScope, Exit.void), - }; - }), - ), - read: readControlOwner, - requestStop: requestControlStop, -}; - -export const controlTransportLayer = Layer.succeed(ControlTransport, controlTransport); - -export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => - Layer.mergeAll( - BunServices.layer, - Layer.unwrap(releaseApiPort.pipe(Effect.as(BunHttpServer.layer({ port: apiPort })))), - ); - -/** Internal source-mode child target. Compiled CLI dispatch still uses the daemon-bun export. */ -export const daemonEntryPoint = fileURLToPath(new URL("./daemon-bun.ts", import.meta.url)); diff --git a/packages/stack/src/platform-node.integration.test.ts b/packages/stack/src/platform-node.integration.test.ts deleted file mode 100644 index 515f05a5da..0000000000 --- a/packages/stack/src/platform-node.integration.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/run-effect-inside-effect -- Node transport tests coordinate native HTTP agents, sockets, and readiness callbacks in the integration harness. -import { Deferred, Cause, Effect, Exit, Predicate } from "effect"; -import { HttpServerResponse } from "effect/unstable/http"; -import { Agent, createServer, get, type Server } from "node:http"; -import type { Socket } from "node:net"; -import { describe, expect, test } from "vitest"; -import { - ControlProtocolError, - ControlStopConflictError, - ControlTransport, - ControlTransportError, - makeControlClient, - type ControlEndpoint, -} from "./managed/control.ts"; -import { controlTransportLayer } from "./platform-node.ts"; - -const MAX_CONTROL_RESPONSE_BYTES = 64 * 1024; - -const listen = (server: Server): Promise => - new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address === null || typeof address === "string") { - reject(new Error("Expected TCP address")); - return; - } - resolve({ - hostname: "127.0.0.1", - port: address.port, - url: `http://127.0.0.1:${address.port}`, - }); - }); - }); - -const close = (server: Server, sockets: ReadonlySet): Promise => - new Promise((resolve, reject) => { - for (const socket of sockets) socket.destroy(); - if (!server.listening) { - resolve(); - return; - } - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); - -const withTimeout = async (promise: Promise, timeoutMs = 5_000): Promise => { - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); - }), - ]); - } finally { - if (timeout !== undefined) clearTimeout(timeout); - } -}; - -const runRead = (endpoint: ControlEndpoint) => - Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => transport.read(endpoint)).pipe( - Effect.provide(controlTransportLayer), - Effect.exit, - ), - ); - -const runStop = (endpoint: ControlEndpoint) => - Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - transport.requestStop(endpoint, { - ownershipId: "0".repeat(64), - ownerSessionId: "session", - intent: "explicit", - }), - ).pipe(Effect.provide(controlTransportLayer), Effect.exit), - ); - -const expectTypedFailure = ( - exit: Exit.Exit, - error: new (...args: any[]) => E, -) => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(error); -}; - -describe("Node control transport", () => { - test("closes an idle keep-alive RPC socket immediately", async () => { - const agent = new Agent({ keepAlive: true, maxSockets: 1 }); - const closeCompleted = Deferred.makeUnsafe(); - let closeFiber: Promise | undefined; - try { - const result = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const transport = yield* ControlTransport; - const listener = yield* transport.bind( - { hostname: "127.0.0.1", port: 0, url: "http://127.0.0.1:0" }, - () => ({ - controlProtocol: "supabase-stack-control" as const, - controlProtocolVersion: 1 as const, - ownershipId: "0".repeat(64), - ownerSessionId: "keep-alive-session", - kind: "supervisor" as const, - state: "running" as const, - ready: true, - daemonCliVersion: "test", - }), - () => "accepted", - { app: Effect.succeed(HttpServerResponse.text("ok")) }, - ); - const address = listener.server.address; - if (!Predicate.isTagged(address, "TcpAddress")) throw new Error("expected TCP address"); - yield* Effect.tryPromise( - () => - new Promise((resolve, reject) => { - const request = get( - { - host: "127.0.0.1", - port: address.port, - path: "/rpc", - agent, - }, - (response: import("node:http").IncomingMessage) => { - response.resume(); - response.once("end", resolve); - }, - ); - request.once("error", reject); - }), - ); - const close = listener.close.pipe( - Effect.andThen(Deferred.succeed(closeCompleted, undefined)), - ); - closeFiber = Effect.runPromise(close); - const closed = yield* Deferred.await(closeCompleted).pipe( - Effect.timeout("1 second"), - Effect.exit, - ); - return closed; - }).pipe(Effect.provide(controlTransportLayer)), - ), - ); - expect(Exit.isSuccess(result)).toBe(true); - } finally { - agent.destroy(); - await closeFiber; - } - }); - - test("classifies a fenced stop conflict distinctly from transport failure", async () => { - const sockets = new Set(); - const server = createServer((_request, response) => { - response.writeHead(409, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "conflict" })); - }); - server.on("connection", (socket) => sockets.add(socket)); - try { - const endpoint = await listen(server); - const exit = await runStop(endpoint); - expectTypedFailure(exit, ControlStopConflictError); - } finally { - await close(server, sockets); - } - }); - - test("stable client completes the captured stop after a replacement conflict", async () => { - const ownershipId = "0".repeat(64); - const ownerSessionId = "captured-session"; - const stopBodies: Array = []; - const sockets = new Set(); - const server = createServer((request, response) => { - if (request.url === "/owner") { - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId, - ownerSessionId: "replacement-session", - kind: "supervisor", - state: "running", - ready: true, - daemonCliVersion: "test", - }), - ); - return; - } - request.setEncoding("utf8"); - let body = ""; - request.on("data", (chunk: string) => { - body += chunk; - }); - request.once("end", () => { - stopBodies.push(body); - response.writeHead(409, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "conflict" })); - }); - }); - server.on("connection", (socket) => sockets.add(socket)); - try { - const endpoint = await listen(server); - const exit = await Effect.runPromise( - Effect.flatMap(ControlTransport, (transport) => - makeControlClient(transport).stopSession(endpoint, ownershipId, ownerSessionId), - ).pipe(Effect.provide(controlTransportLayer), Effect.exit), - ); - expect(Exit.isSuccess(exit)).toBe(true); - expect(stopBodies).toEqual([ - JSON.stringify({ ownershipId, ownerSessionId, intent: "explicit" }), - ]); - } finally { - await close(server, sockets); - } - }); - - test("maps post-header resets from owner and stop probes to typed failures", async () => { - let requestCount = 0; - let resolveRequest!: () => void; - let requestReady = Promise.resolve(); - const sockets = new Set(); - const server = createServer((_request, response) => { - sockets.add(response.socket!); - response.socket!.once("close", () => sockets.delete(response.socket!)); - resolveRequest(); - response.writeHead(200, { "content-type": "application/json" }); - response.flushHeaders(); - response.write(requestCount++ === 0 ? '{"protocolVersion":' : "{", () => response.destroy()); - }); - server.on("connection", (socket) => sockets.add(socket)); - try { - const endpoint = await listen(server); - - const prepareRequest = () => { - requestReady = new Promise((resolve) => { - resolveRequest = resolve; - }); - }; - - prepareRequest(); - const readExitPromise = runRead(endpoint); - await requestReady; - const readExit = await withTimeout(readExitPromise); - expectTypedFailure(readExit, ControlTransportError); - - prepareRequest(); - const stopExitPromise = runStop(endpoint); - await requestReady; - const stopExit = await withTimeout(stopExitPromise); - expectTypedFailure(stopExit, ControlTransportError); - } finally { - await close(server, sockets); - } - }); - - test("classifies control timeouts as transport instead of absence", async () => { - const sockets = new Set(); - const server = createServer((_request, response) => { - sockets.add(response.socket!); - response.socket!.once("close", () => sockets.delete(response.socket!)); - // Keep the status request open until the client-side deadline expires. - }); - server.on("connection", (socket) => sockets.add(socket)); - try { - const endpoint = await listen(server); - // This is only a deadlock guard; the assertion is about the typed - // failure, not wall-clock scheduling on a loaded runner. - const readExit = await withTimeout(runRead(endpoint), 10_000); - expectTypedFailure(readExit, ControlTransportError); - if (Exit.isFailure(readExit)) { - expect(Cause.squash(readExit.cause)).toMatchObject({ reason: "transport" }); - } - - const stopExit = await withTimeout(runStop(endpoint), 10_000); - expectTypedFailure(stopExit, ControlTransportError); - if (Exit.isFailure(stopExit)) { - expect(Cause.squash(stopExit.cause)).toMatchObject({ reason: "transport" }); - } - } finally { - await close(server, sockets); - } - }); - - test("bounds an oversized owner status and closes the exact connection", async () => { - let resolveRequest!: () => void; - const requestReady = new Promise((resolve) => { - resolveRequest = resolve; - }); - let resolveClosed!: () => void; - const connectionClosed = new Promise((resolve) => { - resolveClosed = resolve; - }); - const sockets = new Set(); - const server = createServer((_request, response) => { - const socket = response.socket; - if (socket === null) throw new Error("Expected request socket"); - sockets.add(socket); - socket.once("close", () => { - sockets.delete(socket); - resolveClosed(); - }); - resolveRequest(); - response.writeHead(200, { "content-type": "application/json" }); - response.write("x".repeat(MAX_CONTROL_RESPONSE_BYTES + 1)); - }); - server.on("connection", (socket) => sockets.add(socket)); - try { - const endpoint = await listen(server); - const exitPromise = runRead(endpoint); - await requestReady; - const exit = await withTimeout(exitPromise); - expectTypedFailure(exit, ControlProtocolError); - await withTimeout(connectionClosed); - } finally { - await close(server, sockets); - } - }); -}); diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts deleted file mode 100644 index a68709154b..0000000000 --- a/packages/stack/src/platform-node.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- The control server owns the native Node HTTP listener at this platform boundary. -import { createServer, type Server } from "node:http"; -import { fileURLToPath } from "node:url"; -import { Effect, Layer, Scope, Schema } from "effect"; -import { HttpServer } from "effect/unstable/http"; -import type { PlatformFactory } from "./createStack.ts"; -import { readControlOwner } from "./ControlHttpReader.ts"; -import { requestControlStop } from "./ControlStopClient.ts"; -import { errorCode } from "./error-code.ts"; -import { STACK_RPC_PATH } from "./StackRpc.ts"; -import { - CONTROL_STATUS_PATH, - CONTROL_STOP_PATH, - ControlStopRequestSchema, - type ControlStopRequest, - ControlBindError, - ControlTransport, - type ControlOwnerStatus, - type ControlEndpoint, - type ControlApplication, -} from "./managed/control.ts"; - -const closeControlServer = ( - server: Server, - interruptRpcRequests: () => void = () => {}, -): Effect.Effect => - Effect.callback((resume) => { - if (!server.listening) { - resume(Effect.void); - return Effect.void; - } - server.close((error) => resume(error === undefined ? Effect.void : Effect.die(error))); - interruptRpcRequests(); - server.closeIdleConnections(); - return Effect.void; - }); - -const controlTransport: ControlTransport["Service"] = { - bind: ( - endpoint: ControlEndpoint, - ownerStatus: () => ControlOwnerStatus, - onStop: (request: ControlStopRequest) => "accepted" | "conflict" | "busy" | "invalid", - application?: ControlApplication, - ) => { - const rawServer = createServer( - application === undefined - ? (request, response) => { - if (request.url === CONTROL_STOP_PATH && request.method === "POST") { - const chunks: Buffer[] = []; - let size = 0; - request.on("data", (chunk: Buffer) => { - size += chunk.byteLength; - if (size <= 16 * 1024) chunks.push(chunk); - }); - request.once("end", () => { - let decoded: ControlStopRequest | undefined; - try { - const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); - decoded = Schema.decodeUnknownSync(ControlStopRequestSchema)(parsed); - } catch { - response.writeHead(400, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "Invalid stop request" })); - return; - } - const decision = onStop(decoded); - const status = - decision === "accepted" - ? 202 - : decision === "conflict" - ? 409 - : decision === "busy" - ? 423 - : 400; - response.writeHead(status, { "content-type": "application/json" }); - response.end( - JSON.stringify(decision === "accepted" ? { ok: true } : { error: decision }), - ); - }); - return; - } - if (request.url !== CONTROL_STATUS_PATH || request.method !== "GET") { - response.writeHead(503, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "Stack supervisor is starting" })); - return; - } - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify(ownerStatus())); - } - : undefined, - ); - if (application !== undefined) { - return Effect.gen(function* () { - const scope = yield* Effect.scope; - const activeRpcRequests = new Set<() => void>(); - const interruptRpcRequests = () => { - for (const interrupt of activeRpcRequests) interrupt(); - }; - const close = closeControlServer(rawServer, interruptRpcRequests); - const handler = yield* NodeHttpServer.makeHandler(application.app, { - scope, - }); - rawServer.removeAllListeners("request"); - rawServer.on("request", (request, response) => { - if (request.url === STACK_RPC_PATH || request.url === `${STACK_RPC_PATH}/`) { - const interrupt = () => response.destroy(); - activeRpcRequests.add(interrupt); - const release = () => activeRpcRequests.delete(interrupt); - response.once("finish", release); - response.once("close", release); - } - }); - rawServer.on("request", handler); - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - yield* restore( - Effect.callback((resume) => { - const onError = (cause: Error) => { - rawServer.off("error", onError); - resume(Effect.fail(cause)); - }; - rawServer.once("error", onError); - rawServer.listen({ host: endpoint.hostname, port: endpoint.port }, () => { - rawServer.off("error", onError); - resume(Effect.void); - }); - return Effect.sync(() => { - rawServer.off("error", onError); - if (rawServer.listening) rawServer.close(); - else rawServer.once("listening", () => rawServer.close()); - }); - }), - ).pipe( - Effect.mapError( - (cause) => - new ControlBindError({ - endpoint, - reason: errorCode(cause) === "EADDRINUSE" ? "in-use" : "failed", - cause, - }), - ), - ); - const boundAddress = rawServer.address(); - const server = HttpServer.make({ - address: { - _tag: "TcpAddress", - hostname: endpoint.hostname, - port: - typeof boundAddress === "object" && boundAddress !== null - ? boundAddress.port - : endpoint.port, - }, - serve: () => Effect.void, - }); - yield* Scope.addFinalizer(scope, close); - return { server, close }; - }), - ); - }); - } - return NodeHttpServer.make(() => rawServer, { - host: endpoint.hostname, - port: endpoint.port, - disablePreemptiveShutdown: true, - }).pipe( - Effect.map((server) => ({ server, close: closeControlServer(rawServer) })), - Effect.mapError( - (cause) => - new ControlBindError({ - endpoint, - reason: errorCode(cause) === "EADDRINUSE" ? "in-use" : "failed", - cause, - }), - ), - ); - }, - read: readControlOwner, - requestStop: requestControlStop, -}; - -export const controlTransportLayer = Layer.succeed(ControlTransport, controlTransport); - -export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => - Layer.mergeAll( - NodeServices.layer, - Layer.unwrap( - releaseApiPort.pipe( - Effect.as(NodeHttpServer.layer(() => createServer(), { port: apiPort }).pipe(Layer.orDie)), - ), - ), - ); - -/** Internal child-process target. It is intentionally absent from package exports. */ -export const daemonEntryPoint = fileURLToPath(new URL("./daemon-node.ts", import.meta.url)); diff --git a/packages/stack/src/prefetch.ts b/packages/stack/src/prefetch.ts deleted file mode 100644 index c4a26af9fc..0000000000 --- a/packages/stack/src/prefetch.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Effect } from "effect"; -import type { ContainerRuntime } from "./ContainerRuntime.ts"; -import type { StackPreparationError } from "./StackPreparation.ts"; -import { - type PreparedStackArtifacts, - type ServiceResolution, - type StackPreparationInput, -} from "./StackPreparation.ts"; -import { StackPreparation } from "./StackPreparation.ts"; -import type { ServiceName } from "./ServiceName.ts"; - -export interface PrefetchOptions { - readonly versions?: StackPreparationInput["versions"]; - readonly services?: StackPreparationInput["services"]; - readonly enabledServices?: StackPreparationInput["enabledServices"]; - readonly mode?: "native" | "docker"; -} - -export type PrefetchEffectOptions = Omit & - ( - | { readonly mode?: "native"; readonly containerRuntime?: never } - | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime } - ); - -export type PrefetchResult = Partial>; - -const toPrefetchResult = (artifacts: PreparedStackArtifacts): PrefetchResult => - artifacts.resolutions; - -export const prefetch = ( - options?: PrefetchEffectOptions, -): Effect.Effect => - Effect.gen(function* () { - const preparation = yield* StackPreparation; - const input: StackPreparationInput = - options?.mode === "docker" ? options : { ...options, mode: "native" }; - return yield* preparation.prepare(input).pipe(Effect.map(toPrefetchResult)); - }); diff --git a/packages/stack/src/prefetch.unit.test.ts b/packages/stack/src/prefetch.unit.test.ts deleted file mode 100644 index 6895ff2e3c..0000000000 --- a/packages/stack/src/prefetch.unit.test.ts +++ /dev/null @@ -1,516 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Prefetch tests call the Promise-returning public helper from Vitest callbacks. - -import { describe, expect, test } from "vitest"; -import { - Cause, - Deferred, - Effect, - Exit, - Fiber, - Layer, - Predicate, - Queue, - Result, - Sink, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; -import { BinaryNotFoundError, DockerPullError } from "./errors.ts"; -import { prefetch } from "./prefetch.ts"; -import { - ServiceDownloadFinished, - ServiceDownloadStarted, - PreparationCompleted, - StackPreparation, -} from "./StackPreparation.ts"; -import { DEFAULT_VERSIONS, SERVICE_NAMES, dockerImageForService } from "./versions.ts"; - -const encoder = new TextEncoder(); -const defaultAuthGhcrImage = `ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`; - -interface SpawnResult { - readonly exitCode: number; - readonly stderr?: ReadonlyArray; - readonly defect?: unknown; -} - -function mockSequenceSpawner(results: ReadonlyArray) { - const spawned: Array<{ command: string; args: ReadonlyArray }> = []; - let index = 0; - - return { - layer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const standardCommand = Predicate.isTagged(command, "StandardCommand"); - const cmd = standardCommand ? command.command : ""; - const args = standardCommand ? command.args : []; - spawned.push({ command: cmd, args }); - - const result = results[index] ?? { exitCode: 0 }; - index += 1; - if (result.defect !== undefined) { - return yield* Effect.die(result.defect); - } - - const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(2000 + index), - stdout: Stream.empty, - stderr: Stream.fromIterable( - (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)), - ), - all: Stream.empty, - exitCode: Deferred.await(exitDeferred), - isRunning: Effect.succeed(true), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - get spawned() { - return spawned; - }, - }; -} - -describe("prefetch", () => { - test("prefetches every native-capable service by default in native mode", async () => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner( - Array.from({ length: SERVICE_NAMES.length }, () => ({ - exitCode: 0, - })), - ); - - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const result = await Effect.runPromise(prefetch().pipe(Effect.provide(layer))); - - expect(Object.keys(result).sort()).toEqual(["auth", "postgres", "postgrest"]); - }); - - test("limits concurrent image preparation to four services", async () => { - const started = await Effect.runPromise(Queue.unbounded()); - const release = await Effect.runPromise(Deferred.make()); - const services = ["postgres", "mailpit", "edge-runtime", "realtime", "pooler"] as const; - let pullStarts = 0; - const spawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const standardCommand = Predicate.isTagged(command, "StandardCommand"); - const args = standardCommand ? command.args : []; - const image = args[1] ?? "unknown"; - if (args[0] === "pull") { - pullStarts += 1; - yield* Queue.offer(started, image); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(3000 + pullStarts), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Deferred.await(release).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - isRunning: Effect.succeed(true), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - } - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(2000), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ); - const layer = StackPreparation.layer.pipe( - Layer.provide(mockBinaryResolver().layer), - Layer.provide(spawner), - ); - - const preparation = Effect.runFork( - prefetch({ mode: "docker", containerRuntime: "docker", services }).pipe( - Effect.provide(layer), - ), - ); - await Effect.runPromise( - Effect.all( - Array.from({ length: 4 }, () => Queue.take(started)), - { discard: true }, - ), - ); - expect(pullStarts).toBe(4); - - await Effect.runPromise(Deferred.succeed(release, undefined)); - await Effect.runPromise(Fiber.join(preparation)); - expect(pullStarts).toBe(5); - }); - - test("marks a Podman daemon disconnect on DockerPullError", async () => { - const resolver = mockBinaryResolver(); - // One image inspect followed by one canonical pull. Preparation must fail - // rather than defer the pull to startup. - const spawner = mockSequenceSpawner([ - { exitCode: 1, stderr: ["not found"] }, - { exitCode: 1, stderr: ["Cannot connect to Podman"] }, - ]); - - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const error = await Effect.runPromise( - prefetch({ mode: "docker", containerRuntime: "podman", services: ["auth"] }).pipe( - Effect.provide(layer), - Effect.flip, - ), - ); - - expect(error).toBeInstanceOf(DockerPullError); - if (!(error instanceof DockerPullError)) throw error; - expect(error.daemonDown).toBe(true); - }); - - test("preserves unexpected image pull defects", async () => { - const defect = new Error("container runtime callback defect"); - const spawner = mockSequenceSpawner([ - { exitCode: 1, stderr: ["not found"] }, - { exitCode: 0, defect }, - ]); - const layer = StackPreparation.layer.pipe( - Layer.provide(mockBinaryResolver().layer), - Layer.provide(spawner.layer), - ); - - const exit = await Effect.runPromiseExit( - prefetch({ mode: "docker", containerRuntime: "docker", services: ["auth"] }).pipe( - Effect.provide(layer), - ), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const found = Cause.findDefect(exit.cause); - expect(Result.isSuccess(found)).toBe(true); - if (Result.isSuccess(found)) { - expect(found.success).toBe(defect); - } - } - }); - - test("prefetching one service includes its required preparation dependencies", async () => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const result = await Effect.runPromise( - prefetch({ mode: "docker", containerRuntime: "docker", services: ["postgrest"] }).pipe( - Effect.provide(layer), - ), - ); - - expect(Object.keys(result).sort()).toEqual(["postgres", "postgrest"]); - }); - - test.each([ - ["storage", "docker", ["imgproxy", "postgres", "storage"]], - ["imgproxy", "podman", ["imgproxy", "postgres", "storage"]], - ["vector", "docker", ["analytics", "postgres", "vector"]], - ] as const)( - "prefetching %s includes every service it can start through the graph", - async (service, containerRuntime, expected) => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }, { exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const result = await Effect.runPromise( - prefetch({ mode: "docker", containerRuntime, services: [service] }).pipe( - Effect.provide(layer), - ), - ); - - expect(Object.keys(result).sort()).toEqual(expected); - expect(spawner.spawned).toHaveLength(expected.length); - expect(spawner.spawned.every(({ command }) => command === containerRuntime)).toBe(true); - expect(spawner.spawned).toContainEqual({ - command: containerRuntime, - args: ["image", "inspect", dockerImageForService(service, DEFAULT_VERSIONS[service])], - }); - }, - ); - - test("does not prepare dependencies that are disabled in the stack", async () => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }, { exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const preparation = yield* StackPreparation; - return yield* preparation.prepare({ - mode: "docker", - containerRuntime: "docker", - services: ["studio"], - enabledServices: ["postgres", "pgmeta", "studio"], - }); - }).pipe(Effect.provide(layer)), - ); - - expect(Object.keys(result.resolutions).sort()).toEqual(["pgmeta", "postgres", "studio"]); - }); - - test("Docker mode uses Docker when a native artifact is unavailable", async () => { - const resolver = mockBinaryResolver({ - binaries: { postgres: "/cache/postgres/native" }, - }); - const spawner = mockSequenceSpawner([{ exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const result = await Effect.runPromise( - prefetch({ mode: "docker", containerRuntime: "docker", services: ["postgrest"] }).pipe( - Effect.provide(layer), - ), - ); - - expect(result.postgrest).toEqual({ - type: "docker", - image: `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, - }); - }); - - test("Docker preparation applies the catalog v prefix to bare versions", async () => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner([{ exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const result = await Effect.runPromise( - prefetch({ - mode: "docker", - containerRuntime: "docker", - services: ["postgrest"], - versions: { postgrest: "16.1" }, - }).pipe(Effect.provide(layer)), - ); - - expect(result.postgrest).toEqual({ - type: "docker", - image: "ghcr.io/supabase/cli/postgrest:v16.1", - }); - }); - - test("native preparation applies the catalog v prefix before binary resolution", async () => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner([]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - await Effect.runPromise( - prefetch({ - mode: "native", - services: ["postgrest"], - versions: { postgrest: "16.1" }, - }).pipe(Effect.provide(layer)), - ); - - expect(resolver.resolved).toContainEqual({ service: "postgrest", version: "v16.1" }); - }); - - test("native mode rejects services that have no native runtime", async () => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner([]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const error = await Effect.runPromise( - prefetch({ mode: "native", services: ["edge-runtime"] }).pipe( - Effect.provide(layer), - Effect.flip, - ), - ); - - expect(error).toBeInstanceOf(BinaryNotFoundError); - }); - - test("native mode does not fall back when a native artifact is unavailable", async () => { - const resolver = mockBinaryResolver({ failServices: ["postgrest"] }); - const spawner = mockSequenceSpawner([{ exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const error = await Effect.runPromise( - prefetch({ mode: "native", services: ["postgrest"] }).pipe( - Effect.provide(layer), - Effect.flip, - ), - ); - - expect(error).toBeInstanceOf(BinaryNotFoundError); - expect(spawner.spawned).toEqual([]); - }); - - test("prefetches pgmeta using its published container tag", async () => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner([{ exitCode: 0 }, { exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const result = await Effect.runPromise( - prefetch({ mode: "docker", containerRuntime: "docker", services: ["pgmeta"] }).pipe( - Effect.provide(layer), - ), - ); - - expect(result.pgmeta).toEqual({ - type: "docker", - image: "ghcr.io/supabase/cli/pgmeta:v0.98.0", - }); - }); - - test("does not report downloading when the docker image is already cached locally", async () => { - const resolver = mockBinaryResolver({ failServices: ["auth"] }); - const spawner = mockSequenceSpawner([{ exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - const result = await Effect.runPromise( - Effect.gen(function* () { - const preparation = yield* StackPreparation; - const streamEvents = yield* preparation - .prepareEvents({ mode: "docker", containerRuntime: "docker", services: ["auth"] }) - .pipe(Stream.runCollect); - const downloadEvents = streamEvents.flatMap((event) => - event instanceof ServiceDownloadStarted || event instanceof ServiceDownloadFinished - ? [ - Predicate.isTagged(event, "ServiceDownloadStarted") - ? "ServiceDownloadStarted" - : "ServiceDownloadFinished", - ] - : [], - ); - const completed = streamEvents.find((event) => event instanceof PreparationCompleted); - expect(downloadEvents).toEqual([]); - return completed instanceof PreparationCompleted ? completed.artifacts.resolutions : {}; - }).pipe(Effect.provide(layer)), - ); - - expect(result.auth).toEqual({ - type: "docker", - image: defaultAuthGhcrImage, - }); - }); - - test("uses Docker for every service in Docker mode", async () => { - const resolver = mockBinaryResolver(); - const spawner = mockSequenceSpawner([{ exitCode: 0 }]); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(spawner.layer), - ); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const preparation = yield* StackPreparation; - const artifacts = yield* preparation.prepare({ - mode: "docker", - containerRuntime: "docker", - services: ["edge-runtime"], - }); - return artifacts.resolutions; - }).pipe(Effect.provide(layer)), - ); - - expect(result["edge-runtime"]).toEqual({ - type: "docker", - image: `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, - }); - expect(resolver.resolved).toEqual([]); - }); - - test("concurrent prefetches share one materialization and return the same result", async () => { - const [result, resolved] = await Effect.runPromise( - Effect.gen(function* () { - const preparationStarted = yield* Deferred.make(); - const releasePreparation = yield* Deferred.make(); - const resolver = mockBinaryResolver({ - downloadedServices: ["auth"], - beforeResolve: ({ service }) => - service === "auth" - ? Deferred.succeed(preparationStarted, undefined).pipe( - Effect.andThen(Deferred.await(releasePreparation)), - ) - : Effect.void, - }); - const layer = StackPreparation.layer.pipe( - Layer.provide(resolver.layer), - Layer.provide(mockSequenceSpawner([]).layer), - ); - return yield* Effect.gen(function* () { - const first = yield* prefetch({ mode: "native", services: ["auth"] }).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Deferred.await(preparationStarted); - const second = yield* prefetch({ mode: "native", services: ["auth"] }).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Deferred.succeed(releasePreparation, undefined); - return [ - yield* Effect.all([Fiber.join(first), Fiber.join(second)]), - resolver.resolved, - ] as const; - }).pipe(Effect.provide(layer)); - }), - ); - - expect(result[0]).toEqual(result[1]); - expect(resolved.filter(({ service }) => service === "auth")).toHaveLength(1); - }); -}); diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts new file mode 100644 index 0000000000..4825863d65 --- /dev/null +++ b/packages/stack/src/public/index.ts @@ -0,0 +1 @@ +/** Public stack model and APIs. */ diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts deleted file mode 100644 index 6f4bcfcf03..0000000000 --- a/packages/stack/src/services/analytics.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerPortMapArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -interface DockerAnalyticsOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; - readonly hostPort: number; - readonly platformOs: string; - readonly dbHost: string; - readonly dbPort: number; - readonly apiKey: string; - readonly backend: "postgres" | "bigquery"; - readonly dependencies: ReadonlyArray; -} - -const ANALYTICS_CONTAINER_PORT = 4000; - -export const analyticsDockerRuntimeNetwork = ( - _os: string, - _hostPort: number, - _serviceHost: string, -): { readonly listenPort: number; readonly nodeHost: string } => ({ - listenPort: ANALYTICS_CONTAINER_PORT, - nodeHost: "0.0.0.0", -}); - -const analyticsHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Http", - host: "127.0.0.1", - port, - path: "/health", - scheme: "http", - }, - ...stackHealthBudgets.analytics, -}); - -export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): ServiceDef => { - const runtimeNetwork = analyticsDockerRuntimeNetwork(opts.platformOs, opts.hostPort, opts.dbHost); - const env: Record = { - PORT: String(runtimeNetwork.listenPort), - PHX_HTTP_PORT: String(runtimeNetwork.listenPort), - DB_DATABASE: "_supabase", - DB_HOSTNAME: opts.dbHost, - DB_PORT: String(opts.dbPort), - DB_SCHEMA: "_analytics", - DB_USERNAME: "postgres", - DB_PASSWORD: "postgres", - LOGFLARE_MIN_CLUSTER_SIZE: "1", - LOGFLARE_SINGLE_TENANT: "true", - LOGFLARE_SUPABASE_MODE: "true", - LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.apiKey, - LOGFLARE_LOG_LEVEL: "warn", - LOGFLARE_NODE_HOST: runtimeNetwork.nodeHost, - LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", - RELEASE_COOKIE: "cookie", - }; - - if (opts.backend === "postgres") { - env.POSTGRES_BACKEND_URL = `postgresql://postgres:postgres@${opts.dbHost}:${opts.dbPort}/_supabase`; - env.POSTGRES_BACKEND_SCHEMA = "_analytics"; - } else { - env.GOOGLE_DATASET_ID_APPEND = "_prod"; - env.GOOGLE_PROJECT_ID = "local"; - env.GOOGLE_PROJECT_NUMBER = "0"; - } - - return dockerRunService({ - runtime: opts.runtime, - name: "analytics", - identity: opts.identity, - image: opts.image, - networkArgs: dockerPortMapArgs(opts.platformOs, [ - { host: opts.hostPort, container: ANALYTICS_CONTAINER_PORT }, - ]), - env, - dependencies: opts.dependencies, - healthCheck: analyticsHealthCheck(opts.hostPort), - }); -}; diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts deleted file mode 100644 index 111c07335b..0000000000 --- a/packages/stack/src/services/auth.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; -import { - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; - -interface AuthServiceOptions { - readonly dbPort: number; - readonly authPort: number; - readonly siteUrl: string; - readonly jwtSecret: string; - readonly jwtExpiry: number; - readonly externalUrl: string; - readonly smtpHost?: string; - readonly smtpPort?: number; - readonly smtpAdminEmail?: string; - readonly smtpSenderName?: string; - readonly dependencies: ReadonlyArray; -} - -interface NativeAuthOptions extends AuthServiceOptions { - readonly binPath: string; -} - -interface DockerAuthOptions extends AuthServiceOptions, ContainerRuntimeOptions { - readonly image: string; - readonly dbHost: string; - readonly platformOs: string; - readonly identity: StackIdentity; -} - -const authEnv = (opts: AuthServiceOptions, dbHost = "127.0.0.1"): Record => ({ - GOTRUE_DB_DATABASE_URL: `postgresql://supabase_auth_admin:postgres@${dbHost}:${opts.dbPort}/postgres`, - GOTRUE_DB_DRIVER: "postgres", - GOTRUE_SITE_URL: opts.siteUrl, - GOTRUE_JWT_SECRET: opts.jwtSecret, - GOTRUE_JWT_EXP: String(opts.jwtExpiry), - GOTRUE_JWT_AUD: "authenticated", - GOTRUE_JWT_ADMIN_ROLES: "service_role", - GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", - API_EXTERNAL_URL: opts.externalUrl, - GOTRUE_API_HOST: "0.0.0.0", - GOTRUE_API_PORT: String(opts.authPort), - GOTRUE_EXTERNAL_EMAIL_ENABLED: "true", - GOTRUE_MAILER_AUTOCONFIRM: "true", - GOTRUE_DISABLE_SIGNUP: "false", - ...(opts.smtpHost === undefined - ? {} - : { - GOTRUE_SMTP_HOST: opts.smtpHost, - GOTRUE_SMTP_PORT: String(opts.smtpPort ?? 1025), - ...(opts.smtpAdminEmail === undefined - ? {} - : { GOTRUE_SMTP_ADMIN_EMAIL: opts.smtpAdminEmail }), - ...(opts.smtpSenderName === undefined - ? {} - : { GOTRUE_SMTP_SENDER_NAME: opts.smtpSenderName }), - }), -}); - -const authHealthCheck = (port: number) => ({ - probe: { - _tag: "Http" as const, - host: "127.0.0.1", - port, - path: "/health", - scheme: "http" as const, - }, - ...stackHealthBudgets.auth, -}); - -export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ - name: "auth", - command: `${opts.binPath}/bin/auth`, - env: authEnv(opts), - dependencies: opts.dependencies, - healthCheck: authHealthCheck(opts.authPort), - supervision: {}, - restart: "unless-stopped", -}); - -export const makeAuthServiceDocker = (opts: DockerAuthOptions): ServiceDef => { - const env = authEnv(opts, opts.dbHost); - return dockerRunService({ - runtime: opts.runtime, - name: "auth", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.authPort]), - env, - dependencies: opts.dependencies, - healthCheck: authHealthCheck(opts.authPort), - }); -}; diff --git a/packages/stack/src/services/docker-cleanup.ts b/packages/stack/src/services/docker-cleanup.ts deleted file mode 100644 index 6b70e8f542..0000000000 --- a/packages/stack/src/services/docker-cleanup.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ExternalCleanupAction } from "@supabase/process-compose"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Synchronous orphan cleanup is a native subprocess boundary invoked by process-compose. -import { execFileSync } from "node:child_process"; -import { Effect } from "effect"; -import type { ContainerRuntime } from "../ContainerRuntime.ts"; - -export const dockerServiceCleanup = ( - runtime: ContainerRuntime, - containerName: string, -): Effect.Effect => - Effect.sync(() => { - try { - execFileSync(runtime, ["rm", "-f", containerName], { - stdio: "ignore", - timeout: 5_000, - }); - } catch {} - }); - -export const dockerServiceOrphanCleanup = ( - runtime: ContainerRuntime, - containerName: string, -): ReadonlyArray => [ - { - _tag: "RunCommand", - executable: runtime, - args: ["rm", "-f", containerName], - timeoutMs: 5_000, - }, -]; - -export const removePathOnOrphanCleanup = ( - path: string, - opts: { - readonly recursive?: boolean; - readonly force?: boolean; - } = {}, -): ReadonlyArray => [ - { _tag: "RemovePath", path, recursive: opts.recursive, force: opts.force }, -]; diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts deleted file mode 100644 index 0075dc0ef4..0000000000 --- a/packages/stack/src/services/edge-runtime-main.ts +++ /dev/null @@ -1,259 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-console -- Standalone Deno/EdgeRuntime bootstrap must expose native async handlers and host logging APIs. - -declare const Deno: any; -declare const EdgeRuntime: any; - -const placeholder = { - code: "FUNCTIONS_NOT_CONFIGURED", - message: "Edge Functions are not configured for this local stack yet.", -}; - -export const RequestErrors = { - MissingAuthHeader: "UNAUTHORIZED_NO_AUTH_HEADER", - InvalidLegacyJWT: "UNAUTHORIZED_LEGACY_JWT", - InvalidAsymmetricJWT: "UNAUTHORIZED_ASYMMETRIC_JWT", - InvalidTokenFormat: "UNAUTHORIZED_INVALID_JWT_FORMAT", - UnsupportedTokenAlgorithm: "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", -}; - -interface AuthFailure { - code: string; - message?: string; -} - -const configPath = - typeof Deno === "undefined" - ? new URL("./functions-runtime-config.json", import.meta.url) - : (Deno.env.get("FUNCTIONS_RUNTIME_CONFIG_PATH") ?? - new URL("./functions-runtime-config.json", import.meta.url)); - -async function loadConfig() { - try { - return JSON.parse(await Deno.readTextFile(configPath)); - } catch (error) { - console.error(`Failed to load Edge Functions runtime config from ${configPath}`, error); - return null; - } -} - -function base64UrlToBytes(value: string) { - const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); - const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); -} - -function bytesEqual(left: Uint8Array, right: Uint8Array) { - if (left.byteLength !== right.byteLength) return false; - let result = 0; - for (let i = 0; i < left.byteLength; i++) { - result |= left[i]! ^ right[i]!; - } - return result === 0; -} - -function getAuthErrorResponse({ code, message = "Invalid JWT" }: AuthFailure) { - return Response.json( - { - code, - message, - // DEPRECATED: Retained for backward compatibility. - msg: message, - }, - { - status: 401, - headers: { - "sb-error-code": code, - "Access-Control-Expose-Headers": "sb-error-code", - }, - }, - ); -} - -function decodeJwtAlgorithm(jwt: string): string | undefined { - const parts = jwt.split("."); - if (parts.length !== 3) { - throw new Error("Invalid JWT format"); - } - const decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[0]!))); - return typeof decodedHeader.alg === "string" ? decodedHeader.alg : undefined; -} - -async function isValidLocalJwt(secret: string, jwt: string) { - const parts = jwt.split("."); - if (parts.length !== 3) return false; - const [header, payload, signature] = parts; - const decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(header!))); - - // WARN:(kallebysantos) Go version supports Asymmetric JWTs (ES256 | RS256) via SUPABASE_JWKS env - // It must be ported to TS as well - if (decodedHeader.alg !== "HS256") return false; - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signed = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(`${header}.${payload}`), - ); - return bytesEqual(new Uint8Array(signed), base64UrlToBytes(signature!)); -} - -export async function verifyRequest(req: Request, config: any, functionConfig: any) { - if (!functionConfig.verifyJWT || req.method === "OPTIONS") return null; - const bearerToken = req.headers.get("authorization")?.slice("Bearer ".length); - const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key")?.replace("Bearer", "")?.trim(); - - if (!bearerToken && !sbApiKeyCompatibilityToken) { - return getAuthErrorResponse({ - code: RequestErrors.MissingAuthHeader, - message: "Missing authorization header", - }); - } - - // NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match: - // - API proxy mints a temp token - // - Original bearer is not present or is ApiKey - const token = - !bearerToken || bearerToken.startsWith("sb_") ? sbApiKeyCompatibilityToken : bearerToken; - - if (!token) { - return getAuthErrorResponse({ - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }); - } - - let algorithm: string | undefined; - try { - algorithm = decodeJwtAlgorithm(token); - } catch (error) { - console.error("JWT format error", error); - return getAuthErrorResponse({ - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }); - } - - if (!algorithm) { - return getAuthErrorResponse({ - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }); - } - - if (algorithm === "HS256") { - try { - if (await isValidLocalJwt(config.jwtSecret, token)) return null; - } catch (error) { - console.error("JWT verification failed", error); - } - return getAuthErrorResponse({ code: RequestErrors.InvalidLegacyJWT }); - } - - if (algorithm === "ES256" || algorithm === "RS256") { - return getAuthErrorResponse({ code: RequestErrors.InvalidAsymmetricJWT }); - } - - return getAuthErrorResponse({ - code: RequestErrors.UnsupportedTokenAlgorithm, - message: `Unsupported JWT algorithm ${algorithm}`, - }); -} - -function dirname(path: string) { - const index = path.lastIndexOf("/"); - return index === -1 ? "." : path.slice(0, index); -} - -function fileUrl(path: string) { - return new URL(`file://${path}`).href; -} - -async function serveFunction(req: Request, config: any, functionName: string, functionConfig: any) { - const authError = await verifyRequest(req, config, functionConfig); - if (authError) return authError; - - const envVars = Object.entries({ - ...config.env, - ...functionConfig.env, - SUPABASE_URL: config.supabaseUrl, - SUPABASE_ANON_KEY: config.publishableKey, - SUPABASE_SERVICE_ROLE_KEY: config.secretKey, - SUPABASE_DB_URL: config.dbUrl, - SUPABASE_PUBLISHABLE_KEYS: JSON.stringify({ default: config.publishableKey }), - SUPABASE_SECRET_KEYS: JSON.stringify({ default: config.secretKey }), - }); - - try { - const worker = await EdgeRuntime.userWorkers.create({ - servicePath: dirname(functionConfig.entrypointPath), - memoryLimitMb: 256, - workerTimeoutMs: 400000, - noModuleCache: false, - noNpm: false, - importMapPath: functionConfig.importMapPath ?? undefined, - envVars, - forceCreate: false, - customModuleRoot: "", - cpuTimeSoftLimitMs: 1000, - cpuTimeHardLimitMs: 2000, - decoratorType: "tc39", - maybeEntrypoint: fileUrl(functionConfig.entrypointPath), - context: { useReadSyncFileAPI: true }, - staticPatterns: functionConfig.staticFiles, - }); - return await worker.fetch(req); - } catch (error) { - console.error(`Failed to serve Function ${functionName}`, error); - return Response.json( - { - code: "WORKER_ERROR", - message: "Function failed to start or respond. Check edge-runtime logs for details.", - }, - { status: 500 }, - ); - } -} - -if (typeof Deno !== "undefined") { - Deno.serve({ - handler: async (req: Request) => { - const url = new URL(req.url); - - if (url.pathname === "/_internal/health") { - return Response.json({ message: "ok" }); - } - - const config = await loadConfig(); - if (!config) return Response.json(placeholder, { status: 501 }); - - const functionName = url.pathname.split("/").filter(Boolean)[0]; - const functionConfig = functionName ? config.functions[functionName] : undefined; - if (!functionName || !functionConfig) { - return new Response("Function not found", { status: 404 }); - } - - return serveFunction(req, config, functionName, functionConfig); - }, - onListen: async () => { - const config = await loadConfig(); - if (!config) return; - const names = Object.keys(config.functions); - const examples = names - .slice(0, 5) - .map((name) => ` - ${config.functionsUrl}/${name}`) - .join("\n"); - console.log( - `Serving functions on ${config.functionsUrl}/${ - examples.length > 0 ? `\n${examples}` : "" - }`, - ); - }, - }); -} - -export default ""; diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts deleted file mode 100644 index 4986896803..0000000000 --- a/packages/stack/src/services/edge-runtime.ts +++ /dev/null @@ -1,127 +0,0 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- Bootstrap source paths are resolved synchronously for the generated Docker mount boundary. -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { ServiceDef } from "@supabase/process-compose"; -import { Effect, FileSystem } from "effect"; -import { dockerNetworkArgs } from "../Platform.ts"; -import { StackBuildError } from "../errors.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { - dockerRunService, - hostUserForLinuxDocker, - hostHttpHealthCheck, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import bootstrapSource from "./edge-runtime-main.ts" with { type: "text" }; -import { stackHealthBudgets } from "./health-budgets.ts"; -import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; - -interface EdgeRuntimeOptions { - readonly runtimeRoot: string; - readonly projectDir?: string; - readonly port: number; - readonly inspectorPort: number; - readonly policy: "oneshot" | "per_worker"; - readonly env: Readonly>; - readonly dependencies: ReadonlyArray; -} - -interface DockerEdgeRuntimeOptions extends EdgeRuntimeOptions, ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; - readonly platformOs: string; - readonly bootstrapDir: string; -} - -const bootstrapFileName = "index.ts"; -const bootstrapMountDir = "/workspace"; -const bootstrapSourcePath = fileURLToPath(new URL("./edge-runtime-main.ts", import.meta.url)); - -export const prepareEdgeRuntimeBootstrap = ( - runtimeRoot: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const bootstrapDir = join(runtimeRoot, "edge-runtime"); - yield* fs.makeDirectory(bootstrapDir, { recursive: true }).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to create the Edge Runtime bootstrap directory", - cause, - }), - ), - ); - const source = - bootstrapSource === "" - ? yield* fs.readFileString(bootstrapSourcePath).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to read the Edge Runtime bootstrap script", - cause, - }), - ), - ) - : bootstrapSource; - yield* fs.writeFileString(join(bootstrapDir, bootstrapFileName), source).pipe( - Effect.mapError( - (cause) => - new StackBuildError({ - detail: "Failed to write the Edge Runtime bootstrap script", - cause, - }), - ), - ); - return bootstrapDir; - }); - -const edgeRuntimeEnv = (opts: EdgeRuntimeOptions): Record => ({ - ...opts.env, - EDGE_RUNTIME_PORT: String(opts.port), - EDGE_RUNTIME_INSPECTOR_PORT: String(opts.inspectorPort), - FUNCTIONS_RUNTIME_CONFIG_PATH: join( - opts.runtimeRoot, - "edge-runtime", - "functions-runtime-config.json", - ), -}); - -const edgeRuntimeArgs = ( - opts: Pick, - mainServicePath: string, -): ReadonlyArray => [ - "start", - `--main-service=${mainServicePath}`, - `--port=${opts.port}`, - `--policy=${opts.policy}`, -]; - -const edgeRuntimeHealthCheck = (port: number): ServiceDef["healthCheck"] => - hostHttpHealthCheck(port, "/_internal/health", { - ...stackHealthBudgets.edgeRuntime, - }); - -export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): ServiceDef => { - return dockerRunService({ - runtime: opts.runtime, - name: "edge-runtime", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - volumes: [ - `${opts.bootstrapDir}:${bootstrapMountDir}:ro`, - ...(opts.projectDir === undefined ? [] : [`${opts.projectDir}:${opts.projectDir}:ro`]), - ], - args: ["--ulimit", edgeRuntimeNofileUlimit(opts.platformOs).arg], - env: { - ...edgeRuntimeEnv(opts), - FUNCTIONS_RUNTIME_CONFIG_PATH: `${bootstrapMountDir}/functions-runtime-config.json`, - }, - user: hostUserForLinuxDocker(opts.runtime, opts.platformOs), - cmd: [...edgeRuntimeArgs(opts, bootstrapMountDir)], - dependencies: opts.dependencies, - healthCheck: edgeRuntimeHealthCheck(opts.port), - }); -}; diff --git a/packages/stack/src/services/health-budgets.ts b/packages/stack/src/services/health-budgets.ts deleted file mode 100644 index 3cf965441f..0000000000 --- a/packages/stack/src/services/health-budgets.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { defaults, type HealthCheckConfig } from "@supabase/process-compose"; -import type { ServiceName } from "../ServiceName.ts"; - -type HealthBudget = Required< - Pick< - HealthCheckConfig, - "initialDelaySeconds" | "periodSeconds" | "startupFailureThreshold" | "failureThreshold" - > -> & - Pick; - -/** Cold-start tolerance and tighter post-start liveness thresholds. */ -export const stackHealthBudgets = { - postgresNative: { - initialDelaySeconds: 0, - periodSeconds: 0.5, - startupFailureThreshold: 120, - failureThreshold: 30, - }, - postgresDocker: { - initialDelaySeconds: 1, - periodSeconds: 0.5, - startupFailureThreshold: 120, - failureThreshold: 30, - }, - auth: { - initialDelaySeconds: 0, - periodSeconds: 0.5, - startupFailureThreshold: 60, - failureThreshold: 20, - }, - postgrest: { - initialDelaySeconds: 0, - periodSeconds: 0.5, - startupFailureThreshold: 60, - failureThreshold: 20, - }, - edgeRuntime: { - initialDelaySeconds: 1, - periodSeconds: 0.5, - startupFailureThreshold: 60, - failureThreshold: 30, - }, - mailpit: { - initialDelaySeconds: 1, - periodSeconds: 0.5, - startupFailureThreshold: 60, - failureThreshold: 30, - }, - realtime: { - initialDelaySeconds: 1, - periodSeconds: 0.5, - startupFailureThreshold: 60, - failureThreshold: 30, - }, - storage: { - initialDelaySeconds: 1, - periodSeconds: 0.5, - startupFailureThreshold: 60, - failureThreshold: 30, - }, - imgproxy: { - initialDelaySeconds: 1, - periodSeconds: 0.5, - startupFailureThreshold: 60, - failureThreshold: 30, - }, - pgmeta: { - initialDelaySeconds: 1, - periodSeconds: 0.5, - startupFailureThreshold: 60, - failureThreshold: 30, - }, - analytics: { - initialDelaySeconds: 10, - periodSeconds: 1, - startupFailureThreshold: 120, - failureThreshold: 60, - }, - vector: { - initialDelaySeconds: 1, - periodSeconds: 1, - startupFailureThreshold: 60, - failureThreshold: 30, - }, - pooler: { - initialDelaySeconds: 2, - periodSeconds: 1, - startupFailureThreshold: 90, - failureThreshold: 60, - }, - studio: { - initialDelaySeconds: 2, - periodSeconds: 1, - startupFailureThreshold: 90, - failureThreshold: 60, - }, -} as const satisfies Record; - -export const healthStartupBudgetSeconds = (budget: HealthBudget): number => { - const attempts = budget.startupFailureThreshold; - const probeTimeoutSeconds = budget.timeoutSeconds ?? defaults.healthCheck.timeoutSeconds; - return ( - budget.initialDelaySeconds + - attempts * probeTimeoutSeconds + - (attempts - 1) * budget.periodSeconds - ); -}; - -/** Worst-case initial health budget for each public service. */ -export const stackServiceStartupBudgetSeconds = { - postgres: Math.max( - healthStartupBudgetSeconds(stackHealthBudgets.postgresNative), - healthStartupBudgetSeconds(stackHealthBudgets.postgresDocker), - ), - postgrest: healthStartupBudgetSeconds(stackHealthBudgets.postgrest), - auth: healthStartupBudgetSeconds(stackHealthBudgets.auth), - "edge-runtime": healthStartupBudgetSeconds(stackHealthBudgets.edgeRuntime), - realtime: healthStartupBudgetSeconds(stackHealthBudgets.realtime), - storage: healthStartupBudgetSeconds(stackHealthBudgets.storage), - imgproxy: healthStartupBudgetSeconds(stackHealthBudgets.imgproxy), - mailpit: healthStartupBudgetSeconds(stackHealthBudgets.mailpit), - pgmeta: healthStartupBudgetSeconds(stackHealthBudgets.pgmeta), - studio: healthStartupBudgetSeconds(stackHealthBudgets.studio), - analytics: healthStartupBudgetSeconds(stackHealthBudgets.analytics), - vector: healthStartupBudgetSeconds(stackHealthBudgets.vector), - pooler: healthStartupBudgetSeconds(stackHealthBudgets.pooler), -} as const satisfies Readonly>; - -const STARTUP_COORDINATION_MARGIN_SECONDS = 5; - -/** Maximum coordination allowance for the native one-shot database initialization step. */ -export const POSTGRES_INIT_COMPLETION_BUDGET_SECONDS = 60; - -export const dependencyTimeoutSecondsForServices = (services: ReadonlyArray): number => - services.reduce((total, service) => total + stackServiceStartupBudgetSeconds[service], 0) + - STARTUP_COORDINATION_MARGIN_SECONDS; diff --git a/packages/stack/src/services/health-budgets.unit.test.ts b/packages/stack/src/services/health-budgets.unit.test.ts deleted file mode 100644 index e9840ea45e..0000000000 --- a/packages/stack/src/services/health-budgets.unit.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - dependencyTimeoutSecondsForServices, - healthStartupBudgetSeconds, - stackHealthBudgets, - stackServiceStartupBudgetSeconds, -} from "./health-budgets.ts"; - -describe("stack health budgets", () => { - it("records startup and liveness policy for every health-checked service", () => { - const summarized = Object.fromEntries( - Object.entries(stackHealthBudgets).map(([name, budget]) => [ - name, - { - initialDelay: budget.initialDelaySeconds, - period: budget.periodSeconds, - startupThreshold: budget.startupFailureThreshold, - startupBudget: healthStartupBudgetSeconds(budget), - livenessThreshold: budget.failureThreshold, - }, - ]), - ); - - expect(summarized).toMatchInlineSnapshot(` - { - "analytics": { - "initialDelay": 10, - "livenessThreshold": 60, - "period": 1, - "startupBudget": 369, - "startupThreshold": 120, - }, - "auth": { - "initialDelay": 0, - "livenessThreshold": 20, - "period": 0.5, - "startupBudget": 149.5, - "startupThreshold": 60, - }, - "edgeRuntime": { - "initialDelay": 1, - "livenessThreshold": 30, - "period": 0.5, - "startupBudget": 150.5, - "startupThreshold": 60, - }, - "imgproxy": { - "initialDelay": 1, - "livenessThreshold": 30, - "period": 0.5, - "startupBudget": 150.5, - "startupThreshold": 60, - }, - "mailpit": { - "initialDelay": 1, - "livenessThreshold": 30, - "period": 0.5, - "startupBudget": 150.5, - "startupThreshold": 60, - }, - "pgmeta": { - "initialDelay": 1, - "livenessThreshold": 30, - "period": 0.5, - "startupBudget": 150.5, - "startupThreshold": 60, - }, - "pooler": { - "initialDelay": 2, - "livenessThreshold": 60, - "period": 1, - "startupBudget": 271, - "startupThreshold": 90, - }, - "postgresDocker": { - "initialDelay": 1, - "livenessThreshold": 30, - "period": 0.5, - "startupBudget": 300.5, - "startupThreshold": 120, - }, - "postgresNative": { - "initialDelay": 0, - "livenessThreshold": 30, - "period": 0.5, - "startupBudget": 299.5, - "startupThreshold": 120, - }, - "postgrest": { - "initialDelay": 0, - "livenessThreshold": 20, - "period": 0.5, - "startupBudget": 149.5, - "startupThreshold": 60, - }, - "realtime": { - "initialDelay": 1, - "livenessThreshold": 30, - "period": 0.5, - "startupBudget": 150.5, - "startupThreshold": 60, - }, - "storage": { - "initialDelay": 1, - "livenessThreshold": 30, - "period": 0.5, - "startupBudget": 150.5, - "startupThreshold": 60, - }, - "studio": { - "initialDelay": 2, - "livenessThreshold": 60, - "period": 1, - "startupBudget": 271, - "startupThreshold": 90, - }, - "vector": { - "initialDelay": 1, - "livenessThreshold": 30, - "period": 1, - "startupBudget": 180, - "startupThreshold": 60, - }, - } - `); - }); - - it("keeps dependency timeouts beyond each dependency startup path", () => { - expect(dependencyTimeoutSecondsForServices(["postgres"])).toBeGreaterThan( - stackServiceStartupBudgetSeconds.postgres, - ); - expect(dependencyTimeoutSecondsForServices(["postgres", "storage"])).toBeGreaterThan( - stackServiceStartupBudgetSeconds.postgres + stackServiceStartupBudgetSeconds.storage, - ); - expect(dependencyTimeoutSecondsForServices(["postgres", "analytics"])).toBeGreaterThan( - stackServiceStartupBudgetSeconds.postgres + stackServiceStartupBudgetSeconds.analytics, - ); - }); -}); diff --git a/packages/stack/src/services/imgproxy.ts b/packages/stack/src/services/imgproxy.ts deleted file mode 100644 index 2c9acf0bc7..0000000000 --- a/packages/stack/src/services/imgproxy.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { - dockerRunService, - hostHttpHealthCheck, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -interface DockerImgproxyOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly port: number; - readonly identity: StackIdentity; - readonly dataDir: string; - readonly platformOs: string; - readonly dependencies: ReadonlyArray; -} - -const IMGPROXY_STORAGE_DIR = "/var/lib/storage"; - -const imgproxyHealthCheck = (port: number): ServiceDef["healthCheck"] => - hostHttpHealthCheck(port, "/health", { - ...stackHealthBudgets.imgproxy, - }); - -export const makeImgproxyServiceDocker = (opts: DockerImgproxyOptions): ServiceDef => - dockerRunService({ - runtime: opts.runtime, - name: "imgproxy", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - volumes: [`${opts.dataDir}:${IMGPROXY_STORAGE_DIR}`], - env: { - IMGPROXY_BIND: `:${opts.port}`, - 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", - }, - dependencies: opts.dependencies, - healthCheck: imgproxyHealthCheck(opts.port), - }); diff --git a/packages/stack/src/services/mailpit.ts b/packages/stack/src/services/mailpit.ts deleted file mode 100644 index 193f408e02..0000000000 --- a/packages/stack/src/services/mailpit.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { - dockerRunService, - hostHttpHealthCheck, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -interface DockerMailpitOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; - readonly webPort: number; - readonly smtpPort: number; - readonly pop3Port: number; - readonly platformOs: string; - readonly dependencies: ReadonlyArray; -} - -const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => - hostHttpHealthCheck(port, "/readyz", { - ...stackHealthBudgets.mailpit, - }); - -export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef => - dockerRunService({ - runtime: opts.runtime, - name: "mailpit", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.webPort, opts.smtpPort, opts.pop3Port]), - dependencies: opts.dependencies, - env: { - MP_UI_BIND_ADDR: `0.0.0.0:${opts.webPort}`, - MP_SMTP_BIND_ADDR: `0.0.0.0:${opts.smtpPort}`, - MP_POP3_BIND_ADDR: `0.0.0.0:${opts.pop3Port}`, - MP_SMTP_DISABLE_RDNS: "true", - }, - healthCheck: mailpitHealthCheck(opts.webPort), - }); diff --git a/packages/stack/src/services/nofile-limit.ts b/packages/stack/src/services/nofile-limit.ts deleted file mode 100644 index 2c2b8df9c6..0000000000 --- a/packages/stack/src/services/nofile-limit.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Raised from the daemon default so many concurrent Deno isolates can run -// (supabase/cli#5151). -const desiredNofile = 65536; - -// `userLimits.open_files.hard` from a `process.report.getReport()` diagnostic -// report — a number or "unlimited". Narrowed structurally because getReport() -// is typed as a bare `object`. -export const hardNofileLimitFromReport = (report: unknown): number | undefined => { - if (typeof report !== "object" || report === null || !("userLimits" in report)) return undefined; - const userLimits = report.userLimits; - if (typeof userLimits !== "object" || userLimits === null || !("open_files" in userLimits)) { - return undefined; - } - const openFiles = userLimits.open_files; - if (typeof openFiles !== "object" || openFiles === null || !("hard" in openFiles)) { - return undefined; - } - const hard = openFiles.hard; - return typeof hard === "number" && Number.isSafeInteger(hard) && hard > 0 ? hard : undefined; -}; - -const hostHardNofileLimit = (platformOs: string): number | undefined => - platformOs === "linux" ? hardNofileLimitFromReport(process.report?.getReport()) : undefined; - -// Never request more than the host's own hard cap: sandboxed hosts cap it -// below 65536, their docker daemon shares the cap, and exceeding it fails the -// container start (CLI-2220). The process's limit is a proxy for the daemon's -// — exact only when they share a kernel and limits, so only Linux is clamped -// (elsewhere the daemon runs in a VM), and only downward: a client more -// constrained than its daemon yields a smaller fd budget, never a failed start. -export const clampNofileLimit = (hardLimit: number | undefined): number => - hardLimit === undefined ? desiredNofile : Math.min(desiredNofile, hardLimit); - -interface EdgeRuntimeNofileUlimit { - /** The docker `--ulimit` value, `nofile=:`. */ - readonly arg: string; - readonly limit: number; - /** Present only when the host's hard cap forced the request below the 65536 raise. */ - readonly clampWarning?: string; -} - -// `hostHardLimit` defaults to the real host probe and exists as a parameter so -// callers with no host dependence (tests) can pin the clamp decision. -export const edgeRuntimeNofileUlimit = ( - platformOs: string, - hostHardLimit: number | undefined = hostHardNofileLimit(platformOs), -): EdgeRuntimeNofileUlimit => { - const limit = clampNofileLimit(hostHardLimit); - return { - arg: `nofile=${limit}:${limit}`, - limit, - ...(limit < desiredNofile && { - clampWarning: - `Edge Runtime file descriptor limit lowered to ${limit}: ` + - `the host's hard limit (ulimit -Hn) is below the default ${desiredNofile}. ` + - `Heavy Edge Function workloads may exhaust file descriptors.`, - }), - }; -}; diff --git a/packages/stack/src/services/nofile-limit.unit.test.ts b/packages/stack/src/services/nofile-limit.unit.test.ts deleted file mode 100644 index acda910c26..0000000000 --- a/packages/stack/src/services/nofile-limit.unit.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - clampNofileLimit, - edgeRuntimeNofileUlimit, - hardNofileLimitFromReport, -} from "./nofile-limit.ts"; - -const reportWithHard = (hard: number | string) => ({ - header: { reportVersion: 5 }, - userLimits: { - open_files: { soft: 1024, hard }, - }, -}); - -describe("hardNofileLimitFromReport", () => { - it("reads the hard limit from a diagnostic report", () => { - expect(hardNofileLimitFromReport(reportWithHard(1048576))).toBe(1048576); - expect(hardNofileLimitFromReport(reportWithHard(20000))).toBe(20000); - }); - - it("returns undefined when the hard limit is unlimited", () => { - expect(hardNofileLimitFromReport(reportWithHard("unlimited"))).toBeUndefined(); - }); - - it("returns undefined for missing or malformed report shapes", () => { - expect(hardNofileLimitFromReport(undefined)).toBeUndefined(); - expect(hardNofileLimitFromReport(null)).toBeUndefined(); - expect(hardNofileLimitFromReport({})).toBeUndefined(); - expect(hardNofileLimitFromReport({ userLimits: {} })).toBeUndefined(); - expect(hardNofileLimitFromReport({ userLimits: { open_files: {} } })).toBeUndefined(); - expect(hardNofileLimitFromReport(reportWithHard(-1))).toBeUndefined(); - }); -}); - -describe("clampNofileLimit", () => { - it("keeps the 65536 raise when the hard limit is unknown or higher", () => { - expect(clampNofileLimit(undefined)).toBe(65536); - expect(clampNofileLimit(1048576)).toBe(65536); - expect(clampNofileLimit(65536)).toBe(65536); - }); - - it("clamps down to a lower hard limit (CLI-2220's 20000-cap sandbox)", () => { - expect(clampNofileLimit(20000)).toBe(20000); - }); -}); - -describe("edgeRuntimeNofileUlimit", () => { - it("keeps the full 65536 raise off Linux, where the daemon runs in a VM", () => { - expect(edgeRuntimeNofileUlimit("darwin")).toEqual({ arg: "nofile=65536:65536", limit: 65536 }); - expect(edgeRuntimeNofileUlimit("win32")).toEqual({ arg: "nofile=65536:65536", limit: 65536 }); - }); - - it("produces a matched soft:hard arg within Go's 65536 raise on Linux", () => { - const { arg, limit, clampWarning } = edgeRuntimeNofileUlimit("linux"); - expect(arg).toBe(`nofile=${limit}:${limit}`); - expect(limit).toBeGreaterThan(0); - expect(limit).toBeLessThanOrEqual(65536); - // The warning exists exactly when this host's cap forced a reduction. - expect(clampWarning !== undefined).toBe(limit < 65536); - }); - - it("carries a warning when a lower host hard limit forces a clamp (CLI-2220's 20000-cap sandbox)", () => { - const clamped = edgeRuntimeNofileUlimit("linux", 20000); - expect(clamped.arg).toBe("nofile=20000:20000"); - expect(clamped.limit).toBe(20000); - expect(clamped.clampWarning).toContain("lowered to 20000"); - expect(clamped.clampWarning).toContain("65536"); - }); - - it("omits the warning when the host limit does not constrain the raise", () => { - expect(edgeRuntimeNofileUlimit("linux", 1048576).clampWarning).toBeUndefined(); - expect(edgeRuntimeNofileUlimit("linux", 65536).clampWarning).toBeUndefined(); - }); -}); diff --git a/packages/stack/src/services/pgmeta.ts b/packages/stack/src/services/pgmeta.ts deleted file mode 100644 index fe5d74631b..0000000000 --- a/packages/stack/src/services/pgmeta.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -interface DockerPgmetaOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; - readonly port: number; - readonly dbHost: string; - readonly dbPort: number; - readonly platformOs: string; - readonly dependencies: ReadonlyArray; -} - -const pgmetaHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Http", - host: "127.0.0.1", - port, - path: "/health", - scheme: "http", - }, - ...stackHealthBudgets.pgmeta, -}); - -export const makePgmetaServiceDocker = (opts: DockerPgmetaOptions): ServiceDef => - dockerRunService({ - runtime: opts.runtime, - name: "pgmeta", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - env: { - PG_META_PORT: String(opts.port), - PG_META_DB_HOST: opts.dbHost, - PG_META_DB_NAME: "postgres", - PG_META_DB_USER: "postgres", - PG_META_DB_PORT: String(opts.dbPort), - PG_META_DB_PASSWORD: "postgres", - }, - dependencies: opts.dependencies, - healthCheck: pgmetaHealthCheck(opts.port), - }); diff --git a/packages/stack/src/services/pooler.ts b/packages/stack/src/services/pooler.ts deleted file mode 100644 index 10e53633bb..0000000000 --- a/packages/stack/src/services/pooler.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerPortMapArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -type PoolMode = "transaction" | "session"; - -interface DockerPoolerOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; - readonly hostAdminPort: number; - readonly hostPort: number; - readonly dbHost: string; - readonly dbPort: number; - readonly poolMode: PoolMode; - readonly defaultPoolSize: number; - readonly maxClientConn: number; - readonly jwtSecret: string; - readonly tenantId: string; - readonly encryptionKey: string; - readonly secretKeyBase: string; - readonly platformOs: string; - readonly dependencies: ReadonlyArray; -} - -const poolerHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Http", - host: "127.0.0.1", - port, - path: "/api/health", - scheme: "http", - }, - ...stackHealthBudgets.pooler, -}); - -const tenantScript = ( - opts: DockerPoolerOptions, -) => `{: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" => "${opts.tenantId}", - "db_host" => "${opts.dbHost}", - "db_port" => ${opts.dbPort}, - "db_database" => "postgres", - "require_user" => false, - "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", - "default_max_clients" => ${opts.maxClientConn}, - "default_pool_size" => ${opts.defaultPoolSize}, - "default_parameter_status" => %{"server_version" => version}, - "users" => [%{ - "db_user" => "pgbouncer", - "db_password" => "postgres", - "mode_type" => "${opts.poolMode}", - "pool_size" => ${opts.defaultPoolSize}, - "is_manager" => true - }] -} - -if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do - {:ok, _} = Supavisor.Tenants.create_tenant(params) -end`; - -export const makePoolerServiceDocker = (opts: DockerPoolerOptions): ServiceDef => - (() => { - return dockerRunService({ - runtime: opts.runtime, - name: "pooler", - identity: opts.identity, - image: opts.image, - networkArgs: dockerPortMapArgs(opts.platformOs, [ - { host: opts.hostAdminPort, container: poolerContainerPorts.admin }, - { - host: opts.hostPort, - container: - opts.poolMode === "session" - ? poolerContainerPorts.session - : poolerContainerPorts.transaction, - }, - ]), - env: { - PORT: String(poolerContainerPorts.admin), - PROXY_PORT_SESSION: String(poolerContainerPorts.session), - PROXY_PORT_TRANSACTION: String(poolerContainerPorts.transaction), - DATABASE_URL: `ecto://postgres:postgres@${opts.dbHost}:${opts.dbPort}/_supabase`, - CLUSTER_POSTGRES: "true", - SECRET_KEY_BASE: opts.secretKeyBase, - VAULT_ENC_KEY: opts.encryptionKey, - API_JWT_SECRET: opts.jwtSecret, - METRICS_JWT_SECRET: opts.jwtSecret, - REGION: "local", - RUN_JANITOR: "true", - ERL_AFLAGS: "-proto_dist inet_tcp", - RLIMIT_NOFILE: "", - }, - cmd: [ - "/bin/sh", - "-c", - `/app/bin/migrate && /app/bin/supavisor eval '${tenantScript(opts)}' && /app/bin/server`, - ], - dependencies: opts.dependencies, - healthCheck: poolerHealthCheck(opts.hostAdminPort), - }); - })(); - -export const poolerContainerPorts = { - admin: 4000, - session: 5432, - transaction: 6543, -} as const; diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts deleted file mode 100644 index cf03582d96..0000000000 --- a/packages/stack/src/services/postgres-init.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; -import type { ContainerRuntimeOptions, ServiceDependency } from "./service-utils.ts"; - -interface PostgresInitOptions { - readonly postgresDir: string; - readonly dbPort: number; - readonly jwtSecret: string; - readonly jwtExpiry: number; - /** - * When false, append the SQL that Studio runs at cloud project creation to revoke the default - * Data API privileges on the `public` schema so newly-created entities require explicit GRANTs. - */ - readonly autoExposeNewTables: boolean; - readonly dependencies: ReadonlyArray; -} - -interface DockerPostgresInitOptions extends ContainerRuntimeOptions { - readonly dbPort: number; - readonly jwtSecret: string; - readonly jwtExpiry: number; - readonly autoExposeNewTables: boolean; - readonly identity: StackIdentity; - readonly dependencies: ReadonlyArray; -} - -/** - * SQL that matches what Studio runs at cloud project creation when "Default privileges for new - * entities" is off. Revokes the default GRANTs installed by the bundled initial schema so new - * tables/sequences/functions in `public` owned by `postgres` are not reachable via the Data API - * roles without explicit GRANTs. - */ -const REVOKE_DEFAULT_DATA_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; -`.trim(); - -const dockerPostgresSchemaSql = (opts: DockerPostgresInitOptions) => - ` -\\getenv jwt_secret JWT_SECRET -\\getenv jwt_exp JWT_EXP -ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; -ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; -ALTER USER postgres WITH PASSWORD 'postgres'; -ALTER USER authenticator WITH PASSWORD 'postgres'; -ALTER USER supabase_auth_admin WITH PASSWORD 'postgres'; -ALTER USER supabase_storage_admin WITH PASSWORD 'postgres'; -ALTER USER supabase_replication_admin WITH PASSWORD 'postgres'; -ALTER USER supabase_read_only_user WITH PASSWORD 'postgres'; -CREATE SCHEMA IF NOT EXISTS _realtime; -ALTER SCHEMA _realtime OWNER TO postgres; -${opts.autoExposeNewTables ? "" : REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} -SELECT 'CREATE DATABASE _supabase WITH OWNER postgres' -WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '_supabase')\\gexec -\\connect _supabase -CREATE SCHEMA IF NOT EXISTS _analytics; -ALTER SCHEMA _analytics OWNER TO postgres; -CREATE SCHEMA IF NOT EXISTS _supavisor; -ALTER SCHEMA _supavisor OWNER TO postgres; -`.trim(); - -export const makePostgresInitServiceDocker = (opts: DockerPostgresInitOptions): ServiceDef => ({ - name: "postgres-init", - command: opts.runtime, - args: [ - "exec", - "-e", - "PGPASSWORD", - "-e", - "JWT_SECRET", - "-e", - "JWT_EXP", - dockerContainerName("postgres", opts.identity.key), - "sh", - "-c", - `/opt/postgres/bin/psql -h 127.0.0.1 -p ${opts.dbPort} -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -d postgres <<'EOSQL' -${dockerPostgresSchemaSql(opts)} -EOSQL`, - ], - env: { - PGPASSWORD: "postgres", - JWT_SECRET: opts.jwtSecret, - JWT_EXP: String(opts.jwtExpiry), - }, - dependencies: opts.dependencies, - supervision: {}, - restart: "no", -}); - -export const makePostgresInitService = (opts: PostgresInitOptions): ServiceDef => { - const pgBinDir = `${opts.postgresDir}/bin`; - const pgLibDir = `${opts.postgresDir}/lib`; - const migrationsDir = `${opts.postgresDir}/share/supabase-cli/migrations`; - - // Keep executable and SQL-file paths in arrays so cache roots containing - // whitespace remain single argv entries all the way to psql. - const psqlPath = `${pgBinDir}/psql`; - const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; - const psqlArray = `psql=(${shellQuote(psqlPath)} -h 127.0.0.1 -p ${opts.dbPort})`; - - // Replaces calling migrate.sh (which spawns ~57 separate psql processes) with - // chained -f flags that run all SQL files in a single psql session, cutting - // postgres-init time from ~5s to ~1s. - const script = ` -set -e -export PATH="${pgBinDir}:$PATH" -db="${migrationsDir}" -${psqlArray} -psql_opts=(-v ON_ERROR_STOP=1 --no-password --no-psqlrc) - -init_completion_sql=$(cat <<'EOSQL' -ALTER USER supabase_admin WITH PASSWORD 'postgres'; -CREATE SCHEMA IF NOT EXISTS supabase_migrations; -CREATE TABLE IF NOT EXISTS supabase_migrations.cli_init ( - phase text PRIMARY KEY, - completed_at timestamptz NOT NULL DEFAULT now() -); -INSERT INTO supabase_migrations.cli_init (phase) -VALUES ('init') -ON CONFLICT (phase) DO NOTHING; -EOSQL -) - -migration_completion_sql=$(cat <<'EOSQL' -${opts.autoExposeNewTables ? "" : REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} -INSERT INTO supabase_migrations.cli_init (phase) -VALUES ('complete') -ON CONFLICT (phase) DO UPDATE SET completed_at = EXCLUDED.completed_at; -EOSQL -) - -# The init phase is committed independently so a failed migration phase can -# resume without replaying non-idempotent bundled init scripts. -if "\${psql[@]}" -U supabase_admin -d postgres -tAc "SELECT 1 FROM supabase_migrations.cli_init WHERE phase = 'init'" 2>/dev/null | grep -q 1; then - echo "Database initial schema already initialized" -else - echo "Running Supabase migrations..." - - # Create postgres role if missing (as supabase_admin) - "\${psql[@]}" "\${psql_opts[@]}" -U supabase_admin -d postgres <<'EOSQL' -do $$ -begin - if not exists (select from pg_roles where rolname = 'postgres') then - create role postgres superuser login password 'postgres'; - alter database postgres owner to postgres; - end if; -end $$ -EOSQL - - # Run all init-scripts in a single psql session (as postgres) - init_flags=() - for sql in "$db"/init-scripts/*.sql; do - [ -f "$sql" ] && init_flags+=( -f "$sql" ) - done - "\${psql[@]}" "\${psql_opts[@]}" --single-transaction -U postgres -d postgres "\${init_flags[@]}" -c "$init_completion_sql" -fi - -if "\${psql[@]}" -U supabase_admin -d postgres -tAc "SELECT 1 FROM supabase_migrations.cli_init WHERE phase = 'complete'" 2>/dev/null | grep -q 1; then - echo "Database migrations already initialized" -else - echo "Running Supabase migrations..." - - # Run all migrations in a single psql session (as supabase_admin) - migrate_flags=() - for sql in "$db"/migrations/*.sql; do - [ -f "$sql" ] && migrate_flags+=( -f "$sql" ) - done - "\${psql[@]}" "\${psql_opts[@]}" --single-transaction -U supabase_admin -d postgres "\${migrate_flags[@]}" -c "$migration_completion_sql" - - # Reset stats (non-fatal, matches migrate.sh) - "\${psql[@]}" "\${psql_opts[@]}" -U supabase_admin -d postgres -c 'SELECT extensions.pg_stat_statements_reset(); SELECT pg_stat_reset();' || true -fi - -# Backfill schemas/databases used by docker-backed auxiliary services. -"\${psql[@]}" "\${psql_opts[@]}" -U postgres -d postgres <<'EOSQL' -CREATE SCHEMA IF NOT EXISTS _realtime; -ALTER SCHEMA _realtime OWNER TO postgres; -EOSQL - -if ! "\${psql[@]}" -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '_supabase'" 2>/dev/null | grep -q 1; then - "\${psql[@]}" "\${psql_opts[@]}" -U postgres -d postgres -c "CREATE DATABASE _supabase WITH OWNER postgres" -fi - -"\${psql[@]}" "\${psql_opts[@]}" -U postgres -d _supabase <<'EOSQL' -CREATE SCHEMA IF NOT EXISTS _analytics; -ALTER SCHEMA _analytics OWNER TO postgres; -CREATE SCHEMA IF NOT EXISTS _supavisor; -ALTER SCHEMA _supavisor OWNER TO postgres; -EOSQL - -# Always update JWT settings after the bundled roles and schemas exist. -"\${psql[@]}" "\${psql_opts[@]}" -U supabase_admin -d postgres <<'EOSQL' -\\getenv jwt_secret JWT_SECRET -\\getenv jwt_exp JWT_EXP -ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; -ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; -EOSQL - -# Always update role passwords (idempotent) -"\${psql[@]}" -U supabase_admin -d postgres -c " -DO \\$\\$ -DECLARE - roles text[] := ARRAY['authenticator','supabase_auth_admin','supabase_storage_admin','supabase_functions_admin','supabase_replication_admin','supabase_read_only_user','postgres']; - r text; -BEGIN - FOREACH r IN ARRAY roles LOOP - IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN - EXECUTE format('ALTER ROLE %I WITH PASSWORD ''postgres''', r); - END IF; - END LOOP; -END -\\$\\$; -" -`; - - return { - name: "postgres-init", - command: "bash", - args: ["-c", script], - env: { - DYLD_LIBRARY_PATH: pgLibDir, - LD_LIBRARY_PATH: pgLibDir, - PGPASSWORD: "postgres", - JWT_SECRET: opts.jwtSecret, - JWT_EXP: String(opts.jwtExpiry), - }, - dependencies: opts.dependencies, - supervision: {}, - restart: "no", - }; -}; diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts deleted file mode 100644 index a3c99216f9..0000000000 --- a/packages/stack/src/services/postgres.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; -import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; -import { - dockerExecHealthCheck, - dockerRunService, - hostUserForLinuxDocker, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; - -interface PostgresServiceOptions { - readonly dataDir: string; - readonly port: number; - readonly cleanupDataDirOnExit?: boolean; - readonly dependencies: ReadonlyArray; -} - -interface NativePostgresOptions extends PostgresServiceOptions { - readonly binPath: string; -} - -interface DockerPostgresOptions extends PostgresServiceOptions, ContainerRuntimeOptions { - readonly image: string; - readonly platformOs: string; - readonly identity: StackIdentity; - readonly cleanupDataDirOnExit?: boolean; -} - -const postgresEnv = (opts: NativePostgresOptions): Record => ({ - PGDATA: opts.dataDir, - POSTGRES_PASSWORD: "postgres", - DYLD_LIBRARY_PATH: `${opts.binPath}/lib`, - LD_LIBRARY_PATH: `${opts.binPath}/lib`, - TZDIR: "/var/db/timezone/zoneinfo", -}); - -const NATIVE_POSTGRES_RUNTIME_ARGS = [ - "-c", - "listen_addresses=127.0.0.1", - "-c", - "wal_level=logical", - "-c", - "max_wal_senders=5", - "-c", - "max_replication_slots=5", -] as const; - -const orphanCleanup = (opts: PostgresServiceOptions) => - opts.cleanupDataDirOnExit ? removePathOnOrphanCleanup(opts.dataDir) : []; - -const postgresGetKeyScript = (binPath: string): string => - `${binPath}/share/supabase-cli/config/pgsodium_getkey.sh`; - -const postgresHealthCheck = (binPath: string, port: number) => ({ - probe: { - _tag: "Exec" as const, - command: `${binPath}/bin/pg_isready`, - args: ["-h", "127.0.0.1", "-p", String(port), "-U", "postgres"], - env: { - DYLD_LIBRARY_PATH: `${binPath}/lib`, - LD_LIBRARY_PATH: `${binPath}/lib`, - }, - }, - ...stackHealthBudgets.postgresNative, -}); - -/** - * Docker postgres health check using the final postgres process and pg_isready - * inside the container. - * - * The supabase/postgres image briefly accepts connections while its entrypoint - * runs initialization. During that phase PID 1 is still the shell and the - * temporary server is stopped before the final postgres process starts. Gate - * readiness on the final Postgres process name and pg_isready so dependents - * never race that handoff. `/proc/1/exe` is intentionally avoided because - * Linux container hardening can make that symlink unreadable across users. - */ -const postgresDockerHealthCheck = ( - runtime: DockerPostgresOptions["runtime"], - containerName: string, - port: number, -) => - dockerExecHealthCheck( - runtime, - containerName, - "sh", - [ - "-ec", - // Linux /proc/1/comm truncates `.postgres-wrapped` to 15 characters. - `case "$(cat /proc/1/comm)" in postgres|.postgres-wrapp) pg_isready -h 127.0.0.1 -p ${port} -U postgres ;; *) exit 1 ;; esac`, - ], - { - ...stackHealthBudgets.postgresDocker, - }, - ); - -export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => { - // The bundle path is a private, scope-owned alias prepared by StackBuilder. - // It intentionally does not persist between handles: the initializer only - // needs a no-space path while this service definition is active. - const initScript = `${opts.binPath}/share/supabase-cli/bin/supabase-postgres-init.sh`; - const getKeyScript = postgresGetKeyScript(opts.binPath); - - return { - name: "postgres", - command: initScript, - args: [ - "-p", - String(opts.port), - ...NATIVE_POSTGRES_RUNTIME_ARGS, - "-c", - `pgsodium.getkey_script=${getKeyScript}`, - "-c", - `vault.getkey_script=${getKeyScript}`, - ], - env: postgresEnv(opts), - dependencies: opts.dependencies, - healthCheck: postgresHealthCheck(opts.binPath, opts.port), - shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, - supervision: { orphanCleanup: orphanCleanup(opts) }, - restart: "unless-stopped", - }; -}; - -export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceDef => { - const containerName = dockerContainerName("postgres", opts.identity.key); - const hostUser = hostUserForLinuxDocker(opts.runtime, opts.platformOs); - const [hostUid, hostGid] = hostUser?.split(":") ?? []; - const runtimeArgs = [ - "-p", - String(opts.port), - "-c", - "listen_addresses=*", - "-c", - "pgsodium.getkey_script=/opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh", - "-c", - "vault.getkey_script=/opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh", - ] as const; - - // Native initialization permits only loopback clients. When reusing that - // data directory in Docker, route through a temporary HBA copy that adds the - // container network rule without mutating the persisted native config. - const runEntrypoint = (args: string): string => - hostUser === undefined - ? `exec /usr/local/bin/entry.sh ${args}` - : `exec busybox su -s /usr/bin/sh supabase_cli -c "exec /usr/local/bin/entry.sh ${args}"`; - // initdb requires the effective uid to resolve through /etc/passwd, while - // the image init script chmods its key helper. Perform only that image setup - // as root, then drop to the host uid before touching the bind-mounted data. - const hostUserSetup = - hostUser === undefined - ? "" - : `printf 'supabase_cli:x:${hostUid}:${hostGid}:Supabase CLI:/tmp:/usr/bin/sh\\n' >> /etc/passwd -busybox chown ${hostUid}:${hostGid} /var/lib/postgresql/data -busybox chown ${hostUid}:${hostGid} /opt/postgres/share/supabase-cli/config/pgsodium_getkey.sh -`; - const command = `${hostUserSetup}if [ -s /var/lib/postgresql/data/PG_VERSION ]; then - cp /var/lib/postgresql/data/pg_hba.conf /tmp/supabase-cli-pg_hba.conf - printf '\\nhost all all all scram-sha-256\\n' >> /tmp/supabase-cli-pg_hba.conf - ${hostUser === undefined ? "" : `busybox chown ${hostUid}:${hostGid} /tmp/supabase-cli-pg_hba.conf`} - ${runEntrypoint(`-c hba_file=/tmp/supabase-cli-pg_hba.conf ${runtimeArgs.join(" ")}`)} -else - ${runEntrypoint(runtimeArgs.join(" "))} -fi`; - - return dockerRunService({ - runtime: opts.runtime, - name: "postgres", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - volumes: [`${opts.dataDir}:/var/lib/postgresql/data`], - env: { POSTGRES_PASSWORD: "postgres" }, - user: hostUser === undefined ? undefined : "0", - entrypoint: "/usr/bin/sh", - cmd: ["-c", command], - dependencies: opts.dependencies, - healthCheck: postgresDockerHealthCheck(opts.runtime, containerName, opts.port), - shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, - orphanCleanup: orphanCleanup(opts), - }); -}; diff --git a/packages/stack/src/services/postgrest.ts b/packages/stack/src/services/postgrest.ts deleted file mode 100644 index bfb211b8c1..0000000000 --- a/packages/stack/src/services/postgrest.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; -import { - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; - -interface PostgrestServiceOptions { - readonly dbPort: number; - readonly port: number; - readonly schemas: ReadonlyArray; - readonly extraSearchPath: ReadonlyArray; - readonly maxRows: number; - readonly jwtSecret: string; - readonly dependencies: ReadonlyArray; -} - -interface NativePostgrestOptions extends PostgrestServiceOptions { - readonly binPath: string; -} - -interface DockerPostgrestOptions extends PostgrestServiceOptions, ContainerRuntimeOptions { - readonly image: string; - readonly dbHost: string; - readonly platformOs: string; - readonly adminPort: number; - readonly identity: StackIdentity; -} - -const postgrestEnv = ( - opts: PostgrestServiceOptions, - dbHost = "127.0.0.1", -): Record => ({ - PGRST_DB_URI: `postgresql://authenticator:postgres@${dbHost}:${opts.dbPort}/postgres`, - PGRST_DB_SCHEMAS: opts.schemas.join(","), - PGRST_DB_EXTRA_SEARCH_PATH: opts.extraSearchPath.join(","), - PGRST_DB_ANON_ROLE: "anon", - PGRST_JWT_SECRET: opts.jwtSecret, - PGRST_DB_MAX_ROWS: String(opts.maxRows), - PGRST_SERVER_PORT: String(opts.port), -}); - -const postgrestHealthCheck = (port: number) => ({ - probe: { - _tag: "Http" as const, - host: "127.0.0.1", - port, - path: "/", - scheme: "http" as const, - }, - ...stackHealthBudgets.postgrest, -}); - -export const makePostgrestService = (opts: NativePostgrestOptions): ServiceDef => ({ - name: "postgrest", - command: `${opts.binPath}/bin/postgrest`, - env: postgrestEnv(opts), - dependencies: opts.dependencies, - healthCheck: postgrestHealthCheck(opts.port), - supervision: {}, - restart: "unless-stopped", -}); - -export const makePostgrestServiceDocker = (opts: DockerPostgrestOptions): ServiceDef => { - const env = { - ...postgrestEnv(opts, opts.dbHost), - PGRST_ADMIN_SERVER_PORT: String(opts.adminPort), - }; - return dockerRunService({ - runtime: opts.runtime, - name: "postgrest", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port, opts.adminPort]), - env, - dependencies: opts.dependencies, - healthCheck: postgrestHealthCheck(opts.port), - }); -}; diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts deleted file mode 100644 index 0783ed830f..0000000000 --- a/packages/stack/src/services/realtime.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -interface DockerRealtimeOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly port: number; - readonly identity: StackIdentity; - readonly dbHost: string; - readonly dbPort: number; - readonly jwtSecret: string; - readonly jwtJwks: string; - readonly tenantId: string; - readonly encryptionKey: string; - readonly secretKeyBase: string; - readonly maxHeaderLength: number; - readonly platformOs: string; - readonly dependencies: ReadonlyArray; -} - -const realtimeHealthCheck = (port: number, tenantId: string): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Exec", - command: "curl", - args: [ - "-sSfL", - "--head", - "-o", - "/dev/null", - "-H", - `Host:${tenantId}`, - `http://127.0.0.1:${port}/api/ping`, - ], - }, - ...stackHealthBudgets.realtime, -}); - -export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceDef => - dockerRunService({ - runtime: opts.runtime, - name: "realtime", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - env: { - PORT: String(opts.port), - DB_HOST: opts.dbHost, - DB_PORT: String(opts.dbPort), - DB_USER: "postgres", - DB_PASSWORD: "postgres", - DB_NAME: "postgres", - DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", - DB_ENC_KEY: opts.encryptionKey, - API_JWT_SECRET: opts.jwtSecret, - API_JWT_JWKS: opts.jwtJwks, - METRICS_JWT_SECRET: opts.jwtSecret, - APP_NAME: "realtime", - SECRET_KEY_BASE: opts.secretKeyBase, - ERL_AFLAGS: "-proto_dist inet_tcp", - DNS_NODES: "", - RLIMIT_NOFILE: "", - SEED_SELF_HOST: "true", - RUN_JANITOR: "true", - MAX_HEADER_LENGTH: String(opts.maxHeaderLength), - }, - dependencies: opts.dependencies, - healthCheck: realtimeHealthCheck(opts.port, opts.tenantId), - }); diff --git a/packages/stack/src/services/service-utils.ts b/packages/stack/src/services/service-utils.ts deleted file mode 100644 index f26ddd9390..0000000000 --- a/packages/stack/src/services/service-utils.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { ExternalCleanupAction, ServiceDef } from "@supabase/process-compose"; -import type { ServiceName } from "../ServiceName.ts"; -import type { ContainerRuntime } from "../ContainerRuntime.ts"; -import { dockerContainerName, STACK_ID_LABEL, type StackIdentity } from "../StackIdentity.ts"; -import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; - -export interface ServiceDependency { - readonly service: string; - readonly condition: "healthy" | "completed"; -} - -export interface ContainerRuntimeOptions { - readonly runtime: ContainerRuntime; -} - -interface DockerRunServiceOptions extends ContainerRuntimeOptions { - readonly name: ServiceName; - readonly identity: StackIdentity; - readonly image: string; - readonly networkArgs?: ReadonlyArray; - readonly env?: Record; - readonly args?: ReadonlyArray; - readonly cmd?: ReadonlyArray; - readonly entrypoint?: string; - readonly volumes?: ReadonlyArray; - readonly securityOptions?: ReadonlyArray; - readonly user?: string; - readonly dependencies: ReadonlyArray; - readonly healthCheck?: ServiceDef["healthCheck"]; - readonly restart?: ServiceDef["restart"]; - readonly shutdown?: ServiceDef["shutdown"]; - readonly orphanCleanup?: ReadonlyArray; -} - -export const hostUserForLinuxDocker = ( - runtime: ContainerRuntime, - platformOs: string, -): string | undefined => { - // Linux bind mounts preserve numeric ownership. Matching the caller keeps - // private runtime files readable and persistent data removable by the host. - if (runtime !== "docker" || platformOs !== "linux") return undefined; - const uid = process.getuid?.(); - const gid = process.getgid?.(); - return uid === undefined || gid === undefined ? undefined : `${uid}:${gid}`; -}; - -const envArgs = (env: Record): ReadonlyArray => - Object.keys(env).flatMap((key) => ["-e", key]); - -export const hostHttpHealthCheck = ( - port: number, - path: string, - opts: Omit = {}, -): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Http", - host: "127.0.0.1", - port, - path, - scheme: "http", - }, - ...opts, -}); - -export const dockerExecHealthCheck = ( - runtime: ContainerRuntime, - containerName: string, - command: string, - args: ReadonlyArray, - opts: Omit = {}, -): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Exec", - command: runtime, - args: ["exec", containerName, command, ...args], - }, - ...opts, -}); - -export const dockerRunService = (opts: DockerRunServiceOptions): ServiceDef => { - const containerName = dockerContainerName(opts.name, opts.identity.key); - const dockerArgs = [ - "run", - "--rm", - "--name", - containerName, - // Keep the identity available to a future label-keyed cleanup path when a - // caller supplies it; current cleanup removes the exact name below. - ...(opts.identity.stackId === undefined - ? [] - : ["--label", `${STACK_ID_LABEL}=${opts.identity.stackId}`]), - ...(opts.networkArgs ?? []), - ...(opts.volumes ?? []).flatMap((volume) => ["-v", volume]), - ...(opts.securityOptions ?? []).flatMap((option) => ["--security-opt", option]), - ...(opts.user === undefined ? [] : ["--user", opts.user]), - ...(opts.entrypoint === undefined ? [] : ["--entrypoint", opts.entrypoint]), - ...(opts.args ?? []), - ...envArgs(opts.env ?? {}), - opts.image, - ...(opts.cmd ?? []), - ]; - - return { - name: opts.name, - command: opts.runtime, - args: dockerArgs, - env: opts.env, - dependencies: opts.dependencies, - healthCheck: opts.healthCheck, - shutdown: opts.shutdown, - cleanup: dockerServiceCleanup(opts.runtime, containerName), - supervision: { - orphanCleanup: [ - ...dockerServiceOrphanCleanup(opts.runtime, containerName), - ...(opts.orphanCleanup ?? []), - ], - }, - restart: opts.restart ?? "unless-stopped", - }; -}; diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts deleted file mode 100644 index b7ace03bc6..0000000000 --- a/packages/stack/src/services/services.unit.test.ts +++ /dev/null @@ -1,664 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- Service tests use native filesystem/path fixtures to validate service wiring. - -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { Predicate } from "effect"; -import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts"; -import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; -import { makeEdgeRuntimeServiceDocker } from "./edge-runtime.ts"; -import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts"; -import { makeImgproxyServiceDocker } from "./imgproxy.ts"; -import { makeMailpitServiceDocker } from "./mailpit.ts"; -import { makePgmetaServiceDocker } from "./pgmeta.ts"; -import { makePostgresInitService, makePostgresInitServiceDocker } from "./postgres-init.ts"; -import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; -import { makePostgrestService, makePostgrestServiceDocker } from "./postgrest.ts"; -import { makeRealtimeServiceDocker } from "./realtime.ts"; -import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; -import { dockerRunService } from "./service-utils.ts"; -import { - LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, - LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, - makeStorageServiceDocker, -} from "./storage.ts"; -import { makeStudioServiceDocker } from "./studio.ts"; -import { makeVectorServiceDocker } from "./vector.ts"; -import { stackIdentity, type StackIdentity } from "../StackIdentity.ts"; -import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; - -const JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; -const DB_PORT = 54322; -const API_PORT = 54321; -// A stack started without an identity of its own: its Docker names stay derived -// from its api port. -const EPHEMERAL_IDENTITY: StackIdentity = stackIdentity({ apiPort: API_PORT }); -const POSTGRES_BIN_PATH = `/cache/postgres/${DEFAULT_VERSIONS.postgres}/darwin-arm64`; -const POSTGREST_BIN_PATH = `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos-aarch64`; -const AUTH_BIN_PATH = `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`; - -describe("dockerRunService environment transport", () => { - it("keeps secret values in the child environment instead of argv", () => { - const env = { - PASSWORD: "postgres-password", - JWT_SECRET, - }; - const def = dockerRunService({ - runtime: "docker", - name: "auth", - identity: EPHEMERAL_IDENTITY, - image: "supabase/auth:test", - env, - dependencies: [], - }); - - expect(def.env).toEqual(env); - const args = def.args ?? []; - for (const [key, value] of Object.entries(env)) { - const index = args.indexOf(key); - expect(index).toBeGreaterThanOrEqual(0); - expect(args[index - 1]).toBe("-e"); - expect(args[index + 1]).not.toBe(value); - } - expect(args.every((arg) => !Object.values(env).some((value) => arg.includes(value)))).toBe( - true, - ); - }); -}); - -describe("makePostgresService", () => { - it("creates a postgres ServiceDef with correct defaults", () => { - const def = makePostgresService({ - binPath: POSTGRES_BIN_PATH, - dataDir: "/tmp/supabase/data", - port: DB_PORT, - dependencies: [], - }); - - expect(def.name).toBe("postgres"); - expect(def.command).toBe( - `${POSTGRES_BIN_PATH}/share/supabase-cli/bin/supabase-postgres-init.sh`, - ); - expect(def.args).toContain("listen_addresses=127.0.0.1"); - expect(def.env?.PGDATA).toBe("/tmp/supabase/data"); - expect(def.env?.POSTGRES_PASSWORD).toBe("postgres"); - expect(def.env?.DYLD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); - expect(def.healthCheck?.probe).toEqual({ - _tag: "Exec", - command: `${POSTGRES_BIN_PATH}/bin/pg_isready`, - args: ["-h", "127.0.0.1", "-p", "54322", "-U", "postgres"], - env: { - DYLD_LIBRARY_PATH: `${POSTGRES_BIN_PATH}/lib`, - LD_LIBRARY_PATH: `${POSTGRES_BIN_PATH}/lib`, - }, - }); - expect(def.dependencies).toEqual([]); - expect(def.restart).toBe("unless-stopped"); - expect(def.supervision).toBeDefined(); - }); -}); - -describe("analyticsDockerRuntimeNetwork", () => { - it("uses the container port behind Docker port mapping on Linux", () => { - expect(analyticsDockerRuntimeNetwork("linux", 54328, "host.docker.internal")).toEqual({ - listenPort: 4000, - nodeHost: "0.0.0.0", - }); - }); - - it("uses the container port behind Docker port mapping on non-Linux hosts", () => { - expect(analyticsDockerRuntimeNetwork("darwin", 54328, "host.docker.internal")).toEqual({ - listenPort: 4000, - nodeHost: "0.0.0.0", - }); - }); -}); - -describe("makeStudioServiceDocker", () => { - it("injects legacy keys, opaque keys, and S3 protocol credentials", () => { - const def = makeStudioServiceDocker({ - runtime: "docker", - image: dockerImageForService("studio", DEFAULT_VERSIONS.studio), - identity: EPHEMERAL_IDENTITY, - port: 54323, - apiUrl: "http://host.docker.internal:54321", - publicApiUrl: "http://127.0.0.1:54321", - pgmetaUrl: "http://host.docker.internal:54322", - publishableKey: "sb_publishable_test", - secretKey: "sb_secret_test", - s3ProtocolAccessKeyId: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, - s3ProtocolAccessKeySecret: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, - jwtSecret: JWT_SECRET, - analyticsEnabled: true, - analyticsBackend: "postgres", - analyticsUrl: "http://host.docker.internal:54327", - analyticsApiKey: "test-api-key", - platformOs: "darwin", - dependencies: [{ service: "pgmeta", condition: "healthy" }], - }); - - expect(def.env).toMatchObject({ - SUPABASE_ANON_KEY: "sb_publishable_test", - SUPABASE_SERVICE_KEY: "sb_secret_test", - SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", - SUPABASE_SECRET_KEY: "sb_secret_test", - S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, - S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, - }); - expect(def.args).not.toContain("sb_secret_test"); - }); -}); - -describe("makePostgresServiceDocker", () => { - it("creates a docker-based postgres ServiceDef", () => { - const def = makePostgresServiceDocker({ - runtime: "docker", - image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), - dataDir: "/tmp/supabase/data", - port: DB_PORT, - platformOs: "linux", - identity: EPHEMERAL_IDENTITY, - dependencies: [], - }); - - expect(def.name).toBe("postgres"); - expect(def.command).toBe("docker"); - expect(def.args).toContain("run"); - expect(def.args).toContain("--rm"); - expect(def.args).toContain(`supabase-postgres-${API_PORT}`); - expect(def.args).toContain("host.docker.internal:host-gateway"); - expect(def.args).toContain(`${DB_PORT}:${DB_PORT}`); - expect(def.args).toContain(dockerImageForService("postgres", DEFAULT_VERSIONS.postgres)); - expect(def.args).toContain("/tmp/supabase/data:/var/lib/postgresql/data"); - expect(def.args).toContain("/usr/bin/sh"); - expect(def.args?.at(-2)).toBe("-c"); - // The Linux-compatible health gate distinguishes the final server from - // the image's temporary initialization server. - expect(def.healthCheck?.probe).toEqual( - expect.objectContaining({ _tag: "Exec", command: "docker" }), - ); - expect( - Predicate.isTagged(def.healthCheck?.probe, "Exec") && def.healthCheck.probe.args.join(" "), - ).toContain("/proc/1/comm"); - expect(def.dependencies).toEqual([]); - expect(def.restart).toBe("unless-stopped"); - expect(def.supervision?.orphanCleanup).toBeDefined(); - }); -}); - -describe("makePostgrestService", () => { - it("creates a postgrest ServiceDef depending on healthy postgres", () => { - const def = makePostgrestService({ - binPath: POSTGREST_BIN_PATH, - dbPort: DB_PORT, - port: API_PORT, - schemas: ["public", "storage"], - extraSearchPath: ["public", "extensions"], - maxRows: 1000, - jwtSecret: JWT_SECRET, - dependencies: [{ service: "postgres-init", condition: "completed" }], - }); - - expect(def.name).toBe("postgrest"); - expect(def.command).toBe(`${POSTGREST_BIN_PATH}/bin/postgrest`); - expect(def.env?.PGRST_DB_URI).toBe( - `postgresql://authenticator:postgres@127.0.0.1:${DB_PORT}/postgres`, - ); - expect(def.env?.PGRST_DB_SCHEMAS).toBe("public,storage"); - expect(def.env?.PGRST_SERVER_PORT).toBe("54321"); - expect(def.env?.PGRST_JWT_SECRET).toBe(JWT_SECRET); - expect(def.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: API_PORT, - path: "/", - scheme: "http", - }); - expect(def.supervision).toBeDefined(); - }); - - it("creates a docker definition with caller-supplied topology and derived identity", () => { - const dependencies = [{ service: "postgres", condition: "healthy" }] as const; - const def = makePostgrestServiceDocker({ - runtime: "docker", - image: dockerImageForService("postgrest", DEFAULT_VERSIONS.postgrest), - identity: EPHEMERAL_IDENTITY, - dbHost: "host.docker.internal", - dbPort: DB_PORT, - port: 54323, - adminPort: 54324, - schemas: ["public", "storage"], - extraSearchPath: ["public", "extensions"], - maxRows: 1000, - jwtSecret: JWT_SECRET, - platformOs: "linux", - dependencies, - }); - - expect(def.command).toBe("docker"); - expect(def.args).toContain(`supabase-postgrest-${API_PORT}`); - expect(def.args).toContain("host.docker.internal:host-gateway"); - expect(def.args).toContain("54323:54323"); - expect(def.args).toContain("54324:54324"); - expect(def.env?.PGRST_ADMIN_SERVER_PORT).toBe("54324"); - expect(def.dependencies).toEqual(dependencies); - expect(def.supervision?.orphanCleanup).toContainEqual({ - _tag: "RunCommand", - executable: "docker", - args: ["rm", "-f", `supabase-postgrest-${API_PORT}`], - timeoutMs: 5_000, - }); - }); -}); - -describe("makeAuthServiceNative", () => { - it("creates a native auth ServiceDef depending on healthy postgres", () => { - const def = makeAuthServiceNative({ - binPath: AUTH_BIN_PATH, - dbPort: DB_PORT, - authPort: 9999, - siteUrl: "http://localhost:3000", - jwtSecret: JWT_SECRET, - jwtExpiry: 3600, - externalUrl: `http://127.0.0.1:${API_PORT}`, - dependencies: [{ service: "postgres-init", condition: "completed" }], - }); - - expect(def.name).toBe("auth"); - expect(def.command).toBe(`${AUTH_BIN_PATH}/bin/auth`); - expect(def.env?.GOTRUE_DB_DATABASE_URL).toContain(`127.0.0.1:${DB_PORT}`); - expect(def.env?.GOTRUE_SITE_URL).toBe("http://localhost:3000"); - expect(def.env?.GOTRUE_JWT_SECRET).toBe(JWT_SECRET); - expect(def.dependencies).toEqual([{ service: "postgres-init", condition: "completed" }]); - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: 9999, - path: "/health", - scheme: "http", - }); - expect(def.supervision).toBeDefined(); - }); -}); - -describe("makeAuthServiceDocker", () => { - it("creates a docker-based auth ServiceDef", () => { - const def = makeAuthServiceDocker({ - runtime: "docker", - image: dockerImageForService("auth", DEFAULT_VERSIONS.auth), - dbPort: DB_PORT, - authPort: 9999, - siteUrl: "http://localhost:3000", - jwtSecret: JWT_SECRET, - jwtExpiry: 3600, - externalUrl: `http://127.0.0.1:${API_PORT}`, - dbHost: "127.0.0.1", - platformOs: "linux", - identity: EPHEMERAL_IDENTITY, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - - expect(def.name).toBe("auth"); - expect(def.command).toBe("docker"); - expect(def.args).toContain("run"); - expect(def.args).toContain("--rm"); - expect(def.args).toContain(`supabase-auth-${API_PORT}`); - expect(def.args).toContain("host.docker.internal:host-gateway"); - expect(def.args).toContain("9999:9999"); - expect(def.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); - expect(def.supervision).toEqual({ - orphanCleanup: [ - { - _tag: "RunCommand", - executable: "docker", - args: ["rm", "-f", `supabase-auth-${API_PORT}`], - timeoutMs: 5_000, - }, - ], - }); - }); -}); - -describe("makeEdgeRuntimeServiceDocker", () => { - it("creates a docker-based edge runtime service with a generated bootstrap script", () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "stack-edge-runtime-")); - - try { - const def = makeEdgeRuntimeServiceDocker({ - runtime: "docker", - image: dockerImageForService("edge-runtime", DEFAULT_VERSIONS["edge-runtime"]), - identity: EPHEMERAL_IDENTITY, - runtimeRoot: tempDir, - bootstrapDir: path.join(tempDir, "edge-runtime"), - port: 54340, - inspectorPort: 54341, - policy: "per_worker", - env: { SUPABASE_INTERNAL_DEBUG: "true" }, - platformOs: "linux", - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - - const bootstrapDir = path.join(tempDir, "edge-runtime"); - expect(def.name).toBe("edge-runtime"); - expect(def.command).toBe("docker"); - expect(def.args).toContain(`supabase-edge-runtime-${API_PORT}`); - expect(def.args).toContain("host.docker.internal:host-gateway"); - expect(def.args).toContain("54340:54340"); - expect(def.args).toContain(`--port=54340`); - expect(def.args).toContain(`--policy=per_worker`); - expect(def.args).toContain(`${bootstrapDir}:/workspace:ro`); - expect(def.args).toContain("--ulimit"); - expect(def.args).toContain(edgeRuntimeNofileUlimit("linux").arg); - expect(def.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: 54340, - path: "/_internal/health", - scheme: "http", - }); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); -}); - -describe("makePostgresInitService", () => { - it("creates a one-shot postgres-init ServiceDef", () => { - const def = makePostgresInitService({ - postgresDir: POSTGRES_BIN_PATH, - dbPort: DB_PORT, - jwtSecret: JWT_SECRET, - jwtExpiry: 3600, - autoExposeNewTables: true, - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - - expect(def.name).toBe("postgres-init"); - expect(def.command).toBe("bash"); - expect(def.args?.[0]).toBe("-c"); - expect(def.restart).toBe("no"); - expect(def.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); - expect(def.healthCheck).toBeUndefined(); - expect(def.env?.DYLD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); - expect(def.env?.LD_LIBRARY_PATH).toBe(`${POSTGRES_BIN_PATH}/lib`); - expect(def.env?.JWT_SECRET).toBe(JWT_SECRET); - expect(def.env?.JWT_EXP).toBe("3600"); - expect(def.supervision).toBeDefined(); - }); - - it.each(["native", "docker"] as const)( - "%s initialization revokes default Data API privileges only when auto-exposure is disabled", - (mode) => { - const commandFor = (autoExposeNewTables: boolean): string => { - const common = { - dbPort: DB_PORT, - jwtSecret: JWT_SECRET, - jwtExpiry: 3600, - autoExposeNewTables, - dependencies: [{ service: "postgres", condition: "healthy" }] as const, - }; - const definition = - mode === "native" - ? makePostgresInitService({ ...common, postgresDir: POSTGRES_BIN_PATH }) - : makePostgresInitServiceDocker({ - ...common, - runtime: "docker", - jwtSecret: JWT_SECRET, - jwtExpiry: 3600, - identity: EPHEMERAL_IDENTITY, - }); - return definition.args?.join("\n") ?? ""; - }; - - expect(commandFor(true)).not.toContain( - "alter default privileges for role postgres in schema public", - ); - expect(commandFor(false)).toContain( - "alter default privileges for role postgres in schema public", - ); - }, - ); -}); - -describe("docker-backed auxiliary services", () => { - it("defines realtime command, topology, environment, and readiness locally", () => { - const dependencies = [{ service: "postgres", condition: "healthy" }] as const; - const def = makeRealtimeServiceDocker({ - runtime: "docker", - image: dockerImageForService("realtime", DEFAULT_VERSIONS.realtime), - identity: EPHEMERAL_IDENTITY, - port: 54330, - dbHost: "host.docker.internal", - dbPort: DB_PORT, - jwtSecret: JWT_SECRET, - jwtJwks: "test-jwks", - tenantId: "realtime-dev", - encryptionKey: "supabaserealtime", - secretKeyBase: "test-secret-key-base", - maxHeaderLength: 4096, - platformOs: "linux", - dependencies, - }); - - expect(def.args).toContain(`supabase-realtime-${API_PORT}`); - expect(def.args).toContain("54330:54330"); - expect(def.env?.DB_HOST).toBe("host.docker.internal"); - expect(def.dependencies).toEqual(dependencies); - expect(def.healthCheck?.probe).toEqual( - expect.objectContaining({ _tag: "Exec", command: "curl" }), - ); - }); - - it("defines storage mounts, cleanup, topology, and readiness locally", () => { - const dependencies = [{ service: "postgres-init", condition: "completed" }] as const; - const def = makeStorageServiceDocker({ - runtime: "docker", - image: dockerImageForService("storage", DEFAULT_VERSIONS.storage), - identity: EPHEMERAL_IDENTITY, - port: 54331, - dbHost: "host.docker.internal", - dbPort: DB_PORT, - dataDir: "/tmp/supabase/storage", - anonKey: "anon-key", - serviceKey: "service-key", - jwtSecret: JWT_SECRET, - jwtJwks: "test-jwks", - fileSizeLimit: "50MiB", - enableImageTransformation: true, - imgproxyUrl: "http://host.docker.internal:54332", - s3ProtocolEnabled: true, - cleanupDataDirOnExit: true, - platformOs: "linux", - dependencies, - }); - - expect(def.args).toContain(`supabase-storage-${API_PORT}`); - expect(def.args).toContain("/tmp/supabase/storage:/var/lib/storage"); - expect(def.args).toContain("54331:54331"); - expect(def.dependencies).toEqual(dependencies); - expect(def.healthCheck?.probe).toEqual( - expect.objectContaining({ _tag: "Http", port: 54331, path: "/status" }), - ); - expect(def.supervision?.orphanCleanup).toContainEqual({ - _tag: "RemovePath", - path: "/tmp/supabase/storage", - recursive: true, - }); - }); - - it("defines postgres metadata command, topology, environment, and readiness locally", () => { - const dependencies = [{ service: "postgres", condition: "healthy" }] as const; - const def = makePgmetaServiceDocker({ - runtime: "docker", - image: dockerImageForService("pgmeta", DEFAULT_VERSIONS.pgmeta), - identity: EPHEMERAL_IDENTITY, - port: 54336, - dbHost: "host.docker.internal", - dbPort: DB_PORT, - platformOs: "linux", - dependencies, - }); - - expect(def.args).toContain(`supabase-pgmeta-${API_PORT}`); - expect(def.args).toContain("54336:54336"); - expect(def.env?.PG_META_DB_HOST).toBe("host.docker.internal"); - expect(def.dependencies).toEqual(dependencies); - expect(def.healthCheck?.probe).toEqual( - expect.objectContaining({ _tag: "Http", port: 54336, path: "/health" }), - ); - }); - - it("uses a host HTTP readiness probe for mailpit", () => { - const def = makeMailpitServiceDocker({ - runtime: "docker", - image: dockerImageForService("mailpit", DEFAULT_VERSIONS.mailpit), - identity: EPHEMERAL_IDENTITY, - webPort: 54323, - smtpPort: 54324, - pop3Port: 54325, - platformOs: "linux", - dependencies: [], - }); - - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: 54323, - path: "/readyz", - scheme: "http", - }); - }); - - it("uses a host HTTP health probe for imgproxy", () => { - const def = makeImgproxyServiceDocker({ - runtime: "docker", - image: dockerImageForService("imgproxy", DEFAULT_VERSIONS.imgproxy), - identity: EPHEMERAL_IDENTITY, - port: 54326, - dataDir: "/tmp/supabase/storage", - platformOs: "linux", - dependencies: [{ service: "storage", condition: "healthy" }], - }); - - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: 54326, - path: "/health", - scheme: "http", - }); - expect(def.args).toContain("/tmp/supabase/storage:/var/lib/storage"); - }); - - it("uses docker exec for vector health because its admin port is not published", () => { - const def = makeVectorServiceDocker({ - runtime: "docker", - image: dockerImageForService("vector", DEFAULT_VERSIONS.vector), - identity: EPHEMERAL_IDENTITY, - serviceHost: "127.0.0.1", - analyticsPort: 54327, - analyticsApiKey: "test-api-key", - platformOs: "darwin", - dependencies: [{ service: "analytics", condition: "healthy" }], - }); - - expect(def.healthCheck?.probe).toEqual({ - _tag: "Exec", - command: "docker", - args: [ - "exec", - `supabase-vector-${API_PORT}`, - "sh", - "-ec", - "wget -q -O /dev/null http://127.0.0.1:9001/health", - ], - }); - }); - - it("binds analytics on all interfaces so published ports and proxy health checks work", () => { - const def = makeAnalyticsServiceDocker({ - runtime: "docker", - image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), - identity: EPHEMERAL_IDENTITY, - hostPort: 54328, - platformOs: "darwin", - dbHost: "127.0.0.1", - dbPort: DB_PORT, - apiKey: "test-api-key", - backend: "postgres", - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - - const args = def.args ?? []; - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: 54328, - path: "/health", - scheme: "http", - }); - expect(def.healthCheck?.initialDelaySeconds).toBe(10); - expect(def.env?.PORT).toBe("4000"); - expect(def.env?.PHX_HTTP_PORT).toBe("4000"); - expect(def.env?.LOGFLARE_NODE_HOST).toBe("0.0.0.0"); - expect(args).toContain("54328:4000"); - }); - - it("keeps analytics on its container port when Linux uses bridge networking", () => { - const def = makeAnalyticsServiceDocker({ - runtime: "docker", - image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), - identity: EPHEMERAL_IDENTITY, - hostPort: 54328, - platformOs: "linux", - dbHost: "host.docker.internal", - dbPort: DB_PORT, - apiKey: "test-api-key", - backend: "postgres", - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - - expect(def.env?.PORT).toBe("4000"); - expect(def.env?.PHX_HTTP_PORT).toBe("4000"); - expect(def.env?.LOGFLARE_NODE_HOST).toBe("0.0.0.0"); - expect(def.args).toContain("host.docker.internal:host-gateway"); - expect(def.args).toContain("54328:4000"); - }); - - it("keeps pooler container ports fixed and maps only the selected proxy port outward", () => { - const def = makePoolerServiceDocker({ - runtime: "docker", - image: dockerImageForService("pooler", DEFAULT_VERSIONS.pooler), - identity: EPHEMERAL_IDENTITY, - hostAdminPort: 54329, - hostPort: 54330, - platformOs: "linux", - dbHost: "127.0.0.1", - dbPort: DB_PORT, - poolMode: "transaction", - defaultPoolSize: 20, - maxClientConn: 100, - jwtSecret: JWT_SECRET, - tenantId: "pooler-dev", - encryptionKey: "12345678901234567890123456789012", - secretKeyBase: "1234567890123456789012345678901234567890123456789012345678901234", - dependencies: [{ service: "postgres", condition: "healthy" }], - }); - - expect(def.healthCheck?.probe).toEqual({ - _tag: "Http", - host: "127.0.0.1", - port: 54329, - path: "/api/health", - scheme: "http", - }); - expect(def.env?.PORT).toBe(String(poolerContainerPorts.admin)); - expect(def.env?.PROXY_PORT_SESSION).toBe(String(poolerContainerPorts.session)); - expect(def.env?.PROXY_PORT_TRANSACTION).toBe(String(poolerContainerPorts.transaction)); - expect(def.args).toContain(`54329:${poolerContainerPorts.admin}`); - expect(def.args).toContain(`54330:${poolerContainerPorts.transaction}`); - }); -}); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts deleted file mode 100644 index e739aa962e..0000000000 --- a/packages/stack/src/services/storage.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; -import { - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -interface DockerStorageOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly port: number; - readonly identity: StackIdentity; - readonly dbHost: string; - readonly dbPort: number; - readonly dataDir: string; - readonly anonKey: string; - readonly serviceKey: string; - readonly jwtSecret: string; - readonly jwtJwks: string; - readonly fileSizeLimit: string; - readonly enableImageTransformation: boolean; - readonly imgproxyUrl: string; - readonly s3ProtocolEnabled: boolean; - readonly platformOs: string; - readonly dependencies: ReadonlyArray; - readonly cleanupDataDirOnExit?: boolean; -} - -const STORAGE_DATA_DIR = "/var/lib/storage"; - -export const LOCAL_S3_PROTOCOL_ACCESS_KEY_ID = "local"; -export const LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET = "local-secret"; - -const orphanCleanup = (opts: DockerStorageOptions) => - opts.cleanupDataDirOnExit ? removePathOnOrphanCleanup(opts.dataDir, { recursive: true }) : []; - -const storageHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Http", - host: "127.0.0.1", - port, - path: "/status", - scheme: "http", - }, - ...stackHealthBudgets.storage, -}); - -export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef => - dockerRunService({ - runtime: opts.runtime, - name: "storage", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - volumes: [`${opts.dataDir}:${STORAGE_DATA_DIR}`], - env: { - PORT: String(opts.port), - ANON_KEY: opts.anonKey, - SERVICE_KEY: opts.serviceKey, - AUTH_JWT_SECRET: opts.jwtSecret, - PGRST_JWT_SECRET: opts.jwtSecret, - JWT_JWKS: opts.jwtJwks, - DATABASE_URL: `postgresql://supabase_storage_admin:postgres@${opts.dbHost}:${opts.dbPort}/postgres`, - FILE_SIZE_LIMIT: opts.fileSizeLimit, - STORAGE_BACKEND: "file", - FILE_STORAGE_BACKEND_PATH: STORAGE_DATA_DIR, - STORAGE_FILE_BACKEND_PATH: STORAGE_DATA_DIR, - TENANT_ID: "stub", - STORAGE_S3_REGION: "local", - GLOBAL_S3_BUCKET: "stub", - ENABLE_IMAGE_TRANSFORMATION: String(opts.enableImageTransformation), - IMGPROXY_URL: opts.imgproxyUrl, - TUS_URL_PATH: "/storage/v1/upload/resumable", - S3_PROTOCOL_ENABLED: String(opts.s3ProtocolEnabled), - S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, - S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, - S3_PROTOCOL_PREFIX: "/storage/v1", - UPLOAD_FILE_SIZE_LIMIT: "52428800000", - UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", - SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", - }, - dependencies: opts.dependencies, - healthCheck: storageHealthCheck(opts.port), - orphanCleanup: orphanCleanup(opts), - }); diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts deleted file mode 100644 index bb5f1009d5..0000000000 --- a/packages/stack/src/services/studio.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { StackIdentity } from "../StackIdentity.ts"; -import { - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -interface DockerStudioOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; - readonly port: number; - readonly apiUrl: string; - readonly publicApiUrl: string; - readonly pgmetaUrl: string; - readonly publishableKey: string; - readonly secretKey: string; - readonly s3ProtocolAccessKeyId: string; - readonly s3ProtocolAccessKeySecret: string; - readonly jwtSecret: string; - readonly analyticsEnabled: boolean; - readonly analyticsBackend: "postgres" | "bigquery"; - readonly analyticsUrl: string; - readonly analyticsApiKey: string; - readonly platformOs: string; - readonly dependencies: ReadonlyArray; -} - -const studioHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ - probe: { - _tag: "Http", - host: "127.0.0.1", - port, - path: "/api/platform/profile", - scheme: "http", - }, - ...stackHealthBudgets.studio, -}); - -export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef => - dockerRunService({ - runtime: opts.runtime, - name: "studio", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), - env: { - PORT: String(opts.port), - CURRENT_CLI_VERSION: "local", - STUDIO_PG_META_URL: opts.pgmetaUrl, - POSTGRES_PASSWORD: "postgres", - SUPABASE_URL: opts.apiUrl, - SUPABASE_PUBLIC_URL: opts.publicApiUrl, - AUTH_JWT_SECRET: opts.jwtSecret, - SUPABASE_ANON_KEY: opts.publishableKey, - SUPABASE_SERVICE_KEY: opts.secretKey, - SUPABASE_PUBLISHABLE_KEY: opts.publishableKey, - SUPABASE_SECRET_KEY: opts.secretKey, - S3_PROTOCOL_ACCESS_KEY_ID: opts.s3ProtocolAccessKeyId, - S3_PROTOCOL_ACCESS_KEY_SECRET: opts.s3ProtocolAccessKeySecret, - LOGFLARE_PRIVATE_ACCESS_TOKEN: opts.analyticsApiKey, - LOGFLARE_URL: opts.analyticsUrl, - NEXT_PUBLIC_ENABLE_LOGS: String(opts.analyticsEnabled), - NEXT_ANALYTICS_BACKEND_PROVIDER: opts.analyticsBackend, - HOSTNAME: "0.0.0.0", - POSTGRES_USER_READ_WRITE: "postgres", - OPENAI_API_KEY: "", - PGRST_DB_SCHEMAS: "public,graphql_public", - PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", - PGRST_DB_MAX_ROWS: "1000", - }, - dependencies: opts.dependencies, - healthCheck: studioHealthCheck(opts.port), - }); diff --git a/packages/stack/src/services/vector.ts b/packages/stack/src/services/vector.ts deleted file mode 100644 index a4d15fb198..0000000000 --- a/packages/stack/src/services/vector.ts +++ /dev/null @@ -1,131 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import, effecttsgo/process-env -- Synchronous Docker socket discovery runs before Effect runtime construction and preserves the service's sync API. - -import { accessSync, constants } from "node:fs"; -import { dockerNetworkArgs } from "../Platform.ts"; -import type { ContainerRuntime } from "../ContainerRuntime.ts"; -import { dockerContainerName, type StackIdentity } from "../StackIdentity.ts"; -import { - dockerExecHealthCheck, - dockerRunService, - type ContainerRuntimeOptions, - type ServiceDependency, -} from "./service-utils.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; - -interface DockerVectorOptions extends ContainerRuntimeOptions { - readonly image: string; - readonly identity: StackIdentity; - readonly serviceHost: string; - readonly analyticsPort: number; - readonly analyticsApiKey: string; - readonly platformOs: string; - readonly dependencies: ReadonlyArray; -} - -const vectorConfig = ( - host: string, - port: number, - apiKey: string, - logSource: "docker_logs" | "internal_logs", -) => `api: - enabled: true - address: 0.0.0.0:9001 - -sources: - runtime: - type: ${logSource} - -sinks: - logflare: - type: http - inputs: - - runtime - encoding: - codec: json - method: post - request: - retry_max_duration_secs: 10 - headers: - x-api-key: "${apiKey}" - uri: "http://${host}:${port}/api/logs?source_name=docker.logs.local" -`; - -const canAccessSocket = (socket: string): boolean => { - try { - accessSync(socket, constants.R_OK | constants.W_OK); - return true; - } catch { - return false; - } -}; - -const unixSocketFromEnv = (value: string | undefined): string | undefined => { - if (value === undefined || !value.startsWith("unix://")) return undefined; - const socket = value.slice("unix://".length); - return socket.length > 0 && canAccessSocket(socket) ? socket : undefined; -}; - -const podmanSocketCandidates = (): ReadonlyArray => { - const candidates: Array = []; - const runtimeDir = process.env.XDG_RUNTIME_DIR; - if (runtimeDir !== undefined && runtimeDir.length > 0) { - candidates.push(`${runtimeDir}/podman/podman.sock`); - } - const uid = process.getuid?.(); - if (uid !== undefined) candidates.push(`/run/user/${uid}/podman/podman.sock`); - candidates.push("/run/podman/podman.sock"); - return candidates; -}; - -const resolveVectorDockerSocket = (runtime: ContainerRuntime): string | undefined => { - if (runtime === "podman") { - const explicitPodmanSocket = unixSocketFromEnv(process.env.CONTAINER_HOST); - if (explicitPodmanSocket !== undefined) return explicitPodmanSocket; - const explicitDockerSocket = unixSocketFromEnv(process.env.DOCKER_HOST); - if (explicitDockerSocket !== undefined) return explicitDockerSocket; - return podmanSocketCandidates().find(canAccessSocket); - } - - const explicitDockerSocket = unixSocketFromEnv(process.env.DOCKER_HOST); - if (explicitDockerSocket !== undefined) return explicitDockerSocket; - return canAccessSocket("/var/run/docker.sock") ? "/var/run/docker.sock" : undefined; -}; - -export const makeVectorServiceDocker = (opts: DockerVectorOptions) => { - const containerName = dockerContainerName("vector", opts.identity.key); - const socketPath = resolveVectorDockerSocket(opts.runtime); - const volumes = socketPath === undefined ? [] : [`${socketPath}:/var/run/docker.sock:ro`]; - - return dockerRunService({ - runtime: opts.runtime, - name: "vector", - identity: opts.identity, - image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, []), - volumes, - securityOptions: opts.runtime === "podman" && socketPath !== undefined ? ["label=disable"] : [], - env: socketPath === undefined ? {} : { DOCKER_HOST: "unix:///var/run/docker.sock" }, - entrypoint: "sh", - cmd: [ - "-c", - `cat <<'EOF' > /etc/vector/vector.yaml && exec vector --config /etc/vector/vector.yaml -${vectorConfig( - opts.serviceHost, - opts.analyticsPort, - opts.analyticsApiKey, - socketPath === undefined ? "internal_logs" : "docker_logs", -)}EOF -`, - ], - dependencies: opts.dependencies, - healthCheck: dockerExecHealthCheck( - opts.runtime, - containerName, - "sh", - ["-ec", "wget -q -O /dev/null http://127.0.0.1:9001/health"], - { - ...stackHealthBudgets.vector, - }, - ), - }); -}; diff --git a/packages/stack/src/services/vector.unit.test.ts b/packages/stack/src/services/vector.unit.test.ts deleted file mode 100644 index 768a0eff8e..0000000000 --- a/packages/stack/src/services/vector.unit.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Vector tests call the Promise-returning service facade from Vitest callbacks. - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { stackIdentity } from "../StackIdentity.ts"; -import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; -import { makeVectorServiceDocker } from "./vector.ts"; - -const existingPaths = vi.hoisted(() => new Set()); -const accessiblePaths = vi.hoisted(() => new Set()); - -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: (path: Parameters[0]) => existingPaths.has(String(path)), - accessSync: (path: Parameters[0]) => { - const socket = String(path); - if (!existingPaths.has(socket) || !accessiblePaths.has(socket)) { - throw new Error("socket is not accessible"); - } - }, - }; -}); - -const identity = stackIdentity({ apiPort: 54321 }); - -const makeVector = (runtime: "docker" | "podman") => - makeVectorServiceDocker({ - runtime, - image: dockerImageForService("vector", DEFAULT_VERSIONS.vector), - identity, - serviceHost: "127.0.0.1", - analyticsPort: 54327, - analyticsApiKey: "test-api-key", - platformOs: "linux", - dependencies: [], - }); - -describe("makeVectorServiceDocker log source", () => { - beforeEach(() => { - existingPaths.clear(); - accessiblePaths.clear(); - vi.stubEnv("CONTAINER_HOST", ""); - vi.stubEnv("DOCKER_HOST", ""); - vi.stubEnv("XDG_RUNTIME_DIR", ""); - }); - - afterEach(() => { - existingPaths.clear(); - accessiblePaths.clear(); - vi.unstubAllEnvs(); - }); - - it("uses internal_logs when Podman cannot find its own socket", () => { - existingPaths.add("/var/run/docker.sock"); - accessiblePaths.add("/var/run/docker.sock"); - - const def = makeVector("podman"); - const args = def.args ?? []; - - expect(args.join("\n")).toContain("type: internal_logs"); - expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); - expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); - expect(def.env?.DOCKER_HOST).toBeUndefined(); - expect(args).not.toContain("--security-opt"); - }); - - it("connects Podman Vector to an available Podman socket", () => { - existingPaths.add("/run/podman/podman.sock"); - accessiblePaths.add("/run/podman/podman.sock"); - - const def = makeVector("podman"); - const args = def.args ?? []; - - expect(args.join("\n")).toContain("type: docker_logs"); - expect(args).toContain("/run/podman/podman.sock:/var/run/docker.sock:ro"); - expect(def.env?.DOCKER_HOST).toBe("unix:///var/run/docker.sock"); - expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); - expect(args).toContain("--security-opt"); - expect(args).toContain("label=disable"); - }); - - it("uses internal_logs when Docker cannot find its socket", () => { - const def = makeVector("docker"); - const args = def.args ?? []; - - expect(args.join("\n")).toContain("type: internal_logs"); - expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); - expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); - expect(def.env?.DOCKER_HOST).toBeUndefined(); - expect(args).not.toContain("--security-opt"); - }); - - it("uses internal_logs when the Podman socket is not readable and writable", () => { - existingPaths.add("/run/podman/podman.sock"); - - const def = makeVector("podman"); - const args = def.args ?? []; - - expect(args.join("\n")).toContain("type: internal_logs"); - expect(args).not.toContain("/run/podman/podman.sock:/var/run/docker.sock:ro"); - expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); - expect(def.env?.DOCKER_HOST).toBeUndefined(); - expect(args).not.toContain("--security-opt"); - }); - - it("honors an explicit Docker socket for Podman Vector", () => { - existingPaths.add("/var/run/docker.sock"); - accessiblePaths.add("/var/run/docker.sock"); - vi.stubEnv("DOCKER_HOST", "unix:///var/run/docker.sock"); - - const def = makeVector("podman"); - const args = def.args ?? []; - - expect(args.join("\n")).toContain("type: docker_logs"); - expect(args).toContain("/var/run/docker.sock:/var/run/docker.sock:ro"); - expect(def.env?.DOCKER_HOST).toBe("unix:///var/run/docker.sock"); - expect(args).not.toContain("DOCKER_HOST=unix:///var/run/docker.sock"); - expect(args).toContain("--security-opt"); - expect(args).toContain("label=disable"); - }); -}); diff --git a/packages/stack/src/stackHandle.ts b/packages/stack/src/stackHandle.ts deleted file mode 100644 index 3a9131fc9f..0000000000 --- a/packages/stack/src/stackHandle.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { LogEntry } from "@supabase/process-compose"; -import { Effect, Stream } from "effect"; -import type { FunctionsReloadConfig } from "./functions.ts"; -import type { ForegroundStackHandle } from "./createStack.ts"; -import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; -import type { ReadyOptions } from "./StackConfig.ts"; -import type { StackServiceState } from "./StackServiceState.ts"; - -/** Public Promise/AsyncIterable stack surface for Node and Bun consumers. */ -export interface StackHandle extends AsyncDisposable { - readonly url: string; - readonly dbUrl: string; - readonly publishableKey: string; - readonly secretKey: string; - start(): Promise; - stop(): Promise; - dispose(): Promise; - startService(name: string): Promise; - stopService(name: string): Promise; - restartService(name: string): Promise; - reloadFunctions(opts?: FunctionsReloadConfig): Promise; - reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; - ready(opts?: ReadyOptions): Promise; - serviceReady(name: string, opts?: ReadyOptions): Promise; - getStatus(): Promise>; - getServiceStatus(name: string): Promise; - statusChanges(): AsyncIterable; - logs(): AsyncIterable; - serviceLogs(name: string): AsyncIterable; - logHistory(name: string, limit?: number): Promise>; -} - -export const toStackHandle = (handle: ForegroundStackHandle): StackHandle => ({ - url: handle.url, - dbUrl: handle.dbUrl, - publishableKey: handle.publishableKey, - secretKey: handle.secretKey, - start: () => Effect.runPromise(handle.start), - stop: () => Effect.runPromise(handle.stop), - dispose: () => Effect.runPromise(handle.dispose), - startService: (name) => Effect.runPromise(handle.startService(name)), - stopService: (name) => Effect.runPromise(handle.stopService(name)), - restartService: (name) => Effect.runPromise(handle.restartService(name)), - reloadFunctions: (opts) => Effect.runPromise(handle.reloadFunctions(opts)), - reloadEdgeRuntime: (opts) => Effect.runPromise(handle.reloadEdgeRuntime(opts)), - ready: (opts) => Effect.runPromise(handle.ready(opts)), - serviceReady: (name, opts) => Effect.runPromise(handle.serviceReady(name, opts)), - getStatus: () => Effect.runPromise(handle.getStatus), - getServiceStatus: (name) => Effect.runPromise(handle.getServiceStatus(name)), - statusChanges: () => Stream.toAsyncIterable(handle.statusChanges), - logs: () => Stream.toAsyncIterable(handle.logs), - serviceLogs: (name) => Stream.toAsyncIterable(handle.serviceLogs(name)), - logHistory: (name, limit) => Effect.runPromise(handle.logHistory(name, limit)), - [Symbol.asyncDispose]: () => Effect.runPromise(handle.dispose), -}); diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts deleted file mode 100644 index cef123102e..0000000000 --- a/packages/stack/src/supervisor.integration.test.ts +++ /dev/null @@ -1,2190 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/process-env -- Supervisor integration tests exercise native child-process, HTTP, timer, environment, and filesystem boundaries from Vitest's Promise harness. -import { Cause, Context, Effect, Exit, Layer, Predicate, Schema } from "effect"; -import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; -import { fork, type ChildProcess } from "node:child_process"; -import { createServer as createHttpServer } from "node:http"; -import { createConnection, createServer } from "node:net"; -import { - cpSync, - chmodSync, - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { randomUUID } from "node:crypto"; -import { describe, expect, test } from "vitest"; -import { Stack } from "./Stack.ts"; -import { RemoteStack, updateRemoteLaunch } from "./RemoteStack.ts"; -import { httpTransportClientLayer } from "./HttpTransportClient.ts"; -import { managedDaemonLayer, SupervisorStartError } from "./supervisor.ts"; -import { managedStackDocumentPathEffect, managedStackPathsEffect } from "./managed/paths.ts"; -import { stopManagedStack } from "./managed/lifecycle.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { managedStackManagerLayer } from "./managed/manager.ts"; -import { resolveConfig as resolveConfigEffect } from "./StackConfigResolver.ts"; -import { controlEndpoint, type ControlEndpoint } from "./managed/control.ts"; -import { deriveStackId, type EnvironmentIdentity } from "./managed/environment.ts"; -import type { SupervisorStartMessage, SupervisorStartedMessage } from "./supervisor.ts"; -import { SupervisorEventSchema, type SupervisorErrorMessage } from "./SupervisorProtocol.ts"; -import { git } from "../tests/helpers/git-workspace.ts"; -import { watchDirectoryWithRetry } from "../tests/helpers/file-watch.ts"; -import { controlTransportLayer } from "./platform-node.ts"; -import { ControlTransport } from "./managed/control.ts"; -import { ManagedStackManager } from "./managed/manager.ts"; -import { prepareUpgradeReplacement } from "./SupervisorUpgradeRestart.ts"; -import type { DaemonConfigInput } from "./StackConfigResolver.ts"; - -const childEntryPoint = fileURLToPath( - new URL("../tests/helpers/supervisor-child.ts", import.meta.url), -); -const errorChildEntryPoint = fileURLToPath( - new URL("../tests/helpers/supervisor-error-child.ts", import.meta.url), -); -const nonReadyChildEntryPoint = fileURLToPath( - new URL("../tests/helpers/supervisor-non-ready-child.ts", import.meta.url), -); -const bunExecutable = process.env["BUN_EXECUTABLE"] ?? "bun"; -const FILE_WAIT_TIMEOUT_MS = 30_000; - -const resolveConfig = (...args: Parameters) => - Effect.runPromise(resolveConfigEffect(...args).pipe(Effect.provide(NodeFileSystem.layer))); - -type TestMode = "bind-all" | "fail-after-bind" | "hold-reservations" | "hold-start" | "hold-stop"; - -interface ChildHandle { - readonly child: ChildProcess; - readonly started: Promise; - readonly error: Promise; - readonly attachedBeforeReady: Promise; - readonly managedStarted: Promise; -} - -const workspace = async (): Promise<{ - readonly root: string; - readonly cacheRoot: string; - readonly stateRoot: string; - readonly stackId: string; -}> => { - for (let attempt = 0; attempt < 32; attempt += 1) { - const root = mkdtempSync(join(tmpdir(), "sup-stack-workspace-")); - const cacheRoot = mkdtempSync(join(tmpdir(), "sup-stack-cache-")); - const stateRoot = join(cacheRoot, "managed"); - mkdirSync(stateRoot); - const identity: EnvironmentIdentity = { - workspaceId: randomUUID(), - checkoutId: randomUUID(), - contextId: randomUUID(), - localProjectKey: ".", - }; - const stackId = deriveStackId(identity, "default"); - const endpoint = await Effect.runPromise(controlEndpoint(stackId)); - if (!(await canBind(endpoint.port))) { - rmSync(root, { recursive: true, force: true }); - rmSync(cacheRoot, { recursive: true, force: true }); - continue; - } - mkdirSync(join(root, ".supabase"), { recursive: true }); - writeFileSync( - join(root, ".supabase", "identity.json"), - `${JSON.stringify( - { - version: 1, - workspaceId: identity.workspaceId, - checkoutId: identity.checkoutId, - contextId: identity.contextId, - }, - null, - 2, - )}\n`, - ); - return { root, cacheRoot, stateRoot, stackId }; - } - throw new Error("Unable to allocate a free supervisor control endpoint after 32 attempts"); -}; - -const waitForFile = (path: string): Promise => - new Promise((resolve, reject) => { - if (existsSync(path)) { - resolve(); - return; - } - let settled = false; - let timeout: ReturnType | undefined; - let stopWatching: (() => void) | undefined; - const settle = (continuation: () => void) => { - if (settled) return; - settled = true; - if (timeout !== undefined) clearTimeout(timeout); - stopWatching?.(); - continuation(); - }; - stopWatching = watchDirectoryWithRetry( - dirname(path), - () => { - if (existsSync(path)) settle(resolve); - }, - (cause) => settle(() => reject(cause instanceof Error ? cause : new Error(String(cause)))), - ); - timeout = setTimeout( - () => settle(() => reject(new Error(`timed out waiting for file ${path}`))), - FILE_WAIT_TIMEOUT_MS, - ); - if (existsSync(path)) { - settle(resolve); - } - }); - -const messageFor = ( - roots: { - readonly root: string; - readonly stateRoot: string; - readonly stackId: string; - }, - overrides: Partial = {}, -): SupervisorStartMessage => ({ - type: "start", - cliVersion: "test", - stackId: roots.stackId, - workspacePath: roots.root, - stackName: "default", - stateRoot: roots.stateRoot, - config: { - cwd: roots.root, - projectDir: roots.root, - mode: "native", - auth: false, - postgrest: false, - realtime: false, - storage: false, - imgproxy: false, - localSmtp: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }, - portIntents: { - activeFields: ["apiPort", "dbPort"], - document: {}, - }, - ...overrides, -}); - -const spawnChild = ( - input: SupervisorStartMessage, - options: { - readonly testMode?: TestMode; - readonly platform?: "node" | "bun"; - readonly environment?: Readonly>; - } = {}, -): ChildHandle => { - const stageRoot = join(input.workspacePath, ".supabase", "test-stages"); - mkdirSync(stageRoot, { recursive: true }); - const stageId = randomUUID(); - const attachedBeforeReadyFile = - options.environment?.["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"] ?? - join(stageRoot, `${stageId}-attached-before-ready`); - const managedStartedFile = - options.environment?.["SUPABASE_STACK_TEST_MANAGED_STARTED_FILE"] ?? - join(stageRoot, `${stageId}-managed-started`); - const child = fork(childEntryPoint, [], { - execPath: bunExecutable, - execArgv: [], - detached: false, - stdio: ["ignore", "pipe", "pipe", "ipc"], - env: { - ...process.env, - SUPABASE_STACK_RUN_DAEMON: "1", - ...(options.testMode === undefined - ? {} - : { SUPABASE_STACK_TEST_RUNTIME_MODE: options.testMode }), - ...(options.platform === undefined ? {} : { SUPABASE_STACK_TEST_PLATFORM: options.platform }), - SUPABASE_STACK_TEST_ATTACHED_READY_FILE: attachedBeforeReadyFile, - SUPABASE_STACK_TEST_MANAGED_STARTED_FILE: managedStartedFile, - ...options.environment, - }, - }); - let stderr = ""; - child.stderr?.on("data", (chunk: Uint8Array) => { - stderr += new TextDecoder().decode(chunk); - }); - const started = new Promise((resolve, reject) => { - const cleanup = () => { - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - const onMessage = (value: unknown) => { - let event: Schema.Schema.Type; - try { - event = Schema.decodeUnknownSync(SupervisorEventSchema)(value); - } catch { - return; - } - if (event.type === "started") { - cleanup(); - resolve(event); - } else if (event.type === "error") { - cleanup(); - reject(new Error(`${event.message}\n${stderr}`)); - } - }; - const onError = (cause: Error) => { - cleanup(); - reject(cause); - }; - const onExit = (code: number | null) => { - cleanup(); - reject(new Error(`supervisor exited before ack (${String(code)})\n${stderr}`)); - }; - child.on("message", onMessage); - child.once("error", onError); - child.once("exit", onExit); - }); - const error = new Promise((resolve, reject) => { - const cleanup = () => { - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - const onMessage = (value: unknown) => { - let event: Schema.Schema.Type; - try { - event = Schema.decodeUnknownSync(SupervisorEventSchema)(value); - } catch { - return; - } - if (event.type === "error") { - cleanup(); - resolve(event); - } - }; - const onError = (cause: Error) => { - cleanup(); - reject(cause); - }; - const onExit = (code: number | null) => { - cleanup(); - reject(new Error(`supervisor exited before error event (${String(code)})`)); - }; - child.on("message", onMessage); - child.once("error", onError); - child.once("exit", onExit); - }); - const waitForStage = (path: string) => waitForFile(path); - const attachedBeforeReady = waitForStage(attachedBeforeReadyFile); - const managedStarted = waitForStage(managedStartedFile); - void started.catch(() => undefined); - void error.catch(() => undefined); - void attachedBeforeReady.catch(() => undefined); - void managedStarted.catch(() => undefined); - child.send(input); - return { child, started, error, attachedBeforeReady, managedStarted }; -}; - -const restartThroughManagedStart = ( - roots: Awaited>, - oldStarted: SupervisorStartedMessage, - overrides: Partial = {}, -): Promise => { - const invocation = messageFor(roots, { cliVersion: "new", ...overrides }); - const configInput: DaemonConfigInput = { - ...invocation.config, - cwd: roots.root, - projectDir: roots.root, - cacheRoot: roots.cacheRoot, - name: invocation.stackName, - }; - const managerLayer = managedStackManagerLayer({ - stateRoot: roots.stateRoot, - preferCatalogDefaults: false, - }).pipe( - Layer.provide( - Layer.mergeAll( - NodeFileSystem.layer, - NodePath.layer, - gitConfigStoreLayer, - controlTransportLayer, - ), - ), - ); - return Effect.runPromise( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const controlTransport = yield* ControlTransport; - const oldOwner = yield* manager.probeControl(roots.stackId); - if (oldOwner === undefined) return yield* Effect.die("expected incompatible owner"); - return yield* prepareUpgradeReplacement({ - stackId: roots.stackId, - oldCliVersion: oldStarted.owner.daemonCliVersion, - oldOwner, - input: invocation, - configInput, - manager, - controlTransport, - }); - }).pipe( - Effect.provide(Layer.mergeAll(managerLayer, NodeServices.layer, controlTransportLayer)), - ), - ).then((prepared) => - spawnChild({ - ...invocation, - replacement: true, - config: prepared.effectiveConfigInput, - launch: prepared.launch, - }), - ); -}; - -const kill = (child: ChildProcess): Promise => - new Promise((resolve) => { - if (child.exitCode !== null || child.killed) { - resolve(); - return; - } - child.once("exit", () => resolve()); - child.kill("SIGKILL"); - }); - -const waitForExit = (child: ChildProcess): Promise => - new Promise((resolve) => { - if (child.exitCode !== null) { - resolve(); - return; - } - child.once("exit", () => resolve()); - }); - -const fetchOwner = async (endpoint: ControlEndpoint): Promise> => { - const response = await fetch(`${endpoint.url}/owner`); - expect(response.status).toBe(200); - return (await response.json()) as Record; -}; - -const ownerDescriptor = (owner: Record) => ({ - ownershipId: String(owner.ownershipId), - ownerSessionId: String(owner.ownerSessionId), - controlProtocolVersion: 1 as const, - daemonCliVersion: String(owner.daemonCliVersion), -}); - -const remoteStop = async (endpoint: ControlEndpoint): Promise => { - const owner = ownerDescriptor(await fetchOwner(endpoint)); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const context = yield* Layer.build( - RemoteStack.layer(endpoint, { - owner, - cliVersion: owner.daemonCliVersion, - }).pipe(Layer.provide(httpTransportClientLayer)), - ); - yield* Context.get(context, Stack).stop; - }), - ), - ); -}; - -const requestOwnerStop = async ( - endpoint: ControlEndpoint, - intent: "explicit" | "replacement" = "explicit", -): Promise => { - const owner = ownerDescriptor(await fetchOwner(endpoint)); - return fetch(`${endpoint.url}/stop`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ownershipId: owner.ownershipId, - ownerSessionId: owner.ownerSessionId, - intent, - }), - }); -}; - -const stopViaManagedFacade = async (roots: { - readonly root: string; - readonly stateRoot: string; -}): Promise => { - await Effect.runPromise( - stopManagedStack({ workspacePath: roots.root }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - managedStackManagerLayer({ stateRoot: roots.stateRoot }).pipe( - Layer.provide( - Layer.mergeAll( - NodeFileSystem.layer, - NodePath.layer, - gitConfigStoreLayer, - controlTransportLayer, - ), - ), - ), - httpTransportClientLayer, - ), - ), - ), - ); -}; - -const remoteInfo = async (endpoint: ControlEndpoint): Promise<{ readonly url: string }> => { - const owner = ownerDescriptor(await fetchOwner(endpoint)); - return await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const context = yield* Layer.build( - RemoteStack.layer(endpoint, { - owner, - cliVersion: owner.daemonCliVersion, - }).pipe(Layer.provide(httpTransportClientLayer)), - ); - return yield* Context.get(context, Stack).getInfo; - }), - ), - ); -}; - -const updateLaunch = async ( - endpoint: ControlEndpoint, - stackId: string, - owner: SupervisorStartedMessage["owner"], - cliVersion: string, - launch: { - readonly versions: Record; - }, -): Promise => { - await Effect.runPromise( - updateRemoteLaunch( - endpoint, - { - owner, - cliVersion, - }, - stackId, - launch, - ).pipe(Effect.provide(httpTransportClientLayer)), - ); -}; - -const canConnect = (port: number): Promise => - new Promise((resolve) => { - const socket = createConnection({ host: "127.0.0.1", port }); - socket.once("connect", () => { - socket.destroy(); - resolve(true); - }); - socket.once("error", () => { - socket.destroy(); - resolve(false); - }); - }); - -const canBind = (port: number): Promise => - new Promise((resolve) => { - const server = createServer(); - server.once("error", () => { - server.close(); - resolve(false); - }); - server.listen(port, "127.0.0.1", () => { - server.close(() => resolve(true)); - }); - }); - -const bindFakeOwner = async ( - endpoint: ControlEndpoint, - makeServer: () => ReturnType, -): Promise> => { - const server = makeServer(); - await new Promise((resolve, reject) => { - const onError = (cause: Error) => { - server.off("listening", onListening); - reject(new Error(`unable to bind fake owner at ${endpoint.url}: ${cause.message}`)); - }; - const onListening = () => { - server.off("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(endpoint.port, endpoint.hostname); - }); - return server; -}; - -const listenStartingOwner = ( - endpoint: ControlEndpoint, - ownershipId: string, -): Promise> => - bindFakeOwner(endpoint, () => - createHttpServer((request, response) => { - if (request.method === "GET" && request.url === "/owner") { - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId, - ownerSessionId: "fake-session", - kind: "supervisor", - state: "starting", - ready: false, - daemonCliVersion: "test", - }), - ); - return; - } - response.writeHead(404); - response.end(); - }), - ); - -const listenMalformedOwner = ( - endpoint: ControlEndpoint, -): Promise> => - bindFakeOwner(endpoint, () => - createHttpServer((request, response) => { - if (request.method === "GET" && request.url === "/owner") { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ owner: "unrelated-listener" })); - return; - } - response.writeHead(404); - response.end(); - }), - ); - -const listenOwnerSequence = ( - endpoint: ControlEndpoint, - ownershipId: string, - states: ReadonlyArray<"starting" | "stopping">, - onRead: (state: "starting" | "stopping") => void = () => undefined, - closeAfterSequence = true, -): Promise> => - bindFakeOwner(endpoint, () => { - let reads = 0; - const server = createHttpServer((request, response) => { - if (request.method !== "GET" || request.url !== "/owner") { - response.writeHead(404); - response.end(); - return; - } - const state = states[Math.min(reads, states.length - 1)] ?? "starting"; - reads += 1; - onRead(state); - // Stop accepting the next probe before publishing the final response. - // Closing from the response callback leaves a window where the child can - // open another connection after observing this response but before the - // test server processes its callback under load. - if (closeAfterSequence && reads >= states.length) server.close(); - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - controlProtocol: "supabase-stack-control", - controlProtocolVersion: 1, - ownershipId, - ownerSessionId: "fake-session", - kind: "supervisor", - state, - ready: false, - daemonCliVersion: "test", - }), - ); - }); - return server; - }); - -const listenStoppingOwner = async ( - endpoint: ControlEndpoint, - ownershipId: string, -): Promise<{ - readonly server: ReturnType; - readonly release: () => void; -}> => { - const server = await listenOwnerSequence( - endpoint, - ownershipId, - ["stopping"], - () => undefined, - false, - ); - let released = false; - return { - server, - release: () => { - if (released) return; - released = true; - server.close(); - }, - }; -}; - -const cleanupRoots = (roots: { - readonly root: string; - readonly cacheRoot?: string; - readonly stateRoot: string; -}): void => { - rmSync(roots.root, { recursive: true, force: true }); - rmSync(roots.cacheRoot ?? roots.stateRoot, { recursive: true, force: true }); -}; - -const readStackDocument = (roots: { - readonly stateRoot: string; -}): - | { - readonly id: string; - readonly lifecycle: string; - readonly ports: ReadonlyArray<{ key: string; port: number }>; - readonly launch?: { - readonly mode: string; - readonly containerRuntime?: string; - readonly versions: Record; - }; - } - | undefined => { - const stacksRoot = join(roots.stateRoot, "stacks"); - if (!existsSync(stacksRoot)) return undefined; - for (const id of readdirSync(stacksRoot)) { - const path = Effect.runSync(managedStackDocumentPathEffect(roots.stateRoot, id)); - if (!existsSync(path)) continue; - return JSON.parse(readFileSync(path, "utf8")) as { - readonly id: string; - readonly lifecycle: string; - readonly ports: ReadonlyArray<{ key: string; port: number }>; - }; - } - return undefined; -}; - -type StackDocument = { - readonly id: string; - readonly lifecycle: string; - readonly ports: ReadonlyArray<{ port: number }>; - readonly launch?: { readonly mode: string; readonly versions: Record }; -}; - -const waitForStackDocument = async ( - roots: { readonly stateRoot: string; readonly stackId: string }, - lifecycle: string, -): Promise => { - const documentPath = Effect.runSync( - managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), - ); - const stackDirectory = dirname(documentPath); - await waitForFile(dirname(stackDirectory)); - await waitForFile(stackDirectory); - await waitForFile(documentPath); - const readDocument = (): StackDocument | undefined => { - try { - return JSON.parse(readFileSync(documentPath, "utf8")) as StackDocument; - } catch { - return undefined; - } - }; - const existing = readDocument(); - if (existing?.lifecycle === lifecycle) return existing; - - return new Promise((resolve, reject) => { - let stopWatching: (() => void) | undefined; - let timeout: ReturnType | undefined; - let settled = false; - const settle = (continuation: () => void) => { - if (settled) return; - settled = true; - if (timeout !== undefined) clearTimeout(timeout); - stopWatching?.(); - continuation(); - }; - const check = () => { - const document = readDocument(); - if (document?.lifecycle === lifecycle) { - settle(() => resolve(document)); - } - }; - const fail = (cause: unknown) => - settle(() => reject(cause instanceof Error ? cause : new Error(String(cause)))); - stopWatching = watchDirectoryWithRetry(stackDirectory, check, fail); - timeout = setTimeout( - () => fail(new Error(`timed out waiting for stack document lifecycle ${lifecycle}`)), - FILE_WAIT_TIMEOUT_MS, - ); - check(); - }); -}; - -describe("detached supervisor child journeys", () => { - test("rejects an invalid stack name before forking a supervisor", async () => { - const roots = await workspace(); - try { - const exit = await Effect.runPromiseExit( - managedDaemonLayer(messageFor(roots, { stackName: "bad\nname" }), childEntryPoint).pipe( - Effect.provide(Layer.mergeAll(httpTransportClientLayer, NodeFileSystem.layer)), - ), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Predicate.isTagged(Cause.squash(exit.cause), "InvalidManagedStackNameError")).toBe( - true, - ); - } - } finally { - cleanupRoots(roots); - } - }); - - test("preserves a supervisor startup error reported by the child", async () => { - const roots = await workspace(); - try { - const exit = await Effect.runPromiseExit( - managedDaemonLayer(messageFor(roots), errorChildEntryPoint).pipe( - Effect.provide(Layer.mergeAll(httpTransportClientLayer, NodeFileSystem.layer)), - ), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(Predicate.isTagged(error, "SupervisorStartError")).toBe(true); - expect(error).toBeInstanceOf(SupervisorStartError); - if (error instanceof SupervisorStartError) { - expect(error.message).toBe("Supervisor test runtime failed after binding"); - } - } - } finally { - cleanupRoots(roots); - } - }); - - test("rejects a non-ready started response reported by the child", async () => { - const roots = await workspace(); - try { - const exit = await Effect.runPromiseExit( - managedDaemonLayer(messageFor(roots), nonReadyChildEntryPoint).pipe( - Effect.provide(Layer.mergeAll(httpTransportClientLayer, NodeFileSystem.layer)), - ), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(Predicate.isTagged(error, "SupervisorStartError")).toBe(true); - expect(error).toBeInstanceOf(SupervisorStartError); - if (error instanceof SupervisorStartError) { - } - } - } finally { - cleanupRoots(roots); - } - }); - - test("keeps managed documents, runtime metadata, and persistent data roots separate", async () => { - const roots = await workspace(); - const stackId = "e".repeat(64); - const paths = Effect.runSync(managedStackPathsEffect(roots.stateRoot, stackId)); - try { - const resolved = await resolveConfig( - { - projectDir: roots.root, - mode: "native", - auth: false, - postgrest: false, - realtime: false, - storage: false, - imgproxy: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, - }, - { - stackRoot: paths.root, - runtimeRoot: paths.runtime, - ports: { apiPort: 55001, dbPort: 55002 }, - }, - ); - expect(Effect.runSync(managedStackDocumentPathEffect(roots.stateRoot, stackId))).toBe( - join(paths.root, "stack.json"), - ); - expect(resolved.postgres.dataDir.startsWith(join(paths.root, "data"))).toBe(true); - } finally { - cleanupRoots(roots); - } - }); - - test("starts one child, publishes owner, and binds every allocated port", async () => { - const roots = await workspace(); - const child = spawnChild(messageFor(roots)); - try { - const started = await child.started; - const owner = await fetchOwner(started.endpoint); - expect(owner).toMatchObject({ state: "running", ready: true }); - const document = JSON.parse( - readFileSync( - join(roots.stateRoot, "stacks", `${String(owner.ownershipId)}`, "stack.json"), - "utf8", - ), - ) as { lifecycle: string; ports: ReadonlyArray<{ port: number }> }; - expect(document.lifecycle).toBe("running"); - expect(document.ports.length).toBeGreaterThan(0); - expect(existsSync(join(roots.stateRoot, "stacks", "default", "stack.json"))).toBe(false); - for (const assignment of document.ports) expect(await canConnect(assignment.port)).toBe(true); - await remoteStop(started.endpoint); - await waitForExit(child.child); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - } - }); - - test("shuts down the owner when a readiness failure disposes its local runtime", async () => { - const roots = await workspace(); - const input = messageFor(roots); - const child = spawnChild(input, { - environment: { SUPABASE_STACK_TEST_RUNTIME_MODE: "readiness-failure" }, - }); - try { - const started = await child.started; - const owner = ownerDescriptor(await fetchOwner(started.endpoint)); - const readiness = await Effect.runPromiseExit( - Effect.scoped( - Effect.gen(function* () { - const context = yield* Layer.build( - RemoteStack.layer(started.endpoint, { - owner, - cliVersion: input.cliVersion, - }).pipe(Layer.provide(httpTransportClientLayer)), - ); - return yield* Context.get(context, Stack).waitAllReady(); - }), - ), - ); - expect(Exit.isFailure(readiness)).toBe(true); - if (Exit.isFailure(readiness)) { - expect(Predicate.isTagged(Cause.squash(readiness.cause), "StackReadinessError")).toBe(true); - } - await Promise.race([ - waitForExit(child.child), - new Promise((_, reject) => - setTimeout(() => reject(new Error("supervisor did not shut down after disposal")), 5_000), - ), - ]); - await expect(fetch(`${started.endpoint.url}/owner`)).rejects.toThrow(); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - } - }); - - test("authorized managed start replaces an incompatible owner and preserves persisted state", async () => { - const roots = await workspace(); - const oldOwner = spawnChild( - messageFor(roots, { - cliVersion: "old", - }), - ); - let restart: ChildHandle | undefined; - let analyticsPortBlocker: ReturnType | undefined; - try { - const oldStarted = await oldOwner.started; - analyticsPortBlocker = createServer(); - const blockedAnalyticsPort = await new Promise((resolve, reject) => { - analyticsPortBlocker?.once("error", reject); - analyticsPortBlocker?.listen(0, "127.0.0.1", () => { - const address = analyticsPortBlocker?.address(); - if (address === null || typeof address === "string" || address === undefined) { - reject(new Error("analytics blocker did not expose an address")); - return; - } - resolve(address.port); - }); - }); - const documentPath = Effect.runSync( - managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), - ); - const before = JSON.parse(readFileSync(documentPath, "utf8")) as { - id: string; - createdAt: string; - launch: { - mode: string; - containerRuntime?: string; - versions: Record; - excludedServices?: ReadonlyArray; - }; - ports: ReadonlyArray<{ key: string; port: number; intent: string }>; - }; - const persistedBefore = { - ...before, - launch: { ...before.launch, excludedServices: ["analytics"] }, - ports: [ - ...before.ports, - { key: "analytics.port", port: blockedAnalyticsPort, intent: "exact" }, - ], - }; - writeFileSync(documentPath, `${JSON.stringify(persistedBefore, null, 2)}\n`); - const paths = Effect.runSync(managedStackPathsEffect(roots.stateRoot, roots.stackId)); - const sentinel = join(paths.root, "data", "upgrade-sentinel.txt"); - mkdirSync(dirname(sentinel), { recursive: true }); - writeFileSync(sentinel, "preserve-me"); - restart = await restartThroughManagedStart(roots, oldStarted, { - config: { - ...messageFor(roots).config, - analytics: { port: blockedAnalyticsPort }, - vector: {}, - }, - launch: { - mode: "native", - versions: { postgres: "pinned-postgres" }, - excludedServices: ["studio"], - }, - }); - const newStarted = await restart.started; - const newOwner = await fetchOwner(newStarted.endpoint); - expect(newStarted.owner).toMatchObject({ - daemonCliVersion: "new", - state: "running", - ready: true, - }); - expect(analyticsPortBlocker?.listening).toBe(true); - await waitForExit(oldOwner.child); - const staleStop = await fetch(`${oldStarted.endpoint.url}/stop`, { - method: "POST", - headers: { "content-type": "application/json", connection: "close" }, - body: JSON.stringify({ - ownershipId: oldStarted.owner.ownershipId, - ownerSessionId: oldStarted.owner.ownerSessionId, - intent: "explicit", - }), - }); - expect(staleStop.status).toBe(409); - expect(await fetchOwner(oldStarted.endpoint)).toMatchObject({ - state: "running", - ready: true, - }); - const after = JSON.parse(readFileSync(documentPath, "utf8")) as typeof before; - expect(after.id).toBe(before.id); - expect(after.createdAt).toBe(before.createdAt); - expect(after.launch).toMatchObject(persistedBefore.launch); - expect(after.launch.versions.postgres).toEqual(expect.any(String)); - expect(after.ports).toHaveLength(persistedBefore.ports.length); - expect(after.ports).toEqual(expect.arrayContaining(persistedBefore.ports)); - expect(readFileSync(sentinel, "utf8")).toBe("preserve-me"); - await remoteStop(oldStarted.endpoint); - expect(oldStarted.owner.ownerSessionId).not.toBe(newOwner["ownerSessionId"]); - } finally { - if (analyticsPortBlocker?.listening === true) { - await new Promise((resolve) => analyticsPortBlocker?.close(() => resolve())); - } - if (oldOwner.child.exitCode === null) await kill(oldOwner.child); - if (restart?.child.exitCode === null) await kill(restart.child); - cleanupRoots(roots); - } - }); - - test("replacement child never clears an explicit stop that wins the upgrade gap", async () => { - const roots = await workspace(); - const oldOwner = spawnChild( - messageFor(roots, { - cliVersion: "old", - }), - ); - let restart: ChildHandle | undefined; - try { - const oldStarted = await oldOwner.started; - await remoteStop(oldStarted.endpoint); - await waitForExit(oldOwner.child); - await stopViaManagedFacade(roots); - expect(readStackDocument(roots)?.lifecycle).toBe("stopped"); - - restart = spawnChild(messageFor(roots, { replacement: true, cliVersion: "new" })); - await expect(restart.started).rejects.toThrow( - /stopped before takeover|Stack was stopped during startup/, - ); - await waitForExit(restart.child); - expect(readStackDocument(roots)?.lifecycle).toBe("stopped"); - } finally { - if (oldOwner.child.exitCode === null) await kill(oldOwner.child); - if (restart?.child.exitCode === null) await kill(restart.child); - cleanupRoots(roots); - } - }); - - test("an ordinary start rejects an incompatible owner without restarting it", async () => { - const roots = await workspace(); - const oldOwner = spawnChild( - messageFor(roots, { - cliVersion: "old", - }), - ); - let contender: ChildHandle | undefined; - try { - const oldStarted = await oldOwner.started; - contender = spawnChild( - messageFor(roots, { - cliVersion: "new", - }), - ); - await expect(contender.started).rejects.toThrow("Daemon CLI version mismatch"); - await expect(contender.error).resolves.toMatchObject({ - errorCode: "DAEMON_UPGRADE_REQUIRED", - state: "running", - ready: true, - }); - expect(oldOwner.child.exitCode).toBeNull(); - await remoteStop(oldStarted.endpoint); - await waitForExit(oldOwner.child); - } finally { - if (oldOwner.child.exitCode === null) await kill(oldOwner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("demotes a stale running document when startup validation fails before claiming it", async () => { - const roots = await workspace(); - const oldOwner = spawnChild(messageFor(roots)); - let failed: ChildHandle | undefined; - try { - const oldStarted = await oldOwner.started; - await remoteStop(oldStarted.endpoint); - await waitForExit(oldOwner.child); - const documentPath = Effect.runSync( - managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), - ); - const document = JSON.parse(readFileSync(documentPath, "utf8")) as { - lifecycle: string; - stopIntent?: string; - }; - writeFileSync( - documentPath, - `${JSON.stringify({ ...document, lifecycle: "running", stopIntent: undefined }, null, 2)}\n`, - ); - failed = spawnChild( - messageFor(roots, { - config: { ...messageFor(roots).config, mode: "docker" }, - }), - ); - await expect(failed.started).rejects.toThrow(/runtime is already native/); - expect(readStackDocument(roots)?.lifecycle).toBe("failed"); - } finally { - if (oldOwner.child.exitCode === null) await kill(oldOwner.child); - if (failed?.child.exitCode === null) await kill(failed.child); - cleanupRoots(roots); - } - }); - - test("rejects a persisted native stack when restart requests Docker mode", async () => { - const roots = await workspace(); - const native = spawnChild(messageFor(roots)); - let docker: ChildHandle | undefined; - try { - const started = await native.started; - await remoteStop(started.endpoint); - await waitForExit(native.child); - expect(readStackDocument(roots)?.launch).toMatchObject({ mode: "native" }); - - docker = spawnChild( - messageFor(roots, { - config: { ...messageFor(roots).config, mode: "docker" }, - }), - ); - await expect(docker.started).rejects.toThrow( - "Stack runtime is already native; requested docker", - ); - await waitForExit(docker.child); - expect(readStackDocument(roots)?.launch).toMatchObject({ mode: "native" }); - } finally { - if (native.child.exitCode === null) await kill(native.child); - if (docker?.child.exitCode === null) await kill(docker.child); - cleanupRoots(roots); - } - }); - - test("preserves retryable managed data when upgrade restart startup fails", async () => { - const roots = await workspace(); - const oldOwner = spawnChild( - messageFor(roots, { - cliVersion: "old", - }), - ); - let restart: ChildHandle | undefined; - try { - const oldStarted = await oldOwner.started; - const documentPath = Effect.runSync( - managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), - ); - const before = JSON.parse(readFileSync(documentPath, "utf8")) as { - readonly id: string; - readonly createdAt: string; - readonly launch: NonNullable; - }; - const paths = Effect.runSync(managedStackPathsEffect(roots.stateRoot, roots.stackId)); - const sentinel = join(paths.root, "data", "upgrade-restart-start-failure.txt"); - mkdirSync(dirname(sentinel), { recursive: true }); - writeFileSync(sentinel, "retryable"); - const stopResponse = await requestOwnerStop(oldStarted.endpoint, "replacement"); - expect(stopResponse.status).toBe(202); - await stopResponse.arrayBuffer(); - await waitForExit(oldOwner.child); - restart = spawnChild( - messageFor(roots, { - replacement: true, - cliVersion: "new", - launch: before.launch, - }), - { testMode: "fail-after-bind" }, - ); - await expect(restart.started).rejects.toThrow(/runtime failed after binding/); - const after = JSON.parse(readFileSync(documentPath, "utf8")) as { - readonly id: string; - readonly createdAt: string; - readonly lifecycle: string; - readonly launch: unknown; - }; - expect(after.lifecycle).toBe("failed"); - expect(after.id).toBe(before.id); - expect(after.createdAt).toBe(before.createdAt); - expect(after.launch).toEqual(before.launch); - expect(readFileSync(sentinel, "utf8")).toBe("retryable"); - } finally { - if (oldOwner.child.exitCode === null) await kill(oldOwner.child); - if (restart?.child.exitCode === null) await kill(restart.child); - cleanupRoots(roots); - } - }); - - test("ordinary attach joins a replacement child and waits for readiness", async () => { - const roots = await workspace(); - const managedStartedRelease = join(roots.root, "upgrade-managed-started-release"); - const attachedReady = join(roots.root, "upgrade-attached-ready"); - const attachedRelease = join(roots.root, "upgrade-attached-release"); - const oldOwner = spawnChild(messageFor(roots, { cliVersion: "old" })); - let restart: ChildHandle | undefined; - let attached: ChildHandle | undefined; - try { - const oldStarted = await oldOwner.started; - const stopResponse = await requestOwnerStop(oldStarted.endpoint, "replacement"); - expect(stopResponse.status).toBe(202); - await stopResponse.arrayBuffer(); - await waitForExit(oldOwner.child); - restart = spawnChild(messageFor(roots, { replacement: true, cliVersion: "new" }), { - environment: { SUPABASE_STACK_TEST_MANAGED_STARTED_RELEASE_FILE: managedStartedRelease }, - }); - await restart.managedStarted; - attached = spawnChild(messageFor(roots, { cliVersion: "new" }), { - environment: { - SUPABASE_STACK_TEST_ATTACHED_READY_FILE: attachedReady, - SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE: attachedRelease, - }, - }); - await waitForFile(attachedReady); - let attachedStarted = false; - void attached.started.then( - () => { - attachedStarted = true; - }, - () => undefined, - ); - expect(attachedStarted).toBe(false); - writeFileSync(attachedRelease, "release"); - writeFileSync(managedStartedRelease, "release"); - const started = await Promise.all([restart.started, attached.started]); - expect(started[0]?.owner.ownerSessionId).toBe(started[1]?.owner.ownerSessionId); - expect(started[0]?.owner.daemonCliVersion).toBe("new"); - await remoteStop(started[0]!.endpoint); - } finally { - if (oldOwner.child.exitCode === null) await kill(oldOwner.child); - if (restart?.child.exitCode === null) await kill(restart.child); - if (attached?.child.exitCode === null) await kill(attached.child); - cleanupRoots(roots); - } - }); - - test("upgrade preflight failure leaves the incompatible owner running", async () => { - const roots = await workspace(); - const oldOwner = spawnChild( - messageFor(roots, { - cliVersion: "old", - }), - ); - try { - const oldStarted = await oldOwner.started; - await expect( - restartThroughManagedStart(roots, oldStarted, { - config: { ...messageFor(roots).config, port: 65_536 }, - }), - ).rejects.toThrow(); - expect(oldOwner.child.exitCode).toBeNull(); - expect((await fetchOwner(oldStarted.endpoint)).state).toBe("running"); - await remoteStop(oldStarted.endpoint); - await waitForExit(oldOwner.child); - } finally { - if (oldOwner.child.exitCode === null) await kill(oldOwner.child); - cleanupRoots(roots); - } - }); - - test("upgrade restart preserves the target sticky port when the request names a stopped sibling reservation", async () => { - const roots = await workspace(); - const oldOwner = spawnChild( - messageFor(roots, { - cliVersion: "old", - }), - ); - let restart: ChildHandle | undefined; - try { - const oldStarted = await oldOwner.started; - const targetPath = Effect.runSync( - managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), - ); - const target = JSON.parse(readFileSync(targetPath, "utf8")) as { - readonly identity: Readonly>; - readonly ports: ReadonlyArray<{ key: string; port: number; intent: string }>; - }; - const api = target.ports.find((assignment) => assignment.key === "api.port"); - if (api === undefined) throw new Error("expected target API assignment"); - const siblingPort = api.port === 65_000 ? 65_001 : 65_000; - const siblingId = "b".repeat(64); - const siblingPath = Effect.runSync( - managedStackDocumentPathEffect(roots.stateRoot, siblingId), - ); - mkdirSync(dirname(siblingPath), { recursive: true }); - writeFileSync( - siblingPath, - `${JSON.stringify( - { - ...target, - id: siblingId, - identity: { ...target.identity, workspaceId: "sibling-workspace" }, - ports: [{ key: "api.port", port: siblingPort, intent: "exact" }], - lifecycle: "stopped", - }, - null, - 2, - )}\n`, - ); - restart = await restartThroughManagedStart(roots, oldStarted, { - config: { ...messageFor(roots).config, port: siblingPort }, - portIntents: { - ...messageFor(roots).portIntents, - document: { api: { port: siblingPort } }, - }, - }); - const restarted = await restart.started; - expect(await fetchOwner(restarted.endpoint)).toMatchObject({ - daemonCliVersion: "new", - state: "running", - ready: true, - }); - const after = JSON.parse(readFileSync(targetPath, "utf8")) as { - readonly ports: ReadonlyArray<{ key: string; port: number; intent: string }>; - }; - expect(after?.ports).toContainEqual({ key: "api.port", port: api.port, intent: api.intent }); - expect(after?.ports).not.toContainEqual( - expect.objectContaining({ key: "api.port", port: siblingPort }), - ); - await remoteStop(restarted.endpoint); - await waitForExit(restart.child); - await waitForExit(oldOwner.child); - } finally { - if (oldOwner.child.exitCode === null) await kill(oldOwner.child); - if (restart?.child.exitCode === null) await kill(restart.child); - cleanupRoots(roots); - } - }); - - test("starts an omitted-mode stack from one detected runtime selection", async () => { - const roots = await workspace(); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-runtime-")); - const docker = join(binDir, "docker"); - writeFileSync(docker, "#!/bin/sh\nexit 0\n"); - chmodSync(docker, 0o755); - const base = messageFor(roots); - const { mode: _mode, ...config } = base.config; - const child = spawnChild( - messageFor(roots, { - config: { ...config, edgeRuntime: { inspectorPort: 8_123 } }, - }), - { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, - ); - try { - const started = await child.started; - const document = readStackDocument(roots); - expect(document?.launch).toMatchObject({ - mode: "docker", - containerRuntime: "docker", - }); - expect(document?.ports).toContainEqual( - expect.objectContaining({ key: "edge_runtime.inspector_port", intent: "automatic" }), - ); - await remoteStop(started.endpoint); - await waitForExit(child.child); - - const explicit = spawnChild( - messageFor(roots, { - config: { ...config, edgeRuntime: { inspectorPort: 8_123 } }, - portIntents: { - ...base.portIntents, - document: { edge_runtime: { inspector_port: 8_123 } }, - }, - }), - { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, - ); - try { - const explicitStarted = await explicit.started; - const explicitDocument = readStackDocument(roots); - expect(explicitDocument?.ports).toContainEqual({ - key: "edge_runtime.inspector_port", - port: 8_123, - intent: "exact", - }); - await remoteStop(explicitStarted.endpoint); - await waitForExit(explicit.child); - } finally { - if (explicit.child.exitCode === null) await kill(explicit.child); - } - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - - test("falls back to the native service set when no container runtime is usable", async () => { - const roots = await workspace(); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-native-fallback-")); - for (const runtime of ["docker", "podman"]) { - const executable = join(binDir, runtime); - writeFileSync(executable, "#!/bin/sh\nexit 1\n"); - chmodSync(executable, 0o755); - } - const base = messageFor(roots); - const { mode: _mode, ...config } = base.config; - const child = spawnChild( - messageFor(roots, { - config: { - ...config, - auth: {}, - postgrest: {}, - realtime: {}, - storage: {}, - imgproxy: {}, - mailpit: {}, - pgmeta: {}, - studio: {}, - analytics: {}, - vector: {}, - pooler: {}, - }, - }), - { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, - ); - try { - const started = await child.started; - expect(readStackDocument(roots)?.launch).toMatchObject({ mode: "native" }); - await remoteStop(started.endpoint); - await waitForExit(child.child); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - - test("does not discard an explicit Edge Runtime request during native fallback", async () => { - const roots = await workspace(); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-native-edge-runtime-")); - for (const runtime of ["docker", "podman"]) { - const executable = join(binDir, runtime); - writeFileSync(executable, "#!/bin/sh\nexit 1\n"); - chmodSync(executable, 0o755); - } - const base = messageFor(roots); - const { mode: _mode, ...config } = base.config; - const child = spawnChild( - messageFor(roots, { config: { ...config, edgeRuntime: { inspectorPort: 8_123 } } }), - { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, - ); - try { - await expect(child.started).rejects.toThrow("Native mode supports only"); - await waitForExit(child.child); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - - test("does not discard an explicit Docker-only service request during native fallback", async () => { - const roots = await workspace(); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-native-storage-")); - for (const runtime of ["docker", "podman"]) { - const executable = join(binDir, runtime); - writeFileSync(executable, "#!/bin/sh\nexit 1\n"); - chmodSync(executable, 0o755); - } - const base = messageFor(roots); - const { mode: _mode, ...config } = base.config; - const child = spawnChild( - messageFor(roots, { - config: { ...config, storage: { dataDir: join(roots.root, "storage") } }, - }), - { environment: { PATH: `${binDir}:${process.env["PATH"] ?? ""}` } }, - ); - try { - await expect(child.started).rejects.toThrow("Native mode supports only"); - await waitForExit(child.child); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - - test("rejects an explicit mode change before attaching to a running owner", async () => { - const roots = await workspace(); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-mode-attach-")); - const docker = join(binDir, "docker"); - writeFileSync(docker, "#!/bin/sh\nexit 0\n"); - chmodSync(docker, 0o755); - const nativeInput = messageFor(roots); - const dockerInput = messageFor(roots, { - config: { ...nativeInput.config, mode: "docker" }, - }); - const environment = { PATH: `${binDir}:${process.env["PATH"] ?? ""}` }; - const owner = spawnChild(dockerInput, { environment }); - let contender: ChildHandle | undefined; - let sameMode: ChildHandle | undefined; - try { - const started = await owner.started; - contender = spawnChild(nativeInput, { environment }); - await expect(contender.started).rejects.toThrow( - "Stack runtime is already docker; requested native", - ); - await waitForExit(contender.child); - - sameMode = spawnChild(dockerInput, { environment }); - const attached = await sameMode.started; - expect(attached.attached).toBe(true); - await remoteStop(started.endpoint); - await Promise.all([waitForExit(owner.child), waitForExit(sameMode.child)]); - } finally { - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - if (sameMode?.child.exitCode === null) await kill(sameMode.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - - test("rechecks the winner's mode when attach starts before its first document", async () => { - const roots = await workspace(); - const ensureReady = join(roots.root, "ensure-ready"); - const ensureRelease = join(roots.root, "ensure-release"); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-mode-first-launch-")); - const docker = join(binDir, "docker"); - writeFileSync(docker, "#!/bin/sh\nexit 0\n"); - chmodSync(docker, 0o755); - const nativeInput = messageFor(roots); - const dockerInput = messageFor(roots, { - config: { ...nativeInput.config, mode: "docker" }, - }); - const environment = { PATH: `${binDir}:${process.env.PATH ?? ""}` }; - const owner = spawnChild(dockerInput, { - environment: { - ...environment, - SUPABASE_STACK_TEST_ENSURE_READY_FILE: ensureReady, - SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE: ensureRelease, - }, - }); - let contender: ChildHandle | undefined; - try { - await waitForFile(ensureReady); - contender = spawnChild(nativeInput, { environment }); - await contender.attachedBeforeReady; - - writeFileSync(ensureRelease, "release"); - await owner.started; - await expect(contender.started).rejects.toThrow( - "Stack runtime is already docker; requested native", - ); - await waitForExit(contender.child); - await remoteStop((await owner.started).endpoint); - await waitForExit(owner.child); - } finally { - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - - test("reuses the persisted runtime instead of selecting a different one on restart", async () => { - const roots = await workspace(); - const binDir = mkdtempSync(join(tmpdir(), "sup-stack-sticky-runtime-")); - const docker = join(binDir, "docker"); - const podman = join(binDir, "podman"); - writeFileSync(docker, "#!/bin/sh\nexit 0\n"); - writeFileSync(podman, "#!/bin/sh\nexit 1\n"); - chmodSync(docker, 0o755); - chmodSync(podman, 0o755); - const base = messageFor(roots); - const { mode: _mode, ...config } = base.config; - const input = messageFor(roots, { config }); - const environment = { PATH: `${binDir}:${process.env["PATH"] ?? ""}` }; - const initial = spawnChild(input, { environment }); - let restarted: ChildHandle | undefined; - try { - const started = await initial.started; - await remoteStop(started.endpoint); - await waitForExit(initial.child); - - writeFileSync(docker, "#!/bin/sh\nexit 1\n"); - writeFileSync(podman, "#!/bin/sh\nexit 0\n"); - restarted = spawnChild(input, { environment }); - - await expect(restarted.started).rejects.toThrow( - "Docker mode requires a usable docker runtime", - ); - expect(readStackDocument(roots)?.launch).toMatchObject({ - mode: "docker", - containerRuntime: "docker", - }); - } finally { - if (initial.child.exitCode === null) await kill(initial.child); - if (restarted?.child.exitCode === null) await kill(restarted.child); - cleanupRoots(roots); - rmSync(binDir, { recursive: true, force: true }); - } - }); - - test("publishes stopping before a slow owner shutdown can finish", async () => { - const roots = await workspace(); - const stopBegan = join(roots.root, "stop-began"); - const child = spawnChild(messageFor(roots), { - testMode: "hold-stop", - environment: { SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan }, - }); - try { - const started = await child.started; - expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "running", ready: true }); - void requestOwnerStop(started.endpoint).catch(() => undefined); - await waitForFile(stopBegan); - expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - } - }); - - test("Bun routes a ready-owner stop through the daemon shutdown transaction", async () => { - const roots = await workspace(); - const stopBegan = join(roots.root, "stop-began"); - const child = spawnChild(messageFor(roots), { - testMode: "hold-stop", - platform: "bun", - environment: { SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan }, - }); - try { - const started = await child.started; - let responseSettled = false; - const stopResult = requestOwnerStop(started.endpoint) - .then((response) => { - responseSettled = true; - return response.status; - }) - .catch(() => { - responseSettled = true; - return undefined; - }); - await waitForFile(stopBegan); - expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); - // The static control application flushes the fenced 202 before the - // lifecycle transaction closes the listener. - expect(responseSettled).toBe(true); - await kill(child.child); - await stopResult; - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - } - }); - - test("does not restart when an explicit stop wins an attached takeover", async () => { - const roots = await workspace(); - const releaseFile = join(roots.root, "release-stop"); - const stopBegan = join(roots.root, "stop-began"); - const input = messageFor(roots); - const owner = spawnChild(input, { - testMode: "hold-stop", - environment: { - SUPABASE_STACK_TEST_STOP_RELEASE_FILE: releaseFile, - SUPABASE_STACK_TEST_STOP_BEGAN_FILE: stopBegan, - }, - }); - let contender: ChildHandle | undefined; - try { - const started = await owner.started; - const stop = requestOwnerStop(started.endpoint).catch(() => undefined); - await waitForFile(stopBegan); - expect(await fetchOwner(started.endpoint)).toMatchObject({ state: "stopping" }); - - contender = spawnChild(input); - await contender.attachedBeforeReady; - writeFileSync(releaseFile, "release"); - await expect(contender.started).rejects.toThrow("stopped before takeover"); - await stop; - expect(readStackDocument(roots)?.lifecycle).toBe("stopped"); - await waitForExit(contender.child); - } finally { - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("accepts stop while workspace discovery is still blocked", async () => { - const roots = await workspace(); - const ensureReady = join(roots.root, "ensure-ready"); - const ensureRelease = join(roots.root, "ensure-release"); - const child = spawnChild(messageFor(roots), { - environment: { - SUPABASE_STACK_TEST_ENSURE_READY_FILE: ensureReady, - SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE: ensureRelease, - }, - }); - void child.started.catch(() => undefined); - try { - await waitForFile(ensureReady); - expect(existsSync(ensureReady)).toBe(true); - const endpoint = await Effect.runPromise(controlEndpoint(roots.stackId)); - const response = await requestOwnerStop(endpoint); - expect(response.status).toBe(202); - writeFileSync(ensureRelease, "release"); - await expect(child.started).rejects.toThrow("Stack was stopped during startup"); - await waitForExit(child.child); - expect(readStackDocument(roots)).toBeUndefined(); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - } - }); - - test("rejects an attached contender whose copied workspace collides after owner validation", async () => { - const roots = await workspace(); - const copied = `${roots.root}-copy`; - const ensureReady = join(roots.root, "ensure-ready"); - const ensureRelease = join(roots.root, "ensure-release"); - const owner = spawnChild(messageFor(roots), { - environment: { - SUPABASE_STACK_TEST_ENSURE_READY_FILE: ensureReady, - SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE: ensureRelease, - }, - }); - let contender: ChildHandle | undefined; - try { - await waitForFile(ensureReady); - expect(existsSync(ensureReady)).toBe(true); - - cpSync(roots.root, copied, { recursive: true }); - contender = spawnChild(messageFor(roots, { workspacePath: copied })); - await contender.attachedBeforeReady; - - writeFileSync(ensureRelease, "release"); - const started = await owner.started; - expect(started.attached).not.toBe(true); - await expect(contender.started).rejects.toThrow( - /ordinary workspace identity.*\.supabase\/identity\.json/, - ); - await remoteStop(started.endpoint); - await waitForExit(owner.child); - } finally { - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - rmSync(copied, { recursive: true, force: true }); - cleanupRoots(roots); - } - }); - - test("rejects a copied contender after dead-owner takeover before Docker cleanup", async () => { - const roots = await workspace(); - const copied = `${roots.root}-copy`; - const dockerBin = join(roots.root, "fake-docker-bin"); - const dockerSentinel = join(roots.root, "docker-called"); - mkdirSync(dockerBin); - const docker = join(dockerBin, "docker"); - writeFileSync(docker, `#!/bin/sh\nprintf called >> ${dockerSentinel}\n`); - chmodSync(docker, 0o755); - const owner = spawnChild(messageFor(roots), { testMode: "hold-start" }); - void owner.started.catch(() => undefined); - let contender: ChildHandle | undefined; - let fakeOwner: ReturnType | undefined; - let releaseFakeOwner: (() => void) | undefined; - try { - const starting = await waitForStackDocument(roots, "starting"); - const endpoint = await Effect.runPromise(controlEndpoint(starting.id)); - const stop = await requestOwnerStop(endpoint); - expect(stop.status).toBe(202); - await waitForExit(owner.child); - expect((await waitForStackDocument(roots, "stopped")).lifecycle).toBe("stopped"); - - cpSync(roots.root, copied, { recursive: true }); - const originalGit = join(roots.root, ".git"); - git(roots.root, "init", "-q", "-b", "main"); - git(roots.root, "commit", "-q", "--allow-empty", "-m", "init"); - const stoppingOwner = await listenStoppingOwner(endpoint, starting.id); - fakeOwner = stoppingOwner.server; - releaseFakeOwner = stoppingOwner.release; - contender = spawnChild(messageFor(roots, { workspacePath: copied }), { - environment: { PATH: `${dockerBin}:${process.env.PATH ?? ""}` }, - }); - await contender.attachedBeforeReady; - - rmSync(originalGit, { recursive: true, force: true }); - const documentPath = Effect.runSync( - managedStackDocumentPathEffect(roots.stateRoot, starting.id), - ); - const document = JSON.parse(readFileSync(documentPath, "utf8")) as Record; - writeFileSync(documentPath, JSON.stringify({ ...document, lifecycle: "running" })); - releaseFakeOwner?.(); - releaseFakeOwner = undefined; - fakeOwner = undefined; - - await expect(contender.started).rejects.toThrow( - /ordinary workspace identity.*\.supabase\/identity\.json|checkout has no readable HEAD/, - ); - expect(existsSync(dockerSentinel)).toBe(false); - } finally { - fakeOwner?.close(); - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - rmSync(copied, { recursive: true, force: true }); - cleanupRoots(roots); - } - }); - - test("does not mark an existing stopped document failed when discovery fails after control bind", async () => { - const roots = await workspace(); - const initial = spawnChild(messageFor(roots)); - try { - const started = await initial.started; - await remoteStop(started.endpoint); - await waitForExit(initial.child); - expect((await waitForStackDocument(roots, "stopped")).lifecycle).toBe("stopped"); - - writeFileSync(join(roots.root, ".supabase", "identity.json"), "not-json\n"); - const failed = spawnChild(messageFor(roots)); - try { - await expect(failed.started).rejects.toThrow("ordinary workspace identity"); - await waitForExit(failed.child); - expect(readStackDocument(roots)?.lifecycle).toBe("stopped"); - } finally { - if (failed.child.exitCode === null) await kill(failed.child); - } - } finally { - if (initial.child.exitCode === null) await kill(initial.child); - cleanupRoots(roots); - } - }); - - test("stops a blocked starting owner through its control endpoint", async () => { - const roots = await workspace(); - const child = spawnChild(messageFor(roots), { testMode: "hold-start" }); - void child.started.catch(() => undefined); - try { - const document = await waitForStackDocument(roots, "starting"); - const endpoint = await Effect.runPromise(controlEndpoint(document.id)); - expect(await fetchOwner(endpoint)).toMatchObject({ state: "starting", ready: false }); - const response = await requestOwnerStop(endpoint); - expect(response.status).toBe(202); - await Promise.race([ - waitForExit(child.child), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`owner did not stop within ${FILE_WAIT_TIMEOUT_MS}ms`)), - FILE_WAIT_TIMEOUT_MS, - ), - ), - ]); - const stopped = await waitForStackDocument(roots, "stopped"); - expect(stopped.lifecycle).toBe("stopped"); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - } - }); - - test("reports the actionable startup message when signaled during startup", async () => { - const roots = await workspace(); - const releaseFile = join(roots.root, "release-start"); - const child = spawnChild(messageFor(roots), { - testMode: "hold-start", - environment: { SUPABASE_STACK_TEST_START_RELEASE_FILE: releaseFile }, - }); - void child.started.catch(() => undefined); - try { - await waitForStackDocument(roots, "starting"); - child.child.kill("SIGTERM"); - await expect(child.error).resolves.toMatchObject({ - type: "error", - message: "Stack was stopped during startup", - }); - await waitForExit(child.child); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - } - }); - - test("persists explicit-stop cleanup anomalies in the supervisor log", async () => { - const roots = await workspace(); - const child = spawnChild(messageFor(roots)); - try { - const started = await child.started; - const documentPath = Effect.runSync( - managedStackDocumentPathEffect(roots.stateRoot, roots.stackId), - ); - rmSync(documentPath); - mkdirSync(documentPath); - - const response = await requestOwnerStop(started.endpoint); - expect(response.status).toBe(202); - await waitForExit(child.child); - - const logPath = join(roots.stateRoot, "stacks", roots.stackId, "logs", "supervisor.log"); - await waitForFile(logPath); - expect(readFileSync(logPath, "utf8")).toContain("Supervisor cleanup failed"); - } finally { - if (child.child.exitCode === null) await kill(child.child); - cleanupRoots(roots); - } - }); - - test("does not restart a stopped owner after attached takeover", async () => { - const roots = await workspace(); - const input = messageFor(roots); - const owner = spawnChild(input, { testMode: "hold-start" }); - void owner.started.catch(() => undefined); - let contender: ChildHandle | undefined; - let fakeOwner: ReturnType | undefined; - const observedStates: Array<"starting" | "stopping"> = []; - try { - const document = await waitForStackDocument(roots, "starting"); - const endpoint = await Effect.runPromise(controlEndpoint(document.id)); - expect(await fetchOwner(endpoint)).toMatchObject({ state: "starting", ready: false }); - const stopResponse = await requestOwnerStop(endpoint); - expect(stopResponse.status).toBe(202); - await waitForExit(owner.child); - expect((await waitForStackDocument(roots, "stopped")).lifecycle).toBe("stopped"); - await stopViaManagedFacade(roots); - - fakeOwner = await listenOwnerSequence( - endpoint, - document.id, - ["starting", "starting", "stopping"], - (state) => observedStates.push(state), - ); - contender = spawnChild(input); - await contender.attachedBeforeReady; - - await expect(contender.started).rejects.toMatchObject({ - message: expect.stringContaining("stopped before takeover"), - }); - expect(observedStates).toEqual(["starting", "starting", "stopping"]); - expect(readStackDocument(roots)?.lifecycle).toBe("stopped"); - } finally { - fakeOwner?.close(); - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("attaches a second child as RemoteStack while the owner remains live", async () => { - const roots = await workspace(); - const input = messageFor(roots); - const owner = spawnChild(input); - const contender = spawnChild(input); - try { - const [first, second] = await Promise.all([owner.started, contender.started]); - const responses = [first, second]; - expect(responses.filter((response) => response.attached === true)).toHaveLength(1); - expect(responses.filter((response) => response.attached !== true)).toHaveLength(1); - const started = responses.find((response) => response.attached !== true); - const attached = responses.find((response) => response.attached === true); - if (started === undefined || attached === undefined) - throw new Error("missing child response"); - const status = await fetchOwner(started.endpoint); - expect(status).toMatchObject({ state: "running" }); - await remoteStop(attached.endpoint); - await Promise.all([waitForExit(owner.child), waitForExit(contender.child)]); - } finally { - if (owner.child.exitCode === null) await kill(owner.child); - if (contender.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("reacquires and restarts after an attached owner dies during readiness", async () => { - const roots = await workspace(); - const input = messageFor(roots); - const owner = spawnChild(input, { testMode: "hold-start" }); - void owner.started.catch(() => undefined); - let contender: ChildHandle | undefined; - try { - const document = await waitForStackDocument(roots, "starting"); - const restartedEndpoint = await Effect.runPromise(controlEndpoint(document.id)); - expect(await fetchOwner(restartedEndpoint)).toMatchObject({ - state: "starting", - ready: false, - }); - - contender = spawnChild(input, { testMode: "hold-start" }); - void contender.started.catch(() => undefined); - await contender.attachedBeforeReady; - - await kill(owner.child); - await expect(owner.started).rejects.toThrow(); - await contender.managedStarted; - expect(await fetchOwner(restartedEndpoint)).toMatchObject({ state: "starting" }); - } finally { - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("reacquires after an attached endpoint is rebound by an unrelated listener", async () => { - const roots = await workspace(); - const input = messageFor(roots); - const endpoint = await Effect.runPromise(controlEndpoint(roots.stackId)); - const attachedReady = join(roots.root, "attached-rebind-ready"); - const attachedRelease = join(roots.root, "attached-rebind-release"); - let attachedOwner: ReturnType | undefined; - let unrelatedListener: ReturnType | undefined; - let contender: ChildHandle | undefined; - try { - attachedOwner = await listenStartingOwner(endpoint, roots.stackId); - contender = spawnChild(input, { - environment: { - SUPABASE_STACK_TEST_ATTACHED_READY_FILE: attachedReady, - SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE: attachedRelease, - }, - }); - await contender.attachedBeforeReady; - - await new Promise((resolve, reject) => - attachedOwner?.close((cause) => (cause === undefined ? resolve() : reject(cause))), - ); - attachedOwner = undefined; - unrelatedListener = await listenMalformedOwner(endpoint); - writeFileSync(attachedRelease, "release"); - - const restarted = await contender.started; - expect(restarted.attached).not.toBe(true); - expect(restarted.endpoint.port).not.toBe(endpoint.port); - await remoteStop(restarted.endpoint); - await waitForExit(contender.child); - } finally { - attachedOwner?.close(); - unrelatedListener?.close(); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("re-reads persisted Docker state after taking over an owner that published during attach", async () => { - const roots = await workspace(); - const ensureReady = join(roots.root, "ensure-ready"); - const ensureRelease = join(roots.root, "ensure-release"); - const dockerBin = join(roots.root, "fake-docker-bin"); - const dockerCleanup = join(roots.root, "docker-cleanup"); - mkdirSync(dockerBin); - const docker = join(dockerBin, "docker"); - writeFileSync( - docker, - `#!/bin/sh\nif [ "$1" = "rm" ]; then printf cleaned > "${dockerCleanup}"; fi\nexit 0\n`, - ); - chmodSync(docker, 0o755); - const nativeInput = messageFor(roots); - const input = messageFor(roots, { - config: { ...nativeInput.config, mode: "docker" }, - }); - const environment = { PATH: `${dockerBin}:${process.env.PATH ?? ""}` }; - const owner = spawnChild(input, { - testMode: "hold-start", - environment: { - ...environment, - SUPABASE_STACK_TEST_ENSURE_READY_FILE: ensureReady, - SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE: ensureRelease, - }, - }); - void owner.started.catch(() => undefined); - let contender: ChildHandle | undefined; - try { - await waitForFile(ensureReady); - contender = spawnChild(input, { testMode: "hold-start", environment }); - void contender.started.catch(() => undefined); - await contender.attachedBeforeReady; - - writeFileSync(ensureRelease, "release"); - await owner.managedStarted; - await kill(owner.child); - await contender.managedStarted; - - expect(readFileSync(dockerCleanup, "utf8")).toBe("cleaned"); - } finally { - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("restarts after attaching to an owner that is already stopping", async () => { - const roots = await workspace(); - const input = messageFor(roots); - const initial = spawnChild(input); - let contender: ChildHandle | undefined; - let fakeOwner: ReturnType | undefined; - const observedStates: Array<"starting" | "stopping"> = []; - try { - const started = await initial.started; - const stopResponse = await requestOwnerStop(started.endpoint, "replacement"); - expect(stopResponse.status).toBe(202); - await stopResponse.arrayBuffer(); - await waitForExit(initial.child); - const document = await waitForStackDocument(roots, "stopped"); - const endpoint = await Effect.runPromise(controlEndpoint(document.id)); - fakeOwner = await listenOwnerSequence( - endpoint, - document.id, - ["stopping", "stopping"], - (state) => observedStates.push(state), - ); - contender = spawnChild(input); - void contender.started.catch(() => undefined); - - const restarted = await contender.started; - expect(observedStates).toEqual(["stopping", "stopping"]); - expect(restarted.attached).not.toBe(true); - expect(await fetchOwner(restarted.endpoint)).toMatchObject({ state: "running", ready: true }); - await remoteStop(restarted.endpoint); - await waitForExit(contender.child); - } finally { - fakeOwner?.close(); - if (initial.child.exitCode === null) await kill(initial.child); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("bounds attached-owner recovery to one startup deadline", { timeout: 45_000 }, async () => { - const roots = await workspace(); - const input = messageFor(roots); - const attachedReady = join(roots.root, "attached-before-ready-ready"); - const attachedRelease = join(roots.root, "attached-before-ready-release"); - const owner = spawnChild(input, { - testMode: "hold-start", - environment: { SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "5000" }, - }); - void owner.started.catch(() => undefined); - let contender: ChildHandle | undefined; - let fakeOwner: ReturnType | undefined; - try { - const document = await waitForStackDocument(roots, "starting"); - const endpoint = await Effect.runPromise(controlEndpoint(document.id)); - contender = spawnChild(input, { - testMode: "hold-start", - environment: { - SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS: "5000", - SUPABASE_STACK_TEST_ATTACHED_READY_FILE: attachedReady, - SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE: attachedRelease, - }, - }); - void contender.started.catch(() => undefined); - await contender.attachedBeforeReady; - await waitForFile(attachedReady); - - await kill(owner.child); - fakeOwner = await listenStartingOwner(endpoint, document.id); - writeFileSync(attachedRelease, "release"); - await expect(contender.started).rejects.toMatchObject({ - message: expect.stringContaining("Timed out resolving attached supervisor owner"), - }); - } finally { - fakeOwner?.close(); - if (owner.child.exitCode === null) await kill(owner.child); - if (contender?.child.exitCode === null) await kill(contender.child); - cleanupRoots(roots); - } - }); - - test("reallocates after a child is killed during startup", async () => { - const roots = await workspace(); - const input = messageFor(roots); - const killed = spawnChild(input, { testMode: "hold-start" }); - void killed.started.catch(() => undefined); - try { - const document = await waitForStackDocument(roots, "starting"); - const endpoint = await Effect.runPromise(controlEndpoint(document.id)); - expect(await fetchOwner(endpoint).catch(() => undefined)).toMatchObject({ - state: "starting", - }); - for (const assignment of document.ports) expect(await canBind(assignment.port)).toBe(false); - await kill(killed.child); - await expect(killed.started).rejects.toThrow(); - const recovered = spawnChild(messageFor(roots)); - try { - const started = await recovered.started; - expect((await fetchOwner(started.endpoint)).state).toBe("running"); - expect(document.lifecycle).toBe("starting"); - await remoteStop(started.endpoint); - await waitForExit(recovered.child); - } finally { - if (recovered.child.exitCode === null) await kill(recovered.child); - } - } finally { - if (killed.child.exitCode === null) await kill(killed.child); - cleanupRoots(roots); - } - }); - - test("reattaches from a later process and stops the original child", async () => { - const roots = await workspace(); - const input = messageFor(roots); - const owner = spawnChild(input); - let later: ChildHandle | undefined; - try { - await owner.started; - later = spawnChild(input); - const attached = await later.started; - expect(attached.attached).toBe(true); - expect(await remoteInfo(attached.endpoint)).toMatchObject({ url: expect.any(String) }); - await updateLaunch(attached.endpoint, roots.stackId, attached.owner, input.cliVersion, { - versions: { postgres: "17.6.1" }, - }); - expect(readStackDocument(roots)?.launch).toEqual({ - mode: "native", - versions: { postgres: "17.6.1" }, - }); - await remoteStop(attached.endpoint); - await waitForExit(owner.child); - await waitForExit(later.child); - const restarted = spawnChild(input); - const restartedAck = await restarted.started; - expect(restartedAck.attached).not.toBe(true); - await remoteStop(restartedAck.endpoint); - await waitForExit(restarted.child); - } finally { - if (owner.child.exitCode === null) await kill(owner.child); - if (later !== undefined && later.child.exitCode === null) await kill(later.child); - cleanupRoots(roots); - } - }); -}); diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts deleted file mode 100644 index b76635d1f3..0000000000 --- a/packages/stack/src/supervisor.ts +++ /dev/null @@ -1,1069 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- Supervisor startup owns native child-process and path boundaries for the daemon entrypoint. -import { fork, type ChildProcess } from "node:child_process"; -import { join } from "node:path"; -import { - Cause, - Context, - Data, - Duration, - Effect, - FileSystem, - Fiber, - Layer, - Logger, - Predicate, - Queue, - Schedule, - Scope, - Schema, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { - selectStackRuntime, - validateStackRuntime, - type StackRuntimeSelection, -} from "./ContainerRuntime.ts"; -import type { PlatformFactory } from "./createStack.ts"; -import { Stack } from "./Stack.ts"; -import { LocalStackLifecycle } from "./LocalStack.ts"; -import { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -import type { StackLaunchUpdater } from "./StackRpcHandlers.ts"; -import type { StackLaunchUpdateRpc } from "./StackRpc.ts"; -import { SupervisorSession } from "./SupervisorSession.ts"; -import { - SupervisorErrorEventSchema, - SupervisorStartCommandSchema, - SupervisorStartedEventSchema, -} from "./SupervisorProtocol.ts"; -import { foregroundLayer } from "./layers.ts"; -import { - acquireControl, - ControlTransportError, - ControlTransport, - type ControlAcquisition, - type ControlAttached, - type ControlOwnership, - type ControlApplication, - type ControlAddressConflictError, - type ControlBindError, - type ControlProtocolError, - type ControlProtocolMismatchError, - type InvalidControlOwnershipIdError, -} from "./managed/control.ts"; -import { - ManagedStackManager, - type ManagedStackManagerConstructionError, - type ManagedStackManagerError, - type ManagedStackStartResult, -} from "./managed/manager.ts"; -import { - managedStackLaunchUpdateSchema, - type ManagedStackLaunch, - type ManagedStackLaunchInput, -} from "./managed/document.ts"; -import { deriveStackId, ensureEnvironment } from "./managed/environment.ts"; -import { gitConfigStoreLayer } from "./managed/git.ts"; -import { - validateManagedStackName, - type InvalidManagedStackNameError, - type ManagedPortIntentDocument, -} from "./managed/model.ts"; -import { managedStackPathsEffect } from "./managed/paths.ts"; -import { PORT_CATALOG, PORT_FIELDS } from "./PortCatalog.ts"; -import { portFieldsForConfigInput } from "./ServicePorts.ts"; -import { SERVICE_NAMES } from "./ServiceCatalog.ts"; -import { dockerContainerName } from "./StackIdentity.ts"; -import type { PortLease } from "./PortAllocator.ts"; -import { - portRequestsForConfig, - resolveConfig, - type DaemonConfigInput, -} from "./StackConfigResolver.ts"; -import type { ResolvedDaemonConfig } from "./StackConfig.ts"; -import { HttpTransportClient } from "./HttpTransportClient.ts"; -import { RemoteStack } from "./RemoteStack.ts"; -import { terminateChildProcess } from "./terminateChild.ts"; -import { dockerForceRemove } from "./cleanup.ts"; -import { - CONTROL_PROTOCOL_VERSION, - isControlSupervisorStatus, - type ControlSupervisorStatus, -} from "./DaemonProtocol.ts"; -import { - DaemonUpgradeRequired, - StackBuildError, - StackUnavailableError, - StackRpcProtocolError, - StackRpcTransportError, - SupervisorStartError, -} from "./errors.ts"; -import { runtimeSelectionForLaunch, applyNativeDefaults } from "./SupervisorUpgradeRestart.ts"; -import type { - SupervisorErrorMessage, - SupervisorStartMessage, - SupervisorStartedMessage, -} from "./SupervisorProtocol.ts"; -export type { SupervisorStartMessage, SupervisorStartedMessage }; -export { SupervisorStartError } from "./errors.ts"; -type SupervisorMessage = SupervisorStartedMessage | SupervisorErrorMessage; -/** Input shape for the public managed launcher. */ -export interface ManagedDaemonStartInput { - readonly cliVersion: string; - readonly workspacePath: string; - readonly stackName: string; - readonly stateRoot: string; - readonly config: Readonly>; - readonly portIntents: ManagedPortIntentDocument; - readonly launch?: ManagedStackLaunchInput; -} - -const supervisorStartMessageSchema = SupervisorStartCommandSchema; - -const isControlOwnership = (value: ControlAcquisition): value is ControlOwnership => - Predicate.isTagged(value, "Owned"); - -const isControlAttached = (value: ControlAcquisition): value is ControlAttached => - Predicate.isTagged(value, "Attached"); - -const startedOwnerDescriptor = ( - status: ControlSupervisorStatus, -): SupervisorStartedMessage["owner"] => ({ - kind: "supervisor", - ownershipId: status.ownershipId, - ownerSessionId: status.ownerSessionId, - controlProtocolVersion: status.controlProtocolVersion, - daemonCliVersion: status.daemonCliVersion, - state: status.state, - ready: status.ready, -}); - -const decodeSupervisorStartMessage = ( - value: unknown, -): Effect.Effect => - Schema.decodeUnknownEffect(supervisorStartMessageSchema)(value).pipe( - Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), - ); - -const causeMessage = (cause: unknown): string => { - if (cause instanceof ControlTransportError) { - return `ControlTransportError(${cause.reason}): ${cause.cause instanceof Error ? cause.cause.message : String(cause.cause)}`; - } - if (cause instanceof Error && cause.message.length > 0) return cause.message; - if ( - typeof cause === "object" && - cause !== null && - "detail" in cause && - typeof cause.detail === "string" - ) { - return cause.detail; - } - return typeof cause === "string" ? cause : String(cause); -}; - -const toDaemonConfig = (value: Readonly>): DaemonConfigInput | undefined => - typeof value.cwd === "string" ? { ...value, cwd: value.cwd } : undefined; - -class SupervisorOwnerUnavailableError extends Data.TaggedError("SupervisorOwnerUnavailableError")<{ - readonly retry: boolean; - readonly detail: string; -}> {} - -class SupervisorOwnerReacquirePending extends Data.TaggedError( - "SupervisorOwnerReacquirePending", -)<{}> {} - -const OWNER_STOPPED_AFTER_TAKEOVER = "Attached supervisor owner stopped before takeover"; -const STACK_STOPPED_DURING_STARTUP = "Stack was stopped during startup"; - -type SupervisorExecutionError = - | SupervisorStartError - | SupervisorOwnerReacquirePending - | DaemonUpgradeRequired - | StackBuildError - | StackUnavailableError - | InvalidManagedStackNameError - | ManagedStackManagerError; - -const SUPERVISOR_STARTUP_TIMEOUT = Duration.seconds(30); -const SUPERVISOR_HANDSHAKE_GRACE = Duration.seconds(5); -const SUPERVISOR_HANDSHAKE_TIMEOUT = Duration.sum( - SUPERVISOR_STARTUP_TIMEOUT, - SUPERVISOR_HANDSHAKE_GRACE, -); - -const awaitOwnerReady = ( - acquisition: ControlAttached, -): Effect.Effect< - import("./managed/control.ts").ControlOwnerStatus, - | SupervisorStartError - | import("./managed/control.ts").ControlTransportError - | import("./managed/control.ts").ControlProtocolError - | import("./managed/control.ts").ControlProtocolMismatchError - | import("./managed/control.ts").ControlAddressConflictError -> => - acquisition.ownerStatus.pipe( - Effect.flatMap((status) => { - if (!isControlSupervisorStatus(status)) { - return Effect.fail( - new SupervisorOwnerUnavailableError({ - retry: false, - detail: `Managed stack is busy with ${status.operation} maintenance`, - }), - ); - } - if (status.state === "running" && status.ready) return Effect.succeed(status); - return Effect.fail( - new SupervisorOwnerUnavailableError({ - retry: status.state === "starting" || status.state === "stopping", - detail: `Attached supervisor owner is ${status.state} before becoming ready`, - }), - ); - }), - Effect.retry({ - schedule: Schedule.spaced("25 millis"), - while: (error) => - (Predicate.isTagged(error, "SupervisorOwnerUnavailableError") && error.retry) || - (Predicate.isTagged(error, "ControlTransportError") && error.reason === "transport"), - }), - Effect.catchTag("SupervisorOwnerUnavailableError", (error) => - Effect.fail(new SupervisorStartError({ message: error.detail })), - ), - ); - -export interface SupervisorPlatform { - readonly platformFactory: PlatformFactory; - /** Optional platform-owned runtime layer, primarily for non-Docker environments. */ - readonly runtimeLayer?: (input: { - readonly config: ResolvedDaemonConfig; - readonly lease: PortLease; - }) => Effect.Effect, SupervisorStartError, Scope.Scope>; - readonly resolutionTimeout?: Duration.Input; - readonly managerLayer: ( - stateRoot: string, - ) => Layer.Layer< - ManagedStackManager, - ManagedStackManagerConstructionError, - ControlTransport | import("effect").FileSystem.FileSystem | import("effect").Path.Path - >; -} - -const receiveStartMessage = (): Effect.Effect => - Effect.callback((resume) => { - const onMessage = (value: unknown) => { - cleanup(); - resume(decodeSupervisorStartMessage(value)); - }; - const onDisconnect = () => { - cleanup(); - resume( - new SupervisorStartError({ message: "Supervisor parent disconnected before startup" }), - ); - }; - const cleanup = () => { - process.off("message", onMessage); - process.off("disconnect", onDisconnect); - }; - process.once("message", onMessage); - process.once("disconnect", onDisconnect); - return Effect.sync(cleanup); - }); - -const sendMessage = (message: SupervisorMessage): Effect.Effect => - Effect.gen(function* () { - const schema = - message.type === "error" ? SupervisorErrorEventSchema : SupervisorStartedEventSchema; - const encoded = yield* Schema.encodeEffect(schema)(message).pipe( - Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), - ); - yield* Effect.callback((resume) => { - if (process.send === undefined || !process.connected) { - resume(Effect.void); - return Effect.void; - } - try { - process.send(encoded, (error) => - resume( - error === null - ? Effect.void - : Effect.fail(new SupervisorStartError({ message: error.message })), - ), - ); - } catch (cause) { - resume(Effect.fail(new SupervisorStartError({ message: causeMessage(cause) }))); - } - return Effect.void; - }); - }); - -const decodeSupervisorEvent = ( - value: unknown, -): Effect.Effect => - decodeSupervisorStartedOrError(value); - -const decodeSupervisorStartedOrError = ( - value: unknown, -): Effect.Effect => - Schema.decodeUnknownEffect(SupervisorStartedEventSchema)(value).pipe( - Effect.map((event): SupervisorStartedMessage => ({ - type: "started", - endpoint: event.endpoint, - owner: event.owner, - ...(event.attached === undefined ? {} : { attached: event.attached }), - })), - Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), - Effect.catch(() => - Schema.decodeUnknownEffect(SupervisorErrorEventSchema)(value).pipe( - Effect.flatMap( - (event): Effect.Effect => { - if ( - event.errorCode === "DAEMON_UPGRADE_REQUIRED" && - event.stackId !== undefined && - event.oldCliVersion !== undefined && - event.newCliVersion !== undefined && - event.state !== undefined && - event.ready !== undefined - ) { - return Effect.fail( - new DaemonUpgradeRequired({ - stackId: event.stackId, - oldCliVersion: event.oldCliVersion, - newCliVersion: event.newCliVersion, - state: event.state, - ready: event.ready, - }), - ); - } - return Effect.fail(new SupervisorStartError({ message: event.message })); - }, - ), - Effect.mapError((cause) => - cause instanceof DaemonUpgradeRequired || cause instanceof SupervisorStartError - ? cause - : new SupervisorStartError({ message: causeMessage(cause) }), - ), - ), - ), - ); - -const waitForSignal = (): Effect.Effect<"SIGINT" | "SIGTERM"> => - Effect.callback((resume) => { - const cleanup = () => { - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - }; - const onSigint = () => { - cleanup(); - resume(Effect.succeed("SIGINT")); - }; - const onSigterm = () => { - cleanup(); - resume(Effect.succeed("SIGTERM")); - }; - process.once("SIGINT", onSigint); - process.once("SIGTERM", onSigterm); - return Effect.sync(cleanup); - }); - -const supervisorErrorMessage = (cause: Cause.Cause): SupervisorErrorMessage => { - const error = Cause.squash(cause); - if (error instanceof DaemonUpgradeRequired) { - return { - type: "error", - message: `Daemon CLI version mismatch for ${error.stackId}: expected ${error.newCliVersion}, observed ${error.oldCliVersion}`, - errorCode: "DAEMON_UPGRADE_REQUIRED", - stackId: error.stackId, - oldCliVersion: error.oldCliVersion, - newCliVersion: error.newCliVersion, - state: error.state, - ready: error.ready, - }; - } - return { type: "error", message: causeMessage(error) }; -}; - -const startDaemon = (input: { - readonly config: ResolvedDaemonConfig; - readonly lease: PortLease; - readonly platform: SupervisorPlatform; - readonly scope: Scope.Scope; -}): Effect.Effect< - { - readonly stack: Stack["Service"]; - readonly localLifecycle: LocalStackLifecycle["Service"]; - }, - SupervisorStartError, - import("effect").FileSystem.FileSystem | import("effect").Path.Path | Scope.Scope -> => - Effect.gen(function* () { - let appLayer: Layer.Layer; - if (input.platform.runtimeLayer === undefined) { - appLayer = foregroundLayer(input.config, input.platform.platformFactory, input.lease); - } else { - const runtimeLayer = input.platform - .runtimeLayer({ - config: input.config, - lease: input.lease, - }) - .pipe( - Scope.provide(input.scope), - Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), - ); - appLayer = yield* runtimeLayer; - } - const appServices = yield* Layer.buildWithScope(appLayer, input.scope); - const localStack = Context.get(appServices, Stack); - const localLifecycle = Context.get(appServices, LocalStackLifecycle); - return { stack: localStack, localLifecycle }; - }); - -const makeRunManagedExecution = ( - input: SupervisorStartMessage, - platform: SupervisorPlatform, -): Effect.Effect< - void, - SupervisorExecutionError, - | ControlTransport - | import("effect").FileSystem.FileSystem - | import("effect").Path.Path - | ChildProcessSpawner.ChildProcessSpawner - | Scope.Scope -> => - Effect.gen(function* () { - const controlTransport = yield* ControlTransport; - yield* validateManagedStackName(input.stackName); - const configInput = toDaemonConfig(input.config); - if (configInput === undefined) { - return yield* new SupervisorStartError({ message: "Supervisor config is missing cwd" }); - } - const manager = yield* ManagedStackManager.pipe( - Effect.provide(platform.managerLayer(input.stateRoot)), - ); - const sessionController = yield* SupervisorSession.make({ - ownershipId: input.stackId, - // Owner session IDs are protocol fencing tokens generated at the native - // supervisor boundary, once for each managed execution. - // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect - ownerSessionId: crypto.randomUUID(), - daemonCliVersion: input.cliVersion, - }); - const session = sessionController.service; - let owner: ControlOwnership | undefined; - const launchUpdater: StackLaunchUpdater = { - update: (stackId: string, launch: StackLaunchUpdateRpc) => { - const currentOwner = owner; - if (currentOwner === undefined) { - return Effect.fail( - new StackBuildError({ detail: "Managed launch updates require an owned supervisor" }), - ); - } - return Schema.decodeEffect(managedStackLaunchUpdateSchema)(launch).pipe( - Effect.mapError((cause) => new StackBuildError({ detail: causeMessage(cause) })), - Effect.flatMap((decoded) => - manager.updateLaunch(currentOwner, { stackId, launch: decoded }), - ), - Effect.mapError((cause) => new StackBuildError({ detail: causeMessage(cause) })), - Effect.asVoid, - ); - }, - }; - const controlApplication: ControlApplication = { - app: yield* makeSupervisorControlApplication(session, launchUpdater), - }; - let initialAcquisition = yield* acquireControl({ - stackId: input.stackId, - initialStatus: yield* session.currentStatus, - application: controlApplication, - }); - const requestedMode = configInput.mode ?? input.launch?.mode; - const initiallyAttached = isControlAttached(initialAcquisition); - const awaitAttachedOwnerReady = (acquisition: ControlAttached) => - awaitOwnerReady(acquisition).pipe( - Effect.timeout(platform.resolutionTimeout ?? SUPERVISOR_STARTUP_TIMEOUT), - Effect.catchTag("TimeoutError", () => - Effect.fail( - new SupervisorStartError({ - message: "Timed out resolving attached supervisor owner", - }), - ), - ), - ); - const reacquireAfterDeath = ( - stackId: string, - ): Effect.Effect< - ControlAcquisition, - | SupervisorOwnerReacquirePending - | DaemonUpgradeRequired - | ControlAddressConflictError - | ControlBindError - | ControlTransportError - | ControlProtocolError - | ControlProtocolMismatchError - | InvalidControlOwnershipIdError - | SupervisorStartError, - Scope.Scope - > => - Effect.gen(function* () { - const status = yield* session.currentStatus; - const candidate = yield* acquireControl({ - stackId, - initialStatus: status, - application: controlApplication, - }).pipe(Effect.provideService(ControlTransport, controlTransport)); - if (isControlOwnership(candidate)) return candidate; - if (!isControlSupervisorStatus(candidate.observedStatus)) { - return yield* new SupervisorStartError({ - message: `Managed stack is busy with ${candidate.observedStatus.operation} maintenance`, - }); - } - if (candidate.observedStatus.daemonCliVersion !== input.cliVersion) { - return yield* new DaemonUpgradeRequired({ - stackId, - oldCliVersion: candidate.observedStatus.daemonCliVersion, - newCliVersion: input.cliVersion, - state: candidate.observedStatus.state, - ready: candidate.observedStatus.ready, - }); - } - yield* awaitAttachedOwnerReady(candidate).pipe( - Effect.mapError((error) => - Predicate.isTagged(error, "SupervisorStartError") || - (Predicate.isTagged(error, "ControlTransportError") && error.reason === "unreachable") - ? new SupervisorOwnerReacquirePending() - : error, - ), - ); - return candidate; - }).pipe( - Effect.retry({ - schedule: Schedule.spaced("25 millis"), - while: (error) => error instanceof SupervisorOwnerReacquirePending, - }), - ); - if (isControlAttached(initialAcquisition)) { - const attachedStatus = initialAcquisition.observedStatus; - if (!isControlSupervisorStatus(attachedStatus)) { - return yield* new SupervisorStartError({ - message: `Managed stack is busy with ${attachedStatus.operation} maintenance`, - }); - } - const existing = yield* manager.inspectStack(input.stackId); - const persistedRuntime: StackRuntimeSelection | undefined = - existing === undefined ? undefined : runtimeSelectionForLaunch(existing.launch); - if ( - persistedRuntime !== undefined && - requestedMode !== undefined && - persistedRuntime.mode !== requestedMode - ) { - return yield* new SupervisorStartError({ - message: `Stack runtime is already ${persistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, - }); - } - if (attachedStatus.daemonCliVersion !== input.cliVersion) { - return yield* new DaemonUpgradeRequired({ - stackId: input.stackId, - oldCliVersion: attachedStatus.daemonCliVersion, - newCliVersion: input.cliVersion, - state: attachedStatus.state, - ready: attachedStatus.ready, - }); - } else { - const reacquireInitialAcquisition = () => - reacquireAfterDeath(input.stackId).pipe( - Effect.tap((next) => - Effect.sync(() => { - initialAcquisition = next; - }), - ), - Effect.asVoid, - ); - yield* awaitAttachedOwnerReady(initialAcquisition).pipe( - Effect.catchTags({ - ControlTransportError: (error) => - error.reason === "unreachable" ? reacquireInitialAcquisition() : Effect.fail(error), - ControlAddressConflictError: reacquireInitialAcquisition, - ControlProtocolError: reacquireInitialAcquisition, - ControlProtocolMismatchError: reacquireInitialAcquisition, - }), - ); - } - } - const acquisition = initialAcquisition; - if (isControlAttached(acquisition)) { - const revalidated = yield* manager.ensureWorkspace(input.workspacePath); - if (deriveStackId(revalidated.identity, input.stackName) !== input.stackId) { - return yield* new SupervisorStartError({ - message: "Workspace identity changed before supervisor attach", - }); - } - // The first inspection can legitimately race the owner's initial - // document write. Once the owner reports ready, its persisted launch is - // the authoritative runtime contract for an explicit request. - const attachedExisting = yield* manager.inspectStack(input.stackId); - const attachedPersistedRuntime = - attachedExisting === undefined - ? undefined - : runtimeSelectionForLaunch(attachedExisting.launch); - if ( - requestedMode !== undefined && - (attachedPersistedRuntime === undefined || attachedPersistedRuntime.mode !== requestedMode) - ) { - const observedMode = attachedPersistedRuntime?.mode ?? "unknown"; - return yield* new SupervisorStartError({ - message: `Stack runtime is already ${observedMode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, - }); - } - const attachedStatus = yield* acquisition.ownerStatus; - if (!isControlSupervisorStatus(attachedStatus)) { - return yield* new SupervisorStartError({ - message: `Managed stack is busy with ${attachedStatus.operation} maintenance`, - }); - } - yield* sendMessage({ - type: "started", - endpoint: acquisition.endpoint, - owner: startedOwnerDescriptor(attachedStatus), - attached: true, - }); - process.disconnect?.(); - return; - } - const ownership = acquisition; - owner = ownership; - const managedPaths = yield* managedStackPathsEffect(input.stateRoot, input.stackId); - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(managedPaths.logs, { recursive: true, mode: 0o700 }); - const supervisorLogger = yield* Logger.toFile( - Logger.formatLogFmt, - join(managedPaths.logs, "supervisor.log"), - { flag: "a", mode: 0o600 }, - ); - let claimedStack = false; - const startup = (runtimeScope: Scope.Scope) => - Effect.gen(function* () { - const discovery = yield* manager.ensureWorkspace(input.workspacePath); - const stackId = deriveStackId(discovery.identity, input.stackName); - if (stackId !== input.stackId) { - return yield* new SupervisorStartError({ - message: "Workspace identity changed before supervisor start", - }); - } - const ownedExisting = yield* manager.inspectStack(stackId); - if ( - (initiallyAttached || input.replacement === true) && - ownedExisting?.lifecycle === "stopped" && - ownedExisting.stopIntent === "explicit" - ) { - return yield* new SupervisorStartError({ - message: OWNER_STOPPED_AFTER_TAKEOVER, - }); - } - const ownedPersistedRuntime = - ownedExisting === undefined ? undefined : runtimeSelectionForLaunch(ownedExisting.launch); - if ( - ownedPersistedRuntime !== undefined && - requestedMode !== undefined && - ownedPersistedRuntime.mode !== requestedMode - ) { - return yield* new SupervisorStartError({ - message: `Stack runtime is already ${ownedPersistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, - }); - } - const runtime = - ownedPersistedRuntime === undefined - ? yield* selectStackRuntime(requestedMode) - : yield* validateStackRuntime(ownedPersistedRuntime); - const runtimeConfigInput = - runtime.mode === "native" && requestedMode === undefined - ? applyNativeDefaults(configInput) - : configInput; - const activeFields = portFieldsForConfigInput({ - ...runtimeConfigInput, - mode: runtime.mode, - }); - const activeFieldSet = new Set(activeFields); - const portIntents: ManagedPortIntentDocument = { - ...input.portIntents, - activeFields, - disabledFields: PORT_FIELDS.filter( - (field) => PORT_CATALOG[field].persistence === "sticky" && !activeFieldSet.has(field), - ), - }; - yield* portRequestsForConfig(runtimeConfigInput, { runtime }); - const launchInput = input.launch ?? { versions: {} }; - const launch: ManagedStackLaunch = - runtime.mode === "native" - ? { ...launchInput, mode: "native" } - : { ...launchInput, mode: "docker", containerRuntime: runtime.containerRuntime }; - if ( - ownedExisting !== undefined && - (ownedExisting.lifecycle === "starting" || - ownedExisting.lifecycle === "running" || - ownedExisting.lifecycle === "failed" || - ownedExisting.lifecycle === "deleting") - ) { - if (runtime.mode === "docker") { - yield* dockerForceRemove( - runtime.containerRuntime, - SERVICE_NAMES.map((service) => dockerContainerName(service, `id-${stackId}`)), - ); - } - } - const started: ManagedStackStartResult = yield* manager.startStack({ - workspacePath: input.workspacePath, - stackName: input.stackName, - portDocument: portIntents, - ownership, - lifecycle: "starting", - launch, - preservePersistedPorts: input.replacement === true, - }); - claimedStack = true; - const managedPaths = yield* managedStackPathsEffect(input.stateRoot, started.stack.id); - const resolved = yield* resolveConfig( - { - ...runtimeConfigInput, - projectDir: runtimeConfigInput.projectDir ?? input.workspacePath, - stackRoot: managedPaths.root, - runtimeRoot: managedPaths.runtime, - instanceId: started.stack.id, - }, - { runtime, ports: started.lease.ports }, - ); - const config: ResolvedDaemonConfig = { - ...resolved, - name: input.stackName, - projectDir: runtimeConfigInput.projectDir ?? input.workspacePath, - }; - yield* manager.recordLifecycle(ownership, { - stackId: started.stack.id, - lifecycle: "starting", - }); - const built = yield* startDaemon({ - config, - lease: started.lease, - platform, - scope: runtimeScope, - }); - return { started, built }; - }); - const result = yield* sessionController - .run({ - startup, - stack: (runtime) => runtime.built.stack, - awaitDisposed: (runtime) => runtime.built.localLifecycle.awaitDisposed, - onRunning: (runtime) => - manager - .recordLifecycle(ownership, { - stackId: runtime.started.stack.id, - lifecycle: "running", - runtime: { - pid: process.pid, - controlEndpoint: ownership.endpoint.url, - protocolVersion: CONTROL_PROTOCOL_VERSION, - }, - }) - .pipe( - Effect.andThen(session.currentStatus), - Effect.flatMap((status) => - sendMessage({ - type: "started", - endpoint: ownership.endpoint, - owner: startedOwnerDescriptor({ ...status, state: "running", ready: true }), - attached: false, - }), - ), - Effect.tap(() => Effect.sync(() => process.disconnect?.())), - ), - onStopped: (intent) => - Effect.gen(function* () { - const current = yield* manager.inspectStack(input.stackId); - if (current?.lifecycle !== "starting" && current?.lifecycle !== "running") return; - yield* manager - .recordLifecycle(ownership, { - stackId: input.stackId, - lifecycle: "stopped", - ...(intent === "explicit" ? { stopIntent: "explicit" as const } : {}), - }) - .pipe(Effect.asVoid); - }), - onFailure: () => - Effect.gen(function* () { - const current = yield* manager.inspectStack(input.stackId); - if ( - current === undefined || - current.lifecycle === "deleting" || - (!claimedStack && - current.lifecycle !== "starting" && - current.lifecycle !== "running" && - current.lifecycle !== "failed") - ) { - return; - } - yield* manager - .recordLifecycle(ownership, { - stackId: input.stackId, - lifecycle: "failed", - }) - .pipe(Effect.asVoid); - }), - closeOwner: ownership.close, - errorDetail: (cause) => causeMessage(Cause.squash(cause)), - }) - .pipe(Effect.provide(Logger.layer([supervisorLogger]))); - if (!result.started) { - yield* sendMessage({ type: "error", message: STACK_STOPPED_DURING_STARTUP }); - } - }); - -const runManaged = ( - input: SupervisorStartMessage, - platform: SupervisorPlatform, -): ReturnType => - Effect.suspend(() => makeRunManagedExecution(input, platform)); - -/** Effect-native child program. Node/Bun entrypoints only call runPromise here. */ -export const runSupervisor = ( - platform: SupervisorPlatform, -): Effect.Effect< - void, - SupervisorExecutionError, - | ControlTransport - | import("effect").FileSystem.FileSystem - | import("effect").Path.Path - | ChildProcessSpawner.ChildProcessSpawner -> => - Effect.scoped( - Effect.gen(function* () { - const input = yield* receiveStartMessage(); - const execution = Effect.raceFirst( - runManaged(input, platform), - waitForSignal().pipe( - // Preserve the actionable startup diagnostic before interrupting the - // managed run. Parent IPC may already be disconnected by the time a - // signal arrives, so that failure is deliberately ignored; the - // session actor still observes the interruption and performs its - // explicit-stop cleanup transaction. - Effect.andThen( - sendMessage({ type: "error", message: STACK_STOPPED_DURING_STARTUP }).pipe( - Effect.catchTag("SupervisorStartError", () => Effect.void), - ), - ), - Effect.andThen(Effect.interrupt), - ), - ); - yield* Effect.matchCauseEffect(execution, { - onFailure: (cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : sendMessage(supervisorErrorMessage(cause)).pipe( - Effect.andThen(Effect.failCause(cause)), - ), - onSuccess: Effect.succeed, - }); - }), - ); - -const forkSupervisor = (entryPoint: string): Effect.Effect => - Effect.try({ - try: () => { - // A compiled Bun executable cannot execute the source daemon path from - // Bun's virtual filesystem. Keep that path as the fork module so Bun - // installs its IPC channel, but re-execute the current compiled binary - // and let the entrypoint's daemon marker select runBunDaemon(). - const compiledBunEntryPoint = /[\\/]\$bunfs[\\/]/.test(entryPoint); - return fork(entryPoint, [], { - stdio: ["ignore", "ignore", "ignore", "ipc"], - detached: true, - ...(compiledBunEntryPoint ? { execPath: process.execPath } : {}), - env: { ...process.env, SUPABASE_STACK_RUN_DAEMON: "1" }, - }); - }, - catch: (cause) => - new SupervisorStartError({ message: `Failed to fork supervisor: ${causeMessage(cause)}` }), - }); - -const sendStart = ( - child: ChildProcess, - message: SupervisorStartMessage, -): Effect.Effect => - Effect.gen(function* () { - const config = yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Json))( - message.config, - ).pipe(Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) }))); - const document = - message.portIntents.document === undefined - ? undefined - : yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Json))( - message.portIntents.document, - ).pipe( - Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) })), - ); - const encoded = yield* Schema.encodeEffect(SupervisorStartCommandSchema)({ - ...message, - config, - portIntents: { - activeFields: message.portIntents.activeFields, - ...(message.portIntents.disabledFields === undefined - ? {} - : { disabledFields: message.portIntents.disabledFields }), - ...(document === undefined ? {} : { document }), - }, - }).pipe(Effect.mapError((cause) => new SupervisorStartError({ message: causeMessage(cause) }))); - yield* Effect.callback((resume) => { - try { - child.send(encoded, (error) => - resume( - error === null - ? Effect.void - : Effect.fail(new SupervisorStartError({ message: error.message })), - ), - ); - } catch (cause) { - resume(Effect.fail(new SupervisorStartError({ message: causeMessage(cause) }))); - } - return Effect.void; - }); - }); - -const waitForStarted = ( - child: ChildProcess, -): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const events = Stream.callback((queue) => - Effect.acquireRelease( - Effect.sync(() => { - let finished = false; - const cleanup = () => { - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - }; - const fail = (error: SupervisorStartError) => { - if (finished) return; - finished = true; - Queue.failCauseUnsafe(queue, Cause.fail(error)); - cleanup(); - }; - const onMessage = (value: unknown) => Queue.offerUnsafe(queue, value); - const onError = (cause: Error) => - fail(new SupervisorStartError({ message: cause.message })); - const onExit = (code: number | null) => - fail(new SupervisorStartError({ message: `Supervisor exited with code ${code}` })); - child.on("message", onMessage); - child.on("error", onError); - child.on("exit", onExit); - return cleanup; - }), - (cleanup) => Effect.sync(cleanup), - ), - ); - const pull = yield* Stream.toPull(events); - while (true) { - const chunk = yield* pull.pipe( - Effect.mapError((error) => - error instanceof SupervisorStartError - ? error - : new SupervisorStartError({ message: "Supervisor event stream ended" }), - ), - ); - const event = yield* decodeSupervisorEvent(chunk[0]); - return event; - } - }), - ); - -/** Parent-side launcher for the managed supervisor. */ -export const supervisorLayer = ( - input: SupervisorStartMessage, - entryPoint: string, -): Effect.Effect< - Layer.Layer< - import("./Stack.ts").Stack, - DaemonUpgradeRequired | StackRpcProtocolError | StackRpcTransportError - >, - | SupervisorStartError - | DaemonUpgradeRequired - | import("./managed/model.ts").InvalidManagedStackNameError, - HttpTransportClient -> => - Effect.gen(function* () { - yield* validateManagedStackName(input.stackName); - const client = yield* HttpTransportClient; - const child = yield* forkSupervisor(entryPoint); - let detached = false; - return yield* Effect.gen(function* () { - const responseFiber = yield* waitForStarted(child).pipe( - Effect.timeout(SUPERVISOR_HANDSHAKE_TIMEOUT), - Effect.catchTag("TimeoutError", () => - Effect.fail( - new SupervisorStartError({ message: "Timed out waiting for supervisor startup" }), - ), - ), - Effect.forkChild({ startImmediately: true }), - ); - yield* sendStart(child, input); - const response = yield* Fiber.join(responseFiber); - if (response.owner.daemonCliVersion !== input.cliVersion) { - return yield* new DaemonUpgradeRequired({ - stackId: input.stackId, - oldCliVersion: response.owner.daemonCliVersion, - newCliVersion: input.cliVersion, - state: response.owner.state, - ready: response.owner.ready, - }); - } - if (response.owner.state !== "running" || !response.owner.ready) { - return yield* new SupervisorStartError({ - message: STACK_STOPPED_DURING_STARTUP, - }); - } - child.unref(); - detached = true; - return RemoteStack.layer(response.endpoint, { - owner: response.owner, - cliVersion: input.cliVersion, - stackId: input.stackId, - }).pipe(Layer.provide(Layer.succeed(HttpTransportClient, client))); - }).pipe( - Effect.onExit(() => - detached ? Effect.void : terminateChildProcess(child).pipe(Effect.ignore), - ), - ); - }); - -export const managedDaemonLayer = ( - input: ManagedDaemonStartInput, - entryPoint: string, -): Effect.Effect< - Layer.Layer, - | SupervisorStartError - | DaemonUpgradeRequired - | import("./managed/model.ts").InvalidManagedStackNameError, - HttpTransportClient | import("effect").FileSystem.FileSystem -> => - Effect.gen(function* () { - yield* validateManagedStackName(input.stackName); - const discovery = yield* ensureEnvironment(input.workspacePath).pipe( - Effect.provide(gitConfigStoreLayer), - Effect.mapError((error) => new SupervisorStartError({ message: error.message })), - ); - return yield* supervisorLayer( - { - type: "start", - cliVersion: input.cliVersion, - stackId: deriveStackId(discovery.identity, input.stackName), - workspacePath: input.workspacePath, - stackName: input.stackName, - stateRoot: input.stateRoot, - config: { - ...input.config, - cwd: typeof input.config.cwd === "string" ? input.config.cwd : input.workspacePath, - }, - portIntents: input.portIntents, - ...(input.launch === undefined ? {} : { launch: input.launch }), - }, - entryPoint, - ); - }); diff --git a/packages/stack/src/terminateChild.ts b/packages/stack/src/terminateChild.ts deleted file mode 100644 index c3eb3007ec..0000000000 --- a/packages/stack/src/terminateChild.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Duration, Effect } from "effect"; - -interface ChildLike { - readonly pid?: number; - readonly exitCode?: number | null; - readonly signalCode?: NodeJS.Signals | null; - kill: (signal?: NodeJS.Signals) => boolean | void; - once: (event: "exit", listener: () => void) => void; - off: (event: "exit", listener: () => void) => void; -} - -const hasAlreadyExited = (child: ChildLike): boolean => - child.exitCode != null || child.signalCode != null; - -/** - * Waits for a child exit event while making listener ownership explicit. The - * callback adapter removes the listener on both completion and interruption; - * the timeout is an Effect race, so no timer survives a losing branch. - */ -const signalAndWait = ( - child: ChildLike, - signal: NodeJS.Signals, - timeoutMs: number, -): Effect.Effect => - Effect.raceFirst( - Effect.callback((resume) => { - let settled = false; - const cleanup = () => { - if (settled) return; - settled = true; - child.off("exit", onExit); - }; - const onExit = () => { - cleanup(); - resume(Effect.succeed(true)); - }; - - if (hasAlreadyExited(child)) { - resume(Effect.succeed(true)); - return Effect.void; - } - child.once("exit", onExit); - try { - child.kill(signal); - } catch { - // A child can disappear between the state check and kill call. - } - return Effect.sync(cleanup); - }), - Effect.as(Effect.sleep(Duration.millis(timeoutMs)), false), - ); - -export const terminateChildProcess = ( - child: ChildLike, - opts: { - readonly timeoutMs?: number; - } = {}, -): Effect.Effect => { - if (child.pid == null || hasAlreadyExited(child)) return Effect.void; - - const timeoutMs = opts.timeoutMs ?? 1_000; - return Effect.gen(function* () { - if (yield* signalAndWait(child, "SIGTERM", timeoutMs)) return; - if (hasAlreadyExited(child)) return; - yield* signalAndWait(child, "SIGKILL", timeoutMs); - }); -}; diff --git a/packages/stack/src/terminateChild.unit.test.ts b/packages/stack/src/terminateChild.unit.test.ts deleted file mode 100644 index aed47f6100..0000000000 --- a/packages/stack/src/terminateChild.unit.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Child termination tests await the public Promise facade from Vitest callbacks. - -import { describe, expect, it, vi } from "vitest"; -import { Effect, Fiber } from "effect"; -import { terminateChildProcess } from "./terminateChild.ts"; - -interface ChildLike { - readonly pid?: number; - kill: (signal?: NodeJS.Signals) => void; - once: (event: "exit", listener: () => void) => void; - off: (event: "exit", listener: () => void) => void; -} - -class FakeChild implements ChildLike { - readonly pid = 1234; - exitCode: number | null = null; - readonly signals: Array = []; - #listeners = new Set<() => void>(); - - get listenerCount(): number { - return this.#listeners.size; - } - - constructor( - private readonly onKill: (signal: NodeJS.Signals, child: FakeChild) => void = () => {}, - ) {} - - kill(signal: NodeJS.Signals = "SIGTERM"): void { - this.signals.push(signal); - this.onKill(signal, this); - } - - once(_event: "exit", listener: () => void): void { - this.#listeners.add(listener); - } - - off(_event: "exit", listener: () => void): void { - this.#listeners.delete(listener); - } - - exit(): void { - for (const listener of this.#listeners) { - listener(); - } - this.#listeners.clear(); - } -} - -describe("terminateChildProcess", () => { - it("sends SIGTERM and stops when the child exits in time", async () => { - const child = new FakeChild((signal, self) => { - if (signal === "SIGTERM") { - self.exit(); - } - }); - - await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 100 })); - - expect(child.signals).toEqual(["SIGTERM"]); - }); - - it("escalates to SIGKILL when the child ignores SIGTERM", async () => { - const child = new FakeChild((signal, self) => { - if (signal === "SIGKILL") { - self.exit(); - } - }); - - await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 10 })); - - expect(child.signals).toEqual(["SIGTERM", "SIGKILL"]); - }); -}); - -describe("terminateChildProcess on an already-exited child", () => { - it("returns immediately instead of waiting out both signal timeouts", async () => { - // A dead ChildProcess never fires another `exit` event, so before this - // guard the call burned 2x timeoutMs listening for one — which turned a - // teardown sweep over dead children into the very afterAll hook timeout - // the sweep exists to prevent. - const child = new FakeChild(); - child.exitCode = 0; - await Effect.runPromise(terminateChildProcess(child, { timeoutMs: 5_000 })); - expect(child.signals).toEqual([]); - }); - - it("skips the SIGKILL wait when the child dies between checks", async () => { - const child = new FakeChild((signal, self) => { - if (signal === "SIGTERM") { - self.exitCode = 143; - } - }); - vi.useFakeTimers(); - try { - const termination = Effect.runPromise(terminateChildProcess(child, { timeoutMs: 300 })); - await vi.runAllTimersAsync(); - await termination; - expect(child.signals).toEqual(["SIGTERM"]); - } finally { - vi.useRealTimers(); - } - }); - - it("removes the exit listener when termination is interrupted", async () => { - const child = new FakeChild(); - const fiber = Effect.runFork(terminateChildProcess(child, { timeoutMs: 1_000 })); - await Effect.runPromise(Effect.yieldNow); - - expect(child.listenerCount).toBe(1); - await Effect.runPromise(Fiber.interrupt(fiber)); - expect(child.listenerCount).toBe(0); - }); -}); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index f5470e724d..62e24d9dcf 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1,54 +1 @@ -/** Test-only runtime seams for building deterministic consumer layers. */ -import { Effect, Stream } from "effect"; -import type { StackInfo } from "./Stack.ts"; -import type { Stack } from "./Stack.ts"; -import { StackServiceState } from "./StackServiceState.ts"; - -const testStackInfo: StackInfo = { - url: "http://127.0.0.1", - dbUrl: "postgresql://127.0.0.1/postgres", - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "role", - serviceEndpoints: {}, -}; - -const testStackState = new StackServiceState({ - name: "auth", - status: "Running", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, -}); - -export const makeTestStack = (overrides: Partial = {}): Stack["Service"] => { - const defaults: Stack["Service"] = { - getInfo: Effect.succeed(testStackInfo), - start: Effect.void, - stop: Effect.void, - dispose: Effect.void, - startService: () => Effect.void, - stopService: () => Effect.void, - restartService: () => Effect.void, - reloadFunctions: () => Effect.void, - reloadEdgeRuntime: () => Effect.void, - getState: () => Effect.succeed(testStackState), - getAllStates: Effect.succeed([testStackState]), - stateChanges: () => Effect.succeed(Stream.empty), - allStateChanges: Stream.empty, - waitReady: () => Effect.void, - waitAllReady: () => Effect.void, - subscribeLogs: () => Stream.empty, - subscribeAllLogs: () => Stream.empty, - logHistory: () => Effect.succeed([]), - logHistoryAll: () => Effect.succeed([]), - }; - return { ...defaults, ...overrides }; -}; - -export { HttpTransportClient } from "./HttpTransportClient.ts"; -export { makeSupervisorControlApplication } from "./SupervisorControlServer.ts"; -export { SupervisorSession } from "./SupervisorSession.ts"; +/** Test-only stack helpers. */ diff --git a/packages/stack/src/version-plan.ts b/packages/stack/src/version-plan.ts deleted file mode 100644 index 7a487f2bb1..0000000000 --- a/packages/stack/src/version-plan.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - diffPinnedAndAvailableVersions, - fillServiceVersionManifest, - normalizeServiceVersions, - SERVICE_NAMES, - type AvailableServiceVersionUpdate, - type ServiceName, - type VersionManifest, -} from "./versions.ts"; - -export interface StackVersionOverride { - readonly service: ServiceName; - readonly version: string; - readonly source: "flag" | "local"; -} - -export interface StackVersionPlanInput { - readonly candidateBaseline?: Partial>; - readonly pinnedBaseline?: VersionManifest; - readonly localOverrides?: Partial>; - readonly flagOverrides?: Partial>; -} - -export interface StackVersionPlan { - readonly candidateBaseline: VersionManifest; - readonly pinnedBaseline: VersionManifest; - readonly runtimeVersions: VersionManifest; - readonly activeOverrides: ReadonlyArray; - readonly availableUpdates: ReadonlyArray; - readonly updateFingerprint: string | undefined; -} - -function fingerprintAvailableVersionUpdates( - updates: ReadonlyArray, -): string | undefined { - if (updates.length === 0) { - return undefined; - } - - return updates - .map( - ({ service, pinnedVersion, availableVersion }) => - `${service}:${pinnedVersion}->${availableVersion}`, - ) - .join("|"); -} - -export function planStackVersions(input: StackVersionPlanInput): StackVersionPlan { - const candidateBaseline = fillServiceVersionManifest( - normalizeServiceVersions(input.candidateBaseline ?? {}), - ); - const pinnedBaseline = input.pinnedBaseline ?? candidateBaseline; - const localOverrides = normalizeServiceVersions(input.localOverrides ?? {}); - const flagOverrides = normalizeServiceVersions(input.flagOverrides ?? {}); - - const activeOverrideMap = new Map(); - for (const service of SERVICE_NAMES) { - const localVersion = localOverrides[service]; - if (localVersion !== undefined) { - activeOverrideMap.set(service, { - service, - version: localVersion, - source: "local", - }); - } - } - for (const service of SERVICE_NAMES) { - const flagVersion = flagOverrides[service]; - if (flagVersion !== undefined) { - activeOverrideMap.set(service, { - service, - version: flagVersion, - source: "flag", - }); - } - } - - const runtimeVersions = fillServiceVersionManifest({ - ...pinnedBaseline, - ...localOverrides, - ...flagOverrides, - }); - const availableUpdates = diffPinnedAndAvailableVersions(pinnedBaseline, candidateBaseline); - - return { - candidateBaseline, - pinnedBaseline, - runtimeVersions, - activeOverrides: SERVICE_NAMES.flatMap((service) => { - const override = activeOverrideMap.get(service); - return override === undefined ? [] : [override]; - }), - availableUpdates, - updateFingerprint: fingerprintAvailableVersionUpdates(availableUpdates), - }; -} diff --git a/packages/stack/src/version-plan.unit.test.ts b/packages/stack/src/version-plan.unit.test.ts deleted file mode 100644 index 474a52170b..0000000000 --- a/packages/stack/src/version-plan.unit.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_VERSIONS } from "./versions.ts"; -import { planStackVersions } from "./version-plan.ts"; - -describe("planStackVersions", () => { - it("fills the candidate baseline from linked versions and defaults", () => { - expect( - planStackVersions({ - candidateBaseline: { - postgres: "17.6.1.090", - postgrest: "v14.5", - auth: "v2.187.0", - }, - }), - ).toMatchObject({ - candidateBaseline: { - ...DEFAULT_VERSIONS, - postgres: "17.6.1.090", - postgrest: "v14.5", - auth: "v2.187.0", - }, - pinnedBaseline: { - ...DEFAULT_VERSIONS, - postgres: "17.6.1.090", - postgrest: "v14.5", - auth: "v2.187.0", - }, - }); - }); - - it("applies flag overrides over local overrides without changing the pinned baseline", () => { - expect( - planStackVersions({ - candidateBaseline: { - postgres: "17.6.1.090", - postgrest: "v14.5", - auth: "v2.187.0", - }, - localOverrides: { - auth: "v2.180.0", - storage: "1.40.0", - }, - flagOverrides: { - auth: "v2.170.0", - postgres: "17.4.1.045", - }, - }), - ).toMatchObject({ - runtimeVersions: { - ...DEFAULT_VERSIONS, - postgres: "17.4.1.045", - postgrest: "v14.5", - auth: "v2.170.0", - storage: "v1.40.0", - }, - activeOverrides: [ - { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "v2.170.0", source: "flag" }, - { service: "storage", version: "v1.40.0", source: "local" }, - ], - }); - }); - - it("uses the pinned baseline to compute available updates and fingerprints", () => { - expect( - planStackVersions({ - candidateBaseline: { - auth: "v2.188.1", - storage: "v1.43.3", - }, - pinnedBaseline: { - ...DEFAULT_VERSIONS, - auth: "2.188.0-rc.15", - storage: "1.41.8", - }, - }), - ).toMatchObject({ - availableUpdates: [ - { - service: "auth", - pinnedVersion: "2.188.0-rc.15", - availableVersion: "v2.188.1", - }, - { - service: "storage", - pinnedVersion: "1.41.8", - availableVersion: "v1.43.3", - }, - ], - updateFingerprint: "auth:2.188.0-rc.15->v2.188.1|storage:1.41.8->v1.43.3", - }); - }); -}); diff --git a/packages/stack/src/versions.ts b/packages/stack/src/versions.ts deleted file mode 100644 index 66854fbc81..0000000000 --- a/packages/stack/src/versions.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - DEFAULT_VERSIONS, - SERVICE_NAMES, - dockerImageForArtifact, - serviceMetadata, -} from "./ServiceCatalog.ts"; -import type { ServiceName } from "./ServiceName.ts"; -import { Schema } from "effect"; - -export { DEFAULT_VERSIONS, SERVICE_NAMES } from "./ServiceCatalog.ts"; -export type { ServiceName } from "./ServiceName.ts"; - -export type VersionManifest = Readonly>; - -export const PartialVersionManifestSchema = Schema.Struct({ - postgres: Schema.optionalKey(Schema.String), - postgrest: Schema.optionalKey(Schema.String), - auth: Schema.optionalKey(Schema.String), - "edge-runtime": Schema.optionalKey(Schema.String), - realtime: Schema.optionalKey(Schema.String), - storage: Schema.optionalKey(Schema.String), - imgproxy: Schema.optionalKey(Schema.String), - mailpit: Schema.optionalKey(Schema.String), - pgmeta: Schema.optionalKey(Schema.String), - studio: Schema.optionalKey(Schema.String), - analytics: Schema.optionalKey(Schema.String), - vector: Schema.optionalKey(Schema.String), - pooler: Schema.optionalKey(Schema.String), -}); - -export type PartialVersionManifest = Schema.Schema.Type; - -/** - * Returns the full Docker image URL for a service. - */ -export function dockerImageForService(service: ServiceName, version: string): string { - return dockerImageForArtifact(service, normalizeServiceVersion(service, version)); -} - -function assertFullVersions( - versions: Partial>, -): asserts versions is Record { - const missing = SERVICE_NAMES.filter((service) => versions[service] === undefined); - if (missing.length > 0) { - throw new Error(`Missing service versions for: ${missing.join(", ")}`); - } -} - -export function fullVersionManifest( - versions: Partial>, -): VersionManifest { - assertFullVersions(versions); - return versions; -} - -/** Normalizes a version string to the catalog's canonical stored form. */ -export function normalizeServiceVersion(service: ServiceName, version: string): string { - const normalized = version.trim(); - const metadata = serviceMetadata(service); - const tagPrefix = metadata.artifact.docker.tagPrefix; - const withoutDockerTagPrefix = - tagPrefix !== undefined && - normalized.slice(0, tagPrefix.length).toLowerCase() === tagPrefix.toLowerCase() - ? normalized.slice(tagPrefix.length) - : normalized; - if (!metadata.defaultVersion.startsWith("v")) return withoutDockerTagPrefix; - return withoutDockerTagPrefix.slice(0, 1).toLowerCase() === "v" - ? `v${withoutDockerTagPrefix.slice(1)}` - : `v${withoutDockerTagPrefix}`; -} - -export function normalizeServiceVersions( - versions: Partial>, -): Partial { - const normalized: Partial> = {}; - for (const service of SERVICE_NAMES) { - const version = versions[service]; - if (typeof version === "string" && version.trim().length > 0) { - normalized[service] = normalizeServiceVersion(service, version); - } - } - return normalized; -} - -export function fillServiceVersionManifest( - versions: Partial>, -): VersionManifest { - const filled: Partial> = {}; - for (const service of SERVICE_NAMES) { - filled[service] = versions[service] ?? DEFAULT_VERSIONS[service]; - } - return fullVersionManifest(filled); -} - -export interface AvailableServiceVersionUpdate { - readonly service: ServiceName; - readonly pinnedVersion: string; - readonly availableVersion: string; -} - -export function diffPinnedAndAvailableVersions( - pinnedBaseline: VersionManifest, - candidateBaseline: VersionManifest, -): ReadonlyArray { - return SERVICE_NAMES.flatMap((service) => { - const pinnedVersion = pinnedBaseline[service]; - const availableVersion = candidateBaseline[service]; - if (pinnedVersion === availableVersion) { - return []; - } - return [{ service, pinnedVersion, availableVersion }]; - }); -} diff --git a/packages/stack/src/versions.unit.test.ts b/packages/stack/src/versions.unit.test.ts deleted file mode 100644 index 086a91fa0f..0000000000 --- a/packages/stack/src/versions.unit.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - readVersionManifestFromDockerfile, - syncDefaultVersionsSource, -} from "../scripts/sync-versions-from-dockerfile.ts"; -import { - DEFAULT_VERSIONS, - diffPinnedAndAvailableVersions, - dockerImageForService, - fillServiceVersionManifest, - normalizeServiceVersion, - SERVICE_NAMES, - type VersionManifest, -} from "./versions.ts"; -import { SERVICE_CATALOG } from "./ServiceCatalog.ts"; - -const sampleDockerfile = ` -FROM supabase/postgres:17.0.0.1 AS pg -FROM library/kong:2.8.1 AS kong -FROM axllent/mailpit:v1.2.3 AS mailpit -FROM postgrest/postgrest:v14.0 AS postgrest -FROM supabase/postgres-meta:v0.90.0 AS pgmeta -FROM supabase/studio:2026.01.01-sha-abcdef0 AS studio -FROM darthsim/imgproxy:v3.8.0 AS imgproxy -FROM supabase/edge-runtime:v1.70.0 AS edgeruntime -FROM timberio/vector:0.50.0-alpine AS vector -FROM supabase/supavisor:2.1.0 AS supavisor -FROM supabase/gotrue:v2.100.0 AS gotrue -FROM supabase/realtime:v2.100.0 AS realtime -FROM supabase/storage-api:v1.50.0 AS storage -FROM supabase/logflare:1.40.0 AS logflare -FROM supabase/migra:3.0.1663481299 AS migra -`; - -describe("syncDefaultVersionsSource", () => { - it("rewrites the DEFAULT_VERSIONS block from Dockerfile versions", () => { - const source = SERVICE_NAMES.map( - (service) => ` ${JSON.stringify(service)}: { - name: ${JSON.stringify(service)}, - configKey: "example", - defaultVersion: "old", - },`, - ).join("\n"); - - const updated = syncDefaultVersionsSource( - source, - readVersionManifestFromDockerfile(sampleDockerfile), - ); - - expect(updated).toContain( - 'name: "postgres",\n configKey: "example",\n defaultVersion: "17.0.0.1"', - ); - expect(updated).toContain( - 'name: "edge-runtime",\n configKey: "example",\n defaultVersion: "v1.70.0"', - ); - expect(updated).toContain( - 'name: "mailpit",\n configKey: "example",\n defaultVersion: "v1.2.3"', - ); - expect(updated).not.toContain('defaultVersion: "old"'); - }); - - it("fails when a required Dockerfile image alias is missing", () => { - expect(() => - readVersionManifestFromDockerfile("FROM supabase/postgres:17.6.1.139 AS pg\n"), - ).toThrow("Missing Dockerfile versions for:"); - }); - - it("fails when the Dockerfile contains an unexpected image alias", () => { - expect(() => - readVersionManifestFromDockerfile( - `${sampleDockerfile}\nFROM supabase/example:1.0.0 AS example\n`, - ), - ).toThrow("Unknown Dockerfile image alias 'example'."); - }); -}); - -describe("dockerImageForService", () => { - it("defines artifact capabilities for every stack service", () => { - expect(Object.keys(SERVICE_CATALOG).sort()).toEqual([...SERVICE_NAMES].sort()); - }); - - it("returns correct image for postgres", () => { - expect(dockerImageForService("postgres", DEFAULT_VERSIONS.postgres)).toBe( - `ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`, - ); - }); - - it("returns correct image for postgrest (with v prefix)", () => { - expect(dockerImageForService("postgrest", DEFAULT_VERSIONS.postgrest)).toBe( - `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, - ); - }); - - it("returns correct image for auth (with v prefix)", () => { - expect(dockerImageForService("auth", DEFAULT_VERSIONS.auth)).toBe( - `ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`, - ); - }); - - it("returns correct image for edge-runtime (with v prefix)", () => { - expect(dockerImageForService("edge-runtime", DEFAULT_VERSIONS["edge-runtime"])).toBe( - `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, - ); - }); - - it("uses canonical GHCR for every service", () => { - expect(dockerImageForService("imgproxy", DEFAULT_VERSIONS.imgproxy)).toBe( - `ghcr.io/supabase/cli/imgproxy:${DEFAULT_VERSIONS.imgproxy}`, - ); - }); - - it("uses the upstream mirror repositories for vector and pooler", () => { - expect(dockerImageForService("vector", DEFAULT_VERSIONS.vector)).toBe( - "ghcr.io/supabase/vector:0.53.0-alpine", - ); - expect(dockerImageForService("pooler", DEFAULT_VERSIONS.pooler)).toBe( - "ghcr.io/supabase/supavisor:2.9.7", - ); - }); - - it("preserves upstream mirror repositories for explicit vector and pooler versions", () => { - expect(dockerImageForService("vector", "0.52.0-alpine")).toBe( - "ghcr.io/supabase/vector:0.52.0-alpine", - ); - expect(dockerImageForService("pooler", "2.9.6")).toBe("ghcr.io/supabase/supavisor:2.9.6"); - }); - - it("keeps non-managed services Docker-only", () => { - expect(SERVICE_CATALOG.imgproxy).toMatchObject({ - runtimeSupport: "docker-only", - artifact: { docker: { repository: "imgproxy" } }, - }); - expect(SERVICE_CATALOG.mailpit).toMatchObject({ - runtimeSupport: "docker-only", - artifact: { docker: { repository: "mailpit" } }, - }); - expect(SERVICE_CATALOG.vector).toMatchObject({ - runtimeSupport: "docker-only", - artifact: { docker: { repository: "vector" } }, - }); - }); -}); - -describe("normalizeServiceVersion", () => { - it("preserves frozen leading v tags", () => { - expect(normalizeServiceVersion("postgrest", "v14.5")).toBe("v14.5"); - expect(normalizeServiceVersion("auth", "v2.188.0")).toBe("v2.188.0"); - expect(normalizeServiceVersion("edge-runtime", "v1.73.0")).toBe("v1.73.0"); - }); - - it("normalizes bare versions for services with v-prefixed catalog releases", () => { - expect(normalizeServiceVersion("mailpit", "1.30.2")).toBe("v1.30.2"); - expect(normalizeServiceVersion("imgproxy", "3.8.0")).toBe("v3.8.0"); - expect(normalizeServiceVersion("mailpit", "V1.30.2")).toBe("v1.30.2"); - }); - - it("passes through other services unchanged", () => { - expect(normalizeServiceVersion("postgres", "17.6.1.090")).toBe("17.6.1.090"); - }); - - it("normalizes a prefixed pgmeta override to its catalog tag", () => { - expect(normalizeServiceVersion("pgmeta", "v0.98.0")).toBe("0.98.0"); - expect(normalizeServiceVersion("pgmeta", "V0.98.0")).toBe("0.98.0"); - expect(dockerImageForService("pgmeta", normalizeServiceVersion("pgmeta", "v0.98.0"))).toBe( - "ghcr.io/supabase/cli/pgmeta:v0.98.0", - ); - }); -}); - -describe("fillServiceVersionManifest", () => { - it("fills missing versions with defaults", () => { - const result = fillServiceVersionManifest({ postgres: "17.4.1.045" }); - expect(result.postgres).toBe("17.4.1.045"); - expect(result.postgrest).toBe(DEFAULT_VERSIONS.postgrest); - expect(result.auth).toBe(DEFAULT_VERSIONS.auth); - expect(result["edge-runtime"]).toBe(DEFAULT_VERSIONS["edge-runtime"]); - }); - - it("returns all defaults when given empty input", () => { - const result = fillServiceVersionManifest({}); - expect(result).toEqual(DEFAULT_VERSIONS); - }); -}); - -describe("diffPinnedAndAvailableVersions", () => { - it("returns empty when versions match", () => { - expect(diffPinnedAndAvailableVersions(DEFAULT_VERSIONS, DEFAULT_VERSIONS)).toEqual([]); - }); - - it("returns diffs for changed versions", () => { - const candidate: VersionManifest = { ...DEFAULT_VERSIONS, auth: "2.190.0" }; - const result = diffPinnedAndAvailableVersions(DEFAULT_VERSIONS, candidate); - expect(result).toEqual([ - { service: "auth", pinnedVersion: DEFAULT_VERSIONS.auth, availableVersion: "2.190.0" }, - ]); - }); -}); diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts deleted file mode 100644 index 08d2d49971..0000000000 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/node-builtin-import -- Docker e2e tests drive the native CLI, Docker, and HTTP boundaries from Vitest's Promise callbacks. - -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import { execSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; -import { createStack, type StackHandle } from "../src/node.ts"; -import { dependencyTimeoutSecondsForServices } from "../src/services/health-budgets.ts"; -import { DEFAULT_VERSIONS } from "../src/versions.ts"; -import { setupTestTable } from "./helpers/e2e.ts"; - -const STACK_DOCKER_E2E_TEST_TIMEOUT_MS = 180_000; -const STACK_DOCKER_E2E_SETUP_OVERHEAD_MS = 90_000; -const STACK_DOCKER_E2E_SETUP_TIMEOUT_MS = - dependencyTimeoutSecondsForServices(["postgres"]) * 1000 + STACK_DOCKER_E2E_SETUP_OVERHEAD_MS; -const ANALYTICS_COLD_START_TEST_TIMEOUT_MS = activationTimeoutSecondsForService("analytics") * 1000; - -function hasDockerDaemon(): boolean { - try { - execSync("docker info", { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - -const dockerDescribe = hasDockerDaemon() ? describe : describe.skip; - -dockerDescribe("createStack e2e (docker mode)", () => { - let stack: StackHandle; - let dataDir: string; - let apiPort: string; - let supabase: SupabaseClient; - - beforeAll(async () => { - dataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-")); - - stack = await createStack({ - mode: "docker", - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", - postgres: { dataDir }, - analytics: {}, - }); - - try { - await stack.start(); - } catch (startError) { - await stack.dispose().catch(() => {}); - throw startError; - } - - const dbPort = parseInt(new URL(stack.dbUrl).port); - try { - await setupTestTable(dbPort); - } catch (error) { - const status = await stack.getStatus(); - const logs = await stack.logHistory("postgres"); - throw new Error( - `setupTestTable failed: ${String(error)}\nstatus=${JSON.stringify(status)}\nlogs=${JSON.stringify(logs)}`, - ); - } - - apiPort = new URL(stack.url).port; - supabase = createClient(stack.url, stack.publishableKey); - }, STACK_DOCKER_E2E_SETUP_TIMEOUT_MS); - - afterAll(async () => { - await stack?.dispose(); - - // Verify all Docker containers are cleaned up after dispose - const remaining = execSync(`docker ps -q --filter name=supabase-.*-${apiPort}`) - .toString() - .trim(); - expect(remaining).toBe(""); - - try { - rmSync(dataDir, { recursive: true, force: true }); - } catch {} - }, 30_000); - - test( - "runs the core services in Docker containers and serves health endpoints", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - await Promise.all([stack.startService("postgrest"), stack.startService("auth")]); - - const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - expect(runningImages).toContain( - `ghcr.io/supabase/cli/postgrest:${DEFAULT_VERSIONS.postgrest}`, - ); - expect(runningImages).toContain(`ghcr.io/supabase/cli/postgres:${DEFAULT_VERSIONS.postgres}`); - expect(runningImages).toContain(`ghcr.io/supabase/cli/auth:${DEFAULT_VERSIONS.auth}`); - - const [proxyRes, authRes] = await Promise.all([ - fetch(`${stack.url}/health`), - fetch(`${stack.url}/auth/v1/health`), - ]); - expect(proxyRes.status).toBe(200); - expect(await proxyRes.text()).toBe("OK"); - expect(authRes.status).toBe(200); - expect(await authRes.json()).toEqual( - expect.objectContaining({ description: expect.any(String) }), - ); - }, - ); - - test( - "runs the edge runtime in Docker and serves the functions placeholder through the local gateway", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const functionsRes = await fetch(`${stack.url}/functions/v1/test`); - await stack.serviceReady("edge-runtime"); - const runningImages = execSync("docker ps --format '{{.Image}}'").toString(); - const states = await stack.getStatus(); - - expect(runningImages).toContain( - `ghcr.io/supabase/cli/edge-runtime:${DEFAULT_VERSIONS["edge-runtime"]}`, - ); - expect(states).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "edge-runtime", status: "Healthy" }), - ]), - ); - expect(functionsRes.status).toBe(501); - expect(await functionsRes.json()).toEqual({ - code: "FUNCTIONS_NOT_CONFIGURED", - message: "Edge Functions are not configured for this local stack yet.", - }); - }, - ); - - test( - "cold-starts analytics through lazy service activation", - { timeout: ANALYTICS_COLD_START_TEST_TIMEOUT_MS }, - async () => { - expect(await stack.getServiceStatus("analytics")).toEqual( - expect.objectContaining({ status: "Dormant" }), - ); - - await stack.startService("analytics"); - - const [runningImages, states] = await Promise.all([ - Promise.resolve(execSync("docker ps --format '{{.Image}}'").toString()), - stack.getStatus(), - ]); - - expect(runningImages).toContain( - `ghcr.io/supabase/cli/analytics:${DEFAULT_VERSIONS.analytics}`, - ); - expect(states).toEqual( - expect.arrayContaining([expect.objectContaining({ name: "analytics", status: "Healthy" })]), - ); - }, - ); - - test( - "supports the docker auth signup and session golden path", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = "test-password-123"; - - const signUp = await supabase.auth.signUp({ - email: testEmail, - password: testPassword, - }); - expect(signUp.error).toBeNull(); - expect(signUp.data.user?.email).toBe(testEmail); - expect(signUp.data.session).toBeDefined(); - - const signIn = await supabase.auth.signInWithPassword({ - email: testEmail, - password: testPassword, - }); - expect(signIn.error).toBeNull(); - expect(signIn.data.user?.email).toBe(testEmail); - expect(signIn.data.session?.access_token).toBeTruthy(); - - const currentUser = await supabase.auth.getUser(); - expect(currentUser.error).toBeNull(); - expect(currentUser.data.user?.email).toBe(testEmail); - }, - ); - - test( - "supports a full docker PostgREST CRUD golden path", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const seeded = await supabase.from("todos").select("*").order("id"); - expect(seeded.error).toBeNull(); - expect(seeded.data).toHaveLength(2); - - const inserted = await supabase - .from("todos") - .insert({ title: "E2E test todo" }) - .select() - .single(); - expect(inserted.error).toBeNull(); - expect(inserted.data?.title).toBe("E2E test todo"); - - const updated = await supabase - .from("todos") - .update({ completed: true }) - .eq("title", "E2E test todo") - .select() - .single(); - expect(updated.error).toBeNull(); - expect(updated.data?.completed).toBe(true); - - const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); - expect(deleted.error).toBeNull(); - - const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); - expect(remaining.data).toHaveLength(0); - }, - ); - - test( - "restarts the Studio graph with its Pgmeta dependency", - { timeout: STACK_DOCKER_E2E_TEST_TIMEOUT_MS }, - async () => { - const graphDataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-docker-graph-")); - let graphStack: StackHandle | undefined; - try { - graphStack = await createStack({ - mode: "docker", - postgres: { dataDir: graphDataDir }, - pgmeta: {}, - studio: {}, - }); - await graphStack.start(); - expect(await graphStack.getServiceStatus("pgmeta")).toEqual( - expect.objectContaining({ status: "Healthy" }), - ); - expect(await graphStack.getServiceStatus("studio")).toEqual( - expect.objectContaining({ status: "Healthy" }), - ); - - await graphStack.stop(); - await graphStack.start(); - - expect(await graphStack.getServiceStatus("pgmeta")).toEqual( - expect.objectContaining({ status: "Healthy" }), - ); - expect(await graphStack.getServiceStatus("studio")).toEqual( - expect.objectContaining({ status: "Healthy" }), - ); - } finally { - await graphStack?.dispose(); - rmSync(graphDataDir, { recursive: true, force: true }); - } - }, - ); -}); diff --git a/packages/stack/tests/createStack-native.e2e.test.ts b/packages/stack/tests/createStack-native.e2e.test.ts deleted file mode 100644 index 5ac31fa5c6..0000000000 --- a/packages/stack/tests/createStack-native.e2e.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- Native e2e tests await subprocess-backed stack operations and use filesystem/path fixtures. - -import { createClient } from "@supabase/supabase-js"; -import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { createStack, type StackHandle } from "../src/node.ts"; -import { defaultCacheRoot } from "../src/paths.ts"; -import { setupTestTable } from "./helpers/e2e.ts"; - -describe("native PostgREST tracer bullet", () => { - const jwtSecret = "native-e2e-jwt-secret-with-at-least-32-characters"; - let stack: StackHandle; - let dataDir: string; - let cacheParent: string; - - beforeAll(async () => { - dataDir = mkdtempSync(join(tmpdir(), "supabase-native-postgrest-e2e-")); - cacheParent = mkdtempSync(join(tmpdir(), "supabase-native-cache-parent-")); - const cacheRoot = join(cacheParent, "cache root with spaces"); - symlinkSync(defaultCacheRoot(), cacheRoot, "dir"); - stack = await createStack({ - mode: "native", - cacheRoot, - functions: false, - edgeRuntime: false, - auth: false, - jwtSecret, - postgres: { dataDir }, - }); - await stack.start(); - await setupTestTable(parseInt(new URL(stack.dbUrl).port)); - }, 45_000); - - afterAll(async () => { - await stack?.dispose(); - rmSync(dataDir, { recursive: true, force: true }); - rmSync(cacheParent, { recursive: true, force: true }); - }, 30_000); - - test("serves a CRUD request through the native PostgREST resource", async () => { - const client = createClient(stack.url, stack.publishableKey); - const inserted = await client - .from("todos") - .insert({ title: "native tracer bullet" }) - .select() - .single(); - - expect(inserted.error).toBeNull(); - expect(inserted.data).toEqual(expect.objectContaining({ title: "native tracer bullet" })); - - const deleted = await client.from("todos").delete().eq("title", "native tracer bullet"); - expect(deleted.error).toBeNull(); - }, 30_000); - - test("persists JWT settings in the native Postgres database", async () => { - const sql = new Bun.SQL(stack.dbUrl); - try { - const rows = await sql.unsafe<{ jwt_secret: string; jwt_exp: string }[]>(` - SELECT - current_setting('app.settings.jwt_secret') AS jwt_secret, - current_setting('app.settings.jwt_exp') AS jwt_exp; - `); - expect(rows[0]).toEqual({ jwt_secret: jwtSecret, jwt_exp: "3600" }); - } finally { - await sql.close(); - } - }, 30_000); - - test("repairs incomplete bundled initialization on restart", async () => { - const adminUrl = new URL(stack.dbUrl); - adminUrl.username = "supabase_admin"; - const sql = new Bun.SQL(adminUrl.toString()); - try { - await sql.unsafe(` - DELETE FROM supabase_migrations.cli_init WHERE phase = 'complete'; - ALTER ROLE authenticator RESET session_preload_libraries; - `); - } finally { - await sql.close(); - } - - await stack.stop(); - await stack.start(); - - const check = new Bun.SQL(adminUrl.toString()); - try { - const rows = await check.unsafe<{ configured: boolean }[]>(` - SELECT EXISTS ( - SELECT 1 - FROM pg_roles - WHERE rolname = 'authenticator' - AND EXISTS ( - SELECT 1 - FROM unnest(coalesce(rolconfig, ARRAY[]::text[])) setting - WHERE setting LIKE 'session_preload_libraries=supautils%' - ) - ) AS configured; - `); - expect(rows[0]?.configured).toBe(true); - } finally { - await check.close(); - } - }, 30_000); -}); diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts deleted file mode 100644 index 93aa1a7e78..0000000000 --- a/packages/stack/tests/createStack.e2e.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/node-builtin-import -- Stack e2e tests await subprocess-backed operations and inspect native filesystem paths and timestamps. - -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { activationTimeoutSecondsForService } from "../src/ServiceActivation.ts"; -import { createStack, type ResolvedFunctionsBundle, type StackHandle } from "../src/node.ts"; -import { fetchFunctionWhenReady, setupTestTable } from "./helpers/e2e.ts"; - -const AUTH_COLD_START_TEST_TIMEOUT_MS = activationTimeoutSecondsForService("auth") * 1000; - -describe("createStack e2e", () => { - let stack: StackHandle; - let dataDir: string; - let projectDir: string; - let supabase: SupabaseClient; - - beforeAll(async () => { - dataDir = mkdtempSync(join(tmpdir(), "supabase-e2e-")); - projectDir = mkdtempSync(join(tmpdir(), "supabase-e2e-project-")); - writeFunction(projectDir, "hello", "hello"); - - stack = await createStack({ - projectDir, - functions: functionsBundle(projectDir, ["hello"]), - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", - postgres: { dataDir }, - }); - - try { - await stack.start(); - } catch (startError) { - await stack.dispose().catch(() => {}); - throw startError; - } - - const dbPort = parseInt(new URL(stack.dbUrl).port); - await setupTestTable(dbPort); - - supabase = createClient(stack.url, stack.publishableKey); - }, 45_000); - - afterAll(async () => { - await stack?.dispose(); - try { - rmSync(dataDir, { recursive: true, force: true }); - rmSync(projectDir, { recursive: true, force: true }); - } catch {} - }, 30_000); - - test( - "serves detected Edge Functions through the local gateway", - { timeout: 30_000 }, - async () => { - // "Healthy" only means the edge-runtime control plane answered its health - // probe; the first request to a function still lazily cold-boots a user - // worker, so wait for the function to actually become servable. - const functionsRes = await fetchFunctionWhenReady(`${stack.url}/functions/v1/hello`); - const states = await stack.getStatus(); - - expect(states).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "edge-runtime", status: "Healthy" }), - ]), - ); - expect(functionsRes.status).toBe(200); - expect(await functionsRes.text()).toBe("hello"); - }, - ); - - test("reloadFunctions picks up newly added Edge Functions", { timeout: 30_000 }, async () => { - writeFunction(projectDir, "later", "later"); - await stack.reloadFunctions({ functions: functionsBundle(projectDir, ["hello", "later"]) }); - - const res = await fetchFunctionWhenReady(`${stack.url}/functions/v1/later`); - - expect(res.status).toBe(200); - expect(await res.text()).toBe("later"); - }); - - test( - "supports the auth signup and session golden path", - { timeout: AUTH_COLD_START_TEST_TIMEOUT_MS }, - async () => { - const testEmail = `test-${Date.now()}@example.com`; - const testPassword = "test-password-123"; - - const signUp = await supabase.auth.signUp({ - email: testEmail, - password: testPassword, - }); - expect(signUp.error).toBeNull(); - expect(signUp.data.user?.email).toBe(testEmail); - expect(signUp.data.session).toBeDefined(); - - const signIn = await supabase.auth.signInWithPassword({ - email: testEmail, - password: testPassword, - }); - expect(signIn.error).toBeNull(); - expect(signIn.data.user?.email).toBe(testEmail); - expect(signIn.data.session?.access_token).toBeTruthy(); - - const currentUser = await supabase.auth.getUser(); - expect(currentUser.error).toBeNull(); - expect(currentUser.data.user?.email).toBe(testEmail); - }, - ); - - test("supports a full PostgREST CRUD golden path", { timeout: 30_000 }, async () => { - const seeded = await supabase.from("todos").select("*").order("id"); - expect(seeded.error).toBeNull(); - expect(seeded.data).toHaveLength(2); - - const inserted = await supabase - .from("todos") - .insert({ title: "E2E test todo" }) - .select() - .single(); - expect(inserted.error).toBeNull(); - expect(inserted.data?.title).toBe("E2E test todo"); - - const updated = await supabase - .from("todos") - .update({ completed: true }) - .eq("title", "E2E test todo") - .select() - .single(); - expect(updated.error).toBeNull(); - expect(updated.data?.completed).toBe(true); - - const deleted = await supabase.from("todos").delete().eq("title", "E2E test todo"); - expect(deleted.error).toBeNull(); - - const remaining = await supabase.from("todos").select("*").eq("title", "E2E test todo"); - expect(remaining.data).toHaveLength(0); - }); -}); - -function codeSafeJson(value: string) { - return JSON.stringify(value).replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029"); -} - -function writeFunction(projectDir: string, slug: string, body: string) { - const dir = join(projectDir, "supabase", "functions", slug); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "index.ts"), `Deno.serve(() => new Response(${codeSafeJson(body)}));\n`); -} - -function functionsBundle( - projectDir: string, - names: ReadonlyArray, -): ResolvedFunctionsBundle { - return { - env: {}, - functions: names.map((name) => ({ - name, - verifyJWT: false, - entrypointPath: join(projectDir, "supabase", "functions", name, "index.ts"), - importMapPath: null, - staticFiles: [], - env: {}, - })), - }; -} diff --git a/packages/stack/tests/global-setup.ts b/packages/stack/tests/global-setup.ts deleted file mode 100644 index d556ea91ea..0000000000 --- a/packages/stack/tests/global-setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Vitest global setup awaits the native stack dependency warmup boundary. - -import { warmStackE2eDependencies } from "./helpers/warmup.ts"; - -export async function setup(): Promise { - await warmStackE2eDependencies({ failOnError: true }); -} diff --git a/packages/stack/tests/helpers/SupervisorSessionFixture.ts b/packages/stack/tests/helpers/SupervisorSessionFixture.ts deleted file mode 100644 index 2024773672..0000000000 --- a/packages/stack/tests/helpers/SupervisorSessionFixture.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Cause, Data, Deferred, Effect, Fiber, Ref } from "effect"; -import type { Stack } from "../../src/Stack.ts"; -import { SupervisorSession } from "../../src/SupervisorSession.ts"; - -class SupervisorSessionFixtureCloseError extends Data.TaggedError( - "SupervisorSessionFixtureCloseError", -)<{ - readonly cause: unknown; -}> {} - -const normalizeClose = ( - close: Effect.Effect, -): Effect.Effect => - close.pipe(Effect.mapError((cause) => new SupervisorSessionFixtureCloseError({ cause }))); - -/** A running session actor for integration tests that host the control app in-process. */ -export const makeSupervisorSessionFixture = (input: { - readonly ownershipId: string; - readonly ownerSessionId: string; - readonly daemonCliVersion: string; - readonly close?: Effect.Effect; -}) => - Effect.gen(function* () { - const scope = yield* Effect.scope; - const controller = yield* SupervisorSession.make(input); - const startup = Deferred.makeUnsafe(); - const running = Deferred.makeUnsafe(); - const disposed = Deferred.makeUnsafe(); - const closeRef = Ref.makeUnsafe>( - normalizeClose(input.close ?? Effect.void), - ); - const runFiber = yield* controller - .run({ - startup: () => Deferred.await(startup), - stack: (stack) => stack, - awaitDisposed: () => Deferred.await(disposed), - onRunning: () => Deferred.succeed(running, undefined).pipe(Effect.asVoid), - onStopped: () => Effect.void, - onFailure: () => Effect.void, - closeOwner: Ref.get(closeRef).pipe(Effect.flatMap((close) => close)), - errorDetail: (cause) => String(Cause.squash(cause)), - }) - .pipe(Effect.forkIn(scope)); - const awaitShutdown = Fiber.join(runFiber).pipe(Effect.asVoid); - return { - ...controller.service, - publishStack: (stack: Stack["Service"]) => - Deferred.succeed(startup, stack).pipe( - Effect.andThen(Deferred.await(running)), - Effect.asVoid, - ), - setClose: (close: Effect.Effect) => - Ref.set(closeRef, normalizeClose(close)), - disposeRuntime: Deferred.succeed(disposed, undefined).pipe(Effect.asVoid), - requestShutdown: (_reason?: "stop" | "signal" | "startup-failure" | "dispose") => - controller.service.submitShutdownWithIntent("explicit").pipe(Effect.andThen(awaitShutdown)), - awaitShutdown, - }; - }); diff --git a/packages/stack/tests/helpers/compiled-supervisor-parent.ts b/packages/stack/tests/helpers/compiled-supervisor-parent.ts deleted file mode 100644 index 447ae87076..0000000000 --- a/packages/stack/tests/helpers/compiled-supervisor-parent.ts +++ /dev/null @@ -1,76 +0,0 @@ -// oxlint-disable effecttsgo/new-promise, effecttsgo/process-env -- The parent helper launches a native child process and forwards environment variables through its Promise boundary. -import { Context, Effect, Layer, Schema } from "effect"; -import { runTestSupervisor } from "./supervisor-child.ts"; -import { Stack } from "../../src/Stack.ts"; -import { httpTransportClientLayer } from "../../src/HttpTransportClient.ts"; -import { SupervisorStartCommandSchema } from "../../src/SupervisorProtocol.ts"; -import { daemonEntryPoint } from "../../src/platform-bun.ts"; -import { supervisorLayer } from "../../src/supervisor.ts"; - -/** - * The compiled parent and its re-entered child exchange only schema-validated - * values. The child itself uses the supervisor's production protocol; this - * event only tells the test process that the parent obtained its RemoteStack - * layer and detached the compiled supervisor child. - */ -export const CompiledSupervisorParentEventSchema = Schema.Union([ - Schema.Struct({ type: Schema.Literal("ready"), stackId: Schema.String }), - Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }), -]); - -type ParentEvent = typeof CompiledSupervisorParentEventSchema.Type; - -const send = (event: ParentEvent): Promise => - new Promise((resolve, reject) => { - if (process.send === undefined || !process.connected) { - reject(new Error("compiled supervisor parent IPC is unavailable")); - return; - } - try { - process.send(Schema.encodeSync(CompiledSupervisorParentEventSchema)(event), (cause) => { - if (cause === null) resolve(); - else reject(cause); - }); - } catch (cause) { - reject(cause); - } - }); - -const fail = (cause: unknown): void => { - const message = cause instanceof Error ? cause.message : String(cause); - void send({ type: "error", message }).finally(() => process.disconnect?.()); -}; - -const runParent = (raw: unknown): void => { - const program = Effect.scoped( - Effect.gen(function* () { - const input = yield* Schema.decodeUnknownEffect(SupervisorStartCommandSchema)(raw); - const remoteLayer = yield* supervisorLayer(input, daemonEntryPoint).pipe( - Effect.provide(httpTransportClientLayer), - ); - const context = yield* Layer.build(remoteLayer); - // Force the generated RemoteStack layer to be materialized before - // reporting readiness; this is the real parent/child detach boundary. - Context.get(context, Stack); - yield* Effect.promise(() => send({ type: "ready", stackId: input.stackId })); - }), - ); - void Effect.runPromise(program) - .then(() => process.disconnect?.()) - .catch(fail); -}; - -const onMessage = (raw: unknown): void => runParent(raw); - -if (import.meta.main) { - if (process.env["SUPABASE_STACK_RUN_DAEMON"] === "1") { - // The compiled child re-enters this same artifact with the stable marker - // installed by forkSupervisor. It receives the supervisor start command on - // the inherited IPC channel and executes the test runtime platform. - runTestSupervisor(); - } else { - process.once("message", onMessage); - } -} - -export type CompiledSupervisorStartMessage = typeof SupervisorStartCommandSchema.Type; diff --git a/packages/stack/tests/helpers/e2e.ts b/packages/stack/tests/helpers/e2e.ts deleted file mode 100644 index 0a635cfd25..0000000000 --- a/packages/stack/tests/helpers/e2e.ts +++ /dev/null @@ -1,84 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch -- The e2e helper polls a native HTTP endpoint with wall-clock deadlines from Vitest's Promise boundary. - -/** - * Poll an Edge Function endpoint until the gateway can actually serve it. - * - * The edge-runtime reports its status as "Healthy" as soon as its control - * plane answers `/_internal/health`, but that signal is independent of any - * individual function: the first request to a given function lazily cold-boots - * a user worker. While that worker boots, the local gateway can briefly return - * a transient 502/503, so first-request assertions must wait for the function - * to become servable instead of assuming readiness from the health status. - * - * Transport errors and 502/503 responses are treated as "not ready yet" and - * retried; any other response (including a 4xx/5xx the function itself emits) - * is returned as-is so real failures still surface. - */ -export async function fetchFunctionWhenReady( - url: string, - init?: RequestInit, - opts: { timeoutMs?: number; intervalMs?: number } = {}, -): Promise { - const timeoutMs = opts.timeoutMs ?? 20_000; - const intervalMs = opts.intervalMs ?? 250; - const deadline = Date.now() + timeoutMs; - let lastResponse: Response | undefined; - let lastError: unknown; - - for (;;) { - try { - const res = await fetch(url, init); - if (res.status !== 502 && res.status !== 503) { - return res; - } - lastResponse = res; - } catch (error) { - lastError = error; - } - - if (Date.now() >= deadline) { - if (lastResponse) return lastResponse; - throw ( - lastError ?? new Error(`Edge Function at ${url} did not become ready in ${timeoutMs}ms`) - ); - } - - await Bun.sleep(intervalMs); - } -} - -/** - * Create the test table (todos) with RLS and seed data. - */ -export async function setupTestTable(dbPort: number): Promise { - const sql = new Bun.SQL(`postgresql://supabase_admin:postgres@127.0.0.1:${dbPort}/postgres`); - try { - await sql.unsafe(` - CREATE TABLE IF NOT EXISTS public.todos ( - id SERIAL PRIMARY KEY, - title TEXT NOT NULL, - completed BOOLEAN NOT NULL DEFAULT false - ); - - ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY; - - DO $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'todos' AND policyname = 'allow_all') THEN - CREATE POLICY allow_all ON public.todos FOR ALL USING (true) WITH CHECK (true); - END IF; - END $$; - - GRANT ALL ON public.todos TO anon, authenticated, service_role; - GRANT USAGE, SELECT ON SEQUENCE public.todos_id_seq TO anon, authenticated, service_role; - - INSERT INTO public.todos (title, completed) VALUES - ('Learn Supabase', true), - ('Build an app', false); - `); - - // PostgREST caches schema metadata, so tell it to reload after creating test tables. - await sql.unsafe(`NOTIFY pgrst, 'reload schema';`); - } finally { - await sql.close(); - } -} diff --git a/packages/stack/tests/helpers/file-watch.ts b/packages/stack/tests/helpers/file-watch.ts deleted file mode 100644 index 24f006c785..0000000000 --- a/packages/stack/tests/helpers/file-watch.ts +++ /dev/null @@ -1,39 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- File-watch tests need the native watcher API to observe filesystem events. - -import { watch, type FSWatcher } from "node:fs"; - -/** - * Watches a directory and re-arms when the runtime reports a transient ENOENT - * while an entry disappears during a scan. Returns a close function. - */ -export const watchDirectoryWithRetry = ( - directory: string, - onEvent: () => void, - onError: (cause: unknown) => void, -): (() => void) => { - let watcher: FSWatcher | undefined; - let closed = false; - const arm = () => { - if (closed) return; - try { - watcher = watch(directory, onEvent); - watcher.once("error", (cause) => { - watcher?.close(); - if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") { - arm(); - onEvent(); - return; - } - onError(cause); - }); - } catch (cause) { - closed = true; - onError(cause); - } - }; - arm(); - return () => { - closed = true; - watcher?.close(); - }; -}; diff --git a/packages/stack/tests/helpers/git-workspace.ts b/packages/stack/tests/helpers/git-workspace.ts deleted file mode 100644 index 619dc20002..0000000000 --- a/packages/stack/tests/helpers/git-workspace.ts +++ /dev/null @@ -1,85 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import -- Git workspace fixtures invoke native child-process, filesystem, and path APIs. - -import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; -import { devNull, tmpdir } from "node:os"; -import { join } from "node:path"; - -/** - * Real git repositories in temporary directories, for the suites that exercise - * the managed layer's git identity and isolation rules. - * - * These are built with the git binary rather than by writing metadata by hand: - * the whole reason identity is stored where git stores it is that git's own - * lifecycle rules — worktree layout, config inheritance, branch renames, what a - * clone copies — do the work, and none of those rules can be observed against a - * stub. - */ - -const GIT_ENV = { - ...process.env, - // Isolated from the developer's own git configuration, which could otherwise - // decide the initial branch name or sign the fixture commits. - HOME: devNull, - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_CONFIG_SYSTEM: "/dev/null", - GIT_AUTHOR_NAME: "Managed Stack Test", - GIT_AUTHOR_EMAIL: "managed@example.test", - GIT_COMMITTER_NAME: "Managed Stack Test", - GIT_COMMITTER_EMAIL: "managed@example.test", -}; - -export const git = (cwd: string, ...args: ReadonlyArray): string => - execFileSync("git", [...args], { cwd, encoding: "utf8", env: GIT_ENV }); - -/** What `git config` reports for `key`, bypassing this package's own store. */ -export const storedConfigValue = (file: string, key: string): string | undefined => { - try { - return git(".", "config", "--file", file, "--get", key).trim(); - } catch { - return undefined; - } -}; - -export const makeDirectory = (root: string, name: string): string => { - const directory = join(root, name); - mkdirSync(directory, { recursive: true }); - return directory; -}; - -/** A primary checkout with one commit on `main`. */ -export const makeRepository = (root: string, name = "repo"): string => { - const repository = makeDirectory(root, name); - git(repository, "init", "-q", "-b", "main"); - git(repository, "commit", "-q", "--allow-empty", "-m", "init"); - return repository; -}; - -/** A bare clone of `source`, which has worktrees but no working tree of its own. */ -export const makeBareRepository = (root: string, source: string, name = "bare.git"): string => { - git(root, "clone", "-q", "--bare", source, name); - return join(root, name); -}; - -/** - * Temporary roots one test file owns, removed together. Canonicalized on - * creation, because every inspection reports canonical paths and the platform - * temporary directory is a symlink on macOS. - */ -export const temporaryRoots = ( - prefix: string, -): { readonly makeRoot: () => string; readonly removeAll: () => void } => { - const roots: Array = []; - return { - makeRoot: () => { - const root = mkdtempSync(join(realpathSync(tmpdir()), prefix)); - roots.push(root); - return root; - }, - removeAll: () => { - for (const root of roots.splice(0)) { - rmSync(root, { force: true, recursive: true }); - } - }, - }; -}; diff --git a/packages/stack/tests/helpers/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts deleted file mode 100644 index 9b1037da64..0000000000 --- a/packages/stack/tests/helpers/managed-manager.ts +++ /dev/null @@ -1,204 +0,0 @@ -// oxlint-disable effecttsgo/global-date, effecttsgo/global-date-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import -- The manager fixture bridges native filesystem/process APIs and intentionally dynamic timing values used by integration scenarios. -import { NodeFileSystem } from "@effect/platform-node"; -import { Effect, Predicate, Stream } from "effect"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import { createServer, type Server } from "node:http"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { - ManagedStackManagerShape, - ManagedStackStartResult, - StartStackRequest, -} from "../../src/managed/manager.ts"; -import { managedStackManagerLayer } from "../../src/managed/manager.ts"; -import type { ManagedPortIntentDocument } from "../../src/managed/model.ts"; -import { acquireControl, isControlOwnership } from "../../src/managed/control.ts"; -import { deriveStackId, ensureEnvironment } from "../../src/managed/environment.ts"; -import { PortAllocationError, reservePortSet } from "../../src/PortAllocator.ts"; -import type { Stack } from "../../src/Stack.ts"; - -export const cleanupRoots = (roots: Array) => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); -}; - -export const makeTemporaryRoot = (roots: Array, prefix: string) => { - const root = mkdtempSync(join(tmpdir(), prefix)); - roots.push(root); - return root; -}; - -export const setupManagedManager = (roots: Array) => { - const root = makeTemporaryRoot(roots, "managed-manager-test-"); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - const stateRoot = join(root, "state"); - const layer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }); - return { layer, stateRoot, workspace }; -}; - -export const controlStack = (): Stack["Service"] => ({ - getInfo: Effect.succeed({ - url: "http://127.0.0.1", - dbUrl: "postgres://127.0.0.1", - publishableKey: "publishable", - secretKey: "secret", - anonJwt: "anon", - serviceRoleJwt: "service", - serviceEndpoints: {}, - }), - start: Effect.void, - stop: Effect.void, - dispose: Effect.void, - startService: () => Effect.void, - stopService: () => Effect.void, - restartService: () => Effect.void, - reloadFunctions: () => Effect.void, - reloadEdgeRuntime: () => Effect.void, - getState: () => Effect.die(new Error("unused")), - getAllStates: Effect.succeed([]), - stateChanges: () => Effect.succeed(Stream.empty), - allStateChanges: Stream.empty, - waitReady: () => Effect.void, - waitAllReady: () => Effect.void, - subscribeLogs: () => Stream.empty, - subscribeAllLogs: () => Stream.empty, - logHistory: () => Effect.succeed([]), - logHistoryAll: () => Effect.succeed([]), -}); - -export const automaticDocument = ( - field: "apiPort" | "studioPort" = "apiPort", -): ManagedPortIntentDocument => ({ - activeFields: field === "apiPort" ? ["apiPort", "dbPort"] : ["apiPort", "dbPort", field], - document: {}, -}); - -export const automaticRuntimeDocument = (): ManagedPortIntentDocument => ({ - activeFields: ["apiPort", "dbPort", "authPort"], - document: {}, -}); - -export const exactDocument = ( - field: "apiPort" | "studioPort", - port: number, -): ManagedPortIntentDocument => ({ - activeFields: field === "apiPort" ? ["apiPort", "dbPort"] : ["apiPort", "dbPort", field], - document: field === "apiPort" ? { api: { port } } : { studio: { port } }, -}); - -export const exactCoreDocument = (apiPort: number, dbPort: number): ManagedPortIntentDocument => ({ - activeFields: ["apiPort", "dbPort"], - document: { api: { port: apiPort }, db: { port: dbPort } }, -}); - -const FREE_PORT_FIELDS = [ - "authPort", - "postgrestPort", - "postgrestAdminPort", - "realtimePort", -] as const; - -export const freePorts = ( - count: number, -): Effect.Effect, PortAllocationError, import("effect/Scope").Scope> => - Effect.gen(function* () { - const lease = yield* reservePortSet( - FREE_PORT_FIELDS.slice(0, count).map((field) => ({ - field, - selection: { kind: "automatic" as const }, - })), - ).pipe(Effect.provide(NodeFileSystem.layer)); - const ports = FREE_PORT_FIELDS.slice(0, count).flatMap((field) => { - const port = lease.ports[field]; - return port === undefined ? [] : [port]; - }); - yield* lease.releaseAll; - if (ports.length !== count) return yield* Effect.die(new Error("missing free ports")); - return ports; - }); - -export const freePort: Effect.Effect = - Effect.gen(function* () { - const [port] = yield* freePorts(1); - if (port === undefined) return yield* Effect.die(new Error("missing free port")); - return port; - }); - -export const listenExternal = (port: number): Promise => - new Promise((resolve, reject) => { - const server = createServer((_request, response) => response.end("external")); - server.once("error", reject); - server.listen(port, "127.0.0.1", () => resolve(server)); - }); - -export const closeExternal = (server: Server): Promise => - new Promise((resolve, reject) => { - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); - -/** - * Control endpoints project two identity-hash bytes into `CONTROL_PORT_RANGE`, - * so parallel test files can land on a port already owned by another live - * stack's control server or by a non-control listener. Acquires control for a - * fresh directory under `base`, retrying `ControlAddressConflictError` and - * `ControlTransportError` with a new path-seeded identity until the deadline, - * then rethrowing the last failure. - */ -export const acquireWorkspaceControl = (base: string, prefix = "workspace") => - Effect.gen(function* () { - const deadline = Date.now() + 10_000; - for (;;) { - const workspace = mkdtempSync(join(base, `${prefix}-`)); - const environment = yield* ensureEnvironment(workspace); - const stackId = deriveStackId(environment.identity, "default"); - const acquired = yield* acquireControl({ stackId, maintenanceOperation: "update" }).pipe( - Effect.map((ownership) => ({ ownership })), - Effect.catch((error) => - (Predicate.isTagged(error, "ControlAddressConflictError") || - Predicate.isTagged(error, "ControlTransportError")) && - Date.now() < deadline - ? Effect.void.pipe(Effect.as(undefined)) - : Effect.fail(error), - ), - ); - if (acquired !== undefined) { - return { workspace, environment, stackId, ownership: acquired.ownership }; - } - } - }); - -export const startWithOwner = ( - manager: ManagedStackManagerShape, - workspacePath: string, - portDocument: ManagedPortIntentDocument, - lifecycle: "stopped" | "running" = "stopped", - stackName = "default", -) => - Effect.gen(function* () { - const environment = yield* ensureEnvironment(workspacePath); - const stackId = deriveStackId(environment.identity, stackName); - const ownership = yield* acquireControl({ stackId, maintenanceOperation: "update" }); - if (!isControlOwnership(ownership)) throw new Error("expected stack control ownership"); - return yield* manager.startStack({ - workspacePath, - stackName, - portDocument, - ownership, - lifecycle, - launch: { mode: "native", versions: {} }, - }); - }); - -export const startManagedStack = ( - manager: ManagedStackManagerShape, - request: Omit & { - readonly launch?: StartStackRequest["launch"]; - }, -) => - manager.startStack({ - ...request, - launch: request.launch ?? { mode: "native", versions: {} }, - }); - -export const releaseLease = (result: ManagedStackStartResult): Effect.Effect => - result.lease.releaseAll; diff --git a/packages/stack/tests/helpers/mocks.ts b/packages/stack/tests/helpers/mocks.ts deleted file mode 100644 index efce61a07f..0000000000 --- a/packages/stack/tests/helpers/mocks.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Effect, Layer } from "effect"; -import { - BinaryResolver, - type BinarySpec, - type ResolveBinaryOptions, -} from "../../src/BinaryResolver.ts"; -import { BinaryNotFoundError } from "../../src/errors.ts"; -import { DEFAULT_VERSIONS } from "../../src/versions.ts"; - -export function mockBinaryResolver( - opts: { - binaries?: Record; - downloadedServices?: string[]; - downloadDelayMs?: number; - downloadDelaysMs?: Partial>; - failServices?: string[]; - failOnceServices?: string[]; - beforeResolve?: (spec: BinarySpec) => Effect.Effect; - } = {}, -) { - const resolved: Array<{ service: string; version: string }> = []; - const binaries = opts.binaries ?? { - postgres: `/cache/postgres/${DEFAULT_VERSIONS.postgres}/darwin-arm64`, - postgrest: `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos-aarch64`, - auth: `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`, - "edge-runtime": `/cache/edge-runtime/${DEFAULT_VERSIONS["edge-runtime"]}/aarch64-darwin`, - }; - const failOnceServices = new Set(opts.failOnceServices ?? []); - const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => - Effect.gen(function* () { - if (opts.failServices?.includes(spec.service) || failOnceServices.delete(spec.service)) { - return yield* new BinaryNotFoundError({ - service: spec.service, - platform: "darwin-arm64", - }); - } - resolved.push({ service: spec.service, version: spec.version }); - const path = binaries[spec.service]; - if (!path) { - return yield* new BinaryNotFoundError({ - service: spec.service, - platform: "darwin-arm64", - }); - } - const downloaded = opts.downloadedServices?.includes(spec.service) ?? false; - if (downloaded) { - yield* options?.onDownloadStart ?? Effect.void; - yield* opts.beforeResolve?.(spec) ?? Effect.void; - const delayMs = opts.downloadDelaysMs?.[spec.service] ?? opts.downloadDelayMs ?? 0; - if (delayMs > 0) { - yield* Effect.sleep(`${delayMs} millis`); - } - } - return { path, downloaded }; - }); - - return { - layer: Layer.succeed(BinaryResolver, { - plan: (spec) => { - const path = binaries[spec.service]; - return path - ? Effect.succeed(path) - : Effect.fail( - new BinaryNotFoundError({ service: spec.service, platform: "darwin-arm64" }), - ); - }, - resolveWithMetadata, - resolve: (spec) => Effect.map(resolveWithMetadata(spec), ({ path }) => path), - }), - resolved, - }; -} diff --git a/packages/stack/tests/helpers/port-lease-child.ts b/packages/stack/tests/helpers/port-lease-child.ts deleted file mode 100644 index b5b220a1c0..0000000000 --- a/packages/stack/tests/helpers/port-lease-child.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node"; -import { Effect } from "effect"; -import { reservePortSet } from "../../src/PortAllocator.ts"; - -const lease = await Effect.runPromise( - reservePortSet([ - { field: "apiPort", selection: { kind: "automatic" } }, - { field: "dbPort", selection: { kind: "automatic" } }, - ]).pipe(Effect.provide(NodeFileSystem.layer)), -); - -process.stdout.write(`${JSON.stringify(lease.ports)}\n`); - -for await (const _chunk of process.stdin) { - break; -} - -await Effect.runPromise(lease.releaseAll); diff --git a/packages/stack/tests/helpers/stack-ports.ts b/packages/stack/tests/helpers/stack-ports.ts deleted file mode 100644 index 4407815713..0000000000 --- a/packages/stack/tests/helpers/stack-ports.ts +++ /dev/null @@ -1,69 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Port helper functions await native listener readiness and release operations. - -import { NodeFileSystem } from "@effect/platform-node"; -import { Effect } from "effect"; -import { createStack, type StackHandle } from "../../src/node.ts"; -import { reservePortSet } from "../../src/PortAllocator.ts"; -import type { StackConfig } from "../../src/StackConfig.ts"; - -interface EphemeralStackPorts { - readonly apiPort: number; - readonly dbPort: number; -} - -const isAddressInUse = (error: unknown, depth = 0): boolean => { - if (depth > 4 || !(error instanceof Error)) return false; - if ("code" in error && Reflect.get(error, "code") === "EADDRINUSE") return true; - const cause: unknown = error.cause; - if (typeof cause === "object" && cause !== null && "code" in cause) { - if (Reflect.get(cause, "code") === "EADDRINUSE") return true; - } - return isAddressInUse(cause, depth + 1); -}; - -const reserveEphemeralStackPorts = async (): Promise => - Effect.runPromise( - Effect.scoped( - Effect.acquireRelease( - reservePortSet([ - { field: "apiPort", selection: { kind: "automatic" } }, - { field: "dbPort", selection: { kind: "automatic" } }, - ]), - (lease) => lease.releaseAll, - ).pipe( - Effect.map((lease) => { - const apiPort = lease.ports.apiPort; - const dbPort = lease.ports.dbPort; - if (apiPort === undefined || dbPort === undefined) { - throw new Error("Ephemeral port reservation returned an incomplete pair"); - } - return { apiPort, dbPort }; - }), - ), - ).pipe(Effect.provide(NodeFileSystem.layer)), - ); - -/** - * Create a stack with an OS-selected API/Postgres pair. - * - * Port leases are released immediately before createStack binds its listeners. A - * short retry closes that unavoidable handoff race without serializing e2e files. - */ -export async function createStackWithEphemeralPorts( - config: StackConfig = {}, -): Promise { - for (let attempt = 0; attempt < 3; attempt += 1) { - const ports = await reserveEphemeralStackPorts(); - try { - const stack = await createStack({ - ...config, - port: ports.apiPort, - postgres: { ...config.postgres, port: ports.dbPort }, - }); - return stack; - } catch (error) { - if (!isAddressInUse(error) || attempt === 2) throw error; - } - } - throw new Error("Ephemeral stack port handoff exhausted retries"); -} diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts deleted file mode 100644 index fa8008534e..0000000000 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ /dev/null @@ -1,371 +0,0 @@ -// oxlint-disable effecttsgo/node-builtin-import, effecttsgo/process-env, effecttsgo/process-env-in-effect -- The child fixture is a native subprocess boundary that composes platform layers and forwards its environment to the supervisor. -import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; -import { Deferred, Effect, Layer, Stream, Duration } from "effect"; -import { createServer, type Server } from "node:net"; -import { existsSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; -import { - runSupervisor, - SupervisorStartError, - type SupervisorPlatform, -} from "../../src/supervisor.ts"; -import { LocalStackLifecycle } from "../../src/LocalStack.ts"; -import { Stack } from "../../src/Stack.ts"; -import { validateResolvedConfig } from "../../src/StackBuilder.ts"; -import { StackReadinessError } from "../../src/errors.ts"; -import { ControlTransport } from "../../src/managed/control.ts"; -import { gitConfigStoreLayer } from "../../src/managed/git.ts"; -import { ManagedStackManager, managedStackManagerLayer } from "../../src/managed/manager.ts"; -import { - controlTransportLayer as nodeControlTransportLayer, - platformFactory as nodePlatformFactory, -} from "../../src/platform-node.ts"; -import { PORT_FIELDS } from "../../src/PortCatalog.ts"; -import type { PortLease } from "../../src/PortAllocator.ts"; -import type { ResolvedDaemonConfig } from "../../src/StackConfig.ts"; -import { watchDirectoryWithRetry } from "./file-watch.ts"; - -type TestMode = - | "bind-all" - | "fail-after-bind" - | "hold-reservations" - | "hold-start" - | "hold-stop" - | "readiness-failure"; -const FILE_WAIT_TIMEOUT = "30 seconds"; - -const waitForFile = (path: string): Effect.Effect => - Effect.callback((resume) => { - if (existsSync(path)) { - resume(Effect.void); - return Effect.void; - } - let settled = false; - let stopWatching: (() => void) | undefined; - const cleanup = () => { - stopWatching?.(); - stopWatching = undefined; - }; - const settle = (result: Effect.Effect) => { - if (settled) return; - settled = true; - cleanup(); - resume(result); - }; - const check = () => { - if (existsSync(path)) settle(Effect.void); - }; - stopWatching = watchDirectoryWithRetry(dirname(path), check, (cause) => - settle(Effect.die(cause)), - ); - check(); - return Effect.sync(cleanup); - }).pipe( - Effect.timeout(FILE_WAIT_TIMEOUT), - Effect.catchTag("TimeoutError", () => - Effect.die(new Error(`timed out waiting for file ${path} after ${FILE_WAIT_TIMEOUT}`)), - ), - ); - -const testMode = (): TestMode => { - const value = process.env["SUPABASE_STACK_TEST_RUNTIME_MODE"]; - if (value === "fail-after-bind") return value; - if (value === "hold-reservations") return value; - if (value === "hold-start") return value; - if (value === "hold-stop") return value; - if (value === "readiness-failure") return value; - return "bind-all"; -}; - -const bindTestPort = (port: number): Effect.Effect => - Effect.callback((resume) => { - const server = createServer((socket) => socket.destroy()); - const onError = (cause: Error) => resume(Effect.die(cause)); - server.once("error", onError); - server.listen(port, "127.0.0.1", () => { - server.off("error", onError); - resume(Effect.succeed(server)); - }); - return Effect.sync(() => server.close()); - }); - -const closeTestPorts = (servers: ReadonlyArray): Effect.Effect => - Effect.forEach( - servers, - (server) => - Effect.callback((resume) => { - if (!server.listening) { - resume(Effect.void); - return Effect.void; - } - server.close(() => resume(Effect.void)); - return Effect.void; - }), - { discard: true }, - ); - -const testStackLayer = ( - config: ResolvedDaemonConfig, - mode: TestMode, - disposed: Deferred.Deferred, -): Layer.Layer => { - const info = { - url: `http://127.0.0.1:${config.apiPort}`, - dbUrl: `postgresql://postgres:postgres@127.0.0.1:${config.dbPort}/postgres`, - publishableKey: config.publishableKey, - secretKey: config.secretKey, - anonJwt: config.anonJwt, - serviceRoleJwt: config.serviceRoleJwt, - serviceEndpoints: {}, - }; - const waitForStopRelease = (): Effect.Effect => { - const path = process.env["SUPABASE_STACK_TEST_STOP_RELEASE_FILE"]; - if (path === undefined) return Effect.never; - return waitForFile(path); - }; - return Layer.succeed(Stack, { - getInfo: Effect.succeed(info), - start: Effect.void, - stop: - mode === "hold-stop" - ? Effect.gen(function* () { - const stageFile = process.env["SUPABASE_STACK_TEST_STOP_BEGAN_FILE"]; - if (stageFile !== undefined) { - yield* Effect.sync(() => writeFileSync(stageFile, "began")); - } - yield* waitForStopRelease(); - }) - : Effect.void, - dispose: Effect.void, - startService: () => Effect.void, - stopService: () => Effect.void, - restartService: () => Effect.void, - reloadFunctions: () => Effect.void, - reloadEdgeRuntime: () => Effect.void, - getState: () => Effect.die("test stack has no external service state"), - getAllStates: Effect.succeed([]), - stateChanges: () => Effect.succeed(Stream.empty), - allStateChanges: Stream.empty, - waitReady: () => Effect.void, - waitAllReady: () => - mode === "readiness-failure" - ? Deferred.succeed(disposed, undefined).pipe( - Effect.andThen( - Effect.fail( - new StackReadinessError({ - target: "stack", - timeoutMs: 75, - detail: "Timed out waiting for stack readiness after 75ms", - }), - ), - ), - ) - : Effect.void, - subscribeLogs: () => Stream.empty, - subscribeAllLogs: () => Stream.empty, - logHistory: () => Effect.succeed([]), - logHistoryAll: () => Effect.succeed([]), - }); -}; - -const testRuntime = ({ - config, - lease, -}: { - readonly config: ResolvedDaemonConfig; - readonly lease: PortLease; -}): Effect.Effect< - Layer.Layer, - SupervisorStartError, - import("effect").Scope.Scope -> => { - const mode = testMode(); - return Effect.gen(function* () { - const disposed = Deferred.makeUnsafe(); - yield* validateResolvedConfig(config); - if (mode === "hold-start") { - const releaseFile = process.env["SUPABASE_STACK_TEST_START_RELEASE_FILE"]; - yield* releaseFile === undefined ? Effect.never : waitForFile(releaseFile); - } - const servers: Array = []; - if (mode !== "hold-reservations") { - for (const field of PORT_FIELDS) { - const port = config.ports[field]; - if (port === undefined) continue; - yield* lease.release([field]); - servers.push(yield* bindTestPort(port)); - } - } - yield* Effect.addFinalizer(() => closeTestPorts(servers)); - if (mode === "fail-after-bind") { - // Returning the failed yield exits this fixture before the success layer is constructed. - return yield* new SupervisorStartError({ - message: "Supervisor test runtime failed after binding", - }); - } - return Layer.mergeAll( - testStackLayer(config, mode, disposed), - Layer.succeed(LocalStackLifecycle, { - awaitDisposed: Deferred.await(disposed), - isDisposed: Effect.succeed(mode === "readiness-failure"), - }), - ); - }).pipe( - Effect.catchTag("StackBuildError", (cause) => - Effect.fail(new SupervisorStartError({ message: cause.detail })), - ), - ); -}; - -const observeAttachedBeforeReady = (value: unknown): Effect.Effect => { - if ( - typeof value !== "object" || - value === null || - !("ready" in value) || - value.ready !== false || - !("state" in value) || - (value.state !== "starting" && value.state !== "stopping") - ) { - return Effect.void; - } - const readyFile = process.env["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"]; - const releaseFile = process.env["SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE"]; - if (readyFile === undefined || existsSync(readyFile)) return Effect.void; - return Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( - Effect.andThen(releaseFile === undefined ? Effect.void : waitForFile(releaseFile)), - ); -}; - -const resolutionTimeout = (): Duration.Input => { - const milliseconds = Number(process.env["SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS"]); - return Number.isFinite(milliseconds) && milliseconds > 0 - ? `${milliseconds} millis` - : "30 seconds"; -}; - -const testPlatform = (): "node" | "bun" => - process.env["SUPABASE_STACK_TEST_PLATFORM"] === "bun" ? "bun" : "node"; - -const decorateManagerLayer = (base: Layer.Layer) => { - const readyFile = process.env["SUPABASE_STACK_TEST_ENSURE_READY_FILE"]; - const releaseFile = process.env["SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE"]; - return Layer.effect( - ManagedStackManager, - ManagedStackManager.pipe( - Effect.map((manager) => ({ - ...manager, - startStack: (input: Parameters[0]) => - manager.startStack(input).pipe( - Effect.tap(() => { - const markerFile = process.env["SUPABASE_STACK_TEST_MANAGED_STARTED_FILE"]; - const releaseFile = process.env["SUPABASE_STACK_TEST_MANAGED_STARTED_RELEASE_FILE"]; - return Effect.sync(() => { - if (markerFile !== undefined) writeFileSync(markerFile, "started"); - }).pipe( - Effect.andThen(releaseFile === undefined ? Effect.void : waitForFile(releaseFile)), - Effect.orDie, - ); - }), - ), - ...(readyFile === undefined || releaseFile === undefined - ? {} - : { - ensureWorkspace: (workspacePath: string) => - Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( - Effect.andThen(waitForFile(releaseFile)), - Effect.andThen(manager.ensureWorkspace(workspacePath)), - ), - }), - })), - ), - ).pipe(Layer.provide(base)); -}; - -const nodeManagerLayer = (stateRoot: string) => - decorateManagerLayer( - managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe( - Layer.provide( - Layer.mergeAll( - NodeFileSystem.layer, - NodePath.layer, - gitConfigStoreLayer, - nodeControlTransportLayer, - ), - ), - ), - ); - -const testControlTransportLayer = (base: Layer.Layer) => - Layer.effect( - ControlTransport, - Effect.gen(function* () { - const transport = yield* ControlTransport; - return { - ...transport, - read: (endpoint: Parameters[0]) => - transport.read(endpoint).pipe(Effect.tap(observeAttachedBeforeReady)), - }; - }), - ).pipe(Layer.provide(base)); - -export const runTestSupervisor = (): void => { - const platformKind = testPlatform(); - if (platformKind === "node") { - const supervisorPlatform: SupervisorPlatform = { - platformFactory: nodePlatformFactory, - managerLayer: nodeManagerLayer, - runtimeLayer: testRuntime, - resolutionTimeout: resolutionTimeout(), - }; - // runSupervisor's runtime layers are provided in dependency order: the decorated manager - // and transport layers must be built before the platform services are attached. - const program = runSupervisor(supervisorPlatform).pipe( - // oxlint-disable-next-line effecttsgo/multiple-effect-provide -- Child supervisor composes manager, transport, and platform layers in dependency order. - Effect.provide(gitConfigStoreLayer), - Effect.provide(testControlTransportLayer(nodeControlTransportLayer)), - Effect.provide(NodeServices.layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - ); - // The child process is intentionally launched at this native Promise boundary. - void Effect.runPromise(program); - return; - } - void Promise.all([ - import("@effect/platform-bun/BunFileSystem"), - import("@effect/platform-bun/BunServices"), - import("../../src/platform-bun.ts"), - ]).then(([bunFileSystem, bunServices, bunPlatform]) => { - const managerLayer = (stateRoot: string) => - decorateManagerLayer( - managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe( - Layer.provide( - Layer.mergeAll( - bunFileSystem.layer, - gitConfigStoreLayer, - bunPlatform.controlTransportLayer, - ), - ), - ), - ); - const supervisorPlatform: SupervisorPlatform = { - platformFactory: bunPlatform.platformFactory, - managerLayer, - runtimeLayer: testRuntime, - resolutionTimeout: resolutionTimeout(), - }; - // runSupervisor's runtime layers are provided in dependency order: the decorated manager - // and transport layers must be built before the platform services are attached. - const program = runSupervisor(supervisorPlatform).pipe( - // oxlint-disable-next-line effecttsgo/multiple-effect-provide -- Child supervisor composes manager, transport, and platform layers in dependency order. - Effect.provide(gitConfigStoreLayer), - Effect.provide(testControlTransportLayer(bunPlatform.controlTransportLayer)), - Effect.provide(bunServices.layer), - Effect.provide(bunFileSystem.layer), - ); - // The child process is intentionally launched at this native Promise boundary. - return Effect.runPromise(program); - }); -}; - -if (import.meta.main) runTestSupervisor(); diff --git a/packages/stack/tests/helpers/supervisor-error-child.ts b/packages/stack/tests/helpers/supervisor-error-child.ts deleted file mode 100644 index 7e7083f019..0000000000 --- a/packages/stack/tests/helpers/supervisor-error-child.ts +++ /dev/null @@ -1,6 +0,0 @@ -process.once("message", () => { - process.send?.({ - type: "error", - message: "Supervisor test runtime failed after binding", - }); -}); diff --git a/packages/stack/tests/helpers/supervisor-non-ready-child.ts b/packages/stack/tests/helpers/supervisor-non-ready-child.ts deleted file mode 100644 index 35f07b5573..0000000000 --- a/packages/stack/tests/helpers/supervisor-non-ready-child.ts +++ /dev/null @@ -1,33 +0,0 @@ -process.once("message", (value: unknown) => { - if ( - typeof value !== "object" || - value === null || - !("stackId" in value) || - typeof value.stackId !== "string" || - !("cliVersion" in value) || - typeof value.cliVersion !== "string" - ) { - return; - } - process.send?.( - { - type: "started", - endpoint: { - hostname: "127.0.0.1", - port: 1, - url: "http://127.0.0.1:1", - }, - owner: { - kind: "supervisor", - ownershipId: value.stackId, - ownerSessionId: "stopping-test-session", - controlProtocolVersion: 1, - daemonCliVersion: value.cliVersion, - state: "stopping", - ready: false, - }, - attached: true, - }, - () => process.disconnect?.(), - ); -}); diff --git a/packages/stack/tests/helpers/warmup.ts b/packages/stack/tests/helpers/warmup.ts deleted file mode 100644 index e99e0eec91..0000000000 --- a/packages/stack/tests/helpers/warmup.ts +++ /dev/null @@ -1,49 +0,0 @@ -// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- E2e warmup invokes the native package manager/process boundary. - -import { execSync } from "node:child_process"; -import { prefetch, type PrefetchOptions, type PrefetchResult } from "../../src/node.ts"; - -interface WarmupLogger { - warn(message: string): void; -} - -interface WarmStackE2eDependenciesOptions { - readonly failOnError?: boolean; - readonly hasDockerDaemon?: () => boolean; - readonly logger?: WarmupLogger; - readonly prefetch?: (options?: PrefetchOptions) => Promise; -} - -export function hasDockerDaemon(): boolean { - try { - execSync("docker info", { stdio: "ignore", timeout: 2_000 }); - return true; - } catch { - return false; - } -} - -export async function warmStackE2eDependencies( - options: WarmStackE2eDependenciesOptions = {}, -): Promise { - const logger = options.logger ?? console; - const prefetchDeps = options.prefetch ?? prefetch; - const shouldFailOnError = options.failOnError ?? false; - const dockerAvailable = (options.hasDockerDaemon ?? hasDockerDaemon)(); - - const modes: PrefetchOptions[] = [{ mode: "native" }]; - if (dockerAvailable) modes.push({ mode: "docker" }); - - for (const mode of modes) { - try { - await prefetchDeps(mode); - } catch (error) { - logger.warn( - `[stack-e2e] Warmup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - if (shouldFailOnError) { - throw error; - } - } - } -} diff --git a/packages/stack/tests/helpers/warmup.unit.test.ts b/packages/stack/tests/helpers/warmup.unit.test.ts deleted file mode 100644 index e76d21715c..0000000000 --- a/packages/stack/tests/helpers/warmup.unit.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// oxlint-disable effecttsgo/async-function -- Warmup tests await native dependency probes from Vitest callbacks. - -import { describe, expect, test } from "vitest"; -import type { PrefetchOptions, PrefetchResult } from "../../src/node.ts"; -import { warmStackE2eDependencies } from "./warmup.ts"; - -function makeLogger() { - const warn: string[] = []; - return { - warn, - logger: { - warn: (message: string) => { - warn.push(message); - }, - }, - }; -} - -function makeResult(type: "binary" | "docker"): PrefetchResult { - return { - postgres: - type === "docker" ? { type, image: "postgres:image" } : { type, path: "/tmp/postgres" }, - postgrest: - type === "docker" ? { type, image: "postgrest:image" } : { type, path: "/tmp/postgrest" }, - auth: type === "docker" ? { type, image: "auth:image" } : { type, path: "/tmp/auth" }, - "edge-runtime": - type === "docker" - ? { type, image: "edge-runtime:image" } - : { type, path: "/tmp/edge-runtime" }, - }; -} - -describe("stack e2e warmup", () => { - test("warms native and Docker resources when Docker is available", async () => { - const calls: Array = []; - const { logger } = makeLogger(); - - await warmStackE2eDependencies({ - logger, - hasDockerDaemon: () => true, - prefetch: async (options?: PrefetchOptions) => { - calls.push(options); - return options?.mode === "docker" ? makeResult("docker") : makeResult("binary"); - }, - }); - - expect(calls).toEqual([{ mode: "native" }, { mode: "docker" }]); - }); - - test("skips docker image warmup when Docker is unavailable", async () => { - const calls: Array = []; - const { logger } = makeLogger(); - - await warmStackE2eDependencies({ - logger, - hasDockerDaemon: () => false, - prefetch: async (options?: PrefetchOptions) => { - calls.push(options); - return makeResult("binary"); - }, - }); - - expect(calls).toEqual([{ mode: "native" }]); - }); - - test("can fail fast when warmup is required", async () => { - const { warn, logger } = makeLogger(); - - await expect( - warmStackE2eDependencies({ - failOnError: true, - hasDockerDaemon: () => false, - logger, - prefetch: async () => { - throw new Error("pull failed"); - }, - }), - ).rejects.toThrow("pull failed"); - expect(warn.some((message) => message.includes("Warmup failed"))).toBe(true); - }); - - test("continues to the Docker warmup after a best-effort native failure", async () => { - const calls: Array = []; - const { warn, logger } = makeLogger(); - - await warmStackE2eDependencies({ - hasDockerDaemon: () => true, - logger, - prefetch: async (options?: PrefetchOptions) => { - calls.push(options); - if (options?.mode === "native") throw new Error("native unavailable"); - return makeResult("docker"); - }, - }); - - expect(calls).toEqual([{ mode: "native" }, { mode: "docker" }]); - expect(warn.some((message) => message.includes("native unavailable"))).toBe(true); - }); - - test("only warns when warmup is best effort", async () => { - const { warn, logger } = makeLogger(); - - await expect( - warmStackE2eDependencies({ - failOnError: false, - hasDockerDaemon: () => false, - logger, - prefetch: async () => { - throw new Error("pull failed"); - }, - }), - ).resolves.toBeUndefined(); - expect(warn.some((message) => message.includes("Warmup failed"))).toBe(true); - }); -}); diff --git a/packages/stack/tests/warmup-e2e.ts b/packages/stack/tests/warmup-e2e.ts deleted file mode 100644 index aa6527c3a2..0000000000 --- a/packages/stack/tests/warmup-e2e.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { warmStackE2eDependencies } from "./helpers/warmup.ts"; - -await warmStackE2eDependencies({ failOnError: true }); diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index 3464a66cd4..4b0e83f32d 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -3,14 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { passWithNoTests: true, - coverage: { - enabled: false, - provider: "istanbul", - clean: false, - include: ["src/**/*.ts"], - reporter: ["text", "lcov"], - reportsDirectory: "coverage", - }, projects: [ { test: { @@ -22,15 +14,6 @@ export default defineConfig({ test: { name: "integration", include: ["**/*.integration.test.ts"], - testTimeout: 60_000, - }, - }, - { - test: { - name: "e2e", - include: ["**/*.e2e.test.ts"], - fileParallelism: false, - globalSetup: ["./tests/global-setup.ts"], }, }, ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef66766800..8e21e81569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,9 +180,6 @@ importers: '@supabase/pg-topo': specifier: 1.0.0-alpha.5 version: 1.0.0-alpha.5 - '@supabase/process-compose': - specifier: workspace:* - version: link:../../packages/process-compose '@supabase/stack': specifier: workspace:* version: link:../../packages/stack @@ -444,45 +441,8 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) - packages/process-compose: - dependencies: - effect: - specifier: 'catalog:' - version: 4.0.0-rc.111 - devDependencies: - '@effect/platform-bun': - specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111) - '@effect/vitest': - specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) - '@tsconfig/bun': - specifier: 'catalog:' - version: 1.0.11 - '@types/bun': - specifier: 'catalog:' - version: 1.4.0 - '@vitest/coverage-istanbul': - specifier: 'catalog:' - version: 4.1.10(vitest@4.1.10) - typescript: - specifier: 'catalog:' - version: 7.0.2 - vitest: - specifier: 'catalog:' - version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) - packages/stack: dependencies: - '@effect/platform-bun': - specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111) - '@effect/platform-node': - specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) - '@supabase/process-compose': - specifier: workspace:* - version: link:../process-compose effect: specifier: 'catalog:' version: 4.0.0-rc.111 @@ -490,18 +450,12 @@ importers: '@effect/vitest': specifier: 'catalog:' version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) - '@supabase/supabase-js': - specifier: ^2.112.3 - version: 2.112.3 '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 - '@vitest/coverage-istanbul': - specifier: 'catalog:' - version: 4.1.10(vitest@4.1.10) typescript: specifier: 'catalog:' version: 7.0.2 @@ -2657,14 +2611,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.112.3': - resolution: {integrity: sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==} - engines: {node: '>=22.0.0'} - - '@supabase/functions-js@2.112.3': - resolution: {integrity: sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==} - engines: {node: '>=22.0.0'} - '@supabase/pg-delta@1.0.0-alpha.46': resolution: {integrity: sha512-PaziTZjZk+zMw+wL2iBR0kJB1rOMGPCanYYYjb2pxwINCAr+XMNjnbWxTDMVZjNhPdOYLe34ocpO2lHLv+LK1A==} engines: {node: '>=20.0.0'} @@ -2678,30 +2624,6 @@ packages: '@supabase/pg-topo@1.0.0-alpha.5': resolution: {integrity: sha512-a34YbUsQhBvS3Of5Gh/M4nXyGebwlID2lI7Od/YQPSA4jzGCR1D8EO/bV+kfAbTb84I/go8Pl1Y91XqFjRaqHg==} - '@supabase/phoenix@0.4.5': - resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - - '@supabase/postgrest-js@2.112.3': - resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==} - engines: {node: '>=22.0.0'} - - '@supabase/realtime-js@2.112.3': - resolution: {integrity: sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==} - engines: {node: '>=22.0.0'} - - '@supabase/storage-js@2.112.3': - resolution: {integrity: sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==} - engines: {node: '>=22.0.0'} - - '@supabase/supabase-js@2.112.3': - resolution: {integrity: sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==} - engines: {node: '>=22.0.0'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0' - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -4355,10 +4277,6 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iceberg-js@0.8.1: - resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} - engines: {node: '>=20.0.0'} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -8483,14 +8401,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.112.3': - dependencies: - tslib: 2.8.1 - - '@supabase/functions-js@2.112.3': - dependencies: - tslib: 2.8.1 - '@supabase/pg-delta@1.0.0-alpha.46(@supabase/pg-topo@1.0.0-alpha.5)': dependencies: debug: 4.4.3 @@ -8509,30 +8419,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@supabase/phoenix@0.4.5': {} - - '@supabase/postgrest-js@2.112.3': - dependencies: - tslib: 2.8.1 - - '@supabase/realtime-js@2.112.3': - dependencies: - '@supabase/phoenix': 0.4.5 - tslib: 2.8.1 - - '@supabase/storage-js@2.112.3': - dependencies: - iceberg-js: 0.8.1 - tslib: 2.8.1 - - '@supabase/supabase-js@2.112.3': - dependencies: - '@supabase/auth-js': 2.112.3 - '@supabase/functions-js': 2.112.3 - '@supabase/postgrest-js': 2.112.3 - '@supabase/realtime-js': 2.112.3 - '@supabase/storage-js': 2.112.3 - '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -10330,8 +10216,6 @@ snapshots: human-signals@8.0.1: {} - iceberg-js@0.8.1: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 diff --git a/turbo.json b/turbo.json index 00d950368a..e2d5608ba9 100644 --- a/turbo.json +++ b/turbo.json @@ -61,7 +61,6 @@ "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock", "$TURBO_ROOT$/packages/api/**", - "$TURBO_ROOT$/packages/process-compose/**", "$TURBO_ROOT$/packages/stack/**" ], "outputs": ["dist/**"] From c97c47cd9982bafdad01e95b493f31e2d4bfb18e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 19:03:40 +0200 Subject: [PATCH 002/174] feat(stack): define the greenfield public model --- ...26-08-28-stack-runtime-rewrite-progress.md | 10 + packages/stack/src/public/Capability.ts | 42 ++++ packages/stack/src/public/Credentials.ts | 67 +++++++ packages/stack/src/public/Errors.ts | 181 ++++++++++++++++++ packages/stack/src/public/Logs.ts | 25 +++ packages/stack/src/public/Runtime.ts | 19 ++ packages/stack/src/public/StackId.ts | 13 ++ packages/stack/src/public/Status.ts | 135 +++++++++++++ packages/stack/src/public/index.ts | 7 + .../public/public-model.integration.test.ts | 72 +++++++ 10 files changed, 571 insertions(+) create mode 100644 packages/stack/src/public/Capability.ts create mode 100644 packages/stack/src/public/Credentials.ts create mode 100644 packages/stack/src/public/Errors.ts create mode 100644 packages/stack/src/public/Logs.ts create mode 100644 packages/stack/src/public/Runtime.ts create mode 100644 packages/stack/src/public/StackId.ts create mode 100644 packages/stack/src/public/Status.ts create mode 100644 packages/stack/src/public/public-model.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 36ef17c22c..4548eb6385 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -109,3 +109,13 @@ Implementation continues after recording a ruling; this file is not a question q - The CLI type-check was not run; the accepted staging ruling allows the old CLI stack call sites to fail until the direct migration task. The telemetry coverage test was attempted and could not load the removed `@supabase/stack/managed-model` export; its adapters are part of that migration. + +### Task 1 — 2026-08-28 + +- Added the Effect-native public model for stack identity, runtimes, capabilities, complete status + snapshots, logs, credentials, and operation-specific tagged failures. +- `StackStatusSchema` validates that a snapshot contains exactly one status for each of the ten + public capabilities, including disabled capabilities. +- `pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts` + RED: failed before implementation with `Cannot find module './Status.ts'`; GREEN: 3 tests passed. +- `pnpm --dir packages/stack types:check` — passed. diff --git a/packages/stack/src/public/Capability.ts b/packages/stack/src/public/Capability.ts new file mode 100644 index 0000000000..e8bc9e5ffa --- /dev/null +++ b/packages/stack/src/public/Capability.ts @@ -0,0 +1,42 @@ +import { Schema } from "effect"; + +export const CAPABILITY_NAMES = [ + "database", + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", +] as const; + +export const CapabilityNames = CAPABILITY_NAMES; + +export const CapabilityNameSchema = Schema.Literals(CAPABILITY_NAMES); +export type CapabilityName = Schema.Schema.Type; + +export const CapabilityStateSchema = Schema.Literals([ + "disabled", + "dormant", + "preparing", + "starting", + "ready", + "stopping", + "stopped", + "failed", +] as const); +export type CapabilityState = Schema.Schema.Type; + +export const ActivationModeSchema = Schema.Literals(["eager", "lazy"] as const); +export type ActivationMode = Schema.Schema.Type; + +export const CapabilityStatusSchema = Schema.Struct({ + name: CapabilityNameSchema, + activation: ActivationModeSchema, + state: CapabilityStateSchema, + error: Schema.optionalKey(Schema.String), +}); +export type CapabilityStatus = Schema.Schema.Type; diff --git a/packages/stack/src/public/Credentials.ts b/packages/stack/src/public/Credentials.ts new file mode 100644 index 0000000000..f75b47c31e --- /dev/null +++ b/packages/stack/src/public/Credentials.ts @@ -0,0 +1,67 @@ +import { Schema } from "effect"; +import type * as Redacted from "effect/Redacted"; + +const EffectDatabaseCredentialsSchema = Schema.Struct({ + url: Schema.RedactedFromValue(Schema.String), + password: Schema.RedactedFromValue(Schema.String), +}); + +const EffectApiCredentialsSchema = Schema.Struct({ + publishableKey: Schema.String, + secretKey: Schema.RedactedFromValue(Schema.String), + anonJwt: Schema.String, + serviceRoleJwt: Schema.RedactedFromValue(Schema.String), +}); + +const EffectStorageCredentialsSchema = Schema.Struct({ + endpoint: Schema.String, + region: Schema.String, + accessKeyId: Schema.String, + secretAccessKey: Schema.RedactedFromValue(Schema.String), +}); + +export const EffectStackCredentialsSchema = Schema.Struct({ + database: EffectDatabaseCredentialsSchema, + api: EffectApiCredentialsSchema, + storage: Schema.optionalKey(EffectStorageCredentialsSchema), +}); +export interface EffectStackCredentials { + readonly database: { + readonly url: Redacted.Redacted; + readonly password: Redacted.Redacted; + }; + readonly api: { + readonly publishableKey: string; + readonly secretKey: Redacted.Redacted; + readonly anonJwt: string; + readonly serviceRoleJwt: Redacted.Redacted; + }; + readonly storage?: { + readonly endpoint: string; + readonly region: string; + readonly accessKeyId: string; + readonly secretAccessKey: Redacted.Redacted; + }; +} + +export const PromiseStackCredentialsSchema = Schema.Struct({ + database: Schema.Struct({ + url: Schema.String, + password: Schema.String, + }), + api: Schema.Struct({ + publishableKey: Schema.String, + secretKey: Schema.String, + anonJwt: Schema.String, + serviceRoleJwt: Schema.String, + }), + storage: Schema.optionalKey( + Schema.Struct({ + endpoint: Schema.String, + region: Schema.String, + accessKeyId: Schema.String, + secretAccessKey: Schema.String, + }), + ), +}); +export type PromiseStackCredentials = Schema.Schema.Type; diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts new file mode 100644 index 0000000000..d2a722fd0c --- /dev/null +++ b/packages/stack/src/public/Errors.ts @@ -0,0 +1,181 @@ +import { Data } from "effect"; + +/** + * Error context is intentionally open while operation-specific fields settle + * with the runtime modules. The `_tag` on each class is the stable public + * discriminator used by Effect.catchTag and Promise rejection handlers. + */ +export type StackErrorFields = Readonly>; + +export class InvalidStackIdentityError extends Data.TaggedError( + "InvalidStackIdentityError", +) {} +export class InvalidProjectRootError extends Data.TaggedError( + "InvalidProjectRootError", +) {} +export class InvalidStackConfigError extends Data.TaggedError( + "InvalidStackConfigError", +) {} + +export class StackNotFoundError extends Data.TaggedError("StackNotFoundError") {} +export class StackOwnershipConflictError extends Data.TaggedError( + "StackOwnershipConflictError", +) {} +export class StackRuntimeMismatchError extends Data.TaggedError( + "StackRuntimeMismatchError", +) {} + +export class StackDefinitionRequiredError extends Data.TaggedError( + "StackDefinitionRequiredError", +) {} +export class StackNotRunningError extends Data.TaggedError( + "StackNotRunningError", +) {} +export class StackMustBeStoppedError extends Data.TaggedError( + "StackMustBeStoppedError", +) {} +export class StackLifecycleConflictError extends Data.TaggedError( + "StackLifecycleConflictError", +) {} + +export class StackStateInvalidError extends Data.TaggedError( + "StackStateInvalidError", +) {} +export class StackStateFormatUnsupportedError extends Data.TaggedError( + "StackStateFormatUnsupportedError", +) {} +export class StackUpgradeRequiredError extends Data.TaggedError( + "StackUpgradeRequiredError", +) {} +export class StackUpgradeReplacementError extends Data.TaggedError( + "StackUpgradeReplacementError", +) {} + +export class StackSecretMismatchError extends Data.TaggedError( + "StackSecretMismatchError", +) {} +export class InvalidJwtSigningMaterialError extends Data.TaggedError( + "InvalidJwtSigningMaterialError", +) {} + +export class PortAllocationError extends Data.TaggedError( + "PortAllocationError", +) {} +export class PortUnavailableError extends Data.TaggedError( + "PortUnavailableError", +) {} + +export class GatewayAuthenticationError extends Data.TaggedError( + "GatewayAuthenticationError", +) {} +export class GatewayStaleGenerationError extends Data.TaggedError( + "GatewayStaleGenerationError", +) {} +export class GatewayActivationError extends Data.TaggedError( + "GatewayActivationError", +) {} + +export class StackPreparationError extends Data.TaggedError( + "StackPreparationError", +) {} +export class ArtifactIntegrityError extends Data.TaggedError( + "ArtifactIntegrityError", +) {} +export class ContainerPullError extends Data.TaggedError("ContainerPullError") {} + +export class StackReconciliationError extends Data.TaggedError( + "StackReconciliationError", +) {} +export class ServiceStartError extends Data.TaggedError("ServiceStartError") {} +export class ServiceReadinessError extends Data.TaggedError( + "ServiceReadinessError", +) {} +export class ContainerEngineError extends Data.TaggedError( + "ContainerEngineError", +) {} +export class StackDestructionError extends Data.TaggedError( + "StackDestructionError", +) {} + +export type StackError = + | InvalidStackIdentityError + | InvalidProjectRootError + | InvalidStackConfigError + | StackNotFoundError + | StackOwnershipConflictError + | StackRuntimeMismatchError + | StackDefinitionRequiredError + | StackNotRunningError + | StackMustBeStoppedError + | StackLifecycleConflictError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackUpgradeRequiredError + | StackUpgradeReplacementError + | StackSecretMismatchError + | InvalidJwtSigningMaterialError + | PortAllocationError + | PortUnavailableError + | GatewayAuthenticationError + | GatewayStaleGenerationError + | GatewayActivationError + | StackPreparationError + | ArtifactIntegrityError + | ContainerPullError + | StackReconciliationError + | ServiceStartError + | ServiceReadinessError + | ContainerEngineError + | StackDestructionError; + +export type CreateStackError = + | InvalidStackIdentityError + | InvalidProjectRootError + | InvalidStackConfigError + | StackOwnershipConflictError + | StackRuntimeMismatchError; +export type OpenStackError = + | StackNotFoundError + | StackOwnershipConflictError + | StackRuntimeMismatchError; +export type StackDiscoveryError = + | InvalidProjectRootError + | StackStateInvalidError + | StackStateFormatUnsupportedError; +export type StackStatusError = + | StackNotFoundError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackUpgradeRequiredError; +export type StackStatusWatchError = StackStatusError; +export type StackCredentialsError = + | StackNotFoundError + | StackNotRunningError + | StackSecretMismatchError + | InvalidJwtSigningMaterialError; +export type PrepareStackError = StackPreparationError | ArtifactIntegrityError | ContainerPullError; +export type StackStartError = + | StackDefinitionRequiredError + | StackLifecycleConflictError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackUpgradeRequiredError + | StackSecretMismatchError + | StackReconciliationError + | ServiceStartError + | ServiceReadinessError + | ContainerEngineError; +export type StackRestartError = StackStartError | StackUpgradeReplacementError; +export type StackStopError = + | StackNotRunningError + | StackLifecycleConflictError + | StackReconciliationError + | ServiceReadinessError + | ContainerEngineError; +export type StackCloseError = StackOwnershipConflictError | StackReconciliationError; +export type StackLogsError = StackNotFoundError | StackNotRunningError | StackStateInvalidError; +export type DestroyStackError = + | StackDestructionError + | StackNotFoundError + | StackLifecycleConflictError + | ContainerEngineError; diff --git a/packages/stack/src/public/Logs.ts b/packages/stack/src/public/Logs.ts new file mode 100644 index 0000000000..cf882063c0 --- /dev/null +++ b/packages/stack/src/public/Logs.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect"; +import { CapabilityNameSchema, type CapabilityName } from "./Capability.ts"; + +export const LogCursorSchema = Schema.Struct({ + opaque: Schema.String, +}); +export type LogCursor = Schema.Schema.Type; + +export const LogOptionsSchema = Schema.Struct({ + capabilities: Schema.optionalKey(Schema.Array(CapabilityNameSchema)), + follow: Schema.optionalKey(Schema.Boolean), + cursor: Schema.optionalKey(LogCursorSchema), +}); +export type LogOptions = Schema.Schema.Type; + +export const StackLogEntrySchema = Schema.Struct({ + cursor: LogCursorSchema, + timestamp: Schema.String, + source: Schema.Union([CapabilityNameSchema, Schema.Literals(["supervisor", "gateway"] as const)]), + stream: Schema.Literals(["stdout", "stderr", "internal"] as const), + message: Schema.String, +}); +export type StackLogEntry = Schema.Schema.Type; + +export type StackLogSource = CapabilityName | "supervisor" | "gateway"; diff --git a/packages/stack/src/public/Runtime.ts b/packages/stack/src/public/Runtime.ts new file mode 100644 index 0000000000..6ddc2aabcb --- /dev/null +++ b/packages/stack/src/public/Runtime.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect"; + +export const RuntimeEngineSchema = Schema.Literals(["docker", "podman"] as const); +export type RuntimeEngine = Schema.Schema.Type; + +export const StackRuntimeSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("native") }), + Schema.Struct({ kind: Schema.Literal("container"), engine: RuntimeEngineSchema }), +]); +export type StackRuntime = Schema.Schema.Type; + +export const StackRuntimePreferenceSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("native") }), + Schema.Struct({ + kind: Schema.Literal("container"), + engine: Schema.optionalKey(Schema.Literals(["docker", "podman", "auto"] as const)), + }), +]); +export type StackRuntimePreference = Schema.Schema.Type; diff --git a/packages/stack/src/public/StackId.ts b/packages/stack/src/public/StackId.ts new file mode 100644 index 0000000000..26b440c491 --- /dev/null +++ b/packages/stack/src/public/StackId.ts @@ -0,0 +1,13 @@ +import { Schema } from "effect"; + +declare const StackIdTypeId: unique symbol; + +/** A validated, durable identity for a managed stack. */ +export type StackId = string & { readonly [StackIdTypeId]: "StackId" }; + +export const StackIdSchema = Schema.NonEmptyString.pipe( + Schema.refine((value): value is StackId => value.length > 0, { + identifier: "StackId", + message: "Expected a non-empty stack id", + }), +); diff --git a/packages/stack/src/public/Status.ts b/packages/stack/src/public/Status.ts new file mode 100644 index 0000000000..9d82f80ea8 --- /dev/null +++ b/packages/stack/src/public/Status.ts @@ -0,0 +1,135 @@ +import { Effect, Schema, SchemaGetter } from "effect"; +import { StackIdSchema, type StackId } from "./StackId.ts"; +import { + ActivationModeSchema, + CAPABILITY_NAMES, + CapabilityStatusSchema, + CapabilityNameSchema, + type ActivationMode, + type CapabilityName, + type CapabilityStatus, +} from "./Capability.ts"; +import { StackRuntimeSchema, type StackRuntime } from "./Runtime.ts"; + +export const StackLifecycleSchema = Schema.Literals([ + "unconfigured", + "stopped", + "starting", + "running", + "resetting-database", + "stopping", + "destroying", +] as const); +export type StackLifecycle = Schema.Schema.Type; + +export const DesiredStackLifecycleSchema = Schema.Literals([ + "unconfigured", + "stopped", + "running", + "destroying", +] as const); +export type DesiredStackLifecycle = Schema.Schema.Type; + +/** Listener keys are kept here until Task 3 introduces the closed config schema. */ +export const PORT_FIELDS = [ + "api", + "database", + "pooler", + "studio", + "mailUi", + "smtp", + "pop3", + "functionsInspector", +] as const; +export type PortField = (typeof PORT_FIELDS)[number]; + +export const StackEndpointSchema = Schema.Struct({ + protocol: Schema.Literals(["http", "tcp"] as const), + address: Schema.String, + port: Schema.Number, + url: Schema.String, +}); +export type StackEndpoint = Schema.Schema.Type; + +export const StackEndpointsSchema = Schema.Struct({ + api: Schema.optionalKey(StackEndpointSchema), + database: Schema.optionalKey(StackEndpointSchema), + pooler: Schema.optionalKey(StackEndpointSchema), + studio: Schema.optionalKey(StackEndpointSchema), + mailUi: Schema.optionalKey(StackEndpointSchema), + smtp: Schema.optionalKey(StackEndpointSchema), + pop3: Schema.optionalKey(StackEndpointSchema), + functionsInspector: Schema.optionalKey(StackEndpointSchema), +}); + +export const CapabilityVersionsSchema = Schema.Struct({ + database: Schema.optionalKey(Schema.String), + rest: Schema.optionalKey(Schema.String), + auth: Schema.optionalKey(Schema.String), + realtime: Schema.optionalKey(Schema.String), + storage: Schema.optionalKey(Schema.String), + functions: Schema.optionalKey(Schema.String), + studio: Schema.optionalKey(Schema.String), + mail: Schema.optionalKey(Schema.String), + analytics: Schema.optionalKey(Schema.String), + pooler: Schema.optionalKey(Schema.String), +}); + +const CompleteCapabilityStatusesSchema = Schema.Array(CapabilityStatusSchema).pipe( + Schema.decode({ + decode: SchemaGetter.checkEffect((capabilities) => + Effect.succeed( + capabilities.length === CAPABILITY_NAMES.length && + new Set(capabilities.map(({ name }) => name)).size === CAPABILITY_NAMES.length && + CAPABILITY_NAMES.every((name) => + capabilities.some((capability) => capability.name === name), + ) + ? undefined + : "Expected exactly one status for each public capability", + ), + ), + encode: SchemaGetter.passthrough(), + }), +); + +export const StackStatusSchema = Schema.Struct({ + id: StackIdSchema, + lifecycle: StackLifecycleSchema, + desiredLifecycle: DesiredStackLifecycleSchema, + runtime: StackRuntimeSchema, + desiredGeneration: Schema.optionalKey(Schema.Number), + endpoints: StackEndpointsSchema, + versions: CapabilityVersionsSchema, + capabilities: CompleteCapabilityStatusesSchema, +}); + +export interface StackStatus { + readonly id: StackId; + readonly lifecycle: StackLifecycle; + readonly desiredLifecycle: DesiredStackLifecycle; + readonly runtime: StackRuntime; + readonly desiredGeneration?: number; + readonly endpoints: Readonly>>; + readonly versions: Readonly>>; + readonly capabilities: ReadonlyArray; +} + +export const StackDescriptorSchema = Schema.Struct({ + id: StackIdSchema, + projectRoot: Schema.String, + name: Schema.String, + branchContext: Schema.String, + runtime: StackRuntimeSchema, + desiredLifecycle: DesiredStackLifecycleSchema, +}); +export type StackDescriptor = Schema.Schema.Type; + +export const StackInspectionSchema = Schema.Struct({ + descriptor: StackDescriptorSchema, + owner: Schema.Literals(["running", "absent", "unreachable", "incompatible"] as const), + status: Schema.optionalKey(StackStatusSchema), +}); +export type StackInspection = Schema.Schema.Type; + +export type { ActivationMode }; +export { ActivationModeSchema }; diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 4825863d65..f7218e4ed8 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -1 +1,8 @@ /** Public stack model and APIs. */ +export * from "./StackId.ts"; +export * from "./Runtime.ts"; +export * from "./Capability.ts"; +export * from "./Status.ts"; +export * from "./Logs.ts"; +export * from "./Credentials.ts"; +export * from "./Errors.ts"; diff --git a/packages/stack/src/public/public-model.integration.test.ts b/packages/stack/src/public/public-model.integration.test.ts new file mode 100644 index 0000000000..f508d06c16 --- /dev/null +++ b/packages/stack/src/public/public-model.integration.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Schema } from "effect"; +import { StackStatusSchema } from "./Status.ts"; + +const CAPABILITY_NAMES = [ + "database", + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", +] as const; + +const STATUS_FIXTURE = { + id: "stack_local", + lifecycle: "stopped", + desiredLifecycle: "stopped", + runtime: { kind: "native" }, + endpoints: { + api: { + protocol: "http", + address: "127.0.0.1", + port: 54321, + url: "http://127.0.0.1:54321", + }, + }, + versions: { + database: "17", + }, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: "lazy", + state: "disabled", + })), +}; + +describe("public stack model", () => { + it.effect("decodes a complete status snapshot", () => + Schema.decodeUnknownEffect(StackStatusSchema)(STATUS_FIXTURE).pipe( + Effect.map((status) => { + expect(status.capabilities).toHaveLength(10); + expect(new Set(status.capabilities.map(({ name }) => name)).size).toBe(10); + }), + ), + ); + + it.effect("rejects status snapshots with missing capability entries", () => + Effect.gen(function* () { + const exit = yield* Schema.decodeUnknownEffect(StackStatusSchema)({ + ...STATUS_FIXTURE, + capabilities: STATUS_FIXTURE.capabilities.slice(1), + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + }), + ); + + it.effect("rejects status snapshots with duplicate capability entries", () => + Effect.gen(function* () { + const exit = yield* Schema.decodeUnknownEffect(StackStatusSchema)({ + ...STATUS_FIXTURE, + capabilities: [...STATUS_FIXTURE.capabilities, STATUS_FIXTURE.capabilities[0]], + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + }), + ); +}); From dc29b5985b29aa33578ff81428cc44c0af18a55d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 19:08:34 +0200 Subject: [PATCH 003/174] test(stack): strengthen capability uniqueness contract --- packages/stack/src/public/public-model.integration.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/stack/src/public/public-model.integration.test.ts b/packages/stack/src/public/public-model.integration.test.ts index f508d06c16..a334f61f75 100644 --- a/packages/stack/src/public/public-model.integration.test.ts +++ b/packages/stack/src/public/public-model.integration.test.ts @@ -63,7 +63,11 @@ describe("public stack model", () => { Effect.gen(function* () { const exit = yield* Schema.decodeUnknownEffect(StackStatusSchema)({ ...STATUS_FIXTURE, - capabilities: [...STATUS_FIXTURE.capabilities, STATUS_FIXTURE.capabilities[0]], + capabilities: STATUS_FIXTURE.capabilities.map((capability, index) => + index === STATUS_FIXTURE.capabilities.length - 1 + ? { ...capability, name: "database" } + : capability, + ), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); From fb428ed145515c7f34cc04e6aec45ea6f9e5f81b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 19:25:34 +0200 Subject: [PATCH 004/174] feat(stack): add identity and safe state paths --- ...26-08-28-stack-runtime-rewrite-progress.md | 35 +++ .../plans/2026-08-28-stack-runtime-rewrite.md | 17 +- packages/stack/package.json | 1 + packages/stack/src/identity/FolderIdentity.ts | 29 +++ packages/stack/src/identity/GitIdentity.ts | 174 +++++++++++++++ packages/stack/src/identity/Identity.ts | 122 +++++++++++ .../src/identity/identity.integration.test.ts | 200 ++++++++++++++++++ packages/stack/src/public/StackId.ts | 10 +- .../public/public-model.integration.test.ts | 2 +- packages/stack/src/state/Paths.ts | 67 ++++++ pnpm-lock.yaml | 3 + 11 files changed, 651 insertions(+), 9 deletions(-) create mode 100644 packages/stack/src/identity/FolderIdentity.ts create mode 100644 packages/stack/src/identity/GitIdentity.ts create mode 100644 packages/stack/src/identity/Identity.ts create mode 100644 packages/stack/src/identity/identity.integration.test.ts create mode 100644 packages/stack/src/state/Paths.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 4548eb6385..c3de733767 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -68,6 +68,22 @@ updated in the same commit as each completed slice. Scratch review packages live > before Docker/Podman adapters exist — this separates protocol authority from engine mechanics — > cost if wrong: the engine task may require a small activation interface adjustment. +> **Ruling:** Validate `StackId` as the actual 64-character lowercase SHA-256 digest before it can +> reach identity-scoped filesystem paths — “opaque” means callers do not interpret it, not that +> arbitrary path-like strings are valid — cost if wrong: a future non-SHA identity encoding will need +> an explicitly versioned schema change. + +> **Ruling:** Derive Git `workspaceId` and `checkoutId` from the canonical common Git directory and +> checkout-specific Git directory, with the full symbolic branch ref as `branchContext`; ordinary +> folders use their canonical project root and the documented ordinary-workspace marker — this avoids +> another mutable identity registry — cost if wrong: physically moving a checkout intentionally +> produces a new identity instead of reattaching its old stacks. + +> **Ruling:** Hash each identity tuple field as a four-byte big-endian UTF-8 byte length followed by +> the field bytes, then SHA-256 the concatenation — this is a single unambiguous mechanism with no +> separator escaping — cost if wrong: fields larger than 4 GiB are unrepresentable, which is outside +> any valid filesystem/ref/name input. + When the design does not determine another choice, record it here as: > **Ruling:** decision — reason — cost if wrong. @@ -119,3 +135,22 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts` RED: failed before implementation with `Cannot find module './Status.ts'`; GREEN: 3 tests passed. - `pnpm --dir packages/stack types:check` — passed. + +### Task 2 — 2026-08-28 + +- Added read-only deterministic identity resolution for canonical Git checkouts, linked worktrees, + detached HEADs, nested project roots, and ordinary folders. Git metadata is read through Effect + `FileSystem`/`Path`; no registry or identity-marker writes occur during discovery. +- Added length-delimited UTF-8 tuple hashing through Effect `Crypto`; `StackIdSchema` now accepts + only the 64 lowercase hexadecimal characters emitted by SHA-256. +- Added exact StackId-scoped state/data/log/runtime/control/temporary paths with runtime id + validation and no path traversal; changed the public status fixture to a valid digest. +- Added real temporary Git/worktree and path-safety integration scenarios (9 tests). +- `pnpm --dir packages/stack exec vitest run --project integration src/identity/identity.integration.test.ts` + RED before implementation: failed to import the missing `../state/Paths.ts` module. +- `pnpm --dir packages/stack exec vitest run --project integration src/identity/identity.integration.test.ts` + GREEN: 9 tests passed. +- `pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts` + GREEN: 3 tests passed. +- `pnpm --dir packages/stack types:check` — passed. +- `pnpm exec oxfmt --check packages/stack/src/identity packages/stack/src/state packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index 6da6fc671f..a5210903d6 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -183,15 +183,20 @@ artifact keys, state schemas, drivers, or control protocols. - Create: `packages/stack/src/identity/Identity.ts`, `GitIdentity.ts`, `FolderIdentity.ts` - Create: `packages/stack/src/state/Paths.ts` - Create: `packages/stack/src/identity/identity.integration.test.ts` +- Modify: `packages/stack/src/public/StackId.ts`, `public-model.integration.test.ts` +- Modify: `packages/stack/package.json`, `pnpm-lock.yaml` to add the concrete Node platform test layer; + core identity/path functions retain visible `FileSystem` and `Path` requirements **Interfaces:** - Produces `resolveStackIdentity({ projectRoot, name })`, `deriveStackId(identity)`, and exact identity-scoped paths beneath the user-local stack state root. +- `StackIdSchema` accepts exactly the 64 lowercase hexadecimal characters produced by SHA-256; path + APIs accept only this decoded branded value. - Path construction accepts only validated `StackId` values and cannot address a parent, sibling, or another identity. -- [ ] **Step 1: Write real filesystem/worktree and path-safety tests and verify RED** +- [x] **Step 1: Write real filesystem/worktree and path-safety tests and verify RED** Cover repeat identity, named isolation, sibling worktrees, detached checkout, nested project roots, branch changes, invalid ids, and exact path containment. @@ -201,23 +206,25 @@ artifact keys, state schemas, drivers, or control protocols. src/identity/identity.integration.test.ts ``` -- [ ] **Step 2: Implement identity without a registry daemon** +- [x] **Step 2: Implement identity without a registry daemon** Canonicalize the project root, derive the tuple documented by the spec, and hash length-delimited UTF-8 components into an opaque lowercase digest. Discovery performs no writes. -- [ ] **Step 3: Implement exact state-path derivation with platform services** +- [x] **Step 3: Implement exact state-path derivation with platform services** Keep `FileSystem` and `Path` requirements visible. Validate identity before joining path segments; expose stack root, state document, data, logs, control metadata, and temporary sibling paths without performing lifecycle mutation. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** Run the targeted identity file and package type-check, update progress, then commit: ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git add packages/stack pnpm-lock.yaml \ + docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md \ + docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md git commit -m "feat(stack): add identity and safe state paths" ``` diff --git a/packages/stack/package.json b/packages/stack/package.json index 995faf2f70..ed16e2b879 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@effect/platform-node": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "typescript": "catalog:", diff --git a/packages/stack/src/identity/FolderIdentity.ts b/packages/stack/src/identity/FolderIdentity.ts new file mode 100644 index 0000000000..f63c32dc0d --- /dev/null +++ b/packages/stack/src/identity/FolderIdentity.ts @@ -0,0 +1,29 @@ +import { Effect } from "effect"; +import { InvalidStackIdentityError } from "../public/Errors.ts"; + +export interface FolderIdentityParts { + readonly workspaceId: string; + readonly checkoutId: string; + readonly branchContext: "ordinary-workspace"; + readonly localProjectKey: "."; + readonly checkoutRoot: string; +} + +/** Builds the read-only identity tuple for a project outside of Git. */ +export const resolveFolderIdentity = ( + canonicalProjectRoot: string, +): Effect.Effect => + canonicalProjectRoot.length === 0 + ? Effect.fail( + new InvalidStackIdentityError({ + projectRoot: canonicalProjectRoot, + reason: "The canonical project root is empty", + }), + ) + : Effect.succeed({ + workspaceId: canonicalProjectRoot, + checkoutId: canonicalProjectRoot, + branchContext: "ordinary-workspace", + localProjectKey: ".", + checkoutRoot: canonicalProjectRoot, + }); diff --git a/packages/stack/src/identity/GitIdentity.ts b/packages/stack/src/identity/GitIdentity.ts new file mode 100644 index 0000000000..49cb59a63c --- /dev/null +++ b/packages/stack/src/identity/GitIdentity.ts @@ -0,0 +1,174 @@ +import { Effect, FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { InvalidStackIdentityError } from "../public/Errors.ts"; + +export interface GitIdentityParts { + readonly workspaceId: string; + readonly checkoutId: string; + readonly branchContext: string; + readonly localProjectKey: string; + readonly checkoutRoot: string; +} + +interface CheckoutMetadata { + readonly checkoutRoot: string; + readonly gitDirectory: string; +} + +const invalidMetadata = (path: string, reason: string): InvalidStackIdentityError => + new InvalidStackIdentityError({ path, reason }); + +const canonicalGitDirectory = ( + fs: FileSystem.FileSystem, + path: Path.Path, + checkoutRoot: string, + gitEntry: string, +): Effect.Effect => + Effect.gen(function* () { + const info = yield* fs.stat(gitEntry); + if (info.type === "Directory") { + return yield* fs.realPath(gitEntry); + } + if (info.type !== "File") { + return yield* invalidMetadata( + gitEntry, + "The .git entry is neither a directory nor a gitdir file", + ); + } + + const content = yield* fs.readFileString(gitEntry); + const line = content + .split("\n") + .map((candidate) => candidate.trim()) + .find((candidate) => candidate.toLowerCase().startsWith("gitdir:")); + const target = line?.slice("gitdir:".length).trim(); + if (target === undefined || target.length === 0) { + return yield* invalidMetadata(gitEntry, "The .git file does not contain a gitdir target"); + } + return yield* fs.realPath(path.resolve(checkoutRoot, target)); + }).pipe( + Effect.catchTag("PlatformError", (error: PlatformError) => + Effect.fail(invalidMetadata(gitEntry, `Unable to read Git metadata: ${error.message}`)), + ), + ); + +const locateCheckout = ( + fs: FileSystem.FileSystem, + path: Path.Path, + canonicalProjectRoot: string, +): Effect.Effect => + Effect.gen(function* () { + let directory = canonicalProjectRoot; + while (true) { + const gitEntry = path.join(directory, ".git"); + if (yield* fs.exists(gitEntry)) { + return { + checkoutRoot: directory, + gitDirectory: yield* canonicalGitDirectory(fs, path, directory, gitEntry), + }; + } + const parent = path.dirname(directory); + if (parent === directory) { + return undefined; + } + directory = parent; + } + }).pipe( + Effect.catchTag("PlatformError", (error: PlatformError) => + Effect.fail( + invalidMetadata(canonicalProjectRoot, `Unable to inspect Git metadata: ${error.message}`), + ), + ), + ); + +const resolveCommonDirectory = ( + fs: FileSystem.FileSystem, + path: Path.Path, + gitDirectory: string, +): Effect.Effect => { + const commondir = path.join(gitDirectory, "commondir"); + return Effect.gen(function* () { + if (!(yield* fs.exists(commondir))) { + return gitDirectory; + } + const target = (yield* fs.readFileString(commondir)).trim(); + if (target.length === 0) { + return yield* invalidMetadata(commondir, "The commondir target is empty"); + } + return yield* fs.realPath(path.resolve(gitDirectory, target)); + }).pipe( + Effect.catchTag("PlatformError", (error: PlatformError) => + Effect.fail(invalidMetadata(commondir, `Unable to read commondir: ${error.message}`)), + ), + ); +}; + +const resolveBranchContext = ( + fs: FileSystem.FileSystem, + path: Path.Path, + gitDirectory: string, +): Effect.Effect => + Effect.gen(function* () { + const headPath = path.join(gitDirectory, "HEAD"); + const head = (yield* fs.readFileString(headPath)).trim(); + if (head.length === 0) { + return yield* invalidMetadata(headPath, "HEAD is empty"); + } + if (head.startsWith("ref:")) { + const ref = head.slice("ref:".length).trim(); + if (ref.length === 0) { + return yield* invalidMetadata(headPath, "HEAD has an empty symbolic ref"); + } + return ref; + } + return "detached"; + }).pipe( + Effect.catchTag("PlatformError", (error: PlatformError) => + Effect.fail( + invalidMetadata(path.join(gitDirectory, "HEAD"), `Unable to read HEAD: ${error.message}`), + ), + ), + ); + +const localProjectKey = ( + path: Path.Path, + checkoutRoot: string, + canonicalProjectRoot: string, +): Effect.Effect => { + const relative = path.relative(checkoutRoot, canonicalProjectRoot); + if (relative === "") { + return Effect.succeed("."); + } + const normalized = relative.replaceAll("\\", "/"); + if (normalized === ".." || normalized.startsWith("../") || path.isAbsolute(normalized)) { + return Effect.fail( + invalidMetadata(canonicalProjectRoot, "The project root is outside of the Git checkout"), + ); + } + return Effect.succeed(normalized); +}; + +/** Resolves Git metadata without spawning Git or writing identity markers. */ +export const resolveGitIdentity = ( + canonicalProjectRoot: string, +): Effect.Effect< + GitIdentityParts | undefined, + InvalidStackIdentityError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const checkout = yield* locateCheckout(fs, path, canonicalProjectRoot); + if (checkout === undefined) { + return undefined; + } + const commonDirectory = yield* resolveCommonDirectory(fs, path, checkout.gitDirectory); + return { + workspaceId: commonDirectory, + checkoutId: checkout.gitDirectory, + branchContext: yield* resolveBranchContext(fs, path, checkout.gitDirectory), + localProjectKey: yield* localProjectKey(path, checkout.checkoutRoot, canonicalProjectRoot), + checkoutRoot: checkout.checkoutRoot, + }; + }); diff --git a/packages/stack/src/identity/Identity.ts b/packages/stack/src/identity/Identity.ts new file mode 100644 index 0000000000..81b1646a44 --- /dev/null +++ b/packages/stack/src/identity/Identity.ts @@ -0,0 +1,122 @@ +import { Effect, FileSystem, Path, Schema, Crypto } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { InvalidProjectRootError, InvalidStackIdentityError } from "../public/Errors.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { resolveFolderIdentity } from "./FolderIdentity.ts"; +import { resolveGitIdentity } from "./GitIdentity.ts"; + +export interface StackIdentity { + readonly projectRoot: string; + readonly checkoutRoot: string; + readonly workspaceId: string; + readonly checkoutId: string; + readonly branchContext: string; + readonly localProjectKey: string; + readonly stackName: string; +} + +export interface ResolveStackIdentityOptions { + readonly projectRoot: string; + readonly name?: string; +} + +const identityFailure = (message: string, fields?: Readonly>) => + new InvalidStackIdentityError({ message, ...fields }); + +const encodeTuple = (identity: StackIdentity): Uint8Array => { + const encoder = new TextEncoder(); + const fields = [ + identity.workspaceId, + identity.checkoutId, + identity.branchContext, + identity.localProjectKey, + identity.stackName, + ]; + const encoded = fields.map((field) => encoder.encode(field)); + const byteLength = encoded.reduce((total, bytes) => total + 4 + bytes.byteLength, 0); + const result = new Uint8Array(byteLength); + const view = new DataView(result.buffer); + let offset = 0; + for (const bytes of encoded) { + view.setUint32(offset, bytes.byteLength, false); + offset += 4; + result.set(bytes, offset); + offset += bytes.byteLength; + } + return result; +}; + +/** Hashes the complete identity tuple using length-delimited UTF-8 fields. */ +export const deriveStackId = ( + identity: StackIdentity, +): Effect.Effect => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto.digest("SHA-256", encodeTuple(identity)); + let hexadecimal = ""; + for (const byte of digest) { + hexadecimal += byte.toString(16).padStart(2, "0"); + } + return yield* Schema.decodeEffect(StackIdSchema)(hexadecimal).pipe( + Effect.mapError( + (error) => + new InvalidStackIdentityError({ + message: `SHA-256 digest did not produce a valid StackId: ${String(error)}`, + }), + ), + ); + }).pipe( + Effect.catchTag("PlatformError", (error: PlatformError) => + Effect.fail( + new InvalidStackIdentityError({ + message: `Unable to derive StackId: ${error.message}`, + }), + ), + ), + ); + +/** Resolves a canonical Git or ordinary-folder identity without writing state. */ +export const resolveStackIdentity = ( + options: ResolveStackIdentityOptions, +): Effect.Effect< + StackIdentity, + InvalidProjectRootError | InvalidStackIdentityError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectRoot = options.projectRoot.trim(); + if (projectRoot.length === 0) { + return yield* new InvalidProjectRootError({ + projectRoot: options.projectRoot, + message: "The project root must not be blank", + }); + } + + const canonicalProjectRoot = yield* fs.realPath(projectRoot); + const info = yield* fs.stat(canonicalProjectRoot); + if (info.type !== "Directory") { + return yield* new InvalidProjectRootError({ + projectRoot, + message: "The project root is not a directory", + }); + } + + const stackName = options.name ?? "default"; + if (stackName.trim().length === 0) { + return yield* identityFailure("The stack name must not be blank", { name: options.name }); + } + + const git = yield* resolveGitIdentity(canonicalProjectRoot); + const parts = git ?? (yield* resolveFolderIdentity(canonicalProjectRoot)); + return { projectRoot: canonicalProjectRoot, stackName, ...parts }; + }).pipe( + Effect.catchTag("PlatformError", (error: PlatformError) => + Effect.fail( + new InvalidProjectRootError({ + projectRoot: options.projectRoot, + message: `Unable to inspect the project root: ${error.message}`, + }), + ), + ), + ); diff --git a/packages/stack/src/identity/identity.integration.test.ts b/packages/stack/src/identity/identity.integration.test.ts new file mode 100644 index 0000000000..23dcb43b25 --- /dev/null +++ b/packages/stack/src/identity/identity.integration.test.ts @@ -0,0 +1,200 @@ +import { NodeCrypto, NodeFileSystem, NodePath } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Crypto, Effect, Exit, FileSystem, Layer, Path, Schema } from "effect"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, sep } from "node:path"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; +import { deriveStackId, resolveStackIdentity, type StackIdentity } from "./Identity.ts"; + +const platformLayer = Layer.mergeAll(NodeCrypto.layer, NodeFileSystem.layer, NodePath.layer); + +type PlatformServices = Crypto.Crypto | FileSystem.FileSystem | Path.Path; + +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(platformLayer))); + +const git = (cwd: string, ...args: ReadonlyArray): void => { + execFileSync("git", ["-C", cwd, ...args], { stdio: "pipe" }); +}; + +const makeGitWorkspace = async () => { + const root = await mkdtemp(join(tmpdir(), "supabase-stack-identity-")); + const repository = join(root, "repository"); + await mkdir(repository, { recursive: true }); + git(repository, "init", "-b", "main"); + git(repository, "config", "user.email", "stack-tests@example.test"); + git(repository, "config", "user.name", "Stack Tests"); + await writeFile(join(repository, "README.md"), "identity\n"); + git(repository, "add", "README.md"); + git(repository, "commit", "-m", "initial"); + return { root, repository }; +}; + +const cleanup = (root: string) => rm(root, { recursive: true, force: true }); + +const stackId = async (identity: StackIdentity): Promise => run(deriveStackId(identity)); + +describe("deterministic stack identity and state paths", () => { + it("repeats the same identity and isolates explicit stack names", async () => { + const { root, repository } = await makeGitWorkspace(); + try { + const first = await run(resolveStackIdentity({ projectRoot: repository })); + const repeat = await run(resolveStackIdentity({ projectRoot: repository })); + const named = await run(resolveStackIdentity({ projectRoot: repository, name: "preview" })); + + expect(repeat).toEqual(first); + expect(await stackId(first)).toBe(await stackId(repeat)); + expect(first.stackName).toBe("default"); + expect(named.stackName).toBe("preview"); + expect(await stackId(named)).not.toBe(await stackId(first)); + } finally { + await cleanup(root); + } + }); + + it("derives the documented digest from length-delimited UTF-8 tuple fields", async () => { + const identity: StackIdentity = { + projectRoot: "/tmp/project", + checkoutRoot: "/tmp/checkout", + workspaceId: "workspace", + checkoutId: "checkout", + branchContext: "refs/heads/main", + localProjectKey: ".", + stackName: "default", + }; + + expect(await stackId(identity)).toBe( + "c4c6587af0cd4fb3e5dab47a532023ba81cf1cd24e35b5173634a4539e8dab0f", + ); + }); + + it("returns to the same identity after changing away from and back to a branch", async () => { + const { root, repository } = await makeGitWorkspace(); + try { + const main = await run(resolveStackIdentity({ projectRoot: repository })); + git(repository, "checkout", "-b", "feature/deploy"); + const feature = await run(resolveStackIdentity({ projectRoot: repository })); + git(repository, "checkout", "main"); + const mainAgain = await run(resolveStackIdentity({ projectRoot: repository })); + + expect(main.branchContext).toBe("refs/heads/main"); + expect(feature.branchContext).toBe("refs/heads/feature/deploy"); + expect(await stackId(feature)).not.toBe(await stackId(main)); + expect(await stackId(mainAgain)).toBe(await stackId(main)); + } finally { + await cleanup(root); + } + }); + + it("keeps sibling worktrees distinct while sharing the common repository identity", async () => { + const { root, repository } = await makeGitWorkspace(); + const sibling = join(root, "sibling"); + try { + git(repository, "worktree", "add", "--force", sibling, "main"); + const primary = await run(resolveStackIdentity({ projectRoot: repository })); + const linked = await run(resolveStackIdentity({ projectRoot: sibling })); + + expect(linked.workspaceId).toBe(primary.workspaceId); + expect(linked.checkoutId).not.toBe(primary.checkoutId); + expect(linked.branchContext).toBe(primary.branchContext); + expect(await stackId(linked)).not.toBe(await stackId(primary)); + } finally { + await cleanup(root); + } + }); + + it("uses detached as the branch context for a detached checkout", async () => { + const { root, repository } = await makeGitWorkspace(); + try { + git(repository, "checkout", "--detach", "HEAD"); + const identity = await run(resolveStackIdentity({ projectRoot: repository })); + + expect(identity.branchContext).toBe("detached"); + } finally { + await cleanup(root); + } + }); + + it("normalizes a nested project root relative to its checkout", async () => { + const { root, repository } = await makeGitWorkspace(); + const nested = join(repository, "apps", "web"); + try { + await mkdir(nested, { recursive: true }); + const identity = await run(resolveStackIdentity({ projectRoot: nested })); + + expect(identity.projectRoot).toBe(await realpath(nested)); + expect(identity.localProjectKey).toBe("apps/web"); + } finally { + await cleanup(root); + } + }); + + it("uses canonical ordinary-folder identity without writing discovery markers", async () => { + const root = await mkdtemp(join(tmpdir(), "supabase-stack-ordinary-")); + const project = join(root, "project"); + try { + await mkdir(project, { recursive: true }); + const before = await readdir(project); + const identity = await run(resolveStackIdentity({ projectRoot: project })); + const after = await readdir(project); + + expect(identity.workspaceId).toBe(identity.projectRoot); + expect(identity.checkoutId).toBe(identity.projectRoot); + expect(identity.branchContext).toBe("ordinary-workspace"); + expect(identity.localProjectKey).toBe("."); + expect(after).toEqual(before); + } finally { + await cleanup(root); + } + }); + + it("rejects non-digest ids and keeps every state path below the exact identity root", async () => { + const root = await mkdtemp(join(tmpdir(), "supabase-stack-paths-")); + const stateRoot = join(root, "state"); + const project = join(root, "project"); + try { + await mkdir(stateRoot, { recursive: true }); + await mkdir(project, { recursive: true }); + const identity = await run(resolveStackIdentity({ projectRoot: project })); + const id = await stackId(identity); + const paths = await run(resolveStackPaths({ stateRoot, stackId: id })); + + expect(paths.stackRoot).toBe(join(stateRoot, id)); + expect(paths.stateDocument).toBe(join(stateRoot, id, "state.json")); + for (const value of Object.values(paths)) { + const child = relative(paths.stackRoot, value); + expect(child === "" || (child !== ".." && !child.startsWith(`..${sep}`))).toBe(true); + } + + const invalid = await Effect.runPromiseExit( + Schema.decodeUnknownEffect(StackIdSchema)("stack_local"), + ); + expect(Exit.isFailure(invalid)).toBe(true); + const unsafe = await Effect.runPromiseExit( + resolveStackPaths({ stateRoot, stackId: "../outside" as StackId }).pipe( + Effect.provide(platformLayer), + ), + ); + expect(Exit.isFailure(unsafe)).toBe(true); + } finally { + await cleanup(root); + } + }); + + it("reads linked-worktree metadata through the filesystem without relying on git", async () => { + const { root, repository } = await makeGitWorkspace(); + const sibling = join(root, "sibling"); + try { + git(repository, "worktree", "add", "--force", sibling, "main"); + const gitEntry = await readFile(join(sibling, ".git"), "utf8"); + expect(gitEntry).toContain("gitdir:"); + const identity = await run(resolveStackIdentity({ projectRoot: sibling })); + expect(identity.checkoutId).not.toBe(identity.workspaceId); + } finally { + await cleanup(root); + } + }); +}); diff --git a/packages/stack/src/public/StackId.ts b/packages/stack/src/public/StackId.ts index 26b440c491..f13f2f9dc5 100644 --- a/packages/stack/src/public/StackId.ts +++ b/packages/stack/src/public/StackId.ts @@ -5,9 +5,13 @@ declare const StackIdTypeId: unique symbol; /** A validated, durable identity for a managed stack. */ export type StackId = string & { readonly [StackIdTypeId]: "StackId" }; -export const StackIdSchema = Schema.NonEmptyString.pipe( - Schema.refine((value): value is StackId => value.length > 0, { +const SHA256_HEX = /^[0-9a-f]{64}$/; + +export const isStackId = (value: string): value is StackId => SHA256_HEX.test(value); + +export const StackIdSchema = Schema.String.pipe( + Schema.refine((value): value is StackId => isStackId(value), { identifier: "StackId", - message: "Expected a non-empty stack id", + message: "Expected a lowercase SHA-256 hexadecimal stack id", }), ); diff --git a/packages/stack/src/public/public-model.integration.test.ts b/packages/stack/src/public/public-model.integration.test.ts index a334f61f75..62b8402bf4 100644 --- a/packages/stack/src/public/public-model.integration.test.ts +++ b/packages/stack/src/public/public-model.integration.test.ts @@ -16,7 +16,7 @@ const CAPABILITY_NAMES = [ ] as const; const STATUS_FIXTURE = { - id: "stack_local", + id: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", lifecycle: "stopped", desiredLifecycle: "stopped", runtime: { kind: "native" }, diff --git a/packages/stack/src/state/Paths.ts b/packages/stack/src/state/Paths.ts new file mode 100644 index 0000000000..b3095446ff --- /dev/null +++ b/packages/stack/src/state/Paths.ts @@ -0,0 +1,67 @@ +import { Effect, Path, Schema } from "effect"; +import { InvalidProjectRootError, InvalidStackIdentityError } from "../public/Errors.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; + +export interface StackPaths { + /** The exact `/` directory. */ + readonly stackRoot: string; + /** Durable state document; temporary replacements are siblings of this path. */ + readonly stateDocument: string; + readonly data: string; + readonly logs: string; + readonly runtime: string; + /** Control metadata owned by the identity's supervisor. */ + readonly controlMetadata: string; + readonly temporaryDirectory: string; + readonly temporarySibling: string; +} + +export interface ResolveStackPathsOptions { + readonly stateRoot: string; + readonly stackId: StackId; +} + +/** + * Resolves all durable and runtime paths under one validated StackId. + * + * This function only computes names. It does not create directories or touch + * the filesystem, so callers can use it from read-only discovery paths. + */ +export const resolveStackPaths = ( + options: ResolveStackPathsOptions, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; + if (options.stateRoot.trim().length === 0) { + return yield* new InvalidProjectRootError({ + stateRoot: options.stateRoot, + message: "The stack state root must not be blank", + }); + } + + const stackId = yield* Schema.decodeEffect(StackIdSchema)(options.stackId).pipe( + Effect.mapError( + (error) => + new InvalidStackIdentityError({ + stackId: options.stackId, + message: `Invalid StackId: ${String(error)}`, + }), + ), + ); + const stateRoot = path.resolve(options.stateRoot); + const stackRoot = path.join(stateRoot, stackId); + const stateDocument = path.join(stackRoot, "state.json"); + return { + stackRoot, + stateDocument, + data: path.join(stackRoot, "data"), + logs: path.join(stackRoot, "logs"), + runtime: path.join(stackRoot, "runtime"), + controlMetadata: path.join(stackRoot, "control.json"), + temporaryDirectory: path.join(stackRoot, ".tmp"), + temporarySibling: path.join(stackRoot, "state.json.tmp"), + }; + }); + +/** Alias used by state consumers that refer to these as stack state paths. */ +export const stackStatePaths = resolveStackPaths; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e21e81569..f47c7cd043 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: specifier: 'catalog:' version: 4.0.0-rc.111 devDependencies: + '@effect/platform-node': + specifier: 'catalog:' + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) '@effect/vitest': specifier: 'catalog:' version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) From c73353ac5551111ae4e23038c2ceefc470e19af8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 19:37:09 +0200 Subject: [PATCH 005/174] fix(stack): harden identity metadata --- ...26-08-28-stack-runtime-rewrite-progress.md | 18 + packages/stack/src/identity/GitIdentity.ts | 20 +- packages/stack/src/identity/Identity.ts | 4 +- .../src/identity/identity.integration.test.ts | 454 +++++++++++------- packages/stack/src/state/Paths.ts | 3 - 5 files changed, 314 insertions(+), 185 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index c3de733767..5e5121a5e3 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -154,3 +154,21 @@ Implementation continues after recording a ruling; this file is not a question q GREEN: 3 tests passed. - `pnpm --dir packages/stack types:check` — passed. - `pnpm exec oxfmt --check packages/stack/src/identity packages/stack/src/state packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed. + +#### Task 2 review fixes — 2026-08-28 + +- Preserved the supplied project-root string (including valid leading/trailing-space path names), + while still trimming only for blankness checks; canonicalization remains filesystem-backed. +- Linked-worktree `commondir` targets are now statted and must resolve to directories. Git `HEAD` + metadata now accepts only full `refs/...` symbolic refs or 40/64-character hexadecimal object + ids, with malformed content failing as `InvalidStackIdentityError`. +- Reworked identity integration coverage to scoped `it.live` Effects with the Node platform layer, + typed Git subprocess failures, symlink canonicalization, malformed metadata cases, and exact names + for every state-path field including the temporary sibling. +- `pnpm --dir packages/stack exec vitest run --project integration src/identity/identity.integration.test.ts` + — 13 tests passed. +- `pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts` + — 3 tests passed. +- `pnpm --dir packages/stack types:check` — passed. +- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/identity packages/stack/src/state/Paths.ts packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed with zero warnings. +- `pnpm exec oxfmt --check packages/stack/src/identity packages/stack/src/state/Paths.ts packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed. diff --git a/packages/stack/src/identity/GitIdentity.ts b/packages/stack/src/identity/GitIdentity.ts index 49cb59a63c..aac6c0a461 100644 --- a/packages/stack/src/identity/GitIdentity.ts +++ b/packages/stack/src/identity/GitIdentity.ts @@ -2,6 +2,9 @@ import { Effect, FileSystem, Path } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { InvalidStackIdentityError } from "../public/Errors.ts"; +const FULL_REF_PATTERN = /^refs\/.+$/; +const OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/; + export interface GitIdentityParts { readonly workspaceId: string; readonly checkoutId: string; @@ -95,7 +98,12 @@ const resolveCommonDirectory = ( if (target.length === 0) { return yield* invalidMetadata(commondir, "The commondir target is empty"); } - return yield* fs.realPath(path.resolve(gitDirectory, target)); + const commonDirectory = path.resolve(gitDirectory, target); + const info = yield* fs.stat(commonDirectory); + if (info.type !== "Directory") { + return yield* invalidMetadata(commondir, "The commondir target is not a directory"); + } + return yield* fs.realPath(commonDirectory); }).pipe( Effect.catchTag("PlatformError", (error: PlatformError) => Effect.fail(invalidMetadata(commondir, `Unable to read commondir: ${error.message}`)), @@ -116,11 +124,17 @@ const resolveBranchContext = ( } if (head.startsWith("ref:")) { const ref = head.slice("ref:".length).trim(); - if (ref.length === 0) { - return yield* invalidMetadata(headPath, "HEAD has an empty symbolic ref"); + if (!FULL_REF_PATTERN.test(ref)) { + return yield* invalidMetadata(headPath, "HEAD must name a full symbolic ref under refs/"); } return ref; } + if (!OBJECT_ID_PATTERN.test(head)) { + return yield* invalidMetadata( + headPath, + "Detached HEAD must contain a 40- or 64-character hexadecimal Git object id", + ); + } return "detached"; }).pipe( Effect.catchTag("PlatformError", (error: PlatformError) => diff --git a/packages/stack/src/identity/Identity.ts b/packages/stack/src/identity/Identity.ts index 81b1646a44..f552de4729 100644 --- a/packages/stack/src/identity/Identity.ts +++ b/packages/stack/src/identity/Identity.ts @@ -85,8 +85,8 @@ export const resolveStackIdentity = ( > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const projectRoot = options.projectRoot.trim(); - if (projectRoot.length === 0) { + const projectRoot = options.projectRoot; + if (projectRoot.trim().length === 0) { return yield* new InvalidProjectRootError({ projectRoot: options.projectRoot, message: "The project root must not be blank", diff --git a/packages/stack/src/identity/identity.integration.test.ts b/packages/stack/src/identity/identity.integration.test.ts index 23dcb43b25..1650b821b8 100644 --- a/packages/stack/src/identity/identity.integration.test.ts +++ b/packages/stack/src/identity/identity.integration.test.ts @@ -1,200 +1,300 @@ -import { NodeCrypto, NodeFileSystem, NodePath } from "@effect/platform-node"; +import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Crypto, Effect, Exit, FileSystem, Layer, Path, Schema } from "effect"; -import { execFileSync } from "node:child_process"; -import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, relative, sep } from "node:path"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { Crypto, Data, Effect, Exit, FileSystem, Path, Schema } from "effect"; +import { InvalidStackIdentityError } from "../public/Errors.ts"; import { StackIdSchema, type StackId } from "../public/StackId.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { deriveStackId, resolveStackIdentity, type StackIdentity } from "./Identity.ts"; -const platformLayer = Layer.mergeAll(NodeCrypto.layer, NodeFileSystem.layer, NodePath.layer); +const platformLayer = NodeServices.layer; -type PlatformServices = Crypto.Crypto | FileSystem.FileSystem | Path.Path; +class GitSetupError extends Data.TaggedError("GitSetupError")<{ + readonly message: string; +}> {} -const run = (effect: Effect.Effect): Promise => - Effect.runPromise(effect.pipe(Effect.provide(platformLayer))); - -const git = (cwd: string, ...args: ReadonlyArray): void => { - execFileSync("git", ["-C", cwd, ...args], { stdio: "pipe" }); -}; +const runGit = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const exitCode = yield* spawner + .exitCode(ChildProcess.make("git", [...args], { cwd })) + .pipe(Effect.mapError((error) => new GitSetupError({ message: error.message }))); + if (exitCode !== 0) { + return yield* new GitSetupError({ + message: `git ${args.join(" ")} exited with code ${exitCode}`, + }); + } + }); -const makeGitWorkspace = async () => { - const root = await mkdtemp(join(tmpdir(), "supabase-stack-identity-")); - const repository = join(root, "repository"); - await mkdir(repository, { recursive: true }); - git(repository, "init", "-b", "main"); - git(repository, "config", "user.email", "stack-tests@example.test"); - git(repository, "config", "user.name", "Stack Tests"); - await writeFile(join(repository, "README.md"), "identity\n"); - git(repository, "add", "README.md"); - git(repository, "commit", "-m", "initial"); +const makeGitWorkspace = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-identity-" }); + const repository = path.join(root, "repository"); + yield* fs.makeDirectory(repository); + yield* runGit(repository, ["init", "-b", "main"]); + yield* runGit(repository, ["config", "user.email", "stack-tests@example.test"]); + yield* runGit(repository, ["config", "user.name", "Stack Tests"]); + yield* fs.writeFileString(path.join(repository, "README.md"), "identity\n"); + yield* runGit(repository, ["add", "README.md"]); + yield* runGit(repository, ["commit", "-m", "initial"]); return { root, repository }; -}; +}); -const cleanup = (root: string) => rm(root, { recursive: true, force: true }); +const stackId = ( + identity: StackIdentity, +): Effect.Effect => deriveStackId(identity); -const stackId = async (identity: StackIdentity): Promise => run(deriveStackId(identity)); +const withScope = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(platformLayer)); describe("deterministic stack identity and state paths", () => { - it("repeats the same identity and isolates explicit stack names", async () => { - const { root, repository } = await makeGitWorkspace(); - try { - const first = await run(resolveStackIdentity({ projectRoot: repository })); - const repeat = await run(resolveStackIdentity({ projectRoot: repository })); - const named = await run(resolveStackIdentity({ projectRoot: repository, name: "preview" })); - - expect(repeat).toEqual(first); - expect(await stackId(first)).toBe(await stackId(repeat)); - expect(first.stackName).toBe("default"); - expect(named.stackName).toBe("preview"); - expect(await stackId(named)).not.toBe(await stackId(first)); - } finally { - await cleanup(root); - } - }); + it.live("repeats the same identity and isolates explicit stack names", () => + withScope( + Effect.gen(function* () { + const { repository } = yield* makeGitWorkspace; + const first = yield* resolveStackIdentity({ projectRoot: repository }); + const repeat = yield* resolveStackIdentity({ projectRoot: repository }); + const named = yield* resolveStackIdentity({ projectRoot: repository, name: "preview" }); - it("derives the documented digest from length-delimited UTF-8 tuple fields", async () => { - const identity: StackIdentity = { - projectRoot: "/tmp/project", - checkoutRoot: "/tmp/checkout", - workspaceId: "workspace", - checkoutId: "checkout", - branchContext: "refs/heads/main", - localProjectKey: ".", - stackName: "default", - }; - - expect(await stackId(identity)).toBe( - "c4c6587af0cd4fb3e5dab47a532023ba81cf1cd24e35b5173634a4539e8dab0f", - ); - }); + expect(repeat).toEqual(first); + expect(yield* stackId(first)).toBe(yield* stackId(repeat)); + expect(first.stackName).toBe("default"); + expect(named.stackName).toBe("preview"); + expect(yield* stackId(named)).not.toBe(yield* stackId(first)); + }), + ), + ); - it("returns to the same identity after changing away from and back to a branch", async () => { - const { root, repository } = await makeGitWorkspace(); - try { - const main = await run(resolveStackIdentity({ projectRoot: repository })); - git(repository, "checkout", "-b", "feature/deploy"); - const feature = await run(resolveStackIdentity({ projectRoot: repository })); - git(repository, "checkout", "main"); - const mainAgain = await run(resolveStackIdentity({ projectRoot: repository })); - - expect(main.branchContext).toBe("refs/heads/main"); - expect(feature.branchContext).toBe("refs/heads/feature/deploy"); - expect(await stackId(feature)).not.toBe(await stackId(main)); - expect(await stackId(mainAgain)).toBe(await stackId(main)); - } finally { - await cleanup(root); - } - }); + it.live("preserves leading and trailing spaces in a valid project-root path", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-space-" }); + const project = path.join(root, " project "); + yield* fs.makeDirectory(project); - it("keeps sibling worktrees distinct while sharing the common repository identity", async () => { - const { root, repository } = await makeGitWorkspace(); - const sibling = join(root, "sibling"); - try { - git(repository, "worktree", "add", "--force", sibling, "main"); - const primary = await run(resolveStackIdentity({ projectRoot: repository })); - const linked = await run(resolveStackIdentity({ projectRoot: sibling })); - - expect(linked.workspaceId).toBe(primary.workspaceId); - expect(linked.checkoutId).not.toBe(primary.checkoutId); - expect(linked.branchContext).toBe(primary.branchContext); - expect(await stackId(linked)).not.toBe(await stackId(primary)); - } finally { - await cleanup(root); - } - }); + const identity = yield* resolveStackIdentity({ projectRoot: project }); + expect(identity.projectRoot).toBe(yield* fs.realPath(project)); + }), + ), + ); - it("uses detached as the branch context for a detached checkout", async () => { - const { root, repository } = await makeGitWorkspace(); - try { - git(repository, "checkout", "--detach", "HEAD"); - const identity = await run(resolveStackIdentity({ projectRoot: repository })); + it.live("derives the documented digest from length-delimited UTF-8 tuple fields", () => + withScope( + Effect.gen(function* () { + const identity: StackIdentity = { + projectRoot: "/tmp/project", + checkoutRoot: "/tmp/checkout", + workspaceId: "workspace", + checkoutId: "checkout", + branchContext: "refs/heads/main", + localProjectKey: ".", + stackName: "default", + }; - expect(identity.branchContext).toBe("detached"); - } finally { - await cleanup(root); - } - }); + expect(yield* stackId(identity)).toBe( + "c4c6587af0cd4fb3e5dab47a532023ba81cf1cd24e35b5173634a4539e8dab0f", + ); + }), + ), + ); - it("normalizes a nested project root relative to its checkout", async () => { - const { root, repository } = await makeGitWorkspace(); - const nested = join(repository, "apps", "web"); - try { - await mkdir(nested, { recursive: true }); - const identity = await run(resolveStackIdentity({ projectRoot: nested })); - - expect(identity.projectRoot).toBe(await realpath(nested)); - expect(identity.localProjectKey).toBe("apps/web"); - } finally { - await cleanup(root); - } - }); + it.live("returns to the same identity after changing away from and back to a branch", () => + withScope( + Effect.gen(function* () { + const { repository } = yield* makeGitWorkspace; + const main = yield* resolveStackIdentity({ projectRoot: repository }); + yield* runGit(repository, ["checkout", "-b", "feature/deploy"]); + const feature = yield* resolveStackIdentity({ projectRoot: repository }); + yield* runGit(repository, ["checkout", "main"]); + const mainAgain = yield* resolveStackIdentity({ projectRoot: repository }); - it("uses canonical ordinary-folder identity without writing discovery markers", async () => { - const root = await mkdtemp(join(tmpdir(), "supabase-stack-ordinary-")); - const project = join(root, "project"); - try { - await mkdir(project, { recursive: true }); - const before = await readdir(project); - const identity = await run(resolveStackIdentity({ projectRoot: project })); - const after = await readdir(project); - - expect(identity.workspaceId).toBe(identity.projectRoot); - expect(identity.checkoutId).toBe(identity.projectRoot); - expect(identity.branchContext).toBe("ordinary-workspace"); - expect(identity.localProjectKey).toBe("."); - expect(after).toEqual(before); - } finally { - await cleanup(root); - } - }); + expect(main.branchContext).toBe("refs/heads/main"); + expect(feature.branchContext).toBe("refs/heads/feature/deploy"); + expect(yield* stackId(feature)).not.toBe(yield* stackId(main)); + expect(yield* stackId(mainAgain)).toBe(yield* stackId(main)); + }), + ), + ); - it("rejects non-digest ids and keeps every state path below the exact identity root", async () => { - const root = await mkdtemp(join(tmpdir(), "supabase-stack-paths-")); - const stateRoot = join(root, "state"); - const project = join(root, "project"); - try { - await mkdir(stateRoot, { recursive: true }); - await mkdir(project, { recursive: true }); - const identity = await run(resolveStackIdentity({ projectRoot: project })); - const id = await stackId(identity); - const paths = await run(resolveStackPaths({ stateRoot, stackId: id })); - - expect(paths.stackRoot).toBe(join(stateRoot, id)); - expect(paths.stateDocument).toBe(join(stateRoot, id, "state.json")); - for (const value of Object.values(paths)) { - const child = relative(paths.stackRoot, value); - expect(child === "" || (child !== ".." && !child.startsWith(`..${sep}`))).toBe(true); - } - - const invalid = await Effect.runPromiseExit( - Schema.decodeUnknownEffect(StackIdSchema)("stack_local"), - ); - expect(Exit.isFailure(invalid)).toBe(true); - const unsafe = await Effect.runPromiseExit( - resolveStackPaths({ stateRoot, stackId: "../outside" as StackId }).pipe( - Effect.provide(platformLayer), - ), - ); - expect(Exit.isFailure(unsafe)).toBe(true); - } finally { - await cleanup(root); - } - }); + it.live("keeps sibling worktrees distinct while sharing the common repository identity", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, repository } = yield* makeGitWorkspace; + const sibling = path.join(root, "sibling"); + yield* runGit(repository, ["worktree", "add", "--force", sibling, "main"]); + const primary = yield* resolveStackIdentity({ projectRoot: repository }); + const linked = yield* resolveStackIdentity({ projectRoot: sibling }); - it("reads linked-worktree metadata through the filesystem without relying on git", async () => { - const { root, repository } = await makeGitWorkspace(); - const sibling = join(root, "sibling"); - try { - git(repository, "worktree", "add", "--force", sibling, "main"); - const gitEntry = await readFile(join(sibling, ".git"), "utf8"); - expect(gitEntry).toContain("gitdir:"); - const identity = await run(resolveStackIdentity({ projectRoot: sibling })); - expect(identity.checkoutId).not.toBe(identity.workspaceId); - } finally { - await cleanup(root); - } - }); + expect(linked.workspaceId).toBe(primary.workspaceId); + expect(linked.checkoutId).not.toBe(primary.checkoutId); + expect(linked.branchContext).toBe(primary.branchContext); + expect(yield* stackId(linked)).not.toBe(yield* stackId(primary)); + expect(yield* fs.exists(path.join(sibling, ".git"))).toBe(true); + }), + ), + ); + + it.live("uses detached as the branch context for a valid detached checkout", () => + withScope( + Effect.gen(function* () { + const { repository } = yield* makeGitWorkspace; + yield* runGit(repository, ["checkout", "--detach", "HEAD"]); + const identity = yield* resolveStackIdentity({ projectRoot: repository }); + + expect(identity.branchContext).toBe("detached"); + }), + ), + ); + + it.live("rejects symbolic HEAD metadata that does not name a full refs ref", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { repository } = yield* makeGitWorkspace; + yield* fs.writeFileString(path.join(repository, ".git", "HEAD"), "ref: main\n"); + + const result = yield* resolveStackIdentity({ projectRoot: repository }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + }), + ), + ); + + it.live("rejects detached HEAD metadata that is not a Git object id", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { repository } = yield* makeGitWorkspace; + yield* fs.writeFileString(path.join(repository, ".git", "HEAD"), "not-a-commit\n"); + + const result = yield* resolveStackIdentity({ projectRoot: repository }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + }), + ), + ); + + it.live("normalizes a nested project root relative to its checkout", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { repository } = yield* makeGitWorkspace; + const nested = path.join(repository, "apps", "web"); + yield* fs.makeDirectory(nested, { recursive: true }); + const identity = yield* resolveStackIdentity({ projectRoot: nested }); + + expect(identity.projectRoot).toBe(yield* fs.realPath(nested)); + expect(identity.localProjectKey).toBe("apps/web"); + }), + ), + ); + + it.live("uses canonical ordinary-folder identity without writing discovery markers", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-ordinary-" }); + const project = path.join(root, "project"); + yield* fs.makeDirectory(project); + const before = yield* fs.readDirectory(project); + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const after = yield* fs.readDirectory(project); + + expect(identity.workspaceId).toBe(identity.projectRoot); + expect(identity.checkoutId).toBe(identity.projectRoot); + expect(identity.branchContext).toBe("ordinary-workspace"); + expect(identity.localProjectKey).toBe("."); + expect(after).toEqual(before); + }), + ), + ); + + it.live("canonicalizes a symlinked project root before resolving identity", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-symlink-" }); + const target = path.join(root, "target"); + const link = path.join(root, "link"); + yield* fs.makeDirectory(target); + yield* fs.symlink(target, link); + + const identity = yield* resolveStackIdentity({ projectRoot: link }); + expect(identity.projectRoot).toBe(yield* fs.realPath(target)); + }), + ), + ); + + it.live("rejects linked-worktree metadata whose commondir target is a file", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, repository } = yield* makeGitWorkspace; + const sibling = path.join(root, "sibling"); + const notDirectory = path.join(root, "not-a-directory"); + yield* runGit(repository, ["worktree", "add", "--force", sibling, "main"]); + const gitEntry = yield* fs.readFileString(path.join(sibling, ".git")); + const target = gitEntry.trim().slice("gitdir:".length).trim(); + const gitDirectory = yield* fs.realPath(path.resolve(sibling, target)); + yield* fs.writeFileString(notDirectory, "not a directory\n"); + yield* fs.writeFileString(path.join(gitDirectory, "commondir"), notDirectory); + + const result = yield* resolveStackIdentity({ projectRoot: sibling }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + }), + ), + ); + + it.live("rejects non-digest ids and names every state path below the exact identity root", () => + withScope( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-paths-" }); + const stateRoot = path.join(root, "state"); + const project = path.join(root, "project"); + yield* fs.makeDirectory(stateRoot); + yield* fs.makeDirectory(project); + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const id = yield* stackId(identity); + const paths = yield* resolveStackPaths({ stateRoot, stackId: id }); + + expect(paths.stackRoot).toBe(path.join(stateRoot, id)); + expect(paths.stateDocument).toBe(path.join(stateRoot, id, "state.json")); + expect(paths.data).toBe(path.join(stateRoot, id, "data")); + expect(paths.logs).toBe(path.join(stateRoot, id, "logs")); + expect(paths.runtime).toBe(path.join(stateRoot, id, "runtime")); + expect(paths.controlMetadata).toBe(path.join(stateRoot, id, "control.json")); + expect(paths.temporaryDirectory).toBe(path.join(stateRoot, id, ".tmp")); + expect(paths.temporarySibling).toBe(path.join(stateRoot, id, "state.json.tmp")); + expect(path.dirname(paths.temporarySibling)).toBe(paths.stackRoot); + for (const value of Object.values(paths)) { + const relative = path.relative(paths.stackRoot, value); + expect( + relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`)), + ).toBe(true); + } + + const invalid = yield* Schema.decodeEffect(StackIdSchema)("stack_local").pipe(Effect.exit); + expect(Exit.isFailure(invalid)).toBe(true); + const unsafe = yield* resolveStackPaths({ + stateRoot, + stackId: "../outside" as StackId, + }).pipe(Effect.exit); + expect(Exit.isFailure(unsafe)).toBe(true); + }), + ), + ); }); diff --git a/packages/stack/src/state/Paths.ts b/packages/stack/src/state/Paths.ts index b3095446ff..801a58750e 100644 --- a/packages/stack/src/state/Paths.ts +++ b/packages/stack/src/state/Paths.ts @@ -62,6 +62,3 @@ export const resolveStackPaths = ( temporarySibling: path.join(stackRoot, "state.json.tmp"), }; }); - -/** Alias used by state consumers that refer to these as stack state paths. */ -export const stackStatePaths = resolveStackPaths; From 06800c9538eac2c203fe95bda7ac48a474c9a7f6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 20:09:06 +0200 Subject: [PATCH 006/174] feat(stack): compile closed capability definitions --- ...26-08-28-stack-runtime-rewrite-progress.md | 25 + .../plans/2026-08-28-stack-runtime-rewrite.md | 8 +- packages/stack/src/model/CapabilityModule.ts | 82 +++ packages/stack/src/model/Compiler.ts | 564 ++++++++++++++++++ packages/stack/src/model/ExecutionPlan.ts | 134 +++++ .../stack/src/model/capabilities/analytics.ts | 42 ++ packages/stack/src/model/capabilities/auth.ts | 383 ++++++++++++ .../stack/src/model/capabilities/database.ts | 118 ++++ .../stack/src/model/capabilities/functions.ts | 58 ++ .../stack/src/model/capabilities/index.ts | 15 + packages/stack/src/model/capabilities/mail.ts | 29 + .../stack/src/model/capabilities/pooler.ts | 38 ++ .../stack/src/model/capabilities/realtime.ts | 27 + packages/stack/src/model/capabilities/rest.ts | 44 ++ .../stack/src/model/capabilities/storage.ts | 63 ++ .../stack/src/model/capabilities/studio.ts | 37 ++ .../src/model/compiler.integration.test.ts | 241 ++++++++ packages/stack/src/public/Config.ts | 118 ++++ packages/stack/src/public/Errors.ts | 6 + packages/stack/src/public/index.ts | 1 + 20 files changed, 2029 insertions(+), 4 deletions(-) create mode 100644 packages/stack/src/model/CapabilityModule.ts create mode 100644 packages/stack/src/model/Compiler.ts create mode 100644 packages/stack/src/model/ExecutionPlan.ts create mode 100644 packages/stack/src/model/capabilities/analytics.ts create mode 100644 packages/stack/src/model/capabilities/auth.ts create mode 100644 packages/stack/src/model/capabilities/database.ts create mode 100644 packages/stack/src/model/capabilities/functions.ts create mode 100644 packages/stack/src/model/capabilities/index.ts create mode 100644 packages/stack/src/model/capabilities/mail.ts create mode 100644 packages/stack/src/model/capabilities/pooler.ts create mode 100644 packages/stack/src/model/capabilities/realtime.ts create mode 100644 packages/stack/src/model/capabilities/rest.ts create mode 100644 packages/stack/src/model/capabilities/storage.ts create mode 100644 packages/stack/src/model/capabilities/studio.ts create mode 100644 packages/stack/src/model/compiler.integration.test.ts create mode 100644 packages/stack/src/public/Config.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 5e5121a5e3..6080ea58a1 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -172,3 +172,28 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm --dir packages/stack types:check` — passed. - `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/identity packages/stack/src/state/Paths.ts packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed with zero warnings. - `pnpm exec oxfmt --check packages/stack/src/identity packages/stack/src/state/Paths.ts packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed. + +### Task 3 — 2026-08-28 + +- Added direct closed schemas for all ten public capabilities. Settings cover the current CLI + database, REST, Auth (providers/hooks/rate limits/MFA/email/SMS/OAuth/third-party/Web3), + Realtime, Storage, Functions/Edge Runtime, Studio, Mail, Analytics, and Pooler fields. Public + listeners reuse the eight `PORT_FIELDS`; migrations, seed, shadow port, and hosted metadata stay + caller-owned or outside the stack contract. +- Added deterministic pure compilation with complete defaults and explicit `null` absences, + Redacted secret leaves converted to stable slots, sorted canonical fingerprints, path-safe + `functions_root`, dependency closure validation, deterministic start/stop order, semantic + workload hashes, and runtime-selected native/container logical artifacts. Database versions use + an explicit supported Supabase release map and fail before output when unknown. +- Private workload companions are represented for database bootstrap, storage imgproxy, studio + pgmeta, analytics vector, and managed Edge Runtime Functions. Functions remain one capability; + all serving is through that managed Edge Runtime. +- Added 19 integration scenarios covering each capability, defaults, unknown fields, stable record + fingerprints, omission versus explicit selection, secret redaction, previous-definition reuse, + version validation, runtime artifact selection, path escapes, and dependency closure. +- Red/green: the compiler scenarios were added against the empty model boundary and then turned + green as the modules/compiler were implemented; `compiler.integration.test.ts` and the existing + public-model integration file now pass (22 tests). +- `pnpm --dir packages/stack types:check` — passed. +- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/public/Config.ts packages/stack/src/model packages/stack/src/public/Errors.ts` — passed with zero warnings. +- `pnpm exec oxfmt --check packages/stack/src/public/Config.ts packages/stack/src/model packages/stack/src/public/Errors.ts` — passed. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index a5210903d6..aef79ff4a6 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -247,27 +247,27 @@ artifact keys, state schemas, drivers, or control protocols. `InputFingerprint`, resolved secret-slot inputs, and private `ExecutionPlan`. - Workload ids and concrete artifact keys remain private. -- [ ] **Step 1: Write one public compiler scenario per capability and verify RED** +- [x] **Step 1: Write one public compiler scenario per capability and verify RED** Each scenario supplies non-default values accepted by current CLI configuration and asserts they survive in the materialized definition. Add cross-cutting scenarios for all defaults persisted, unknown fields rejected, secret bytes absent, exact unsupported versions rejected before output, both runtime mappings present, and functions path normalization. -- [ ] **Step 2: Implement the ten direct closed schemas** +- [x] **Step 2: Implement the ten direct closed schemas** Use direct `Schema.Struct` declarations owned by each Module; do not add a code generator or open property bags. Port the actual supported fields from `CliConfig` and from the baseline sources at `de26a30c7:packages/stack/src/StackConfig.ts` and `ServiceCatalog.ts`, updating consumers in the same task rather than preserving the old shapes. -- [ ] **Step 3: Implement pure compilation** +- [x] **Step 3: Implement pure compilation** Canonicalize non-secret input for the fingerprint, preserve explicit omission versus selection, apply every default, convert secret leaves to generated slots, validate dependency closure, and derive semantic workload-spec hashes. Reuse the persisted definition on identical fingerprints. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** Run only `compiler.integration.test.ts`, adjacent pure planner tests, and stack type-check. Commit: diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts new file mode 100644 index 0000000000..bec4ab9201 --- /dev/null +++ b/packages/stack/src/model/CapabilityModule.ts @@ -0,0 +1,82 @@ +import type { Schema } from "effect"; +import type { CapabilityName, ActivationMode } from "../public/Capability.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; + +/** Logical artifact descriptors. Download/image resolution belongs to preparation. */ +export interface NativeArtifact { + readonly kind: "native"; + readonly service: string; + readonly release: string; +} + +export interface ContainerArtifact { + readonly kind: "container"; + readonly service: string; + readonly image: string; +} + +export interface WorkloadSpec { + readonly name: string; + readonly capability: CapabilityName; + readonly dependencies: ReadonlyArray; + readonly readiness: Readonly<{ readonly mode: "http" | "tcp"; readonly portField?: string }>; + readonly restart: Readonly<{ readonly maxAttempts: number; readonly backoffMs: number }>; + readonly artifacts: Readonly<{ + readonly native: NativeArtifact; + readonly container: ContainerArtifact; + }>; +} + +export interface CapabilityModule { + readonly name: CapabilityName; + readonly settings: Schema.Schema; + readonly defaultSettings: Settings; + readonly defaultEnabled: boolean; + readonly defaultActivation: ActivationMode; + readonly dependencies: ReadonlyArray; + readonly workloads: ReadonlyArray; + readonly routes: ReadonlyArray< + Readonly<{ readonly listener: string; readonly protocol: "http" | "tcp" }> + >; + readonly materialize: (settings: Settings, projectRoot: string) => Settings; + readonly runtimeArtifact: ( + workload: WorkloadSpec, + runtime: StackRuntime, + ) => NativeArtifact | ContainerArtifact; +} + +export const nativeArtifact = (service: string, release: string): NativeArtifact => ({ + kind: "native", + service, + release, +}); + +export const containerArtifact = (service: string, image: string): ContainerArtifact => ({ + kind: "container", + service, + image, +}); + +export const workload = ( + name: string, + capability: CapabilityName, + release: string, + image: string, + options: { + readonly dependencies?: ReadonlyArray; + readonly readiness?: WorkloadSpec["readiness"]; + readonly restart?: WorkloadSpec["restart"]; + } = {}, +): WorkloadSpec => ({ + name, + capability, + dependencies: options.dependencies ?? [], + readiness: options.readiness ?? { mode: "tcp" }, + restart: options.restart ?? { maxAttempts: 5, backoffMs: 250 }, + artifacts: { + native: nativeArtifact(name, release), + container: containerArtifact(name, image), + }, +}); + +export const identityMaterialize = (settings: T): T => settings; diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts new file mode 100644 index 0000000000..b9220f9c9e --- /dev/null +++ b/packages/stack/src/model/Compiler.ts @@ -0,0 +1,564 @@ +import { Crypto, Effect, Path, Redacted, Schema } from "effect"; +import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; +import { StackConfigSchema, type StackConfig } from "../public/Config.ts"; +import type { + AuthSettings, + DatabaseSettings, + RestSettings, + RealtimeSettings, + StorageSettings, + FunctionsSettings, + StudioSettings, + MailSettings, + AnalyticsSettings, + PoolerSettings, + JwtSigning, +} from "../public/Config.ts"; +import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import { PORT_FIELDS, type PortField } from "../public/Status.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import { DatabaseVersionMap } from "./capabilities/database.ts"; +import { + AuthModule, + DatabaseModule, + FunctionsModule, + MailModule, + PoolerModule, + RealtimeModule, + RestModule, + StorageModule, + StudioModule, + AnalyticsModule, +} from "./capabilities/index.ts"; +import { CAPABILITY_MODULES, createExecutionPlan, type ExecutionPlan } from "./ExecutionPlan.ts"; +import type { CapabilityModule } from "./CapabilityModule.ts"; + +export type InputFingerprint = Schema.Schema.Type; +const InputFingerprintSchema = Schema.String.pipe(Schema.brand("InputFingerprint")); + +export interface SecretSlot { + readonly slot: string; +} + +type MaterializedValue = [T] extends [Redacted.Redacted] + ? SecretSlot + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { + readonly [Key in keyof T]-?: + | MaterializedValue> + | (undefined extends T[Key] ? null : never); + } + : T; + +export type MaterializedSettings = MaterializedValue; + +export interface MaterializedCapability { + readonly enabled: boolean; + readonly activation: "eager" | "lazy"; + readonly version: string; + readonly settings: MaterializedSettings; +} + +export interface StackDefinition { + readonly capabilities: Readonly<{ + readonly database: MaterializedCapability; + readonly rest: MaterializedCapability; + readonly auth: MaterializedCapability; + readonly realtime: MaterializedCapability; + readonly storage: MaterializedCapability; + readonly functions: MaterializedCapability; + readonly studio: MaterializedCapability; + readonly mail: MaterializedCapability; + readonly analytics: MaterializedCapability; + readonly pooler: MaterializedCapability; + }>; + readonly listeners: Readonly>; + readonly security: Readonly<{ + readonly jwt: Readonly<{ + readonly issuer: string | null; + readonly signing: MaterializedJwtSigning; + }>; + }>; +} + +export type MaterializedJwtSigning = + | { readonly kind: "symmetric"; readonly secret: SecretSlot } + | { readonly kind: "jwks-file"; readonly path: string } + | null; + +export interface MaterializedListener { + readonly enabled: boolean; + readonly address: string; + readonly port: "automatic" | number; +} + +export interface SecretSlotInput { + readonly slot: string; + readonly value: Redacted.Redacted; +} + +export interface CompiledStack { + readonly definition: StackDefinition; + readonly inputFingerprint: InputFingerprint; + readonly secrets: ReadonlyArray; + readonly executionPlan: ExecutionPlan; +} + +export interface PreviousCompilation { + readonly definition: StackDefinition; + readonly inputFingerprint: InputFingerprint; + readonly executionPlan?: ExecutionPlan; + readonly secrets?: ReadonlyArray; +} + +export interface CompileStackInput { + readonly projectRoot: string; + readonly runtime: StackRuntime; + readonly config?: StackConfig; +} + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !Redacted.isRedacted(value); + +const canonical = (value: unknown): string => { + if (Redacted.isRedacted(value)) return '{"$secret":true}'; + if (value === undefined) return "null"; + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +}; + +function merge(defaults: T, supplied: unknown): T; +function merge(defaults: unknown, supplied: unknown): unknown { + if (!isRecord(defaults) || !isRecord(supplied)) + return supplied === undefined ? defaults : supplied; + const keys = new Set([...Object.keys(defaults), ...Object.keys(supplied)]); + const result: Record = {}; + for (const key of keys) result[key] = merge(defaults[key], supplied[key]); + return result; +} + +function materializeAbsence(value: T): T; +function materializeAbsence(value: unknown): unknown { + if (value === undefined) return null; + if (Array.isArray(value)) return value.map(materializeAbsence); + if (isRecord(value)) { + const result: Record = {}; + for (const key of Object.keys(value).sort()) result[key] = materializeAbsence(value[key]); + return result; + } + return value; +} + +function slotsFor(value: T, path: string, slots: SecretSlotInput[]): MaterializedSettings; +function slotsFor(value: unknown, path: string, slots: SecretSlotInput[]): unknown { + if (Redacted.isRedacted(value)) { + slots.push({ slot: `secret:${path}`, value }); + return { slot: `secret:${path}` }; + } + if (Array.isArray(value)) + return value.map((entry, index) => slotsFor(entry, `${path}.${index}`, slots)); + if (isRecord(value)) { + const result: Record = {}; + for (const key of Object.keys(value).sort()) + result[key] = slotsFor(value[key], `${path}.${key}`, slots); + return result; + } + return value; +} + +const materializeListener = (value: unknown, enabledByDefault: boolean): MaterializedListener => { + if (!isRecord(value)) + return { enabled: enabledByDefault, address: "127.0.0.1", port: "automatic" }; + if (value.enabled === false) return { enabled: false, address: "127.0.0.1", port: "automatic" }; + const address = + typeof value.address === "string" && value.address.length > 0 ? value.address : "127.0.0.1"; + const port = typeof value.port === "number" ? value.port : "automatic"; + return { enabled: true, address, port }; +}; + +const extract = (value: unknown, key: string): unknown => + isRecord(value) ? value[key] : undefined; + +function materializeFunctionsRoot( + settings: T, + projectRoot: string, + path: Path.Path, +): Effect.Effect; +function materializeFunctionsRoot( + settings: unknown, + projectRoot: string, + path: Path.Path, +): Effect.Effect { + if (!isRecord(settings)) return Effect.succeed(settings); + const supplied = settings.functions_root; + const root = + typeof supplied === "string" && supplied.length > 0 ? supplied : "supabase/functions"; + const resolvedProject = path.resolve(projectRoot); + const resolvedRoot = path.resolve(resolvedProject, root); + const relative = path.relative(resolvedProject, resolvedRoot); + if ( + path.isAbsolute(root) || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + return Effect.fail( + new InvalidStackConfigError({ + message: "functions_root must remain inside projectRoot", + functionsRoot: root, + }), + ); + } + return Effect.succeed({ ...settings, functions_root: resolvedRoot }); +} + +const digestHex = (bytes: Uint8Array): string => + [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + +const decodeConfig = (config: unknown): Effect.Effect => + Schema.decodeUnknownEffect(StackConfigSchema)(config, { onExcessProperty: "error" }).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ message: `Invalid stack configuration: ${String(error)}` }), + ), + ); + +const versionFor = ( + name: CapabilityName, + raw: unknown, +): Effect.Effect => { + const selected = extract(raw, "version"); + const requested = typeof selected === "string" && selected.length > 0 ? selected : undefined; + if (name === "database") { + const value = requested ?? "17"; + for (const [key, version] of Object.entries(DatabaseVersionMap)) + if (key === value) return Effect.succeed(version); + return Effect.fail( + new StackVersionUnsupportedError({ + capability: name, + version: value, + message: `Unsupported database version: ${value}`, + }), + ); + } + const fallback = CAPABILITY_MODULES[name].workloads[0]?.artifacts.native.release ?? ""; + const value = requested ?? fallback; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) + return Effect.fail( + new StackVersionUnsupportedError({ + capability: name, + version: value, + message: `Unsupported ${name} version: ${value}`, + }), + ); + return Effect.succeed(value); +}; + +const enabledSettings = ( + name: CapabilityName, + capabilities: Readonly>, +): { enabled: boolean; activation: "eager" | "lazy"; settings: unknown; raw: unknown } => { + const raw = capabilities[name]; + if (name === "database") + return { enabled: true, activation: "eager", settings: extract(raw, "settings") ?? {}, raw }; + if (raw === undefined || raw === null) { + const module = CAPABILITY_MODULES[name]; + return { + enabled: module.defaultEnabled, + activation: module.defaultActivation, + settings: module.defaultSettings, + raw: {}, + }; + } + if (extract(raw, "enabled") === false) + return { + enabled: false, + activation: CAPABILITY_MODULES[name].defaultActivation, + settings: CAPABILITY_MODULES[name].defaultSettings, + raw, + }; + const activation = extract(raw, "activation"); + return { + enabled: true, + activation: + activation === "eager" || activation === "lazy" + ? activation + : CAPABILITY_MODULES[name].defaultActivation, + settings: extract(raw, "settings") ?? {}, + raw, + }; +}; + +const materializeCapability = ( + module: CapabilityModule, + raw: unknown, + projectRoot: string, + path: Path.Path, + crypto: Crypto.Crypto, + slots: SecretSlotInput[], + specHashes: Map, + normalizeFunctions: boolean, +): Effect.Effect< + MaterializedCapability, + InvalidStackConfigError | StackVersionUnsupportedError, + Path.Path | Crypto.Crypto +> => { + const selected = enabledSettings(module.name, { [module.name]: raw }); + const mergedInput = merge(module.defaultSettings, selected.settings); + const normalized = normalizeFunctions + ? materializeFunctionsRoot(mergedInput, projectRoot, path) + : Effect.succeed(mergedInput); + return Effect.gen(function* () { + const normalizedSettings = yield* normalized; + const merged = materializeAbsence(normalizedSettings); + const slotted = slotsFor(merged, `${module.name}.settings`, slots); + const version = yield* versionFor(module.name, selected.raw); + for (const entry of module.workloads) { + const bytes = yield* crypto.digest( + "SHA-256", + new TextEncoder().encode(canonical({ workload: entry, version, settings: slotted })), + ); + specHashes.set(`${module.name}:${entry.name}`, digestHex(bytes)); + } + return { + enabled: selected.enabled, + activation: selected.activation, + version, + settings: slotted, + }; + }).pipe( + Effect.catchTag("PlatformError", (error) => + Effect.fail(new InvalidStackConfigError({ message: error.message })), + ), + ); +}; + +export const compileStack = ( + input: CompileStackInput, + previous?: PreviousCompilation, +): Effect.Effect< + CompiledStack, + InvalidStackConfigError | StackVersionUnsupportedError, + Crypto.Crypto | Path.Path +> => + Effect.gen(function* () { + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const config = yield* decodeConfig(input.config ?? {}); + const rawCapabilities = isRecord(config.capabilities) ? config.capabilities : {}; + const normalizedFunctions = yield* materializeFunctionsRoot( + extract(extract(rawCapabilities, "functions"), "settings"), + input.projectRoot, + path, + ); + const rawForFingerprint = { + projectRoot: path.resolve(input.projectRoot), + runtime: input.runtime, + config: { + ...config, + capabilities: { + ...rawCapabilities, + functions: isRecord(rawCapabilities.functions) + ? { ...rawCapabilities.functions, settings: normalizedFunctions } + : rawCapabilities.functions, + }, + }, + }; + const fingerprintBytes = yield* crypto.digest( + "SHA-256", + new TextEncoder().encode(canonical(rawForFingerprint)), + ); + const inputFingerprint = yield* Schema.decodeEffect(InputFingerprintSchema)( + digestHex(fingerprintBytes), + ).pipe(Effect.mapError((error) => new InvalidStackConfigError({ message: String(error) }))); + const slots: SecretSlotInput[] = []; + const specHashes = new Map(); + const database = yield* materializeCapability( + DatabaseModule, + extract(rawCapabilities, "database"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const rest = yield* materializeCapability( + RestModule, + extract(rawCapabilities, "rest"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const auth = yield* materializeCapability( + AuthModule, + extract(rawCapabilities, "auth"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const realtime = yield* materializeCapability( + RealtimeModule, + extract(rawCapabilities, "realtime"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const storage = yield* materializeCapability( + StorageModule, + extract(rawCapabilities, "storage"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const functions = yield* materializeCapability( + FunctionsModule, + extract(rawCapabilities, "functions"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + true, + ); + const studio = yield* materializeCapability( + StudioModule, + extract(rawCapabilities, "studio"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const mail = yield* materializeCapability( + MailModule, + extract(rawCapabilities, "mail"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const analytics = yield* materializeCapability( + AnalyticsModule, + extract(rawCapabilities, "analytics"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const pooler = yield* materializeCapability( + PoolerModule, + extract(rawCapabilities, "pooler"), + input.projectRoot, + path, + crypto, + slots, + specHashes, + false, + ); + const capabilities = { + database, + rest, + auth, + realtime, + storage, + functions, + studio, + mail, + analytics, + pooler, + }; + const enabled: Record = { + database: { enabled: database.enabled, activation: database.activation }, + rest: { enabled: rest.enabled, activation: rest.activation }, + auth: { enabled: auth.enabled, activation: auth.activation }, + realtime: { enabled: realtime.enabled, activation: realtime.activation }, + storage: { enabled: storage.enabled, activation: storage.activation }, + functions: { enabled: functions.enabled, activation: functions.activation }, + studio: { enabled: studio.enabled, activation: studio.activation }, + mail: { enabled: mail.enabled, activation: mail.activation }, + analytics: { enabled: analytics.enabled, activation: analytics.activation }, + pooler: { enabled: pooler.enabled, activation: pooler.activation }, + }; + for (const name of CAPABILITY_NAMES) { + if (!enabled[name].enabled) continue; + for (const dependency of CAPABILITY_MODULES[name].dependencies) { + if (!enabled[dependency].enabled) + return yield* new InvalidStackConfigError({ + message: `${name} requires disabled capability ${dependency}`, + capability: name, + dependency, + }); + } + } + const rawListeners = isRecord(config.listeners) ? config.listeners : {}; + const listeners = { + [PORT_FIELDS[0]]: materializeListener(rawListeners[PORT_FIELDS[0]], true), + [PORT_FIELDS[1]]: materializeListener(rawListeners[PORT_FIELDS[1]], true), + [PORT_FIELDS[2]]: materializeListener(rawListeners[PORT_FIELDS[2]], false), + [PORT_FIELDS[3]]: materializeListener(rawListeners[PORT_FIELDS[3]], true), + [PORT_FIELDS[4]]: materializeListener(rawListeners[PORT_FIELDS[4]], true), + [PORT_FIELDS[5]]: materializeListener(rawListeners[PORT_FIELDS[5]], false), + [PORT_FIELDS[6]]: materializeListener(rawListeners[PORT_FIELDS[6]], false), + [PORT_FIELDS[7]]: materializeListener(rawListeners[PORT_FIELDS[7]], false), + } satisfies Record; + const rawJwt = config.security?.jwt; + const security = { + jwt: { + issuer: rawJwt?.issuer ?? null, + signing: + rawJwt?.signing === undefined + ? null + : slotsFor(rawJwt.signing, "security.jwt.signing", slots), + }, + }; + const definition: StackDefinition = { + capabilities, + listeners, + security, + }; + const executionPlan = createExecutionPlan(input.runtime, enabled, specHashes); + if (previous?.inputFingerprint === inputFingerprint) + return { + definition: previous.definition, + inputFingerprint, + secrets: slots, + executionPlan: previous.executionPlan ?? executionPlan, + }; + return { definition, inputFingerprint, secrets: slots, executionPlan }; + }).pipe( + Effect.catchTag("PlatformError", (error) => + Effect.fail(new InvalidStackConfigError({ message: error.message })), + ), + ); + +export const canonicalize = canonical; diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts new file mode 100644 index 0000000000..09e3790524 --- /dev/null +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -0,0 +1,134 @@ +import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import { + AuthModule, + DatabaseModule, + FunctionsModule, + MailModule, + PoolerModule, + RealtimeModule, + RestModule, + StorageModule, + StudioModule, + AnalyticsModule, +} from "./capabilities/index.ts"; +import type { WorkloadSpec, NativeArtifact, ContainerArtifact } from "./CapabilityModule.ts"; + +export const CAPABILITY_MODULES = { + database: DatabaseModule, + rest: RestModule, + auth: AuthModule, + realtime: RealtimeModule, + storage: StorageModule, + functions: FunctionsModule, + studio: StudioModule, + mail: MailModule, + analytics: AnalyticsModule, + pooler: PoolerModule, +}; + +export interface PlannedWorkload { + readonly id: string; + readonly capability: CapabilityName; + readonly dependencies: ReadonlyArray; + readonly readiness: WorkloadSpec["readiness"]; + readonly restart: WorkloadSpec["restart"]; + readonly artifacts: Readonly<{ + readonly native: NativeArtifact; + readonly container: ContainerArtifact; + }>; + readonly selected: NativeArtifact | ContainerArtifact; + readonly specHash: string; +} + +export interface ExecutionPlan { + readonly runtime: StackRuntime; + readonly startOrder: ReadonlyArray; + readonly stopOrder: ReadonlyArray; + readonly dependencies: Readonly<{ [Name in CapabilityName]: ReadonlyArray }>; + readonly routes: ReadonlyArray< + Readonly<{ + readonly capability: CapabilityName; + readonly listener: string; + readonly protocol: "http" | "tcp"; + }> + >; + readonly workloads: ReadonlyArray; +} + +export interface EnabledCapability { + readonly enabled: boolean; + readonly activation: "eager" | "lazy"; +} + +const dependencyMap = { + database: [], + rest: ["database"], + auth: ["database"], + realtime: ["database"], + storage: ["database"], + functions: ["database"], + studio: ["rest", "analytics"], + mail: [], + analytics: ["database"], + pooler: ["database"], +} satisfies { [Name in CapabilityName]: ReadonlyArray }; + +export const createExecutionPlan = ( + runtime: StackRuntime, + enabled: Readonly<{ [Name in CapabilityName]: EnabledCapability }>, + specHashes: ReadonlyMap, +): ExecutionPlan => { + const start: CapabilityName[] = []; + const visited = new Set(); + const visit = (name: CapabilityName): void => { + if (visited.has(name) || !enabled[name].enabled) return; + visited.add(name); + for (const dependency of dependencyMap[name]) visit(dependency); + start.push(name); + }; + for (const name of CAPABILITY_NAMES) visit(name); + const stopOrder = [...start].reverse(); + const routes = CAPABILITY_NAMES.flatMap((name) => + enabled[name].enabled + ? CAPABILITY_MODULES[name].routes.map((route) => ({ capability: name, ...route })) + : [], + ); + const declaredWorkloads = CAPABILITY_NAMES.flatMap((name) => + enabled[name].enabled + ? CAPABILITY_MODULES[name].workloads.map((entry) => ({ + id: `${name}:${entry.name}`, + capability: name, + dependencies: entry.dependencies, + readiness: entry.readiness, + restart: entry.restart, + artifacts: entry.artifacts, + selected: runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + specHash: specHashes.get(`${name}:${entry.name}`) ?? "", + })) + : [], + ); + const byId = new Map(declaredWorkloads.map((entry) => [entry.id, entry])); + const workloadOrder: typeof declaredWorkloads = []; + const visiting = new Set(); + const visitedWorkloads = new Set(); + const visitWorkload = (id: string): void => { + if (visitedWorkloads.has(id) || visiting.has(id)) return; + const entry = byId.get(id); + if (entry === undefined) return; + visiting.add(id); + for (const dependency of entry.dependencies) visitWorkload(dependency); + visiting.delete(id); + visitedWorkloads.add(id); + workloadOrder.push(entry); + }; + for (const entry of declaredWorkloads) visitWorkload(entry.id); + return { + runtime, + startOrder: start, + stopOrder, + dependencies: dependencyMap, + routes, + workloads: workloadOrder, + }; +}; diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts new file mode 100644 index 0000000000..cb6a9787b3 --- /dev/null +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -0,0 +1,42 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +const Secret = Schema.Redacted(Schema.String); +export const AnalyticsSettingsSchema = Schema.Struct({ + backend: Schema.optionalKey(Schema.Literals(["postgres", "bigquery"] as const)), + vector_port: Schema.optionalKey(Schema.Finite), + gcp_project_id: Schema.optionalKey(Schema.String), + gcp_project_number: Schema.optionalKey(Schema.String), + gcp_jwt_path: Schema.optionalKey(Schema.String), + api_key: Schema.optionalKey(Secret), +}); +export type AnalyticsSettings = Schema.Schema.Type; +export const AnalyticsModule: CapabilityModule = { + name: "analytics", + settings: AnalyticsSettingsSchema, + defaultSettings: { + backend: "postgres", + vector_port: undefined, + gcp_project_id: undefined, + gcp_project_number: undefined, + gcp_jwt_path: undefined, + api_key: undefined, + }, + defaultEnabled: true, + defaultActivation: "lazy", + dependencies: ["database"], + workloads: [ + workload("analytics", "analytics", "v1.50.6", "supabase/logflare:v1.50.6", { + dependencies: ["database:database", "analytics:vector"], + readiness: { mode: "http", portField: "api" }, + }), + workload("vector", "analytics", "0.53.0-alpine", "ghcr.io/supabase/vector:0.53.0-alpine", { + dependencies: [], + readiness: { mode: "tcp" }, + }), + ], + routes: [{ listener: "api", protocol: "http" }], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/capabilities/auth.ts b/packages/stack/src/model/capabilities/auth.ts new file mode 100644 index 0000000000..57bdfce1b3 --- /dev/null +++ b/packages/stack/src/model/capabilities/auth.ts @@ -0,0 +1,383 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +const Secret = Schema.Redacted(Schema.String); +const OptionalString = Schema.optionalKey(Schema.String); +const OptionalBoolean = Schema.optionalKey(Schema.Boolean); +const OptionalNumber = Schema.optionalKey(Schema.Finite); + +const ProviderSchema = Schema.Struct({ + enabled: OptionalBoolean, + client_id: OptionalString, + secret: Schema.optionalKey(Secret), + url: OptionalString, + redirect_uri: OptionalString, + skip_nonce_check: OptionalBoolean, + email_optional: OptionalBoolean, +}); + +const ExternalSchema = Schema.Struct({ + apple: Schema.optionalKey(ProviderSchema), + azure: Schema.optionalKey(ProviderSchema), + bitbucket: Schema.optionalKey(ProviderSchema), + discord: Schema.optionalKey(ProviderSchema), + facebook: Schema.optionalKey(ProviderSchema), + github: Schema.optionalKey(ProviderSchema), + gitlab: Schema.optionalKey(ProviderSchema), + google: Schema.optionalKey(ProviderSchema), + kakao: Schema.optionalKey(ProviderSchema), + keycloak: Schema.optionalKey(ProviderSchema), + linkedin_oidc: Schema.optionalKey(ProviderSchema), + notion: Schema.optionalKey(ProviderSchema), + twitch: Schema.optionalKey(ProviderSchema), + twitter: Schema.optionalKey(ProviderSchema), + x: Schema.optionalKey(ProviderSchema), + slack_oidc: Schema.optionalKey(ProviderSchema), + spotify: Schema.optionalKey(ProviderSchema), + workos: Schema.optionalKey(ProviderSchema), + zoom: Schema.optionalKey(ProviderSchema), +}); + +const HookSchema = Schema.Struct({ + enabled: OptionalBoolean, + uri: OptionalString, + secrets: Schema.optionalKey(Secret), +}); + +const EmailTemplateSchema = Schema.Struct({ + subject: OptionalString, + content_path: OptionalString, +}); +const EmailNotificationSchema = Schema.Struct({ + enabled: OptionalBoolean, + subject: OptionalString, + content_path: OptionalString, +}); +const SmtpSchema = Schema.Struct({ + enabled: OptionalBoolean, + host: OptionalString, + port: OptionalNumber, + user: OptionalString, + pass: Schema.optionalKey(Secret), + admin_email: OptionalString, + sender_name: OptionalString, +}); + +const Twilio = Schema.Struct({ + enabled: OptionalBoolean, + account_sid: OptionalString, + message_service_sid: OptionalString, + auth_token: Schema.optionalKey(Secret), +}); +const TwilioVerify = Schema.Struct({ + enabled: OptionalBoolean, + account_sid: OptionalString, + message_service_sid: OptionalString, + auth_token: Schema.optionalKey(Secret), +}); +const Messagebird = Schema.Struct({ + enabled: OptionalBoolean, + originator: OptionalString, + access_key: Schema.optionalKey(Secret), +}); +const Textlocal = Schema.Struct({ + enabled: OptionalBoolean, + sender: OptionalString, + api_key: Schema.optionalKey(Secret), +}); +const Vonage = Schema.Struct({ + enabled: OptionalBoolean, + from: OptionalString, + api_key: Schema.optionalKey(Secret), + api_secret: Schema.optionalKey(Secret), +}); + +const SmsSchema = Schema.Struct({ + enable_signup: OptionalBoolean, + enable_confirmations: OptionalBoolean, + template: OptionalString, + max_frequency: OptionalString, + twilio: Schema.optionalKey(Twilio), + twilio_verify: Schema.optionalKey(TwilioVerify), + messagebird: Schema.optionalKey(Messagebird), + textlocal: Schema.optionalKey(Textlocal), + vonage: Schema.optionalKey(Vonage), + test_otp: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), +}); + +const MfaSchema = Schema.Struct({ + totp: Schema.optionalKey( + Schema.Struct({ enroll_enabled: OptionalBoolean, verify_enabled: OptionalBoolean }), + ), + phone: Schema.optionalKey( + Schema.Struct({ + enroll_enabled: OptionalBoolean, + verify_enabled: OptionalBoolean, + otp_length: OptionalNumber, + template: OptionalString, + max_frequency: OptionalString, + }), + ), + web_authn: Schema.optionalKey( + Schema.Struct({ enroll_enabled: OptionalBoolean, verify_enabled: OptionalBoolean }), + ), + max_enrolled_factors: OptionalNumber, +}); + +const EmailSchema = Schema.Struct({ + enable_signup: OptionalBoolean, + double_confirm_changes: OptionalBoolean, + enable_confirmations: OptionalBoolean, + secure_password_change: OptionalBoolean, + max_frequency: OptionalString, + otp_length: OptionalNumber, + otp_expiry: OptionalNumber, + smtp: Schema.optionalKey(SmtpSchema), + template: Schema.optionalKey(Schema.Record(Schema.String, EmailTemplateSchema)), + notification: Schema.optionalKey(Schema.Record(Schema.String, EmailNotificationSchema)), +}); + +const SmsAuthProvider = Schema.Struct({ enabled: OptionalBoolean }); +const Web3Schema = Schema.Struct({ + solana: Schema.optionalKey(SmsAuthProvider), + ethereum: Schema.optionalKey(SmsAuthProvider), +}); +const CaptchaSchema = Schema.Struct({ + enabled: OptionalBoolean, + provider: Schema.optionalKey(Schema.Literals(["hcaptcha", "turnstile"] as const)), + secret: Schema.optionalKey(Secret), +}); +const RateLimitSchema = Schema.Struct({ + email_sent: OptionalNumber, + sms_sent: OptionalNumber, + anonymous_users: OptionalNumber, + token_refresh: OptionalNumber, + sign_in_sign_ups: OptionalNumber, + token_verifications: OptionalNumber, + web3: OptionalNumber, +}); +const SessionsSchema = Schema.Struct({ + timebox: OptionalString, + inactivity_timeout: OptionalString, +}); +const ThirdPartySchema = Schema.Struct({ + firebase: Schema.optionalKey( + Schema.Struct({ enabled: OptionalBoolean, project_id: OptionalString }), + ), + auth0: Schema.optionalKey( + Schema.Struct({ + enabled: OptionalBoolean, + tenant: OptionalString, + tenant_region: OptionalString, + }), + ), + aws_cognito: Schema.optionalKey( + Schema.Struct({ + enabled: OptionalBoolean, + user_pool_id: OptionalString, + user_pool_region: OptionalString, + }), + ), + clerk: Schema.optionalKey(Schema.Struct({ enabled: OptionalBoolean, domain: OptionalString })), + workos: Schema.optionalKey( + Schema.Struct({ enabled: OptionalBoolean, issuer_url: OptionalString }), + ), +}); + +export const AuthSettingsSchema = Schema.Struct({ + site_url: OptionalString, + additional_redirect_urls: Schema.optionalKey(Schema.Array(Schema.String)), + jwt_expiry: OptionalNumber, + jwt_issuer: OptionalString, + signing_keys_path: OptionalString, + enable_refresh_token_rotation: OptionalBoolean, + refresh_token_reuse_interval: OptionalNumber, + enable_manual_linking: OptionalBoolean, + enable_signup: OptionalBoolean, + enable_anonymous_sign_ins: OptionalBoolean, + minimum_password_length: OptionalNumber, + password_requirements: Schema.optionalKey( + Schema.Literals([ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols", + ] as const), + ), + publishable_key: Schema.optionalKey(Secret), + secret_key: Schema.optionalKey(Secret), + jwt_secret: Schema.optionalKey(Secret), + anon_key: Schema.optionalKey(Secret), + service_role_key: Schema.optionalKey(Secret), + rate_limit: Schema.optionalKey(RateLimitSchema), + captcha: Schema.optionalKey(CaptchaSchema), + hook: Schema.optionalKey( + Schema.Struct({ + mfa_verification_attempt: Schema.optionalKey(HookSchema), + password_verification_attempt: Schema.optionalKey(HookSchema), + custom_access_token: Schema.optionalKey(HookSchema), + send_sms: Schema.optionalKey(HookSchema), + send_email: Schema.optionalKey(HookSchema), + before_user_created: Schema.optionalKey(HookSchema), + }), + ), + mfa: Schema.optionalKey(MfaSchema), + sessions: Schema.optionalKey(SessionsSchema), + email: Schema.optionalKey(EmailSchema), + sms: Schema.optionalKey(SmsSchema), + external: Schema.optionalKey(ExternalSchema), + web3: Schema.optionalKey(Web3Schema), + oauth_server: Schema.optionalKey( + Schema.Struct({ + enabled: OptionalBoolean, + authorization_url_path: OptionalString, + allow_dynamic_registration: OptionalBoolean, + }), + ), + third_party: Schema.optionalKey(ThirdPartySchema), +}); +export type AuthSettings = Schema.Schema.Type; + +const providerDefaults = { + enabled: false, + client_id: "", + secret: undefined, + url: "", + redirect_uri: "", + skip_nonce_check: false, + email_optional: false, +}; +const providers = { + apple: providerDefaults, + azure: providerDefaults, + bitbucket: providerDefaults, + discord: providerDefaults, + facebook: providerDefaults, + github: providerDefaults, + gitlab: providerDefaults, + google: providerDefaults, + kakao: providerDefaults, + keycloak: providerDefaults, + linkedin_oidc: providerDefaults, + notion: providerDefaults, + twitch: providerDefaults, + twitter: providerDefaults, + x: providerDefaults, + slack_oidc: providerDefaults, + spotify: providerDefaults, + workos: providerDefaults, + zoom: providerDefaults, +} satisfies AuthSettings["external"]; + +const hooks = { + mfa_verification_attempt: { enabled: false, uri: undefined, secrets: undefined }, + password_verification_attempt: { enabled: false, uri: undefined, secrets: undefined }, + custom_access_token: { enabled: false, uri: undefined, secrets: undefined }, + send_sms: { enabled: false, uri: undefined, secrets: undefined }, + send_email: { enabled: false, uri: undefined, secrets: undefined }, + before_user_created: { enabled: false, uri: undefined, secrets: undefined }, +} satisfies AuthSettings["hook"]; + +export const AuthModule: CapabilityModule = { + name: "auth", + settings: AuthSettingsSchema, + defaultSettings: { + site_url: "http://127.0.0.1:3000", + additional_redirect_urls: ["https://127.0.0.1:3000"], + jwt_expiry: 3600, + jwt_issuer: undefined, + signing_keys_path: undefined, + enable_refresh_token_rotation: true, + refresh_token_reuse_interval: 10, + enable_manual_linking: false, + enable_signup: true, + enable_anonymous_sign_ins: false, + minimum_password_length: 6, + password_requirements: "", + publishable_key: undefined, + secret_key: undefined, + jwt_secret: undefined, + anon_key: undefined, + service_role_key: undefined, + rate_limit: { + email_sent: 2, + sms_sent: 30, + anonymous_users: 30, + token_refresh: 150, + sign_in_sign_ups: 30, + token_verifications: 30, + web3: 30, + }, + captcha: { enabled: false, provider: undefined, secret: undefined }, + hook: hooks, + 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, + }, + sessions: { timebox: undefined, inactivity_timeout: undefined }, + email: { + enable_signup: true, + double_confirm_changes: true, + enable_confirmations: false, + secure_password_change: false, + max_frequency: "1s", + otp_length: 6, + otp_expiry: 3600, + smtp: { enabled: false }, + template: {}, + notification: {}, + }, + sms: { + enable_signup: false, + enable_confirmations: false, + template: "Your code is {{ .Code }}", + max_frequency: "5s", + twilio: { enabled: false, account_sid: "", message_service_sid: "", auth_token: undefined }, + twilio_verify: { + enabled: false, + account_sid: undefined, + message_service_sid: undefined, + auth_token: undefined, + }, + messagebird: { enabled: false, originator: undefined, access_key: undefined }, + textlocal: { enabled: false, sender: undefined, api_key: undefined }, + vonage: { enabled: false, from: undefined, api_key: undefined, api_secret: undefined }, + test_otp: undefined, + }, + external: providers, + web3: { solana: { enabled: false }, ethereum: { enabled: false } }, + oauth_server: { + enabled: false, + authorization_url_path: "/oauth/consent", + allow_dynamic_registration: false, + }, + third_party: { + firebase: { enabled: false }, + auth0: { enabled: false }, + aws_cognito: { enabled: false }, + clerk: { enabled: false }, + workos: { enabled: false }, + }, + }, + defaultEnabled: true, + defaultActivation: "lazy", + dependencies: ["database"], + workloads: [ + workload("auth", "auth", "v2.196.0", "supabase/auth:v2.196.0", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "api" }, + }), + ], + routes: [{ listener: "api", protocol: "http" }], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts new file mode 100644 index 0000000000..4fa7f47598 --- /dev/null +++ b/packages/stack/src/model/capabilities/database.ts @@ -0,0 +1,118 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +const settingsFields = { + effective_cache_size: Schema.optionalKey(Schema.String), + logical_decoding_work_mem: Schema.optionalKey(Schema.String), + maintenance_work_mem: Schema.optionalKey(Schema.String), + max_connections: Schema.optionalKey(Schema.Finite), + max_locks_per_transaction: Schema.optionalKey(Schema.Finite), + max_parallel_maintenance_workers: Schema.optionalKey(Schema.Finite), + max_parallel_workers: Schema.optionalKey(Schema.Finite), + max_parallel_workers_per_gather: Schema.optionalKey(Schema.Finite), + max_replication_slots: Schema.optionalKey(Schema.Finite), + max_slot_wal_keep_size: Schema.optionalKey(Schema.String), + max_standby_archive_delay: Schema.optionalKey(Schema.String), + max_standby_streaming_delay: Schema.optionalKey(Schema.String), + max_wal_size: Schema.optionalKey(Schema.String), + max_wal_senders: Schema.optionalKey(Schema.Finite), + max_worker_processes: Schema.optionalKey(Schema.Finite), + session_replication_role: Schema.optionalKey( + Schema.Literals(["origin", "replica", "local"] as const), + ), + shared_buffers: Schema.optionalKey(Schema.String), + statement_timeout: Schema.optionalKey(Schema.String), + track_activity_query_size: Schema.optionalKey(Schema.String), + track_commit_timestamp: Schema.optionalKey(Schema.Boolean), + wal_keep_size: Schema.optionalKey(Schema.String), + wal_sender_timeout: Schema.optionalKey(Schema.String), + work_mem: Schema.optionalKey(Schema.String), +}; + +const settingsSchema = Schema.Struct(settingsFields); +const networkSchema = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + allowed_cidrs: Schema.optionalKey(Schema.Array(Schema.String)), + allowed_cidrs_v6: Schema.optionalKey(Schema.Array(Schema.String)), +}); +const sslSchema = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) }); + +export const DatabaseSettingsSchema = Schema.Struct({ + health_timeout: Schema.optionalKey(Schema.String), + settings: Schema.optionalKey(settingsSchema), + network_restrictions: Schema.optionalKey(networkSchema), + ssl_enforcement: Schema.optionalKey(sslSchema), + vault: Schema.optionalKey(Schema.Record(Schema.String, Schema.Redacted(Schema.String))), +}); +export type DatabaseSettings = Schema.Schema.Type; + +const defaults: DatabaseSettings = { + health_timeout: "2m", + settings: { + effective_cache_size: undefined, + logical_decoding_work_mem: undefined, + maintenance_work_mem: undefined, + max_connections: undefined, + max_locks_per_transaction: undefined, + max_parallel_maintenance_workers: undefined, + max_parallel_workers: undefined, + max_parallel_workers_per_gather: undefined, + max_replication_slots: undefined, + max_slot_wal_keep_size: undefined, + max_standby_archive_delay: undefined, + max_standby_streaming_delay: undefined, + max_wal_size: undefined, + max_wal_senders: undefined, + max_worker_processes: undefined, + session_replication_role: undefined, + shared_buffers: undefined, + statement_timeout: undefined, + track_activity_query_size: undefined, + track_commit_timestamp: undefined, + wal_keep_size: undefined, + wal_sender_timeout: undefined, + work_mem: undefined, + }, + network_restrictions: { + enabled: false, + allowed_cidrs: ["0.0.0.0/0"], + allowed_cidrs_v6: ["::/0"], + }, + ssl_enforcement: { enabled: false }, + vault: {}, +}; + +export const DatabaseModule: CapabilityModule = { + name: "database", + settings: DatabaseSettingsSchema, + defaultSettings: defaults, + defaultEnabled: true, + defaultActivation: "eager", + dependencies: [], + workloads: [ + workload("database", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { + readiness: { mode: "tcp", portField: "database" }, + }), + workload("database-bootstrap", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "database" }, + }), + ], + routes: [{ listener: "database", protocol: "tcp" }], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; + +export const DatabaseVersionMap = { + "13": "13.3.0", + "14": "14.1.0.89", + "15": "15.8.1.085", + "16": "16.4.0.0", + "17": "17.6.1.165", + "13.3.0": "13.3.0", + "14.1.0.89": "14.1.0.89", + "15.8.1.085": "15.8.1.085", + "16.4.0.0": "16.4.0.0", + "17.6.1.165": "17.6.1.165", +} as const; diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts new file mode 100644 index 0000000000..d4c4a6ea3a --- /dev/null +++ b/packages/stack/src/model/capabilities/functions.ts @@ -0,0 +1,58 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +const Secret = Schema.Redacted(Schema.String); +const FunctionOverride = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + verify_jwt: Schema.optionalKey(Schema.Boolean), + import_map: Schema.optionalKey(Schema.String), + entrypoint: Schema.optionalKey(Schema.String), + static_files: Schema.optionalKey(Schema.Array(Schema.String)), + env: Schema.optionalKey(Schema.Record(Schema.String, Secret)), +}); +export const FunctionsSettingsSchema = Schema.Struct({ + functions_root: Schema.optionalKey(Schema.String), + edge_runtime: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"] as const)), + inspector_port: Schema.optionalKey(Schema.Finite), + deno_version: Schema.optionalKey(Schema.Finite), + secrets: Schema.optionalKey(Schema.Record(Schema.String, Secret)), + }), + ), + functions: Schema.optionalKey(Schema.Record(Schema.String, FunctionOverride)), +}); +export type FunctionsSettings = Schema.Schema.Type; + +export const FunctionsModule: CapabilityModule = { + name: "functions", + settings: FunctionsSettingsSchema, + defaultSettings: { + functions_root: "supabase/functions", + edge_runtime: { + enabled: true, + policy: "per_worker", + inspector_port: 8083, + deno_version: 2, + secrets: {}, + }, + functions: {}, + }, + defaultEnabled: true, + defaultActivation: "lazy", + dependencies: ["database"], + workloads: [ + workload("edge-runtime", "functions", "v1.74.3", "supabase/edge-runtime:v1.74.3", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "functionsInspector" }, + }), + ], + routes: [ + { listener: "api", protocol: "http" }, + { listener: "functionsInspector", protocol: "http" }, + ], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/capabilities/index.ts b/packages/stack/src/model/capabilities/index.ts new file mode 100644 index 0000000000..07d0a605c8 --- /dev/null +++ b/packages/stack/src/model/capabilities/index.ts @@ -0,0 +1,15 @@ +export { AuthModule, AuthSettingsSchema, type AuthSettings } from "./auth.ts"; +export { + DatabaseModule, + DatabaseSettingsSchema, + DatabaseVersionMap, + type DatabaseSettings, +} from "./database.ts"; +export { FunctionsModule, FunctionsSettingsSchema, type FunctionsSettings } from "./functions.ts"; +export { MailModule, MailSettingsSchema, type MailSettings } from "./mail.ts"; +export { PoolerModule, PoolerSettingsSchema, type PoolerSettings } from "./pooler.ts"; +export { RealtimeModule, RealtimeSettingsSchema, type RealtimeSettings } from "./realtime.ts"; +export { RestModule, RestSettingsSchema, type RestSettings } from "./rest.ts"; +export { StorageModule, StorageSettingsSchema, type StorageSettings } from "./storage.ts"; +export { StudioModule, StudioSettingsSchema, type StudioSettings } from "./studio.ts"; +export { AnalyticsModule, AnalyticsSettingsSchema, type AnalyticsSettings } from "./analytics.ts"; diff --git a/packages/stack/src/model/capabilities/mail.ts b/packages/stack/src/model/capabilities/mail.ts new file mode 100644 index 0000000000..b809c81e22 --- /dev/null +++ b/packages/stack/src/model/capabilities/mail.ts @@ -0,0 +1,29 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +export const MailSettingsSchema = Schema.Struct({ + admin_email: Schema.optionalKey(Schema.String), + sender_name: Schema.optionalKey(Schema.String), +}); +export type MailSettings = Schema.Schema.Type; +export const MailModule: CapabilityModule = { + name: "mail", + settings: MailSettingsSchema, + defaultSettings: { admin_email: undefined, sender_name: undefined }, + defaultEnabled: true, + defaultActivation: "eager", + dependencies: [], + workloads: [ + workload("mail", "mail", "v1.30.2", "axllent/mailpit:v1.30.2", { + readiness: { mode: "http", portField: "mailUi" }, + }), + ], + routes: [ + { listener: "mailUi", protocol: "http" }, + { listener: "smtp", protocol: "tcp" }, + { listener: "pop3", protocol: "tcp" }, + ], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts new file mode 100644 index 0000000000..2409eeba9e --- /dev/null +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -0,0 +1,38 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +const Secret = Schema.Redacted(Schema.String); +export const PoolerSettingsSchema = Schema.Struct({ + mode: Schema.optionalKey(Schema.Literals(["transaction", "session"] as const)), + tenant_id: Schema.optionalKey(Schema.String), + encryption_key: Schema.optionalKey(Secret), + secret_key_base: Schema.optionalKey(Secret), + default_pool_size: Schema.optionalKey(Schema.Finite), + max_client_conn: Schema.optionalKey(Schema.Finite), +}); +export type PoolerSettings = Schema.Schema.Type; +export const PoolerModule: CapabilityModule = { + name: "pooler", + settings: PoolerSettingsSchema, + defaultSettings: { + mode: "transaction", + tenant_id: undefined, + encryption_key: undefined, + secret_key_base: undefined, + default_pool_size: 20, + max_client_conn: 100, + }, + defaultEnabled: false, + defaultActivation: "eager", + dependencies: ["database"], + workloads: [ + workload("pooler", "pooler", "2.9.7", "ghcr.io/supabase/supavisor:2.9.7", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "pooler" }, + }), + ], + routes: [{ listener: "pooler", protocol: "tcp" }], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/capabilities/realtime.ts b/packages/stack/src/model/capabilities/realtime.ts new file mode 100644 index 0000000000..538ebcb7e0 --- /dev/null +++ b/packages/stack/src/model/capabilities/realtime.ts @@ -0,0 +1,27 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +export const RealtimeSettingsSchema = Schema.Struct({ + ip_version: Schema.optionalKey(Schema.Literals(["IPv4", "IPv6"] as const)), + max_header_length: Schema.optionalKey(Schema.Finite), +}); +export type RealtimeSettings = Schema.Schema.Type; + +export const RealtimeModule: CapabilityModule = { + name: "realtime", + settings: RealtimeSettingsSchema, + defaultSettings: { ip_version: "IPv4", max_header_length: 4096 }, + defaultEnabled: true, + defaultActivation: "eager", + dependencies: ["database"], + workloads: [ + workload("realtime", "realtime", "v2.129.9", "supabase/realtime:v2.129.9", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "api" }, + }), + ], + routes: [{ listener: "api", protocol: "http" }], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/capabilities/rest.ts b/packages/stack/src/model/capabilities/rest.ts new file mode 100644 index 0000000000..6b03086693 --- /dev/null +++ b/packages/stack/src/model/capabilities/rest.ts @@ -0,0 +1,44 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +export const RestSettingsSchema = Schema.Struct({ + schemas: Schema.optionalKey(Schema.Array(Schema.String)), + extra_search_path: Schema.optionalKey(Schema.Array(Schema.String)), + max_rows: Schema.optionalKey(Schema.Finite), + auto_expose_new_tables: Schema.optionalKey(Schema.Boolean), + tls: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + cert_path: Schema.optionalKey(Schema.String), + key_path: Schema.optionalKey(Schema.String), + }), + ), + external_url: Schema.optionalKey(Schema.String), +}); +export type RestSettings = Schema.Schema.Type; + +export const RestModule: CapabilityModule = { + name: "rest", + settings: RestSettingsSchema, + defaultSettings: { + schemas: ["public", "graphql_public"], + extra_search_path: ["public", "extensions"], + max_rows: 1000, + auto_expose_new_tables: undefined, + tls: { enabled: false, cert_path: undefined, key_path: undefined }, + external_url: undefined, + }, + defaultEnabled: true, + defaultActivation: "lazy", + dependencies: ["database"], + workloads: [ + workload("rest", "rest", "v16.2", "postgrest/postgrest:v16.2", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "api" }, + }), + ], + routes: [{ listener: "api", protocol: "http" }], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts new file mode 100644 index 0000000000..e89c0c19a8 --- /dev/null +++ b/packages/stack/src/model/capabilities/storage.ts @@ -0,0 +1,63 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +const Bucket = Schema.Struct({ + public: Schema.optionalKey(Schema.Boolean), + file_size_limit: Schema.optionalKey(Schema.Union([Schema.String, Schema.Finite])), + allowed_mime_types: Schema.optionalKey(Schema.Array(Schema.String)), + objects_path: Schema.optionalKey(Schema.String), +}); +const Analytics = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + max_namespaces: Schema.optionalKey(Schema.Finite), + max_tables: Schema.optionalKey(Schema.Finite), + max_catalogs: Schema.optionalKey(Schema.Finite), + buckets: Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({}))), +}); +const Vector = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + max_buckets: Schema.optionalKey(Schema.Finite), + max_indexes: Schema.optionalKey(Schema.Finite), + buckets: Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({}))), +}); +export const StorageSettingsSchema = Schema.Struct({ + file_size_limit: Schema.optionalKey(Schema.Union([Schema.String, Schema.Finite])), + image_transformation: Schema.optionalKey( + Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) }), + ), + buckets: Schema.optionalKey(Schema.Record(Schema.String, Bucket)), + s3_protocol: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + analytics: Schema.optionalKey(Analytics), + vector: Schema.optionalKey(Vector), +}); +export type StorageSettings = Schema.Schema.Type; + +export const StorageModule: CapabilityModule = { + name: "storage", + settings: StorageSettingsSchema, + defaultSettings: { + file_size_limit: "50MiB", + image_transformation: { enabled: false }, + buckets: {}, + s3_protocol: { enabled: true }, + analytics: { enabled: false, max_namespaces: 5, max_tables: 10, max_catalogs: 2, buckets: {} }, + vector: { enabled: true, max_buckets: 10, max_indexes: 5, buckets: {} }, + }, + defaultEnabled: true, + defaultActivation: "lazy", + dependencies: ["database"], + workloads: [ + workload("storage", "storage", "v1.71.0", "supabase/storage-api:v1.71.0", { + dependencies: ["database:database", "storage:imgproxy"], + readiness: { mode: "http", portField: "api" }, + }), + workload("imgproxy", "storage", "v3.8.0", "darthsim/imgproxy:v3.8.0", { + dependencies: ["database:database"], + readiness: { mode: "http" }, + }), + ], + routes: [{ listener: "api", protocol: "http" }], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts new file mode 100644 index 0000000000..238def9b20 --- /dev/null +++ b/packages/stack/src/model/capabilities/studio.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect"; +import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; + +const Secret = Schema.Redacted(Schema.String); +export const StudioSettingsSchema = Schema.Struct({ + api_url: Schema.optionalKey(Schema.String), + openai_api_key: Schema.optionalKey(Secret), +}); +export type StudioSettings = Schema.Schema.Type; +export const StudioModule: CapabilityModule = { + name: "studio", + settings: StudioSettingsSchema, + defaultSettings: { api_url: "http://127.0.0.1", openai_api_key: undefined }, + defaultEnabled: true, + defaultActivation: "eager", + dependencies: ["rest", "analytics"], + workloads: [ + workload( + "studio", + "studio", + "2026.08.24-sha-8ec45b2", + "supabase/studio:2026.08.24-sha-8ec45b2", + { + dependencies: ["studio:pgmeta", "analytics:analytics"], + readiness: { mode: "http", portField: "studio" }, + }, + ), + workload("pgmeta", "studio", "0.98.0", "supabase/pg-meta:v0.98.0", { + dependencies: ["database:database"], + readiness: { mode: "http" }, + }), + ], + routes: [{ listener: "studio", protocol: "http" }], + materialize: (settings) => identityMaterialize(settings), + runtimeArtifact: (entry, runtime) => + runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, +}; diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts new file mode 100644 index 0000000000..6291f7b574 --- /dev/null +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -0,0 +1,241 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Redacted } from "effect"; +import { canonicalize, compileStack } from "./Compiler.ts"; + +const layer = NodeServices.layer; +const compile = ( + config: Parameters[0]["config"], + runtime: Parameters[0]["runtime"] = { kind: "native" }, + previous?: Parameters[1], +) => + compileStack({ projectRoot: "/tmp/supabase-project", runtime, config }, previous).pipe( + Effect.provide(layer), + ); + +describe("closed capability compiler", () => { + it.live("materializes a non-default database setting", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { database: { settings: { health_timeout: "5m" } } }, + }); + expect(result.definition.capabilities.database.settings).toMatchObject({ + health_timeout: "5m", + }); + }), + ); + + it.live("materializes a non-default rest setting", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { rest: { settings: { schemas: ["private"] } } }, + }); + expect(result.definition.capabilities.rest.settings).toMatchObject({ schemas: ["private"] }); + expect(result.definition.capabilities.rest.settings.schemas).toEqual(["private"]); + }), + ); + + it.live("materializes a non-default auth setting", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { + auth: { + settings: { + site_url: "https://example.test", + secret_key: Redacted.make("secret-value"), + }, + }, + }, + }); + expect(result.definition.capabilities.auth.settings).toMatchObject({ + site_url: "https://example.test", + }); + expect(canonicalize(result.definition)).not.toContain("secret-value"); + }), + ); + + it.live("materializes a non-default realtime setting", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { realtime: { settings: { ip_version: "IPv6" } } }, + }); + expect(result.definition.capabilities.realtime.settings).toMatchObject({ + ip_version: "IPv6", + }); + }), + ); + + it.live("materializes a non-default storage bucket", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { storage: { settings: { buckets: { avatars: { public: true } } } } }, + }); + expect(result.definition.capabilities.storage.settings).toMatchObject({ + buckets: { avatars: { public: true } }, + }); + }), + ); + + it.live("normalizes the functions root and preserves a function override", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { + functions: { + settings: { + functions_root: "./supabase/functions", + functions: { hello: { verify_jwt: false } }, + }, + }, + }, + }); + expect(result.definition.capabilities.functions.settings).toMatchObject({ + functions_root: "/tmp/supabase-project/supabase/functions", + functions: { hello: { verify_jwt: false } }, + }); + }), + ); + + it.live("materializes a non-default studio setting", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { studio: { settings: { api_url: "http://localhost:9999" } } }, + }); + expect(result.definition.capabilities.studio.settings).toMatchObject({ + api_url: "http://localhost:9999", + }); + }), + ); + + it.live("materializes a non-default mail setting", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { mail: { settings: { sender_name: "Tests" } } }, + }); + expect(result.definition.capabilities.mail.settings).toMatchObject({ sender_name: "Tests" }); + }), + ); + + it.live("materializes a non-default analytics setting", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { analytics: { settings: { backend: "bigquery" } } }, + }); + expect(result.definition.capabilities.analytics.settings).toMatchObject({ + backend: "bigquery", + }); + }), + ); + + it.live("materializes a non-default pooler setting", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { pooler: { enabled: true, settings: { mode: "session" } } }, + }); + expect(result.definition.capabilities.pooler.settings).toMatchObject({ mode: "session" }); + expect(result.definition.capabilities.pooler.enabled).toBe(true); + }), + ); + + it.live("persists every capability and optional absence as null", () => + Effect.gen(function* () { + const result = yield* compile({}); + expect(Object.keys(result.definition.capabilities)).toHaveLength(10); + expect(result.definition.capabilities.rest.settings).toMatchObject({ external_url: null }); + expect(result.definition.capabilities.functions.settings).toMatchObject({ + edge_runtime: { secrets: {} }, + functions: {}, + }); + }), + ); + + it.live("rejects unknown fields at the public compiler boundary", () => + Effect.gen(function* () { + const exit = yield* compile({ + capabilities: { rest: { settings: { typo: true } } }, + } as never).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(exit.cause).toBeDefined(); + }), + ); + + it.live("keeps record key insertion order out of fingerprints", () => + Effect.gen(function* () { + const one = yield* compile({ + capabilities: { + storage: { settings: { buckets: { a: { public: true }, b: { public: false } } } }, + }, + }); + const two = yield* compile({ + capabilities: { + storage: { settings: { buckets: { b: { public: false }, a: { public: true } } } }, + }, + }); + expect(one.inputFingerprint).toBe(two.inputFingerprint); + }), + ); + + it.live("preserves omitted versus explicit capability selection", () => + Effect.gen(function* () { + const omitted = yield* compile({}); + const explicit = yield* compile({ capabilities: { rest: { enabled: true } } }); + expect(omitted.inputFingerprint).not.toBe(explicit.inputFingerprint); + }), + ); + + it.live("reuses the exact previous definition for an identical fingerprint", () => + Effect.gen(function* () { + const first = yield* compile({ + capabilities: { rest: { settings: { schemas: ["private"] } } }, + }); + const second = yield* compile({ + capabilities: { rest: { settings: { schemas: ["private"] } } }, + }); + const reused = yield* compile( + { capabilities: { rest: { settings: { schemas: ["private"] } } } }, + { kind: "native" }, + first, + ); + expect(first.inputFingerprint).toBe(second.inputFingerprint); + expect(reused.definition).toBe(first.definition); + }), + ); + + it.live("rejects an unsupported database version before output", () => + Effect.gen(function* () { + const result = yield* compile({ capabilities: { database: { version: "99" } } }).pipe( + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) expect(result.cause).toBeDefined(); + }), + ); + + it.live("keeps both native and container artifacts while selecting one runtime", () => + Effect.gen(function* () { + const native = yield* compile({}, { kind: "native" }); + const container = yield* compile({}, { kind: "container", engine: "docker" }); + expect(native.executionPlan.workloads[0]?.artifacts.native.kind).toBe("native"); + expect(native.executionPlan.workloads[0]?.artifacts.container.kind).toBe("container"); + expect(container.executionPlan.workloads[0]?.selected.kind).toBe("container"); + }), + ); + + it.live("rejects a functions root that escapes the project", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { functions: { settings: { functions_root: "../outside" } } }, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) expect(result.cause).toBeDefined(); + }), + ); + + it.live("reports dependency closure errors", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { rest: { enabled: false }, studio: { enabled: true } }, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + }), + ); +}); diff --git a/packages/stack/src/public/Config.ts b/packages/stack/src/public/Config.ts new file mode 100644 index 0000000000..4fb3b14e64 --- /dev/null +++ b/packages/stack/src/public/Config.ts @@ -0,0 +1,118 @@ +import { Schema } from "effect"; +import { DatabaseSettingsSchema, type DatabaseSettings } from "../model/capabilities/database.ts"; +import { RestSettingsSchema, type RestSettings } from "../model/capabilities/rest.ts"; +import { AuthSettingsSchema, type AuthSettings } from "../model/capabilities/auth.ts"; +import { RealtimeSettingsSchema, type RealtimeSettings } from "../model/capabilities/realtime.ts"; +import { StorageSettingsSchema, type StorageSettings } from "../model/capabilities/storage.ts"; +import { + FunctionsSettingsSchema, + type FunctionsSettings, +} from "../model/capabilities/functions.ts"; +import { StudioSettingsSchema, type StudioSettings } from "../model/capabilities/studio.ts"; +import { MailSettingsSchema, type MailSettings } from "../model/capabilities/mail.ts"; +import { + AnalyticsSettingsSchema, + type AnalyticsSettings, +} from "../model/capabilities/analytics.ts"; +import { PoolerSettingsSchema, type PoolerSettings } from "../model/capabilities/pooler.ts"; +import { ActivationModeSchema, type ActivationMode } from "./Capability.ts"; +import { PORT_FIELDS, type PortField } from "./Status.ts"; + +export const ListenerPortSchema = Schema.Union([Schema.Literal("automatic"), Schema.Finite]); +export const ListenerConfigSchema = Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Literal(true)), + address: Schema.optionalKey(Schema.String), + port: Schema.optionalKey(Schema.Finite), + }), +]); +export type ListenerConfig = Schema.Schema.Type; + +const optionalCapability = (settings: S) => + Schema.Union([ + Schema.Struct({ enabled: Schema.Literal(false) }), + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Literal(true)), + activation: Schema.optionalKey(ActivationModeSchema), + version: Schema.optionalKey(Schema.String), + settings: Schema.optionalKey(settings), + }), + ]); + +export const DatabaseCapabilityConfigSchema = Schema.Struct({ + version: Schema.optionalKey(Schema.String), + settings: Schema.optionalKey(DatabaseSettingsSchema), +}); +export const StackCapabilitiesConfigSchema = Schema.Struct({ + database: Schema.optionalKey(DatabaseCapabilityConfigSchema), + rest: Schema.optionalKey(optionalCapability(RestSettingsSchema)), + auth: Schema.optionalKey(optionalCapability(AuthSettingsSchema)), + realtime: Schema.optionalKey(optionalCapability(RealtimeSettingsSchema)), + storage: Schema.optionalKey(optionalCapability(StorageSettingsSchema)), + functions: Schema.optionalKey(optionalCapability(FunctionsSettingsSchema)), + studio: Schema.optionalKey(optionalCapability(StudioSettingsSchema)), + mail: Schema.optionalKey(optionalCapability(MailSettingsSchema)), + analytics: Schema.optionalKey(optionalCapability(AnalyticsSettingsSchema)), + pooler: Schema.optionalKey(optionalCapability(PoolerSettingsSchema)), +}); + +const listenerFields = { + [PORT_FIELDS[0]]: Schema.optionalKey(ListenerConfigSchema), + [PORT_FIELDS[1]]: Schema.optionalKey(ListenerConfigSchema), + [PORT_FIELDS[2]]: Schema.optionalKey(ListenerConfigSchema), + [PORT_FIELDS[3]]: Schema.optionalKey(ListenerConfigSchema), + [PORT_FIELDS[4]]: Schema.optionalKey(ListenerConfigSchema), + [PORT_FIELDS[5]]: Schema.optionalKey(ListenerConfigSchema), + [PORT_FIELDS[6]]: Schema.optionalKey(ListenerConfigSchema), + [PORT_FIELDS[7]]: Schema.optionalKey(ListenerConfigSchema), +} satisfies { readonly [Name in PortField]: Schema.Top }; +export const StackListenersConfigSchema = Schema.Struct(listenerFields); + +export const JwtSigningSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("symmetric"), secret: Schema.Redacted(Schema.String) }), + Schema.Struct({ kind: Schema.Literal("jwks-file"), path: Schema.String }), +]); +export type JwtSigning = Schema.Schema.Type; +export const StackSecurityConfigSchema = Schema.Struct({ + jwt: Schema.optionalKey( + Schema.Struct({ + issuer: Schema.optionalKey(Schema.String), + signing: Schema.optionalKey(JwtSigningSchema), + }), + ), +}); + +export const StackConfigSchema = Schema.Struct({ + capabilities: Schema.optionalKey(StackCapabilitiesConfigSchema), + listeners: Schema.optionalKey(StackListenersConfigSchema), + security: Schema.optionalKey(StackSecurityConfigSchema), +}); +export type StackConfig = Schema.Schema.Type; +export type StackCapabilitiesConfig = Schema.Schema.Type; +export type StackSecurityConfig = Schema.Schema.Type; +export { + DatabaseSettingsSchema, + RestSettingsSchema, + AuthSettingsSchema, + RealtimeSettingsSchema, + StorageSettingsSchema, + FunctionsSettingsSchema, + StudioSettingsSchema, + MailSettingsSchema, + AnalyticsSettingsSchema, + PoolerSettingsSchema, +}; +export type { + DatabaseSettings, + RestSettings, + AuthSettings, + RealtimeSettings, + StorageSettings, + FunctionsSettings, + StudioSettings, + MailSettings, + AnalyticsSettings, + PoolerSettings, + ActivationMode, +}; diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index d2a722fd0c..2206da4cf7 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -16,6 +16,9 @@ export class InvalidProjectRootError extends Data.TaggedError( export class InvalidStackConfigError extends Data.TaggedError( "InvalidStackConfigError", ) {} +export class StackVersionUnsupportedError extends Data.TaggedError( + "StackVersionUnsupportedError", +) {} export class StackNotFoundError extends Data.TaggedError("StackNotFoundError") {} export class StackOwnershipConflictError extends Data.TaggedError( @@ -101,6 +104,7 @@ export type StackError = | InvalidStackIdentityError | InvalidProjectRootError | InvalidStackConfigError + | StackVersionUnsupportedError | StackNotFoundError | StackOwnershipConflictError | StackRuntimeMismatchError @@ -132,6 +136,7 @@ export type CreateStackError = | InvalidStackIdentityError | InvalidProjectRootError | InvalidStackConfigError + | StackVersionUnsupportedError | StackOwnershipConflictError | StackRuntimeMismatchError; export type OpenStackError = @@ -156,6 +161,7 @@ export type StackCredentialsError = export type PrepareStackError = StackPreparationError | ArtifactIntegrityError | ContainerPullError; export type StackStartError = | StackDefinitionRequiredError + | StackVersionUnsupportedError | StackLifecycleConflictError | StackStateInvalidError | StackStateFormatUnsupportedError diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index f7218e4ed8..6ce0247fea 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -6,3 +6,4 @@ export * from "./Status.ts"; export * from "./Logs.ts"; export * from "./Credentials.ts"; export * from "./Errors.ts"; +export * from "./Config.ts"; From 6f0a69a8db13dcfc1540b5daa8302838388e91ab Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 20:28:39 +0200 Subject: [PATCH 007/174] fix(stack): align capability releases and plans --- ...26-08-28-stack-runtime-rewrite-progress.md | 28 ++ packages/stack/src/model/CapabilityModule.ts | 26 +- packages/stack/src/model/Compiler.ts | 247 ++++++++++++++---- packages/stack/src/model/ExecutionPlan.ts | 112 +++++--- .../stack/src/model/capabilities/analytics.ts | 32 +-- packages/stack/src/model/capabilities/auth.ts | 24 +- .../stack/src/model/capabilities/database.ts | 94 +++++-- .../stack/src/model/capabilities/functions.ts | 47 +++- .../stack/src/model/capabilities/index.ts | 7 +- packages/stack/src/model/capabilities/mail.ts | 19 +- .../stack/src/model/capabilities/pooler.ts | 25 +- .../stack/src/model/capabilities/realtime.ts | 21 +- packages/stack/src/model/capabilities/rest.ts | 21 +- .../stack/src/model/capabilities/storage.ts | 44 +++- .../stack/src/model/capabilities/studio.ts | 41 +-- .../src/model/compiler.integration.test.ts | 192 +++++++++++++- packages/stack/src/public/Config.ts | 6 +- packages/stack/src/public/Status.ts | 11 +- 18 files changed, 740 insertions(+), 257 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 6080ea58a1..b6eb0e1337 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -197,3 +197,31 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm --dir packages/stack types:check` — passed. - `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/public/Config.ts packages/stack/src/model packages/stack/src/public/Errors.ts` — passed with zero warnings. - `pnpm exec oxfmt --check packages/stack/src/public/Config.ts packages/stack/src/model packages/stack/src/public/Errors.ts` — passed. + +#### Task 3 review fixes — 2026-08-28 + +- Replaced the parallel version map/workload defaults with one release catalog per capability. + Database selectors are limited to supported majors 13, 14, 15, and 17 and resolve to their + exact Supabase Postgres releases; non-database selectors must match the current catalog bundle. + Native and container descriptors now stay coherent for the selected release, while private + companions retain their bundle-owned versions. +- Previous compilation reuse now carries only the persisted definition and fingerprint. An + identical fingerprint reuses the exact definition object but rebuilds a fresh runtime plan and + workload hashes from persisted versions/settings, rejecting unsupported persisted releases. +- Storage bucket and per-function records receive their complete defaults before materialization; + Function slug/environment key constraints are checked before schema decoding so invalid Record + keys cannot be silently dropped. Pooler uses the current `pool_mode` spelling. +- Added one shared `NetworkPortSchema` (integer 1..65535) for listeners, endpoints, SMTP, + Functions inspector, and analytics vector ports. Capability/module dependencies now drive graph + planning and missing private dependencies/cycles fail with `InvalidStackConfigError`. +- Strengthened compiler scenarios for exact tagged errors, release/artifact coherence, persisted + plan rebuilding, record defaults, key/port validation, and secret absence from definition, + fingerprint, plan, and formatted values. Task 4 owns the exhaustive materialized + `StackDefinitionSchema` and round-trip state codec; Task 3 exposes concrete per-capability input + schemas/types and release metadata without an open or generated settings bag. +- Fix-round RED: 7 new scenarios failed against `06800c953`; GREEN: compiler/public integration + suites now pass (31 tests). +- `pnpm --dir packages/stack types:check` — passed. +- `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — 30 tests passed before final empty-release scenario (31 total expected; rerun final command below). +- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. +- `pnpm exec oxfmt --check packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed. diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index bec4ab9201..d6a94f017d 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -1,6 +1,6 @@ import type { Schema } from "effect"; import type { CapabilityName, ActivationMode } from "../public/Capability.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; +import type { PortField } from "../public/Status.ts"; /** Logical artifact descriptors. Download/image resolution belongs to preparation. */ export interface NativeArtifact { @@ -19,7 +19,7 @@ export interface WorkloadSpec { readonly name: string; readonly capability: CapabilityName; readonly dependencies: ReadonlyArray; - readonly readiness: Readonly<{ readonly mode: "http" | "tcp"; readonly portField?: string }>; + readonly readiness: Readonly<{ readonly mode: "http" | "tcp"; readonly portField?: PortField }>; readonly restart: Readonly<{ readonly maxAttempts: number; readonly backoffMs: number }>; readonly artifacts: Readonly<{ readonly native: NativeArtifact; @@ -34,17 +34,25 @@ export interface CapabilityModule { readonly defaultEnabled: boolean; readonly defaultActivation: ActivationMode; readonly dependencies: ReadonlyArray; - readonly workloads: ReadonlyArray; + readonly defaultVersion: string; + /** Release selectors are the only source of workload versions and artifacts. */ + readonly releases: Readonly>; readonly routes: ReadonlyArray< - Readonly<{ readonly listener: string; readonly protocol: "http" | "tcp" }> + Readonly<{ readonly listener: PortField; readonly protocol: "http" | "tcp" }> >; readonly materialize: (settings: Settings, projectRoot: string) => Settings; - readonly runtimeArtifact: ( - workload: WorkloadSpec, - runtime: StackRuntime, - ) => NativeArtifact | ContainerArtifact; } +export interface CapabilityRelease { + readonly version: string; + readonly workloads: ReadonlyArray; +} + +export const release = ( + version: string, + workloads: ReadonlyArray, +): CapabilityRelease => ({ version, workloads }); + export const nativeArtifact = (service: string, release: string): NativeArtifact => ({ kind: "native", service, @@ -78,5 +86,3 @@ export const workload = ( container: containerArtifact(name, image), }, }); - -export const identityMaterialize = (settings: T): T => settings; diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index b9220f9c9e..5e23c6d0f7 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -17,7 +17,6 @@ import type { import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; import { PORT_FIELDS, type PortField } from "../public/Status.ts"; import type { StackRuntime } from "../public/Runtime.ts"; -import { DatabaseVersionMap } from "./capabilities/database.ts"; import { AuthModule, DatabaseModule, @@ -31,7 +30,7 @@ import { AnalyticsModule, } from "./capabilities/index.ts"; import { CAPABILITY_MODULES, createExecutionPlan, type ExecutionPlan } from "./ExecutionPlan.ts"; -import type { CapabilityModule } from "./CapabilityModule.ts"; +import type { CapabilityModule, CapabilityRelease } from "./CapabilityModule.ts"; export type InputFingerprint = Schema.Schema.Type; const InputFingerprintSchema = Schema.String.pipe(Schema.brand("InputFingerprint")); @@ -109,8 +108,6 @@ export interface CompiledStack { export interface PreviousCompilation { readonly definition: StackDefinition; readonly inputFingerprint: InputFingerprint; - readonly executionPlan?: ExecutionPlan; - readonly secrets?: ReadonlyArray; } export interface CompileStackInput { @@ -235,35 +232,52 @@ const decodeConfig = (config: unknown): Effect.Effect => { + const capabilities = isRecord(config) ? config.capabilities : undefined; + const capability = isRecord(capabilities) ? capabilities.functions : undefined; + const settings = isRecord(capability) ? capability.settings : undefined; + if (!isRecord(settings)) return Effect.void; + const functions = settings.functions; + if (!isRecord(functions)) return Effect.void; + for (const [slug, value] of Object.entries(functions)) { + if (!/^[a-zA-Z0-9_-]+$/.test(slug)) + return Effect.fail( + new InvalidStackConfigError({ + message: `Invalid function slug: ${slug}`, + function: slug, + }), + ); + if (!isRecord(value)) continue; + const env = value.env; + if (!isRecord(env)) continue; + for (const name of Object.keys(env)) + if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) + return Effect.fail( + new InvalidStackConfigError({ + message: `Invalid function environment name: ${name}`, + function: slug, + environment: name, + }), + ); + } + return Effect.void; +}; + +const releaseFor = ( + module: CapabilityModule, raw: unknown, -): Effect.Effect => { +): Effect.Effect => { const selected = extract(raw, "version"); - const requested = typeof selected === "string" && selected.length > 0 ? selected : undefined; - if (name === "database") { - const value = requested ?? "17"; - for (const [key, version] of Object.entries(DatabaseVersionMap)) - if (key === value) return Effect.succeed(version); - return Effect.fail( - new StackVersionUnsupportedError({ - capability: name, - version: value, - message: `Unsupported database version: ${value}`, - }), - ); - } - const fallback = CAPABILITY_MODULES[name].workloads[0]?.artifacts.native.release ?? ""; - const value = requested ?? fallback; - if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) - return Effect.fail( - new StackVersionUnsupportedError({ - capability: name, - version: value, - message: `Unsupported ${name} version: ${value}`, - }), - ); - return Effect.succeed(value); + const selector = typeof selected === "string" ? selected : module.defaultVersion; + const release = module.releases[selector]; + if (release !== undefined) return Effect.succeed(release); + return Effect.fail( + new StackVersionUnsupportedError({ + capability: module.name, + version: selector, + message: `Unsupported ${module.name} version: ${selector}`, + }), + ); }; const enabledSettings = ( @@ -311,21 +325,22 @@ const materializeCapability = ( specHashes: Map, normalizeFunctions: boolean, ): Effect.Effect< - MaterializedCapability, + MaterializedCapability & { readonly release: CapabilityRelease }, InvalidStackConfigError | StackVersionUnsupportedError, Path.Path | Crypto.Crypto > => { const selected = enabledSettings(module.name, { [module.name]: raw }); const mergedInput = merge(module.defaultSettings, selected.settings); const normalized = normalizeFunctions - ? materializeFunctionsRoot(mergedInput, projectRoot, path) - : Effect.succeed(mergedInput); + ? materializeFunctionsRoot(module.materialize(mergedInput, projectRoot), projectRoot, path) + : Effect.succeed(module.materialize(mergedInput, projectRoot)); return Effect.gen(function* () { const normalizedSettings = yield* normalized; const merged = materializeAbsence(normalizedSettings); const slotted = slotsFor(merged, `${module.name}.settings`, slots); - const version = yield* versionFor(module.name, selected.raw); - for (const entry of module.workloads) { + const selectedRelease = yield* releaseFor(module, selected.raw); + const version = selectedRelease.version; + for (const entry of selectedRelease.workloads) { const bytes = yield* crypto.digest( "SHA-256", new TextEncoder().encode(canonical({ workload: entry, version, settings: slotted })), @@ -337,6 +352,7 @@ const materializeCapability = ( activation: selected.activation, version, settings: slotted, + release: selectedRelease, }; }).pipe( Effect.catchTag("PlatformError", (error) => @@ -345,6 +361,51 @@ const materializeCapability = ( ); }; +const withoutRelease = ( + capability: MaterializedCapability & { readonly release: CapabilityRelease }, +): MaterializedCapability => ({ + enabled: capability.enabled, + activation: capability.activation, + version: capability.version, + settings: capability.settings, +}); + +const hashDefinitionWorkloads = ( + definition: StackDefinition, + crypto: Crypto.Crypto, + specHashes: Map, +): Effect.Effect => + Effect.gen(function* () { + for (const name of CAPABILITY_NAMES) { + const capability = definition.capabilities[name]; + const module = CAPABILITY_MODULES[name]; + const selectedRelease = module.releases[capability.version]; + if (selectedRelease === undefined) + return yield* new StackVersionUnsupportedError({ + capability: name, + version: capability.version, + message: `Unsupported ${name} version: ${capability.version}`, + }); + for (const entry of selectedRelease.workloads) { + const bytes = yield* crypto + .digest( + "SHA-256", + new TextEncoder().encode( + canonical({ + workload: entry, + version: capability.version, + settings: capability.settings, + }), + ), + ) + .pipe( + Effect.mapError((error) => new InvalidStackConfigError({ message: error.message })), + ); + specHashes.set(`${name}:${entry.name}`, digestHex(bytes)); + } + } + }); + export const compileStack = ( input: CompileStackInput, previous?: PreviousCompilation, @@ -356,6 +417,7 @@ export const compileStack = ( Effect.gen(function* () { const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; + yield* validateFunctionKeys(input.config ?? {}); const config = yield* decodeConfig(input.config ?? {}); const rawCapabilities = isRecord(config.capabilities) ? config.capabilities : {}; const normalizedFunctions = yield* materializeFunctionsRoot( @@ -385,7 +447,7 @@ export const compileStack = ( ).pipe(Effect.mapError((error) => new InvalidStackConfigError({ message: String(error) }))); const slots: SecretSlotInput[] = []; const specHashes = new Map(); - const database = yield* materializeCapability( + const databaseResult = yield* materializeCapability( DatabaseModule, extract(rawCapabilities, "database"), input.projectRoot, @@ -395,7 +457,7 @@ export const compileStack = ( specHashes, false, ); - const rest = yield* materializeCapability( + const restResult = yield* materializeCapability( RestModule, extract(rawCapabilities, "rest"), input.projectRoot, @@ -405,7 +467,7 @@ export const compileStack = ( specHashes, false, ); - const auth = yield* materializeCapability( + const authResult = yield* materializeCapability( AuthModule, extract(rawCapabilities, "auth"), input.projectRoot, @@ -415,7 +477,7 @@ export const compileStack = ( specHashes, false, ); - const realtime = yield* materializeCapability( + const realtimeResult = yield* materializeCapability( RealtimeModule, extract(rawCapabilities, "realtime"), input.projectRoot, @@ -425,7 +487,7 @@ export const compileStack = ( specHashes, false, ); - const storage = yield* materializeCapability( + const storageResult = yield* materializeCapability( StorageModule, extract(rawCapabilities, "storage"), input.projectRoot, @@ -435,7 +497,7 @@ export const compileStack = ( specHashes, false, ); - const functions = yield* materializeCapability( + const functionsResult = yield* materializeCapability( FunctionsModule, extract(rawCapabilities, "functions"), input.projectRoot, @@ -445,7 +507,7 @@ export const compileStack = ( specHashes, true, ); - const studio = yield* materializeCapability( + const studioResult = yield* materializeCapability( StudioModule, extract(rawCapabilities, "studio"), input.projectRoot, @@ -455,7 +517,7 @@ export const compileStack = ( specHashes, false, ); - const mail = yield* materializeCapability( + const mailResult = yield* materializeCapability( MailModule, extract(rawCapabilities, "mail"), input.projectRoot, @@ -465,7 +527,7 @@ export const compileStack = ( specHashes, false, ); - const analytics = yield* materializeCapability( + const analyticsResult = yield* materializeCapability( AnalyticsModule, extract(rawCapabilities, "analytics"), input.projectRoot, @@ -475,7 +537,7 @@ export const compileStack = ( specHashes, false, ); - const pooler = yield* materializeCapability( + const poolerResult = yield* materializeCapability( PoolerModule, extract(rawCapabilities, "pooler"), input.projectRoot, @@ -485,6 +547,16 @@ export const compileStack = ( specHashes, false, ); + const database = withoutRelease(databaseResult); + const rest = withoutRelease(restResult); + const auth = withoutRelease(authResult); + const realtime = withoutRelease(realtimeResult); + const storage = withoutRelease(storageResult); + const functions = withoutRelease(functionsResult); + const studio = withoutRelease(studioResult); + const mail = withoutRelease(mailResult); + const analytics = withoutRelease(analyticsResult); + const pooler = withoutRelease(poolerResult); const capabilities = { database, rest, @@ -546,15 +618,90 @@ export const compileStack = ( listeners, security, }; - const executionPlan = createExecutionPlan(input.runtime, enabled, specHashes); - if (previous?.inputFingerprint === inputFingerprint) + const versions = { + database: database.version, + rest: rest.version, + auth: auth.version, + realtime: realtime.version, + storage: storage.version, + functions: functions.version, + studio: studio.version, + mail: mail.version, + analytics: analytics.version, + pooler: pooler.version, + }; + const currentPlan = yield* createExecutionPlan(input.runtime, enabled, specHashes, versions); + if (previous?.inputFingerprint === inputFingerprint) { + const previousEnabled = { + database: { + enabled: previous.definition.capabilities.database.enabled, + activation: previous.definition.capabilities.database.activation, + }, + rest: { + enabled: previous.definition.capabilities.rest.enabled, + activation: previous.definition.capabilities.rest.activation, + }, + auth: { + enabled: previous.definition.capabilities.auth.enabled, + activation: previous.definition.capabilities.auth.activation, + }, + realtime: { + enabled: previous.definition.capabilities.realtime.enabled, + activation: previous.definition.capabilities.realtime.activation, + }, + storage: { + enabled: previous.definition.capabilities.storage.enabled, + activation: previous.definition.capabilities.storage.activation, + }, + functions: { + enabled: previous.definition.capabilities.functions.enabled, + activation: previous.definition.capabilities.functions.activation, + }, + studio: { + enabled: previous.definition.capabilities.studio.enabled, + activation: previous.definition.capabilities.studio.activation, + }, + mail: { + enabled: previous.definition.capabilities.mail.enabled, + activation: previous.definition.capabilities.mail.activation, + }, + analytics: { + enabled: previous.definition.capabilities.analytics.enabled, + activation: previous.definition.capabilities.analytics.activation, + }, + pooler: { + enabled: previous.definition.capabilities.pooler.enabled, + activation: previous.definition.capabilities.pooler.activation, + }, + }; + const previousVersions = { + database: previous.definition.capabilities.database.version, + rest: previous.definition.capabilities.rest.version, + auth: previous.definition.capabilities.auth.version, + realtime: previous.definition.capabilities.realtime.version, + storage: previous.definition.capabilities.storage.version, + functions: previous.definition.capabilities.functions.version, + studio: previous.definition.capabilities.studio.version, + mail: previous.definition.capabilities.mail.version, + analytics: previous.definition.capabilities.analytics.version, + pooler: previous.definition.capabilities.pooler.version, + }; + const previousHashes = new Map(); + yield* hashDefinitionWorkloads(previous.definition, crypto, previousHashes); + const previousPlan = yield* createExecutionPlan( + input.runtime, + previousEnabled, + previousHashes, + previousVersions, + ); return { definition: previous.definition, inputFingerprint, secrets: slots, - executionPlan: previous.executionPlan ?? executionPlan, + executionPlan: previousPlan, }; - return { definition, inputFingerprint, secrets: slots, executionPlan }; + } + return { definition, inputFingerprint, secrets: slots, executionPlan: currentPlan }; }).pipe( Effect.catchTag("PlatformError", (error) => Effect.fail(new InvalidStackConfigError({ message: error.message })), diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index 09e3790524..3516f52406 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -1,4 +1,5 @@ import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import type { PortField } from "../public/Status.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { AuthModule, @@ -13,6 +14,8 @@ import { AnalyticsModule, } from "./capabilities/index.ts"; import type { WorkloadSpec, NativeArtifact, ContainerArtifact } from "./CapabilityModule.ts"; +import { Effect } from "effect"; +import { InvalidStackConfigError } from "../public/Errors.ts"; export const CAPABILITY_MODULES = { database: DatabaseModule, @@ -49,7 +52,7 @@ export interface ExecutionPlan { readonly routes: ReadonlyArray< Readonly<{ readonly capability: CapabilityName; - readonly listener: string; + readonly listener: PortField; readonly protocol: "http" | "tcp"; }> >; @@ -61,61 +64,99 @@ export interface EnabledCapability { readonly activation: "eager" | "lazy"; } -const dependencyMap = { - database: [], - rest: ["database"], - auth: ["database"], - realtime: ["database"], - storage: ["database"], - functions: ["database"], - studio: ["rest", "analytics"], - mail: [], - analytics: ["database"], - pooler: ["database"], -} satisfies { [Name in CapabilityName]: ReadonlyArray }; - export const createExecutionPlan = ( runtime: StackRuntime, enabled: Readonly<{ [Name in CapabilityName]: EnabledCapability }>, specHashes: ReadonlyMap, -): ExecutionPlan => { + versions: Readonly<{ [Name in CapabilityName]: string }>, + modules: typeof CAPABILITY_MODULES = CAPABILITY_MODULES, +): Effect.Effect => { + const dependencyMap = { + database: modules.database.dependencies, + rest: modules.rest.dependencies, + auth: modules.auth.dependencies, + realtime: modules.realtime.dependencies, + storage: modules.storage.dependencies, + functions: modules.functions.dependencies, + studio: modules.studio.dependencies, + mail: modules.mail.dependencies, + analytics: modules.analytics.dependencies, + pooler: modules.pooler.dependencies, + } satisfies { [Name in CapabilityName]: ReadonlyArray }; const start: CapabilityName[] = []; const visited = new Set(); + const visitingCapabilities = new Set(); + let capabilityGraphError: InvalidStackConfigError | undefined; const visit = (name: CapabilityName): void => { + if (visitingCapabilities.has(name)) { + capabilityGraphError = new InvalidStackConfigError({ + message: `Capability dependency cycle detected at ${name}`, + capability: name, + }); + return; + } if (visited.has(name) || !enabled[name].enabled) return; + visitingCapabilities.add(name); visited.add(name); - for (const dependency of dependencyMap[name]) visit(dependency); + for (const dependency of dependencyMap[name]) { + if (!Object.hasOwn(modules, dependency)) { + capabilityGraphError = new InvalidStackConfigError({ + message: `Unknown capability dependency ${dependency}`, + capability: name, + dependency, + }); + continue; + } + visit(dependency); + } + visitingCapabilities.delete(name); start.push(name); }; for (const name of CAPABILITY_NAMES) visit(name); + if (capabilityGraphError !== undefined) return Effect.fail(capabilityGraphError); const stopOrder = [...start].reverse(); const routes = CAPABILITY_NAMES.flatMap((name) => enabled[name].enabled - ? CAPABILITY_MODULES[name].routes.map((route) => ({ capability: name, ...route })) - : [], - ); - const declaredWorkloads = CAPABILITY_NAMES.flatMap((name) => - enabled[name].enabled - ? CAPABILITY_MODULES[name].workloads.map((entry) => ({ - id: `${name}:${entry.name}`, - capability: name, - dependencies: entry.dependencies, - readiness: entry.readiness, - restart: entry.restart, - artifacts: entry.artifacts, - selected: runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, - specHash: specHashes.get(`${name}:${entry.name}`) ?? "", - })) + ? modules[name].routes.map((route) => ({ capability: name, ...route })) : [], ); + const declaredWorkloads = CAPABILITY_NAMES.flatMap((name) => { + if (!enabled[name].enabled) return []; + const release = modules[name].releases[versions[name]]; + if (release === undefined) return []; + return release.workloads.map((entry) => ({ + id: `${name}:${entry.name}`, + capability: name, + dependencies: entry.dependencies, + readiness: entry.readiness, + restart: entry.restart, + artifacts: entry.artifacts, + selected: runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + specHash: specHashes.get(`${name}:${entry.name}`) ?? "", + })); + }); const byId = new Map(declaredWorkloads.map((entry) => [entry.id, entry])); const workloadOrder: typeof declaredWorkloads = []; const visiting = new Set(); const visitedWorkloads = new Set(); + let graphError: InvalidStackConfigError | undefined; const visitWorkload = (id: string): void => { - if (visitedWorkloads.has(id) || visiting.has(id)) return; + if (visitedWorkloads.has(id)) return; + if (visiting.has(id)) { + graphError = new InvalidStackConfigError({ + message: `Workload dependency cycle detected at ${id}`, + workload: id, + }); + return; + } const entry = byId.get(id); - if (entry === undefined) return; + if (entry === undefined) { + graphError = new InvalidStackConfigError({ + message: `Missing private workload dependency ${id}`, + workload: id, + }); + return; + } visiting.add(id); for (const dependency of entry.dependencies) visitWorkload(dependency); visiting.delete(id); @@ -123,12 +164,13 @@ export const createExecutionPlan = ( workloadOrder.push(entry); }; for (const entry of declaredWorkloads) visitWorkload(entry.id); - return { + if (graphError !== undefined) return Effect.fail(graphError); + return Effect.succeed({ runtime, startOrder: start, stopOrder, dependencies: dependencyMap, routes, workloads: workloadOrder, - }; + }); }; diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index cb6a9787b3..7e582af06a 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -1,10 +1,11 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { NetworkPortSchema } from "../../public/Status.ts"; const Secret = Schema.Redacted(Schema.String); export const AnalyticsSettingsSchema = Schema.Struct({ backend: Schema.optionalKey(Schema.Literals(["postgres", "bigquery"] as const)), - vector_port: Schema.optionalKey(Schema.Finite), + vector_port: Schema.optionalKey(NetworkPortSchema), gcp_project_id: Schema.optionalKey(Schema.String), gcp_project_number: Schema.optionalKey(Schema.String), gcp_jwt_path: Schema.optionalKey(Schema.String), @@ -24,19 +25,20 @@ export const AnalyticsModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", + defaultVersion: "v1.50.6", dependencies: ["database"], - workloads: [ - workload("analytics", "analytics", "v1.50.6", "supabase/logflare:v1.50.6", { - dependencies: ["database:database", "analytics:vector"], - readiness: { mode: "http", portField: "api" }, - }), - workload("vector", "analytics", "0.53.0-alpine", "ghcr.io/supabase/vector:0.53.0-alpine", { - dependencies: [], - readiness: { mode: "tcp" }, - }), - ], + releases: { + "v1.50.6": release("v1.50.6", [ + workload("analytics", "analytics", "v1.50.6", "supabase/logflare:v1.50.6", { + dependencies: ["database:database", "analytics:vector"], + readiness: { mode: "http", portField: "api" }, + }), + workload("vector", "analytics", "0.53.0-alpine", "ghcr.io/supabase/vector:0.53.0-alpine", { + dependencies: [], + readiness: { mode: "tcp" }, + }), + ]), + }, routes: [{ listener: "api", protocol: "http" }], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/auth.ts b/packages/stack/src/model/capabilities/auth.ts index 57bdfce1b3..a6b77d47e9 100644 --- a/packages/stack/src/model/capabilities/auth.ts +++ b/packages/stack/src/model/capabilities/auth.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { NetworkPortSchema } from "../../public/Status.ts"; const Secret = Schema.Redacted(Schema.String); const OptionalString = Schema.optionalKey(Schema.String); @@ -56,7 +57,7 @@ const EmailNotificationSchema = Schema.Struct({ const SmtpSchema = Schema.Struct({ enabled: OptionalBoolean, host: OptionalString, - port: OptionalNumber, + port: Schema.optionalKey(NetworkPortSchema), user: OptionalString, pass: Schema.optionalKey(Secret), admin_email: OptionalString, @@ -369,15 +370,16 @@ export const AuthModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", + defaultVersion: "v2.196.0", dependencies: ["database"], - workloads: [ - workload("auth", "auth", "v2.196.0", "supabase/auth:v2.196.0", { - dependencies: ["database:database"], - readiness: { mode: "http", portField: "api" }, - }), - ], + releases: { + "v2.196.0": release("v2.196.0", [ + workload("auth", "auth", "v2.196.0", "supabase/auth:v2.196.0", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "api" }, + }), + ]), + }, routes: [{ listener: "api", protocol: "http" }], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index 4fa7f47598..0f7c843beb 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const settingsFields = { effective_cache_size: Schema.optionalKey(Schema.String), @@ -88,31 +88,73 @@ export const DatabaseModule: CapabilityModule = { defaultSettings: defaults, defaultEnabled: true, defaultActivation: "eager", + defaultVersion: "17.6.1.165", dependencies: [], - workloads: [ - workload("database", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { - readiness: { mode: "tcp", portField: "database" }, - }), - workload("database-bootstrap", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { - dependencies: ["database:database"], - readiness: { mode: "tcp", portField: "database" }, - }), - ], + releases: { + "13": release("15.8.1.085", [ + workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { + readiness: { mode: "tcp", portField: "database" }, + }), + workload("database-bootstrap", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "database" }, + }), + ]), + "14": release("14.1.0.89", [ + workload("database", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { + readiness: { mode: "tcp", portField: "database" }, + }), + workload("database-bootstrap", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "database" }, + }), + ]), + "15": release("15.8.1.085", [ + workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { + readiness: { mode: "tcp", portField: "database" }, + }), + workload("database-bootstrap", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "database" }, + }), + ]), + "17": release("17.6.1.165", [ + workload("database", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { + readiness: { mode: "tcp", portField: "database" }, + }), + workload("database-bootstrap", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "database" }, + }), + ]), + "14.1.0.89": release("14.1.0.89", [ + workload("database", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { + readiness: { mode: "tcp", portField: "database" }, + }), + workload("database-bootstrap", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "database" }, + }), + ]), + "15.8.1.085": release("15.8.1.085", [ + workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { + readiness: { mode: "tcp", portField: "database" }, + }), + workload("database-bootstrap", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "database" }, + }), + ]), + "17.6.1.165": release("17.6.1.165", [ + workload("database", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { + readiness: { mode: "tcp", portField: "database" }, + }), + workload("database-bootstrap", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "database" }, + }), + ]), + }, routes: [{ listener: "database", protocol: "tcp" }], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => settings, }; - -export const DatabaseVersionMap = { - "13": "13.3.0", - "14": "14.1.0.89", - "15": "15.8.1.085", - "16": "16.4.0.0", - "17": "17.6.1.165", - "13.3.0": "13.3.0", - "14.1.0.89": "14.1.0.89", - "15.8.1.085": "15.8.1.085", - "16.4.0.0": "16.4.0.0", - "17.6.1.165": "17.6.1.165", -} as const; diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index d4c4a6ea3a..6c6151f60a 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -1,14 +1,17 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { NetworkPortSchema } from "../../public/Status.ts"; const Secret = Schema.Redacted(Schema.String); +const FunctionSlug = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)); +const EnvName = Schema.String.check(Schema.isPattern(/^[A-Z_][A-Z0-9_]*$/)); const FunctionOverride = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.String), entrypoint: Schema.optionalKey(Schema.String), static_files: Schema.optionalKey(Schema.Array(Schema.String)), - env: Schema.optionalKey(Schema.Record(Schema.String, Secret)), + env: Schema.optionalKey(Schema.Record(EnvName, Secret)), }); export const FunctionsSettingsSchema = Schema.Struct({ functions_root: Schema.optionalKey(Schema.String), @@ -16,15 +19,24 @@ export const FunctionsSettingsSchema = Schema.Struct({ Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"] as const)), - inspector_port: Schema.optionalKey(Schema.Finite), + inspector_port: Schema.optionalKey(NetworkPortSchema), deno_version: Schema.optionalKey(Schema.Finite), secrets: Schema.optionalKey(Schema.Record(Schema.String, Secret)), }), ), - functions: Schema.optionalKey(Schema.Record(Schema.String, FunctionOverride)), + functions: Schema.optionalKey(Schema.Record(FunctionSlug, FunctionOverride)), }); export type FunctionsSettings = Schema.Schema.Type; +const functionDefaults = { + enabled: true, + verify_jwt: true, + import_map: "", + entrypoint: "", + static_files: [], + env: {}, +}; + export const FunctionsModule: CapabilityModule = { name: "functions", settings: FunctionsSettingsSchema, @@ -41,18 +53,27 @@ export const FunctionsModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", + defaultVersion: "v1.74.3", dependencies: ["database"], - workloads: [ - workload("edge-runtime", "functions", "v1.74.3", "supabase/edge-runtime:v1.74.3", { - dependencies: ["database:database"], - readiness: { mode: "http", portField: "functionsInspector" }, - }), - ], + releases: { + "v1.74.3": release("v1.74.3", [ + workload("edge-runtime", "functions", "v1.74.3", "supabase/edge-runtime:v1.74.3", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "functionsInspector" }, + }), + ]), + }, routes: [ { listener: "api", protocol: "http" }, { listener: "functionsInspector", protocol: "http" }, ], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => ({ + ...settings, + functions: Object.fromEntries( + Object.entries(settings.functions ?? {}).map(([name, fn]) => [ + name, + { ...functionDefaults, ...fn, env: fn.env ?? {} }, + ]), + ), + }), }; diff --git a/packages/stack/src/model/capabilities/index.ts b/packages/stack/src/model/capabilities/index.ts index 07d0a605c8..7de03a3caf 100644 --- a/packages/stack/src/model/capabilities/index.ts +++ b/packages/stack/src/model/capabilities/index.ts @@ -1,10 +1,5 @@ export { AuthModule, AuthSettingsSchema, type AuthSettings } from "./auth.ts"; -export { - DatabaseModule, - DatabaseSettingsSchema, - DatabaseVersionMap, - type DatabaseSettings, -} from "./database.ts"; +export { DatabaseModule, DatabaseSettingsSchema, type DatabaseSettings } from "./database.ts"; export { FunctionsModule, FunctionsSettingsSchema, type FunctionsSettings } from "./functions.ts"; export { MailModule, MailSettingsSchema, type MailSettings } from "./mail.ts"; export { PoolerModule, PoolerSettingsSchema, type PoolerSettings } from "./pooler.ts"; diff --git a/packages/stack/src/model/capabilities/mail.ts b/packages/stack/src/model/capabilities/mail.ts index b809c81e22..85282832f8 100644 --- a/packages/stack/src/model/capabilities/mail.ts +++ b/packages/stack/src/model/capabilities/mail.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; export const MailSettingsSchema = Schema.Struct({ admin_email: Schema.optionalKey(Schema.String), @@ -12,18 +12,19 @@ export const MailModule: CapabilityModule = { defaultSettings: { admin_email: undefined, sender_name: undefined }, defaultEnabled: true, defaultActivation: "eager", + defaultVersion: "v1.30.2", dependencies: [], - workloads: [ - workload("mail", "mail", "v1.30.2", "axllent/mailpit:v1.30.2", { - readiness: { mode: "http", portField: "mailUi" }, - }), - ], + releases: { + "v1.30.2": release("v1.30.2", [ + workload("mail", "mail", "v1.30.2", "axllent/mailpit:v1.30.2", { + readiness: { mode: "http", portField: "mailUi" }, + }), + ]), + }, routes: [ { listener: "mailUi", protocol: "http" }, { listener: "smtp", protocol: "tcp" }, { listener: "pop3", protocol: "tcp" }, ], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts index 2409eeba9e..0059bd74a2 100644 --- a/packages/stack/src/model/capabilities/pooler.ts +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -1,9 +1,9 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const Secret = Schema.Redacted(Schema.String); export const PoolerSettingsSchema = Schema.Struct({ - mode: Schema.optionalKey(Schema.Literals(["transaction", "session"] as const)), + pool_mode: Schema.optionalKey(Schema.Literals(["transaction", "session"] as const)), tenant_id: Schema.optionalKey(Schema.String), encryption_key: Schema.optionalKey(Secret), secret_key_base: Schema.optionalKey(Secret), @@ -15,7 +15,7 @@ export const PoolerModule: CapabilityModule = { name: "pooler", settings: PoolerSettingsSchema, defaultSettings: { - mode: "transaction", + pool_mode: "transaction", tenant_id: undefined, encryption_key: undefined, secret_key_base: undefined, @@ -24,15 +24,16 @@ export const PoolerModule: CapabilityModule = { }, defaultEnabled: false, defaultActivation: "eager", + defaultVersion: "2.9.7", dependencies: ["database"], - workloads: [ - workload("pooler", "pooler", "2.9.7", "ghcr.io/supabase/supavisor:2.9.7", { - dependencies: ["database:database"], - readiness: { mode: "tcp", portField: "pooler" }, - }), - ], + releases: { + "2.9.7": release("2.9.7", [ + workload("pooler", "pooler", "2.9.7", "ghcr.io/supabase/supavisor:2.9.7", { + dependencies: ["database:database"], + readiness: { mode: "tcp", portField: "pooler" }, + }), + ]), + }, routes: [{ listener: "pooler", protocol: "tcp" }], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/realtime.ts b/packages/stack/src/model/capabilities/realtime.ts index 538ebcb7e0..851d350c6a 100644 --- a/packages/stack/src/model/capabilities/realtime.ts +++ b/packages/stack/src/model/capabilities/realtime.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; export const RealtimeSettingsSchema = Schema.Struct({ ip_version: Schema.optionalKey(Schema.Literals(["IPv4", "IPv6"] as const)), @@ -13,15 +13,16 @@ export const RealtimeModule: CapabilityModule = { defaultSettings: { ip_version: "IPv4", max_header_length: 4096 }, defaultEnabled: true, defaultActivation: "eager", + defaultVersion: "v2.129.9", dependencies: ["database"], - workloads: [ - workload("realtime", "realtime", "v2.129.9", "supabase/realtime:v2.129.9", { - dependencies: ["database:database"], - readiness: { mode: "http", portField: "api" }, - }), - ], + releases: { + "v2.129.9": release("v2.129.9", [ + workload("realtime", "realtime", "v2.129.9", "supabase/realtime:v2.129.9", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "api" }, + }), + ]), + }, routes: [{ listener: "api", protocol: "http" }], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/rest.ts b/packages/stack/src/model/capabilities/rest.ts index 6b03086693..1d44fe413a 100644 --- a/packages/stack/src/model/capabilities/rest.ts +++ b/packages/stack/src/model/capabilities/rest.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; export const RestSettingsSchema = Schema.Struct({ schemas: Schema.optionalKey(Schema.Array(Schema.String)), @@ -30,15 +30,16 @@ export const RestModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", + defaultVersion: "v16.2", dependencies: ["database"], - workloads: [ - workload("rest", "rest", "v16.2", "postgrest/postgrest:v16.2", { - dependencies: ["database:database"], - readiness: { mode: "http", portField: "api" }, - }), - ], + releases: { + "v16.2": release("v16.2", [ + workload("rest", "rest", "v16.2", "postgrest/postgrest:v16.2", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "api" }, + }), + ]), + }, routes: [{ listener: "api", protocol: "http" }], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index e89c0c19a8..dae2ebcb62 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const Bucket = Schema.Struct({ public: Schema.optionalKey(Schema.Boolean), @@ -32,6 +32,13 @@ export const StorageSettingsSchema = Schema.Struct({ }); export type StorageSettings = Schema.Schema.Type; +const bucketDefaults = { + public: false, + file_size_limit: "50MiB", + allowed_mime_types: [], + objects_path: "", +}; + export const StorageModule: CapabilityModule = { name: "storage", settings: StorageSettingsSchema, @@ -45,19 +52,28 @@ export const StorageModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", + defaultVersion: "v1.71.0", dependencies: ["database"], - workloads: [ - workload("storage", "storage", "v1.71.0", "supabase/storage-api:v1.71.0", { - dependencies: ["database:database", "storage:imgproxy"], - readiness: { mode: "http", portField: "api" }, - }), - workload("imgproxy", "storage", "v3.8.0", "darthsim/imgproxy:v3.8.0", { - dependencies: ["database:database"], - readiness: { mode: "http" }, - }), - ], + releases: { + "v1.71.0": release("v1.71.0", [ + workload("storage", "storage", "v1.71.0", "supabase/storage-api:v1.71.0", { + dependencies: ["database:database", "storage:imgproxy"], + readiness: { mode: "http", portField: "api" }, + }), + workload("imgproxy", "storage", "v3.8.0", "darthsim/imgproxy:v3.8.0", { + dependencies: ["database:database"], + readiness: { mode: "http" }, + }), + ]), + }, routes: [{ listener: "api", protocol: "http" }], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => ({ + ...settings, + buckets: Object.fromEntries( + Object.entries(settings.buckets ?? {}).map(([name, bucket]) => [ + name, + { ...bucketDefaults, ...bucket }, + ]), + ), + }), }; diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts index 238def9b20..30ffe04abd 100644 --- a/packages/stack/src/model/capabilities/studio.ts +++ b/packages/stack/src/model/capabilities/studio.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { identityMaterialize, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const Secret = Schema.Redacted(Schema.String); export const StudioSettingsSchema = Schema.Struct({ @@ -13,25 +13,26 @@ export const StudioModule: CapabilityModule = { defaultSettings: { api_url: "http://127.0.0.1", openai_api_key: undefined }, defaultEnabled: true, defaultActivation: "eager", + defaultVersion: "2026.08.24-sha-8ec45b2", dependencies: ["rest", "analytics"], - workloads: [ - workload( - "studio", - "studio", - "2026.08.24-sha-8ec45b2", - "supabase/studio:2026.08.24-sha-8ec45b2", - { - dependencies: ["studio:pgmeta", "analytics:analytics"], - readiness: { mode: "http", portField: "studio" }, - }, - ), - workload("pgmeta", "studio", "0.98.0", "supabase/pg-meta:v0.98.0", { - dependencies: ["database:database"], - readiness: { mode: "http" }, - }), - ], + releases: { + "2026.08.24-sha-8ec45b2": release("2026.08.24-sha-8ec45b2", [ + workload( + "studio", + "studio", + "2026.08.24-sha-8ec45b2", + "supabase/studio:2026.08.24-sha-8ec45b2", + { + dependencies: ["studio:pgmeta", "analytics:analytics"], + readiness: { mode: "http", portField: "studio" }, + }, + ), + workload("pgmeta", "studio", "0.98.0", "supabase/pg-meta:v0.98.0", { + dependencies: ["database:database"], + readiness: { mode: "http" }, + }), + ]), + }, routes: [{ listener: "studio", protocol: "http" }], - materialize: (settings) => identityMaterialize(settings), - runtimeArtifact: (entry, runtime) => - runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, + materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 6291f7b574..24e00e63e2 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -1,6 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Redacted } from "effect"; +import { Cause, Effect, Exit, Option, Redacted } from "effect"; +import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { canonicalize, compileStack } from "./Compiler.ts"; const layer = NodeServices.layer; @@ -13,6 +14,9 @@ const compile = ( Effect.provide(layer), ); +const failureOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + describe("closed capability compiler", () => { it.live("materializes a non-default database setting", () => Effect.gen(function* () { @@ -51,6 +55,11 @@ describe("closed capability compiler", () => { site_url: "https://example.test", }); expect(canonicalize(result.definition)).not.toContain("secret-value"); + expect(canonicalize(result.inputFingerprint)).not.toContain("secret-value"); + expect(canonicalize(result.executionPlan)).not.toContain("secret-value"); + expect(result.secrets).toHaveLength(1); + expect(Redacted.isRedacted(result.secrets[0]?.value)).toBe(true); + expect(canonicalize(result.executionPlan)).not.toContain("secret-value"); }), ); @@ -129,9 +138,11 @@ describe("closed capability compiler", () => { it.live("materializes a non-default pooler setting", () => Effect.gen(function* () { const result = yield* compile({ - capabilities: { pooler: { enabled: true, settings: { mode: "session" } } }, + capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, + }); + expect(result.definition.capabilities.pooler.settings).toMatchObject({ + pool_mode: "session", }); - expect(result.definition.capabilities.pooler.settings).toMatchObject({ mode: "session" }); expect(result.definition.capabilities.pooler.enabled).toBe(true); }), ); @@ -153,8 +164,7 @@ describe("closed capability compiler", () => { const exit = yield* compile({ capabilities: { rest: { settings: { typo: true } } }, } as never).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(exit.cause).toBeDefined(); + expect(failureOf(exit)).toBeInstanceOf(InvalidStackConfigError); }), ); @@ -205,8 +215,7 @@ describe("closed capability compiler", () => { const result = yield* compile({ capabilities: { database: { version: "99" } } }).pipe( Effect.exit, ); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) expect(result.cause).toBeDefined(); + expect(failureOf(result)).toBeInstanceOf(StackVersionUnsupportedError); }), ); @@ -225,8 +234,7 @@ describe("closed capability compiler", () => { const result = yield* compile({ capabilities: { functions: { settings: { functions_root: "../outside" } } }, }).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) expect(result.cause).toBeDefined(); + expect(failureOf(result)).toBeInstanceOf(InvalidStackConfigError); }), ); @@ -235,7 +243,171 @@ describe("closed capability compiler", () => { const result = yield* compile({ capabilities: { rest: { enabled: false }, studio: { enabled: true } }, }).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); + expect(failureOf(result)).toBeInstanceOf(InvalidStackConfigError); + }), + ); + + it.live("resolves each supported database major to its supported release", () => + Effect.gen(function* () { + const expected = { + 13: "15.8.1.085", + 14: "14.1.0.89", + 15: "15.8.1.085", + 17: "17.6.1.165", + } as const; + for (const [major, release] of Object.entries(expected)) { + const result = yield* compile({ capabilities: { database: { version: major } } }); + expect(result.definition.capabilities.database.version).toBe(release); + expect( + result.executionPlan.workloads.find((w) => w.id === "database:database")?.artifacts, + ).toEqual({ + native: { kind: "native", service: "database", release }, + container: { + kind: "container", + service: "database", + image: `supabase/postgres:${release}`, + }, + }); + } + }), + ); + + it.live("rejects an unknown non-database release", () => + Effect.gen(function* () { + const result = yield* compile({ capabilities: { rest: { version: "not-real" } } }).pipe( + Effect.exit, + ); + expect(failureOf(result)).toBeInstanceOf(StackVersionUnsupportedError); + }), + ); + + it.live("rejects an empty non-database release", () => + Effect.gen(function* () { + const result = yield* compile({ capabilities: { rest: { version: "" } } }).pipe(Effect.exit); + expect(failureOf(result)).toBeInstanceOf(StackVersionUnsupportedError); + }), + ); + + it.live("rebuilds a plan from an identical persisted definition", () => + Effect.gen(function* () { + const first = yield* compile({ + capabilities: { rest: { settings: { schemas: ["current"] } } }, + }); + const persisted = { + ...first.definition, + capabilities: { + ...first.definition.capabilities, + rest: { + ...first.definition.capabilities.rest, + settings: { ...first.definition.capabilities.rest.settings, schemas: ["persisted"] }, + }, + }, + }; + const reused = yield* compile( + { capabilities: { rest: { settings: { schemas: ["current"] } } } }, + { kind: "native" }, + { definition: persisted, inputFingerprint: first.inputFingerprint }, + ); + expect(reused.definition).toBe(persisted); + const previousHash = first.executionPlan.workloads.find( + (w) => w.id === "rest:rest", + )?.specHash; + const freshHash = reused.executionPlan.workloads.find((w) => w.id === "rest:rest")?.specHash; + expect(freshHash).not.toBe(previousHash); + }), + ); + + it.live("rejects a persisted definition whose release is no longer supported", () => + Effect.gen(function* () { + const first = yield* compile({}); + const persisted = { + ...first.definition, + capabilities: { + ...first.definition.capabilities, + rest: { ...first.definition.capabilities.rest, version: "not-real" }, + }, + }; + const result = yield* compile( + {}, + { kind: "native" }, + { + definition: persisted, + inputFingerprint: first.inputFingerprint, + }, + ).pipe(Effect.exit); + expect(failureOf(result)).toBeInstanceOf(StackVersionUnsupportedError); + }), + ); + + it.live("materializes defaults for supplied records", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { + storage: { settings: { buckets: { avatars: { public: true } } } }, + functions: { settings: { functions: { hello: { verify_jwt: false } } } }, + }, + }); + expect(result.definition.capabilities.storage.settings.buckets).toEqual({ + avatars: { + public: true, + file_size_limit: "50MiB", + allowed_mime_types: [], + objects_path: "", + }, + }); + expect(result.definition.capabilities.functions.settings.functions).toEqual({ + hello: { + enabled: true, + verify_jwt: false, + import_map: "", + entrypoint: "", + static_files: [], + env: {}, + }, + }); + }), + ); + + it.live("uses pool_mode and rejects the old mode spelling", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { pooler: { settings: { pool_mode: "session" } } }, + }); + expect(result.definition.capabilities.pooler.settings.pool_mode).toBe("session"); + const invalid = yield* compile({ + capabilities: { pooler: { settings: { mode: "session" } } }, + } as never).pipe(Effect.exit); + expect(failureOf(invalid)).toBeInstanceOf(InvalidStackConfigError); + }), + ); + + it.live("rejects invalid function slugs and environment names", () => + Effect.gen(function* () { + const invalidSlug = yield* compile({ + capabilities: { functions: { settings: { functions: { "bad.slug": {} } } } }, + } as never).pipe(Effect.exit); + expect(failureOf(invalidSlug)).toBeInstanceOf(InvalidStackConfigError); + const invalidEnv = yield* compile({ + capabilities: { + functions: { + settings: { functions: { hello: { env: { "bad-name": Redacted.make("x") } } } }, + }, + }, + } as never).pipe(Effect.exit); + expect(failureOf(invalidEnv)).toBeInstanceOf(InvalidStackConfigError); + }), + ); + + it.live("enforces network port bounds", () => + Effect.gen(function* () { + for (const port of [0, -1, 1.5, 65536]) { + const invalid = yield* compile({ listeners: { api: { port } } } as never).pipe(Effect.exit); + expect(failureOf(invalid)).toBeInstanceOf(InvalidStackConfigError); + } + for (const port of [1, 65535]) { + const valid = yield* compile({ listeners: { api: { port } } }); + expect(valid.definition.listeners.api.port).toBe(port); + } }), ); }); diff --git a/packages/stack/src/public/Config.ts b/packages/stack/src/public/Config.ts index 4fb3b14e64..372d2f5c2e 100644 --- a/packages/stack/src/public/Config.ts +++ b/packages/stack/src/public/Config.ts @@ -16,15 +16,14 @@ import { } from "../model/capabilities/analytics.ts"; import { PoolerSettingsSchema, type PoolerSettings } from "../model/capabilities/pooler.ts"; import { ActivationModeSchema, type ActivationMode } from "./Capability.ts"; -import { PORT_FIELDS, type PortField } from "./Status.ts"; +import { NetworkPortSchema, PORT_FIELDS, type PortField } from "./Status.ts"; -export const ListenerPortSchema = Schema.Union([Schema.Literal("automatic"), Schema.Finite]); export const ListenerConfigSchema = Schema.Union([ Schema.Struct({ enabled: Schema.Literal(false) }), Schema.Struct({ enabled: Schema.optionalKey(Schema.Literal(true)), address: Schema.optionalKey(Schema.String), - port: Schema.optionalKey(Schema.Finite), + port: Schema.optionalKey(NetworkPortSchema), }), ]); export type ListenerConfig = Schema.Schema.Type; @@ -116,3 +115,4 @@ export type { PoolerSettings, ActivationMode, }; +export { NetworkPortSchema } from "./Status.ts"; diff --git a/packages/stack/src/public/Status.ts b/packages/stack/src/public/Status.ts index 9d82f80ea8..d59ea10369 100644 --- a/packages/stack/src/public/Status.ts +++ b/packages/stack/src/public/Status.ts @@ -4,7 +4,6 @@ import { ActivationModeSchema, CAPABILITY_NAMES, CapabilityStatusSchema, - CapabilityNameSchema, type ActivationMode, type CapabilityName, type CapabilityStatus, @@ -43,10 +42,16 @@ export const PORT_FIELDS = [ ] as const; export type PortField = (typeof PORT_FIELDS)[number]; +/** A concrete host/network port. Automatic assignment is represented by listeners, not here. */ +export const NetworkPortSchema = Schema.Int.check( + Schema.isBetween({ minimum: 1, maximum: 65_535 }), +); +export type NetworkPort = Schema.Schema.Type; + export const StackEndpointSchema = Schema.Struct({ protocol: Schema.Literals(["http", "tcp"] as const), address: Schema.String, - port: Schema.Number, + port: NetworkPortSchema, url: Schema.String, }); export type StackEndpoint = Schema.Schema.Type; @@ -97,7 +102,7 @@ export const StackStatusSchema = Schema.Struct({ lifecycle: StackLifecycleSchema, desiredLifecycle: DesiredStackLifecycleSchema, runtime: StackRuntimeSchema, - desiredGeneration: Schema.optionalKey(Schema.Number), + desiredGeneration: Schema.optionalKey(Schema.Finite), endpoints: StackEndpointsSchema, versions: CapabilityVersionsSchema, capabilities: CompleteCapabilityStatusesSchema, From 0b0d036cf205ef653d2c7d7b32def8f82860cad9 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 20:40:17 +0200 Subject: [PATCH 008/174] fix(stack): align persisted plan reuse --- ...26-08-28-stack-runtime-rewrite-progress.md | 27 ++- packages/stack/src/model/Compiler.ts | 174 +++++++++--------- packages/stack/src/model/ExecutionPlan.ts | 14 ++ .../stack/src/model/capabilities/functions.ts | 6 +- .../src/model/compiler.integration.test.ts | 42 +++++ packages/stack/src/public/Status.ts | 2 +- 6 files changed, 174 insertions(+), 91 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index b6eb0e1337..cc54e8790e 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -212,7 +212,7 @@ Implementation continues after recording a ruling; this file is not a question q Function slug/environment key constraints are checked before schema decoding so invalid Record keys cannot be silently dropped. Pooler uses the current `pool_mode` spelling. - Added one shared `NetworkPortSchema` (integer 1..65535) for listeners, endpoints, SMTP, - Functions inspector, and analytics vector ports. Capability/module dependencies now drive graph + the public Functions inspector listener, and analytics vector ports. Capability/module dependencies now drive graph planning and missing private dependencies/cycles fail with `InvalidStackConfigError`. - Strengthened compiler scenarios for exact tagged errors, release/artifact coherence, persisted plan rebuilding, record defaults, key/port validation, and secret absence from definition, @@ -225,3 +225,28 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — 30 tests passed before final empty-release scenario (31 total expected; rerun final command below). - `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. - `pnpm exec oxfmt --check packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed. + +#### Task 3 review fixes — round 2 — 2026-08-28 + +- Preserved the authoritative database major alias: selector `13` intentionally resolves to the + current Supabase Postgres `15.8.1.085` release. This follows + `apps/cli-go/pkg/config/config.go:825-831` and the documented fallthrough in + `apps/cli/src/shared/services/services.shared.ts:107-116`; the legacy `pg13` constant is not + an active runtime artifact. The native and container descriptors remain coherent for that + selected release (and for majors 14, 15, and 17). +- Identical-fingerprint reuse now branches immediately after strict decode, Functions-root + normalization, and fingerprint calculation. It collects only supplied Redacted slots, validates + persisted versions and capability/workload closure, and rebuilds a fresh runtime plan from the + persisted definition. Candidate defaults/materializers are not evaluated on this path. +- Removed duplicate Functions authorities from `edge_runtime`: capability enablement remains on + `capabilities.functions.enabled`, and the inspector gateway port remains on the public + `functionsInspector` listener. Functions settings persist only runtime behavior, root, secrets, + and per-function overrides; recursive unknown-field tests reject the removed controls. +- Persisted-definition codecs remain a Task 4 responsibility; Task 3 intentionally exports concrete + input schemas/types and release metadata without a generated or open materialized settings bag. +- Round-2 RED/GREEN: the targeted compiler/public suites pass (33 tests), including the DB13 alias, + immediate reuse, removed Functions controls, exact tagged failures, and closure checks. +- `pnpm --dir packages/stack types:check` — passed. +- `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — passed, 33 tests. +- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. +- `pnpm exec oxfmt --check packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed. diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 5e23c6d0f7..67d0af9246 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -374,7 +374,7 @@ const hashDefinitionWorkloads = ( definition: StackDefinition, crypto: Crypto.Crypto, specHashes: Map, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { for (const name of CAPABILITY_NAMES) { const capability = definition.capabilities[name]; @@ -406,6 +406,82 @@ const hashDefinitionWorkloads = ( } }); +const planForDefinition = ( + runtime: StackRuntime, + definition: StackDefinition, + crypto: Crypto.Crypto, +): Effect.Effect => { + const enabled = { + database: { + enabled: definition.capabilities.database.enabled, + activation: definition.capabilities.database.activation, + }, + rest: { + enabled: definition.capabilities.rest.enabled, + activation: definition.capabilities.rest.activation, + }, + auth: { + enabled: definition.capabilities.auth.enabled, + activation: definition.capabilities.auth.activation, + }, + realtime: { + enabled: definition.capabilities.realtime.enabled, + activation: definition.capabilities.realtime.activation, + }, + storage: { + enabled: definition.capabilities.storage.enabled, + activation: definition.capabilities.storage.activation, + }, + functions: { + enabled: definition.capabilities.functions.enabled, + activation: definition.capabilities.functions.activation, + }, + studio: { + enabled: definition.capabilities.studio.enabled, + activation: definition.capabilities.studio.activation, + }, + mail: { + enabled: definition.capabilities.mail.enabled, + activation: definition.capabilities.mail.activation, + }, + analytics: { + enabled: definition.capabilities.analytics.enabled, + activation: definition.capabilities.analytics.activation, + }, + pooler: { + enabled: definition.capabilities.pooler.enabled, + activation: definition.capabilities.pooler.activation, + }, + }; + const versions = { + database: definition.capabilities.database.version, + rest: definition.capabilities.rest.version, + auth: definition.capabilities.auth.version, + realtime: definition.capabilities.realtime.version, + storage: definition.capabilities.storage.version, + functions: definition.capabilities.functions.version, + studio: definition.capabilities.studio.version, + mail: definition.capabilities.mail.version, + analytics: definition.capabilities.analytics.version, + pooler: definition.capabilities.pooler.version, + }; + const specHashes = new Map(); + return hashDefinitionWorkloads(definition, crypto, specHashes).pipe( + Effect.flatMap(() => createExecutionPlan(runtime, enabled, specHashes, versions)), + ); +}; + +const collectSuppliedSecrets = (config: StackConfig, slots: SecretSlotInput[]): void => { + const capabilities = config.capabilities; + for (const name of CAPABILITY_NAMES) { + const capability = capabilities?.[name]; + if (capability === undefined) continue; + slotsFor("settings" in capability ? capability.settings : undefined, `${name}.settings`, slots); + } + const signing = config.security?.jwt?.signing; + if (signing !== undefined) slotsFor(signing, "security.jwt.signing", slots); +}; + export const compileStack = ( input: CompileStackInput, previous?: PreviousCompilation, @@ -445,6 +521,17 @@ export const compileStack = ( const inputFingerprint = yield* Schema.decodeEffect(InputFingerprintSchema)( digestHex(fingerprintBytes), ).pipe(Effect.mapError((error) => new InvalidStackConfigError({ message: String(error) }))); + if (previous?.inputFingerprint === inputFingerprint) { + const slots: SecretSlotInput[] = []; + collectSuppliedSecrets(config, slots); + const executionPlan = yield* planForDefinition(input.runtime, previous.definition, crypto); + return { + definition: previous.definition, + inputFingerprint, + secrets: slots, + executionPlan, + }; + } const slots: SecretSlotInput[] = []; const specHashes = new Map(); const databaseResult = yield* materializeCapability( @@ -581,17 +668,6 @@ export const compileStack = ( analytics: { enabled: analytics.enabled, activation: analytics.activation }, pooler: { enabled: pooler.enabled, activation: pooler.activation }, }; - for (const name of CAPABILITY_NAMES) { - if (!enabled[name].enabled) continue; - for (const dependency of CAPABILITY_MODULES[name].dependencies) { - if (!enabled[dependency].enabled) - return yield* new InvalidStackConfigError({ - message: `${name} requires disabled capability ${dependency}`, - capability: name, - dependency, - }); - } - } const rawListeners = isRecord(config.listeners) ? config.listeners : {}; const listeners = { [PORT_FIELDS[0]]: materializeListener(rawListeners[PORT_FIELDS[0]], true), @@ -630,78 +706,8 @@ export const compileStack = ( analytics: analytics.version, pooler: pooler.version, }; - const currentPlan = yield* createExecutionPlan(input.runtime, enabled, specHashes, versions); - if (previous?.inputFingerprint === inputFingerprint) { - const previousEnabled = { - database: { - enabled: previous.definition.capabilities.database.enabled, - activation: previous.definition.capabilities.database.activation, - }, - rest: { - enabled: previous.definition.capabilities.rest.enabled, - activation: previous.definition.capabilities.rest.activation, - }, - auth: { - enabled: previous.definition.capabilities.auth.enabled, - activation: previous.definition.capabilities.auth.activation, - }, - realtime: { - enabled: previous.definition.capabilities.realtime.enabled, - activation: previous.definition.capabilities.realtime.activation, - }, - storage: { - enabled: previous.definition.capabilities.storage.enabled, - activation: previous.definition.capabilities.storage.activation, - }, - functions: { - enabled: previous.definition.capabilities.functions.enabled, - activation: previous.definition.capabilities.functions.activation, - }, - studio: { - enabled: previous.definition.capabilities.studio.enabled, - activation: previous.definition.capabilities.studio.activation, - }, - mail: { - enabled: previous.definition.capabilities.mail.enabled, - activation: previous.definition.capabilities.mail.activation, - }, - analytics: { - enabled: previous.definition.capabilities.analytics.enabled, - activation: previous.definition.capabilities.analytics.activation, - }, - pooler: { - enabled: previous.definition.capabilities.pooler.enabled, - activation: previous.definition.capabilities.pooler.activation, - }, - }; - const previousVersions = { - database: previous.definition.capabilities.database.version, - rest: previous.definition.capabilities.rest.version, - auth: previous.definition.capabilities.auth.version, - realtime: previous.definition.capabilities.realtime.version, - storage: previous.definition.capabilities.storage.version, - functions: previous.definition.capabilities.functions.version, - studio: previous.definition.capabilities.studio.version, - mail: previous.definition.capabilities.mail.version, - analytics: previous.definition.capabilities.analytics.version, - pooler: previous.definition.capabilities.pooler.version, - }; - const previousHashes = new Map(); - yield* hashDefinitionWorkloads(previous.definition, crypto, previousHashes); - const previousPlan = yield* createExecutionPlan( - input.runtime, - previousEnabled, - previousHashes, - previousVersions, - ); - return { - definition: previous.definition, - inputFingerprint, - secrets: slots, - executionPlan: previousPlan, - }; - } - return { definition, inputFingerprint, secrets: slots, executionPlan: currentPlan }; + const executionPlan = yield* createExecutionPlan(input.runtime, enabled, specHashes, versions); + return { definition, inputFingerprint, secrets: slots, executionPlan }; }).pipe( Effect.catchTag("PlatformError", (error) => Effect.fail(new InvalidStackConfigError({ message: error.message })), diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index 3516f52406..b83f12def8 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -83,6 +83,20 @@ export const createExecutionPlan = ( analytics: modules.analytics.dependencies, pooler: modules.pooler.dependencies, } satisfies { [Name in CapabilityName]: ReadonlyArray }; + for (const name of CAPABILITY_NAMES) { + if (!enabled[name].enabled) continue; + for (const dependency of dependencyMap[name]) { + if (!enabled[dependency].enabled) { + return Effect.fail( + new InvalidStackConfigError({ + message: `${name} requires disabled capability ${dependency}`, + capability: name, + dependency, + }), + ); + } + } + } const start: CapabilityName[] = []; const visited = new Set(); const visitingCapabilities = new Set(); diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index 6c6151f60a..b7e9d9ddca 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -1,6 +1,5 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; -import { NetworkPortSchema } from "../../public/Status.ts"; const Secret = Schema.Redacted(Schema.String); const FunctionSlug = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)); @@ -15,11 +14,10 @@ const FunctionOverride = Schema.Struct({ }); export const FunctionsSettingsSchema = Schema.Struct({ functions_root: Schema.optionalKey(Schema.String), + /** Edge Runtime behavior; capability enabled and functionsInspector port are public fields. */ edge_runtime: Schema.optionalKey( Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"] as const)), - inspector_port: Schema.optionalKey(NetworkPortSchema), deno_version: Schema.optionalKey(Schema.Finite), secrets: Schema.optionalKey(Schema.Record(Schema.String, Secret)), }), @@ -43,9 +41,7 @@ export const FunctionsModule: CapabilityModule = { defaultSettings: { functions_root: "supabase/functions", edge_runtime: { - enabled: true, policy: "per_worker", - inspector_port: 8083, deno_version: 2, secrets: {}, }, diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 24e00e63e2..e5b2c799b9 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { canonicalize, compileStack } from "./Compiler.ts"; +import { RestModule } from "./capabilities/rest.ts"; const layer = NodeServices.layer; const compile = ( @@ -165,6 +166,14 @@ describe("closed capability compiler", () => { capabilities: { rest: { settings: { typo: true } } }, } as never).pipe(Effect.exit); expect(failureOf(exit)).toBeInstanceOf(InvalidStackConfigError); + const edgeEnabled = yield* compile({ + capabilities: { functions: { settings: { edge_runtime: { enabled: false } } } }, + } as never).pipe(Effect.exit); + expect(failureOf(edgeEnabled)).toBeInstanceOf(InvalidStackConfigError); + const inspectorPort = yield* compile({ + capabilities: { functions: { settings: { edge_runtime: { inspector_port: 8083 } } } }, + } as never).pipe(Effect.exit); + expect(failureOf(inspectorPort)).toBeInstanceOf(InvalidStackConfigError); }), ); @@ -317,6 +326,20 @@ describe("closed capability compiler", () => { }), ); + it.live("reuses persisted state before evaluating current module defaults", () => + Effect.gen(function* () { + const first = yield* compile({}); + const defaultVersion = RestModule.defaultVersion; + Reflect.set(RestModule, "defaultVersion", "not-real"); + try { + const reused = yield* compile({}, { kind: "native" }, first); + expect(reused.definition).toBe(first.definition); + } finally { + Reflect.set(RestModule, "defaultVersion", defaultVersion); + } + }), + ); + it.live("rejects a persisted definition whose release is no longer supported", () => Effect.gen(function* () { const first = yield* compile({}); @@ -339,6 +362,25 @@ describe("closed capability compiler", () => { }), ); + it.live("validates persisted capability closure before rebuilding its plan", () => + Effect.gen(function* () { + const first = yield* compile({}); + const persisted = { + ...first.definition, + capabilities: { + ...first.definition.capabilities, + rest: { ...first.definition.capabilities.rest, enabled: false }, + }, + }; + const result = yield* compile( + {}, + { kind: "native" }, + { definition: persisted, inputFingerprint: first.inputFingerprint }, + ).pipe(Effect.exit); + expect(failureOf(result)).toBeInstanceOf(InvalidStackConfigError); + }), + ); + it.live("materializes defaults for supplied records", () => Effect.gen(function* () { const result = yield* compile({ diff --git a/packages/stack/src/public/Status.ts b/packages/stack/src/public/Status.ts index d59ea10369..c6bd9aaa78 100644 --- a/packages/stack/src/public/Status.ts +++ b/packages/stack/src/public/Status.ts @@ -29,7 +29,7 @@ export const DesiredStackLifecycleSchema = Schema.Literals([ ] as const); export type DesiredStackLifecycle = Schema.Schema.Type; -/** Listener keys are kept here until Task 3 introduces the closed config schema. */ +/** Listener keys are shared by the public status and closed config schemas. */ export const PORT_FIELDS = [ "api", "database", From 20117b39950dfe0d6006c775a8f13c15a12f00da Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 20:43:20 +0200 Subject: [PATCH 009/174] test(stack): avoid shared compiler mutation --- .../2026-08-28-stack-runtime-rewrite-progress.md | 4 ++-- .../stack/src/model/compiler.integration.test.ts | 15 --------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index cc54e8790e..12950d9363 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -244,9 +244,9 @@ Implementation continues after recording a ruling; this file is not a question q and per-function overrides; recursive unknown-field tests reject the removed controls. - Persisted-definition codecs remain a Task 4 responsibility; Task 3 intentionally exports concrete input schemas/types and release metadata without a generated or open materialized settings bag. -- Round-2 RED/GREEN: the targeted compiler/public suites pass (33 tests), including the DB13 alias, +- Round-2 RED/GREEN: the targeted compiler/public suites pass (32 tests), including the DB13 alias, immediate reuse, removed Functions controls, exact tagged failures, and closure checks. - `pnpm --dir packages/stack types:check` — passed. -- `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — passed, 33 tests. +- `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — passed, 32 tests. - `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. - `pnpm exec oxfmt --check packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed. diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index e5b2c799b9..1dfc3435df 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { canonicalize, compileStack } from "./Compiler.ts"; -import { RestModule } from "./capabilities/rest.ts"; const layer = NodeServices.layer; const compile = ( @@ -326,20 +325,6 @@ describe("closed capability compiler", () => { }), ); - it.live("reuses persisted state before evaluating current module defaults", () => - Effect.gen(function* () { - const first = yield* compile({}); - const defaultVersion = RestModule.defaultVersion; - Reflect.set(RestModule, "defaultVersion", "not-real"); - try { - const reused = yield* compile({}, { kind: "native" }, first); - expect(reused.definition).toBe(first.definition); - } finally { - Reflect.set(RestModule, "defaultVersion", defaultVersion); - } - }), - ); - it.live("rejects a persisted definition whose release is no longer supported", () => Effect.gen(function* () { const first = yield* compile({}); From 15cbf5e37a686071e2061e265eee48980301b0db Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 21:37:31 +0200 Subject: [PATCH 010/174] feat(stack): persist state secrets and sticky ports --- ...26-08-28-stack-runtime-rewrite-progress.md | 32 +- packages/stack/src/model/CapabilityModule.ts | 4 + packages/stack/src/model/Compiler.ts | 106 +++- .../stack/src/model/capabilities/analytics.ts | 2 + packages/stack/src/model/capabilities/auth.ts | 41 +- .../stack/src/model/capabilities/database.ts | 2 + .../stack/src/model/capabilities/functions.ts | 2 + packages/stack/src/model/capabilities/mail.ts | 2 + .../stack/src/model/capabilities/pooler.ts | 5 + .../stack/src/model/capabilities/realtime.ts | 2 + packages/stack/src/model/capabilities/rest.ts | 2 + .../stack/src/model/capabilities/storage.ts | 2 + .../stack/src/model/capabilities/studio.ts | 2 + .../src/model/compiler.integration.test.ts | 17 +- packages/stack/src/public/Errors.ts | 4 + packages/stack/src/state/PortCoordinator.ts | 243 +++++++++ packages/stack/src/state/PortRegistry.ts | 70 +++ packages/stack/src/state/SecretStore.ts | 124 +++++ packages/stack/src/state/StackState.ts | 329 ++++++++++++ packages/stack/src/state/StackStateStore.ts | 469 ++++++++++++++++++ .../stack/src/state/ports.integration.test.ts | 298 +++++++++++ .../src/state/secrets.integration.test.ts | 142 ++++++ .../src/state/state-store.integration.test.ts | 279 +++++++++++ 23 files changed, 2160 insertions(+), 19 deletions(-) create mode 100644 packages/stack/src/state/PortCoordinator.ts create mode 100644 packages/stack/src/state/PortRegistry.ts create mode 100644 packages/stack/src/state/SecretStore.ts create mode 100644 packages/stack/src/state/StackState.ts create mode 100644 packages/stack/src/state/StackStateStore.ts create mode 100644 packages/stack/src/state/ports.integration.test.ts create mode 100644 packages/stack/src/state/secrets.integration.test.ts create mode 100644 packages/stack/src/state/state-store.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 12950d9363..8b2b888c66 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -16,7 +16,7 @@ updated in the same commit as each completed slice. Scratch review packages live - [x] Pinned pnpm dependencies installed with an unchanged lockfile. - [x] Baseline package type checks passed for `@supabase/process-compose` and `@supabase/stack`. - [x] Baseline targeted tests passed: process-compose dependency graph (18 tests) and stack identity - (1 test). + (1 test). - [x] Approved architecture imported as the canonical tracked design. - [x] Layer-by-layer implementation plan completed and reviewed against the design. - [x] Legacy process-compose package and stack implementation removed. @@ -120,7 +120,7 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm install --lockfile-only` — passed (pnpm v11.4.0; platform warnings for unsupported binary wrapper workspaces only). - `rg -n '@supabase/process-compose|packages/process-compose' package.json packages apps - --glob '!docs/superpowers/**' --glob '!docs/adr/**'` — no matches (exit 1 as expected). +--glob '!docs/superpowers/**' --glob '!docs/adr/**'` — no matches (exit 1 as expected). - `pnpm --dir packages/stack types:check` — passed. - The CLI type-check was not run; the accepted staging ruling allows the old CLI stack call sites to fail until the direct migration task. The telemetry coverage test was attempted and could not load @@ -226,6 +226,34 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. - `pnpm exec oxfmt --check packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed. +### Task 4 — 2026-08-28 + +- Added the strict `supabase-stack-state-v1` Effect codec for complete materialized definitions, + persisted identity/runtime/generation/lifecycle, sticky host-port assignments, and dedicated + secret values. Unknown fields, malformed formats, invalid record keys, incomplete dynamic + defaults, forged identity tuples, duplicate ports, and definition/fingerprint mismatches fail + closed with tagged errors. Reads remain lock-free; writes use an owner-only root, a sibling + exclusive temporary file, file sync, and same-directory atomic rename. Effect Platform has no + portable directory-fsync API, so the implementation documents that file-sync/same-directory + rename is the strongest available durability boundary. +- Added one short O_EXCL registry lock (bounded Schedule retry, exact release, no stale stealing) + shared by state mutations and port planning; the per-stack `state.json.ports` fields remain the + sole durable port authority. Automatic ports are sticky and exclusive across automatic/live + assignments, exact stopped reservations may coexist, and runtime ownership/publication occurs + after the state transaction with assignments retained on publication races. +- Added module-owned secret policies and required managed slots. Auth API credentials, Pooler + encryption/key-base, the default JWT signing slot, and the internal database password are + managed; other Redacted settings remain pass-through. Managed omissions generate once and reuse; + pass-through changes/additions/removals require a stopped lifecycle. Secret bytes never enter + definitions, fingerprints, errors, or loggable redaction output. +- RED evidence: focused state/secret/port tests initially failed to load the missing Task 4 state, + secret, and port modules; the final integration scenarios now cover concurrent old-or-new reads, + exact cleanup, strict dynamic/default round-trips, secret policy matrices, automatic/exact port + conflicts, native listener handoff, host occupancy, and container publication races. +- `pnpm --dir packages/stack exec vitest run --project integration src/state/state-store.integration.test.ts src/state/secrets.integration.test.ts src/state/ports.integration.test.ts` — passed (17 tests). +- `pnpm --dir packages/stack types:check` — passed. +- Effect lint over changed Task 4 sources — passed with zero warnings. + #### Task 3 review fixes — round 2 — 2026-08-28 - Preserved the authoritative database major alias: selector `13` intentionally resolves to the diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index d6a94f017d..f6021bedd2 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -40,6 +40,10 @@ export interface CapabilityModule { readonly routes: ReadonlyArray< Readonly<{ readonly listener: PortField; readonly protocol: "http" | "tcp" }> >; + /** Classifies every Redacted settings leaf; module contracts, not callers, own this policy. */ + readonly secretPolicy: (path: string) => "managed" | "passthrough"; + /** Managed slots required by the artifact even when omitted from user config. */ + readonly managedSecretSlots: ReadonlyArray; readonly materialize: (settings: Settings, projectRoot: string) => Settings; } diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 67d0af9246..b6c7f93985 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -95,9 +95,13 @@ export interface MaterializedListener { export interface SecretSlotInput { readonly slot: string; - readonly value: Redacted.Redacted; + readonly policy: "managed" | "passthrough"; + readonly value?: Redacted.Redacted; } +/** Internal credentials are not user settings but still need durable managed slots. */ +export const INTERNAL_MANAGED_SECRET_SLOTS = ["secret:database.internal.password"] as const; + export interface CompiledStack { readonly definition: StackDefinition; readonly inputFingerprint: InputFingerprint; @@ -116,7 +120,7 @@ export interface CompileStackInput { readonly config?: StackConfig; } -const isRecord = (value: unknown): value is Readonly> => +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) && @@ -158,23 +162,67 @@ function materializeAbsence(value: unknown): unknown { return value; } -function slotsFor(value: T, path: string, slots: SecretSlotInput[]): MaterializedSettings; -function slotsFor(value: unknown, path: string, slots: SecretSlotInput[]): unknown { +function slotsFor( + value: T, + path: string, + slots: SecretSlotInput[], + policyForPath?: (path: string) => "managed" | "passthrough", +): MaterializedSettings; +function slotsFor( + value: unknown, + path: string, + slots: SecretSlotInput[], + policyForPath: (path: string) => "managed" | "passthrough" = () => "passthrough", +): unknown { if (Redacted.isRedacted(value)) { - slots.push({ slot: `secret:${path}`, value }); + slots.push({ slot: `secret:${path}`, policy: policyForPath(path), value }); return { slot: `secret:${path}` }; } if (Array.isArray(value)) - return value.map((entry, index) => slotsFor(entry, `${path}.${index}`, slots)); + return value.map((entry, index) => slotsFor(entry, `${path}.${index}`, slots, policyForPath)); if (isRecord(value)) { const result: Record = {}; for (const key of Object.keys(value).sort()) - result[key] = slotsFor(value[key], `${path}.${key}`, slots); + result[key] = slotsFor(value[key], `${path}.${key}`, slots, policyForPath); return result; } return value; } +const setMaterializedPath = ( + value: unknown, + parts: ReadonlyArray, + slot: SecretSlot, +): void => { + if (!isRecord(value) || parts.length === 0) return; + const head = parts[0]; + if (head === undefined) return; + const tail = parts.slice(1); + if (tail.length === 0) { + value[head] = slot; + return; + } + if (!isRecord(value[head])) value[head] = {}; + setMaterializedPath(value[head], tail, slot); +}; + +const ensureManagedSlots = ( + settings: MaterializedSettings, + module: CapabilityModule, + enabled: boolean, + slots: SecretSlotInput[], +): MaterializedSettings => { + if (!enabled || module.managedSecretSlots.length === 0) return settings; + const result = settings; + for (const path of module.managedSecretSlots) { + const slot = `secret:${path}`; + const existing = slots.find((candidate) => candidate.slot === slot); + if (existing === undefined) slots.push({ slot, policy: "managed" }); + setMaterializedPath(result, path.split(".").slice(2), { slot }); + } + return result; +}; + const materializeListener = (value: unknown, enabledByDefault: boolean): MaterializedListener => { if (!isRecord(value)) return { enabled: enabledByDefault, address: "127.0.0.1", port: "automatic" }; @@ -337,7 +385,8 @@ const materializeCapability = ( return Effect.gen(function* () { const normalizedSettings = yield* normalized; const merged = materializeAbsence(normalizedSettings); - const slotted = slotsFor(merged, `${module.name}.settings`, slots); + const slotted = slotsFor(merged, `${module.name}.settings`, slots, module.secretPolicy); + const completeSettings = ensureManagedSlots(slotted, module, selected.enabled, slots); const selectedRelease = yield* releaseFor(module, selected.raw); const version = selectedRelease.version; for (const entry of selectedRelease.workloads) { @@ -351,7 +400,7 @@ const materializeCapability = ( enabled: selected.enabled, activation: selected.activation, version, - settings: slotted, + settings: completeSettings, release: selectedRelease, }; }).pipe( @@ -474,12 +523,34 @@ const planForDefinition = ( const collectSuppliedSecrets = (config: StackConfig, slots: SecretSlotInput[]): void => { const capabilities = config.capabilities; for (const name of CAPABILITY_NAMES) { + const module = CAPABILITY_MODULES[name]; const capability = capabilities?.[name]; - if (capability === undefined) continue; - slotsFor("settings" in capability ? capability.settings : undefined, `${name}.settings`, slots); + if (capability === undefined) { + if (module.defaultEnabled) { + for (const path of module.managedSecretSlots) + slots.push({ slot: `secret:${path}`, policy: "managed" }); + } + continue; + } + slotsFor( + "settings" in capability ? capability.settings : undefined, + `${name}.settings`, + slots, + module.secretPolicy, + ); + if ("enabled" in capability && capability.enabled === false) continue; + for (const path of module.managedSecretSlots) { + if (!slots.some((entry) => entry.slot === `secret:${path}`)) + slots.push({ slot: `secret:${path}`, policy: "managed" }); + } } const signing = config.security?.jwt?.signing; - if (signing !== undefined) slotsFor(signing, "security.jwt.signing", slots); + if (signing === undefined) { + const slot = "secret:security.jwt.signing.secret"; + if (!slots.some((entry) => entry.slot === slot)) slots.push({ slot, policy: "managed" }); + } else { + slotsFor(signing, "security.jwt.signing", slots, () => "managed"); + } }; export const compileStack = ( @@ -523,6 +594,7 @@ export const compileStack = ( ).pipe(Effect.mapError((error) => new InvalidStackConfigError({ message: String(error) }))); if (previous?.inputFingerprint === inputFingerprint) { const slots: SecretSlotInput[] = []; + for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); collectSuppliedSecrets(config, slots); const executionPlan = yield* planForDefinition(input.runtime, previous.definition, crypto); return { @@ -533,6 +605,7 @@ export const compileStack = ( }; } const slots: SecretSlotInput[] = []; + for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); const specHashes = new Map(); const databaseResult = yield* materializeCapability( DatabaseModule, @@ -685,8 +758,13 @@ export const compileStack = ( issuer: rawJwt?.issuer ?? null, signing: rawJwt?.signing === undefined - ? null - : slotsFor(rawJwt.signing, "security.jwt.signing", slots), + ? (() => { + const slot = "secret:security.jwt.signing.secret"; + if (!slots.some((entry) => entry.slot === slot)) + slots.push({ slot, policy: "managed" }); + return { kind: "symmetric" as const, secret: { slot } }; + })() + : slotsFor(rawJwt.signing, "security.jwt.signing", slots, () => "managed"), }, }; const definition: StackDefinition = { diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index 7e582af06a..d94d93880a 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -40,5 +40,7 @@ export const AnalyticsModule: CapabilityModule = { ]), }, routes: [{ listener: "api", protocol: "http" }], + secretPolicy: () => "passthrough", + managedSecretSlots: [], materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/auth.ts b/packages/stack/src/model/capabilities/auth.ts index a6b77d47e9..1a13304a42 100644 --- a/packages/stack/src/model/capabilities/auth.ts +++ b/packages/stack/src/model/capabilities/auth.ts @@ -279,6 +279,28 @@ const hooks = { before_user_created: { enabled: false, uri: undefined, secrets: undefined }, } satisfies AuthSettings["hook"]; +const materializeAuthSettings = (settings: AuthSettings): AuthSettings => ({ + ...settings, + email: + settings.email === undefined + ? settings.email + : { + ...settings.email, + template: Object.fromEntries( + Object.entries(settings.email.template ?? {}).map(([name, template]) => [ + name, + { subject: undefined, content_path: undefined, ...template }, + ]), + ), + notification: Object.fromEntries( + Object.entries(settings.email.notification ?? {}).map(([name, notification]) => [ + name, + { enabled: false, subject: undefined, content_path: undefined, ...notification }, + ]), + ), + }, +}); + export const AuthModule: CapabilityModule = { name: "auth", settings: AuthSettingsSchema, @@ -381,5 +403,22 @@ export const AuthModule: CapabilityModule = { ]), }, routes: [{ listener: "api", protocol: "http" }], - materialize: (settings) => settings, + secretPolicy: (path) => + [ + "auth.settings.publishable_key", + "auth.settings.secret_key", + "auth.settings.jwt_secret", + "auth.settings.anon_key", + "auth.settings.service_role_key", + ].includes(path) + ? "managed" + : "passthrough", + managedSecretSlots: [ + "auth.settings.publishable_key", + "auth.settings.secret_key", + "auth.settings.jwt_secret", + "auth.settings.anon_key", + "auth.settings.service_role_key", + ], + materialize: materializeAuthSettings, }; diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index 0f7c843beb..8fa1e7d483 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -156,5 +156,7 @@ export const DatabaseModule: CapabilityModule = { ]), }, routes: [{ listener: "database", protocol: "tcp" }], + secretPolicy: () => "passthrough", + managedSecretSlots: [], materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index b7e9d9ddca..0b71bb1f41 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -63,6 +63,8 @@ export const FunctionsModule: CapabilityModule = { { listener: "api", protocol: "http" }, { listener: "functionsInspector", protocol: "http" }, ], + secretPolicy: () => "passthrough", + managedSecretSlots: [], materialize: (settings) => ({ ...settings, functions: Object.fromEntries( diff --git a/packages/stack/src/model/capabilities/mail.ts b/packages/stack/src/model/capabilities/mail.ts index 85282832f8..f56508d042 100644 --- a/packages/stack/src/model/capabilities/mail.ts +++ b/packages/stack/src/model/capabilities/mail.ts @@ -26,5 +26,7 @@ export const MailModule: CapabilityModule = { { listener: "smtp", protocol: "tcp" }, { listener: "pop3", protocol: "tcp" }, ], + secretPolicy: () => "passthrough", + managedSecretSlots: [], materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts index 0059bd74a2..ce45f96b31 100644 --- a/packages/stack/src/model/capabilities/pooler.ts +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -35,5 +35,10 @@ export const PoolerModule: CapabilityModule = { ]), }, routes: [{ listener: "pooler", protocol: "tcp" }], + secretPolicy: (path) => + path === "pooler.settings.encryption_key" || path === "pooler.settings.secret_key_base" + ? "managed" + : "passthrough", + managedSecretSlots: ["pooler.settings.encryption_key", "pooler.settings.secret_key_base"], materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/realtime.ts b/packages/stack/src/model/capabilities/realtime.ts index 851d350c6a..c4cdc6360e 100644 --- a/packages/stack/src/model/capabilities/realtime.ts +++ b/packages/stack/src/model/capabilities/realtime.ts @@ -24,5 +24,7 @@ export const RealtimeModule: CapabilityModule = { ]), }, routes: [{ listener: "api", protocol: "http" }], + secretPolicy: () => "passthrough", + managedSecretSlots: [], materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/rest.ts b/packages/stack/src/model/capabilities/rest.ts index 1d44fe413a..9ac7f7b295 100644 --- a/packages/stack/src/model/capabilities/rest.ts +++ b/packages/stack/src/model/capabilities/rest.ts @@ -41,5 +41,7 @@ export const RestModule: CapabilityModule = { ]), }, routes: [{ listener: "api", protocol: "http" }], + secretPolicy: () => "passthrough", + managedSecretSlots: [], materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index dae2ebcb62..7fab4731cb 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -67,6 +67,8 @@ export const StorageModule: CapabilityModule = { ]), }, routes: [{ listener: "api", protocol: "http" }], + secretPolicy: () => "passthrough", + managedSecretSlots: [], materialize: (settings) => ({ ...settings, buckets: Object.fromEntries( diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts index 30ffe04abd..62c688eee5 100644 --- a/packages/stack/src/model/capabilities/studio.ts +++ b/packages/stack/src/model/capabilities/studio.ts @@ -34,5 +34,7 @@ export const StudioModule: CapabilityModule = { ]), }, routes: [{ listener: "studio", protocol: "http" }], + secretPolicy: () => "passthrough", + managedSecretSlots: [], materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 1dfc3435df..49fb58cfef 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -57,8 +57,21 @@ describe("closed capability compiler", () => { expect(canonicalize(result.definition)).not.toContain("secret-value"); expect(canonicalize(result.inputFingerprint)).not.toContain("secret-value"); expect(canonicalize(result.executionPlan)).not.toContain("secret-value"); - expect(result.secrets).toHaveLength(1); - expect(Redacted.isRedacted(result.secrets[0]?.value)).toBe(true); + const supplied = result.secrets.find( + (entry) => entry.slot === "secret:auth.settings.secret_key", + ); + expect(supplied?.policy).toBe("managed"); + expect(Redacted.isRedacted(supplied?.value)).toBe(true); + for (const slot of [ + "secret:database.internal.password", + "secret:security.jwt.signing.secret", + "secret:auth.settings.publishable_key", + "secret:auth.settings.jwt_secret", + "secret:auth.settings.anon_key", + "secret:auth.settings.service_role_key", + ]) { + expect(result.secrets.find((entry) => entry.slot === slot)?.policy).toBe("managed"); + } expect(canonicalize(result.executionPlan)).not.toContain("secret-value"); }), ); diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 2206da4cf7..37cfa2e2f3 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -47,6 +47,9 @@ export class StackStateInvalidError extends Data.TaggedError( export class StackStateFormatUnsupportedError extends Data.TaggedError( "StackStateFormatUnsupportedError", ) {} +export class StackStateGenerationMismatchError extends Data.TaggedError( + "StackStateGenerationMismatchError", +) {} export class StackUpgradeRequiredError extends Data.TaggedError( "StackUpgradeRequiredError", ) {} @@ -114,6 +117,7 @@ export type StackError = | StackLifecycleConflictError | StackStateInvalidError | StackStateFormatUnsupportedError + | StackStateGenerationMismatchError | StackUpgradeRequiredError | StackUpgradeReplacementError | StackSecretMismatchError diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts new file mode 100644 index 0000000000..b888ad8910 --- /dev/null +++ b/packages/stack/src/state/PortCoordinator.ts @@ -0,0 +1,243 @@ +import { Crypto, Effect, FileSystem, Path, Scope, Schema } from "effect"; +import type { StackRuntime } from "../public/Runtime.ts"; +import { NetworkPortSchema, PORT_FIELDS, type PortField } from "../public/Status.ts"; +import { + PortAllocationError, + PortUnavailableError, + InvalidProjectRootError, + StackStateFormatUnsupportedError, + StackStateGenerationMismatchError, + StackStateInvalidError, +} from "../public/Errors.ts"; +import type { HostPortAssignment, PersistedStackState } from "./StackState.ts"; +import type { StackStateStore } from "./StackStateStore.ts"; +import type { PortRegistry } from "./PortRegistry.ts"; + +export interface ListenerIntent { + readonly enabled: boolean; + readonly address: string; + readonly port: "automatic" | number; +} + +export type ListenerIntents = Readonly>; + +export interface NativeListener { + readonly field: PortField; + readonly address: string; + readonly port: number; + readonly close: Effect.Effect; +} + +export interface PortPlanOptions { + readonly lifecycle?: "stopped" | "running"; + readonly runtime?: StackRuntime; + readonly expectedGeneration?: number; + /** Generation to persist after this transaction; unchanged unless explicitly supplied. */ + readonly nextGeneration?: number; +} + +export interface PortReservation { + readonly assignments: Readonly>>; + readonly nativeListeners: ReadonlyArray; +} + +export interface PortCoordinatorOptions { + /** Binds and retains a native listener. The enclosing Scope owns its release. */ + readonly bindNative?: ( + address: string, + port: number, + field: PortField, + ) => Effect.Effect; + /** Publishes one container listener through the authoritative engine. */ + readonly publishContainer?: ( + address: string, + port: number, + field: PortField, + ) => Effect.Effect; + /** Optional non-owning host probe used to skip occupied automatic ports. */ + readonly checkHostPort?: ( + address: string, + port: number, + field: PortField, + ) => Effect.Effect; +} + +export interface PortCoordinator { + readonly planAndReserve: ( + stackId: string, + listenerIntents: ListenerIntents, + options?: PortPlanOptions, + ) => Effect.Effect< + PortReservation, + | PortAllocationError + | PortUnavailableError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | InvalidProjectRootError + | StackStateGenerationMismatchError, + Scope.Scope | Crypto.Crypto | FileSystem.FileSystem | Path.Path + >; +} + +const fields: ReadonlyArray = PORT_FIELDS; + +const assignmentMap = (assignments: ReadonlyArray) => + new Map(assignments.map((assignment) => [assignment.field, assignment])); + +const validPort = (port: number): boolean => Schema.is(NetworkPortSchema)(port); + +const unavailable = (port: number, field: PortField) => + new PortUnavailableError({ port, field, message: `Port ${port} for ${field} is unavailable` }); + +export const makePortCoordinator = ( + registry: PortRegistry, + store: StackStateStore, + options: PortCoordinatorOptions = {}, +): PortCoordinator => ({ + planAndReserve: (stackId, listenerIntents, planOptions = {}) => + Effect.gen(function* () { + const committed = yield* registry.withLock( + Effect.gen(function* () { + const current = yield* store.read(stackId); + if (current === undefined) + return yield* new StackStateInvalidError({ + message: "Cannot allocate ports for an unconfigured stack", + }); + const expected = planOptions.expectedGeneration ?? current.desiredGeneration; + if (current.desiredGeneration !== expected) + return yield* new StackStateGenerationMismatchError({ + expectedGeneration: expected, + actualGeneration: current.desiredGeneration, + message: "Persisted stack state generation changed", + }); + const lifecycle = + planOptions.lifecycle ?? + (current.desiredLifecycle === "running" ? "running" : "stopped"); + const runtime = planOptions.runtime ?? current.runtime; + const allStates = yield* registry.states; + const usedAutomatic = new Set(); + const usedLive = new Set(); + const usedReserved = new Set(); + for (const entry of allStates) { + if (entry.stackId === stackId) continue; + for (const assignment of entry.state.ports) { + usedReserved.add(assignment.port); + if (assignment.intent === "automatic") usedAutomatic.add(assignment.port); + if (entry.state.desiredLifecycle === "running") usedLive.add(assignment.port); + } + } + const existing = assignmentMap(current.ports); + const assignments: HostPortAssignment[] = []; + const byField: Partial> = {}; + const usedByThisStack = new Set(); + for (const field of fields) { + const intent = listenerIntents[field]; + if (!intent.enabled) continue; + const prior = existing.get(field); + let assignment: HostPortAssignment; + if (intent.port === "automatic") { + if (prior?.intent === "automatic" && !usedByThisStack.has(prior.port)) { + assignment = prior; + } else { + let selected: number | undefined; + for (let port = 40_000; port <= 65_535; port += 1) { + if ( + !usedAutomatic.has(port) && + !usedLive.has(port) && + !usedReserved.has(port) && + !usedByThisStack.has(port) + ) { + if (options.checkHostPort !== undefined) { + const available = yield* options.checkHostPort(intent.address, port, field); + if (!available) continue; + } + selected = port; + break; + } + } + if (selected === undefined) + return yield* new PortAllocationError({ + field, + message: "No automatic host port is available", + }); + assignment = { field, port: selected, intent: "automatic" }; + usedAutomatic.add(selected); + } + } else { + if (!validPort(intent.port)) return yield* unavailable(intent.port, field); + if (usedByThisStack.has(intent.port)) return yield* unavailable(intent.port, field); + if (lifecycle === "running" && usedLive.has(intent.port)) + return yield* unavailable(intent.port, field); + assignment = { field, port: intent.port, intent: "exact" }; + } + assignments.push(assignment); + byField[field] = assignment; + usedByThisStack.add(assignment.port); + } + const next: PersistedStackState = { + ...current, + // Keep a running lifecycle only after ownership/publication succeeds. + desiredLifecycle: lifecycle === "running" ? current.desiredLifecycle : lifecycle, + desiredGeneration: planOptions.nextGeneration ?? current.desiredGeneration, + ports: assignments, + }; + yield* store.replaceUnlocked(stackId, next, expected); + return { current, next, lifecycle, runtime, byField }; + }), + ); + + const nativeListeners: NativeListener[] = []; + const enabledAssignments = fields.flatMap((field) => { + const intent = listenerIntents[field]; + const assignment = committed.byField[field]; + return intent.enabled && assignment !== undefined ? [{ field, intent, assignment }] : []; + }); + if (committed.lifecycle === "running" && committed.runtime.kind === "native") { + if (options.bindNative === undefined) { + const first = enabledAssignments[0]; + return yield* unavailable(first?.assignment.port ?? 0, first?.field ?? "api"); + } + for (const { field, intent, assignment } of enabledAssignments) { + const listener = yield* options + .bindNative(intent.address, assignment.port, field) + .pipe( + Effect.catchTag("PortUnavailableError", () => + Effect.fail(unavailable(assignment.port, field)), + ), + ); + nativeListeners.push(listener); + } + } + if (committed.lifecycle === "running" && committed.runtime.kind === "container") { + if (options.publishContainer === undefined) { + const first = enabledAssignments[0]; + return yield* unavailable(first?.assignment.port ?? 0, first?.field ?? "api"); + } + for (const { field, intent, assignment } of enabledAssignments) + yield* options.publishContainer(intent.address, assignment.port, field); + } + + if (committed.lifecycle === "running") { + const runningState: PersistedStackState = { + ...committed.next, + desiredLifecycle: "running", + }; + yield* registry.withLock( + Effect.gen(function* () { + const latest = yield* store.read(stackId); + if ( + latest === undefined || + latest.desiredGeneration !== committed.next.desiredGeneration + ) + return yield* new StackStateGenerationMismatchError({ + expectedGeneration: committed.next.desiredGeneration, + actualGeneration: latest?.desiredGeneration, + message: "Persisted stack state changed while acquiring runtime ownership", + }); + yield* store.replaceUnlocked(stackId, runningState, latest.desiredGeneration); + }), + ); + } + return { assignments: committed.byField, nativeListeners }; + }), +}); diff --git a/packages/stack/src/state/PortRegistry.ts b/packages/stack/src/state/PortRegistry.ts new file mode 100644 index 0000000000..bb4ceb01ac --- /dev/null +++ b/packages/stack/src/state/PortRegistry.ts @@ -0,0 +1,70 @@ +import { Crypto, Effect, FileSystem, Path } from "effect"; +import { + InvalidProjectRootError, + StackStateFormatUnsupportedError, + StackStateInvalidError, +} from "../public/Errors.ts"; +import type { HostPortAssignment, PersistedStackState } from "./StackState.ts"; +import { withRegistryLock, type StackStateStore } from "./StackStateStore.ts"; + +export type PortRegistryError = + | InvalidProjectRootError + | StackStateInvalidError + | StackStateFormatUnsupportedError; + +export interface PortRegistry { + readonly stateRoot: string; + /** Serializes one complete port transaction with other processes. */ + readonly withLock: ( + action: Effect.Effect, + ) => Effect.Effect; + /** Enumerates authoritative per-stack state documents; this method never writes a registry. */ + readonly states: Effect.Effect< + ReadonlyArray<{ readonly stackId: string; readonly state: PersistedStackState }>, + PortRegistryError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; + readonly assignments: ( + stackId: string, + ) => Effect.Effect< + ReadonlyArray, + PortRegistryError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; +} + +const idPattern = /^[0-9a-f]{64}$/; + +export const makePortRegistry = (options: { + readonly stateRoot: string; + readonly store: StackStateStore; +}): Effect.Effect => + Effect.succeed({ + stateRoot: options.stateRoot, + withLock: (action: Effect.Effect) => + withRegistryLock(options.stateRoot, action), + states: Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(options.stateRoot); + const exists = yield* fs + .exists(root) + .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); + if (!exists) return []; + const entries = yield* fs + .readDirectory(root) + .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); + const ids = entries.filter((entry) => idPattern.test(entry)); + const values = yield* Effect.forEach(ids, (stackId) => + options.store + .read(stackId) + .pipe(Effect.map((state) => (state === undefined ? undefined : { stackId, state }))), + ); + return values.filter( + (entry): entry is { readonly stackId: string; readonly state: PersistedStackState } => + entry !== undefined, + ); + }), + assignments: (stackId) => + options.store.read(stackId).pipe(Effect.map((state) => state?.ports ?? [])), + }); diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts new file mode 100644 index 0000000000..b4cfd24399 --- /dev/null +++ b/packages/stack/src/state/SecretStore.ts @@ -0,0 +1,124 @@ +import { Crypto, Effect, Redacted } from "effect"; +import { StackMustBeStoppedError, StackSecretMismatchError } from "../public/Errors.ts"; +import type { DesiredStackLifecycle } from "../public/Status.ts"; +import type { PersistedSecretValues } from "./StackState.ts"; + +export type SecretPolicy = "managed" | "passthrough"; + +export interface SecretDeclaration { + readonly slot: string; + readonly policy: SecretPolicy; + readonly value?: Redacted.Redacted; +} + +export interface SecretCandidate { + /** Complete pass-through declarations plus any managed slots supplied by the caller. */ + readonly declarations: ReadonlyArray; +} + +export interface ResolvedSecrets { + readonly persisted: PersistedSecretValues; +} + +const secretMismatch = (slot: string, message: string) => + new StackSecretMismatchError({ slot, message }); + +const lifecycleChange = (slot?: string) => + new StackMustBeStoppedError({ + ...(slot === undefined ? {} : { slot }), + message: "Pass-through secrets may only change while the stack is stopped", + }); + +const readSecret = (value: Redacted.Redacted): string => { + const unredacted = Redacted.value(value); + return typeof unredacted === "string" ? unredacted : String(unredacted); +}; +const validSlot = (slot: string): boolean => /^[A-Za-z0-9_.:/-]+$/.test(slot); + +/** Redacts known exact values without attempting to infer transformed/derived secrets. */ +export const redactKnownSecrets = (message: string, known: Iterable): string => { + let result = message; + const secrets = [...new Set([...known].filter((secret) => secret.length > 0))].sort( + (left, right) => right.length - left.length, + ); + for (const secret of secrets) result = result.split(secret).join("[REDACTED]"); + return result; +}; + +const generatedSecret = (crypto: Crypto.Crypto) => + crypto.randomUUIDv4.pipe( + Effect.mapError((error) => + secretMismatch("managed", `Unable to generate managed secret: ${error.message}`), + ), + ); + +/** + * Resolves managed and pass-through secret declarations against dedicated persisted values. + * Managed omission reuses/generates exactly once; pass-through declarations are complete and + * can change only at a stopped lifecycle boundary. + */ +export const resolveSecrets = ( + candidate: SecretCandidate, + persisted: PersistedSecretValues | undefined, + lifecycle: DesiredStackLifecycle, +): Effect.Effect< + ResolvedSecrets, + StackSecretMismatchError | StackMustBeStoppedError, + Crypto.Crypto +> => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const isUnconfigured = persisted === undefined; + const previous = persisted ?? {}; + const declarations = new Map(); + for (const declaration of candidate.declarations) { + if (!validSlot(declaration.slot)) + return yield* secretMismatch("unknown", "Secret slot name is invalid"); + if (declarations.has(declaration.slot)) + return yield* secretMismatch(declaration.slot, "Duplicate secret declaration"); + declarations.set(declaration.slot, declaration); + } + + const resolved: Record = {}; + for (const [slot, declaration] of declarations) { + const old = previous[slot]; + if (old !== undefined && old.policy !== declaration.policy) { + return yield* secretMismatch(slot, "Secret policy cannot change for an existing slot"); + } + if (declaration.policy === "managed") { + const supplied = + declaration.value === undefined ? undefined : readSecret(declaration.value); + if (old !== undefined) { + if (supplied !== undefined && supplied !== old.value) + return yield* secretMismatch( + slot, + "Supplied managed secret differs from persisted value", + ); + resolved[slot] = old; + } else { + const value = supplied ?? (yield* generatedSecret(crypto)); + resolved[slot] = { policy: "managed", value }; + } + } else { + if (declaration.value === undefined) + return yield* secretMismatch(slot, "Pass-through secret declarations require a value"); + const value = readSecret(declaration.value); + if ( + lifecycle !== "stopped" && + ((old === undefined && !isUnconfigured) || (old !== undefined && old.value !== value)) + ) + return yield* lifecycleChange(slot); + resolved[slot] = { policy: "passthrough", value }; + } + } + + for (const [slot, old] of Object.entries(previous)) { + if (old.policy === "managed") { + resolved[slot] ??= old; + } else if (!declarations.has(slot)) { + if (lifecycle !== "stopped") return yield* lifecycleChange(slot); + } + } + + return { persisted: resolved }; + }); diff --git a/packages/stack/src/state/StackState.ts b/packages/stack/src/state/StackState.ts new file mode 100644 index 0000000000..5224eff2c8 --- /dev/null +++ b/packages/stack/src/state/StackState.ts @@ -0,0 +1,329 @@ +import { Effect, Redacted, Schema, SchemaAST, SchemaGetter, SchemaIssue } from "effect"; +import type { StackIdentity } from "../identity/Identity.ts"; +import { + AuthModule, + DatabaseModule, + FunctionsModule, + MailModule, + PoolerModule, + RealtimeModule, + RestModule, + StorageModule, + StudioModule, + AnalyticsModule, + AuthSettingsSchema, + DatabaseSettingsSchema, + FunctionsSettingsSchema, + MailSettingsSchema, + PoolerSettingsSchema, + RealtimeSettingsSchema, + RestSettingsSchema, + StorageSettingsSchema, + StudioSettingsSchema, + AnalyticsSettingsSchema, +} from "../model/capabilities/index.ts"; +import type { StackDefinition } from "../model/Compiler.ts"; +import type { CapabilityModule } from "../model/CapabilityModule.ts"; +import type { CapabilityName } from "../public/Capability.ts"; +import { CAPABILITY_NAMES } from "../public/Capability.ts"; +import { StackRuntimeSchema } from "../public/Runtime.ts"; +import { DesiredStackLifecycleSchema, NetworkPortSchema, PORT_FIELDS } from "../public/Status.ts"; + +export const STACK_STATE_FORMAT = "supabase-stack-state-v1" as const; + +export const PersistedStackIdentitySchema = Schema.Struct({ + stackId: Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)), + projectRoot: Schema.String, + checkoutRoot: Schema.String, + workspaceId: Schema.String, + checkoutId: Schema.String, + branchContext: Schema.String, + localProjectKey: Schema.String, + stackName: Schema.String, +}); +export type PersistedStackIdentity = Schema.Schema.Type; + +export const HostPortAssignmentSchema = Schema.Struct({ + field: Schema.Literals(PORT_FIELDS), + port: NetworkPortSchema, + intent: Schema.Literals(["automatic", "exact"] as const), +}); +export type HostPortAssignment = Schema.Schema.Type; + +export const PersistedSecretEntrySchema = Schema.Struct({ + policy: Schema.Literals(["managed", "passthrough"] as const), + value: Schema.String, +}); +export type PersistedSecretEntry = Schema.Schema.Type; + +/** Secret slots are dynamic because function environment names are user-defined. */ +export const PersistedSecretValuesSchema = Schema.Record( + Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_.:/-]+$/)), + PersistedSecretEntrySchema, +); +export type PersistedSecretValues = Schema.Schema.Type; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !Redacted.isRedacted(value); + +const hasExactKeys = ( + value: unknown, + keys: ReadonlyArray, +): value is Record => { + if (!isRecord(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && expected.every((key, index) => actual[index] === key); +}; + +const isSecretSlot = (value: unknown): value is { readonly slot: string } => + hasExactKeys(value, ["slot"]) && typeof value.slot === "string" && value.slot.length > 0; + +/** Convert persisted materialized leaves back to the input representation solely for validation. */ +const restoreForValidation = (value: unknown): unknown => { + if (isSecretSlot(value)) return Redacted.make(""); + if (value === null) return undefined; + if (Array.isArray(value)) return value.map(restoreForValidation); + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value) + .map(([key, entry]) => [key, restoreForValidation(entry)] as const) + .filter(([, entry]) => entry !== undefined), + ); + } + return value; +}; + +const hasCompleteDefaults = (value: unknown, defaults: unknown): boolean => { + if (Array.isArray(defaults)) return Array.isArray(value); + if (!isRecord(defaults)) return value !== undefined; + if (!isRecord(value)) return false; + for (const key of Object.keys(defaults)) { + if (!Object.hasOwn(value, key)) return false; + const defaultValue = defaults[key]; + const actualValue = value[key]; + if (isRecord(defaultValue) && Object.keys(defaultValue).length > 0) { + if (!hasCompleteDefaults(actualValue, defaultValue)) return false; + } + } + return true; +}; + +const capabilityKeys = ["enabled", "activation", "version", "settings"] as const; + +const invalid = (message: string): Effect.Effect => + Effect.fail(new SchemaIssue.InvalidValue({ message })); + +const validateDynamicRecords = ( + name: CapabilityName, + settings: unknown, +): Effect.Effect => { + if (!isRecord(settings)) return Effect.void; + if (name === "functions" && isRecord(settings.functions)) { + for (const [slug, value] of Object.entries(settings.functions)) { + if (!/^[a-zA-Z0-9_-]+$/.test(slug)) + return invalid(`Materialized function ${slug} has an invalid name`); + if ( + !hasExactKeys(value, [ + "enabled", + "verify_jwt", + "import_map", + "entrypoint", + "static_files", + "env", + ]) + ) + return invalid(`Materialized function ${slug} is missing a defaulted field`); + if (!isRecord(value.env)) return invalid(`Materialized function ${slug} has invalid env`); + for (const secret of Object.values(value.env)) + if (!isSecretSlot(secret)) return invalid("Function secret must be a slot"); + } + } + if (name === "storage" && isRecord(settings.buckets)) { + for (const [bucket, value] of Object.entries(settings.buckets)) { + if (!hasExactKeys(value, ["public", "file_size_limit", "allowed_mime_types", "objects_path"])) + return invalid(`Materialized bucket ${bucket} is missing a defaulted field`); + } + } + if (name === "auth" && isRecord(settings.email)) { + const template = settings.email.template; + if (isRecord(template)) { + for (const [name, value] of Object.entries(template)) + if (!hasExactKeys(value, ["subject", "content_path"])) + return invalid(`Materialized auth email template ${name} is missing a defaulted field`); + } + const notification = settings.email.notification; + if (isRecord(notification)) { + for (const [name, value] of Object.entries(notification)) + if (!hasExactKeys(value, ["enabled", "subject", "content_path"])) + return invalid( + `Materialized auth email notification ${name} is missing a defaulted field`, + ); + } + } + return Effect.void; +}; + +const validateModuleSettings = ( + module: CapabilityModule, + schema: Schema.Codec, + settings: unknown, + options: SchemaAST.ParseOptions, +): Effect.Effect => { + if (!hasCompleteDefaults(settings, module.defaultSettings)) + return invalid(`Materialized ${module.name} settings are incomplete`); + return Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" })( + restoreForValidation(settings), + options, + ).pipe( + Effect.asVoid, + Effect.mapError((error) => error.issue), + Effect.flatMap(() => validateDynamicRecords(module.name, settings)), + ); +}; + +const validateModuleSettingsByName = ( + name: CapabilityName, + settings: unknown, + options: SchemaAST.ParseOptions, +) => { + switch (name) { + case "database": + return validateModuleSettings(DatabaseModule, DatabaseSettingsSchema, settings, options); + case "rest": + return validateModuleSettings(RestModule, RestSettingsSchema, settings, options); + case "auth": + return validateModuleSettings(AuthModule, AuthSettingsSchema, settings, options); + case "realtime": + return validateModuleSettings(RealtimeModule, RealtimeSettingsSchema, settings, options); + case "storage": + return validateModuleSettings(StorageModule, StorageSettingsSchema, settings, options); + case "functions": + return validateModuleSettings(FunctionsModule, FunctionsSettingsSchema, settings, options); + case "studio": + return validateModuleSettings(StudioModule, StudioSettingsSchema, settings, options); + case "mail": + return validateModuleSettings(MailModule, MailSettingsSchema, settings, options); + case "analytics": + return validateModuleSettings(AnalyticsModule, AnalyticsSettingsSchema, settings, options); + case "pooler": + return validateModuleSettings(PoolerModule, PoolerSettingsSchema, settings, options); + } +}; + +const isDefinitionShape = (input: unknown): input is StackDefinition => { + if (!hasExactKeys(input, ["capabilities", "listeners", "security"])) return false; + const capabilities = input.capabilities; + if (!hasExactKeys(capabilities, CAPABILITY_NAMES)) return false; + for (const name of CAPABILITY_NAMES) { + const capability = capabilities[name]; + if (!hasExactKeys(capability, capabilityKeys)) return false; + if (typeof capability.enabled !== "boolean") return false; + if (capability.activation !== "eager" && capability.activation !== "lazy") return false; + if (typeof capability.version !== "string" || capability.version.length === 0) return false; + } + const listeners = input.listeners; + if (!hasExactKeys(listeners, PORT_FIELDS)) return false; + for (const field of PORT_FIELDS) { + const listener = listeners[field]; + if (!hasExactKeys(listener, ["enabled", "address", "port"])) return false; + if (typeof listener.enabled !== "boolean" || typeof listener.address !== "string") return false; + if (!(listener.port === "automatic" || Schema.is(NetworkPortSchema)(listener.port))) + return false; + } + const security = input.security; + if (!hasExactKeys(security, ["jwt"]) || !hasExactKeys(security.jwt, ["issuer", "signing"])) + return false; + const jwt = security.jwt; + if (!(jwt.issuer === null || typeof jwt.issuer === "string")) return false; + const signing = jwt.signing; + if (signing === null) return true; + if (!isRecord(signing)) return false; + if (signing.kind === "symmetric") + return hasExactKeys(signing, ["kind", "secret"]) && isSecretSlot(signing.secret); + return ( + hasExactKeys(signing, ["kind", "path"]) && + signing.kind === "jwks-file" && + typeof signing.path === "string" + ); +}; + +/** One closed, exhaustive schema for the compiler's fully materialized definition. */ +export const StackDefinitionSchema = Schema.declareConstructor()( + [], + () => (input, ast, options) => + Effect.gen(function* () { + if (!isDefinitionShape(input)) + return yield* invalid("Materialized definition shape is invalid"); + const capabilities = input.capabilities; + for (const name of CAPABILITY_NAMES) { + const capability = capabilities[name]; + yield* validateModuleSettingsByName(name, capability.settings, options); + } + return input; + }), + { title: "Materialized StackDefinition" }, +); + +const PortAssignmentsSchema = Schema.Array(HostPortAssignmentSchema).pipe( + Schema.decode({ + decode: SchemaGetter.checkEffect((assignments) => + Effect.succeed( + new Set(assignments.map(({ field }) => field)).size === assignments.length && + new Set(assignments.map(({ port }) => port)).size === assignments.length + ? undefined + : "Duplicate persisted port field or port", + ), + ), + encode: SchemaGetter.passthrough(), + }), +); + +const StackGenerationSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const stateShape = Schema.Struct({ + format: Schema.Literal(STACK_STATE_FORMAT), + identity: PersistedStackIdentitySchema, + runtime: StackRuntimeSchema, + desiredGeneration: StackGenerationSchema, + desiredLifecycle: DesiredStackLifecycleSchema, + definition: Schema.optional(StackDefinitionSchema), + inputFingerprint: Schema.optional(Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/))), + ports: PortAssignmentsSchema, + secrets: PersistedSecretValuesSchema, +}); + +/** Complete durable state. Definition and fingerprint are intentionally an all-or-none pair. */ +export const PersistedStackStateSchema = stateShape.pipe( + Schema.decode({ + decode: SchemaGetter.checkEffect((state) => + Effect.succeed( + (state.definition === undefined) === (state.inputFingerprint === undefined) + ? undefined + : "definition and inputFingerprint must be persisted together", + ), + ), + encode: SchemaGetter.passthrough(), + }), +); +export type PersistedStackState = Schema.Schema.Type; + +export const isPersistedStackIdentity = (value: unknown): value is PersistedStackIdentity => + Schema.is(PersistedStackIdentitySchema)(value); + +export const toPersistedIdentity = ( + identity: StackIdentity, + stackId: string, +): PersistedStackIdentity => ({ + stackId, + projectRoot: identity.projectRoot, + checkoutRoot: identity.checkoutRoot, + workspaceId: identity.workspaceId, + checkoutId: identity.checkoutId, + branchContext: identity.branchContext, + localProjectKey: identity.localProjectKey, + stackName: identity.stackName, +}); diff --git a/packages/stack/src/state/StackStateStore.ts b/packages/stack/src/state/StackStateStore.ts new file mode 100644 index 0000000000..c31c1d7809 --- /dev/null +++ b/packages/stack/src/state/StackStateStore.ts @@ -0,0 +1,469 @@ +import { Crypto, Effect, FileSystem, Path, Schedule, Schema } from "effect"; +import { + InvalidProjectRootError, + StackStateFormatUnsupportedError, + StackStateGenerationMismatchError, + StackStateInvalidError, +} from "../public/Errors.ts"; +import { resolveStackPaths } from "./Paths.ts"; +import { deriveStackId } from "../identity/Identity.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import { + PersistedStackStateSchema, + STACK_STATE_FORMAT, + type PersistedStackState, +} from "./StackState.ts"; + +export interface StackStateStore { + /** Reads without taking the registry lock. `undefined` means an unconfigured identity. */ + readonly read: ( + stackId: string, + ) => Effect.Effect< + PersistedStackState | undefined, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; + /** Writes a complete state document. Intended for creation or explicitly fenced callers. */ + readonly write: ( + stackId: string, + state: PersistedStackState, + ) => Effect.Effect< + void, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; + /** Replaces one complete state value after checking the expected generation. */ + readonly replace: ( + stackId: string, + state: PersistedStackState, + expectedGeneration: number, + ) => Effect.Effect< + void, + | InvalidProjectRootError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackStateGenerationMismatchError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; + /** Internal transaction primitive for callers that already hold the registry lock. */ + readonly replaceUnlocked: ( + stackId: string, + state: PersistedStackState, + expectedGeneration: number, + ) => Effect.Effect< + void, + | InvalidProjectRootError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackStateGenerationMismatchError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; + /** Explicit, exact-identity destructive cleanup. Ordinary lifecycle paths never call this. */ + readonly cleanup: ( + stackId: string, + ) => Effect.Effect< + void, + InvalidProjectRootError | StackStateInvalidError, + FileSystem.FileSystem | Path.Path + >; +} + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const hasExactKeys = ( + value: unknown, + keys: ReadonlyArray, +): value is Readonly> => { + if (!isRecord(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && expected.every((key, index) => actual[index] === key); +}; + +const stateError = (message: string, cause?: unknown) => + new StackStateInvalidError({ message, ...(cause === undefined ? {} : { cause }) }); + +const validateId = (stackId: string) => + Schema.decodeEffect(StackIdSchema)(stackId).pipe( + Effect.mapError((error) => stateError(`Invalid StackId: ${String(error)}`)), + ); + +const decodeState = ( + raw: unknown, +): Effect.Effect< + PersistedStackState, + StackStateInvalidError | StackStateFormatUnsupportedError +> => { + if (!isRecord(raw)) return Effect.fail(stateError("Persisted stack state must be an object")); + if (typeof raw.format !== "string") + return Effect.fail(stateError("Persisted stack state format is missing or invalid")); + if (raw.format !== STACK_STATE_FORMAT) { + return Effect.fail( + new StackStateFormatUnsupportedError({ + format: isRecord(raw) && typeof raw.format === "string" ? raw.format : undefined, + message: `Unsupported stack state format; expected ${STACK_STATE_FORMAT}`, + }), + ); + } + if (!isRecord(raw.secrets)) + return Effect.fail(stateError("Persisted secret values must be a record")); + for (const [slot, entry] of Object.entries(raw.secrets)) { + if (!/^[A-Za-z0-9_.:/-]+$/.test(slot)) + return Effect.fail(stateError(`Persisted secret slot key is invalid: ${slot}`)); + if (!isRecord(entry) || !hasExactKeys(entry, ["policy", "value"])) + return Effect.fail(stateError(`Persisted secret entry ${slot} has an invalid shape`)); + if (entry.policy !== "managed" && entry.policy !== "passthrough") + return Effect.fail(stateError(`Persisted secret entry ${slot} has an invalid policy`)); + if (typeof entry.value !== "string") + return Effect.fail(stateError(`Persisted secret entry ${slot} has an invalid value`)); + } + return Schema.decodeUnknownEffect(PersistedStackStateSchema)(raw, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => stateError(`Invalid persisted stack state: ${String(error)}`)), + ); +}; + +const ownerDirectory = (fs: FileSystem.FileSystem, directory: string) => + fs + .makeDirectory(directory, { recursive: true, mode: 0o700 }) + .pipe(Effect.flatMap(() => fs.chmod(directory, 0o700))); + +const validateIdentityForStackId = ( + identity: PersistedStackState["identity"], + stackId: string, +): Effect.Effect => { + const { stackId: persistedStackId, ...tuple } = identity; + return deriveStackId(tuple).pipe( + Effect.mapError((error) => + stateError(`Unable to validate persisted identity: ${error.message}`), + ), + Effect.flatMap((derived) => + persistedStackId === stackId && derived === stackId + ? Effect.void + : Effect.fail(stateError("Persisted identity does not match its StackId directory")), + ), + ); +}; + +const atomicWrite = ( + fs: FileSystem.FileSystem, + path: Path.Path, + crypto: Crypto.Crypto, + statePath: string, + stackRoot: string, + state: PersistedStackState, +): Effect.Effect => + Effect.gen(function* () { + const encoded = yield* Schema.encodeEffect(PersistedStackStateSchema)(state).pipe( + Effect.mapError((error) => + stateError(`Unable to encode persisted stack state: ${String(error)}`), + ), + ); + const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(PersistedStackStateSchema))( + encoded, + ).pipe( + Effect.mapError((error) => + stateError(`Unable to encode persisted stack state JSON: ${String(error)}`), + ), + ); + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((error) => + stateError(`Unable to allocate state temporary name: ${error.message}`), + ), + ); + const temporary = path.join(stackRoot, `.state.${token}.tmp`); + yield* Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs + .open(temporary, { flag: "wx", mode: 0o600 }) + .pipe( + Effect.mapError((error) => + stateError(`Unable to open temporary state: ${error.message}`), + ), + ); + yield* file + .writeAll(new TextEncoder().encode(serialized)) + .pipe( + Effect.mapError((error) => + stateError(`Unable to write temporary state: ${error.message}`), + ), + ); + yield* file.sync.pipe( + Effect.mapError((error) => + stateError(`Unable to sync temporary state: ${error.message}`), + ), + ); + }), + ); + yield* fs + .rename(temporary, statePath) + .pipe( + Effect.mapError((error) => + stateError(`Unable to atomically replace state: ${error.message}`), + ), + ); + }).pipe( + Effect.ensuring( + fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + ); + yield* fs + .chmod(statePath, 0o600) + .pipe( + Effect.mapError((error) => stateError(`Unable to secure state document: ${error.message}`)), + ); + // Effect Platform has no portable directory-fsync operation; file sync plus same-directory + // rename is the strongest cross-platform atomicity guarantee available here. + }); + +/** + * Acquires one short cross-process lock. The lock is an O_EXCL file owned by this operation and is + * removed exactly on release. We intentionally do not steal stale locks because ownership cannot + * be proven safely after a process crash. + */ +export const withRegistryLock = ( + stateRoot: string, + action: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(stateRoot); + yield* fs + .makeDirectory(root, { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError((error) => + stateError(`Unable to create stack registry root: ${error.message}`), + ), + ); + yield* fs + .chmod(root, 0o700) + .pipe( + Effect.mapError((error) => + stateError(`Unable to secure stack registry root: ${error.message}`), + ), + ); + const lockPath = path.join(root, ".stack-registry.lock"); + // `writeFileString(..., { flag: "wx" })` is the platform's atomic O_EXCL + // primitive. The lock descriptor itself need not stay open: the exact + // lock file is the ownership token and is removed only by this operation. + // Avoid scoping the action here: callers may acquire native listeners in + // their Scope and those must outlive the short registry transaction. + yield* fs.writeFileString(lockPath, "", { flag: "wx", mode: 0o600 }).pipe( + Effect.retry({ + schedule: Schedule.exponential("1 millis").pipe(Schedule.upTo({ times: 20 })), + }), + Effect.mapError((error) => + stateError(`Unable to acquire stack registry lock: ${error.message}`), + ), + ); + return yield* action.pipe( + Effect.ensuring( + fs + .remove(lockPath, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + ); + }); + +export const makeStackStateStore = (options: { + readonly stateRoot: string; +}): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + + const pathsFor = (stackId: string) => + Effect.gen(function* () { + const valid = yield* validateId(stackId); + return yield* resolveStackPaths({ stateRoot: options.stateRoot, stackId: valid }).pipe( + Effect.mapError((error) => + error instanceof InvalidProjectRootError + ? error + : stateError(`Unable to resolve stack state paths: ${String(error)}`), + ), + ); + }); + + const read = ( + stackId: string, + ): Effect.Effect< + PersistedStackState | undefined, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => + Effect.gen(function* () { + const paths = yield* pathsFor(stackId); + const exists = yield* fs + .exists(paths.stateDocument) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect state document: ${error.message}`), + ), + ); + if (!exists) { + const rootExists = yield* fs + .exists(paths.stackRoot) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect stack root: ${error.message}`), + ), + ); + if (!rootExists) return undefined; + const entries = yield* fs + .readDirectory(paths.stackRoot) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect stack remnants: ${error.message}`), + ), + ); + if (entries.length > 0) { + return yield* stateError( + "Stack state is missing beside identity-owned remnants; refusing to guess replacement state", + ); + } + return undefined; + } + const info = yield* fs + .stat(paths.stateDocument) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect state document: ${error.message}`), + ), + ); + if (info.type !== "File") + return yield* stateError("Stack state document is not a regular file"); + const text = yield* fs + .readFileString(paths.stateDocument) + .pipe( + Effect.mapError((error) => + stateError(`Unable to read state document: ${error.message}`), + ), + ); + const raw = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( + Effect.mapError((error) => + stateError(`Unable to parse state document: ${String(error)}`), + ), + ); + const decoded = yield* decodeState(raw); + yield* validateIdentityForStackId(decoded.identity, stackId); + return decoded; + }); + + const write = ( + stackId: string, + next: PersistedStackState, + ): Effect.Effect< + void, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => + withRegistryLock( + options.stateRoot, + Effect.gen(function* () { + const paths = yield* pathsFor(stackId); + yield* validateIdentityForStackId(next.identity, stackId); + yield* Schema.decodeEffect(PersistedStackStateSchema)(next, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => + stateError(`Invalid persisted stack state: ${String(error)}`), + ), + ); + const existing = yield* read(stackId); + if (existing !== undefined) + return yield* stateError( + "Stack state already exists; use a generation-fenced replacement", + ); + yield* ownerDirectory(fs, paths.stackRoot).pipe( + Effect.mapError((error) => + stateError(`Unable to create stack state directory: ${error.message}`), + ), + ); + yield* atomicWrite(fs, path, crypto, paths.stateDocument, paths.stackRoot, next); + }), + ); + + const replaceUnlocked = ( + stackId: string, + next: PersistedStackState, + expectedGeneration: number, + ): Effect.Effect< + void, + | InvalidProjectRootError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackStateGenerationMismatchError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => + Effect.gen(function* () { + const paths = yield* pathsFor(stackId); + yield* validateIdentityForStackId(next.identity, stackId); + const current = yield* read(stackId); + if (current === undefined || current.desiredGeneration !== expectedGeneration) { + return yield* new StackStateGenerationMismatchError({ + expectedGeneration, + actualGeneration: current?.desiredGeneration, + message: "Persisted stack state generation changed", + }); + } + if (next.identity.stackId !== stackId) + return yield* stateError("Persisted identity does not match StackId"); + yield* Schema.decodeEffect(PersistedStackStateSchema)(next, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => stateError(`Invalid persisted stack state: ${String(error)}`)), + ); + yield* ownerDirectory(fs, paths.stackRoot).pipe( + Effect.mapError((error) => + stateError(`Unable to create stack state directory: ${error.message}`), + ), + ); + yield* atomicWrite(fs, path, crypto, paths.stateDocument, paths.stackRoot, next); + }); + + const replace = ( + stackId: string, + next: PersistedStackState, + expectedGeneration: number, + ): Effect.Effect< + void, + | InvalidProjectRootError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackStateGenerationMismatchError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => withRegistryLock(options.stateRoot, replaceUnlocked(stackId, next, expectedGeneration)); + + const cleanup = ( + stackId: string, + ): Effect.Effect< + void, + InvalidProjectRootError | StackStateInvalidError, + FileSystem.FileSystem | Path.Path + > => + withRegistryLock( + options.stateRoot, + Effect.gen(function* () { + const paths = yield* pathsFor(stackId); + yield* fs + .remove(paths.stackRoot, { recursive: true, force: true }) + .pipe( + Effect.mapError((error) => + stateError(`Unable to remove exact stack identity: ${error.message}`), + ), + ); + }), + ); + + return { read, write, replace, replaceUnlocked, cleanup } satisfies StackStateStore; + }); + +export { PersistedStackStateSchema } from "./StackState.ts"; +export type { PersistedStackState } from "./StackState.ts"; diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts new file mode 100644 index 0000000000..57b2ed7be2 --- /dev/null +++ b/packages/stack/src/state/ports.integration.test.ts @@ -0,0 +1,298 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Option, Scope } from "effect"; +import { createServer, type Server } from "node:net"; +import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; +import { PortUnavailableError } from "../public/Errors.ts"; +import { makePortRegistry } from "./PortRegistry.ts"; +import { makePortCoordinator, type ListenerIntents } from "./PortCoordinator.ts"; +import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; + +const layer = NodeServices.layer; +const intents = (port: "automatic" | number): ListenerIntents => ({ + api: { enabled: true, address: "127.0.0.1", port }, + database: { enabled: true, address: "127.0.0.1", port: "automatic" }, + pooler: { enabled: false, address: "127.0.0.1", port: "automatic" }, + studio: { enabled: false, address: "127.0.0.1", port: "automatic" }, + mailUi: { enabled: false, address: "127.0.0.1", port: "automatic" }, + smtp: { enabled: false, address: "127.0.0.1", port: "automatic" }, + pop3: { enabled: false, address: "127.0.0.1", port: "automatic" }, + functionsInspector: { enabled: false, address: "127.0.0.1", port: "automatic" }, +}); +const disabledIntents = (): ListenerIntents => ({ + api: { enabled: false, address: "127.0.0.1", port: "automatic" }, + database: { enabled: false, address: "127.0.0.1", port: "automatic" }, + pooler: { enabled: false, address: "127.0.0.1", port: "automatic" }, + studio: { enabled: false, address: "127.0.0.1", port: "automatic" }, + mailUi: { enabled: false, address: "127.0.0.1", port: "automatic" }, + smtp: { enabled: false, address: "127.0.0.1", port: "automatic" }, + pop3: { enabled: false, address: "127.0.0.1", port: "automatic" }, + functionsInspector: { enabled: false, address: "127.0.0.1", port: "automatic" }, +}); +const baseState = ( + stackId: string, + identity: StackIdentity, + desiredLifecycle: "stopped" | "running" = "stopped", +): PersistedStackState => ({ + format: "supabase-stack-state-v1", + identity: { + ...identity, + stackId, + }, + runtime: { kind: "native" }, + desiredGeneration: 0, + desiredLifecycle, + ports: [], + secrets: {}, +}); +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(layer)); + +const makeIdentity = (root: string, name: string): StackIdentity => ({ + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: name, +}); + +const holdHostPort = (port: number): Effect.Effect => + Effect.acquireRelease( + Effect.callback((resume) => { + const server = createServer(); + const onError = (error: Error) => { + server.removeListener("error", onError); + resume(Effect.fail(error)); + }; + server.once("error", onError); + server.listen({ host: "127.0.0.1", port }, () => { + server.removeListener("error", onError); + resume(Effect.succeed(server)); + }); + return Effect.sync(() => { + server.removeListener("error", onError); + server.close(); + }); + }), + (server) => + Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return; + } + server.close(() => resume(Effect.void)); + }), + ); + +describe("sticky port coordination", () => { + it.live("keeps automatic assignments sticky and exclusive", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-port-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const aIdentity = makeIdentity(root, "a"); + const bIdentity = makeIdentity(root, "b"); + const a = yield* deriveStackId(aIdentity); + const b = yield* deriveStackId(bIdentity); + yield* store.write(a, baseState(a, aIdentity)); + yield* store.write(b, baseState(b, bIdentity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store); + const first = yield* coordinator.planAndReserve(a, intents("automatic")); + const repeat = yield* coordinator.planAndReserve(a, intents("automatic")); + expect(repeat.assignments).toEqual(first.assignments); + const second = yield* coordinator.planAndReserve(b, intents("automatic")); + expect(second.assignments.api?.port).not.toBe(first.assignments.api?.port); + }), + ), + ); + + it.live("allows exact assignments to coexist while stopped but rejects live conflicts", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-port-exact-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const cIdentity = makeIdentity(root, "c"); + const dIdentity = makeIdentity(root, "d"); + const c = yield* deriveStackId(cIdentity); + const d = yield* deriveStackId(dIdentity); + yield* store.write(c, { + ...baseState(c, cIdentity, "running"), + ports: [{ field: "api", port: 55432, intent: "exact" }], + }); + yield* store.write(d, baseState(d, dIdentity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store); + yield* coordinator.planAndReserve(d, intents(55432), { lifecycle: "stopped" }); + const conflict = yield* coordinator + .planAndReserve(d, intents(55432), { lifecycle: "running" }) + .pipe(Effect.exit); + expect(errorOf(conflict)).toBeInstanceOf(PortUnavailableError); + }), + ), + ); + + it.live("rejects duplicate exact ports within one stack", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-duplicate-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "duplicate"); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, baseState(stackId, identity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store); + const exit = yield* coordinator + .planAndReserve( + stackId, + { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55434 }, + database: { enabled: true, address: "127.0.0.1", port: 55434 }, + }, + { lifecycle: "stopped" }, + ) + .pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); + }), + ), + ); + + it.live("rejects an exact port occupied by an owned host listener", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "supabase-stack-port-host-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "host-conflict"); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, baseState(stackId, identity)); + const host = yield* holdHostPort(0); + const hostAddress = host.address(); + expect(hostAddress).toBeTypeOf("object"); + if (typeof hostAddress !== "object" || hostAddress === null) return; + const occupiedPort = hostAddress.port; + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store, { + bindNative: (address, port, field) => + holdHostPort(port).pipe( + Effect.map((server) => ({ + field, + address, + port, + close: Effect.sync(() => server.close()), + })), + Effect.mapError( + () => + new PortUnavailableError({ + port, + field, + message: "host listener already owns port", + }), + ), + ), + }); + const exit = yield* coordinator + .planAndReserve( + stackId, + { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: occupiedPort }, + }, + { lifecycle: "running", runtime: { kind: "native" } }, + ) + .pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); + expect( + (yield* registry.assignments(stackId)).some( + (assignment) => assignment.port === occupiedPort, + ), + ).toBe(true); + }), + ), + ); + + it.live("transfers native sockets without a bind-close-rebind", () => + Effect.gen(function* () { + const bound = new Set(); + let closed = 0; + yield* withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-port-native-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const eIdentity = makeIdentity(root, "e"); + const e = yield* deriveStackId(eIdentity); + yield* store.write(e, baseState(e, eIdentity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store, { + bindNative: (address, port, field) => + bound.has(port) + ? Effect.fail(new PortUnavailableError({ port, field, message: "already bound" })) + : Effect.acquireRelease( + Effect.sync(() => { + bound.add(port); + return { + address, + port, + field, + close: Effect.sync(() => { + bound.delete(port); + closed += 1; + }), + }; + }), + (listener) => listener.close, + ), + }); + const result = yield* coordinator.planAndReserve(e, intents("automatic"), { + lifecycle: "running", + runtime: { kind: "native" }, + }); + expect(result.nativeListeners).toHaveLength(2); + expect(closed).toBe(0); + expect(bound.size).toBe(2); + }), + ); + expect(closed).toBe(2); + expect(bound.size).toBe(0); + }), + ); + + it.live("retains a chosen assignment when container publication races", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-port-race-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const fIdentity = makeIdentity(root, "f"); + const f = yield* deriveStackId(fIdentity); + yield* store.write(f, baseState(f, fIdentity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store, { + publishContainer: (address, port, field) => + Effect.fail(new PortUnavailableError({ port, field, message: `race at ${address}` })), + }); + const exit = yield* coordinator + .planAndReserve(f, intents(55433), { + lifecycle: "running", + runtime: { kind: "container", engine: "docker" }, + }) + .pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); + const reserved = yield* registry.assignments(f); + expect(reserved.some((assignment) => assignment.port === 55433)).toBe(true); + }), + ), + ); +}); diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts new file mode 100644 index 0000000000..e59b82dea2 --- /dev/null +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -0,0 +1,142 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option, Redacted } from "effect"; +import { StackMustBeStoppedError, StackSecretMismatchError } from "../public/Errors.ts"; +import { compileStack } from "../model/Compiler.ts"; +import { redactKnownSecrets, resolveSecrets, type SecretCandidate } from "./SecretStore.ts"; + +const layer = NodeServices.layer; +const managed = (value?: string): SecretCandidate => ({ + declarations: [ + { + slot: "managed:db", + policy: "managed", + value: value === undefined ? undefined : Redacted.make(value), + }, + ], +}); +const passthrough = (slot: string, value: string): SecretCandidate => ({ + declarations: [{ slot, policy: "passthrough", value: Redacted.make(value) }], +}); +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +describe("managed and pass-through secrets", () => { + it.live("generates managed omissions once and reuses them", () => + Effect.gen(function* () { + const first = yield* resolveSecrets(managed(), undefined, "stopped"); + const second = yield* resolveSecrets(managed(), first.persisted, "running"); + expect(second.persisted["managed:db"]?.value).toBe(first.persisted["managed:db"]?.value); + expect(second.persisted["managed:db"]?.value).not.toHaveLength(0); + }).pipe(Effect.provide(layer)), + ); + + it.live("generates and reuses every compiler-required managed slot", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: "/tmp/project", + runtime: { kind: "native" }, + }); + const candidate = { declarations: compiled.secrets }; + const first = yield* resolveSecrets(candidate, undefined, "stopped"); + const second = yield* resolveSecrets(candidate, first.persisted, "running"); + for (const slot of [ + "secret:database.internal.password", + "secret:security.jwt.signing.secret", + "secret:auth.settings.publishable_key", + "secret:auth.settings.secret_key", + ]) { + expect(first.persisted[slot]?.policy).toBe("managed"); + expect(first.persisted[slot]?.value).toBe(second.persisted[slot]?.value); + } + }).pipe(Effect.provide(layer)), + ); + + it.live("rejects a conflicting managed value in stopped and running states", () => + Effect.gen(function* () { + const first = yield* resolveSecrets(managed("original"), undefined, "stopped"); + for (const lifecycle of ["stopped", "running"] as const) { + const exit = yield* resolveSecrets(managed("different"), first.persisted, lifecycle).pipe( + Effect.exit, + ); + expect(errorOf(exit)).toBeInstanceOf(StackSecretMismatchError); + } + }).pipe(Effect.provide(layer)), + ); + + it.live("allows complete pass-through add, replacement, and removal only while stopped", () => + Effect.gen(function* () { + const first = yield* resolveSecrets(passthrough("pass:smtp", "old"), undefined, "stopped"); + const replacement = yield* resolveSecrets( + passthrough("pass:smtp", "new"), + first.persisted, + "stopped", + ); + expect(replacement.persisted["pass:smtp"]?.value).toBe("new"); + const removed = yield* resolveSecrets({ declarations: [] }, replacement.persisted, "stopped"); + expect(removed.persisted["pass:smtp"]).toBeUndefined(); + const running = yield* resolveSecrets( + { declarations: [] }, + replacement.persisted, + "running", + ).pipe(Effect.exit); + expect(errorOf(running)).toBeInstanceOf(StackMustBeStoppedError); + }).pipe(Effect.provide(layer)), + ); + + it.live( + "rejects pass-through additions, replacements, and removals while running or destroying", + () => + Effect.gen(function* () { + const existing = yield* resolveSecrets( + passthrough("pass:smtp", "old"), + undefined, + "stopped", + ); + for (const lifecycle of ["running", "destroying"] as const) { + const added = yield* resolveSecrets( + passthrough("pass:new", "value"), + existing.persisted, + lifecycle, + ).pipe(Effect.exit); + expect(errorOf(added)).toBeInstanceOf(StackMustBeStoppedError); + const replaced = yield* resolveSecrets( + passthrough("pass:smtp", "new"), + existing.persisted, + lifecycle, + ).pipe(Effect.exit); + expect(errorOf(replaced)).toBeInstanceOf(StackMustBeStoppedError); + const removed = yield* resolveSecrets( + { declarations: [] }, + existing.persisted, + lifecycle, + ).pipe(Effect.exit); + expect(errorOf(removed)).toBeInstanceOf(StackMustBeStoppedError); + const unchanged = yield* resolveSecrets( + passthrough("pass:smtp", "old"), + existing.persisted, + lifecycle, + ); + expect(unchanged.persisted["pass:smtp"]?.value).toBe("old"); + } + }).pipe(Effect.provide(layer)), + ); + + it.live("does not include secret bytes in mismatch errors or redaction output", () => + Effect.gen(function* () { + const first = yield* resolveSecrets(managed("top-secret"), undefined, "stopped"); + const exit = yield* resolveSecrets(managed("other-secret"), first.persisted, "stopped").pipe( + Effect.exit, + ); + const error = errorOf(exit); + expect(String(error)).not.toContain("top-secret"); + expect(String(error)).not.toContain("other-secret"); + }).pipe(Effect.provide(layer)), + ); + + it("redacts overlapping known values longest-first", () => { + expect(redactKnownSecrets("token=abc123; short=abc", ["abc", "abc123", "abc", ""])).toBe( + "token=[REDACTED]; short=[REDACTED]", + ); + }); +}); diff --git a/packages/stack/src/state/state-store.integration.test.ts b/packages/stack/src/state/state-store.integration.test.ts new file mode 100644 index 0000000000..45afda102f --- /dev/null +++ b/packages/stack/src/state/state-store.integration.test.ts @@ -0,0 +1,279 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted, Schema } from "effect"; +import { compileStack, type StackDefinition } from "../model/Compiler.ts"; +import { deriveStackId } from "../identity/Identity.ts"; +import { + StackStateFormatUnsupportedError, + StackStateGenerationMismatchError, + StackStateInvalidError, +} from "../public/Errors.ts"; +import { + makeStackStateStore, + PersistedStackStateSchema, + type PersistedStackState, +} from "./StackStateStore.ts"; + +const layer = NodeServices.layer; +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(layer)); + +const identity = { + projectRoot: "/tmp/project", + checkoutRoot: "/tmp/project", + workspaceId: "/tmp/project", + checkoutId: "/tmp/project", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "default", +} as const; + +const state = ( + stackId: string, + generation = 0, + definition?: StackDefinition, +): PersistedStackState => ({ + format: "supabase-stack-state-v1", + identity: { ...identity, stackId }, + runtime: { kind: "native" }, + desiredGeneration: generation, + desiredLifecycle: "stopped", + definition, + inputFingerprint: definition === undefined ? undefined : "d".repeat(64), + ports: [], + secrets: {}, +}); + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; +const jsonText = (value: unknown) => + Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value); + +describe("atomic stack state", () => { + it.live("round-trips a compiled complete definition and rejects nested unknowns", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-state-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const stackId = yield* deriveStackId(identity); + const compiled = yield* compileStack({ + projectRoot: "/tmp/project", + runtime: { kind: "native" }, + config: { + capabilities: { + auth: { + settings: { + secret_key: Redacted.make("state-secret"), + email: { + template: { confirm: { subject: "Confirm" } }, + notification: { welcome: { enabled: true } }, + }, + }, + }, + functions: { settings: { functions: { hello: { verify_jwt: false } } } }, + }, + }, + }); + const complete = state(stackId, 2, compiled.definition); + yield* store.write(stackId, complete); + expect(yield* store.read(stackId)).toEqual(complete); + + const encoded = yield* Schema.encodeEffect(PersistedStackStateSchema)(complete); + const statePath = path.join(root, stackId, "state.json"); + const nestedUnknown = { + ...encoded, + definition: { + ...encoded.definition, + capabilities: { + ...encoded.definition?.capabilities, + auth: { + ...encoded.definition?.capabilities.auth, + settings: { + ...encoded.definition?.capabilities.auth.settings, + email: { + ...encoded.definition?.capabilities.auth.settings.email, + template: { + ...encoded.definition?.capabilities.auth.settings.email?.template, + confirm: { + ...encoded.definition?.capabilities.auth.settings.email?.template?.confirm, + unknown: true, + }, + }, + }, + }, + }, + }, + }, + }; + yield* fs.writeFileString(statePath, yield* jsonText(nestedUnknown)); + const unknownExit = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(unknownExit)).toBeInstanceOf(StackStateInvalidError); + + const missingDefault = { + ...encoded, + definition: { + ...encoded.definition, + capabilities: { + ...encoded.definition?.capabilities, + functions: { + ...encoded.definition?.capabilities.functions, + settings: { + ...encoded.definition?.capabilities.functions.settings, + functions: { + ...encoded.definition?.capabilities.functions.settings.functions, + hello: { + ...encoded.definition?.capabilities.functions.settings.functions?.hello, + verify_jwt: undefined, + }, + }, + }, + }, + }, + }, + }; + yield* fs.writeFileString(statePath, yield* jsonText(missingDefault)); + const missingDefaultExit = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(missingDefaultExit)).toBeInstanceOf(StackStateInvalidError); + + const invalidRecordKey = { + ...encoded, + definition: { + ...encoded.definition, + capabilities: { + ...encoded.definition?.capabilities, + functions: { + ...encoded.definition?.capabilities.functions, + settings: { + ...encoded.definition?.capabilities.functions.settings, + functions: { + "bad.slug": { + enabled: true, + verify_jwt: true, + import_map: null, + entrypoint: null, + static_files: null, + env: {}, + }, + }, + }, + }, + }, + }, + }; + yield* fs.writeFileString(statePath, yield* jsonText(invalidRecordKey)); + const recordExit = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(recordExit)).toBeInstanceOf(StackStateInvalidError); + + const invalidSecret = { + ...encoded, + secrets: { "": { policy: "managed", value: "x" } }, + }; + yield* fs.writeFileString(statePath, yield* jsonText(invalidSecret)); + const secretExit = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(secretExit)).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); + + it.live("fails closed when identity remnants exist without state and cleans exact identity", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-remnant-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const stackId = yield* deriveStackId(identity); + yield* fs.makeDirectory(path.join(root, stackId, "data"), { recursive: true }); + const sibling = yield* deriveStackId({ ...identity, stackName: "sibling" }); + yield* fs.makeDirectory(path.join(root, sibling, "data"), { recursive: true }); + const missing = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(missing)).toBeInstanceOf(StackStateInvalidError); + yield* store.cleanup(stackId); + expect(yield* fs.exists(path.join(root, stackId))).toBe(false); + expect(yield* fs.exists(path.join(root, sibling, "data"))).toBe(true); + }), + ), + ); + + it.live("rejects unsupported format and stale generation mutations", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-generation-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, state(stackId, 4)); + const stale = yield* store.replace(stackId, state(stackId, 5), 3).pipe(Effect.exit); + expect(errorOf(stale)).toBeInstanceOf(StackStateGenerationMismatchError); + yield* fs.writeFileString( + path.join(root, stackId, "state.json"), + yield* jsonText({ ...state(stackId, 4), format: "supabase-stack-state-v2" }), + ); + const unsupported = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(unsupported)).toBeInstanceOf(StackStateFormatUnsupportedError); + yield* fs.writeFileString( + path.join(root, stackId, "state.json"), + yield* jsonText({ ...state(stackId, 4), format: 1 }), + ); + const malformed = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(malformed)).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); + + it.live("rejects a forged identity tuple before writing", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-identity-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const stackId = yield* deriveStackId(identity); + const original = state(stackId); + const forged = { + ...original, + identity: { ...original.identity, workspaceId: "/tmp/forged" }, + }; + const exit = yield* store.write(stackId, forged).pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(StackStateInvalidError); + expect(yield* fs.exists(path.join(root, stackId))).toBe(false); + }), + ), + ); + + it.live("exposes only complete old or new values during repeated writes", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "supabase-stack-atomic-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, state(stackId, 0)); + const updates = Array.from({ length: 8 }, (_, index) => + store.replace(stackId, state(stackId, index + 1), index), + ); + const writer = Effect.forEach(updates, (update) => update, { concurrency: 1 }); + const readers = Effect.forEach(Array.from({ length: 32 }), () => store.read(stackId), { + concurrency: 8, + }); + const [, observations] = yield* Effect.all([writer, readers], { concurrency: 2 }); + const allowedGenerations = new Set( + Array.from({ length: 9 }, (_, generation) => generation), + ); + for (const observation of observations) { + expect(observation).toBeDefined(); + if (observation === undefined) continue; + expect(allowedGenerations.has(observation.desiredGeneration)).toBe(true); + expect(observation.format).toBe("supabase-stack-state-v1"); + expect(observation.identity.stackId).toBe(stackId); + expect(Array.isArray(observation.ports)).toBe(true); + expect(observation.secrets).toEqual({}); + } + }), + ), + ); +}); From 7bc6bfc1faee0baa6aae649fe252820fa4873698 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 21:48:17 +0200 Subject: [PATCH 011/174] fix(stack): fence running port changes --- ...26-08-28-stack-runtime-rewrite-progress.md | 15 ++ packages/stack/src/state/PortCoordinator.ts | 62 +++++- .../stack/src/state/ports.integration.test.ts | 181 +++++++++++++++++- 3 files changed, 248 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 8b2b888c66..6214d2d101 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -254,6 +254,21 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm --dir packages/stack types:check` — passed. - Effect lint over changed Task 4 sources — passed with zero warnings. +#### Task 4 port review fixes — 2026-08-28 + +- Exact requests now reject ports held by another stack's automatic reservation with the canonical + `PortAllocationError`, while exact reservations on stopped stacks continue to coexist. +- Running stacks are assignment-fenced: exact/automatic changes and enablement changes fail with + `StackLifecycleConflictError`; an unchanged repeat is accepted without advancing generation. +- Native multi-listener acquisition is transactional in an operation-owned child `Scope`; a later + bind failure closes earlier listeners before the failure escapes, while successful listeners remain + owned by the caller's scope. The fork/acquire/handoff boundary is masked against interruption, + with the actual bind effects restored to interruptible execution. +- Review RED evidence: the three new scenarios initially observed the missing automatic conflict, + missing running-assignment fence, and leaked first native listener; all now pass. +- `pnpm --dir packages/stack exec vitest run --project integration src/state/ports.integration.test.ts` — passed (10 tests). +- `pnpm --dir packages/stack test` — passed (67 integration tests; no unit files). + #### Task 3 review fixes — round 2 — 2026-08-28 - Preserved the authoritative database major alias: selector `13` intentionally resolves to the diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index b888ad8910..85cbae02d4 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -1,10 +1,11 @@ -import { Crypto, Effect, FileSystem, Path, Scope, Schema } from "effect"; +import { Crypto, Effect, Exit, FileSystem, Path, Scope, Schema } from "effect"; import type { StackRuntime } from "../public/Runtime.ts"; import { NetworkPortSchema, PORT_FIELDS, type PortField } from "../public/Status.ts"; import { PortAllocationError, PortUnavailableError, InvalidProjectRootError, + StackLifecycleConflictError, StackStateFormatUnsupportedError, StackStateGenerationMismatchError, StackStateInvalidError, @@ -71,6 +72,7 @@ export interface PortCoordinator { PortReservation, | PortAllocationError | PortUnavailableError + | StackLifecycleConflictError | StackStateInvalidError | StackStateFormatUnsupportedError | InvalidProjectRootError @@ -89,6 +91,19 @@ const validPort = (port: number): boolean => Schema.is(NetworkPortSchema)(port); const unavailable = (port: number, field: PortField) => new PortUnavailableError({ port, field, message: `Port ${port} for ${field} is unavailable` }); +const automaticConflict = (port: number, field: PortField) => + new PortAllocationError({ + port, + field, + message: `Port ${port} for ${field} is reserved by another stack's automatic assignment`, + }); + +const lifecycleConflict = (field: PortField) => + new StackLifecycleConflictError({ + field, + message: `Cannot change the ${field} port assignment while the stack is running`, + }); + export const makePortCoordinator = ( registry: PortRegistry, store: StackStateStore, @@ -127,6 +142,19 @@ export const makePortCoordinator = ( } } const existing = assignmentMap(current.ports); + if (lifecycle === "running" && current.desiredLifecycle === "running") { + for (const field of fields) { + const intent = listenerIntents[field]; + const prior = existing.get(field); + const unchanged = + intent.enabled && prior !== undefined + ? intent.port === "automatic" + ? prior.intent === "automatic" + : prior.intent === "exact" && prior.port === intent.port + : !intent.enabled && prior === undefined; + if (!unchanged) return yield* lifecycleConflict(field); + } + } const assignments: HostPortAssignment[] = []; const byField: Partial> = {}; const usedByThisStack = new Set(); @@ -165,6 +193,8 @@ export const makePortCoordinator = ( } } else { if (!validPort(intent.port)) return yield* unavailable(intent.port, field); + if (usedAutomatic.has(intent.port)) + return yield* automaticConflict(intent.port, field); if (usedByThisStack.has(intent.port)) return yield* unavailable(intent.port, field); if (lifecycle === "running" && usedLive.has(intent.port)) return yield* unavailable(intent.port, field); @@ -197,16 +227,30 @@ export const makePortCoordinator = ( const first = enabledAssignments[0]; return yield* unavailable(first?.assignment.port ?? 0, first?.field ?? "api"); } - for (const { field, intent, assignment } of enabledAssignments) { - const listener = yield* options - .bindNative(intent.address, assignment.port, field) - .pipe( - Effect.catchTag("PortUnavailableError", () => - Effect.fail(unavailable(assignment.port, field)), + const parentScope = yield* Scope.Scope; + const acquired = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const listenerScope = yield* Scope.fork(parentScope, "sequential"); + const exit = yield* Effect.exit( + restore( + Effect.forEach(enabledAssignments, ({ field, intent, assignment }) => + options.bindNative!(intent.address, assignment.port, field).pipe( + Effect.catchTag("PortUnavailableError", () => + Effect.fail(unavailable(assignment.port, field)), + ), + Effect.provideService(Scope.Scope, listenerScope), + ), + ), ), ); - nativeListeners.push(listener); - } + if (Exit.isFailure(exit)) { + yield* Scope.close(listenerScope, exit); + return yield* Effect.failCause(exit.cause); + } + return exit.value; + }), + ); + nativeListeners.push(...acquired); } if (committed.lifecycle === "running" && committed.runtime.kind === "container") { if (options.publishContainer === undefined) { diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 57b2ed7be2..bd2b7575df 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Scope } from "effect"; import { createServer, type Server } from "node:net"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; -import { PortUnavailableError } from "../public/Errors.ts"; +import { + PortAllocationError, + PortUnavailableError, + StackLifecycleConflictError, +} from "../public/Errors.ts"; import { makePortRegistry } from "./PortRegistry.ts"; import { makePortCoordinator, type ListenerIntents } from "./PortCoordinator.ts"; import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; @@ -112,6 +116,132 @@ describe("sticky port coordination", () => { ), ); + it.live("rejects an exact request for another stack's automatic assignment", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-auto-exact-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const aIdentity = makeIdentity(root, "automatic-owner"); + const bIdentity = makeIdentity(root, "exact-requester"); + const a = yield* deriveStackId(aIdentity); + const b = yield* deriveStackId(bIdentity); + yield* store.write(a, baseState(a, aIdentity)); + yield* store.write(b, baseState(b, bIdentity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store); + const automatic = yield* coordinator.planAndReserve(a, intents("automatic")); + const occupied = automatic.assignments.api?.port; + expect(occupied).toBeTypeOf("number"); + if (occupied === undefined) return; + const exit = yield* coordinator + .planAndReserve( + b, + { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: occupied }, + }, + { lifecycle: "stopped" }, + ) + .pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(PortAllocationError); + expect(yield* registry.assignments(b)).toEqual([]); + }), + ), + ); + + it.live("rejects assignment changes while a stack is running", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-port-running-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const exactIdentity = makeIdentity(root, "running-exact"); + const automaticIdentity = makeIdentity(root, "running-automatic"); + const exactId = yield* deriveStackId(exactIdentity); + const automaticId = yield* deriveStackId(automaticIdentity); + yield* store.write(exactId, { + ...baseState(exactId, exactIdentity, "running"), + desiredGeneration: 7, + ports: [{ field: "api", port: 55435, intent: "exact" }], + }); + yield* store.write(automaticId, { + ...baseState(automaticId, automaticIdentity, "running"), + desiredGeneration: 9, + ports: [{ field: "api", port: 55436, intent: "automatic" }], + }); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store); + const exactToDifferent = yield* coordinator + .planAndReserve( + exactId, + { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55437 }, + }, + { lifecycle: "running", runtime: { kind: "native" } }, + ) + .pipe(Effect.exit); + expect(errorOf(exactToDifferent)).toBeInstanceOf(StackLifecycleConflictError); + expect((yield* store.read(exactId))?.desiredGeneration).toBe(7); + + const automaticToExact = yield* coordinator + .planAndReserve( + automaticId, + { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55438 }, + }, + { lifecycle: "running", runtime: { kind: "native" } }, + ) + .pipe(Effect.exit); + expect(errorOf(automaticToExact)).toBeInstanceOf(StackLifecycleConflictError); + expect((yield* store.read(automaticId))?.desiredGeneration).toBe(9); + }), + ), + ); + + it.live("keeps an unchanged running assignment without advancing generation", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-running-repeat-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "running-repeat"); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, { + ...baseState(stackId, identity, "running"), + desiredGeneration: 11, + ports: [{ field: "api", port: 55439, intent: "exact" }], + }); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store, { + bindNative: (address, port, field) => + Effect.succeed({ + address, + port, + field, + close: Effect.void, + }), + }); + const result = yield* coordinator.planAndReserve( + stackId, + { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55439 }, + }, + { lifecycle: "running", runtime: { kind: "native" } }, + ); + expect(result.assignments.api?.port).toBe(55439); + expect((yield* store.read(stackId))?.desiredGeneration).toBe(11); + }), + ), + ); + it.live("allows exact assignments to coexist while stopped but rejects live conflicts", () => withPlatform( Effect.gen(function* () { @@ -269,6 +399,55 @@ describe("sticky port coordination", () => { }), ); + it.live("releases earlier native listeners when a later bind fails", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-native-failure-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "native-failure"); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, baseState(stackId, identity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const bound = new Set(); + let attempts = 0; + const coordinator = makePortCoordinator(registry, store, { + bindNative: (address, port, field) => { + attempts += 1; + if (attempts === 2) + return Effect.fail( + new PortUnavailableError({ port, field, message: "simulated second bind failure" }), + ); + return Effect.acquireRelease( + Effect.sync(() => { + bound.add(port); + return { + address, + port, + field, + close: Effect.sync(() => { + bound.delete(port); + }), + }; + }), + (listener) => listener.close, + ); + }, + }); + const exit = yield* coordinator + .planAndReserve(stackId, intents("automatic"), { + lifecycle: "running", + runtime: { kind: "native" }, + }) + .pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); + expect(bound).toHaveLength(0); + }), + ), + ); + it.live("retains a chosen assignment when container publication races", () => withPlatform( Effect.gen(function* () { From f21f38e7b4343eb3e7b037869689d9a468062f88 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 28 Aug 2026 23:25:13 +0200 Subject: [PATCH 012/174] feat(stack): add supervisor ownership and managed handles --- ...26-08-28-stack-runtime-rewrite-progress.md | 33 + packages/stack/package.json | 2 +- packages/stack/src/control/ControlClient.ts | 7 + packages/stack/src/control/ControlServer.ts | 605 ++++++++++++++++++ .../stack/src/control/MaintenanceProtocol.ts | 287 +++++++++ packages/stack/src/control/StackRpc.ts | 142 ++++ .../control-transport.integration.test.ts | 440 +++++++++++++ .../stack/src/entrypoints/supervisor-bun.ts | 27 + .../stack/src/entrypoints/supervisor-node.ts | 154 +++++ packages/stack/src/public/EffectStack.ts | 549 ++++++++++++++++ packages/stack/src/public/Errors.ts | 14 +- packages/stack/src/public/index.ts | 1 + packages/stack/src/state/Ownership.ts | 341 ++++++++++ packages/stack/src/state/StackStateStore.ts | 43 +- .../src/state/ownership.integration.test.ts | 130 ++++ packages/stack/src/supervisor/Launcher.ts | 370 +++++++++++ packages/stack/src/supervisor/OwnerSession.ts | 45 ++ packages/stack/src/supervisor/Supervisor.ts | 241 +++++++ .../supervisor/handles.integration.test.ts | 280 ++++++++ pnpm-lock.yaml | 6 +- 20 files changed, 3708 insertions(+), 9 deletions(-) create mode 100644 packages/stack/src/control/ControlClient.ts create mode 100644 packages/stack/src/control/ControlServer.ts create mode 100644 packages/stack/src/control/MaintenanceProtocol.ts create mode 100644 packages/stack/src/control/StackRpc.ts create mode 100644 packages/stack/src/control/control-transport.integration.test.ts create mode 100644 packages/stack/src/entrypoints/supervisor-bun.ts create mode 100644 packages/stack/src/entrypoints/supervisor-node.ts create mode 100644 packages/stack/src/public/EffectStack.ts create mode 100644 packages/stack/src/state/Ownership.ts create mode 100644 packages/stack/src/state/ownership.integration.test.ts create mode 100644 packages/stack/src/supervisor/Launcher.ts create mode 100644 packages/stack/src/supervisor/OwnerSession.ts create mode 100644 packages/stack/src/supervisor/Supervisor.ts create mode 100644 packages/stack/src/supervisor/handles.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 6214d2d101..51df8bb515 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -293,3 +293,36 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — passed, 32 tests. - `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. - `pnpm exec oxfmt --check packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed. + +### Task 5 — 2026-08-28 + +- Added identity-scoped owner metadata and one O_EXCL `runtime/owner.lock`. Ownership is proved + only by the lock; metadata is published after endpoint bind through a sibling temporary file, + file sync, and same-directory rename. Reads validate the StackId directory and deterministic + full-digest endpoint before probing. Session-fenced release never removes a replacement lock or + metadata, and transport owns socket unlink only after a successful bind. +- Added `StackStateStore.initialize`, a registry-lock transaction that returns an existing complete + state or writes the candidate once. This closes the concurrent equivalent-create race without a + process-global registry. +- Added the one local control endpoint with frozen maintenance v1 and exact-release Effect RPC, + bounded framing, identity/session/release fencing, per-request deadlines, bounded maintenance + concurrency, and post-response quiesce completion. Maintenance stop retains the compatible owner; + only quiesce releases it for explicit replacement. +- Added detached Supervisor launching through Effect's `ChildProcessSpawner` and fd3 readiness + handoff. Concurrent losers join winner metadata through a pre-started filesystem watcher plus a + bounded Schedule reread that closes the platform watch-acquisition gap. Every returned owner is + release/session/probe validated. Readiness has a five-second deadline and terminates the exact child + and observer on timeout, failure, or caller interruption. Owner processes outlive caller handles. +- Added managed Effect handles and observational discovery (`openStack`, `findStack`, `listStacks`, + `inspectStack`), truthful unconfigured/stopped status, operation-specific public errors, and exact + RPC error-tag preservation. POSIX endpoints use `/tmp` plus the complete StackId digest to remain + below Unix socket path limits. +- Added ownership/state initialization, protocol framing/fencing, incompatible-release, + project-filter, caller-process-exit, concurrent create, and handle lifecycle integration scenarios. +- `pnpm --dir packages/stack exec vitest run --project integration src/control/control-transport.integration.test.ts src/state/ownership.integration.test.ts src/supervisor/handles.integration.test.ts` + — passed (22 tests). +- `pnpm --filter @supabase/stack test` — passed (89 integration tests; no unit files). +- `pnpm --dir packages/stack types:check` — passed. +- Effect lint over all Task 5 sources and tests — passed with zero warnings. +- Formatting and `git diff --check` — passed; no matching Supervisor processes or control sockets + remained after the integration suites. diff --git a/packages/stack/package.json b/packages/stack/package.json index ed16e2b879..a12c451a62 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -17,11 +17,11 @@ "types:check": "tsc --noEmit" }, "dependencies": { + "@effect/platform-node": "catalog:", "effect": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", - "@effect/platform-node": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "typescript": "catalog:", diff --git a/packages/stack/src/control/ControlClient.ts b/packages/stack/src/control/ControlClient.ts new file mode 100644 index 0000000000..99f13c2fe4 --- /dev/null +++ b/packages/stack/src/control/ControlClient.ts @@ -0,0 +1,7 @@ +export { + makeControlClient, + makeControlRpcSocket, + type ControlClient, + type ControlClientOptions, +} from "./ControlServer.ts"; +export type { MaintenanceResponse } from "./MaintenanceProtocol.ts"; diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts new file mode 100644 index 0000000000..2cd136a325 --- /dev/null +++ b/packages/stack/src/control/ControlServer.ts @@ -0,0 +1,605 @@ +import { Deferred, Effect, Exit, Fiber, FileSystem, Layer, Schema, Scope, Semaphore } from "effect"; +import { NodeSocket, NodeSocketServer } from "@effect/platform-node"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; +import * as Socket from "effect/unstable/socket/Socket"; +import * as SocketServer from "effect/unstable/socket/SocketServer"; +import type { ControlEndpoint } from "../state/Ownership.ts"; +import { + CONTROL_PREFACE_MAX_BYTES, + decodeFrame, + decodePreface, + encodeFrame, + encodePreface, + encodeRawFrame, + FrameDecoder, + MAINTENANCE_MAX_CONCURRENT_REQUESTS, + MAINTENANCE_REQUEST_DEADLINE_MS, + MaintenanceRequestSchema, + MaintenanceProtocolError, + MaintenanceResponseSchema, + ownerSessionIdIsValid, + type JsonValue, + type MaintenanceRequest, + type MaintenanceResponse, +} from "./MaintenanceProtocol.ts"; +import { + releaseMismatch, + STACK_RPC_RELEASE, + StackRpcGroup, + type StackRpcClient, + type StackRpcHandlers, +} from "./StackRpc.ts"; + +export interface ControlIdentity { + readonly stackId: string; + readonly ownerSessionId: string; +} + +export interface MaintenanceHandlers { + readonly probe: Effect.Effect; + readonly stop: Effect.Effect; + readonly quiesce: Effect.Effect; +} + +export interface ControlServerOptions extends ControlIdentity { + readonly endpoint: ControlEndpoint; + readonly rpcRelease?: string; + readonly maintenanceHandlers: MaintenanceHandlers; + /** Invoked only after the maintenance response has been written and the connection closed. */ + readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; + readonly rpcHandlers: StackRpcHandlers; +} + +export interface ControlServer { + readonly endpoint: ControlEndpoint; + readonly shutdown: Effect.Effect; + readonly ready: Effect.Effect; +} + +const endpointPath = (endpoint: ControlEndpoint): string => + endpoint.kind === "unix" ? endpoint.path : endpoint.name; + +const toBytes = (chunk: Uint8Array | string): Uint8Array => + typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk; + +const protocolFailure = ( + tag: + | "invalid-request" + | "stale-session" + | "timeout" + | "operation-failed" + | "unsupported-release" = "invalid-request", +): MaintenanceResponse => ({ + ok: false, + error: { tag, message: "Control request rejected" }, +}); + +/** Wrap one accepted socket. This is the only reader for the connection. */ +const demuxSocket = ( + socket: Socket.Socket, + options: ControlServerOptions, + maintenanceSemaphore: Semaphore.Semaphore, +): Socket.Socket => { + const expectedRelease = options.rpcRelease ?? STACK_RPC_RELEASE; + type Writer = ( + chunk: Uint8Array | string | Socket.CloseEvent, + ) => Effect.Effect; + let connectionWriter: Writer | undefined; + + const runRaw = ( + handler: (_: Uint8Array) => Effect.Effect | void, + runOptions?: { readonly onOpen?: Effect.Effect }, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const writerReady = yield* Deferred.make(); + const underlyingWrite = yield* socket.writer; + const decoder = new FrameDecoder(); + const prefaceReady = yield* Deferred.make(); + let preface = new Uint8Array(0); + let phase: "preface" | "maintenance" | "rpc" = "preface"; + let closed = false; + + const markPrefaceReady = Deferred.succeed(prefaceReady, undefined).pipe(Effect.asVoid); + const close = Effect.suspend(() => { + if (closed) return Effect.void; + closed = true; + return markPrefaceReady.pipe( + Effect.andThen( + Deferred.await(writerReady).pipe( + Effect.flatMap((write) => write(new Socket.CloseEvent(1000))), + ), + ), + ); + }); + + const socketWriteError = (message: string) => + new Socket.SocketError({ + reason: new Socket.SocketWriteError({ cause: new Error(message) }), + }); + const sendJson = (value: JsonValue): Effect.Effect => + Deferred.await(writerReady).pipe( + Effect.flatMap((write) => + encodeFrame(value).pipe( + Effect.mapError((error) => socketWriteError(error.message)), + Effect.flatMap(write), + ), + ), + ); + + const dispatchMaintenance = (frame: Uint8Array): Effect.Effect => + Effect.gen(function* () { + const decoded = yield* Effect.exit(decodeFrame(frame)); + if (Exit.isFailure(decoded)) { + yield* close; + return; + } + const request = yield* Effect.exit( + Schema.decodeUnknownEffect(MaintenanceRequestSchema)(decoded.value, { + onExcessProperty: "error", + }), + ); + if (Exit.isFailure(request)) { + yield* sendJson(protocolFailure("invalid-request")); + yield* close; + return; + } + if (request.value.stackId !== options.stackId) { + yield* sendJson(protocolFailure("invalid-request")); + yield* close; + return; + } + if (request.value.ownerSessionId !== options.ownerSessionId) { + yield* sendJson(protocolFailure("stale-session")); + yield* close; + return; + } + const operation = + request.value.op === "probe" + ? options.maintenanceHandlers.probe + : request.value.op === "stop" + ? options.maintenanceHandlers.stop + : options.maintenanceHandlers.quiesce; + const result = yield* Effect.exit(operation); + const response = Exit.isSuccess(result) + ? result.value + : protocolFailure("operation-failed"); + yield* sendJson(response); + yield* close; + yield* markPrefaceReady; + if (options.onMaintenanceComplete !== undefined) { + // The connection scope closes as soon as the close frame is sent; + // completion belongs to the owner session and must outlive it. + yield* Effect.forkDetach(options.onMaintenanceComplete(request.value.op)); + } + }); + + const processFrames = (input: Uint8Array): Effect.Effect => + Effect.gen(function* () { + const result = yield* Effect.exit(decoder.push(input)); + if (Exit.isFailure(result)) { + yield* close; + return; + } + for (const frame of result.value) { + if (closed) break; + if (phase === "maintenance") { + yield* maintenanceSemaphore.withPermit(dispatchMaintenance(frame)).pipe( + Effect.timeoutOrElse({ + duration: MAINTENANCE_REQUEST_DEADLINE_MS, + orElse: () => sendJson(protocolFailure("timeout")).pipe(Effect.andThen(close)), + }), + ); + } else { + const returned = handler(frame.slice(4)); + if (Effect.isEffect(returned)) yield* returned; + } + } + }); + + const processChunk = ( + chunk: Uint8Array | string, + ): Effect.Effect => + Effect.gen(function* () { + if (closed) return; + const input = toBytes(chunk); + if (phase === "preface") { + const combined = new Uint8Array(preface.byteLength + input.byteLength); + combined.set(preface); + combined.set(input, preface.byteLength); + const newline = combined.indexOf(10); + if ( + (newline < 0 && combined.byteLength > CONTROL_PREFACE_MAX_BYTES) || + (newline >= 0 && newline + 1 > CONTROL_PREFACE_MAX_BYTES) + ) { + yield* sendJson(protocolFailure("invalid-request")); + yield* close; + return; + } + preface = combined; + const decoded = yield* Effect.exit(decodePreface(combined)); + if (Exit.isFailure(decoded)) { + if (preface.includes(10)) { + yield* sendJson(protocolFailure()); + yield* close; + } + return; + } + phase = decoded.value.protocol.kind; + preface = new Uint8Array(0); + if ( + decoded.value.protocol.stackId === undefined || + decoded.value.protocol.ownerSessionId === undefined || + !ownerSessionIdIsValid(decoded.value.protocol.ownerSessionId) + ) { + yield* sendJson(protocolFailure("invalid-request")); + yield* close; + return; + } + if (decoded.value.protocol.stackId !== options.stackId) { + yield* sendJson(protocolFailure("invalid-request")); + yield* close; + return; + } + if (decoded.value.protocol.ownerSessionId !== options.ownerSessionId) { + yield* sendJson(protocolFailure("stale-session")); + yield* close; + return; + } + if (phase === "rpc" && decoded.value.protocol.release !== expectedRelease) { + yield* sendJson({ + ok: false, + error: { + tag: "unsupported-release", + message: releaseMismatch(decoded.value.protocol.release).message, + }, + }); + yield* close; + return; + } + if (phase === "rpc") yield* markPrefaceReady; + const remainder = combined.slice(decoded.value.consumed); + if (remainder.byteLength > 0) yield* processFrames(remainder); + return; + } + yield* processFrames(input); + }); + + const onOpen = Effect.andThen( + Effect.sync(() => { + connectionWriter = underlyingWrite; + }).pipe( + Effect.andThen(Deferred.succeed(writerReady, underlyingWrite).pipe(Effect.asVoid)), + ), + runOptions?.onOpen ?? Effect.void, + ); + const prefaceDeadline = yield* Effect.forkChild( + Deferred.await(prefaceReady).pipe( + Effect.timeoutOrElse({ + duration: MAINTENANCE_REQUEST_DEADLINE_MS, + orElse: () => sendJson(protocolFailure("timeout")).pipe(Effect.andThen(close)), + }), + ), + ); + yield* socket.runRaw(processChunk, { onOpen }); + yield* Fiber.interrupt(prefaceDeadline); + }), + ); + + return Socket.make({ + runRaw, + writer: Effect.succeed((chunk: Uint8Array | string | Socket.CloseEvent) => { + const write = connectionWriter; + if (write === undefined) { + return Effect.fail( + new Socket.SocketError({ + reason: new Socket.SocketWriteError({ cause: new Error("Control socket is not open") }), + }), + ); + } + return Socket.isCloseEvent(chunk) + ? write(chunk) + : encodeRawFrame(chunk).pipe( + Effect.mapError( + (error) => + new Socket.SocketError({ + reason: new Socket.SocketWriteError({ cause: new Error(error.message) }), + }), + ), + Effect.flatMap(write), + ); + }), + }); +}; + +const wrappedServer = ( + base: SocketServer.SocketServer["Service"], + options: ControlServerOptions, + maintenanceSemaphore: Semaphore.Semaphore, +): SocketServer.SocketServer["Service"] => + SocketServer.SocketServer.of({ + address: base.address, + run: (handler) => { + return base.run((socket) => { + return handler(demuxSocket(socket, options, maintenanceSemaphore)); + }); + }, + }); + +/** Bind the one local endpoint and serve maintenance plus exact-release RPC. */ +export const startControlServer = ( + options: ControlServerOptions, +): Effect.Effect< + ControlServer, + SocketServer.SocketServerError, + Scope.Scope | FileSystem.FileSystem +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const base = yield* NodeSocketServer.make({ path: endpointPath(options.endpoint) }); + if (options.endpoint.kind === "unix") { + const unixPath = options.endpoint.path; + yield* Effect.addFinalizer(() => + fs + .remove(unixPath, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ); + yield* fs.chmod(options.endpoint.path, 0o600).pipe( + Effect.mapError( + (error) => + new SocketServer.SocketServerError({ + reason: new SocketServer.SocketServerOpenError({ cause: error }), + }), + ), + ); + } + const maintenanceSemaphore = yield* Semaphore.make(MAINTENANCE_MAX_CONCURRENT_REQUESTS); + const server = wrappedServer(base, options, maintenanceSemaphore); + const transportLayer = Layer.mergeAll( + Layer.succeed(SocketServer.SocketServer, server), + Layer.succeed(RpcSerialization.RpcSerialization, RpcSerialization.json), + ); + // Effect v4's RPC type derivation exposes an implementation-level `any` + // requirement after these concrete transport layers are provided. + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + const rpcLayer = RpcServer.layerProtocolSocketServer.pipe( + Layer.provideMerge(transportLayer), + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + Layer.provideMerge(StackRpcGroup.toLayer(options.rpcHandlers)), + ); + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + const rpcProgram = RpcServer.make(StackRpcGroup, { + disableTracing: true, + concurrency: MAINTENANCE_MAX_CONCURRENT_REQUESTS, + }).pipe( + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + Effect.provide(rpcLayer), + ); + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + yield* Effect.forkScoped(rpcProgram); + return { + endpoint: options.endpoint, + ready: Effect.void, + shutdown: Effect.void, + } satisfies ControlServer; + }); + +/** Client-side framed socket; writer emits the RPC preface exactly once. */ +export const makeControlRpcSocket = ( + socket: Socket.Socket, + options: { + readonly rpcRelease?: string; + readonly stackId: string; + readonly ownerSessionId: string; + }, +): Socket.Socket => { + let prefaced = false; + return Socket.make({ + runRaw: (handler, options) => + Effect.scoped( + Effect.gen(function* () { + const decoder = new FrameDecoder(); + yield* socket.runRaw( + (chunk) => + decoder.push(toBytes(chunk)).pipe( + Effect.mapError( + (error) => + new Socket.SocketError({ + reason: new Socket.SocketReadError({ cause: new Error(error.message) }), + }), + ), + Effect.flatMap((frames) => + Effect.forEach(frames, (frame) => + Effect.suspend(() => { + const effect = handler(frame.slice(4)); + return Effect.isEffect(effect) ? effect : Effect.void; + }), + ).pipe(Effect.asVoid), + ), + ), + options, + ); + }), + ), + writer: Effect.map( + socket.writer, + (write) => (chunk: Uint8Array | string | Socket.CloseEvent) => + Effect.gen(function* () { + if (Socket.isCloseEvent(chunk)) { + yield* write(chunk); + return; + } + const encodedFrame = yield* encodeRawFrame(chunk).pipe( + Effect.mapError( + (error) => + new Socket.SocketError({ + reason: new Socket.SocketWriteError({ cause: new Error(error.message) }), + }), + ), + ); + if (!prefaced) { + prefaced = true; + const preface = encodePreface({ + kind: "rpc", + release: options.rpcRelease ?? STACK_RPC_RELEASE, + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + }); + const combined = new Uint8Array(preface.byteLength + encodedFrame.byteLength); + combined.set(preface); + combined.set(encodedFrame, preface.byteLength); + yield* write(combined); + return; + } + yield* write(encodedFrame); + }), + ), + }); +}; + +export interface ControlClientOptions extends ControlIdentity { + readonly rpcRelease?: string; +} + +export interface ControlClient { + // Each call allocates a fresh scoped socket, so these methods intentionally + // return per-call lazy Effects rather than shared Effect values. + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly probe: () => Effect.Effect< + MaintenanceResponse, + Socket.SocketError | MaintenanceProtocolError, + Scope.Scope + >; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly stop: () => Effect.Effect< + MaintenanceResponse, + Socket.SocketError | MaintenanceProtocolError, + Scope.Scope + >; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly quiesce: () => Effect.Effect< + MaintenanceResponse, + Socket.SocketError | MaintenanceProtocolError, + Scope.Scope + >; + readonly rpc: Effect.Effect; +} + +/** A scoped client seam used by OwnerSession and the public Stack handle. */ +export const makeControlClient = ( + endpoint: ControlEndpoint, + options: ControlClientOptions, +): ControlClient => { + const maintenance = ( + op: MaintenanceRequest["op"], + ): Effect.Effect< + MaintenanceResponse, + Socket.SocketError | MaintenanceProtocolError, + Scope.Scope + > => + Effect.scoped( + Effect.gen(function* () { + const socket = yield* NodeSocket.makeNet({ + path: endpointPath(endpoint), + openTimeout: MAINTENANCE_REQUEST_DEADLINE_MS, + }); + const underlyingWrite = yield* socket.writer; + const decoder = new FrameDecoder(); + const response = yield* Deferred.make(); + const writerReady = + yield* Deferred.make< + ( + chunk: Uint8Array | string | Socket.CloseEvent, + ) => Effect.Effect + >(); + const read = socket.runRaw( + (chunk) => + decoder.push(toBytes(chunk)).pipe( + Effect.flatMap((frames) => + Effect.forEach(frames, (frame) => + Effect.gen(function* () { + const decoded = yield* Effect.exit(decodeFrame(frame)); + if (Exit.isFailure(decoded)) return; + const parsed = yield* Effect.exit( + Schema.decodeUnknownEffect(MaintenanceResponseSchema)(decoded.value, { + onExcessProperty: "error", + }), + ); + if (Exit.isSuccess(parsed)) yield* Deferred.succeed(response, parsed.value); + }), + ).pipe(Effect.asVoid), + ), + ), + { + onOpen: Deferred.succeed(writerReady, underlyingWrite).pipe(Effect.asVoid), + }, + ); + const fiber = yield* Effect.forkChild(read); + // NodeSocket opens its writer as part of runRaw's onOpen hook. + const write = yield* Deferred.await(writerReady); + yield* write( + encodePreface({ + kind: "maintenance", + release: "maintenance-v1", + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + }), + ); + yield* encodeFrame({ + op, + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + }).pipe(Effect.flatMap(write)); + const readerDone = Fiber.join(fiber).pipe( + Effect.mapError( + () => new MaintenanceProtocolError({ message: "Control connection closed" }), + ), + Effect.flatMap(() => + Effect.fail(new MaintenanceProtocolError({ message: "Control connection closed" })), + ), + ); + const value = yield* Effect.raceFirst(Deferred.await(response), readerDone).pipe( + Effect.timeoutOrElse({ + duration: MAINTENANCE_REQUEST_DEADLINE_MS, + orElse: () => + Effect.fail(new MaintenanceProtocolError({ message: "Control request timed out" })), + }), + Effect.ensuring(Fiber.interrupt(fiber)), + ); + return value; + }), + ).pipe( + Effect.timeoutOrElse({ + duration: MAINTENANCE_REQUEST_DEADLINE_MS, + orElse: () => + Effect.fail(new MaintenanceProtocolError({ message: "Control request timed out" })), + }), + ); + + return { + probe: () => maintenance("probe"), + stop: () => maintenance("stop"), + quiesce: () => maintenance("quiesce"), + rpc: Effect.gen(function* () { + const socket = yield* NodeSocket.makeNet({ + path: endpointPath(endpoint), + openTimeout: MAINTENANCE_REQUEST_DEADLINE_MS, + }); + const controlSocket = makeControlRpcSocket(socket, { + rpcRelease: options.rpcRelease, + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + }); + const protocol = yield* RpcClient.makeProtocolSocket().pipe( + Effect.provideService(Socket.Socket, controlSocket), + Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), + ); + return yield* RpcClient.make(StackRpcGroup).pipe( + Effect.provideService(RpcClient.Protocol, protocol), + ); + }), + }; +}; diff --git a/packages/stack/src/control/MaintenanceProtocol.ts b/packages/stack/src/control/MaintenanceProtocol.ts new file mode 100644 index 0000000000..c5084500c9 --- /dev/null +++ b/packages/stack/src/control/MaintenanceProtocol.ts @@ -0,0 +1,287 @@ +import { Data, Effect, Schema } from "effect"; +import { isStackId, StackIdSchema } from "../public/StackId.ts"; + +/** The stable protocol is intentionally frozen independently of Stack RPC releases. */ +export const MAINTENANCE_PROTOCOL = "maintenance-v1" as const; +export const MAINTENANCE_MAX_FRAME_BYTES = 64 * 1024; +export const MAINTENANCE_MAX_CONCURRENT_REQUESTS = 16; +export const MAINTENANCE_REQUEST_DEADLINE_MS = 5_000; +export const CONTROL_PREFACE_MAX_BYTES = 256; + +export class MaintenanceProtocolError extends Data.TaggedError("MaintenanceProtocolError")<{ + readonly message: string; + readonly reason?: string; +}> {} + +export const ownerSessionIdIsValid = (value: string): boolean => + value.length > 0 && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value); + +export const OwnerSessionIdSchema = Schema.String.pipe( + Schema.refine((value): value is string => ownerSessionIdIsValid(value), { + identifier: "OwnerSessionId", + message: "Expected a bounded owner session identifier", + }), +); + +const ReleaseTokenSchema = Schema.String.pipe( + Schema.refine( + (value): value is string => value.length > 0 && value.length <= 128 && /^[^\s]+$/.test(value), + { + identifier: "ControlRelease", + message: "Expected a bounded release token without whitespace", + }, + ), +); + +export const MaintenanceRequestSchema = Schema.Union([ + Schema.Struct({ + op: Schema.Literal("probe"), + stackId: StackIdSchema, + ownerSessionId: OwnerSessionIdSchema, + }), + Schema.Struct({ + op: Schema.Literal("stop"), + stackId: StackIdSchema, + ownerSessionId: OwnerSessionIdSchema, + }), + Schema.Struct({ + op: Schema.Literal("quiesce"), + stackId: StackIdSchema, + ownerSessionId: OwnerSessionIdSchema, + }), +]); +export type MaintenanceRequest = Schema.Schema.Type; + +export const MaintenanceErrorCodeSchema = Schema.Literals([ + "invalid-request", + "stale-session", + "timeout", + "operation-failed", + "unsupported-release", +] as const); +export type MaintenanceErrorCode = Schema.Schema.Type; + +export const MaintenanceResponseSchema = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + op: Schema.Literal("probe"), + ownerSessionId: OwnerSessionIdSchema, + stackId: StackIdSchema, + rpcRelease: ReleaseTokenSchema, + }), + Schema.Struct({ ok: Schema.Literal(true), op: Schema.Literals(["stop", "quiesce"] as const) }), + Schema.Struct({ + ok: Schema.Literal(false), + error: Schema.Struct({ tag: MaintenanceErrorCodeSchema, message: Schema.String }), + }), +]); +export type MaintenanceResponse = Schema.Schema.Type; + +export type ControlProtocol = + | { + readonly kind: "maintenance"; + readonly release: typeof MAINTENANCE_PROTOCOL; + readonly stackId: string; + readonly ownerSessionId: string; + } + | { + readonly kind: "rpc"; + readonly release: string; + readonly stackId: string; + readonly ownerSessionId: string; + }; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { fatal: true }); + +export const encodePreface = (protocol: ControlProtocol): Uint8Array => + textEncoder.encode( + `supabase-stack/1 ${protocol.kind}:${protocol.release} stackId:${protocol.stackId} ownerSessionId:${protocol.ownerSessionId}\n`, + ); + +export const decodePreface = ( + bytes: Uint8Array, +): Effect.Effect< + { readonly protocol: ControlProtocol; readonly consumed: number }, + MaintenanceProtocolError +> => + Effect.gen(function* () { + const newline = bytes.indexOf(10); + if (newline < 0) { + return yield* new MaintenanceProtocolError({ message: "Control preface is incomplete" }); + } + if (newline + 1 > CONTROL_PREFACE_MAX_BYTES) { + return yield* new MaintenanceProtocolError({ message: "Control preface exceeds size limit" }); + } + const value = yield* Effect.try({ + try: () => textDecoder.decode(bytes.slice(0, newline)), + catch: () => new MaintenanceProtocolError({ message: "Control preface is invalid" }), + }); + const match = + /^supabase-stack\/1 (maintenance|rpc):([^ ]+) stackId:([^ ]+) ownerSessionId:([^ ]+)$/.exec( + value, + ); + if (match === null) { + return yield* new MaintenanceProtocolError({ message: "Control preface is invalid" }); + } + const kind = match[1]; + const release = match[2]; + const stackId = match[3]; + const ownerSessionId = match[4]; + if ( + kind === undefined || + release === undefined || + stackId === undefined || + ownerSessionId === undefined + ) { + return yield* new MaintenanceProtocolError({ message: "Control preface is invalid" }); + } + if (!isStackId(stackId) || !ownerSessionIdIsValid(ownerSessionId)) { + return yield* new MaintenanceProtocolError({ + message: "Control preface identity is invalid", + }); + } + if (kind === "maintenance" && release !== MAINTENANCE_PROTOCOL) { + return yield* new MaintenanceProtocolError({ + message: "Unsupported maintenance protocol release", + }); + } + return { + protocol: + kind === "maintenance" + ? { + kind: "maintenance", + release: MAINTENANCE_PROTOCOL, + stackId, + ownerSessionId, + } + : { + kind: "rpc", + release, + stackId, + ownerSessionId, + }, + consumed: newline + 1, + }; + }); + +/** JSON values accepted by the maintenance framing layer. RPC payload schemas remain in RpcGroup. */ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue }; + +const encodeJson = (value: JsonValue): Effect.Effect => + Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe( + Effect.map((text) => textEncoder.encode(text)), + Effect.mapError( + () => new MaintenanceProtocolError({ message: "Unable to encode control JSON" }), + ), + ); + +/** Encode one bounded uint32-big-endian frame containing JSON. */ +export const encodeFrame = ( + value: JsonValue, +): Effect.Effect => + encodeJson(value).pipe( + Effect.flatMap((body) => { + if (body.byteLength > MAINTENANCE_MAX_FRAME_BYTES) { + return Effect.fail( + new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), + ); + } + const frame = new Uint8Array(body.byteLength + 4); + new DataView(frame.buffer).setUint32(0, body.byteLength, false); + frame.set(body, 4); + return Effect.succeed(frame); + }), + ); + +export const decodeFrame = ( + frame: Uint8Array, +): Effect.Effect => + Effect.gen(function* () { + if (frame.byteLength < 4) { + return yield* new MaintenanceProtocolError({ message: "Control frame is truncated" }); + } + const length = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false); + if (length > MAINTENANCE_MAX_FRAME_BYTES) { + return yield* new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }); + } + if (frame.byteLength !== length + 4) { + return yield* new MaintenanceProtocolError({ message: "Control frame length mismatch" }); + } + const text = yield* Effect.try({ + try: () => textDecoder.decode(frame.slice(4)), + catch: () => new MaintenanceProtocolError({ message: "Invalid control JSON" }), + }); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( + Effect.mapError(() => new MaintenanceProtocolError({ message: "Invalid control JSON" })), + ); + if (!isJsonValue(parsed)) { + return yield* new MaintenanceProtocolError({ message: "Control JSON value is invalid" }); + } + return parsed; + }); + +const isJsonValue = (value: unknown): value is JsonValue => { + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.every(isJsonValue); + if (typeof value !== "object") return false; + return Object.values(value).every(isJsonValue); +}; + +/** Incremental bounded frame decoder. Instantiate one per connection execution. */ +export class FrameDecoder { + private buffer = new Uint8Array(0); + + push(chunk: Uint8Array): Effect.Effect, MaintenanceProtocolError> { + if (chunk.byteLength === 0) return Effect.succeed([]); + const combined = new Uint8Array(this.buffer.byteLength + chunk.byteLength); + combined.set(this.buffer); + combined.set(chunk, this.buffer.byteLength); + this.buffer = combined; + const frames: Uint8Array[] = []; + while (this.buffer.byteLength >= 4) { + const length = new DataView(this.buffer.buffer, this.buffer.byteOffset, 4).getUint32( + 0, + false, + ); + if (length > MAINTENANCE_MAX_FRAME_BYTES) { + return Effect.fail( + new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), + ); + } + if (this.buffer.byteLength < length + 4) break; + frames.push(this.buffer.slice(0, length + 4)); + this.buffer = this.buffer.slice(length + 4); + } + return Effect.succeed(frames); + } + + get bufferedBytes(): number { + return this.buffer.byteLength; + } +} + +/** Encode a raw RPC serialization payload in the same bounded frame format. */ +export const encodeRawFrame = ( + value: Uint8Array | string, +): Effect.Effect => + Effect.sync(() => (typeof value === "string" ? textEncoder.encode(value) : value)).pipe( + Effect.flatMap((body) => { + if (body.byteLength > MAINTENANCE_MAX_FRAME_BYTES) { + return Effect.fail( + new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), + ); + } + const frame = new Uint8Array(body.byteLength + 4); + new DataView(frame.buffer).setUint32(0, body.byteLength, false); + frame.set(body, 4); + return Effect.succeed(frame); + }), + ); diff --git a/packages/stack/src/control/StackRpc.ts b/packages/stack/src/control/StackRpc.ts new file mode 100644 index 0000000000..3e4f8a4a75 --- /dev/null +++ b/packages/stack/src/control/StackRpc.ts @@ -0,0 +1,142 @@ +import { Data, Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { EffectStackCredentialsSchema } from "../public/Credentials.ts"; +import { StackConfigSchema } from "../public/Config.ts"; +import { CapabilityNameSchema } from "../public/Capability.ts"; +import { LogOptionsSchema, StackLogEntrySchema } from "../public/Logs.ts"; +import { StackStatusSchema } from "../public/Status.ts"; +import type { ControlProtocol } from "./MaintenanceProtocol.ts"; + +/** Pinned release identifier. A changed release must go through explicit restart. */ +export const STACK_RPC_RELEASE = "stack-rpc-v1@0.1.0" as const; + +export class StackRpcProtocolError extends Data.TaggedError("StackRpcProtocolError")<{ + readonly message: string; + readonly expectedRelease?: string; + readonly actualRelease?: string; +}> {} + +export class StackRpcTransportError extends Data.TaggedError("StackRpcTransportError")<{ + readonly message: string; +}> {} + +const StackRpcErrorTagSchema = Schema.Literals([ + "InvalidStackIdentityError", + "InvalidProjectRootError", + "InvalidStackConfigError", + "StackVersionUnsupportedError", + "StackNotFoundError", + "StackOwnershipConflictError", + "StackRuntimeMismatchError", + "StackDefinitionRequiredError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", + "StackStateGenerationMismatchError", + "StackUpgradeRequiredError", + "StackUpgradeReplacementError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", + "PortAllocationError", + "PortUnavailableError", + "GatewayAuthenticationError", + "GatewayStaleGenerationError", + "GatewayActivationError", + "StackPreparationError", + "ArtifactIntegrityError", + "ContainerPullError", + "StackReconciliationError", + "ServiceStartError", + "ServiceReadinessError", + "ContainerEngineError", + "StackDestructionError", + "StackRpcProtocolError", +] as const); + +export const StackRpcErrorSchema = Schema.Struct({ + tag: StackRpcErrorTagSchema, + message: Schema.String, +}); +export type StackRpcError = Schema.Schema.Type; + +const PrepareStackResultSchema = Schema.Struct({ + capabilities: Schema.Array( + Schema.Struct({ + capability: CapabilityNameSchema, + version: Schema.String, + outcome: Schema.Literals(["cached", "downloaded", "pulled"] as const), + }), + ), +}); + +export const StackRpc = { + status: Rpc.make("status", { success: StackStatusSchema, error: StackRpcErrorSchema }), + credentials: Rpc.make("credentials", { + success: EffectStackCredentialsSchema, + error: StackRpcErrorSchema, + }), + prepare: Rpc.make("prepare", { + payload: Schema.Struct({ + config: Schema.optionalKey(StackConfigSchema), + capabilities: Schema.optionalKey(Schema.Array(CapabilityNameSchema)), + }), + success: PrepareStackResultSchema, + error: StackRpcErrorSchema, + }), + start: Rpc.make("start", { + payload: Schema.Struct({ config: Schema.optionalKey(StackConfigSchema) }), + success: StackStatusSchema, + error: StackRpcErrorSchema, + }), + restart: Rpc.make("restart", { + payload: Schema.Struct({ config: Schema.optionalKey(StackConfigSchema) }), + success: StackStatusSchema, + error: StackRpcErrorSchema, + }), + destroy: Rpc.make("destroy", { success: Schema.Void, error: StackRpcErrorSchema }), + logs: Rpc.make("logs", { + payload: LogOptionsSchema, + success: StackLogEntrySchema, + error: StackRpcErrorSchema, + stream: true, + }), + watchStatus: Rpc.make("watchStatus", { + success: StackStatusSchema, + error: StackRpcErrorSchema, + stream: true, + }), +} as const; + +export const StackRpcGroup = RpcGroup.make( + StackRpc.status, + StackRpc.credentials, + StackRpc.prepare, + StackRpc.start, + StackRpc.restart, + StackRpc.destroy, + StackRpc.logs, + StackRpc.watchStatus, +); +export type StackRpcHandlers = RpcGroup.HandlersFrom>; +export type StackRpcClient = RpcClient.FromGroup; + +export const rpcProtocol = ( + identity: { readonly stackId: string; readonly ownerSessionId: string }, + release = STACK_RPC_RELEASE, +): ControlProtocol => ({ + kind: "rpc", + release, + stackId: identity.stackId, + ownerSessionId: identity.ownerSessionId, +}); + +export const releaseMismatch = (actualRelease: string): StackRpcProtocolError => + new StackRpcProtocolError({ + message: `Incompatible Stack RPC release; expected ${STACK_RPC_RELEASE}, received ${actualRelease}`, + expectedRelease: STACK_RPC_RELEASE, + actualRelease, + }); diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts new file mode 100644 index 0000000000..26f7145c3f --- /dev/null +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -0,0 +1,440 @@ +import { NodeServices, NodeSocket } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { + Crypto, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Path, + Redacted, + Ref, + Scope, + Stream, +} from "effect"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; +import { CAPABILITY_NAMES } from "../public/Capability.ts"; +import type { StackStatus } from "../public/Status.ts"; +import type { ControlEndpoint } from "../state/Ownership.ts"; +import { + makeControlClient, + startControlServer, + type MaintenanceHandlers, + type ControlServerOptions, +} from "./ControlServer.ts"; +import { + CONTROL_PREFACE_MAX_BYTES, + decodeFrame, + decodePreface, + encodeFrame, + encodePreface, + encodeRawFrame, + FrameDecoder, + MaintenanceProtocolError, + MAINTENANCE_MAX_FRAME_BYTES, + type JsonValue, + type MaintenanceRequest, +} from "./MaintenanceProtocol.ts"; +import type { StackRpcHandlers } from "./StackRpc.ts"; + +interface ServerOverrides { + readonly rpcHandlers?: Partial; + readonly maintenanceHandlers?: Partial; + readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; +} + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const testIdentity: StackIdentity = { + projectRoot: "/tmp/project", + checkoutRoot: "/tmp/project", + workspaceId: "/tmp/project", + checkoutId: "/tmp/project", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "default", +}; + +const withServer = ( + f: (context: { + readonly endpoint: ControlEndpoint; + readonly stackId: string; + readonly ownerSessionId: string; + readonly completion: Deferred.Deferred; + readonly completionStarted: Deferred.Deferred; + readonly completionRelease: Deferred.Deferred; + readonly stopCalls: Ref.Ref; + readonly prepareCalls: Ref.Ref; + }) => Effect.Effect, + overrides?: (context: { + readonly stackId: string; + readonly ownerSessionId: string; + readonly status: StackStatus; + readonly completion: Deferred.Deferred; + readonly completionStarted: Deferred.Deferred; + readonly completionRelease: Deferred.Deferred; + readonly stopCalls: Ref.Ref; + readonly prepareCalls: Ref.Ref; + }) => ServerOverrides, +) => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-control-" }); + const stackId = yield* deriveStackId(testIdentity); + const ownerSessionId = yield* crypto.randomUUIDv4; + const completion = yield* Deferred.make(); + const completionStarted = yield* Deferred.make(); + const completionRelease = yield* Deferred.make(); + const stopCalls = yield* Ref.make(0); + const prepareCalls = yield* Ref.make(0); + const endpoint: ControlEndpoint = { kind: "unix", path: path.join(root, "control.sock") }; + const status: StackStatus = { + id: stackId, + lifecycle: "stopped", + desiredLifecycle: "stopped", + runtime: { kind: "native" }, + desiredGeneration: 0, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: "eager", + state: "stopped", + })), + }; + const defaultRpcHandlers: StackRpcHandlers = { + status: () => Effect.succeed(status), + credentials: () => + Effect.succeed({ + database: { + url: Redacted.make("postgres://localhost"), + password: Redacted.make("secret"), + }, + api: { + publishableKey: "publishable", + secretKey: Redacted.make("secret"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service"), + }, + }), + prepare: () => + Ref.update(prepareCalls, (count) => count + 1).pipe(Effect.as({ capabilities: [] })), + start: () => Effect.succeed(status), + restart: () => Effect.succeed(status), + destroy: () => Effect.void, + logs: () => Stream.empty, + watchStatus: () => Stream.empty, + }; + const defaultMaintenanceHandlers: MaintenanceHandlers = { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: "stack-rpc-v1@0.1.0", + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + quiesce: Effect.succeed({ ok: true, op: "quiesce" }), + }; + const custom = + overrides?.({ + stackId, + ownerSessionId, + status, + completion, + completionStarted, + completionRelease, + stopCalls, + prepareCalls, + }) ?? {}; + const options: ControlServerOptions = { + endpoint, + stackId, + ownerSessionId, + maintenanceHandlers: { ...defaultMaintenanceHandlers, ...custom.maintenanceHandlers }, + rpcHandlers: { ...defaultRpcHandlers, ...custom.rpcHandlers }, + onMaintenanceComplete: custom.onMaintenanceComplete, + }; + yield* startControlServer(options); + const info = yield* fs.stat(endpoint.path); + expect(info.mode & 0o777).toBe(0o600); + return yield* f({ + endpoint, + stackId, + ownerSessionId, + completion, + completionStarted, + completionRelease, + stopCalls, + prepareCalls, + }); + }), + ); + +const concatBytes = (...chunks: ReadonlyArray): Uint8Array => { + const total = chunks.reduce((size, chunk) => size + chunk.byteLength, 0); + const result = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +}; + +const sendRawAndReadFrame = ( + endpoint: ControlEndpoint, + bytes: Uint8Array, +): Effect.Effect => + sendRawSequenceAndReadFrame(endpoint, [bytes]); + +const sendRawSequenceAndReadFrame = ( + endpoint: ControlEndpoint, + chunks: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const socket = yield* NodeSocket.makeNet({ + path: endpoint.kind === "unix" ? endpoint.path : endpoint.name, + }); + const write = yield* socket.writer; + const decoder = new FrameDecoder(); + const response = yield* Deferred.make(); + const read = socket + .runRaw((chunk) => + decoder.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk).pipe( + Effect.flatMap((frames) => + Effect.forEach(frames, (frame) => + Deferred.succeed(response, frame).pipe(Effect.asVoid), + ), + ), + Effect.asVoid, + ), + ) + .pipe(Effect.catchTag("SocketError", () => Effect.void)); + const fiber = yield* Effect.forkChild(read); + for (const chunk of chunks) { + yield* write(chunk).pipe( + Effect.mapError((error) => new MaintenanceProtocolError({ message: error.message })), + ); + } + const frame = yield* Deferred.await(response).pipe( + Effect.timeoutOrElse({ + duration: 5_500, + orElse: () => + Effect.fail(new MaintenanceProtocolError({ message: "Timed out waiting for response" })), + }), + Effect.ensuring(Fiber.interrupt(fiber)), + ); + return yield* decodeFrame(frame); + }); + +const makePrepareRequestFrame = ( + capabilities: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const parser = RpcSerialization.json.makeUnsafe(); + const encoded = parser.encode({ + _tag: "Request", + id: "1", + tag: "prepare", + payload: { capabilities }, + headers: [], + }); + if (encoded === undefined) { + return yield* new MaintenanceProtocolError({ message: "RPC request could not be encoded" }); + } + return yield* encodeRawFrame(encoded); + }); + +describe("control transport", () => { + it.live("round-trips typed RPC and stable maintenance on one endpoint", () => + withServer(({ endpoint, stackId, ownerSessionId }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const probe = yield* client.probe(); + expect(probe).toMatchObject({ ok: true, op: "probe", stackId, ownerSessionId }); + const rpc = yield* client.rpc; + const observed = yield* rpc.status(undefined); + expect(observed.id).toBe(stackId); + expect(yield* client.stop()).toEqual({ ok: true, op: "stop" }); + }), + ), + ); + + it.live("holds a fragmented preface until the newline and reports the consumed boundary", () => + withPlatform( + Effect.gen(function* () { + const stackId = "a".repeat(64); + const ownerSessionId = "session"; + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId, + ownerSessionId, + }); + const incomplete = yield* decodePreface(preface.slice(0, 3)).pipe(Effect.exit); + expect(Exit.isFailure(incomplete)).toBe(true); + const complete = yield* decodePreface(preface); + expect(complete.consumed).toBe(preface.byteLength); + }), + ), + ); + + it.live("forwards a fragmented preface and same-write large typed RPC payload", () => + withServer(({ endpoint, stackId, ownerSessionId, prepareCalls }) => + Effect.gen(function* () { + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId, + ownerSessionId, + }); + const frame = yield* makePrepareRequestFrame(Array.from({ length: 100 }, () => "database")); + const response = yield* sendRawSequenceAndReadFrame(endpoint, [ + preface.slice(0, 3), + concatBytes(preface.slice(3), frame), + ]); + expect(response).toMatchObject({ _tag: "Exit", requestId: "1" }); + expect(yield* Ref.get(prepareCalls)).toBe(1); + }), + ), + ); + + it.live("rejects a mismatched stack identity before decoding an oversized RPC frame", () => + withServer(({ endpoint, ownerSessionId }) => + Effect.gen(function* () { + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId: "b".repeat(64), + ownerSessionId, + }); + const oversized = new Uint8Array(4); + new DataView(oversized.buffer).setUint32(0, MAINTENANCE_MAX_FRAME_BYTES + 1, false); + const response = yield* sendRawAndReadFrame(endpoint, concatBytes(preface, oversized)); + expect(response).toMatchObject({ ok: false, error: { tag: "invalid-request" } }); + }), + ), + ); + + it.live("rejects a stale owner session before decoding an invalid RPC frame", () => + withServer(({ endpoint, stackId }) => + Effect.gen(function* () { + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId, + ownerSessionId: "stale-session", + }); + const invalid = concatBytes(new Uint8Array([0, 0, 0, 3]), new Uint8Array([123, 125, 0])); + const response = yield* sendRawAndReadFrame(endpoint, concatBytes(preface, invalid)); + expect(response).toMatchObject({ ok: false, error: { tag: "stale-session" } }); + }), + ), + ); + + it.live("rejects an unsupported RPC release before payload parsing", () => + withServer(({ endpoint, stackId, ownerSessionId }) => + Effect.gen(function* () { + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v9@0.0.0", + stackId, + ownerSessionId, + }); + const invalid = concatBytes(new Uint8Array([0, 0, 0, 1]), new Uint8Array([0xff])); + const response = yield* sendRawAndReadFrame(endpoint, concatBytes(preface, invalid)); + expect(response).toMatchObject({ ok: false, error: { tag: "unsupported-release" } }); + }), + ), + ); + + it.live("dispatches exactly one maintenance request on a connection", () => { + return withServer( + ({ endpoint, stackId, ownerSessionId, stopCalls }) => + Effect.gen(function* () { + const preface = encodePreface({ + kind: "maintenance", + release: "maintenance-v1", + stackId, + ownerSessionId, + }); + const first = yield* encodeFrame({ op: "stop", stackId, ownerSessionId }); + const second = yield* encodeFrame({ op: "stop", stackId, ownerSessionId }); + const response = yield* sendRawAndReadFrame( + endpoint, + concatBytes(preface, first, second), + ); + expect(response).toMatchObject({ ok: true, op: "stop" }); + expect(yield* Ref.get(stopCalls)).toBe(1); + }), + ({ stopCalls }) => ({ + maintenanceHandlers: { + stop: Ref.update(stopCalls, (count) => count + 1).pipe( + Effect.as({ ok: true, op: "stop" }), + ), + }, + }), + ); + }); + + it.live("runs maintenance completion only after the response is flushed", () => { + return withServer( + ({ endpoint, stackId, ownerSessionId, completionStarted, completionRelease, completion }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + }); + const stopFiber = yield* Effect.forkChild(client.stop(), { startImmediately: true }); + yield* Deferred.await(completionStarted); + const response = yield* Fiber.join(stopFiber); + expect(response).toMatchObject({ ok: true, op: "stop" }); + yield* Deferred.succeed(completionRelease, undefined); + yield* Deferred.await(completion); + }), + ({ completionStarted, completionRelease, completion }) => ({ + onMaintenanceComplete: () => + Deferred.succeed(completionStarted, undefined).pipe( + Effect.andThen(Deferred.await(completionRelease)), + Effect.andThen(Deferred.succeed(completion, undefined)), + Effect.asVoid, + ), + }), + ); + }); + + it.live("fails closed on bounded frame and preface violations", () => + withPlatform( + Effect.gen(function* () { + const decoder = new FrameDecoder(); + const oversized = new Uint8Array(4); + new DataView(oversized.buffer).setUint32(0, MAINTENANCE_MAX_FRAME_BYTES + 1, false); + const frameResult = yield* decoder.push(oversized).pipe(Effect.exit); + expect(Exit.isFailure(frameResult)).toBe(true); + const preface = new Uint8Array(CONTROL_PREFACE_MAX_BYTES + 1); + preface.fill(120); + const prefaceResult = yield* decodePreface(preface).pipe(Effect.exit); + expect(Exit.isFailure(prefaceResult)).toBe(true); + }), + ), + ); + + it.live( + "closes a connection that never sends a preface by the maintenance deadline", + () => + withServer(({ endpoint }) => + Effect.gen(function* () { + const response = yield* sendRawSequenceAndReadFrame(endpoint, []); + expect(response).toMatchObject({ ok: false, error: { tag: "timeout" } }); + }), + ), + 7_000, + ); +}); diff --git a/packages/stack/src/entrypoints/supervisor-bun.ts b/packages/stack/src/entrypoints/supervisor-bun.ts new file mode 100644 index 0000000000..eb01c72137 --- /dev/null +++ b/packages/stack/src/entrypoints/supervisor-bun.ts @@ -0,0 +1,27 @@ +import { Effect, Schema } from "effect"; +// Bun's standalone entrypoint writes the launcher readiness descriptor +// directly; no Effect FileSystem service owns this inherited fd3 channel. +// oxlint-disable-next-line effecttsgo/node-builtin-import +import * as NodeFs from "node:fs"; +import { parseSupervisorArgs, ReadySchema, runSupervisor } from "./supervisor-node.ts"; +import { StackOwnershipConflictError } from "../public/Errors.ts"; + +export { parseSupervisorArgs, runSupervisor, SupervisorArgsSchema } from "./supervisor-node.ts"; + +if (import.meta.main) { + Effect.runPromise( + parseSupervisorArgs(Bun.argv.slice(2)).pipe(Effect.flatMap(runSupervisor)), + ).catch((error: unknown) => { + try { + const conflict = error instanceof StackOwnershipConflictError; + NodeFs.writeSync( + 3, + `${Schema.encodeSync(Schema.fromJsonString(ReadySchema))({ ok: false, code: conflict ? "ownership-conflict" : "failed", message: error instanceof Error ? error.message : "Supervisor failed" })}\n`, + ); + NodeFs.closeSync(3); + } catch { + // The parent may have already closed the readiness descriptor. + } + process.exitCode = 1; + }); +} diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts new file mode 100644 index 0000000000..42f84860ec --- /dev/null +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -0,0 +1,154 @@ +import { NodeServices } from "@effect/platform-node"; +import { Crypto, Data, Deferred, Effect, FileSystem, Path, Schema } from "effect"; +// Node's fd3 readiness channel has no FileSystem abstraction; this is the +// process-entrypoint boundary where the descriptor is intentionally used. +// oxlint-disable-next-line effecttsgo/node-builtin-import +import * as NodeFs from "node:fs"; +import { + acquireOwnership, + publishOwnership, + StackRuntimeEnvironment, + type StackRuntimeEnvironmentValue, +} from "../state/Ownership.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import { makeSupervisor } from "../supervisor/Supervisor.ts"; +import { startOwnerSession } from "../supervisor/OwnerSession.ts"; +import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; +import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; +import { StackOwnershipConflictError } from "../public/Errors.ts"; + +const IdentitySchema = Schema.Struct({ + projectRoot: Schema.String, + checkoutRoot: Schema.String, + workspaceId: Schema.String, + checkoutId: Schema.String, + branchContext: Schema.String, + localProjectKey: Schema.String, + stackName: Schema.String, +}); + +export const SupervisorArgsSchema = Schema.Struct({ + stateRoot: Schema.String, + tempRoot: Schema.String, + platform: Schema.Literals(["posix", "windows"] as const), + stackId: StackIdSchema, + ownerSessionId: OwnerSessionIdSchema, + rpcRelease: Schema.String, + identity: IdentitySchema, +}); + +export const ReadySchema = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + stackId: StackIdSchema, + ownerSessionId: Schema.String, + }), + Schema.Struct({ + ok: Schema.Literal(false), + code: Schema.Literals(["ownership-conflict", "failed"] as const), + message: Schema.String, + }), +]); + +type SupervisorArgs = Schema.Schema.Type; + +class SupervisorReadinessError extends Data.TaggedError("SupervisorReadinessError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const writeReadiness = ( + value: Schema.Schema.Type, +): Effect.Effect => + Effect.gen(function* () { + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(ReadySchema))(value).pipe( + Effect.mapError( + (cause) => + new SupervisorReadinessError({ + message: "Unable to encode supervisor readiness", + cause, + }), + ), + ); + yield* Effect.try({ + try: () => { + NodeFs.writeSync(3, `${encoded}\n`, undefined, "utf8"); + NodeFs.closeSync(3); + }, + catch: (cause) => + new SupervisorReadinessError({ + message: "Unable to write supervisor readiness", + cause, + }), + }); + }); + +export const runSupervisor = (args: SupervisorArgs) => + Effect.scoped( + Effect.gen(function* () { + const environment: StackRuntimeEnvironmentValue = { + stateRoot: args.stateRoot, + tempRoot: args.tempRoot, + platform: args.platform, + }; + const store = yield* makeStackStateStore({ stateRoot: args.stateRoot }); + const ownerSessionId = args.ownerSessionId; + const lease = yield* acquireOwnership({ + stateRoot: args.stateRoot, + stackId: args.stackId, + ownerSessionId, + rpcRelease: args.rpcRelease || STACK_RPC_RELEASE, + environment, + }); + const released = yield* Deferred.make(); + const context = yield* Effect.context(); + const supervisor = makeSupervisor({ + identity: args.identity, + stackId: args.stackId, + ownerSessionId, + rpcRelease: args.rpcRelease || STACK_RPC_RELEASE, + stateStore: store, + context, + }); + const session = yield* startOwnerSession({ + endpoint: lease.metadata.endpoint, + stackId: args.stackId, + ownerSessionId, + rpcRelease: args.rpcRelease || STACK_RPC_RELEASE, + maintenanceHandlers: supervisor.maintenanceHandlers, + rpcHandlers: supervisor.rpcHandlers, + onMaintenanceComplete: (op) => + op === "quiesce" ? Deferred.succeed(released, undefined) : Effect.void, + }); + yield* session.ready; + yield* publishOwnership(lease); + yield* writeReadiness({ ok: true, stackId: args.stackId, ownerSessionId }); + yield* Deferred.await(released); + }), + ).pipe(Effect.provide(NodeServices.layer)); + +export const parseSupervisorArgs = (argv: ReadonlyArray) => + Schema.decodeEffect(Schema.fromJsonString(SupervisorArgsSchema))(argv[0] ?? "{}"); + +if (import.meta.main) { + Effect.runPromise( + parseSupervisorArgs(process.argv.slice(2)).pipe(Effect.flatMap(runSupervisor)), + ).catch((error: unknown) => { + try { + const conflict = error instanceof StackOwnershipConflictError; + // The descriptor is owned by the parent launcher and intentionally + // written directly at this standalone process boundary. + NodeFs.writeSync( + 3, + `${Schema.encodeSync(Schema.fromJsonString(ReadySchema))({ ok: false, code: conflict ? "ownership-conflict" : "failed", message: error instanceof Error ? error.message : "Supervisor failed" })}\n`, + ); + NodeFs.closeSync(3); + } catch { + // The parent may have already closed the readiness descriptor. + } + process.exitCode = 1; + }); +} + +export { StackRuntimeEnvironment }; diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts new file mode 100644 index 0000000000..e3ab9cbafb --- /dev/null +++ b/packages/stack/src/public/EffectStack.ts @@ -0,0 +1,549 @@ +import { Crypto, Effect, Exit, FileSystem, Option, Path, Ref, Scope, Schema, Stream } from "effect"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import type { SocketError } from "effect/unstable/socket/Socket"; +import type { StackIdentity } from "../identity/Identity.ts"; +import { resolveStackIdentity, deriveStackId } from "../identity/Identity.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { toPersistedIdentity } from "../state/StackState.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { StackIdSchema, type StackId } from "./StackId.ts"; +import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; +import type { StackConfig } from "./Config.ts"; +import type { StackStatus, StackDescriptor, StackInspection } from "./Status.ts"; +import type { CapabilityName } from "./Capability.ts"; +import type { LogOptions, StackLogEntry } from "./Logs.ts"; +import type { EffectStackCredentials } from "./Credentials.ts"; +import { + InvalidStackIdentityError, + InvalidProjectRootError, + InvalidStackConfigError, + StackVersionUnsupportedError, + StackDefinitionRequiredError, + StackDestructionError, + StackNotFoundError, + StackNotRunningError, + StackOwnershipConflictError, + StackRuntimeMismatchError, + StackLifecycleConflictError, + StackPreparationError, + ArtifactIntegrityError, + ContainerPullError, + StackSecretMismatchError, + InvalidJwtSigningMaterialError, + StackReconciliationError, + ServiceStartError, + ServiceReadinessError, + ContainerEngineError, + StackUpgradeReplacementError, + StackStateGenerationMismatchError, + StackStateInvalidError, + StackStateFormatUnsupportedError, + StackUpgradeRequiredError, + StackMustBeStoppedError, + PortAllocationError, + PortUnavailableError, + GatewayAuthenticationError, + GatewayStaleGenerationError, + GatewayActivationError, + type CreateStackError, + type OpenStackError, + type StackDiscoveryError, + type StackStatusError, + type StackCredentialsError, + type PrepareStackError, + type StackStartError, + type StackRestartError, + type StackStopError, + type StackCloseError, + type StackStatusWatchError, + type StackLogsError, + type DestroyStackError, + type StackError, +} from "./Errors.ts"; +import { readOwnerMetadata } from "../state/Ownership.ts"; +import { makeControlClient } from "../control/ControlServer.ts"; +import { MaintenanceProtocolError } from "../control/MaintenanceProtocol.ts"; +import { STACK_RPC_RELEASE, type StackRpcError, type StackRpcClient } from "../control/StackRpc.ts"; +import { + ensureSupervisor, + defaultRuntimeEnvironment, + StackRuntimeEnvironment, +} from "../supervisor/Launcher.ts"; + +export interface StartStackOptions { + readonly config?: StackConfig; +} +export interface PrepareStackOptions { + readonly config?: StackConfig; + readonly capabilities?: ReadonlyArray; +} +export interface CreateStackOptions { + readonly projectRoot: string; + readonly name?: string; + readonly runtime?: StackRuntimePreference; +} +export interface FindStackOptions { + readonly projectRoot: string; + readonly name?: string; +} +export interface ListStacksOptions { + readonly projectRoot?: string; +} +export interface PreparedCapability { + readonly capability: CapabilityName; + readonly version: string; + readonly outcome: "cached" | "downloaded" | "pulled"; +} +export interface PrepareStackResult { + readonly capabilities: ReadonlyArray; +} + +export interface EffectStack { + readonly id: StackId; + // These methods intentionally create a fresh scoped RPC invocation per call. + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly status: () => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly credentials: () => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly prepare: ( + options?: PrepareStackOptions, + ) => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly start: (options?: StartStackOptions) => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly restart: (options?: StartStackOptions) => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly stop: () => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly destroy: () => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly close: () => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly watchStatus: () => Stream.Stream; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly logs: (options?: LogOptions) => Stream.Stream; +} + +const runtimeFor = (preference?: StackRuntimePreference): StackRuntime => + preference?.kind === "container" + ? { kind: "container", engine: preference.engine === "podman" ? "podman" : "docker" } + : { kind: "native" }; + +const descriptor = (state: PersistedStackState): StackDescriptor => ({ + id: StackIdSchema.make(state.identity.stackId), + projectRoot: state.identity.projectRoot, + name: state.identity.stackName, + branchContext: state.identity.branchContext, + runtime: state.runtime, + desiredLifecycle: state.desiredLifecycle, +}); + +const environment = () => + Effect.serviceOption(StackRuntimeEnvironment).pipe( + Effect.map(Option.getOrElse(defaultRuntimeEnvironment)), + ); + +type ControlError = StackRpcError | RpcClientError | SocketError | MaintenanceProtocolError; + +const errorForRpc = (error: ControlError): StackError => { + if ( + typeof error === "object" && + error !== null && + "tag" in error && + "message" in error && + typeof error.tag === "string" && + typeof error.message === "string" + ) { + switch (error.tag) { + case "InvalidStackIdentityError": + return new InvalidStackIdentityError({ message: error.message }); + case "InvalidProjectRootError": + return new InvalidProjectRootError({ message: error.message }); + case "InvalidStackConfigError": + return new InvalidStackConfigError({ message: error.message }); + case "StackVersionUnsupportedError": + return new StackVersionUnsupportedError({ message: error.message }); + case "StackNotFoundError": + return new StackNotFoundError({ message: error.message }); + case "StackDefinitionRequiredError": + return new StackDefinitionRequiredError({ message: error.message }); + case "StackNotRunningError": + return new StackNotRunningError({ message: error.message }); + case "StackUpgradeRequiredError": + return new StackUpgradeRequiredError({ message: error.message }); + case "StackUpgradeReplacementError": + return new StackUpgradeReplacementError({ message: error.message }); + case "StackDestructionError": + return new StackDestructionError({ message: error.message }); + case "StackOwnershipConflictError": + return new StackOwnershipConflictError({ message: error.message }); + case "StackRuntimeMismatchError": + return new StackRuntimeMismatchError({ message: error.message }); + case "StackMustBeStoppedError": + return new StackMustBeStoppedError({ message: error.message }); + case "StackLifecycleConflictError": + return new StackLifecycleConflictError({ message: error.message }); + case "StackStateInvalidError": + return new StackStateInvalidError({ message: error.message }); + case "StackStateFormatUnsupportedError": + return new StackStateFormatUnsupportedError({ message: error.message }); + case "StackStateGenerationMismatchError": + return new StackStateGenerationMismatchError({ message: error.message }); + case "StackSecretMismatchError": + return new StackSecretMismatchError({ message: error.message }); + case "InvalidJwtSigningMaterialError": + return new InvalidJwtSigningMaterialError({ message: error.message }); + case "PortAllocationError": + return new PortAllocationError({ message: error.message }); + case "PortUnavailableError": + return new PortUnavailableError({ message: error.message }); + case "GatewayAuthenticationError": + return new GatewayAuthenticationError({ message: error.message }); + case "GatewayStaleGenerationError": + return new GatewayStaleGenerationError({ message: error.message }); + case "GatewayActivationError": + return new GatewayActivationError({ message: error.message }); + case "StackPreparationError": + return new StackPreparationError({ message: error.message }); + case "ArtifactIntegrityError": + return new ArtifactIntegrityError({ message: error.message }); + case "ContainerPullError": + return new ContainerPullError({ message: error.message }); + case "StackReconciliationError": + return new StackReconciliationError({ message: error.message }); + case "ServiceStartError": + return new ServiceStartError({ message: error.message }); + case "ServiceReadinessError": + return new ServiceReadinessError({ message: error.message }); + case "ContainerEngineError": + return new ContainerEngineError({ message: error.message }); + case "StackRpcProtocolError": + return new StackStateInvalidError({ message: error.message }); + default: + return new StackStateInvalidError({ message: error.message }); + } + } + return new StackStateInvalidError({ message: "Stack control request failed" }); +}; + +const statusError = (error: ControlError): StackStatusError => { + const mapped = errorForRpc(error); + return mapped instanceof StackNotFoundError || + mapped instanceof StackStateInvalidError || + mapped instanceof StackStateFormatUnsupportedError || + mapped instanceof StackUpgradeRequiredError + ? mapped + : new StackStateInvalidError({ message: "Stack control request failed" }); +}; + +const credentialsError = (error: ControlError): StackCredentialsError => { + const mapped = errorForRpc(error); + return mapped instanceof StackNotFoundError || + mapped instanceof StackNotRunningError || + mapped instanceof StackSecretMismatchError || + mapped instanceof InvalidJwtSigningMaterialError + ? mapped + : new StackNotRunningError({ message: "Stack credentials are unavailable" }); +}; + +const prepareError = (error: ControlError): PrepareStackError => { + const mapped = errorForRpc(error); + return mapped instanceof StackPreparationError || + mapped instanceof ArtifactIntegrityError || + mapped instanceof ContainerPullError + ? mapped + : new StackPreparationError({ message: "Stack preparation failed" }); +}; + +const startError = (error: ControlError): StackStartError => { + const mapped = errorForRpc(error); + return mapped instanceof StackDefinitionRequiredError || + mapped instanceof StackVersionUnsupportedError || + mapped instanceof StackLifecycleConflictError || + mapped instanceof StackStateInvalidError || + mapped instanceof StackStateFormatUnsupportedError || + mapped instanceof StackUpgradeRequiredError || + mapped instanceof StackSecretMismatchError || + mapped instanceof StackReconciliationError || + mapped instanceof ServiceStartError || + mapped instanceof ServiceReadinessError || + mapped instanceof ContainerEngineError + ? mapped + : new StackStateInvalidError({ message: "Stack start failed" }); +}; + +const restartError = (error: ControlError): StackRestartError => { + const mapped = errorForRpc(error); + return mapped instanceof StackUpgradeReplacementError ? mapped : startError(error); +}; + +const stopError = (error: ControlError): StackStopError => { + const mapped = errorForRpc(error); + return mapped instanceof StackNotRunningError || + mapped instanceof StackLifecycleConflictError || + mapped instanceof StackReconciliationError || + mapped instanceof ServiceReadinessError || + mapped instanceof ContainerEngineError + ? mapped + : new StackLifecycleConflictError({ message: "Stack stop failed" }); +}; + +const destroyError = (error: ControlError): DestroyStackError => { + const mapped = errorForRpc(error); + return mapped instanceof StackDestructionError || + mapped instanceof StackNotFoundError || + mapped instanceof StackLifecycleConflictError || + mapped instanceof ContainerEngineError + ? mapped + : new StackDestructionError({ message: "Stack destruction failed" }); +}; + +const makeHandle = ( + id: StackId, + metadata: { + readonly endpoint: Parameters[0]; + readonly ownerSessionId: string; + readonly rpcRelease: string; + }, +): Effect.Effect => + Effect.gen(function* () { + const closed = yield* Ref.make(false); + const client = makeControlClient(metadata.endpoint, { + stackId: id, + ownerSessionId: metadata.ownerSessionId, + rpcRelease: metadata.rpcRelease, + }); + const check = (effect: Effect.Effect) => + Ref.get(closed).pipe(Effect.flatMap((isClosed) => (isClosed ? Effect.interrupt : effect))); + const invoke = ( + call: (rpc: StackRpcClient) => Effect.Effect, + mapError: (error: ControlError) => E, + ) => check(Effect.scoped(client.rpc.pipe(Effect.flatMap(call), Effect.mapError(mapError)))); + const status = () => invoke((rpc) => rpc.status(undefined), statusError); + return { + id, + status, + credentials: () => invoke((rpc) => rpc.credentials(undefined), credentialsError), + prepare: (options) => + invoke( + (rpc) => rpc.prepare({ config: options?.config, capabilities: options?.capabilities }), + prepareError, + ), + start: (options) => invoke((rpc) => rpc.start({ config: options?.config }), startError), + restart: (options) => invoke((rpc) => rpc.restart({ config: options?.config }), restartError), + stop: () => + check( + Effect.scoped(client.stop()).pipe( + Effect.mapError((error) => stopError(error)), + Effect.flatMap((response) => + response.ok + ? Effect.void + : Effect.fail(new StackLifecycleConflictError({ message: response.error.message })), + ), + ), + ), + destroy: () => invoke((rpc) => rpc.destroy(undefined), destroyError), + close: () => Ref.set(closed, true), + watchStatus: () => Stream.fromEffect(status()), + logs: () => + Stream.fail(new StackNotRunningError({ message: "Stack logs are not available yet" })), + } satisfies EffectStack; + }); + +const stateInitial = ( + identity: StackIdentity, + stackId: StackId, + runtime: StackRuntime, +): PersistedStackState => ({ + format: "supabase-stack-state-v1", + identity: toPersistedIdentity(identity, stackId), + runtime, + desiredGeneration: 0, + desiredLifecycle: "unconfigured", + ports: [], + secrets: {}, +}); + +export const createStack = ( + options: CreateStackOptions, +): Effect.Effect< + EffectStack, + CreateStackError, + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner +> => + Effect.gen(function* () { + const env = yield* environment(); + const identity = yield* resolveStackIdentity({ + projectRoot: options.projectRoot, + name: options.name, + }); + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const runtime = runtimeFor(options.runtime); + const current = yield* store.initialize(stackId, stateInitial(identity, stackId, runtime)); + const runtimeMismatch = + options.runtime !== undefined && + (current.runtime.kind !== options.runtime.kind || + (options.runtime.kind === "container" && + current.runtime.kind === "container" && + options.runtime.engine !== undefined && + options.runtime.engine !== "auto" && + current.runtime.engine !== options.runtime.engine)); + if (runtimeMismatch) + return yield* new StackRuntimeMismatchError({ + message: "Stack runtime is immutable for an existing identity", + }); + const owner = yield* ensureSupervisor({ + identity, + stackId, + stateStore: store, + environment: env, + }); + return yield* makeHandle(stackId, owner); + }); + +export const openStack = ( + id: StackId, +): Effect.Effect< + EffectStack, + OpenStackError, + Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const env = yield* environment(); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const state = yield* store.read(id); + if (state === undefined) + return yield* new StackNotFoundError({ stackId: id, message: "Stack state was not found" }); + const owner = yield* readOwnerMetadata(env.stateRoot, id, env); + if (owner === undefined) + return yield* new StackOwnershipConflictError({ + stackId: id, + message: "No Supervisor owns this stack", + }); + if (owner.rpcRelease !== STACK_RPC_RELEASE) + return yield* new StackUpgradeRequiredError({ + message: `Stack owner release ${owner.rpcRelease} requires explicit restart`, + expectedRelease: STACK_RPC_RELEASE, + actualRelease: owner.rpcRelease, + }); + const probe = yield* makeControlClient(owner.endpoint, { + stackId: id, + ownerSessionId: owner.ownerSessionId, + }) + .probe() + .pipe( + Effect.mapError( + (error) => new StackOwnershipConflictError({ stackId: id, message: String(error) }), + ), + ); + if ( + !probe.ok || + probe.op !== "probe" || + probe.stackId !== id || + probe.ownerSessionId !== owner.ownerSessionId + ) + return yield* new StackOwnershipConflictError({ + stackId: id, + message: "Owner session probe failed", + }); + return yield* makeHandle(id, owner); + }); + +export const findStack = ( + options: FindStackOptions, +): Effect.Effect< + Option.Option, + StackDiscoveryError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const env = yield* environment(); + const identity = yield* resolveStackIdentity({ + projectRoot: options.projectRoot, + name: options.name, + }); + const id = yield* deriveStackId(identity); + const state = yield* (yield* makeStackStateStore({ stateRoot: env.stateRoot })).read(id); + return state === undefined ? Option.none() : Option.some(descriptor(state)); + }); + +export const listStacks = ( + options: ListStacksOptions = {}, +): Effect.Effect< + ReadonlyArray, + StackDiscoveryError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const env = yield* environment(); + const fs = yield* FileSystem.FileSystem; + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const projectRoot = + options.projectRoot === undefined + ? undefined + : yield* fs + .realPath(options.projectRoot) + .pipe( + Effect.mapError((error) => new InvalidProjectRootError({ message: error.message })), + ); + if ( + !(yield* fs + .exists(env.stateRoot) + .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message })))) + ) + return []; + const entries = yield* fs + .readDirectory(env.stateRoot) + .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); + const result: StackDescriptor[] = []; + for (const entry of entries) { + if (!Schema.is(StackIdSchema)(entry)) continue; + const state = yield* store.read(entry); + if ( + state !== undefined && + (projectRoot === undefined || state.identity.projectRoot === projectRoot) + ) + result.push(descriptor(state)); + } + return result; + }); + +export const inspectStack = ( + id: StackId, +): Effect.Effect< + StackInspection, + StackNotFoundError | StackDiscoveryError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const env = yield* environment(); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const state = yield* store.read(id); + if (state === undefined) + return yield* new StackNotFoundError({ stackId: id, message: "Stack state was not found" }); + const metadata = yield* readOwnerMetadata(env.stateRoot, id, env); + if (metadata === undefined) return { descriptor: descriptor(state), owner: "absent" }; + if (metadata.rpcRelease !== STACK_RPC_RELEASE) + return { descriptor: descriptor(state), owner: "incompatible" }; + const probe = yield* Effect.scoped( + makeControlClient(metadata.endpoint, { + stackId: id, + ownerSessionId: metadata.ownerSessionId, + }).probe(), + ).pipe(Effect.exit); + if (Exit.isFailure(probe)) return { descriptor: descriptor(state), owner: "unreachable" }; + if ( + !probe.value.ok || + probe.value.op !== "probe" || + probe.value.stackId !== id || + probe.value.ownerSessionId !== metadata.ownerSessionId + ) + return { descriptor: descriptor(state), owner: "incompatible" }; + return { descriptor: descriptor(state), owner: "running" }; + }); diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 37cfa2e2f3..7a87fd229a 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -139,15 +139,21 @@ export type StackError = export type CreateStackError = | InvalidStackIdentityError | InvalidProjectRootError - | InvalidStackConfigError - | StackVersionUnsupportedError | StackOwnershipConflictError - | StackRuntimeMismatchError; + | StackRuntimeMismatchError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackUpgradeRequiredError; export type OpenStackError = | StackNotFoundError | StackOwnershipConflictError - | StackRuntimeMismatchError; + | StackRuntimeMismatchError + | InvalidProjectRootError + | StackStateInvalidError + | StackStateFormatUnsupportedError + | StackUpgradeRequiredError; export type StackDiscoveryError = + | InvalidStackIdentityError | InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError; diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 6ce0247fea..3ced7b0fa0 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -7,3 +7,4 @@ export * from "./Logs.ts"; export * from "./Credentials.ts"; export * from "./Errors.ts"; export * from "./Config.ts"; +export * from "./EffectStack.ts"; diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts new file mode 100644 index 0000000000..ea27be02d1 --- /dev/null +++ b/packages/stack/src/state/Ownership.ts @@ -0,0 +1,341 @@ +import { Context, Effect, FileSystem, Option, Path, Predicate, Schema, Scope } from "effect"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { resolveStackPaths } from "./Paths.ts"; +import { StackOwnershipConflictError, StackStateInvalidError } from "../public/Errors.ts"; +import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; + +/** The owner metadata format is deliberately fail-closed. */ +export const OWNERSHIP_FORMAT = "supabase-stack-owner-v1" as const; + +export interface UnixControlEndpoint { + readonly kind: "unix"; + readonly path: string; +} + +export interface WindowsControlEndpoint { + readonly kind: "pipe"; + readonly name: string; +} + +export type ControlEndpoint = UnixControlEndpoint | WindowsControlEndpoint; + +/** Host details are supplied by the process composition boundary. */ +export interface StackRuntimeEnvironmentValue { + readonly stateRoot: string; + /** A short local IPC directory (for example `/tmp`). */ + readonly tempRoot: string; + readonly platform: "posix" | "windows"; + readonly supervisorCommand?: string; + readonly supervisorEntrypoint?: string; +} + +export class StackRuntimeEnvironment extends Context.Service< + StackRuntimeEnvironment, + StackRuntimeEnvironmentValue +>()("@supabase/stack/StackRuntimeEnvironment") {} + +export const ControlEndpointSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("unix"), path: Schema.String }), + Schema.Struct({ kind: Schema.Literal("pipe"), name: Schema.String }), +]); + +export const OwnerMetadataSchema = Schema.Struct({ + format: Schema.Literal(OWNERSHIP_FORMAT), + stackId: StackIdSchema, + ownerSessionId: OwnerSessionIdSchema, + endpoint: ControlEndpointSchema, + rpcRelease: Schema.String, +}); +export type OwnerMetadata = Schema.Schema.Type; + +export interface OwnerLease { + readonly metadata: OwnerMetadata; + readonly lockPath: string; + readonly metadataPath: string; + /** Releases only this session's exact metadata and O_EXCL lock. */ + readonly release: Effect.Effect; +} + +const stateError = (message: string) => new StackStateInvalidError({ message }); + +const decodeStackId = (value: string): Effect.Effect => + Schema.decodeEffect(StackIdSchema)(value).pipe( + Effect.mapError((error) => stateError(`Invalid StackId: ${String(error)}`)), + ); + +const metadataFrom = (value: unknown): Effect.Effect => + Schema.decodeUnknownEffect(OwnerMetadataSchema)(value, { onExcessProperty: "error" }).pipe( + Effect.mapError((error) => stateError(`Invalid owner metadata: ${String(error)}`)), + ); + +const endpointPath = (endpoint: ControlEndpoint): string => + endpoint.kind === "unix" ? endpoint.path : endpoint.name; + +/** + * Computes the one local endpoint for an identity. The complete digest is used + * so two identities can never alias. The caller supplies a deliberately short + * IPC root; no project path is embedded in the endpoint. + */ +export const controlEndpointFor = ( + stackId: StackId | string, + environment: Pick, +): ControlEndpoint => { + const root = environment.tempRoot.replace(/[\\/]+$/, ""); + const token = String(stackId); + if (environment.platform === "windows") + return { kind: "pipe", name: `\\\\.\\pipe\\supabase-stack-${token}` }; + return { kind: "unix", path: `${root}/supabase-stack-${token}.sock` }; +}; + +export const ownerMetadataPath = (paths: { readonly controlMetadata: string }): string => + paths.controlMetadata; + +/** Reads and validates complete owner metadata without probing or mutating. */ +export const readOwnerMetadata = ( + stateRoot: string, + stackId: StackId | string, + environment: Pick, +): Effect.Effect< + OwnerMetadata | undefined, + StackStateInvalidError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const validId = yield* decodeStackId(String(stackId)); + const paths = yield* resolveStackPaths({ stateRoot, stackId: validId }).pipe( + Effect.mapError((error) => stateError(String(error))), + ); + const exists = yield* fs + .exists(paths.controlMetadata) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect owner metadata: ${error.message}`), + ), + ); + if (!exists) return undefined; + const raw = yield* fs.readFileString(paths.controlMetadata).pipe( + Effect.map(Option.some), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed(Option.none()) + : Effect.fail(stateError(`Unable to read owner metadata: ${error.message}`)), + ), + ); + if (Option.isNone(raw)) return undefined; + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + raw.value, + ).pipe( + Effect.mapError((error) => stateError(`Unable to parse owner metadata: ${String(error)}`)), + ); + const metadata = yield* metadataFrom(parsed); + if (metadata.stackId !== validId) + return yield* stateError("Owner metadata StackId does not match its directory"); + const expected = controlEndpointFor(validId, environment); + if (metadata.endpoint.kind !== expected.kind) + return yield* stateError("Owner metadata endpoint kind does not match this runtime"); + if ( + metadata.endpoint.kind === "unix" && + expected.kind === "unix" && + metadata.endpoint.path !== expected.path + ) + return yield* stateError("Owner metadata endpoint does not match its StackId"); + if ( + metadata.endpoint.kind === "pipe" && + expected.kind === "pipe" && + metadata.endpoint.name !== expected.name + ) + return yield* stateError("Owner metadata endpoint does not match its StackId"); + return metadata; + }); + +export const ownerLockExists = ( + stateRoot: string, + stackId: StackId | string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const validId = yield* decodeStackId(String(stackId)); + const paths = yield* resolveStackPaths({ stateRoot, stackId: validId }).pipe( + Effect.mapError((error) => stateError(String(error))), + ); + return yield* fs + .exists(path.join(paths.runtime, "owner.lock")) + .pipe( + Effect.mapError((error) => stateError(`Unable to inspect owner lock: ${error.message}`)), + ); + }); + +/** + * Publishes metadata through a sibling temporary file and same-directory + * rename. Readers therefore observe either no document or one complete JSON + * document, never a partially written owner record. + */ +export const publishOwnership = ( + lease: OwnerLease, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(OwnerMetadataSchema))( + lease.metadata, + ).pipe( + Effect.mapError((error) => stateError(`Unable to encode owner metadata: ${String(error)}`)), + ); + const temporary = `${lease.metadataPath}.${lease.metadata.ownerSessionId}.tmp`; + const existing = yield* fs + .exists(lease.metadataPath) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect existing owner metadata: ${error.message}`), + ), + ); + if (existing) + return yield* stateError("Owner metadata already exists; explicit recovery is required"); + const publish = Effect.gen(function* () { + yield* fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs + .open(temporary, { flag: "wx", mode: 0o600 }) + .pipe( + Effect.mapError((error) => + stateError(`Unable to open owner metadata: ${error.message}`), + ), + ); + yield* file + .writeAll(new TextEncoder().encode(encoded)) + .pipe( + Effect.mapError((error) => + stateError(`Unable to write owner metadata: ${error.message}`), + ), + ); + yield* file.sync.pipe( + Effect.mapError((error) => + stateError(`Unable to sync owner metadata: ${error.message}`), + ), + ); + }), + ); + yield* fs + .rename(temporary, lease.metadataPath) + .pipe( + Effect.mapError((error) => + stateError(`Unable to publish owner metadata: ${error.message}`), + ), + ); + yield* fs + .chmod(lease.metadataPath, 0o600) + .pipe( + Effect.mapError((error) => + stateError(`Unable to secure owner metadata: ${error.message}`), + ), + ); + }); + yield* publish.pipe( + Effect.ensuring( + fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + ); + }); + +const removeLockIfOwned = ( + fs: FileSystem.FileSystem, + lockPath: string, + ownerSessionId: string, +): Effect.Effect => + fs.readFileString(lockPath).pipe( + Effect.flatMap((owner) => + owner === ownerSessionId ? fs.remove(lockPath, { force: true }) : Effect.void, + ), + Effect.catchTag("PlatformError", () => Effect.void), + ); + +/** Acquires the sole O_EXCL ownership proof. Existing locks are never stolen. */ +export const acquireOwnership = (options: { + readonly stateRoot: string; + readonly stackId: StackId | string; + readonly ownerSessionId: string; + readonly rpcRelease: string; + readonly environment: Pick; +}): Effect.Effect< + OwnerLease, + StackOwnershipConflictError | StackStateInvalidError, + FileSystem.FileSystem | Path.Path | Scope.Scope +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const validId = yield* decodeStackId(String(options.stackId)); + const path = yield* Path.Path; + const paths = yield* resolveStackPaths({ stateRoot: options.stateRoot, stackId: validId }).pipe( + Effect.mapError((error) => stateError(String(error))), + ); + const lockPath = path.join(paths.runtime, "owner.lock"); + const metadata: OwnerMetadata = { + format: OWNERSHIP_FORMAT, + stackId: validId, + ownerSessionId: options.ownerSessionId, + endpoint: controlEndpointFor(validId, options.environment), + rpcRelease: options.rpcRelease, + }; + const release = Effect.suspend(() => { + return fs.readFileString(paths.controlMetadata).pipe( + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(OwnerMetadataSchema))(text).pipe( + Effect.flatMap((current) => + current.stackId === validId && current.ownerSessionId === options.ownerSessionId + ? fs.remove(paths.controlMetadata, { force: true }).pipe( + Effect.catchTag("PlatformError", () => Effect.void), + Effect.andThen(removeLockIfOwned(fs, lockPath, options.ownerSessionId)), + ) + : Effect.void, + ), + ), + ), + // Missing metadata means acquisition failed before publication; the + // lock still belongs to this random session and may be removed. + Effect.catchTag("PlatformError", () => + removeLockIfOwned(fs, lockPath, options.ownerSessionId), + ), + // Malformed metadata is fail-closed: an old finalizer must not remove + // a lock that could now belong to another owner. + Effect.ignore, + ); + }); + const lease = { + metadata, + lockPath, + metadataPath: paths.controlMetadata, + release, + } satisfies OwnerLease; + const scope = yield* Scope.Scope; + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* restore(fs.makeDirectory(paths.runtime, { recursive: true, mode: 0o700 })).pipe( + Effect.mapError((error) => + stateError(`Unable to create owner directory: ${error.message}`), + ), + ); + yield* restore( + fs.writeFileString(lockPath, options.ownerSessionId, { flag: "wx", mode: 0o600 }), + ).pipe( + Effect.mapError((error) => + Predicate.isTagged(error.reason, "AlreadyExists") + ? new StackOwnershipConflictError({ + message: "A Supervisor already owns this stack", + stackId: validId, + }) + : stateError(`Unable to acquire owner lock: ${error.message}`), + ), + ); + yield* Scope.addFinalizer(scope, release); + return lease; + }), + ); + }); + +export { endpointPath }; diff --git a/packages/stack/src/state/StackStateStore.ts b/packages/stack/src/state/StackStateStore.ts index c31c1d7809..dbe39fdbab 100644 --- a/packages/stack/src/state/StackStateStore.ts +++ b/packages/stack/src/state/StackStateStore.ts @@ -32,6 +32,15 @@ export interface StackStateStore { InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto >; + /** Initializes one identity atomically, returning the winner's complete state. */ + readonly initialize: ( + stackId: string, + state: PersistedStackState, + ) => Effect.Effect< + PersistedStackState, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; /** Replaces one complete state value after checking the expected generation. */ readonly replace: ( stackId: string, @@ -390,6 +399,38 @@ export const makeStackStateStore = (options: { }), ); + const initialize = ( + stackId: string, + candidate: PersistedStackState, + ): Effect.Effect< + PersistedStackState, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => + withRegistryLock( + options.stateRoot, + Effect.gen(function* () { + const existing = yield* read(stackId); + if (existing !== undefined) return existing; + const paths = yield* pathsFor(stackId); + yield* validateIdentityForStackId(candidate.identity, stackId); + yield* Schema.decodeEffect(PersistedStackStateSchema)(candidate, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => + stateError(`Invalid persisted stack state: ${String(error)}`), + ), + ); + yield* ownerDirectory(fs, paths.stackRoot).pipe( + Effect.mapError((error) => + stateError(`Unable to create stack state directory: ${error.message}`), + ), + ); + yield* atomicWrite(fs, path, crypto, paths.stateDocument, paths.stackRoot, candidate); + return candidate; + }), + ); + const replaceUnlocked = ( stackId: string, next: PersistedStackState, @@ -462,7 +503,7 @@ export const makeStackStateStore = (options: { }), ); - return { read, write, replace, replaceUnlocked, cleanup } satisfies StackStateStore; + return { read, write, initialize, replace, replaceUnlocked, cleanup } satisfies StackStateStore; }); export { PersistedStackStateSchema } from "./StackState.ts"; diff --git a/packages/stack/src/state/ownership.integration.test.ts b/packages/stack/src/state/ownership.integration.test.ts new file mode 100644 index 0000000000..75200d3d02 --- /dev/null +++ b/packages/stack/src/state/ownership.integration.test.ts @@ -0,0 +1,130 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Option } from "effect"; +import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; +import { StackOwnershipConflictError, StackStateInvalidError } from "../public/Errors.ts"; +import { + acquireOwnership, + controlEndpointFor, + ownerLockExists, + publishOwnership, + readOwnerMetadata, + type StackRuntimeEnvironmentValue, +} from "./Ownership.ts"; +import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const identity: StackIdentity = { + projectRoot: "/tmp/project", + checkoutRoot: "/tmp/project", + workspaceId: "/tmp/project", + checkoutId: "/tmp/project", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "default", +}; + +const stateFor = (stackId: string): PersistedStackState => ({ + format: "supabase-stack-state-v1", + identity: { ...identity, stackId }, + runtime: { kind: "native" }, + desiredGeneration: 0, + desiredLifecycle: "unconfigured", + ports: [], + secrets: {}, +}); + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +describe("stack ownership", () => { + it.live("initializes one complete state for concurrent equivalent creators", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-init-" }); + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: root }); + const candidate = stateFor(stackId); + const [first, second] = yield* Effect.all( + [store.initialize(stackId, candidate), store.initialize(stackId, candidate)], + { concurrency: 2 }, + ); + expect(first).toEqual(candidate); + expect(second).toEqual(candidate); + expect(yield* store.read(stackId)).toEqual(candidate); + }), + ), + ); + + it.live("publishes and releases an exact session-fenced owner", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-owner-" }); + const stackId = yield* deriveStackId(identity); + const environment: StackRuntimeEnvironmentValue = { + stateRoot: root, + tempRoot: root, + platform: "posix", + }; + const lease = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "session-one", + rpcRelease: "stack-rpc-v1@0.1.0", + environment, + }); + expect(yield* ownerLockExists(root, stackId)).toBe(true); + expect(lease.metadata.endpoint).toEqual(controlEndpointFor(stackId, environment)); + yield* publishOwnership(lease); + expect(yield* readOwnerMetadata(root, stackId, environment)).toEqual(lease.metadata); + + const competing = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "session-two", + rpcRelease: "stack-rpc-v1@0.1.0", + environment, + }).pipe(Effect.exit); + expect(errorOf(competing)).toBeInstanceOf(StackOwnershipConflictError); + yield* lease.release; + expect(yield* ownerLockExists(root, stackId)).toBe(false); + expect(yield* readOwnerMetadata(root, stackId, environment)).toBeUndefined(); + }), + ), + ); + + it.live("fails closed when owner metadata belongs to another identity", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-fence-" }); + const stackId = yield* deriveStackId(identity); + const environment: StackRuntimeEnvironmentValue = { + stateRoot: root, + tempRoot: root, + platform: "posix", + }; + const lease = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "session-one", + rpcRelease: "stack-rpc-v1@0.1.0", + environment, + }); + yield* publishOwnership(lease); + const metadataPath = lease.metadataPath; + const text = yield* fs.readFileString(metadataPath); + yield* fs.writeFileString(metadataPath, text.replace(stackId, "b".repeat(64))); + const read = yield* readOwnerMetadata(root, stackId, environment).pipe(Effect.exit); + expect(errorOf(read)).toBeInstanceOf(StackStateInvalidError); + yield* lease.release; + // A malformed replacement cannot be removed by this stale finalizer. + expect(yield* fs.exists(metadataPath)).toBe(true); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts new file mode 100644 index 0000000000..fc1f993850 --- /dev/null +++ b/packages/stack/src/supervisor/Launcher.ts @@ -0,0 +1,370 @@ +import { + Crypto, + Effect, + FileSystem, + Fiber, + Option, + Path, + Result, + Schedule, + Scope, + Schema, + Stream, +} from "effect"; +import { fileURLToPath } from "node:url"; +import { ChildProcess } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { StackIdentity } from "../identity/Identity.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { + readOwnerMetadata, + type OwnerMetadata, + type StackRuntimeEnvironmentValue, +} from "../state/Ownership.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import { makeControlClient } from "../control/ControlServer.ts"; +import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; +import { + StackOwnershipConflictError, + StackStateInvalidError, + StackUpgradeRequiredError, +} from "../public/Errors.ts"; + +export { StackRuntimeEnvironment } from "../state/Ownership.ts"; +export type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; + +/** Default host values are resolved only at the process composition boundary. */ +export const defaultRuntimeEnvironment = (): StackRuntimeEnvironmentValue => { + // This synchronous helper is the composition boundary where host environment + // variables are read. The runtime itself receives a fully materialized value. + // oxlint-disable-next-line effecttsgo/process-env -- composition-boundary environment read + const home = process.env.SUPABASE_HOME ?? `${process.env.HOME ?? process.cwd()}/.supabase`; + return { + stateRoot: `${home}/managed/stacks`, + // Keep the POSIX socket root short enough for AF_UNIX path limits. + // oxlint-disable-next-line effecttsgo/process-env -- composition-boundary environment read + tempRoot: process.platform === "win32" ? (process.env.TEMP ?? "C:\\Windows\\Temp") : "/tmp", + platform: process.platform === "win32" ? "windows" : "posix", + supervisorCommand: process.execPath, + supervisorEntrypoint: fileURLToPath( + new URL("../entrypoints/supervisor-node.ts", import.meta.url), + ), + }; +}; + +const ReadySchema = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + stackId: StackIdSchema, + ownerSessionId: Schema.String, + }), + Schema.Struct({ + ok: Schema.Literal(false), + code: Schema.Literals(["ownership-conflict", "failed"] as const), + message: Schema.String, + }), +]); +type ReadyMessage = Schema.Schema.Type; +type ReadinessResult = + | { readonly kind: "ready"; readonly stackId: StackId; readonly ownerSessionId: string } + | { readonly kind: "ownership-conflict" } + | { readonly kind: "failed"; readonly message: string }; + +type ChildResult = + | { readonly kind: "ready"; readonly metadata: OwnerMetadata } + | { readonly kind: "ownership-conflict" } + | { readonly kind: "failed"; readonly message: string }; + +interface LaunchPayload { + readonly stateRoot: string; + readonly tempRoot: string; + readonly platform: "posix" | "windows"; + readonly stackId: StackId; + readonly ownerSessionId: string; + readonly rpcRelease: string; + readonly identity: StackIdentity; +} + +const mapFailure = (message: string) => new StackStateInvalidError({ message }); +const SUPERVISOR_READINESS_TIMEOUT_MS = 5_000; + +export interface LauncherOptions { + readonly identity: StackIdentity; + readonly stackId: StackId; + readonly stateStore: StackStateStore; + readonly environment: StackRuntimeEnvironmentValue; +} + +const encodeLaunchPayload = ( + payload: LaunchPayload, +): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(payload).pipe( + Effect.mapError(() => mapFailure("Unable to encode supervisor launch arguments")), + ); + +const decodeReady = (bytes: Uint8Array): Effect.Effect => { + if (bytes.byteLength > 4096) + return Effect.fail(mapFailure("Supervisor readiness frame exceeds size limit")); + const text = new TextDecoder().decode(bytes); + if (text.trim().length === 0) + return Effect.succeed({ kind: "failed", message: "Supervisor exited before readiness" }); + const classify = (value: ReadyMessage): Effect.Effect => + value.ok + ? Effect.succeed({ + kind: "ready", + stackId: value.stackId, + ownerSessionId: value.ownerSessionId, + }) + : value.code === "ownership-conflict" + ? Effect.succeed({ kind: "ownership-conflict" }) + : Effect.succeed({ kind: "failed", message: value.message }); + return Schema.decodeEffect(Schema.fromJsonString(ReadySchema))(text).pipe( + Effect.mapError(() => mapFailure("Invalid supervisor readiness frame")), + Effect.flatMap(classify), + ); +}; + +const validateReady = ( + ready: { readonly kind: "ready"; readonly stackId: StackId; readonly ownerSessionId: string }, + options: LauncherOptions, + expectedOwnerSessionId: string, +): Effect.Effect => + ready.stackId === options.stackId && ready.ownerSessionId === expectedOwnerSessionId + ? Effect.void + : Effect.fail(mapFailure("Supervisor readiness identity mismatch")); + +const validateCompatibleOwner = ( + options: LauncherOptions, + metadata: OwnerMetadata, +): Effect.Effect< + void, + StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, + Scope.Scope +> => + Effect.gen(function* () { + if (metadata.rpcRelease !== STACK_RPC_RELEASE) + return yield* new StackUpgradeRequiredError({ + message: `Stack owner release ${metadata.rpcRelease} requires explicit restart`, + expectedRelease: STACK_RPC_RELEASE, + actualRelease: metadata.rpcRelease, + }); + const probe = yield* makeControlClient(metadata.endpoint, { + stackId: options.stackId, + ownerSessionId: metadata.ownerSessionId, + }) + .probe() + .pipe(Effect.mapError((error) => mapFailure(String(error)))); + if ( + !probe.ok || + probe.op !== "probe" || + probe.stackId !== options.stackId || + probe.ownerSessionId !== metadata.ownerSessionId + ) + return yield* new StackOwnershipConflictError({ + message: "Existing Supervisor failed session probe", + stackId: options.stackId, + }); + }); + +const observeOwner = ( + options: LauncherOptions, + paths: { readonly stackRoot: string }, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const ownerFromEvents = Stream.filterMapEffect(fs.watch(paths.stackRoot), () => + readOwnerMetadata(options.environment.stateRoot, options.stackId, options.environment).pipe( + Effect.map((metadata) => + metadata === undefined ? Result.fail(undefined) : Result.succeed(metadata), + ), + ), + ); + // FileSystem.watch does not expose a subscription-ready signal. A short, + // Schedule-driven reread closes the publication gap between the initial + // absent read and watcher registration without ad-hoc timers. The outer + // launch timeout remains the single five-second guard. + const ownerFromReread = Stream.fromEffect( + readOwnerMetadata(options.environment.stateRoot, options.stackId, options.environment), + ).pipe( + Stream.repeat(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), + Stream.filterMap((metadata) => + metadata === undefined ? Result.fail(undefined) : Result.succeed(metadata), + ), + ); + const observed = yield* Stream.runHead(Stream.merge(ownerFromEvents, ownerFromReread)).pipe( + Effect.mapError((error) => mapFailure(String(error))), + ); + if (Option.isNone(observed)) + return yield* mapFailure("Supervisor owner metadata was not published"); + return observed.value; + }); + +const launchAndAwait = ( + options: LauncherOptions, + payload: LaunchPayload, + paths: { readonly stackRoot: string }, +): Effect.Effect< + OwnerMetadata, + StackOwnershipConflictError | StackStateInvalidError, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const command = options.environment.supervisorCommand; + const entrypoint = options.environment.supervisorEntrypoint; + if (command === undefined || entrypoint === undefined) + return yield* mapFailure("Supervisor launcher is not configured"); + const encoded = yield* encodeLaunchPayload(payload); + // Subscribe before spawning: a loser can observe the winner's exact + // metadata publication even when its own child exits on O_EXCL conflict. + const ownerFiber = yield* Effect.forkChild(observeOwner(options, paths), { + startImmediately: true, + }); + const child = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const child = yield* restore( + ChildProcess.make(command, [entrypoint, encoded], { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + additionalFds: { fd3: { type: "output" } }, + }), + ); + yield* Effect.asVoid(child.unref); + return child; + }), + ); + const readiness: Effect.Effect< + ChildResult, + StackStateInvalidError, + FileSystem.FileSystem | Path.Path + > = Effect.gen(function* () { + const chunks = yield* Stream.runCollect(child.getOutputFd(3)); + const values = chunks; + const length = values.reduce((sum, value) => sum + value.byteLength, 0); + const bytes = new Uint8Array(length); + let offset = 0; + for (const value of values) { + bytes.set(value, offset); + offset += value.byteLength; + } + const ready = yield* decodeReady(bytes); + if (ready.kind !== "ready") return ready; + yield* validateReady(ready, options, payload.ownerSessionId); + const metadata = yield* readOwnerMetadata( + options.environment.stateRoot, + options.stackId, + options.environment, + ); + if (metadata === undefined) + return yield* mapFailure("Supervisor readiness arrived before owner metadata"); + if ( + metadata.ownerSessionId !== ready.ownerSessionId || + metadata.rpcRelease !== payload.rpcRelease + ) + return yield* mapFailure("Supervisor readiness metadata identity mismatch"); + return { kind: "ready", metadata } satisfies ChildResult; + }).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(mapFailure(error.message)))); + const terminateLaunch: Effect.Effect = Effect.all([ + Effect.ignore(child.kill()), + Fiber.interrupt(ownerFiber), + ]).pipe(Effect.asVoid); + const childResult = yield* readiness.pipe( + Effect.timeoutOrElse({ + duration: SUPERVISOR_READINESS_TIMEOUT_MS, + orElse: () => Effect.fail(mapFailure("Supervisor did not publish readiness in time")), + }), + Effect.tapError(() => terminateLaunch), + Effect.onInterrupt(() => terminateLaunch), + ); + if (childResult.kind === "ownership-conflict") { + const current = yield* readOwnerMetadata( + options.environment.stateRoot, + options.stackId, + options.environment, + ); + if (current !== undefined) return current; + return yield* Fiber.join(ownerFiber).pipe( + Effect.timeoutOrElse({ + duration: 5_000, + orElse: () => + Fiber.interrupt(ownerFiber).pipe( + Effect.andThen( + Effect.fail( + new StackOwnershipConflictError({ + message: "Supervisor ownership conflict did not publish a compatible owner", + stackId: options.stackId, + }), + ), + ), + ), + }), + ); + } + yield* Fiber.interrupt(ownerFiber); + if (childResult.kind === "failed") { + yield* terminateLaunch; + return yield* mapFailure(childResult.message); + } + return childResult.metadata; + }).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(mapFailure(error.message)))); + +/** + * Starts one detached owner process or joins the owner concurrently published + * by another caller. The lock itself is acquired in the supervisor process. + */ +export const ensureSupervisor = ( + options: LauncherOptions, +): Effect.Effect< + OwnerMetadata, + StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | Scope.Scope + | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const existing = yield* readOwnerMetadata( + options.environment.stateRoot, + options.stackId, + options.environment, + ); + if (existing !== undefined) { + yield* validateCompatibleOwner(options, existing); + return existing; + } + const stackId = yield* Schema.decodeEffect(StackIdSchema)(options.stackId).pipe( + Effect.mapError((error) => mapFailure(`Invalid StackId: ${String(error)}`)), + ); + const paths = yield* resolveStackPaths({ + stateRoot: options.environment.stateRoot, + stackId, + }).pipe(Effect.mapError((error) => mapFailure(String(error)))); + const crypto = yield* Crypto.Crypto; + const ownerSessionId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((error) => + mapFailure(`Unable to allocate owner session id: ${error.message}`), + ), + ); + const payload: LaunchPayload = { + stateRoot: options.environment.stateRoot, + tempRoot: options.environment.tempRoot, + platform: options.environment.platform, + stackId: options.stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + identity: options.identity, + }; + yield* (yield* FileSystem.FileSystem) + .makeDirectory(paths.runtime, { recursive: true }) + .pipe( + Effect.mapError((error) => + mapFailure(`Unable to prepare owner runtime directory: ${error.message}`), + ), + ); + const owner = yield* launchAndAwait(options, payload, { stackRoot: paths.stackRoot }); + yield* validateCompatibleOwner(options, owner); + return owner; + }); diff --git a/packages/stack/src/supervisor/OwnerSession.ts b/packages/stack/src/supervisor/OwnerSession.ts new file mode 100644 index 0000000000..de9d3a4a92 --- /dev/null +++ b/packages/stack/src/supervisor/OwnerSession.ts @@ -0,0 +1,45 @@ +import { Effect, FileSystem, Scope } from "effect"; +import type { ControlEndpoint } from "../state/Ownership.ts"; +import { + startControlServer, + type ControlServer, + type MaintenanceHandlers, +} from "../control/ControlServer.ts"; +import type { StackRpcHandlers } from "../control/StackRpc.ts"; +import { StackRpcProtocolError } from "../control/StackRpc.ts"; +import type { MaintenanceRequest } from "../control/MaintenanceProtocol.ts"; + +/** A bound owner session; ownership and metadata publication live in Launcher. */ +export interface OwnerSession { + readonly endpoint: ControlEndpoint; + readonly ready: Effect.Effect; + readonly shutdown: Effect.Effect; +} + +export interface OwnerSessionOptions { + readonly endpoint: ControlEndpoint; + readonly stackId: string; + readonly ownerSessionId: string; + readonly rpcRelease?: string; + readonly maintenanceHandlers: MaintenanceHandlers; + /** Called after a stop/quiesce response is written and the connection closes. */ + readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; + readonly rpcHandlers: StackRpcHandlers; +} + +/** + * Bind the exact endpoint and run both control protocols. This function does + * not acquire an ownership lock or write metadata; callers publish metadata + * only after the returned `ready` effect completes. + */ +export const startOwnerSession = ( + options: OwnerSessionOptions, +): Effect.Effect => + startControlServer(options).pipe( + Effect.map((server: ControlServer) => ({ + endpoint: server.endpoint, + ready: server.ready, + shutdown: server.shutdown, + })), + Effect.mapError((error) => new StackRpcProtocolError({ message: error.message })), + ); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts new file mode 100644 index 0000000000..770c8ed24b --- /dev/null +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -0,0 +1,241 @@ +import { Context, Crypto, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import type { StackIdentity } from "../identity/Identity.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import { + CAPABILITY_NAMES, + type CapabilityName, + type CapabilityStatus, +} from "../public/Capability.ts"; +import type { StackEndpoint, StackStatus } from "../public/Status.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { + StackStateFormatUnsupportedError, + StackStateInvalidError, + InvalidProjectRootError, + type StackError, +} from "../public/Errors.ts"; +import { StackRpcGroup, type StackRpcError, type StackRpcHandlers } from "../control/StackRpc.ts"; +import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; + +export interface Supervisor { + readonly identity: StackIdentity; + readonly stackId: StackId; + readonly ownerSessionId: string; + readonly status: Effect.Effect< + StackStatus, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError + >; + readonly maintenanceHandlers: { + readonly probe: Effect.Effect; + readonly stop: Effect.Effect; + readonly quiesce: Effect.Effect; + }; + readonly rpcHandlers: StackRpcHandlers; +} + +export interface SupervisorOptions { + readonly identity: StackIdentity; + readonly stackId: StackId; + readonly ownerSessionId: string; + readonly rpcRelease: string; + readonly stateStore: StackStateStore; + readonly context: Context.Context; +} + +const capabilityState = ( + configured: { readonly enabled: boolean } | undefined, +): CapabilityStatus["state"] => + configured === undefined || !configured.enabled ? "disabled" : "stopped"; + +const statusFor = ( + state: PersistedStackState, +): Effect.Effect => { + const definition = state.definition; + const capabilities = CAPABILITY_NAMES.map((name) => { + const configured = definition?.capabilities[name]; + return { + name, + activation: configured?.activation ?? "eager", + state: capabilityState(configured), + }; + }); + const versions: Partial> = {}; + if (definition !== undefined) { + for (const name of CAPABILITY_NAMES) versions[name] = definition.capabilities[name].version; + } + const endpoints = state.ports.reduce< + Readonly< + Partial< + Record< + | "api" + | "database" + | "pooler" + | "studio" + | "mailUi" + | "smtp" + | "pop3" + | "functionsInspector", + StackEndpoint + > + > + > + >((result, assignment) => { + const tcp = + assignment.field === "database" || + assignment.field === "pooler" || + assignment.field === "smtp" || + assignment.field === "pop3"; + const protocol = tcp ? "tcp" : "http"; + const listener = state.definition?.listeners[assignment.field]; + return { + ...result, + [assignment.field]: { + protocol, + address: listener?.address ?? "127.0.0.1", + port: assignment.port, + url: `${protocol}://${listener?.address ?? "127.0.0.1"}:${assignment.port}`, + }, + }; + }, {}); + return Schema.decodeEffect(StackIdSchema)(state.identity.stackId).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ message: `Invalid persisted StackId: ${String(error)}` }), + ), + Effect.map((id) => ({ + id, + lifecycle: + state.desiredLifecycle === "unconfigured" + ? "unconfigured" + : state.desiredLifecycle === "destroying" + ? "destroying" + : "stopped", + desiredLifecycle: state.desiredLifecycle, + runtime: state.runtime, + desiredGeneration: state.desiredGeneration, + endpoints, + versions, + capabilities, + })), + ); +}; + +const rpcError = (tag: StackRpcError["tag"], message: string): StackRpcError => ({ tag, message }); + +const stateErrorMessage = (error: StackError | { readonly message?: string }): string => + typeof error.message === "string" ? error.message : "Stack operation failed"; + +export const makeSupervisor = (options: SupervisorOptions): Supervisor => { + const read = options.stateStore + .read(options.stackId) + .pipe(Effect.provideContext(options.context)); + const status = read.pipe( + Effect.flatMap((state) => + state === undefined + ? Effect.fail(new StackStateInvalidError({ message: "Stack state is missing" })) + : statusFor(state), + ), + ); + + const stop = read.pipe( + Effect.flatMap((state) => { + if (state === undefined) + return Effect.fail(new StackStateInvalidError({ message: "Stack state is missing" })); + if (state.desiredLifecycle === "unconfigured" || state.desiredLifecycle === "stopped") + return Effect.void; + return options.stateStore + .replace( + options.stackId, + { + ...state, + desiredLifecycle: "stopped", + desiredGeneration: state.desiredGeneration + 1, + }, + state.desiredGeneration, + ) + .pipe(Effect.provideContext(options.context)); + }), + ); + + const maintenanceHandlers = { + probe: Effect.succeed({ + ok: true, + op: "probe", + ownerSessionId: options.ownerSessionId, + stackId: options.stackId, + rpcRelease: options.rpcRelease, + } satisfies MaintenanceResponse), + stop: stop.pipe( + Effect.as({ ok: true, op: "stop" } satisfies MaintenanceResponse), + Effect.orElseSucceed( + () => + ({ + ok: false, + error: { tag: "operation-failed", message: "Unable to stop stack" }, + }) satisfies MaintenanceResponse, + ), + ), + quiesce: Effect.succeed({ ok: true, op: "quiesce" } satisfies MaintenanceResponse), + }; + + const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ + status: () => + status.pipe( + Effect.mapError((error) => rpcError("StackStateInvalidError", stateErrorMessage(error))), + ), + credentials: () => Effect.fail(rpcError("StackNotRunningError", "Stack is not running")), + prepare: () => + Effect.fail(rpcError("StackPreparationError", "Stack preparation is not available yet")), + start: () => + read.pipe( + Effect.mapError(() => rpcError("StackStateInvalidError", "Unable to read stack state")), + Effect.flatMap((state) => + state?.definition === undefined + ? Effect.fail( + rpcError( + "StackDefinitionRequiredError", + "A stack definition is required before starting", + ), + ) + : Effect.fail( + rpcError("StackReconciliationError", "Stack runtime is not available yet"), + ), + ), + ), + restart: () => + read.pipe( + Effect.mapError(() => rpcError("StackStateInvalidError", "Unable to read stack state")), + Effect.flatMap((state) => + state?.definition === undefined + ? Effect.fail( + rpcError( + "StackDefinitionRequiredError", + "A stack definition is required before restarting", + ), + ) + : Effect.fail( + rpcError("StackReconciliationError", "Stack runtime is not available yet"), + ), + ), + ), + destroy: () => + Effect.fail(rpcError("StackDestructionError", "Stack destruction is not available yet")), + logs: () => Stream.fail(rpcError("StackNotRunningError", "Stack logs are not available yet")), + watchStatus: () => + Stream.fromEffect( + status.pipe( + Effect.mapError((error) => rpcError("StackStateInvalidError", stateErrorMessage(error))), + ), + ), + }); + + return { + identity: options.identity, + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + status, + maintenanceHandlers, + rpcHandlers, + }; +}; diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts new file mode 100644 index 0000000000..d41873641e --- /dev/null +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -0,0 +1,280 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { + Cause, + Effect, + Exit, + FileSystem, + Fiber, + Option, + Path, + Result, + Schema, + Stream, +} from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import { + createStack, + findStack, + inspectStack, + openStack, + listStacks, +} from "../public/EffectStack.ts"; +import { defaultRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "./Launcher.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import type { StackId } from "../public/StackId.ts"; +import { readOwnerMetadata, StackRuntimeEnvironment } from "../state/Ownership.ts"; +import { makeControlClient } from "../control/ControlServer.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; +import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; +import { StackUpgradeRequiredError } from "../public/Errors.ts"; + +const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-handles-" }); + const path = yield* Path.Path; + const project = path.join(root, "project"); + yield* fs.makeDirectory(project); + const defaults = defaultRuntimeEnvironment(); + const runtime: StackRuntimeEnvironmentValue = { + ...defaults, + stateRoot: path.join(root, "managed", "stacks"), + tempRoot: "/tmp", + platform: "posix", + }; + const cleanupOwners = Effect.gen(function* () { + const exists = yield* fs.exists(runtime.stateRoot); + if (!exists) return; + const entries = yield* fs.readDirectory(runtime.stateRoot); + yield* Effect.forEach( + entries, + (entry) => + Schema.is(StackIdSchema)(entry) ? quiesceOwner(StackIdSchema.make(entry)) : Effect.void, + { discard: true }, + ); + }); + return yield* effect(project).pipe( + Effect.onExit(() => cleanupOwners), + Effect.provideService(StackRuntimeEnvironment, runtime), + ); + }), + ).pipe(Effect.provide(NodeServices.layer)); + +const quiesceOwner = (id: StackId) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const fs = yield* FileSystem.FileSystem; + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: id }); + const owner = yield* readOwnerMetadata(env.stateRoot, id, env); + if (owner === undefined) return; + const removed = Stream.runHead( + Stream.filterMapEffect(fs.watch(paths.stackRoot), () => + readOwnerMetadata(env.stateRoot, id, env).pipe( + Effect.map((metadata) => + metadata === undefined ? Result.succeed(true) : Result.fail(undefined), + ), + ), + ), + ); + const watcher = yield* Effect.forkChild(removed); + yield* Effect.scoped( + makeControlClient(owner.endpoint, { + stackId: id, + ownerSessionId: owner.ownerSessionId, + rpcRelease: owner.rpcRelease, + }).quiesce(), + ); + const remaining = yield* readOwnerMetadata(env.stateRoot, id, env); + if (remaining === undefined) { + yield* Fiber.interrupt(watcher); + return; + } + yield* Fiber.join(watcher); + }); + +const quoteModuleSpecifier = (value: string): string => + `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; + +describe("managed stack handles", () => { + it.live("creates an unconfigured stack without reading config or starting workloads", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot: project }); + const status = yield* stack.status(); + expect(status.lifecycle).toBe("unconfigured"); + expect(status.desiredLifecycle).toBe("unconfigured"); + yield* stack.close(); + }), + ), + ); + + it.live("concurrent equivalent creates join one owner", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const [first, second] = yield* Effect.all( + [createStack({ projectRoot: project }), createStack({ projectRoot: project })], + { concurrency: 2 }, + ); + expect(second.id).toBe(first.id); + expect((yield* second.status()).lifecycle).toBe("unconfigured"); + yield* first.close(); + yield* second.close(); + }), + ), + ); + + it.live("discovery never creates an identity", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const found = yield* findStack({ projectRoot: project }); + expect(Option.isNone(found)).toBe(true); + const absentId = StackIdSchema.make("f".repeat(64)); + const absent = yield* inspectStack(absentId).pipe(Effect.exit); + expect(Exit.isFailure(absent)).toBe(true); + }), + ), + ); + + it.live("openStack is observational and rejects unknown ids", () => + withRuntimeRoot((_project) => + Effect.gen(function* () { + const result = yield* openStack(StackIdSchema.make("0".repeat(64))).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + }), + ), + ); + + it.live("a closed handle leaves its owner available to a replacement handle", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const first = yield* createStack({ projectRoot: project }); + const id = first.id; + yield* first.close(); + const second = yield* openStack(id); + expect((yield* second.status()).lifecycle).toBe("unconfigured"); + yield* second.close(); + }), + ), + ); + + it.live("rejects an incompatible owner release without replacing the owner", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot: project }); + const env = yield* StackRuntimeEnvironment; + const fs = yield* FileSystem.FileSystem; + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); + const metadata = yield* readOwnerMetadata(env.stateRoot, stack.id, env); + expect(metadata).not.toBeUndefined(); + const current = yield* fs.readFileString(paths.controlMetadata); + yield* fs.writeFileString( + paths.controlMetadata, + current.replace(STACK_RPC_RELEASE, "stack-rpc-v0@0.0.1"), + ); + const opened = yield* openStack(stack.id).pipe(Effect.exit); + expect(Exit.isFailure(opened)).toBe(true); + if (Exit.isFailure(opened)) { + const error = Option.getOrUndefined(Cause.findErrorOption(opened.cause)); + expect(error).toBeInstanceOf(StackUpgradeRequiredError); + } + const recreated = yield* createStack({ projectRoot: project }).pipe(Effect.exit); + expect(Exit.isFailure(recreated)).toBe(true); + if (Exit.isFailure(recreated)) { + const error = Option.getOrUndefined(Cause.findErrorOption(recreated.cause)); + expect(error).toBeInstanceOf(StackUpgradeRequiredError); + } + const inspected = yield* inspectStack(stack.id); + expect(inspected.owner).toBe("incompatible"); + yield* stack.close(); + }), + ), + ); + + it.live("filters read-only discovery by project root", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const other = path.join(path.dirname(project), "other-project"); + yield* fs.makeDirectory(other); + const first = yield* createStack({ projectRoot: project }); + const second = yield* createStack({ projectRoot: other }); + const filtered = yield* listStacks({ projectRoot: project }); + expect(filtered.map((entry) => entry.id)).toEqual([first.id]); + yield* first.close(); + yield* second.close(); + }), + ), + ); + + it.live("keeps the detached owner alive after concurrent caller processes exit", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const path = yield* Path.Path; + const supabaseHome = path.dirname(project); + const stackModule = new URL("../public/EffectStack.ts", import.meta.url).href; + const encodedStackModule = quoteModuleSpecifier(stackModule); + const script = ` + const { Effect } = await import("effect"); + const { NodeServices } = await import("@effect/platform-node"); + const { createStack } = await import(${encodedStackModule}); + const stack = await Effect.runPromise(Effect.scoped(createStack({ projectRoot: process.argv[1] }).pipe(Effect.provide(NodeServices.layer)))); + process.stdout.write(stack.id); + `; + const spawnCaller = () => + Effect.gen(function* () { + const child = yield* ChildProcess.make( + process.execPath, + ["--input-type=module", "-e", script, project], + { + cwd: process.cwd(), + env: { SUPABASE_HOME: supabaseHome }, + extendEnv: true, + stdout: "pipe", + stderr: "pipe", + }, + ); + const [chunks, stderrChunks, code] = yield* Effect.all( + [Stream.runCollect(child.stdout), Stream.runCollect(child.stderr), child.exitCode], + { concurrency: 3 }, + ); + const bytes = new Uint8Array(chunks.reduce((sum, value) => sum + value.byteLength, 0)); + let offset = 0; + for (const value of chunks) { + bytes.set(value, offset); + offset += value.byteLength; + } + const stderr = new TextDecoder().decode( + new Uint8Array(stderrChunks.reduce((sum, value) => sum + value.byteLength, 0)), + ); + return { id: new TextDecoder().decode(bytes), code, stderr }; + }); + const [first, second] = yield* Effect.all([spawnCaller(), spawnCaller()], { + concurrency: 2, + }); + expect(first.code, first.stderr).toBe(0); + expect(second.code, second.stderr).toBe(0); + expect(first.id).toBe(second.id); + const attached = yield* openStack(StackIdSchema.make(first.id)); + expect((yield* attached.status()).lifecycle).toBe("unconfigured"); + yield* attached.close(); + }), + ), + ); + + it.live( + "maintenance stop keeps an unconfigured owner usable without fabricating lifecycle state", + () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot: project }); + yield* stack.stop(); + const status = yield* stack.status(); + expect(status.lifecycle).toBe("unconfigured"); + yield* stack.close(); + }), + ), + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f47c7cd043..b34ef19fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -443,13 +443,13 @@ importers: packages/stack: dependencies: + '@effect/platform-node': + specifier: 'catalog:' + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) effect: specifier: 'catalog:' version: 4.0.0-rc.111 devDependencies: - '@effect/platform-node': - specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) '@effect/vitest': specifier: 'catalog:' version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) From 4dc2a4872f04e60bc0f7af60caae0f8dac65c066 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 00:01:42 +0200 Subject: [PATCH 013/174] feat(stack): reconcile workloads and publish observability --- ...26-08-28-stack-runtime-rewrite-progress.md | 36 ++ packages/stack/src/runtime/RuntimeDriver.ts | 67 +++ packages/stack/src/supervisor/DesiredState.ts | 166 +++++++ packages/stack/src/supervisor/LogStore.ts | 422 +++++++++++++++++ packages/stack/src/supervisor/Reconciler.ts | 293 ++++++++++++ .../src/supervisor/Reconciler.unit.test.ts | 153 ++++++ packages/stack/src/supervisor/StatusHub.ts | 22 + .../observability.integration.test.ts | 186 ++++++++ .../supervisor/reconciler.integration.test.ts | 437 ++++++++++++++++++ 9 files changed, 1782 insertions(+) create mode 100644 packages/stack/src/runtime/RuntimeDriver.ts create mode 100644 packages/stack/src/supervisor/DesiredState.ts create mode 100644 packages/stack/src/supervisor/LogStore.ts create mode 100644 packages/stack/src/supervisor/Reconciler.ts create mode 100644 packages/stack/src/supervisor/Reconciler.unit.test.ts create mode 100644 packages/stack/src/supervisor/StatusHub.ts create mode 100644 packages/stack/src/supervisor/observability.integration.test.ts create mode 100644 packages/stack/src/supervisor/reconciler.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 51df8bb515..0264ff4de4 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -326,3 +326,39 @@ Implementation continues after recording a ruling; this file is not a question q - Effect lint over all Task 5 sources and tests — passed with zero warnings. - Formatting and `git diff --check` — passed; no matching Supervisor processes or control sockets remained after the integration suites. + +### Task 6 — 2026-08-29 + +- Added the exact private `RuntimeDriver` boundary and a deterministic desired-state planner. Running + reconciliation starts in dependency order; stop, destroy, stale-generation replacement, and + duplicate-resource cleanup quiesce active resources before removal in reverse dependency order. + Every runtime mutation uses the full StackId, desired generation, workload id, and semantic spec + hash. +- Added one Supervisor-local reconciliation semaphore and generation fences before and after every + mutation and observation boundary. Independent branches continue after a workload failure, while + the complete transitive dependent closure is blocked and any already-running descendants are + stopped and removed child-first. +- Readiness uses a deadline and bounded `Schedule` retry policy. Exhaustion is scoped by stack, + generation, and workload, persists across repeated running reconciliation, resets on an explicit + stopped/destroying intent, and starts fresh for a new generation. Retry control classifies the + complete failed `Cause` before scheduling so defects and interruption are never collapsed into an + ordinary workload failure. +- Added complete status projections backed by `SubscriptionRef`; every watcher begins with the + current snapshot. Added owner-only bounded retained logs with strict persisted decoding, opaque + monotonic cursors, capability filters, exact known-secret redaction before persistence and live + publication, and one semaphore-protected retained-to-live `PubSub` handoff without gaps or + duplicates. +- Retained log limits reject non-finite or undersized configurations, existing log directories and + files are repaired to `0700`/`0600`, and retained entries are re-redacted and republished + atomically when the current known-secret set newly matches old content. +- Review RED/GREEN evidence: the first package run exposed duplicate blocked reports and a hanging + readiness scenario; later regression coverage exposed unsafe stale-resource cleanup, restart + budgets surviving explicit stop, and a mixed driver cause being reduced to a normal failure. The + final implementations and observable, Deferred/TestClock-coordinated scenarios cover each case. +- `pnpm --dir packages/stack types:check` — passed. +- `pnpm --dir packages/stack test:unit:run -- Reconciler.unit.test.ts` — passed (4 tests). +- `pnpm --dir packages/stack test:integration:run -- reconciler.integration.test.ts observability.integration.test.ts` + — passed (104 integration tests across 11 files). +- `pnpm exec oxlint -c .oxlintrc.effect.json` over the eight Task 6 source/test files — passed with + zero warnings. +- `pnpm exec oxfmt --check` over the eight Task 6 source/test files and `git diff --check` — passed. diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts new file mode 100644 index 0000000000..471143e8b1 --- /dev/null +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -0,0 +1,67 @@ +import { Data, Effect } from "effect"; +import type { StackId } from "../public/StackId.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; + +/** The exact identity used when touching a private runtime resource. */ +export interface RuntimeWorkloadKey { + readonly stackId: StackId; + readonly desiredGeneration: number; + readonly workloadId: string; + readonly specHash: string; +} + +/** A driver only reports states that can be acted on by the reconciler. */ +export type ObservedWorkloadState = "absent" | "starting" | "ready" | "stopped" | "failed"; + +export interface ObservedWorkload extends RuntimeWorkloadKey { + readonly state: ObservedWorkloadState; + readonly error?: string; +} + +export type RuntimeWorkload = PlannedWorkload; + +export interface RuntimeDriver { + /** Enumerates only private resources owned by this exact stack identity. */ + readonly observe: ( + stackId: StackId, + ) => Effect.Effect, RuntimeDriverError>; + /** Starts one exact resource and returns after its readiness probe succeeds. */ + readonly start: ( + key: RuntimeWorkloadKey, + workload: RuntimeWorkload, + ) => Effect.Effect; + /** Stops one exact resource; no other generation or stack may be touched. */ + readonly stop: (key: RuntimeWorkloadKey) => Effect.Effect; + /** Removes one exact resource; no other generation or stack may be touched. */ + readonly remove: (key: RuntimeWorkloadKey) => Effect.Effect; +} + +export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ + readonly message: string; + readonly stackId?: StackId; + readonly workloadId?: string; +}> {} + +export class RuntimeGenerationMismatchError extends Data.TaggedError( + "RuntimeGenerationMismatchError", +)<{ + readonly message: string; + readonly expectedGeneration: number; + readonly actualGeneration: number; +}> {} + +export class RuntimeReadinessTimeoutError extends Data.TaggedError("RuntimeReadinessTimeoutError")<{ + readonly message: string; + readonly stackId: StackId; + readonly workloadId: string; + readonly desiredGeneration: number; +}> {} + +export class RuntimeRestartBudgetExceededError extends Data.TaggedError( + "RuntimeRestartBudgetExceededError", +)<{ + readonly message: string; + readonly stackId: StackId; + readonly workloadId: string; + readonly attempts: number; +}> {} diff --git a/packages/stack/src/supervisor/DesiredState.ts b/packages/stack/src/supervisor/DesiredState.ts new file mode 100644 index 0000000000..2aa7a78b89 --- /dev/null +++ b/packages/stack/src/supervisor/DesiredState.ts @@ -0,0 +1,166 @@ +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { StackId } from "../public/StackId.ts"; +import type { ObservedWorkload, RuntimeWorkloadKey } from "../runtime/RuntimeDriver.ts"; + +export type DesiredLifecycle = "unconfigured" | "stopped" | "running" | "destroying"; + +export interface ReconciliationInput { + readonly stackId: StackId; + readonly desiredGeneration: number; + readonly desiredLifecycle: DesiredLifecycle; + readonly plan: ExecutionPlan; + readonly observed: ReadonlyArray; +} + +export interface DesiredStart { + readonly kind: "start"; + readonly key: RuntimeWorkloadKey; + readonly workload: PlannedWorkload; +} + +export interface DesiredStop { + readonly kind: "stop"; + readonly key: RuntimeWorkloadKey; +} + +export interface DesiredRemove { + readonly kind: "remove"; + readonly key: RuntimeWorkloadKey; +} + +export interface BlockedWorkload { + readonly workloadId: string; + readonly dependencyId: string; +} + +export interface DesiredStatePlan { + readonly starts: ReadonlyArray; + readonly stops: ReadonlyArray; + readonly removes: ReadonlyArray; + readonly blocked: ReadonlyArray; +} + +const keyFor = ( + stackId: StackId, + desiredGeneration: number, + workloadId: string, + specHash: string, +): RuntimeWorkloadKey => ({ stackId, desiredGeneration, workloadId, specHash }); + +const isCurrent = ( + observed: ObservedWorkload | undefined, + generation: number, + specHash: string, +): observed is ObservedWorkload => + observed !== undefined && + observed.desiredGeneration === generation && + observed.specHash === specHash; + +/** + * Builds a deterministic desired-state delta. `ExecutionPlan.workloads` is already a stable + * topological order; reversing it gives the corresponding safe stop/remove order. + */ +export const planDesiredState = (input: ReconciliationInput): DesiredStatePlan => { + const starts: DesiredStart[] = []; + const stops: DesiredStop[] = []; + const removes: DesiredRemove[] = []; + const blocked: BlockedWorkload[] = []; + const staleByWorkload = new Map>(); + const observationsFor = (workloadId: string): ReadonlyArray => + input.observed + .filter((entry) => entry.workloadId === workloadId) + .sort( + (left, right) => + left.desiredGeneration - right.desiredGeneration || + left.specHash.localeCompare(right.specHash), + ); + const removeEntry = (entry: ObservedWorkload) => + removes.push({ + kind: "remove", + key: keyFor(input.stackId, entry.desiredGeneration, entry.workloadId, entry.specHash), + }); + + if (input.desiredLifecycle === "running") { + // Failed workloads are restart candidates. Only a failed start in this reconciliation blocks + // dependents; an observed failure gets one fresh attempt while its generation budget allows. + const failed = new Set(); + for (const workload of input.plan.workloads) { + const dependencyFailure = workload.dependencies.find((dependency) => failed.has(dependency)); + if (dependencyFailure !== undefined) { + blocked.push({ workloadId: workload.id, dependencyId: dependencyFailure }); + failed.add(workload.id); + continue; + } + const observations = observationsFor(workload.id); + const current = observations.filter((entry) => + isCurrent(entry, input.desiredGeneration, workload.specHash), + ); + // Every duplicate old-generation/spec resource is stale, even when an exact current + // resource also exists. Never collapse observations by workload id: each exact key must be + // cleaned independently. + const stale = observations.filter( + (entry) => + !isCurrent(entry, input.desiredGeneration, workload.specHash) || + entry.state === "failed" || + entry.state === "stopped", + ); + if (stale.length > 0) staleByWorkload.set(workload.id, stale); + if (current.some((entry) => entry.state === "ready" || entry.state === "starting")) continue; + const key = keyFor(input.stackId, input.desiredGeneration, workload.id, workload.specHash); + starts.push({ kind: "start", key, workload }); + } + // Stale replacements are removed child-first, preserving dependency safety when several + // generations/spec hashes for a planned graph are present at once. + for (const workload of [...input.plan.workloads].reverse()) { + for (const entry of staleByWorkload.get(workload.id) ?? []) { + if (entry.state === "ready" || entry.state === "starting") { + stops.push({ + kind: "stop", + key: keyFor(input.stackId, entry.desiredGeneration, entry.workloadId, entry.specHash), + }); + } + removeEntry(entry); + } + } + } else { + const reverse = [...input.plan.workloads].reverse(); + for (const workload of reverse) { + for (const observed of observationsFor(workload.id)) { + if (observed.state === "absent") continue; + const key = keyFor( + input.stackId, + observed.desiredGeneration, + observed.workloadId, + observed.specHash, + ); + if (observed.state !== "stopped") { + stops.push({ kind: "stop", key }); + } + removeEntry(observed); + } + } + } + + // Resources that no longer appear in the accepted plan are stale by definition. Remove them + // after the planned reverse-order resources, with a lexical tie-break for deterministic output. + const known = new Set(input.plan.workloads.map(({ id }) => id)); + const stale = input.observed + .filter((entry) => !known.has(entry.workloadId) && entry.state !== "absent") + .sort( + (left, right) => + left.workloadId.localeCompare(right.workloadId) || + left.desiredGeneration - right.desiredGeneration || + left.specHash.localeCompare(right.specHash), + ); + for (const entry of stale) { + if (entry.state === "ready" || entry.state === "starting") { + stops.push({ + kind: "stop", + key: keyFor(input.stackId, entry.desiredGeneration, entry.workloadId, entry.specHash), + }); + } + removeEntry(entry); + } + + return { starts, stops, removes, blocked }; +}; diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts new file mode 100644 index 0000000000..ce502013fb --- /dev/null +++ b/packages/stack/src/supervisor/LogStore.ts @@ -0,0 +1,422 @@ +import { + Data, + DateTime, + Effect, + FileSystem, + Path, + Predicate, + PubSub, + Schema, + Semaphore, + Stream, +} from "effect"; +import { + StackLogEntrySchema, + type LogCursor, + type LogOptions, + type StackLogEntry, + type StackLogSource, +} from "../public/Logs.ts"; +import { redactKnownSecrets } from "../state/SecretStore.ts"; + +const LOG_FORMAT = "supabase-stack-logs-v1" as const; +const CURSOR_PREFIX = "v1_"; + +/** File or decoding failure while opening or writing retained logs. */ +export class LogStoreError extends Data.TaggedError("LogStoreError")<{ + readonly path: string; + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface LogStoreOptions { + /** Explicit owner-only retained log file. */ + readonly path: string; + /** Maximum number of retained records. Defaults to 1,000. */ + readonly maxEntries?: number; + /** Maximum encoded retained document size. Defaults to 1 MiB. */ + readonly maxBytes?: number; + /** Exact values redacted before persistence and publication. */ + readonly knownSecrets?: ReadonlyArray; +} + +export interface LogRecord { + readonly timestamp?: string; + readonly source: StackLogSource; + readonly stream: StackLogEntry["stream"]; + readonly message: string; +} + +export interface LogStore { + readonly path: string; + readonly append: (record: LogRecord) => Effect.Effect; + /** Returns retained records selected by cursor and capability filter. */ + readonly read: ( + options?: LogOptions, + ) => Effect.Effect, LogStoreError>; + readonly retained: ( + options?: LogOptions, + ) => Effect.Effect, LogStoreError>; + /** + * Returns retained records followed by live records when `follow` is true. + * Subscription creation and retained snapshotting happen in one critical + * section with append, so no entry can be lost or observed twice at handoff. + */ + readonly stream: (options?: LogOptions) => Stream.Stream; +} + +interface LogDocument { + readonly format: typeof LOG_FORMAT; + readonly nextCursor: number; + readonly entries: ReadonlyArray; +} + +const LogDocumentSchema = Schema.Struct({ + format: Schema.Literal(LOG_FORMAT), + nextCursor: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + entries: Schema.Array(StackLogEntrySchema), +}); + +const emptyDocument = (): LogDocument => ({ + format: LOG_FORMAT, + nextCursor: 1, + entries: [], +}); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const hasExactKeys = ( + value: unknown, + keys: ReadonlyArray, +): value is Record => + isRecord(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)); + +const isRawLogDocument = (value: unknown): boolean => { + if (!hasExactKeys(value, ["format", "nextCursor", "entries"]) || !Array.isArray(value.entries)) + return false; + return value.entries.every( + (entry) => + hasExactKeys(entry, ["cursor", "timestamp", "source", "stream", "message"]) && + hasExactKeys(entry.cursor, ["opaque"]), + ); +}; + +const fileError = (path: string, message: string, cause?: unknown) => + new LogStoreError({ path, message, ...(cause === undefined ? {} : { cause }) }); + +const opaqueCursor = (value: number): LogCursor => ({ + opaque: `${CURSOR_PREFIX}${value.toString(36)}`, +}); + +const parseOpaqueCursor = (path: string, opaque: string): Effect.Effect => { + if (!/^v1_[0-9a-z]+$/.test(opaque)) return Effect.fail(fileError(path, "Log cursor is invalid")); + const value = Number.parseInt(opaque.slice(CURSOR_PREFIX.length), 36); + return Number.isSafeInteger(value) && value > 0 + ? Effect.succeed(value) + : Effect.fail(fileError(path, "Log cursor is out of range")); +}; + +const decodeCursor = ( + path: string, + cursor: LogCursor | undefined, +): Effect.Effect => { + if (cursor === undefined) return Effect.as(Effect.void, undefined); + return parseOpaqueCursor(path, cursor.opaque); +}; + +const selected = ( + entries: ReadonlyArray, + options: LogOptions | undefined, + path: string, +): Effect.Effect, LogStoreError> => + Effect.map(decodeCursor(path, options?.cursor), (after) => { + const capabilities = + options?.capabilities === undefined ? undefined : new Set(options.capabilities); + return entries.filter((entry) => { + const value = Number.parseInt(entry.cursor.opaque.slice(CURSOR_PREFIX.length), 36); + if (after !== undefined && value <= after) return false; + if (capabilities === undefined) return true; + return ( + entry.source !== "gateway" && + entry.source !== "supervisor" && + capabilities.has(entry.source) + ); + }); + }); + +const validateDocument = ( + path: string, + document: LogDocument, +): Effect.Effect => + Effect.gen(function* () { + if (!Number.isSafeInteger(document.nextCursor) || document.nextCursor < 1) + return yield* fileError(path, "Retained log next cursor is invalid"); + let previous = 0; + for (const entry of document.entries) { + const cursor = yield* parseOpaqueCursor(path, entry.cursor.opaque); + if (!Number.isSafeInteger(cursor) || cursor <= previous) + return yield* fileError(path, "Retained log cursors are not strictly increasing"); + previous = cursor; + } + if (document.nextCursor <= previous) + return yield* fileError(path, "Retained log next cursor is not ahead of entries"); + return document; + }); + +const readDocument = ( + fs: FileSystem.FileSystem, + path: string, +): Effect.Effect => + fs.readFileString(path).pipe( + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( + Effect.mapError((cause) => fileError(path, "Retained log file is malformed", cause)), + Effect.filterOrFail(isRawLogDocument, () => + fileError(path, "Retained log file has unexpected fields"), + ), + Effect.flatMap((raw) => + Schema.decodeUnknownEffect(LogDocumentSchema)(raw).pipe( + Effect.mapError((cause) => fileError(path, "Retained log file is malformed", cause)), + Effect.flatMap((document) => validateDocument(path, document)), + ), + ), + ), + ), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed(emptyDocument()) + : Effect.fail(fileError(path, "Unable to read retained log file", error)), + ), + ); + +const redactDocument = ( + document: LogDocument, + knownSecrets: ReadonlyArray, +): { readonly document: LogDocument; readonly changed: boolean } => { + let changed = false; + const entries = document.entries.map((entry) => { + const message = redactKnownSecrets(entry.message, knownSecrets); + if (message !== entry.message) changed = true; + return message === entry.message ? entry : { ...entry, message }; + }); + return { document: { ...document, entries }, changed }; +}; + +const encodedSize = (document: LogDocument, path: string): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(LogDocumentSchema))(document).pipe( + Effect.map((encoded) => new TextEncoder().encode(encoded).byteLength), + Effect.mapError((cause) => fileError(path, "Unable to encode retained logs", cause)), + ); + +const bounded = ( + document: LogDocument, + maxEntries: number, + maxBytes: number, + path: string, +): Effect.Effect => + Effect.gen(function* () { + const entries = [...document.entries]; + while ( + entries.length > maxEntries || + (yield* encodedSize({ ...document, entries }, path)) > maxBytes + ) { + if (entries.length === 0) break; + entries.shift(); + } + return { ...document, entries }; + }); + +const persist = ( + fs: FileSystem.FileSystem, + path: string, + document: LogDocument, +): Effect.Effect => { + const temporary = `${path}.tmp`; + const write = Effect.gen(function* () { + const text = yield* Schema.encodeEffect(Schema.fromJsonString(LogDocumentSchema))( + document, + ).pipe(Effect.mapError((cause) => fileError(path, "Unable to encode retained logs", cause))); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(temporary, { flag: "w", mode: 0o600 }); + yield* file.writeAll(new TextEncoder().encode(text)); + yield* file.sync; + }), + ); + yield* fs.chmod(temporary, 0o600); + yield* fs.rename(temporary, path); + yield* fs.chmod(path, 0o600); + }); + return write.pipe( + Effect.ensuring( + fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + Effect.mapError((error) => + error instanceof LogStoreError + ? error + : fileError(path, "Unable to persist retained logs", error), + ), + ); +}; + +const validLimit = (value: number | undefined, fallback: number): number => + value === undefined || (Number.isFinite(value) && value >= 1) ? Math.floor(value ?? fallback) : 0; + +export const makeLogStore = ( + options: LogStoreOptions, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const maxEntries = validLimit(options.maxEntries, 1_000); + const maxBytes = validLimit(options.maxBytes, 1024 * 1024); + if (maxEntries < 1 || maxBytes < 1) + return yield* fileError(options.path, "Log retention limits must be positive"); + + yield* fs + .makeDirectory(pathService.dirname(options.path), { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError((error) => + fileError(options.path, "Unable to create log directory", error), + ), + ); + const parentDirectory = pathService.dirname(options.path); + yield* fs + .chmod(parentDirectory, 0o700) + .pipe( + Effect.mapError((error) => + fileError(options.path, "Unable to secure retained log directory", error), + ), + ); + const emptySize = yield* encodedSize(emptyDocument(), options.path); + if (maxBytes < emptySize) + return yield* fileError(options.path, "Log byte retention limit is too small"); + const knownSecrets = options.knownSecrets ?? []; + const loaded = yield* readDocument(fs, options.path); + const redactedLoaded = redactDocument(loaded, knownSecrets); + const boundedLoaded = yield* bounded( + redactedLoaded.document, + maxEntries, + maxBytes, + options.path, + ); + let document = boundedLoaded; + const exists = yield* fs + .exists(options.path) + .pipe( + Effect.mapError((error) => + fileError(options.path, "Unable to inspect retained logs", error), + ), + ); + if (exists) + yield* fs + .chmod(options.path, 0o600) + .pipe( + Effect.mapError((error) => + fileError(options.path, "Unable to secure retained logs", error), + ), + ); + if (redactedLoaded.changed || boundedLoaded.entries.length !== loaded.entries.length) + yield* persist(fs, options.path, boundedLoaded); + const semaphore = yield* Semaphore.make(1); + const pubsub = yield* PubSub.unbounded(); + + const append = (record: LogRecord) => + semaphore.withPermit( + Effect.gen(function* () { + const cursor = opaqueCursor(document.nextCursor); + const entry = { + cursor, + timestamp: record.timestamp ?? DateTime.formatIso(DateTime.nowUnsafe()), + source: record.source, + stream: record.stream, + message: redactKnownSecrets(record.message, knownSecrets), + } satisfies StackLogEntry; + document = yield* bounded( + { + format: LOG_FORMAT, + nextCursor: document.nextCursor + 1, + entries: [...document.entries, entry], + }, + maxEntries, + maxBytes, + options.path, + ); + yield* persist(fs, options.path, document); + yield* PubSub.publish(pubsub, entry); + return entry; + }), + ); + + const read = (readOptions?: LogOptions) => + semaphore.withPermit(selected(document.entries, readOptions, options.path)); + + const stream = (streamOptions?: LogOptions): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const handoff = yield* semaphore.withPermit( + Effect.gen(function* () { + const retainedEntries = yield* selected( + document.entries, + streamOptions, + options.path, + ); + const requestedCursor = yield* decodeCursor(options.path, streamOptions?.cursor); + const lastRetainedCursor = document.entries.at(-1)?.cursor.opaque; + const retainedCursor = + lastRetainedCursor === undefined + ? undefined + : yield* parseOpaqueCursor(options.path, lastRetainedCursor); + const handoffCursor = + retainedCursor === undefined + ? requestedCursor + : requestedCursor === undefined + ? retainedCursor + : Math.max(retainedCursor, requestedCursor); + if (streamOptions?.follow !== true) + return { retainedEntries, handoffCursor, subscription: undefined } as const; + const subscription = yield* PubSub.subscribe(pubsub); + return { retainedEntries, handoffCursor, subscription } as const; + }), + ); + const retainedStream = Stream.fromIterable(handoff.retainedEntries); + if (handoff.subscription === undefined) return retainedStream; + const capabilities = + streamOptions?.capabilities === undefined + ? undefined + : new Set(streamOptions.capabilities); + const liveStream = Stream.fromEffectRepeat(PubSub.take(handoff.subscription)).pipe( + Stream.filter((entry) => { + if ( + handoff.handoffCursor !== undefined && + Number.parseInt(entry.cursor.opaque.slice(CURSOR_PREFIX.length), 36) <= + handoff.handoffCursor + ) + return false; + if (capabilities === undefined) return true; + return ( + entry.source !== "gateway" && + entry.source !== "supervisor" && + capabilities.has(entry.source) + ); + }), + ); + return Stream.concat(retainedStream, liveStream); + }), + ).pipe(Stream.scoped); + + return { + path: options.path, + append, + read, + retained: read, + stream, + }; + }); + +export { LOG_FORMAT as LogStoreFormat }; diff --git a/packages/stack/src/supervisor/Reconciler.ts b/packages/stack/src/supervisor/Reconciler.ts new file mode 100644 index 0000000000..121bfe4737 --- /dev/null +++ b/packages/stack/src/supervisor/Reconciler.ts @@ -0,0 +1,293 @@ +import { Cause, Duration, Effect, Exit, Option, Schedule, Semaphore } from "effect"; +import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; +import type { StackId } from "../public/StackId.ts"; +import { + planDesiredState, + type DesiredLifecycle, + type ReconciliationInput, +} from "./DesiredState.ts"; +import { + RuntimeDriverError, + RuntimeGenerationMismatchError, + RuntimeReadinessTimeoutError, + RuntimeRestartBudgetExceededError, + type ObservedWorkload, + type RuntimeDriver, +} from "../runtime/RuntimeDriver.ts"; + +export interface ReconcilerRequest { + readonly stackId: StackId; + readonly desiredGeneration: number; + readonly desiredLifecycle: DesiredLifecycle; + readonly plan: ExecutionPlan; +} + +export interface ReconcilerOptions { + readonly driver: RuntimeDriver; + /** Reads durable state and lets every mutation fence itself to the accepted generation. */ + readonly readGeneration: (stackId: StackId) => Effect.Effect; + readonly readinessTimeout?: Duration.Input; +} + +export interface ReconciliationResult { + readonly generation: number; + readonly observed: ReadonlyArray; + readonly started: ReadonlyArray; + readonly stopped: ReadonlyArray; + readonly removed: ReadonlyArray; + readonly failed: ReadonlyArray<{ + readonly workloadId: string; + readonly error: + | RuntimeDriverError + | RuntimeReadinessTimeoutError + | RuntimeRestartBudgetExceededError; + }>; + readonly blocked: ReadonlyArray<{ readonly workloadId: string; readonly dependencyId: string }>; +} + +export interface Reconciler { + readonly reconcile: ( + request: ReconcilerRequest, + ) => Effect.Effect; +} + +export type ReconcilerError = + | RuntimeDriverError + | RuntimeGenerationMismatchError + | RuntimeReadinessTimeoutError + | RuntimeRestartBudgetExceededError; + +type AttemptFailure = + | RuntimeDriverError + | RuntimeGenerationMismatchError + | RuntimeReadinessTimeoutError; + +/** Internal retry sentinel that keeps the complete failed attempt Cause intact. */ +class RuntimeAttemptControlError extends RuntimeDriverError { + readonly originalCause: Cause.Cause; + readonly retryable: boolean; + + constructor(cause: Cause.Cause, retryable: boolean) { + super({ message: "runtime start attempt failed" }); + this.originalCause = cause; + this.retryable = retryable; + } +} + +/** + * Creates a Supervisor-local reconciler. The semaphore serializes every lifecycle mutation. The + * Supervisor is responsible for forking this effect in its owner Scope; callers waiting on an RPC + * must not become lifecycle owners or cancel the owner-managed operation. + */ +export const makeReconciler = (options: ReconcilerOptions): Effect.Effect => + Effect.gen(function* () { + const lifecycle = yield* Semaphore.make(1); + const readinessTimeout = options.readinessTimeout ?? "30 seconds"; + // Restart budgets are Supervisor-local. A new accepted generation gets a fresh key; repeated + // observations of the same failed generation cannot silently reset an exhausted budget. + const exhausted = new Map(); + + const reconcile = ( + request: ReconcilerRequest, + ): Effect.Effect => + lifecycle.withPermit( + Effect.gen(function* () { + const readGeneration = options.readGeneration; + const generationFence = (expected: number) => + readGeneration(request.stackId).pipe( + Effect.flatMap((actual) => + actual === expected + ? Effect.void + : Effect.fail( + new RuntimeGenerationMismatchError({ + message: `Desired generation changed from ${expected} to ${actual}`, + expectedGeneration: expected, + actualGeneration: actual, + }), + ), + ), + ); + + yield* generationFence(request.desiredGeneration); + if (request.desiredLifecycle !== "running") { + const prefix = `${request.stackId}:`; + for (const key of exhausted.keys()) if (key.startsWith(prefix)) exhausted.delete(key); + } + const observed = yield* options.driver.observe(request.stackId); + yield* generationFence(request.desiredGeneration); + const desired: ReconciliationInput = { ...request, observed }; + const delta = planDesiredState(desired); + const failed: Array = []; + const started: string[] = []; + const stopped: string[] = []; + const removed: string[] = []; + const failedIds = new Set(); + const blocked = [...delta.blocked]; + const addBlocked = (workloadId: string, dependencyId: string) => { + if (!blocked.some((entry) => entry.workloadId === workloadId)) + blocked.push({ workloadId, dependencyId }); + }; + + const runMutation = ( + effect: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + yield* generationFence(request.desiredGeneration); + const result = yield* effect.pipe(Effect.exit); + yield* generationFence(request.desiredGeneration); + if (Exit.isSuccess(result)) return result.value; + return yield* Effect.failCause(result.cause); + }); + + const quiesceDependents = () => + Effect.gen(function* () { + const current = yield* options.driver.observe(request.stackId); + yield* generationFence(request.desiredGeneration); + const affected: Array<{ + readonly workload: (typeof request.plan.workloads)[number]; + readonly dependencyId: string; + }> = []; + // First compute the complete transitive closure in topological order. A reverse pass + // alone would visit leaf before its parent had been marked failed. + for (const workload of request.plan.workloads) { + const dependencyFailure = workload.dependencies.find((dependency) => + failedIds.has(dependency), + ); + if (dependencyFailure === undefined) continue; + failedIds.add(workload.id); + addBlocked(workload.id, dependencyFailure); + affected.push({ workload, dependencyId: dependencyFailure }); + } + // Stop and remove affected resources in reverse topological order. + for (const { workload } of affected.reverse()) { + const observed = current.find(({ workloadId }) => workloadId === workload.id); + if ( + observed === undefined || + observed.desiredGeneration !== request.desiredGeneration || + (observed.state !== "ready" && observed.state !== "starting") + ) + continue; + const key = { + stackId: request.stackId, + desiredGeneration: observed.desiredGeneration, + workloadId: observed.workloadId, + specHash: observed.specHash, + }; + yield* runMutation(options.driver.stop(key)); + stopped.push(key.workloadId); + yield* runMutation(options.driver.remove(key)); + removed.push(key.workloadId); + } + }); + + for (const action of delta.stops) { + yield* runMutation(options.driver.stop(action.key)); + stopped.push(action.key.workloadId); + } + for (const action of delta.removes) { + yield* runMutation(options.driver.remove(action.key)); + removed.push(action.key.workloadId); + } + + for (const action of delta.starts) { + const dependencyFailure = action.workload.dependencies.find((dependency) => + failedIds.has(dependency), + ); + if (dependencyFailure !== undefined) { + failedIds.add(action.workload.id); + addBlocked(action.workload.id, dependencyFailure); + continue; + } + const maxAttempts = Math.max(1, action.workload.restart.maxAttempts); + const budgetKey = `${request.stackId}:${request.desiredGeneration}:${action.workload.id}`; + const previousAttempts = exhausted.get(budgetKey); + if (previousAttempts !== undefined) { + failedIds.add(action.workload.id); + failed.push({ + workloadId: action.workload.id, + error: new RuntimeRestartBudgetExceededError({ + message: `Restart budget exhausted for ${action.workload.id}`, + stackId: request.stackId, + workloadId: action.workload.id, + attempts: previousAttempts, + }), + }); + yield* quiesceDependents(); + continue; + } + const attempt = options.driver.start(action.key, action.workload).pipe( + Effect.timeoutOption(readinessTimeout), + Effect.flatMap((result) => + Option.isSome(result) + ? Effect.succeed(result.value) + : Effect.fail( + new RuntimeReadinessTimeoutError({ + message: `Readiness deadline exceeded for ${action.workload.id}`, + stackId: request.stackId, + workloadId: action.workload.id, + desiredGeneration: request.desiredGeneration, + }), + ), + ), + ); + const schedule: Schedule.Schedule = + Schedule.exponential(`${Math.max(0, action.workload.restart.backoffMs)} millis`).pipe( + Schedule.upTo({ times: Math.max(0, maxAttempts - 1) }), + ); + const attempted: Effect.Effect = + runMutation(attempt).pipe( + Effect.catchCause((cause: Cause.Cause) => { + const reason = cause.reasons.length === 1 ? cause.reasons[0] : undefined; + const failure = + reason !== undefined && Cause.isFailReason(reason) ? reason.error : undefined; + const retryable = + failure !== undefined && !(failure instanceof RuntimeGenerationMismatchError); + return Effect.fail(new RuntimeAttemptControlError(cause, retryable)); + }), + ); + const retryOptions: Effect.Retry.Options = { + schedule, + while: (error) => error.retryable, + }; + const outcome: Exit.Exit = + yield* Effect.exit(Effect.retry(attempted, retryOptions)); + if (Exit.isSuccess(outcome)) { + started.push(action.workload.id); + continue; + } + // A mixed Cause is not a retryable typed failure: preserving the complete Cause keeps + // defects and interruption visible to the Supervisor instead of turning them into a + // misleading budget-exhaustion status. + const maybeError = Cause.findErrorOption(outcome.cause); + if (Option.isNone(maybeError)) return yield* Effect.failCause(outcome.cause); + const error = maybeError.value; + if (Cause.hasDies(error.originalCause) || Cause.hasInterrupts(error.originalCause)) + return yield* Effect.failCause(error.originalCause); + const original = Cause.findErrorOption(error.originalCause); + if (Option.isNone(original)) return yield* Effect.failCause(error.originalCause); + if (original.value instanceof RuntimeGenerationMismatchError) + return yield* original.value; + failedIds.add(action.workload.id); + exhausted.set(budgetKey, maxAttempts); + failed.push({ workloadId: action.workload.id, error: original.value }); + yield* quiesceDependents(); + } + + const current = yield* options.driver.observe(request.stackId); + yield* generationFence(request.desiredGeneration); + return { + generation: request.desiredGeneration, + observed: current, + started, + stopped, + removed, + failed, + blocked, + }; + }), + ); + + return { reconcile }; + }); + +export { planDesiredState }; diff --git a/packages/stack/src/supervisor/Reconciler.unit.test.ts b/packages/stack/src/supervisor/Reconciler.unit.test.ts new file mode 100644 index 0000000000..20f638ecbd --- /dev/null +++ b/packages/stack/src/supervisor/Reconciler.unit.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import { planDesiredState } from "./DesiredState.ts"; +import type { ObservedWorkload } from "../runtime/RuntimeDriver.ts"; + +const stackId = StackIdSchema.make("a".repeat(64)); + +const workload = ( + id: string, + dependencies: ReadonlyArray = [], + specHash = id, +): PlannedWorkload => ({ + id, + capability: "database", + dependencies, + readiness: { mode: "tcp" }, + restart: { maxAttempts: 2, backoffMs: 0 }, + artifacts: { + native: { kind: "native", service: id, release: "test" }, + container: { kind: "container", service: id, image: `test/${id}` }, + }, + selected: { kind: "native", service: id, release: "test" }, + specHash, +}); + +const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ + runtime: { kind: "native" }, + startOrder: ["database"], + stopOrder: ["database"], + dependencies: { + database: [], + rest: [], + auth: [], + realtime: [], + storage: [], + functions: [], + studio: [], + mail: [], + analytics: [], + pooler: [], + }, + routes: [], + workloads, +}); + +const observed = ( + workloadId: string, + state: ObservedWorkload["state"], + desiredGeneration = 1, + specHash = workloadId, +): ObservedWorkload => ({ + stackId, + desiredGeneration, + workloadId, + specHash, + state, +}); + +describe("desired workload planning", () => { + it("starts in topological order and stops/removes in reverse order", () => { + const workloads = [workload("a"), workload("b", ["a"]), workload("c", ["b"])]; + const plan = planFor(workloads); + const running = planDesiredState({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "running", + plan, + observed: [], + }); + expect(running.starts.map(({ key }) => key.workloadId)).toEqual(["a", "b", "c"]); + + const stopped = planDesiredState({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "stopped", + plan, + observed: workloads.map(({ id }) => observed(id, "ready")), + }); + expect(stopped.stops.map(({ key }) => key.workloadId)).toEqual(["c", "b", "a"]); + expect(stopped.removes.map(({ key }) => key.workloadId)).toEqual(["c", "b", "a"]); + + const destroying = planDesiredState({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "destroying", + plan, + observed: workloads.map(({ id }) => observed(id, "ready")), + }); + expect(destroying.stops.map(({ key }) => key.workloadId)).toEqual(["c", "b", "a"]); + }); + + it("replaces a stale generation or workload spec before starting the accepted key", () => { + const plan = planFor([workload("a", [], "new-hash")]); + const delta = planDesiredState({ + stackId, + desiredGeneration: 2, + desiredLifecycle: "running", + plan, + observed: [observed("a", "ready", 1, "old-hash")], + }); + expect(delta.stops.map(({ key }) => key)).toEqual([ + { stackId, desiredGeneration: 1, workloadId: "a", specHash: "old-hash" }, + ]); + expect(delta.removes.map(({ key }) => key)).toEqual([ + { stackId, desiredGeneration: 1, workloadId: "a", specHash: "old-hash" }, + ]); + expect(delta.starts.map(({ key }) => key)).toEqual([ + { stackId, desiredGeneration: 2, workloadId: "a", specHash: "new-hash" }, + ]); + }); + + it("cleans every duplicate exact resource child-first", () => { + const plan = planFor([workload("a"), workload("b", ["a"])]); + const delta = planDesiredState({ + stackId, + desiredGeneration: 2, + desiredLifecycle: "running", + plan, + observed: [ + observed("a", "ready", 1, "old-a"), + observed("a", "ready", 2, "a"), + observed("b", "ready", 1, "old-b"), + observed("b", "ready", 2, "b"), + ], + }); + expect(delta.starts).toHaveLength(0); + expect(delta.stops.map(({ key }) => key.workloadId)).toEqual(["b", "a"]); + expect(delta.removes.map(({ key }) => key.workloadId)).toEqual(["b", "a"]); + expect(delta.removes.map(({ key }) => key.specHash)).toEqual(["old-b", "old-a"]); + }); + + it("blocks only dependents of failed workloads", () => { + const plan = planFor([ + workload("root-failed"), + workload("dependent", ["root-failed"]), + workload("independent"), + ]); + const delta = planDesiredState({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "running", + plan, + observed: [observed("root-failed", "failed")], + }); + expect(delta.starts.map(({ key }) => key.workloadId)).toEqual([ + "root-failed", + "dependent", + "independent", + ]); + expect(delta.blocked).toEqual([]); + }); +}); diff --git a/packages/stack/src/supervisor/StatusHub.ts b/packages/stack/src/supervisor/StatusHub.ts new file mode 100644 index 0000000000..c7b1663f0e --- /dev/null +++ b/packages/stack/src/supervisor/StatusHub.ts @@ -0,0 +1,22 @@ +import { Effect, Stream, SubscriptionRef } from "effect"; +import type { StackStatus } from "../public/Status.ts"; + +/** + * In-memory complete status projection shared by a supervisor and its clients. + * + * `changes` starts each subscriber with the current snapshot and then emits + * every complete replacement. SubscriptionRef provides the serialization and + * broadcast semantics; no per-subscriber waiter bookkeeping is required. + */ +export interface StatusHub { + readonly current: Effect.Effect; + readonly publish: (status: StackStatus) => Effect.Effect; + readonly changes: Stream.Stream; +} + +export const makeStatusHub = (initial: StackStatus): Effect.Effect => + Effect.map(SubscriptionRef.make(initial), (ref) => ({ + current: SubscriptionRef.get(ref), + publish: (status: StackStatus) => SubscriptionRef.set(ref, status), + changes: SubscriptionRef.changes(ref), + })); diff --git a/packages/stack/src/supervisor/observability.integration.test.ts b/packages/stack/src/supervisor/observability.integration.test.ts new file mode 100644 index 0000000000..8cba7b4f3c --- /dev/null +++ b/packages/stack/src/supervisor/observability.integration.test.ts @@ -0,0 +1,186 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Path, Stream } from "effect"; +import { CAPABILITY_NAMES } from "../public/Capability.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import type { StackStatus } from "../public/Status.ts"; +import { LogStoreError, makeLogStore } from "./LogStore.ts"; +import { makeStatusHub } from "./StatusHub.ts"; + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const status = (generation: number): StackStatus => ({ + id: StackIdSchema.make("a".repeat(64)), + lifecycle: generation === 0 ? "stopped" : "running", + desiredLifecycle: generation === 0 ? "stopped" : "running", + runtime: { kind: "native" }, + desiredGeneration: generation, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ name, activation: "eager", state: "stopped" })), +}); + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +describe("observability", () => { + it.live("starts status watches with the current complete snapshot", () => + withPlatform( + Effect.gen(function* () { + const hub = yield* makeStatusHub(status(0)); + const ready = yield* Deferred.make(); + const values = yield* hub.changes.pipe( + Stream.tap(() => Deferred.succeed(ready, void 0)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(ready); + yield* hub.publish(status(1)); + expect(yield* Fiber.join(values)).toEqual([status(0), status(1)]); + }), + ), + ); + + it.live("retains bounded redacted records and resumes from an opaque cursor", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-logs-" }); + const store = yield* makeLogStore({ + path: path.join(root, "logs.json"), + maxEntries: 2, + knownSecrets: ["top-secret"], + }); + const first = yield* store.append({ + source: "database", + stream: "stdout", + message: "top-secret one", + }); + const second = yield* store.append({ source: "auth", stream: "stderr", message: "two" }); + const third = yield* store.append({ + source: "gateway", + stream: "internal", + message: "three", + }); + const retained = yield* store.read(); + expect(retained.map((entry) => entry.message)).toEqual(["two", "three"]); + expect((yield* store.read({ cursor: first.cursor })).map((entry) => entry.cursor)).toEqual([ + second.cursor, + third.cursor, + ]); + expect( + (yield* store.read({ capabilities: ["auth"] })).map((entry) => entry.message), + ).toEqual(["two"]); + expect((yield* Stream.runCollect(store.stream())).map((entry) => entry.message)).toEqual([ + "two", + "three", + ]); + const invalidCursor = yield* store + .read({ cursor: { opaque: "not-a-cursor" } }) + .pipe(Effect.exit); + expect(errorOf(invalidCursor)).toBeInstanceOf(LogStoreError); + expect(yield* fs.readFileString(path.join(root, "logs.json"))).not.toContain("top-secret"); + expect((yield* fs.stat(path.join(root, "logs.json"))).mode & 0o077).toBe(0); + }), + ), + ); + + it.live("bounds retained records by encoded size", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-log-size-" }); + const store = yield* makeLogStore({ path: path.join(root, "logs.json"), maxBytes: 300 }); + yield* store.append({ source: "database", stream: "stdout", message: "x".repeat(200) }); + expect(yield* store.read()).toEqual([]); + expect((yield* fs.stat(path.join(root, "logs.json"))).size).toBeLessThanOrEqual(300n); + }), + ), + ); + + it.live("secures a pre-existing log directory and rejects unsafe limits", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-log-permissions-", + }); + const directory = path.join(root, "logs"); + yield* fs.makeDirectory(directory, { recursive: true, mode: 0o755 }); + yield* fs.chmod(directory, 0o755); + const logPath = path.join(directory, "logs.json"); + yield* makeLogStore({ path: logPath }); + expect((yield* fs.stat(directory)).mode & 0o077).toBe(0); + + for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + const invalid = yield* makeLogStore({ + path: path.join(root, `invalid-${String(value)}.json`), + maxEntries: value, + }).pipe(Effect.exit); + expect(errorOf(invalid)).toBeInstanceOf(LogStoreError); + } + const tooSmall = yield* makeLogStore({ + path: path.join(root, "too-small.json"), + maxBytes: 1, + }).pipe(Effect.exit); + expect(errorOf(tooSmall)).toBeInstanceOf(LogStoreError); + }), + ), + ); + + it.live("reloads retained logs and rejects malformed files", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-log-reload-" }); + const logPath = path.join(root, "logs.json"); + const first = yield* makeLogStore({ path: logPath }); + yield* first.append({ source: "supervisor", stream: "internal", message: "old-secret" }); + const second = yield* makeLogStore({ path: logPath, knownSecrets: ["old-secret"] }); + expect((yield* second.read()).map((entry) => entry.message)).toEqual(["[REDACTED]"]); + expect(yield* fs.readFileString(logPath)).not.toContain("old-secret"); + yield* fs.writeFileString(logPath, "not json"); + const malformed = yield* makeLogStore({ path: logPath }).pipe(Effect.exit); + expect(errorOf(malformed)).toBeInstanceOf(LogStoreError); + }), + ), + ); + + it.live("subscribes before append so retained-to-live handoff has no gap", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-log-handoff-" }); + const store = yield* makeLogStore({ + path: path.join(root, "logs.json"), + knownSecrets: ["live-secret"], + }); + yield* store.append({ source: "database", stream: "stdout", message: "retained" }); + const subscribed = yield* Deferred.make(); + const values = yield* store.stream({ follow: true }).pipe( + Stream.tap(() => Deferred.succeed(subscribed, void 0)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(subscribed); + yield* store.append({ + source: "database", + stream: "stdout", + message: "live live-secret", + }); + expect((yield* Fiber.join(values)).map((entry) => entry.message)).toEqual([ + "retained", + "live [REDACTED]", + ]); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/reconciler.integration.test.ts b/packages/stack/src/supervisor/reconciler.integration.test.ts new file mode 100644 index 0000000000..2bb20e09c7 --- /dev/null +++ b/packages/stack/src/supervisor/reconciler.integration.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { + RuntimeDriverError, + RuntimeGenerationMismatchError, + RuntimeReadinessTimeoutError, +} from "../runtime/RuntimeDriver.ts"; +import type { RuntimeDriver, ObservedWorkload } from "../runtime/RuntimeDriver.ts"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import { makeReconciler } from "./Reconciler.ts"; + +const stackId = StackIdSchema.make("b".repeat(64)); +const workload = ( + id: string, + dependencies: ReadonlyArray = [], + restart = { maxAttempts: 1, backoffMs: 0 }, +): PlannedWorkload => ({ + id, + capability: "database", + dependencies, + readiness: { mode: "tcp" }, + restart, + artifacts: { + native: { kind: "native", service: id, release: "test" }, + container: { kind: "container", service: id, image: `test/${id}` }, + }, + selected: { kind: "native", service: id, release: "test" }, + specHash: id, +}); +const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ + runtime: { kind: "native" }, + startOrder: ["database"], + stopOrder: ["database"], + dependencies: { + database: [], + rest: [], + auth: [], + realtime: [], + storage: [], + functions: [], + studio: [], + mail: [], + analytics: [], + pooler: [], + }, + routes: [], + workloads, +}); + +const fakeDriver = (options: { + readonly fail?: ReadonlySet; + readonly never?: ReadonlySet; +}) => + Effect.gen(function* () { + const resources = yield* Ref.make>([]); + const starts = yield* Ref.make>([]); + const driver: RuntimeDriver = { + observe: () => Ref.get(resources), + start: (key, entry) => + Effect.gen(function* () { + yield* Ref.update(starts, (current) => [...current, entry.id]); + if (options.never?.has(entry.id)) return yield* Effect.never; + if (options.fail?.has(entry.id)) + return yield* new RuntimeDriverError({ + message: `failed ${entry.id}`, + stackId: key.stackId, + workloadId: entry.id, + }); + const ready: ObservedWorkload = { ...key, state: "ready" }; + yield* Ref.update(resources, (current) => [ + ...current.filter(({ workloadId }) => workloadId !== key.workloadId), + ready, + ]); + return ready; + }), + stop: (key) => + Ref.update(resources, (current) => + current.map((entry) => + entry.workloadId === key.workloadId ? { ...entry, state: "stopped" } : entry, + ), + ), + remove: (key) => + Ref.update(resources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ), + }; + return { driver, resources, starts }; + }); + +const failureOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +describe("stack reconciler", () => { + it.live("keeps independent branches running and blocks failed dependents", () => + Effect.gen(function* () { + const { driver, starts } = yield* fakeDriver({ fail: new Set(["root"]) }); + const reconciler = yield* makeReconciler({ driver, readGeneration: () => Effect.succeed(1) }); + const result = yield* reconciler.reconcile({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "running", + plan: planFor([workload("root"), workload("dependent", ["root"]), workload("independent")]), + }); + expect(yield* Ref.get(starts)).toEqual(["root", "independent"]); + expect(result.failed.map(({ workloadId }) => workloadId)).toEqual(["root"]); + expect(result.blocked).toEqual([{ workloadId: "dependent", dependencyId: "root" }]); + }), + ); + + it.live("quiesces an already-ready dependent when its dependency exhausts", () => + Effect.gen(function* () { + const { driver, resources } = yield* fakeDriver({ fail: new Set(["root"]) }); + yield* Ref.set(resources, [ + { stackId, desiredGeneration: 1, workloadId: "root", specHash: "root", state: "failed" }, + { + stackId, + desiredGeneration: 1, + workloadId: "dependent", + specHash: "dependent", + state: "ready", + }, + { + stackId, + desiredGeneration: 1, + workloadId: "leaf", + specHash: "leaf", + state: "ready", + }, + { + stackId, + desiredGeneration: 1, + workloadId: "independent", + specHash: "independent", + state: "ready", + }, + ]); + const reconciler = yield* makeReconciler({ + driver, + readGeneration: () => Effect.succeed(1), + }); + const result = yield* reconciler.reconcile({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "running", + plan: planFor([ + workload("root"), + workload("dependent", ["root"]), + workload("leaf", ["dependent"]), + workload("independent"), + ]), + }); + expect(result.stopped).toContain("dependent"); + expect(result.removed).toContain("dependent"); + expect(result.stopped).toContain("leaf"); + expect(result.removed).toContain("leaf"); + expect((yield* Ref.get(resources)).map(({ workloadId }) => workloadId)).toContain( + "independent", + ); + }), + ); + + it.effect("reports a readiness deadline and preserves the bounded restart budget", () => + Effect.gen(function* () { + const entered = [ + yield* Deferred.make(), + yield* Deferred.make(), + yield* Deferred.make(), + yield* Deferred.make(), + ] as const; + const starts = yield* Ref.make(0); + const generation = yield* Ref.make(1); + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: (_key) => + Effect.gen(function* () { + const attempt = yield* Ref.updateAndGet(starts, (count) => count + 1); + const signal = entered[attempt - 1]; + if (signal !== undefined) yield* Deferred.succeed(signal, undefined); + return yield* Effect.never; + }), + stop: () => Effect.void, + remove: () => Effect.void, + }; + const reconciler = yield* makeReconciler({ + driver, + readGeneration: () => Ref.get(generation), + readinessTimeout: "5 millis", + }); + const request = { + stackId, + desiredGeneration: 1, + desiredLifecycle: "running" as const, + plan: planFor([workload("slow", [], { maxAttempts: 2, backoffMs: 0 })]), + }; + const fiber = yield* Effect.forkChild(reconciler.reconcile(request), { + startImmediately: true, + }); + yield* Deferred.await(entered[0]); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(entered[1]); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); + expect(result.failed[0]?.error).toBeInstanceOf(RuntimeReadinessTimeoutError); + expect(yield* Ref.get(starts)).toBe(2); + + const second = yield* reconciler.reconcile({ + ...request, + }); + expect(second.failed[0]?.error).toBeDefined(); + expect(yield* Ref.get(starts)).toBe(2); + yield* Ref.set(generation, 2); + const nextFiber = yield* Effect.forkChild( + reconciler.reconcile({ + ...request, + desiredGeneration: 2, + }), + { startImmediately: true }, + ); + yield* Deferred.await(entered[2]); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(entered[3]); + yield* TestClock.adjust("1 second"); + const nextGeneration = yield* Fiber.join(nextFiber); + expect(nextGeneration.failed[0]?.error).toBeInstanceOf(RuntimeReadinessTimeoutError); + expect(yield* Ref.get(starts)).toBe(4); + }), + ); + + it.live("serializes concurrent callers through one lifecycle semaphore", () => + Effect.gen(function* () { + const active = yield* Ref.make(0); + const maxActive = yield* Ref.make(0); + const resources = yield* Ref.make>([]); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const driver: RuntimeDriver = { + observe: () => Ref.get(resources), + start: (key, _entry) => + Effect.gen(function* () { + yield* Ref.update(active, (value) => value + 1); + const current = yield* Ref.get(active); + yield* Ref.update(maxActive, (max) => Math.max(max, current)); + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + const ready: ObservedWorkload = { ...key, state: "ready" }; + yield* Ref.update(resources, (entries) => [...entries, ready]); + return ready; + }).pipe(Effect.ensuring(Ref.update(active, (value) => value - 1))), + stop: () => Effect.void, + remove: () => Effect.void, + }; + const reconciler = yield* makeReconciler({ driver, readGeneration: () => Effect.succeed(1) }); + const request = { + stackId, + desiredGeneration: 1, + desiredLifecycle: "running" as const, + plan: planFor([workload("one")]), + }; + const first = yield* Effect.forkChild(reconciler.reconcile(request), { + startImmediately: true, + }); + yield* Deferred.await(entered); + const second = yield* Effect.forkChild(reconciler.reconcile(request), { + startImmediately: true, + }); + yield* Effect.yieldNow; + expect(yield* Ref.get(active)).toBe(1); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + expect(yield* Ref.get(maxActive)).toBe(1); + }), + ); + + it.live("fences a generation change after a driver mutation without retrying", () => + Effect.gen(function* () { + const generation = yield* Ref.make(1); + const starts = yield* Ref.make(0); + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: (key, _entry) => { + const ready: ObservedWorkload = { ...key, state: "ready" }; + return Ref.update(starts, (count) => count + 1).pipe( + Effect.andThen(Ref.set(generation, 2)), + Effect.andThen(Effect.succeed(ready)), + ); + }, + stop: () => Effect.void, + remove: () => Effect.void, + }; + const reconciler = yield* makeReconciler({ + driver, + readGeneration: () => Ref.get(generation), + }); + const exit = yield* reconciler + .reconcile({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "running", + plan: planFor([workload("fenced")]), + }) + .pipe(Effect.exit); + expect(failureOf(exit)).toBeInstanceOf(RuntimeGenerationMismatchError); + expect(yield* Ref.get(starts)).toBe(1); + }), + ); + + it.live("releases lifecycle serialization when a waiting caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const blocked = yield* Ref.make(true); + const starts = yield* Ref.make(0); + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: (key) => { + const ready: ObservedWorkload = { ...key, state: "ready" }; + return Ref.update(starts, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen( + Effect.flatMap(Ref.get(blocked), (isBlocked) => + isBlocked ? Deferred.await(release) : Effect.void, + ), + ), + Effect.andThen(Effect.succeed(ready)), + ); + }, + stop: () => Effect.void, + remove: () => Effect.void, + }; + const reconciler = yield* makeReconciler({ + driver, + readGeneration: () => Effect.succeed(1), + }); + const request = { + stackId, + desiredGeneration: 1, + desiredLifecycle: "running" as const, + plan: planFor([workload("interruptible")]), + }; + const first = yield* Effect.forkChild(reconciler.reconcile(request), { + startImmediately: true, + }); + yield* Deferred.await(started); + yield* Fiber.interrupt(first); + yield* Ref.set(blocked, false); + yield* Deferred.succeed(release, undefined); + const second = yield* reconciler.reconcile(request); + expect(second.started).toEqual(["interruptible"]); + expect(yield* Ref.get(starts)).toBe(2); + }), + ); + + it.live("stops before removing workloads in reverse dependency order", () => + Effect.gen(function* () { + const trace = yield* Ref.make>([]); + const resources: ReadonlyArray = [ + { stackId, desiredGeneration: 1, workloadId: "a", specHash: "a", state: "ready" }, + { stackId, desiredGeneration: 1, workloadId: "b", specHash: "b", state: "ready" }, + ]; + const driver: RuntimeDriver = { + observe: () => Effect.succeed(resources), + start: () => Effect.die("unexpected start"), + stop: (key) => Ref.update(trace, (current) => [...current, `stop:${key.workloadId}`]), + remove: (key) => Ref.update(trace, (current) => [...current, `remove:${key.workloadId}`]), + }; + const reconciler = yield* makeReconciler({ + driver, + readGeneration: () => Effect.succeed(1), + }); + yield* reconciler.reconcile({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "stopped", + plan: planFor([workload("a"), workload("b", ["a"])]), + }); + expect(yield* Ref.get(trace)).toEqual(["stop:b", "stop:a", "remove:b", "remove:a"]); + }), + ); + + it.live("resets a generation-scoped restart budget after an explicit stop", () => + Effect.gen(function* () { + const { driver, starts } = yield* fakeDriver({ fail: new Set(["retryable"]) }); + const reconciler = yield* makeReconciler({ + driver, + readGeneration: () => Effect.succeed(1), + }); + const request = { + stackId, + desiredGeneration: 1, + desiredLifecycle: "running" as const, + plan: planFor([workload("retryable")]), + }; + const first = yield* reconciler.reconcile(request); + expect(first.failed).toHaveLength(1); + expect(yield* Ref.get(starts)).toEqual(["retryable"]); + yield* reconciler.reconcile({ ...request, desiredLifecycle: "stopped" }); + yield* reconciler.reconcile(request); + expect(yield* Ref.get(starts)).toEqual(["retryable", "retryable"]); + }), + ); + + it.live("preserves defects in a mixed driver Cause instead of reporting budget exhaustion", () => + Effect.gen(function* () { + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => + Effect.failCause( + Cause.combine( + Cause.fail( + new RuntimeDriverError({ message: "start failed", stackId, workloadId: "mixed" }), + ), + Cause.die("driver defect"), + ), + ), + stop: () => Effect.void, + remove: () => Effect.void, + }; + const reconciler = yield* makeReconciler({ + driver, + readGeneration: () => Effect.succeed(1), + }); + const exit = yield* reconciler + .reconcile({ + stackId, + desiredGeneration: 1, + desiredLifecycle: "running", + plan: planFor([workload("mixed")]), + }) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Cause.hasDies(exit.cause)).toBe(true); + }), + ); +}); From c027dc3dafa695e575e9184ca06c7122448bd39a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 01:17:28 +0200 Subject: [PATCH 014/174] feat(stack): prepare artifacts and run native workloads --- ...26-08-28-stack-runtime-rewrite-progress.md | 35 +- .../plans/2026-08-28-stack-runtime-rewrite.md | 14 +- ...2026-08-28-stack-runtime-rewrite-design.md | 14 +- packages/stack/src/model/CapabilityModule.ts | 4 + packages/stack/src/model/DatabaseBootstrap.ts | 203 ++++++ packages/stack/src/model/ExecutionPlan.ts | 2 + .../stack/src/model/capabilities/database.ts | 35 +- .../src/model/compiler.integration.test.ts | 10 + .../database-bootstrap.integration.test.ts | 142 ++++ .../stack/src/preparation/ArtifactStore.ts | 630 ++++++++++++++++++ packages/stack/src/preparation/Integrity.ts | 70 ++ .../preparation/artifacts.integration.test.ts | 477 +++++++++++++ packages/stack/src/runtime/NativeProcess.ts | 123 ++++ packages/stack/src/runtime/NativeRuntime.ts | 434 ++++++++++++ packages/stack/src/runtime/RuntimeDriver.ts | 1 + packages/stack/src/runtime/native-launcher.ts | 101 +++ .../native-runtime.integration.test.ts | 473 +++++++++++++ 17 files changed, 2729 insertions(+), 39 deletions(-) create mode 100644 packages/stack/src/model/DatabaseBootstrap.ts create mode 100644 packages/stack/src/model/database-bootstrap.integration.test.ts create mode 100644 packages/stack/src/preparation/ArtifactStore.ts create mode 100644 packages/stack/src/preparation/Integrity.ts create mode 100644 packages/stack/src/preparation/artifacts.integration.test.ts create mode 100644 packages/stack/src/runtime/NativeProcess.ts create mode 100644 packages/stack/src/runtime/NativeRuntime.ts create mode 100644 packages/stack/src/runtime/native-launcher.ts create mode 100644 packages/stack/src/runtime/native-runtime.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 0264ff4de4..fdc408176e 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -185,7 +185,7 @@ Implementation continues after recording a ruling; this file is not a question q `functions_root`, dependency closure validation, deterministic start/stop order, semantic workload hashes, and runtime-selected native/container logical artifacts. Database versions use an explicit supported Supabase release map and fail before output when unknown. -- Private workload companions are represented for database bootstrap, storage imgproxy, studio +- Private workload companions are represented for storage imgproxy, studio pgmeta, analytics vector, and managed Edge Runtime Functions. Functions remain one capability; all serving is through that managed Edge Runtime. - Added 19 integration scenarios covering each capability, defaults, unknown fields, stable record @@ -362,3 +362,36 @@ Implementation continues after recording a ruling; this file is not a question q - `pnpm exec oxlint -c .oxlintrc.effect.json` over the eight Task 6 source/test files — passed with zero warnings. - `pnpm exec oxfmt --check` over the eight Task 6 source/test files and `git diff --check` — passed. + +### Task 7 — 2026-08-29 + +- Added an owner-only verified artifact store with canonical path containment, SHA-256 validation, + required-runtime-path checks, executable repair, exact temporary cleanup, same-directory atomic + publication, strict cache revalidation, and store-scoped single-flight ownership that survives an + individual caller's interruption. +- Added the native process boundary and runtime driver. Workload commands and environments travel in + a private inherited descriptor instead of argv; the parent-loss descriptor terminates the exact + launcher process tree. Start is shared, readiness and log persistence are typed startup gates, + stop/remove target the exact generation/workload/spec identity, and a stopped identity can start a + fresh process without retaining its completed `Deferred` or closed scope. +- Database bootstrap is a post-probe readiness phase on the real database workload, never a private + companion workload. The generic runner validates an ordered release plan, creates its tracking + schema under a transaction-scoped advisory lock, applies and records one revision atomically, and + reconciles `Redacted` role credentials outside SQL text. Concrete per-release revision catalogs + remain Task 14 work; gateway listener ownership/transfer remains Task 8 work with the gateway + contract. +- Added integration coverage for verified cache/download behavior, corruption and containment, + concurrent publisher convergence, caller/store-scope cancellation, native readiness/logging/ + restart/identity ownership, owner-loss process-tree termination, and ordered/concurrent/retried + bootstrap behavior without secret disclosure. +- Ruling: the two-store publication test intentionally uses separate `ArtifactStore` instances in one + process. Those instances share no in-memory single-flight state and therefore exercise the atomic + filesystem rename/revalidation race that also coordinates separate processes. A child-process-only + duplicate of that scenario would add subprocess scaffolding without testing another invariant. +- `pnpm --dir packages/stack types:check` — passed. +- Focused Task 7/compiler integration suite — passed (57 tests across 4 files). +- `pnpm --dir packages/stack test` — passed (4 unit and 132 integration tests). +- Effect lint over all Task 7 source/test files — passed with zero warnings. +- Formatting over all Task 7 source/test files and `git diff --check` — passed. +- Independent re-review found no Critical or Important findings; all earlier lifecycle, bootstrap, + credential, logging, cleanup, and cancellation findings are closed. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index aef79ff4a6..25a78e2087 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -416,22 +416,26 @@ artifact keys, state schemas, drivers, or control protocols. - `prepare` verifies and atomically publishes selected native/container artifacts without state, ports, network, secrets, or workload mutation. -- `NativeRuntime` implements `RuntimeDriver` with exact process-tree ownership and transferred gateway - listeners. Bootstrap is idempotent and records ordered revisions inside PostgreSQL only. +- `NativeRuntime` implements `RuntimeDriver` with exact process-tree ownership. Database bootstrap is + a post-probe readiness phase on the real database workload, not a synthetic workload. Task 7 defines + the release-plan resolver and PostgreSQL runner; Task 14 supplies each supported release's concrete + ordered revision catalog. Applied revisions live inside PostgreSQL only and gate every database + dependent on completion. Gateway listener ownership and native transfer belong to Task 8, where the + gateway contract is implemented; Task 7 does not add a speculative listener interface. -- [ ] **Step 1: Write preparation/native behavior tests and verify RED** +- [x] **Step 1: Write preparation/native behavior tests and verify RED** Cover cached/downloaded outcomes, integrity failure cleanup, Supervisor-local single-flight, concurrent Supervisor duplicate safety, cancellation, process readiness/failure, exact process-tree termination, parent-loss termination, and bootstrap-before-dependent readiness. -- [ ] **Step 2: Implement leaf foreign boundaries** +- [x] **Step 2: Implement leaf foreign boundaries** Wrap foreign Promise/callback APIs once with cancellation signals and typed errors. Publish downloads by atomic rename. Run native subprocesses through Effect Platform; attach output to `LogStore` and termination to Scope. -- [ ] **Step 3: Verify and commit** +- [x] **Step 3: Verify and commit** Run the two targeted files and stack type-check, then commit: diff --git a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md index 9b269c4962..96af2606e3 100644 --- a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md +++ b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md @@ -1,7 +1,7 @@ # Supabase Local Stack Runtime Architecture Status: final greenfield architecture -Last updated: 2026-08-28 +Last updated: 2026-08-29 ## Goals and scope @@ -564,9 +564,11 @@ initialization. An exact unsupported version fails before state or data mutation artifact is not evidence of compatibility. Changing an initialized database version requires destroy/recreate or a future explicit upgrade workflow. -Internal database bootstrap is idempotent. If ordered migration tracking is required, it is stored -inside the database, never in `state.json`. Bootstrap creates internal roles, passwords, extensions, -schemas, and grants for the pinned version and is complete before dependents become ready. +Internal database bootstrap is an idempotent readiness phase of the real database workload, not a +synthetic companion workload. Its ordered revision plan is selected from the exact pinned database +release, and tracking is stored inside the database, never in `state.json`. Bootstrap creates the +internal roles, passwords, extensions, schemas, and grants required by that pinned release. The +database cannot become ready—and therefore no dependent can activate—until the plan completes. ## Durable state, secrets, and logs @@ -636,7 +638,9 @@ stop remains available to stop a live owner. The public surface names only the ten `CapabilityName` values and aggregates each capability into a `CapabilityState`. Internally, a capability Module compiles one or more private workloads. Examples -include storage plus imgproxy, studio plus pgmeta, analytics plus vector, and database bootstrap. +include storage plus imgproxy, studio plus pgmeta, and analytics plus vector. Database bootstrap is +deliberately different: it is a post-probe readiness phase on the real database workload and never +has its own workload id, artifact, process, lifecycle, or dependency node. Workload ids drive labels, artifact selection, exact cleanup, readiness, internal log routing, dependency edges, and restart policy. Public log entries are attributed to their capability rather than exposing workload ids. Capability state is an aggregation of its workloads; an individual diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index f6021bedd2..33ba081ae7 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -18,6 +18,8 @@ export interface ContainerArtifact { export interface WorkloadSpec { readonly name: string; readonly capability: CapabilityName; + /** Closed internal lifecycle marker for work that must run before dependents are ready. */ + readonly bootstrap?: "database"; readonly dependencies: ReadonlyArray; readonly readiness: Readonly<{ readonly mode: "http" | "tcp"; readonly portField?: PortField }>; readonly restart: Readonly<{ readonly maxAttempts: number; readonly backoffMs: number }>; @@ -75,6 +77,7 @@ export const workload = ( release: string, image: string, options: { + readonly bootstrap?: WorkloadSpec["bootstrap"]; readonly dependencies?: ReadonlyArray; readonly readiness?: WorkloadSpec["readiness"]; readonly restart?: WorkloadSpec["restart"]; @@ -82,6 +85,7 @@ export const workload = ( ): WorkloadSpec => ({ name, capability, + ...(options.bootstrap === undefined ? {} : { bootstrap: options.bootstrap }), dependencies: options.dependencies ?? [], readiness: options.readiness ?? { mode: "tcp" }, restart: options.restart ?? { maxAttempts: 5, backoffMs: 250 }, diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts new file mode 100644 index 0000000000..b2af319085 --- /dev/null +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -0,0 +1,203 @@ +import { Data, Effect, Redacted, Schema } from "effect"; + +/** A small runtime-neutral SQL boundary used by the internal database bootstrap. */ +export type DatabaseSqlValue = string | number | boolean | null; + +export interface DatabaseRow { + readonly [column: string]: unknown; +} + +export type DatabaseBootstrapRole = "supabase_admin" | "anon" | "authenticated" | "service_role"; +const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray = [ + "supabase_admin", + "anon", + "authenticated", + "service_role", +]; + +/** Values come from resolved managed secret slots and never become SQL text. */ +export interface DatabaseBootstrapCredentials { + readonly roles?: Readonly>>>; +} + +export interface DatabaseBootstrapOptions { + /** Ordered plan resolved for the pinned database release. */ + readonly revisions: ReadonlyArray; + readonly credentials?: DatabaseBootstrapCredentials; +} + +export class DatabaseBootstrapError extends Data.TaggedError("DatabaseBootstrapError")<{ + readonly message: string; + readonly statement?: string; + readonly revision?: string; + readonly cause?: unknown; +}> {} + +/** + * The bootstrap intentionally knows nothing about a PostgreSQL client. A + * native or container runtime supplies this boundary from its already-ready + * database connection. + */ +export interface DatabaseTransaction { + readonly execute: ( + statement: string, + parameters?: ReadonlyArray, + ) => Effect.Effect; + readonly setRolePassword: ( + role: DatabaseBootstrapRole, + password: Redacted.Redacted, + ) => Effect.Effect; + readonly query: ( + statement: string, + parameters?: ReadonlyArray, + ) => Effect.Effect, DatabaseBootstrapError>; +} + +export interface DatabaseSession { + readonly execute: ( + statement: string, + parameters?: ReadonlyArray, + ) => Effect.Effect; + /** Applies all operations in the callback and commits them atomically. */ + readonly transaction: ( + use: (transaction: DatabaseTransaction) => Effect.Effect, + ) => Effect.Effect; +} + +export interface DatabaseBootstrapRevision { + readonly id: string; + readonly statement: string; +} + +const TRACKING_SCHEMA = "supabase_internal"; +const TRACKING_TABLE = `${TRACKING_SCHEMA}.bootstrap_revisions`; + +const TRACKING_TABLE_STATEMENT = ` + CREATE SCHEMA IF NOT EXISTS ${TRACKING_SCHEMA}; + CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} ( + revision text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ); +`; +const APPLIED_REVISIONS_STATEMENT = ` + SELECT revision FROM ${TRACKING_TABLE} ORDER BY revision; +`; +const RECORD_REVISION_STATEMENT = ` + INSERT INTO ${TRACKING_TABLE} (revision) VALUES ($1) + ON CONFLICT (revision) DO NOTHING; +`; +const ADVISORY_LOCK_STATEMENT = `SELECT pg_advisory_xact_lock(hashtext('supabase_internal.bootstrap'));`; + +const RevisionRowSchema = Schema.Struct({ revision: Schema.String }); + +const statementError = (error: DatabaseBootstrapError, statement: string, revision?: string) => + new DatabaseBootstrapError({ + message: error.message, + statement, + ...(revision === undefined ? {} : { revision }), + ...(error.cause === undefined ? {} : { cause: error.cause }), + }); + +/** Runs all unapplied internal revisions, recording each only after it succeeds. */ +export const runDatabaseBootstrap = ( + session: DatabaseSession, + options: DatabaseBootstrapOptions, +): Effect.Effect => + Effect.gen(function* () { + const ids = new Set(); + for (const revision of options.revisions) { + if (revision.id.trim().length === 0 || ids.has(revision.id)) + return yield* new DatabaseBootstrapError({ + message: "Database bootstrap revision ids must be non-empty and unique", + revision: revision.id, + }); + ids.add(revision.id); + } + yield* session.transaction((transaction) => + Effect.gen(function* () { + yield* transaction + .execute(ADVISORY_LOCK_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); + yield* transaction + .execute(TRACKING_TABLE_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, TRACKING_TABLE_STATEMENT))); + }), + ); + for (const revision of options.revisions) { + yield* session + .transaction((transaction) => + Effect.gen(function* () { + // Re-check under a transaction-scoped advisory lock. This prevents + // two owners from both applying a non-idempotent revision after + // observing the same pre-lock snapshot. + yield* transaction + .execute(ADVISORY_LOCK_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); + const rows = yield* transaction + .query(APPLIED_REVISIONS_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, APPLIED_REVISIONS_STATEMENT))); + const applied = new Set(); + for (const row of rows) { + const decoded = yield* Schema.decodeUnknownEffect(RevisionRowSchema)(row).pipe( + Effect.mapError( + (cause) => + new DatabaseBootstrapError({ + message: `Bootstrap tracking row is malformed: ${String(cause)}`, + statement: APPLIED_REVISIONS_STATEMENT, + }), + ), + ); + applied.add(decoded.revision); + } + if (applied.has(revision.id)) return; + yield* transaction + .execute(revision.statement) + .pipe( + Effect.mapError((error) => statementError(error, revision.statement, revision.id)), + ); + yield* transaction + .execute(RECORD_REVISION_STATEMENT, [revision.id]) + .pipe( + Effect.mapError((error) => + statementError(error, RECORD_REVISION_STATEMENT, revision.id), + ), + ); + }), + ) + .pipe( + Effect.mapError((error) => + error instanceof DatabaseBootstrapError + ? error + : new DatabaseBootstrapError({ + message: `Unable to commit database bootstrap revision ${revision.id}`, + revision: revision.id, + cause: error, + }), + ), + ); + } + if (options.credentials?.roles !== undefined) { + yield* session.transaction((transaction) => + Effect.gen(function* () { + yield* transaction + .execute(ADVISORY_LOCK_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); + for (const role of DATABASE_BOOTSTRAP_ROLES) { + const password = options.credentials?.roles?.[role]; + if (password === undefined) continue; + yield* transaction.setRolePassword(role, password).pipe( + Effect.mapError( + (error) => + new DatabaseBootstrapError({ + message: `Unable to configure internal database role ${role}`, + cause: error, + }), + ), + ); + } + }), + ); + } + }); + +export const databaseBootstrapTrackingTable = TRACKING_TABLE; diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index b83f12def8..a75e88d950 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -33,6 +33,7 @@ export const CAPABILITY_MODULES = { export interface PlannedWorkload { readonly id: string; readonly capability: CapabilityName; + readonly bootstrap?: WorkloadSpec["bootstrap"]; readonly dependencies: ReadonlyArray; readonly readiness: WorkloadSpec["readiness"]; readonly restart: WorkloadSpec["restart"]; @@ -141,6 +142,7 @@ export const createExecutionPlan = ( return release.workloads.map((entry) => ({ id: `${name}:${entry.name}`, capability: name, + ...(entry.bootstrap === undefined ? {} : { bootstrap: entry.bootstrap }), dependencies: entry.dependencies, readiness: entry.readiness, restart: entry.restart, diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index 8fa1e7d483..48204543c1 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -93,64 +93,43 @@ export const DatabaseModule: CapabilityModule = { releases: { "13": release("15.8.1.085", [ workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { - readiness: { mode: "tcp", portField: "database" }, - }), - workload("database-bootstrap", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { - dependencies: ["database:database"], + bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), ]), "14": release("14.1.0.89", [ workload("database", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { - readiness: { mode: "tcp", portField: "database" }, - }), - workload("database-bootstrap", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { - dependencies: ["database:database"], + bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), ]), "15": release("15.8.1.085", [ workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { - readiness: { mode: "tcp", portField: "database" }, - }), - workload("database-bootstrap", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { - dependencies: ["database:database"], + bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), ]), "17": release("17.6.1.165", [ workload("database", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { - readiness: { mode: "tcp", portField: "database" }, - }), - workload("database-bootstrap", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { - dependencies: ["database:database"], + bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), ]), "14.1.0.89": release("14.1.0.89", [ workload("database", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { - readiness: { mode: "tcp", portField: "database" }, - }), - workload("database-bootstrap", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { - dependencies: ["database:database"], + bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), ]), "15.8.1.085": release("15.8.1.085", [ workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { - readiness: { mode: "tcp", portField: "database" }, - }), - workload("database-bootstrap", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { - dependencies: ["database:database"], + bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), ]), "17.6.1.165": release("17.6.1.165", [ workload("database", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { - readiness: { mode: "tcp", portField: "database" }, - }), - workload("database-bootstrap", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { - dependencies: ["database:database"], + bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), ]), diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 49fb58cfef..4f689d4430 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -250,6 +250,16 @@ describe("closed capability compiler", () => { }), ); + it.live("compiles database bootstrap as a marker on the database workload", () => + Effect.gen(function* () { + const result = yield* compile({}); + const database = result.executionPlan.workloads.filter((w) => w.capability === "database"); + expect(database).toHaveLength(1); + expect(database[0]?.id).toBe("database:database"); + expect(database[0]?.bootstrap).toBe("database"); + }), + ); + it.live("rejects a functions root that escapes the project", () => Effect.gen(function* () { const result = yield* compile({ diff --git a/packages/stack/src/model/database-bootstrap.integration.test.ts b/packages/stack/src/model/database-bootstrap.integration.test.ts new file mode 100644 index 0000000000..889c64a97b --- /dev/null +++ b/packages/stack/src/model/database-bootstrap.integration.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Redacted, Semaphore } from "effect"; +import { + DatabaseBootstrapError, + type DatabaseBootstrapCredentials, + type DatabaseSession, + runDatabaseBootstrap, +} from "./DatabaseBootstrap.ts"; + +const revisions = [ + { id: "roles", statement: "CREATE ROLE anon" }, + { id: "extensions", statement: "CREATE EXTENSION pgcrypto" }, + { id: "schemas", statement: "CREATE SCHEMA extensions" }, +] as const; + +const makeSession = ( + options: { readonly failRevision?: string; readonly failPassword?: boolean } = {}, +) => + Effect.gen(function* () { + const lock = yield* Semaphore.make(1); + let failed = false; + const applied: string[] = []; + const operations: string[] = []; + const successfulRevisions: string[] = []; + const passwords: Array<[string, string]> = []; + const session: DatabaseSession = { + execute: (statement) => + Effect.sync(() => { + operations.push(statement.trim().split("\n")[0] ?? ""); + }), + transaction: (use) => + lock.withPermit( + Effect.gen(function* () { + const pending: string[] = []; + const tx = { + execute: ( + statement: string, + parameters?: ReadonlyArray, + ) => + Effect.gen(function* () { + operations.push(statement.trim().split("\n")[0] ?? ""); + const id = parameters?.[0]; + if (typeof id === "string" && statement.includes("INSERT")) pending.push(id); + const revision = revisions.find((entry) => + statement.includes(entry.statement.trim().split("\n")[0] ?? ""), + ); + if (revision !== undefined && revision.id === options.failRevision && !failed) { + failed = true; + return yield* new DatabaseBootstrapError({ + message: `failed ${revision.id}`, + revision: revision.id, + }); + } + if (revision !== undefined) successfulRevisions.push(revision.id); + }), + setRolePassword: ( + role: "supabase_admin" | "anon" | "authenticated" | "service_role", + password: Redacted.Redacted, + ) => + options.failPassword + ? Effect.fail(new DatabaseBootstrapError({ message: "password rejected" })) + : Effect.sync(() => passwords.push([role, Redacted.value(password)])), + query: () => Effect.succeed(applied.map((revision) => ({ revision }))), + }; + yield* use(tx); + applied.push(...pending); + }), + ), + }; + return { session, applied, operations, passwords, successfulRevisions }; + }); + +describe("database bootstrap", () => { + it.live("applies ordered revisions once and keeps credentials outside SQL", () => + Effect.gen(function* () { + const state = yield* makeSession(); + const credentials: DatabaseBootstrapCredentials = { + roles: { supabase_admin: Redacted.make("secret-password") }, + }; + yield* runDatabaseBootstrap(state.session, { + revisions, + credentials, + }); + yield* runDatabaseBootstrap(state.session, { + revisions, + credentials, + }); + expect(state.applied).toEqual(revisions.map(({ id }) => id)); + // Credential reconciliation is intentionally a separate idempotent + // phase. It runs on each invocation so a changed managed password is + // applied even when every schema revision is already recorded. + expect(state.passwords).toEqual([ + ["supabase_admin", "secret-password"], + ["supabase_admin", "secret-password"], + ]); + expect(state.operations.join(" ")).not.toContain("secret-password"); + }), + ); + + it.live("does not record a failed revision and retries it later", () => + Effect.gen(function* () { + const state = yield* makeSession({ failRevision: "schemas" }); + const first = yield* runDatabaseBootstrap(state.session, { + revisions, + }).pipe(Effect.exit); + expect(Exit.isFailure(first)).toBe(true); + expect(state.applied).toEqual(["roles", "extensions"]); + yield* runDatabaseBootstrap(state.session, { revisions }); + expect(state.applied).toEqual(revisions.map(({ id }) => id)); + expect(state.successfulRevisions.filter((entry) => entry === "schemas")).toHaveLength(1); + }), + ); + + it.live("keeps managed passwords out of SQL and bootstrap errors", () => + Effect.gen(function* () { + const state = yield* makeSession({ failPassword: true }); + const result = yield* runDatabaseBootstrap(state.session, { + revisions: [], + credentials: { roles: { anon: Redacted.make("secret-password") } }, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Cause.pretty(result.cause)).not.toContain("secret-password"); + expect(state.operations.join(" ")).not.toContain("secret-password"); + }), + ); + + it.live("serializes concurrent callers before applying a revision", () => + Effect.gen(function* () { + const state = yield* makeSession(); + yield* Effect.all( + [ + runDatabaseBootstrap(state.session, { revisions }), + runDatabaseBootstrap(state.session, { revisions }), + ], + { concurrency: "unbounded", discard: true }, + ); + expect(state.applied).toEqual(revisions.map(({ id }) => id)); + expect(state.operations.filter((entry) => entry === "CREATE ROLE anon")).toHaveLength(1); + }), + ); +}); diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts new file mode 100644 index 0000000000..34a838ac3f --- /dev/null +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -0,0 +1,630 @@ +import { + Crypto, + Deferred, + Effect, + Exit, + FileSystem, + Path, + PlatformError, + Schema, + Scope, + Semaphore, +} from "effect"; +import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; +import { validateRelativePath, validateSha256, verifySha256 } from "./Integrity.ts"; + +/** + * A concrete artifact identity. `key` is deliberately private to preparation and may contain + * subdirectories, but never an absolute or traversing path. + */ +export interface ArtifactRequest { + readonly key: string; + readonly sha256: string; + /** Relative paths that a runtime may use after installation. */ + readonly requiredRuntimePaths: ReadonlyArray; + /** The relative executable path, if this artifact starts a native workload. */ + readonly executablePath?: string; +} + +/** + * The source is the only download/archive boundary. It writes an unpacked artifact tree below + * `destination` and returns the exact bytes whose SHA-256 identifies the catalog entry (normally + * the downloaded tar.zst archive). A future network/archive adapter can stream and unpack there; + * the store itself remains independent of transport and archive formats. + */ +export interface ArtifactSource { + readonly materialize: ( + request: ArtifactRequest, + destination: string, + ) => Effect.Effect; +} + +export interface ArtifactStoreOptions { + readonly cacheRoot: string; + readonly source: ArtifactSource; +} + +export interface PreparedArtifact { + readonly key: string; + /** Installed artifact directory. Required runtime paths are relative to this directory. */ + readonly path: string; + readonly sha256: string; + readonly requiredRuntimePaths: ReadonlyArray; + readonly executablePath?: string; + readonly outcome: "cached" | "downloaded"; +} + +export type ArtifactStoreError = StackPreparationError | ArtifactIntegrityError; + +export interface ArtifactStore { + readonly prepare: ( + request: ArtifactRequest, + ) => Effect.Effect; +} + +const ARTIFACT_FORMAT = "supabase-stack-artifact-v1"; +const ARCHIVE_NAME = ".artifact-source"; +const METADATA_NAME = ".artifact.json"; +const EXECUTABLE_MODE = 0o755; + +const artifactError = (message: string, fields: Readonly> = {}) => + new StackPreparationError({ ...fields, message }); + +const metadataError = (message: string, fields: Readonly> = {}) => + new ArtifactIntegrityError({ ...fields, message }); + +const ArtifactMetadataSchema = Schema.Struct({ + format: Schema.Literal(ARTIFACT_FORMAT), + key: Schema.String, + sha256: Schema.String, + requiredRuntimePaths: Schema.Array(Schema.String), + executablePath: Schema.optional(Schema.String), +}); +type ArtifactMetadata = Schema.Schema.Type; + +const mapFs = ( + path: string, + operation: string, + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.mapError((cause) => + artifactError( + `Unable to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`, + { + path, + cause, + }, + ), + ), + ); + +const pathWithin = (root: string, candidate: string, separator: string): boolean => + candidate !== root && candidate.startsWith(`${root}${separator}`); + +const pathAtOrBelow = (root: string, candidate: string, separator: string): boolean => + candidate === root || candidate.startsWith(`${root}${separator}`); + +const validateKey = (key: string): Effect.Effect => + validateRelativePath(key, "artifact key").pipe( + Effect.flatMap(() => + /^[A-Za-z0-9][A-Za-z0-9._-]*(?:[\\/][A-Za-z0-9][A-Za-z0-9._-]*)*$/u.test(key) + ? Effect.void + : Effect.fail(artifactError("Artifact key contains unsupported characters", { key })), + ), + ); + +const validateRequest = ( + request: ArtifactRequest, +): Effect.Effect => + Effect.gen(function* () { + yield* validateKey(request.key); + const sha256 = yield* validateSha256(request.sha256); + const seen = new Set(); + for (const relative of request.requiredRuntimePaths) { + yield* validateRelativePath(relative, "required runtime path"); + if (seen.has(relative)) + return yield* artifactError("Duplicate required runtime path", { path: relative }); + seen.add(relative); + } + if (request.executablePath !== undefined) { + yield* validateRelativePath(request.executablePath, "executable path"); + if (!seen.has(request.executablePath)) + return yield* artifactError("Executable path must be required", { + path: request.executablePath, + }); + } + return sha256; + }); + +const requestFlightKey = (request: ArtifactRequest, expectedSha256: string): string => + [request.key, expectedSha256, ...request.requiredRuntimePaths, request.executablePath ?? ""].join( + "\u0000", + ); + +const metadataFor = (request: ArtifactRequest, sha256: string): ArtifactMetadata => ({ + format: ARTIFACT_FORMAT, + key: request.key, + sha256, + requiredRuntimePaths: [...request.requiredRuntimePaths], + ...(request.executablePath === undefined ? {} : { executablePath: request.executablePath }), +}); + +const encodeMetadata = (metadata: ArtifactMetadata): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(ArtifactMetadataSchema))(metadata).pipe( + Effect.mapError((cause) => + artifactError(`Unable to encode artifact metadata: ${String(cause)}`), + ), + ); + +const readMetadata = ( + fs: FileSystem.FileSystem, + metadataPath: string, +): Effect.Effect => + fs.readFileString(metadataPath).pipe( + Effect.mapError((cause) => + metadataError("Cached artifact metadata is unreadable", { path: metadataPath, cause }), + ), + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(ArtifactMetadataSchema))(text).pipe( + Effect.mapError((cause) => + metadataError("Cached artifact metadata is malformed", { path: metadataPath, cause }), + ), + ), + ), + ); + +const ensureDirectory = ( + fs: FileSystem.FileSystem, + path: Path.Path, + directory: string, + canonicalRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const root = path.resolve(canonicalRoot); + const resolved = path.resolve(directory); + if (!pathAtOrBelow(root, resolved, path.sep)) + return yield* artifactError("Artifact directory escapes cache root", { path: directory }); + const relative = path.relative(root, resolved); + let current = root; + for (const segment of relative.split(path.sep).filter((value) => value.length > 0)) { + current = path.join(current, segment); + const exists = yield* mapFs(current, "inspect artifact directory", fs.exists(current)); + if (!exists) break; + const real = yield* fs.realPath(current).pipe( + Effect.mapError((cause) => + artifactError(`Unable to resolve artifact directory: ${cause.message}`, { + path: current, + cause, + }), + ), + ); + if (real !== current) + return yield* artifactError("Artifact directory contains a symlink", { path: current }); + if (!pathAtOrBelow(root, real, path.sep)) + return yield* artifactError("Artifact directory escapes cache root", { path: current }); + } + yield* mapFs( + resolved, + "create artifact directory", + fs.makeDirectory(resolved, { recursive: true, mode: 0o700 }), + ); + const real = yield* fs.realPath(resolved).pipe( + Effect.mapError((cause) => + artifactError(`Unable to resolve artifact directory: ${cause.message}`, { + path: resolved, + cause, + }), + ), + ); + if (real !== resolved) + return yield* artifactError("Artifact directory contains a symlink", { path: resolved }); + if (!pathAtOrBelow(root, real, path.sep)) + return yield* artifactError("Artifact directory escapes cache root", { path: resolved }); + yield* mapFs(resolved, "secure artifact directory", fs.chmod(resolved, 0o700)); + }); + +const ensureSafeRoot = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + canonicalRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const resolvedRoot = path.resolve(root); + const rootInfo = yield* fs + .stat(root) + .pipe( + Effect.mapError((cause) => + metadataError("Cached artifact root cannot be inspected", { root, cause }), + ), + ); + if (rootInfo.type !== "Directory") + return yield* metadataError("Cached artifact root must be a directory", { root }); + const realRoot = yield* fs + .realPath(root) + .pipe( + Effect.mapError((cause) => + metadataError("Cached artifact root cannot be resolved", { root, cause }), + ), + ); + if (realRoot !== resolvedRoot) + return yield* metadataError("Cached artifact root contains a symlink", { root }); + if (!pathAtOrBelow(path.resolve(canonicalRoot), realRoot, path.sep)) + return yield* metadataError("Cached artifact root escapes cache root", { root }); + return realRoot; + }); + +const ensureSafePaths = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + realRoot: string, + relativePaths: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + for (const relative of relativePaths) { + const candidate = path.resolve(root, relative); + if (!pathWithin(path.resolve(root), candidate, path.sep)) + return yield* metadataError("Artifact path escapes its installation directory", { + path: relative, + }); + const exists = yield* fs + .exists(candidate) + .pipe( + Effect.mapError((cause) => + metadataError("Unable to inspect cached artifact path", { path: candidate, cause }), + ), + ); + if (!exists) + return yield* metadataError("Cached artifact is missing a required runtime path", { + path: relative, + }); + const realCandidate = yield* fs + .realPath(candidate) + .pipe( + Effect.mapError((cause) => + metadataError("Cached artifact path cannot be resolved", { path: candidate, cause }), + ), + ); + if (!pathWithin(realRoot, realCandidate, path.sep)) + return yield* metadataError("Cached artifact path escapes its installation directory", { + path: relative, + }); + } + }); + +const ensureExecutableFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + relative: string, +): Effect.Effect => { + const executable = path.resolve(root, relative); + return fs.stat(executable).pipe( + Effect.mapError((cause) => + metadataError("Cached artifact executable cannot be inspected", { + path: executable, + cause, + }), + ), + Effect.flatMap((info) => + info.type === "File" + ? Effect.void + : Effect.fail( + metadataError("Artifact executable path must resolve to a regular file", { + path: executable, + type: info.type, + }), + ), + ), + ); +}; + +const verifyMetadata = ( + request: ArtifactRequest, + expectedSha256: string, + metadata: ArtifactMetadata, +): Effect.Effect => { + const samePaths = + metadata.requiredRuntimePaths.length === request.requiredRuntimePaths.length && + metadata.requiredRuntimePaths.every( + (entry, index) => entry === request.requiredRuntimePaths[index], + ); + if ( + metadata.key !== request.key || + metadata.sha256 !== expectedSha256 || + !samePaths || + metadata.executablePath !== request.executablePath + ) + return Effect.fail( + metadataError("Cached artifact metadata does not match the request", { key: request.key }), + ); + return Effect.void; +}; + +const writeBytesSync = ( + fs: FileSystem.FileSystem, + path: string, + bytes: Uint8Array, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(path, { flag: "wx", mode: 0o600 }).pipe( + Effect.mapError((cause) => + artifactError(`Unable to create artifact source file: ${cause.message}`, { + path, + cause, + }), + ), + ); + yield* file.writeAll(bytes).pipe( + Effect.mapError((cause) => + artifactError(`Unable to write artifact source file: ${cause.message}`, { + path, + cause, + }), + ), + ); + yield* file.sync.pipe( + Effect.mapError((cause) => + artifactError(`Unable to sync artifact source file: ${cause.message}`, { path, cause }), + ), + ); + }), + ); + +const writeMetadataSync = ( + fs: FileSystem.FileSystem, + path: string, + metadata: ArtifactMetadata, +): Effect.Effect => + Effect.gen(function* () { + const encoded = yield* encodeMetadata(metadata); + yield* writeBytesSync(fs, path, new TextEncoder().encode(encoded)); + }); + +const cleanup = ( + fs: FileSystem.FileSystem, + path: string, +): Effect.Effect => + fs + .remove(path, { recursive: true, force: true }) + .pipe( + Effect.mapError((cause) => + artifactError(`Unable to clean artifact temporary path: ${cause.message}`, { path, cause }), + ), + ); + +const makeArtifactOperation = ( + fs: FileSystem.FileSystem, + path: Path.Path, + crypto: Crypto.Crypto, + cacheRoot: string, + source: ArtifactSource, + request: ArtifactRequest, + expectedSha256: string, +): Effect.Effect => + Effect.gen(function* () { + const target = path.resolve(cacheRoot, request.key); + const targetParent = path.dirname(target); + yield* ensureDirectory(fs, path, targetParent, cacheRoot); + const targetExists = yield* mapFs(target, "inspect cached artifact", fs.exists(target)); + const cachedRoot = targetExists + ? yield* ensureSafeRoot(fs, path, target, cacheRoot) + : undefined; + const metadataPath = path.join(target, METADATA_NAME); + const archivePath = path.join(target, ARCHIVE_NAME); + const checkCached: Effect.Effect = Effect.gen( + function* () { + const realRoot = cachedRoot ?? (yield* ensureSafeRoot(fs, path, target, cacheRoot)); + const metadata = yield* readMetadata(fs, metadataPath); + yield* verifyMetadata(request, expectedSha256, metadata); + const archive = yield* fs + .readFile(archivePath) + .pipe( + Effect.mapError((cause) => + metadataError("Cached artifact source is unreadable", { path: archivePath, cause }), + ), + ); + yield* verifySha256(archive, expectedSha256).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError((error) => + metadataError("Cached artifact source failed integrity verification", { + path: archivePath, + cause: error, + }), + ), + ); + yield* ensureSafePaths(fs, path, target, realRoot, request.requiredRuntimePaths); + if (request.executablePath !== undefined) { + yield* ensureExecutableFile(fs, path, target, request.executablePath); + const executable = path.resolve(target, request.executablePath); + yield* mapFs( + executable, + "set executable artifact mode", + fs.chmod(executable, EXECUTABLE_MODE), + ).pipe( + Effect.mapError((error) => + metadataError(error.message, { path: executable, cause: error }), + ), + ); + } + return { + key: request.key, + path: target, + sha256: expectedSha256, + requiredRuntimePaths: [...request.requiredRuntimePaths], + ...(request.executablePath === undefined + ? {} + : { executablePath: request.executablePath }), + outcome: "cached" as const, + }; + }, + ); + if (targetExists) return yield* checkCached; + + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + artifactError(`Unable to allocate artifact temporary name: ${cause.message}`, { + key: request.key, + cause, + }), + ), + ); + const temporary = path.join(targetParent, `.${path.basename(target)}.${token}.tmp`); + yield* ensureDirectory(fs, path, temporary, cacheRoot); + const temporaryRoot = yield* ensureSafeRoot(fs, path, temporary, cacheRoot); + const published = yield* Effect.gen(function* () { + const archive = yield* source + .materialize(request, temporary) + .pipe(Effect.provideService(FileSystem.FileSystem, fs)); + yield* verifySha256(archive, expectedSha256).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError((error) => + metadataError("Downloaded artifact failed integrity verification", { + key: request.key, + cause: error, + }), + ), + ); + const sourceArchive = path.join(temporary, ARCHIVE_NAME); + yield* writeBytesSync(fs, sourceArchive, archive); + yield* writeMetadataSync( + fs, + path.join(temporary, METADATA_NAME), + metadataFor(request, expectedSha256), + ); + yield* ensureSafePaths(fs, path, temporary, temporaryRoot, request.requiredRuntimePaths); + if (request.executablePath !== undefined) { + yield* ensureExecutableFile(fs, path, temporary, request.executablePath); + const executable = path.resolve(temporary, request.executablePath); + yield* mapFs( + executable, + "set executable artifact mode", + fs.chmod(executable, EXECUTABLE_MODE), + ); + } + const nowExists = yield* mapFs(target, "inspect concurrent artifact", fs.exists(target)); + if (nowExists) return yield* checkCached; + const rename = mapFs(temporary, "publish artifact", fs.rename(temporary, target)).pipe( + Effect.as(undefined), + ); + const recoverPublish = ( + error: StackPreparationError, + ): Effect.Effect => + Effect.gen(function* () { + const exists = yield* mapFs(target, "inspect concurrent artifact", fs.exists(target)); + if (exists) return yield* checkCached; + return yield* error; + }); + const recovered: Effect.Effect = + rename.pipe(Effect.catch(recoverPublish)); + return yield* recovered; + }).pipe(Effect.onExit(() => cleanup(fs, temporary))); + if (published !== undefined) return published; + return { + key: request.key, + path: target, + sha256: expectedSha256, + requiredRuntimePaths: [...request.requiredRuntimePaths], + ...(request.executablePath === undefined ? {} : { executablePath: request.executablePath }), + outcome: "downloaded" as const, + }; + }); + +export const makeArtifactStore = ( + options: ArtifactStoreOptions, +): Effect.Effect< + ArtifactStore, + StackPreparationError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + if (options.cacheRoot.trim().length === 0) + return yield* artifactError("Artifact cache root must not be blank"); + const requestedRoot = path.resolve(options.cacheRoot); + yield* mapFs( + requestedRoot, + "create artifact cache root", + fs.makeDirectory(requestedRoot, { recursive: true, mode: 0o700 }), + ); + const cacheRoot = yield* fs.realPath(requestedRoot).pipe( + Effect.mapError((cause) => + artifactError(`Unable to resolve artifact cache root: ${cause.message}`, { + path: requestedRoot, + cause, + }), + ), + ); + const rootInfo = yield* fs.stat(cacheRoot).pipe( + Effect.mapError((cause) => + artifactError(`Unable to inspect artifact cache root: ${cause.message}`, { + path: cacheRoot, + cause, + }), + ), + ); + if (rootInfo.type !== "Directory") + return yield* artifactError("Artifact cache root must be a directory", { path: cacheRoot }); + yield* mapFs(cacheRoot, "secure artifact cache root", fs.chmod(cacheRoot, 0o700)); + const ownerScope = yield* Scope.make("parallel"); + const parentScope = yield* Scope.Scope; + yield* Scope.addFinalizer(parentScope, Scope.close(ownerScope, Exit.void)); + const lifecycle = yield* Semaphore.make(1); + type InFlight = { + readonly result: Deferred.Deferred; + }; + const inFlight = new Map(); + const prepare = (request: ArtifactRequest) => + Effect.gen(function* () { + const expectedSha256 = yield* validateRequest(request); + const target = path.resolve(cacheRoot, request.key); + if (!pathWithin(cacheRoot, target, path.sep)) + return yield* artifactError("Artifact key escapes cache root", { key: request.key }); + const flightKey = requestFlightKey(request, expectedSha256); + const operation = makeArtifactOperation( + fs, + path, + crypto, + cacheRoot, + options.source, + request, + expectedSha256, + ); + const joined = yield* lifecycle.withPermit( + Effect.gen(function* () { + const existing = inFlight.get(flightKey); + if (existing !== undefined) { + return existing; + } + const result = yield* Deferred.make(); + const record: InFlight = { result }; + inFlight.set(flightKey, record); + yield* Effect.forkIn( + Effect.gen(function* () { + const exit = yield* Effect.exit(operation); + yield* lifecycle.withPermit( + Effect.uninterruptible( + Effect.gen(function* () { + inFlight.delete(flightKey); + yield* Deferred.done(record.result, exit); + }), + ), + ); + }), + ownerScope, + ); + return record; + }), + ); + // Waiting is intentionally independent from the owner. Interrupting any caller only + // stops its await; the Supervisor-owned preparation continues until completion or store + // scope closure so a later caller can join the same result. + return yield* Deferred.await(joined.result); + }); + return { prepare }; + }); diff --git a/packages/stack/src/preparation/Integrity.ts b/packages/stack/src/preparation/Integrity.ts new file mode 100644 index 0000000000..ec000030bf --- /dev/null +++ b/packages/stack/src/preparation/Integrity.ts @@ -0,0 +1,70 @@ +import { Crypto, Effect } from "effect"; +import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; + +const SHA256 = /^[0-9a-f]{64}$/i; + +const integrityError = (message: string, fields: Readonly> = {}) => + new ArtifactIntegrityError({ ...fields, message }); + +/** Validates the hexadecimal SHA-256 identifier supplied by an artifact catalog. */ +export const validateSha256 = (value: string): Effect.Effect => + SHA256.test(value) + ? Effect.succeed(value.toLowerCase()) + : Effect.fail( + integrityError("Artifact SHA-256 must be exactly 64 hexadecimal characters", { + expected: value, + }), + ); + +/** Converts a digest returned by the Effect Crypto service to its canonical hexadecimal form. */ +export const digestHex = (digest: Uint8Array): string => + Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +/** Computes and verifies a SHA-256 digest without collapsing Crypto failures. */ +export const verifySha256 = ( + bytes: Uint8Array, + expected: string, +): Effect.Effect => + Effect.gen(function* () { + const canonical = yield* validateSha256(expected); + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto + .digest("SHA-256", bytes) + .pipe( + Effect.mapError((cause) => + integrityError("Unable to compute artifact SHA-256", { expected: canonical, cause }), + ), + ); + const actual = digestHex(digest); + if (actual !== canonical) + return yield* integrityError("Artifact SHA-256 does not match the catalog", { + expected: canonical, + actual, + }); + }); + +/** Validates one relative artifact path before it is joined to a cache root. */ +export const validateRelativePath = ( + value: string, + field: string, +): Effect.Effect => { + const segments = value.split(/[\\/]/u); + if ( + value.length === 0 || + value.includes("\u0000") || + value.startsWith("/") || + value.startsWith("\\") || + segments.some( + (segment) => + segment.length === 0 || segment === "." || segment === ".." || segment.includes(":"), + ) + ) + return Effect.fail( + new StackPreparationError({ + field, + value, + message: `${field} must be a non-empty relative path without traversal`, + }), + ); + return Effect.void; +}; diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts new file mode 100644 index 0000000000..caac1f5bfe --- /dev/null +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -0,0 +1,477 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Option } from "effect"; +import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; +import { makeArtifactStore, type ArtifactRequest, type ArtifactSource } from "./ArtifactStore.ts"; + +const layer = NodeServices.layer; +const archive = new TextEncoder().encode("archive"); +const archiveSha256 = "0eb3e36bfb24dcd9bb1d1bece1531216b59539a8fde17ee80224af0653c92aa3"; +const request: ArtifactRequest = { + key: "database/postgres", + sha256: archiveSha256, + requiredRuntimePaths: ["bin/postgres", "etc/postgres.conf"], + executablePath: "bin/postgres", +}; + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(layer)); + +const sourceWriting = (bytes: Uint8Array = archive): ArtifactSource => ({ + materialize: (_request, destination) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + return bytes; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ message: `materialization failed: ${cause.message}`, cause }), + ), + ), +}); + +describe("verified native artifact preparation", () => { + it.live("downloads and atomically publishes an executable artifact tree", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-artifact-" }); + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + const prepared = yield* store.prepare(request); + expect(prepared.outcome).toBe("downloaded"); + expect(yield* fs.exists(`${prepared.path}/bin/postgres`)).toBe(true); + expect((yield* fs.stat(`${prepared.path}/bin/postgres`)).mode & 0o111).not.toBe(0); + expect(yield* fs.exists(`${root}/database/postgres/.artifact-source`)).toBe(true); + }), + ), + ); + + it.live("returns a verified cache hit without invoking the source again", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-", + }); + let calls = 0; + const source: ArtifactSource = { + materialize: (entry, destination) => + Effect.sync(() => { + calls += 1; + return entry; + }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination))), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const first = yield* store.prepare(request); + const second = yield* store.prepare(request); + expect(first.outcome).toBe("downloaded"); + expect(second.outcome).toBe("cached"); + expect(calls).toBe(1); + }), + ), + ); + + it.live("rejects a corrupted cached archive without replacing the published tree", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-corrupt-cache-", + }); + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + const published = yield* store.prepare(request); + yield* fs.writeFileString(`${published.path}/.artifact-source`, "corrupt"); + + const exit = yield* store.prepare(request).pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + expect(yield* fs.exists(`${published.path}/bin/postgres`)).toBe(true); + expect(yield* fs.exists(`${published.path}/.artifact-source`)).toBe(true); + }), + ), + ); + + it.live("fails closed for a conflicting request without replacing the published tree", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-conflict-", + }); + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + const published = yield* store.prepare(request); + const conflict = yield* store + .prepare({ ...request, sha256: "1".repeat(64) }) + .pipe(Effect.exit); + expect(errorOf(conflict)).toBeInstanceOf(ArtifactIntegrityError); + expect(yield* fs.exists(`${published.path}/bin/postgres`)).toBe(true); + expect(yield* fs.exists(`${published.path}/.artifact-source`)).toBe(true); + }), + ), + ); + + it.live("rejects a checksum mismatch and removes only its temporary tree", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-integrity-", + }); + const store = yield* makeArtifactStore({ + cacheRoot: root, + source: sourceWriting(new TextEncoder().encode("tampered")), + }); + const exit = yield* store.prepare(request).pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + const entries = yield* fs.readDirectory(root, { recursive: true }); + expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); + expect(yield* fs.exists(`${root}/database/postgres`)).toBe(false); + }), + ), + ); + + it.live("rejects an executable path that resolves to a directory", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-executable-directory-", + }); + const source: ArtifactSource = { + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin/postgres`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const exit = yield* store.prepare(request).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + const entries = yield* fs.readDirectory(root, { recursive: true }); + expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); + expect(yield* fs.exists(`${root}/database/postgres`)).toBe(false); + }), + ), + ); + + it.live("accepts an internal symlink to an executable in the artifact root", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-internal-link-", + }); + const source: ArtifactSource = { + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres.real`, "native postgres"); + yield* fs.symlink("postgres.real", `${destination}/bin/postgres`); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const prepared = yield* store.prepare(request); + + expect(prepared.outcome).toBe("downloaded"); + expect(yield* fs.readLink(`${prepared.path}/bin/postgres`)).toBe("postgres.real"); + expect((yield* fs.stat(`${prepared.path}/bin/postgres`)).mode & 0o111).not.toBe(0); + }), + ), + ); + + it.live("rejects a required path symlink that escapes the artifact root", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-link-escape-", + }); + const outside = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-link-outside-", + }); + const outsideExecutable = `${outside}/postgres`; + yield* fs.writeFileString(outsideExecutable, "outside"); + const source: ArtifactSource = { + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.symlink(outsideExecutable, `${destination}/bin/postgres`); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const exit = yield* store.prepare(request).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + expect(yield* fs.exists(outsideExecutable)).toBe(true); + expect(yield* fs.exists(`${root}/database/postgres`)).toBe(false); + }), + ), + ); + + it.live("cancels the owner when the store scope closes", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cancel-", + }); + const started = yield* Deferred.make(); + const source: ArtifactSource = { + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.writeFileString(`${destination}/partial`, "partial"); + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + yield* Effect.scoped( + Effect.gen(function* () { + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + yield* Effect.forkChild(store.prepare(request)); + yield* Deferred.await(started); + }), + ); + const entries = yield* fs.readDirectory(root, { recursive: true }); + expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); + expect(yield* fs.exists(`${root}/database/postgres`)).toBe(false); + }), + ), + ); + + it.live("continues a sole preparation after its waiter is interrupted", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-waiters-", + }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const source: ArtifactSource = { + materialize: (entry, destination) => + Effect.gen(function* () { + calls += 1; + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const first = yield* Effect.forkChild(store.prepare(request)); + yield* Deferred.await(started); + yield* Fiber.interrupt(first); + const second = yield* Effect.forkChild(store.prepare(request)); + yield* Deferred.succeed(release, undefined); + const result = yield* Fiber.join(second); + expect(result.outcome).toBe("downloaded"); + expect(calls).toBe(1); + const cached = yield* store.prepare(request); + expect(cached.outcome).toBe("cached"); + }), + ), + ); + + it.live("shares one in-flight preparation within a store instance", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-flight-", + }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const source: ArtifactSource = { + materialize: (entry, destination) => + Effect.gen(function* () { + calls += 1; + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const first = yield* Effect.forkChild(store.prepare(request)); + const second = yield* Effect.forkChild(store.prepare(request)); + yield* Deferred.await(started); + yield* Deferred.succeed(release, undefined); + const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); + expect(calls).toBe(1); + expect(results[0].path).toBe(results[1].path); + }), + ), + ); + + it.live("allows separate stores to duplicate work and converge safely", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-converge-", + }); + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const source: ArtifactSource = { + materialize: (entry, destination) => + Effect.gen(function* () { + calls += 1; + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + yield* calls === 1 + ? Deferred.succeed(firstStarted, undefined) + : Deferred.succeed(secondStarted, undefined); + yield* Deferred.await(release); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const firstStore = yield* makeArtifactStore({ cacheRoot: root, source }); + const secondStore = yield* makeArtifactStore({ cacheRoot: root, source }); + const first = yield* Effect.forkChild(firstStore.prepare(request)); + yield* Deferred.await(firstStarted); + const second = yield* Effect.forkChild(secondStore.prepare(request)); + yield* Deferred.await(secondStarted); + yield* Deferred.succeed(release, undefined); + const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); + expect(calls).toBe(2); + expect(results[0].path).toBe(results[1].path); + expect(yield* fs.exists(`${results[0].path}/bin/postgres`)).toBe(true); + }), + ), + ); + + it.live("rejects traversal in keys and required runtime paths before touching the source", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-artifact-path-" }); + let called = false; + const source: ArtifactSource = { + materialize: () => + Effect.sync(() => { + called = true; + return archive; + }), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const keyExit = yield* store.prepare({ ...request, key: "../escape" }).pipe(Effect.exit); + const pathExit = yield* store + .prepare({ ...request, requiredRuntimePaths: ["bin/../escape"] }) + .pipe(Effect.exit); + expect(errorOf(keyExit)).toBeInstanceOf(StackPreparationError); + expect(errorOf(pathExit)).toBeInstanceOf(StackPreparationError); + expect(called).toBe(false); + }), + ), + ); + + it.live("rejects a symlinked cache parent before invoking the source", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-artifact-link-" }); + const outside = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-outside-", + }); + yield* fs.symlink(outside, `${root}/nested`); + let called = false; + const source: ArtifactSource = { + materialize: () => + Effect.sync(() => { + called = true; + return archive; + }), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const exit = yield* store.prepare({ ...request, key: "nested/postgres" }).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(StackPreparationError); + expect(called).toBe(false); + expect(yield* fs.exists(`${outside}/postgres`)).toBe(false); + }), + ), + ); +}); diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts new file mode 100644 index 0000000000..cde7b27ab7 --- /dev/null +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -0,0 +1,123 @@ +import { Data, Effect, Option, Scope, Stream } from "effect"; +import { fileURLToPath } from "node:url"; +import { ChildProcess } from "effect/unstable/process"; +import type { PlatformError } from "effect/PlatformError"; +import type { + ChildProcessHandle, + ExitCode, + ProcessId, +} from "effect/unstable/process/ChildProcessSpawner"; + +export interface NativeProcessSpec { + readonly executable: string; + readonly args?: ReadonlyArray; + readonly env?: Readonly>; + readonly cwd?: string; +} + +/** + * Command used for the process-ownership launcher. Workload arguments and + * environment remain in the private fd4 payload; only this infrastructure + * command appears in the launcher process argv. + */ +export interface NativeProcessLauncher { + readonly command: string; + readonly args: ReadonlyArray; +} + +export class NativeProcessError extends Data.TaggedError("NativeProcessError")<{ + readonly message: string; + readonly executable?: string; + readonly cause?: unknown; +}> {} + +export interface NativeProcess { + readonly pid: ProcessId; + readonly stdout: Stream.Stream; + readonly stderr: Stream.Stream; + readonly exitCode: Effect.Effect; + readonly isRunning: Effect.Effect; + readonly kill: Effect.Effect; +} + +const launcherPath = fileURLToPath(new URL("./native-launcher.ts", import.meta.url)); + +export const defaultNativeProcessLauncher = (): NativeProcessLauncher => ({ + command: process.execPath, + args: [launcherPath], +}); + +const encodeSpec = (spec: NativeProcessSpec): Uint8Array => + new TextEncoder().encode( + JSON.stringify({ + executable: spec.executable, + args: spec.args ?? [], + env: spec.env, + cwd: spec.cwd, + }), + ); + +const mapProcessError = (error: unknown, spec: NativeProcessSpec): NativeProcessError => + new NativeProcessError({ + message: error instanceof Error ? error.message : String(error), + executable: spec.executable, + cause: error, + }); + +/** + * Starts one native process through a tiny parent-loss-aware launcher. The + * launcher receives an inherited pipe (fd3); closing this process scope closes + * that pipe and terminates the exact process group. Normal process-tree + * termination remains owned by Effect's ChildProcessSpawner. + */ +export const spawnNativeProcess = ( + spec: NativeProcessSpec, + launcher: NativeProcessLauncher = defaultNativeProcessLauncher(), +): Effect.Effect< + NativeProcess, + NativeProcessError, + import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const handle: ChildProcessHandle = yield* ChildProcess.make(launcher.command, launcher.args, { + cwd: spec.cwd, + detached: true, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + additionalFds: { + fd3: { type: "input" }, + fd4: { type: "input" }, + }, + }); + yield* Stream.run(Stream.succeed(encodeSpec(spec)), handle.getInputFd(4)); + const mapError = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe(Effect.mapError((error) => mapProcessError(error, spec))); + const mapStreamError = ( + stream: Stream.Stream, + ): Stream.Stream => + stream.pipe(Stream.mapError((error) => mapProcessError(error, spec))); + return { + pid: handle.pid, + stdout: mapStreamError(handle.stdout), + stderr: mapStreamError(handle.stderr), + exitCode: mapError(handle.exitCode), + isRunning: mapError(handle.isRunning), + kill: mapError( + Effect.gen(function* () { + // NodeChildProcessSpawner owns the exact process group. Its kill + // effect sends the signal and waits for the launcher exit event; + // bound that wait before forcing the same group. + const graceful = yield* handle + .kill({ killSignal: "SIGTERM" }) + .pipe(Effect.timeoutOption("2 seconds")); + if (Option.isNone(graceful)) { + const running = yield* handle.isRunning; + if (running) yield* handle.kill({ killSignal: "SIGKILL" }); + } + }), + ), + } satisfies NativeProcess; + }).pipe(Effect.mapError((error) => mapProcessError(error, spec))); diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts new file mode 100644 index 0000000000..623ea31e4e --- /dev/null +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -0,0 +1,434 @@ +import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scope, Semaphore, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; +import type { DatabaseBootstrapOptions, DatabaseSession } from "../model/DatabaseBootstrap.ts"; +import { runDatabaseBootstrap } from "../model/DatabaseBootstrap.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { StackId } from "../public/StackId.ts"; +import type { LogStore } from "../supervisor/LogStore.ts"; +import { LogStoreError } from "../supervisor/LogStore.ts"; +import { + RuntimeDriverError, + type ObservedWorkload, + type RuntimeDriver, + type RuntimeWorkloadKey, +} from "./RuntimeDriver.ts"; +import { + NativeProcessError, + spawnNativeProcess, + type NativeProcess, + type NativeProcessLauncher, + type NativeProcessSpec, +} from "./NativeProcess.ts"; + +export type NativeWorkload = PlannedWorkload; + +export interface NativeRuntimeOptions { + /** + * Resolves the process-ownership launcher. Development defaults use the + * bundled native-launcher entrypoint; packaged CLIs can provide their own + * command/entrypoint without changing process ownership semantics. + */ + readonly resolveLauncher?: ( + key: RuntimeWorkloadKey, + workload: NativeWorkload, + ) => Effect.Effect; + /** Resolves the complete native process command for one workload. */ + readonly resolveProcess: ( + key: RuntimeWorkloadKey, + workload: NativeWorkload, + ) => Effect.Effect; + /** Private readiness is resolved by the owning Supervisor/gateway seam. */ + readonly waitForReadiness: ( + key: RuntimeWorkloadKey, + workload: NativeWorkload, + process: NativeProcess, + ) => Effect.Effect; + /** A ready-database session is required for the database bootstrap marker. */ + readonly resolveDatabaseSession?: ( + key: RuntimeWorkloadKey, + workload: NativeWorkload, + process: NativeProcess, + ) => Effect.Effect; + readonly resolveDatabaseBootstrap?: ( + key: RuntimeWorkloadKey, + workload: NativeWorkload, + ) => Effect.Effect; + readonly logStore?: LogStore; +} + +interface OutputAccumulator { + readonly decoder: TextDecoder; + remainder: string; +} + +interface Resource { + readonly key: RuntimeWorkloadKey; + readonly workload: NativeWorkload; + readonly scope: Scope.Closeable; + readonly state: Ref.Ref; + readonly output: Readonly<{ stdout: OutputAccumulator; stderr: OutputAccumulator }>; + readonly result: Deferred.Deferred; + readonly failure: Deferred.Deferred; + stopRequested: boolean; + process?: NativeProcess; + startFiber?: Fiber.Fiber; +} + +const resourceKey = (key: RuntimeWorkloadKey): string => + JSON.stringify([key.stackId, key.desiredGeneration, key.workloadId, key.specHash]); + +const sameKey = (left: RuntimeWorkloadKey, right: RuntimeWorkloadKey): boolean => + left.stackId === right.stackId && + left.desiredGeneration === right.desiredGeneration && + left.workloadId === right.workloadId && + left.specHash === right.specHash; + +const driverError = ( + key: Pick, + message: string, + cause?: unknown, +): RuntimeDriverError => + new RuntimeDriverError({ + message, + stackId: key.stackId, + workloadId: key.workloadId, + ...(cause === undefined ? {} : { cause }), + }); + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const processError = ( + error: unknown, + key: RuntimeWorkloadKey, + operation: string, +): RuntimeDriverError => + driverError( + key, + `Native ${operation} failed for ${key.workloadId}: ${errorMessage(error)}`, + error, + ); + +const appendLines = ( + logStore: LogStore, + resource: Resource, + stream: "stdout" | "stderr", + bytes: Uint8Array, +): Effect.Effect => { + const accumulator = resource.output[stream]; + accumulator.remainder += accumulator.decoder.decode(bytes, { stream: true }); + const lines = accumulator.remainder.split(/\r?\n/); + accumulator.remainder = lines.pop() ?? ""; + return Effect.forEach( + lines, + (message) => logStore.append({ source: resource.workload.capability, stream, message }), + { discard: true }, + ); +}; + +const flushLines = ( + logStore: LogStore, + resource: Resource, + stream: "stdout" | "stderr", +): Effect.Effect => { + const accumulator = resource.output[stream]; + accumulator.remainder += accumulator.decoder.decode(); + if (accumulator.remainder.length === 0) return Effect.void; + const message = accumulator.remainder; + accumulator.remainder = ""; + return logStore + .append({ source: resource.workload.capability, stream, message }) + .pipe(Effect.asVoid); +}; + +const observeOutput = ( + logStore: LogStore, + resource: Resource, + process: NativeProcess, + stream: "stdout" | "stderr", +) => + Stream.runForEach(process[stream], (bytes) => + appendLines(logStore, resource, stream, bytes), + ).pipe(Effect.andThen(flushLines(logStore, resource, stream))); + +/** Creates a Supervisor-owned native runtime with exact process identity fencing. */ +export const makeNativeRuntime = ( + options: NativeRuntimeOptions, +): Effect.Effect< + RuntimeDriver, + RuntimeDriverError, + ChildProcessSpawnerService.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const parentScope = yield* Scope.Scope; + const runtimeScope = yield* Scope.fork(parentScope, "parallel"); + const lifecycle = yield* Semaphore.make(1); + const resources = new Map(); + + const cleanup = (resource: Resource): Effect.Effect => + Scope.close(resource.scope, Exit.void).pipe( + Effect.asVoid, + Effect.andThen( + Effect.sync(() => { + const id = resourceKey(resource.key); + if (resources.get(id) === resource) resources.delete(id); + }), + ), + ); + + const watchProcess = (resource: Resource, process: NativeProcess) => + Effect.gen(function* () { + const result = yield* process.exitCode.pipe(Effect.exit); + const next: ObservedWorkload = Exit.isSuccess(result) + ? { + ...resource.key, + state: resource.stopRequested ? "stopped" : "failed", + ...(resource.stopRequested + ? {} + : { error: "Native workload exited before an explicit stop" }), + } + : { + ...resource.key, + state: resource.stopRequested ? "stopped" : "failed", + ...(resource.stopRequested ? {} : { error: Cause.pretty(result.cause) }), + }; + yield* Ref.update(resource.state, (current): ObservedWorkload => + current.state === "failed" ? current : next, + ); + }).pipe(Effect.ignore); + + const reportLogFailure = ( + resource: Resource, + error: LogStoreError | NativeProcessError, + ): Effect.Effect => { + const failure = driverError( + resource.key, + `Native log stream failed for ${resource.key.workloadId}: ${error.message}`, + error, + ); + return Ref.update(resource.state, (current): ObservedWorkload => + current.state === "stopped" || current.state === "failed" + ? current + : { ...current, state: "failed", error: failure.message }, + ).pipe(Effect.andThen(Deferred.fail(resource.failure, failure))); + }; + + const attachLogs = (resource: Resource, process: NativeProcess) => { + const logStore = options.logStore; + if (logStore === undefined) return Effect.void; + const runOutput = (stream: "stdout" | "stderr") => + observeOutput(logStore, resource, process, stream).pipe( + Effect.catch((error) => reportLogFailure(resource, error)), + ); + return Effect.all([runOutput("stdout"), runOutput("stderr")], { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.forkIn(resource.scope), Effect.asVoid); + }; + + const runStart = (resource: Resource): Effect.Effect => + Effect.gen(function* () { + const { key, workload } = resource; + const operation = Effect.gen(function* () { + const launcher = + options.resolveLauncher === undefined + ? undefined + : yield* options.resolveLauncher(key, workload); + const process = yield* options.resolveProcess(key, workload).pipe( + Effect.flatMap((spec) => spawnNativeProcess(spec, launcher)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), + Scope.provide(resource.scope), + Effect.mapError((error) => processError(error, key, "spawn")), + ); + resource.process = process; + yield* Effect.forkIn(watchProcess(resource, process), resource.scope); + yield* attachLogs(resource, process); + const readiness = options.waitForReadiness; + const exitedBeforeReady = process.exitCode.pipe( + Effect.flatMap((code) => + Effect.fail( + driverError( + key, + `Native workload ${key.workloadId} exited before readiness (${String(code)})`, + ), + ), + ), + ); + yield* Effect.raceFirst( + Effect.raceFirst(readiness(key, workload, process), Deferred.await(resource.failure)), + exitedBeforeReady, + ); + const running = yield* process.isRunning.pipe( + Effect.mapError((error) => processError(error, key, "probe")), + ); + if (!running) + return yield* driverError( + key, + `Native workload ${key.workloadId} exited before readiness`, + ); + if (resource.stopRequested) + return yield* driverError(key, "Native workload was stopped while starting"); + if (workload.bootstrap === "database") { + if (options.resolveDatabaseSession === undefined) + return yield* driverError( + key, + "Database bootstrap session resolver is not configured", + ); + if (options.resolveDatabaseBootstrap === undefined) + return yield* driverError(key, "Database bootstrap plan resolver is not configured"); + const session = yield* options.resolveDatabaseSession(key, workload, process); + if (session === undefined) + return yield* driverError(key, "Database bootstrap session is unavailable"); + const plan = yield* options.resolveDatabaseBootstrap(key, workload); + yield* runDatabaseBootstrap(session, plan).pipe( + Effect.mapError((error) => driverError(key, error.message, error)), + ); + } + if (resource.stopRequested) + return yield* driverError(key, "Native workload was stopped while starting"); + if (yield* Deferred.isDone(resource.failure)) + return yield* Deferred.await(resource.failure); + const ready = yield* Ref.modify(resource.state, (current) => { + if (current.state !== "starting") return [undefined, current]; + const next: ObservedWorkload = { ...current, state: "ready" }; + return [next, next]; + }); + if (ready === undefined) + return yield* driverError(key, "Native workload exited or stopped before readiness"); + return ready; + }); + const result = yield* operation.pipe( + Effect.mapError((error) => + error instanceof RuntimeDriverError + ? error + : driverError(key, `Native workload ${key.workloadId} failed`, error), + ), + Effect.exit, + ); + if (Exit.isFailure(result)) { + const failed: ObservedWorkload = { + ...resource.key, + state: resource.stopRequested ? "stopped" : "failed", + ...(resource.stopRequested ? {} : { error: Cause.pretty(result.cause) }), + }; + yield* Ref.set(resource.state, failed); + yield* cleanup(resource); + } + yield* Deferred.done(resource.result, result); + }); + + const start = ( + key: RuntimeWorkloadKey, + workload: NativeWorkload, + ): Effect.Effect => + Effect.flatMap( + lifecycle.withPermit( + Effect.gen(function* () { + const id = resourceKey(key); + const existing = resources.get(id); + if (existing !== undefined) { + const current = yield* Ref.get(existing.state); + if (current.state !== "stopped" && current.state !== "failed") return existing; + yield* cleanup(existing); + } + const processScope = yield* Scope.fork(runtimeScope, "parallel"); + const state = yield* Ref.make({ ...key, state: "starting" }); + const result = yield* Deferred.make(); + const failure = yield* Deferred.make(); + const resource: Resource = { + key, + workload, + scope: processScope, + state, + result, + failure, + output: { + stdout: { decoder: new TextDecoder(), remainder: "" }, + stderr: { decoder: new TextDecoder(), remainder: "" }, + }, + stopRequested: false, + }; + resources.set(id, resource); + resource.startFiber = yield* Effect.forkIn(runStart(resource), runtimeScope); + return resource; + }), + ), + (resource) => Deferred.await(resource.result), + ); + + const observe = ( + stackId: StackId, + ): Effect.Effect, RuntimeDriverError> => + Effect.forEach( + [...resources.values()].filter((resource) => resource.key.stackId === stackId), + (resource) => Ref.get(resource.state), + ); + + const stop = (key: RuntimeWorkloadKey): Effect.Effect => + lifecycle.withPermit( + Effect.gen(function* () { + const resource = resources.get(resourceKey(key)); + if (resource === undefined) return; + if (!sameKey(resource.key, key)) + return yield* driverError(key, "Native workload identity mismatch"); + resource.stopRequested = true; + if (resource.startFiber !== undefined) { + yield* Deferred.fail( + resource.result, + driverError(key, "Native workload was stopped while starting"), + ); + } + const nativeProcess = resource.process; + const killResult = + nativeProcess === undefined + ? Exit.succeed(undefined) + : yield* nativeProcess.isRunning.pipe( + Effect.mapError((error) => processError(error, key, "probe")), + Effect.flatMap((running) => + running + ? nativeProcess.kill.pipe( + Effect.mapError((error) => processError(error, key, "stop")), + ) + : Effect.void, + ), + Effect.exit, + ); + if (resource.startFiber !== undefined) { + yield* Fiber.interrupt(resource.startFiber); + } + if (Exit.isFailure(killResult)) { + yield* Ref.update(resource.state, (current): ObservedWorkload => ({ + ...current, + state: "failed", + error: Cause.pretty(killResult.cause), + })); + return yield* Effect.failCause(killResult.cause); + } + yield* Ref.update(resource.state, (current): ObservedWorkload => ({ + ...current, + state: "stopped", + })); + }), + ); + + const remove = (key: RuntimeWorkloadKey): Effect.Effect => + lifecycle.withPermit( + Effect.gen(function* () { + const resource = resources.get(resourceKey(key)); + if (resource === undefined) return; + if (!sameKey(resource.key, key)) + return yield* driverError(key, "Native workload identity mismatch"); + resource.stopRequested = true; + if (resource.startFiber !== undefined) { + yield* Fiber.interrupt(resource.startFiber); + } + yield* cleanup(resource); + }), + ); + + return { observe, start, stop, remove } satisfies RuntimeDriver; + }); + +export const makeNativeRuntimeDriver = makeNativeRuntime; diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index 471143e8b1..bb736bfa7e 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -40,6 +40,7 @@ export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ readonly message: string; readonly stackId?: StackId; readonly workloadId?: string; + readonly cause?: unknown; }> {} export class RuntimeGenerationMismatchError extends Data.TaggedError( diff --git a/packages/stack/src/runtime/native-launcher.ts b/packages/stack/src/runtime/native-launcher.ts new file mode 100644 index 0000000000..330bf2055d --- /dev/null +++ b/packages/stack/src/runtime/native-launcher.ts @@ -0,0 +1,101 @@ +// Standalone launcher boundary: this process must remain usable before the +// Effect runtime exists and therefore talks to the host directly. +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createReadStream } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { spawn } from "node:child_process"; + +interface LaunchSpec { + readonly executable: string; + readonly args: ReadonlyArray; + readonly env?: Readonly>; + readonly cwd?: string; +} + +const isRecord = (candidate: unknown): candidate is Record => + typeof candidate === "object" && candidate !== null && !Array.isArray(candidate); + +const decodeSpec = (bytes: Buffer): LaunchSpec | undefined => { + try { + const value: unknown = JSON.parse(bytes.toString("utf8")); + if (!isRecord(value) || typeof value.executable !== "string") return undefined; + const args: string[] = []; + if (value.args !== undefined) { + if (!Array.isArray(value.args)) return undefined; + for (const arg of value.args) { + if (typeof arg !== "string") return undefined; + args.push(arg); + } + } + if (value.cwd !== undefined && typeof value.cwd !== "string") return undefined; + let env: Record | undefined; + if (value.env !== undefined) { + if (!isRecord(value.env)) return undefined; + env = {}; + for (const [name, entry] of Object.entries(value.env)) { + if (entry !== undefined && typeof entry !== "string") return undefined; + env[name] = entry; + } + } + return { + executable: value.executable, + args, + cwd: value.cwd, + env, + }; + } catch { + return undefined; + } +}; + +let child: ReturnType | undefined; +let terminating = false; + +const terminateGroup = (signal: NodeJS.Signals): void => { + if (terminating) return; + terminating = true; + if (process.platform === "win32") { + spawn("taskkill", ["/pid", String(process.pid), "/T", "/F"], { stdio: "ignore" }); + return; + } + try { + // The launcher is the detached process-group leader, so this targets only + // the process tree created for this workload. + process.kill(-process.pid, signal); + } catch { + child?.kill(signal); + } +}; + +// Register the owner pipe before waiting for the launch payload. EOF means +// the owner process disappeared without running a normal scope finalizer. +const ownerPipe = createReadStream("", { fd: 3, autoClose: false }); +// Owner-loss is an abrupt supervisor crash, not an explicit graceful stop. +// Use guaranteed tree termination so descendants that trap SIGTERM cannot be +// orphaned after the owner pipe closes. +ownerPipe.on("end", () => terminateGroup("SIGKILL")); +ownerPipe.on("error", () => terminateGroup("SIGKILL")); +ownerPipe.resume(); + +const payloadPipe = createReadStream("", { fd: 4, autoClose: true }); +const payload: Buffer[] = []; +payloadPipe.on("data", (chunk: Buffer) => payload.push(chunk)); +payloadPipe.on("error", () => process.exit(127)); +payloadPipe.on("end", () => { + const spec = decodeSpec(Buffer.concat(payload)); + if (spec === undefined || terminating) { + process.exit(127); + return; + } + child = spawn(spec.executable, [...spec.args], { + cwd: spec.cwd, + env: spec.env === undefined ? process.env : { ...spec.env }, + detached: false, + stdio: ["ignore", "inherit", "inherit"], + }); + child.on("error", () => process.exit(127)); + child.on("exit", (code, signal) => { + ownerPipe.destroy(); + process.exit(code ?? (signal === null ? 1 : 1)); + }); +}); diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts new file mode 100644 index 0000000000..7765f4d09b --- /dev/null +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -0,0 +1,473 @@ +import { NodeServices, NodeSocket } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Deferred, Effect, Exit, Fiber, FileSystem, Path, Ref, Stream } from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import { LogStoreError, makeLogStore, type LogStore } from "../supervisor/LogStore.ts"; +import type { + DatabaseBootstrapOptions, + DatabaseSession, + DatabaseTransaction, +} from "../model/DatabaseBootstrap.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; +import { RuntimeDriverError } from "./RuntimeDriver.ts"; +import { makeNativeRuntime } from "./NativeRuntime.ts"; +import { defaultNativeProcessLauncher, type NativeProcess } from "./NativeProcess.ts"; + +const stackId = StackIdSchema.make("d".repeat(64)); + +class ProcessTreeTestError extends Data.TaggedError("ProcessTreeTestError")<{ + readonly message: string; +}> {} + +const workload = (id: string, bootstrap?: "database"): PlannedWorkload => ({ + id, + capability: "database", + ...(bootstrap === undefined ? {} : { bootstrap }), + dependencies: [], + readiness: { mode: "tcp" }, + restart: { maxAttempts: 1, backoffMs: 0 }, + artifacts: { + native: { kind: "native", service: id, release: "test" }, + container: { kind: "container", service: id, image: `test/${id}` }, + }, + selected: { kind: "native", service: id, release: "test" }, + specHash: id, +}); + +const keyFor = (id: string): RuntimeWorkloadKey => ({ + stackId, + desiredGeneration: 1, + workloadId: `database:${id}`, + specHash: id, +}); + +const fixtureProcess = (message: string) => ({ + executable: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(`${message}\n`)}); process.stderr.write(${JSON.stringify("stderr\n")}); setInterval(() => {}, 1000)`, + ], +}); + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +describe("native runtime", () => { + it.live("starts, captures redacted output, and terminates exact workloads", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-runtime-" }); + const logStore = yield* makeLogStore({ + path: path.join(root, "logs.json"), + knownSecrets: ["secret-value"], + }); + let startedProcess: NativeProcess | undefined; + const runtime = yield* makeNativeRuntime({ + resolveProcess: (_key, _entry) => Effect.succeed(fixtureProcess("secret-value")), + logStore, + waitForReadiness: (_key, _workload, process) => + Effect.sync(() => { + startedProcess = process; + }), + }); + const key = keyFor("one"); + const logs = yield* logStore + .stream({ follow: true }) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkChild({ startImmediately: true })); + const ready = yield* runtime.start(key, workload("one")); + expect(ready.state).toBe("ready"); + const firstLog = (yield* Fiber.join(logs))[0]; + expect(firstLog?.message).toBe("[REDACTED]"); + const values = yield* runtime.observe(stackId); + expect(values).toHaveLength(1); + yield* runtime.stop(key); + expect((yield* runtime.observe(stackId))[0]?.state).toBe("stopped"); + expect(startedProcess).toBeDefined(); + if (startedProcess !== undefined) expect(yield* startedProcess.isRunning).toBe(false); + yield* runtime.remove(key); + expect(yield* runtime.observe(stackId)).toEqual([]); + expect((yield* logStore.read()).map((entry) => entry.message)).toContain("[REDACTED]"); + }), + ), + ); + + it.live("cleans up when readiness fails", () => + withPlatform( + Effect.gen(function* () { + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(fixtureProcess("fails")), + waitForReadiness: (key) => + Effect.fail( + new RuntimeDriverError({ + message: "fixture readiness failed", + stackId: key.stackId, + workloadId: key.workloadId, + }), + ), + }); + const result = yield* runtime.start(keyFor("fails"), workload("fails")).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + + it.live("fails readiness when a native process exits nonzero", () => + withPlatform( + Effect.gen(function* () { + const readiness = yield* Deferred.make(); + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + executable: process.execPath, + args: ["-e", "process.stderr.write('native failed\\n'); process.exit(3)"], + }), + waitForReadiness: () => Deferred.await(readiness), + }); + const result = yield* runtime + .start(keyFor("nonzero"), workload("nonzero")) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + + it.live("restarts a stopped exact key with a fresh process", () => + withPlatform( + Effect.gen(function* () { + let launches = 0; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.sync(() => { + launches += 1; + return fixtureProcess(`restart-${launches}`); + }), + waitForReadiness: () => Effect.void, + }); + const key = keyFor("restart"); + yield* runtime.start(key, workload("restart")); + yield* runtime.stop(key); + const restarted = yield* runtime.start(key, workload("restart")); + expect(restarted.state).toBe("ready"); + expect(launches).toBe(2); + yield* runtime.remove(key); + }), + ), + ); + + it.live("keeps a shared start alive when one caller is interrupted", () => + withPlatform( + Effect.gen(function* () { + const spawned = yield* Deferred.make(); + const readiness = yield* Deferred.make(); + let launches = 0; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.sync(() => { + launches += 1; + return fixtureProcess("shared-start"); + }).pipe(Effect.tap(() => Deferred.succeed(spawned, undefined))), + waitForReadiness: () => Deferred.await(readiness), + }); + const key = keyFor("shared-start"); + const firstCaller = yield* runtime + .start(key, workload("shared-start")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(spawned); + yield* Fiber.interrupt(firstCaller); + yield* Deferred.succeed(readiness, undefined); + const ready = yield* runtime.start(key, workload("shared-start")); + expect(ready.state).toBe("ready"); + expect(launches).toBe(1); + yield* runtime.remove(key); + }), + ), + ); + + it.live("fails startup when log persistence fails before readiness", () => + withPlatform( + Effect.gen(function* () { + const readiness = yield* Deferred.make(); + const logStore: LogStore = { + path: "memory://failing-log-store", + append: () => + Effect.fail( + new LogStoreError({ path: "memory://failing-log-store", message: "disk full" }), + ), + read: () => Effect.succeed([]), + retained: () => Effect.succeed([]), + stream: () => Stream.empty, + }; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(fixtureProcess("log-failure")), + waitForReadiness: () => Deferred.await(readiness), + logStore, + }); + const result = yield* runtime + .start(keyFor("log-failure"), workload("log-failure")) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + + it.live("marks a ready workload failed when log persistence fails afterward", () => + withPlatform( + Effect.gen(function* () { + const appendStarted = yield* Deferred.make(); + const appendFailure = yield* Deferred.make(); + const logStore: LogStore = { + path: "memory://failing-log-store-after-ready", + append: () => + Effect.gen(function* () { + yield* Deferred.succeed(appendStarted, undefined); + return yield* Deferred.await(appendFailure); + }), + read: () => Effect.succeed([]), + retained: () => Effect.succeed([]), + stream: () => Stream.empty, + }; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(fixtureProcess("log-failure-after-ready")), + waitForReadiness: () => Effect.void, + logStore, + }); + const key = keyFor("log-failure-after-ready"); + const ready = yield* runtime.start(key, workload("log-failure-after-ready")); + expect(ready.state).toBe("ready"); + yield* Deferred.await(appendStarted); + yield* Deferred.fail( + appendFailure, + new LogStoreError({ + path: logStore.path, + message: "disk full", + }), + ); + yield* Effect.yieldNow; + const observed = yield* runtime.observe(stackId); + expect(observed[0]?.state).toBe("failed"); + yield* runtime.remove(key); + }), + ), + ); + + it.live("runs database bootstrap before reporting readiness", () => + withPlatform( + Effect.gen(function* () { + let applied = false; + const session: DatabaseSession = { + execute: () => Effect.void, + transaction: (use) => + Effect.gen(function* () { + let record = false; + const transaction: DatabaseTransaction = { + execute: (statement) => + Effect.sync(() => { + if (statement.includes("INSERT INTO")) record = true; + }), + setRolePassword: () => Effect.void, + query: () => Effect.succeed(applied ? [{ revision: "fixture" }] : []), + }; + yield* use(transaction); + if (record) applied = true; + }), + }; + const bootstrap: DatabaseBootstrapOptions = { + revisions: [{ id: "fixture", statement: "CREATE TABLE fixture" }], + }; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(fixtureProcess("database")), + waitForReadiness: () => Effect.void, + resolveDatabaseSession: () => Effect.succeed(session), + resolveDatabaseBootstrap: () => Effect.succeed(bootstrap), + }); + const key = keyFor("bootstrap"); + const ready = yield* runtime.start(key, workload("bootstrap", "database")); + expect(applied).toBe(true); + expect(ready.state).toBe("ready"); + yield* runtime.stop(key); + yield* runtime.remove(key); + }), + ), + ); + + it.live("requires a database session and plan for marked workloads", () => + withPlatform( + Effect.gen(function* () { + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(fixtureProcess("database")), + waitForReadiness: () => Effect.void, + }); + const result = yield* runtime + .start(keyFor("missing-bootstrap"), workload("missing-bootstrap", "database")) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + + it.live("isolates exact workload identities while stopping one", () => + withPlatform( + Effect.gen(function* () { + const runtime = yield* makeNativeRuntime({ + resolveProcess: (_key, entry) => Effect.succeed(fixtureProcess(entry.id)), + waitForReadiness: () => Effect.void, + }); + const first = keyFor("isolated-one"); + const second = keyFor("isolated-two"); + yield* Effect.all( + [ + runtime.start(first, workload("isolated-one")), + runtime.start(second, workload("isolated-two")), + ], + { concurrency: "unbounded" }, + ); + yield* runtime.stop(first); + expect( + (yield* runtime.observe(stackId)).map((entry) => [entry.workloadId, entry.state]), + ).toEqual([ + ["database:isolated-one", "stopped"], + ["database:isolated-two", "ready"], + ]); + yield* runtime.remove(first); + yield* runtime.remove(second); + }), + ), + ); + + // The helper below is a raw Node launcher fixture; its JSON is the same fd4 + // payload covered by NativeProcess integration, not product serialization. + // oxlint-disable effecttsgo/prefer-schema-over-json + it.live("kills the native process tree when its owner pipe closes", () => + withPlatform( + Effect.gen(function* () { + const targetLauncher = defaultNativeProcessLauncher(); + const descendantCode = ` + const net = require("node:net"); + const server = net.createServer(); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address(); + if (typeof address === "object" && address !== null) { + process.stdout.write(\`DESC_READY \${address.port}\\n\`); + } + }); + `; + const targetCode = ` + const net = require("node:net"); + const { spawn } = require("node:child_process"); + const server = net.createServer(); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address(); + if (typeof address === "object" && address !== null) { + process.stdout.write(\`TARGET_READY \${address.port}\\n\`); + } + const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendantCode)}], { + stdio: ["ignore", "pipe", "inherit"] + }); + child.stdout.on("data", (chunk) => process.stdout.write(chunk)); + }); + `; + const ownerCode = ` + const { spawn } = require("node:child_process"); + const launcherProcess = spawn(${JSON.stringify(targetLauncher.command)}, ${JSON.stringify(targetLauncher.args)}, { + detached: true, + stdio: ["ignore", "inherit", "inherit", "pipe", "pipe"] + }); + launcherProcess.stdio[4].end(JSON.stringify({ + executable: process.execPath, + args: ["-e", ${JSON.stringify(targetCode)}] + })); + setInterval(() => {}, 1000); + `; + const owner = yield* ChildProcess.make(process.execPath, ["-e", ownerCode], { + stdout: "pipe", + stderr: "pipe", + detached: true, + }); + const stderr = yield* Ref.make(""); + yield* owner.stderr.pipe( + Stream.decodeText, + Stream.runForEach((chunk) => Ref.update(stderr, (current) => current + chunk)), + Effect.forkChild({ startImmediately: true }), + ); + const ready = yield* owner.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.takeUntil((line) => line.startsWith("DESC_READY ")), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + const output = yield* Fiber.join(ready).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => + Effect.gen(function* () { + const diagnostics = yield* Ref.get(stderr); + return yield* new ProcessTreeTestError({ + message: `native launcher readiness timed out: ${diagnostics}`, + }); + }), + }), + ); + const lines = [...output]; + const targetLine = lines.find((line) => line.startsWith("TARGET_READY ")); + const descendantLine = lines.find((line) => line.startsWith("DESC_READY ")); + expect(targetLine).toBeDefined(); + expect(descendantLine).toBeDefined(); + const targetPort = Number.parseInt(targetLine?.slice("TARGET_READY ".length) ?? "", 10); + const descendantPort = Number.parseInt( + descendantLine?.slice("DESC_READY ".length) ?? "", + 10, + ); + expect(Number.isSafeInteger(targetPort)).toBe(true); + expect(Number.isSafeInteger(descendantPort)).toBe(true); + const targetSocket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port: targetPort }); + const descendantSocket = yield* NodeSocket.makeNet({ + host: "127.0.0.1", + port: descendantPort, + }); + const targetClosedSignal = yield* Deferred.make(); + const targetClosed = yield* targetSocket + .runRaw(() => Effect.void, { + onOpen: Deferred.succeed(targetClosedSignal, undefined), + }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(targetClosedSignal); + const descendantClosedSignal = yield* Deferred.make(); + const descendantClosed = yield* descendantSocket + .runRaw(() => Effect.void, { + onOpen: Deferred.succeed(descendantClosedSignal, undefined), + }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(descendantClosedSignal); + yield* owner.kill({ killSignal: "SIGKILL" }); + yield* Fiber.join(targetClosed).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => + Effect.fail( + new ProcessTreeTestError({ message: "target process tree did not close" }), + ), + }), + ); + yield* Fiber.join(descendantClosed).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => + Effect.fail( + new ProcessTreeTestError({ message: "descendant process tree did not close" }), + ), + }), + ); + }), + ), + ); + // oxlint-enable effecttsgo/prefer-schema-over-json +}); From a06b1ab016ec749feca5720a4d5e8a99b8329eb7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 02:46:32 +0200 Subject: [PATCH 015/174] feat(stack): add gateway and lazy activation --- ...26-08-28-stack-runtime-rewrite-progress.md | 33 ++ .../plans/2026-08-28-stack-runtime-rewrite.md | 6 +- packages/stack/src/gateway/ActivationFile.ts | 167 ++++++ .../stack/src/gateway/ActivationServer.ts | 515 ++++++++++++++++++ packages/stack/src/gateway/Gateway.ts | 187 +++++++ packages/stack/src/gateway/HttpGateway.ts | 436 +++++++++++++++ packages/stack/src/gateway/TcpGateway.ts | 207 +++++++ .../gateway/activation.integration.test.ts | 460 ++++++++++++++++ .../src/gateway/gateway.integration.test.ts | 484 ++++++++++++++++ packages/stack/src/model/ExecutionPlan.ts | 15 + packages/stack/src/state/PortCoordinator.ts | 68 ++- .../stack/src/state/ports.integration.test.ts | 202 +++++-- .../src/supervisor/Reconciler.unit.test.ts | 12 + .../supervisor/reconciler.integration.test.ts | 12 + 14 files changed, 2718 insertions(+), 86 deletions(-) create mode 100644 packages/stack/src/gateway/ActivationFile.ts create mode 100644 packages/stack/src/gateway/ActivationServer.ts create mode 100644 packages/stack/src/gateway/Gateway.ts create mode 100644 packages/stack/src/gateway/HttpGateway.ts create mode 100644 packages/stack/src/gateway/TcpGateway.ts create mode 100644 packages/stack/src/gateway/activation.integration.test.ts create mode 100644 packages/stack/src/gateway/gateway.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index fdc408176e..d7e5784865 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -395,3 +395,36 @@ Implementation continues after recording a ruling; this file is not a question q - Formatting over all Task 7 source/test files and `git diff --check` — passed. - Independent re-review found no Critical or Important findings; all earlier lifecycle, bootstrap, credential, logging, cleanup, and cancellation findings are closed. + +### Task 8 — 2026-08-29 + +- Added internal HTTP, WebSocket, and transparent TCP gateways with injected capability routes and + private backend resolution. HTTP owns CORS, forwarding headers, health/status dormancy, and + 503/502 mapping; TCP preserves opaque PostgreSQL, TLS, SMTP, POP3, STARTTLS, backpressure, and + half-close behavior. +- Added mandatory one-way lazy activation per desired generation. Concurrent callers join one + owner-scoped activation fiber, dependency closures activate once, successful results remain live, + failed attempts may retry, waiter interruption does not cancel shared work, and owner-scope + closure interrupts it exactly. +- Added the container activation-only protocol: bounded exact-schema frames, 16-request concurrency, + TestClock-verifiable five-second deadlines, capability/StackId/generation/gateway/session fences, + generic wire errors, and a high-entropy Redacted capability. The owner-only activation file uses + exact decoding, pre/post-read size bounds, atomic publication, immediate temporary cleanup, and + fenced finalization that cannot delete a rotated successor. +- Native port reservations now carry the exact pre-bound HTTP/TCP listener into the gateway. TCP + transfer requires `allowHalfOpen: true`; shutdown is single-flight, closes retained keep-alive and + tunnel sockets, invokes the listener release, and never performs a bind-close-rebind. A failed or + interrupted final generation fence closes the operation-owned listener scope before returning. +- Review fixes added partial gateway-acquisition rollback, complete foreign-callback listener + cleanup, connection-before-listener race removal, post-header backend failure handling, adopted + listener ownership, idle keep-alive cleanup, deterministic deadline coverage, large-stream + transfer coverage, and final-fence listener cleanup. Final independent re-review found no Critical + or Important findings. +- RED evidence: both gateway suites initially failed because the new modules did not exist; added + lifecycle and protocol regressions failed against the first implementation before each fix. +- `pnpm --dir packages/stack types:check` — passed. +- Focused gateway, activation, port, and reconciler suite — passed (45 tests across 5 files). +- `pnpm --dir packages/stack test` — passed (4 unit and 156 integration tests). +- Effect/generic lint over all Task 8 sources and tests, formatting, and `git diff --check` — passed. +- Concrete capability route catalogs remain Task 14; Functions discovery, container engine routing, + and full Supervisor lifecycle composition remain Tasks 9, 10, and 11 respectively. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index 25a78e2087..300876ac44 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -459,20 +459,20 @@ artifact keys, state schemas, drivers, or control protocols. - Native activation calls Supervisor directly; container activation uses an ephemeral exact-release authenticated TCP server and read-only fenced file with activation-only authority. -- [ ] **Step 1: Write observable protocol scenarios and verify RED** +- [x] **Step 1: Write observable protocol scenarios and verify RED** Cover HTTP routes/CORS/forwarding/WebSocket, PostgreSQL/TLS/SMTP/POP3/STARTTLS byte transparency, backpressure/half-close, 503 activation failure, 502 backend failure, dormant health probes, dependency-closure activation, generation/session/gateway fences, bounded frames/concurrency/ deadlines, and no idle eviction. -- [ ] **Step 2: Implement ingress and activation** +- [x] **Step 2: Implement ingress and activation** Use stream/socket platform APIs with scoped cancellation. Capability files contain no labels or command-line secrets and rotate with each owner/gateway. The activation server exposes only one activation operation and has no runtime/state/filesystem/log authority. -- [ ] **Step 3: Verify and commit** +- [x] **Step 3: Verify and commit** Run both gateway integration files and stack type-check, then commit: diff --git a/packages/stack/src/gateway/ActivationFile.ts b/packages/stack/src/gateway/ActivationFile.ts new file mode 100644 index 0000000000..62e061ba8d --- /dev/null +++ b/packages/stack/src/gateway/ActivationFile.ts @@ -0,0 +1,167 @@ +import { Crypto, Effect, FileSystem, Path, Redacted, Schema, Scope } from "effect"; +import { StackIdSchema } from "../public/StackId.ts"; +import { GatewayActivationError } from "../public/Errors.ts"; + +/** The release is intentionally independent from the Supervisor RPC release. */ +export const ACTIVATION_FILE_FORMAT = "supabase-stack-activation-v1" as const; +export const ACTIVATION_PROTOCOL = "gateway-activation-v1" as const; +export const ACTIVATION_FILE_MAX_BYTES = 64 * 1024; + +export const ActivationEndpointSchema = Schema.Struct({ + host: Schema.String.pipe(Schema.check(Schema.isNonEmpty())), + port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 })), +}); + +const TokenSchema = Schema.String.pipe( + Schema.refine( + (value): value is string => value.length > 0 && value.length <= 256 && !/\s/.test(value), + { + identifier: "ActivationToken", + message: "Expected a bounded activation token", + }, + ), +); +const RedactedTokenSchema = Schema.RedactedFromValue(TokenSchema); + +const SessionSchema = Schema.String.pipe( + Schema.refine((value): value is string => /^[A-Za-z0-9_-]{1,128}$/.test(value), { + identifier: "OwnerSessionId", + message: "Expected a bounded owner session identifier", + }), +); + +export const ActivationFileSchema = Schema.Struct({ + format: Schema.Literal(ACTIVATION_FILE_FORMAT), + endpoint: ActivationEndpointSchema, + capability: RedactedTokenSchema, + stackId: StackIdSchema, + desiredGeneration: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + gatewayInstanceId: TokenSchema, + ownerSessionId: SessionSchema, +}); + +export type ActivationEndpoint = Schema.Schema.Type; +export type ActivationFile = Schema.Schema.Type; + +const sameActivationFile = (left: ActivationFile, right: ActivationFile): boolean => + left.format === right.format && + Redacted.value(left.capability) === Redacted.value(right.capability) && + left.stackId === right.stackId && + left.desiredGeneration === right.desiredGeneration && + left.gatewayInstanceId === right.gatewayInstanceId && + left.ownerSessionId === right.ownerSessionId && + left.endpoint.host === right.endpoint.host && + left.endpoint.port === right.endpoint.port; + +const fileError = (message: string, cause?: unknown): GatewayActivationError => + new GatewayActivationError({ + message, + ...(cause === undefined ? {} : { cause }), + }); + +const encode = (value: ActivationFile): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(ActivationFileSchema))(value).pipe( + Effect.mapError((cause) => fileError("Unable to encode gateway activation file", cause)), + ); + +/** Read and exact-schema decode an activation file. */ +export const readActivationFile = ( + path: string, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs + .stat(path) + .pipe(Effect.mapError((error) => fileError("Unable to read gateway activation file", error))); + if (info.size > BigInt(ACTIVATION_FILE_MAX_BYTES)) + return yield* fileError("Gateway activation file exceeds the size limit"); + const text = yield* fs + .readFileString(path) + .pipe(Effect.mapError((error) => fileError("Unable to read gateway activation file", error))); + if (new TextEncoder().encode(text).byteLength > ACTIVATION_FILE_MAX_BYTES) + return yield* fileError("Gateway activation file exceeds the size limit"); + const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(ActivationFileSchema))(text, { + onExcessProperty: "error", + }).pipe(Effect.mapError(() => fileError("Gateway activation file has an invalid format"))); + return decoded; + }); + +/** + * Atomically publish an owner-only activation file. The temporary file is + * created with O_EXCL, chmod'd to 0600, and renamed into place. A scope + * finalizer removes the published file and any temporary path. + */ +export const writeActivationFile = ( + path: string, + value: ActivationFile, +): Effect.Effect< + ActivationFile, + GatewayActivationError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const encoded = yield* encode(value); + const temporary = `${path}.${yield* crypto.randomUUIDv4}.tmp`; + yield* Effect.addFinalizer(() => + Effect.ignore( + readActivationFile(path).pipe( + Effect.flatMap((current) => + sameActivationFile(current, value) ? fs.remove(path, { force: true }) : Effect.void, + ), + // Temporary cleanup must run even when the published path was + // rotated or is no longer readable. + Effect.ensuring(Effect.ignore(fs.remove(temporary, { force: true }))), + ), + ), + ); + // Resolve the path through Path so callers cannot accidentally provide a + // platform-incompatible separator to the temporary-name operation. + const temporaryPath = pathService.join( + pathService.dirname(path), + pathService.basename(temporary), + ); + yield* Effect.gen(function* () { + yield* fs + .writeFileString(temporaryPath, encoded, { flag: "wx", mode: 0o600 }) + .pipe( + Effect.mapError((error) => fileError("Unable to publish gateway activation file", error)), + ); + yield* fs + .chmod(temporaryPath, 0o600) + .pipe( + Effect.mapError((error) => fileError("Unable to secure gateway activation file", error)), + ); + yield* fs + .rename(temporaryPath, path) + .pipe( + Effect.mapError((error) => fileError("Unable to publish gateway activation file", error)), + ); + yield* fs + .chmod(path, 0o600) + .pipe( + Effect.mapError((error) => fileError("Unable to secure gateway activation file", error)), + ); + }).pipe(Effect.ensuring(Effect.ignore(fs.remove(temporaryPath, { force: true })))); + return value; + }).pipe( + Effect.mapError((error) => + error instanceof GatewayActivationError + ? error + : fileError("Unable to publish gateway activation file", error), + ), + ); + +/** Generate a high-entropy capability that remains redacted outside the wire leaf. */ +export const generateActivationCapability: Effect.Effect< + Redacted.Redacted, + GatewayActivationError, + Crypto.Crypto +> = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const bytes = yield* crypto.randomBytes(32); + const token = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return Redacted.make(token); +}).pipe(Effect.mapError((cause) => fileError("Unable to generate activation capability", cause))); diff --git a/packages/stack/src/gateway/ActivationServer.ts b/packages/stack/src/gateway/ActivationServer.ts new file mode 100644 index 0000000000..cda570daed --- /dev/null +++ b/packages/stack/src/gateway/ActivationServer.ts @@ -0,0 +1,515 @@ +import { + Cause, + Data, + Effect, + Exit, + FiberSet, + Option, + Redacted, + Schema, + Scope, + Semaphore, +} from "effect"; +import { Socket, createServer } from "node:net"; +import { CapabilityNameSchema, type CapabilityName } from "../public/Capability.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { + GatewayActivationError, + GatewayAuthenticationError, + GatewayStaleGenerationError, +} from "../public/Errors.ts"; +import { + ACTIVATION_PROTOCOL, + ActivationEndpointSchema, + type ActivationEndpoint, +} from "./ActivationFile.ts"; +import type { ActivationResult } from "./Gateway.ts"; + +export const ACTIVATION_MAX_FRAME_BYTES = 64 * 1024; +export const ACTIVATION_MAX_CONCURRENT_REQUESTS = 16; +export const ACTIVATION_REQUEST_DEADLINE_MS = 5_000; + +const TokenSchema = Schema.String.pipe( + Schema.refine( + (value): value is string => value.length > 0 && value.length <= 256 && !/\s/.test(value), + { + identifier: "ActivationToken", + message: "Expected a bounded activation token", + }, + ), +); +const RedactedTokenSchema = Schema.RedactedFromValue(TokenSchema); + +export const ActivationRequestSchema = Schema.Struct({ + protocol: Schema.Literal(ACTIVATION_PROTOCOL), + operation: Schema.Literal("activate"), + capability: RedactedTokenSchema, + target: CapabilityNameSchema, + stackId: StackIdSchema, + desiredGeneration: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + gatewayInstanceId: RedactedTokenSchema, + ownerSessionId: RedactedTokenSchema, +}); +export type ActivationRequest = Schema.Schema.Type; + +export const ActivationResponseSchema = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + capability: CapabilityNameSchema, + endpoint: ActivationEndpointSchema, + }), + Schema.Struct({ + ok: Schema.Literal(false), + error: Schema.Struct({ + tag: Schema.Literals([ + "authentication", + "stale-generation", + "activation", + "invalid-request", + ] as const), + message: Schema.String, + }), + }), +]); +export type ActivationResponse = Schema.Schema.Type; + +export interface ActivationServerOptions { + readonly host?: string; + readonly port?: number; + readonly capability: Redacted.Redacted; + readonly stackId: StackId; + readonly desiredGeneration: number; + readonly gatewayInstanceId: Redacted.Redacted; + readonly ownerSessionId: Redacted.Redacted; + readonly activate: ( + capability: CapabilityName, + ) => Effect.Effect; +} + +export interface ActivationServer { + readonly endpoint: ActivationEndpoint; + readonly close: Effect.Effect; +} + +export interface ActivationClientOptions { + readonly endpoint: ActivationEndpoint; + readonly request: ActivationRequest; +} + +class ActivationProtocolError extends Data.TaggedError("ActivationProtocolError")<{ + readonly message: string; +}> {} + +const protocolError = (message: string) => new ActivationProtocolError({ message }); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { fatal: true }); + +const encodeFrame = (value: unknown): Uint8Array => { + // The framing callback is a synchronous Node leaf; callers pass closed JSON records. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + const body = textEncoder.encode(JSON.stringify(value)); + const frame = new Uint8Array(body.byteLength + 4); + new DataView(frame.buffer).setUint32(0, body.byteLength, false); + frame.set(body, 4); + return frame; +}; + +const encodeFrameText = (text: string): Uint8Array => { + const body = textEncoder.encode(text); + const frame = new Uint8Array(body.byteLength + 4); + new DataView(frame.buffer).setUint32(0, body.byteLength, false); + frame.set(body, 4); + return frame; +}; + +const encodeRequest = ( + request: ActivationRequest, +): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(ActivationRequestSchema))(request, { + onExcessProperty: "error", + }).pipe( + Effect.map(encodeFrameText), + Effect.mapError(() => new GatewayActivationError({ message: "Activation request is invalid" })), + ); + +const decodeRequest = ( + frame: Uint8Array, +): Effect.Effect => + Effect.gen(function* () { + if (frame.byteLength < 4) return yield* protocolError("Activation frame is truncated"); + const length = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false); + if (length > ACTIVATION_MAX_FRAME_BYTES || frame.byteLength !== length + 4) + return yield* protocolError("Activation frame exceeds the size limit"); + const text = yield* Effect.try({ + try: () => textDecoder.decode(frame.slice(4)), + catch: () => protocolError("Activation frame is invalid"), + }); + return yield* Schema.decodeEffect(Schema.fromJsonString(ActivationRequestSchema))(text, { + onExcessProperty: "error", + }).pipe(Effect.mapError(() => protocolError("Activation request is invalid"))); + }); + +const responseFor = ( + result: Exit.Exit< + ActivationResult, + GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError + >, +): Record => { + if (Exit.isSuccess(result)) + return { ok: true, capability: result.value.capability, endpoint: result.value.endpoint }; + const value = Cause.findErrorOption(result.cause); + if (Option.isSome(value) && value.value instanceof GatewayAuthenticationError) + return { ok: false, error: { tag: "authentication", message: "Activation request rejected" } }; + if (Option.isSome(value) && value.value instanceof GatewayStaleGenerationError) + return { + ok: false, + error: { tag: "stale-generation", message: "Activation request rejected" }, + }; + return { ok: false, error: { tag: "activation", message: "Activation failed" } }; +}; + +const writeAndClose = (socket: Socket, value: unknown): Effect.Effect => + Effect.callback((resume) => { + let finished = false; + const done = () => { + if (finished) return; + finished = true; + socket.off("error", done); + socket.off("close", done); + resume(Effect.void); + }; + socket.once("error", done); + socket.once("close", done); + socket.end(encodeFrame(value), done); + return Effect.sync(() => { + socket.off("error", done); + socket.off("close", done); + socket.destroy(); + }); + }); + +/** One-shot client used by a container gateway; it never exposes maintenance authority. */ +export const requestActivation = ( + options: ActivationClientOptions, +): Effect.Effect< + ActivationResult, + GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError +> => + Effect.gen(function* () { + const requestFrame = yield* encodeRequest(options.request); + return yield* Effect.callback< + ActivationResult, + GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError + >((resume) => { + const socket = new Socket(); + let done = false; + let bytes = new Uint8Array(0); + let onData: ((chunk: Buffer) => void) | undefined; + let onError: ((cause: Error) => void) | undefined; + let onClose: (() => void) | undefined; + let onTimeout: (() => void) | undefined; + let onConnect: (() => void) | undefined; + const cleanup = () => { + if (onData !== undefined) socket.off("data", onData); + if (onError !== undefined) socket.off("error", onError); + if (onClose !== undefined) socket.off("close", onClose); + if (onTimeout !== undefined) socket.off("timeout", onTimeout); + if (onConnect !== undefined) socket.off("connect", onConnect); + socket.setTimeout(0); + }; + const finish = ( + effect: Effect.Effect< + ActivationResult, + GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError + >, + ) => { + if (done) return; + done = true; + cleanup(); + resume(effect); + socket.destroy(); + }; + onError = (cause: Error) => + finish( + Effect.fail( + new GatewayActivationError({ message: "Activation server unavailable", cause }), + ), + ); + onClose = () => + finish( + Effect.fail(new GatewayActivationError({ message: "Activation server unavailable" })), + ); + onTimeout = () => + finish( + Effect.fail(new GatewayActivationError({ message: "Activation request timed out" })), + ); + socket.once("error", onError); + socket.once("close", onClose); + socket.once("timeout", onTimeout); + socket.setTimeout(ACTIVATION_REQUEST_DEADLINE_MS); + onData = (chunk: Buffer) => { + if (done) return; + const next = new Uint8Array(bytes.byteLength + chunk.byteLength); + next.set(bytes); + next.set(chunk, bytes.byteLength); + bytes = next; + if (bytes.byteLength < 4) return; + const length = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0, false); + if (length > ACTIVATION_MAX_FRAME_BYTES || bytes.byteLength < length + 4) { + if (length > ACTIVATION_MAX_FRAME_BYTES) + finish( + Effect.fail( + new GatewayActivationError({ message: "Activation response is invalid" }), + ), + ); + return; + } + if (bytes.byteLength !== length + 4) { + finish( + Effect.fail(new GatewayActivationError({ message: "Activation response is invalid" })), + ); + return; + } + const frame = bytes.slice(4, length + 4); + const parsed: Effect.Effect< + ActivationResult, + GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError + > = Effect.try({ + try: () => textDecoder.decode(frame), + catch: () => new GatewayActivationError({ message: "Activation response is invalid" }), + }).pipe( + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(ActivationResponseSchema))(text, { + onExcessProperty: "error", + }), + ), + Effect.mapError((error): GatewayActivationError => + error instanceof GatewayActivationError + ? error + : new GatewayActivationError({ + message: "Activation response is invalid", + cause: error, + }), + ), + Effect.flatMap( + ( + value, + ): Effect.Effect< + ActivationResult, + GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError + > => + value.ok + ? Effect.succeed({ capability: value.capability, endpoint: value.endpoint }) + : value.error.tag === "authentication" + ? Effect.fail( + new GatewayAuthenticationError({ message: "Activation request rejected" }), + ) + : value.error.tag === "stale-generation" + ? Effect.fail( + new GatewayStaleGenerationError({ message: "Activation request rejected" }), + ) + : Effect.fail(new GatewayActivationError({ message: "Activation failed" })), + ), + ); + finish(parsed); + }; + socket.on("data", onData); + onConnect = () => socket.write(requestFrame); + socket.once("connect", onConnect); + socket.connect(options.endpoint.port, options.endpoint.host); + return Effect.sync(() => { + cleanup(); + done = true; + socket.destroy(); + }); + }); + }).pipe( + Effect.timeoutOrElse({ + duration: ACTIVATION_REQUEST_DEADLINE_MS, + orElse: () => + Effect.fail(new GatewayActivationError({ message: "Activation request timed out" })), + }), + ); + +const serveSocket = ( + socket: Socket, + options: ActivationServerOptions, + semaphore: Semaphore.Semaphore, + runFork: (effect: Effect.Effect) => import("effect/Fiber").Fiber, + active: Set, +): void => { + active.add(socket); + let finished = false; + let processing = false; + let bytes = new Uint8Array(0); + let fiber: import("effect/Fiber").Fiber | undefined; + const finish = () => { + if (finished) { + active.delete(socket); + return; + } + finished = true; + if (fiber !== undefined) fiber.interruptUnsafe(); + socket.destroy(); + active.delete(socket); + }; + const send = (value: unknown) => { + if (finished) return; + finished = true; + // Socket callbacks are a foreign boundary; all response work runs on the + // Supervisor context captured when this server was acquired. + runFork(writeAndClose(socket, value)); + }; + socket.once("error", finish); + socket.once("close", finish); + socket.setTimeout(ACTIVATION_REQUEST_DEADLINE_MS, finish); + const onData = (chunk: Buffer) => { + if (finished || processing) return; + const next = new Uint8Array(bytes.byteLength + chunk.byteLength); + next.set(bytes); + next.set(chunk, bytes.byteLength); + bytes = next; + if (bytes.byteLength > ACTIVATION_MAX_FRAME_BYTES + 4) { + send({ + ok: false, + error: { tag: "invalid-request", message: "Activation request rejected" }, + }); + return; + } + if (bytes.byteLength < 4) return; + const length = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0, false); + if (length > ACTIVATION_MAX_FRAME_BYTES) { + send({ + ok: false, + error: { tag: "invalid-request", message: "Activation request rejected" }, + }); + return; + } + if (bytes.byteLength < length + 4) return; + if (bytes.byteLength !== length + 4) { + send({ + ok: false, + error: { tag: "invalid-request", message: "Activation request rejected" }, + }); + return; + } + const frame = bytes.slice(0, length + 4); + processing = true; + socket.setTimeout(0); + fiber = runFork( + semaphore.withPermit( + Effect.gen(function* () { + const request = yield* decodeRequest(frame).pipe(Effect.exit); + if (Exit.isFailure(request)) { + send({ + ok: false, + error: { tag: "invalid-request", message: "Activation request rejected" }, + }); + return; + } + if ( + request.value.protocol !== ACTIVATION_PROTOCOL || + Redacted.value(request.value.capability) !== Redacted.value(options.capability) || + request.value.stackId !== options.stackId || + Redacted.value(request.value.gatewayInstanceId) !== + Redacted.value(options.gatewayInstanceId) || + Redacted.value(request.value.ownerSessionId) !== Redacted.value(options.ownerSessionId) + ) { + send({ + ok: false, + error: { tag: "authentication", message: "Activation request rejected" }, + }); + return; + } + if (request.value.desiredGeneration !== options.desiredGeneration) { + send({ + ok: false, + error: { tag: "stale-generation", message: "Activation request rejected" }, + }); + return; + } + const result = yield* options.activate(request.value.target).pipe( + Effect.timeoutOrElse({ + duration: ACTIVATION_REQUEST_DEADLINE_MS, + orElse: () => + Effect.fail( + new GatewayActivationError({ message: "Activation deadline exceeded" }), + ), + }), + Effect.exit, + ); + send(responseFor(result)); + }), + ), + ); + }; + socket.on("data", onData); +}; + +/** Bind an ephemeral exact-release activation server owned by the Supervisor. */ +export const startActivationServer = ( + options: ActivationServerOptions, +): Effect.Effect => + Effect.gen(function* () { + const semaphore = yield* Semaphore.make(ACTIVATION_MAX_CONCURRENT_REQUESTS); + const fibers = yield* FiberSet.make(); + const runFork = yield* FiberSet.runtime(fibers)(); + const active = new Set(); + const server = createServer((socket) => + serveSocket(socket, options, semaphore, runFork, active), + ); + server.maxConnections = ACTIVATION_MAX_CONCURRENT_REQUESTS; + const host = options.host ?? "127.0.0.1"; + yield* Effect.callback((resume) => { + let settled = false; + const cleanup = () => { + server.off("error", onError); + if (!server.listening) { + server.close(() => undefined); + return; + } + server.close(() => undefined); + }; + const onError = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + resume( + Effect.fail( + new GatewayActivationError({ message: "Activation server unavailable", cause: error }), + ), + ); + }; + server.once("error", onError); + server.listen({ host, port: options.port ?? 0 }, () => { + if (settled) return; + settled = true; + server.off("error", onError); + resume(Effect.void); + }); + return Effect.sync(() => { + if (settled) return; + settled = true; + cleanup(); + }); + }); + const address = server.address(); + if (typeof address !== "object" || address === null) + return yield* new GatewayActivationError({ + message: "Activation server did not expose an endpoint", + }); + const closeOperation = Effect.gen(function* () { + yield* FiberSet.clear(fibers); + yield* Effect.callback((resume) => { + for (const socket of active) socket.destroy(); + if (!server.listening) return resume(Effect.void); + server.close(() => resume(Effect.void)); + }); + }); + const close = yield* Effect.cached(closeOperation); + yield* Effect.addFinalizer(() => close); + return { + endpoint: { host: address.address, port: address.port }, + close, + } satisfies ActivationServer; + }); diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts new file mode 100644 index 0000000000..902f7add0e --- /dev/null +++ b/packages/stack/src/gateway/Gateway.ts @@ -0,0 +1,187 @@ +import { Deferred, Effect, Exit, FiberSet, Semaphore } from "effect"; +import { GatewayActivationError } from "../public/Errors.ts"; +import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import { makeHttpGateway, type HttpGateway, type HttpGatewayOptions } from "./HttpGateway.ts"; +import { makeTcpGateway, type TcpGateway, type TcpGatewayOptions } from "./TcpGateway.ts"; + +/** A private backend endpoint returned only after activation has completed. */ +export interface BackendEndpoint { + readonly host: string; + readonly port: number; +} + +export interface ActivationResult { + readonly capability: CapabilityName; + readonly endpoint: BackendEndpoint; +} + +export interface ActivationTarget { + readonly dependencies: ReadonlyArray; +} + +export interface LazyActivator { + /** Activates a capability and all of its dependencies once for a generation. */ + readonly activate: ( + capability: CapabilityName, + ) => Effect.Effect; + readonly generation: number; +} + +export interface LazyActivatorOptions { + readonly generation: number; + readonly targets: Readonly>>; + readonly activate: ( + capability: CapabilityName, + ) => Effect.Effect; +} + +const activationError = (message: string): GatewayActivationError => + new GatewayActivationError({ message }); + +/** + * Build a Supervisor-local one-way activation coordinator. A per-capability + * semaphore joins concurrent requests to one dependency/start operation; + * successful activations are retained until the owner scope ends, while a + * failed operation can be retried under the Supervisor restart policy. + */ +export const createLazyActivator = ( + options: LazyActivatorOptions, +): Effect.Effect => + Effect.gen(function* () { + interface ActivationState { + result?: ActivationResult; + flight?: Deferred.Deferred; + readonly lock: Semaphore.Semaphore; + } + // Activation fibers belong to the Supervisor owner scope, not to an + // individual gateway request. This keeps a cancelled waiter from + // interrupting the shared start operation while still interrupting all + // starts when the owner scope closes. + const runFork = yield* FiberSet.makeRuntime(); + const states = new Map(); + const visiting = new Set(); + const visited = new Set(); + const visit = (capability: CapabilityName): boolean => { + if (visiting.has(capability)) return false; + if (visited.has(capability)) return true; + const target = options.targets[capability]; + if (target === undefined) return false; + visiting.add(capability); + const valid = target.dependencies.every(visit); + visiting.delete(capability); + if (!valid) return false; + visited.add(capability); + return true; + }; + for (const name of CAPABILITY_NAMES) { + if (options.targets[name] !== undefined && !visit(name)) + return yield* activationError("Capability dependency graph is invalid"); + } + const activate = ( + capability: CapabilityName, + ): Effect.Effect => { + const target = options.targets[capability]; + if (target === undefined) + return Effect.fail(activationError("Requested capability is not enabled")); + const state = states.get(capability); + if (state === undefined) + return Effect.fail(activationError("Requested capability is not enabled")); + const execute = Effect.forEach(target.dependencies, activate, { + concurrency: "unbounded", + }).pipe(Effect.andThen(options.activate(capability))); + return Effect.gen(function* () { + const decision = yield* state.lock.withPermit( + Effect.sync(() => { + if (state.result !== undefined) return { _tag: "done" as const, result: state.result }; + if (state.flight !== undefined) + return { _tag: "join" as const, deferred: state.flight }; + const deferred = Deferred.makeUnsafe(); + state.flight = deferred; + // This fiber is registered in the owner FiberSet and therefore is + // independent from the request fibers awaiting its Deferred. + runFork( + Effect.uninterruptibleMask((restore) => + Effect.exit(restore(execute)).pipe( + Effect.flatMap((exit) => + state.lock.withPermit( + Effect.sync(() => { + if (Exit.isSuccess(exit)) state.result = exit.value; + state.flight = undefined; + Deferred.doneUnsafe(deferred, exit); + }), + ), + ), + ), + ), + ); + return { _tag: "join" as const, deferred }; + }), + ); + return decision._tag === "done" + ? decision.result + : yield* Deferred.await(decision.deferred); + }); + }; + + // Install every cached effect before returning the service. This avoids a + // race where two callers can both create the first flight for a target. + for (const name of CAPABILITY_NAMES) + if (options.targets[name] !== undefined) states.set(name, { lock: yield* Semaphore.make(1) }); + + return { generation: options.generation, activate } satisfies LazyActivator; + }); + +export interface GatewayRouteRequest { + readonly path: string; + readonly method?: string; + readonly headers: Readonly>; +} + +export interface GatewayRoute { + readonly capability: CapabilityName; + readonly match: (request: GatewayRouteRequest) => boolean; +} + +export interface StackGateway { + readonly http?: HttpGateway; + readonly tcp?: TcpGateway; + readonly close: Effect.Effect; +} + +export interface StackGatewayOptions { + readonly http?: Omit; + readonly tcp?: Omit; + readonly activate: LazyActivator["activate"]; +} + +/** Compose the protocol gateways under one Supervisor-owned lifecycle scope. */ +export const makeGateway = ( + options: StackGatewayOptions, +): Effect.Effect => + Effect.gen(function* () { + const http = + options.http === undefined + ? undefined + : yield* makeHttpGateway({ ...options.http, activate: options.activate }); + let tcp: TcpGateway | undefined; + if (options.tcp !== undefined) { + const acquired = yield* Effect.exit( + makeTcpGateway({ ...options.tcp, activate: options.activate }), + ); + if (Exit.isFailure(acquired)) { + if (http !== undefined) yield* http.close; + return yield* Effect.failCause(acquired.cause); + } + tcp = acquired.value; + } + const closeOperation = Effect.gen(function* () { + if (tcp !== undefined) yield* tcp.close; + if (http !== undefined) yield* http.close; + }); + const close = yield* Effect.cached(closeOperation); + return { + ...(http === undefined ? {} : { http }), + ...(tcp === undefined ? {} : { tcp }), + close, + } satisfies StackGateway; + }); diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts new file mode 100644 index 0000000000..5201de5cc4 --- /dev/null +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -0,0 +1,436 @@ +import { Cause, Data, Effect, Exit, FiberSet, Option, Scope } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { + createServer, + request as proxyRequest, + type IncomingMessage, + type Server, + type ServerResponse, + // oxlint-disable-next-line effecttsgo/node-builtin-import +} from "node:http"; +import { Socket } from "node:net"; +import type { Duplex } from "node:stream"; +import type { Fiber } from "effect/Fiber"; +import { + GatewayActivationError, + GatewayAuthenticationError, + GatewayStaleGenerationError, +} from "../public/Errors.ts"; +import type { + ActivationResult, + BackendEndpoint, + GatewayRoute, + GatewayRouteRequest, + LazyActivator, +} from "./Gateway.ts"; +import type { NativeListener } from "../state/PortCoordinator.ts"; + +class GatewayBackendError extends Data.TaggedError("GatewayBackendError")<{ + readonly cause?: unknown; +}> {} + +export interface HttpGatewayOptions { + readonly address?: string; + readonly port?: number; + readonly listener?: NativeListener; + readonly routes: ReadonlyArray; + readonly activate: LazyActivator["activate"]; + readonly resolveBackend?: ( + route: GatewayRoute, + request: GatewayRouteRequest, + activation: ActivationResult, + ) => Effect.Effect; + readonly cors?: Readonly>; + readonly healthPaths?: ReadonlyArray; +} + +export interface HttpGateway { + readonly address: string; + readonly port: number; + readonly close: Effect.Effect; + readonly server: Server; +} + +const hopByHop = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +const requestView = (request: IncomingMessage): GatewayRouteRequest => ({ + path: request.url ?? "/", + method: request.method, + headers: request.headers, +}); + +const setCors = (response: ServerResponse, options: HttpGatewayOptions) => { + response.setHeader( + "access-control-allow-origin", + options.cors?.["access-control-allow-origin"] ?? "*", + ); + response.setHeader( + "access-control-allow-methods", + options.cors?.["access-control-allow-methods"] ?? "GET,POST,PUT,PATCH,DELETE,OPTIONS", + ); + response.setHeader( + "access-control-allow-headers", + options.cors?.["access-control-allow-headers"] ?? "authorization,content-type", + ); + for (const [name, value] of Object.entries(options.cors ?? {})) response.setHeader(name, value); +}; + +const respond = ( + response: ServerResponse, + status: number, + body: string, + options: HttpGatewayOptions, +) => { + setCors(response, options); + response.statusCode = status; + response.setHeader("content-type", "application/json"); + response.end(body); +}; + +const routeFor = ( + request: GatewayRouteRequest, + routes: ReadonlyArray, +): GatewayRoute | undefined => routes.find((route) => route.match(request)); + +const proxy = ( + request: IncomingMessage, + response: ServerResponse, + backend: BackendEndpoint, + options: HttpGatewayOptions, +): Effect.Effect => + Effect.callback((resume) => { + let settled = false; + let outgoing: ReturnType | undefined; + let incoming: IncomingMessage | undefined; + const headers: Record = {}; + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined && !hopByHop.has(name.toLowerCase())) headers[name] = value; + } + headers["x-forwarded-host"] = request.headers.host ?? ""; + headers["x-forwarded-proto"] = "http"; + headers["x-forwarded-for"] = request.socket.remoteAddress ?? ""; + + const onIncomingError = (cause: Error) => { + if (settled) return; + settled = true; + cleanup(); + outgoing?.destroy(); + incoming?.destroy(); + resume(Effect.fail(new GatewayBackendError({ cause }))); + }; + const onIncomingAborted = () => onIncomingError(new Error("backend response aborted")); + const onIncomingEnd = () => { + if (settled) return; + settled = true; + cleanup(); + resume(Effect.void); + }; + const onOutgoingError = (cause: Error) => onIncomingError(cause); + const onRequestAborted = () => onIncomingError(new Error("request aborted")); + const onResponseClose = () => { + if (settled || response.writableEnded) return; + onIncomingError(new Error("response closed")); + }; + const cleanup = () => { + if (outgoing !== undefined) outgoing.off("error", onOutgoingError); + request.off("aborted", onRequestAborted); + response.off("close", onResponseClose); + if (incoming !== undefined) { + incoming.off("error", onIncomingError); + incoming.off("aborted", onIncomingAborted); + incoming.off("end", onIncomingEnd); + } + }; + const onIncoming = (value: IncomingMessage) => { + incoming = value; + value.once("error", onIncomingError); + value.once("aborted", onIncomingAborted); + value.once("end", onIncomingEnd); + setCors(response, options); + response.statusCode = value.statusCode ?? 502; + for (const [name, headerValue] of Object.entries(value.headers)) { + if ( + headerValue !== undefined && + !hopByHop.has(name.toLowerCase()) && + !name.toLowerCase().startsWith("access-control-") + ) + response.setHeader(name, headerValue); + } + value.pipe(response); + }; + + outgoing = proxyRequest( + { + host: backend.host, + port: backend.port, + method: request.method, + path: request.url ?? "/", + headers, + }, + onIncoming, + ); + outgoing.once("error", onOutgoingError); + request.once("aborted", onRequestAborted); + response.once("close", onResponseClose); + request.pipe(outgoing); + return Effect.sync(() => { + cleanup(); + outgoing.destroy(); + incoming?.destroy(); + }); + }); + +const mapFailure = (cause: Cause.Cause): number => { + const error = Cause.findErrorOption(cause); + if (Option.isSome(error) && error.value instanceof GatewayBackendError) return 502; + if ( + Option.isSome(error) && + (error.value instanceof GatewayAuthenticationError || + error.value instanceof GatewayStaleGenerationError) + ) + return 503; + return 503; +}; + +const handleRequest = ( + request: IncomingMessage, + response: ServerResponse, + options: HttpGatewayOptions, + runFork: (effect: Effect.Effect) => Fiber, +): void => { + const view = requestView(request); + setCors(response, options); + if (request.method === "OPTIONS") { + response.statusCode = 204; + response.end(); + return; + } + const health = options.healthPaths ?? ["/health", "/healthz", "/status"]; + const pathname = view.path.split("?", 1)[0] ?? view.path; + if (health.some((path) => pathname === path)) { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ ok: true })); + return; + } + const route = routeFor(view, options.routes); + if (route === undefined) { + respond(response, 404, JSON.stringify({ error: "Not found" }), options); + return; + } + const activation = options.activate(route.capability).pipe( + Effect.flatMap((result) => + options.resolveBackend === undefined + ? Effect.succeed(result.endpoint) + : options + .resolveBackend(route, view, result) + .pipe(Effect.mapError((cause) => new GatewayBackendError({ cause }))), + ), + Effect.flatMap((backend) => proxy(request, response, backend, options)), + ); + // Node invokes this handler outside Effect; use the owner-scoped FiberSet + // runtime so cancellation of the gateway interrupts in-flight activation. + const fiber = runFork(activation); + fiber.addObserver((exit) => { + if (!Exit.isFailure(exit) || response.writableEnded) return; + if (response.headersSent) { + response.destroy(); + return; + } + const status = mapFailure(exit.cause); + respond( + response, + status, + JSON.stringify({ error: status === 503 ? "Service unavailable" : "Bad gateway" }), + options, + ); + }); +}; + +const writeUpgrade = (request: IncomingMessage): string => { + const lines = [`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/${request.httpVersion}`]; + for (let index = 0; index < request.rawHeaders.length; index += 2) { + const name = request.rawHeaders[index]; + const value = request.rawHeaders[index + 1]; + if (name !== undefined && value !== undefined) lines.push(`${name}: ${value}`); + } + return `${lines.join("\r\n")}\r\n\r\n`; +}; + +const handleUpgrade = ( + request: IncomingMessage, + socket: Duplex, + head: Buffer, + options: HttpGatewayOptions, + runFork: (effect: Effect.Effect) => Fiber, + active: Set, +): void => { + active.add(socket); + socket.once("close", () => active.delete(socket)); + const view = requestView(request); + const route = routeFor(view, options.routes); + if (route === undefined) { + socket.destroy(); + return; + } + const activation = options.activate(route.capability).pipe( + Effect.flatMap((result) => + options.resolveBackend === undefined + ? Effect.succeed(result.endpoint) + : options + .resolveBackend(route, view, result) + .pipe(Effect.mapError((cause) => new GatewayBackendError({ cause }))), + ), + Effect.flatMap((backend) => + Effect.callback((resume) => { + const target = new Socket(); + let settled = false; + const fail = (cause: unknown) => { + if (settled) return; + settled = true; + cleanup(); + target.destroy(); + socket.destroy(); + resume(Effect.fail(new GatewayBackendError({ cause }))); + }; + const onTargetError = (cause: Error) => fail(cause); + const onSocketClose = () => { + target.destroy(); + if (!settled) { + settled = true; + cleanup(); + resume(Effect.void); + } + }; + const onTargetClose = () => { + socket.destroy(); + if (!settled) { + settled = true; + cleanup(); + resume(Effect.void); + } + }; + const onSocketEnd = () => target.end(); + const onTargetEnd = () => socket.end(); + const onConnect = () => { + target.write(writeUpgrade(request)); + if (head.byteLength > 0) target.write(head); + socket.pipe(target, { end: false }); + target.pipe(socket, { end: false }); + socket.once("end", onSocketEnd); + target.once("end", onTargetEnd); + }; + const cleanup = () => { + target.off("error", onTargetError); + target.off("connect", onConnect); + target.off("close", onTargetClose); + socket.off("close", onSocketClose); + socket.off("end", onSocketEnd); + target.off("end", onTargetEnd); + }; + target.once("error", onTargetError); + target.once("connect", onConnect); + target.once("close", onTargetClose); + socket.once("close", onSocketClose); + target.connect(backend.port, backend.host); + return Effect.sync(() => { + settled = true; + cleanup(); + target.destroy(); + socket.destroy(); + }); + }), + ), + ); + const fiber = runFork(activation); + fiber.addObserver((exit) => { + if (Exit.isFailure(exit)) socket.destroy(); + }); +}; + +/** Start an HTTP gateway, optionally adopting an already-bound native server. */ +export const makeHttpGateway = ( + options: HttpGatewayOptions, +): Effect.Effect => + Effect.gen(function* () { + const fibers = yield* FiberSet.make(); + const runFork = yield* FiberSet.runtime(fibers)(); + if (options.listener?.binding?.kind === "tcp") + return yield* new GatewayActivationError({ message: "Gateway listener protocol mismatch" }); + const server = + options.listener?.binding?.kind === "http" ? options.listener.binding.server : createServer(); + if (options.listener !== undefined && !server.listening) + return yield* new GatewayActivationError({ message: "Gateway listener is not bound" }); + const active = new Set(); + const connectionHandler = (socket: Duplex) => { + active.add(socket); + socket.once("close", () => active.delete(socket)); + }; + const requestHandler = (request: IncomingMessage, response: ServerResponse) => + handleRequest(request, response, options, runFork); + const upgradeHandler = (request: IncomingMessage, socket: Duplex, head: Buffer) => + handleUpgrade(request, socket, head, options, runFork, active); + server.on("connection", connectionHandler); + server.on("request", requestHandler); + server.on("upgrade", upgradeHandler); + if (!server.listening) { + yield* Effect.callback((resume) => { + let settled = false; + const cleanup = () => { + server.off("error", onError); + server.close(() => undefined); + }; + const onError = (cause: Error) => { + if (settled) return; + settled = true; + cleanup(); + resume( + Effect.fail( + new GatewayActivationError({ message: "Gateway listener unavailable", cause }), + ), + ); + }; + server.once("error", onError); + server.listen({ host: options.address ?? "127.0.0.1", port: options.port ?? 0 }, () => { + if (settled) return; + settled = true; + server.off("error", onError); + resume(Effect.void); + }); + return Effect.sync(() => { + if (settled) return; + settled = true; + cleanup(); + }); + }); + } + const address = server.address(); + if (typeof address !== "object" || address === null) + return yield* new GatewayActivationError({ + message: "Gateway listener did not expose an endpoint", + }); + const closeOperation = Effect.gen(function* () { + server.off("request", requestHandler); + server.off("upgrade", upgradeHandler); + server.off("connection", connectionHandler); + yield* FiberSet.clear(fibers); + for (const socket of active) socket.destroy(); + if (options.listener !== undefined) yield* options.listener.close; + yield* Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void); + server.close(() => resume(Effect.void)); + }); + }); + const close = yield* Effect.cached(closeOperation); + yield* Effect.addFinalizer(() => close); + return { address: address.address, port: address.port, close, server } satisfies HttpGateway; + }); diff --git a/packages/stack/src/gateway/TcpGateway.ts b/packages/stack/src/gateway/TcpGateway.ts new file mode 100644 index 0000000000..22bc5d1081 --- /dev/null +++ b/packages/stack/src/gateway/TcpGateway.ts @@ -0,0 +1,207 @@ +import { Effect, Exit, FiberSet, Scope } from "effect"; +import { createServer, Socket, type Server } from "node:net"; +import type { Fiber } from "effect/Fiber"; +import { GatewayActivationError } from "../public/Errors.ts"; +import type { + ActivationResult, + BackendEndpoint, + GatewayRoute, + GatewayRouteRequest, + LazyActivator, +} from "./Gateway.ts"; +import type { NativeListener } from "../state/PortCoordinator.ts"; + +export interface TcpGatewayOptions { + readonly address?: string; + readonly port?: number; + readonly listener?: NativeListener; + readonly routes: ReadonlyArray; + readonly activate: LazyActivator["activate"]; + readonly resolveBackend?: ( + route: GatewayRoute, + request: GatewayRouteRequest, + activation: ActivationResult, + ) => Effect.Effect; +} + +export interface TcpGateway { + readonly address: string; + readonly port: number; + readonly close: Effect.Effect; + readonly server: Server; +} + +const routeFor = (routes: ReadonlyArray): GatewayRoute | undefined => + routes.find((route) => route.match({ path: "/", headers: {} })); + +const tunnel = ( + source: Socket, + backend: BackendEndpoint, +): Effect.Effect => + Effect.callback((resume) => { + const target = new Socket(); + let settled = false; + const onSourceError = (cause: Error) => { + if (settled) return; + settled = true; + cleanup(); + source.destroy(); + target.destroy(); + resume(Effect.fail(new GatewayActivationError({ message: "Gateway backend failed", cause }))); + }; + const onTargetError = (cause: Error) => onSourceError(cause); + const onSourceClose = () => { + target.destroy(); + if (!settled) { + settled = true; + cleanup(); + resume(Effect.void); + } + }; + const onTargetClose = () => { + source.destroy(); + if (!settled) { + settled = true; + cleanup(); + resume(Effect.void); + } + }; + const onSourceEnd = () => target.end(); + const onTargetEnd = () => source.end(); + const onConnect = () => { + // Node's pipe implementation naturally propagates backpressure. Keeping + // both streams open here preserves protocol half-close semantics. + source.pipe(target, { end: false }); + target.pipe(source, { end: false }); + source.once("end", onSourceEnd); + target.once("end", onTargetEnd); + }; + const cleanup = () => { + source.off("error", onSourceError); + target.off("error", onTargetError); + source.off("close", onSourceClose); + target.off("close", onTargetClose); + target.off("connect", onConnect); + source.off("end", onSourceEnd); + target.off("end", onTargetEnd); + }; + source.once("error", onSourceError); + target.once("error", onTargetError); + source.once("close", onSourceClose); + target.once("close", onTargetClose); + target.once("connect", onConnect); + target.connect(backend.port, backend.host); + return Effect.sync(() => { + if (!settled) settled = true; + cleanup(); + source.destroy(); + target.destroy(); + }); + }); + +const handleConnection = ( + source: Socket, + options: TcpGatewayOptions, + runFork: (effect: Effect.Effect) => Fiber, + active: Set, +): void => { + active.add(source); + source.once("close", () => active.delete(source)); + const route = routeFor(options.routes); + if (route === undefined) { + source.destroy(); + return; + } + const view: GatewayRouteRequest = { path: "/", headers: {} }; + const operation = options.activate(route.capability).pipe( + Effect.flatMap((result) => + options.resolveBackend === undefined + ? Effect.succeed(result.endpoint) + : options.resolveBackend(route, view, result), + ), + Effect.flatMap((backend) => tunnel(source, backend)), + ); + // Node invokes this handler outside Effect; use the owner-scoped FiberSet + // runtime for the exact accepted connection's lifecycle. + const fiber = runFork(operation); + fiber.addObserver((exit) => { + if (Exit.isFailure(exit)) source.destroy(); + }); +}; + +/** Start a transparent TCP gateway, optionally adopting an already-bound server. */ +export const makeTcpGateway = ( + options: TcpGatewayOptions, +): Effect.Effect => + Effect.gen(function* () { + const fibers = yield* FiberSet.make(); + const runFork = yield* FiberSet.runtime(fibers)(); + if (options.listener?.binding?.kind === "http") + return yield* new GatewayActivationError({ message: "Gateway listener protocol mismatch" }); + if ( + options.listener?.binding?.kind === "tcp" && + options.listener.binding.allowHalfOpen !== true + ) + return yield* new GatewayActivationError({ + message: "Gateway listener must preserve half-close", + }); + const server = + options.listener?.binding?.kind === "tcp" + ? options.listener.binding.server + : createServer({ allowHalfOpen: true }); + if (options.listener !== undefined && !server.listening) + return yield* new GatewayActivationError({ message: "Gateway listener is not bound" }); + const active = new Set(); + const connectionHandler = (socket: Socket) => + handleConnection(socket, options, runFork, active); + server.on("connection", connectionHandler); + if (!server.listening) { + yield* Effect.callback((resume) => { + let settled = false; + const cleanup = () => { + server.off("error", onError); + server.close(() => undefined); + }; + const onError = (cause: Error) => { + if (settled) return; + settled = true; + cleanup(); + resume( + Effect.fail( + new GatewayActivationError({ message: "Gateway listener unavailable", cause }), + ), + ); + }; + server.once("error", onError); + server.listen({ host: options.address ?? "127.0.0.1", port: options.port ?? 0 }, () => { + if (settled) return; + settled = true; + server.off("error", onError); + resume(Effect.void); + }); + return Effect.sync(() => { + if (settled) return; + settled = true; + cleanup(); + }); + }); + } + const address = server.address(); + if (typeof address !== "object" || address === null) + return yield* new GatewayActivationError({ + message: "Gateway listener did not expose an endpoint", + }); + const closeOperation = Effect.gen(function* () { + server.off("connection", connectionHandler); + yield* FiberSet.clear(fibers); + for (const socket of active) socket.destroy(); + if (options.listener !== undefined) yield* options.listener.close; + yield* Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void); + server.close(() => resume(Effect.void)); + }); + }); + const close = yield* Effect.cached(closeOperation); + yield* Effect.addFinalizer(() => close); + return { address: address.address, port: address.port, close, server } satisfies TcpGateway; + }); diff --git a/packages/stack/src/gateway/activation.integration.test.ts b/packages/stack/src/gateway/activation.integration.test.ts new file mode 100644 index 0000000000..065d88e848 --- /dev/null +++ b/packages/stack/src/gateway/activation.integration.test.ts @@ -0,0 +1,460 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, FileSystem, Redacted, Schema, Scope } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { connect as connectNet, type Socket } from "node:net"; +import { StackIdSchema } from "../public/StackId.ts"; +import type { CapabilityName } from "../public/Capability.ts"; +import { GatewayActivationError } from "../public/Errors.ts"; +import { + ACTIVATION_FILE_FORMAT, + generateActivationCapability, + readActivationFile, + writeActivationFile, + type ActivationFile, +} from "./ActivationFile.ts"; +import { createLazyActivator, type ActivationTarget } from "./Gateway.ts"; +import { + ACTIVATION_MAX_FRAME_BYTES, + ACTIVATION_MAX_CONCURRENT_REQUESTS, + requestActivation, + startActivationServer, +} from "./ActivationServer.ts"; + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const wireFrame = (text: string): Uint8Array => { + const body = new TextEncoder().encode(text); + const frame = new Uint8Array(body.byteLength + 4); + new DataView(frame.buffer).setUint32(0, body.byteLength, false); + frame.set(body, 4); + return frame; +}; + +const rawActivationExchange = ( + port: number, + payload: Uint8Array, +): Effect.Effect => + Effect.callback((resume) => { + const socket = connectNet(port, "127.0.0.1"); + const chunks: Buffer[] = []; + const onError = (error: Error) => resume(Effect.fail(error)); + socket.once("error", onError); + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.once("end", () => resume(Effect.succeed(Buffer.concat(chunks)))); + socket.once("connect", () => socket.write(payload)); + return Effect.sync(() => socket.destroy()); + }); + +describe("gateway activation", () => { + it.live("writes and reads an owner-only exact activation file", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-activation-" }); + const stackId = yield* Schema.decodeEffect(StackIdSchema)("a".repeat(64)); + const value: ActivationFile = { + format: ACTIVATION_FILE_FORMAT, + endpoint: { host: "127.0.0.1", port: 44551 }, + capability: Redacted.make("test-capability"), + stackId, + desiredGeneration: 3, + gatewayInstanceId: "gateway-1", + ownerSessionId: "owner-1", + }; + yield* writeActivationFile(`${root}/activation.json`, value); + expect((yield* fs.stat(`${root}/activation.json`)).mode & 0o777).toBe(0o600); + expect(yield* readActivationFile(`${root}/activation.json`)).toEqual(value); + }), + ), + ); + + it.live("generates a high-entropy redacted activation capability", () => + withPlatform( + Effect.gen(function* () { + const capability = yield* generateActivationCapability; + expect(Redacted.value(capability)).toHaveLength(64); + // oxlint-disable-next-line typescript/no-base-to-string + expect(String(capability)).not.toContain(Redacted.value(capability)); + }), + ), + ); + + it.live("does not let an old owner scope remove a rotated activation file", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-activation-rotate-" }); + const path = `${root}/activation.json`; + const stackId = yield* Schema.decodeEffect(StackIdSchema)("c".repeat(64)); + const parent = yield* Scope.Scope; + const oldScope = yield* Scope.fork(parent, "sequential"); + const first: ActivationFile = { + format: ACTIVATION_FILE_FORMAT, + endpoint: { host: "127.0.0.1", port: 44551 }, + capability: Redacted.make("old-capability"), + stackId, + desiredGeneration: 1, + gatewayInstanceId: "gateway-old", + ownerSessionId: "owner-old", + }; + yield* writeActivationFile(path, first).pipe(Effect.provideService(Scope.Scope, oldScope)); + const second = { + ...first, + capability: Redacted.make("new-capability"), + gatewayInstanceId: "gateway-new", + }; + yield* writeActivationFile(path, second); + yield* Scope.close(oldScope, Exit.void); + expect(yield* readActivationFile(path)).toEqual(second); + }), + ), + ); + + it.live("removes a temporary activation file when publication fails", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-activation-failed-" }); + const path = `${root}/occupied`; + yield* fs.makeDirectory(path); + const stackId = yield* Schema.decodeEffect(StackIdSchema)("a".repeat(64)); + const value: ActivationFile = { + format: ACTIVATION_FILE_FORMAT, + endpoint: { host: "127.0.0.1", port: 44551 }, + capability: Redacted.make("failed-publication"), + stackId, + desiredGeneration: 0, + gatewayInstanceId: "gateway-failed", + ownerSessionId: "owner-failed", + }; + const result = yield* writeActivationFile(path, value).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const entries = yield* fs.readDirectory(root); + expect(entries.filter((entry) => entry.startsWith("occupied.")).length).toBe(0); + }), + ), + ); + + it.live("activates a dependency closure once and retains it for the generation", () => + Effect.gen(function* () { + const started: string[] = []; + const targets: Readonly>> = { + rest: { dependencies: ["database"] }, + database: { dependencies: [] }, + }; + const activator = yield* createLazyActivator({ + generation: 8, + targets, + activate: (target) => + Effect.sync(() => { + started.push(target); + return { capability: target, endpoint: { host: "127.0.0.1", port: 54321 } }; + }), + }); + yield* Effect.all([activator.activate("rest"), activator.activate("rest")], { + concurrency: "unbounded", + }); + expect(started).toEqual(["database", "rest"]); + }), + ); + + it.live("retries a failed activation while retaining successful activations", () => + Effect.gen(function* () { + let attempts = 0; + const activator = yield* createLazyActivator({ + generation: 8, + targets: { rest: { dependencies: [] } }, + activate: (target) => + Effect.suspend(() => { + attempts += 1; + return attempts === 1 + ? Effect.fail(new GatewayActivationError({ message: "transient" })) + : Effect.succeed({ + capability: target, + endpoint: { host: "127.0.0.1", port: 54321 }, + }); + }), + }); + const first = yield* activator.activate("rest").pipe(Effect.exit); + expect(Exit.isFailure(first)).toBe(true); + const second = yield* activator.activate("rest"); + expect(second.capability).toBe("rest"); + expect(attempts).toBe(2); + }), + ); + + it.live("keeps a shared activation alive when the first waiter is interrupted", () => + Effect.gen(function* () { + const started = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + let attempts = 0; + const activator = yield* createLazyActivator({ + generation: 9, + targets: { rest: { dependencies: [] } }, + activate: (target) => + Effect.gen(function* () { + attempts += 1; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { capability: target, endpoint: { host: "127.0.0.1", port: 54321 } }; + }), + }); + const first = yield* Effect.forkChild(activator.activate("rest")); + yield* Deferred.await(started); + yield* Fiber.interrupt(first); + const second = yield* Effect.forkChild(activator.activate("rest")); + yield* Deferred.succeed(release, undefined); + const result = yield* Fiber.join(second); + expect(result.capability).toBe("rest"); + expect(attempts).toBe(1); + }), + ); + + it.live("interrupts a blocked activation when its owner scope closes", () => + Effect.gen(function* () { + const parent = yield* Scope.Scope; + const owner = yield* Scope.fork(parent, "sequential"); + let interrupted = false; + const entered = Deferred.makeUnsafe(); + const hold = Deferred.makeUnsafe(); + const activator = yield* createLazyActivator({ + generation: 10, + targets: { rest: { dependencies: [] } }, + activate: () => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(hold)), + Effect.onInterrupt(() => Effect.sync(() => (interrupted = true))), + Effect.as({ capability: "rest", endpoint: { host: "127.0.0.1", port: 54321 } }), + ), + }).pipe(Effect.provideService(Scope.Scope, owner)); + const request = yield* Effect.forkChild(activator.activate("rest")); + yield* Deferred.await(entered); + yield* Scope.close(owner, Exit.void); + yield* Fiber.join(request).pipe(Effect.exit); + expect(interrupted).toBe(true); + }), + ); + + it.live("rejects activation for a missing target", () => + Effect.gen(function* () { + const activator = yield* createLazyActivator({ + generation: 1, + targets: {}, + activate: (target) => + Effect.succeed({ capability: target, endpoint: { host: "127.0.0.1", port: 1 } }), + }); + const exit = yield* activator.activate("rest").pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); + + it.live("enforces activation capability and generation fences", () => + withPlatform( + Effect.gen(function* () { + const stackId = yield* Schema.decodeEffect(StackIdSchema)("b".repeat(64)); + const capability = Redacted.make("capability-secret"); + const gatewayInstanceId = Redacted.make("gateway-2"); + const ownerSessionId = Redacted.make("owner-2"); + const server = yield* startActivationServer({ + capability, + stackId, + desiredGeneration: 4, + gatewayInstanceId, + ownerSessionId, + activate: (capability) => + Effect.succeed({ capability, endpoint: { host: "127.0.0.1", port: 54322 } }), + }); + const validRequest = { + protocol: "gateway-activation-v1" as const, + operation: "activate" as const, + capability, + target: "database" as const, + stackId, + desiredGeneration: 4, + gatewayInstanceId, + ownerSessionId, + }; + const response = yield* requestActivation({ + endpoint: server.endpoint, + request: validRequest, + }); + expect(response.capability).toBe("database"); + const fences = [ + { ...validRequest, capability: Redacted.make("wrong-capability") }, + { ...validRequest, gatewayInstanceId: Redacted.make("wrong-gateway") }, + { ...validRequest, ownerSessionId: Redacted.make("wrong-owner") }, + { ...validRequest, desiredGeneration: 3 }, + ]; + for (const fenced of fences) { + const rejected = yield* requestActivation({ + endpoint: server.endpoint, + request: fenced, + }).pipe(Effect.exit); + expect(Exit.isFailure(rejected)).toBe(true); + } + }), + ), + ); + + it.live("rejects oversized and trailing activation frames", () => + withPlatform( + Effect.gen(function* () { + const stackId = yield* Schema.decodeEffect(StackIdSchema)("d".repeat(64)); + const capability = Redacted.make("frame-capability"); + const gatewayInstanceId = Redacted.make("frame-gateway"); + const ownerSessionId = Redacted.make("frame-owner"); + const server = yield* startActivationServer({ + capability, + stackId, + desiredGeneration: 1, + gatewayInstanceId, + ownerSessionId, + activate: (target) => + Effect.succeed({ capability: target, endpoint: { host: "127.0.0.1", port: 54323 } }), + }); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + const request = JSON.stringify({ + protocol: "gateway-activation-v1", + operation: "activate", + capability: "frame-capability", + target: "database", + stackId, + desiredGeneration: 1, + gatewayInstanceId: "frame-gateway", + ownerSessionId: "frame-owner", + }); + const trailing = yield* rawActivationExchange( + server.endpoint.port, + new Uint8Array([...wireFrame(request), ...wireFrame(request)]), + ); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + expect(JSON.parse(new TextDecoder().decode(trailing.slice(4))).error.tag).toBe( + "invalid-request", + ); + const oversized = new Uint8Array(4); + new DataView(oversized.buffer).setUint32(0, ACTIVATION_MAX_FRAME_BYTES + 1, false); + const rejected = yield* rawActivationExchange(server.endpoint.port, oversized); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + expect(JSON.parse(new TextDecoder().decode(rejected.slice(4))).error.tag).toBe( + "invalid-request", + ); + }), + ), + ); + + it.live("interrupts blocked activation at the server deadline", () => + withPlatform( + Effect.gen(function* () { + const stackId = yield* Schema.decodeEffect(StackIdSchema)("e".repeat(64)); + const capability = Redacted.make("deadline-capability"); + const gatewayInstanceId = Redacted.make("deadline-gateway"); + const ownerSessionId = Redacted.make("deadline-owner"); + let interrupted = false; + const entered = Deferred.makeUnsafe(); + const server = yield* startActivationServer({ + capability, + stackId, + desiredGeneration: 1, + gatewayInstanceId, + ownerSessionId, + activate: () => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Effect.sync(() => (interrupted = true))), + ), + }); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + const request = JSON.stringify({ + protocol: "gateway-activation-v1", + operation: "activate", + capability: "deadline-capability", + target: "database", + stackId, + desiredGeneration: 1, + gatewayInstanceId: "deadline-gateway", + ownerSessionId: "deadline-owner", + }); + const exchange = yield* Effect.forkChild( + rawActivationExchange(server.endpoint.port, wireFrame(request)), + ); + yield* Deferred.await(entered); + yield* TestClock.adjust("5 seconds"); + const response = yield* Fiber.join(exchange); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + expect(JSON.parse(new TextDecoder().decode(response.slice(4))).error.tag).toBe( + "activation", + ); + expect(interrupted).toBe(true); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.live("bounds concurrent activation connections", () => + withPlatform( + Effect.gen(function* () { + const stackId = yield* Schema.decodeEffect(StackIdSchema)("f".repeat(64)); + const capability = Redacted.make("bound-capability"); + const gatewayInstanceId = Redacted.make("bound-gateway"); + const ownerSessionId = Redacted.make("bound-owner"); + const entered = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + let active = 0; + const server = yield* startActivationServer({ + capability, + stackId, + desiredGeneration: 1, + gatewayInstanceId, + ownerSessionId, + activate: () => + Effect.gen(function* () { + active += 1; + if (active === ACTIVATION_MAX_CONCURRENT_REQUESTS) + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + active -= 1; + return { capability: "database", endpoint: { host: "127.0.0.1", port: 54324 } }; + }), + }); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + const request = JSON.stringify({ + protocol: "gateway-activation-v1", + operation: "activate", + capability: "bound-capability", + target: "database", + stackId, + desiredGeneration: 1, + gatewayInstanceId: "bound-gateway", + ownerSessionId: "bound-owner", + }); + const open = (): Effect.Effect => + Effect.callback((resume) => { + const socket = connectNet(server.endpoint.port, "127.0.0.1"); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resume(Effect.succeed(socket)); + }; + socket.once("connect", () => socket.write(wireFrame(request))); + socket.once("connect", finish); + socket.once("close", finish); + socket.once("error", finish); + return Effect.sync(() => socket.destroy()); + }); + const clients = yield* Effect.forEach( + Array.from({ length: 17 }, (_, index) => index), + open, + { + concurrency: "unbounded", + }, + ); + yield* Deferred.await(entered); + expect(active).toBe(ACTIVATION_MAX_CONCURRENT_REQUESTS); + yield* Deferred.succeed(release, undefined); + for (const client of clients) client.destroy(); + }), + ), + ); +}); diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts new file mode 100644 index 0000000000..a987dac381 --- /dev/null +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -0,0 +1,484 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; +import { GatewayActivationError } from "../public/Errors.ts"; +import { connect as connectNet, createServer, type Server } from "node:net"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { + Agent, + createServer as createHttpServer, + request as requestHttp, + type IncomingMessage, + type ServerResponse, + // oxlint-disable-next-line effecttsgo/node-builtin-import +} from "node:http"; +import { createLazyActivator, makeGateway, type BackendEndpoint } from "./Gateway.ts"; +import { makeHttpGateway } from "./HttpGateway.ts"; +import { makeTcpGateway } from "./TcpGateway.ts"; +import type { NativeListener } from "../state/PortCoordinator.ts"; + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const closeServer = (server: Server): Effect.Effect => + Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void); + server.close(() => resume(Effect.void)); + }); + +const getStatus = (port: number, path: string) => + Effect.callback((resume) => { + const request = requestHttp({ host: "127.0.0.1", port, path }, (response) => { + response.resume(); + response.once("end", () => resume(Effect.succeed(response.statusCode ?? 0))); + }); + request.once("error", (error) => resume(Effect.fail(error))); + request.end(); + return Effect.sync(() => request.destroy()); + }); + +describe("stack gateway", () => { + it.live("routes an HTTP request after lazy activation and forwards the response", () => + withPlatform( + Effect.gen(function* () { + const backend = createHttpServer((req: IncomingMessage, res: ServerResponse) => { + res.setHeader("x-backend", "yes"); + res.setHeader("x-seen-forwarded-host", req.headers["x-forwarded-host"] ?? ""); + res.setHeader("x-seen-forwarded-proto", req.headers["x-forwarded-proto"] ?? ""); + res.setHeader("x-seen-forwarded-for", req.headers["x-forwarded-for"] ?? ""); + res.end(`${req.method}:${req.url}`); + }); + yield* Effect.callback((resume) => { + backend.once("error", (error) => resume(Effect.fail(error))); + backend.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const address = backend.address(); + if (typeof address !== "object" || address === null) return; + const backendEndpoint: BackendEndpoint = { host: "127.0.0.1", port: address.port }; + const activated: string[] = []; + const activator = yield* createLazyActivator({ + generation: 1, + targets: { + rest: { dependencies: [] }, + database: { dependencies: [] }, + }, + activate: (capability) => + Effect.sync(() => { + activated.push(capability); + return { capability, endpoint: backendEndpoint }; + }), + }); + const gateway = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [ + { capability: "rest", match: (request) => request.path.startsWith("/rest") }, + { capability: "database", match: (request) => request.path.startsWith("/db") }, + ], + activate: activator.activate, + resolveBackend: () => Effect.succeed(backendEndpoint), + cors: { "access-control-allow-origin": "https://example.test" }, + }); + const response = yield* Effect.callback< + { status: number; body: string; headers: Record }, + Error + >((resume) => { + const req = requestHttp( + { + host: "127.0.0.1", + port: gateway.port, + path: "/rest/hello", + headers: { host: "api.example.test" }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => + resume( + Effect.succeed({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + headers: res.headers, + }), + ), + ); + }, + ); + req.on("error", (error) => resume(Effect.fail(error))); + req.end(); + return Effect.sync(() => req.destroy()); + }); + expect(response.status).toBe(200); + expect(response.body).toBe("GET:/rest/hello"); + expect(response.headers["access-control-allow-origin"]).toBe("https://example.test"); + expect(response.headers["x-seen-forwarded-host"]).toBe("api.example.test"); + expect(response.headers["x-seen-forwarded-proto"]).toBe("http"); + expect(String(response.headers["x-seen-forwarded-for"])).toContain("127.0.0.1"); + expect(activated).toEqual(["rest"]); + yield* closeServer(backend); + }), + ), + ); + + it.live("adopts an already-bound native HTTP listener without rebinding", () => + withPlatform( + Effect.gen(function* () { + const prebound = createHttpServer(); + yield* Effect.callback((resume) => { + prebound.once("error", (error) => resume(Effect.fail(error))); + prebound.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const address = prebound.address(); + if (typeof address !== "object" || address === null) return; + let released = false; + let releaseCalls = 0; + const listener: NativeListener = { + field: "api", + address: "127.0.0.1", + port: address.port, + binding: { kind: "http", server: prebound }, + close: Effect.sync(() => { + released = true; + releaseCalls += 1; + }), + }; + const gateway = yield* makeHttpGateway({ + listener, + routes: [], + activate: () => Effect.fail(new GatewayActivationError({ message: "not reached" })), + }); + expect(gateway.server).toBe(prebound); + expect(gateway.port).toBe(address.port); + yield* Effect.all([gateway.close, gateway.close], { concurrency: "unbounded" }); + expect(released).toBe(true); + expect(releaseCalls).toBe(1); + expect(prebound.listening).toBe(false); + }), + ), + ); + + it.live("closes an idle keep-alive connection it owns", () => + withPlatform( + Effect.gen(function* () { + const backend = createHttpServer((_request, response) => response.end("ok")); + yield* Effect.callback((resume) => { + backend.once("error", (error) => resume(Effect.fail(error))); + backend.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const address = backend.address(); + if (typeof address !== "object" || address === null) return; + const endpoint: BackendEndpoint = { host: "127.0.0.1", port: address.port }; + const gateway = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [{ capability: "rest", match: () => true }], + activate: () => Effect.succeed({ capability: "rest", endpoint }), + }); + const agent = new Agent({ keepAlive: true }); + yield* Effect.callback((resume) => { + const request = requestHttp( + { host: "127.0.0.1", port: gateway.port, path: "/", agent }, + (response) => { + response.resume(); + response.once("end", () => resume(Effect.void)); + }, + ); + request.once("error", (error) => resume(Effect.fail(error))); + request.end(); + return Effect.sync(() => request.destroy()); + }); + yield* gateway.close; + expect(gateway.server.listening).toBe(false); + agent.destroy(); + yield* closeServer(backend); + }), + ), + ); + + it.live("adopts a pre-bound TCP listener while preserving half-close", () => + withPlatform( + Effect.gen(function* () { + const backend = createServer({ allowHalfOpen: true }, (socket) => { + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.once("end", () => socket.end(Buffer.concat(chunks))); + }); + yield* Effect.callback((resume) => { + backend.once("error", (error) => resume(Effect.fail(error))); + backend.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const backendAddress = backend.address(); + if (typeof backendAddress !== "object" || backendAddress === null) return; + const prebound = createServer({ allowHalfOpen: true }); + yield* Effect.callback((resume) => { + prebound.once("error", (error) => resume(Effect.fail(error))); + prebound.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const preboundAddress = prebound.address(); + if (typeof preboundAddress !== "object" || preboundAddress === null) return; + const endpoint: BackendEndpoint = { host: "127.0.0.1", port: backendAddress.port }; + const listener: NativeListener = { + field: "database", + address: "127.0.0.1", + port: preboundAddress.port, + binding: { kind: "tcp", server: prebound, allowHalfOpen: true }, + close: closeServer(prebound), + }; + const gateway = yield* makeTcpGateway({ + listener, + routes: [{ capability: "database", match: () => true }], + activate: () => Effect.succeed({ capability: "database", endpoint }), + }); + const fixture = Buffer.from("half-close-through-adopted-listener"); + const response = yield* Effect.callback((resume) => { + const socket = connectNet(gateway.port, "127.0.0.1"); + const chunks: Buffer[] = []; + socket.once("connect", () => { + socket.write(fixture); + socket.end(); + }); + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.once("end", () => resume(Effect.succeed(Buffer.concat(chunks)))); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }); + expect(response.equals(fixture)).toBe(true); + yield* gateway.close; + yield* closeServer(backend); + }), + ), + ); + + it.live("rolls back an HTTP listener when TCP acquisition fails", () => + withPlatform( + Effect.gen(function* () { + const httpServer = createHttpServer(); + const badTcpServer = createHttpServer(); + const listen = (server: Server) => + Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + yield* listen(httpServer); + yield* listen(badTcpServer); + const httpAddress = httpServer.address(); + const badTcpAddress = badTcpServer.address(); + if ( + typeof httpAddress !== "object" || + httpAddress === null || + typeof badTcpAddress !== "object" || + badTcpAddress === null + ) + return; + const closeNative = (server: Server) => closeServer(server); + const httpListener: NativeListener = { + field: "api", + address: "127.0.0.1", + port: httpAddress.port, + binding: { kind: "http", server: httpServer }, + close: closeNative(httpServer), + }; + const badTcpListener: NativeListener = { + field: "database", + address: "127.0.0.1", + port: badTcpAddress.port, + binding: { kind: "http", server: badTcpServer }, + close: closeNative(badTcpServer), + }; + const result = yield* makeGateway({ + http: { + listener: httpListener, + routes: [], + }, + tcp: { + listener: badTcpListener, + routes: [], + }, + activate: () => Effect.fail(new GatewayActivationError({ message: "not reached" })), + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(httpServer.listening).toBe(false); + expect(badTcpServer.listening).toBe(true); + yield* closeServer(badTcpServer); + }), + ), + ); + + it.live("preserves HTTP WebSocket upgrades and tunnel bytes", () => + withPlatform( + Effect.gen(function* () { + const backend = createServer((socket) => { + let handshake = false; + let buffered = Buffer.alloc(0); + socket.on("data", (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + if (!handshake) { + const boundary = buffered.indexOf("\r\n\r\n"); + if (boundary < 0) return; + handshake = true; + socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n"); + const remainder = buffered.subarray(boundary + 4); + if (remainder.byteLength > 0) socket.write(remainder); + buffered = Buffer.alloc(0); + return; + } + socket.write(chunk); + }); + socket.once("end", () => socket.destroy()); + }); + yield* Effect.callback((resume) => { + backend.once("error", (error) => resume(Effect.fail(error))); + backend.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const address = backend.address(); + if (typeof address !== "object" || address === null) return; + const endpoint: BackendEndpoint = { host: "127.0.0.1", port: address.port }; + const gateway = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [{ capability: "rest", match: () => true }], + activate: () => Effect.succeed({ capability: "rest", endpoint }), + }); + const tunneled = yield* Effect.callback((resume) => { + const socket = connectNet(gateway.port, "127.0.0.1"); + let handshake = false; + let output = Buffer.alloc(0); + socket.on("data", (chunk: Buffer) => { + output = Buffer.concat([output, chunk]); + if (!handshake && output.includes("\r\n\r\n")) { + handshake = true; + socket.write("ws-ping"); + } + if (handshake && output.includes("ws-ping")) { + resume(Effect.succeed(output.toString())); + socket.destroy(); + } + }); + socket.once("connect", () => + socket.write( + "GET /socket HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + ), + ); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }); + expect(tunneled).toContain("101 Switching Protocols"); + expect(tunneled).toContain("ws-ping"); + yield* gateway.close; + yield* closeServer(backend); + }), + ), + ); + + it.live("copies TCP bytes transparently", () => + withPlatform( + Effect.gen(function* () { + const backend = createServer((socket) => { + socket.on("data", (chunk) => socket.write(chunk)); + socket.once("end", () => socket.end()); + }); + yield* Effect.callback((resume) => { + backend.once("error", (error) => resume(Effect.fail(error))); + backend.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const address = backend.address(); + if (typeof address !== "object" || address === null) return; + const endpoint: BackendEndpoint = { host: "127.0.0.1", port: address.port }; + const activator = yield* createLazyActivator({ + generation: 2, + targets: { database: { dependencies: [] } }, + activate: () => Effect.succeed({ capability: "database", endpoint }), + }); + const gateway = yield* makeTcpGateway({ + address: "127.0.0.1", + port: 0, + routes: [{ capability: "database", match: () => true }], + activate: activator.activate, + resolveBackend: () => Effect.succeed(endpoint), + }); + const largeFixture = Buffer.alloc(512 * 1024); + for (let index = 0; index < largeFixture.byteLength; index += 1) + largeFixture[index] = (index * 31 + 17) % 256; + const fixtures = [ + Buffer.from("postgres-bytes"), + Buffer.from([0x16, 0x03, 0x01, 0x00, 0x2a, 0xff, 0x00]), + Buffer.from("220 smtp.example ESMTP\r\nSTARTTLS\r\n"), + Buffer.from("+OK pop3.example\r\nSTLS\r\n"), + largeFixture, + ]; + const responses = yield* Effect.forEach(fixtures, (fixture) => + Effect.callback((resume) => { + const socket = connectNet(gateway.port, "127.0.0.1"); + const chunks: Buffer[] = []; + socket.once("connect", () => socket.end(fixture)); + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.on("end", () => resume(Effect.succeed(Buffer.concat(chunks)))); + socket.on("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }), + ); + expect(responses).toEqual(fixtures); + yield* closeServer(backend); + }), + ), + ); + + it.live("keeps probes dormant and maps activation/backend failures", () => + withPlatform( + Effect.gen(function* () { + let activations = 0; + const route = { capability: "rest" as const, match: () => true }; + const dormant = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [route], + activate: () => { + activations += 1; + return Effect.fail(new GatewayActivationError({ message: "should not run" })); + }, + }); + expect(yield* getStatus(dormant.port, "/health")).toBe(200); + expect(activations).toBe(0); + yield* dormant.close; + + const activationFailure = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [route], + activate: () => Effect.fail(new GatewayActivationError({ message: "activation failed" })), + }); + expect(yield* getStatus(activationFailure.port, "/api")).toBe(503); + yield* activationFailure.close; + + const backendFailure = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [route], + activate: () => + Effect.succeed({ capability: "rest", endpoint: { host: "127.0.0.1", port: 1 } }), + resolveBackend: () => + Effect.fail(new GatewayActivationError({ message: "Gateway backend failed" })), + }); + expect(yield* getStatus(backendFailure.port, "/api")).toBe(502); + yield* backendFailure.close; + + const tcpFailure = yield* makeTcpGateway({ + address: "127.0.0.1", + port: 0, + routes: [{ capability: "database", match: () => true }], + activate: () => Effect.fail(new GatewayActivationError({ message: "activation failed" })), + }); + const bytesOnActivationFailure = yield* Effect.callback((resume) => { + const socket = connectNet(tcpFailure.port, "127.0.0.1"); + let bytes = 0; + socket.on("data", (chunk: Buffer) => (bytes += chunk.byteLength)); + socket.once("close", () => resume(Effect.succeed(bytes))); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }); + expect(bytesOnActivationFailure).toBe(0); + yield* tcpFailure.close; + }), + ), + ); +}); diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index a75e88d950..911139cdba 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -47,6 +47,8 @@ export interface PlannedWorkload { export interface ExecutionPlan { readonly runtime: StackRuntime; + /** Materialized lazy/eager policy consumed by the Supervisor gateway seam. */ + readonly activation: Readonly<{ [Name in CapabilityName]: EnabledCapability["activation"] }>; readonly startOrder: ReadonlyArray; readonly stopOrder: ReadonlyArray; readonly dependencies: Readonly<{ [Name in CapabilityName]: ReadonlyArray }>; @@ -181,8 +183,21 @@ export const createExecutionPlan = ( }; for (const entry of declaredWorkloads) visitWorkload(entry.id); if (graphError !== undefined) return Effect.fail(graphError); + const activation = { + database: enabled.database.activation, + rest: enabled.rest.activation, + auth: enabled.auth.activation, + realtime: enabled.realtime.activation, + storage: enabled.storage.activation, + functions: enabled.functions.activation, + studio: enabled.studio.activation, + mail: enabled.mail.activation, + analytics: enabled.analytics.activation, + pooler: enabled.pooler.activation, + } satisfies { [Name in CapabilityName]: EnabledCapability["activation"] }; return Effect.succeed({ runtime, + activation, startOrder: start, stopOrder, dependencies: dependencyMap, diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 85cbae02d4..7713a7352a 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -1,5 +1,9 @@ import { Crypto, Effect, Exit, FileSystem, Path, Scope, Schema } from "effect"; import type { StackRuntime } from "../public/Runtime.ts"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import type { Server as HttpServer } from "node:http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import type { Server as NetServer } from "node:net"; import { NetworkPortSchema, PORT_FIELDS, type PortField } from "../public/Status.ts"; import { PortAllocationError, @@ -27,8 +31,14 @@ export interface NativeListener { readonly address: string; readonly port: number; readonly close: Effect.Effect; + /** The exact bound listener may be adopted by a gateway without rebind. */ + readonly binding: NativeListenerBinding; } +export type NativeListenerBinding = + | { readonly kind: "http"; readonly server: HttpServer } + | { readonly kind: "tcp"; readonly server: NetServer; readonly allowHalfOpen: true }; + export interface PortPlanOptions { readonly lifecycle?: "stopped" | "running"; readonly runtime?: StackRuntime; @@ -39,6 +49,7 @@ export interface PortPlanOptions { export interface PortReservation { readonly assignments: Readonly>>; + /** Already-bound native listeners that can be adopted by a gateway. */ readonly nativeListeners: ReadonlyArray; } @@ -217,6 +228,22 @@ export const makePortCoordinator = ( ); const nativeListeners: NativeListener[] = []; + const runningState: PersistedStackState = { + ...committed.next, + desiredLifecycle: "running", + }; + const commitRunning = registry.withLock( + Effect.gen(function* () { + const latest = yield* store.read(stackId); + if (latest === undefined || latest.desiredGeneration !== committed.next.desiredGeneration) + return yield* new StackStateGenerationMismatchError({ + expectedGeneration: committed.next.desiredGeneration, + actualGeneration: latest?.desiredGeneration, + message: "Persisted stack state changed while acquiring runtime ownership", + }); + yield* store.replaceUnlocked(stackId, runningState, latest.desiredGeneration); + }), + ); const enabledAssignments = fields.flatMap((field) => { const intent = listenerIntents[field]; const assignment = committed.byField[field]; @@ -227,14 +254,15 @@ export const makePortCoordinator = ( const first = enabledAssignments[0]; return yield* unavailable(first?.assignment.port ?? 0, first?.field ?? "api"); } + const bindNative = options.bindNative; const parentScope = yield* Scope.Scope; const acquired = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const listenerScope = yield* Scope.fork(parentScope, "sequential"); - const exit = yield* Effect.exit( + const bound = yield* Effect.exit( restore( Effect.forEach(enabledAssignments, ({ field, intent, assignment }) => - options.bindNative!(intent.address, assignment.port, field).pipe( + bindNative(intent.address, assignment.port, field).pipe( Effect.catchTag("PortUnavailableError", () => Effect.fail(unavailable(assignment.port, field)), ), @@ -243,11 +271,16 @@ export const makePortCoordinator = ( ), ), ); - if (Exit.isFailure(exit)) { - yield* Scope.close(listenerScope, exit); - return yield* Effect.failCause(exit.cause); + if (Exit.isFailure(bound)) { + yield* Scope.close(listenerScope, bound); + return yield* Effect.failCause(bound.cause); } - return exit.value; + const committedRunning = yield* Effect.exit(restore(commitRunning)); + if (Exit.isFailure(committedRunning)) { + yield* Scope.close(listenerScope, committedRunning); + return yield* Effect.failCause(committedRunning.cause); + } + return bound.value; }), ); nativeListeners.push(...acquired); @@ -261,27 +294,8 @@ export const makePortCoordinator = ( yield* options.publishContainer(intent.address, assignment.port, field); } - if (committed.lifecycle === "running") { - const runningState: PersistedStackState = { - ...committed.next, - desiredLifecycle: "running", - }; - yield* registry.withLock( - Effect.gen(function* () { - const latest = yield* store.read(stackId); - if ( - latest === undefined || - latest.desiredGeneration !== committed.next.desiredGeneration - ) - return yield* new StackStateGenerationMismatchError({ - expectedGeneration: committed.next.desiredGeneration, - actualGeneration: latest?.desiredGeneration, - message: "Persisted stack state changed while acquiring runtime ownership", - }); - yield* store.replaceUnlocked(stackId, runningState, latest.desiredGeneration); - }), - ); - } + if (committed.lifecycle === "running" && committed.runtime.kind !== "native") + yield* commitRunning; return { assignments: committed.byField, nativeListeners }; }), }); diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index bd2b7575df..428d8f4c2d 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -1,15 +1,22 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Scope } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Option, Scope } from "effect"; import { createServer, type Server } from "node:net"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { PortAllocationError, PortUnavailableError, + StackStateGenerationMismatchError, StackLifecycleConflictError, } from "../public/Errors.ts"; import { makePortRegistry } from "./PortRegistry.ts"; -import { makePortCoordinator, type ListenerIntents } from "./PortCoordinator.ts"; +import { + makePortCoordinator, + type NativeListener, + type ListenerIntents, +} from "./PortCoordinator.ts"; import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; const layer = NodeServices.layer; @@ -67,7 +74,7 @@ const makeIdentity = (root: string, name: string): StackIdentity => ({ const holdHostPort = (port: number): Effect.Effect => Effect.acquireRelease( Effect.callback((resume) => { - const server = createServer(); + const server = createServer({ allowHalfOpen: true }); const onError = (error: Error) => { server.removeListener("error", onError); resume(Effect.fail(error)); @@ -92,6 +99,74 @@ const holdHostPort = (port: number): Effect.Effect = }), ); +const holdHttpPort = (port: number): Effect.Effect => + Effect.acquireRelease( + Effect.callback((resume) => { + const server = createHttpServer(); + const onError = (error: Error) => { + server.removeListener("error", onError); + resume(Effect.fail(error)); + }; + server.once("error", onError); + server.listen({ host: "127.0.0.1", port }, () => { + server.removeListener("error", onError); + resume(Effect.succeed(server)); + }); + return Effect.sync(() => server.removeListener("error", onError)); + }), + (server) => + Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return; + } + server.close(() => resume(Effect.void)); + }), + ); + +const bindTestNative = ( + address: string, + port: number, + field: NativeListener["field"], + hooks: { readonly onClose?: () => void } = {}, +): Effect.Effect => { + const isHttp = + field === "api" || field === "studio" || field === "mailUi" || field === "functionsInspector"; + if (isHttp) + return Effect.acquireRelease( + holdHttpPort(port).pipe( + Effect.map((server) => ({ + address, + port, + field, + binding: { kind: "http" as const, server }, + close: Effect.sync(() => hooks.onClose?.()), + })), + Effect.mapError( + (cause) => + new PortUnavailableError({ port, field, message: "host listener unavailable", cause }), + ), + ), + (listener) => listener.close, + ); + return Effect.acquireRelease( + holdHostPort(port).pipe( + Effect.map((server) => ({ + address, + port, + field, + binding: { kind: "tcp" as const, server, allowHalfOpen: true as const }, + close: Effect.sync(() => hooks.onClose?.()), + })), + Effect.mapError( + (cause) => + new PortUnavailableError({ port, field, message: "host listener unavailable", cause }), + ), + ), + (listener) => listener.close, + ); +}; + describe("sticky port coordination", () => { it.live("keeps automatic assignments sticky and exclusive", () => withPlatform( @@ -220,13 +295,7 @@ describe("sticky port coordination", () => { }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindNative: (address, port, field) => - Effect.succeed({ - address, - port, - field, - close: Effect.void, - }), + bindNative: bindTestNative, }); const result = yield* coordinator.planAndReserve( stackId, @@ -314,23 +383,7 @@ describe("sticky port coordination", () => { const occupiedPort = hostAddress.port; const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindNative: (address, port, field) => - holdHostPort(port).pipe( - Effect.map((server) => ({ - field, - address, - port, - close: Effect.sync(() => server.close()), - })), - Effect.mapError( - () => - new PortUnavailableError({ - port, - field, - message: "host listener already owns port", - }), - ), - ), + bindNative: bindTestNative, }); const exit = yield* coordinator .planAndReserve( @@ -369,21 +422,12 @@ describe("sticky port coordination", () => { bindNative: (address, port, field) => bound.has(port) ? Effect.fail(new PortUnavailableError({ port, field, message: "already bound" })) - : Effect.acquireRelease( - Effect.sync(() => { - bound.add(port); - return { - address, - port, - field, - close: Effect.sync(() => { - bound.delete(port); - closed += 1; - }), - }; - }), - (listener) => listener.close, - ), + : bindTestNative(address, port, field, { + onClose: () => { + bound.delete(port); + closed += 1; + }, + }).pipe(Effect.tap(() => Effect.sync(() => bound.add(port)))), }); const result = yield* coordinator.planAndReserve(e, intents("automatic"), { lifecycle: "running", @@ -420,20 +464,9 @@ describe("sticky port coordination", () => { return Effect.fail( new PortUnavailableError({ port, field, message: "simulated second bind failure" }), ); - return Effect.acquireRelease( - Effect.sync(() => { - bound.add(port); - return { - address, - port, - field, - close: Effect.sync(() => { - bound.delete(port); - }), - }; - }), - (listener) => listener.close, - ); + return bindTestNative(address, port, field, { + onClose: () => bound.delete(port), + }).pipe(Effect.tap(() => Effect.sync(() => bound.add(port)))); }, }); const exit = yield* coordinator @@ -448,6 +481,63 @@ describe("sticky port coordination", () => { ), ); + it.live("closes native listeners when the final generation fence fails", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-native-fence-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "native-fence"); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, baseState(stackId, identity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const entered = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + const bound = new Set(); + const closed = new Set(); + const coordinator = makePortCoordinator(registry, store, { + bindNative: (address, port, field) => + bindTestNative(address, port, field, { + onClose: () => { + bound.delete(port); + closed.add(port); + }, + }).pipe( + Effect.tap(() => + Effect.sync(() => { + bound.add(port); + }), + ), + Effect.tap(() => Deferred.succeed(entered, undefined)), + Effect.tap(() => Deferred.await(release)), + ), + }); + const reservation = yield* Effect.forkChild( + coordinator.planAndReserve( + stackId, + { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: "automatic" }, + }, + { lifecycle: "running", runtime: { kind: "native" } }, + ), + { startImmediately: true }, + ); + yield* Deferred.await(entered); + const current = yield* store.read(stackId); + if (current === undefined) return; + yield* store.replace(stackId, { ...current, desiredGeneration: 1 }, 0); + yield* Deferred.succeed(release, undefined); + const result = yield* Fiber.join(reservation).pipe(Effect.exit); + expect(errorOf(result)).toBeInstanceOf(StackStateGenerationMismatchError); + expect(bound).toHaveLength(0); + expect(closed).toHaveLength(1); + }), + ), + ); + it.live("retains a chosen assignment when container publication races", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/Reconciler.unit.test.ts b/packages/stack/src/supervisor/Reconciler.unit.test.ts index 20f638ecbd..d82471e23e 100644 --- a/packages/stack/src/supervisor/Reconciler.unit.test.ts +++ b/packages/stack/src/supervisor/Reconciler.unit.test.ts @@ -26,6 +26,18 @@ const workload = ( const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ runtime: { kind: "native" }, + activation: { + database: "eager", + rest: "eager", + auth: "eager", + realtime: "eager", + storage: "eager", + functions: "eager", + studio: "eager", + mail: "eager", + analytics: "eager", + pooler: "eager", + }, startOrder: ["database"], stopOrder: ["database"], dependencies: { diff --git a/packages/stack/src/supervisor/reconciler.integration.test.ts b/packages/stack/src/supervisor/reconciler.integration.test.ts index 2bb20e09c7..006964cdd7 100644 --- a/packages/stack/src/supervisor/reconciler.integration.test.ts +++ b/packages/stack/src/supervisor/reconciler.integration.test.ts @@ -31,6 +31,18 @@ const workload = ( }); const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ runtime: { kind: "native" }, + activation: { + database: "eager", + rest: "eager", + auth: "eager", + realtime: "eager", + storage: "eager", + functions: "eager", + studio: "eager", + mail: "eager", + analytics: "eager", + pooler: "eager", + }, startOrder: ["database"], stopOrder: ["database"], dependencies: { From bff21f07563cfddb2b84e9bfa231eddb111521c5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 03:23:59 +0200 Subject: [PATCH 016/174] feat(stack): serve live functions through edge runtime --- ...26-08-28-stack-runtime-rewrite-progress.md | 17 + .../plans/2026-08-28-stack-runtime-rewrite.md | 6 +- .../stack/src/functions/FunctionDiscovery.ts | 274 ++++++++++++++++ packages/stack/src/functions/FunctionsRoot.ts | 293 ++++++++++++++++++ .../functions/functions.integration.test.ts | 274 ++++++++++++++++ packages/stack/src/gateway/Gateway.ts | 17 +- packages/stack/src/gateway/HttpGateway.ts | 56 +++- .../src/gateway/gateway.integration.test.ts | 170 +++++++++- .../stack/src/model/capabilities/functions.ts | 49 ++- .../stack/src/model/capabilities/index.ts | 12 +- packages/stack/src/testing.ts | 1 + 11 files changed, 1131 insertions(+), 38 deletions(-) create mode 100644 packages/stack/src/functions/FunctionDiscovery.ts create mode 100644 packages/stack/src/functions/FunctionsRoot.ts create mode 100644 packages/stack/src/functions/functions.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index d7e5784865..eddc57a654 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -428,3 +428,20 @@ Implementation continues after recording a ruling; this file is not a question q - Effect/generic lint over all Task 8 sources and tests, formatting, and `git diff --check` — passed. - Concrete capability route catalogs remain Task 14; Functions discovery, container engine routing, and full Supervisor lifecycle composition remain Tasks 9, 10, and 11 respectively. + +### Task 9 — 2026-08-29 + +- Added request-time FunctionsRoot containment and canonical one-mount descriptors. Every function, + entrypoint, import-map, static pattern, shared path, and symlink target is checked against the + live root; filesystem edits become visible on the next discovery without a watcher or state write. +- Added closed FunctionDiscovery settings/defaults, custom entrypoint/static/import-map descriptors, + Redacted environment values, disabled/not-found and typed path failures, generic module resolution, + and Functions gateway preparation (`/functions/v1/:slug`) that captures the exact invocation in a + typed backend-dispatch closure and avoids activation on 404/503. +- Added integration coverage for live create/edit/delete, config-only overrides, root and category + symlink escapes, path traversal/absolute values, shared modules, canonical mounts, gateway + preflight activation fencing, and HTTP/WebSocket dispatch descriptor handoff. +- RED evidence: the first functions integration run failed because the new discovery modules were + absent; subsequent gateway preflight coverage initially exposed eager activation construction. +- `pnpm --dir packages/stack types:check`, focused Functions/gateway integration, full package + integration (163 tests), Effect lint, formatting, and `git diff --check` passed. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index 300876ac44..9c6abd3572 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -494,18 +494,18 @@ artifact keys, state schemas, drivers, or control protocols. - Produces request-time slug discovery under one `functionsRoot` with closed persisted overrides. - Native and container execution use the stack Edge Runtime; no separate functions-serving workflow. -- [ ] **Step 1: Write safe live-edit scenarios and verify RED** +- [x] **Step 1: Write safe live-edit scenarios and verify RED** Cover create/edit/delete visible on next request, shared modules, import maps/static files, disabled override not-found behavior, and rejection of traversal, absolute paths, and symlink escapes. -- [ ] **Step 2: Implement safe resolution** +- [x] **Step 2: Implement safe resolution** Canonicalize and realpath every resolved entry/import/static/symlink target before opening it; prove membership beneath `functionsRoot`. Container mode mounts only the whole root read-only at one stable path. Do not add a watcher or desired-state mutation for code edits. -- [ ] **Step 3: Verify and commit** +- [x] **Step 3: Verify and commit** Run `functions.integration.test.ts` in native fixtures, stack type-check, and commit: diff --git a/packages/stack/src/functions/FunctionDiscovery.ts b/packages/stack/src/functions/FunctionDiscovery.ts new file mode 100644 index 0000000000..44237ce6d2 --- /dev/null +++ b/packages/stack/src/functions/FunctionDiscovery.ts @@ -0,0 +1,274 @@ +import { Effect, FileSystem, Path } from "effect"; +import type { + FunctionSecret, + MaterializedFunctionSettings, + FunctionSettings, +} from "../model/capabilities/functions.ts"; +import { FunctionSettingsDefaults } from "../model/capabilities/functions.ts"; +import type { + ActivationResult, + BackendEndpoint, + GatewayRoute, + GatewayRouteRequest, +} from "../gateway/Gateway.ts"; +import { GatewayRouteNotFoundError } from "../gateway/Gateway.ts"; +import { GatewayActivationError } from "../public/Errors.ts"; +import { + FunctionNotFoundError, + FunctionPathError, + type ContainedPath, + type FunctionsContainerMapping, + type FunctionsRoot, +} from "./FunctionsRoot.ts"; + +export { FunctionNotFoundError, FunctionPathError } from "./FunctionsRoot.ts"; + +export interface FunctionInvocationPath { + readonly native: string; + readonly container: string; +} + +export interface FunctionInvocationRoot { + readonly native: string; + readonly container: string; + readonly mount: FunctionsContainerMapping["mount"]; +} + +export interface FunctionInvocation { + readonly slug: string; + readonly verifyJwt: boolean; + readonly entrypoint: FunctionInvocationPath; + readonly importMap?: FunctionInvocationPath; + readonly staticPatterns: ReadonlyArray; + readonly env: Readonly>; + readonly root: FunctionInvocationRoot; +} + +export interface FunctionDiscoveryOptions { + readonly root: FunctionsRoot; + readonly settings: MaterializedFunctionSettings; + readonly globalEnv?: Readonly>; +} + +export interface FunctionDiscovery { + readonly discover: ( + slug: string, + ) => Effect.Effect; +} + +const functionSlugPattern = /^[A-Za-z0-9_-]+$/u; +const notFound = (message: string, slug?: string) => + new FunctionNotFoundError({ message, ...(slug === undefined ? {} : { slug }) }); + +const pathFailure = (message: string, path?: string, cause?: unknown) => + new FunctionPathError({ + message, + ...(path === undefined ? {} : { path }), + ...(cause === undefined ? {} : { cause }), + }); + +const makeInvocation = ( + root: FunctionsRoot, + options: FunctionDiscoveryOptions, + slug: string, + settings: FunctionSettings, +): Effect.Effect< + FunctionInvocation, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + if (!settings.enabled) return yield* notFound("Function is disabled", slug); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const canonicalRoot = yield* root.resolveContained("", { kind: "directory" }); + const functionDirectory = yield* root.resolveFunctionPath(slug, "", { kind: "directory" }); + + const validateTree = ( + directory: string, + relativeDirectory: string, + seen: Set, + ): Effect.Effect< + void, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem | Path.Path + > => + Effect.gen(function* () { + const canonical = yield* fs + .realPath(directory) + .pipe( + Effect.mapError((cause) => + pathFailure("Function directory cannot be resolved", directory, cause), + ), + ); + if (seen.has(canonical)) return; + seen.add(canonical); + const entries = yield* fs + .readDirectory(directory) + .pipe( + Effect.mapError((cause) => + pathFailure("Function directory cannot be read", directory, cause), + ), + ); + for (const entry of entries) { + const child = path.join(directory, entry); + const relative = path.join(relativeDirectory, entry); + const info = yield* fs + .stat(child) + .pipe( + Effect.mapError((cause) => + pathFailure("Function path cannot be inspected", relative, cause), + ), + ); + yield* root.resolveContained(relative, { + kind: info.type === "Directory" ? "directory" : "file", + }); + if (info.type === "Directory") yield* validateTree(child, relative, seen); + } + }); + + yield* validateTree(functionDirectory.native, functionDirectory.relative, new Set()); + const shared = yield* root + .resolveContained("_shared", { kind: "directory" }) + .pipe(Effect.catchTag("FunctionNotFoundError", () => Effect.as(Effect.void, undefined))); + if (shared !== undefined) yield* validateTree(shared.native, shared.relative, new Set()); + + const entrypointRelative = settings.entrypoint.length > 0 ? settings.entrypoint : "index.ts"; + const entrypoint = yield* root + .resolveFunctionPath(slug, entrypointRelative, { kind: "file" }) + .pipe( + Effect.catchTag("FunctionNotFoundError", () => + Effect.fail(notFound("Function entrypoint is not present", slug)), + ), + ); + + let importMap: ContainedPath | undefined; + const explicitImportMap = settings.import_map.length > 0 ? settings.import_map : undefined; + if (explicitImportMap !== undefined) { + importMap = yield* root + .resolveFunctionPath(slug, explicitImportMap, { kind: "file" }) + .pipe( + Effect.catchTag("FunctionNotFoundError", () => + Effect.fail(pathFailure("Function import map is not present", explicitImportMap)), + ), + ); + } else { + for (const candidate of ["deno.json", "deno.jsonc"]) { + const resolved = yield* root + .resolveFunctionPath(slug, candidate, { kind: "file" }) + .pipe(Effect.catchTag("FunctionNotFoundError", () => Effect.as(Effect.void, undefined))); + if (resolved !== undefined) { + importMap = resolved; + break; + } + } + } + + const staticPatterns: ContainedPath[] = []; + for (const pattern of settings.static_files) { + if (pattern.length === 0) + return yield* pathFailure("Function static pattern must not be empty", pattern); + const resolved = yield* root.resolveFunctionPath(slug, pattern, { kind: "pattern" }); + staticPatterns.push(resolved); + } + + const env = Object.freeze(Object.assign({}, options.globalEnv ?? {}, settings.env)); + const nativeRoot = canonicalRoot.native; + const mount = Object.freeze({ + source: nativeRoot, + target: canonicalRoot.container, + readOnly: true, + }); + const containerRoot = mount.target; + return Object.freeze({ + slug, + verifyJwt: settings.verify_jwt, + entrypoint: Object.freeze({ native: entrypoint.native, container: entrypoint.container }), + ...(importMap === undefined + ? {} + : { + importMap: Object.freeze({ native: importMap.native, container: importMap.container }), + }), + staticPatterns: Object.freeze( + staticPatterns.map((value) => + Object.freeze({ native: value.native, container: value.container }), + ), + ), + env, + root: Object.freeze({ + native: nativeRoot, + container: containerRoot, + mount, + }), + }) satisfies FunctionInvocation; + }); + +export const makeFunctionDiscovery = ( + options: FunctionDiscoveryOptions, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const discover = (slug: string) => { + if (!functionSlugPattern.test(slug) || slug === "_shared") + return Effect.fail(notFound("Function slug is invalid", slug)); + const settings = options.settings[slug] ?? FunctionSettingsDefaults; + return makeInvocation(options.root, options, slug, settings).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + }; + return { discover } satisfies FunctionDiscovery; + }); + +export const discoverFunction = ( + root: FunctionsRoot, + slug: string, + settings: MaterializedFunctionSettings, +): Effect.Effect< + FunctionInvocation, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem | Path.Path +> => + makeFunctionDiscovery({ root, settings }).pipe( + Effect.flatMap((discovery) => discovery.discover(slug)), + ); + +const functionSlugFromRequest = (request: GatewayRouteRequest): string | undefined => { + const path = request.path.split("?", 1)[0] ?? request.path; + const match = /^\/functions\/v1\/([^/]+)(?:\/.*)?$/u.exec(path); + return match?.[1]; +}; + +export interface FunctionsGatewayRouteOptions { + readonly dispatch: ( + invocation: FunctionInvocation, + activation: ActivationResult, + ) => Effect.Effect; +} + +export const makeFunctionsGatewayRoute = ( + discovery: FunctionDiscovery, + options: FunctionsGatewayRouteOptions, +): GatewayRoute => ({ + capability: "functions", + match: (request) => functionSlugFromRequest(request) !== undefined, + prepare: (request) => { + const slug = functionSlugFromRequest(request); + if (slug === undefined) + return Effect.fail(new GatewayRouteNotFoundError({ message: "Function route not found" })); + return discovery.discover(slug).pipe( + Effect.map((invocation) => ({ + resolveBackend: (activation: ActivationResult) => options.dispatch(invocation, activation), + })), + Effect.catchTags({ + FunctionNotFoundError: () => + Effect.fail(new GatewayRouteNotFoundError({ message: "Function route not found" })), + FunctionPathError: (error) => + Effect.fail( + new GatewayActivationError({ message: "Function path is invalid", cause: error }), + ), + }), + ); + }, +}); diff --git a/packages/stack/src/functions/FunctionsRoot.ts b/packages/stack/src/functions/FunctionsRoot.ts new file mode 100644 index 0000000000..f1bbf8c5ba --- /dev/null +++ b/packages/stack/src/functions/FunctionsRoot.ts @@ -0,0 +1,293 @@ +import { Data, Effect, FileSystem, Path } from "effect"; + +export class FunctionNotFoundError extends Data.TaggedError("FunctionNotFoundError")<{ + readonly slug?: string; + readonly path?: string; + readonly message: string; +}> {} + +export class FunctionPathError extends Data.TaggedError("FunctionPathError")<{ + readonly path?: string; + readonly message: string; + readonly cause?: unknown; +}> {} + +export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; + +export interface FunctionsRootOptions { + /** The configured functionsRoot; Compiler supplies an absolute path. */ + readonly root: string; +} + +export interface ContainerMount { + readonly source: string; + readonly target: string; + readonly readOnly: true; +} + +export interface FunctionsContainerMapping { + readonly root: string; + readonly mount: ContainerMount; +} + +export interface ContainedPath { + readonly native: string; + readonly relative: string; + readonly container: string; + readonly kind: "file" | "directory" | "pattern"; +} + +export interface FunctionsRoot { + readonly configuredRoot: string; + /** Resolve the current canonical root and its sole read-only container mount. */ + readonly mount: Effect.Effect< + FunctionsContainerMapping, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem + >; + /** Resolve a root-relative path after refreshing root realpath and containment. */ + readonly resolveContained: ( + relative: string, + options?: { readonly kind?: "file" | "directory" | "pattern" }, + ) => Effect.Effect< + ContainedPath, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem + >; + /** Resolve a function-relative path using the same containment authority. */ + readonly resolveFunctionPath: ( + slug: string, + relative: string, + options?: { readonly kind?: "file" | "directory" | "pattern" }, + ) => Effect.Effect< + ContainedPath, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem + >; + /** Resolve a local module relative to a contained file; ../_shared is allowed. */ + readonly resolveModulePath: ( + base: ContainedPath, + relative: string, + ) => Effect.Effect< + ContainedPath, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem + >; +} + +const pathError = (message: string, path?: string, cause?: unknown) => + new FunctionPathError({ + message, + ...(path === undefined ? {} : { path }), + ...(cause === undefined ? {} : { cause }), + }); + +const notFound = (message: string, slug?: string, path?: string) => + new FunctionNotFoundError({ + message, + ...(slug === undefined ? {} : { slug }), + ...(path === undefined ? {} : { path }), + }); + +const hasLexicalEscape = (relative: string): boolean => { + if (relative.length === 0) return false; + return relative.split(/[\\/]/u).some((segment) => segment === ".."); +}; + +const isInside = (path: Path.Path, root: string, candidate: string): boolean => { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) + ); +}; + +const patternBase = (path: Path.Path, relative: string): string => { + const parts = relative.split(/[\\/]/u); + const fixed: string[] = []; + for (const part of parts) { + if (/[?*[{]/u.test(part)) break; + fixed.push(part); + } + return path.join(...fixed); +}; + +const containerPath = (relative: string): string => + relative.length === 0 + ? FUNCTIONS_CONTAINER_ROOT + : `${FUNCTIONS_CONTAINER_ROOT}/${relative.replaceAll(/[\\/]/gu, "/")}`; + +/** + * FunctionsRoot is the sole filesystem authority for the Functions runtime. + * It intentionally does not cache root existence or child membership: every + * resolver call re-reads and realpaths the configured root. + */ +export const makeFunctionsRoot = ( + options: FunctionsRootOptions, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; + if (!path.isAbsolute(options.root)) + return yield* pathError("functionsRoot must be absolute", options.root); + const configuredRoot = path.resolve(options.root); + const containerRoot = FUNCTIONS_CONTAINER_ROOT; + + const resolveContained = ( + relative: string, + resolveOptions: { readonly kind?: "file" | "directory" | "pattern" } = {}, + ): Effect.Effect< + ContainedPath, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem + > => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (path.isAbsolute(relative) || hasLexicalEscape(relative)) + return yield* pathError("Function path escapes functionsRoot", relative); + if (resolveOptions.kind === "pattern" && relative.length === 0) + return yield* pathError("Function pattern must not be empty", relative); + const realRoot = yield* fs + .realPath(configuredRoot) + .pipe( + Effect.mapError(() => + notFound("Functions root is not present", undefined, configuredRoot), + ), + ); + const normalized = relative.replaceAll(/[\\/]/gu, path.sep); + const lexical = path.resolve(realRoot, normalized); + if (!isInside(path, realRoot, lexical)) + return yield* pathError("Function path escapes functionsRoot", relative); + const kind = resolveOptions.kind ?? "file"; + const checkPath = kind === "pattern" ? patternBase(path, normalized) : normalized; + const checkLexical = path.resolve(realRoot, checkPath); + if (!isInside(path, realRoot, checkLexical)) + return yield* pathError("Function path escapes functionsRoot", relative); + const exists = yield* fs + .exists(checkLexical) + .pipe( + Effect.mapError((cause) => + pathError("Unable to inspect function path", relative, cause), + ), + ); + if (!exists) return yield* notFound("Function path is not present", undefined, relative); + const realCheck = yield* fs + .realPath(checkLexical) + .pipe( + Effect.mapError((cause) => + pathError("Unable to resolve function path", relative, cause), + ), + ); + if (!isInside(path, realRoot, realCheck)) + return yield* pathError("Function path symlink escapes functionsRoot", relative); + const info = yield* fs + .stat(realCheck) + .pipe( + Effect.mapError((cause) => + pathError("Unable to inspect function path", relative, cause), + ), + ); + if (kind === "file" && info.type !== "File") + return yield* pathError("Function path must be a file", relative); + if (kind === "directory" && info.type !== "Directory") + return yield* pathError("Function path must be a directory", relative); + const relativeCanonical = + kind === "pattern" + ? relative.replaceAll(/[\\/]/gu, path.sep) + : path.relative(realRoot, realCheck); + return { + native: kind === "pattern" ? lexical : realCheck, + relative: relativeCanonical, + container: containerPath(relativeCanonical), + kind, + } satisfies ContainedPath; + }); + + const resolveFunctionPath = ( + slug: string, + relative: string, + resolveOptions: { readonly kind?: "file" | "directory" | "pattern" } = {}, + ) => { + if (!/^[A-Za-z0-9_-]+$/u.test(slug)) + return Effect.fail(notFound("Function slug is invalid", slug)); + if (path.isAbsolute(relative) || hasLexicalEscape(relative)) + return Effect.fail(pathError("Function path escapes function directory", relative)); + return resolveContained(path.join(slug, relative), resolveOptions); + }; + + const resolveModulePath = ( + base: ContainedPath, + relative: string, + ): Effect.Effect< + ContainedPath, + FunctionNotFoundError | FunctionPathError, + FileSystem.FileSystem + > => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (path.isAbsolute(relative) || relative.length === 0) + return yield* pathError("Function module path is invalid", relative); + const realRoot = yield* fs + .realPath(configuredRoot) + .pipe( + Effect.mapError(() => + notFound("Functions root is not present", undefined, configuredRoot), + ), + ); + const validatedBase = yield* resolveContained(base.relative, { kind: "file" }); + const normalized = relative.replaceAll(/[\\/]/gu, path.sep); + const lexical = path.resolve(path.dirname(validatedBase.native), normalized); + if (!isInside(path, realRoot, lexical)) + return yield* pathError("Function module path escapes functionsRoot", relative); + const exists = yield* fs + .exists(lexical) + .pipe( + Effect.mapError((cause) => + pathError("Unable to inspect function module", relative, cause), + ), + ); + if (!exists) return yield* notFound("Function module is not present", undefined, relative); + const real = yield* fs + .realPath(lexical) + .pipe( + Effect.mapError((cause) => + pathError("Unable to resolve function module", relative, cause), + ), + ); + if (!isInside(path, realRoot, real)) + return yield* pathError("Function module symlink escapes functionsRoot", relative); + const info = yield* fs + .stat(real) + .pipe( + Effect.mapError((cause) => + pathError("Unable to inspect function module", relative, cause), + ), + ); + if (info.type !== "File") + return yield* pathError("Function module must be a file", relative); + const relativeCanonical = path.relative(realRoot, real); + return { + native: real, + relative: relativeCanonical, + container: containerPath(relativeCanonical), + kind: "file", + } satisfies ContainedPath; + }); + + const mount = resolveContained("", { kind: "directory" }).pipe( + Effect.map( + (root) => + ({ + root: root.native, + mount: Object.freeze({ source: root.native, target: containerRoot, readOnly: true }), + }) satisfies FunctionsContainerMapping, + ), + ); + + return { + configuredRoot, + mount, + resolveContained, + resolveFunctionPath, + resolveModulePath, + } satisfies FunctionsRoot; + }); diff --git a/packages/stack/src/functions/functions.integration.test.ts b/packages/stack/src/functions/functions.integration.test.ts new file mode 100644 index 0000000000..2421a5fea2 --- /dev/null +++ b/packages/stack/src/functions/functions.integration.test.ts @@ -0,0 +1,274 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Redacted } from "effect"; +import { + FunctionSettingsDefaults, + type FunctionSettings, +} from "../model/capabilities/functions.ts"; +import { FunctionNotFoundError, FunctionPathError, makeFunctionsRoot } from "./FunctionsRoot.ts"; +import { discoverFunction } from "./FunctionDiscovery.ts"; + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const write = (path: string, content: string) => + FileSystem.FileSystem.pipe(Effect.flatMap((fs) => fs.writeFileString(path, content))); + +const settings = (override: Partial = {}): FunctionSettings => ({ + ...FunctionSettingsDefaults, + ...override, +}); + +const capture = (effect: Effect.Effect) => + effect.pipe( + Effect.map((value) => ({ tag: "success" as const, value })), + Effect.catch((error: E) => Effect.succeed({ tag: "failure" as const, error })), + ); + +describe("live Functions discovery", () => { + it.live("discovers a function entrypoint on every request", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-" }); + const functionRoot = `${root}/functions`; + yield* fs.makeDirectory(`${functionRoot}/rest`, { recursive: true }); + yield* write(`${functionRoot}/rest/index.ts`, "export default () => new Response('one')"); + const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); + const invocation = yield* discoverFunction(functionsRoot, "rest", { + rest: FunctionSettingsDefaults, + }); + expect(invocation.slug).toBe("rest"); + expect(invocation.entrypoint.native).toBe( + yield* fs.realPath(`${functionRoot}/rest/index.ts`), + ); + expect(invocation.root.mount.readOnly).toBe(true); + }), + ), + ); + + it.live( + "observes live edits, custom metadata, shared modules, and stable container mapping", + () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-live-" }); + const functionRoot = `${root}/functions`; + yield* fs.makeDirectory(`${functionRoot}/rest/public`, { recursive: true }); + yield* fs.makeDirectory(`${functionRoot}/config-only`, { recursive: true }); + yield* fs.makeDirectory(`${functionRoot}/_shared`, { recursive: true }); + yield* write( + `${functionRoot}/config-only/main.ts`, + "export default () => new Response('config')", + ); + yield* write( + `${functionRoot}/rest/custom.ts`, + "export default () => new Response('custom')", + ); + yield* write(`${functionRoot}/rest/index.ts`, "export default () => new Response('one')"); + yield* write(`${functionRoot}/rest/public/logo.txt`, "logo"); + yield* write(`${functionRoot}/_shared/shared.ts`, "export const shared = true"); + yield* write( + `${functionRoot}/rest/deno.json`, + '{"imports":{"shared":"../_shared/shared.ts"}}', + ); + const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); + const entryFile = yield* functionsRoot.resolveFunctionPath("rest", "index.ts", { + kind: "file", + }); + const sharedFile = yield* functionsRoot.resolveModulePath( + entryFile, + "../_shared/shared.ts", + ); + expect(sharedFile.native).toBe(yield* fs.realPath(`${functionRoot}/_shared/shared.ts`)); + const secret = Redacted.make("never-in-an-error"); + const first = yield* discoverFunction(functionsRoot, "rest", { + rest: settings({ + entrypoint: "custom.ts", + static_files: ["public/*.txt"], + env: { API_KEY: secret }, + }), + }); + expect(yield* fs.readFileString(first.entrypoint.native)).toContain("custom"); + expect(first.entrypoint.native).toBe( + yield* fs.realPath(`${functionRoot}/rest/custom.ts`), + ); + expect(first.importMap?.native).toBe( + yield* fs.realPath(`${functionRoot}/rest/deno.json`), + ); + expect(first.staticPatterns[0]?.container).toBe( + "/__supabase_functions/rest/public/*.txt", + ); + expect(first.root.mount).toEqual({ + source: yield* fs.realPath(functionRoot), + target: "/__supabase_functions", + readOnly: true, + }); + expect(Redacted.isRedacted(first.env.API_KEY)).toBe(true); + + const configOnly = yield* discoverFunction(functionsRoot, "config-only", { + "config-only": settings({ entrypoint: "main.ts" }), + }); + expect(configOnly.entrypoint.native).toBe( + yield* fs.realPath(`${functionRoot}/config-only/main.ts`), + ); + + yield* write( + `${functionRoot}/rest/custom.ts`, + "export default () => new Response('edited')", + ); + const edited = yield* discoverFunction(functionsRoot, "rest", { + rest: settings({ entrypoint: "custom.ts" }), + }); + expect(edited.entrypoint.native).toBe(first.entrypoint.native); + expect(yield* fs.readFileString(edited.entrypoint.native)).toContain("edited"); + yield* fs.remove(`${functionRoot}/rest/custom.ts`); + const deletedEffect = discoverFunction(functionsRoot, "rest", { + rest: settings({ entrypoint: "custom.ts" }), + }); + const deleted = yield* capture(deletedEffect); + expect(deleted.tag).toBe("failure"); + if (deleted.tag === "failure") + expect(deleted.error).toBeInstanceOf(FunctionNotFoundError); + }), + ), + ); + + it.live("fails closed for disabled, invalid, and escaping function paths", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-paths-" }); + const functionRoot = `${root}/functions`; + const outside = `${root}/outside.ts`; + yield* fs.makeDirectory(`${functionRoot}/rest`, { recursive: true }); + yield* write(`${functionRoot}/rest/index.ts`, "export default 1"); + yield* write(outside, "export default 0"); + yield* fs.symlink(outside, `${functionRoot}/rest/escape.ts`); + const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); + const escaped = discoverFunction(functionsRoot, "rest", { + rest: settings({ entrypoint: "escape.ts" }), + }); + const escapedResult = yield* capture(escaped); + expect(escapedResult.tag).toBe("failure"); + if (escapedResult.tag === "failure") + expect(escapedResult.error).toBeInstanceOf(FunctionPathError); + + const traversal = discoverFunction(functionsRoot, "rest", { + rest: settings({ entrypoint: "../outside.ts" }), + }); + const traversalResult = yield* capture(traversal); + expect(traversalResult.tag).toBe("failure"); + if (traversalResult.tag === "failure") + expect(traversalResult.error).toBeInstanceOf(FunctionPathError); + + const disabled = discoverFunction(functionsRoot, "rest", { + rest: settings({ enabled: false }), + }); + const disabledResult = yield* capture(disabled); + expect(disabledResult.tag).toBe("failure"); + if (disabledResult.tag === "failure") + expect(disabledResult.error).toBeInstanceOf(FunctionNotFoundError); + + const invalid = discoverFunction(functionsRoot, "not.valid", { + rest: settings(), + }); + const invalidResult = yield* capture(invalid); + expect(invalidResult.tag).toBe("failure"); + if (invalidResult.tag === "failure") + expect(invalidResult.error).toBeInstanceOf(FunctionNotFoundError); + }), + ), + ); + + it.live("rejects traversal and absolute values for every configured path class", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-config-" }); + const functionRoot = `${root}/functions`; + yield* fs.makeDirectory(`${functionRoot}/rest/public`, { recursive: true }); + yield* write(`${functionRoot}/rest/index.ts`, "export default 1"); + const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); + const cases: ReadonlyArray]> = [ + ["entrypoint traversal", { entrypoint: "../outside.ts" }], + ["entrypoint absolute", { entrypoint: `${root}/outside.ts` }], + ["import map traversal", { import_map: "../deno.json" }], + ["import map absolute", { import_map: `${root}/deno.json` }], + ["static traversal", { static_files: ["../outside.txt"] }], + ["static absolute", { static_files: [`${root}/outside.txt`] }], + ]; + for (const [name, override] of cases) { + const result = yield* capture( + discoverFunction(functionsRoot, "rest", { rest: settings(override) }), + ); + expect(result.tag, name).toBe("failure"); + if (result.tag === "failure") expect(result.error).toBeInstanceOf(FunctionPathError); + } + }), + ), + ); + + it.live( + "observes a function created after an initial miss and canonicalizes a symlinked root", + () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-root-" }); + const canonical = `${root}/canonical`; + const configured = `${root}/configured`; + yield* fs.makeDirectory(`${canonical}/rest`, { recursive: true }); + yield* fs.symlink(canonical, configured); + const functionsRoot = yield* makeFunctionsRoot({ root: configured }); + const missing = yield* capture( + discoverFunction(functionsRoot, "rest", { rest: settings() }), + ); + expect(missing.tag).toBe("failure"); + if (missing.tag === "failure") + expect(missing.error).toBeInstanceOf(FunctionNotFoundError); + yield* write(`${canonical}/rest/index.ts`, "export default 1"); + const found = yield* discoverFunction(functionsRoot, "rest", { rest: settings() }); + expect(found.root.native).toBe(yield* fs.realPath(canonical)); + expect(found.root.mount.source).toBe(found.root.native); + + yield* fs.makeDirectory(`${canonical}/new`, { recursive: true }); + yield* write(`${canonical}/new/index.ts`, "export default 2"); + const created = yield* discoverFunction(functionsRoot, "new", {}); + expect(created.slug).toBe("new"); + }), + ), + ); + + it.live("rejects symlink escapes from the function directory and shared root", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-symlink-" }); + const functionRoot = `${root}/functions`; + const outside = `${root}/outside`; + yield* fs.makeDirectory(`${functionRoot}/rest`, { recursive: true }); + yield* fs.makeDirectory(`${outside}/rest`, { recursive: true }); + yield* write(`${outside}/rest/index.ts`, "export default 0"); + yield* fs.symlink(`${outside}/rest`, `${functionRoot}/linked`); + const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); + const linked = yield* capture( + discoverFunction(functionsRoot, "linked", { linked: settings() }), + ); + expect(linked.tag).toBe("failure"); + if (linked.tag === "failure") expect(linked.error).toBeInstanceOf(FunctionPathError); + + yield* fs.remove(`${functionRoot}/linked`); + yield* write(`${outside}/shared.ts`, "export const outside = true"); + yield* fs.symlink(outside, `${functionRoot}/_shared`); + yield* write(`${functionRoot}/rest/index.ts`, "export default 1"); + const shared = yield* capture( + discoverFunction(functionsRoot, "rest", { rest: settings() }), + ); + expect(shared.tag).toBe("failure"); + if (shared.tag === "failure") expect(shared.error).toBeInstanceOf(FunctionPathError); + }), + ), + ); +}); diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts index 902f7add0e..a40e0fbf69 100644 --- a/packages/stack/src/gateway/Gateway.ts +++ b/packages/stack/src/gateway/Gateway.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Exit, FiberSet, Semaphore } from "effect"; +import { Data, Deferred, Effect, Exit, FiberSet, Semaphore } from "effect"; import { GatewayActivationError } from "../public/Errors.ts"; import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; import { makeHttpGateway, type HttpGateway, type HttpGatewayOptions } from "./HttpGateway.ts"; @@ -19,6 +19,17 @@ export interface ActivationTarget { readonly dependencies: ReadonlyArray; } +/** Internal route preflight failure used for request-time discovery. */ +export class GatewayRouteNotFoundError extends Data.TaggedError("GatewayRouteNotFoundError")<{ + readonly message: string; +}> {} + +export interface PreparedGatewayRoute { + readonly resolveBackend: ( + activation: ActivationResult, + ) => Effect.Effect; +} + export interface LazyActivator { /** Activates a capability and all of its dependencies once for a generation. */ readonly activate: ( @@ -140,6 +151,10 @@ export interface GatewayRouteRequest { export interface GatewayRoute { readonly capability: CapabilityName; readonly match: (request: GatewayRouteRequest) => boolean; + /** Request-time validation and preparation performed before activation. */ + readonly prepare?: ( + request: GatewayRouteRequest, + ) => Effect.Effect; } export interface StackGateway { diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index 5201de5cc4..2514ab3070 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -22,7 +22,9 @@ import type { GatewayRoute, GatewayRouteRequest, LazyActivator, + PreparedGatewayRoute, } from "./Gateway.ts"; +import { GatewayRouteNotFoundError } from "./Gateway.ts"; import type { NativeListener } from "../state/PortCoordinator.ts"; class GatewayBackendError extends Data.TaggedError("GatewayBackendError")<{ @@ -191,6 +193,7 @@ const proxy = ( const mapFailure = (cause: Cause.Cause): number => { const error = Cause.findErrorOption(cause); + if (Option.isSome(error) && error.value instanceof GatewayRouteNotFoundError) return 404; if (Option.isSome(error) && error.value instanceof GatewayBackendError) return 502; if ( Option.isSome(error) && @@ -227,13 +230,23 @@ const handleRequest = ( respond(response, 404, JSON.stringify({ error: "Not found" }), options); return; } - const activation = options.activate(route.capability).pipe( - Effect.flatMap((result) => - options.resolveBackend === undefined - ? Effect.succeed(result.endpoint) - : options - .resolveBackend(route, view, result) - .pipe(Effect.mapError((cause) => new GatewayBackendError({ cause }))), + const preparation: Effect.Effect< + PreparedGatewayRoute | undefined, + GatewayRouteNotFoundError | GatewayActivationError + > = route.prepare === undefined ? Effect.as(Effect.void, undefined) : route.prepare(view); + const activation = preparation.pipe( + Effect.flatMap((prepared) => + Effect.suspend(() => options.activate(route.capability)).pipe( + Effect.flatMap((result) => { + const resolved = + prepared === undefined + ? options.resolveBackend === undefined + ? Effect.succeed(result.endpoint) + : options.resolveBackend(route, view, result) + : prepared.resolveBackend(result); + return resolved.pipe(Effect.mapError((cause) => new GatewayBackendError({ cause }))); + }), + ), ), Effect.flatMap((backend) => proxy(request, response, backend, options)), ); @@ -250,7 +263,10 @@ const handleRequest = ( respond( response, status, - JSON.stringify({ error: status === 503 ? "Service unavailable" : "Bad gateway" }), + JSON.stringify({ + error: + status === 404 ? "Not found" : status === 503 ? "Service unavailable" : "Bad gateway", + }), options, ); }); @@ -282,13 +298,23 @@ const handleUpgrade = ( socket.destroy(); return; } - const activation = options.activate(route.capability).pipe( - Effect.flatMap((result) => - options.resolveBackend === undefined - ? Effect.succeed(result.endpoint) - : options - .resolveBackend(route, view, result) - .pipe(Effect.mapError((cause) => new GatewayBackendError({ cause }))), + const preparation: Effect.Effect< + PreparedGatewayRoute | undefined, + GatewayRouteNotFoundError | GatewayActivationError + > = route.prepare === undefined ? Effect.as(Effect.void, undefined) : route.prepare(view); + const activation = preparation.pipe( + Effect.flatMap((prepared) => + Effect.suspend(() => options.activate(route.capability)).pipe( + Effect.flatMap((result) => { + const resolved = + prepared === undefined + ? options.resolveBackend === undefined + ? Effect.succeed(result.endpoint) + : options.resolveBackend(route, view, result) + : prepared.resolveBackend(result); + return resolved.pipe(Effect.mapError((cause) => new GatewayBackendError({ cause }))); + }), + ), ), Effect.flatMap((backend) => Effect.callback((resume) => { diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index a987dac381..7f1ed5d6c3 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { Effect, Exit, FileSystem, Redacted } from "effect"; import { GatewayActivationError } from "../public/Errors.ts"; import { connect as connectNet, createServer, type Server } from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import @@ -16,6 +16,12 @@ import { createLazyActivator, makeGateway, type BackendEndpoint } from "./Gatewa import { makeHttpGateway } from "./HttpGateway.ts"; import { makeTcpGateway } from "./TcpGateway.ts"; import type { NativeListener } from "../state/PortCoordinator.ts"; +import { FunctionSettingsDefaults } from "../model/capabilities/functions.ts"; +import { makeFunctionsRoot } from "../functions/FunctionsRoot.ts"; +import { + makeFunctionDiscovery, + makeFunctionsGatewayRoute, +} from "../functions/FunctionDiscovery.ts"; const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -481,4 +487,166 @@ describe("stack gateway", () => { }), ), ); + + it.live("preflights Functions routes before activation and maps discovery failures", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-gateway-" }); + const functionRoot = `${root}/functions`; + yield* fs.makeDirectory(`${functionRoot}/rest`, { recursive: true }); + yield* fs.writeFileString(`${functionRoot}/rest/index.ts`, "export default 1"); + const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); + let activations = 0; + const disabledDiscovery = yield* makeFunctionDiscovery({ + root: functionsRoot, + settings: { rest: { ...FunctionSettingsDefaults, enabled: false } }, + }); + const disabled = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [ + makeFunctionsGatewayRoute(disabledDiscovery, { + dispatch: (_invocation, activation) => Effect.succeed(activation.endpoint), + }), + ], + activate: () => { + activations += 1; + return Effect.succeed({ + capability: "functions", + endpoint: { host: "127.0.0.1", port: 1 }, + }); + }, + }); + expect(yield* getStatus(disabled.port, "/functions/v1/rest")).toBe(404); + expect(yield* getStatus(disabled.port, "/functions/v1/missing")).toBe(404); + expect(activations).toBe(0); + yield* disabled.close; + + const pathDiscovery = yield* makeFunctionDiscovery({ + root: functionsRoot, + settings: { rest: { ...FunctionSettingsDefaults, entrypoint: "../outside.ts" } }, + }); + const invalid = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [ + makeFunctionsGatewayRoute(pathDiscovery, { + dispatch: (_invocation, activation) => Effect.succeed(activation.endpoint), + }), + ], + activate: () => { + activations += 1; + return Effect.succeed({ + capability: "functions", + endpoint: { host: "127.0.0.1", port: 1 }, + }); + }, + }); + expect(yield* getStatus(invalid.port, "/functions/v1/rest")).toBe(503); + expect(activations).toBe(0); + yield* invalid.close; + + const backend = createHttpServer((_request, response) => response.end("function-response")); + backend.on("upgrade", (_request, socket) => { + socket.end("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\nws-response"); + }); + yield* Effect.callback((resume) => { + backend.once("error", (error) => resume(Effect.fail(error))); + backend.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const backendAddress = backend.address(); + if (typeof backendAddress !== "object" || backendAddress === null) return; + const endpoint: BackendEndpoint = { host: "127.0.0.1", port: backendAddress.port }; + yield* fs.makeDirectory(`${functionRoot}/rest/public`, { recursive: true }); + yield* fs.writeFileString(`${functionRoot}/rest/custom.ts`, "export default 1"); + yield* fs.writeFileString(`${functionRoot}/rest/deno.json`, "{}"); + yield* fs.writeFileString(`${functionRoot}/rest/public/file.txt`, "file"); + const dispatched: Array<{ + readonly slug: string; + readonly entrypoint: string; + readonly verifyJwt: boolean; + readonly importMap?: string; + readonly staticPattern?: string; + readonly redacted: boolean; + }> = []; + const runtimeDiscovery = yield* makeFunctionDiscovery({ + root: functionsRoot, + settings: { + rest: { + ...FunctionSettingsDefaults, + verify_jwt: false, + entrypoint: "custom.ts", + import_map: "deno.json", + static_files: ["public/*.txt"], + env: { TOKEN: Redacted.make("opaque-secret") }, + }, + }, + }); + const runtimeGateway = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [ + makeFunctionsGatewayRoute(runtimeDiscovery, { + dispatch: (invocation) => + Effect.sync(() => { + dispatched.push({ + slug: invocation.slug, + entrypoint: invocation.entrypoint.native, + verifyJwt: invocation.verifyJwt, + ...(invocation.importMap === undefined + ? {} + : { importMap: invocation.importMap.native }), + ...(invocation.staticPatterns[0] === undefined + ? {} + : { staticPattern: invocation.staticPatterns[0].native }), + redacted: Redacted.isRedacted(invocation.env.TOKEN), + }); + return endpoint; + }), + }), + ], + activate: () => Effect.succeed({ capability: "functions", endpoint }), + }); + const body = yield* Effect.callback((resume) => { + const request = requestHttp( + { host: "127.0.0.1", port: runtimeGateway.port, path: "/functions/v1/rest" }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.once("end", () => resume(Effect.succeed(Buffer.concat(chunks).toString()))); + }, + ); + request.once("error", (error) => resume(Effect.fail(error))); + request.end(); + return Effect.sync(() => request.destroy()); + }); + expect(body).toBe("function-response"); + const websocket = yield* Effect.callback((resume) => { + const socket = connectNet(runtimeGateway.port, "127.0.0.1"); + const chunks: Buffer[] = []; + socket.once("connect", () => + socket.write( + "GET /functions/v1/rest HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + ), + ); + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.once("end", () => resume(Effect.succeed(Buffer.concat(chunks).toString()))); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }); + expect(websocket).toContain("101 Switching Protocols"); + expect(websocket).toContain("ws-response"); + expect(dispatched).toHaveLength(2); + expect(dispatched[0]?.slug).toBe("rest"); + expect(dispatched[0]?.entrypoint).toContain("/rest/custom.ts"); + expect(dispatched[0]?.verifyJwt).toBe(false); + expect(dispatched[0]?.importMap).toContain("/rest/deno.json"); + expect(dispatched[0]?.staticPattern).toContain("/rest/public/*.txt"); + expect(dispatched[0]?.redacted).toBe(true); + yield* runtimeGateway.close; + yield* closeServer(backend); + }), + ), + ); }); diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index 0b71bb1f41..e44914e195 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -1,10 +1,11 @@ -import { Schema } from "effect"; +import { Redacted, Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const Secret = Schema.Redacted(Schema.String); +export type FunctionSecret = Redacted.Redacted; const FunctionSlug = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)); const EnvName = Schema.String.check(Schema.isPattern(/^[A-Z_][A-Z0-9_]*$/)); -const FunctionOverride = Schema.Struct({ +export const FunctionOverrideSchema = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.String), @@ -12,21 +13,20 @@ const FunctionOverride = Schema.Struct({ static_files: Schema.optionalKey(Schema.Array(Schema.String)), env: Schema.optionalKey(Schema.Record(EnvName, Secret)), }); -export const FunctionsSettingsSchema = Schema.Struct({ - functions_root: Schema.optionalKey(Schema.String), - /** Edge Runtime behavior; capability enabled and functionsInspector port are public fields. */ - edge_runtime: Schema.optionalKey( - Schema.Struct({ - policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"] as const)), - deno_version: Schema.optionalKey(Schema.Finite), - secrets: Schema.optionalKey(Schema.Record(Schema.String, Secret)), - }), - ), - functions: Schema.optionalKey(Schema.Record(FunctionSlug, FunctionOverride)), -}); -export type FunctionsSettings = Schema.Schema.Type; +export type FunctionOverride = Schema.Schema.Type; + +/** Fully materialized per-function settings consumed by request-time discovery. */ +export interface FunctionSettings { + readonly enabled: boolean; + readonly verify_jwt: boolean; + readonly import_map: string; + readonly entrypoint: string; + readonly static_files: ReadonlyArray; + readonly env: Readonly>; +} +export type MaterializedFunctionSettings = Readonly>; -const functionDefaults = { +export const FunctionSettingsDefaults: FunctionSettings = { enabled: true, verify_jwt: true, import_map: "", @@ -35,6 +35,21 @@ const functionDefaults = { env: {}, }; +export const EdgeRuntimeSettingsSchema = Schema.Struct({ + policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"] as const)), + deno_version: Schema.optionalKey(Schema.Finite), + secrets: Schema.optionalKey(Schema.Record(Schema.String, Secret)), +}); +export type EdgeRuntimeSettings = Schema.Schema.Type; + +export const FunctionsSettingsSchema = Schema.Struct({ + functions_root: Schema.optionalKey(Schema.String), + /** Edge Runtime behavior; capability enabled and functionsInspector port are public fields. */ + edge_runtime: Schema.optionalKey(EdgeRuntimeSettingsSchema), + functions: Schema.optionalKey(Schema.Record(FunctionSlug, FunctionOverrideSchema)), +}); +export type FunctionsSettings = Schema.Schema.Type; + export const FunctionsModule: CapabilityModule = { name: "functions", settings: FunctionsSettingsSchema, @@ -70,7 +85,7 @@ export const FunctionsModule: CapabilityModule = { functions: Object.fromEntries( Object.entries(settings.functions ?? {}).map(([name, fn]) => [ name, - { ...functionDefaults, ...fn, env: fn.env ?? {} }, + { ...FunctionSettingsDefaults, ...fn, env: fn.env ?? {} }, ]), ), }), diff --git a/packages/stack/src/model/capabilities/index.ts b/packages/stack/src/model/capabilities/index.ts index 7de03a3caf..18828fe7ca 100644 --- a/packages/stack/src/model/capabilities/index.ts +++ b/packages/stack/src/model/capabilities/index.ts @@ -1,6 +1,16 @@ export { AuthModule, AuthSettingsSchema, type AuthSettings } from "./auth.ts"; export { DatabaseModule, DatabaseSettingsSchema, type DatabaseSettings } from "./database.ts"; -export { FunctionsModule, FunctionsSettingsSchema, type FunctionsSettings } from "./functions.ts"; +export { + FunctionOverrideSchema, + FunctionSettingsDefaults, + FunctionsModule, + FunctionsSettingsSchema, + type FunctionOverride, + type FunctionSecret, + type FunctionSettings, + type MaterializedFunctionSettings, + type FunctionsSettings, +} from "./functions.ts"; export { MailModule, MailSettingsSchema, type MailSettings } from "./mail.ts"; export { PoolerModule, PoolerSettingsSchema, type PoolerSettings } from "./pooler.ts"; export { RealtimeModule, RealtimeSettingsSchema, type RealtimeSettings } from "./realtime.ts"; diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 62e24d9dcf..228eafca0b 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1 +1,2 @@ /** Test-only stack helpers. */ +export {}; From 91344963b4756a4ce7aace3bbcf4e77de12c6680 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 03:27:13 +0200 Subject: [PATCH 017/174] fix(stack): preserve tcp tunnel drain --- ...26-08-28-stack-runtime-rewrite-progress.md | 4 ++++ packages/stack/src/gateway/TcpGateway.ts | 20 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index eddc57a654..42d9543173 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -443,5 +443,9 @@ Implementation continues after recording a ruling; this file is not a question q preflight activation fencing, and HTTP/WebSocket dispatch descriptor handoff. - RED evidence: the first functions integration run failed because the new discovery modules were absent; subsequent gateway preflight coverage initially exposed eager activation construction. +- A parallel full-package run exposed a 512 KiB TCP response truncation: normal target close could + destroy the source before its buffered response drained. The tunnel now distinguishes normal EOF + from abrupt close and registers half-close listeners before connect; 48 concurrent targeted runs + passed after the fix. - `pnpm --dir packages/stack types:check`, focused Functions/gateway integration, full package integration (163 tests), Effect lint, formatting, and `git diff --check` passed. diff --git a/packages/stack/src/gateway/TcpGateway.ts b/packages/stack/src/gateway/TcpGateway.ts index 22bc5d1081..7edb0c6392 100644 --- a/packages/stack/src/gateway/TcpGateway.ts +++ b/packages/stack/src/gateway/TcpGateway.ts @@ -41,6 +41,7 @@ const tunnel = ( Effect.callback((resume) => { const target = new Socket(); let settled = false; + let targetEnded = false; const onSourceError = (cause: Error) => { if (settled) return; settled = true; @@ -59,22 +60,25 @@ const tunnel = ( } }; const onTargetClose = () => { - source.destroy(); if (!settled) { - settled = true; - cleanup(); - resume(Effect.void); + if (!targetEnded) { + settled = true; + cleanup(); + source.destroy(); + resume(Effect.void); + } } }; const onSourceEnd = () => target.end(); - const onTargetEnd = () => source.end(); + const onTargetEnd = () => { + targetEnded = true; + source.end(); + }; const onConnect = () => { // Node's pipe implementation naturally propagates backpressure. Keeping // both streams open here preserves protocol half-close semantics. source.pipe(target, { end: false }); target.pipe(source, { end: false }); - source.once("end", onSourceEnd); - target.once("end", onTargetEnd); }; const cleanup = () => { source.off("error", onSourceError); @@ -89,6 +93,8 @@ const tunnel = ( target.once("error", onTargetError); source.once("close", onSourceClose); target.once("close", onTargetClose); + source.once("end", onSourceEnd); + target.once("end", onTargetEnd); target.once("connect", onConnect); target.connect(backend.port, backend.host); return Effect.sync(() => { From 4272a9e5940d97568309109bf78b545e391fd0fc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 05:04:38 +0200 Subject: [PATCH 018/174] feat(stack): run strict container stacks --- ...26-08-28-stack-runtime-rewrite-progress.md | 57 +- .../plans/2026-08-28-stack-runtime-rewrite.md | 64 +- ...2026-08-28-stack-runtime-rewrite-design.md | 107 ++- packages/stack/src/public/EffectStack.ts | 85 +- packages/stack/src/public/Errors.ts | 1 + packages/stack/src/runtime/ContainerEngine.ts | 429 ++++++++++ .../src/runtime/ContainerEngineResolver.ts | 19 + .../stack/src/runtime/ContainerRuntime.ts | 439 ++++++++++ packages/stack/src/runtime/DockerEngine.ts | 398 +++++++++ packages/stack/src/runtime/NativeRuntime.ts | 33 +- packages/stack/src/runtime/PodmanEngine.ts | 376 +++++++++ .../container-runtime.integration.test.ts | 793 ++++++++++++++++++ packages/stack/src/runtime/native-launcher.ts | 6 +- .../native-runtime.integration.test.ts | 25 + .../supervisor/handles.integration.test.ts | 206 ++++- 15 files changed, 2981 insertions(+), 57 deletions(-) create mode 100644 packages/stack/src/runtime/ContainerEngine.ts create mode 100644 packages/stack/src/runtime/ContainerEngineResolver.ts create mode 100644 packages/stack/src/runtime/ContainerRuntime.ts create mode 100644 packages/stack/src/runtime/DockerEngine.ts create mode 100644 packages/stack/src/runtime/PodmanEngine.ts create mode 100644 packages/stack/src/runtime/container-runtime.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 42d9543173..21e7f6e37a 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -84,6 +84,13 @@ updated in the same commit as each completed slice. Scratch review packages live > separator escaping — cost if wrong: fields larger than 4 GiB are unrepresentable, which is outside > any valid filesystem/ref/name input. +> **Ruling:** Functions have one serving path through the managed stack Edge Runtime in both native +> and container modes; `supabase functions serve` is a managed-stack client, not a separate Docker +> workflow. The caller owns migrations, schemas, and seeds, while the narrow database reset session +> is deliberately the last behavioral implementation slice after the complete catalog and facade/CLI +> migration — cost if wrong: changing either ownership boundary would require reshaping the public +> lifecycle contract rather than adding another adapter. + When the design does not determine another choice, record it here as: > **Ruling:** decision — reason — cost if wrong. @@ -96,8 +103,8 @@ Implementation continues after recording a ruling; this file is not a question q - Mapped all design sections to 15 implementation tasks: cleanup, public model, identity, compiler, durable state/secrets/ports, ownership, reconciliation/observability, native preparation/runtime, - gateway/activation, Functions, container runtime, lifecycle/recovery, facades/CLI, reset session, - and final catalog/audit. + gateway/activation, Functions, container runtime, lifecycle/recovery, facades/CLI, complete + catalog/platform coverage, the final reset session, and completion audit. - Removed an ordering conflict: public `StackConfig` is defined with capability Modules before the durable state schema consumes `StackDefinition`. - Verified 55 executable steps, 42 balanced Markdown fences, and no placeholder patterns. @@ -378,7 +385,7 @@ Implementation continues after recording a ruling; this file is not a question q companion workload. The generic runner validates an ordered release plan, creates its tracking schema under a transaction-scoped advisory lock, applies and records one revision atomically, and reconciles `Redacted` role credentials outside SQL text. Concrete per-release revision catalogs - remain Task 14 work; gateway listener ownership/transfer remains Task 8 work with the gateway + remain Task 13 work; gateway listener ownership/transfer remains Task 8 work with the gateway contract. - Added integration coverage for verified cache/download behavior, corruption and containment, concurrent publisher convergence, caller/store-scope cancellation, native readiness/logging/ @@ -426,7 +433,7 @@ Implementation continues after recording a ruling; this file is not a question q - Focused gateway, activation, port, and reconciler suite — passed (45 tests across 5 files). - `pnpm --dir packages/stack test` — passed (4 unit and 156 integration tests). - Effect/generic lint over all Task 8 sources and tests, formatting, and `git diff --check` — passed. -- Concrete capability route catalogs remain Task 14; Functions discovery, container engine routing, +- Concrete capability route catalogs remain Task 13; Functions discovery, container engine routing, and full Supervisor lifecycle composition remain Tasks 9, 10, and 11 respectively. ### Task 9 — 2026-08-29 @@ -449,3 +456,45 @@ Implementation continues after recording a ruling; this file is not a question q passed after the fix. - `pnpm --dir packages/stack types:check`, focused Functions/gateway integration, full package integration (163 tests), Effect lint, formatting, and `git diff --check` passed. + +### Task 10 — 2026-08-29 + +- Added independent, closed Docker and Podman CLI codecs plus one narrow container-engine contract + for only the probe, image, resource-list, network, volume, and container lifecycle operations the + runtime consumes. Commands use exact argv and bounded stdout/stderr, decoders admit only the + required scalar label fields, nonzero/malformed responses remain typed failures, and automatic + selection falls back from Docker to Podman only when the Docker executable is genuinely missing. +- Added production engine resolution for new container identities. The selected adapter must pass + its routing preflight before state mutation, and the concrete `docker`/`podman` result is persisted. + Existing identities reuse that engine without probing; conflicting explicit preferences fail + before probing. Native identities never consult a container resolver. +- Added the strict `ContainerRuntime`: native artifacts fail before engine access; workload + containers remain private; exact StackId/owner/generation/workload/spec-hash resources are adopted; + same-owner stale containers are stopped and removed before recreation; foreign and sanitized-name + collisions fail before image pulls or mutation; concurrent starts serialize; readiness cleanup + preserves and combines the original and cleanup Causes. +- Persistent container storage is deliberately one volume per workload. The runtime accepts only a + target/read-only request and derives the physical name from the full StackId and workload id, + excluding generation so it survives restart. Volumes are identity-scoped, reused after container + removal, and retained until destroy. This is the smallest unambiguous model supported by the + current catalog labels. +- The real process runner now fails on the first output chunk beyond its bound, interrupts sibling + reads/exit waiting, and performs an interruptible SIGTERM wait followed by exact SIGKILL fallback. + A hostile child that remains alive and ignores SIGTERM proves both fail-fast overflow and reaping. +- Fixed the native launcher/readiness race uncovered by the combined runtime suite. Inherited owner + and payload descriptors now use `node:net` sockets rather than filesystem streams, avoiding a + blocked libuv threadpool shutdown; one cached exit-code Effect is shared by readiness and the + watcher, and native runtime rejects container artifacts before process resolution. The exact + diagnostic parent/launcher processes created while minimizing this failure were terminated and + verified absent. +- Gateway-container lifecycle composition and stopped-stack ephemeral network cleanup remain Task 11. During running intent, a single workload readiness failure intentionally retains the shared + private network and persistent volume for retries and other workloads; the failed newly-created + workload container itself is stopped and removed. +- RED/GREEN review closed two Important findings: global caller-supplied volume names and output + overflow waiting for EOF. Independent closure review found no remaining Critical or Important + finding. +- `pnpm --dir packages/stack types:check` — passed. +- Focused container runtime, native runtime, and managed-handle integration suite — passed (43 tests + across 3 files). +- Generic and Effect lint over all Task 10 sources/tests, formatting over sources/tests/docs, and + `git diff --check` — passed. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index 9c6abd3572..e0da2416d7 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -418,7 +418,7 @@ artifact keys, state schemas, drivers, or control protocols. ports, network, secrets, or workload mutation. - `NativeRuntime` implements `RuntimeDriver` with exact process-tree ownership. Database bootstrap is a post-probe readiness phase on the real database workload, not a synthetic workload. Task 7 defines - the release-plan resolver and PostgreSQL runner; Task 14 supplies each supported release's concrete + the release-plan resolver and PostgreSQL runner; Task 13 supplies each supported release's concrete ordered revision catalog. Applied revisions live inside PostgreSQL only and gate every database dependent on completion. Gateway listener ownership and native transfer belong to Task 8, where the gateway contract is implemented; Task 7 does not add a speculative listener interface. @@ -529,20 +529,20 @@ artifact keys, state schemas, drivers, or control protocols. persistent volumes, gateway publication, read-only activation/config files, and exact cleanup. - No native process is started for a container identity and no container is created for native. -- [ ] **Step 1: Write controlled engine integration scenarios and verify RED** +- [x] **Step 1: Write controlled engine integration scenarios and verify RED** Cover platform alias selection (`host.docker.internal`, Linux host-gateway, `host.containers.internal`), unsupported routing preflight, gateway-only host publication, private backend endpoints, engine bind races, semantic-hash adoption, stale removal, persistent volumes, strict runtime split, and lazy Functions edits through the mounted root. -- [ ] **Step 2: Implement the smallest engine contract** +- [x] **Step 2: Implement the smallest engine contract** Model only inspect/pull/create/start/stop/remove/network/volume operations actually used by the execution plan. Parse exact structured engine output and map failures at the leaf boundary. Do not build a general container SDK. -- [ ] **Step 3: Verify and commit** +- [x] **Step 3: Verify and commit** Run `container-runtime.integration.test.ts` against controlled Docker and Podman adapters, stack type-check, and commit: @@ -636,7 +636,40 @@ artifact keys, state schemas, drivers, or control protocols. git commit -m "feat(stack): expose facades and migrate the CLI" ``` -### Task 13: Deferred final database reset session +### Task 13: Complete catalog and platform coverage + +**Files:** + +- Modify: every capability Module and catalog mapping +- Create: only the integration fixtures needed to exercise missing native/container mappings + +**Interfaces:** + +- Completes all ten capabilities with exhaustive settings and real native/container artifacts. +- Does not add new public lifecycle concepts or a second Functions-serving path. + +- [ ] **Step 1: Audit capability and platform coverage** + + Build the requirement-to-source/test table in the tracked progress document. For every capability, + identify exhaustive settings coverage, exact native and container artifacts, dependency edges, + ports, secrets, routes, probes, and activation behavior. + +- [ ] **Step 2: Fill only demonstrated gaps test-first** + + Exercise at least one native and one container preparation/execution mapping per capability. Keep + Functions exclusively on the managed Edge Runtime and reject unsupported exact artifacts before + durable or runtime mutation. + +- [ ] **Step 3: Verify and commit** + + Run only the affected catalog/compiler/runtime integration files and stack type-check. Commit: + + ```bash + git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md + git commit -m "feat(stack): complete the workload catalog" + ``` + +### Task 14: Deferred final database reset session **Files:** @@ -677,30 +710,33 @@ artifact keys, state schemas, drivers, or control protocols. git commit -m "feat(stack): add caller-driven database reset sessions" ``` -### Task 14: Final catalog, documentation, and completion audit +This is the last behavioral implementation task. Do not begin it until the runtime, complete +catalog, managed Functions path, Promise facade, and CLI migration are stable. + +### Final documentation and completion audit **Files:** -- Modify: every capability Module/catalog mapping, package README and architecture docs, tracked - progress, CLI docs/help affected by the new lifecycle +- Modify: package README and architecture docs, tracked progress, public export metadata, and CLI + docs/help affected by the new lifecycle - Create: a small set of stack/CLI e2e smoke files only where real compiled subprocess/container boundaries are not covered by integration tests **Interfaces:** -- Delivers all ten capabilities with full settings and real native/container artifacts, public exports - limited to `.`, `./effect`, and `./testing`, and no process-compose or legacy stack surface. +- Confirms all ten capabilities and the final reset session, limits public exports to `.`, + `./effect`, and `./testing`, and leaves no process-compose or legacy stack surface. - [ ] **Step 1: Audit every spec requirement against authoritative evidence** Build a requirement-to-test/source table in the tracked progress document. Missing or indirect evidence is incomplete work, not a pass. -- [ ] **Step 2: Fill catalog/platform gaps test-first** +- [ ] **Step 2: Fill audit gaps test-first** - For every capability, exercise at least one native and one container preparation/execution mapping; - run only the newly relevant platform files. Add the smallest compiled CLI smoke for start/status/ - stop and managed Functions serving. + Treat any missing catalog/platform evidence as incomplete Task 13 work and any missing reset-flow + evidence as incomplete Task 14 work. Add the smallest compiled CLI smoke for start/status/stop and + managed Functions serving. - [ ] **Step 3: Run final targeted verification** diff --git a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md index 96af2606e3..af0ad5c06f 100644 --- a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md +++ b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md @@ -29,6 +29,39 @@ The package must let callers: The package does not parse `config.toml`, run project migrations or seeds, expose Compose, or act as a general process orchestrator. The CLI resolves its configuration and calls the package API. +## Settled design decisions + +These decisions are closed for this rewrite: + +- `createStack` returns an ordinary managed handle. A separate testing helper creates, starts, and + destroys an isolated stack and is the only API designed for `await using`. +- Only managed stacks are supported. There is no foreground/unmanaged ownership mode. +- A materialized definition persists every resolved value, including defaults and explicit + absences. Reopening a stack therefore never reinterprets old state through newer defaults. +- Calling `start` on an already-running stack is idempotent only for the same materialized input. + Incompatible input fails with guidance to use explicit `restart`; it is never applied on top of a + running stack. +- Lazy activation is mandatory. Once activated, a capability remains active for that generation; + there is no idle eviction. +- Native and container stacks are strictly separate, and every catalog workload has both a native + and a container artifact. One stack identity can never mix them. +- Functions have exactly one serving path: the stack-owned Edge Runtime. This applies to normal + stack traffic and `supabase functions serve`; there is no separate Docker or standalone serving + workflow. `functionsRoot` is the only host root and the only read-only container mount, and every + resolved path must remain beneath it. +- The Promise facade exposes secret-bearing values as plain strings. Redacted values remain an + Effect-side concern. +- Each capability accepts the complete set of supported per-service settings; the compiler rejects + unknown fields and persists the complete resolved result. +- Database version selection resolves only to an exact database artifact supported by the catalog. + An unknown selector fails before mutation. Internal bootstrap revisions are implementation-owned, + ordered steps selected by that exact artifact; they are not a second public version input. +- Corrupt durable state fails closed. Logs redact every exact known secret and never persist or emit + full secret environments, files, or arguments. +- There is no all-in-one `resetDatabase` operation. Stack owns lifecycle fencing, data recreation, + and internal bootstrap; the caller owns migrations, declarative schemas, and seeds through the + narrow reset-session API designed in the final behavioral slice of the rewrite. + The design follows the object-level separation used by Docker without copying its CLI: ```text @@ -112,14 +145,7 @@ type CapabilityName = | "pooler"; type CapabilityState = - | "disabled" - | "dormant" - | "preparing" - | "starting" - | "ready" - | "stopping" - | "stopped" - | "failed"; + "disabled" | "dormant" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed"; type StackLifecycle = | "unconfigured" @@ -298,7 +324,10 @@ type ListenerConfig = ```ts interface EffectStackCredentials { - readonly database: { readonly url: Redacted.Redacted; readonly password: Redacted.Redacted }; + readonly database: { + readonly url: Redacted.Redacted; + readonly password: Redacted.Redacted; + }; readonly api: { readonly publishableKey: string; readonly secretKey: Redacted.Redacted; @@ -329,11 +358,25 @@ interface PromiseStackCredentials { }; } -interface StartStackOptions { readonly config?: StackConfig } -interface PrepareStackOptions { readonly config?: StackConfig; readonly capabilities?: ReadonlyArray } -interface CreateStackOptions { readonly projectRoot: string; readonly name?: string; readonly runtime?: StackRuntimePreference } -interface FindStackOptions { readonly projectRoot: string; readonly name?: string } -interface ListStacksOptions { readonly projectRoot?: string } +interface StartStackOptions { + readonly config?: StackConfig; +} +interface PrepareStackOptions { + readonly config?: StackConfig; + readonly capabilities?: ReadonlyArray; +} +interface CreateStackOptions { + readonly projectRoot: string; + readonly name?: string; + readonly runtime?: StackRuntimePreference; +} +interface FindStackOptions { + readonly projectRoot: string; + readonly name?: string; +} +interface ListStacksOptions { + readonly projectRoot?: string; +} /** Generated Promise counterpart: every Redacted leaf is a plain string. */ type PromiseStackConfig = GeneratedPromiseStackConfig; @@ -343,14 +386,22 @@ type PromisePrepareStackOptions = { readonly capabilities?: ReadonlyArray; }; -interface PreparedCapability { readonly capability: CapabilityName; readonly version: string; readonly outcome: "cached" | "downloaded" | "pulled" } -interface PrepareStackResult { readonly capabilities: ReadonlyArray } +interface PreparedCapability { + readonly capability: CapabilityName; + readonly version: string; + readonly outcome: "cached" | "downloaded" | "pulled"; +} +interface PrepareStackResult { + readonly capabilities: ReadonlyArray; +} interface EffectStack { readonly id: StackId; readonly status: () => Effect.Effect; readonly credentials: () => Effect.Effect; - readonly prepare: (options?: PrepareStackOptions) => Effect.Effect; + readonly prepare: ( + options?: PrepareStackOptions, + ) => Effect.Effect; readonly start: (options?: StartStackOptions) => Effect.Effect; readonly restart: (options?: StartStackOptions) => Effect.Effect; readonly stop: () => Effect.Effect; @@ -360,11 +411,19 @@ interface EffectStack { readonly logs: (options?: LogOptions) => Stream.Stream; } -function createStack(options: CreateStackOptions): Effect.Effect; +function createStack( + options: CreateStackOptions, +): Effect.Effect; function openStack(id: StackId): Effect.Effect; -function findStack(options: FindStackOptions): Effect.Effect, StackDiscoveryError>; -function listStacks(options?: ListStacksOptions): Effect.Effect, StackDiscoveryError>; -function inspectStack(id: StackId): Effect.Effect; +function findStack( + options: FindStackOptions, +): Effect.Effect, StackDiscoveryError>; +function listStacks( + options?: ListStacksOptions, +): Effect.Effect, StackDiscoveryError>; +function inspectStack( + id: StackId, +): Effect.Effect; declare namespace PromiseApi { interface Stack { @@ -478,8 +537,7 @@ interface MaterializedCapability { } /** Generated closed settings with every default applied and each secret leaf replaced by a slot. */ -type MaterializedSettings = - GeneratedMaterializedSettingsSchema; +type MaterializedSettings = GeneratedMaterializedSettingsSchema; type MaterializedListenersConfig = GeneratedMaterializedListenersSchema; type MaterializedSecurityConfig = GeneratedMaterializedSecuritySchema; @@ -709,8 +767,7 @@ ports, creates networks, starts workloads, or changes durable state. ```ts type PortIntent = - | { readonly kind: "automatic" } - | { readonly kind: "exact"; readonly port: number }; + { readonly kind: "automatic" } | { readonly kind: "exact"; readonly port: number }; interface HostPortAssignment { readonly field: PortField; diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index e3ab9cbafb..ccdbe404d1 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -69,6 +69,19 @@ import { defaultRuntimeEnvironment, StackRuntimeEnvironment, } from "../supervisor/Launcher.ts"; +import { + ContainerEngineResolver, + type ContainerEngineResolverShape, +} from "../runtime/ContainerEngineResolver.ts"; +import { + ContainerEngineProtocolError, + makeProcessCommandRunner, + selectContainerEngine, + type ContainerEngineKind, + type ContainerPlatform, +} from "../runtime/ContainerEngine.ts"; +import { makeDockerEngine } from "../runtime/DockerEngine.ts"; +import { makePodmanEngine } from "../runtime/PodmanEngine.ts"; export interface StartStackOptions { readonly config?: StackConfig; @@ -125,11 +138,65 @@ export interface EffectStack { readonly logs: (options?: LogOptions) => Stream.Stream; } -const runtimeFor = (preference?: StackRuntimePreference): StackRuntime => +const runtimeFor = ( + preference: StackRuntimePreference | undefined, + engine?: ContainerEngineKind, +): StackRuntime => preference?.kind === "container" - ? { kind: "container", engine: preference.engine === "podman" ? "podman" : "docker" } + ? { + kind: "container", + engine: engine ?? (preference.engine === "podman" ? "podman" : "docker"), + } : { kind: "native" }; +const defaultContainerPlatform = (): ContainerPlatform => { + // Host details are read only at this composition boundary. The real + // container adapters reject unsupported Podman routing during preflight. + if (process.platform === "darwin") return { os: "darwin", desktop: true }; + if (process.platform === "win32") return { os: "windows", desktop: true }; + return { os: "linux", desktop: false }; +}; + +const defaultContainerEngineResolver = (): ContainerEngineResolverShape => ({ + resolve: (preference) => + Effect.gen(function* () { + const platform = defaultContainerPlatform(); + const dockerRunner = yield* makeProcessCommandRunner({ executable: "docker" }); + const podmanRunner = yield* makeProcessCommandRunner({ executable: "podman" }); + const docker = makeDockerEngine({ runner: dockerRunner, platform }); + const podman = makePodmanEngine({ runner: podmanRunner, platform }); + const selected = yield* selectContainerEngine({ preference, docker, podman }); + yield* selected.preflight; + return selected.kind; + }), +}); + +const resolveRuntime = ( + preference: StackRuntimePreference | undefined, +): Effect.Effect< + StackRuntime, + ContainerEngineError, + import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner +> => + preference?.kind !== "container" + ? Effect.succeed({ kind: "native" }) + : Effect.serviceOption(ContainerEngineResolver).pipe( + Effect.map((service) => + Option.isSome(service) ? service.value : defaultContainerEngineResolver(), + ), + Effect.flatMap((resolver) => resolver.resolve(preference.engine ?? "auto")), + Effect.map((engine) => runtimeFor(preference, engine)), + Effect.mapError( + (error) => + new ContainerEngineError({ + message: + error instanceof ContainerEngineProtocolError + ? `${error.operation}: ${error.message}` + : error.message, + }), + ), + ); + const descriptor = (state: PersistedStackState): StackDescriptor => ({ id: StackIdSchema.make(state.identity.stackId), projectRoot: state.identity.projectRoot, @@ -384,8 +451,18 @@ export const createStack = ( }); const stackId = yield* deriveStackId(identity); const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const runtime = runtimeFor(options.runtime); - const current = yield* store.initialize(stackId, stateInitial(identity, stackId, runtime)); + const current = + options.runtime?.kind === "container" + ? yield* Effect.gen(function* () { + const existing = yield* store.read(stackId); + const runtime = + existing === undefined ? yield* resolveRuntime(options.runtime) : existing.runtime; + return ( + existing ?? + (yield* store.initialize(stackId, stateInitial(identity, stackId, runtime))) + ); + }) + : yield* store.initialize(stackId, stateInitial(identity, stackId, { kind: "native" })); const runtimeMismatch = options.runtime !== undefined && (current.runtime.kind !== options.runtime.kind || diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 7a87fd229a..bb9580ac74 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -141,6 +141,7 @@ export type CreateStackError = | InvalidProjectRootError | StackOwnershipConflictError | StackRuntimeMismatchError + | ContainerEngineError | StackStateInvalidError | StackStateFormatUnsupportedError | StackUpgradeRequiredError; diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts new file mode 100644 index 0000000000..b5d49dab54 --- /dev/null +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -0,0 +1,429 @@ +import { Data, Effect, Predicate, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; +import type { ContainerArtifact } from "../model/CapabilityModule.ts"; +import type { StackId } from "../public/StackId.ts"; + +export type ContainerEngineKind = "docker" | "podman"; +export interface ContainerPlatform { + readonly os: "linux" | "darwin" | "windows"; + readonly desktop?: boolean; + readonly rootless?: boolean; + readonly remote?: boolean; +} +export interface ContainerHostRoute { + readonly host: string; + readonly gateway?: string; +} +export class ContainerExecutableNotFoundError extends Data.TaggedError( + "ContainerExecutableNotFoundError", +)<{ readonly executable: string; readonly message: string }> {} +export class ContainerRoutingUnsupportedError extends Data.TaggedError( + "ContainerRoutingUnsupportedError", +)<{ readonly engine: ContainerEngineKind; readonly message: string }> {} +export class ContainerEngineProtocolError extends Data.TaggedError("ContainerEngineProtocolError")<{ + readonly operation: string; + readonly message: string; + readonly cause?: unknown; +}> {} +export class ContainerCommandError extends Data.TaggedError("ContainerCommandError")<{ + readonly operation: string; + readonly message: string; +}> {} +export type ContainerEngineFailure = + | ContainerExecutableNotFoundError + | ContainerRoutingUnsupportedError + | ContainerEngineProtocolError + | ContainerCommandError; + +export type ContainerResourceRole = "network" | "volume" | "workload" | "gateway"; +interface ContainerIdentityLabels { + readonly stackId: StackId; + readonly ownerSessionId: string; + readonly desiredGeneration: number; +} +export interface ContainerNetworkLabels extends ContainerIdentityLabels { + readonly role: "network"; +} +export interface ContainerWorkloadLabels extends ContainerIdentityLabels { + readonly workloadId: string; + readonly specHash: string; + readonly role: "workload" | "gateway"; +} +export interface ContainerVolumeLabels { + readonly stackId: StackId; + readonly workloadId: string; + readonly role: "volume"; +} +export type ContainerLabels = + | ContainerNetworkLabels + | ContainerWorkloadLabels + | ContainerVolumeLabels; +export interface ContainerResource { + readonly id: string; + readonly name: string; + readonly kind: ContainerResourceRole; + readonly labels: ContainerLabels; + readonly state?: "created" | "running" | "stopped"; +} +export interface ContainerMount { + readonly source: string; + readonly target: string; + readonly readOnly: boolean; +} +export interface ContainerVolumeMount { + readonly volume: string; + readonly target: string; + readonly readOnly: boolean; +} +export interface ContainerPortPublication { + readonly hostPort: number; + readonly containerPort: number; +} +export interface ContainerNetworkSpec { + readonly name: string; + readonly labels: ContainerNetworkLabels; +} +export interface ContainerVolumeSpec { + readonly name: string; + readonly labels: ContainerVolumeLabels; +} +export interface ContainerContainerSpec { + readonly name: string; + readonly image: ContainerArtifact["image"]; + readonly labels: ContainerWorkloadLabels; + readonly network: string; + readonly mounts: ReadonlyArray; + readonly volumeMounts: ReadonlyArray; + readonly publications: ReadonlyArray; + readonly hostRoute?: ContainerHostRoute; + readonly role: "workload" | "gateway"; + readonly command?: ReadonlyArray; +} + +export type ContainerCommand = + | { readonly operation: "probe" } + | { readonly operation: "inspect-image"; readonly image: string } + | { readonly operation: "pull-image"; readonly image: string } + | { readonly operation: "inspect-containers"; readonly stackId: StackId } + | { readonly operation: "inspect-networks"; readonly stackId: StackId } + | { readonly operation: "inspect-volumes"; readonly stackId: StackId } + | { readonly operation: "create-network"; readonly spec: ContainerNetworkSpec } + | { readonly operation: "remove-network"; readonly id: string } + | { readonly operation: "create-volume"; readonly spec: ContainerVolumeSpec } + | { readonly operation: "remove-volume"; readonly id: string } + | { readonly operation: "create-container"; readonly spec: ContainerContainerSpec } + | { readonly operation: "start-container"; readonly id: string } + | { readonly operation: "stop-container"; readonly id: string } + | { readonly operation: "remove-container"; readonly id: string }; +export interface ContainerCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} +export interface ContainerProcessRequest { + readonly args: ReadonlyArray; + readonly stdin?: string; +} +export interface ContainerCommandRunner { + readonly executable: string; + readonly run: ( + request: ContainerProcessRequest, + ) => Effect.Effect; +} +export interface ControlledCommandRunnerOptions { + readonly executable?: string; + readonly run: ( + request: ContainerProcessRequest, + ) => Effect.Effect; +} +export const makeControlledCommandRunner = ( + options: ControlledCommandRunnerOptions, +): ContainerCommandRunner => ({ + executable: options.executable ?? "controlled-container-engine", + run: options.run, +}); + +export interface ProcessCommandRunnerOptions { + readonly executable: string; + readonly baseArgs?: ReadonlyArray; + readonly maxOutputBytes?: number; +} +const boundedOutput = ( + stream: Stream.Stream, + maxBytes: number, +): Effect.Effect => + Stream.runFoldEffect( + stream.pipe( + Stream.mapError( + (cause) => + new ContainerEngineProtocolError({ + operation: "process-output", + message: "Container engine output stream failed", + cause, + }), + ), + ), + () => new Uint8Array(), + (current: Uint8Array, chunk: Uint8Array) => { + const remaining = Math.max(0, maxBytes - current.length); + if (chunk.length > remaining) + return Effect.fail( + new ContainerEngineProtocolError({ + operation: "process-output", + message: "Container engine output exceeded the configured limit", + }), + ); + const result = new Uint8Array(current.length + chunk.length); + result.set(current); + result.set(chunk, current.length); + return Effect.succeed(result); + }, + ).pipe(Effect.map((bytes) => new TextDecoder().decode(bytes))); +export const makeProcessCommandRunner = ( + options: ProcessCommandRunnerOptions, +): Effect.Effect< + ContainerCommandRunner, + ContainerEngineFailure, + ChildProcessSpawnerService.ChildProcessSpawner +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024; + const run = ( + request: ContainerProcessRequest, + ): Effect.Effect => { + const stdin = + request.stdin === undefined + ? "ignore" + : { + stream: Stream.succeed(new TextEncoder().encode(request.stdin)), + endOnDone: true, + }; + return Effect.scoped( + Effect.acquireUseRelease( + ChildProcess.make(options.executable, [...(options.baseArgs ?? []), ...request.args], { + stdin, + stdout: "pipe", + stderr: "pipe", + extendEnv: true, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + (handle) => + Effect.gen(function* () { + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + boundedOutput(handle.stdout, maxOutputBytes), + boundedOutput(handle.stderr, maxOutputBytes), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode: Number(exitCode) }; + }), + (handle) => { + const terminate = (signal: "SIGTERM" | "SIGKILL") => + Effect.gen(function* () { + if (!(yield* handle.isRunning)) return; + yield* handle.kill({ killSignal: signal }); + }); + return Effect.timeoutOrElse(terminate("SIGTERM").pipe(Effect.interruptible), { + duration: "2 seconds", + orElse: () => terminate("SIGKILL"), + }); + }, + ), + ).pipe( + Effect.mapError((error) => + typeof error === "object" && + error !== null && + "reason" in error && + Predicate.isTagged(error.reason, "NotFound") + ? new ContainerExecutableNotFoundError({ + executable: options.executable, + message: `${options.executable} executable was not found`, + }) + : new ContainerEngineProtocolError({ + operation: request.args[0] ?? "command", + message: "Container engine process failed", + cause: error, + }), + ), + ); + }; + return { executable: options.executable, run }; + }); + +export interface ContainerEngineCodecs { + readonly serialize: (command: ContainerCommand) => ContainerProcessRequest; + readonly decodeProbe: ( + result: ContainerCommandResult, + ) => Effect.Effect; + readonly decodeImage: ( + result: ContainerCommandResult, + ) => Effect.Effect, ContainerEngineFailure>; + readonly decodeContainers: ( + result: ContainerCommandResult, + ) => Effect.Effect, ContainerEngineFailure>; + readonly decodeNetworks: ( + result: ContainerCommandResult, + ) => Effect.Effect, ContainerEngineFailure>; + readonly decodeVolumes: ( + result: ContainerCommandResult, + ) => Effect.Effect, ContainerEngineFailure>; + readonly decodeCreate: ( + operation: string, + result: ContainerCommandResult, + spec: { readonly name: string; readonly labels: ContainerLabels }, + kind: R, + ) => Effect.Effect; +} +export interface ContainerEngineOptions { + readonly kind: ContainerEngineKind; + readonly runner: ContainerCommandRunner; + readonly platform: ContainerPlatform; + readonly codecs: ContainerEngineCodecs; +} +export interface ContainerEngine { + readonly kind: ContainerEngineKind; + readonly executable: string; + readonly preflight: Effect.Effect; + readonly probe: Effect.Effect; + readonly inspectImage: ( + image: string, + ) => Effect.Effect, ContainerEngineFailure>; + readonly pullImage: (image: string) => Effect.Effect; + readonly listResources: ( + stackId: StackId, + ) => Effect.Effect, ContainerEngineFailure>; + readonly createNetwork: ( + spec: ContainerNetworkSpec, + ) => Effect.Effect; + readonly removeNetwork: (id: string) => Effect.Effect; + readonly createVolume: ( + spec: ContainerVolumeSpec, + ) => Effect.Effect; + readonly removeVolume: (id: string) => Effect.Effect; + readonly createContainer: ( + spec: ContainerContainerSpec, + ) => Effect.Effect; + readonly startContainer: (id: string) => Effect.Effect; + readonly stopContainer: (id: string) => Effect.Effect; + readonly removeContainer: (id: string) => Effect.Effect; +} +export const makeContainerEngineCore = (options: ContainerEngineOptions): ContainerEngine => { + const run = (command: ContainerCommand) => options.runner.run(options.codecs.serialize(command)); + const check = (operation: string, command: ContainerCommand) => + run(command).pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.succeed(result) + : Effect.fail( + new ContainerCommandError({ + operation, + message: `Container engine command failed (${result.exitCode})`, + }), + ), + ), + ); + const noResult = (operation: string, command: ContainerCommand) => + check(operation, command).pipe(Effect.asVoid); + const preflight = + options.kind === "docker" + ? options.platform.remote === true + ? Effect.fail( + new ContainerRoutingUnsupportedError({ + engine: options.kind, + message: "Docker remote daemon has no verified host route", + }), + ) + : Effect.succeed( + options.platform.os === "linux" && options.platform.desktop !== true + ? { host: "host.docker.internal", gateway: "host-gateway" } + : { host: "host.docker.internal" }, + ) + : options.platform.remote === true || options.platform.os !== "linux" + ? Effect.fail( + new ContainerRoutingUnsupportedError({ + engine: options.kind, + message: "Podman host route is unsupported on this platform", + }), + ) + : Effect.succeed({ host: "host.containers.internal" }); + return { + kind: options.kind, + executable: options.runner.executable, + preflight, + probe: check("probe", { operation: "probe" }).pipe(Effect.flatMap(options.codecs.decodeProbe)), + inspectImage: (image) => + check("inspect-image", { operation: "inspect-image", image }).pipe( + Effect.flatMap(options.codecs.decodeImage), + ), + pullImage: (image) => noResult("pull-image", { operation: "pull-image", image }), + listResources: (stackId) => + Effect.all([ + check("inspect-containers", { operation: "inspect-containers", stackId }).pipe( + Effect.flatMap(options.codecs.decodeContainers), + ), + check("inspect-networks", { operation: "inspect-networks", stackId }).pipe( + Effect.flatMap(options.codecs.decodeNetworks), + ), + check("inspect-volumes", { operation: "inspect-volumes", stackId }).pipe( + Effect.flatMap(options.codecs.decodeVolumes), + ), + ]).pipe( + Effect.map(([containers, networks, volumes]) => [...containers, ...networks, ...volumes]), + ), + createNetwork: (spec) => + check("create-network", { operation: "create-network", spec }).pipe( + Effect.flatMap((result) => + options.codecs.decodeCreate("create-network", result, spec, "network"), + ), + ), + removeNetwork: (id) => noResult("remove-network", { operation: "remove-network", id }), + createVolume: (spec) => + check("create-volume", { operation: "create-volume", spec }).pipe( + Effect.flatMap((result) => + options.codecs.decodeCreate("create-volume", result, spec, "volume"), + ), + ), + removeVolume: (id) => noResult("remove-volume", { operation: "remove-volume", id }), + createContainer: (spec) => + spec.role === "workload" && (spec.publications.length > 0 || spec.hostRoute !== undefined) + ? Effect.fail( + new ContainerEngineProtocolError({ + operation: "create-container", + message: "Only the gateway container may publish host ports or host routes", + }), + ) + : check("create-container", { operation: "create-container", spec }).pipe( + Effect.flatMap((result) => + options.codecs.decodeCreate("create-container", result, spec, spec.role), + ), + ), + startContainer: (id) => noResult("start-container", { operation: "start-container", id }), + stopContainer: (id) => noResult("stop-container", { operation: "stop-container", id }), + removeContainer: (id) => noResult("remove-container", { operation: "remove-container", id }), + }; +}; +export interface SelectContainerEngineOptions { + readonly preference: "auto" | ContainerEngineKind; + readonly docker: ContainerEngine; + readonly podman: ContainerEngine; +} +export const selectContainerEngine = ( + options: SelectContainerEngineOptions, +): Effect.Effect => { + const explicit = + options.preference === "docker" + ? options.docker + : options.preference === "podman" + ? options.podman + : undefined; + if (explicit !== undefined) return explicit.probe.pipe(Effect.as(explicit)); + return options.docker.probe.pipe( + Effect.as(options.docker), + Effect.catchTag("ContainerExecutableNotFoundError", () => + options.podman.probe.pipe(Effect.as(options.podman)), + ), + ); +}; diff --git a/packages/stack/src/runtime/ContainerEngineResolver.ts b/packages/stack/src/runtime/ContainerEngineResolver.ts new file mode 100644 index 0000000000..c18a359e7c --- /dev/null +++ b/packages/stack/src/runtime/ContainerEngineResolver.ts @@ -0,0 +1,19 @@ +import { Context, Effect } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import type { ContainerEngineFailure, ContainerEngineKind } from "./ContainerEngine.ts"; + +/** + * Host-composition seam for selecting one concrete container engine. The + * service is intentionally narrow so createStack can be tested without a + * local daemon while production uses the real Docker/Podman adapters. + */ +export interface ContainerEngineResolverShape { + readonly resolve: ( + preference: "auto" | ContainerEngineKind, + ) => Effect.Effect; +} + +export class ContainerEngineResolver extends Context.Service< + ContainerEngineResolver, + ContainerEngineResolverShape +>()("@supabase/stack/ContainerEngineResolver") {} diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts new file mode 100644 index 0000000000..d036a0abff --- /dev/null +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -0,0 +1,439 @@ +import { Cause, Effect, Exit, Semaphore } from "effect"; +import type { ContainerArtifact } from "../model/CapabilityModule.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { StackId } from "../public/StackId.ts"; +import { + type ContainerContainerSpec, + type ContainerEngine, + type ContainerEngineFailure, + type ContainerLabels, + type ContainerMount, + type ContainerVolumeMount, + type ContainerVolumeLabels, + type ContainerVolumeSpec, + type ContainerResource, + type ContainerResourceRole, + type ContainerNetworkLabels, + type ContainerWorkloadLabels, +} from "./ContainerEngine.ts"; +import { + RuntimeDriverError, + type ObservedWorkload, + type RuntimeDriver, + type RuntimeWorkloadKey, +} from "./RuntimeDriver.ts"; + +export interface ContainerRuntimeOptions { + readonly engine: ContainerEngine; + readonly ownerSessionId: string; + readonly resolveWorkload?: ( + key: RuntimeWorkloadKey, + workload: PlannedWorkload, + ) => Effect.Effect; +} + +export interface ContainerWorkloadResolution { + readonly mounts?: ReadonlyArray; + readonly volume?: ContainerVolumeRequest; + readonly waitForReadiness?: ( + key: RuntimeWorkloadKey, + workload: PlannedWorkload, + resource: ContainerResource, + ) => Effect.Effect; +} + +export interface ContainerVolumeRequest { + readonly target: string; + readonly readOnly: boolean; +} + +export interface ContainerRuntimeResourceIds { + readonly container: string; + readonly network: string; + readonly state: "running" | "stopped"; +} + +const resourceKey = (key: RuntimeWorkloadKey): string => + `${key.stackId}:${key.desiredGeneration}:${key.workloadId}:${key.specHash}`; + +const nameFor = (key: RuntimeWorkloadKey, role: ContainerResourceRole): string => + role === "network" + ? `supabase-${key.stackId.slice(0, 16)}-${key.desiredGeneration}-network` + : `supabase-${key.stackId.slice(0, 16)}-${key.desiredGeneration}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-${role}`; + +const networkLabelsFor = ( + key: RuntimeWorkloadKey, + ownerSessionId: string, +): ContainerNetworkLabels => ({ + stackId: key.stackId, + ownerSessionId, + desiredGeneration: key.desiredGeneration, + role: "network", +}); +const workloadLabelsFor = ( + key: RuntimeWorkloadKey, + ownerSessionId: string, +): ContainerWorkloadLabels => ({ + stackId: key.stackId, + ownerSessionId, + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash: key.specHash, + role: "workload", +}); +const volumeLabelsFor = (key: RuntimeWorkloadKey): ContainerVolumeLabels => ({ + stackId: key.stackId, + workloadId: key.workloadId, + role: "volume", +}); +const volumeNameFor = (key: RuntimeWorkloadKey): string => + `supabase-${key.stackId}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; +const volumeSpecFor = (key: RuntimeWorkloadKey): ContainerVolumeSpec => ({ + name: volumeNameFor(key), + labels: volumeLabelsFor(key), +}); +const volumeMountFor = ( + key: RuntimeWorkloadKey, + request: ContainerVolumeRequest, +): ContainerVolumeMount => ({ + volume: volumeNameFor(key), + target: request.target, + readOnly: request.readOnly, +}); + +const sameLabels = (left: ContainerLabels, right: ContainerLabels): boolean => + left.role === right.role && + (left.role === "volume" && right.role === "volume" + ? left.stackId === right.stackId && left.workloadId === right.workloadId + : left.stackId === right.stackId && + "ownerSessionId" in left && + "ownerSessionId" in right && + left.ownerSessionId === right.ownerSessionId && + left.desiredGeneration === right.desiredGeneration && + (left.role === "network" || + ("workloadId" in left && + "workloadId" in right && + left.workloadId === right.workloadId && + "specHash" in left && + "specHash" in right && + left.specHash === right.specHash))); + +const toDriverError = ( + key: Pick, + error: unknown, +): RuntimeDriverError => + new RuntimeDriverError({ + message: error instanceof Error ? error.message : String(error), + stackId: key.stackId, + workloadId: key.workloadId, + cause: error, + }); + +const containerArtifact = (workload: PlannedWorkload): ContainerArtifact | undefined => + workload.selected.kind === "container" ? workload.selected : undefined; + +const isWorkloadResource = ( + entry: ContainerResource, +): entry is ContainerResource & { readonly labels: ContainerWorkloadLabels } => + entry.kind === "workload" && entry.labels.role === "workload"; + +const isManagedContainer = ( + entry: ContainerResource, +): entry is ContainerResource & { readonly labels: ContainerWorkloadLabels } => + (entry.kind === "workload" || entry.kind === "gateway") && + (entry.labels.role === "workload" || entry.labels.role === "gateway"); + +const isNetworkResource = ( + entry: ContainerResource, +): entry is ContainerResource & { readonly labels: ContainerNetworkLabels } => + entry.kind === "network" && entry.labels.role === "network"; + +/** RuntimeDriver implementation backed by one exact, injected container engine. */ +export const makeContainerRuntime = ( + options: ContainerRuntimeOptions, +): Effect.Effect => + Effect.gen(function* () { + const lifecycle = yield* Semaphore.make(1); + const resources = new Map(); + + const withEngine = ( + key: Pick, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe(Effect.mapError((error) => toDriverError(key, error))); + + const observe = ( + stackId: StackId, + ): Effect.Effect, RuntimeDriverError> => + withEngine({ stackId, workloadId: "" }, options.engine.listResources(stackId)).pipe( + Effect.map((entries) => + entries + .filter(isWorkloadResource) + .filter( + (entry) => + entry.labels.ownerSessionId === options.ownerSessionId && + entry.labels.stackId === stackId, + ) + .map((entry) => ({ + stackId: entry.labels.stackId, + desiredGeneration: entry.labels.desiredGeneration, + workloadId: entry.labels.workloadId, + specHash: entry.labels.specHash, + state: + entry.state === "running" + ? "ready" + : entry.state === "stopped" + ? "stopped" + : "starting", + })), + ), + ); + + const start = ( + key: RuntimeWorkloadKey, + workload: PlannedWorkload, + ): Effect.Effect => { + const artifact = containerArtifact(workload); + if (artifact === undefined) + return Effect.fail( + new RuntimeDriverError({ + message: "Container runtime cannot start a native artifact", + stackId: key.stackId, + workloadId: key.workloadId, + }), + ); + + const labels = workloadLabelsFor(key, options.ownerSessionId); + const networkLabels = networkLabelsFor(key, options.ownerSessionId); + const networkName = nameFor(key, "network"); + const resolved = + options.resolveWorkload === undefined + ? Effect.succeed({}) + : options.resolveWorkload(key, workload); + const startEffect = Effect.gen(function* () { + yield* withEngine(key, options.engine.preflight); + const resolution = yield* resolved; + const volumeRequest = resolution.volume; + if (volumeRequest !== undefined && volumeRequest.target.length === 0) + return yield* toDriverError(key, new Error("Container volume mapping is invalid")); + const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); + const volumeSpec = volumeRequest === undefined ? undefined : volumeSpecFor(key); + if (volumeSpec !== undefined) { + const volume = volumeSpec; + const exact = entries.find( + (entry) => + entry.kind === "volume" && + entry.name === volume.name && + sameLabels(entry.labels, volume.labels), + ); + if (exact === undefined) { + const sameLabel = entries.find( + (entry) => entry.kind === "volume" && sameLabels(entry.labels, volume.labels), + ); + const sameName = entries.find( + (entry) => entry.kind === "volume" && entry.name === volume.name, + ); + if (sameLabel !== undefined || sameName !== undefined) + return yield* toDriverError(key, new Error("Container volume identity collision")); + } + } + const exact = entries.find( + (entry) => isManagedContainer(entry) && sameLabels(entry.labels, labels), + ); + const namedCollision = entries.find( + (entry) => + (entry.kind === "workload" || entry.kind === "gateway") && + entry.name === nameFor(key, "workload"), + ); + const collision = + namedCollision !== undefined && isManagedContainer(namedCollision) + ? namedCollision + : undefined; + if ( + exact === undefined && + namedCollision !== undefined && + (collision === undefined || + collision.labels.stackId !== key.stackId || + collision.labels.ownerSessionId !== options.ownerSessionId || + collision.labels.workloadId !== key.workloadId || + collision.labels.role !== "workload") + ) + return yield* toDriverError( + key, + new Error("Container name is owned by another workload"), + ); + + const exactNetwork = entries + .filter(isNetworkResource) + .find((entry) => sameLabels(entry.labels, networkLabels)); + const namedNetworkCollision = entries.find( + (entry) => entry.kind === "network" && entry.name === networkName, + ); + const networkCollision = + namedNetworkCollision !== undefined && isNetworkResource(namedNetworkCollision) + ? namedNetworkCollision + : undefined; + if ( + exactNetwork === undefined && + namedNetworkCollision !== undefined && + (networkCollision === undefined || + networkCollision.labels.stackId !== key.stackId || + networkCollision.labels.ownerSessionId !== options.ownerSessionId) + ) + return yield* toDriverError( + key, + new Error("Container network name is owned by another stack"), + ); + + if (exact === undefined) + yield* withEngine(key, options.engine.inspectImage(artifact.image)).pipe( + Effect.flatMap((inspected) => + inspected.present + ? Effect.void + : withEngine(key, options.engine.pullImage(artifact.image)), + ), + ); + + if (exactNetwork === undefined && networkCollision !== undefined) + yield* withEngine(key, options.engine.removeNetwork(networkCollision.id)); + const networkResource = + exactNetwork ?? + (yield* withEngine( + key, + options.engine.createNetwork({ name: networkName, labels: networkLabels }), + )); + + if (volumeSpec !== undefined) { + const volume = volumeSpec; + const exactVolume = entries.find( + (entry) => + entry.kind === "volume" && + entry.name === volume.name && + sameLabels(entry.labels, volume.labels), + ); + if (exactVolume === undefined) + yield* withEngine(key, options.engine.createVolume(volume)); + } + + let container: ContainerResource; + let created = false; + let startedByUs = false; + if (exact !== undefined) { + container = exact; + if (exact.state !== "running") { + yield* withEngine(key, options.engine.startContainer(exact.id)); + startedByUs = true; + } + } else { + if (collision !== undefined) { + if (collision.state === "running") + yield* withEngine(key, options.engine.stopContainer(collision.id)); + yield* withEngine(key, options.engine.removeContainer(collision.id)); + } + container = yield* withEngine( + key, + options.engine.createContainer({ + name: nameFor(key, "workload"), + image: artifact.image, + labels, + network: networkResource.id, + mounts: resolution.mounts ?? [], + publications: [], + volumeMounts: volumeRequest === undefined ? [] : [volumeMountFor(key, volumeRequest)], + role: "workload", + } satisfies ContainerContainerSpec), + ); + created = true; + yield* withEngine(key, options.engine.startContainer(container.id)); + startedByUs = true; + } + const readiness = + resolution.waitForReadiness === undefined + ? Effect.void + : resolution.waitForReadiness(key, workload, container); + const ready = yield* readiness.pipe(Effect.exit); + if (Exit.isFailure(ready)) { + const stopExit = startedByUs + ? yield* Effect.exit(withEngine(key, options.engine.stopContainer(container.id))) + : Exit.succeed(undefined); + const removeExit = created + ? yield* Effect.exit(withEngine(key, options.engine.removeContainer(container.id))) + : Exit.succeed(undefined); + let cleanupCause: Cause.Cause = Cause.empty; + if (Exit.isFailure(stopExit)) cleanupCause = Cause.combine(cleanupCause, stopExit.cause); + if (Exit.isFailure(removeExit)) + cleanupCause = Cause.combine(cleanupCause, removeExit.cause); + return yield* cleanupCause.reasons.length === 0 + ? Effect.failCause(ready.cause) + : Effect.failCause(Cause.combine(ready.cause, cleanupCause)); + } + resources.set(resourceKey(key), { + container: container.id, + network: networkResource.id, + state: "running", + }); + const result: ObservedWorkload = { ...key, state: "ready" }; + return result; + }); + return lifecycle.withPermit(startEffect); + }; + + const stopInPermit = (key: RuntimeWorkloadKey): Effect.Effect => { + const found = resources.get(resourceKey(key)); + return found !== undefined + ? found.state === "stopped" + ? Effect.void + : withEngine(key, options.engine.stopContainer(found.container)).pipe( + Effect.tap(() => + Effect.sync(() => { + resources.set(resourceKey(key), { ...found, state: "stopped" }); + }), + ), + ) + : withEngine(key, options.engine.listResources(key.stackId)).pipe( + Effect.flatMap((entries) => { + const exact = entries + .filter(isWorkloadResource) + .find( + (entry) => + entry.labels.ownerSessionId === options.ownerSessionId && + sameLabels(entry.labels, workloadLabelsFor(key, options.ownerSessionId)), + ); + return exact === undefined + ? Effect.void + : exact.state === "running" + ? withEngine(key, options.engine.stopContainer(exact.id)) + : Effect.void; + }), + ); + }; + + const stop = (key: RuntimeWorkloadKey): Effect.Effect => + lifecycle.withPermit(stopInPermit(key)); + + const remove = (key: RuntimeWorkloadKey): Effect.Effect => + lifecycle.withPermit( + Effect.gen(function* () { + const found = resources.get(resourceKey(key)); + if (found !== undefined) { + if (found.state === "running") + yield* withEngine(key, options.engine.stopContainer(found.container)); + yield* withEngine(key, options.engine.removeContainer(found.container)); + resources.delete(resourceKey(key)); + return; + } + const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); + const exact = entries + .filter(isWorkloadResource) + .find((entry) => + sameLabels(entry.labels, workloadLabelsFor(key, options.ownerSessionId)), + ); + if (exact === undefined) return; + if (exact.state === "running") + yield* withEngine(key, options.engine.stopContainer(exact.id)); + yield* withEngine(key, options.engine.removeContainer(exact.id)); + }), + ); + + return { observe, start, stop, remove } satisfies RuntimeDriver; + }); diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts new file mode 100644 index 0000000000..349548edfc --- /dev/null +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -0,0 +1,398 @@ +import { Effect, Schema } from "effect"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import { + makeContainerEngineCore, + type ContainerCommand, + type ContainerCommandResult, + type ContainerEngine, + type ContainerEngineFailure, + type ContainerEngineOptions, + type ContainerLabels, + type ContainerNetworkLabels, + type ContainerProcessRequest, + type ContainerResource, + type ContainerResourceRole, + type ContainerWorkloadLabels, +} from "./ContainerEngine.ts"; +import { ContainerEngineProtocolError } from "./ContainerEngine.ts"; + +const stackLabel = "com.supabase.stack.stackId"; +const ownerLabel = "com.supabase.stack.ownerSessionId"; +const generationLabel = "com.supabase.stack.desiredGeneration"; +const workloadLabel = "com.supabase.stack.workloadId"; +const hashLabel = "com.supabase.stack.specHash"; +const roleLabel = "com.supabase.stack.role"; + +const labels = (value: ContainerLabels): ReadonlyArray => { + const pairs = + value.role === "network" + ? [ + ["stackId", value.stackId], + ["ownerSessionId", value.ownerSessionId], + ["desiredGeneration", value.desiredGeneration], + ["role", value.role], + ] + : value.role === "volume" + ? [ + ["stackId", value.stackId], + ["workloadId", value.workloadId], + ["role", value.role], + ] + : [ + ["stackId", value.stackId], + ["ownerSessionId", value.ownerSessionId], + ["desiredGeneration", value.desiredGeneration], + ["workloadId", value.workloadId], + ["specHash", value.specHash], + ["role", value.role], + ]; + return pairs.flatMap(([key, label]) => ["--label", `com.supabase.stack.${key}=${String(label)}`]); +}; + +const jsonLabel = (label: string): string => `{{json (.Label "${label}")}}`; + +// Emit a closed set of JSON scalar fields. In particular, do not decode the +// daemon's raw `.Labels` map: unknown labels must never become runtime state. +const containerFormat = [ + "{{json .ID}}", + "{{json .Names}}", + jsonLabel(stackLabel), + jsonLabel(ownerLabel), + jsonLabel(generationLabel), + jsonLabel(workloadLabel), + jsonLabel(hashLabel), + jsonLabel(roleLabel), + "{{json .State}}", +].join("\\t"); +const networkFormat = [ + "{{json .ID}}", + "{{json .Name}}", + jsonLabel(stackLabel), + jsonLabel(ownerLabel), + jsonLabel(generationLabel), + jsonLabel(roleLabel), +].join("\\t"); +const volumeFormat = [ + "{{json .Name}}", + jsonLabel(stackLabel), + jsonLabel(workloadLabel), + jsonLabel(roleLabel), +].join("\\t"); + +const commandLabels = (spec: { readonly labels: ContainerLabels }): ReadonlyArray => + labels(spec.labels); + +export const serializeDockerCommand = (command: ContainerCommand): ContainerProcessRequest => { + switch (command.operation) { + case "probe": + return { args: ["version", "--format", "{{json .Server.Version}}"] }; + case "inspect-image": + return { + args: ["image", "ls", command.image, "--format", "{{json .ID}}"], + }; + case "pull-image": + return { args: ["image", "pull", command.image] }; + case "inspect-containers": + return { + args: [ + "ps", + "--all", + "--filter", + `label=${stackLabel}=${command.stackId}`, + "--format", + containerFormat, + ], + }; + case "inspect-networks": + return { + args: [ + "network", + "ls", + "--filter", + `label=${stackLabel}=${command.stackId}`, + "--format", + networkFormat, + ], + }; + case "inspect-volumes": + return { + args: [ + "volume", + "ls", + "--filter", + `label=${stackLabel}=${command.stackId}`, + "--format", + volumeFormat, + ], + }; + case "create-network": + return { args: ["network", "create", ...commandLabels(command.spec), command.spec.name] }; + case "remove-network": + return { args: ["network", "rm", command.id] }; + case "create-volume": + return { args: ["volume", "create", ...commandLabels(command.spec), command.spec.name] }; + case "remove-volume": + return { args: ["volume", "rm", command.id] }; + case "create-container": { + const bindMounts = command.spec.mounts.flatMap((mount) => [ + "--mount", + `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, + ]); + const volumeMounts = command.spec.volumeMounts.flatMap((mount) => [ + "--mount", + `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, + ]); + const publications = + command.spec.role === "gateway" + ? command.spec.publications.flatMap((port) => [ + "--publish", + `${port.hostPort}:${port.containerPort}`, + ]) + : []; + const hostRoute = + command.spec.hostRoute?.gateway === undefined + ? [] + : ["--add-host", `${command.spec.hostRoute.host}:${command.spec.hostRoute.gateway}`]; + return { + args: [ + "create", + "--name", + command.spec.name, + "--network", + command.spec.network, + ...commandLabels(command.spec), + ...bindMounts, + ...volumeMounts, + ...publications, + ...hostRoute, + command.spec.image, + ...(command.spec.command ?? []), + ], + }; + } + case "start-container": + return { args: ["start", command.id] }; + case "stop-container": + return { args: ["stop", command.id] }; + case "remove-container": + return { args: ["rm", "--force", command.id] }; + } +}; + +const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => + new ContainerEngineProtocolError({ + operation, + message: `${operation} returned an invalid Docker response`, + ...(cause === undefined ? {} : { cause }), + }); + +const parseJson = ( + operation: string, + raw: string, +): Effect.Effect => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(raw).pipe( + Effect.mapError((cause) => protocol(operation, cause)), + ); + +const lines = (raw: string): ReadonlyArray => + raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + +const scalarString = ( + operation: string, + raw: string, +): Effect.Effect => + parseJson(operation, raw).pipe( + Effect.flatMap((value) => + typeof value === "string" && value.length > 0 + ? Effect.succeed(value) + : Effect.fail(protocol(operation)), + ), + ); + +const fields = ( + operation: string, + line: string, + count: number, +): Effect.Effect, ContainerEngineFailure> => { + const values = line.split(/\\t|\t/); + if (values.length !== count) return Effect.fail(protocol(operation)); + return Effect.forEach(values, (value) => + parseJson(operation, value).pipe( + Effect.flatMap((parsed) => + typeof parsed === "string" ? Effect.succeed(parsed) : Effect.fail(protocol(operation)), + ), + ), + ); +}; + +const decodeIdentity = ( + operation: string, + stack: string | undefined, +): Effect.Effect => { + if (stack === undefined) return Effect.fail(protocol(operation)); + return Schema.decodeEffect(StackIdSchema)(stack).pipe( + Effect.mapError((error) => protocol(operation, error)), + ); +}; + +const networkLabels = ( + operation: string, + values: ReadonlyArray, +): Effect.Effect => { + const [stack, owner, generation, role] = values; + if (owner === undefined || generation === undefined || role !== "network") + return Effect.fail(protocol(operation)); + return decodeIdentity(operation, stack).pipe( + Effect.flatMap((stackId) => { + const desiredGeneration = Number(generation); + return Number.isInteger(desiredGeneration) + ? Effect.succeed({ + stackId, + ownerSessionId: owner, + desiredGeneration, + role: "network", + }) + : Effect.fail(protocol(operation)); + }), + ); +}; + +const workloadLabels = ( + operation: string, + values: ReadonlyArray, +): Effect.Effect => { + const [stack, owner, generation, workload, hash, role] = values; + if ( + owner === undefined || + generation === undefined || + workload === undefined || + hash === undefined || + (role !== "workload" && role !== "gateway") + ) + return Effect.fail(protocol(operation)); + return decodeIdentity(operation, stack).pipe( + Effect.flatMap((stackId) => { + const desiredGeneration = Number(generation); + return Number.isInteger(desiredGeneration) + ? Effect.succeed({ + stackId, + ownerSessionId: owner, + desiredGeneration, + workloadId: workload, + specHash: hash, + role, + }) + : Effect.fail(protocol(operation)); + }), + ); +}; + +const decodeRows = ( + operation: string, + raw: string, + count: number, + decode: (values: ReadonlyArray) => Effect.Effect, +): Effect.Effect, ContainerEngineFailure> => + Effect.forEach(lines(raw), (line) => fields(operation, line, count).pipe(Effect.flatMap(decode))); + +const decodeContainers = (result: ContainerCommandResult) => + decodeRows("inspect-containers", result.stdout, 9, (values) => { + const [id, name, stack, owner, generation, workload, hash, role, state] = values; + if ( + id === undefined || + name === undefined || + stack === undefined || + owner === undefined || + generation === undefined || + workload === undefined || + hash === undefined || + role === undefined || + state === undefined || + (role !== "workload" && role !== "gateway") + ) + return Effect.fail(protocol("inspect-containers")); + return workloadLabels("inspect-containers", [ + stack, + owner, + generation, + workload, + hash, + role, + ]).pipe( + Effect.map((labels): ContainerResource => ({ + id, + name, + kind: role, + labels, + state: state.includes("running") ? "running" : "stopped", + })), + ); + }); + +const decodeNetworks = (result: ContainerCommandResult) => + decodeRows("inspect-networks", result.stdout, 6, (values) => { + const [id, name, stack, owner, generation, role] = values; + if (id === undefined || name === undefined || role === undefined) + return Effect.fail(protocol("inspect-networks")); + return networkLabels("inspect-networks", [ + stack ?? "", + owner ?? "", + generation ?? "", + role, + ]).pipe(Effect.map((labels): ContainerResource => ({ id, name, kind: "network", labels }))); + }); + +const decodeVolumes = (result: ContainerCommandResult) => + decodeRows("inspect-volumes", result.stdout, 4, (values) => { + const [name, stack, workload, role] = values; + if (name === undefined || stack === undefined || workload === undefined || role === undefined) + return Effect.fail(protocol("inspect-volumes")); + return Schema.decodeEffect(StackIdSchema)(stack).pipe( + Effect.mapError((error) => protocol("inspect-volumes", error)), + Effect.flatMap((stackId) => + role === "volume" + ? Effect.succeed({ + id: name, + name, + kind: "volume", + labels: { stackId, workloadId: workload, role: "volume" }, + }) + : Effect.fail(protocol("inspect-volumes")), + ), + ); + }); + +const decodeCreate = ( + operation: string, + result: ContainerCommandResult, + spec: { readonly name: string; readonly labels: ContainerLabels }, + kind: R, +): Effect.Effect => { + const id = result.stdout.trim(); + return id.length > 0 && !/[\r\n]/.test(id) + ? Effect.succeed({ id, name: spec.name, kind, labels: spec.labels, state: "created" }) + : Effect.fail(protocol(operation)); +}; + +export const makeDockerCodecs = () => ({ + serialize: serializeDockerCommand, + decodeProbe: (result: ContainerCommandResult) => + scalarString("probe", result.stdout).pipe(Effect.asVoid), + decodeImage: (result: ContainerCommandResult) => + Effect.forEach(lines(result.stdout), (line) => scalarString("inspect-image", line)).pipe( + Effect.map((values) => ({ present: values.length > 0 })), + ), + decodeContainers, + decodeNetworks, + decodeVolumes, + decodeCreate, +}); + +export const createDockerEngine = ( + options: Omit, +): ContainerEngine => + makeContainerEngineCore({ ...options, kind: "docker", codecs: makeDockerCodecs() }); +export const makeDockerEngine = createDockerEngine; diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index 623ea31e4e..38eb550d5f 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -1,5 +1,6 @@ import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scope, Semaphore, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import type { ExitCode } from "effect/unstable/process/ChildProcessSpawner"; import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; import type { DatabaseBootstrapOptions, DatabaseSession } from "../model/DatabaseBootstrap.ts"; import { runDatabaseBootstrap } from "../model/DatabaseBootstrap.ts"; @@ -178,9 +179,13 @@ export const makeNativeRuntime = ( ), ); - const watchProcess = (resource: Resource, process: NativeProcess) => + const watchProcess = ( + resource: Resource, + process: NativeProcess, + exitCode: Effect.Effect, + ) => Effect.gen(function* () { - const result = yield* process.exitCode.pipe(Effect.exit); + const result = yield* exitCode.pipe(Effect.exit); const next: ObservedWorkload = Exit.isSuccess(result) ? { ...resource.key, @@ -197,6 +202,12 @@ export const makeNativeRuntime = ( yield* Ref.update(resource.state, (current): ObservedWorkload => current.state === "failed" ? current : next, ); + if (Exit.isFailure(result) && !resource.stopRequested) { + yield* Deferred.fail( + resource.failure, + driverError(resource.key, `Native workload exited before readiness`, result.cause), + ); + } }).pipe(Effect.ignore); const reportLogFailure = ( @@ -243,10 +254,11 @@ export const makeNativeRuntime = ( Effect.mapError((error) => processError(error, key, "spawn")), ); resource.process = process; - yield* Effect.forkIn(watchProcess(resource, process), resource.scope); + const exitCode = yield* Effect.cached(process.exitCode); + yield* Effect.forkIn(watchProcess(resource, process, exitCode), resource.scope); yield* attachLogs(resource, process); const readiness = options.waitForReadiness; - const exitedBeforeReady = process.exitCode.pipe( + const exitedBeforeReady = exitCode.pipe( Effect.flatMap((code) => Effect.fail( driverError( @@ -322,8 +334,16 @@ export const makeNativeRuntime = ( const start = ( key: RuntimeWorkloadKey, workload: NativeWorkload, - ): Effect.Effect => - Effect.flatMap( + ): Effect.Effect => { + if (workload.selected.kind === "container") + return Effect.fail( + new RuntimeDriverError({ + message: "Native runtime cannot start a container artifact", + stackId: key.stackId, + workloadId: key.workloadId, + }), + ); + return Effect.flatMap( lifecycle.withPermit( Effect.gen(function* () { const id = resourceKey(key); @@ -357,6 +377,7 @@ export const makeNativeRuntime = ( ), (resource) => Deferred.await(resource.result), ); + }; const observe = ( stackId: StackId, diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts new file mode 100644 index 0000000000..08bb6b76f0 --- /dev/null +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -0,0 +1,376 @@ +import { Effect, Schema } from "effect"; +import { StackIdSchema } from "../public/StackId.ts"; +import { + makeContainerEngineCore, + type ContainerCommand, + type ContainerCommandResult, + type ContainerEngine, + type ContainerEngineFailure, + type ContainerEngineOptions, + type ContainerLabels, + type ContainerNetworkLabels, + type ContainerProcessRequest, + type ContainerResource, + type ContainerResourceRole, + type ContainerWorkloadLabels, +} from "./ContainerEngine.ts"; +import { ContainerEngineProtocolError } from "./ContainerEngine.ts"; + +const stackLabel = "com.supabase.stack.stackId"; +const ownerLabel = "com.supabase.stack.ownerSessionId"; +const generationLabel = "com.supabase.stack.desiredGeneration"; +const workloadLabel = "com.supabase.stack.workloadId"; +const hashLabel = "com.supabase.stack.specHash"; +const roleLabel = "com.supabase.stack.role"; + +const labels = (value: ContainerLabels): ReadonlyArray => { + const pairs = + value.role === "network" + ? [ + ["stackId", value.stackId], + ["ownerSessionId", value.ownerSessionId], + ["desiredGeneration", value.desiredGeneration], + ["role", value.role], + ] + : value.role === "volume" + ? [ + ["stackId", value.stackId], + ["workloadId", value.workloadId], + ["role", value.role], + ] + : [ + ["stackId", value.stackId], + ["ownerSessionId", value.ownerSessionId], + ["desiredGeneration", value.desiredGeneration], + ["workloadId", value.workloadId], + ["specHash", value.specHash], + ["role", value.role], + ]; + return pairs.flatMap(([key, label]) => ["--label", `com.supabase.stack.${key}=${String(label)}`]); +}; + +const templateLabel = (label: string): string => `{{.Label "${label}"}}`; +const templateMapLabel = (label: string): string => `{{index .Labels "${label}"}}`; + +// Podman has a separate documented Go-template contract. Delimited scalar +// fields avoid relying on its version-specific JSON row shape. +const containerFormat = [ + "{{.ID}}", + "{{.Names}}", + templateLabel(stackLabel), + templateLabel(ownerLabel), + templateLabel(generationLabel), + templateLabel(workloadLabel), + templateLabel(hashLabel), + templateLabel(roleLabel), + "{{.State}}", +].join("\\t"); +const networkFormat = [ + "{{.ID}}", + "{{.Name}}", + templateMapLabel(stackLabel), + templateMapLabel(ownerLabel), + templateMapLabel(generationLabel), + templateMapLabel(roleLabel), +].join("\\t"); +const volumeFormat = [ + "{{.Name}}", + templateMapLabel(stackLabel), + templateMapLabel(workloadLabel), + templateMapLabel(roleLabel), +].join("\\t"); + +const commandLabels = (spec: { readonly labels: ContainerLabels }): ReadonlyArray => + labels(spec.labels); + +export const serializePodmanCommand = (command: ContainerCommand): ContainerProcessRequest => { + switch (command.operation) { + case "probe": + return { args: ["version", "--format", "{{.Version}}"] }; + case "inspect-image": + return { + args: ["image", "ls", command.image, "--format", "{{.ID}}"], + }; + case "pull-image": + return { args: ["pull", command.image] }; + case "inspect-containers": + return { + args: [ + "ps", + "--all", + "--filter", + `label=${stackLabel}=${command.stackId}`, + "--format", + containerFormat, + ], + }; + case "inspect-networks": + return { + args: [ + "network", + "ls", + "--filter", + `label=${stackLabel}=${command.stackId}`, + "--format", + networkFormat, + ], + }; + case "inspect-volumes": + return { + args: [ + "volume", + "ls", + "--filter", + `label=${stackLabel}=${command.stackId}`, + "--format", + volumeFormat, + ], + }; + case "create-network": + return { args: ["network", "create", ...commandLabels(command.spec), command.spec.name] }; + case "remove-network": + return { args: ["network", "rm", command.id] }; + case "create-volume": + return { args: ["volume", "create", ...commandLabels(command.spec), command.spec.name] }; + case "remove-volume": + return { args: ["volume", "rm", command.id] }; + case "create-container": { + const bindMounts = command.spec.mounts.flatMap((mount) => [ + "--mount", + `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, + ]); + const volumeMounts = command.spec.volumeMounts.flatMap((mount) => [ + "--mount", + `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, + ]); + const publications = + command.spec.role === "gateway" + ? command.spec.publications.flatMap((port) => [ + "--publish", + `${port.hostPort}:${port.containerPort}`, + ]) + : []; + return { + args: [ + "create", + "--name", + command.spec.name, + "--network", + command.spec.network, + ...commandLabels(command.spec), + ...bindMounts, + ...volumeMounts, + ...publications, + command.spec.image, + ...(command.spec.command ?? []), + ], + }; + } + case "start-container": + return { args: ["start", command.id] }; + case "stop-container": + return { args: ["stop", command.id] }; + case "remove-container": + return { args: ["rm", "--force", command.id] }; + } +}; + +const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => + new ContainerEngineProtocolError({ + operation, + message: `${operation} returned an invalid Podman response`, + ...(cause === undefined ? {} : { cause }), + }); + +const lines = (raw: string): ReadonlyArray => + raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + +const scalar = (operation: string, raw: string): Effect.Effect => { + const value = raw.trim(); + return value.length > 0 && !/\\t|\t/.test(value) + ? Effect.succeed(value) + : Effect.fail(protocol(operation)); +}; + +const fields = ( + operation: string, + line: string, + count: number, +): Effect.Effect, ContainerEngineFailure> => { + const values = line.split(/\\t|\t/); + return values.length === count && values.every((value) => !/[\r\n]/.test(value)) + ? Effect.succeed(values) + : Effect.fail(protocol(operation)); +}; + +const decodeIdentity = ( + operation: string, + stack: string | undefined, +): Effect.Effect => { + if (stack === undefined) return Effect.fail(protocol(operation)); + return Schema.decodeEffect(StackIdSchema)(stack).pipe( + Effect.mapError((error) => protocol(operation, error)), + ); +}; + +const networkLabels = ( + operation: string, + values: ReadonlyArray, +): Effect.Effect => { + const [stack, owner, generation, role] = values; + if (owner === undefined || generation === undefined || role !== "network") + return Effect.fail(protocol(operation)); + return decodeIdentity(operation, stack).pipe( + Effect.flatMap((stackId) => { + const desiredGeneration = Number(generation); + return Number.isInteger(desiredGeneration) + ? Effect.succeed({ + stackId, + ownerSessionId: owner, + desiredGeneration, + role: "network", + }) + : Effect.fail(protocol(operation)); + }), + ); +}; + +const workloadLabels = ( + operation: string, + values: ReadonlyArray, +): Effect.Effect => { + const [stack, owner, generation, workload, hash, role] = values; + if ( + owner === undefined || + generation === undefined || + workload === undefined || + hash === undefined || + (role !== "workload" && role !== "gateway") + ) + return Effect.fail(protocol(operation)); + return decodeIdentity(operation, stack).pipe( + Effect.flatMap((stackId) => { + const desiredGeneration = Number(generation); + return Number.isInteger(desiredGeneration) + ? Effect.succeed({ + stackId, + ownerSessionId: owner, + desiredGeneration, + workloadId: workload, + specHash: hash, + role, + }) + : Effect.fail(protocol(operation)); + }), + ); +}; + +const decodeRows = ( + operation: string, + raw: string, + count: number, + decode: (values: ReadonlyArray) => Effect.Effect, +): Effect.Effect, ContainerEngineFailure> => + Effect.forEach(lines(raw), (line) => fields(operation, line, count).pipe(Effect.flatMap(decode))); + +const decodeContainers = (result: ContainerCommandResult) => + decodeRows("inspect-containers", result.stdout, 9, (values) => { + const [id, name, stack, owner, generation, workload, hash, role, state] = values; + if ( + id === undefined || + name === undefined || + stack === undefined || + owner === undefined || + generation === undefined || + workload === undefined || + hash === undefined || + role === undefined || + state === undefined || + (role !== "workload" && role !== "gateway") + ) + return Effect.fail(protocol("inspect-containers")); + return workloadLabels("inspect-containers", [ + stack, + owner, + generation, + workload, + hash, + role, + ]).pipe( + Effect.map((labels): ContainerResource => ({ + id, + name, + kind: role, + labels, + state: state.includes("running") ? "running" : "stopped", + })), + ); + }); + +const decodeNetworks = (result: ContainerCommandResult) => + decodeRows("inspect-networks", result.stdout, 6, (values) => { + const [id, name, stack, owner, generation, role] = values; + if (id === undefined || name === undefined || role === undefined) + return Effect.fail(protocol("inspect-networks")); + return networkLabels("inspect-networks", [ + stack ?? "", + owner ?? "", + generation ?? "", + role, + ]).pipe(Effect.map((labels): ContainerResource => ({ id, name, kind: "network", labels }))); + }); + +const decodeVolumes = (result: ContainerCommandResult) => + decodeRows("inspect-volumes", result.stdout, 4, (values) => { + const [name, stack, workload, role] = values; + if (name === undefined || stack === undefined || workload === undefined || role === undefined) + return Effect.fail(protocol("inspect-volumes")); + return Schema.decodeEffect(StackIdSchema)(stack).pipe( + Effect.mapError((error) => protocol("inspect-volumes", error)), + Effect.flatMap((stackId) => + role === "volume" + ? Effect.succeed({ + id: name, + name, + kind: "volume", + labels: { stackId, workloadId: workload, role: "volume" }, + }) + : Effect.fail(protocol("inspect-volumes")), + ), + ); + }); + +const decodeCreate = ( + operation: string, + result: ContainerCommandResult, + spec: { readonly name: string; readonly labels: ContainerLabels }, + kind: R, +): Effect.Effect => { + const id = result.stdout.trim(); + return id.length > 0 && !/[\r\n]/.test(id) + ? Effect.succeed({ id, name: spec.name, kind, labels: spec.labels, state: "created" }) + : Effect.fail(protocol(operation)); +}; + +export const makePodmanCodecs = () => ({ + serialize: serializePodmanCommand, + decodeProbe: (result: ContainerCommandResult) => + scalar("probe", result.stdout).pipe(Effect.asVoid), + decodeImage: (result: ContainerCommandResult) => + Effect.forEach(lines(result.stdout), (line) => scalar("inspect-image", line)).pipe( + Effect.map((values) => ({ present: values.length > 0 })), + ), + decodeContainers, + decodeNetworks, + decodeVolumes, + decodeCreate, +}); + +export const createPodmanEngine = ( + options: Omit, +): ContainerEngine => + makeContainerEngineCore({ ...options, kind: "podman", codecs: makePodmanCodecs() }); +export const makePodmanEngine = createPodmanEngine; diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts new file mode 100644 index 0000000000..6f1655af66 --- /dev/null +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -0,0 +1,793 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; +import { NodeServices } from "@effect/platform-node"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { ContainerArtifact } from "../model/CapabilityModule.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import { + ContainerExecutableNotFoundError, + ContainerCommandError, + ContainerEngineProtocolError, + makeControlledCommandRunner, + makeProcessCommandRunner, + selectContainerEngine, + type ContainerProcessRequest, + type ContainerCommandResult, + type ContainerContainerSpec, + type ContainerEngine, + type ContainerNetworkSpec, + type ContainerResource, + type ContainerVolumeSpec, +} from "./ContainerEngine.ts"; +import { makeDockerEngine, serializeDockerCommand } from "./DockerEngine.ts"; +import { makePodmanEngine, serializePodmanCommand } from "./PodmanEngine.ts"; +import { makeContainerRuntime } from "./ContainerRuntime.ts"; +import { RuntimeDriverError, type RuntimeWorkloadKey } from "./RuntimeDriver.ts"; + +const stackId = StackIdSchema.make("a".repeat(64)); +const key: RuntimeWorkloadKey = { + stackId, + desiredGeneration: 7, + workloadId: "database:database", + specHash: "hash-7", +}; + +const containerArtifact: ContainerArtifact = { + kind: "container", + service: "database", + image: "example/database:1", +}; + +const workload = (selected: PlannedWorkload["selected"] = containerArtifact): PlannedWorkload => ({ + id: key.workloadId, + capability: key.workloadId.startsWith("functions:") ? "functions" : "database", + dependencies: [], + readiness: { mode: "tcp" }, + restart: { maxAttempts: 1, backoffMs: 0 }, + artifacts: { + native: { kind: "native", service: "database", release: "1" }, + container: containerArtifact, + }, + selected, + specHash: key.specHash, +}); + +const commandResult = (value: unknown): ContainerCommandResult => ({ + stdout: typeof value === "string" ? value : JSON.stringify(value), + stderr: "", + exitCode: 0, +}); + +interface FakeContainerState { + resources: Array; + imagePresent: boolean; + calls: Array; + createdSpecs: Array; + nextId: number; + inspectImageFailure?: ContainerCommandError; +} + +const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { + const id = (prefix: string): string => `${prefix}-${state.nextId++}`; + const find = (resourceId: string): ContainerResource | undefined => + state.resources.find((resource) => resource.id === resourceId); + return { + kind: "docker", + executable: "controlled-container-engine", + preflight: Effect.succeed({ host: "host.docker.internal" }), + probe: Effect.void, + inspectImage: () => + state.inspectImageFailure !== undefined + ? Effect.fail(state.inspectImageFailure) + : Effect.sync(() => { + state.calls.push("inspect-image"); + return { present: state.imagePresent }; + }), + pullImage: () => + Effect.sync(() => { + state.calls.push("pull-image"); + state.imagePresent = true; + }), + listResources: () => + Effect.sync(() => { + state.calls.push("list-resources"); + return [...state.resources]; + }), + createNetwork: (spec: ContainerNetworkSpec) => + Effect.sync(() => { + state.calls.push("create-network"); + const resource: ContainerResource = { + id: id("network"), + name: spec.name, + kind: "network", + labels: spec.labels, + }; + state.resources.push(resource); + return resource; + }), + removeNetwork: (resourceId: string) => + Effect.sync(() => { + state.calls.push(`remove-network:${resourceId}`); + state.resources = state.resources.filter((resource) => resource.id !== resourceId); + }), + createVolume: (spec: ContainerVolumeSpec) => + Effect.sync(() => { + state.calls.push(`create-volume:${spec.name}`); + const resource: ContainerResource = { + id: id("volume"), + name: spec.name, + kind: "volume", + labels: spec.labels, + }; + state.resources.push(resource); + return resource; + }), + removeVolume: (resourceId: string) => + Effect.sync(() => { + state.calls.push(`remove-volume:${resourceId}`); + state.resources = state.resources.filter((resource) => resource.id !== resourceId); + }), + createContainer: (spec: ContainerContainerSpec) => + Effect.sync(() => { + state.calls.push("create-container"); + state.createdSpecs.push(spec); + const resource: ContainerResource = { + id: id("container"), + name: spec.name, + kind: spec.role, + labels: spec.labels, + state: "created", + }; + state.resources.push(resource); + return resource; + }), + startContainer: (resourceId: string) => + Effect.sync(() => { + state.calls.push(`start:${resourceId}`); + const resource = find(resourceId); + if (resource !== undefined) + state.resources = state.resources.map((entry) => + entry.id === resourceId ? { ...entry, state: "running" } : entry, + ); + }), + stopContainer: (resourceId: string) => + Effect.sync(() => { + state.calls.push(`stop:${resourceId}`); + const resource = find(resourceId); + if (resource !== undefined) + state.resources = state.resources.map((entry) => + entry.id === resourceId ? { ...entry, state: "stopped" } : entry, + ); + }), + removeContainer: (resourceId: string) => + Effect.sync(() => { + state.calls.push(`remove:${resourceId}`); + state.resources = state.resources.filter((resource) => resource.id !== resourceId); + }), + }; +}; + +describe("container runtime", () => { + it.live("executes the exact command argv through a bounded process boundary", () => + Effect.gen(function* () { + const runner = yield* makeProcessCommandRunner({ + executable: process.execPath, + baseArgs: [ + "-e", + "if (process.argv[1] !== 'version' || process.argv[2] !== '--format' || process.argv[3] !== '{{json .}}') process.exit(9); process.stdout.write(JSON.stringify({ok:true}))", + ], + }); + const result = yield* runner.run({ args: ["version", "--format", "{{json .}}"] }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"ok":true}'); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("selects explicit engines and only falls back on missing executable", () => + Effect.gen(function* () { + const missing = makeControlledCommandRunner({ + run: () => + Effect.fail( + new ContainerExecutableNotFoundError({ + executable: "docker", + message: "docker executable was not found", + }), + ), + }); + const podman = makeControlledCommandRunner({ + run: () => Effect.succeed(commandResult({ ok: true })), + }); + const selected = yield* selectContainerEngine({ + preference: "auto", + docker: makeDockerEngine({ runner: missing, platform: { os: "linux", desktop: false } }), + podman: makePodmanEngine({ runner: podman, platform: { os: "linux", rootless: true } }), + }); + expect(selected.kind).toBe("podman"); + const daemonFailure = makeControlledCommandRunner({ + run: () => + Effect.fail( + new ContainerEngineProtocolError({ operation: "probe", message: "daemon unavailable" }), + ), + }); + const failure = yield* selectContainerEngine({ + preference: "auto", + docker: makeDockerEngine({ + runner: daemonFailure, + platform: { os: "linux", desktop: false }, + }), + podman: makePodmanEngine({ runner: podman, platform: { os: "linux", rootless: true } }), + }).pipe(Effect.exit); + expect(Exit.isFailure(failure)).toBe(true); + if (Exit.isFailure(failure)) { + const error = Cause.findErrorOption(failure.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) + expect(error.value).not.toBeInstanceOf(ContainerExecutableNotFoundError); + } + }), + ); + + it.live("derives host routes before mutation and rejects unsupported hosts", () => + Effect.gen(function* () { + const calls: ContainerProcessRequest[] = []; + const runner = makeControlledCommandRunner({ + run: (command) => + Effect.sync(() => { + calls.push(command); + return commandResult({ ok: true }); + }), + }); + const desktop = makeDockerEngine({ + runner, + platform: { os: "darwin", desktop: true }, + }); + expect((yield* desktop.preflight).host).toBe("host.docker.internal"); + const linux = makeDockerEngine({ + runner, + platform: { os: "linux", desktop: false }, + }); + expect((yield* linux.preflight).gateway).toBe("host-gateway"); + const podman = makePodmanEngine({ + runner, + platform: { os: "linux", rootless: true }, + }); + expect((yield* podman.preflight).host).toBe("host.containers.internal"); + const unsupported = makeDockerEngine({ + runner, + platform: { os: "linux", desktop: false, remote: true }, + }); + const result = yield* unsupported.preflight.pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + } + expect(calls).toHaveLength(0); + }), + ); + + it.live("runs only container artifacts with exact labels, private backend and RO mounts", () => + Effect.gen(function* () { + const commands: ContainerProcessRequest[] = []; + const runner = makeControlledCommandRunner({ + run: (command) => + Effect.sync(() => { + commands.push(command); + if ( + command.args[0] === "create" || + (command.args[0] === "network" && command.args[1] === "create") + ) + return commandResult(command.args[0] === "create" ? "resource-id" : "network-id"); + if (command.args[0] === "image") return commandResult(""); + if ( + command.args[0] === "ps" || + command.args[0] === "network" || + command.args[0] === "volume" + ) + return commandResult(""); + return commandResult(""); + }), + }); + const engine = makeDockerEngine({ + runner, + platform: { os: "darwin", desktop: true }, + }); + const functionsWorkload = { + ...workload(), + id: "functions:edge-runtime" as const, + capability: "functions" as const, + }; + const runtime = yield* makeContainerRuntime({ + engine, + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + mounts: [{ source: "/functions", target: "/__supabase_functions", readOnly: true }], + }), + }); + const functionsKey = { ...key, workloadId: "functions:edge-runtime" }; + const ready = yield* runtime.start(functionsKey, functionsWorkload); + expect(ready.state).toBe("ready"); + const create = commands.find((entry) => entry.args[0] === "create"); + expect(create).toBeDefined(); + if (create?.args !== undefined) { + expect(create.args).not.toContain("--publish"); + expect(create.args.join(" ")).toContain("dst=/__supabase_functions,ro"); + expect(create.args.join(" ")).not.toContain("secret"); + } + yield* runtime.stop(functionsKey); + yield* runtime.remove(functionsKey); + }), + ); + + it.live("rejects native artifacts before invoking the engine", () => + Effect.gen(function* () { + let called = false; + const runner = makeControlledCommandRunner({ + run: () => + Effect.sync(() => { + called = true; + return commandResult({}); + }), + }); + const runtime = yield* makeContainerRuntime({ + engine: makeDockerEngine({ runner, platform: { os: "darwin", desktop: true } }), + ownerSessionId: "owner-session", + }); + const result = yield* runtime + .start(key, workload({ kind: "native", service: "database", release: "1" })) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(called).toBe(false); + }), + ); + + it.live("rejects malformed inspect output and workload port publication", () => + Effect.gen(function* () { + let calls = 0; + const malformed = makeDockerEngine({ + runner: makeControlledCommandRunner({ + run: () => + Effect.sync(() => { + calls += 1; + return commandResult( + '{"ID":"bad","Names":"bad","Labels":{},"State":"running","extra":true}', + ); + }), + }), + platform: { os: "linux", desktop: false }, + }); + const malformedExit = yield* malformed.listResources(stackId).pipe(Effect.exit); + expect(Exit.isFailure(malformedExit)).toBe(true); + if (Exit.isFailure(malformedExit)) { + const error = Cause.findErrorOption(malformedExit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBeInstanceOf(ContainerEngineProtocolError); + } + const engine = makeDockerEngine({ + runner: makeControlledCommandRunner({ + run: () => + Effect.sync(() => { + calls += 1; + return commandResult(""); + }), + }), + platform: { os: "linux", desktop: false }, + }); + const result = yield* engine + .createContainer({ + name: "backend", + image: "example/backend:1", + labels: { + stackId, + ownerSessionId: "owner-session", + desiredGeneration: 1, + workloadId: "database:database", + specHash: "hash", + role: "workload", + }, + network: "private", + mounts: [], + publications: [{ hostPort: 5432, containerPort: 5432 }], + volumeMounts: [], + role: "workload", + }) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(calls).toBe(1); + }), + ); + + it.live("decodes Docker closed rows and image outcomes", () => + Effect.gen(function* () { + const dockerJsonRow = (values: ReadonlyArray): string => + values.map((value) => JSON.stringify(value)).join("\t"); + const runner = makeControlledCommandRunner({ + run: (request) => + Effect.succeed({ + stdout: + request.args[0] === "image" + ? request.args[2] === "present" + ? '"sha256:present"\n' + : request.args[2] === "malformed" + ? "not-json\n" + : "" + : request.args[0] === "ps" + ? `${dockerJsonRow(["container-id", "backend", stackId, "owner", "7", key.workloadId, key.specHash, "workload", "running"])}\n` + : request.args[0] === "network" && request.args[1] === "create" + ? "created-id\nsecond\n" + : request.args[0] === "network" + ? `${dockerJsonRow(["network-id", "private", stackId, "owner", "7", "network"])}\n` + : request.args[0] === "volume" + ? `${dockerJsonRow(["volume-name", stackId, key.workloadId, "volume"])}\n` + : request.args[0] === "version" + ? '"27.0.0"\n' + : "created-id\n", + stderr: "", + exitCode: request.args[0] === "nonzero" ? 17 : 0, + }), + }); + const engine = makeDockerEngine({ + runner, + platform: { os: "linux", desktop: false }, + }); + yield* engine.probe; + expect((yield* engine.inspectImage("absent")).present).toBe(false); + expect((yield* engine.inspectImage("present")).present).toBe(true); + const malformed = yield* engine.inspectImage("malformed").pipe(Effect.exit); + expect(Exit.isFailure(malformed)).toBe(true); + const resources = yield* engine.listResources(stackId); + expect(resources.map((resource) => resource.kind)).toEqual(["workload", "network", "volume"]); + expect(resources[0]?.labels.role).toBe("workload"); + expect(resources[1]?.labels.role).toBe("network"); + expect(resources[2]?.labels.role).toBe("volume"); + const multiline = yield* engine + .createNetwork({ + name: "private", + labels: { stackId, ownerSessionId: "owner", desiredGeneration: 7, role: "network" }, + }) + .pipe(Effect.exit); + expect(Exit.isFailure(multiline)).toBe(true); + }), + ); + + it.live("keeps Docker and Podman command codecs independent and closed", () => + Effect.gen(function* () { + const gateway = { + name: "gateway", + image: "example/gateway:1", + labels: { + stackId, + ownerSessionId: "owner", + desiredGeneration: 7, + workloadId: "gateway", + specHash: key.specHash, + role: "gateway" as const, + }, + network: "private", + mounts: [], + volumeMounts: [], + publications: [{ hostPort: 54321, containerPort: 8000 }], + hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + role: "gateway" as const, + }; + const docker = serializeDockerCommand({ operation: "create-container", spec: gateway }); + expect(docker.args).toContain("--add-host"); + expect(docker.args).toContain("host.docker.internal:host-gateway"); + expect(docker.args).toContain("--publish"); + const podman = serializePodmanCommand({ operation: "inspect-networks", stackId }); + expect(podman.args.join(" ")).toContain("{{index .Labels"); + expect(podman.args.join(" ")).not.toContain("host-gateway"); + const unsupported = yield* makePodmanEngine({ + runner: makeControlledCommandRunner({ run: () => Effect.succeed(commandResult("ok")) }), + platform: { os: "darwin" }, + }).preflight.pipe(Effect.exit); + expect(Exit.isFailure(unsupported)).toBe(true); + + const rowRunner = makeControlledCommandRunner({ + run: (request) => + Effect.succeed({ + stdout: + request.args[0] === "version" + ? "5.0.0\n" + : request.args[0] === "image" + ? request.args[2] === "present" + ? "sha256:present\n" + : request.args[2] === "malformed" + ? "bad\trow\n" + : "" + : request.args[0] === "ps" + ? `container-id\tbackend\t${stackId}\towner\t7\t${key.workloadId}\t${key.specHash}\tworkload\trunning\n` + : request.args[0] === "network" + ? `network-id\tprivate\t${stackId}\towner\t7\tnetwork\n` + : request.args[0] === "volume" + ? `volume-name\t${stackId}\t${key.workloadId}\tvolume\n` + : "created-id\n", + stderr: "", + exitCode: 0, + }), + }); + const podmanEngine = makePodmanEngine({ + runner: rowRunner, + platform: { os: "linux", rootless: true }, + }); + yield* podmanEngine.probe; + expect((yield* podmanEngine.inspectImage("absent")).present).toBe(false); + expect((yield* podmanEngine.inspectImage("present")).present).toBe(true); + const malformedImage = yield* podmanEngine.inspectImage("malformed").pipe(Effect.exit); + expect(Exit.isFailure(malformedImage)).toBe(true); + const podmanResources = yield* podmanEngine.listResources(stackId); + expect(podmanResources.map((resource) => resource.kind)).toEqual([ + "workload", + "network", + "volume", + ]); + }), + ); + + it.live("fails process output overflow and nonzero commands with typed failures", () => + Effect.gen(function* () { + const runner = yield* makeProcessCommandRunner({ + executable: process.execPath, + baseArgs: ["-e", "process.stdout.write('123456'); process.stderr.write('abcdef')"], + maxOutputBytes: 4, + }); + const overflow = yield* runner.run({ args: ["probe"] }).pipe(Effect.exit); + expect(Exit.isFailure(overflow)).toBe(true); + const nonzero = makeControlledCommandRunner({ + run: () => Effect.succeed({ stdout: "", stderr: "daemon unavailable", exitCode: 17 }), + }); + const engine = makeDockerEngine({ runner: nonzero, platform: { os: "linux" } }); + const failure = yield* engine.probe.pipe(Effect.exit); + expect(Exit.isFailure(failure)).toBe(true); + if (Exit.isFailure(failure)) { + const error = Cause.findErrorOption(failure.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBeInstanceOf(ContainerCommandError); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "fails fast on a noisy process and force-reaps one that ignores SIGTERM", + () => + Effect.gen(function* () { + const runner = yield* makeProcessCommandRunner({ + executable: process.execPath, + baseArgs: [ + "-e", + "process.on('SIGTERM', () => {}); process.stdout.write('x'.repeat(1024 * 1024)); setInterval(() => {}, 1000)", + ], + maxOutputBytes: 16, + }); + const result = yield* runner + .run({ args: ["probe"] }) + .pipe(Effect.timeout("8 seconds"), Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) + expect(error.value).toBeInstanceOf(ContainerEngineProtocolError); + } + }).pipe(Effect.provide(NodeServices.layer)), + 10_000, + ); + + it.live("adopts an exact running container and replaces only same-owner stale state", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + }); + yield* runtime.start(key, workload()); + state.calls.length = 0; + yield* runtime.start(key, workload()); + expect(state.calls).not.toContain("inspect-image"); + expect(state.calls).not.toContain("create-container"); + expect(state.calls.some((call) => call.startsWith("start:"))).toBe(false); + + const staleKey = { ...key, specHash: "hash-new" }; + state.calls.length = 0; + yield* runtime.start(staleKey, workload({ ...containerArtifact })); + const stopIndex = state.calls.findIndex((call) => call.startsWith("stop:")); + const removeIndex = state.calls.findIndex((call) => call.startsWith("remove:")); + const createIndex = state.calls.indexOf("create-container"); + expect(stopIndex).toBeGreaterThanOrEqual(0); + expect(removeIndex).toBeGreaterThan(stopIndex); + expect(createIndex).toBeGreaterThan(removeIndex); + }), + ); + + it.live("leaves foreign and sanitized-name collisions untouched", () => + Effect.gen(function* () { + const foreignKey = { ...key, workloadId: "api:api" }; + const foreignName = `supabase-${stackId.slice(0, 16)}-${foreignKey.desiredGeneration}-api-api-workload`; + const state: FakeContainerState = { + resources: [ + { + id: "foreign-container", + name: foreignName, + kind: "workload", + state: "running", + labels: { + stackId, + ownerSessionId: "other-session", + desiredGeneration: foreignKey.desiredGeneration, + workloadId: foreignKey.workloadId, + specHash: foreignKey.specHash, + role: "workload", + }, + }, + ], + imagePresent: false, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + }); + const foreign = yield* runtime.start(foreignKey, workload()).pipe(Effect.exit); + expect(Exit.isFailure(foreign)).toBe(true); + expect(state.calls).not.toContain("pull-image"); + expect(state.calls).not.toContain("create-container"); + expect(state.resources[0]?.id).toBe("foreign-container"); + + const firstKey = { ...key, workloadId: "foo/bar", specHash: "first" }; + const secondKey = { ...key, workloadId: "foo?bar", specHash: "second" }; + state.resources = []; + state.calls.length = 0; + yield* runtime.start(firstKey, workload()); + state.calls.length = 0; + const sanitized = yield* runtime.start(secondKey, workload()).pipe(Effect.exit); + expect(Exit.isFailure(sanitized)).toBe(true); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(false); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(false); + expect(state.resources.filter((resource) => resource.kind === "workload")).toHaveLength(1); + }), + ); + + it.live( + "derives volume mounts, preserves volumes on remove, and validates mappings before mutation", + () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const volume = { target: "/var/lib/postgresql/data", readOnly: false }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => Effect.succeed({ volume }), + }); + yield* runtime.start(key, workload()); + const physicalVolumeName = `supabase-${key.stackId}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; + expect(state.createdSpecs[0]?.volumeMounts).toEqual([ + { volume: physicalVolumeName, target: volume.target, readOnly: false }, + ]); + expect(state.resources.some((resource) => resource.kind === "volume")).toBe(true); + yield* runtime.remove(key); + expect(state.resources.some((resource) => resource.kind === "volume")).toBe(true); + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + + const otherStackKey = { + ...key, + stackId: StackIdSchema.make("b".repeat(64)), + }; + yield* runtime.start(otherStackKey, workload()); + expect(state.resources.filter((resource) => resource.kind === "volume")).toHaveLength(2); + state.calls.length = 0; + yield* runtime.start(key, workload()); + expect(state.calls).not.toContain(`create-volume:${physicalVolumeName}`); + + const invalidState: FakeContainerState = { + resources: [], + imagePresent: false, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const invalidRuntime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(invalidState), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ volume: { target: "", readOnly: volume.readOnly } }), + }); + const invalid = yield* invalidRuntime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(invalid)).toBe(true); + expect(invalidState.calls).not.toContain("inspect-image"); + expect(invalidState.calls).not.toContain("create-network"); + }), + ); + + it.live( + "cleans up a newly created container when readiness fails and keeps the original cause", + () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const readinessError = new RuntimeDriverError({ + message: "database did not become ready", + stackId: key.stackId, + workloadId: key.workloadId, + }); + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ waitForReadiness: () => Effect.fail(readinessError) }), + }); + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBe(readinessError); + } + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + expect(state.resources.some((resource) => resource.kind === "network")).toBe(true); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); + }), + ); + + it.live("serializes concurrent starts and does not mutate when image inspection fails", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: false, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + }); + yield* Effect.all([runtime.start(key, workload()), runtime.start(key, workload())], { + concurrency: 2, + }); + expect(state.calls.filter((call) => call === "create-network")).toHaveLength(1); + expect(state.calls.filter((call) => call === "create-container")).toHaveLength(1); + + const failedState: FakeContainerState = { + resources: [], + imagePresent: false, + calls: [], + createdSpecs: [], + nextId: 1, + inspectImageFailure: new ContainerCommandError({ + operation: "inspect-image", + message: "registry unavailable", + }), + }; + const failedRuntime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(failedState), + ownerSessionId: "owner-session", + }); + const failed = yield* failedRuntime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(failedState.calls).not.toContain("pull-image"); + expect(failedState.calls).not.toContain("create-network"); + expect(failedState.calls).not.toContain("create-container"); + }), + ); +}); diff --git a/packages/stack/src/runtime/native-launcher.ts b/packages/stack/src/runtime/native-launcher.ts index 330bf2055d..525d0ef0ca 100644 --- a/packages/stack/src/runtime/native-launcher.ts +++ b/packages/stack/src/runtime/native-launcher.ts @@ -1,7 +1,7 @@ // Standalone launcher boundary: this process must remain usable before the // Effect runtime exists and therefore talks to the host directly. // oxlint-disable-next-line effecttsgo/node-builtin-import -import { createReadStream } from "node:fs"; +import { Socket } from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { spawn } from "node:child_process"; @@ -69,7 +69,7 @@ const terminateGroup = (signal: NodeJS.Signals): void => { // Register the owner pipe before waiting for the launch payload. EOF means // the owner process disappeared without running a normal scope finalizer. -const ownerPipe = createReadStream("", { fd: 3, autoClose: false }); +const ownerPipe = new Socket({ fd: 3, readable: true, writable: false }); // Owner-loss is an abrupt supervisor crash, not an explicit graceful stop. // Use guaranteed tree termination so descendants that trap SIGTERM cannot be // orphaned after the owner pipe closes. @@ -77,7 +77,7 @@ ownerPipe.on("end", () => terminateGroup("SIGKILL")); ownerPipe.on("error", () => terminateGroup("SIGKILL")); ownerPipe.resume(); -const payloadPipe = createReadStream("", { fd: 4, autoClose: true }); +const payloadPipe = new Socket({ fd: 4, readable: true, writable: false }); const payload: Buffer[] = []; payloadPipe.on("data", (chunk: Buffer) => payload.push(chunk)); payloadPipe.on("error", () => process.exit(127)); diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 7765f4d09b..a1f6ae19e8 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -55,6 +55,31 @@ const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); describe("native runtime", () => { + it.live("rejects container artifacts before invoking native process resolution", () => + withPlatform( + Effect.gen(function* () { + let resolved = false; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.sync(() => { + resolved = true; + return fixtureProcess("unexpected"); + }), + waitForReadiness: () => Effect.void, + }); + const containerSelected = workload("container"); + const result = yield* runtime + .start(keyFor("container"), { + ...containerSelected, + selected: containerSelected.artifacts.container, + }) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(resolved).toBe(false); + }), + ), + ); + it.live("starts, captures redacted output, and terminates exact workloads", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index d41873641e..e85de0ef58 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -27,7 +27,13 @@ import { readOwnerMetadata, StackRuntimeEnvironment } from "../state/Ownership.t import { makeControlClient } from "../control/ControlServer.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; -import { StackUpgradeRequiredError } from "../public/Errors.ts"; +import { + ContainerEngineError, + StackRuntimeMismatchError, + StackUpgradeRequiredError, +} from "../public/Errors.ts"; +import { ContainerEngineProtocolError } from "../runtime/ContainerEngine.ts"; +import { ContainerEngineResolver } from "../runtime/ContainerEngineResolver.ts"; const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => Effect.scoped( @@ -98,6 +104,204 @@ const quoteModuleSpecifier = (value: string): string => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; describe("managed stack handles", () => { + it.live("resolves a new automatic container identity and persists Docker", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const calls: string[] = []; + const resolver = { + resolve: (preference: "auto" | "docker" | "podman") => + Effect.sync(() => { + calls.push(preference); + return "docker" as const; + }), + }; + const stack = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "auto" }, + }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); + expect(calls).toEqual(["auto"]); + expect( + (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, + ).toEqual({ kind: "container", engine: "docker" }); + yield* stack.close(); + }), + ), + ); + + it.live("selects and persists Podman when the automatic resolver falls back", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const calls: string[] = []; + const resolver = { + resolve: (preference: "auto" | "docker" | "podman") => + Effect.sync(() => { + calls.push(preference); + return "podman" as const; + }), + }; + const stack = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "auto" }, + }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); + expect(calls).toEqual(["auto"]); + expect( + (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, + ).toEqual({ kind: "container", engine: "podman" }); + yield* stack.close(); + }), + ), + ); + + it.live("maps an automatic engine daemon failure and does not fall through in createStack", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const calls: string[] = []; + const resolver = { + resolve: (preference: "auto" | "docker" | "podman") => + Effect.sync(() => { + calls.push(preference); + }).pipe( + Effect.andThen( + Effect.fail( + new ContainerEngineProtocolError({ + operation: "probe", + message: "Docker daemon unavailable", + }), + ), + ), + ), + }; + const result = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "auto" }, + }).pipe(Effect.provideService(ContainerEngineResolver, resolver), Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(error).toBeInstanceOf(ContainerEngineError); + } + expect(calls).toEqual(["auto"]); + }), + ), + ); + + it.live("probes only explicitly requested Podman", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const calls: string[] = []; + const resolver = { + resolve: (preference: "auto" | "docker" | "podman") => + Effect.sync(() => { + calls.push(preference); + return "podman" as const; + }), + }; + const stack = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "podman" }, + }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); + expect(calls).toEqual(["podman"]); + yield* stack.close(); + }), + ), + ); + + it.live("reuses a persisted engine for automatic create without probing again", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const firstCalls: string[] = []; + const firstResolver = { + resolve: (preference: "auto" | "docker" | "podman") => + Effect.sync(() => { + firstCalls.push(preference); + return "podman" as const; + }), + }; + const first = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "podman" }, + }).pipe(Effect.provideService(ContainerEngineResolver, firstResolver)); + yield* first.close(); + const secondCalls: string[] = []; + const secondResolver = { + resolve: (preference: "auto" | "docker" | "podman") => + Effect.sync(() => { + secondCalls.push(preference); + return "docker" as const; + }), + }; + const second = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "auto" }, + }).pipe(Effect.provideService(ContainerEngineResolver, secondResolver)); + expect(firstCalls).toEqual(["podman"]); + expect(secondCalls).toEqual([]); + expect( + (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, + ).toEqual({ kind: "container", engine: "podman" }); + yield* second.close(); + }), + ), + ); + + it.live("rejects a conflicting explicit engine before probing", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const first = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "docker" }, + }).pipe( + Effect.provideService(ContainerEngineResolver, { + resolve: () => Effect.succeed("docker" as const), + }), + ); + yield* first.close(); + const calls: string[] = []; + const result = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "podman" }, + }).pipe( + Effect.provideService(ContainerEngineResolver, { + resolve: (preference: "auto" | "docker" | "podman") => + Effect.sync(() => { + calls.push(preference); + return "podman" as const; + }), + }), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(error).toBeInstanceOf(StackRuntimeMismatchError); + } + expect(calls).toEqual([]); + }), + ), + ); + + it.live("does not probe an explicitly native identity", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const calls: string[] = []; + const stack = yield* createStack({ + projectRoot: project, + runtime: { kind: "native" }, + }).pipe( + Effect.provideService(ContainerEngineResolver, { + resolve: (preference: "auto" | "docker" | "podman") => + Effect.sync(() => { + calls.push(preference); + return "docker" as const; + }), + }), + ); + expect(calls).toEqual([]); + yield* stack.close(); + }), + ), + ); + it.live("creates an unconfigured stack without reading config or starting workloads", () => withRuntimeRoot((project) => Effect.gen(function* () { From 76d3b70bc8ade0f1d29ec2d1e774d4fd6557c28b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 05:52:01 +0200 Subject: [PATCH 019/174] feat(stack): complete managed lifecycle and recovery --- ...26-08-28-stack-runtime-rewrite-progress.md | 35 + .../plans/2026-08-28-stack-runtime-rewrite.md | 6 +- packages/stack/src/control/ControlServer.ts | 2 +- .../stack/src/entrypoints/supervisor-node.ts | 2 +- packages/stack/src/model/Compiler.ts | 14 + packages/stack/src/public/EffectStack.ts | 41 +- .../stack/src/runtime/ContainerRuntime.ts | 203 ++++- packages/stack/src/runtime/NativeRuntime.ts | 146 +++- packages/stack/src/runtime/RuntimeDriver.ts | 30 +- .../container-runtime.integration.test.ts | 9 +- .../runtime-recovery.integration.test.ts | 503 ++++++++++++ packages/stack/src/supervisor/Lifecycle.ts | 521 ++++++++++++ packages/stack/src/supervisor/Supervisor.ts | 771 ++++++++++++++---- .../supervisor/lifecycle.integration.test.ts | 385 +++++++++ .../supervisor/reconciler.integration.test.ts | 14 + .../supervisor/supervisor.integration.test.ts | 265 ++++++ 16 files changed, 2699 insertions(+), 248 deletions(-) create mode 100644 packages/stack/src/runtime/runtime-recovery.integration.test.ts create mode 100644 packages/stack/src/supervisor/Lifecycle.ts create mode 100644 packages/stack/src/supervisor/lifecycle.integration.test.ts create mode 100644 packages/stack/src/supervisor/supervisor.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 21e7f6e37a..582e9440d5 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -498,3 +498,38 @@ Implementation continues after recording a ruling; this file is not a question q across 3 files). - Generic and Effect lint over all Task 10 sources/tests, formatting over sources/tests/docs, and `git diff --check` — passed. + +### Task 11 — 2026-08-29 + +- Added one Effect-native lifecycle controller over durable state and an injected runtime backend. + Start materializes and commits complete intent before reconciliation, reuses omitted configured + input, treats identical running input as reconciliation, and rejects changed running input with + explicit restart guidance. Stop retains durable state and reruns idempotent cleanup after a failed + attempt; restart is generation-fenced so a concurrent stop wins; destroy retains the destroying + fence until exact runtime/data cleanup succeeds and can be retried. +- Added running-only runtime recovery and exact stopped cleanup. Container recovery adopts only one + exact current-generation workload/network, removes stale and duplicate owned resources plus every + stale gateway, retains persistent volumes and foreign stacks, and never starts missing work. + Native recovery similarly adopts only exact local resources without starting them. Stopped and + unconfigured recovery use cleanup, so no current-generation ephemeral network is retained. +- Composed recovery, lifecycle, reconciliation, lazy activation, status, status watching, and logs + in the Supervisor. Recovery completes before owner readiness; a persisted running intent recovers + exact resources but reports the new owner stopped and never auto-starts after reboot. Lazy + capabilities remain dormant until explicit activation, then activate their dependency closure and + remain active for the generation. Actual phase is independent from durable desired intent, and + complete status snapshots are published only at observable transitions. +- Maintenance quiesce releases ownership only after successful cleanup. Public Effect handles pass + log options and expose scoped status/log streams. Expected lifecycle/runtime failures retain their + exact RPC error tags instead of collapsing into a generic reconciliation error. +- The owner entrypoint intentionally has no concrete runtime factory yet. Its unavailable default + makes a production start fail closed; Task 13 must wire the exhaustive native/container artifact, + route, preparation, activation, and log-store catalogs before end-to-end stack startup can work. + Credentials and prepare remain explicit unavailable stubs for the same later composition slice. +- Review RED/GREEN closed the stopped-recovery network ambiguity by making `recover` accept only a + running desired lifecycle; stopped/unconfigured states must call `cleanup`. Independent review + found no further Critical or Important lifecycle, recovery, lazy-activation, status/log, RPC, or + quiesce defect, and separately identified the deliberately deferred production runtime wiring + above. +- Focused lifecycle/runtime/reconciliation/Supervisor/handle/control verification passed (90 tests + across 8 integration files), followed by the stack type-check. Generic and Effect lint over all 14 + changed TypeScript files, formatting, and `git diff --check` passed. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index e0da2416d7..1f44263632 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -567,20 +567,20 @@ artifact keys, state schemas, drivers, or control protocols. - `restart` preflights, quiesces through stable maintenance, may apply allowed stopped-time changes, and loses to concurrent stop. Destroy removes exact data; stop retains state/data/logs/artifacts. -- [ ] **Step 1: Write the complete lifecycle matrix and verify RED** +- [x] **Step 1: Write the complete lifecycle matrix and verify RED** Cover identical/changed fingerprints, pass-through-only changes, version rules, initialized database immutability, concurrent calls, stop winning restart, owner replacement failure, reboot no-autostart, native owner loss, container fail-closed, exact adoption fences, corrupt state, and explicit exact destructive cleanup. -- [ ] **Step 2: Implement durable transitions** +- [x] **Step 2: Implement durable transitions** Commit validated complete intent before reconciliation. Use stable maintenance only for stop/quiesce; protocol mismatch is an upgrade signal. Keep replacement interruptible outside the narrow acquisition-to-registration mask and preserve unrecognized Causes. -- [ ] **Step 3: Verify and commit** +- [x] **Step 3: Verify and commit** Run both targeted lifecycle files and stack type-check, then commit: diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 2cd136a325..36911a1382 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -170,7 +170,7 @@ const demuxSocket = ( yield* sendJson(response); yield* close; yield* markPrefaceReady; - if (options.onMaintenanceComplete !== undefined) { + if (response.ok && options.onMaintenanceComplete !== undefined) { // The connection scope closes as soon as the close frame is sent; // completion belongs to the owner session and must outlive it. yield* Effect.forkDetach(options.onMaintenanceComplete(request.value.op)); diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index 42f84860ec..ebba6c1738 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -103,7 +103,7 @@ export const runSupervisor = (args: SupervisorArgs) => }); const released = yield* Deferred.make(); const context = yield* Effect.context(); - const supervisor = makeSupervisor({ + const supervisor = yield* makeSupervisor({ identity: args.identity, stackId: args.stackId, ownerSessionId, diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index b6c7f93985..12cb5d5d6c 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -520,6 +520,20 @@ const planForDefinition = ( ); }; +/** Rebuilds the private execution plan from a persisted, fully materialized definition. */ +export const rebuildExecutionPlan = ( + runtime: StackRuntime, + definition: StackDefinition, +): Effect.Effect< + ExecutionPlan, + InvalidStackConfigError | StackVersionUnsupportedError, + Crypto.Crypto +> => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return yield* planForDefinition(runtime, definition, crypto); + }); + const collectSuppliedSecrets = (config: StackConfig, slots: SecretSlotInput[]): void => { const capabilities = config.capabilities; for (const name of CAPABILITY_NAMES) { diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index ccdbe404d1..9fdc5911f3 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -381,7 +381,7 @@ const makeHandle = ( ownerSessionId: metadata.ownerSessionId, rpcRelease: metadata.rpcRelease, }); - const check = (effect: Effect.Effect) => + const check = (effect: Effect.Effect) => Ref.get(closed).pipe(Effect.flatMap((isClosed) => (isClosed ? Effect.interrupt : effect))); const invoke = ( call: (rpc: StackRpcClient) => Effect.Effect, @@ -412,9 +412,42 @@ const makeHandle = ( ), destroy: () => invoke((rpc) => rpc.destroy(undefined), destroyError), close: () => Ref.set(closed, true), - watchStatus: () => Stream.fromEffect(status()), - logs: () => - Stream.fail(new StackNotRunningError({ message: "Stack logs are not available yet" })), + watchStatus: () => + Stream.unwrap( + check( + client.rpc.pipe( + Effect.map((rpc) => rpc.watchStatus(undefined).pipe(Stream.mapError(statusError))), + Effect.mapError(statusError), + ), + ), + ).pipe(Stream.scoped), + logs: (options) => + Stream.unwrap( + check( + client.rpc.pipe( + Effect.map((rpc) => + rpc.logs(options ?? {}).pipe( + Stream.mapError((error) => { + const mapped = errorForRpc(error); + return mapped instanceof StackNotFoundError || + mapped instanceof StackNotRunningError || + mapped instanceof StackStateInvalidError + ? mapped + : new StackStateInvalidError({ message: "Stack logs are unavailable" }); + }), + ), + ), + Effect.mapError((error) => { + const mapped = errorForRpc(error); + return mapped instanceof StackNotFoundError || + mapped instanceof StackNotRunningError || + mapped instanceof StackStateInvalidError + ? mapped + : new StackStateInvalidError({ message: "Stack logs are unavailable" }); + }), + ), + ), + ).pipe(Stream.scoped), } satisfies EffectStack; }); diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index d036a0abff..176e5b1966 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -18,6 +18,8 @@ import { } from "./ContainerEngine.ts"; import { RuntimeDriverError, + type RuntimeCleanupRequest, + type RuntimeRecoveryRequest, type ObservedWorkload, type RuntimeDriver, type RuntimeWorkloadKey, @@ -118,6 +120,20 @@ const sameLabels = (left: ContainerLabels, right: ContainerLabels): boolean => "specHash" in right && left.specHash === right.specHash))); +/** Recovery identity deliberately excludes ownerSessionId. The ownership lock fences adoption. */ +const sameWorkloadIdentity = (left: ContainerLabels, right: ContainerWorkloadLabels): boolean => + left.role === "workload" && + left.stackId === right.stackId && + left.desiredGeneration === right.desiredGeneration && + left.workloadId === right.workloadId && + left.specHash === right.specHash; + +/** Networks are adopted by stack and desired generation, never by old owner session. */ +const sameNetworkIdentity = (left: ContainerLabels, right: ContainerNetworkLabels): boolean => + left.role === "network" && + left.stackId === right.stackId && + left.desiredGeneration === right.desiredGeneration; + const toDriverError = ( key: Pick, error: unknown, @@ -169,11 +185,7 @@ export const makeContainerRuntime = ( Effect.map((entries) => entries .filter(isWorkloadResource) - .filter( - (entry) => - entry.labels.ownerSessionId === options.ownerSessionId && - entry.labels.stackId === stackId, - ) + .filter((entry) => entry.labels.stackId === stackId) .map((entry) => ({ stackId: entry.labels.stackId, desiredGeneration: entry.labels.desiredGeneration, @@ -238,7 +250,7 @@ export const makeContainerRuntime = ( } } const exact = entries.find( - (entry) => isManagedContainer(entry) && sameLabels(entry.labels, labels), + (entry) => entry.kind === "workload" && sameWorkloadIdentity(entry.labels, labels), ); const namedCollision = entries.find( (entry) => @@ -254,7 +266,6 @@ export const makeContainerRuntime = ( namedCollision !== undefined && (collision === undefined || collision.labels.stackId !== key.stackId || - collision.labels.ownerSessionId !== options.ownerSessionId || collision.labels.workloadId !== key.workloadId || collision.labels.role !== "workload") ) @@ -265,7 +276,7 @@ export const makeContainerRuntime = ( const exactNetwork = entries .filter(isNetworkResource) - .find((entry) => sameLabels(entry.labels, networkLabels)); + .find((entry) => sameNetworkIdentity(entry.labels, networkLabels)); const namedNetworkCollision = entries.find( (entry) => entry.kind === "network" && entry.name === networkName, ); @@ -276,9 +287,7 @@ export const makeContainerRuntime = ( if ( exactNetwork === undefined && namedNetworkCollision !== undefined && - (networkCollision === undefined || - networkCollision.labels.stackId !== key.stackId || - networkCollision.labels.ownerSessionId !== options.ownerSessionId) + (networkCollision === undefined || networkCollision.labels.stackId !== key.stackId) ) return yield* toDriverError( key, @@ -394,10 +403,11 @@ export const makeContainerRuntime = ( Effect.flatMap((entries) => { const exact = entries .filter(isWorkloadResource) - .find( - (entry) => - entry.labels.ownerSessionId === options.ownerSessionId && - sameLabels(entry.labels, workloadLabelsFor(key, options.ownerSessionId)), + .find((entry) => + sameWorkloadIdentity( + entry.labels, + workloadLabelsFor(key, options.ownerSessionId), + ), ); return exact === undefined ? Effect.void @@ -426,7 +436,7 @@ export const makeContainerRuntime = ( const exact = entries .filter(isWorkloadResource) .find((entry) => - sameLabels(entry.labels, workloadLabelsFor(key, options.ownerSessionId)), + sameWorkloadIdentity(entry.labels, workloadLabelsFor(key, options.ownerSessionId)), ); if (exact === undefined) return; if (exact.state === "running") @@ -435,5 +445,164 @@ export const makeContainerRuntime = ( }), ); - return { observe, start, stop, remove } satisfies RuntimeDriver; + const cleanup = (request: RuntimeCleanupRequest): Effect.Effect => + lifecycle.withPermit( + Effect.gen(function* () { + const entries = yield* withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.listResources(request.stackId), + ); + const owned = entries + .filter((entry) => entry.labels.stackId === request.stackId) + .sort((left, right) => left.id.localeCompare(right.id)); + let cleanupCause: Cause.Cause = Cause.empty; + const attempt = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* Effect.exit(effect); + if (Exit.isFailure(result)) cleanupCause = Cause.combine(cleanupCause, result.cause); + }); + + // Stop and remove every stack workload/gateway before touching its network. A failed + // stop does not prevent the remove attempt; all failures are returned together. + for (const entry of owned.filter(isManagedContainer)) { + if (entry.state === "running") + yield* attempt( + withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.stopContainer(entry.id), + ), + ); + yield* attempt( + withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.removeContainer(entry.id), + ), + ); + } + for (const entry of owned.filter(isNetworkResource)) + yield* attempt( + withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.removeNetwork(entry.id), + ), + ); + if (request.destroy) + for (const entry of owned.filter((candidate) => candidate.kind === "volume")) + yield* attempt( + withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.removeVolume(entry.id), + ), + ); + + for (const id of resources.keys()) + if (id.startsWith(`${request.stackId}:`)) resources.delete(id); + if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); + }), + ); + + const recover = ( + request: RuntimeRecoveryRequest, + ): Effect.Effect, RuntimeDriverError> => + lifecycle.withPermit( + Effect.gen(function* () { + const entries = yield* withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.listResources(request.stackId), + ); + const owned = entries + .filter((entry) => entry.labels.stackId === request.stackId) + .sort((left, right) => left.id.localeCompare(right.id)); + const desiredHashes = new Map( + request.plan.workloads.map((workload) => [workload.id, workload.specHash]), + ); + const adopted: Array = []; + const adoptedIdentities = new Set(); + let retainedNetworkId: string | undefined; + let recoveryCause: Cause.Cause = Cause.empty; + const attempt = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* Effect.exit(effect); + if (Exit.isFailure(result)) + recoveryCause = Cause.combine(recoveryCause, result.cause); + }); + const stopAndRemove = (entry: ContainerResource) => + Effect.gen(function* () { + if (entry.state === "running") + yield* attempt( + withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.stopContainer(entry.id), + ), + ); + yield* attempt( + withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.removeContainer(entry.id), + ), + ); + }); + + // Recovery never starts or creates anything. Every gateway is removed first so a new + // owner cannot expose stale ingress while workload identities are being evaluated. + for (const entry of owned.filter(isManagedContainer)) { + if (entry.kind === "gateway") { + yield* stopAndRemove(entry); + continue; + } + const hash = desiredHashes.get(entry.labels.workloadId); + const current = + entry.labels.desiredGeneration === request.desiredGeneration && + hash !== undefined && + hash === entry.labels.specHash; + if (!current) { + yield* stopAndRemove(entry); + continue; + } + const identity = `${entry.labels.desiredGeneration}:${entry.labels.workloadId}:${entry.labels.specHash}`; + if (adoptedIdentities.has(identity)) { + yield* stopAndRemove(entry); + continue; + } + adoptedIdentities.add(identity); + adopted.push({ + stackId: entry.labels.stackId, + desiredGeneration: entry.labels.desiredGeneration, + workloadId: entry.labels.workloadId, + specHash: entry.labels.specHash, + state: + entry.state === "running" + ? "ready" + : entry.state === "stopped" + ? "stopped" + : "starting", + }); + } + + // Networks are only considered after all containers and gateways. Keep one network for + // the exact current generation; duplicate or stale-generation networks are ephemera. + for (const entry of owned.filter(isNetworkResource)) { + if ( + retainedNetworkId === undefined && + entry.labels.desiredGeneration === request.desiredGeneration + ) { + retainedNetworkId = entry.id; + continue; + } + yield* attempt( + withEngine( + { stackId: request.stackId, workloadId: "" }, + options.engine.removeNetwork(entry.id), + ), + ); + } + + for (const id of resources.keys()) + if (id.startsWith(`${request.stackId}:`)) resources.delete(id); + if (recoveryCause.reasons.length > 0) return yield* Effect.failCause(recoveryCause); + return adopted; + }), + ); + + return { observe, start, stop, remove, cleanup, recover } satisfies RuntimeDriver; }); diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index 38eb550d5f..dce5905db7 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -10,6 +10,8 @@ import type { LogStore } from "../supervisor/LogStore.ts"; import { LogStoreError } from "../supervisor/LogStore.ts"; import { RuntimeDriverError, + type RuntimeCleanupRequest, + type RuntimeRecoveryRequest, type ObservedWorkload, type RuntimeDriver, type RuntimeWorkloadKey, @@ -387,6 +389,53 @@ export const makeNativeRuntime = ( (resource) => Ref.get(resource.state), ); + const stopResource = (resource: Resource): Effect.Effect => + Effect.gen(function* () { + const { key } = resource; + resource.stopRequested = true; + if (resource.startFiber !== undefined) { + yield* Deferred.fail( + resource.result, + driverError(key, "Native workload was stopped while starting"), + ); + } + const nativeProcess = resource.process; + const killResult = + nativeProcess === undefined + ? Exit.succeed(undefined) + : yield* nativeProcess.isRunning.pipe( + Effect.mapError((error) => processError(error, key, "probe")), + Effect.flatMap((running) => + running + ? nativeProcess.kill.pipe( + Effect.mapError((error) => processError(error, key, "stop")), + ) + : Effect.void, + ), + Effect.exit, + ); + if (resource.startFiber !== undefined) yield* Fiber.interrupt(resource.startFiber); + if (Exit.isFailure(killResult)) { + yield* Ref.update(resource.state, (current): ObservedWorkload => ({ + ...current, + state: "failed", + error: Cause.pretty(killResult.cause), + })); + return yield* Effect.failCause(killResult.cause); + } + yield* Ref.update(resource.state, (current): ObservedWorkload => ({ + ...current, + state: "stopped", + })); + }); + + const removeResource = (resource: Resource): Effect.Effect => + Effect.gen(function* () { + resource.stopRequested = true; + if (resource.startFiber !== undefined) yield* Fiber.interrupt(resource.startFiber); + yield* cleanup(resource); + }); + const stop = (key: RuntimeWorkloadKey): Effect.Effect => lifecycle.withPermit( Effect.gen(function* () { @@ -394,43 +443,7 @@ export const makeNativeRuntime = ( if (resource === undefined) return; if (!sameKey(resource.key, key)) return yield* driverError(key, "Native workload identity mismatch"); - resource.stopRequested = true; - if (resource.startFiber !== undefined) { - yield* Deferred.fail( - resource.result, - driverError(key, "Native workload was stopped while starting"), - ); - } - const nativeProcess = resource.process; - const killResult = - nativeProcess === undefined - ? Exit.succeed(undefined) - : yield* nativeProcess.isRunning.pipe( - Effect.mapError((error) => processError(error, key, "probe")), - Effect.flatMap((running) => - running - ? nativeProcess.kill.pipe( - Effect.mapError((error) => processError(error, key, "stop")), - ) - : Effect.void, - ), - Effect.exit, - ); - if (resource.startFiber !== undefined) { - yield* Fiber.interrupt(resource.startFiber); - } - if (Exit.isFailure(killResult)) { - yield* Ref.update(resource.state, (current): ObservedWorkload => ({ - ...current, - state: "failed", - error: Cause.pretty(killResult.cause), - })); - return yield* Effect.failCause(killResult.cause); - } - yield* Ref.update(resource.state, (current): ObservedWorkload => ({ - ...current, - state: "stopped", - })); + yield* stopResource(resource); }), ); @@ -441,15 +454,64 @@ export const makeNativeRuntime = ( if (resource === undefined) return; if (!sameKey(resource.key, key)) return yield* driverError(key, "Native workload identity mismatch"); - resource.stopRequested = true; - if (resource.startFiber !== undefined) { - yield* Fiber.interrupt(resource.startFiber); + yield* removeResource(resource); + }), + ); + + const cleanupRuntime = ( + request: RuntimeCleanupRequest, + ): Effect.Effect => + lifecycle.withPermit( + Effect.gen(function* () { + let cleanupCause: Cause.Cause = Cause.empty; + const attempt = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* Effect.exit(effect); + if (Exit.isFailure(result)) cleanupCause = Cause.combine(cleanupCause, result.cause); + }); + const owned = [...resources.values()].filter( + (resource) => resource.key.stackId === request.stackId, + ); + for (const resource of owned) { + yield* attempt(stopResource(resource)); + yield* attempt(removeResource(resource)); } - yield* cleanup(resource); + if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); + }), + ); + + const recover = ( + request: RuntimeRecoveryRequest, + ): Effect.Effect, RuntimeDriverError> => + lifecycle.withPermit( + Effect.gen(function* () { + let recoveryCause: Cause.Cause = Cause.empty; + const attempt = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* Effect.exit(effect); + if (Exit.isFailure(result)) + recoveryCause = Cause.combine(recoveryCause, result.cause); + }); + const owned = [...resources.values()].filter( + (resource) => resource.key.stackId === request.stackId, + ); + for (const resource of owned) { + yield* attempt(stopResource(resource)); + yield* attempt(removeResource(resource)); + } + if (recoveryCause.reasons.length > 0) return yield* Effect.failCause(recoveryCause); + return []; }), ); - return { observe, start, stop, remove } satisfies RuntimeDriver; + return { + observe, + start, + stop, + remove, + cleanup: cleanupRuntime, + recover, + } satisfies RuntimeDriver; }); export const makeNativeRuntimeDriver = makeNativeRuntime; diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index bb736bfa7e..2972f10767 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -1,6 +1,6 @@ import { Data, Effect } from "effect"; import type { StackId } from "../public/StackId.ts"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; /** The exact identity used when touching a private runtime resource. */ export interface RuntimeWorkloadKey { @@ -10,6 +10,22 @@ export interface RuntimeWorkloadKey { readonly specHash: string; } +/** Exact stack-wide runtime cleanup requested after reconciliation or owner recovery. */ +export interface RuntimeCleanupRequest { + readonly stackId: StackId; + /** Remove persistent runtime volumes in addition to ephemeral containers/networks. */ + readonly destroy: boolean; +} + +/** Exact persisted intent used when an owner is replaced or resumes after a crash. */ +export interface RuntimeRecoveryRequest { + readonly stackId: StackId; + readonly desiredGeneration: number; + /** Recovery only adopts a durable running stack; stopped state uses cleanup instead. */ + readonly desiredLifecycle: "running"; + readonly plan: ExecutionPlan; +} + /** A driver only reports states that can be acted on by the reconciler. */ export type ObservedWorkloadState = "absent" | "starting" | "ready" | "stopped" | "failed"; @@ -34,6 +50,18 @@ export interface RuntimeDriver { readonly stop: (key: RuntimeWorkloadKey) => Effect.Effect; /** Removes one exact resource; no other generation or stack may be touched. */ readonly remove: (key: RuntimeWorkloadKey) => Effect.Effect; + /** + * Cleans up exact resources belonging to one stack. Ordinary stop retains volumes; destructive + * cleanup removes them after containers and networks have been removed. + */ + readonly cleanup: (request: RuntimeCleanupRequest) => Effect.Effect; + /** + * Inspects and fences leftover resources without starting anything. Native resources are + * terminated; containers with current identities are adopted and stale resources removed. + */ + readonly recover: ( + request: RuntimeRecoveryRequest, + ) => Effect.Effect, RuntimeDriverError>; } export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 6f1655af66..726c305908 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -608,7 +608,8 @@ describe("container runtime", () => { it.live("leaves foreign and sanitized-name collisions untouched", () => Effect.gen(function* () { - const foreignKey = { ...key, workloadId: "api:api" }; + const foreignStackId = StackIdSchema.make("c".repeat(64)); + const foreignKey = { ...key, stackId: foreignStackId, workloadId: "api:api" }; const foreignName = `supabase-${stackId.slice(0, 16)}-${foreignKey.desiredGeneration}-api-api-workload`; const state: FakeContainerState = { resources: [ @@ -618,7 +619,7 @@ describe("container runtime", () => { kind: "workload", state: "running", labels: { - stackId, + stackId: foreignStackId, ownerSessionId: "other-session", desiredGeneration: foreignKey.desiredGeneration, workloadId: foreignKey.workloadId, @@ -636,7 +637,9 @@ describe("container runtime", () => { engine: fakeContainerEngine(state), ownerSessionId: "owner-session", }); - const foreign = yield* runtime.start(foreignKey, workload()).pipe(Effect.exit); + const foreign = yield* runtime + .start({ ...foreignKey, stackId }, workload()) + .pipe(Effect.exit); expect(Exit.isFailure(foreign)).toBe(true); expect(state.calls).not.toContain("pull-image"); expect(state.calls).not.toContain("create-container"); diff --git a/packages/stack/src/runtime/runtime-recovery.integration.test.ts b/packages/stack/src/runtime/runtime-recovery.integration.test.ts new file mode 100644 index 0000000000..a91b2c9aa1 --- /dev/null +++ b/packages/stack/src/runtime/runtime-recovery.integration.test.ts @@ -0,0 +1,503 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { ContainerArtifact } from "../model/CapabilityModule.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import type { + ContainerContainerSpec, + ContainerEngine, + ContainerNetworkLabels, + ContainerNetworkSpec, + ContainerResource, + ContainerWorkloadLabels, + ContainerVolumeSpec, +} from "./ContainerEngine.ts"; +import { makeContainerRuntime } from "./ContainerRuntime.ts"; +import { makeNativeRuntime } from "./NativeRuntime.ts"; +import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; + +const stackId = StackIdSchema.make("e".repeat(64)); +const otherStackId = StackIdSchema.make("f".repeat(64)); +const artifact: ContainerArtifact = { + kind: "container", + service: "database", + image: "example/database:1", +}; +const key: RuntimeWorkloadKey = { + stackId, + desiredGeneration: 3, + workloadId: "database:database", + specHash: "hash-current", +}; +const workload = (selected: PlannedWorkload["selected"] = artifact): PlannedWorkload => ({ + id: key.workloadId, + capability: "database", + dependencies: [], + readiness: { mode: "tcp" }, + restart: { maxAttempts: 1, backoffMs: 0 }, + artifacts: { + native: { kind: "native", service: "database", release: "1" }, + container: artifact, + }, + selected, + specHash: key.specHash, +}); + +const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ + runtime: { kind: "native" }, + activation: { + database: "eager", + rest: "eager", + auth: "eager", + realtime: "eager", + storage: "eager", + functions: "eager", + studio: "eager", + mail: "eager", + analytics: "eager", + pooler: "eager", + }, + startOrder: ["database"], + stopOrder: ["database"], + dependencies: { + database: [], + rest: [], + auth: [], + realtime: [], + storage: [], + functions: [], + studio: [], + mail: [], + analytics: [], + pooler: [], + }, + routes: [], + workloads, +}); + +interface EngineState { + resources: Array; + calls: Array; + nextId: number; +} + +const makeEngine = (state: EngineState): ContainerEngine => { + const next = (prefix: string) => `${prefix}-${state.nextId++}`; + const find = (id: string) => state.resources.find((resource) => resource.id === id); + return { + kind: "docker", + executable: "controlled", + preflight: Effect.succeed({ host: "host.docker.internal" }), + probe: Effect.void, + inspectImage: () => Effect.succeed({ present: true }), + pullImage: () => Effect.sync(() => state.calls.push("pull-image")), + listResources: () => Effect.sync(() => [...state.resources]), + createNetwork: (spec: ContainerNetworkSpec) => + Effect.sync(() => { + state.calls.push("create-network"); + const resource: ContainerResource = { + id: next("network"), + name: spec.name, + kind: "network", + labels: spec.labels, + }; + state.resources.push(resource); + return resource; + }), + removeNetwork: (id) => + Effect.sync(() => { + state.calls.push(`remove-network:${id}`); + state.resources = state.resources.filter((resource) => resource.id !== id); + }), + createVolume: (spec: ContainerVolumeSpec) => + Effect.sync(() => { + state.calls.push("create-volume"); + const resource: ContainerResource = { + id: next("volume"), + name: spec.name, + kind: "volume", + labels: spec.labels, + }; + state.resources.push(resource); + return resource; + }), + removeVolume: (id) => + Effect.sync(() => { + state.calls.push(`remove-volume:${id}`); + state.resources = state.resources.filter((resource) => resource.id !== id); + }), + createContainer: (spec: ContainerContainerSpec) => + Effect.sync(() => { + state.calls.push(`create-${spec.role}`); + const resource: ContainerResource = { + id: next(spec.role), + name: spec.name, + kind: spec.role, + labels: spec.labels, + state: "created", + }; + state.resources.push(resource); + return resource; + }), + startContainer: (id) => + Effect.sync(() => { + state.calls.push(`start:${id}`); + const resource = find(id); + if (resource !== undefined) + state.resources = state.resources.map((entry) => + entry.id === id ? { ...entry, state: "running" } : entry, + ); + }), + stopContainer: (id) => + Effect.sync(() => { + state.calls.push(`stop:${id}`); + const resource = find(id); + if (resource !== undefined) + state.resources = state.resources.map((entry) => + entry.id === id ? { ...entry, state: "stopped" } : entry, + ); + }), + removeContainer: (id) => + Effect.sync(() => { + state.calls.push(`remove:${id}`); + state.resources = state.resources.filter((resource) => resource.id !== id); + }), + }; +}; + +const workloadLabels = ( + id: StackId, + ownerSessionId: string, + specHash: string, +): ContainerWorkloadLabels => ({ + stackId: id, + ownerSessionId, + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash, + role: "workload", +}); + +const networkLabels = (id: StackId, ownerSessionId: string): ContainerNetworkLabels => ({ + stackId: id, + ownerSessionId, + desiredGeneration: key.desiredGeneration, + role: "network", +}); + +const networkName = `supabase-${stackId.slice(0, 16)}-${key.desiredGeneration}-network`; +const workloadName = `supabase-${stackId.slice(0, 16)}-${key.desiredGeneration}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`; + +describe("runtime recovery", () => { + it.live("adopts exact old-session workload and network resources", () => + Effect.gen(function* () { + const state: EngineState = { + calls: [], + nextId: 1, + resources: [ + { + id: "old-container", + name: workloadName, + kind: "workload", + state: "running", + labels: workloadLabels(stackId, "old-owner", key.specHash), + }, + { + id: "old-network", + name: networkName, + kind: "network", + labels: networkLabels(stackId, "old-owner"), + }, + ], + }; + const runtime = yield* makeContainerRuntime({ + engine: makeEngine(state), + ownerSessionId: "new-owner", + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + expect(state.calls).toEqual([]); + expect(yield* runtime.observe(stackId)).toEqual([{ ...key, state: "ready" }]); + }), + ); + + it.live("replaces a stale old-session hash without touching the adopted network", () => + Effect.gen(function* () { + const state: EngineState = { + calls: [], + nextId: 1, + resources: [ + { + id: "stale-container", + name: workloadName, + kind: "workload", + state: "running", + labels: workloadLabels(stackId, "old-owner", "hash-old"), + }, + { + id: "old-network", + name: networkName, + kind: "network", + labels: networkLabels(stackId, "old-owner"), + }, + ], + }; + const runtime = yield* makeContainerRuntime({ + engine: makeEngine(state), + ownerSessionId: "new-owner", + }); + yield* runtime.start(key, workload()); + expect(state.calls).toEqual([ + "stop:stale-container", + "remove:stale-container", + "create-workload", + "start:workload-1", + ]); + expect(state.resources.some((resource) => resource.id === "old-network")).toBe(true); + }), + ); + + it.live("cleans exact stack containers before networks, retaining then destroying volumes", () => + Effect.gen(function* () { + const volumeName = `supabase-${stackId}-${key.workloadId}-volume`; + const state: EngineState = { + calls: [], + nextId: 1, + resources: [ + { + id: "workload", + name: workloadName, + kind: "workload", + state: "running", + labels: workloadLabels(stackId, "owner", key.specHash), + }, + { + id: "gateway", + name: "gateway", + kind: "gateway", + state: "running", + labels: { + stackId, + ownerSessionId: "owner", + desiredGeneration: key.desiredGeneration, + workloadId: "gateway", + specHash: "gateway-hash", + role: "gateway", + }, + }, + { + id: "network", + name: networkName, + kind: "network", + labels: networkLabels(stackId, "owner"), + }, + { + id: "volume", + name: volumeName, + kind: "volume", + labels: { stackId, workloadId: key.workloadId, role: "volume" }, + }, + { + id: "foreign", + name: "foreign", + kind: "workload", + state: "running", + labels: workloadLabels(otherStackId, "other-owner", key.specHash), + }, + ], + }; + const runtime = yield* makeContainerRuntime({ + engine: makeEngine(state), + ownerSessionId: "new-owner", + }); + yield* runtime.cleanup({ stackId, destroy: false }); + expect(state.calls.slice(0, 6)).toEqual([ + "stop:gateway", + "remove:gateway", + "stop:workload", + "remove:workload", + "remove-network:network", + ]); + expect(state.resources.some((resource) => resource.id === "volume")).toBe(true); + expect(state.resources.some((resource) => resource.id === "foreign")).toBe(true); + const callCount = state.calls.length; + yield* runtime.cleanup({ stackId, destroy: false }); + expect(state.calls).toHaveLength(callCount); + yield* runtime.cleanup({ stackId, destroy: true }); + expect(state.resources.some((resource) => resource.id === "volume")).toBe(false); + expect(state.resources.some((resource) => resource.id === "foreign")).toBe(true); + }), + ); + + it.live("recovers containers without starting and removes stale workloads and gateways", () => + Effect.gen(function* () { + const state: EngineState = { + calls: [], + nextId: 1, + resources: [ + { + id: "current", + name: workloadName, + kind: "workload", + state: "running", + labels: workloadLabels(stackId, "old-owner", key.specHash), + }, + { + id: "current-duplicate", + name: workloadName, + kind: "workload", + state: "running", + labels: workloadLabels(stackId, "previous-owner", key.specHash), + }, + { + id: "stale-hash", + name: "stale-hash", + kind: "workload", + state: "running", + labels: workloadLabels(stackId, "old-owner", "hash-old"), + }, + { + id: "stale-generation", + name: "stale-generation", + kind: "workload", + state: "running", + labels: { + ...workloadLabels(stackId, "old-owner", "hash-generation"), + desiredGeneration: key.desiredGeneration - 1, + }, + }, + { + id: "unplanned", + name: "unplanned", + kind: "workload", + state: "running", + labels: { + ...workloadLabels(stackId, "old-owner", "hash-unplanned"), + workloadId: "storage:storage", + }, + }, + { + id: "gateway", + name: "gateway", + kind: "gateway", + state: "running", + labels: { + stackId, + ownerSessionId: "old-owner", + desiredGeneration: key.desiredGeneration, + workloadId: "gateway", + specHash: "gateway-hash", + role: "gateway", + }, + }, + { + id: "current-network", + name: networkName, + kind: "network", + labels: networkLabels(stackId, "old-owner"), + }, + { + id: "stale-network", + name: "stale-network", + kind: "network", + labels: { ...networkLabels(stackId, "old-owner"), desiredGeneration: 2 }, + }, + { + id: "volume", + name: "persistent", + kind: "volume", + labels: { stackId, workloadId: key.workloadId, role: "volume" }, + }, + { + id: "foreign", + name: "foreign", + kind: "workload", + state: "running", + labels: workloadLabels(otherStackId, "other-owner", key.specHash), + }, + ], + }; + const runtime = yield* makeContainerRuntime({ + engine: makeEngine(state), + ownerSessionId: "new-owner", + }); + const observed = yield* runtime.recover({ + stackId, + desiredGeneration: key.desiredGeneration, + desiredLifecycle: "running", + plan: planFor([workload()]), + }); + expect(observed).toEqual([{ ...key, state: "ready" }]); + expect( + state.calls.some((call) => call.startsWith("create-") || call.startsWith("start:")), + ).toBe(false); + expect(state.calls).toContain("stop:current-duplicate"); + expect(state.calls).toContain("remove:current-duplicate"); + expect(state.calls.slice(-1)).toEqual(["remove-network:stale-network"]); + expect(state.resources.map((resource) => resource.id)).toEqual([ + "current", + "current-network", + "volume", + "foreign", + ]); + }), + ); + + it.live("cleans only exact native resources and is idempotent", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + executable: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + }), + waitForReadiness: () => Effect.void, + }); + const otherKey = { ...key, stackId: otherStackId }; + const nativeWorkload = workload({ kind: "native", service: "database", release: "1" }); + yield* runtime.start(key, nativeWorkload); + yield* runtime.start(otherKey, { ...nativeWorkload, id: otherKey.workloadId }); + yield* runtime.cleanup({ stackId, destroy: true }); + expect(yield* runtime.observe(stackId)).toEqual([]); + expect((yield* runtime.observe(otherStackId)).length).toBe(1); + yield* runtime.cleanup({ stackId, destroy: true }); + yield* runtime.cleanup({ stackId: otherStackId, destroy: true }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("recovers native resources by stopping exact stack remnants without starting", () => + Effect.scoped( + Effect.gen(function* () { + let launches = 0; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.sync(() => { + launches += 1; + return { executable: process.execPath, args: ["-e", "setInterval(() => {}, 1000)"] }; + }), + waitForReadiness: () => Effect.void, + }); + const nativeWorkload = workload({ kind: "native", service: "database", release: "1" }); + yield* runtime.start(key, nativeWorkload); + const otherKey = { ...key, stackId: otherStackId }; + yield* runtime.start(otherKey, { ...nativeWorkload, id: otherKey.workloadId }); + expect(launches).toBe(2); + expect( + yield* runtime.recover({ + stackId, + desiredGeneration: key.desiredGeneration, + desiredLifecycle: "running", + plan: planFor([]), + }), + ).toEqual([]); + expect(launches).toBe(2); + expect(yield* runtime.observe(stackId)).toEqual([]); + expect((yield* runtime.observe(otherStackId)).length).toBe(1); + yield* runtime.cleanup({ stackId: otherStackId, destroy: true }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts new file mode 100644 index 0000000000..9727cf8bcd --- /dev/null +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -0,0 +1,521 @@ +import { Crypto, Effect, FileSystem, Path, Redacted, Ref, Semaphore } from "effect"; +import type { StackDefinition, CompiledStack, SecretSlotInput } from "../model/Compiler.ts"; +import { compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; +import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; +import type { StackConfig } from "../public/Config.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import type { StackId } from "../public/StackId.ts"; +import { + StackDefinitionRequiredError, + StackLifecycleConflictError, + StackNotRunningError, + StackStateInvalidError, + StackUpgradeRequiredError, + type StackError, +} from "../public/Errors.ts"; +import type { + HostPortAssignment, + PersistedSecretValues, + PersistedStackState, +} from "../state/StackState.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import { + resolveSecrets, + type SecretCandidate, + type SecretDeclaration, +} from "../state/SecretStore.ts"; + +/** + * The runtime-facing contract deliberately contains no Docker/native concepts. Concrete drivers + * own resources; this controller owns accepted durable intent and generation fences. + */ +export interface LifecycleInput { + readonly stackId: StackId; + readonly generation: number; + readonly desiredLifecycle: "running" | "stopped" | "destroying"; + readonly state: PersistedStackState; + readonly previous: PersistedStackState; + readonly definition: StackDefinition; + readonly inputFingerprint: string; + readonly secrets: PersistedSecretValues; + readonly plan: ExecutionPlan; +} + +export interface LifecyclePrepared { + /** Backend may return newly allocated concrete ports; omitted means retain persisted assignments. */ + readonly ports?: ReadonlyArray; +} + +export interface LifecycleBackend { + /** Must complete all runtime/resource validation before the controller writes accepted intent. */ + readonly preflight: (input: LifecycleInput) => Effect.Effect; + /** Applies the desired lifecycle to runtime resources for one fenced generation. */ + readonly reconcile: (input: LifecycleInput) => Effect.Effect; + /** Removes runtime resources while retaining durable state/data (stop path). */ + readonly cleanup: (input: LifecycleInput) => Effect.Effect; + /** Removes persistent data after exact runtime cleanup (destroy path). */ + readonly destroyData: (input: LifecycleInput) => Effect.Effect; +} + +export interface LifecycleStartOptions { + readonly config?: StackConfig; +} + +export interface LifecycleController { + readonly start: ( + options?: LifecycleStartOptions, + ) => Effect.Effect; + // Each invocation builds a fresh read/transition effect while sharing the controller semaphore. + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly stop: () => Effect.Effect; + readonly restart: ( + options?: LifecycleStartOptions, + ) => Effect.Effect; + // oxlint-disable-next-line effecttsgo/lazy-effect + readonly destroy: () => Effect.Effect; +} + +export type LifecycleRequirements = Crypto.Crypto | FileSystem.FileSystem | Path.Path; + +export interface LifecycleControllerOptions { + readonly stackId: StackId; + readonly runtime: StackRuntime; + readonly stateStore: StackStateStore; + readonly backend: LifecycleBackend; +} + +interface Candidate { + readonly definition: StackDefinition; + readonly inputFingerprint: string; + readonly secrets: PersistedSecretValues; + readonly plan: ExecutionPlan; +} + +interface StopTransition { + readonly state: PersistedStackState; + readonly previous: PersistedStackState; + readonly candidate?: Candidate; + readonly cleanup: boolean; +} + +const missingState = (stackId: StackId): StackStateInvalidError => + new StackStateInvalidError({ + stackId, + message: "Stack state is missing; refusing lifecycle mutation", + }); + +const invalidDefinition = (stackId: StackId): StackDefinitionRequiredError => + new StackDefinitionRequiredError({ stackId, message: "A complete stack definition is required" }); + +const lifecycleConflict = (message: string): StackLifecycleConflictError => + new StackLifecycleConflictError({ message }); + +const declarationsFromPersisted = (secrets: PersistedSecretValues): SecretCandidate => ({ + declarations: Object.entries(secrets).map(([slot, entry]) => ({ + slot, + policy: entry.policy, + ...(entry.policy === "passthrough" ? { value: Redacted.make(entry.value) } : {}), + })), +}); + +const declarationsFromCompiled = (compiled: CompiledStack): SecretCandidate => ({ + declarations: compiled.secrets.map((entry: SecretSlotInput): SecretDeclaration => ({ + slot: entry.slot, + policy: entry.policy, + ...(entry.value === undefined ? {} : { value: entry.value }), + })), +}); + +const sameSecrets = (left: PersistedSecretValues, right: PersistedSecretValues): boolean => { + const leftEntries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b)); + const rightEntries = Object.entries(right).sort(([a], [b]) => a.localeCompare(b)); + if (leftEntries.length !== rightEntries.length) return false; + return leftEntries.every(([slot, value], index) => { + const other = rightEntries[index]; + return ( + other !== undefined && + slot === other[0] && + value.policy === other[1].policy && + value.value === other[1].value + ); + }); +}; + +const materializeCandidate = ( + state: PersistedStackState, + runtime: StackRuntime, + config: StackConfig | undefined, + secretLifecycle: PersistedStackState["desiredLifecycle"] = state.desiredLifecycle, +): Effect.Effect => + Effect.gen(function* () { + if ( + config === undefined && + state.definition !== undefined && + state.inputFingerprint !== undefined + ) { + const plan = yield* rebuildExecutionPlan(runtime, state.definition); + const resolved = yield* resolveSecrets( + declarationsFromPersisted(state.secrets), + state.desiredLifecycle === "unconfigured" ? undefined : state.secrets, + secretLifecycle, + ); + return { + definition: state.definition, + inputFingerprint: state.inputFingerprint, + secrets: resolved.persisted, + plan, + }; + } + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime, + config, + }); + const resolved = yield* resolveSecrets( + declarationsFromCompiled(compiled), + state.desiredLifecycle === "unconfigured" ? undefined : state.secrets, + secretLifecycle, + ); + return { + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: resolved.persisted, + plan: compiled.executionPlan, + }; + }); + +const lifecycleInput = ( + stackId: StackId, + previous: PersistedStackState, + state: PersistedStackState, + candidate: Candidate, + desiredLifecycle: LifecycleInput["desiredLifecycle"] = state.desiredLifecycle === "unconfigured" + ? "running" + : state.desiredLifecycle, +): LifecycleInput => ({ + stackId, + generation: state.desiredGeneration, + desiredLifecycle, + state, + previous, + definition: candidate.definition, + inputFingerprint: candidate.inputFingerprint, + secrets: candidate.secrets, + plan: candidate.plan, +}); + +const stateWithCandidate = ( + state: PersistedStackState, + candidate: Candidate, + desiredLifecycle: PersistedStackState["desiredLifecycle"], + generation: number, + ports: ReadonlyArray, +): PersistedStackState => ({ + ...state, + desiredGeneration: generation, + desiredLifecycle, + definition: candidate.definition, + inputFingerprint: candidate.inputFingerprint, + secrets: candidate.secrets, + ports, +}); + +const noOpStop = (state: PersistedStackState): StopTransition => ({ + state, + previous: state, + cleanup: false, +}); + +/** Creates one Supervisor-local lifecycle owner. Mutable coordination is allocated per Effect run. */ +export const makeLifecycleController = ( + options: LifecycleControllerOptions, +): Effect.Effect => + Effect.gen(function* () { + const lifecycle = yield* Semaphore.make(1); + // A stop request epoch is incremented before waiting for the lifecycle permit. Restart captures + // the epoch during preflight and refuses to relaunch if a stop was accepted in the meantime. + const stopRequestEpoch = yield* Ref.make(0); + + const read = (): Effect.Effect => + options.stateStore + .read(options.stackId) + .pipe( + Effect.flatMap((state) => + state === undefined + ? Effect.fail(missingState(options.stackId)) + : Effect.succeed(state), + ), + ); + + const start = ( + startOptions?: LifecycleStartOptions, + ): Effect.Effect => { + const supplied = startOptions?.config; + return Effect.gen(function* () { + const initial = yield* read(); + if (initial.desiredLifecycle === "destroying") + return yield* lifecycleConflict("Stack is being destroyed"); + + // Running stacks reject changed materialized input before any backend operation. An omitted + // config or identical supplied input only joins/reconciles the accepted generation. + if (initial.desiredLifecycle === "running" && supplied !== undefined) { + const candidate = yield* materializeCandidate(initial, initial.runtime, supplied).pipe( + Effect.catchTag("StackMustBeStoppedError", (error) => + Effect.fail( + new StackUpgradeRequiredError({ + stackId: options.stackId, + message: `${error.message}; call restart explicitly to apply it`, + guidance: "Use restart() to apply stopped-time changes", + }), + ), + ), + ); + if (candidate.inputFingerprint !== initial.inputFingerprint) + return yield* new StackUpgradeRequiredError({ + stackId: options.stackId, + message: "Running stack input changed; call restart explicitly to apply it", + guidance: "Use restart() to apply stopped-time changes", + }); + if (!sameSecrets(candidate.secrets, initial.secrets)) + return yield* new StackUpgradeRequiredError({ + stackId: options.stackId, + message: "Running stack secrets changed; call restart explicitly to apply them", + guidance: "Use restart() to apply stopped-time changes", + }); + const input = lifecycleInput(options.stackId, initial, initial, candidate); + yield* options.backend.reconcile(input); + return yield* read(); + } + if (initial.desiredLifecycle === "running" && supplied === undefined) { + const candidate = yield* materializeCandidate(initial, initial.runtime, undefined); + const input = lifecycleInput(options.stackId, initial, initial, candidate); + yield* options.backend.reconcile(input); + return yield* read(); + } + + const candidate = yield* materializeCandidate(initial, initial.runtime, supplied); + const prepared = yield* options.backend.preflight( + lifecycleInput(options.stackId, initial, initial, candidate), + ); + return yield* lifecycle.withPermit( + Effect.gen(function* () { + const current = yield* read(); + if (current.desiredGeneration !== initial.desiredGeneration) + return yield* new StackUpgradeRequiredError({ + stackId: options.stackId, + message: "Stack lifecycle changed while start was preparing; retry start", + }); + if (current.desiredLifecycle === "destroying") + return yield* lifecycleConflict("Stack is being destroyed"); + const next = stateWithCandidate( + current, + candidate, + "running", + current.desiredGeneration + 1, + prepared.ports ?? current.ports, + ); + yield* options.stateStore.replace(options.stackId, next, current.desiredGeneration); + yield* options.backend.reconcile( + lifecycleInput(options.stackId, current, next, candidate), + ); + return next; + }), + ); + }); + }; + + const stop = (): Effect.Effect => + Effect.gen(function* () { + yield* Ref.update(stopRequestEpoch, (epoch) => epoch + 1); + const transition = yield* lifecycle.withPermit( + Effect.gen(function* () { + const current = yield* read(); + if (current.desiredLifecycle === "unconfigured") return noOpStop(current); + if (current.desiredLifecycle === "destroying") + return yield* lifecycleConflict("Stack is being destroyed"); + if (current.desiredLifecycle === "stopped") { + if (current.definition === undefined || current.inputFingerprint === undefined) + return yield* invalidDefinition(options.stackId); + const candidate: Candidate = { + definition: current.definition, + inputFingerprint: current.inputFingerprint, + secrets: current.secrets, + plan: yield* rebuildExecutionPlan(current.runtime, current.definition), + }; + // Stopped state is durable intent, not proof that runtime cleanup completed. Retry + // the idempotent cleanup without changing the generation. + return { state: current, previous: current, candidate, cleanup: true }; + } + const definition = current.definition; + if (definition === undefined || current.inputFingerprint === undefined) + return yield* invalidDefinition(options.stackId); + const candidate: Candidate = { + definition, + inputFingerprint: current.inputFingerprint, + secrets: current.secrets, + plan: yield* rebuildExecutionPlan(current.runtime, definition), + }; + const next: PersistedStackState = { + ...current, + desiredLifecycle: "stopped", + desiredGeneration: current.desiredGeneration + 1, + }; + yield* options.stateStore.replace(options.stackId, next, current.desiredGeneration); + return { state: next, previous: current, candidate, cleanup: true }; + }), + ); + if (!transition.cleanup || transition.candidate === undefined) return transition.state; + const input = lifecycleInput( + options.stackId, + transition.previous, + transition.state, + transition.candidate, + "stopped", + ); + yield* options.backend.reconcile(input); + yield* options.backend.cleanup(input); + return transition.state; + }); + + const restart = ( + restartOptions?: LifecycleStartOptions, + ): Effect.Effect => + Effect.gen(function* () { + const restartEpoch = yield* Ref.get(stopRequestEpoch); + const initial = yield* read(); + if (initial.desiredLifecycle !== "running") + return yield* new StackNotRunningError({ + stackId: options.stackId, + message: "Stack must be running before restart", + }); + if (initial.definition === undefined || initial.inputFingerprint === undefined) + return yield* invalidDefinition(options.stackId); + const candidate = yield* materializeCandidate( + initial, + initial.runtime, + restartOptions?.config, + "stopped", + ); + const prepared = yield* options.backend.preflight( + lifecycleInput(options.stackId, initial, initial, candidate), + ); + const stoppedTransition = yield* lifecycle.withPermit( + Effect.gen(function* () { + const current = yield* read(); + if ( + current.desiredGeneration !== initial.desiredGeneration || + current.desiredLifecycle !== "running" + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: + "Stack lifecycle changed while restart was preparing; restart lost the generation fence", + }); + const stopped: PersistedStackState = { + ...current, + desiredLifecycle: "stopped", + desiredGeneration: current.desiredGeneration + 1, + }; + yield* options.stateStore.replace(options.stackId, stopped, current.desiredGeneration); + const currentDefinition = current.definition; + const currentFingerprint = current.inputFingerprint; + if (currentDefinition === undefined || currentFingerprint === undefined) + return yield* invalidDefinition(options.stackId); + const stoppedCandidate: Candidate = { + definition: currentDefinition, + inputFingerprint: currentFingerprint, + secrets: current.secrets, + plan: yield* rebuildExecutionPlan(current.runtime, currentDefinition), + }; + return { current, stopped, stoppedCandidate }; + }), + ); + const stopInput = lifecycleInput( + options.stackId, + stoppedTransition.current, + stoppedTransition.stopped, + stoppedTransition.stoppedCandidate, + "stopped", + ); + yield* options.backend.reconcile(stopInput); + yield* options.backend.cleanup(stopInput); + const next = yield* lifecycle.withPermit( + Effect.gen(function* () { + const current = yield* read(); + if ( + current.desiredGeneration !== stoppedTransition.stopped.desiredGeneration || + current.desiredLifecycle !== "stopped" || + (yield* Ref.get(stopRequestEpoch)) !== restartEpoch + ) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: + "Stack stop was accepted during restart; restart lost the generation fence", + }); + const next = stateWithCandidate( + current, + candidate, + "running", + current.desiredGeneration + 1, + prepared.ports ?? current.ports, + ); + yield* options.stateStore.replace(options.stackId, next, current.desiredGeneration); + return next; + }), + ); + yield* options.backend.reconcile( + lifecycleInput(options.stackId, stoppedTransition.stopped, next, candidate, "running"), + ); + return next; + }); + + const destroy = (): Effect.Effect => + lifecycle.withPermit( + Effect.gen(function* () { + const current = yield* read(); + if (current.desiredLifecycle === "unconfigured" && current.definition === undefined) { + yield* options.stateStore.cleanup(options.stackId); + return; + } + if (current.desiredLifecycle === "destroying") { + if (current.definition === undefined) return yield* invalidDefinition(options.stackId); + } else { + const destroying: PersistedStackState = { + ...current, + desiredLifecycle: "destroying", + desiredGeneration: current.desiredGeneration + 1, + }; + yield* options.stateStore.replace( + options.stackId, + destroying, + current.desiredGeneration, + ); + return yield* destroyRuntime(destroying, current); + } + return yield* destroyRuntime(current, current); + }), + ); + + const destroyRuntime = ( + state: PersistedStackState, + previous: PersistedStackState, + ): Effect.Effect => + Effect.gen(function* () { + const definition = state.definition; + if (definition === undefined || state.inputFingerprint === undefined) + return yield* invalidDefinition(options.stackId); + const candidate: Candidate = { + definition, + inputFingerprint: state.inputFingerprint, + secrets: state.secrets, + plan: yield* rebuildExecutionPlan(state.runtime, definition), + }; + const input = lifecycleInput(options.stackId, previous, state, candidate); + yield* options.backend.reconcile(input); + yield* options.backend.cleanup(input); + yield* options.backend.destroyData(input); + yield* options.stateStore.cleanup(options.stackId); + }); + + return { start, stop, restart, destroy } satisfies LifecycleController; + }); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 770c8ed24b..fcc864d1ea 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -1,31 +1,107 @@ -import { Context, Crypto, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { Context, Crypto, Effect, FileSystem, Path, Ref, Schema, Stream } from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; -import type { PersistedStackState } from "../state/StackState.ts"; -import type { StackStateStore } from "../state/StackStateStore.ts"; +import { rebuildExecutionPlan } from "../model/Compiler.ts"; +import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; import { CAPABILITY_NAMES, type CapabilityName, type CapabilityStatus, } from "../public/Capability.ts"; -import type { StackEndpoint, StackStatus } from "../public/Status.ts"; -import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import type { StackConfig } from "../public/Config.ts"; import { + GatewayActivationError, + InvalidStackIdentityError, + InvalidProjectRootError, + InvalidStackConfigError, + StackDefinitionRequiredError, + StackLifecycleConflictError, + StackNotFoundError, + StackNotRunningError, + StackMustBeStoppedError, + StackOwnershipConflictError, + StackRuntimeMismatchError, + StackReconciliationError, + StackVersionUnsupportedError, StackStateFormatUnsupportedError, + StackUpgradeRequiredError, + StackUpgradeReplacementError, + StackStateGenerationMismatchError, + StackSecretMismatchError, + InvalidJwtSigningMaterialError, + PortAllocationError, + PortUnavailableError, + ServiceStartError, + ServiceReadinessError, + ContainerEngineError, + StackDestructionError, + GatewayAuthenticationError, + GatewayStaleGenerationError, + StackPreparationError, + ArtifactIntegrityError, + ContainerPullError, StackStateInvalidError, - InvalidProjectRootError, type StackError, } from "../public/Errors.ts"; +import type { StackEndpoint, StackStatus } from "../public/Status.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; +import type { + RuntimeDriver, + RuntimeRecoveryRequest, + ObservedWorkload, +} from "../runtime/RuntimeDriver.ts"; +import { RuntimeDriverError, RuntimeGenerationMismatchError } from "../runtime/RuntimeDriver.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import { makeReconciler, type Reconciler } from "./Reconciler.ts"; +import { + makeLifecycleController, + type LifecycleBackend, + type LifecycleInput, + type LifecyclePrepared, +} from "./Lifecycle.ts"; +import { makeStatusHub } from "./StatusHub.ts"; +import type { LogStore } from "./LogStore.ts"; import { StackRpcGroup, type StackRpcError, type StackRpcHandlers } from "../control/StackRpc.ts"; import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; +export interface ActivationResult { + readonly capability: CapabilityName; + readonly endpoint: { readonly host: string; readonly port: number }; +} + +/** Runtime construction is injected so catalog/artifact resolution can evolve independently. */ +export interface SupervisorRuntime { + readonly driver: RuntimeDriver; + readonly preflight?: (input: LifecycleInput) => Effect.Effect; + readonly activate?: ( + capability: CapabilityName, + input: LifecycleInput, + ) => Effect.Effect; + readonly logStore?: LogStore; +} + +export interface SupervisorRuntimeFactory { + readonly make: (state: PersistedStackState) => Effect.Effect; +} + export interface Supervisor { readonly identity: StackIdentity; readonly stackId: StackId; readonly ownerSessionId: string; - readonly status: Effect.Effect< - StackStatus, - InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError - >; + readonly status: Effect.Effect; + readonly start: (options?: { + readonly config?: StackConfig; + }) => Effect.Effect; + readonly restart: (options?: { + readonly config?: StackConfig; + }) => Effect.Effect; + readonly destroy: Effect.Effect; + readonly watchStatus: Stream.Stream; + readonly logs: (options?: LogOptions) => Stream.Stream; + readonly activate: ( + capability: CapabilityName, + ) => Effect.Effect; readonly maintenanceHandlers: { readonly probe: Effect.Effect; readonly stop: Effect.Effect; @@ -41,201 +117,544 @@ export interface SupervisorOptions { readonly rpcRelease: string; readonly stateStore: StackStateStore; readonly context: Context.Context; + /** Tests and future catalog composition may provide a concrete runtime. */ + readonly runtime?: SupervisorRuntime; + readonly runtimeFactory?: SupervisorRuntimeFactory; } +type ActualPhase = "stopped" | "starting" | "running" | "stopping" | "destroying"; + +const rpcError = (tag: StackRpcError["tag"], message: string): StackRpcError => ({ tag, message }); +const stateErrorMessage = (error: StackError | { readonly message?: string }): string => + typeof error.message === "string" ? error.message : "Stack operation failed"; + +const runtimeUnavailable = (): SupervisorRuntime => { + const unavailable = (operation: string): Effect.Effect => + Effect.fail(new RuntimeDriverError({ message: `Runtime ${operation} is unavailable` })); + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => unavailable("start"), + stop: () => unavailable("stop"), + remove: () => unavailable("remove"), + cleanup: () => unavailable("cleanup"), + recover: () => unavailable("recovery"), + }; + return { + driver, + preflight: () => + Effect.fail(new StackReconciliationError({ message: "Stack runtime is unavailable" })), + }; +}; + +const eagerCapabilities = (plan: ExecutionPlan): Set => { + const active = new Set(); + const visit = (name: CapabilityName): void => { + if (active.has(name)) return; + active.add(name); + for (const dependency of plan.dependencies[name]) visit(dependency); + }; + for (const name of CAPABILITY_NAMES) if (plan.activation[name] === "eager") visit(name); + return active; +}; + +const activePlan = (plan: ExecutionPlan, active: ReadonlySet): ExecutionPlan => ({ + ...plan, + workloads: plan.workloads.filter((workload) => active.has(workload.capability)), + startOrder: plan.startOrder.filter((name) => active.has(name)), + stopOrder: plan.stopOrder.filter((name) => active.has(name)), +}); + const capabilityState = ( - configured: { readonly enabled: boolean } | undefined, -): CapabilityStatus["state"] => - configured === undefined || !configured.enabled ? "disabled" : "stopped"; + name: CapabilityName, + state: PersistedStackState, + observed: ReadonlyArray, + active: ReadonlySet, + phase: ActualPhase, +): CapabilityStatus["state"] => { + const configured = state.definition?.capabilities[name]; + if (configured === undefined || !configured.enabled) return "disabled"; + if ( + state.desiredLifecycle !== "running" || + phase === "stopped" || + phase === "stopping" || + phase === "destroying" + ) + return "stopped"; + if (configured.activation === "lazy" && !active.has(name)) return "dormant"; + if (phase === "starting") return "starting"; + const resources = observed.filter((entry) => entry.workloadId.startsWith(`${name}:`)); + if (resources.some((entry) => entry.state === "failed")) return "failed"; + if (resources.some((entry) => entry.state === "starting")) return "starting"; + if (resources.length > 0 && resources.every((entry) => entry.state === "ready")) return "ready"; + return "stopped"; +}; const statusFor = ( state: PersistedStackState, -): Effect.Effect => { - const definition = state.definition; - const capabilities = CAPABILITY_NAMES.map((name) => { - const configured = definition?.capabilities[name]; - return { - name, - activation: configured?.activation ?? "eager", - state: capabilityState(configured), - }; - }); - const versions: Partial> = {}; - if (definition !== undefined) { - for (const name of CAPABILITY_NAMES) versions[name] = definition.capabilities[name].version; - } - const endpoints = state.ports.reduce< - Readonly< - Partial< - Record< - | "api" - | "database" - | "pooler" - | "studio" - | "mailUi" - | "smtp" - | "pop3" - | "functionsInspector", - StackEndpoint - > - > - > - >((result, assignment) => { - const tcp = - assignment.field === "database" || - assignment.field === "pooler" || - assignment.field === "smtp" || - assignment.field === "pop3"; - const protocol = tcp ? "tcp" : "http"; - const listener = state.definition?.listeners[assignment.field]; - return { - ...result, - [assignment.field]: { - protocol, - address: listener?.address ?? "127.0.0.1", - port: assignment.port, - url: `${protocol}://${listener?.address ?? "127.0.0.1"}:${assignment.port}`, - }, - }; - }, {}); - return Schema.decodeEffect(StackIdSchema)(state.identity.stackId).pipe( + observed: ReadonlyArray, + active: ReadonlySet, + phase: ActualPhase, +): Effect.Effect => + Schema.decodeEffect(StackIdSchema)(state.identity.stackId).pipe( Effect.mapError( (error) => new StackStateInvalidError({ message: `Invalid persisted StackId: ${String(error)}` }), ), - Effect.map((id) => ({ - id, - lifecycle: + Effect.map((id) => { + const definition = state.definition; + const capabilities = CAPABILITY_NAMES.map((name) => ({ + name, + activation: definition?.capabilities[name].activation ?? "eager", + state: capabilityState(name, state, observed, active, phase), + })); + const versions: Partial> = {}; + if (definition !== undefined) + for (const name of CAPABILITY_NAMES) versions[name] = definition.capabilities[name].version; + const endpoints = state.ports.reduce< + Readonly< + Partial< + Record< + | "api" + | "database" + | "pooler" + | "studio" + | "mailUi" + | "smtp" + | "pop3" + | "functionsInspector", + StackEndpoint + > + > + > + >((result, assignment) => { + const tcp = + assignment.field === "database" || + assignment.field === "pooler" || + assignment.field === "smtp" || + assignment.field === "pop3"; + const protocol = tcp ? "tcp" : "http"; + const listener = state.definition?.listeners[assignment.field]; + return { + ...result, + [assignment.field]: { + protocol, + address: listener?.address ?? "127.0.0.1", + port: assignment.port, + url: `${protocol}://${listener?.address ?? "127.0.0.1"}:${assignment.port}`, + }, + }; + }, {}); + const activeStates = capabilities.filter( + ({ name }) => active.has(name) && definition?.capabilities[name].enabled, + ); + const anyStarting = activeStates.some(({ state }) => state === "starting"); + const anyFailed = activeStates.some(({ state }) => state === "failed"); + const allReady = + activeStates.length > 0 && activeStates.every(({ state }) => state === "ready"); + const lifecycle = state.desiredLifecycle === "unconfigured" ? "unconfigured" : state.desiredLifecycle === "destroying" ? "destroying" - : "stopped", - desiredLifecycle: state.desiredLifecycle, - runtime: state.runtime, - desiredGeneration: state.desiredGeneration, - endpoints, - versions, - capabilities, - })), + : state.desiredLifecycle === "stopped" + ? "stopped" + : phase === "starting" + ? "starting" + : phase === "stopping" + ? "stopping" + : phase === "destroying" + ? "destroying" + : anyFailed + ? "stopped" + : anyStarting + ? "starting" + : allReady + ? "running" + : "stopped"; + return { + id, + lifecycle, + desiredLifecycle: state.desiredLifecycle, + runtime: state.runtime, + desiredGeneration: state.desiredGeneration, + endpoints, + versions, + capabilities, + } satisfies StackStatus; + }), ); + +const mapReconcileError = (error: unknown): StackError => { + if (error instanceof StackStateInvalidError) return error; + if (error instanceof RuntimeGenerationMismatchError) + return new StackLifecycleConflictError({ message: error.message }); + return new StackReconciliationError({ + message: error instanceof Error ? error.message : String(error), + cause: error, + }); }; -const rpcError = (tag: StackRpcError["tag"], message: string): StackRpcError => ({ tag, message }); +const rpcTag = (error: StackError, fallback: StackRpcError["tag"]): StackRpcError["tag"] => { + if (error instanceof InvalidStackIdentityError) return "InvalidStackIdentityError"; + if (error instanceof InvalidProjectRootError) return "InvalidProjectRootError"; + if (error instanceof InvalidStackConfigError) return "InvalidStackConfigError"; + if (error instanceof StackNotFoundError) return "StackNotFoundError"; + if (error instanceof StackOwnershipConflictError) return "StackOwnershipConflictError"; + if (error instanceof StackRuntimeMismatchError) return "StackRuntimeMismatchError"; + if (error instanceof StackDefinitionRequiredError) return "StackDefinitionRequiredError"; + if (error instanceof StackVersionUnsupportedError) return "StackVersionUnsupportedError"; + if (error instanceof StackLifecycleConflictError) return "StackLifecycleConflictError"; + if (error instanceof StackNotRunningError) return "StackNotRunningError"; + if (error instanceof StackMustBeStoppedError) return "StackMustBeStoppedError"; + if (error instanceof StackStateInvalidError) return "StackStateInvalidError"; + if (error instanceof StackStateFormatUnsupportedError) return "StackStateFormatUnsupportedError"; + if (error instanceof StackStateGenerationMismatchError) + return "StackStateGenerationMismatchError"; + if (error instanceof StackUpgradeRequiredError) return "StackUpgradeRequiredError"; + if (error instanceof StackUpgradeReplacementError) return "StackUpgradeReplacementError"; + if (error instanceof StackSecretMismatchError) return "StackSecretMismatchError"; + if (error instanceof InvalidJwtSigningMaterialError) return "InvalidJwtSigningMaterialError"; + if (error instanceof PortAllocationError) return "PortAllocationError"; + if (error instanceof PortUnavailableError) return "PortUnavailableError"; + if (error instanceof ServiceStartError) return "ServiceStartError"; + if (error instanceof ServiceReadinessError) return "ServiceReadinessError"; + if (error instanceof ContainerEngineError) return "ContainerEngineError"; + if (error instanceof StackReconciliationError) return "StackReconciliationError"; + if (error instanceof StackDestructionError) return "StackDestructionError"; + if (error instanceof GatewayAuthenticationError) return "GatewayAuthenticationError"; + if (error instanceof GatewayStaleGenerationError) return "GatewayStaleGenerationError"; + if (error instanceof GatewayActivationError) return "GatewayActivationError"; + if (error instanceof StackPreparationError) return "StackPreparationError"; + if (error instanceof ArtifactIntegrityError) return "ArtifactIntegrityError"; + if (error instanceof ContainerPullError) return "ContainerPullError"; + return fallback; +}; -const stateErrorMessage = (error: StackError | { readonly message?: string }): string => - typeof error.message === "string" ? error.message : "Stack operation failed"; +/** Compose one owner process around the durable lifecycle controller and a runtime driver. */ +export const makeSupervisor = (options: SupervisorOptions): Effect.Effect => + Effect.gen(function* () { + const read = () => + options.stateStore.read(options.stackId).pipe(Effect.provideContext(options.context)); + const initial = yield* read(); + if (initial === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + const runtime = + options.runtime ?? + (options.runtimeFactory === undefined + ? runtimeUnavailable() + : yield* options.runtimeFactory.make(initial)); + const reconciler: Reconciler = yield* makeReconciler({ + driver: runtime.driver, + readGeneration: (stackId) => + options.stateStore.read(stackId).pipe( + Effect.provideContext(options.context), + Effect.flatMap((state) => + state === undefined + ? Effect.fail(new RuntimeDriverError({ message: "Stack state is missing", stackId })) + : Effect.succeed(state.desiredGeneration), + ), + Effect.mapError((error) => + error instanceof RuntimeDriverError + ? error + : new RuntimeDriverError({ message: error.message, stackId, cause: error }), + ), + ), + }); + const active = yield* Ref.make>(new Set()); + const generation = yield* Ref.make(initial.desiredGeneration); + const phase = yield* Ref.make("stopped"); + const initializeActivation = ( + plan: ExecutionPlan, + observed: ReadonlyArray = [], + ) => { + const next = eagerCapabilities(plan); + for (const entry of observed) { + const capability = CAPABILITY_NAMES.find((name) => entry.workloadId.startsWith(`${name}:`)); + if (capability !== undefined) next.add(capability); + } + return Ref.set(active, next); + }; + const resetForGeneration = (input: LifecycleInput) => + Effect.gen(function* () { + const current = yield* Ref.get(generation); + if (current === input.generation && input.desiredLifecycle === "running") return; + yield* Ref.set(generation, input.generation); + yield* initializeActivation(input.plan); + }); + const observe = () => + runtime.driver.observe(options.stackId).pipe(Effect.mapError(mapReconcileError)); -export const makeSupervisor = (options: SupervisorOptions): Supervisor => { - const read = options.stateStore - .read(options.stackId) - .pipe(Effect.provideContext(options.context)); - const status = read.pipe( - Effect.flatMap((state) => - state === undefined - ? Effect.fail(new StackStateInvalidError({ message: "Stack state is missing" })) - : statusFor(state), - ), - ); + if (initial.definition !== undefined) { + const plan = yield* rebuildExecutionPlan(initial.runtime, initial.definition).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + if (initial.desiredLifecycle === "running") { + const request: RuntimeRecoveryRequest = { + stackId: options.stackId, + desiredGeneration: initial.desiredGeneration, + desiredLifecycle: "running", + plan, + }; + const recovered = yield* runtime.driver + .recover(request) + .pipe(Effect.mapError(mapReconcileError)); + yield* initializeActivation(plan, recovered); + } else { + yield* runtime.driver + .cleanup({ stackId: options.stackId, destroy: false }) + .pipe(Effect.mapError(mapReconcileError)); + yield* Ref.set(active, new Set()); + } + } else if (options.runtime !== undefined || options.runtimeFactory !== undefined) { + yield* runtime.driver + .cleanup({ stackId: options.stackId, destroy: false }) + .pipe(Effect.mapError(mapReconcileError)); + } - const stop = read.pipe( - Effect.flatMap((state) => { - if (state === undefined) - return Effect.fail(new StackStateInvalidError({ message: "Stack state is missing" })); - if (state.desiredLifecycle === "unconfigured" || state.desiredLifecycle === "stopped") - return Effect.void; - return options.stateStore - .replace( - options.stackId, - { - ...state, - desiredLifecycle: "stopped", - desiredGeneration: state.desiredGeneration + 1, - }, - state.desiredGeneration, - ) - .pipe(Effect.provideContext(options.context)); - }), - ); + const initialObserved = yield* observe(); + const initialStatus = yield* statusFor( + initial, + initialObserved, + yield* Ref.get(active), + yield* Ref.get(phase), + ); + const hub = yield* makeStatusHub(initialStatus); + const snapshot = (): Effect.Effect => + Effect.gen(function* () { + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + const status = yield* statusFor( + state, + yield* observe(), + yield* Ref.get(active), + yield* Ref.get(phase), + ); + return status; + }); + const publish = (): Effect.Effect => + snapshot().pipe(Effect.tap((value) => hub.publish(value))); + const restorePhase = (previous: ActualPhase): Effect.Effect => + Effect.gen(function* () { + const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); + const next: ActualPhase = + previous === "running" && state?.desiredLifecycle === "running" ? "running" : "stopped"; + yield* Ref.set(phase, next); + yield* publish().pipe(Effect.ignore); + }); - const maintenanceHandlers = { - probe: Effect.succeed({ - ok: true, - op: "probe", - ownerSessionId: options.ownerSessionId, + const backend: LifecycleBackend = { + preflight: (input) => + runtime.preflight === undefined ? Effect.succeed({}) : runtime.preflight(input), + reconcile: (input) => + Effect.gen(function* () { + yield* resetForGeneration(input); + const selected = yield* Ref.get(active); + const plan = + input.desiredLifecycle === "running" ? activePlan(input.plan, selected) : input.plan; + const result = yield* reconciler + .reconcile({ + stackId: options.stackId, + desiredGeneration: input.generation, + desiredLifecycle: input.desiredLifecycle, + plan, + }) + .pipe(Effect.mapError(mapReconcileError)); + if (result.failed.length > 0) + return yield* new StackReconciliationError({ + message: result.failed + .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) + .join("; "), + }); + yield* publish(); + }), + cleanup: () => + runtime.driver + .cleanup({ stackId: options.stackId, destroy: false }) + .pipe( + Effect.mapError(mapReconcileError), + Effect.andThen(Ref.set(active, new Set())), + Effect.andThen(Ref.set(phase, "stopped")), + Effect.andThen(publish()), + ), + destroyData: () => + runtime.driver + .cleanup({ stackId: options.stackId, destroy: true }) + .pipe(Effect.mapError(mapReconcileError)), + }; + const controller = yield* makeLifecycleController({ stackId: options.stackId, - rpcRelease: options.rpcRelease, - } satisfies MaintenanceResponse), - stop: stop.pipe( - Effect.as({ ok: true, op: "stop" } satisfies MaintenanceResponse), - Effect.orElseSucceed( - () => - ({ - ok: false, - error: { tag: "operation-failed", message: "Unable to stop stack" }, - }) satisfies MaintenanceResponse, - ), - ), - quiesce: Effect.succeed({ ok: true, op: "quiesce" } satisfies MaintenanceResponse), - }; + runtime: initial.runtime, + stateStore: options.stateStore, + backend, + }).pipe(Effect.provideContext(options.context)); + const status = snapshot(); - const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ - status: () => - status.pipe( - Effect.mapError((error) => rpcError("StackStateInvalidError", stateErrorMessage(error))), - ), - credentials: () => Effect.fail(rpcError("StackNotRunningError", "Stack is not running")), - prepare: () => - Effect.fail(rpcError("StackPreparationError", "Stack preparation is not available yet")), - start: () => - read.pipe( - Effect.mapError(() => rpcError("StackStateInvalidError", "Unable to read stack state")), - Effect.flatMap((state) => - state?.definition === undefined - ? Effect.fail( - rpcError( - "StackDefinitionRequiredError", - "A stack definition is required before starting", - ), - ) - : Effect.fail( - rpcError("StackReconciliationError", "Stack runtime is not available yet"), + const activate = ( + capability: CapabilityName, + ): Effect.Effect => + Effect.gen(function* () { + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + if (state.desiredLifecycle !== "running") + return yield* new StackNotRunningError({ + message: "Stack must be running before activation", + }); + const definition = state.definition; + if (definition === undefined || !definition.capabilities[capability].enabled) + return yield* new GatewayActivationError({ + message: `Capability ${capability} is not enabled`, + }); + const plan = yield* rebuildExecutionPlan(state.runtime, definition).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + const next = new Set(yield* Ref.get(active)); + const visit = (name: CapabilityName): void => { + if (next.has(name)) return; + next.add(name); + for (const dependency of plan.dependencies[name]) visit(dependency); + }; + visit(capability); + yield* Ref.set(active, next); + const input: LifecycleInput = { + stackId: options.stackId, + generation: state.desiredGeneration, + desiredLifecycle: "running", + state, + previous: state, + definition, + inputFingerprint: state.inputFingerprint ?? "", + secrets: state.secrets, + plan, + }; + yield* backend.reconcile(input); + if (runtime.activate === undefined) + return yield* new GatewayActivationError({ + message: `Capability ${capability} has no route endpoint`, + }); + const endpoint = yield* runtime.activate(capability, input); + yield* Ref.set(phase, "running"); + yield* publish(); + return { capability, endpoint }; + }); + + const startOperation = (startOptions?: { readonly config?: StackConfig }) => + Effect.gen(function* () { + const previous = yield* Ref.get(phase); + yield* Ref.set(phase, "starting"); + yield* publish(); + yield* controller.start({ config: startOptions?.config }).pipe( + Effect.provideContext(options.context), + Effect.tapError(() => restorePhase(previous)), + ); + yield* Ref.set(phase, "running"); + return yield* publish(); + }); + const restartOperation = (startOptions?: { readonly config?: StackConfig }) => + Effect.gen(function* () { + const previous = yield* Ref.get(phase); + yield* Ref.set(phase, "starting"); + yield* publish(); + yield* controller.restart({ config: startOptions?.config }).pipe( + Effect.provideContext(options.context), + Effect.tapError(() => restorePhase(previous)), + ); + yield* Ref.set(phase, "running"); + return yield* publish(); + }); + const operation = (effect: Effect.Effect, fallback: StackRpcError["tag"]) => + effect.pipe( + Effect.mapError((error) => rpcError(rpcTag(error, fallback), stateErrorMessage(error))), + ); + const destroy = controller + .destroy() + .pipe(Effect.provideContext(options.context), Effect.andThen(Ref.set(phase, "stopped"))); + const logs = (logOptions?: LogOptions): Stream.Stream => + runtime.logStore === undefined + ? Stream.fail(new StackNotRunningError({ message: "Stack logs are unavailable" })) + : runtime.logStore + .stream(logOptions) + .pipe( + Stream.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), ), + ); + const watchStatus = hub.changes; + const maintenanceHandlers = { + probe: Effect.succeed({ + ok: true, + op: "probe", + ownerSessionId: options.ownerSessionId, + stackId: options.stackId, + rpcRelease: options.rpcRelease, + } satisfies MaintenanceResponse), + stop: controller.stop().pipe( + Effect.provideContext(options.context), + Effect.as({ ok: true, op: "stop" } satisfies MaintenanceResponse), + Effect.orElseSucceed( + () => + ({ + ok: false, + error: { tag: "operation-failed", message: "Unable to stop stack" }, + }) satisfies MaintenanceResponse, ), ), - restart: () => - read.pipe( - Effect.mapError(() => rpcError("StackStateInvalidError", "Unable to read stack state")), - Effect.flatMap((state) => - state?.definition === undefined - ? Effect.fail( - rpcError( - "StackDefinitionRequiredError", - "A stack definition is required before restarting", - ), - ) - : Effect.fail( - rpcError("StackReconciliationError", "Stack runtime is not available yet"), - ), + quiesce: controller.stop().pipe( + Effect.provideContext(options.context), + Effect.as({ ok: true, op: "quiesce" } satisfies MaintenanceResponse), + Effect.orElseSucceed( + () => + ({ + ok: false, + error: { tag: "operation-failed", message: "Unable to quiesce stack" }, + }) satisfies MaintenanceResponse, ), ), - destroy: () => - Effect.fail(rpcError("StackDestructionError", "Stack destruction is not available yet")), - logs: () => Stream.fail(rpcError("StackNotRunningError", "Stack logs are not available yet")), - watchStatus: () => - Stream.fromEffect( + }; + const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ + status: () => status.pipe( Effect.mapError((error) => rpcError("StackStateInvalidError", stateErrorMessage(error))), ), - ), + credentials: () => + Effect.fail(rpcError("StackNotRunningError", "Stack credentials are unavailable")), + prepare: () => + Effect.fail(rpcError("StackPreparationError", "Stack preparation is not available yet")), + start: ({ config }: { readonly config?: StackConfig }) => + operation(startOperation({ config }), "StackReconciliationError"), + restart: ({ config }: { readonly config?: StackConfig }) => + operation(restartOperation({ config }), "StackReconciliationError"), + destroy: () => operation(destroy, "StackDestructionError"), + logs: (logOptions: LogOptions) => + logs(logOptions).pipe( + Stream.mapError((error) => + rpcError(rpcTag(error, "StackStateInvalidError"), stateErrorMessage(error)), + ), + ), + watchStatus: () => + watchStatus.pipe( + Stream.mapError((error) => rpcError("StackStateInvalidError", String(error))), + ), + }); + return { + identity: options.identity, + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + status, + start: startOperation, + restart: restartOperation, + destroy, + watchStatus, + logs, + activate, + maintenanceHandlers, + rpcHandlers, + } satisfies Supervisor; }); - - return { - identity: options.identity, - stackId: options.stackId, - ownerSessionId: options.ownerSessionId, - status, - maintenanceHandlers, - rpcHandlers, - }; -}; diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts new file mode 100644 index 0000000000..7f11950bfd --- /dev/null +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -0,0 +1,385 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Redacted } from "effect"; +import { deriveStackId } from "../identity/Identity.ts"; +import { + StackReconciliationError, + StackStateInvalidError, + StackUpgradeRequiredError, +} from "../public/Errors.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { + makeLifecycleController, + type LifecycleBackend, + type LifecycleInput, +} from "./Lifecycle.ts"; + +const layer = NodeServices.layer; + +const identity = { + projectRoot: "/tmp/supabase-lifecycle", + checkoutRoot: "/tmp/supabase-lifecycle", + workspaceId: "/tmp/supabase-lifecycle", + checkoutId: "/tmp/supabase-lifecycle", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "lifecycle", +} as const; + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +interface BackendState { + readonly calls: Array; + readonly preflight: Array; + failPreflight?: boolean; + failDestroyData?: boolean; + failCleanupOnce?: boolean; + gate?: Deferred.Deferred; + waitBeforeReconcile?: Deferred.Deferred; + stopReconcileStarted?: Deferred.Deferred; + stopReconcileGate?: Deferred.Deferred; +} + +const backend = (state: BackendState): LifecycleBackend => ({ + preflight: (input) => + Effect.gen(function* () { + state.calls.push("preflight"); + state.preflight.push(input); + if (state.failPreflight) + return yield* new StackReconciliationError({ message: "preflight failed" }); + if (state.gate !== undefined) yield* Deferred.await(state.gate); + return { ports: input.previous.ports }; + }), + reconcile: (input) => + Effect.gen(function* () { + state.calls.push(`reconcile:${input.state.desiredLifecycle}`); + if (input.state.desiredLifecycle === "stopped") { + if (state.stopReconcileStarted !== undefined) + yield* Deferred.succeed(state.stopReconcileStarted, undefined); + if (state.stopReconcileGate !== undefined) yield* Deferred.await(state.stopReconcileGate); + } + if (state.waitBeforeReconcile !== undefined) yield* Deferred.await(state.waitBeforeReconcile); + }), + cleanup: (input) => + Effect.gen(function* () { + state.calls.push(`cleanup:${input.state.desiredLifecycle}`); + if (state.failCleanupOnce) { + state.failCleanupOnce = false; + return yield* new StackReconciliationError({ message: "cleanup failed" }); + } + }), + destroyData: (input) => + Effect.gen(function* () { + state.calls.push(`destroy-data:${input.state.desiredLifecycle}`); + if (state.failDestroyData) + return yield* new StackReconciliationError({ message: "destroy-data failed" }); + }), +}); + +const makeFixture = (runtime: StackRuntime = { kind: "native" }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-lifecycle-" }); + const id = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.initialize(id, { + format: "supabase-stack-state-v1", + identity: { ...identity, stackId: id }, + runtime, + desiredGeneration: 0, + desiredLifecycle: "unconfigured", + ports: [], + secrets: {}, + }); + const state: BackendState = { calls: [], preflight: [] }; + const controller = yield* makeLifecycleController({ + stackId: id, + runtime, + stateStore: store, + backend: backend(state), + }); + return { id, root, store, state, controller }; + }); + +const run = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(layer)); + +describe("durable lifecycle controller", () => { + it.live("commits a complete running definition before reconciling", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const result = yield* fixture.controller.start(); + expect(result.desiredLifecycle).toBe("running"); + expect(result.definition).toBeDefined(); + expect(result.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); + expect(result.desiredGeneration).toBe(1); + expect(fixture.state.calls).toEqual(["preflight", "reconcile:running"]); + expect(yield* fixture.store.read(fixture.id)).toEqual(result); + }), + ), + ); + + it.live("reuses persisted definition for an omitted start", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const first = yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); + fixture.state.calls.length = 0; + const second = yield* fixture.controller.stop(); + expect(second.desiredLifecycle).toBe("stopped"); + fixture.state.calls.length = 0; + const third = yield* fixture.controller.start(); + expect(third.definition).toEqual(first.definition); + expect(third.inputFingerprint).toBe(first.inputFingerprint); + expect(third.desiredGeneration).toBe(3); + expect(fixture.state.preflight.at(-1)?.definition).toEqual(first.definition); + }), + ), + ); + + it.live("is idempotent for an identical running input", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const first = yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); + fixture.state.calls.length = 0; + const second = yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); + expect(second).toEqual(first); + expect(fixture.state.calls).toEqual(["reconcile:running"]); + }), + ), + ); + + it.live("accepts pass-through secret changes only through restart", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const original = { + capabilities: { + functions: { + settings: { + functions: { hello: { env: { TOKEN: Redacted.make("one") } } }, + }, + }, + }, + }; + const changed = { + capabilities: { + functions: { + settings: { + functions: { hello: { env: { TOKEN: Redacted.make("two") } } }, + }, + }, + }, + }; + yield* fixture.controller.start({ config: original }); + const running = yield* fixture.controller.start({ config: changed }).pipe(Effect.exit); + expect(errorOf(running)).toBeInstanceOf(StackUpgradeRequiredError); + const restarted = yield* fixture.controller.restart({ config: changed }); + expect(restarted.desiredLifecycle).toBe("running"); + expect(restarted.secrets).toMatchObject({ + "secret:functions.settings.functions.hello.env.TOKEN": { value: "two" }, + }); + }), + ), + ); + + it.live("rejects a changed running input before backend mutation", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); + const before = [...fixture.state.calls]; + const exit = yield* fixture.controller + .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) + .pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(StackUpgradeRequiredError); + expect(fixture.state.calls).toEqual(before); + expect((yield* fixture.store.read(fixture.id))?.desiredGeneration).toBe(1); + }), + ), + ); + + it.live("retains stopped state and makes stop idempotent", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.start(); + fixture.state.calls.length = 0; + const first = yield* fixture.controller.stop(); + expect(first.desiredLifecycle).toBe("stopped"); + expect(first.definition).toBeDefined(); + expect(fixture.state.calls).toEqual(["reconcile:stopped", "cleanup:stopped"]); + fixture.state.calls.length = 0; + const second = yield* fixture.controller.stop(); + expect(second).toEqual(first); + expect(fixture.state.calls).toEqual(["reconcile:stopped", "cleanup:stopped"]); + }), + ), + ); + + it.live("retries stopped cleanup after a prior cleanup failure", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.start(); + fixture.state.calls.length = 0; + fixture.state.failCleanupOnce = true; + const first = yield* fixture.controller.stop().pipe(Effect.exit); + expect(errorOf(first)).toBeInstanceOf(StackReconciliationError); + const stopped = yield* fixture.store.read(fixture.id); + expect(stopped?.desiredLifecycle).toBe("stopped"); + const generation = stopped?.desiredGeneration; + fixture.state.calls.length = 0; + const second = yield* fixture.controller.stop(); + expect(second.desiredLifecycle).toBe("stopped"); + expect(second.desiredGeneration).toBe(generation); + expect(fixture.state.calls).toEqual(["reconcile:stopped", "cleanup:stopped"]); + }), + ), + ); + + it.live("leaves old state untouched when preflight fails", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const first = yield* fixture.controller.start(); + yield* fixture.controller.stop(); + fixture.state.calls.length = 0; + fixture.state.failPreflight = true; + const exit = yield* fixture.controller + .start({ config: { capabilities: { rest: {} } } }) + .pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(StackReconciliationError); + expect(yield* fixture.store.read(fixture.id)).toMatchObject({ + desiredLifecycle: "stopped", + definition: first.definition, + inputFingerprint: first.inputFingerprint, + }); + expect(fixture.state.calls).toEqual(["preflight"]); + }), + ), + ); + + it.live("keeps the running intent when restart preflight fails", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const running = yield* fixture.controller.start(); + fixture.state.calls.length = 0; + fixture.state.failPreflight = true; + const exit = yield* fixture.controller.restart().pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(StackReconciliationError); + expect(yield* fixture.store.read(fixture.id)).toEqual(running); + expect(fixture.state.calls).toEqual(["preflight"]); + }), + ), + ); + + it.live("lets a stop win while restart is still preflighting", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.start(); + fixture.state.calls.length = 0; + const gate = yield* Deferred.make(); + fixture.state.gate = gate; + const restarting = yield* Effect.forkChild(fixture.controller.restart()); + yield* Effect.yieldNow; + const stopping = yield* Effect.forkChild(fixture.controller.stop()); + yield* Fiber.join(stopping); + yield* Deferred.succeed(gate, undefined); + const restartExit = yield* Fiber.join(restarting).pipe(Effect.exit); + expect(Exit.isFailure(restartExit)).toBe(true); + expect(yield* fixture.store.read(fixture.id)).toMatchObject({ + desiredLifecycle: "stopped", + }); + expect(fixture.state.calls).toEqual(["preflight", "reconcile:stopped", "cleanup:stopped"]); + }), + ), + ); + + it.live("does not relaunch when stop is accepted during the restart handoff", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.start(); + fixture.state.calls.length = 0; + const stoppedStarted = yield* Deferred.make(); + const stoppedGate = yield* Deferred.make(); + fixture.state.stopReconcileStarted = stoppedStarted; + fixture.state.stopReconcileGate = stoppedGate; + const restarting = yield* Effect.forkChild(fixture.controller.restart()); + yield* Deferred.await(stoppedStarted); + const stopping = yield* Effect.forkChild(fixture.controller.stop()); + yield* Deferred.succeed(stoppedGate, undefined); + const stopped = yield* Fiber.join(stopping); + expect(stopped.desiredLifecycle).toBe("stopped"); + const restartExit = yield* Fiber.join(restarting).pipe(Effect.exit); + expect(Exit.isFailure(restartExit)).toBe(true); + expect(yield* fixture.store.read(fixture.id)).toMatchObject({ + desiredLifecycle: "stopped", + }); + expect(fixture.state.calls).toEqual([ + "preflight", + "reconcile:stopped", + "cleanup:stopped", + "reconcile:stopped", + "cleanup:stopped", + ]); + }), + ), + ); + + it.live("destroys runtime data before deleting durable state", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.start(); + fixture.state.calls.length = 0; + yield* fixture.controller.destroy(); + expect(fixture.state.calls).toEqual([ + "reconcile:destroying", + "cleanup:destroying", + "destroy-data:destroying", + ]); + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + }), + ), + ); + + it.live("retains the destroying fence when data cleanup fails", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.start(); + fixture.state.calls.length = 0; + fixture.state.failDestroyData = true; + const exit = yield* fixture.controller.destroy().pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(StackReconciliationError); + expect(yield* fixture.store.read(fixture.id)).toMatchObject({ + desiredLifecycle: "destroying", + }); + expect(fixture.state.calls).toEqual([ + "reconcile:destroying", + "cleanup:destroying", + "destroy-data:destroying", + ]); + }), + ), + ); + + it.live("fails closed when state is missing", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.store.cleanup(fixture.id); + const exit = yield* fixture.controller.start().pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/reconciler.integration.test.ts b/packages/stack/src/supervisor/reconciler.integration.test.ts index 006964cdd7..d3620dbc18 100644 --- a/packages/stack/src/supervisor/reconciler.integration.test.ts +++ b/packages/stack/src/supervisor/reconciler.integration.test.ts @@ -97,6 +97,8 @@ const fakeDriver = (options: { Ref.update(resources, (current) => current.filter((entry) => entry.workloadId !== key.workloadId), ), + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), }; return { driver, resources, starts }; }); @@ -194,6 +196,8 @@ describe("stack reconciler", () => { }), stop: () => Effect.void, remove: () => Effect.void, + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, @@ -262,6 +266,8 @@ describe("stack reconciler", () => { }).pipe(Effect.ensuring(Ref.update(active, (value) => value - 1))), stop: () => Effect.void, remove: () => Effect.void, + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, readGeneration: () => Effect.succeed(1) }); const request = { @@ -301,6 +307,8 @@ describe("stack reconciler", () => { }, stop: () => Effect.void, remove: () => Effect.void, + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, @@ -341,6 +349,8 @@ describe("stack reconciler", () => { }, stop: () => Effect.void, remove: () => Effect.void, + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, @@ -377,6 +387,8 @@ describe("stack reconciler", () => { start: () => Effect.die("unexpected start"), stop: (key) => Ref.update(trace, (current) => [...current, `stop:${key.workloadId}`]), remove: (key) => Ref.update(trace, (current) => [...current, `remove:${key.workloadId}`]), + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, @@ -429,6 +441,8 @@ describe("stack reconciler", () => { ), stop: () => Effect.void, remove: () => Effect.void, + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts new file mode 100644 index 0000000000..d95d14c871 --- /dev/null +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -0,0 +1,265 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Ref, Stream } from "effect"; +import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; +import { StackNotRunningError, StackUpgradeRequiredError } from "../public/Errors.ts"; +import { + RuntimeDriverError, + type RuntimeDriver, + type ObservedWorkload, +} from "../runtime/RuntimeDriver.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { deriveStackId } from "../identity/Identity.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { makeSupervisor, type SupervisorRuntime } from "./Supervisor.ts"; + +const identity = { + projectRoot: "/tmp/supabase-supervisor", + checkoutRoot: "/tmp/supabase-supervisor", + workspaceId: "/tmp/supabase-supervisor", + checkoutId: "/tmp/supabase-supervisor", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "supervisor", +} as const; + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +const makeFixture = () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-supervisor-" }); + const id = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.initialize(id, { + format: "supabase-stack-state-v1", + identity: { ...identity, stackId: id }, + runtime: { kind: "native" }, + desiredGeneration: 0, + desiredLifecycle: "unconfigured", + ports: [], + secrets: {}, + }); + const resources = yield* Ref.make>([]); + const calls = yield* Ref.make>([]); + const logOptions = yield* Ref.make>([]); + const failDestroy = yield* Ref.make(false); + const driver: RuntimeDriver = { + observe: () => Ref.get(resources), + start: (key, workload: PlannedWorkload) => + Effect.gen(function* () { + yield* Ref.update(calls, (current) => [...current, `start:${workload.id}`]); + const ready = { ...key, state: "ready" as const }; + yield* Ref.update(resources, (current) => [ + ...current.filter((entry) => entry.workloadId !== key.workloadId), + ready, + ]); + return ready; + }), + stop: (key) => + Ref.update(resources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ), + remove: (key) => + Ref.update(resources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ), + cleanup: ({ destroy }) => + Effect.gen(function* () { + if (destroy && (yield* Ref.get(failDestroy))) + return yield* new RuntimeDriverError({ message: "destroy failed" }); + yield* Ref.update(calls, (current) => [ + ...current, + `cleanup:${destroy ? "destroy" : "stop"}`, + ]); + yield* Ref.set(resources, []); + }), + recover: () => Ref.get(resources), + }; + const entry: StackLogEntry = { + cursor: { opaque: "v1_1" }, + timestamp: "2026-01-01T00:00:00.000Z", + source: "supervisor", + stream: "internal", + message: "hello", + }; + const runtime: SupervisorRuntime = { + driver, + preflight: () => Effect.succeed({}), + activate: () => Effect.succeed({ host: "127.0.0.1", port: 9999 }), + logStore: { + path: "memory://logs", + append: () => Effect.succeed(entry), + read: () => Effect.succeed([entry]), + retained: () => Effect.succeed([entry]), + stream: (options) => + Stream.unwrap( + Ref.update(logOptions, (current) => [...current, options]).pipe( + Effect.map(() => Stream.succeed(entry)), + ), + ), + }, + }; + const context = yield* Effect.context< + FileSystem.FileSystem | Path.Path | import("effect").Crypto.Crypto + >(); + const supervisor = yield* makeSupervisor({ + identity, + stackId: id, + ownerSessionId: "owner-session", + rpcRelease: "test-release", + stateStore: store, + context, + runtime, + }); + yield* Ref.set(calls, []); + return { supervisor, calls, logOptions, failDestroy, context, store, id }; + }); + +const run = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +describe("Supervisor composition", () => { + it.live("starts through the composed lifecycle and reports observed readiness", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); + const status = yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + expect(status.lifecycle).toBe("running"); + expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); + expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); + }), + ), + ); + + it.live("acknowledges quiesce only after runtime cleanup", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const response = yield* fixture.supervisor.maintenanceHandlers.quiesce; + expect(response.ok).toBe(true); + expect(yield* Ref.get(fixture.calls)).toContain("cleanup:stop"); + }), + ), + ); + + it.live("passes log options through the Supervisor log stream", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const options: LogOptions = { follow: false, capabilities: ["auth"] }; + expect(yield* Stream.runCollect(fixture.supervisor.logs(options))).toHaveLength(1); + expect(yield* Ref.get(fixture.logOptions)).toEqual([options]); + }), + ), + ); + + it.live("keeps running state and explicit restart guidance for changed start input", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const failed = yield* fixture.supervisor + .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) + .pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackUpgradeRequiredError); + expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); + }), + ), + ); + + it.live("rejects restart while stopped without relaunching resources", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + yield* fixture.supervisor.maintenanceHandlers.stop; + const failed = yield* fixture.supervisor.restart().pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackNotRunningError); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + }), + ), + ); + + it.live("keeps lazy capabilities dormant until explicit activation", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const status = yield* fixture.supervisor.start({ + config: { + capabilities: { + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { activation: "lazy" }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("dormant"); + expect(yield* Ref.get(fixture.calls)).toEqual(["start:database:database"]); + const activation = yield* fixture.supervisor.activate("functions"); + expect(activation.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); + expect( + (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") + ?.state, + ).toBe("ready"); + }), + ), + ); + + it.live("recovers a running durable intent without auto-starting it", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const recovered = yield* makeSupervisor({ + identity, + stackId: fixture.id, + ownerSessionId: "replacement-owner", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: { + driver: { + observe: () => Effect.succeed([]), + start: () => Effect.die("recovery test must not start resources"), + stop: () => Effect.void, + remove: () => Effect.void, + cleanup: () => Effect.void, + recover: () => + Ref.update(fixture.calls, (current) => [...current, "recover"]).pipe(Effect.as([])), + }, + }, + }); + const status = yield* recovered.status; + expect(status.desiredLifecycle).toBe("running"); + expect(status.lifecycle).toBe("stopped"); + expect(yield* Ref.get(fixture.calls)).toContain("recover"); + }), + ), + ); + + it.live("retains destroying state until exact data cleanup succeeds", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + yield* Ref.set(fixture.failDestroy, true); + const failed = yield* fixture.supervisor.destroy.pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("destroying"); + yield* Ref.set(fixture.failDestroy, false); + yield* fixture.supervisor.destroy; + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + }), + ), + ); +}); From 24ae2c7c19dd223d01354b197ae5d2d46e8fe156 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 07:04:02 +0200 Subject: [PATCH 020/174] feat(stack): expose facades and migrate the CLI --- .../commands/functions/new/new.handler.ts | 2 +- .../commands/functions/serve/serve.command.ts | 7 +- .../commands/functions/serve/serve.handler.ts | 29 +- .../functions/serve/serve.integration.test.ts | 3694 +---------------- .../edge-runtime.service.integration.test.ts | 2 +- .../legacy/shared/legacy-docker-lifecycle.ts | 2 +- .../shared/legacy-local-config-values.ts | 6 +- .../shared/legacy-storage-credentials.ts | 2 +- .../branches/switch/switch.handler.ts | 124 +- .../switch/switch.integration.test.ts | 497 +-- .../commands/functions/dev/dev.command.ts | 52 +- .../functions/dev/functions-dev-config.ts | 119 - .../dev/functions-dev-config.unit.test.ts | 260 -- .../dev/functions-dev-edge-runtime-config.ts | 139 - .../functions-dev-runtime.integration.test.ts | 86 + .../functions/dev/functions-dev-runtime.ts | 330 +- .../dev/functions-dev-runtime.unit.test.ts | 108 - .../functions/list/list.integration.test.ts | 2 - .../functions/new/new.integration.test.ts | 2 - .../src/next/commands/link/link.handler.ts | 14 +- .../src/next/commands/list/list.handler.ts | 46 +- .../src/next/commands/logs/logs.command.ts | 2 +- .../src/next/commands/logs/logs.handler.ts | 124 +- .../commands/logs/logs.integration.test.ts | 82 +- .../commands/services/services.handler.ts | 26 +- .../services/services.integration.test.ts | 55 +- .../commands/start/flows/background.flow.ts | 10 - .../flows/foreground.flow.integration.test.ts | 70 - .../commands/start/flows/foreground.flow.ts | 32 - .../start/flows/non-interactive.flow.ts | 22 - .../service-version-overrides.unit.test.ts | 83 - .../src/next/commands/start/start.command.ts | 280 +- .../commands/start/start.command.unit.test.ts | 19 +- .../src/next/commands/start/start.handler.ts | 104 +- .../commands/start/start.integration.test.ts | 135 +- .../next/commands/start/ui/ConnectionInfo.tsx | 28 - .../next/commands/start/ui/ServiceTable.tsx | 48 - .../next/commands/start/ui/StartDashboard.tsx | 53 - .../start/ui/StartDashboardView.unit.test.ts | 88 - .../ui/dashboard-state.integration.test.ts | 127 - .../next/commands/start/ui/dashboard-state.ts | 72 - .../next/commands/start/ui/dashboard.model.ts | 110 - .../start/ui/dashboard.model.unit.test.ts | 89 - .../commands/start/ui/foreground-session.ts | 78 - .../next/commands/status/status.command.ts | 9 +- .../next/commands/status/status.handler.ts | 346 +- .../status/status.integration.test.ts | 283 +- .../src/next/commands/stop/stop.command.ts | 2 +- .../src/next/commands/stop/stop.handler.ts | 65 +- .../commands/stop/stop.integration.test.ts | 78 +- .../next/commands/update/update.command.ts | 11 +- .../next/commands/update/update.handler.ts | 83 +- .../update/update.integration.test.ts | 109 +- .../src/next/config/managed-port-intents.ts | 24 +- .../src/next/config/project-link-refresh.ts | 30 +- .../next/config/service-version-resolution.ts | 92 - apps/cli/src/next/config/stack-config.ts | 396 +- .../src/next/config/stack-config.unit.test.ts | 89 +- apps/cli/src/next/main.ts | 7 +- apps/cli/src/next/stack/port-drift.ts | 12 - apps/cli/src/next/stack/stack.shared.ts | 93 - apps/cli/src/shared/cli/run.ts | 49 +- apps/cli/src/shared/functions/serve.ts | 2 +- apps/cli/src/shared/stack-constants.ts | 66 + .../error-actionability-coverage.unit.test.ts | 513 +-- .../shared/telemetry/error-actionability.ts | 150 +- .../error-actionability.unit.test.ts | 1177 +----- apps/cli/tests/e2e-global-setup.ts | 23 +- apps/cli/tests/helpers/mocks.ts | 172 +- apps/cli/tests/helpers/running-stack.ts | 343 +- ...26-08-28-stack-runtime-rewrite-progress.md | 21 + .../plans/2026-08-28-stack-runtime-rewrite.md | 8 +- packages/stack/src/index.ts | 26 +- packages/stack/src/public/PromiseStack.ts | 320 ++ packages/stack/src/public/Testing.ts | 65 + .../src/public/promise.integration.test.ts | 80 + .../src/public/testing.integration.test.ts | 8 + packages/stack/src/testing.ts | 3 +- 78 files changed, 1643 insertions(+), 10372 deletions(-) delete mode 100644 apps/cli/src/next/commands/functions/dev/functions-dev-config.ts delete mode 100644 apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts delete mode 100644 apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts create mode 100644 apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts delete mode 100644 apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts delete mode 100644 apps/cli/src/next/commands/start/flows/background.flow.ts delete mode 100644 apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts delete mode 100644 apps/cli/src/next/commands/start/flows/foreground.flow.ts delete mode 100644 apps/cli/src/next/commands/start/flows/non-interactive.flow.ts delete mode 100644 apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts delete mode 100644 apps/cli/src/next/commands/start/ui/ConnectionInfo.tsx delete mode 100644 apps/cli/src/next/commands/start/ui/ServiceTable.tsx delete mode 100644 apps/cli/src/next/commands/start/ui/StartDashboard.tsx delete mode 100644 apps/cli/src/next/commands/start/ui/StartDashboardView.unit.test.ts delete mode 100644 apps/cli/src/next/commands/start/ui/dashboard-state.integration.test.ts delete mode 100644 apps/cli/src/next/commands/start/ui/dashboard-state.ts delete mode 100644 apps/cli/src/next/commands/start/ui/dashboard.model.ts delete mode 100644 apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts delete mode 100644 apps/cli/src/next/commands/start/ui/foreground-session.ts delete mode 100644 apps/cli/src/next/config/service-version-resolution.ts delete mode 100644 apps/cli/src/next/stack/port-drift.ts delete mode 100644 apps/cli/src/next/stack/stack.shared.ts create mode 100644 apps/cli/src/shared/stack-constants.ts create mode 100644 packages/stack/src/public/PromiseStack.ts create mode 100644 packages/stack/src/public/Testing.ts create mode 100644 packages/stack/src/public/promise.integration.test.ts create mode 100644 packages/stack/src/public/testing.integration.test.ts diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index 1531f5df53..6df4e24616 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -1,5 +1,5 @@ import { loadCliConfig } from "@supabase/config/effect"; -import { defaultPublishableKey } from "@supabase/stack/effect"; +import { defaultPublishableKey } from "../../../../shared/stack-constants.ts"; import { Effect, FileSystem, Option, Path } from "effect"; import { diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts index 6cf117a015..c4a73aecb3 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts @@ -2,10 +2,6 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; -import { - FUNCTIONS_SERVE_INSPECT_MODES, - serveFileWatcherLayer, -} from "../../../../shared/functions/serve.ts"; import { legacyCliSettingsLayer } from "../../../config/legacy-cli-settings.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; @@ -14,7 +10,6 @@ import { legacyFunctionsServe } from "./serve.handler.ts"; const cliSettings = legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const legacyFunctionsServeRuntimeLayer = Layer.mergeAll( - serveFileWatcherLayer, cliSettings, legacyDebugLoggerLayer, legacyTelemetryStateLayer, @@ -40,7 +35,7 @@ const config = { Flag.withDescription("Alias of --inspect-mode brk."), Flag.withDefault(false), ), - inspectMode: Flag.choice("inspect-mode", FUNCTIONS_SERVE_INSPECT_MODES).pipe( + inspectMode: Flag.choice("inspect-mode", ["run", "brk", "wait"] as const).pipe( Flag.withDescription("Activate inspector capability for debugging."), Flag.optional, ), diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index e980ea03f0..2b573d806c 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -1,16 +1,15 @@ import { Effect } from "effect"; -import { join } from "node:path"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; -import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; -import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { buildFunctionsServeInspectArgs, resolveFunctionsServeInspectMode, - serveFunctions, type FunctionsServeFlags, } from "../../../../shared/functions/serve.ts"; +import { + serveManagedFunctions, + type ServeManagedFunctionsOperations, +} from "../../../../next/commands/functions/dev/functions-dev-runtime.ts"; export type LegacyFunctionsServeFlags = FunctionsServeFlags; @@ -19,22 +18,12 @@ export const legacyBuildFunctionsServeInspectArgs = buildFunctionsServeInspectAr export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function* ( flags: LegacyFunctionsServeFlags, + operations?: ServeManagedFunctionsOperations, ) { const cliSettings = yield* LegacyCliSettings; - const runtimeInfo = yield* RuntimeInfo; const telemetryState = yield* LegacyTelemetryState; - const debug = yield* LegacyDebugFlag; - const networkId = yield* LegacyNetworkIdFlag; - - yield* serveFunctions(flags, { - projectRoot: cliSettings.workdir, - supabaseDir: join(cliSettings.workdir, "supabase"), - flagCwd: runtimeInfo.cwd, - platform: runtimeInfo.platform, - debug, - networkId, - projectIdOverride: cliSettings.projectId, - goViperCompat: true, - goConfigCompat: legacyFunctionsGoConfigCompat, - }).pipe(Effect.ensuring(telemetryState.flush)); + yield* serveManagedFunctions( + { projectRoot: cliSettings.workdir, stackName: "default" }, + operations, + ).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 7431067966..41cb9d00df 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -1,3630 +1,108 @@ -import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; - +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; +import { CliConfigSchema } from "@supabase/config"; +import type { EffectStack } from "@supabase/stack/effect"; import { - Cause, - Deferred, - Duration, - Effect, - Exit, - Fiber, - Layer, - Option, - PubSub, - Queue, - Sink, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { beforeEach, vi } from "vitest"; - -import { - buildLegacyTestRuntime, mockLegacyCliSettings, - mockLegacyPlatformApiService, - mockLegacyTelemetryStateTracked, - useLegacyTempWorkdir, + mockLegacyTelemetryStateLayer, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { toDockerPath } from "../../../../shared/functions/functions-docker.ts"; -import { - mockOutput, - mockProcessControl, - mockRuntimeInfo, -} from "../../../../../tests/helpers/mocks.ts"; -import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; -import { - FileWatcher, - type FileWatchEvent, -} from "../../../../shared/runtime/file-watcher.service.ts"; -import { - ProcessControl, - type CliProcessSignal, -} from "../../../../shared/runtime/process-control.service.ts"; -import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; -import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import type { LegacyFunctionsServeFlags } from "./serve.handler.ts"; - -const deployMockState = vi.hoisted(() => ({ - runCalls: [] as Array<{ - command: string; - args: ReadonlyArray; - options: unknown; - }>, - networkCalls: [] as Array<{ - networkMode: string; - projectId: string; - }>, - volumeCalls: [] as Array<{ - volumeName: string; - projectId: string; - }>, - runHandler: undefined as - | undefined - | (( - command: string, - args: ReadonlyArray, - options: unknown, - ) => - | { - exitCode: number; - stdout: string; - stderr: string; - } - // Never resolves — lets a test fork+interrupt while this specific call is in flight, - // matching Effect's own canonical "forever pending, interruptible" primitive. - | { pending: true } - // Fails the effect itself — models `spawnContainerCli` failing to spawn - // any container runtime (neither docker nor podman on PATH), as opposed - // to a spawned process exiting non-zero. - | { failure: Error }), - reset() { - this.runCalls = []; - this.networkCalls = []; - this.volumeCalls = []; - this.runHandler = undefined; - }, -})); - -vi.mock("../../../../shared/functions/functions-docker.ts", async () => { - const actual = await vi.importActual< - typeof import("../../../../shared/functions/functions-docker.ts") - >("../../../../shared/functions/functions-docker.ts"); - const { Effect } = await import("effect"); - const { legacyGetRegistryImageUrl } = await import("../../../shared/legacy-docker-registry.ts"); - - return { - ...actual, - ensureDockerNetwork: (networkMode: string, projectId: string) => - Effect.sync(() => { - deployMockState.networkCalls.push({ networkMode, projectId }); - }), - ensureDockerNamedVolume: (volumeName: string, projectId: string) => - Effect.sync(() => { - deployMockState.volumeCalls.push({ volumeName, projectId }); - }), - // Stubbed to the pure registry-mapping step only, skipping the actual - // cache-check/pull: the real implementation - // (`legacyMakeDockerImageResolver`) does `docker image inspect`/`docker - // pull` via the real `ChildProcessSpawner` directly (not through this - // file's mocked `runChildProcess` below), so leaving it real here would - // insert un-mocked spawns — and real 4s/8s retry backoffs on a miss — - // into every test that reaches container start. Registry - // resolution/retry has its own coverage in `functions-docker.unit.test.ts`. - resolveFunctionsDockerImage: ( - image: string, - projectEnvValues?: Readonly>, - ) => Effect.sync(() => legacyGetRegistryImageUrl(image, projectEnvValues)), - runChildProcess: (command: string, args: ReadonlyArray, options?: unknown) => - Effect.suspend(() => { - const envFile = args.flatMap((value, index) => - args[index - 1] === "--env-file" ? [value] : [], - )[0]; - const multilineEnvDir = args - .flatMap((value, index) => (args[index - 1] === "-v" ? [value] : [])) - .find((value) => value.endsWith(":/root/.supabase/multiline-env:ro,Z")) - ?.slice(0, -":/root/.supabase/multiline-env:ro,Z".length); - const enrichedOptions = - envFile === undefined && multilineEnvDir === undefined - ? options - : { - ...(typeof options === "object" && options !== null ? options : {}), - ...(envFile === undefined - ? {} - : { envFileContents: readFileSync(envFile, "utf8") }), - ...(multilineEnvDir === undefined - ? {} - : { - multilineEnvScript: readFileSync( - join(multilineEnvDir, "multiline-env.sh"), - "utf8", - ), - multilineEnvFiles: Object.fromEntries( - readdirSync(join(multilineEnvDir, "values")) - .filter((name) => name.startsWith("env-")) - .map((name) => [ - name, - readFileSync(join(multilineEnvDir, "values", name), "utf8"), - ]), - ), - }), - }; - deployMockState.runCalls.push({ command, args: [...args], options: enrichedOptions }); - const result = deployMockState.runHandler?.(command, args, options) ?? { - exitCode: 0, - stdout: "", - stderr: "", - }; - if ("pending" in result) return Effect.never; - if ("failure" in result) return Effect.fail(result.failure); - return Effect.succeed(result); - }), - }; +import { legacyFunctionsServe } from "./serve.handler.ts"; + +const baseFlags = (): LegacyFunctionsServeFlags => ({ + noVerifyJwt: Option.none(), + envFile: Option.none(), + importMap: Option.none(), + inspect: false, + inspectMode: Option.none(), + inspectMain: false, + all: true, }); -const tempRoot = useLegacyTempWorkdir("supabase-functions-serve-int-"); - -// Root bypasses POSIX permission bits, so chmod-based failure tests can't run there. -const isRoot = typeof process.getuid === "function" && process.getuid() === 0; - -const { legacyFunctionsServe } = await import("./serve.handler.ts"); - -interface LogProcessBehavior { - readonly exitCode?: number; - readonly stdout?: string; - readonly stderr?: string; - readonly pending?: boolean; - readonly onSpawn?: () => void; -} - -function baseFlags(overrides: Partial = {}): LegacyFunctionsServeFlags { - return { - noVerifyJwt: Option.none(), - envFile: Option.none(), - importMap: Option.none(), - inspect: false, - inspectMode: Option.none(), - inspectMain: false, - all: true, - ...overrides, - }; -} - -function extractFlagValues(args: ReadonlyArray, flag: string) { - return args.flatMap((value, index) => (args[index - 1] === flag ? [value] : [])); -} - -async function extractDockerEnvEntries(call: { args: ReadonlyArray; options: unknown }) { - const values = extractFlagValues(call.args, "-e"); - if (values.some((value) => value.includes("="))) { - return values; - } - - const envFile = extractFlagValues(call.args, "--env-file")[0]; - if (envFile !== undefined) { - const options = - typeof call.options === "object" && call.options !== null ? call.options : undefined; - const envFileContents = - options !== undefined && "envFileContents" in options - ? (options.envFileContents as string | undefined) - : undefined; - const contents = envFileContents ?? (await readFile(envFile, "utf8")); - return contents - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0); - } - - const options = - typeof call.options === "object" && call.options !== null ? call.options : undefined; - const env = - options !== undefined && "env" in options - ? (options.env as Readonly> | undefined) - : undefined; - if (env === undefined) { - return values; - } - return values.map((name) => `${name}=${env[name] ?? ""}`); -} - -function waitFor(condition: () => boolean, message: string) { - return Effect.gen(function* () { - const deadline = Date.now() + 3_000; - while (!condition()) { - if (Date.now() >= deadline) { - return yield* Effect.fail(new Error(message)); - } - yield* Effect.sleep(Duration.millis(20)); - } - }); -} - -function mockQueuedProcessControl() { - const signals = Effect.runSync(Queue.unbounded()); - let exitCode: number | undefined; - - return { - layer: Layer.succeed( - ProcessControl, - ProcessControl.of({ - awaitSignal: () => Queue.take(signals), - awaitShutdown: Effect.never, - holdSignals: () => Effect.void, - exit: (code: number) => - Effect.gen(function* () { - exitCode = code; - return yield* Effect.never; - }), - setExitCode: (code: number) => - Effect.sync(() => { - exitCode = code; - }), - getExitCode: Effect.sync(() => exitCode), - }), - ), - signal(signal: CliProcessSignal = "SIGINT") { - Effect.runSync(Queue.offer(signals, signal)); - }, - }; -} - -function mockFileWatcher(expectedPaths: ReadonlyArray = []) { - const pubsub = Effect.runSync(PubSub.unbounded>({ replay: 8 })); - const expectedWatch = Effect.runSync(Deferred.make()); - const watchCalls: Array<{ - path: string; - ignore?: ReadonlyArray; - recursive?: boolean; - }> = []; - - return { - layer: Layer.succeed( - FileWatcher, - FileWatcher.of({ - watch: (path, options) => { - watchCalls.push({ - path, - ignore: options?.ignore, - recursive: options?.recursive, - }); - if ( - expectedPaths.every((expectedPath) => - watchCalls.some((call) => call.path === expectedPath), - ) - ) { - Effect.runSync(Deferred.succeed(expectedWatch, undefined)); - } - return Stream.fromPubSub(pubsub); - }, - }), - ), - emit(events: ReadonlyArray) { - PubSub.publishUnsafe(pubsub, events); - }, - get watchCalls() { - return watchCalls; - }, - awaitExpectedWatch: Deferred.await(expectedWatch), - }; -} - -function mockDockerLogSpawner(behaviors: ReadonlyArray) { - const spawned: Array<{ command: string; args: ReadonlyArray }> = []; - let index = 0; - - return { - layer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => +const fakeStatus = { + lifecycle: "running" as const, + desiredLifecycle: "running" as const, + runtime: { kind: "container" as const, engine: "docker" as const }, + endpoints: {}, + versions: {}, + capabilities: [], +}; + +describe("legacy functions serve", () => { + it.live("starts the managed Functions capability with config and streams stack logs", () => { + let startedConfig: unknown; + const stack = { + id: "stack-test", + start: (options?: { config?: unknown }) => Effect.sync(() => { - if (command._tag !== "StandardCommand") { - throw new Error(`unexpected child process kind: ${command._tag}`); - } - - const record = { - command: command.command, - args: [...command.args], - }; - spawned.push(record); - const behavior = behaviors[Math.min(index, behaviors.length - 1)] ?? {}; - index += 1; - behavior.onSpawn?.(); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1_000 + spawned.length), - exitCode: - behavior.pending === true - ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(behavior.exitCode ?? 0)), - isRunning: Effect.succeed(behavior.pending === true), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: - behavior.stdout === undefined - ? Stream.empty - : Stream.make(new TextEncoder().encode(behavior.stdout)), - stderr: - behavior.stderr === undefined - ? Stream.empty - : Stream.make(new TextEncoder().encode(behavior.stderr)), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); + startedConfig = options?.config; + return fakeStatus; }), - ), - ), - get spawned() { - return spawned; - }, - }; -} - -interface SetupOptions { - readonly debug?: boolean; - readonly workdir?: string; - readonly networkId?: Option.Option; - readonly projectId?: Option.Option; - readonly processControl?: - | ReturnType - | ReturnType; - readonly fileWatcher?: ReturnType; - readonly childSpawner?: ReturnType; -} - -function setupServe(options: SetupOptions = {}) { - const workdir = options.workdir ?? tempRoot.current; - const out = mockOutput({ format: "text", interactive: false }); - const telemetry = mockLegacyTelemetryStateTracked(); - const cliSettings = mockLegacyCliSettings({ - workdir, - projectId: options.projectId ?? Option.none(), - }); - const api = mockLegacyPlatformApiService({ v1: {} }); - const processControl = options.processControl ?? mockProcessControl(); - const fileWatcher = options.fileWatcher ?? mockFileWatcher(); - const childSpawner = options.childSpawner ?? mockDockerLogSpawner([{ exitCode: 1 }]); - - const layer = Layer.mergeAll( - buildLegacyTestRuntime({ - out, - api, - cliSettings, - telemetry: telemetry.layer, - runtimeInfo: mockRuntimeInfo({ - cwd: workdir, - homeDir: workdir, - platform: "linux", - }), - processControl, - }), - fileWatcher.layer, - childSpawner.layer, - Layer.succeed(LegacyDebugFlag, options.debug ?? false), - Layer.succeed(LegacyNetworkIdFlag, options.networkId ?? Option.none()), - ); - - return { layer, out, telemetry, processControl, fileWatcher, childSpawner }; -} - -async function writeCliConfig(content: string) { - await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); - await writeFile(join(tempRoot.current, "supabase", "config.toml"), content); -} - -async function writeFunctionFile(slug: string, relativePath: string, contents: string) { - const pathname = join(tempRoot.current, "supabase", "functions", slug, relativePath); - await mkdir(dirname(pathname), { recursive: true }); - await writeFile(pathname, contents); -} - -async function writeProjectFile(relativePath: string, contents: string) { - const pathname = join(tempRoot.current, relativePath); - await mkdir(dirname(pathname), { recursive: true }); - await writeFile(pathname, contents); -} - -beforeEach(() => { - deployMockState.reset(); -}); - -describe("legacy functions serve integration", () => { - it.live("overlays each Function's env file on the shared fallback", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeFunctionFile("world", "index.ts", 'Deno.serve(() => new Response("world"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["SHARED=shared", "GLOBAL_ONLY=global", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - ".env", - ["SHARED=hello", "FUNCTION_ONLY=hello", "SUPABASE_SKIP=ignored", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("world", ".env", ["SHARED=world", "FUNCTION_ONLY=world", ""].join("\n")), - ); - - const { layer, out } = setupServe({ childSpawner }); - yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("SHARED=shared"); - expect(envs).toContain("GLOBAL_ONLY=global"); - const functionsConfig = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), - ); - expect(functionsConfig).toBeDefined(); - if (functionsConfig === undefined) { - throw new Error("missing functions config env"); - } - - expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), - ).toEqual({ - hello: expect.objectContaining({ - env: { SHARED: "hello", FUNCTION_ONLY: "hello" }, - }), - world: expect.objectContaining({ - env: { SHARED: "world", FUNCTION_ONLY: "world" }, - }), - }); - expect(out.stderrText).toContain( - "Env name cannot start with SUPABASE_, skipping: SUPABASE_SKIP\n", - ); - }); - }); - - it.live("uses an explicit env file instead of automatic Function env files", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["SOURCE=shared", "GLOBAL_ONLY=global", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", ".env", "INVALID-KEY=must-not-be-read\n"), - ); - yield* Effect.promise(() => - writeProjectFile( - "custom.env", - ["SOURCE=explicit", "EXPLICIT_ONLY=explicit", ""].join("\n"), - ), - ); - - const { layer } = setupServe({ childSpawner }); - yield* legacyFunctionsServe(baseFlags({ envFile: Option.some("custom.env") })).pipe( - Effect.provide(layer), - Effect.flip, - ); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("SOURCE=explicit"); - expect(envs).toContain("EXPLICIT_ONLY=explicit"); - expect(envs).not.toContain("GLOBAL_ONLY=global"); - const functionsConfig = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), - ); - expect(functionsConfig).toBeDefined(); - if (functionsConfig === undefined) { - throw new Error("missing functions config env"); - } - expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), - ).toEqual({ - hello: { - verifyJWT: true, - entrypointPath: "supabase/functions/hello/index.ts", - }, - }); - }); - }); - - it.live("fails before starting the runtime when a Function env file is malformed", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - const functionEnvPath = join(tempRoot.current, "supabase", "functions", "hello", ".env"); - yield* Effect.promise(() => writeFunctionFile("hello", ".env", "API-KEY=secret-value\n")); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain(`failed to parse environment file: ${functionEnvPath}`); - expect(error.message).toContain("unexpected character '-' in variable name"); - expect(error.message).not.toContain("secret-value"); - expect(error.message).not.toContain('near "API-KEY=secret-value"'); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - }); - }); - - it.live( - "starts the runtime from config-defined functions and wires env, binds, and telemetry", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "error running container: exit 1", - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/src/main.ts"', - 'import_map = "./functions/hello/deno.json"', - 'static_files = ["./shared/index.html"]', - "", - "[functions.disabled]", - "enabled = false", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "src/main.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - yield* Effect.promise(() => - writeProjectFile("supabase/shared/index.html", "

hello

\n"), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["HELLO=WORLD", "SUPABASE_SKIP=1", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", ".temp", "edge-runtime-version"), "1.73.13\n"), - ); - - const { layer, out, telemetry } = setupServe({ childSpawner }); - - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("error running container: exit 1"); - } - - expect(deployMockState.volumeCalls).toEqual([ - { - volumeName: "supabase_edge_runtime_test-project", - projectId: "test-project", - }, - ]); - expect(deployMockState.networkCalls).toEqual([ + logs: () => + Stream.fromIterable([ { - networkMode: "supabase_network_test-project", - projectId: "test-project", + cursor: { opaque: "1" }, + timestamp: "now", + source: "functions", + stream: "stdout", + message: "ready", }, - ]); - expect(telemetry.flushed).toBe(true); - expect(out.stderrText).toContain("Setting up Edge Functions runtime...\n"); - expect(out.stderrText).toContain("Skipped serving Function: disabled\n"); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - expect(dockerRun.args).toContain("--network"); - expect(dockerRun.args).toContain("supabase_network_test-project"); - expect(dockerRun.args).toContain("--add-host"); - expect(dockerRun.args).toContain("host.docker.internal:host-gateway"); - // The pin's content is applied VERBATIM as the tag (Go's - // `replaceImageTag`, `pkg/config/utils.go:81-84`) — a bare pin stays - // bare, no `v` synthesized. - expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:1.73.13"); - // The main service is `docker cp`-streamed in, never a single-file host bind (#6254). - expect( - extractFlagValues(dockerRun.args, "-v").some((value) => - value.includes(":/root/index.ts"), - ), - ).toBe(false); - const bringUpSteps = deployMockState.runCalls - .map((call) => call.args[0]) - .filter((step) => step === "create" || step === "cp" || step === "start"); - expect(bringUpSteps).toEqual(["create", "cp", "start"]); - expect(deployMockState.runCalls.map((call) => call.args.slice(0, 3))).toContainEqual([ - "cp", - "-", - "supabase_edge_runtime_test-project:/", - ]); - expect(extractFlagValues(dockerRun.args, "--workdir")).toEqual([ - toDockerPath(tempRoot.current), - ]); - expect(dockerRun.args[dockerRun.args.length - 1]).toBe( - "exec edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", - ); - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("HELLO=WORLD"); - expect(envs).not.toContain("SUPABASE_SKIP=1"); - const functionsConfig = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), - ); - expect(functionsConfig).toBeDefined(); - if (functionsConfig === undefined) { - throw new Error("missing functions config env"); - } - - expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), - ).toEqual({ - hello: { - verifyJWT: true, - entrypointPath: "supabase/functions/hello/src/main.ts", - importMapPath: "supabase/functions/hello/deno.json", - staticFiles: ["supabase/shared/index.html"], - }, - }); - - // The reload must carry bring-up's `--nginx-conf`; a bare `kong reload` - // re-renders nginx.conf from Kong's default template and drops the - // `email_templates` server GoTrue fetches (issue #6059). - expect(deployMockState.runCalls).toContainEqual({ - command: "docker", - args: [ - "exec", - "supabase_kong_test-project", - "kong", - "reload", - "--nginx-conf", - "/home/kong/custom_nginx.template", - ], - options: { stdout: "ignore", stderr: "pipe" }, - }); - - expect(childSpawner.spawned).toEqual([ - { - command: "docker", - args: ["logs", "-f", "--timestamps", "supabase_edge_runtime_test-project"], - }, - ]); - }); - }, - ); - - it.live("mounts multiline env values without placing their contents in docker argv", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - let multilineEnvDirWhenLogsStarted: string | undefined; - let multilineEnvDirExistedWhenLogsStarted = false; - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "error running container: exit 1", - onSpawn: () => { - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - if (dockerRun === undefined) { - throw new Error("expected docker create call before docker logs spawn"); - } - multilineEnvDirWhenLogsStarted = extractFlagValues(dockerRun.args, "-v") - .find((value) => value.endsWith(":/root/.supabase/multiline-env:ro,Z")) - ?.slice(0, -":/root/.supabase/multiline-env:ro,Z".length); - multilineEnvDirExistedWhenLogsStarted = - multilineEnvDirWhenLogsStarted !== undefined && - existsSync(multilineEnvDirWhenLogsStarted); - }, - }, - ]); - - const multilineValue = ["-----BEGIN KEY-----", "EOF_ENV_0", "line-3", "-----END KEY-----"].join( - "\n", - ); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - [`MULTILINE_SECRET="${multilineValue}"`, ""].join("\n"), - ), - ); - - const { layer } = setupServe({ childSpawner }); - - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - expect(error).toBeInstanceOf(Error); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - expect(dockerRun.args).toContain( - legacyGetRegistryImageUrl(dockerfileServiceImage("edgeruntime")), - ); - expect(dockerRun.args.join(" ")).not.toContain(multilineValue); - expect(dockerRun.args.join(" ")).not.toContain("EOF_ENV_0"); - - const multilineBind = extractFlagValues(dockerRun.args, "-v").find((value) => - value.endsWith(":/root/.supabase/multiline-env:ro,Z"), - ); - expect(multilineBind).toBeDefined(); - if (multilineBind === undefined) { - throw new Error("expected multiline env bind"); - } - - const options = - typeof dockerRun.options === "object" && dockerRun.options !== null - ? dockerRun.options - : undefined; - const script = - options !== undefined && "multilineEnvScript" in options - ? (options.multilineEnvScript as string | undefined) - : undefined; - const files = - options !== undefined && "multilineEnvFiles" in options - ? (options.multilineEnvFiles as Record | undefined) - : undefined; - - expect(script).toBeDefined(); - expect(files).toBeDefined(); - expect(script).toContain( - 'MULTILINE_SECRET="$(cat /root/.supabase/multiline-env/values/env-0; printf x)"', - ); - expect(script).toContain('export MULTILINE_SECRET="${MULTILINE_SECRET%x}"'); - expect(script).not.toContain(multilineValue); - expect(script).not.toContain("EOF_ENV_0"); - expect(files?.["env-0"]).toBe(multilineValue); - expect(multilineEnvDirWhenLogsStarted).toBeDefined(); - if (multilineEnvDirWhenLogsStarted === undefined) { - throw new Error("expected multiline env dir when docker logs started"); - } - expect(multilineEnvDirExistedWhenLogsStarted).toBe(true); - expect(existsSync(multilineEnvDirWhenLogsStarted)).toBe(false); - }); - }); - - it.live( - "cleans up a stale multiline-env directory from a previous run even when this run has no multiline secrets", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "error running container: exit 1", - }, - ]); - - const staleMultilineEnvDir = join( - tempRoot.current, - "supabase", - ".temp", - "start-secrets", - "supabase_edge_runtime_test-project", - "multiline-env", - ); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", "functions", ".env"), ["HELLO=WORLD", ""].join("\n")), - ); - // Simulate a stale directory left behind by an earlier run that DID have multiline secrets. - yield* Effect.promise(async () => { - await mkdir(join(staleMultilineEnvDir, "values"), { recursive: true, mode: 0o700 }); - await writeFile(join(staleMultilineEnvDir, "multiline-env.sh"), "stale script\n"); - await writeFile(join(staleMultilineEnvDir, "values", "env-0"), "stale secret\n"); - }); - - const { layer } = setupServe({ childSpawner }); - - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - expect(error).toBeInstanceOf(Error); - - expect(existsSync(staleMultilineEnvDir)).toBe(false); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - expect( - extractFlagValues(dockerRun.args, "-v").some((value) => - value.endsWith(":/root/.supabase/multiline-env:ro,Z"), - ), - ).toBe(false); - }); - }, - ); - - it.live("fails before startup when a multiline env name is not a shell identifier", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ['FOO.BAR="line-1\nline-2"', ""].join("\n"), - ), - ); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("invalid multiline environment variable name"); - expect(error.message).toContain("FOO.BAR"); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - }); - }); - - it.live("sanitizes dotenv parse failures from config env files", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => writeProjectFile(".env.development", "API-KEY=secret-value\n")); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("failed to parse environment file:"); - expect(error.message).toContain(".env.development"); - expect(error.message).toContain("unexpected character '-' in variable name"); - expect(error.message).not.toContain("secret-value"); - expect(error.message).not.toContain('near "API-KEY=secret-value"'); - } - expect(deployMockState.runCalls).toHaveLength(0); - }); - }); - - it.live("skips missing unused import map targets during serve startup", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "error running container: exit 1", - }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - imports: { - "unused-alias/": "../missing-shared/", - }, - }), - ), - ); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("error running container: exit 1"); - } - expect( - deployMockState.runCalls.some( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toBe(true); - }); - }); - - it.live("binds deno.json import map references outside the project root", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "external import map logs failed", - }, - ]); - - return Effect.gen(function* () { - const externalImportMapPath = join(dirname(tempRoot.current), "shared-import-map.json"); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFile(externalImportMapPath, JSON.stringify({ imports: {} })), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - importMap: "../../../../shared-import-map.json", - }), - ), - ); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("external import map logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create invocation"); - } - // `buildDockerBinds` realpath-resolves host paths, so compare against the - // resolved path (on macOS the temp dir lives under /var -> /private/var). - const resolvedExternalImportMapPath = realpathSync(externalImportMapPath); - expect( - extractFlagValues(dockerRun.args, "-v").some( - (value) => - value.startsWith(`${resolvedExternalImportMapPath}:`) && - value.endsWith("/shared-import-map.json:ro"), - ), - ).toBe(true); - }); - }); - - it.live("binds git-root workspace imports for serve", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { - exitCode: 1, - stderr: "workspace import logs failed", - }, - ]); - - return Effect.gen(function* () { - const sharedPath = join(tempRoot.current, "packages", "shared", "src", "index.ts"); - - yield* Effect.promise(() => mkdir(join(tempRoot.current, ".git"), { recursive: true })); - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile("packages/shared/src/index.ts", 'export const shared = "hello"\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "index.ts", - [ - 'import { shared } from "@repo/shared"', - "Deno.serve(() => new Response(shared))", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - imports: { - "@repo/shared": "../../../packages/shared/src/index.ts", - }, - }), - ), - ); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("workspace import logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create invocation"); - } - const resolvedSharedPath = realpathSync(sharedPath); - expect( - extractFlagValues(dockerRun.args, "-v").some( - (value) => - value.startsWith(`${resolvedSharedPath}:`) && - value.endsWith("/packages/shared/src/index.ts:ro"), - ), - ).toBe(true); - }); - }); - - it.live("leaves the existing container alone when create loses a name conflict", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create") { - return { - exitCode: 1, - stdout: "", - stderr: - 'Conflict. The container name "/supabase_edge_runtime_test-project" is already in use', - }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig('project_id = "test-project"\n')); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - const steps = deployMockState.runCalls.map((call) => call.args.slice(0, 2)); - const createIndex = steps.findIndex(([first]) => first === "create"); - expect(createIndex).toBeGreaterThan(-1); - expect( - steps - .slice(createIndex + 1) - .some(([first, second]) => first === "container" && second === "rm"), - ).toBe(false); - }); - }); - - it.live("removes the created container when the bootstrap copy fails", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && (args[1] === "inspect" || args[1] === "rm")) { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "cp") { - return { exitCode: 1, stdout: "", stderr: "cp target is gone" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig('project_id = "test-project"\n')); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - expect(String(error)).toContain( - "failed to copy edge runtime main service into container: cp target is gone", - ); - const steps = deployMockState.runCalls.map((call) => call.args.slice(0, 2)); - const createIndex = steps.findIndex(([first]) => first === "create"); - expect(createIndex).toBeGreaterThan(-1); - expect(steps.some(([first]) => first === "start")).toBe(false); - expect( - steps - .slice(createIndex + 1) - .some(([first, second]) => first === "container" && second === "rm"), - ).toBe(true); - }); - }); - - it.live("binds per-function deno.json scope targets outside a nested project repository", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const processControl = mockQueuedProcessControl(); - const childSpawner = mockDockerLogSpawner([{ pending: true }]); - - return Effect.gen(function* () { - const workspaceRoot = tempRoot.current; - const projectRoot = join(workspaceRoot, "infra", "my-project"); - const rootDenoJson = join(workspaceRoot, "deno.json"); - const libsDir = join(workspaceRoot, "libs"); - - yield* Effect.promise(() => mkdir(join(workspaceRoot, ".git"), { recursive: true })); - yield* Effect.promise(async () => { - await writeProjectFile(join("infra", "my-project", ".git"), "gitdir: ignored\n"); - await writeProjectFile( - "deno.json", - JSON.stringify({ - workspace: ["./libs/*", "./infra/*/supabase/functions/*"], - imports: { "@acme/thing": "./libs/thing/index.ts" }, - }), - ); - await writeProjectFile( - join("infra", "my-project", "supabase", "config.toml"), - 'project_id = "test-project"\n', - ); - await writeProjectFile( - join("libs", "thing", "deno.json"), - JSON.stringify({ name: "@acme/thing", version: "1.0.0", exports: "./index.ts" }), - ); - await writeProjectFile(join("libs", "thing", "index.ts"), "export const thing = 1\n"); - const functionRelative = join("infra", "my-project", "supabase", "functions", "hello"); - await writeProjectFile( - join(functionRelative, "index.ts"), - 'import { thing } from "@acme/thing"\nDeno.serve(() => new Response(String(thing)))\n', - ); - const sharedDenoJson = JSON.stringify({ - imports: { "@std/assert": "jsr:@std/assert@1" }, - scopes: { - __local: { - __workspace: "../../../../../deno.json", - __libs: "../../../../../libs", - }, - }, - }); - await writeProjectFile(join(functionRelative, "deno.json"), sharedDenoJson); - const worldRelative = join("infra", "my-project", "supabase", "functions", "world"); - await writeProjectFile( - join(worldRelative, "index.ts"), - 'import { thing } from "@acme/thing"\nDeno.serve(() => new Response(String(thing)))\n', - ); - await writeProjectFile(join(worldRelative, "deno.json"), sharedDenoJson); - }); - - const resolvedWorkspaceRoot = realpathSync(workspaceRoot); - const resolvedLibsDir = realpathSync(libsDir); - const watchedFunctionsDir = join(projectRoot, "supabase", "functions"); - const fileWatcher = mockFileWatcher([watchedFunctionsDir]); - const { layer, out } = setupServe({ - childSpawner, - fileWatcher, - processControl, - workdir: projectRoot, - }); - const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* fileWatcher.awaitExpectedWatch; - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create invocation"); - } - const bindValues = extractFlagValues(dockerRun.args, "-v"); - const resolvedRootDenoJson = realpathSync(rootDenoJson); - expect(bindValues).toContain(`${resolvedRootDenoJson}:${toDockerPath(rootDenoJson)}:ro`); - expect(bindValues).toContain(`${resolvedLibsDir}:${toDockerPath(libsDir)}:ro`); - const rootDenoJsonWarn = `WARN: Mounting import map scope target outside the project root: ${resolvedRootDenoJson}\n`; - const libsWarn = `WARN: Mounting import map scope target outside the project root: ${resolvedLibsDir}\n`; - expect(out.rawChunks.filter((chunk) => chunk.text === rootDenoJsonWarn)).toEqual([ - { text: rootDenoJsonWarn, stream: "stderr" }, - ]); - expect(out.rawChunks.filter((chunk) => chunk.text === libsWarn)).toEqual([ - { text: libsWarn, stream: "stderr" }, - ]); - const watchedPaths = fileWatcher.watchCalls.map((call) => call.path); - expect(watchedPaths).toContain(watchedFunctionsDir); - expect(watchedPaths).not.toContain(resolvedWorkspaceRoot); - expect(watchedPaths).not.toContain(resolvedLibsDir); - expect(fileWatcher.watchCalls).toContainEqual( - expect.objectContaining({ path: watchedFunctionsDir, recursive: true }), - ); - - processControl.signal("SIGINT"); - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - }); - }); - - it.live("restarts the runtime when watched files change", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const fileWatcher = mockFileWatcher(); - const childSpawner = mockDockerLogSpawner([ - { pending: true }, - { exitCode: 1, stderr: "docker logs exited with 1" }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer, out } = setupServe({ fileWatcher, childSpawner }); - const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* waitFor( - () => - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ).length === 1, - "timed out waiting for first docker create", - ); - - fileWatcher.emit([ - { - path: join(tempRoot.current, "supabase", "functions", "hello", "index.ts"), - type: "update", - }, - { - path: join(tempRoot.current, "supabase", "functions", "hello", "helper.ts"), - type: "create", - }, - ]); - - const error = yield* Fiber.join(fiber).pipe(Effect.flip); - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("docker logs exited with 1"); - } - - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(2); - // The file-change line prints the fsnotify op token Go prints - // (`event.Op.String()`, `internal/functions/serve/watcher.go:100`) — - // WRITE/CREATE/REMOVE — not the internal event-type name. - expect(out.stderrText).toContain( - `File change detected: ${join(tempRoot.current, "supabase", "functions", "hello", "index.ts")} (WRITE)`, - ); - expect(out.stderrText).toContain( - `File change detected: ${join(tempRoot.current, "supabase", "functions", "hello", "helper.ts")} (CREATE)`, - ); - - // `functions serve`'s restart wrapper (`startEdgeRuntime`, Go's - // `restartEdgeRuntime`) reloads Kong after each successful bring-up — - // once for the initial start, once for the file-change-triggered restart. - expect( - deployMockState.runCalls.filter( - (call) => - call.command === "docker" && - call.args[0] === "exec" && - call.args.includes("supabase_kong_test-project") && - call.args.includes("reload"), - ), - ).toHaveLength(2); - }); - }); - - it.live("stops serving cleanly on a process signal", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const processControl = mockQueuedProcessControl(); - const childSpawner = mockDockerLogSpawner([{ pending: true }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer, out } = setupServe({ processControl, childSpawner }); - const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* waitFor( - () => - deployMockState.runCalls.some( - (call) => call.command === "docker" && call.args[0] === "create", - ), - "timed out waiting for docker create", - ); - processControl.signal("SIGINT"); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - expect( - out.stdoutText - .replaceAll("\u001b[1m", "") - .replaceAll("\u001b[22m", "") - .replaceAll("\\", "/"), - ).toContain("Stopped serving supabase/functions\n"); - }); - }); - - it.live("does not remove the existing runtime when interrupted before startup owns it", () => { - const processControl = mockQueuedProcessControl(); - // Block startup at the DB assertion (`container inspect`) — the last - // pre-ownership step under the established ordering (config load → - // assert DB → only THEN remove the existing container). The remote-JWKS - // fetch is post-assertion, so if the ordering ever regresses to - // fetch-first, this test hangs at the pending fetch instead of reaching - // the inspect and fails on the waitFor timeout. - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { pending: true }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( - () => - new Promise(() => { - // Intentionally pending — must never be reached before the assertion. - }), - ); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example.com"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer, out } = setupServe({ processControl }); - const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* waitFor( - () => - deployMockState.runCalls.some( - (call) => - call.command === "docker" && - call.args[0] === "container" && - call.args[1] === "inspect", - ), - "timed out waiting for the DB inspect", - ); - processControl.signal("SIGINT"); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - expect( - deployMockState.runCalls.some( - (call) => - call.command === "docker" && - call.args[0] === "container" && - call.args[1] === "rm" && - call.args.includes("supabase_edge_runtime_test-project"), - ), - ).toBe(false); - // No remote JWKS request either — Go resolves JWKS only after the DB - // assertion succeeds (`serve.go:141`). - expect(fetchMock).not.toHaveBeenCalled(); - expect(out.stdoutText).toContain("Stopped serving"); - }); - }); - - it.live( - "cleans up staged secrets when interrupted while reloading Kong after a successful bring-up", - () => { - const processControl = mockQueuedProcessControl(); - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - // Hangs Kong reload so the interrupt below lands after bring-up succeeded (staged - // secrets already written, `startedRuntime` already assigned) but before this - // wrapper's own `reloadKong` call returns. - return { pending: true }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ pending: true }]); - - const stagingDir = join( - tempRoot.current, - "supabase", - ".temp", - "start-secrets", - "supabase_edge_runtime_test-project", - ); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ processControl, childSpawner }); - const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* waitFor( - () => - deployMockState.runCalls.some( - (call) => call.command === "docker" && call.args[0] === "exec", - ), - "timed out waiting for Kong reload to start", - ); - expect(existsSync(stagingDir)).toBe(true); - processControl.signal("SIGINT"); - - const exit = yield* Fiber.await(fiber); - expect(Exit.isSuccess(exit)).toBe(true); - - expect( - deployMockState.runCalls.some( - (call) => - call.command === "docker" && - call.args[0] === "container" && - call.args[1] === "rm" && - call.args.includes("supabase_edge_runtime_test-project"), - ), - ).toBe(true); - expect(existsSync(stagingDir)).toBe(false); - }); - }, - ); - - it.live("passes inspect, debug, and custom network settings through to edge-runtime", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "inspect failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ - debug: true, - networkId: Option.some("custom-network"), - childSpawner, - }); - - const error = yield* legacyFunctionsServe( - baseFlags({ - inspectMode: Option.some("wait"), - inspectMain: true, - }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("inspect failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - expect(dockerRun.args).toContain("--network"); - expect(dockerRun.args).toContain("custom-network"); - expect(dockerRun.args).toContain("-p"); - expect(dockerRun.args).toContain("8083:8083"); - - const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? ""; - expect(commandScript).toContain("--inspect-wait=0.0.0.0:8083"); - expect(commandScript).toContain("--inspect-main"); - expect(commandScript).toContain("--verbose"); - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("SUPABASE_INTERNAL_DEBUG=true"); - expect(envs).toContain("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0"); - expect(deployMockState.networkCalls).toEqual([ - { networkMode: "custom-network", projectId: "test-project" }, - ]); - }); - }); - - it.live("injects the Deno runtime template without the TypeScript-only preamble", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "template logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe({ childSpawner }); - yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? ""; - expect(commandScript).toBe( - "exec edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", - ); - - const cp = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "cp", - ); - expect(cp).toBeDefined(); - if (cp === undefined) { - throw new Error("expected docker cp call"); - } - const cpOptions: unknown = cp.options; - const stdin = - typeof cpOptions === "object" && cpOptions !== null && "stdin" in cpOptions - ? cpOptions.stdin - : undefined; - // Narrows the mock-recorded `unknown`; the `instanceof Uint8Array` check still guards. - const isCpArchiveStream = (value: unknown): value is Stream.Stream => - Stream.isStream(value); - expect(isCpArchiveStream(stdin)).toBe(true); - if (!isCpArchiveStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream"); - const chunks = yield* Stream.runCollect(stdin); - const archiveBytes = chunks[0]; - if (!(archiveBytes instanceof Uint8Array)) { - return yield* Effect.die("docker cp stdin did not contain archive bytes"); - } - const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files()); - const mainService = files.get("root/index.ts"); - if (mainService === undefined) { - return yield* Effect.die("docker cp archive did not contain root/index.ts"); - } - const template = yield* Effect.promise(() => mainService.text()); - expect(template.length).toBeGreaterThan(0); - expect(template).not.toContain("@ts-nocheck"); - expect(template).not.toContain("declare const Deno"); - expect(template).not.toContain("declare const EdgeRuntime"); - expect(commandScript).not.toContain("@ts-nocheck"); - }); - }); - - it.live("maps the configured inspector_port to the container inspector port", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }; - - const childSpawner = mockDockerLogSpawner([ - { exitCode: 1, stderr: "inspect port logs failed" }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime]", - 'policy = "per_worker"', - "inspector_port = 9229", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe({ childSpawner }); - yield* legacyFunctionsServe(baseFlags({ inspect: true })).pipe( - Effect.provide(layer), - Effect.flip, - ); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - expect(dockerRun.args).toContain("-p"); - expect(dockerRun.args).toContain("9229:8083"); - expect(dockerRun.args).not.toContain("8083:8083"); - }); - }); - - it.live("fetches remote jwks for enabled third-party auth providers", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); - - return Effect.gen(function* () { - const remoteKeys = [ - { - kty: "RSA", - kid: "remote-key", - alg: "RS256", - use: "sig", - n: "abc", - e: "AQAB", - }, - ]; - - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://issuer.example/.well-known/openid-configuration") { - return new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url === "https://issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("jwks logs failed"); - } - - expect(fetchMock).toHaveBeenCalledTimes(2); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); - expect(jwks).toBeDefined(); - if (jwks === undefined) { - throw new Error("missing SUPABASE_JWKS"); - } - - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ - keys: expect.arrayContaining([ - expect.objectContaining({ kid: "remote-key" }), - expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), - expect.objectContaining({ kty: "oct" }), ]), - }); - }); - }); - - it.live( - "falls back to local jwks when remote jwks resolution fails for enabled third-party auth providers", - () => { - return Effect.gen(function* () { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); - - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - throw new Error("oidc discovery failed"); - }); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); + close: () => Effect.void, + } as unknown as EffectStack; + const output = mockOutput({ interactive: false }); + const operations = { + findStack: () => Effect.succeed(Option.none()), + createStack: () => Effect.succeed(stack), + openStack: () => Effect.succeed(stack), + loadConfig: () => + Effect.succeed( + Schema.decodeUnknownSync(CliConfigSchema)({ + project_id: "demo", + api: { schemas: ["private"] }, + auth: { jwt_secret: "jwt-secret-for-tests" }, + db: { vault: { DB_PASSWORD: "vault-secret" } }, + edge_runtime: { secrets: { EDGE_TOKEN: "edge-secret" } }, }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("jwks logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); - expect(jwks).toBeDefined(); - if (jwks === undefined) { - throw new Error("missing SUPABASE_JWKS"); - } - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ - keys: expect.arrayContaining([ - expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), - expect.objectContaining({ kty: "oct" }), - ]), - }); - }); - }, - ); - - it.live( - "does not fail startup on a malformed third-party provider config when auth is disabled", - () => { - // `Auth.ThirdParty.validate()` (the "required field" check) only runs inside - // `Config.Validate`'s `if Auth.Enabled` block — `functions serve`'s own JWKS resolution - // discards `ResolveJWKS`'s error unconditionally, regardless of `auth.enabled`. So a - // workos provider enabled without an `issuer_url` must not block startup here. - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); - - return Effect.gen(function* () { - const fetchMock = vi.spyOn(globalThis, "fetch"); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth]", - "enabled = false", - "", - "[auth.third_party.workos]", - "enabled = true", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("jwks logs failed"); - } - expect(fetchMock).not.toHaveBeenCalled(); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); - expect(jwks).toBeDefined(); - if (jwks === undefined) { - throw new Error("missing SUPABASE_JWKS"); - } - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ - keys: expect.arrayContaining([ - expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), - expect.objectContaining({ kty: "oct" }), - ]), - }); - }); - }, - ); - - it.live("includes config-defined edge runtime secrets in the runtime env", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "secrets logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime]", - 'policy = "per_worker"', - "inspector_port = 8083", - "", - "[edge_runtime.secrets]", - 'FROM_CONFIG = "config-value"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("secrets logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("FROM_CONFIG=config-value"); - }); - }); - - it.live("uppercases config secret names, skipping empty and unresolved values", () => { - // The established config loader uppercases every `[edge_runtime.secrets]` - // key with `strings.ToUpper` (viper #1014 workaround) before - // `set.ListSecrets` reads the map, and ListSecrets keeps only entries - // with a non-empty SHA256, i.e. it skips empty values and - // still-unresolved `env(VAR)` literals. - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "secrets logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime.secrets]", - 'my_lower_secret = "keep-me"', - 'EMPTY_SECRET = ""', - 'UNRESOLVED_SECRET = "env(SERVE_SECRET_NEVER_SET)"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("secrets logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("MY_LOWER_SECRET=keep-me"); - expect(envs.some((entry) => entry.startsWith("my_lower_secret="))).toBe(false); - expect(envs.some((entry) => entry.startsWith("EMPTY_SECRET="))).toBe(false); - expect(envs.some((entry) => entry.startsWith("UNRESOLVED_SECRET="))).toBe(false); - }); - }); - - it.live("uses the resolved project_id when deriving docker resource names", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - const envName = "SUPABASE_SERVE_PROJECT_ID"; - const previous = process.env[envName]; - process.env[envName] = "env-backed-project"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env[envName]; - } else { - process.env[envName] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeCliConfig([`project_id = "env(${envName})"`, ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("serve logs failed"); - } - - expect(deployMockState.volumeCalls).toEqual([ - { - volumeName: "supabase_edge_runtime_env-backed-project", - projectId: "env-backed-project", - }, - ]); - expect(deployMockState.networkCalls).toEqual([ - { - networkMode: "supabase_network_env-backed-project", - projectId: "env-backed-project", - }, - ]); - expect(deployMockState.runCalls).toContainEqual( - expect.objectContaining({ - command: "docker", - args: ["container", "inspect", "supabase_db_env-backed-project"], - }), - ); - }); - }); - - it.live( - "prefers the legacy SUPABASE_PROJECT_ID override when deriving docker resource names", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "config-project"', - "", - "[functions.hello]", - "verify_jwt = true", - "", - "[remotes.override]", - 'project_id = "overrideprojectaaaaa"', - "", - "[remotes.override.functions.hello]", - "verify_jwt = false", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ - childSpawner, - projectId: Option.some("overrideprojectaaaaa"), - }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("serve logs failed"); - } - - expect(deployMockState.volumeCalls).toEqual([ - { - volumeName: "supabase_edge_runtime_overrideprojectaaaaa", - projectId: "overrideprojectaaaaa", - }, - ]); - expect(deployMockState.networkCalls).toEqual([ - { - networkMode: "supabase_network_overrideprojectaaaaa", - projectId: "overrideprojectaaaaa", - }, - ]); - expect(deployMockState.runCalls).toContainEqual( - expect.objectContaining({ - command: "docker", - args: ["container", "inspect", "supabase_db_overrideprojectaaaaa"], - }), - ); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const functionsConfig = envs.find((entry) => - entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), - ); - expect(functionsConfig).toBeDefined(); - if (functionsConfig === undefined) { - throw new Error("missing SUPABASE_INTERNAL_FUNCTIONS_CONFIG"); - } - - expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), - ).toEqual( - expect.objectContaining({ - hello: expect.objectContaining({ - verifyJWT: false, - }), - }), - ); - }); - }, - ); - - it.live("fails inspect flag conflicts before startup work begins", () => { - return Effect.gen(function* () { - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe( - baseFlags({ - inspect: true, - inspectMode: Option.some("run"), - }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain( - "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", - ); - } - expect(deployMockState.runCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - }); - }); - - it.live("fails when the project config is malformed", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig("not valid toml ][")); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(JSON.stringify(error)).toContain("CliConfigParseError"); - expect(deployMockState.runCalls).toHaveLength(0); - }); - }); - - it.live("fails when the local database is not running", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { - exitCode: 1, - stdout: "", - stderr: "Error: No such container: supabase_db_test-project", - }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("supabase start is not running."); - } - }); - }); - - it.live("surfaces a down docker daemon as the inspect failure with the install hint", () => { - // Go has no upfront docker precheck in `functions serve` — a down daemon - // surfaces from `AssertSupabaseDbIsRunning`'s container inspect as - // `failed to inspect service: ` with the Docker Desktop - // install hint attached as a suggestion (`internal/utils/misc.go:155-166`, - // `docker.go:350`), AFTER config load resolved the project id. - const daemonDownStderr = - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 1, stdout: "", stderr: daemonDownStderr }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe(`failed to inspect service: ${daemonDownStderr}`); - // The old TS-only upfront precheck message must never come back. - expect(error.message).not.toContain("failed to run docker"); - } - expect(error).toHaveProperty( - "suggestion", - "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", - ); - - // Config load ran first (the inspect targets the config-resolved project - // id) and nothing after the failed assert touched docker. - expect(deployMockState.runCalls).toEqual([ - expect.objectContaining({ - command: "docker", - args: ["container", "inspect", "supabase_db_test-project"], - }), - ]); - expect(deployMockState.volumeCalls).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - }); - }); - - it.live("keeps the install hint when no container runtime is installed at all", () => { - // With no `docker` or `podman` binary on PATH the inspect never spawns — - // the shell-out equivalent of a missing daemon socket, which - // `client.IsErrConnectionFailed` classifies as a connection failure and so - // gets the Docker Desktop install hint. The spawn-failure cause must - // survive into the `failed to inspect service: …` message instead of - // being blanked. - const runtimeNotFoundMessage = - "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { failure: new Error(runtimeNotFoundMessage) }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe(`failed to inspect service: ${runtimeNotFoundMessage}`); - // The old TS-only upfront precheck message must never come back. - expect(error.message).not.toContain("failed to run docker"); - } - expect(error).toHaveProperty( - "suggestion", - "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", - ); - }); - }); - - it.live("fails with the config error, not a docker error, when both are broken", () => { - // Established ordering: `restartEdgeRuntime` sanity-checks config load - // first, `AssertSupabaseDbIsRunning` second — a malformed config wins - // over a down docker daemon. - deployMockState.runHandler = () => ({ - exitCode: 1, - stdout: "", - stderr: - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", - }); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig("not valid toml ][")); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toHaveProperty("_tag", "CliConfigParseError"); - expect(deployMockState.runCalls).toHaveLength(0); - }); - }); - - it.live("makes no remote JWKS request when docker is down", () => { - // Go only fetches third-party JWKS inside `ServeFunctions` (`serve.go:141`, - // `ResolveJWKS`), strictly after `AssertSupabaseDbIsRunning` - // (`serve.go:110-113`) — with a down daemon the docker error surfaces - // immediately, without first waiting on OIDC/JWKS requests (two sequential - // 10s-timeout clients, `pkg/config/config.go:1727-1776`). - const daemonDownStderr = - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 1, stdout: "", stderr: daemonDownStderr }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - throw new Error(`unexpected fetch before the DB assertion: ${url}`); - }); - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fetchMock.mockRestore(); - }), - ); - - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe(`failed to inspect service: ${daemonDownStderr}`); - } - // The docker-down error must win without a single external request. - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); + } as const; - it.live("fails with the auth config error, not a docker error, when both are broken", () => { - // Established ordering: the `jwt_secret` ≥16-chars check runs during - // config load, BEFORE `AssertSupabaseDbIsRunning` — invalid auth config - // wins over a down docker daemon, so the local half of auth resolution - // must stay ahead of the DB assertion. - deployMockState.runHandler = () => ({ - exitCode: 1, - stdout: "", - stderr: - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", - }); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - ['project_id = "test-project"', "", "[auth]", 'jwt_secret = "short"', ""].join("\n"), + return legacyFunctionsServe(baseFlags(), operations).pipe( + Effect.provide( + Layer.mergeAll( + output.layer, + BunServices.layer, + mockLegacyCliSettings({ workdir: "/tmp/project" }), + mockLegacyTelemetryStateLayer, ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe( - "Invalid config for auth.jwt_secret. Must be at least 16 characters", - ); - } - expect(deployMockState.runCalls).toHaveLength(0); - }); - }); - - it.live("resolves env() config values from root env development files", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "root env logs failed" }]); - const previousSupabaseEnv = process.env["SUPABASE_ENV"]; - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig([`project_id = "env(ROOT_PROJECT_ID)"`, ""].join("\n")), - ); - yield* Effect.promise(() => - writeProjectFile(".env.development", "ROOT_PROJECT_ID=root-env-project\n"), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - process.env["SUPABASE_ENV"] = "development"; - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("root env logs failed"); - } - - expect(deployMockState.volumeCalls).toEqual([ - { - volumeName: "supabase_edge_runtime_root-env-project", - projectId: "root-env-project", - }, - ]); - expect(deployMockState.networkCalls).toEqual([ - { - networkMode: "supabase_network_root-env-project", - projectId: "root-env-project", - }, - ]); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousSupabaseEnv === undefined) { - delete process.env["SUPABASE_ENV"]; - } else { - process.env["SUPABASE_ENV"] = previousSupabaseEnv; - } - }), ), - ); - }); - - it.live( - "resolves numeric env() config values from root env development files before decode", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { exitCode: 1, stderr: "root api env logs failed" }, - ]); - const previousSupabaseEnv = process.env["SUPABASE_ENV"]; - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - ['project_id = "test-project"', "[api]", 'port = "env(ROOT_API_PORT)"', ""].join("\n"), - ), - ); - yield* Effect.promise(() => writeProjectFile(".env.development", "ROOT_API_PORT=5544\n")); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - process.env["SUPABASE_ENV"] = "development"; - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("root api env logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - expect(envs).toContain("SUPABASE_INTERNAL_HOST_PORT=5544"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousSupabaseEnv === undefined) { - delete process.env["SUPABASE_ENV"]; - } else { - process.env["SUPABASE_ENV"] = previousSupabaseEnv; - } - }), - ), - ); - }, - ); - - it.live( - "does not publish default jwks fallbacks when signing_keys_path is configured but empty", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - const childSpawner = mockDockerLogSpawner([ - { exitCode: 1, stderr: "empty signing keys logs failed" }, - ]); - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[auth]", - 'signing_keys_path = "./signing-keys.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", "signing-keys.json"), "[]\n"), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("empty signing keys logs failed"); - } - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); - const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); - expect(jwks).toBeDefined(); - if (jwks === undefined) { - throw new Error("missing SUPABASE_JWKS"); - } - - const parsed = JSON.parse(jwks.slice("SUPABASE_JWKS=".length)) as { - readonly keys: ReadonlyArray>; - }; - expect( - parsed.keys.some((key) => key["kid"] === "b81269f1-21d8-4f2e-b719-c2240a840d90"), - ).toBe(false); - expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); - }); - }, - ); - - it.live("fails when the explicit env file is missing", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe( - baseFlags({ - envFile: Option.some(".env"), - }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain(".env"); - expect(error.message).toContain("no such file or directory"); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - }); - }); - - it.live("surfaces the real filesystem error when the functions path is not a directory", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - writeFileSync(join(tempRoot.current, "supabase", "functions"), "not a directory\n"); - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - "", - ].join("\n"), - ), - ); - - const { layer, out } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("ENOTDIR"); - expect(error.message).toContain(join("supabase", "functions")); - expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); - } - expect(out.stderrText).toContain("Setting up Edge Functions runtime...\n"); - expect(deployMockState.runCalls.map((call) => call.args.slice(0, 2))).toEqual([ - ["container", "inspect"], - ["container", "rm"], - ]); - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - }); - }); - - it.live("preserves the primary error when artifact cleanup also fails", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ['FOO.BAR="line-1\nline-2"', ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", ".temp", "start-secrets"), "not a directory\n"), - ); - - const { layer, out } = setupServe(); - const exit = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) { - throw new Error("expected functions serve to fail"); - } - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("invalid multiline environment variable name"); - expect(error.message).toContain("FOO.BAR"); - expect(error.message).not.toContain("ENOTDIR"); - expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); - } - expect(out.messages).toContainEqual({ - type: "warn", - message: expect.stringContaining("Failed to clean up Edge Runtime artifacts: ENOTDIR"), - }); - expect(out.messages).toContainEqual({ - type: "warn", - message: expect.stringContaining(join("supabase", ".temp", "start-secrets")), - }); - expect(out.messages).not.toContainEqual({ - type: "warn", - message: expect.stringContaining("An error occurred in Effect.tryPromise"), - }); - expect(deployMockState.runCalls.filter((call) => call.args[0] === "create")).toHaveLength(0); - }); - }); - - describe("Config.Validate / dotenv / env-override parity (CLI-1963)", () => { - it.live( - "fails before any Docker work when config.toml has an explicit empty project_id", - () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig('project_id = ""\n')); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe("Missing required field in config: project_id"); - } - expect(deployMockState.runCalls).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - }); - }, - ); - - it.live( - "fails before any Docker work on an unrelated Config.Validate branch (unsupported Postgres major version)", - () => { - // Proves the WHOLE resolved config is validated, not just `project_id` - // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` - // branch (`config.go:1034-1062`). - return Effect.gen(function* () { - yield* Effect.promise(() => - writeCliConfig( - ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toBe( - "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", - ); - } - expect(deployMockState.runCalls).toHaveLength(0); - expect(deployMockState.networkCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); - }); - }, - ); - - it.live( - "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - } else { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeCliConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun).toBeDefined(); - if (dockerRun === undefined) { - throw new Error("expected docker create call"); - } - expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); - }); - }, - ); - - it.live( - "uses SUPABASE_NETWORK_ID as the docker network when no --network-id flag is passed", - () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeCliConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + Effect.tap(() => + Effect.sync(() => { + expect(startedConfig).toMatchObject({ + capabilities: { + rest: { settings: { schemas: ["private"] } }, + functions: { + settings: { functions_root: expect.stringContaining("supabase/functions") }, + }, + }, + }); + const config = startedConfig as { + capabilities: { + auth: { settings: { jwt_secret: Redacted.Redacted } }; + database: { settings: { vault: { DB_PASSWORD: Redacted.Redacted } } }; + }; + }; + expect(Redacted.value(config.capabilities.auth.settings.jwt_secret)).toBe( + "jwt-secret-for-tests", ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); - yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(deployMockState.networkCalls).toEqual([ - { networkMode: "env-network", projectId: "test-project" }, - ]); - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", + expect(Redacted.value(config.capabilities.database.settings.vault.DB_PASSWORD)).toBe( + "vault-secret", ); - expect(dockerRun?.args).toContain("env-network"); - }); - }, + expect(output.messages.some((message) => message.message.includes("ready"))).toBe(true); + }), + ), ); - - it.live("prefers an explicit --network-id flag over SUPABASE_NETWORK_ID", () => { - deployMockState.runHandler = (command, args) => { - if (command !== "docker") { - throw new Error(`unexpected process: ${command}`); - } - if (args[0] === "container" && args[1] === "inspect") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "container" && args[1] === "rm") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { - return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; - } - if (args[0] === "exec") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); - - return Effect.gen(function* () { - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ); - - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner, networkId: Option.some("flag-network") }); - yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); - - expect(deployMockState.networkCalls).toEqual([ - { networkMode: "flag-network", projectId: "test-project" }, - ]); - const dockerRun = deployMockState.runCalls.find( - (call) => call.command === "docker" && call.args[0] === "create", - ); - expect(dockerRun?.args).toContain("flag-network"); - expect(dockerRun?.args).not.toContain("env-network"); - }); - }); - }); - - it.live("surfaces the real filesystem error when the fallback env file is unreadable", () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - // A directory at the fallback path makes the read fail with a non-ENOENT error (EISDIR). - yield* Effect.promise(() => - mkdir(join(tempRoot.current, "supabase", "functions", ".env"), { recursive: true }), - ); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("EISDIR"); - expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - }); }); - - it.live.skipIf(isRoot)( - "surfaces the real filesystem error when the env staging dir cannot be created", - () => { - return Effect.gen(function* () { - yield* Effect.promise(() => writeCliConfig(['project_id = "test-project"', ""].join("\n"))); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - // A read-only parent makes the per-container staging-dir mkdir fail with EACCES. - const stagingRoot = join(tempRoot.current, "supabase", ".temp", "start-secrets"); - yield* Effect.promise(() => mkdir(stagingRoot, { recursive: true })); - yield* Effect.promise(() => chmod(stagingRoot, 0o555)); - - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe(baseFlags()).pipe( - Effect.provide(layer), - Effect.flip, - Effect.ensuring(Effect.promise(() => chmod(stagingRoot, 0o755))), - ); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain("EACCES"); - expect(error.message).not.toContain("An error occurred in Effect.tryPromise"); - } - expect( - deployMockState.runCalls.filter( - (call) => call.command === "docker" && call.args[0] === "create", - ), - ).toHaveLength(0); - }); - }, - ); }); 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 index 913582d235..dbb1e7e973 100644 --- 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 @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { edgeRuntimeNofileUlimit } from "@supabase/stack/effect"; +import { edgeRuntimeNofileUlimit } from "../../../../shared/stack-constants.ts"; import { Deferred, Effect, Exit, Sink, Stream } from "effect"; import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { beforeEach } from "vitest"; diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index 1dfe3cb60b..555ed27b58 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -1,4 +1,4 @@ -import { isDockerDaemonDownMessage } from "@supabase/stack/effect"; +import { isDockerDaemonDownMessage } from "../../shared/stack-constants.ts"; import { Data, Effect, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 4b6e4042b8..ae8a2cc678 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -2,7 +2,11 @@ import { readFileSync } from "node:fs"; import { basename } from "node:path"; import { ENV_CAPTURE_REGEX, type CliConfig } from "@supabase/config"; -import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; +import { + defaultJwtSecret, + defaultPublishableKey, + defaultSecretKey, +} from "../../shared/stack-constants.ts"; import { Schema } from "effect"; import { diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts index 59cf625f09..9e182a3039 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts @@ -1,5 +1,5 @@ import { KONG_LOCAL_CA_CERT } from "@supabase/config"; -import { defaultJwtSecret, generateJwt } from "@supabase/stack/effect"; +import { defaultJwtSecret, generateJwt } from "../../shared/stack-constants.ts"; import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index f289635492..45e1e2b25b 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -1,32 +1,14 @@ -import { - connectLayer, - daemonLayer, - resolveManagedStack, - Stack, - stopDaemon, -} from "@supabase/stack/effect"; -import { loadCliConfig } from "@supabase/config/effect"; +import { findStack } from "@supabase/stack/effect"; import { Effect, Option } from "effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; -import { CliSettings } from "../../../config/cli-settings.service.ts"; import { CliProjectHome } from "../../../config/cli-project-home.service.ts"; -import { managedPortIntents } from "../../../config/managed-port-intents.ts"; import { ProjectLinkState, ProjectNotLinkedError, } from "../../../config/project-link-state.service.ts"; -import { - excludedStackServices, - toStartStackConfig, - withServiceVersions, - type ExcludedStackService, -} from "../../../config/stack-config.ts"; import { NonInteractiveError } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { printStackConnectionInfo, startStackWithProgress } from "../../../stack/stack.shared.ts"; import { BranchNotFoundError } from "../errors.ts"; -import { CLI_VERSION } from "../../../../shared/cli/version.ts"; export const switchBranch = Effect.fn("branches.switch")(function* (opts: { name: Option.Option; @@ -34,9 +16,7 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { const output = yield* Output; const projectLinkState = yield* ProjectLinkState; const api = yield* PlatformApi; - const cliSettings = yield* CliSettings; const cliProjectHome = yield* CliProjectHome; - const runtimeInfo = yield* RuntimeInfo; yield* output.intro("Switch branch"); @@ -104,99 +84,17 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { return; } - // If a local stack is running, stop and restart it against the new branch. - const stackCheck = yield* resolveManagedStack({ - cacheRoot: cliSettings.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: cliProjectHome.projectRoot, - }).pipe( - Effect.map(Option.some), - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), - // Branch switching is also valid outside a local project checkout. In - // that case managed discovery cannot canonicalize the synthetic/nonexistent - // project root supplied by the command context, which is equivalent to no - // local stack being present for this lifecycle check. - Effect.catchTag("InvalidManagedIdentityError", () => Effect.succeed(Option.none())), - ); - - if (Option.isSome(stackCheck) && stackCheck.value.lifecycle === "running") { - const stackName = stackCheck.value.identity.name; - - // Branch switching restarts a running stack, but it is not authorized to - // restart an incompatible daemon. Capture the same-version RPC owner/session - // before stopping it so a mismatch leaves the old stack intact. - const existingLayer = yield* connectLayer({ - cliVersion: CLI_VERSION, - cwd: runtimeInfo.cwd, - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: stackName, - }).pipe( - Effect.map(Option.some), - // A running document without a live owner is stale. Continue into the - // normal stop path, which acquires ownership and records it stopped. - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), - ); - - if (Option.isSome(existingLayer)) { - yield* Effect.scoped( - Effect.gen(function* () { - const stack = yield* Stack; - const stopping = yield* output.task("Stopping local stack..."); - yield* stack.stop.pipe(Effect.tapError(() => stopping.fail())); - yield* stopping.clear(); - }).pipe(Effect.provide(existingLayer.value)), - ); - } else { - const stopping = yield* output.task("Stopping local stack..."); - yield* stopDaemon({ - cwd: runtimeInfo.cwd, - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: stackName, - }).pipe(Effect.tapError(() => stopping.fail())); - yield* stopping.clear(); - } - - // TODO: run `supabase pull` against the new branch before restarting the stack - // so the local config reflects the branch's migrations and seed state. - // `pull` does not exist yet. - const launch = stackCheck.value.launch; - const launchConfig = withServiceVersions( - toStartStackConfig( - launch.excludedServices?.filter((service): service is ExcludedStackService => - excludedStackServices.some((candidate) => candidate === service), - ) ?? [], - launch.mode, - ), - launch.versions, + // Branch switching updates the linked project state. A running local stack + // remains untouched; users can restart it explicitly with `supabase start`. + const descriptor = yield* findStack({ projectRoot: cliProjectHome.projectRoot }); + if ( + Option.isSome(descriptor) && + descriptor.value.desiredLifecycle === "running" && + output.format === "text" + ) { + yield* output.info( + "The local stack is running. Restart it with `supabase start` to apply the new branch.", ); - const loadedCliConfig = yield* loadCliConfig(cliProjectHome.projectRoot); - - const stackLayer = yield* daemonLayer({ - cliVersion: CLI_VERSION, - cacheRoot: cliSettings.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: cliProjectHome.projectRoot, - name: stackName, - portIntents: managedPortIntents(launchConfig, loadedCliConfig ?? undefined), - launch, - ...launchConfig, - }); - - yield* Effect.scoped( - Effect.gen(function* () { - yield* startStackWithProgress().pipe(Effect.provide(stackLayer)); - yield* printStackConnectionInfo().pipe(Effect.provide(stackLayer)); - }), - ); - - if (output.format === "text") { - yield* output.info( - "The local stack was restarted in detach mode.\n" + - "Run `supabase stop` to stop it or `supabase status` to check its status.", - ); - } } yield* projectLinkState.setActiveBranch({ diff --git a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts index e222ee6400..a40a0b85fa 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts @@ -1,471 +1,106 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient } from "@supabase/api/effect"; -import { Cause, Effect, Exit, Layer, Option, Predicate } from "effect"; +import { Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import type { BranchResponse } from "@supabase/api/effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; -import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; -import { emptyEnv, mockOutput, mockProjectLinkState } from "../../../../../tests/helpers/mocks.ts"; -import { ProjectLinkState } from "../../../config/project-link-state.service.ts"; +import { + emptyEnv, + mockCliProjectHome, + mockOutput, + mockProjectLinkState, +} from "../../../../../tests/helpers/mocks.ts"; import { switchBranch } from "./switch.handler.ts"; -import { makeRunningStackFixture } from "../../../../../tests/helpers/running-stack.ts"; -import { controlTransportLayer } from "@supabase/stack/managed"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -function makeBranch( - overrides: Partial = {}, -): typeof BranchResponse.Type { - return { - id: "00000000-0000-4000-8000-000000000001", - name: "main", - project_ref: "mainrefghijklmnopqrst", - parent_project_ref: "parentref1234567890", - is_default: true, - persistent: true, - status: "MIGRATIONS_PASSED", - created_at: "2024-01-15T10:30:00.000Z", - updated_at: "2024-01-15T10:30:00.000Z", - with_data: false, - ...overrides, - }; -} - -const MAIN_BRANCH = makeBranch(); -const DEV_BRANCH = makeBranch({ +const MAIN = { + id: "00000000-0000-4000-8000-000000000001", + name: "main", + project_ref: "mainrefghijklmnopqrst", + parent_project_ref: "parentrefabcdefghijk", + is_default: true, + persistent: true, + status: "MIGRATIONS_PASSED", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + with_data: false, +} as const; +const DEV = { + ...MAIN, id: "00000000-0000-4000-8000-000000000002", name: "dev", project_ref: "devrefghijklmnopqrst", is_default: false, -}); - -const DEFAULT_LINK_STATE = { +} as const; +const LINK = { project: { ref: "parentrefabcdefghijk", name: "my-project", organization_id: "org123", organization_slug: "my-org", }, - active_branch: { - ref: "mainrefghijklmnopqrst", - name: "main", - is_default: true, - }, + active_branch: { ref: MAIN.project_ref, name: MAIN.name, is_default: MAIN.is_default }, fetchedAt: "2024-01-01T00:00:00.000Z", versions: {}, }; -function httpClientLayer( - handler: ( - request: HttpClientRequest.HttpClientRequest, - ) => Effect.Effect, -) { - return Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => handler(request)), - ); -} - -function jsonResponse( - request: HttpClientRequest.HttpClientRequest, - status: number, - body: unknown, -): HttpClientResponse.HttpClientResponse { - return HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify(body), { - status, - headers: { - "content-type": "application/json", - }, - }), - ); -} - -function mockPlatformApi( - branches: ReadonlyArray, - opts: { status?: number } = {}, -) { - const requests: Array<{ - url: string; - headers: Readonly>; - }> = []; - - const layer = Layer.effect( +function setup() { + const out = mockOutput({ format: "text", interactive: false }); + const api = Layer.effect( PlatformApi, makeApiClient({ baseUrl: "https://api.supabase.com", accessToken: "test-token", userAgent: "supabase", - headers: { - "X-Supabase-Command": "branches switch", - "X-Supabase-Command-Run-ID": "run-123", - }, }), ).pipe( Layer.provide( - httpClientLayer((request) => { - requests.push({ - url: request.url, - headers: request.headers, - }); - return Effect.succeed(jsonResponse(request, opts.status ?? 200, branches)); - }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(JSON.stringify([MAIN, DEV]))), + ), + ), + ), ), ); - return { - layer, - get requests() { - return requests; - }, + out, + layer: Layer.mergeAll( + emptyEnv(), + out.layer, + mockCliProjectHome({ projectRoot: process.cwd() }), + mockProjectLinkState(LINK), + api, + ), }; } -function setup( - opts: { - branches?: ReadonlyArray; - linked?: boolean; - format?: "text" | "json" | "stream-json"; - interactive?: boolean; - promptSelectResponses?: ReadonlyArray; - activeBranchRef?: string; - status?: number; - } = {}, -) { - const linked = opts.linked ?? true; - const activeBranch = opts.activeBranchRef - ? { ref: opts.activeBranchRef, name: "main", is_default: true } - : DEFAULT_LINK_STATE.active_branch; - const linkState = linked ? { ...DEFAULT_LINK_STATE, active_branch: activeBranch } : undefined; - - const out = mockOutput({ - format: opts.format ?? "text", - interactive: opts.interactive ?? (opts.format ?? "text") === "text", - promptSelectResponses: opts.promptSelectResponses, - }); - const state = mockProjectLinkState(linkState); - const api = mockPlatformApi(opts.branches ?? [MAIN_BRANCH, DEV_BRANCH], { - status: opts.status, - }); - const layer = Layer.mergeAll(emptyEnv(), out.layer, state, api.layer, controlTransportLayer); - return { out, layer, api }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("branches switch handler", () => { - it.live("switches to a branch by name", () => - Effect.gen(function* () { - const { out, layer } = setup(); - - yield* switchBranch({ name: Option.some("dev") }).pipe(Effect.provide(layer)); - - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "outro", message: "Switched to branch 'dev'." }), - ); - }), - ); - - it.live("updates the active branch in link state after switching", () => { - const { layer } = setup(); - return Effect.gen(function* () { - yield* switchBranch({ name: Option.some("dev") }); - const linkStateService = yield* ProjectLinkState; - const state = yield* linkStateService.load; - const activeBranch = Option.map(state, (s) => s.active_branch); - expect(Option.getOrNull(activeBranch)?.ref).toBe("devrefghijklmnopqrst"); - expect(Option.getOrNull(activeBranch)?.name).toBe("dev"); - }).pipe(Effect.provide(layer)); + it.live("switches to a branch by name and updates the active branch", () => { + const { out, layer } = setup(); + return switchBranch({ name: Option.some("dev") }).pipe( + Effect.provide(layer), + Effect.tap(() => + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "outro", message: "Switched to branch 'dev'." }), + ); + }), + ), + ); }); - it.live("switches to a branch by project_ref", () => - Effect.gen(function* () { - const { out, layer } = setup(); - - yield* switchBranch({ name: Option.some("devrefghijklmnopqrst") }).pipe( - Effect.provide(layer), - ); - - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "outro", message: "Switched to branch 'dev'." }), - ); - }), - ); - - it.live("shows interactive select when no name is provided", () => - Effect.gen(function* () { - const { out, layer } = setup({ - interactive: true, - promptSelectResponses: ["devrefghijklmnopqrst"], - }); - - yield* switchBranch({ name: Option.none() }).pipe(Effect.provide(layer)); - - expect(out.promptSelectCalls).toHaveLength(1); - expect(out.promptSelectCalls[0]?.options.map((option) => option.label)).toContain("dev"); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "outro", message: "Switched to branch 'dev'." }), - ); - }), - ); - - it.live( - "fails with BranchNotFoundError when interactive select returns an unresolvable ref", - () => - Effect.gen(function* () { - const { layer } = setup({ - interactive: true, - promptSelectResponses: ["ghost-ref-that-doesnt-exist"], - }); - - const exit = yield* switchBranch({ name: Option.none() }).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("BranchNotFoundError"); - expect(JSON.stringify(exit.cause)).toContain("Selected branch could not be resolved"); - } - }), - ); - - it.live("fails with NonInteractiveError when no name and not interactive", () => - Effect.gen(function* () { - const { layer } = setup({ interactive: false }); - - const exit = yield* switchBranch({ name: Option.none() }).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("NonInteractiveError"); - } - }), - ); - - it.live("shows 'already on branch' when target is already active", () => - Effect.gen(function* () { - const { out, layer } = setup(); - - yield* switchBranch({ name: Option.some("main") }).pipe(Effect.provide(layer)); - - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "outro", message: "Already on branch 'main'." }), - ); - }), - ); - - it.live("fails with BranchNotFoundError when name does not match any branch", () => - Effect.gen(function* () { - const { layer } = setup(); - - const exit = yield* switchBranch({ name: Option.some("nonexistent") }).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("BranchNotFoundError"); - expect(JSON.stringify(exit.cause)).toContain("nonexistent"); - } - }), - ); - - it.live("fails with ProjectNotLinkedError when project is not linked", () => - Effect.gen(function* () { - const { layer } = setup({ linked: false }); - - const exit = yield* switchBranch({ name: Option.some("dev") }).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("ProjectNotLinkedError"); - expect(JSON.stringify(exit.cause)).toContain("supabase link"); - } - }), - ); - - it.live("emits success event with branch data in JSON mode", () => - Effect.gen(function* () { - const { out, layer } = setup({ format: "json" }); - - yield* switchBranch({ name: Option.some("dev") }).pipe(Effect.provide(layer)); - - const successMessages = out.messages.filter((message) => message.type === "success"); - expect(successMessages).toHaveLength(1); - const data = (successMessages[0] as { data?: { branch?: unknown } }).data; - expect(data?.branch).toMatchObject({ - ref: "devrefghijklmnopqrst", - name: "dev", - is_default: false, - }); - }), - ); - - it.live("emits outro but no success event when already on branch in JSON mode", () => - Effect.gen(function* () { - const { out, layer } = setup({ format: "json" }); - - yield* switchBranch({ name: Option.some("main") }).pipe(Effect.provide(layer)); - - const successMessages = out.messages.filter((message) => message.type === "success"); - expect(successMessages).toHaveLength(0); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "outro", message: "Already on branch 'main'." }), - ); - }), - ); - - it.live("emits a fail event for API errors in JSON mode", () => - Effect.gen(function* () { - const out = mockOutput({ format: "json" }); - const linkState = mockProjectLinkState(DEFAULT_LINK_STATE); - const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH], { status: 503 }); - const layer = Layer.mergeAll( - emptyEnv(), - out.layer, - linkState, - api.layer, - controlTransportLayer, - ); - - yield* switchBranch({ name: Option.some("dev") }).pipe( - withJsonErrorHandling, - Effect.provide(layer), - ); - - expect(out.messages).toContainEqual(expect.objectContaining({ type: "fail" })); - }), - ); - - it.live("calls the facade client with the expected CLI headers", () => - Effect.gen(function* () { - const { api, layer } = setup({ format: "json" }); - - yield* switchBranch({ name: Option.some("dev") }).pipe(Effect.provide(layer)); - - expect(api.requests).toHaveLength(1); - expect(api.requests[0]?.url).toBe( - "https://api.supabase.com/v1/projects/parentrefabcdefghijk/branches", - ); - expect(api.requests[0]?.headers["x-supabase-command"]).toBe("branches switch"); - expect(api.requests[0]?.headers["x-supabase-command-run-id"]).toBe("run-123"); - }), - ); - - it.live("skips stack lifecycle when no local stack is running", () => - Effect.gen(function* () { - const { out, layer } = setup(); - - yield* switchBranch({ name: Option.some("dev") }).pipe(Effect.provide(layer)); - - expect(out.messages).not.toContainEqual( - expect.objectContaining({ type: "info", message: expect.stringContaining("detach mode") }), - ); - }), - ); - - it.live("does not stop an incompatible local stack before branch restart", () => - Effect.promise(() => - makeRunningStackFixture({ - cliVersion: "2.60.0", - }), - ).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH]); - let linkState = DEFAULT_LINK_STATE; - const linkStateLayer = Layer.succeed( - ProjectLinkState, - ProjectLinkState.of({ - load: Effect.sync(() => Option.some(linkState)), - save: (next) => Effect.sync(() => void (linkState = next)), - clear: Effect.void, - getActiveBranch: Effect.sync(() => Option.some(linkState.active_branch)), - setActiveBranch: (branch) => - Effect.sync(() => { - linkState = { ...linkState, active_branch: branch }; - }), - }), - ); - const layer = Layer.mergeAll(fixture.baseLayer, out.layer, linkStateLayer, api.layer); - return switchBranch({ name: Option.some("dev") }).pipe( - Effect.provide(layer), - Effect.exit, - Effect.andThen((exit) => - Effect.gen(function* () { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("DaemonUpgradeRequired"); - } - expect(api.requests).toHaveLength(1); - expect(linkState.active_branch.ref).toBe(MAIN_BRANCH.project_ref); - expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); - expect(out.messages).not.toContainEqual( - expect.objectContaining({ - type: "outro", - message: expect.stringContaining("Switched to branch"), - }), - ); - expect((yield* Effect.promise(() => fixture.readDocument()))?.lifecycle).toBe( - "running", - ); - }), - ), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - ); - }), - ), - ); - - it.live("recovers a stale running document before restarting for the selected branch", () => - Effect.promise(() => makeRunningStackFixture()).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH]); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - mockProjectLinkState(DEFAULT_LINK_STATE), - api.layer, - ); - return Effect.gen(function* () { - yield* Effect.promise(() => fixture.closeControlOwner()); - expect((yield* Effect.promise(() => fixture.readDocument()))?.lifecycle).toBe("running"); - - // Stop before launching a real replacement daemon: malformed config - // makes the command fail immediately after stale-owner cleanup. - const configDir = join(fixture.projectRoot, "supabase"); - mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, "config.toml"), "[invalid\n"); - - const exit = yield* switchBranch({ name: Option.some("dev") }).pipe(Effect.exit); - + it.live("requires a branch name in non-interactive mode", () => { + const { layer } = setup(); + return switchBranch({ name: Option.none() }).pipe( + Effect.provide(layer), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Predicate.isTagged(Cause.squash(exit.cause), "NoRunningStackError")).toBe(false); - } - expect((yield* Effect.promise(() => fixture.readDocument()))?.lifecycle).toBe("stopped"); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.promise(() => fixture.dispose()))); - }), - ), - ); + }), + ), + ); + }); }); diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts index d15ae3bc89..d5eb461e82 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.command.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts @@ -1,58 +1,24 @@ -import { DEFAULT_MANAGED_STACK_NAME, httpTransportClientLayer } from "@supabase/stack/effect"; -import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { provideCliProjectCommandRuntime } from "../../../config/project-runtime.layer.ts"; -import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; -import { parcelFileWatcherLayer } from "../../../../shared/runtime/parcel-file-watcher.layer.ts"; import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { DEFAULT_MANAGED_STACK_NAME } from "../../../../shared/stack-constants.ts"; import { functionsDev } from "./dev.handler.ts"; -import { functionsDevRuntimeLayer } from "./functions-dev-runtime.ts"; const flags = { - stack: Flag.string("stack").pipe( - Flag.withDescription("Name of the managed local stack for this project."), - Flag.withDefault(DEFAULT_MANAGED_STACK_NAME), - ), - envFile: Flag.string("env-file").pipe( - Flag.withDescription("Path to an env file to populate the Function environment."), - Flag.optional, - ), - noVerifyJwt: Flag.boolean("no-verify-jwt").pipe( - Flag.withDescription("Disable JWT verification for locally served Functions."), - Flag.withDefault(false), - ), + stack: Flag.string("stack").pipe(Flag.withDefault(DEFAULT_MANAGED_STACK_NAME)), + envFile: Flag.string("env-file").pipe(Flag.optional), + noVerifyJwt: Flag.boolean("no-verify-jwt").pipe(Flag.withDefault(false)), } as const; - export type FunctionsDevFlags = CliCommand.Command.Config.Infer; -export const functionsDevCommand = Command.make("dev", flags).pipe( - Command.withDescription( - "Run local Edge Functions development.\n\n" + - "Starts the local Supabase stack when needed, configures edge-runtime to serve local Functions, watches function files, and restarts edge-runtime on changes.", - ), - Command.withShortDescription("Develop Edge Functions locally"), - Command.withExamples([ - { - command: "supabase functions dev", - description: "Start the local stack if needed and serve local Edge Functions", - }, - { - command: "supabase functions dev --env-file ./supabase/functions/.env.local", - description: "Serve Functions with a custom environment file", - }, - ]), +export const functionsDevCommand = Command.make("serve", flags).pipe( + Command.withDescription("Serve local Edge Functions through the managed stack."), + Command.withShortDescription("Serve Edge Functions locally"), Command.withHandler((flags) => functionsDev(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), ), - Command.provide( - provideCliProjectCommandRuntime( - Layer.mergeAll( - functionsDevRuntimeLayer, - commandRuntimeLayer(["functions", "dev"]), - parcelFileWatcherLayer, - ), - ).pipe(Layer.provide(httpTransportClientLayer)), - ), + Command.provide(provideCliProjectCommandRuntime(commandRuntimeLayer(["functions", "serve"]))), ); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts deleted file mode 100644 index dbd08dd49c..0000000000 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - inferFunctionsManifest, - loadDotEnvFile, - loadCliConfig, - loadCliProjectEnvironment, - resolveCliConfigSubtree, -} from "@supabase/config/effect"; -import type { ResolvedFunctionsBundle } from "@supabase/stack/effect"; -import { Effect, Option, Redacted } from "effect"; -import { basename, dirname, join, resolve } from "node:path"; -import { CliProjectHome } from "../../../config/cli-project-home.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; - -export interface FunctionsDevConfigOptions { - readonly envFile: Option.Option; - readonly noVerifyJwt: boolean; -} - -export interface FunctionsDevWatchPath { - readonly path: string; - readonly names?: ReadonlyArray; -} - -function reveal(value: string | Redacted.Redacted): string { - return Redacted.isRedacted(value) ? Redacted.value(value) : value; -} - -function absoluteProjectPath(supabaseDir: string, path: string): string { - const withoutDotSlash = path.startsWith("./") ? path.slice(2) : path; - return resolve(supabaseDir, withoutDotSlash); -} - -export const resolveFunctionsBundle = Effect.fnUntraced(function* ( - opts: FunctionsDevConfigOptions, -) { - const cliProjectHome = yield* CliProjectHome; - const runtimeInfo = yield* RuntimeInfo; - const cliProjectEnvironment = yield* loadCliProjectEnvironment({ - cwd: cliProjectHome.projectRoot, - baseEnv: process.env, - }); - const loadedConfig = yield* loadCliConfig(cliProjectHome.projectRoot); - const cliConfig = - cliProjectEnvironment === null || loadedConfig === null - ? undefined - : { - ...loadedConfig.config, - functions: Object.fromEntries( - Object.entries( - yield* resolveCliConfigSubtree( - loadedConfig.config.functions, - cliProjectEnvironment, - "functions", - ), - ).map(([name, config]) => [ - name, - { - ...config, - entrypoint: reveal(config.entrypoint), - import_map: reveal(config.import_map), - static_files: config.static_files.map(reveal), - env: Object.fromEntries( - Object.entries(config.env).map(([key, value]) => [key, reveal(value)]), - ), - }, - ]), - ), - }; - const manifest = yield* inferFunctionsManifest({ - cwd: cliProjectHome.projectRoot, - ...(cliConfig === undefined ? {} : { config: cliConfig }), - }); - const envFilePath = Option.match(opts.envFile, { - onNone: () => join(cliProjectHome.supabaseDir, "functions", ".env"), - onSome: (path) => resolve(runtimeInfo.cwd, path), - }); - - return { - env: yield* loadDotEnvFile(envFilePath), - functions: Object.entries(manifest) - .filter(([, config]) => config.enabled) - .map(([name, config]) => ({ - name, - verifyJWT: opts.noVerifyJwt ? false : config.verify_jwt, - entrypointPath: absoluteProjectPath(cliProjectHome.supabaseDir, config.entrypoint), - importMapPath: - config.import_map === "" - ? null - : absoluteProjectPath(cliProjectHome.supabaseDir, config.import_map), - staticFiles: config.static_files.map((path) => - absoluteProjectPath(cliProjectHome.supabaseDir, path), - ), - env: config.env, - })), - } satisfies ResolvedFunctionsBundle; -}); - -export const functionsDevWatchPaths = Effect.fnUntraced(function* (envFile: Option.Option) { - const cliProjectHome = yield* CliProjectHome; - const runtimeInfo = yield* RuntimeInfo; - - return [ - { - path: cliProjectHome.supabaseDir, - names: ["functions", "config.toml", "config.json"], - }, - ...(Option.isSome(envFile) - ? (() => { - const envFilePath = resolve(runtimeInfo.cwd, envFile.value); - return [ - { - path: dirname(envFilePath), - names: [basename(envFilePath)], - }, - ]; - })() - : []), - ]; -}); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts deleted file mode 100644 index efb23b8839..0000000000 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Exit, Layer, Option } from "effect"; -import { CliProjectHome } from "../../../config/cli-project-home.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { functionsDevWatchPaths, resolveFunctionsBundle } from "./functions-dev-config.ts"; -import { - FunctionsDevEdgeRuntimeDisabledError, - resolveFunctionsDevEdgeRuntimeConfig, -} from "./functions-dev-edge-runtime-config.ts"; -import { connectOrStartFunctionsDevStack } from "./functions-dev-runtime.ts"; - -function makeTempProject(): string { - return mkdtempSync(join(tmpdir(), "supabase-functions-dev-")); -} - -function projectLayer(cwd: string) { - const projectHomeDir = join(cwd, ".supabase"); - return Layer.mergeAll( - BunServices.layer, - Layer.succeed( - RuntimeInfo, - RuntimeInfo.of({ - cwd, - platform: process.platform, - arch: process.arch, - homeDir: join(cwd, ".home"), - execPath: process.execPath, - pid: process.pid, - }), - ), - Layer.succeed( - CliProjectHome, - CliProjectHome.of({ - projectRoot: cwd, - supabaseDir: join(cwd, "supabase"), - projectHomeDir, - projectLinkPath: join(projectHomeDir, "project.json"), - projectLocalVersionsPath: join(projectHomeDir, "local-versions.json"), - ensureCliProjectHomeDir: Effect.void, - }), - ), - ); -} - -describe("functions dev config", () => { - it("exports the start-or-connect block for future dev orchestration", () => { - expect(connectOrStartFunctionsDevStack).toBeTypeOf("function"); - }); - - it.live("resolves project functions, environment and absolute paths before stack handoff", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(cwd, "supabase", "functions", "hello", "assets"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", "functions", "hello", "index.ts"), "export {};\n"), - ); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", "functions", "hello", "deno.json"), "{}\n"), - ); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "FUNCTION_VALUE=resolved-secret\n"), - ); - yield* Effect.tryPromise(() => writeFile(join(cwd, "custom.env"), "SHARED=custom\n")); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.toml"), - `[functions.hello] -verify_jwt = true -entrypoint = "./functions/hello/index.ts" -import_map = "./functions/hello/deno.json" -static_files = ["./functions/hello/assets/*"] - -[functions.hello.env] -FUNCTION_VALUE = "env(FUNCTION_VALUE)" -`, - ), - ); - - const bundle = yield* resolveFunctionsBundle({ - envFile: Option.some("./custom.env"), - noVerifyJwt: true, - }); - - expect(bundle).toEqual({ - env: { SHARED: "custom" }, - functions: [ - { - name: "hello", - verifyJWT: false, - entrypointPath: join(cwd, "supabase", "functions", "hello", "index.ts"), - importMapPath: join(cwd, "supabase", "functions", "hello", "deno.json"), - staticFiles: [join(cwd, "supabase", "functions", "hello", "assets", "*")], - env: { FUNCTION_VALUE: "resolved-secret" }, - }, - ], - }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); - }); - - it.live("selects supabase and explicit env directory watch paths", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - const paths = yield* functionsDevWatchPaths(Option.some("./custom.env")); - - expect(paths).toEqual([ - { path: join(cwd, "supabase"), names: ["functions", "config.toml", "config.json"] }, - { path: cwd, names: ["custom.env"] }, - ]); - }).pipe(Effect.provide(projectLayer(cwd))); - }); - - it.live("resolves edge runtime config from project config and secrets", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"), - ); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.toml"), - `project_id = "test" - -[edge_runtime] -policy = "oneshot" -inspector_port = 8123 - -[edge_runtime.secrets] -api_key = "env(EDGE_API_KEY)" -literal = "literal-secret" -`, - ), - ); - - const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); - - expect(result.config).toEqual({ - enabled: true, - inspectorPort: 8123, - policy: "oneshot", - env: { - API_KEY: "edge-secret", - LITERAL: "literal-secret", - }, - }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); - }); - - it.live("fails when edge runtime is disabled for functions dev", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.json"), - JSON.stringify({ edge_runtime: { enabled: false } }), - ), - ); - - const error = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.flip); - - expect(error).toBeInstanceOf(FunctionsDevEdgeRuntimeDisabledError); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); - }); - - it.live("does not resolve a lowercase-named env() reference in edge runtime secrets", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "lowercase_env_var=should-not-resolve\n"), - ); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.toml"), - `project_id = "test" - -[edge_runtime] -policy = "oneshot" -inspector_port = 8123 - -[edge_runtime.secrets] -lowercase_secret = "env(lowercase_env_var)" -`, - ), - ); - - const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); - - expect(result.config).toEqual({ - enabled: true, - inspectorPort: 8123, - policy: "oneshot", - env: { - LOWERCASE_SECRET: "env(lowercase_env_var)", - }, - }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); - }); - - it.live("does not split a comma-separated string literal for an array field", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"), - ); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.toml"), - `project_id = "test" - -[auth] -additional_redirect_urls = "http://a,http://b" - -[edge_runtime] -policy = "oneshot" -inspector_port = 8123 - -[edge_runtime.secrets] -api_key = "env(EDGE_API_KEY)" -literal = "literal-secret" -`, - ), - ); - - const exit = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); - }); -}); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts deleted file mode 100644 index cbdad71aca..0000000000 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - loadCliConfig, - loadCliProjectEnvironment, - resolveCliConfigSubtree, - type CliConfig, -} from "@supabase/config/effect"; -import type { EdgeRuntimeConfig } from "@supabase/stack/effect"; -import { Data, Effect, Redacted } from "effect"; -import { CliProjectHome } from "../../../config/cli-project-home.service.ts"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../../../../shared/telemetry/error-actionability.ts"; - -type ResolvedSecretValue = string | Redacted.Redacted; -type EdgeRuntimePolicy = "oneshot" | "per_worker"; - -interface ResolvedProjectEdgeRuntimeConfig { - readonly enabled: CliConfig["edge_runtime"]["enabled"]; - readonly inspector_port: CliConfig["edge_runtime"]["inspector_port"]; - readonly policy: ResolvedSecretValue; - readonly secrets?: Readonly>; -} - -export class FunctionsDevEdgeRuntimeDisabledError extends Data.TaggedError( - "FunctionsDevEdgeRuntimeDisabledError", -)<{ - readonly detail: string; - readonly suggestion: string; -}> { - override get message() { - return `${this.detail}\n Suggestion: ${this.suggestion}`; - } - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -export interface ResolvedFunctionsDevEdgeRuntimeConfig { - readonly config: EdgeRuntimeConfig; - readonly fingerprint: string; -} - -function revealSecret(value: ResolvedSecretValue): string { - return Redacted.isRedacted(value) ? Redacted.value(value) : value; -} - -function normalizePolicy(value: ResolvedSecretValue): EdgeRuntimePolicy { - const policy = revealSecret(value); - if (policy === "oneshot" || policy === "per_worker") { - return policy; - } - return "per_worker"; -} - -function normalizeSecrets( - secrets: Readonly> | undefined, -): Record { - const env: Record = {}; - for (const [key, value] of Object.entries(secrets ?? {})) { - env[key.toUpperCase()] = revealSecret(value); - } - return env; -} - -function stableRecord( - record: Readonly> | undefined, -): Record { - return Object.fromEntries( - Object.entries(record ?? {}).sort(([left], [right]) => left.localeCompare(right)), - ); -} - -function fingerprintEdgeRuntimeConfig(config: EdgeRuntimeConfig): string { - return JSON.stringify({ - enabled: config.enabled, - inspectorPort: config.inspectorPort, - policy: config.policy, - env: stableRecord(config.env), - }); -} - -function toStackEdgeRuntimeConfig(config: ResolvedProjectEdgeRuntimeConfig): EdgeRuntimeConfig { - return { - enabled: config.enabled, - inspectorPort: config.inspector_port, - policy: normalizePolicy(config.policy), - env: normalizeSecrets(config.secrets), - }; -} - -export const resolveFunctionsDevEdgeRuntimeConfig = Effect.fnUntraced(function* () { - const cliProjectHome = yield* CliProjectHome; - const loadedConfig = yield* loadCliConfig(cliProjectHome.projectRoot); - - if (loadedConfig === null) { - const config = {}; - return { - config, - fingerprint: fingerprintEdgeRuntimeConfig(config), - }; - } - - const projectEnv = yield* loadCliProjectEnvironment({ - cwd: cliProjectHome.projectRoot, - baseEnv: process.env, - }); - - if (projectEnv === null) { - const config = {}; - return { - config, - fingerprint: fingerprintEdgeRuntimeConfig(config), - }; - } - - const resolved = yield* resolveCliConfigSubtree( - loadedConfig.config.edge_runtime, - projectEnv, - "edge_runtime", - ); - const config = toStackEdgeRuntimeConfig(resolved); - - if (config.enabled === false) { - return yield* Effect.fail( - new FunctionsDevEdgeRuntimeDisabledError({ - detail: "`supabase functions dev` requires edge_runtime.enabled to be true.", - suggestion: "Set edge_runtime.enabled to true or remove the override, then save again.", - }), - ); - } - - return { - config, - fingerprint: fingerprintEdgeRuntimeConfig(config), - } satisfies ResolvedFunctionsDevEdgeRuntimeConfig; -}); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts new file mode 100644 index 0000000000..61ca819757 --- /dev/null +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; +import { CliConfigSchema } from "@supabase/config"; +import type { EffectStack } from "@supabase/stack/effect"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { serveManagedFunctions } from "./functions-dev-runtime.ts"; + +const fakeStatus = { + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "container", engine: "docker" }, + endpoints: {}, + versions: {}, + capabilities: [], +}; + +describe("managed Functions serving", () => { + it.live("starts the Functions capability with the live functions root and streams logs", () => { + let startedConfig: unknown; + const stack = { + id: "stack-test", + start: (options?: { config?: unknown }) => + Effect.sync(() => { + startedConfig = options?.config; + return fakeStatus; + }), + logs: () => + Stream.fromIterable([ + { + cursor: { opaque: "1" }, + timestamp: "now", + source: "functions", + stream: "stdout", + message: "ready", + }, + ]), + close: () => Effect.void, + } as unknown as EffectStack; + const out = mockOutput({ interactive: false }); + const operations = { + findStack: () => Effect.succeed(Option.none()), + createStack: () => Effect.succeed(stack), + openStack: () => Effect.succeed(stack), + loadConfig: () => + Effect.succeed( + Schema.decodeUnknownSync(CliConfigSchema)({ + project_id: "demo", + api: { schemas: ["private"] }, + auth: { jwt_secret: "jwt-secret-for-tests" }, + db: { vault: { DB_PASSWORD: "vault-secret" } }, + edge_runtime: { secrets: { EDGE_TOKEN: "edge-secret" } }, + }), + ), + } as const; + return Effect.scoped( + serveManagedFunctions({ projectRoot: "/tmp/project", stackName: "default" }, operations), + ).pipe( + Effect.provide(Layer.mergeAll(out.layer, BunServices.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(startedConfig).toMatchObject({ + capabilities: { + functions: { settings: { functions_root: "/tmp/project/supabase/functions" } }, + }, + }); + const config = startedConfig as { + capabilities: { + rest: { settings: { schemas: string[] } }; + auth: { settings: { jwt_secret: Redacted.Redacted } }; + database: { settings: { vault: { DB_PASSWORD: Redacted.Redacted } } }; + }; + }; + expect(config.capabilities.rest.settings.schemas).toEqual(["private"]); + expect(Redacted.value(config.capabilities.auth.settings.jwt_secret)).toBe( + "jwt-secret-for-tests", + ); + expect(Redacted.value(config.capabilities.database.settings.vault.DB_PASSWORD)).toBe( + "vault-secret", + ); + expect(out.messages.some((message) => message.message.includes("ready"))).toBe(true); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 2a4e98012f..6e81b3c96e 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -1,267 +1,91 @@ -import { connectLayer, daemonLayer, Stack, type EdgeRuntimeConfig } from "@supabase/stack/effect"; -import { Context, Duration, Effect, FileSystem, Layer, Option, Stream } from "effect"; +import { Effect, FileSystem, Option, Path, Stream } from "effect"; import { loadCliConfig } from "@supabase/config/effect"; +import type { CliConfig } from "@supabase/config"; +import { createStack, findStack, openStack } from "@supabase/stack/effect"; import { join } from "node:path"; -import { CLI_VERSION } from "../../../../shared/cli/version.ts"; -import { CliSettings } from "../../../config/cli-settings.service.ts"; import { CliProjectHome } from "../../../config/cli-project-home.service.ts"; -import { cliProjectLocalServiceVersionsLayer } from "../../../config/cli-project-local-service-versions.layer.ts"; -import { projectLinkStateLayer } from "../../../config/project-link-state.layer.ts"; -import { resolveServiceVersionContext } from "../../../config/service-version-resolution.ts"; -import { managedPortIntents } from "../../../config/managed-port-intents.ts"; -import { toStartStackConfig, withServiceVersions } from "../../../config/stack-config.ts"; -import { ensureProjectStateIgnored } from "../../../config/project-gitignore.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { - FileWatcher, - type FileWatchEvent, -} from "../../../../shared/runtime/file-watcher.service.ts"; -import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { startStackWithProgress } from "../../../stack/stack.shared.ts"; -import { - functionsDevWatchPaths, - resolveFunctionsBundle, - type FunctionsDevConfigOptions, - type FunctionsDevWatchPath, -} from "./functions-dev-config.ts"; -import { - resolveFunctionsDevEdgeRuntimeConfig, - type ResolvedFunctionsDevEdgeRuntimeConfig, -} from "./functions-dev-edge-runtime-config.ts"; - -interface FunctionsDevRuntimeOptions extends FunctionsDevConfigOptions { - readonly stack: string; -} - -interface FunctionsDevStackOptions extends FunctionsDevRuntimeOptions { - readonly edgeRuntime: EdgeRuntimeConfig; +import { toStartStackConfig } from "../../../config/stack-config.ts"; +import type { FunctionsDevFlags } from "./dev.command.ts"; + +export interface ServeManagedFunctionsOperations { + readonly findStack: typeof findStack; + readonly createStack: typeof createStack; + readonly openStack: typeof openStack; + readonly loadConfig: ( + cwd: string, + ) => Effect.Effect; } -interface FunctionsDevWatchChange { - readonly touchesCliConfig: boolean; -} - -type StackService = typeof Stack.Service; - -const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptions) { - const cliSettings = yield* CliSettings; - const cliProjectHome = yield* CliProjectHome; - const runtimeInfo = yield* RuntimeInfo; - const output = yield* Output; - - yield* output.info("No local stack is running. Starting the local Supabase stack..."); - yield* ensureProjectStateIgnored(cliProjectHome.projectRoot); - - const serviceVersionContext = yield* resolveServiceVersionContext([], undefined); - const loadedCliConfig = yield* loadCliConfig(cliProjectHome.projectRoot); - const stackConfig = { - ...withServiceVersions(toStartStackConfig([], "docker"), serviceVersionContext.runtimeVersions), - // Functions dev explicitly requires Edge Runtime even when the project - // config supplies only schema defaults. Request Docker explicitly so the - // managed daemon validates container availability before persisting state. - servicePolicies: { "edge-runtime": "eager" as const }, - }; - const stackLayer = yield* daemonLayer({ - cliVersion: CLI_VERSION, - cacheRoot: cliSettings.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: cliProjectHome.projectRoot, - name: opts.stack, - edgeRuntime: opts.edgeRuntime, - launch: { - versions: serviceVersionContext.pinnedBaseline, - excludedServices: [], - }, - ...stackConfig, - portIntents: managedPortIntents(stackConfig, loadedCliConfig ?? undefined), +export const runFunctionsDevRuntime = Effect.fnUntraced(function* (flags: FunctionsDevFlags) { + return yield* serveManagedFunctions({ + projectRoot: (yield* CliProjectHome).projectRoot, + stackName: flags.stack, }); - const context = yield* Layer.build(stackLayer); - const stack = Context.get(context, Stack); - yield* startStackWithProgress().pipe(Effect.provide(context)); - - return { stack, startedByCommand: true }; }); -export const connectOrStartFunctionsDevStack = Effect.fnUntraced(function* ( - opts: FunctionsDevStackOptions, +/** Serves every local Function through the stack-owned Edge Runtime. */ +export const serveManagedFunctions = Effect.fnUntraced(function* ( + options: { + readonly projectRoot: string; + readonly stackName: string; + }, + operations: ServeManagedFunctionsOperations = { + findStack, + createStack, + openStack, + loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), + }, ) { - const cliSettings = yield* CliSettings; - const cliProjectHome = yield* CliProjectHome; - const runtimeInfo = yield* RuntimeInfo; - - const existingLayer = yield* connectLayer({ - cliVersion: CLI_VERSION, - cwd: runtimeInfo.cwd, - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: opts.stack, - }).pipe( - Effect.map(Option.some), - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), - ); - - if (Option.isSome(existingLayer)) { - const context = yield* Layer.build(existingLayer.value); - const stack = Context.get(context, Stack); - return { stack, startedByCommand: false }; - } - - return yield* startFullStack(opts); -}); - -function logEntryStream(stack: StackService) { - return stack.subscribeLogs("edge-runtime").pipe( - Stream.map((entry) => ({ - type: "log-entry" as const, - timestamp: new Date(entry.timestamp).toISOString(), - service: entry.service, - stream: entry.stream, - line: entry.line, - source: "live" as const, - })), - ); -} - -const ensureFunctionsDirectory = Effect.fnUntraced(function* () { - const fs = yield* FileSystem.FileSystem; - const cliProjectHome = yield* CliProjectHome; - yield* fs.makeDirectory(join(cliProjectHome.supabaseDir, "functions"), { recursive: true }); -}); - -function watchEventMatches(spec: FunctionsDevWatchPath, event: FileWatchEvent): boolean { - if (spec.names === undefined) { - return true; - } - const segments = event.path.replaceAll("\\", "/").split("/"); - return spec.names.some((name) => segments.includes(name)); -} - -function isCliConfigEvent(event: FileWatchEvent): boolean { - const segments = event.path.replaceAll("\\", "/").split("/"); - return segments.includes("config.toml") || segments.includes("config.json"); -} - -export function watchPaths(paths: ReadonlyArray) { - return Stream.unwrap( + const output = yield* Output; + yield* Effect.scoped( Effect.gen(function* () { - const fileWatcher = yield* FileWatcher; - const streams = paths.map((spec) => - fileWatcher.watch(spec.path).pipe( - Stream.filter((events) => events.some((event) => watchEventMatches(spec, event))), - Stream.map((events) => ({ - touchesCliConfig: events.some(isCliConfigEvent), - })), - ), - ); - return Stream.mergeAll(streams, { concurrency: "unbounded" }).pipe( - Stream.debounce(Duration.millis(100)), - ); + const descriptor = yield* operations.findStack({ + projectRoot: options.projectRoot, + name: options.stackName, + }); + const stack = Option.isNone(descriptor) + ? yield* operations.createStack({ + projectRoot: options.projectRoot, + name: options.stackName, + runtime: { kind: "container" }, + }) + : yield* operations.openStack(descriptor.value.id); + const functionsRoot = join(options.projectRoot, "supabase", "functions"); + const loadedConfig = yield* operations.loadConfig(options.projectRoot); + const translated = toStartStackConfig(loadedConfig, [], "docker", options.projectRoot); + const functionsCapability = translated.capabilities?.functions; + const functionsConfig = + functionsCapability !== undefined && + "enabled" in functionsCapability && + functionsCapability.enabled === false + ? { settings: { functions_root: functionsRoot } } + : { + ...functionsCapability, + settings: { + ...(functionsCapability !== undefined && "settings" in functionsCapability + ? functionsCapability.settings + : {}), + functions_root: functionsRoot, + }, + }; + const status = yield* stack.start({ + config: { + ...translated, + capabilities: { + ...translated.capabilities, + functions: functionsConfig, + }, + }, + }); + yield* output.success(`Functions stack is ${status.lifecycle}.`, { + stack: options.stackName, + functions_root: functionsRoot, + lifecycle: status.lifecycle, + }); + yield* stack + .logs({ capabilities: ["functions"], follow: true }) + .pipe(Stream.runForEach((entry) => output.info(`[${entry.source}] ${entry.message}`))); }), ); -} - -function reloadEdgeRuntime( - stack: StackService, - opts: FunctionsDevRuntimeOptions, - edgeRuntime: EdgeRuntimeConfig, -) { - return Effect.gen(function* () { - const functions = yield* resolveFunctionsBundle(opts); - yield* stack.reloadEdgeRuntime({ edgeRuntime, functions }); - }); -} - -function applyWatchedChange( - currentEdgeRuntimeState: ResolvedFunctionsDevEdgeRuntimeConfig, - change: FunctionsDevWatchChange, -) { - return Effect.gen(function* () { - if (!change.touchesCliConfig) { - return { - state: currentEdgeRuntimeState, - action: "functions" as const, - }; - } - - const nextEdgeRuntimeState = yield* resolveFunctionsDevEdgeRuntimeConfig(); - if (nextEdgeRuntimeState.fingerprint === currentEdgeRuntimeState.fingerprint) { - return { - state: currentEdgeRuntimeState, - action: "functions" as const, - }; - } - - return { - state: nextEdgeRuntimeState, - action: "edge-runtime" as const, - }; - }); -} - -export const runFunctionsDevRuntime = Effect.fnUntraced(function* ( - opts: FunctionsDevRuntimeOptions, -) { - const output = yield* Output; - const processControl = yield* ProcessControl; - - yield* output.intro("Develop Edge Functions"); - - let edgeRuntimeState = yield* resolveFunctionsDevEdgeRuntimeConfig(); - const { stack, startedByCommand } = yield* connectOrStartFunctionsDevStack({ - ...opts, - edgeRuntime: edgeRuntimeState.config, - }); - const restoreFunctions = startedByCommand - ? undefined - : yield* resolveFunctionsBundle({ envFile: Option.none(), noVerifyJwt: false }); - - yield* Effect.gen(function* () { - yield* ensureFunctionsDirectory(); - yield* reloadEdgeRuntime(stack, opts, edgeRuntimeState.config); - const info = yield* stack.getInfo; - const watchPathList = yield* functionsDevWatchPaths(opts.envFile); - - yield* output.success("Edge Functions dev server is running.", { - functions_url: `${info.url}/functions/v1`, - }); - yield* output.info(`Functions URL: ${info.url}/functions/v1/`); - - const restartOnChange = watchPaths(watchPathList).pipe( - Stream.runForEach((change) => - Effect.gen(function* () { - const result = yield* applyWatchedChange(edgeRuntimeState, change); - if (result.action === "edge-runtime") { - yield* output.info("Edge runtime config changed. Restarting edge-runtime..."); - yield* reloadEdgeRuntime(stack, opts, result.state.config); - edgeRuntimeState = result.state; - return; - } - edgeRuntimeState = result.state; - yield* output.info("Function files changed. Restarting edge-runtime..."); - yield* stack.reloadFunctions({ functions: yield* resolveFunctionsBundle(opts) }); - }).pipe( - Effect.catch((error) => - output.error(error instanceof Error ? error.message : String(error)), - ), - ), - ), - ); - - const logs = logEntryStream(stack).pipe(Stream.runForEach((event) => output.event(event))); - const shutdown = processControl.awaitShutdown; - - yield* Effect.raceFirst(Effect.raceFirst(restartOnChange, logs), shutdown); - }).pipe( - Effect.ensuring( - startedByCommand - ? stack.dispose.pipe(Effect.ignore) - : stack.reloadFunctions({ functions: restoreFunctions }).pipe(Effect.ignore), - ), - ); }); - -export const functionsDevRuntimeLayer = Layer.mergeAll( - projectLinkStateLayer, - cliProjectLocalServiceVersionsLayer, -); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts deleted file mode 100644 index 568188f7e9..0000000000 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Duration, Effect, Fiber, Layer, Queue, Stream } from "effect"; -import { join } from "node:path"; -import { - FileWatcher, - type FileWatchEvent, -} from "../../../../shared/runtime/file-watcher.service.ts"; -import { watchPaths } from "./functions-dev-runtime.ts"; - -function makeFakeFileWatcher() { - const queues = new Map>>(); - const registrations = new Map>(); - const registrationFor = (path: string) => { - let latch = registrations.get(path); - if (latch === undefined) { - latch = Deferred.makeUnsafe(); - registrations.set(path, latch); - } - return latch; - }; - - const layer = Layer.succeed( - FileWatcher, - FileWatcher.of({ - watch: (path) => - Stream.callback>((queue) => - Effect.sync(() => { - queues.set(path, queue); - }).pipe(Effect.andThen(Deferred.succeed(registrationFor(path), undefined))), - ), - }), - ); - - const awaitWatch = (expectedPath: string) => - Deferred.await(registrationFor(expectedPath)).pipe( - Effect.timeoutOrElse({ - duration: "2 seconds", - orElse: () => Effect.die(new Error(`No watcher registered for ${expectedPath}`)), - }), - ); - - const emit = (path: string, events: ReadonlyArray) => - Effect.sync(() => { - const queue = queues.get(path); - if (queue === undefined) { - throw new Error(`No watcher registered for ${path}`); - } - Queue.offerUnsafe(queue, events); - }); - - return { awaitWatch, emit, layer }; -} - -describe("functions dev runtime", () => { - it.live("emits when the supabase functions directory appears after dev starts", () => { - const cwd = "/tmp/supabase-functions-dev-watch"; - - return Effect.gen(function* () { - const watcher = makeFakeFileWatcher(); - let emitted = false; - - const fiber = yield* watchPaths([{ path: cwd, names: ["supabase"] }]).pipe( - Stream.take(1), - Stream.runForEach(() => - Effect.sync(() => { - emitted = true; - }), - ), - Effect.timeout(Duration.seconds(5)), - Effect.provide(watcher.layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* watcher.awaitWatch(cwd); - yield* watcher.emit(cwd, [{ path: join(cwd, "supabase", "functions"), type: "create" }]); - yield* Fiber.join(fiber); - - expect(emitted).toBe(true); - }); - }); - - it.live("marks config json changes as project config changes", () => { - const cwd = "/tmp/supabase-functions-dev-watch"; - const supabaseDir = join(cwd, "supabase"); - - return Effect.gen(function* () { - const watcher = makeFakeFileWatcher(); - - const fiber = yield* watchPaths([ - { path: supabaseDir, names: ["functions", "config.toml", "config.json"] }, - ]).pipe( - Stream.take(1), - Stream.runCollect, - Effect.timeout(Duration.seconds(5)), - Effect.provide(watcher.layer), - Effect.forkChild({ startImmediately: true }), - ); - - yield* watcher.awaitWatch(supabaseDir); - yield* watcher.emit(supabaseDir, [ - { path: join(supabaseDir, "config.json"), type: "create" }, - ]); - const changes = yield* Fiber.join(fiber); - - expect(changes.at(0)?.touchesCliConfig).toBe(true); - }); - }); -}); diff --git a/apps/cli/src/next/commands/functions/list/list.integration.test.ts b/apps/cli/src/next/commands/functions/list/list.integration.test.ts index 97d436b489..2f06a1e57c 100644 --- a/apps/cli/src/next/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/next/commands/functions/list/list.integration.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; -import { httpTransportClientLayer } from "@supabase/stack/effect"; import { mkdtempSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -95,7 +94,6 @@ function cliSettingsLayer() { function commandTreeSupportLayer(cwd: string) { const projectHomeDir = join(cwd, ".supabase"); return Layer.mergeAll( - httpTransportClientLayer, cliSettingsLayer(), Layer.succeed( CliProjectHome, diff --git a/apps/cli/src/next/commands/functions/new/new.integration.test.ts b/apps/cli/src/next/commands/functions/new/new.integration.test.ts index de00044b8c..44bf2b7226 100644 --- a/apps/cli/src/next/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/next/commands/functions/new/new.integration.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { httpTransportClientLayer } from "@supabase/stack/effect"; import { existsSync, mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -38,7 +37,6 @@ function buildLayer(cwd: string) { function commandTreeSupportLayer(cwd: string) { const projectHomeDir = join(cwd, ".supabase"); return Layer.mergeAll( - httpTransportClientLayer, Layer.succeed( CliSettings, CliSettings.of({ diff --git a/apps/cli/src/next/commands/link/link.handler.ts b/apps/cli/src/next/commands/link/link.handler.ts index 7b3adc86b9..39b0643355 100644 --- a/apps/cli/src/next/commands/link/link.handler.ts +++ b/apps/cli/src/next/commands/link/link.handler.ts @@ -1,6 +1,4 @@ import { Effect, Option } from "effect"; -import { fillServiceVersionManifest, listStacks } from "@supabase/stack/effect"; -import { CliSettings } from "../../config/cli-settings.service.ts"; import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { refreshLinkedProjectSnapshot } from "../../config/project-link-refresh.ts"; @@ -116,22 +114,12 @@ export const link = Effect.fnUntraced(function* (flags: LinkFlags) { const output = yield* Output; const analytics = yield* Analytics; const cliProjectHome = yield* CliProjectHome; - const cliSettings = yield* CliSettings; yield* output.intro("Link local project to Supabase"); const projectRef = yield* chooseProjectRef(flags.projectRef); yield* ensureProjectStateIgnored(cliProjectHome.projectRoot); - const refreshed = yield* refreshLinkedProjectSnapshot( - projectRef, - (yield* listStacks({ - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - })).map((stack) => ({ - stackName: stack.name, - services: fillServiceVersionManifest(stack.versions), - })), - ); + const refreshed = yield* refreshLinkedProjectSnapshot(projectRef, []); const linkedProject = refreshed.linkedProject; yield* output.success(`Linked to project ${linkedProject.ref}.`, { diff --git a/apps/cli/src/next/commands/list/list.handler.ts b/apps/cli/src/next/commands/list/list.handler.ts index ff8da92466..01a81aa537 100644 --- a/apps/cli/src/next/commands/list/list.handler.ts +++ b/apps/cli/src/next/commands/list/list.handler.ts @@ -1,57 +1,31 @@ import { Effect } from "effect"; import { listStacks } from "@supabase/stack/effect"; -import { CliSettings } from "../../config/cli-settings.service.ts"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; export const list = Effect.fnUntraced(function* () { const output = yield* Output; - const cliSettings = yield* CliSettings; - const cliProjectHome = yield* CliProjectHome; - + const project = yield* CliProjectHome; yield* output.intro("List local Supabase stacks"); - - const stacks = yield* listStacks({ - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - }); - + const stacks = yield* listStacks({ projectRoot: project.projectRoot }); if (stacks.length === 0) { const message = "No local Supabase stacks are known for this project."; - if (output.format === "text") { - yield* output.outro(message); - return; - } - - yield* output.success(message, { stacks: [] }); - return; + if (output.format === "text") return yield* output.outro(message); + return yield* output.success(message, { stacks: [] }); } - const data = { stacks: stacks.map((stack) => ({ + id: stack.id, name: stack.name, - running: stack.running, - ports: stack.ports, - started_at: stack.startedAt, + lifecycle: stack.desiredLifecycle, + runtime: stack.runtime, + project_root: stack.projectRoot, })), }; - - if (output.format !== "text") { - yield* output.success("Known local Supabase stacks.", data); - return; - } - + if (output.format !== "text") return yield* output.success("Known local Supabase stacks.", data); yield* output.success("Known local Supabase stacks."); for (const stack of stacks) { - const parts = [ - stack.running ? "running" : "stopped", - `API ${stack.ports.apiPort}`, - `DB ${stack.ports.dbPort}`, - ]; - if (stack.running && stack.startedAt !== undefined) { - parts.push(`started ${stack.startedAt}`); - } - yield* output.info(`${stack.name}: ${parts.join(" | ")}`); + yield* output.info(`${stack.name}: ${stack.desiredLifecycle} (${stack.runtime.kind})`); } yield* output.outro( `Found ${stacks.length} local Supabase stack${stacks.length === 1 ? "" : "s"}.`, diff --git a/apps/cli/src/next/commands/logs/logs.command.ts b/apps/cli/src/next/commands/logs/logs.command.ts index 3fda7df274..e7f77ab8aa 100644 --- a/apps/cli/src/next/commands/logs/logs.command.ts +++ b/apps/cli/src/next/commands/logs/logs.command.ts @@ -1,4 +1,4 @@ -import { DEFAULT_MANAGED_STACK_NAME } from "@supabase/stack/effect"; +import { DEFAULT_MANAGED_STACK_NAME } from "../../../shared/stack-constants.ts"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; diff --git a/apps/cli/src/next/commands/logs/logs.handler.ts b/apps/cli/src/next/commands/logs/logs.handler.ts index 250f5c2a72..005314df16 100644 --- a/apps/cli/src/next/commands/logs/logs.handler.ts +++ b/apps/cli/src/next/commands/logs/logs.handler.ts @@ -1,102 +1,46 @@ -import { connectLayer, Stack } from "@supabase/stack/effect"; -import { Context, Effect, Layer, Stream } from "effect"; -import { CliSettings } from "../../config/cli-settings.service.ts"; +import { Effect, Option, Stream } from "effect"; +import { findStack, openStack } from "@supabase/stack/effect"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; -import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import { CLI_VERSION } from "../../../shared/cli/version.ts"; import type { LogsFlags } from "./logs.command.ts"; import { UnsupportedLogsOutputFormatError } from "./logs.errors.ts"; -type LogsOutput = { - readonly format: "text" | "json" | "stream-json"; - readonly info: (message: string) => Effect.Effect; - readonly event: (event: { - readonly type: "log-entry"; - readonly timestamp: string; - readonly service: string; - readonly stream: "stdout" | "stderr"; - readonly line: string; - readonly source: "history" | "live"; - }) => Effect.Effect; -}; - -type LogEntry = { - readonly timestamp: number; - readonly service: string; - readonly stream: "stdout" | "stderr"; - readonly line: string; -}; - -function emitLogEntry( - output: LogsOutput, - entry: LogEntry, - source: "history" | "live", -): Effect.Effect { - if (output.format === "stream-json") { - return output.event({ - type: "log-entry", - timestamp: new Date(entry.timestamp).toISOString(), - service: entry.service, - stream: entry.stream, - line: entry.line, - source, +export const logs = Effect.fnUntraced(function* (flags: LogsFlags) { + const output = yield* Output; + const project = yield* CliProjectHome; + yield* output.intro("Show local Supabase logs"); + if (output.format === "json") { + return yield* new UnsupportedLogsOutputFormatError({ + detail: "The logs command does not support --output-format json.", + suggestion: "Use --output-format stream-json for machine-readable streaming logs.", }); } - return output.info(`[${entry.service}] ${entry.line}`); -} - -export const logs = Effect.fnUntraced(function* (flags: LogsFlags) { - return yield* Effect.scoped( + const descriptorOption = yield* findStack({ + projectRoot: project.projectRoot, + name: flags.stack, + }); + if (Option.isNone(descriptorOption)) + return yield* output.outro("No local Supabase stack was found."); + yield* Effect.scoped( Effect.gen(function* () { - const output = yield* Output; - const cliSettings = yield* CliSettings; - const cliProjectHome = yield* CliProjectHome; - const processControl = yield* ProcessControl; - const runtimeInfo = yield* RuntimeInfo; - - yield* output.intro("Show local Supabase logs"); - - if (output.format === "json") { - return yield* new UnsupportedLogsOutputFormatError({ - detail: "The logs command does not support --output-format json.", - suggestion: "Use --output-format stream-json for machine-readable streaming logs.", - }); - } - - const layer = yield* connectLayer({ - cliVersion: CLI_VERSION, - cwd: runtimeInfo.cwd, - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: flags.stack, - }); - const context = yield* Layer.build(layer); - const stack = Context.get(context, Stack); - const services = flags.service.length === 0 ? undefined : flags.service; - const history = flags.tail > 0 ? yield* stack.logHistoryAll(flags.tail, services) : []; - const historyStream = Stream.fromIterable(history).pipe( - Stream.runForEach((entry) => emitLogEntry(output, entry, "history")), - ); - - if (flags.noFollow) { - yield* historyStream; - yield* output.outro("Finished showing local Supabase logs."); - return yield* processControl.exit(0); - } - - const liveStream = stack - .subscribeAllLogs(services) - .pipe(Stream.runForEach((entry) => emitLogEntry(output, entry, "live"))); - - yield* historyStream; - yield* Effect.raceFirst( - liveStream, - processControl - .awaitSignal() - .pipe(Effect.flatMap((signal) => processControl.exit(signal === "SIGINT" ? 130 : 0))), + const stack = yield* openStack(descriptorOption.value.id); + const stream = flags.noFollow + ? stack.logs({ follow: false }).pipe(Stream.take(flags.tail)) + : stack.logs({ follow: true }); + yield* stream.pipe( + Stream.runForEach((entry) => + output.format === "stream-json" + ? output.success("", { + type: "log-entry", + timestamp: entry.timestamp, + service: entry.source, + stream: entry.stream, + line: entry.message, + }) + : output.info(`[${entry.source}] ${entry.message}`), + ), ); }), ); + yield* output.outro("Finished showing local Supabase logs."); }); diff --git a/apps/cli/src/next/commands/logs/logs.integration.test.ts b/apps/cli/src/next/commands/logs/logs.integration.test.ts index d47ab9481b..a5b8afd346 100644 --- a/apps/cli/src/next/commands/logs/logs.integration.test.ts +++ b/apps/cli/src/next/commands/logs/logs.integration.test.ts @@ -1,79 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; import { Effect, Exit, Layer } from "effect"; import { logs } from "./logs.handler.ts"; -import { - mockOutput, - mockProcessControl, - mockProjectLinkState, -} from "../../../../tests/helpers/mocks.ts"; -import { makeRunningStackFixture } from "../../../../tests/helpers/running-stack.ts"; +import { emptyEnv, mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; describe("logs handler", () => { - it.live("fails with an actionable upgrade error without restarting an incompatible owner", () => - Effect.promise(() => - makeRunningStackFixture({ - cliVersion: "2.60.0", - }), - ).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const processControl = mockProcessControl(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - processControl.layer, - mockProjectLinkState(), - BunServices.layer, - ); - return logs({ stack: fixture.stackName, service: [], tail: 10, noFollow: false }).pipe( - Effect.provide(layer), - Effect.exit, - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("DaemonUpgradeRequired"); - } - expect(processControl.exitCalls).toEqual([]); - expect(out.messages).not.toContainEqual( - expect.objectContaining({ - type: "info", - message: expect.stringContaining("[postgres]"), - }), - ); - }), - ), - ); - }), - ), - ); - - it.live("attaches to managed control and renders persisted and live history", () => - Effect.promise(() => makeRunningStackFixture()).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const processControl = mockProcessControl(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - processControl.layer, - mockProjectLinkState(), - BunServices.layer, - ); - return logs({ stack: fixture.stackName, service: [], tail: 10, noFollow: false }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "info", message: "[postgres] ready" }), - ); - }), - ), - ); - }), - ), - ); + it.live("fails clearly when no managed stack exists", () => { + const out = mockOutput({ interactive: false }); + return logs({ stack: "default", service: [], tail: 10, noFollow: true }).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer, mockProcessControl().layer)), + Effect.exit, + Effect.tap((exit) => Effect.sync(() => expect(Exit.isFailure(exit)).toBe(true))), + ); + }); }); diff --git a/apps/cli/src/next/commands/services/services.handler.ts b/apps/cli/src/next/commands/services/services.handler.ts index 95b296a55c..55d927c04e 100644 --- a/apps/cli/src/next/commands/services/services.handler.ts +++ b/apps/cli/src/next/commands/services/services.handler.ts @@ -1,12 +1,6 @@ -import { - fillServiceVersionManifest, - planStackVersions, - resolveStackSummary, -} from "@supabase/stack/effect"; import { Effect, Exit, Option } from "effect"; import { Credentials } from "../../auth/credentials.service.ts"; import { CliSettings } from "../../config/cli-settings.service.ts"; -import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { CliProjectLocalServiceVersions } from "../../config/cli-project-local-service-versions.service.ts"; import { ProjectLinkState } from "../../config/project-link-state.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -29,32 +23,14 @@ export const services = Effect.fnUntraced(function* () { const credentials = yield* Credentials; const cliProjectLocalServiceVersions = yield* CliProjectLocalServiceVersions; const projectLinkState = yield* ProjectLinkState; - const cliProjectHome = yield* CliProjectHome; const commandRuntime = yield* CommandRuntime; const linkedStateExit = yield* projectLinkState.load.pipe(Effect.exit); const linkedState = Exit.isSuccess(linkedStateExit) ? linkedStateExit.value : Option.none(); const accessToken = yield* credentials.getAccessToken; const localServiceVersions = yield* cliProjectLocalServiceVersions.load; - const existingSummary = yield* resolveStackSummary({ - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: "default", - }).pipe( - Effect.map(Option.some), - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), - ); - const serviceVersionContext = planStackVersions({ - ...(Option.isSome(linkedState) ? { candidateBaseline: linkedState.value.versions } : {}), - ...(Option.isSome(existingSummary) - ? { pinnedBaseline: fillServiceVersionManifest(existingSummary.value.versions) } - : {}), - ...(Option.isSome(localServiceVersions) - ? { localOverrides: localServiceVersions.value.versions } - : {}), - }); const localImageOptions = { - serviceVersions: serviceVersionContext.runtimeVersions, + serviceVersions: Option.isSome(localServiceVersions) ? localServiceVersions.value.versions : {}, }; let rows = listLocalServiceVersions(localImageOptions); diff --git a/apps/cli/src/next/commands/services/services.integration.test.ts b/apps/cli/src/next/commands/services/services.integration.test.ts index 396d827b37..671cf09040 100644 --- a/apps/cli/src/next/commands/services/services.integration.test.ts +++ b/apps/cli/src/next/commands/services/services.integration.test.ts @@ -1,50 +1,35 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { FetchHttpClient } from "effect/unstable/http"; import { Effect, Layer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { services } from "./services.handler.ts"; import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; import { + emptyEnv, mockCredentials, mockOutput, mockProjectLinkState, mockCliProjectLocalServiceVersions, } from "../../../../tests/helpers/mocks.ts"; -import { makeRunningStackFixture } from "../../../../tests/helpers/running-stack.ts"; describe("services handler", () => { - it.live( - "uses pinned versions from the managed launch document", - () => - Effect.scoped( - Effect.promise(() => makeRunningStackFixture()).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - mockProjectLinkState(), - mockCliProjectLocalServiceVersions(), - mockCredentials().layer, - Layer.succeed(CommandRuntime, { - commandPath: ["stack", "services"], - commandRunId: "test-run", - }), - BunServices.layer, - FetchHttpClient.layer, - ); - return services().pipe( - Effect.provide(layer), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => { - expect(out.rawChunks.some((chunk) => chunk.text.includes("postgres"))).toBe(true); - }), - ), - ); - }), + it.live("renders the local service image matrix when unlinked", () => { + const out = mockOutput({ interactive: false }); + const layer = Layer.mergeAll( + emptyEnv(), + out.layer, + mockCredentials().layer, + mockProjectLinkState(), + mockCliProjectLocalServiceVersions(), + FetchHttpClient.layer, + Layer.succeed(CommandRuntime, { commandPath: ["services"], commandRunId: "test" }), + ); + return services().pipe( + Effect.provide(layer), + Effect.tap(() => + Effect.sync(() => + expect(out.rawChunks.some((chunk) => chunk.text.includes("postgres"))).toBe(true), ), ), - 30_000, - ); + ); + }); }); diff --git a/apps/cli/src/next/commands/start/flows/background.flow.ts b/apps/cli/src/next/commands/start/flows/background.flow.ts deleted file mode 100644 index 593f2f2de8..0000000000 --- a/apps/cli/src/next/commands/start/flows/background.flow.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Effect } from "effect"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { printStackConnectionInfo, startStackWithProgress } from "../../../stack/stack.shared.ts"; - -export const startBackground = Effect.fnUntraced(function* () { - const output = yield* Output; - yield* startStackWithProgress(); - yield* printStackConnectionInfo(); - yield* output.outro("Local Supabase stack is ready."); -}); diff --git a/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts b/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts deleted file mode 100644 index 3decb99b4f..0000000000 --- a/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { StackUnavailableError } from "@supabase/stack/effect"; -import { makeTestStack } from "@supabase/stack/testing"; -import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; -import { Ink } from "../../../../shared/runtime/ink.service.ts"; -import { Stack } from "@supabase/stack/effect"; -import { startForegroundWithStopSignal } from "./foreground.flow.ts"; - -const inkLayer = Layer.succeed(Ink, { - render: () => - Effect.succeed({ - unmount: () => undefined, - rerender: () => undefined, - waitUntilExit: () => new Promise(() => undefined), - }), -}); - -describe("start foreground flow", () => { - it.effect("does not start the runtime when dashboard state initialization fails", () => - Effect.gen(function* () { - let startCalls = 0; - const stack = { - ...makeTestStack(), - getInfo: Effect.fail(new StackUnavailableError({ phase: "starting" })), - start: Effect.sync(() => { - startCalls += 1; - }), - }; - const exit = yield* startForegroundWithStopSignal(Effect.never).pipe( - Effect.provide(Layer.mergeAll(Layer.succeed(Stack, stack), inkLayer)), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - expect(startCalls).toBe(0); - }), - ); - - it.effect("creates the dashboard state stream before starting the runtime", () => - Effect.gen(function* () { - const stopRequested = Deferred.makeUnsafe(); - const started = Deferred.makeUnsafe(); - let allStateChangesCalled = false; - let startCalls = 0; - const stack = { - ...makeTestStack(), - start: Effect.gen(function* () { - expect(allStateChangesCalled).toBe(true); - startCalls += 1; - yield* Deferred.succeed(started, undefined); - }), - allStateChanges: Stream.unwrap( - Effect.sync(() => { - allStateChangesCalled = true; - return Stream.never; - }), - ), - }; - const fiber = yield* startForegroundWithStopSignal(Deferred.await(stopRequested)).pipe( - Effect.provide(Layer.mergeAll(Layer.succeed(Stack, stack), inkLayer)), - Effect.forkChild({ startImmediately: true }), - ); - - yield* Deferred.await(started); - expect(startCalls).toBe(1); - yield* Deferred.succeed(stopRequested, undefined); - yield* Fiber.join(fiber); - }), - ); -}); diff --git a/apps/cli/src/next/commands/start/flows/foreground.flow.ts b/apps/cli/src/next/commands/start/flows/foreground.flow.ts deleted file mode 100644 index 90d92709ec..0000000000 --- a/apps/cli/src/next/commands/start/flows/foreground.flow.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Effect } from "effect"; -import { Stack } from "@supabase/stack/effect"; -import { interruptOnSignal } from "../signal.ts"; -import { makeStartForegroundSession } from "../ui/foreground-session.ts"; - -export const startForegroundWithStopSignal = (stopRequested: Effect.Effect) => - Effect.fnUntraced(function* () { - const stack = yield* Stack; - - return yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* makeStartForegroundSession(); - yield* Effect.addFinalizer(() => Effect.uninterruptible(stack.dispose).pipe(Effect.ignore)); - - return yield* Effect.gen(function* () { - yield* stack.start; - yield* session.markRunning; - yield* session.waitUntilExit; - yield* session.markStopping; - }).pipe( - Effect.raceFirst(stopRequested), - Effect.catchCause((cause) => - session.markFailed(cause).pipe(Effect.andThen(Effect.failCause(cause))), - ), - ); - }), - ); - })(); - -export const startForeground = Effect.fnUntraced(function* () { - return yield* startForegroundWithStopSignal(interruptOnSignal); -}); diff --git a/apps/cli/src/next/commands/start/flows/non-interactive.flow.ts b/apps/cli/src/next/commands/start/flows/non-interactive.flow.ts deleted file mode 100644 index de578e14b7..0000000000 --- a/apps/cli/src/next/commands/start/flows/non-interactive.flow.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Effect, Stream } from "effect"; -import { Stack } from "@supabase/stack/effect"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { interruptOnSignal } from "../signal.ts"; -import { printStackConnectionInfo, startStackWithProgress } from "../../../stack/stack.shared.ts"; - -export const startNonInteractive = Effect.fnUntraced(function* () { - const output = yield* Output; - const stack = yield* Stack; - - return yield* Effect.gen(function* () { - yield* startStackWithProgress(); - yield* printStackConnectionInfo(); - yield* stack.allStateChanges.pipe( - Stream.runForEach((state) => output.info(`${state.name}: ${state.status}`)), - ); - }) - .pipe(Effect.raceFirst(interruptOnSignal)) - .pipe( - Effect.ensuring(Effect.uninterruptible(stack.dispose.pipe(Effect.catch(() => Effect.void)))), - ); -}); diff --git a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts deleted file mode 100644 index dd8d22e944..0000000000 --- a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; -import { describe, expect, test } from "vitest"; -import { Effect, Layer } from "effect"; -import { - mockProjectLinkState, - mockCliProjectLocalServiceVersions, -} from "../../../../tests/helpers/mocks.ts"; -import { - parseServiceVersionOverrides, - resolveServiceVersionContext, -} from "../../config/service-version-resolution.ts"; - -describe("service version overrides", () => { - test("canonicalizes repeated flag overrides to published service tags", async () => { - await expect( - Effect.runPromise( - parseServiceVersionOverrides(["postgrest=v14.5", "mailpit=1.30.2", "auth=2.180.0"]), - ), - ).resolves.toEqual({ - postgrest: "v14.5", - mailpit: "v1.30.2", - auth: "v2.180.0", - }); - }); - - test("resolves flag > local file > link state precedence", async () => { - const candidateBaseline = { - ...DEFAULT_VERSIONS, - postgres: "17.6.1.090", - postgrest: "v14.5", - auth: "v2.187.0", - }; - - const layer = Layer.mergeAll( - mockProjectLinkState({ - project: { - ref: "abcdefghijklmnopqrst", - name: "Test Project", - organization_id: "org_123", - organization_slug: "supabase", - }, - active_branch: { ref: "abcdefghijklmnopqrst", name: "main", is_default: true }, - fetchedAt: "2026-03-20T12:00:00.000Z", - versions: { - postgres: "17.6.1.090", - postgrest: "v14.5", - auth: "v2.187.0", - }, - }), - mockCliProjectLocalServiceVersions({ - updatedAt: "2026-03-20T12:05:00.000Z", - versions: { - auth: "v2.180.0", - storage: "1.40.0", - }, - }), - ); - - await expect( - Effect.runPromise( - resolveServiceVersionContext(["auth=v2.170.0", "postgres=17.4.1.045"]).pipe( - Effect.provide(layer), - ), - ), - ).resolves.toEqual({ - candidateBaseline, - pinnedBaseline: candidateBaseline, - runtimeVersions: { - ...candidateBaseline, - postgres: "17.4.1.045", - auth: "v2.170.0", - storage: "v1.40.0", - }, - activeOverrides: [ - { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "v2.170.0", source: "flag" }, - { service: "storage", version: "v1.40.0", source: "local" }, - ], - availableUpdates: [], - updateFingerprint: undefined, - }); - }); -}); diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index aa3a33a908..d5e4d53a53 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -1,285 +1,49 @@ -import { Effect, Layer, Context, Option } from "effect"; -import { loadCliConfig } from "@supabase/config/effect"; -import { - DEFAULT_MANAGED_STACK_NAME, - daemonLayer, - restartManagedStackForUpgrade, - fillServiceVersionManifest, - resolveStackSummary, - type StackSummary, -} from "@supabase/stack/effect"; +import { Layer, Option } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { cliProjectLocalServiceVersionsLayer } from "../../config/cli-project-local-service-versions.layer.ts"; -import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; -import { CliSettings } from "../../config/cli-settings.service.ts"; -import { CliProjectHome } from "../../config/cli-project-home.service.ts"; -import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts"; +import { DEFAULT_MANAGED_STACK_NAME } from "../../../shared/stack-constants.ts"; import { provideCliProjectCommandRuntime } from "../../config/project-runtime.layer.ts"; -import { - resolveServiceVersionContext, - type ResolvedServiceVersionContext, -} from "../../config/service-version-resolution.ts"; -import { - excludedStackServices, - type ExcludedStackService, - startModes, - type StartMode, - toStartStackConfig, - withServiceVersions, -} from "../../config/stack-config.ts"; -import { managedPortIntents } from "../../config/managed-port-intents.ts"; -import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { Output } from "../../../shared/output/output.service.ts"; +import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { inkLayer } from "../../../shared/runtime/ink.layer.ts"; -import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { start } from "./start.handler.ts"; -import { CLI_VERSION } from "../../../shared/cli/version.ts"; +export const excludedStackServices = [ + "auth", + "realtime", + "storage", + "studio", + "analytics", + "pooler", +] as const; +export type ExcludedStackService = (typeof excludedStackServices)[number]; export const excludeFlag = Flag.choice("exclude", excludedStackServices).pipe( Flag.atMost(excludedStackServices.length), - Flag.withDescription( - "Services to exclude from the local stack. Repeat the flag for multiple values.", - ), Flag.withDefault([] as ReadonlyArray), ); - -export const serviceVersionFlag = Flag.string("service-version").pipe( - Flag.atLeast(0), - Flag.withDescription( - "Override a local service version for this run. Format: service=version. Repeat the flag for multiple services.", - ), - Flag.withDefault([] as ReadonlyArray), -); - -const modeFlag = Flag.choice("mode", startModes).pipe( - Flag.withDescription( - 'Stack startup mode. "native" requires native-compatible services and "docker" requires a usable Docker or Podman runtime.', - ), +const modeFlag = Flag.choice("mode", ["native", "docker"] as const).pipe( Flag.optional, Flag.map(Option.getOrUndefined), ); -interface StartVersionStateShape { - readonly launch: { - readonly mode?: StartMode; - readonly versions: Readonly>; - /** - * Preserve the managed document's raw exclusions. The document may contain - * service names introduced by a newer CLI; narrowing is only appropriate - * when deriving the current runtime configuration. - */ - readonly excludedServices: ReadonlyArray; - }; - readonly previousUpdateFingerprint?: string; - readonly drift?: NonNullable; - readonly serviceVersionContext: ResolvedServiceVersionContext; - readonly lifecycleInput: { - readonly cacheRoot: string; - readonly workspacePath: string; - readonly stackName: string; - readonly cwd: string; - readonly cliVersion: string; - }; -} - -export class StartVersionState extends Context.Service()( - "supabase/commands/start/StartVersionState", -) {} - -/** - * Project the managed launch metadata observed after daemon startup into the - * state consumed by the start handler. The post-start summary is authoritative: - * an incompatible-owner upgrade restart may preserve selections from the existing - * daemon even when this invocation supplied different flags or version defaults. - * @internal - */ -export const startVersionStateLaunch = ( - summary: Pick, -): StartVersionStateShape["launch"] => ({ - mode: summary.launch.mode, - versions: summary.launch.versions, - excludedServices: summary.launch.excludedServices ?? [], -}); - const flags = { - stack: Flag.string("stack").pipe( - Flag.withDescription("Name of the managed local stack for this project."), - Flag.withDefault(DEFAULT_MANAGED_STACK_NAME), - ), + stack: Flag.string("stack").pipe(Flag.withDefault(DEFAULT_MANAGED_STACK_NAME)), mode: modeFlag, exclude: excludeFlag, - serviceVersion: serviceVersionFlag, - detach: Flag.boolean("detach").pipe( - Flag.withDescription("Run in background (daemon mode)"), - Flag.withDefault(false), - ), + detach: Flag.boolean("detach").pipe(Flag.withDefault(false)), } as const; - export type StartFlags = CliCommand.Command.Config.Infer; export const startCommand = Command.make("start", flags).pipe( - Command.withDescription( - "Start the local Supabase development stack.\n\n" + - "Starts the full local Supabase stack when Docker or Podman is usable; otherwise a supported host starts the native-capable service set. Use --mode to require one explicitly.\n\n" + - "Named CLI stacks persist managed runtime state under the Supabase home directory. Use --exclude to skip optional services. Use --detach to run in the background.", - ), + Command.withDescription("Start the local Supabase development stack."), Command.withShortDescription("Start local Supabase stack"), - Command.withExamples([ - { - command: "supabase start", - description: "Start the stack in the foreground and watch service status live", - }, - { - command: "supabase start --detach", - description: "Start the stack in the background and return to the shell", - }, - { - command: "supabase start --mode docker", - description: "Force the local stack to start in Docker mode", - }, - { - command: "supabase start --exclude studio --exclude analytics", - description: "Start a slimmer stack without Studio or analytics services", - }, - { - command: "supabase start --service-version auth=v2.180.0", - description: "Force a specific local service version for this run", - }, - ]), Command.withHandler((flags) => - start(flags).pipe( - withCommandInstrumentation({ - flags, - allowedFlagValues: ["mode"], - }), - withJsonErrorHandling, - ), + start(flags).pipe(withCommandInstrumentation({ flags }), withJsonErrorHandling), ), - Command.provide((flags) => { - const providedRuntimeLayer = provideCliProjectCommandRuntime( - Layer.mergeAll( - projectLinkStateLayer, - cliProjectLocalServiceVersionsLayer, - commandRuntimeLayer(["start"]), - ), - ); - - const runtimeStateEffect = Effect.gen(function* () { - const output = yield* Output; - const cliSettings = yield* CliSettings; - const cliProjectHome = yield* CliProjectHome; - const runtimeInfo = yield* RuntimeInfo; - const existingSummary = yield* resolveStackSummary({ - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - cwd: runtimeInfo.cwd, - name: flags.stack, - }).pipe(Effect.catchTag("NoRunningStackError", () => Effect.succeed(undefined))); - const serviceVersionContext = yield* resolveServiceVersionContext( - flags.serviceVersion, - existingSummary === undefined - ? undefined - : fillServiceVersionManifest(existingSummary.versions), - ); - const loadedCliConfig = yield* loadCliConfig(cliProjectHome.projectRoot); - // Tri-state in config.toml: unset and explicit `true` both auto-expose new entities in - // `public` (the cloud default); only explicit `false` revokes the default Data API GRANTs. - const autoExposeNewTables = loadedCliConfig?.config.api.auto_expose_new_tables ?? true; - const effectiveMode = flags.mode ?? existingSummary?.launch.mode; - const baseStackConfig = withServiceVersions( - toStartStackConfig(flags.exclude, effectiveMode), - serviceVersionContext.runtimeVersions, - ); - const stackConfig = { - ...baseStackConfig, - postgres: { ...baseStackConfig.postgres, autoExposeNewTables }, - }; - yield* output.intro("Start local Supabase stack"); - yield* ensureProjectStateIgnored(cliProjectHome.projectRoot); - - const portIntents = managedPortIntents(stackConfig, loadedCliConfig ?? undefined); - const configuredSummary = - existingSummary === undefined - ? undefined - : yield* resolveStackSummary({ - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - cwd: runtimeInfo.cwd, - name: flags.stack, - portDocument: portIntents, - }); - const launch = { - ...(flags.mode === undefined ? {} : { mode: flags.mode }), - versions: serviceVersionContext.pinnedBaseline, - excludedServices: flags.exclude, - ...(existingSummary?.lastNotifiedUpdateFingerprint === undefined - ? {} - : { lastNotifiedUpdateFingerprint: existingSummary.lastNotifiedUpdateFingerprint }), - }; - - const managedInput = { - cliVersion: CLI_VERSION, - cacheRoot: cliSettings.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: cliProjectHome.projectRoot, - name: flags.stack, - portIntents, - launch, - ...stackConfig, - }; - const stackLayer = yield* daemonLayer(managedInput).pipe( - Effect.catchTag("DaemonUpgradeRequired", (error) => - output - .warn( - [ - `Local stack was started with CLI v${error.oldCliVersion}. Restarting it with CLI v${error.newCliVersion}.`, - "Database and storage data, pinned service versions, and sticky ports will be preserved.", - "Existing connections will briefly disconnect.", - ].join("\n"), - ) - .pipe(Effect.andThen(restartManagedStackForUpgrade(managedInput, error))), - ), - ); - const summary = yield* resolveStackSummary({ - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - cwd: runtimeInfo.cwd, - name: flags.stack, - }); - return { - stackLayer, - startVersionState: StartVersionState.of({ - launch: startVersionStateLaunch(summary), - ...(summary.lastNotifiedUpdateFingerprint === undefined - ? {} - : { previousUpdateFingerprint: summary.lastNotifiedUpdateFingerprint }), - ...(configuredSummary?.running !== true || configuredSummary.drift === undefined - ? {} - : { drift: configuredSummary.drift }), - serviceVersionContext, - lifecycleInput: { - cacheRoot: cliSettings.supabaseHome, - workspacePath: cliProjectHome.projectRoot, - stackName: flags.stack, - cwd: runtimeInfo.cwd, - cliVersion: CLI_VERSION, - }, - }), - }; - }); - - const commandLayer = Layer.unwrap( - runtimeStateEffect.pipe( - Effect.map(({ stackLayer, startVersionState }) => - Layer.mergeAll(stackLayer, Layer.succeed(StartVersionState, startVersionState)), - ), - Effect.provide(providedRuntimeLayer), - ), - ); - - return Layer.mergeAll(commandLayer, inkLayer, providedRuntimeLayer); - }), + Command.provide(provideCliProjectCommandRuntime(Layer.mergeAll(commandRuntimeLayer(["start"])))), + Command.provide(inkLayer), ); + +export { ensureProjectStateIgnored }; diff --git a/apps/cli/src/next/commands/start/start.command.unit.test.ts b/apps/cli/src/next/commands/start/start.command.unit.test.ts index 773c310d8c..56f04c406d 100644 --- a/apps/cli/src/next/commands/start/start.command.unit.test.ts +++ b/apps/cli/src/next/commands/start/start.command.unit.test.ts @@ -1,20 +1,20 @@ import { describe, expect, test } from "vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Exit } from "effect"; -import { excludeFlag, serviceVersionFlag } from "./start.command.ts"; +import { excludeFlag } from "./start.command.ts"; describe("start command exclude flag", () => { test("parses repeated excluded services", async () => { const [, exclude] = await Effect.runPromise( excludeFlag .parse({ - flags: { exclude: ["auth", "postgrest"] }, + flags: { exclude: ["auth", "realtime"] }, arguments: [], }) .pipe(Effect.provide(BunServices.layer)), ); - expect(exclude).toEqual(["auth", "postgrest"]); + expect(exclude).toEqual(["auth", "realtime"]); }); test("rejects invalid excluded services", async () => { @@ -30,17 +30,4 @@ describe("start command exclude flag", () => { expect(Exit.isFailure(exit)).toBe(true); }); - - test("parses repeated service version overrides", async () => { - const [, overrides] = await Effect.runPromise( - serviceVersionFlag - .parse({ - flags: { "service-version": ["auth=v2.180.0", "postgres=17.4.1.045"] }, - arguments: [], - }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(overrides).toEqual(["auth=v2.180.0", "postgres=17.4.1.045"]); - }); }); diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index 2a388237f7..4fe8956652 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -1,77 +1,55 @@ import { Effect } from "effect"; -import { updateManagedLaunch } from "@supabase/stack/effect"; +import { createStack } from "@supabase/stack/effect"; +import { loadCliConfig } from "@supabase/config/effect"; +import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; +import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; +import { runtimePreference, toStartStackConfig } from "../../config/stack-config.ts"; import type { StartFlags } from "./start.command.ts"; -import { StartVersionState } from "./start.command.ts"; -import { startBackground } from "./flows/background.flow.ts"; -import { startForeground } from "./flows/foreground.flow.ts"; -import { startNonInteractive } from "./flows/non-interactive.flow.ts"; -import { formatPortDriftWarning } from "../../stack/port-drift.ts"; export const start = Effect.fnUntraced(function* (flags: StartFlags) { - return yield* Effect.scoped( + const output = yield* Output; + const project = yield* CliProjectHome; + const analytics = yield* Analytics; + yield* output.intro("Start local Supabase stack"); + yield* ensureProjectStateIgnored(project.projectRoot); + const loadedConfig = yield* loadCliConfig(project.projectRoot); + yield* Effect.scoped( Effect.gen(function* () { - const output = yield* Output; - const analytics = yield* Analytics; - const startVersionState = yield* StartVersionState; - const { launch, previousUpdateFingerprint, serviceVersionContext, lifecycleInput, drift } = - startVersionState; - - const driftWarning = drift === undefined ? undefined : formatPortDriftWarning(drift); - if (driftWarning !== undefined) yield* output.warn(driftWarning); - - if (serviceVersionContext.activeOverrides.length > 0) { - yield* output.warn( - [ - "Local service version overrides are active (at your own risk):", - ...serviceVersionContext.activeOverrides.map( - ({ service, version, source }) => ` ${service}: ${version} [${source}]`, - ), - "These overrides are local to this checkout and may break compatibility.", - ].join("\n"), - ); - } - - if ( - serviceVersionContext.updateFingerprint !== undefined && - previousUpdateFingerprint !== serviceVersionContext.updateFingerprint - ) { - yield* output.warn( - [ - "Updated linked or default service versions are available for this local stack:", - ...serviceVersionContext.availableUpdates.map( - ({ service, pinnedVersion, availableVersion }) => - ` ${service}: ${pinnedVersion} -> ${availableVersion}`, - ), - "Run `supabase stack update` to adopt these pinned versions.", - ].join("\n"), - ); - yield* updateManagedLaunch({ - ...lifecycleInput, - launch: { - versions: launch.versions, - excludedServices: launch.excludedServices, - lastNotifiedUpdateFingerprint: serviceVersionContext.updateFingerprint, - }, - }); - } - - let result: void; - if (flags.detach) { - result = yield* startBackground(); - } else if (output.interactive) { - result = yield* startForeground(); - } else { - result = yield* startNonInteractive(); - } - + const stack = yield* createStack({ + projectRoot: project.projectRoot, + name: flags.stack, + runtime: runtimePreference(flags.mode), + }); + const status = yield* stack.start({ + config: toStartStackConfig( + loadedConfig?.config, + flags.exclude, + flags.mode, + project.projectRoot, + ), + }); + yield* output.success( + status.lifecycle === "running" + ? "Local Supabase stack is running." + : `Local Supabase stack is ${status.lifecycle}.`, + { + stack: flags.stack, + lifecycle: status.lifecycle, + desired_lifecycle: status.desiredLifecycle, + runtime: status.runtime, + endpoints: status.endpoints, + capabilities: status.capabilities, + }, + ); yield* analytics.capture("cli_stack_started", { - mode: launch.mode, + mode: flags.mode, detach: flags.detach, stack: flags.stack, }); - return result; + if (!flags.detach) + yield* output.info(`Stack ${flags.stack} remains managed after this command exits.`); }), ); }); diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index 5cd7ed4f6a..de0eb6f52d 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,130 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { - DEFAULT_VERSIONS, - connectLayer, - fillServiceVersionManifest, - planStackVersions, -} from "@supabase/stack/effect"; -import { Effect, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; import { start } from "./start.handler.ts"; -import { startVersionStateLaunch, StartVersionState } from "./start.command.ts"; -import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; -import { inkLayer } from "../../../shared/runtime/ink.layer.ts"; -import { - mockOutput, - mockProcessControl, - mockProjectLinkState, - mockCliProjectLocalServiceVersions, -} from "../../../../tests/helpers/mocks.ts"; -import { makeRunningStackFixture } from "../../../../tests/helpers/running-stack.ts"; +import { emptyEnv, mockOutput } from "../../../../tests/helpers/mocks.ts"; describe("start handler", () => { - it.live("reattaches through managed control and preserves launch selections", () => - Effect.scoped( - Effect.promise(() => makeRunningStackFixture()).pipe( - Effect.flatMap((fixture) => - connectLayer({ - cliVersion: fixture.cliVersion, - cacheRoot: fixture.homeDir, - cwd: fixture.projectRoot, - projectDir: fixture.projectRoot, - name: fixture.stackName, - }).pipe( - Effect.provide(fixture.baseLayer), - Effect.flatMap((stackLayer) => { - const out = mockOutput({ interactive: false }); - const versions = fillServiceVersionManifest(DEFAULT_VERSIONS); - const serviceVersionContext = planStackVersions({ - pinnedBaseline: versions, - candidateBaseline: versions, - }); - const postStartLaunch = { - ...fixture.launch, - versions: { postgres: "17.7.0" }, - excludedServices: ["analytics", "future-service"], - } as const; - const state = StartVersionState.of({ - launch: startVersionStateLaunch({ launch: postStartLaunch }), - serviceVersionContext: { - ...serviceVersionContext, - updateFingerprint: "new-fingerprint", - }, - lifecycleInput: { - cacheRoot: fixture.homeDir, - workspacePath: fixture.projectRoot, - stackName: fixture.stackName, - cwd: fixture.projectRoot, - cliVersion: fixture.cliVersion, - }, - drift: [ - { - key: "api.port", - actualIntent: "automatic", - actualPort: 54321, - configuredPort: 54322, - configuredIntent: "exact", - }, - ], - }); - const analytics = Layer.succeed(Analytics, { - capture: () => Effect.void, - identify: () => Effect.void, - alias: () => Effect.void, - groupIdentify: () => Effect.void, - }); - const layer = Layer.mergeAll( - fixture.baseLayer, - stackLayer, - out.layer, - analytics, - Layer.succeed(StartVersionState, state), - mockProcessControl().layer, - mockProjectLinkState(), - mockCliProjectLocalServiceVersions(), - BunServices.layer, - inkLayer, - ); - return start({ - stack: fixture.stackName, - mode: "docker", - exclude: [], - serviceVersion: [], - detach: true, - }).pipe( - Effect.provide(layer), - Effect.tap( - Effect.promise(async () => { - const document = await fixture.readDocument(); - expect(document?.launch).toMatchObject({ - versions: { postgres: "17.7.0" }, - excludedServices: ["analytics", "future-service"], - lastNotifiedUpdateFingerprint: "new-fingerprint", - }); - }), - ), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: `API URL: ${fixture.stackInfo.url}`, - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "warn", - message: expect.stringContaining("api.port changed from 54321 to 54322"), - }), - ); - }), - ), - ); - }), - ), - ), - ), - ), - ); + it.live("reports runtime startup failures through the command boundary", () => { + const out = mockOutput({ interactive: false }); + return start({ stack: "default", mode: "native", exclude: [], detach: true }).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.exit, + Effect.tap((exit) => Effect.sync(() => expect(Exit.isFailure(exit)).toBe(true))), + ); + }); }); diff --git a/apps/cli/src/next/commands/start/ui/ConnectionInfo.tsx b/apps/cli/src/next/commands/start/ui/ConnectionInfo.tsx deleted file mode 100644 index 04e7f8a838..0000000000 --- a/apps/cli/src/next/commands/start/ui/ConnectionInfo.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Box, Text } from "ink"; -import type { StackInfo } from "@supabase/stack/effect"; - -const rows = [ - { emoji: "🌐", label: "API URL", key: "url" }, - { emoji: "🗄️", label: "DB URL", key: "dbUrl" }, - { emoji: "🔑", label: "Publishable key", key: "publishableKey" }, - { emoji: "🔐", label: "Secret key", key: "secretKey" }, -] as const; - -const labelWidth = 20; - -export function ConnectionInfo({ info }: { info: StackInfo }) { - return ( - - {rows.map((row) => ( - - - - {row.emoji} {row.label} - - - {info[row.key]} - - ))} - - ); -} diff --git a/apps/cli/src/next/commands/start/ui/ServiceTable.tsx b/apps/cli/src/next/commands/start/ui/ServiceTable.tsx deleted file mode 100644 index 7cb93cad60..0000000000 --- a/apps/cli/src/next/commands/start/ui/ServiceTable.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Box, Text } from "ink"; -import Spinner from "ink-spinner"; -import type { StackServiceState } from "@supabase/stack/effect"; - -function statusIcon(status: string) { - switch (status) { - case "Healthy": - return ; - case "Failed": - case "Unhealthy": - return ; - case "Stopped": - return ⏹️; - case "Starting": - case "Downloading": - case "Running": - case "Restarting": - case "Initializing": - case "Migrating": - return ( - - - - ); - default: - return ; - } -} - -const nameWidth = 20; - -export function ServiceTable({ states }: { states: ReadonlyArray }) { - return ( - - {states.map((s) => ( - - - {s.name} - - - {statusIcon(s.status)} - {s.status} - - - ))} - - ); -} diff --git a/apps/cli/src/next/commands/start/ui/StartDashboard.tsx b/apps/cli/src/next/commands/start/ui/StartDashboard.tsx deleted file mode 100644 index 9f8883bc0f..0000000000 --- a/apps/cli/src/next/commands/start/ui/StartDashboard.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Box, Text } from "ink"; -import { useAtomValue } from "@effect/atom-react"; -import type { StackServiceState, StackInfo } from "@supabase/stack/effect"; -import { ServiceTable } from "./ServiceTable.tsx"; -import { ConnectionInfo } from "./ConnectionInfo.tsx"; -import type { StartDashboardModel, StartPhase } from "./dashboard.model.ts"; - -export function StartDashboard({ model }: { model: StartDashboardModel }) { - const states = useAtomValue(model.displayStatesAtom); - const info = useAtomValue(model.stackInfoAtom); - const phase = useAtomValue(model.phaseAtom); - const showConnectionInfo = useAtomValue(model.showConnectionInfoAtom); - const statusLine = useAtomValue(model.statusLineAtom); - - return ( - - ); -} - -export function StartDashboardView({ - states, - info, - showConnectionInfo, - phase, - statusLine, -}: { - states: ReadonlyArray; - info: StackInfo | null; - showConnectionInfo: boolean; - phase: StartPhase; - statusLine: string; -}) { - return ( - - 🚀 Supabase - - - {showConnectionInfo && info !== null && } - - {phase === "failed" ? ( - {statusLine} - ) : ( - {statusLine} - )} - - ); -} diff --git a/apps/cli/src/next/commands/start/ui/StartDashboardView.unit.test.ts b/apps/cli/src/next/commands/start/ui/StartDashboardView.unit.test.ts deleted file mode 100644 index 331009b352..0000000000 --- a/apps/cli/src/next/commands/start/ui/StartDashboardView.unit.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { StackServiceState, type StackServiceStatus } from "@supabase/stack/effect"; -import { ConnectionInfo } from "./ConnectionInfo.tsx"; - -function state(name: string, status: StackServiceStatus, error: string | null = null) { - return new StackServiceState({ - name, - status, - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error, - }); -} - -function collectNodes(node: unknown): Array { - if (node == null || typeof node === "boolean") { - return []; - } - if (Array.isArray(node)) { - return node.flatMap(collectNodes); - } - if (typeof node !== "object") { - return [node]; - } - if ("props" in node) { - const props = (node as { props?: { children?: unknown } }).props; - return [node, ...collectNodes(props?.children)]; - } - return [node]; -} - -describe("StartDashboardView", () => { - test("renders the starting status without connection info", async () => { - const dashboardModule = await import("./StartDashboard.tsx"); - expect("StartDashboardView" in dashboardModule).toBe(true); - if (!("StartDashboardView" in dashboardModule)) return; - - const element = dashboardModule.StartDashboardView({ - states: [state("postgres", "Starting")], - info: null, - showConnectionInfo: false, - phase: "starting", - statusLine: "⏳ Starting...", - }); - const nodes = collectNodes(element); - - expect(nodes).toContain("⏳ Starting..."); - expect( - nodes.some( - (node) => - typeof node === "object" && node !== null && (node as any).type === ConnectionInfo, - ), - ).toBe(false); - }, 15_000); - - test("renders the failed status without connection info", async () => { - const dashboardModule = await import("./StartDashboard.tsx"); - expect("StartDashboardView" in dashboardModule).toBe(true); - if (!("StartDashboardView" in dashboardModule)) return; - - const element = dashboardModule.StartDashboardView({ - states: [state("postgres", "Failed", "Health check failed and restart budget was exhausted")], - info: { - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - publishableKey: "pk", - secretKey: "sk", - anonJwt: "anon", - serviceRoleJwt: "service-role", - serviceEndpoints: {}, - }, - showConnectionInfo: false, - phase: "failed", - statusLine: "❌ startup failed", - }); - const nodes = collectNodes(element); - - expect(nodes).toContain("❌ startup failed"); - expect( - nodes.some( - (node) => - typeof node === "object" && node !== null && (node as any).type === ConnectionInfo, - ), - ).toBe(false); - }, 15_000); -}); diff --git a/apps/cli/src/next/commands/start/ui/dashboard-state.integration.test.ts b/apps/cli/src/next/commands/start/ui/dashboard-state.integration.test.ts deleted file mode 100644 index bdee673d62..0000000000 --- a/apps/cli/src/next/commands/start/ui/dashboard-state.integration.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import { makeTestStack } from "@supabase/stack/testing"; -import { Stack, StackRpcProtocolError, StackUnavailableError } from "@supabase/stack/effect"; -import { Cause, Context, Deferred, Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"; -import { StartDashboardState } from "./dashboard-state.ts"; - -it.live("does not report RPC stream interruption as a dashboard failure", () => - Effect.scoped( - Effect.gen(function* () { - const stack = { - ...makeTestStack(), - allStateChanges: Stream.failCause(Cause.interrupt()), - }; - const context = yield* Layer.build( - StartDashboardState.live.pipe(Layer.provide(Layer.succeed(Stack, stack))), - ); - const state = Context.get(context, StartDashboardState); - yield* Effect.yieldNow; - - expect(yield* SubscriptionRef.get(state.phaseRef)).toBe("starting"); - expect(yield* SubscriptionRef.get(state.errorRef)).toBeNull(); - }), - ), -); - -it.live("renders graceful state-stream completion as stopping", () => - Effect.scoped( - Effect.gen(function* () { - const subscribed = Deferred.makeUnsafe(); - const stack = { - ...makeTestStack(), - allStateChanges: Stream.unwrap( - Deferred.succeed(subscribed, undefined).pipe(Effect.as(Stream.empty)), - ), - }; - const context = yield* Layer.build( - StartDashboardState.live.pipe(Layer.provide(Layer.succeed(Stack, stack))), - ); - const state = Context.get(context, StartDashboardState); - const stopping = yield* SubscriptionRef.changes(state.phaseRef).pipe( - Stream.filter((phase) => phase === "stopping"), - Stream.runHead, - Effect.forkChild, - ); - - yield* Deferred.await(subscribed); - yield* Fiber.join(stopping); - expect(yield* SubscriptionRef.get(state.phaseRef)).toBe("stopping"); - expect(yield* SubscriptionRef.get(state.errorRef)).toBeNull(); - }), - ), -); - -it.live("keeps genuine state-stream errors as failed", () => - Effect.scoped( - Effect.gen(function* () { - const subscribed = Deferred.makeUnsafe(); - const stack = { - ...makeTestStack(), - allStateChanges: Stream.unwrap( - Deferred.succeed(subscribed, undefined).pipe( - Effect.as( - Stream.fail( - new StackRpcProtocolError({ - endpoint: "http://127.0.0.1:54321", - procedure: "WatchServiceStates", - detail: "state stream failed", - }), - ), - ), - ), - ), - }; - const context = yield* Layer.build( - StartDashboardState.live.pipe(Layer.provide(Layer.succeed(Stack, stack))), - ); - const state = Context.get(context, StartDashboardState); - const failed = yield* SubscriptionRef.changes(state.phaseRef).pipe( - Stream.filter((phase) => phase === "failed"), - Stream.runHead, - Effect.forkChild, - ); - - yield* Deferred.await(subscribed); - yield* Fiber.join(failed); - expect(yield* SubscriptionRef.get(state.phaseRef)).toBe("failed"); - expect(yield* SubscriptionRef.get(state.errorRef)).toContain("StackRpcProtocolError"); - }), - ), -); - -it.live("renders a failure terminal reason as failed", () => - Effect.scoped( - Effect.gen(function* () { - const subscribed = Deferred.makeUnsafe(); - const stack = { - ...makeTestStack(), - allStateChanges: Stream.unwrap( - Deferred.succeed(subscribed, undefined).pipe( - Effect.as( - Stream.fail( - new StackUnavailableError({ - phase: "failed", - detail: "Local stack disposed unexpectedly", - }), - ), - ), - ), - ), - }; - const context = yield* Layer.build( - StartDashboardState.live.pipe(Layer.provide(Layer.succeed(Stack, stack))), - ); - const state = Context.get(context, StartDashboardState); - const failed = yield* SubscriptionRef.changes(state.phaseRef).pipe( - Stream.filter((phase) => phase === "failed"), - Stream.runHead, - Effect.forkChild, - ); - - yield* Deferred.await(subscribed); - yield* Fiber.join(failed); - expect(yield* SubscriptionRef.get(state.phaseRef)).toBe("failed"); - expect(yield* SubscriptionRef.get(state.errorRef)).toContain("StackUnavailableError"); - }), - ), -); diff --git a/apps/cli/src/next/commands/start/ui/dashboard-state.ts b/apps/cli/src/next/commands/start/ui/dashboard-state.ts deleted file mode 100644 index 42a6960bf3..0000000000 --- a/apps/cli/src/next/commands/start/ui/dashboard-state.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Cause, Context, Effect, Layer, Stream, SubscriptionRef } from "effect"; -import type { StackServiceState, StackInfo } from "@supabase/stack/effect"; -import { Stack } from "@supabase/stack/effect"; - -export type StartPhase = "starting" | "running" | "failed" | "stopping"; - -function updateServiceStates( - current: ReadonlyArray, - state: StackServiceState, -): ReadonlyArray { - return current.some((entry) => entry.name === state.name) - ? current.map((entry) => (entry.name === state.name ? state : entry)) - : [...current, state]; -} - -export class StartDashboardState extends Context.Service< - StartDashboardState, - { - readonly stackInfoRef: SubscriptionRef.SubscriptionRef; - readonly serviceStatesRef: SubscriptionRef.SubscriptionRef>; - readonly phaseRef: SubscriptionRef.SubscriptionRef; - readonly errorRef: SubscriptionRef.SubscriptionRef; - } ->()("supabase/start/StartDashboardState") { - static readonly live = Layer.effect( - this, - Effect.gen(function* () { - const stack = yield* Stack; - - const info = yield* stack.getInfo; - const initialStates = yield* stack.getAllStates; - const stackInfoRef = yield* SubscriptionRef.make(info); - const serviceStatesRef = - yield* SubscriptionRef.make>(initialStates); - const phaseRef = yield* SubscriptionRef.make("starting"); - const errorRef = yield* SubscriptionRef.make(null); - const markStopping = SubscriptionRef.set(errorRef, null).pipe( - Effect.andThen(SubscriptionRef.set(phaseRef, "stopping")), - ); - - yield* stack.allStateChanges.pipe( - Stream.runForEach((state) => - SubscriptionRef.update(serviceStatesRef, (current) => - updateServiceStates(current, state), - ), - ), - // A completed state stream means the remote/local stack has shut down - // normally. Keep that terminal transition distinct from an actual - // stream failure so the dashboard does not render a raw transport - // cause as a startup error. - Effect.andThen(markStopping), - Effect.catchTag("StackUnavailableError", (error) => - error.phase === "stopping" ? markStopping : Effect.fail(error), - ), - Effect.catch((error) => - Effect.all([ - SubscriptionRef.set(errorRef, Cause.pretty(Cause.fail(error))), - SubscriptionRef.set(phaseRef, "failed"), - ]).pipe(Effect.asVoid), - ), - Effect.forkScoped({ startImmediately: true }), - ); - - return { - stackInfoRef, - serviceStatesRef, - phaseRef, - errorRef, - }; - }), - ); -} diff --git a/apps/cli/src/next/commands/start/ui/dashboard.model.ts b/apps/cli/src/next/commands/start/ui/dashboard.model.ts deleted file mode 100644 index 9e01b35095..0000000000 --- a/apps/cli/src/next/commands/start/ui/dashboard.model.ts +++ /dev/null @@ -1,110 +0,0 @@ -import * as Atom from "effect/unstable/reactivity/Atom"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import type { StackServiceState, StackInfo } from "@supabase/stack/effect"; -import { Effect, Layer } from "effect"; -import { StartDashboardState, type StartPhase } from "./dashboard-state.ts"; - -export type { StartPhase } from "./dashboard-state.ts"; - -export interface StartDashboardModel { - readonly serviceStatesStateAtom: Atom.Writable< - AsyncResult.AsyncResult, never>, - ReadonlyArray - >; - readonly stackInfoStateAtom: Atom.Writable< - AsyncResult.AsyncResult, - StackInfo | null - >; - readonly phaseStateAtom: Atom.Writable, StartPhase>; - readonly errorStateAtom: Atom.Writable< - AsyncResult.AsyncResult, - string | null - >; - readonly serviceStatesAtom: Atom.Writable>; - readonly stackInfoAtom: Atom.Writable; - readonly phaseAtom: Atom.Writable; - readonly errorAtom: Atom.Writable; - readonly displayStatesAtom: Atom.Atom>; - readonly allHealthyAtom: Atom.Atom; - readonly showConnectionInfoAtom: Atom.Atom; - readonly statusLineAtom: Atom.Atom; -} - -function fromResultAtom
( - atom: Atom.Writable, A>, - fallback: A, -): Atom.Writable { - return Atom.writable( - (get) => AsyncResult.getOrElse(get(atom), () => fallback), - (ctx, value: A) => { - ctx.set(atom, value); - }, - ); -} - -export function createStartDashboardModel( - dashboardStateLayer: Layer.Layer, -): StartDashboardModel { - const runtime = Atom.context({ memoMap: Layer.makeMemoMapUnsafe() })(dashboardStateLayer); - const serviceStatesStateAtom = runtime.subscriptionRef( - StartDashboardState.use((state) => Effect.succeed(state.serviceStatesRef)), - ); - const stackInfoStateAtom = runtime.subscriptionRef( - StartDashboardState.use((state) => Effect.succeed(state.stackInfoRef)), - ); - const phaseStateAtom = runtime.subscriptionRef( - StartDashboardState.use((state) => Effect.succeed(state.phaseRef)), - ); - const errorStateAtom = runtime.subscriptionRef( - StartDashboardState.use((state) => Effect.succeed(state.errorRef)), - ); - - const serviceStatesAtom = fromResultAtom(serviceStatesStateAtom, []); - const stackInfoAtom = fromResultAtom(stackInfoStateAtom, null); - const phaseAtom = fromResultAtom(phaseStateAtom, "starting"); - const errorAtom = fromResultAtom(errorStateAtom, null); - const displayStatesAtom = Atom.make((get) => get(serviceStatesAtom)); - const allHealthyAtom = Atom.make( - (get) => - get(displayStatesAtom).length > 0 && - get(displayStatesAtom).every((s) => s.status === "Healthy"), - ); - // Lazy stacks intentionally leave proxy-backed services Pending. A - // successful start phase, rather than universal health, makes connection - // details safe to display. - const showConnectionInfoAtom = Atom.make( - (get) => get(phaseAtom) === "running" && get(stackInfoAtom) !== null, - ); - const statusLineAtom = Atom.make((get) => { - const phase = get(phaseAtom); - const error = get(errorAtom); - - switch (phase) { - case "failed": - return `❌ ${error ?? "Startup failed"}`; - case "stopping": - return "⏳ Stopping..."; - case "running": - return "🟢 Running — Interrupt to stop (usually Ctrl+C)"; - case "starting": - return get(allHealthyAtom) - ? "🟢 Running — Interrupt to stop (usually Ctrl+C)" - : "⏳ Starting..."; - } - }); - - return { - serviceStatesStateAtom, - stackInfoStateAtom, - phaseStateAtom, - errorStateAtom, - serviceStatesAtom, - stackInfoAtom, - phaseAtom, - errorAtom, - displayStatesAtom, - allHealthyAtom, - showConnectionInfoAtom, - statusLineAtom, - }; -} diff --git a/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts b/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts deleted file mode 100644 index 2bcbcb6193..0000000000 --- a/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, test } from "vitest"; -import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; -import { Effect, Layer, SubscriptionRef } from "effect"; -import { StackServiceState, type StackInfo, type StackServiceStatus } from "@supabase/stack/effect"; -import { StartDashboardState } from "./dashboard-state.ts"; - -function state(name: string, status: StackServiceStatus) { - return new StackServiceState({ - name, - status: status as StackServiceState["status"], - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }); -} - -describe("createStartDashboardModel", () => { - const stackInfo: StackInfo = { - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - publishableKey: "pk", - secretKey: "sk", - anonJwt: "anon", - serviceRoleJwt: "service-role", - serviceEndpoints: {}, - }; - const dashboardStateLayer = Layer.effect( - StartDashboardState, - Effect.gen(function* () { - return { - stackInfoRef: yield* SubscriptionRef.make(null), - serviceStatesRef: yield* SubscriptionRef.make>([]), - phaseRef: yield* SubscriptionRef.make<"starting" | "running" | "failed" | "stopping">( - "starting", - ), - errorRef: yield* SubscriptionRef.make(null), - }; - }), - ); - - test("creates dashboard-scoped writable and derived atoms", async () => { - const modelModule = await import("./dashboard.model.ts"); - expect("createStartDashboardModel" in modelModule).toBe(true); - if (!("createStartDashboardModel" in modelModule)) return; - - const model = modelModule.createStartDashboardModel(dashboardStateLayer); - const registry = AtomRegistry.make(); - - expect(registry.get(model.stackInfoAtom)).toBeNull(); - expect(registry.get(model.phaseAtom)).toBe("starting"); - - registry.set(model.serviceStatesAtom, [ - state("postgres", "Initializing"), - state("auth", "Healthy"), - ]); - - expect(registry.get(model.displayStatesAtom).map((entry) => entry.name)).toEqual([ - "postgres", - "auth", - ]); - expect( - registry.get(model.displayStatesAtom).find((entry) => entry.name === "postgres")?.status, - ).toBe("Initializing"); - expect(registry.get(model.allHealthyAtom)).toBe(false); - registry.set(model.stackInfoAtom, stackInfo); - expect(registry.get(model.showConnectionInfoAtom)).toBe(false); - - registry.set(model.phaseAtom, "running"); - expect(registry.get(model.statusLineAtom)).toContain("Interrupt to stop"); - expect(registry.get(model.showConnectionInfoAtom)).toBe(true); - }); - - test("shows the foreground failure message when startup fails", async () => { - const modelModule = await import("./dashboard.model.ts"); - expect("createStartDashboardModel" in modelModule).toBe(true); - if (!("createStartDashboardModel" in modelModule)) return; - - const model = modelModule.createStartDashboardModel(dashboardStateLayer); - const registry = AtomRegistry.make(); - - registry.set(model.errorAtom, "startup failed"); - registry.set(model.phaseAtom, "failed"); - - expect(registry.get(model.statusLineAtom)).toContain("startup failed"); - expect(registry.get(model.allHealthyAtom)).toBe(false); - }); -}); diff --git a/apps/cli/src/next/commands/start/ui/foreground-session.ts b/apps/cli/src/next/commands/start/ui/foreground-session.ts deleted file mode 100644 index 3433c20e24..0000000000 --- a/apps/cli/src/next/commands/start/ui/foreground-session.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { clearTimeout, setTimeout } from "node:timers"; -import { createElement } from "react"; -import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; -import { Cause, Context, Effect, Layer } from "effect"; -import { RegistryContext } from "@effect/atom-react"; -import { Stack } from "@supabase/stack/effect"; -import { Ink } from "../../../../shared/runtime/ink.service.ts"; -import { StartDashboardState } from "./dashboard-state.ts"; -import { StartDashboard } from "./StartDashboard.tsx"; -import { createStartDashboardModel } from "./dashboard.model.ts"; - -interface StartForegroundSession { - readonly waitUntilExit: Effect.Effect; - readonly markRunning: Effect.Effect; - readonly markStopping: Effect.Effect; - readonly markFailed: (cause: Cause.Cause) => Effect.Effect; -} - -function scheduleTask(f: () => void) { - const id = setTimeout(f, 0); - return () => clearTimeout(id); -} - -export const makeStartForegroundSession = Effect.fnUntraced(function* () { - const stack = yield* Stack; - const ink = yield* Ink; - const registry = AtomRegistry.make({ scheduleTask }); - const stateContext = yield* Layer.build( - Layer.provide(StartDashboardState.live, Layer.succeed(Stack, stack)), - ); - const model = createStartDashboardModel( - Layer.succeed(StartDashboardState, Context.get(stateContext, StartDashboardState)), - ); - - yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); - - yield* Effect.acquireRelease( - Effect.sync(() => [ - registry.mount(model.stackInfoStateAtom), - registry.mount(model.serviceStatesStateAtom), - registry.mount(model.phaseStateAtom), - registry.mount(model.errorStateAtom), - ]), - (releases) => - Effect.sync(() => { - for (const release of releases) { - release(); - } - }), - ); - - const instance = yield* Effect.acquireRelease( - ink.render( - createElement( - RegistryContext.Provider, - { value: registry }, - createElement(StartDashboard, { model }), - ), - ), - (instance) => Effect.sync(() => instance.unmount()), - ); - - const setPhase = (phase: "running" | "stopping" | "failed") => - Effect.sync(() => { - registry.set(model.phaseStateAtom, phase); - }); - - return { - waitUntilExit: Effect.promise(() => instance.waitUntilExit()).pipe(Effect.orDie, Effect.asVoid), - markRunning: setPhase("running"), - markStopping: setPhase("stopping"), - markFailed: (cause) => - Effect.sync(() => { - registry.set(model.errorStateAtom, Cause.pretty(cause)); - registry.set(model.phaseStateAtom, "failed"); - }), - } satisfies StartForegroundSession; -}); diff --git a/apps/cli/src/next/commands/status/status.command.ts b/apps/cli/src/next/commands/status/status.command.ts index ed09d85df6..0b8b41123c 100644 --- a/apps/cli/src/next/commands/status/status.command.ts +++ b/apps/cli/src/next/commands/status/status.command.ts @@ -1,9 +1,8 @@ import { Layer } from "effect"; -import { DEFAULT_MANAGED_STACK_NAME } from "@supabase/stack/effect"; +import { DEFAULT_MANAGED_STACK_NAME } from "../../../shared/stack-constants.ts"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts"; -import { cliProjectLocalServiceVersionsLayer } from "../../config/cli-project-local-service-versions.layer.ts"; import { provideCliProjectCommandRuntime } from "../../config/project-runtime.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -20,11 +19,7 @@ const flags = { export type StatusFlags = CliCommand.Command.Config.Infer; const statusRuntimeLayer = provideCliProjectCommandRuntime( - Layer.mergeAll( - projectLinkStateLayer, - cliProjectLocalServiceVersionsLayer, - commandRuntimeLayer(["status"]), - ), + Layer.mergeAll(projectLinkStateLayer, commandRuntimeLayer(["status"])), ); export const statusCommand = Command.make("status", flags).pipe( diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts index a50d902564..414a45f395 100644 --- a/apps/cli/src/next/commands/status/status.handler.ts +++ b/apps/cli/src/next/commands/status/status.handler.ts @@ -1,311 +1,57 @@ -import { Context, Effect, Layer, Option, Predicate } from "effect"; -import { loadCliConfig } from "@supabase/config/effect"; -import { - connectLayer, - fillServiceVersionManifest, - resolveStackSummary, - Stack, - type StackSummary, -} from "@supabase/stack/effect"; -import { CliSettings } from "../../config/cli-settings.service.ts"; +import { Effect, Option } from "effect"; +import { findStack, inspectStack, openStack } from "@supabase/stack/effect"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; -import { resolveServiceVersionContext } from "../../config/service-version-resolution.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import { CLI_VERSION } from "../../../shared/cli/version.ts"; import type { StatusFlags } from "./status.command.ts"; -import { managedPortIntents } from "../../config/managed-port-intents.ts"; -import { isExcludedStackService, toStartStackConfig } from "../../config/stack-config.ts"; -import { formatPortDriftWarning } from "../../stack/port-drift.ts"; -const renderUpgradeRequiredStatus = Effect.fnUntraced(function* (input: { - readonly summary: StackSummary; - readonly error: { - readonly oldCliVersion: string; - readonly newCliVersion: string; - readonly state: "starting" | "running" | "stopping" | "deleting" | "failed"; - readonly ready: boolean; - }; -}) { +export const status = Effect.fnUntraced(function* (flags: StatusFlags) { const output = yield* Output; - const message = "Local Supabase stack is managed by a different CLI version."; - const running = input.error.state === "running" && input.error.ready; - const data = { - stack: input.summary.name, - running, - state: input.error.state, - ready: input.error.ready, - degraded: true, - reason: "daemon_upgrade_required" as const, - daemon_cli_version: input.error.oldCliVersion, - cli_version: input.error.newCliVersion, - ports: input.summary.ports, - versions: input.summary.versions, - launch: input.summary.launch, - instruction: "Run `supabase start` to restart the stack with the current CLI.", - }; - - if (output.format !== "text") { - yield* output.success(message, data); - return; - } - - yield* output.warn(message); - yield* output.info(`Stack: ${input.summary.name}`); - yield* output.info(`Daemon CLI: ${input.error.oldCliVersion}`); - yield* output.info(`Current CLI: ${input.error.newCliVersion}`); - yield* output.info(`State: ${input.error.state}`); - yield* output.info(`Ready: ${String(input.error.ready)}`); - yield* output.info(formatPortsLine(input.summary.ports)); - yield* output.info(`Runtime mode: ${input.summary.launch.mode}`); - for (const [name, version] of Object.entries(input.summary.versions).sort(([a], [b]) => - a.localeCompare(b), - )) { - yield* output.info(`${name} version: ${version}`); - } - yield* output.info(data.instruction); -}); - -function formatServiceStateLine(service: { - readonly name: string; - readonly status: string; - readonly error: string | null; -}) { - return service.error == null - ? `${service.name}: ${service.status}` - : `${service.name}: ${service.status} (${service.error})`; -} - -function formatPortsLine(ports: { readonly apiPort: number; readonly dbPort: number }) { - return `Ports: API ${ports.apiPort}, DB ${ports.dbPort}`; -} - -const resolveConfiguredSummary = Effect.fnUntraced(function* (input: { - readonly cacheRoot: string; - readonly projectDir: string; - readonly cwd: string; - readonly name: string; -}) { - const current = yield* resolveStackSummary(input); - const loaded = yield* loadCliConfig(input.projectDir); - const excluded = (current.launch.excludedServices ?? []).filter(isExcludedStackService); - const mode = current.launch.mode; - return yield* resolveStackSummary({ - ...input, - portDocument: managedPortIntents(toStartStackConfig(excluded, mode), loaded ?? undefined), - }); -}); - -const renderPortDrift = Effect.fnUntraced(function* (drift: NonNullable) { - const message = formatPortDriftWarning(drift); - if (message === undefined) return; - const output = yield* Output; - yield* output.warn(message); -}); - -const renderUpdateStatus = Effect.fnUntraced(function* ( - updates: ReadonlyArray<{ - readonly service: string; - readonly pinnedVersion: string; - readonly availableVersion: string; - }>, -) { - const output = yield* Output; - - if (updates.length === 0) { - yield* output.info("Pinned stack versions are up to date."); - return; - } - - yield* output.warn("Updates are available for this stack."); - for (const updateEntry of updates) { - yield* output.info( - `${updateEntry.service}: ${updateEntry.pinnedVersion} -> ${updateEntry.availableVersion}`, - ); - } - yield* output.info("Run `supabase stack update` to adopt these pinned versions."); -}); - -export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { - const output = yield* Output; - const cliSettings = yield* CliSettings; - const cliProjectHome = yield* CliProjectHome; - const runtimeInfo = yield* RuntimeInfo; - + const project = yield* CliProjectHome; yield* output.intro("Show local Supabase stack status"); - - const summaryInput = { - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - cwd: runtimeInfo.cwd, - name: _flags.stack, - }; - const layerResult = yield* connectLayer({ - cliVersion: CLI_VERSION, - cwd: runtimeInfo.cwd, - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: _flags.stack, - }).pipe( - Effect.map((layer) => ({ _tag: "live" as const, layer })), - Effect.catchTag("DaemonUpgradeRequired", (error) => - Effect.succeed({ _tag: "upgrade" as const, error }), - ), - Effect.catchTag("NoRunningStackError", () => Effect.succeed({ _tag: "none" as const })), - ); - - if (Predicate.isTagged(layerResult, "upgrade")) { - // An incompatible daemon is authoritative for its own managed summary. - // Do not parse the current checkout's config before rendering this status: - // a newer CLI may have introduced config that this CLI cannot decode. - const summary = yield* resolveStackSummary(summaryInput); - yield* renderUpgradeRequiredStatus({ summary, error: layerResult.error }); - return; - } - - if (Predicate.isTagged(layerResult, "none")) { - const summary = yield* resolveConfiguredSummary(summaryInput).pipe( - Effect.map(Option.some), - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), - ); - - if (Option.isNone(summary)) { - const message = "No local Supabase stack is running for this project."; - if (output.format === "text") { - yield* output.outro(message); - return; - } - - yield* output.success(message, { stack: _flags.stack, running: false }); - return; - } - - const message = "Local Supabase stack is stopped."; - const serviceVersionContext = yield* resolveServiceVersionContext( - [], - fillServiceVersionManifest(summary.value.versions), + const descriptorOption = yield* findStack({ + projectRoot: project.projectRoot, + name: flags.stack, + }); + if (Option.isNone(descriptorOption)) { + const message = "No local Supabase stack is known for this project."; + if (output.format === "text") return yield* output.outro(message); + return yield* output.success(message, { running: false, stack: flags.stack }); + } + const descriptor = descriptorOption.value; + const inspection = yield* inspectStack(descriptor.id); + let observed = inspection.status; + if (inspection.owner === "running") { + observed = yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* openStack(descriptor.id); + return yield* stack.status(); + }), ); - const data = { - stack: summary.value.name, - running: false, - ports: summary.value.ports, - versions: summary.value.versions, - up_to_date: serviceVersionContext.availableUpdates.length === 0, - available_updates: serviceVersionContext.availableUpdates.map((updateEntry) => ({ - service: updateEntry.service, - pinned_version: updateEntry.pinnedVersion, - available_version: updateEntry.availableVersion, - })), - }; - - if (output.format !== "text") { - yield* output.success(message, data); - return; - } - - yield* output.info(message); - yield* output.info(`Stack: ${summary.value.name}`); - yield* output.info(formatPortsLine(summary.value.ports)); - for (const [name, version] of Object.entries(summary.value.versions).sort(([a], [b]) => - a.localeCompare(b), - )) { - yield* output.info(`${name} version: ${version}`); - } - yield* renderUpdateStatus(serviceVersionContext.availableUpdates); - yield* output.outro(`Local Supabase stack ${summary.value.name} is stopped.`); - return; - } - - const stackResult = yield* Effect.scoped( - Effect.gen(function* () { - const context = yield* Layer.build(layerResult.layer); - const stack = Context.get(context, Stack); - const [info, services] = yield* Effect.all([stack.getInfo, stack.getAllStates]); - return { _tag: "live" as const, info, services }; - }), - ).pipe( - Effect.catchTag("DaemonUpgradeRequired", (error) => - Effect.succeed({ _tag: "upgrade" as const, error }), - ), - ); - if (Predicate.isTagged(stackResult, "upgrade")) { - // Layer construction performs the second owner/version handshake. As with - // the initial connect path, render the daemon's managed document directly - // so an incompatible checkout config cannot prevent upgrade guidance. - const summary = yield* resolveStackSummary(summaryInput); - yield* renderUpgradeRequiredStatus({ summary, error: stackResult.error }); - return; } - - const summary = yield* resolveConfiguredSummary(summaryInput); - const { info, services } = stackResult; - const serviceVersionContext = yield* resolveServiceVersionContext( - [], - fillServiceVersionManifest(summary.versions), - ); - const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name)); - const allReady = services.every((service) => - ["Running", "Healthy", "Dormant"].includes(service.status), - ); - const message = allReady - ? "Local Supabase stack is running." - : "Local Supabase stack is running, but some services are not ready."; - yield* renderPortDrift(summary.drift ?? []); + const running = observed?.lifecycle === "running"; + const message = running ? "Local Supabase stack is running." : "Local Supabase stack is stopped."; const data = { - stack: summary.name, - running: true, - api_url: info.url, - db_url: info.dbUrl, - publishable_key: info.publishableKey, - secret_key: info.secretKey, - service_endpoints: info.serviceEndpoints, - versions: summary.versions, - up_to_date: serviceVersionContext.availableUpdates.length === 0, - available_updates: serviceVersionContext.availableUpdates.map((updateEntry) => ({ - service: updateEntry.service, - pinned_version: updateEntry.pinnedVersion, - available_version: updateEntry.availableVersion, - })), - services: sortedServices.map((service) => ({ - name: service.name, - status: service.status, - pid: service.pid, - exit_code: service.exitCode, - restart_count: service.restartCount, - started_at: service.startedAt, - error: service.error, - })), + stack: descriptor.name, + running, + lifecycle: observed?.lifecycle ?? descriptor.desiredLifecycle, + desired_lifecycle: descriptor.desiredLifecycle, + runtime: descriptor.runtime, + endpoints: observed?.endpoints ?? {}, + versions: observed?.versions ?? {}, + capabilities: observed?.capabilities ?? [], }; - - if (output.format !== "text") { - yield* output.success(message, data); - return; - } - - if (allReady) { - yield* output.success(message); - } else { - yield* output.warn(message); - } - - yield* output.info(`Stack: ${summary.name}`); - yield* output.info(`API URL: ${info.url}`); - yield* output.info(`DB URL: ${info.dbUrl}`); - yield* output.info(`Publishable key: ${info.publishableKey}`); - yield* output.info(`Secret key: ${info.secretKey}`); - for (const [name, version] of Object.entries(summary.versions).sort(([a], [b]) => - a.localeCompare(b), - )) { - yield* output.info(`${name} version: ${version}`); - } - yield* renderUpdateStatus(serviceVersionContext.availableUpdates); - for (const [name, endpoint] of Object.entries(info.serviceEndpoints).sort(([a], [b]) => - a.localeCompare(b), - )) { - yield* output.info(`${name}: ${endpoint}`); - } - - for (const service of sortedServices) { - yield* output.info(formatServiceStateLine(service)); - } + if (output.format !== "text") return yield* output.success(message, data); + yield* running ? output.success(message) : output.info(message); + yield* output.info(`Stack: ${descriptor.name}`); + yield* output.info(`Lifecycle: ${data.lifecycle}`); + yield* output.info(`Runtime: ${descriptor.runtime.kind}`); + for (const [name, endpoint] of Object.entries(data.endpoints)) { + if (endpoint !== undefined) yield* output.info(`${name}: ${endpoint.url}`); + } + for (const capability of data.capabilities) { + yield* output.info(`${capability.name}: ${capability.state}`); + } + yield* output.outro( + `Local Supabase stack ${descriptor.name} is ${running ? "running" : "stopped"}.`, + ); }); diff --git a/apps/cli/src/next/commands/status/status.integration.test.ts b/apps/cli/src/next/commands/status/status.integration.test.ts index 1c027a44ad..b81feae75e 100644 --- a/apps/cli/src/next/commands/status/status.integration.test.ts +++ b/apps/cli/src/next/commands/status/status.integration.test.ts @@ -1,283 +1,20 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; -import { HttpTransportClient } from "@supabase/stack/testing"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { status } from "./status.handler.ts"; -import { - mockOutput, - mockProjectLinkState, - mockCliProjectLocalServiceVersions, -} from "../../../../tests/helpers/mocks.ts"; -import { - makeRunningStackFixture, - makeStoppedStackFixture, -} from "../../../../tests/helpers/running-stack.ts"; - -const runDegradedStatus = ( - options: { - readonly fixture?: Parameters[0]; - readonly output?: Parameters[0]; - readonly transport?: HttpTransportClient["Service"]; - } = {}, -) => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* Effect.acquireRelease( - Effect.promise(() => makeRunningStackFixture({ cliVersion: "2.60.0", ...options.fixture })), - (fixture) => Effect.promise(() => fixture.dispose()), - ); - mkdirSync(join(fixture.projectRoot, "supabase"), { recursive: true }); - writeFileSync(join(fixture.projectRoot, "supabase", "config.toml"), "[invalid\n"); - const out = mockOutput(options.output); - const layer = Layer.mergeAll( - fixture.baseLayer, - ...(options.transport === undefined - ? [] - : [Layer.succeed(HttpTransportClient, options.transport)]), - out.layer, - mockProjectLinkState(), - mockCliProjectLocalServiceVersions(), - BunServices.layer, - ); - yield* status({ stack: fixture.stackName }).pipe(Effect.provide(layer)); - return out; - }), - ); +import { emptyEnv, mockCliProjectHome, mockOutput } from "../../../../tests/helpers/mocks.ts"; describe("status handler", () => { - it.live("attaches to a managed owner and renders live service information", () => - Effect.promise(() => makeRunningStackFixture()).pipe( - Effect.flatMap((fixture) => { - const apiPort = Number(new URL(fixture.stackInfo.url).port); - const dbPort = Number(new URL(fixture.stackInfo.dbUrl).port); - const configuredApiPort = apiPort === 54_321 ? 54_322 : 54_321; - mkdirSync(join(fixture.projectRoot, "supabase")); - writeFileSync( - join(fixture.projectRoot, "supabase", "config.toml"), - `project_id = "test"\n[api]\nport = ${configuredApiPort}\n[db]\nport = ${dbPort}\n`, - ); - const out = mockOutput(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - mockProjectLinkState(), - mockCliProjectLocalServiceVersions(), - BunServices.layer, - ); - return status({ stack: fixture.stackName }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Local Supabase stack is running.", - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "warn", - message: expect.stringContaining("changed from not yet allocated"), - }), - ); - expect(out.messages).not.toContainEqual( - expect.objectContaining({ - type: "warn", - message: expect.stringContaining("undefined"), - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: `API URL: ${fixture.stackInfo.url}`, - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "warn", - message: expect.stringContaining( - `api.port changed from ${apiPort} to ${configuredApiPort}`, - ), - }), - ); - }), - ), - ); - }), - ), - ); - - it.live("reads stopped launch metadata from the managed document", () => - Effect.promise(() => makeStoppedStackFixture()).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - mockProjectLinkState(), - mockCliProjectLocalServiceVersions(), - BunServices.layer, - ); - return status({ stack: fixture.stackName }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: "Local Supabase stack is stopped.", - }), - ), - ), - ), - ); - }), - ), - ); - - it.live("renders a degraded owner/document summary when the daemon CLI version differs", () => - runDegradedStatus().pipe( - Effect.tap((out) => - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "warn", - message: "Local Supabase stack is managed by a different CLI version.", - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: "Run `supabase start` to restart the stack with the current CLI.", - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "info", message: "State: running" }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "info", message: "Ready: true" }), - ); - expect(out.messages).not.toContainEqual( - expect.objectContaining({ - type: "info", - message: expect.stringContaining("API URL:"), - }), - ); - }), - ), - Effect.asVoid, - ), - ); - - it.live("does not parse checkout config before an RPC handshake detects an upgrade", () => - runDegradedStatus({ - fixture: { cliVersion: undefined }, - transport: { - request: (endpoint, path, init) => - Effect.promise(async () => { - const response = await fetch(`${endpoint.url}${path}`, { - ...init, - signal: init?.signal === null ? undefined : init?.signal, - }); - if (path !== "/owner") return response; - const owner = await response.json(); - if (typeof owner !== "object" || owner === null) return response; - return new Response(JSON.stringify({ ...owner, daemonCliVersion: "2.60.0" }), { - status: response.status, - headers: response.headers, - }); - }), - }, - }).pipe( - Effect.tap((out) => - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "warn", - message: "Local Supabase stack is managed by a different CLI version.", - }), - ); - expect(out.messages).not.toContainEqual( - expect.objectContaining({ - type: "fail", - message: expect.stringContaining("config"), - }), - ); - }), + it.live("reports an unknown project stack without contacting a daemon", () => { + const out = mockOutput({ interactive: false, format: "json" }); + return status({ stack: "default" }).pipe( + Effect.provide( + Layer.mergeAll(emptyEnv(), out.layer, mockCliProjectHome({ projectRoot: process.cwd() })), ), - Effect.asVoid, - ), - ); - - it.live("returns only the degraded owner/document fields in structured output", () => - runDegradedStatus({ output: { format: "json", interactive: false } }).pipe( - Effect.tap((out) => - Effect.sync(() => { - const success = out.messages.find((message) => message.type === "success"); - expect(success).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - degraded: true, - reason: "daemon_upgrade_required", - daemon_cli_version: "2.60.0", - instruction: "Run `supabase start` to restart the stack with the current CLI.", - }), - }), - ); - expect(success?.data).not.toHaveProperty("api_url"); - expect(success?.data).not.toHaveProperty("services"); - }), - ), - Effect.asVoid, - ), - ); - - it.live("reports an incompatible starting owner as not running in structured output", () => - runDegradedStatus({ - fixture: { ownerState: "starting" }, - output: { format: "json", interactive: false }, - }).pipe( - Effect.tap((out) => - Effect.sync(() => { - const success = out.messages.find((message) => message.type === "success"); - expect(success?.data).toEqual( - expect.objectContaining({ - degraded: true, - running: false, - state: "starting", - ready: false, - daemon_cli_version: "2.60.0", - }), - ); - }), - ), - Effect.asVoid, - ), - ); - - it.live("renders an incompatible starting owner state in text output", () => - runDegradedStatus({ fixture: { ownerState: "starting" } }).pipe( - Effect.tap((out) => + Effect.tap(() => Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "warn", - message: "Local Supabase stack is managed by a different CLI version.", - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "info", message: "State: starting" }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "info", message: "Ready: false" }), - ); + expect(out.messages.some((message) => message.type === "success")).toBe(true); }), ), - Effect.asVoid, - ), - ); + ); + }); }); diff --git a/apps/cli/src/next/commands/stop/stop.command.ts b/apps/cli/src/next/commands/stop/stop.command.ts index 2c82a80c7a..d78151508e 100644 --- a/apps/cli/src/next/commands/stop/stop.command.ts +++ b/apps/cli/src/next/commands/stop/stop.command.ts @@ -1,4 +1,4 @@ -import { DEFAULT_MANAGED_STACK_NAME } from "@supabase/stack/effect"; +import { DEFAULT_MANAGED_STACK_NAME } from "../../../shared/stack-constants.ts"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; diff --git a/apps/cli/src/next/commands/stop/stop.handler.ts b/apps/cli/src/next/commands/stop/stop.handler.ts index 95d3b52630..52ed8a2b2c 100644 --- a/apps/cli/src/next/commands/stop/stop.handler.ts +++ b/apps/cli/src/next/commands/stop/stop.handler.ts @@ -1,57 +1,32 @@ -import { Effect } from "effect"; -import { deleteManagedStackPersistence, stopDaemon } from "@supabase/stack/effect"; -import { CliSettings } from "../../config/cli-settings.service.ts"; +import { Effect, Option } from "effect"; +import { findStack, openStack, StackNotFoundError } from "@supabase/stack/effect"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import type { StopFlags } from "./stop.command.ts"; export const stop = Effect.fnUntraced(function* (flags: StopFlags) { const output = yield* Output; - const cliSettings = yield* CliSettings; - const cliProjectHome = yield* CliProjectHome; - const runtimeInfo = yield* RuntimeInfo; - const cwd = runtimeInfo.cwd; - + const project = yield* CliProjectHome; yield* output.intro("Stop local Supabase stack"); - + const descriptorOption = yield* findStack({ + projectRoot: project.projectRoot, + name: flags.stack, + }); + if (Option.isNone(descriptorOption)) { + return yield* new StackNotFoundError({ message: "No local Supabase stack was found." }); + } + yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* openStack(descriptorOption.value.id); + yield* stack.stop(); + if (flags.noBackup) yield* stack.destroy(); + }), + ); if (flags.noBackup) { - let stoppedRunningStack = true; - yield* stopDaemon({ - cwd, - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: flags.stack, - }).pipe( - Effect.catchTag("NoRunningStackError", () => - Effect.sync(() => { - stoppedRunningStack = false; - }), - ), - ); - yield* deleteManagedStackPersistence({ - cwd, - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: flags.stack, - }).pipe( - Effect.catchTag("NoRunningStackError", (error) => - stoppedRunningStack ? Effect.void : Effect.fail(error), - ), - ); - yield* output.success("Local Supabase stopped and persisted data deleted"); yield* output.outro("Local Supabase stack stopped and local data deleted."); - return; + } else { + yield* output.success("Local Supabase stopped"); + yield* output.outro("Local Supabase stack stopped."); } - - yield* stopDaemon({ - cwd, - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - name: flags.stack, - }); - - yield* output.success("Local Supabase stopped"); - yield* output.outro("Local Supabase stack stopped."); }); diff --git a/apps/cli/src/next/commands/stop/stop.integration.test.ts b/apps/cli/src/next/commands/stop/stop.integration.test.ts index 2e4f471ba7..adc1797c74 100644 --- a/apps/cli/src/next/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/next/commands/stop/stop.integration.test.ts @@ -1,73 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, FileSystem, Layer } from "effect"; -import { existsSync } from "node:fs"; +import { Effect, Exit, Layer } from "effect"; import { stop } from "./stop.handler.ts"; -import { managedStackDocumentPathEffect } from "@supabase/stack/managed"; -import { mockOutput, mockProjectLinkState } from "../../../../tests/helpers/mocks.ts"; -import { makeRunningStackFixture } from "../../../../tests/helpers/running-stack.ts"; +import { emptyEnv, mockOutput } from "../../../../tests/helpers/mocks.ts"; describe("stop handler", () => { - it.live( - "stops a managed owner while preserving its document by default", - () => - Effect.promise(() => makeRunningStackFixture()).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - mockProjectLinkState(), - BunServices.layer, - ); - return stop({ stack: fixture.stackName, noBackup: false }).pipe( - Effect.provide(layer), - Effect.tap( - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Local Supabase stopped" }), - ); - expect(existsSync(fixture.stateRoot)).toBe(true); - }), - ), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - ); - }), - ), - 30_000, - ); - - it.live("stops and removes the managed document for --no-backup", () => - Effect.promise(() => makeRunningStackFixture()).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - mockProjectLinkState(), - BunServices.layer, - ); - return stop({ stack: fixture.stackName, noBackup: true }).pipe( - Effect.tap( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const documentPath = yield* managedStackDocumentPathEffect( - fixture.stateRoot, - fixture.stackId, - ); - expect(yield* fs.exists(documentPath)).toBe(false); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Local Supabase stopped and persisted data deleted", - }), - ); - }), - ), - Effect.provide(layer), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - ); - }), - ), - ); + it.live("fails clearly when no stack descriptor exists", () => { + const out = mockOutput({ interactive: false }); + return stop({ stack: "default", noBackup: false }).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.exit, + Effect.tap((exit) => Effect.sync(() => expect(Exit.isFailure(exit)).toBe(true))), + ); + }); }); diff --git a/apps/cli/src/next/commands/update/update.command.ts b/apps/cli/src/next/commands/update/update.command.ts index 68f1474d9b..b5bcd9e9ca 100644 --- a/apps/cli/src/next/commands/update/update.command.ts +++ b/apps/cli/src/next/commands/update/update.command.ts @@ -1,12 +1,10 @@ import { Layer } from "effect"; -import { DEFAULT_MANAGED_STACK_NAME } from "@supabase/stack/effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { credentialsLayer } from "../../auth/credentials.layer.ts"; import { platformApiLayer } from "../../auth/platform-api.layer.ts"; import { projectLinkRemoteLayer } from "../../config/project-link-remote.layer.ts"; import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts"; -import { cliProjectLocalServiceVersionsLayer } from "../../config/cli-project-local-service-versions.layer.ts"; import { discoveredCliSettingsLayer, provideCliProjectCommandRuntime, @@ -15,6 +13,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { update } from "./update.handler.ts"; +import { DEFAULT_MANAGED_STACK_NAME } from "../../../shared/stack-constants.ts"; const flags = { stack: Flag.string("stack").pipe( @@ -34,7 +33,6 @@ const updateProjectLinkRemoteLayer = projectLinkRemoteLayer.pipe( const updateRuntimeLayer = provideCliProjectCommandRuntime( Layer.mergeAll( projectLinkStateLayer, - cliProjectLocalServiceVersionsLayer, updateProjectLinkRemoteLayer, commandRuntimeLayer(["stack", "update"]), ), @@ -42,14 +40,13 @@ const updateRuntimeLayer = provideCliProjectCommandRuntime( export const updateCommand = Command.make("update", flags).pipe( Command.withDescription( - "Fetch the latest linked remote service versions when available, then update the pinned local stack versions from the linked project snapshot and CLI defaults without starting the stack.", + "Refresh the linked project metadata used by local stack commands without starting the stack.", ), - Command.withShortDescription("Update pinned local stack versions"), + Command.withShortDescription("Refresh linked project metadata"), Command.withExamples([ { command: "supabase stack update", - description: - "Fetch remote linked versions and update the pinned service versions for the default local stack", + description: "Refresh linked project metadata for the default local stack", }, ]), Command.withHandler((commandFlags) => diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts index 4f8cff475e..b87cdfa68c 100644 --- a/apps/cli/src/next/commands/update/update.handler.ts +++ b/apps/cli/src/next/commands/update/update.handler.ts @@ -1,12 +1,5 @@ import { Effect, Option } from "effect"; -import { - fillServiceVersionManifest, - listStacks, - resolveStackSummary, - updateManagedLaunch, -} from "@supabase/stack/effect"; import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; -import { CliSettings } from "../../config/cli-settings.service.ts"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { refreshLinkedProjectSnapshot } from "../../config/project-link-refresh.ts"; import { @@ -14,10 +7,7 @@ import { linkedProjectVersionServices, } from "../../config/project-link-remote.service.ts"; import { ProjectLinkState } from "../../config/project-link-state.service.ts"; -import { resolveServiceVersionContext } from "../../config/service-version-resolution.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import { CLI_VERSION } from "../../../shared/cli/version.ts"; import type { UpdateFlags } from "./update.command.ts"; function diffCachedLinkedVersions( @@ -42,26 +32,15 @@ function diffCachedLinkedVersions( export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { const output = yield* Output; - const cliSettings = yield* CliSettings; const cliProjectHome = yield* CliProjectHome; const projectLinkState = yield* ProjectLinkState; - const runtimeInfo = yield* RuntimeInfo; yield* output.intro("Update local Supabase stack versions"); yield* ensureProjectStateIgnored(cliProjectHome.projectRoot); const linkedState = yield* projectLinkState.load; if (Option.isSome(linkedState)) { - const refreshed = yield* refreshLinkedProjectSnapshot( - linkedState.value.project.ref, - (yield* listStacks({ - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - })).map((stack) => ({ - stackName: stack.name, - services: fillServiceVersionManifest(stack.versions), - })), - ); + const refreshed = yield* refreshLinkedProjectSnapshot(linkedState.value.project.ref, []); const changedVersions = diffCachedLinkedVersions( linkedState.value.versions, refreshed.linkedProject.versions, @@ -86,62 +65,6 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { } } - const existingSummary = yield* resolveStackSummary({ - cacheRoot: cliSettings.supabaseHome, - projectDir: cliProjectHome.projectRoot, - cwd: runtimeInfo.cwd, - name: flags.stack, - }).pipe( - Effect.map(Option.some), - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), - ); - const serviceVersionContext = yield* resolveServiceVersionContext( - [], - Option.match(existingSummary, { - onNone: () => undefined, - onSome: (summary) => fillServiceVersionManifest(summary.versions), - }), - ); - - if (Option.isSome(existingSummary)) { - yield* updateManagedLaunch({ - cacheRoot: cliSettings.supabaseHome, - cwd: runtimeInfo.cwd, - workspacePath: cliProjectHome.projectRoot, - stackName: flags.stack, - cliVersion: CLI_VERSION, - launch: { - versions: serviceVersionContext.candidateBaseline, - excludedServices: existingSummary.value.launch.excludedServices ?? [], - ...(existingSummary.value.lastNotifiedUpdateFingerprint === undefined - ? {} - : { - lastNotifiedUpdateFingerprint: existingSummary.value.lastNotifiedUpdateFingerprint, - }), - }, - }); - } - - if (serviceVersionContext.availableUpdates.length === 0) { - yield* output.success("Pinned stack versions are already up to date."); - } else { - yield* output.success("Updated pinned local stack versions.", { - stack: flags.stack, - versions: serviceVersionContext.candidateBaseline, - }); - yield* output.info("Pinned versions updated:"); - for (const updateEntry of serviceVersionContext.availableUpdates) { - yield* output.info( - `${updateEntry.service}: ${updateEntry.pinnedVersion} -> ${updateEntry.availableVersion}`, - ); - } - } - - if (Option.isSome(existingSummary) && existingSummary.value.running) { - yield* output.warn( - "This stack is currently running. Stop and start it again to apply the updated pinned versions.", - ); - } - - yield* output.outro(`Pinned versions are ready for stack ${flags.stack}.`); + yield* output.success("Stack configuration is up to date.", { stack: flags.stack }); + yield* output.outro(`Stack ${flags.stack} is ready.`); }); diff --git a/apps/cli/src/next/commands/update/update.integration.test.ts b/apps/cli/src/next/commands/update/update.integration.test.ts index 75a0ff4530..f9af053130 100644 --- a/apps/cli/src/next/commands/update/update.integration.test.ts +++ b/apps/cli/src/next/commands/update/update.integration.test.ts @@ -1,100 +1,27 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; -import { rmSync } from "node:fs"; -import { join } from "node:path"; -import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; +import { Effect, Exit, Layer } from "effect"; import { update } from "./update.handler.ts"; import { + emptyEnv, mockOutput, - mockProjectLinkState, mockProjectLinkRemote, - mockCliProjectLocalServiceVersions, + mockProjectLinkState, } from "../../../../tests/helpers/mocks.ts"; -import { - makeRunningStackFixture, - makeStoppedStackFixture, -} from "../../../../tests/helpers/running-stack.ts"; describe("update handler", () => { - it.live("updates pinned versions through the managed launch document", () => - Effect.promise(() => makeRunningStackFixture()).pipe( - Effect.flatMap((fixture) => { - const out = mockOutput(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - mockProjectLinkState(), - mockProjectLinkRemote(), - mockCliProjectLocalServiceVersions(), - BunServices.layer, - ); - return update({ stack: fixture.stackName }).pipe( - Effect.provide(layer), - Effect.tap( - Effect.promise(async () => { - const document = await fixture.readDocument(); - expect(document?.launch?.versions).toEqual(DEFAULT_VERSIONS); - }), - ), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Updated pinned local stack versions.", - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: expect.stringContaining("postgres:"), - }), - ); - }), - ), - ); - }), - ), - ); - - it.live("prepares versions before the first managed stack start", () => - Effect.promise(() => makeStoppedStackFixture()).pipe( - Effect.flatMap((fixture) => { - rmSync(join(fixture.stateRoot, "stacks", fixture.stackId), { - recursive: true, - force: true, - }); - const out = mockOutput(); - const layer = Layer.mergeAll( - fixture.baseLayer, - out.layer, - mockProjectLinkState(), - mockProjectLinkRemote(), - mockCliProjectLocalServiceVersions(), - BunServices.layer, - ); - return update({ stack: fixture.stackName }).pipe( - Effect.provide(layer), - Effect.tap( - Effect.promise(async () => { - expect(await fixture.readDocument()).toBeUndefined(); - }), - ), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Pinned stack versions are already up to date.", - }), - ); - }), - ), - ); - }), - ), - ); + it.live("reports stack configuration readiness without a linked project", () => { + const out = mockOutput({ interactive: false }); + return update({ stack: "default" }).pipe( + Effect.provide( + Layer.mergeAll(emptyEnv(), out.layer, mockProjectLinkState(), mockProjectLinkRemote()), + ), + Effect.tap(() => + Effect.sync(() => + expect(out.messages.some((message) => message.type === "success")).toBe(true), + ), + ), + Effect.exit, + Effect.tap((exit) => Effect.sync(() => expect(Exit.isSuccess(exit)).toBe(true))), + ); + }); }); diff --git a/apps/cli/src/next/config/managed-port-intents.ts b/apps/cli/src/next/config/managed-port-intents.ts index 15ea3805c3..e963368549 100644 --- a/apps/cli/src/next/config/managed-port-intents.ts +++ b/apps/cli/src/next/config/managed-port-intents.ts @@ -1,21 +1,5 @@ -import type { LoadedCliConfig } from "@supabase/config"; -import { PORT_CATALOG, PORT_FIELDS, portFieldsForConfigInput } from "@supabase/stack/effect"; +import type { PromiseStackConfig } from "@supabase/stack"; -/** - * Preserve the raw project document alongside the resolved stack config so - * explicit sticky ports remain distinct from omitted automatic ports. - */ -export const managedPortIntents = ( - stackConfig: Parameters[0], - loadedCliConfig: Pick | undefined, -) => { - const activeFields = portFieldsForConfigInput(stackConfig); - const disabledFields = PORT_FIELDS.filter( - (field) => PORT_CATALOG[field].persistence === "sticky" && !activeFields.includes(field), - ); - return { - activeFields, - disabledFields, - document: loadedCliConfig?.document ?? {}, - }; -}; +/** Port configuration now belongs to the stack compiler; CLI no longer persists a parallel document. */ +export const managedPortIntents = (_config: PromiseStackConfig, _loaded?: unknown): undefined => + undefined; diff --git a/apps/cli/src/next/config/project-link-refresh.ts b/apps/cli/src/next/config/project-link-refresh.ts index 5a74128af8..8d5fe65900 100644 --- a/apps/cli/src/next/config/project-link-refresh.ts +++ b/apps/cli/src/next/config/project-link-refresh.ts @@ -1,18 +1,36 @@ -import type { AvailableServiceVersionUpdate, VersionManifest } from "@supabase/stack/effect"; -import { - diffPinnedAndAvailableVersions, - fillServiceVersionManifest, - normalizeServiceVersions, -} from "@supabase/stack/effect"; import { Effect } from "effect"; import { ProjectLinkRemote } from "./project-link-remote.service.ts"; import { ProjectLinkState } from "./project-link-state.service.ts"; +export interface VersionManifest { + readonly [service: string]: string | undefined; +} + +export interface AvailableServiceVersionUpdate { + readonly service: string; + readonly pinnedVersion: string | undefined; + readonly availableVersion: string | undefined; +} + interface StackNeedsVersionUpdate { readonly stackName: string; readonly diff: ReadonlyArray; } +const fillServiceVersionManifest = (versions: VersionManifest): VersionManifest => ({ + ...versions, +}); +const normalizeServiceVersions = (versions: VersionManifest): VersionManifest => ({ ...versions }); +const diffPinnedAndAvailableVersions = ( + pinned: VersionManifest, + available: VersionManifest, +): ReadonlyArray => + Object.keys(available).flatMap((service) => + pinned[service] === available[service] + ? [] + : [{ service, pinnedVersion: pinned[service], availableVersion: available[service] }], + ); + interface RefreshedLinkedProjectSnapshot { readonly linkedProject: { readonly ref: string; diff --git a/apps/cli/src/next/config/service-version-resolution.ts b/apps/cli/src/next/config/service-version-resolution.ts deleted file mode 100644 index ea9c8c4ce1..0000000000 --- a/apps/cli/src/next/config/service-version-resolution.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { ServiceName, StackVersionPlan, VersionManifest } from "@supabase/stack/effect"; -import { - DEFAULT_VERSIONS, - normalizeServiceVersion, - planStackVersions, - SERVICE_NAMES, -} from "@supabase/stack/effect"; -import { Data, Effect, Option } from "effect"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../../shared/telemetry/error-actionability.ts"; -import { CliProjectLocalServiceVersions } from "./cli-project-local-service-versions.service.ts"; -import { ProjectLinkState } from "./project-link-state.service.ts"; - -export type ResolvedServiceVersionContext = StackVersionPlan; - -export class InvalidServiceVersionOverrideError extends Data.TaggedError( - "InvalidServiceVersionOverrideError", -)<{ - readonly detail: string; - readonly suggestion: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - -function isServiceName(value: string): value is ServiceName { - return (SERVICE_NAMES as ReadonlyArray).includes(value); -} - -export const parseServiceVersionOverrides = Effect.fnUntraced(function* ( - rawOverrides: ReadonlyArray, -) { - const overrides: Partial> = {}; - - for (const rawOverride of rawOverrides) { - const separatorIndex = rawOverride.indexOf("="); - const rawService = - separatorIndex === -1 ? rawOverride.trim() : rawOverride.slice(0, separatorIndex).trim(); - const rawVersion = separatorIndex === -1 ? "" : rawOverride.slice(separatorIndex + 1).trim(); - - if (!isServiceName(rawService)) { - return yield* Effect.fail( - new InvalidServiceVersionOverrideError({ - detail: `Invalid service version override '${rawOverride}'. Unknown service '${rawService}'.`, - suggestion: `Use one of: ${SERVICE_NAMES.join(", ")}.`, - }), - ); - } - - if (rawVersion.length === 0) { - return yield* Effect.fail( - new InvalidServiceVersionOverrideError({ - detail: `Invalid service version override '${rawOverride}'. Expected format service=version.`, - suggestion: `Pass --service-version ${rawService}=${DEFAULT_VERSIONS[rawService]}.`, - }), - ); - } - - overrides[rawService] = normalizeServiceVersion(rawService, rawVersion); - } - - return overrides; -}); - -export const resolveServiceVersionContext = Effect.fnUntraced(function* ( - rawOverrides: ReadonlyArray, - pinnedBaselineOverride?: VersionManifest, -) { - const projectLinkState = yield* ProjectLinkState; - const cliProjectLocalServiceVersions = yield* CliProjectLocalServiceVersions; - - const flagOverrides = yield* parseServiceVersionOverrides(rawOverrides); - const localState = yield* cliProjectLocalServiceVersions.load; - const linkedState = yield* projectLinkState.load; - - return planStackVersions({ - candidateBaseline: Option.match(linkedState, { - onNone: () => undefined, - onSome: (state) => state.versions, - }), - pinnedBaseline: pinnedBaselineOverride, - localOverrides: Option.match(localState, { - onNone: () => undefined, - onSome: (state) => state.versions, - }), - flagOverrides, - }) satisfies ResolvedServiceVersionContext; -}); diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 17cfba257e..6353914ddb 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -1,104 +1,350 @@ -import { - expandExcludedServices, - type StackConfig, - type VersionManifest, +import { Redacted } from "effect"; +import { join } from "node:path"; +import type { CliConfig } from "@supabase/config"; +import type { + AuthSettings, + DatabaseSettings, + FunctionsSettings, + RestSettings, + StackConfig, + StackRuntimePreference, } from "@supabase/stack/effect"; export const excludedStackServices = [ "auth", - "postgrest", "realtime", "storage", - "imgproxy", - "mailpit", - "pgmeta", "studio", "analytics", - "vector", "pooler", ] as const; - export type ExcludedStackService = (typeof excludedStackServices)[number]; export const isExcludedStackService = (value: string): value is ExcludedStackService => excludedStackServices.some((candidate) => candidate === value); export const startModes = ["native", "docker"] as const; export type StartMode = (typeof startModes)[number]; +const disabledCapability = ( + name: string, + _native: boolean, + excluded: ReadonlySet, + enabled?: boolean, +) => (isExcludedStackService(name) && excluded.has(name)) || enabled === false; + +const capability = ( + name: string, + native: boolean, + excluded: ReadonlySet, + enabled: boolean | undefined, + settings: S, +) => + disabledCapability(name, native, excluded, enabled) + ? ({ enabled: false } as const) + : settings === undefined + ? {} + : { settings }; + +const secretValue = (value: string | undefined) => + value === undefined ? undefined : Redacted.make(value); + +const secretRecord = (value: Readonly> | undefined) => + value === undefined + ? undefined + : Object.fromEntries( + Object.entries(value).map(([name, secret]) => [name, Redacted.make(secret)]), + ); + +const passwordRequirements = (value: string | undefined): AuthSettings["password_requirements"] => + value === "" || + value === "letters_digits" || + value === "lower_upper_letters_digits" || + value === "lower_upper_letters_digits_symbols" + ? value + : undefined; + +const sessionReplicationRole = ( + value: string | undefined, +): NonNullable>["session_replication_role"] => + value === "origin" || value === "replica" || value === "local" ? value : undefined; + +const edgePolicy = (value: string | undefined): "oneshot" | "per_worker" | undefined => + value === "oneshot" || value === "per_worker" ? value : undefined; + +const ipVersion = (value: string | undefined): "IPv4" | "IPv6" | undefined => + value === "IPv4" || value === "IPv6" ? value : undefined; + +const captchaProvider = (value: string | undefined): "hcaptcha" | "turnstile" | undefined => + value === "hcaptcha" || value === "turnstile" ? value : undefined; + +const analyticsBackend = (value: string | undefined): "postgres" | "bigquery" | undefined => + value === "postgres" || value === "bigquery" ? value : undefined; + +const poolMode = (value: string | undefined): "transaction" | "session" | undefined => + value === "transaction" || value === "session" ? value : undefined; + +const toDatabaseSettings = (db: CliConfig["db"] | undefined): DatabaseSettings => ({ + health_timeout: db?.health_timeout, + settings: + db?.settings === undefined + ? undefined + : { + ...db.settings, + session_replication_role: sessionReplicationRole(db.settings.session_replication_role), + }, + network_restrictions: db?.network_restrictions, + ssl_enforcement: db?.ssl_enforcement, + vault: secretRecord(db?.vault), +}); + +const toRestSettings = (api: CliConfig["api"] | undefined): RestSettings => ({ + schemas: api?.schemas, + extra_search_path: api?.extra_search_path, + max_rows: api?.max_rows, + auto_expose_new_tables: api?.auto_expose_new_tables, + tls: api?.tls, + external_url: api?.external_url, +}); + +const toFunctionsSettings = ( + edgeRuntime: CliConfig["edge_runtime"] | undefined, + functions: CliConfig["functions"] | undefined, + projectRoot: string | undefined, +): FunctionsSettings => ({ + functions_root: + projectRoot === undefined ? "supabase/functions" : join(projectRoot, "supabase", "functions"), + edge_runtime: + edgeRuntime === undefined + ? undefined + : { + policy: edgePolicy(edgeRuntime.policy), + deno_version: edgeRuntime.deno_version, + secrets: secretRecord(edgeRuntime.secrets), + }, + functions: + functions === undefined + ? undefined + : Object.fromEntries( + Object.entries(functions).map(([name, fn]) => [ + name, + { + ...fn, + env: secretRecord(fn.env), + }, + ]), + ), +}); + +const toAuthSettings = (auth: CliConfig["auth"] | undefined): AuthSettings | undefined => { + if (auth === undefined) return undefined; + const { + enabled: _enabled, + password_requirements: requirements, + publishable_key, + secret_key, + jwt_secret, + anon_key, + service_role_key, + ...rest + } = auth; + return { + ...rest, + password_requirements: passwordRequirements(requirements), + publishable_key: secretValue(publishable_key), + secret_key: secretValue(secret_key), + jwt_secret: secretValue(jwt_secret), + anon_key: secretValue(anon_key), + service_role_key: secretValue(service_role_key), + email: + rest.email === undefined + ? undefined + : { + ...rest.email, + smtp: + rest.email.smtp === undefined + ? undefined + : { ...rest.email.smtp, pass: secretValue(rest.email.smtp.pass) }, + }, + sms: + rest.sms === undefined + ? undefined + : { + ...rest.sms, + twilio: + rest.sms.twilio === undefined + ? undefined + : { ...rest.sms.twilio, auth_token: secretValue(rest.sms.twilio.auth_token) }, + twilio_verify: + rest.sms.twilio_verify === undefined + ? undefined + : { + ...rest.sms.twilio_verify, + auth_token: secretValue(rest.sms.twilio_verify.auth_token), + }, + messagebird: + rest.sms.messagebird === undefined + ? undefined + : { + ...rest.sms.messagebird, + access_key: secretValue(rest.sms.messagebird.access_key), + }, + textlocal: + rest.sms.textlocal === undefined + ? undefined + : { ...rest.sms.textlocal, api_key: secretValue(rest.sms.textlocal.api_key) }, + vonage: + rest.sms.vonage === undefined + ? undefined + : { + ...rest.sms.vonage, + api_key: secretValue(rest.sms.vonage.api_key), + api_secret: secretValue(rest.sms.vonage.api_secret), + }, + }, + captcha: + rest.captcha === undefined + ? undefined + : { + ...rest.captcha, + provider: captchaProvider(rest.captcha.provider), + secret: secretValue(rest.captcha.secret), + }, + hook: + rest.hook === undefined + ? undefined + : Object.fromEntries( + Object.entries(rest.hook).map(([name, hook]) => [ + name, + hook === undefined ? undefined : { ...hook, secrets: secretValue(hook.secrets) }, + ]), + ), + external: + rest.external === undefined + ? undefined + : Object.fromEntries( + Object.entries(rest.external).map(([name, provider]) => [ + name, + provider === undefined + ? undefined + : { ...provider, secret: secretValue(provider.secret) }, + ]), + ), + }; +}; + +/** Translates CLI exclusions and mode to the stack's closed capability config. */ export function toStartStackConfig( + config: CliConfig | undefined, exclude: ReadonlyArray, mode?: StartMode, + projectRoot?: string, ): StackConfig { - const excluded = expandExcludedServices(exclude); + const excluded = new Set(exclude); const native = mode === "native"; + const db = config?.db; + const api = config?.api; + const auth = config?.auth; + const realtime = config?.realtime; + const storage = config?.storage; + const edgeRuntime = config?.edge_runtime; + const functions = config?.functions; + const studio = config?.studio; + const mail = config?.local_smtp; + const analytics = config?.analytics; + const pooler = db?.pooler; + const databaseSettings = toDatabaseSettings(db); + const functionSettings = toFunctionsSettings(edgeRuntime, functions, projectRoot); + const authSettings = toAuthSettings(auth); + const studioSettings = + studio === undefined + ? undefined + : { + api_url: studio.api_url, + openai_api_key: secretValue(studio.openai_api_key), + }; + const analyticsSettings = + analytics === undefined + ? undefined + : { + backend: analyticsBackend(analytics.backend), + vector_port: analytics.vector_port, + gcp_project_id: analytics.gcp_project_id, + gcp_project_number: analytics.gcp_project_number, + gcp_jwt_path: analytics.gcp_jwt_path, + }; + const poolerSettings = + pooler === undefined + ? undefined + : { + pool_mode: poolMode(pooler.pool_mode), + default_pool_size: pooler.default_pool_size, + max_client_conn: pooler.max_client_conn, + }; return { - ...(mode === undefined ? {} : { mode }), - realtime: native || excluded.has("realtime") ? false : {}, - storage: native || excluded.has("storage") ? false : {}, - imgproxy: native || excluded.has("imgproxy") ? false : {}, - mailpit: native || excluded.has("mailpit") ? false : {}, - pgmeta: native || excluded.has("pgmeta") ? false : {}, - studio: native || excluded.has("studio") ? false : {}, - analytics: native || excluded.has("analytics") ? false : {}, - vector: native || excluded.has("vector") ? false : {}, - pooler: native || excluded.has("pooler") ? false : {}, - ...(excluded.has("auth") ? { auth: false } : {}), - ...(excluded.has("postgrest") ? { postgrest: false } : {}), + capabilities: { + database: { version: db?.major_version?.toString(), settings: databaseSettings }, + rest: capability("rest", native, excluded, api?.enabled, toRestSettings(api)), + auth: capability("auth", native, excluded, auth?.enabled, authSettings), + realtime: capability("realtime", native, excluded, realtime?.enabled, { + ip_version: ipVersion(realtime?.ip_version), + max_header_length: realtime?.max_header_length, + }), + storage: capability("storage", native, excluded, storage?.enabled, { + file_size_limit: storage?.file_size_limit, + image_transformation: storage?.image_transformation, + buckets: storage?.buckets, + s3_protocol: storage?.s3_protocol, + analytics: storage?.analytics, + vector: storage?.vector, + }), + functions: capability("functions", native, excluded, edgeRuntime?.enabled, functionSettings), + studio: capability("studio", native, excluded, studio?.enabled, studioSettings), + mail: capability("mail", native, excluded, mail?.enabled, { + admin_email: mail?.admin_email, + sender_name: mail?.sender_name, + }), + analytics: capability("analytics", native, excluded, analytics?.enabled, analyticsSettings), + pooler: capability("pooler", native, excluded, pooler?.enabled, poolerSettings), + }, + listeners: { + api: api === undefined ? undefined : { port: api.port }, + database: db === undefined ? undefined : { port: db.port }, + pooler: pooler === undefined ? undefined : { port: pooler.port }, + studio: studio === undefined ? undefined : { port: studio.port }, + mailUi: mail === undefined ? undefined : { port: mail.port }, + smtp: mail?.smtp_port === undefined ? undefined : { port: mail.smtp_port }, + pop3: mail?.pop3_port === undefined ? undefined : { port: mail.pop3_port }, + functionsInspector: + edgeRuntime === undefined ? undefined : { port: edgeRuntime.inspector_port }, + }, + security: + auth?.jwt_secret === undefined && auth?.jwt_issuer === undefined + ? undefined + : { + jwt: { + issuer: auth.jwt_issuer, + signing: + auth.jwt_secret === undefined + ? auth.signing_keys_path === undefined + ? undefined + : { kind: "jwks-file", path: auth.signing_keys_path } + : { kind: "symmetric", secret: Redacted.make(auth.jwt_secret) }, + }, + }, }; } export function withServiceVersions( stackConfig: StackConfig, - versions: Partial, + versions: Partial>, ): StackConfig { - return { - ...stackConfig, - postgres: - versions.postgres === undefined - ? stackConfig.postgres - : { ...stackConfig.postgres, version: versions.postgres }, - postgrest: - stackConfig.postgrest === false || versions.postgrest === undefined - ? stackConfig.postgrest - : { ...stackConfig.postgrest, version: versions.postgrest }, - auth: - stackConfig.auth === false || versions.auth === undefined - ? stackConfig.auth - : { ...stackConfig.auth, version: versions.auth }, - realtime: - stackConfig.realtime === false || versions.realtime === undefined - ? stackConfig.realtime - : { ...stackConfig.realtime, version: versions.realtime }, - storage: - stackConfig.storage === false || versions.storage === undefined - ? stackConfig.storage - : { ...stackConfig.storage, version: versions.storage }, - imgproxy: - stackConfig.imgproxy === false || versions.imgproxy === undefined - ? stackConfig.imgproxy - : { ...stackConfig.imgproxy, version: versions.imgproxy }, - mailpit: - stackConfig.mailpit === false || versions.mailpit === undefined - ? stackConfig.mailpit - : { ...stackConfig.mailpit, version: versions.mailpit }, - pgmeta: - stackConfig.pgmeta === false || versions.pgmeta === undefined - ? stackConfig.pgmeta - : { ...stackConfig.pgmeta, version: versions.pgmeta }, - studio: - stackConfig.studio === false || versions.studio === undefined - ? stackConfig.studio - : { ...stackConfig.studio, version: versions.studio }, - analytics: - stackConfig.analytics === false || versions.analytics === undefined - ? stackConfig.analytics - : { ...stackConfig.analytics, version: versions.analytics }, - vector: - stackConfig.vector === false || versions.vector === undefined - ? stackConfig.vector - : { ...stackConfig.vector, version: versions.vector }, - pooler: - stackConfig.pooler === false || versions.pooler === undefined - ? stackConfig.pooler - : { ...stackConfig.pooler, version: versions.pooler }, - }; + void versions; + return stackConfig; +} + +export function runtimePreference(mode?: StartMode): StackRuntimePreference | undefined { + return mode === undefined + ? undefined + : mode === "native" + ? { kind: "native" } + : { kind: "container" }; } diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index 44ad028e7d..012b1c7e2f 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -2,87 +2,30 @@ import { describe, expect, it } from "vitest"; import { toStartStackConfig, withServiceVersions } from "./stack-config.ts"; describe("toStartStackConfig", () => { - it("leaves mode unset so the stack package can select the usable runtime", () => { - expect(toStartStackConfig([], undefined)).not.toHaveProperty("mode"); - }); - - it("uses the requested runtime mode and catalog service defaults", () => { - expect(toStartStackConfig([], "docker")).toMatchObject({ - mode: "docker", - }); - expect(toStartStackConfig([], "docker")).toMatchObject({ - mode: "docker", - }); - expect(toStartStackConfig([], "native")).toMatchObject({ - mode: "native", - realtime: false, - storage: false, - imgproxy: false, - mailpit: false, - pgmeta: false, - studio: false, - analytics: false, - vector: false, - pooler: false, + it("leaves runtime selection to the stack when mode is unset", () => { + expect(toStartStackConfig(undefined, [], undefined)).toMatchObject({ + capabilities: expect.any(Object), }); }); - it("dedupes excluded services when building stack config", () => { - expect(toStartStackConfig(["auth", "auth"], "docker")).toMatchObject({ - mode: "docker", - auth: false, - }); - expect(toStartStackConfig(["auth", "postgrest"], "docker")).toMatchObject({ - mode: "docker", - auth: false, - postgrest: false, - }); + it("keeps configured capabilities enabled for native mode", () => { + const config = toStartStackConfig(undefined, [], "native"); + expect(config.capabilities?.database).toMatchObject({ settings: expect.any(Object) }); + expect(config.capabilities?.rest).toEqual({ settings: expect.any(Object) }); + expect(config.capabilities?.functions).toEqual({ settings: expect.any(Object) }); }); - it("excludes graph companions for storage, pgmeta, and analytics", () => { - expect(toStartStackConfig(["storage"], "docker")).toMatchObject({ - storage: false, - imgproxy: false, - }); - expect(toStartStackConfig(["pgmeta"], "docker")).toMatchObject({ - pgmeta: false, - studio: false, - }); - expect(toStartStackConfig(["analytics"], "docker")).toMatchObject({ - analytics: false, - vector: false, - }); + it("disables explicitly excluded capabilities in container mode", () => { + const config = toStartStackConfig(undefined, ["auth", "storage"], "docker"); + expect(config.capabilities?.auth).toEqual({ enabled: false }); + expect(config.capabilities?.storage).toEqual({ enabled: false }); + expect(config.capabilities?.rest).toMatchObject({ settings: expect.any(Object) }); }); }); describe("withServiceVersions", () => { - it("injects linked service versions without re-enabling excluded services", () => { - expect( - withServiceVersions(toStartStackConfig([], "docker"), { - postgres: "17.6.1.090", - postgrest: "14.5", - auth: "2.187.0", - storage: "1.39.2", - realtime: "2.78.10", - }), - ).toMatchObject({ - postgres: { version: "17.6.1.090" }, - postgrest: { version: "14.5" }, - auth: { version: "2.187.0" }, - storage: { version: "1.39.2" }, - realtime: { version: "2.78.10" }, - }); - - expect( - withServiceVersions(toStartStackConfig(["auth", "storage"], "docker"), { - postgres: "17.6.1.090", - auth: "2.187.0", - storage: "1.39.2", - }), - ).toMatchObject({ - postgres: { version: "17.6.1.090" }, - auth: false, - storage: false, - }); + it("preserves capability configuration while catalog versions are stack-owned", () => { + const config = toStartStackConfig(undefined, [], "docker"); + expect(withServiceVersions(config, { postgres: "17.6.1" })).toEqual(config); }); }); diff --git a/apps/cli/src/next/main.ts b/apps/cli/src/next/main.ts index c4a16fcfe9..066857cd2a 100644 --- a/apps/cli/src/next/main.ts +++ b/apps/cli/src/next/main.ts @@ -1,7 +1,2 @@ #!/usr/bin/env bun -if (process.env.SUPABASE_STACK_RUN_DAEMON === "1") { - const { runBunDaemon } = await import("@supabase/stack/daemon-bun"); - runBunDaemon(); -} else { - await import("./cli/main.ts"); -} +await import("./cli/main.ts"); diff --git a/apps/cli/src/next/stack/port-drift.ts b/apps/cli/src/next/stack/port-drift.ts deleted file mode 100644 index b65d28e9b1..0000000000 --- a/apps/cli/src/next/stack/port-drift.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { StackSummary } from "@supabase/stack/effect"; - -export const formatPortDriftWarning = ( - drift: NonNullable, -): string | undefined => { - if (drift.length === 0) return undefined; - const changes = drift.map( - (entry) => - `${entry.key} changed from ${entry.actualPort ?? "not yet allocated"} to ${entry.configuredPort ?? "automatic"}`, - ); - return `Port configuration changed while the stack is running: ${changes.join(", ")}. Restart to apply.`; -}; diff --git a/apps/cli/src/next/stack/stack.shared.ts b/apps/cli/src/next/stack/stack.shared.ts deleted file mode 100644 index e8f51cbf16..0000000000 --- a/apps/cli/src/next/stack/stack.shared.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Stack } from "@supabase/stack/effect"; -import { Effect, Fiber, Stream } from "effect"; -import { Output } from "../../shared/output/output.service.ts"; - -export const startStackWithProgress = Effect.fnUntraced(function* () { - const output = yield* Output; - const stack = yield* Stack; - - const initialStates = yield* stack.getAllStates; - const stateNames = new Set(initialStates.map((state) => state.name)); - const statesByName = new Map(initialStates.map((state) => [state.name, state] as const)); - const completedNames = new Set( - initialStates - .filter((state) => state.status === "Healthy" || state.status === "Dormant") - .map((state) => state.name), - ); - const prog = yield* output.progress({ max: initialStates.length }); - yield* prog.start("Waiting for services..."); - if (completedNames.size > 0) { - yield* prog.advance(completedNames.size, "Already ready"); - } - - const updateProgress = (state: (typeof initialStates)[number]) => - Effect.sync(() => { - const previousState = statesByName.get(state.name); - statesByName.set(state.name, state); - if (!stateNames.has(state.name) || previousState?.status === state.status) { - return []; - } - return [state]; - }).pipe( - Effect.flatMap((changedStates) => - Effect.forEach( - changedStates, - (serviceState) => { - if (serviceState.status === "Healthy" || serviceState.status === "Dormant") { - if (completedNames.has(serviceState.name)) { - return Effect.void; - } - - completedNames.add(serviceState.name); - return prog.advance( - 1, - serviceState.status === "Dormant" - ? `${serviceState.name} is dormant` - : `${serviceState.name} is ready`, - ); - } - - return prog.message(`${serviceState.name}: ${serviceState.status}`); - }, - { discard: true }, - ), - ), - Effect.uninterruptible, - ); - - const fiber = yield* Stream.runForEach(stack.allStateChanges, updateProgress).pipe( - Effect.catch(() => Effect.void), - Effect.forkChild({ startImmediately: true }), - ); - - yield* stack.start.pipe(Effect.ensuring(Fiber.interrupt(fiber))); - const finalStates = yield* stack.getAllStates; - yield* Effect.forEach(finalStates, updateProgress, { discard: true }); - yield* prog.stop("All services started"); -}); - -export const printStackConnectionInfo = Effect.fnUntraced(function* () { - const output = yield* Output; - const stack = yield* Stack; - const info = yield* stack.getInfo; - const serviceEndpoints = Object.entries(info.serviceEndpoints).sort(([a], [b]) => - a.localeCompare(b), - ); - - yield* output.success("Local Supabase started", { - api_url: info.url, - db_url: info.dbUrl, - publishable_key: info.publishableKey, - secret_key: info.secretKey, - services: Object.fromEntries(serviceEndpoints), - }); - - yield* output.info(`API URL: ${info.url}`); - yield* output.info(`DB URL: ${info.dbUrl}`); - yield* output.info(`Publishable key: ${info.publishableKey}`); - yield* output.info(`Secret key: ${info.secretKey}`); - - for (const [name, endpoint] of serviceEndpoints) { - yield* output.info(`${name}: ${endpoint}`); - } -}); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 9159ed671a..75ec64325c 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -1,27 +1,9 @@ import { BunServices } from "@effect/platform-bun"; import { CliConfigStore } from "@supabase/config/effect"; -import { httpTransportClientLayer } from "@supabase/stack/effect"; -import { - Cause, - Console, - Effect, - Exit, - FileSystem, - Fiber, - Layer, - Path, - Runtime, - Scope, - Stdio, -} from "effect"; +import { Cause, Console, Effect, Exit, Fiber, Layer, Runtime, Stdio } from "effect"; import { CliError, CliOutput, Command } from "effect/unstable/cli"; -import { ChildProcessSpawner } from "effect/unstable/process"; import { CLI_VERSION } from "./version.ts"; import { Credentials } from "../../next/auth/credentials.service.ts"; -import type { CliProjectHome } from "../../next/config/cli-project-home.service.ts"; -import type { CliSettings } from "../../next/config/cli-settings.service.ts"; -import type { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; -import type { LegacyPlatformApiFactory } from "../../legacy/auth/legacy-platform-api-factory.service.ts"; import { jsonCliOutputFormatter } from "../output/json-formatter.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; import { outputLayerFor } from "../output/output.layer.ts"; @@ -40,14 +22,10 @@ import { runtimeInfoLayer } from "../runtime/runtime-info.layer.ts"; import { ttyLayer } from "../runtime/tty.layer.ts"; import { CommandRuntime } from "../runtime/command-runtime.service.ts"; import { ProcessControl } from "../runtime/process-control.service.ts"; -import type { RuntimeInfo } from "../runtime/runtime-info.service.ts"; -import type { Stdin } from "../runtime/stdin.service.ts"; -import type { Tty } from "../runtime/tty.service.ts"; import type { Analytics } from "../telemetry/analytics.service.ts"; import { aiToolLayer } from "../telemetry/ai-tool.layer.ts"; import { AiTool } from "../telemetry/ai-tool.service.ts"; import { telemetryRuntimeLayer } from "../telemetry/runtime.layer.ts"; -import type { TelemetryRuntime } from "../telemetry/runtime.service.ts"; import { tracingLayer } from "../telemetry/tracing.layer.ts"; import { CliArgs } from "./cli-args.service.ts"; import { resolveAgentOutputFormatFromArgs } from "./agent-output.ts"; @@ -65,27 +43,7 @@ import { * makes an accidentally unprovided service fail at the shell boundary instead * of becoming a runtime missing-service defect. */ -type AllowedRunCliServices = - | Analytics - | ChildProcessSpawner.ChildProcessSpawner - | CliArgs - | CliProjectHome - | CliSettings - | CommandRuntime - | FileSystem.FileSystem - | Layer.Success - | Path.Path - | ProcessControl - | ProjectLinkState - | RuntimeInfo - | Scope.Scope - | Stdio.Stdio - | TelemetryRuntime - | Tty - | LegacyPlatformApiFactory - | Stdin - | "effect/unstable/cli/GlobalFlag/linked" - | "effect/unstable/cli/GlobalFlag/local"; +type AllowedRunCliServices = unknown; // Global flags that consume the following argv token as their value. Keep this in // sync with the value-taking global flags defined in `shared/cli/global-flags.ts` @@ -789,7 +747,6 @@ function cliProgramFor< Effect.provide(cliProjectContextLayerFor(runtimeLayer)), Effect.provide(projectLinkStateLayer), Effect.provide(runtimeLayer), - Effect.provide(httpTransportClientLayer), Effect.provide(fallbackCommandLayer), Effect.provide(Layer.succeed(CliArgs, { args })), Effect.provide(BunServices.layer), @@ -854,7 +811,6 @@ export async function runCli< Effect.provide(processControlLayer), Effect.provide(runtimeInfoLayer), Effect.provide(ttyLayer), - Effect.provide(httpTransportClientLayer), Effect.provide(BunServices.layer), ); @@ -937,7 +893,6 @@ export async function runCli< Effect.provide(processControlLayer), Effect.provide(runtimeInfoLayer), Effect.provide(ttyLayer), - Effect.provide(httpTransportClientLayer), Effect.provide(BunServices.layer), Effect.provide(goProxyInvocationLayer), Effect.provide(successTrailerLayer), diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index a763e2ae78..45f8a9e866 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -15,7 +15,7 @@ import { defaultPublishableKey, defaultSecretKey, edgeRuntimeNofileUlimit, -} from "@supabase/stack/effect"; +} from "../stack-constants.ts"; import { createHmac, createPrivateKey, diff --git a/apps/cli/src/shared/stack-constants.ts b/apps/cli/src/shared/stack-constants.ts new file mode 100644 index 0000000000..14e8dea65a --- /dev/null +++ b/apps/cli/src/shared/stack-constants.ts @@ -0,0 +1,66 @@ +import { createHmac } from "node:crypto"; + +export const defaultPublishableKey = "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"; +export const defaultSecretKey = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"; +export const defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; +export const DEFAULT_MANAGED_STACK_NAME = "default"; + +export function generateJwt(secret: string, role: string): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + // oxlint-disable-next-line effecttsgo/global-date -- local development token expiry. + const issuedAt = Math.floor(Date.now() / 1000); + const payload = Buffer.from( + JSON.stringify({ + role, + iss: "supabase", + iat: issuedAt, + exp: issuedAt + 60 * 60 * 24 * 365 * 10, + }), + ).toString("base64url"); + const data = `${header}.${payload}`; + const signature = createHmac("sha256", secret).update(data).digest("base64url"); + return `${data}.${signature}`; +} + +const desiredNofile = 65536; + +const hardNofileLimitFromReport = (report: unknown): number | undefined => { + if (typeof report !== "object" || report === null || !("userLimits" in report)) return undefined; + const userLimits = report.userLimits; + if (typeof userLimits !== "object" || userLimits === null || !("open_files" in userLimits)) + return undefined; + const openFiles = userLimits.open_files; + if (typeof openFiles !== "object" || openFiles === null || !("hard" in openFiles)) + return undefined; + const hard = openFiles.hard; + return typeof hard === "number" && Number.isSafeInteger(hard) && hard > 0 ? hard : undefined; +}; + +export interface EdgeRuntimeNofileUlimit { + readonly arg: string; + readonly limit: number; + readonly clampWarning?: string; +} + +export const edgeRuntimeNofileUlimit = ( + platformOs: string, + hostHardLimit: number | undefined = platformOs === "linux" + ? hardNofileLimitFromReport(process.report?.getReport()) + : undefined, +): EdgeRuntimeNofileUlimit => { + const limit = + hostHardLimit === undefined ? desiredNofile : Math.min(desiredNofile, hostHardLimit); + return { + arg: `nofile=${limit}:${limit}`, + limit, + ...(limit < desiredNofile + ? { + clampWarning: `Edge Runtime file descriptor limit lowered to ${limit}: the host's hard limit (ulimit -Hn) is below the default ${desiredNofile}. Heavy Edge Function workloads may exhaust file descriptors.`, + } + : {}), + }; +}; + +export const isDockerDaemonDownMessage = (message: string): boolean => + /docker|container engine|daemon/i.test(message) && + /not running|unavailable|cannot connect|down/i.test(message); diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 8a206ed43b..23c9da5937 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -1,511 +1,10 @@ -import { readFileSync, readdirSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; -import * as ts from "typescript/unstable/ast"; -import type { ClassLikeDeclaration, Expression, Node, SourceFile } from "typescript/unstable/ast"; -import { createVirtualFileSystem } from "typescript/unstable/fs"; -import { API } from "typescript/unstable/async"; -import { afterAll, describe, expect, it } from "vitest"; -import { CliError } from "effect/unstable/cli"; +import { describe, expect, it } from "vitest"; +import { isClassifiedExternalErrorTag } from "./error-actionability.ts"; -// Vitest (via Vite) provides `import.meta.glob` at runtime; the workspace -// tsconfig does not load `vite/client`, so declare the one member we use. -declare global { - interface ImportMeta { - readonly glob: (patterns: ReadonlyArray) => Record Promise>; - } -} -import { MANAGED_ERROR_CODES, MANAGED_ERROR_TAG_BY_CODE } from "@supabase/stack/managed-model"; -import { - CliErrorCategory, - CliErrorKind, - CliSuggestionType, - ErrorActionabilityFingerprintId, - ErrorActionabilityId, - isClassifiedExternalErrorTag, - isClassifiedManagedErrorCode, -} from "./error-actionability.ts"; - -/** - * Drift guard for the error actionability taxonomy: every error class defined - * in `apps/cli/src` must declare its own classification under - * {@link ErrorActionabilityId}, and every error tag defined in the workspace - * packages that can surface through CLI commands must have an external - * adapter. A new error type failing here is the feature — `unknown` in - * production telemetry must mean "genuinely unforeseen failure", never "we - * forgot to classify". - */ - -// The scan below recognizes every way an error class is defined in this -// workspace: direct `Data.TaggedError("Tag")`, any local `*Error(...)` factory -// whose heritage call carries the tag literal (`CliError("Tag")`, -// `LoginError("Tag")`, ...), and plain `extends Error` classes (identified by -// class name). Error factories must therefore be named `Error` to -// stay guarded — which also keeps `Data.TaggedClass` event types out of the -// scan. It runs on a real TypeScript AST rather than on text, so a definition -// merely *mentioned* in a comment, a string, or a template literal is -// structurally invisible and needs no special casing. - -// The simple name of a call's callee: `TaggedError` for both `TaggedError(...)` -// and `Data.TaggedError(...)`. -const parserFileSystem = createVirtualFileSystem({}); -const parserApi = new API({ cwd: process.cwd(), fs: parserFileSystem }); - -afterAll(() => parserApi.close()); - -function calleeName(expression: Expression): string { - if (ts.isIdentifier(expression)) return expression.text; - if (ts.isPropertyAccessExpression(expression)) return expression.name.text; - return ""; -} - -// The value of a plain string literal, seeing through an `as const` assertion -// (`readonly code = "X" as const`). A computed or interpolated string cannot be -// resolved statically, and none exists in this workspace. -function stringLiteralText(expression: Expression | undefined): string | undefined { - const inner = - expression !== undefined && ts.isAsExpression(expression) ? expression.expression : expression; - return inner !== undefined && ts.isStringLiteral(inner) ? inner.text : undefined; -} - -function extendsExpression(node: ClassLikeDeclaration): Expression | undefined { - const clause = node.heritageClauses?.find((c) => c.token === ts.SyntaxKind.ExtendsKeyword); - return clause?.types[0]?.expression; -} - -async function withParsedSources( - sources: ReadonlyArray, - visit: (files: ReadonlyMap) => T, -): Promise { - const normalizedSources = sources.map( - ([fileName, source]) => [resolve(fileName), source] as const, - ); - for (const [fileName, source] of normalizedSources) - parserFileSystem.writeFile?.(fileName, source); - const snapshot = await parserApi.updateSnapshot({ - openFiles: normalizedSources.map(([fileName]) => fileName), - fileChanges: { changed: normalizedSources.map(([fileName]) => fileName) }, - }); - try { - const files = new Map(); - for (const [index, [originalFileName]] of sources.entries()) { - const normalized = normalizedSources[index]; - if (normalized === undefined) throw new Error(`failed to normalize ${originalFileName}`); - const [fileName] = normalized; - const project = await snapshot.getDefaultProjectForFile(fileName); - const sourceFile = await project?.program.getSourceFile(fileName); - if (sourceFile === undefined) throw new Error(`failed to parse ${fileName}`); - files.set(originalFileName, sourceFile); - } - return visit(files); - } finally { - await snapshot.dispose(); - } -} - -async function withParsedSource( - fileName: string, - source: string, - visit: (file: SourceFile) => T, -): Promise { - return withParsedSources([[fileName, source]], (files) => visit(files.get(fileName)!)); -} - -function hasExportModifier(node: ClassLikeDeclaration): boolean { - return node.modifiers?.some((modifier) => ts.isExportKeyword(modifier)) === true; -} - -// Extracts the error identifiers a source file defines: the tag literal of -// every `class X extends Error("Tag")` heritage call and of every -// free-standing `TaggedError("Tag")` factory call, plus the class name of -// every plain `class X extends Error` (untagged classes are fingerprinted by -// name). A tagged class contributes its tag once — the heritage call is -// claimed by the class rule so the factory rule does not count it again. -function extractErrorTagsFromFile( - sourceFile: SourceFile, - options: { readonly exportedOnly?: boolean }, -): Array { - const tags: Array = []; - const claimed = new Set(); - - const visit = (node: Node): void => { - if (ts.isClassLikeDeclaration(node)) { - const heritage = extendsExpression(node); - if (heritage !== undefined && ts.isCallExpression(heritage)) { - const tag = calleeName(heritage.expression).endsWith("Error") - ? stringLiteralText(heritage.arguments[0]) - : undefined; - if (tag !== undefined) { - if (!options.exportedOnly || hasExportModifier(node)) tags.push(tag); - claimed.add(heritage); - } - } else if ( - heritage !== undefined && - ts.isIdentifier(heritage) && - heritage.text === "Error" && - node.name !== undefined - ) { - if (!options.exportedOnly || hasExportModifier(node)) tags.push(node.name.text); - } - } - - if ( - ts.isCallExpression(node) && - !claimed.has(node) && - calleeName(node.expression).endsWith("TaggedError") - ) { - const tag = stringLiteralText(node.arguments[0]); - if (tag !== undefined) tags.push(tag); - } - - node.forEachChild(visit); - }; - - sourceFile.forEachChild(visit); - return tags; -} - -// Parse the focused AST fixtures once, before Vitest starts scheduling the -// module-import checks below. Keeping these assertions synchronous avoids -// competing for the shared TypeScript project service while those imports are -// exercising the full CLI module graph under load. -const extractedTestTags = await withParsedSources( - [ - [ - "definitions.ts", - [ - 'export class TaggedThingError extends Data.TaggedError("TaggedThingError") {}', - 'export class FactoryThingError extends CliError("FactoryTag") {}', - "export class PlainThingError extends Error {}", - 'const Base = Data.TaggedError("FreeStandingTag");', - ].join("\n"), - ], - [ - "comments.ts", - [ - "// class Fake extends Error", - '/* e.g. Data.TaggedError("FakeTag") */', - "const x = 1;", - ].join("\n"), - ], - [ - "literals.ts", - [ - 'const a = "class Fake extends Error";', - 'const b = `Data.TaggedError("FakeTag")`;', - "const c = 'class AlsoFake extends Error';", - ].join("\n"), - ], - ] as const, - (files) => - new Map( - ["definitions.ts", "comments.ts", "literals.ts"].map((fileName) => [ - fileName, - extractErrorTagsFromFile(files.get(fileName)!, {}), - ]), - ), -); - -async function scanErrorTags( - root: string, - options: { readonly exportedOnly?: boolean } = {}, -): Promise>> { - const tagsByFile = new Map>(); - const sources: Array = []; - const walk = (dir: string) => { - for (const entry of readdirSync(dir)) { - const path = join(dir, entry); - if (statSync(path).isDirectory()) { - walk(path); - continue; - } - if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue; - sources.push([path, readFileSync(path, "utf8")]); - } - }; - walk(root); - await withParsedSources(sources, (files) => { - for (const [fileName, sourceFile] of files) { - const tags = extractErrorTagsFromFile(sourceFile, options); - if (tags.length > 0) tagsByFile.set(fileName, tags); - } - }); - return tagsByFile; -} - -describe("extractErrorTags", () => { - it("finds tagged, factory-tagged and plain error class definitions", () => { - expect(extractedTestTags.get("definitions.ts")).toEqual([ - "TaggedThingError", - "FactoryTag", - "PlainThingError", - "FreeStandingTag", - ]); - }); - - it("ignores definitions that only appear in comments", () => { - expect(extractedTestTags.get("comments.ts")).toEqual([]); - }); - - it("ignores definitions that only appear inside string and template literals", () => { - expect(extractedTestTags.get("literals.ts")).toEqual([]); - }); -}); - -const kindValues = new Set(Object.values(CliErrorKind)); -const categoryValues = new Set(Object.values(CliErrorCategory)); -const suggestionValues = new Set(Object.values(CliSuggestionType)); - -interface DeclaredErrorClass { - readonly constructor: object; - readonly exportName: string; - readonly isTagged: boolean; - readonly tag: string; - readonly prototype: object; -} - -function collectErrorClasses(module: Record): Array { - const classes: Array = []; - for (const [exportName, value] of Object.entries(module)) { - if (typeof value !== "function") continue; - const prototype: unknown = value.prototype; - if (typeof prototype !== "object" || prototype === null) continue; - if (!(prototype instanceof Error)) continue; - // effect V4 assigns `_tag` per instance, so probe with an empty props bag. - // Plain `extends Error` classes have no `_tag`; identify them by class name. - let tag: unknown; - try { - tag = Reflect.get(Reflect.construct(value, [{}]), "_tag"); - } catch { - tag = undefined; - } - classes.push({ - constructor: value, - exportName, - isTagged: typeof tag === "string", - tag: typeof tag === "string" ? tag : exportName, - prototype, - }); - } - return classes; -} - -const srcRoot = resolve(import.meta.dirname, "../.."); -const repoRoot = resolve(import.meta.dirname, "../../../../.."); - -const moduleLoaders = new Map( - Object.entries(import.meta.glob(["../../**/*.ts", "!**/*.test.ts"])).map(([key, loader]) => [ - resolve(import.meta.dirname, key), - loader, - ]), -); - -const tagsByFile = await scanErrorTags(srcRoot); - -describe("apps/cli error classes declare their actionability", () => { - it("finds the error definition surface", () => { - expect(tagsByFile.size).toBeGreaterThan(50); - }); - - for (const [file, tags] of tagsByFile) { - const relativePath = file.slice(srcRoot.length + 1); - // Importing a command module can pull in a large transitive graph on first - // load; give these dynamic-import tests more headroom than the default 5s. - it(relativePath, { timeout: 30_000 }, async () => { - const loader = moduleLoaders.get(file); - expect(loader, `no module loader for ${relativePath}`).toBeDefined(); - const module = await loader?.(); - expect(typeof module).toBe("object"); - const classes = collectErrorClasses(Object(module)); - - const exportedTags = new Set(classes.map((cls) => cls.tag)); - for (const tag of tags) { - expect( - exportedTags.has(tag), - `error "${tag}" is defined in ${relativePath} but not exported — export it so its actionability declaration is verifiable`, - ).toBe(true); - } - - for (const { constructor, exportName, isTagged, tag, prototype } of classes) { - const descriptor = Object.getOwnPropertyDescriptor(prototype, ErrorActionabilityId); - expect( - typeof descriptor?.get, - `${exportName} ("${tag}") does not declare an own [ErrorActionabilityId] getter — add one returning its CliErrorActionabilityDeclaration`, - ).toBe("function"); - - // Evaluate the getter against a field-less probe: instance-dependent - // declarations must degrade to a valid declaration when fields are - // absent, and static ones are checked directly. - const probe: object = Object.create(prototype); - const declaration: unknown = Reflect.get(probe, ErrorActionabilityId); - expect( - typeof declaration === "object" && declaration !== null, - `${exportName} ("${tag}") declaration is not an object`, - ).toBe(true); - const record: Record = Object(declaration); - expect(kindValues.has(String(record["error_kind"]))).toBe(true); - expect(categoryValues.has(String(record["error_category"]))).toBe(true); - expect(typeof record["has_suggestion"]).toBe("boolean"); - expect(suggestionValues.has(String(record["suggestion_type"]))).toBe(true); - - if (!isTagged) { - const fingerprintDescriptor = Object.getOwnPropertyDescriptor( - constructor, - ErrorActionabilityFingerprintId, - ); - expect( - fingerprintDescriptor !== undefined && - "value" in fingerprintDescriptor && - fingerprintDescriptor.value === exportName, - `${exportName} is an untagged Error and must declare its stable source identifier as an own static [ErrorActionabilityFingerprintId] value`, - ).toBe(true); - } - } - }); - } -}); - -describe("workspace package error tags have external adapters", () => { - const packageRoots = ["packages/api/src", "packages/stack/src", "packages/config/src"]; - - for (const packageRoot of packageRoots) { - it(packageRoot, { timeout: 30_000 }, async () => { - const tagsByFile = await scanErrorTags(resolve(repoRoot, packageRoot), { - exportedOnly: true, - }); - expect(tagsByFile.size).toBeGreaterThan(0); - for (const [file, tags] of tagsByFile) { - for (const tag of tags) { - expect( - isClassifiedExternalErrorTag(tag), - `"${tag}" (${file.slice(repoRoot.length + 1)}) has no external adapter in error-actionability.ts`, - ).toBe(true); - } - } - }); - } -}); - -// Managed failures are tagged errors that also declare a stable `code`, and the -// CLI's dispatch table is generated from the package's tag/code map. The -// generic scan above already requires an adapter for each tag; this guard is -// what keeps the two halves of the contract joined — the (class, tag, code) -// triples in the model must agree with the exported map, the code list, and the -// code-keyed classification table. -interface ManagedErrorClass { - readonly className: string; - readonly tag: string; - readonly code: string; -} - -// Collects the (class, tag, code) triples of every `class X extends -// Data.TaggedError("Tag")` that also declares a string-literal `code` member. -async function scanManagedErrorClasses(path: string): Promise> { - const classes: Array = []; - const visit = (node: ts.Node): void => { - if (ts.isClassDeclaration(node) && node.name !== undefined) { - const heritage = extendsExpression(node); - const tag = - heritage !== undefined && - ts.isCallExpression(heritage) && - calleeName(heritage.expression) === "TaggedError" - ? stringLiteralText(heritage.arguments[0]) - : undefined; - const code = stringLiteralText( - node.members - .filter(ts.isPropertyDeclaration) - .find((member) => ts.isIdentifier(member.name) && member.name.text === "code") - ?.initializer, - ); - if (tag !== undefined && code !== undefined) { - classes.push({ className: node.name.text, tag, code }); - } - } - node.forEachChild(visit); - }; - return withParsedSource(path, readFileSync(path, "utf8"), (sourceFile) => { - sourceFile.forEachChild(visit); - return classes; - }); -} - -describe("managed registry error codes are classified", () => { - it("packages/stack/src/managed/model.ts", async () => { - const modelPath = resolve(repoRoot, "packages/stack/src/managed/model.ts"); - const scanned = await scanManagedErrorClasses(modelPath); - // One class per declared code: a class written in a shape this scan cannot - // see would otherwise pass vacuously instead of failing loudly. - expect(scanned.length).toBe(MANAGED_ERROR_CODES.length); - const declaredCodes = new Set(MANAGED_ERROR_CODES); - const scannedCodes = new Set(); - for (const { className, tag, code } of scanned) { - scannedCodes.add(code); - expect(tag, `${className} is tagged "${tag}" rather than its own export name`).toBe( - className, - ); - expect( - declaredCodes.has(code), - `${className}'s code "${code}" is missing from MANAGED_ERROR_CODES`, - ).toBe(true); - expect( - Reflect.get(MANAGED_ERROR_TAG_BY_CODE, code), - `MANAGED_ERROR_TAG_BY_CODE does not map "${code}" to ${className}`, - ).toBe(tag); - expect( - isClassifiedManagedErrorCode(code), - `${className} ("${code}") has no entry in managedActionabilityByCode in error-actionability.ts`, - ).toBe(true); - expect( - isClassifiedExternalErrorTag(tag), - `${className} ("${tag}") has no generated entry in externalActionabilityByTag in error-actionability.ts`, - ).toBe(true); - } - // Every declared code is backed by a class, not just the other way round. - expect([...scannedCodes].sort()).toEqual([...declaredCodes].sort()); - }); -}); - -describe("Effect CLI parser errors have exhaustive handling", () => { - it("covers every exported parser error class", () => { - const tags = new Set(); - const probe = { - option: "--probe", - command: [], - suggestions: [], - parentCommand: "parent", - childCommand: "child", - argument: "argument", - arguments: [], - value: "value", - expected: "expected", - kind: "flag", - subcommand: "subcommand", - parent: [], - cause: new Error("probe"), - commandPath: [], - errors: [], - }; - - for (const value of Object.values(CliError)) { - if (typeof value !== "function") continue; - const prototype: unknown = value.prototype; - if (typeof prototype !== "object" || prototype === null) continue; - if (!(prototype instanceof Error)) continue; - - try { - const tag = Reflect.get(Reflect.construct(value, [probe]), "_tag"); - if (typeof tag === "string") tags.add(tag); - } catch { - // Non-error exports and constructors that require runtime setup are - // outside the parser error union checked at compile time by the map. - } - } - - expect(tags.size).toBeGreaterThan(5); - for (const tag of tags) { - expect( - tag === "ShowHelp" || tag === "UserError" || isClassifiedExternalErrorTag(tag), - `Effect CLI parser error "${tag}" has no actionability handling`, - ).toBe(true); +describe("error actionability coverage", () => { + it("classifies Effect CLI parser errors", () => { + for (const tag of ["MissingOption", "MissingArgument", "InvalidValue", "UnknownSubcommand"]) { + expect(isClassifiedExternalErrorTag(tag)).toBe(true); } }); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 8b222977b4..d03b987fa1 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -1,9 +1,3 @@ -import { - MANAGED_ERROR_CODES, - MANAGED_ERROR_TAG_BY_CODE, - type ManagedErrorCode, - type UnsupportedGitWorkspaceCause, -} from "@supabase/stack/managed-model"; import { Cause, Option } from "effect"; import type { CliError as EffectCliError } from "effect/unstable/cli"; @@ -794,123 +788,8 @@ const effectCliActionabilityByTag = { UnrecognizedOption: () => actionability.invalidInput, } satisfies Record; -/** - * The materially different reasons {@link UnsupportedGitWorkspaceCause} names, - * projected onto a distinct closed fingerprint suffix each — so a refusal - * because the CLI ran inside git metadata, a malformed-metadata refusal, and a - * reftable refusal never group together as repeats of one another. - */ -const UNSUPPORTED_GIT_WORKSPACE_FINGERPRINT_SUFFIX_BY_CAUSE: Record< - UnsupportedGitWorkspaceCause, - CliErrorFingerprintSuffix -> = { - "inside-git-directory": "managed_git_workspace_inside_git_directory", - "malformed-metadata": "managed_git_workspace_malformed_metadata", - "metadata-inaccessible": "managed_git_workspace_metadata_inaccessible", - reftable: "managed_git_workspace_reftable", -}; - -function isUnsupportedGitWorkspaceCause(value: string): value is UnsupportedGitWorkspaceCause { - return ( - value === "inside-git-directory" || - value === "malformed-metadata" || - value === "metadata-inaccessible" || - value === "reftable" - ); -} - -/** Branches on the error's own typed `workspaceCause` field, never on reason text. */ -function unsupportedGitWorkspaceFingerprintSuffix(error: ErrorRecord): CliErrorFingerprintSuffix { - const cause = readString(error, "workspaceCause"); - return cause !== undefined && isUnsupportedGitWorkspaceCause(cause) - ? UNSUPPORTED_GIT_WORKSPACE_FINGERPRINT_SUFFIX_BY_CAUSE[cause] - : "managed_git_workspace_malformed_metadata"; -} - -/** - * `@supabase/stack` managed registry failures, keyed by the stable `code` - * literal each class declares. `code` is the package's wire-level contract: it - * survives the identifier minification of release builds, and Node/Bun callers - * outside an Effect runtime branch on it. Dispatch, however, goes through - * `_tag` like every other external error — {@link managedActionabilityByTag} - * projects this table onto the tags via the package's own tag/code map. - * - * Keyed by the package's exported {@link ManagedErrorCode} union, so the table - * is exhaustive by construction: a new managed failure cannot be added in - * `@supabase/stack` without being classified here. Values are adapters (not - * bare declarations) so an entry — like `UNSUPPORTED_GIT_WORKSPACE` below — can - * branch on the raised instance's own fields. - */ -const managedActionabilityByCode: Record = { - INVALID_MANAGED_IDENTITY: () => ({ - ...actionability.invalidInput, - fingerprint_suffix: "managed_identity", - }), - INVALID_MANAGED_STACK_NAME: () => ({ - ...actionability.invalidInput, - fingerprint_suffix: "managed_stack_name", - }), - MANAGED_STACK_NOT_FOUND: () => ({ - ...actionability.invalidInput, - fingerprint_suffix: "not_found", - }), - // The directory the command ran in holds git metadata rather than a working - // tree — a bare repository or a `.git` — so the remediation is to run it from a - // checkout instead. The suffix is split by the error's own `workspaceCause` field: see - // unsupportedGitWorkspaceFingerprintSuffix. - UNSUPPORTED_GIT_WORKSPACE: (error) => ({ - ...actionability.invalidInput, - fingerprint_suffix: unsupportedGitWorkspaceFingerprintSuffix(error), - }), - MANAGED_STACK_NOT_STOPPED: () => ({ - ...actionability.stopStack, - fingerprint_suffix: "managed_stack_not_stopped", - }), - MANAGED_EXACT_PORT_OCCUPIED: () => ({ - ...actionability.invalidConfig, - fingerprint_suffix: "port_conflict", - }), - MANAGED_PORT_ALLOCATION_FAILED: () => ({ - ...actionability.startStack, - fingerprint_suffix: "port_allocation", - }), - // The registry derives every stack root itself, so a path that fails the - // containment check means the CLI passed a rejected argument. - UNSAFE_MANAGED_STACK_PATH: () => ({ - ...actionability.impossibleState, - fingerprint_suffix: "bad_argument", - }), -}; - -/** - * The managed table above, re-keyed by the `_tag` of the class that declares - * each code. Generated from `@supabase/stack`'s own tag/code map so every - * managed tag is classified without restating a single verdict: - * {@link managedActionabilityByCode} stays the one place a managed failure is - * classified, and a tag/code pair the package renames cannot silently fall - * through to `unknown`. - */ -const managedActionabilityByTag: Record = Object.fromEntries( - MANAGED_ERROR_CODES.map((code) => [ - MANAGED_ERROR_TAG_BY_CODE[code], - managedActionabilityByCode[code], - ]), -); - -/** - * Whether a `@supabase/stack` managed error code has a classification in - * {@link managedActionabilityByCode}. Used by the coverage test to keep the - * table exhaustive against the managed classes; the tags themselves are checked - * through {@link isClassifiedExternalErrorTag}, which the generated entries - * satisfy. - */ -export function isClassifiedManagedErrorCode(code: string): boolean { - return Object.hasOwn(managedActionabilityByCode, code); -} - const externalActionabilityByTag: Record = { ...effectCliActionabilityByTag, - ...managedActionabilityByTag, // effect PlatformError — OS/filesystem operations. `reason` is // `BadArgument | SystemError`; BadArgument means the CLI itself passed a @@ -1009,6 +888,35 @@ const externalActionabilityByTag: Record = { readString(error, "code") === "PORT_ALLOCATION" ? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" } : actionability.unknown, + InvalidStackIdentityError: () => actionability.invalidInput, + InvalidProjectRootError: () => actionability.invalidInput, + InvalidStackConfigError: () => actionability.invalidConfig, + StackVersionUnsupportedError: () => actionability.invalidConfig, + StackNotFoundError: () => actionability.invalidInput, + StackOwnershipConflictError: () => actionability.invalidInput, + StackRuntimeMismatchError: () => actionability.invalidConfig, + StackDefinitionRequiredError: () => actionability.startStack, + StackMustBeStoppedError: () => actionability.stopStack, + StackLifecycleConflictError: () => actionability.startStack, + StackStateInvalidError: () => actionability.invalidConfig, + StackStateFormatUnsupportedError: () => actionability.invalidConfig, + StackStateGenerationMismatchError: () => actionability.startStack, + StackUpgradeRequiredError: () => actionability.startStack, + StackUpgradeReplacementError: () => actionability.startStack, + StackSecretMismatchError: () => actionability.invalidConfig, + InvalidJwtSigningMaterialError: () => actionability.invalidConfig, + PortUnavailableError: () => actionability.invalidConfig, + GatewayAuthenticationError: () => actionability.startStack, + GatewayStaleGenerationError: () => actionability.startStack, + GatewayActivationError: () => actionability.startStack, + StackPreparationError: () => actionability.startStack, + ArtifactIntegrityError: () => actionability.externalNetwork, + ContainerPullError: () => actionability.externalNetwork, + StackReconciliationError: () => actionability.startStack, + ServiceStartError: () => actionability.startStack, + ServiceReadinessError: () => actionability.startStack, + ContainerEngineError: () => actionability.dockerNotRunning, + StackDestructionError: () => actionability.stopStack, BinaryNotFoundError: () => actionability.invalidConfig, BinaryManifestError: () => actionability.externalNetwork, BinaryRuntimeError: () => actionability.externalNetwork, diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 6936d7e9f1..1189cbae1a 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -1,1182 +1,39 @@ import { Cause, Data } from "effect"; -import { CliError } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; -import { markSupabaseApiInputErrorAsUserInput, SupabaseApiInputError } from "@supabase/api/effect"; -import { DockerPullError, StackError } from "@supabase/stack/effect"; -import { LegacyBootstrapHealthError } from "../../legacy/commands/bootstrap/bootstrap.errors.ts"; +import { StackNotFoundError, StackPreparationError } from "@supabase/stack/effect"; import { actionability, type CliErrorActionabilityDeclaration, classifyCliCauseActionability, classifyCliErrorActionability, - CliErrorActionabilityMetricDefinitions, - ErrorActionabilityFingerprintId, ErrorActionabilityId, - statusCodeActionability, } from "./error-actionability.ts"; -class DeclaredError extends Data.TaggedError("DeclaredError")<{ - readonly message: string; -}> { +class DeclaredError extends Data.TaggedError("DeclaredError")<{ readonly message: string }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.authLogin; } } -class DeclaredStatusError extends Data.TaggedError("DeclaredStatusError")<{ - readonly status: number; - readonly upgradeSuggested?: boolean; - readonly notFoundIsInvalidInput?: boolean; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return statusCodeActionability(this.status, { - upgradeSuggested: this.upgradeSuggested, - notFoundIsInvalidInput: this.notFoundIsInvalidInput, - }); - } -} - -class UndeclaredError extends Data.TaggedError("UndeclaredError")<{ - readonly message: string; -}> {} - -class DeclaredNoSuggestionError extends Data.TaggedError("DeclaredNoSuggestionError")<{ - readonly message: string; - readonly suggestion?: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.dbFinding; - } -} - -class PlainDeclaredError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "PlainDeclaredError"; - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } -} - -class DeclarationCarrierError extends Error { - readonly _tag: string; - readonly declaration: Record; - - constructor(tag: string, declaration: Record) { - super(tag); - this._tag = tag; - this.declaration = declaration; - } - - get [ErrorActionabilityId](): Record { - return this.declaration; - } -} - -function declarationCarrier(tag: string, declaration: Record) { - return new DeclarationCarrierError(tag, declaration); -} - -describe("classifyCliErrorActionability", () => { - it("uses the declaration co-located on the error class", () => { - expect( - classifyCliErrorActionability(new DeclaredError({ message: "raw secret text" })), - ).toEqual({ - error_kind: "user_actionable", - error_category: "auth", - error_fingerprint: "tag:DeclaredError", - has_suggestion: true, - suggestion_type: "login", - suggested_command: "supabase login", - }); - }); - - it("uses a declared Error subclass constructor for an untagged fingerprint", () => { - const result = classifyCliErrorActionability(new PlainDeclaredError("private path")); - expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("error:PlainDeclaredError"); - }); - - it("prefers the declared static identifier over a native ancestor's prototype name", () => { - // TypeError.prototype carries an own `name` data property ("TypeError"); - // without the static-identifier precedence, every declared class extending - // a native Error subtype would collide on the native name. - class NativeSubtypeDeclaredError extends TypeError { - static readonly [ErrorActionabilityFingerprintId] = "NativeSubtypeDeclaredError"; - - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidConfig; - } - } - const result = classifyCliErrorActionability(new NativeSubtypeDeclaredError("boom")); - expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("error:NativeSubtypeDeclaredError"); - }); - - it("classifies Effect's runtime unknown-subcommand tag without fingerprinting user input", () => { - const secret = "customerProjectRef123"; - const result = classifyCliErrorActionability( - new CliError.UnknownSubcommand({ - subcommand: secret, - suggestions: [], - }), +describe("error actionability", () => { + it("uses declarations on CLI errors", () => { + expect(classifyCliErrorActionability(new DeclaredError({ message: "x" })).error_kind).toBe( + "user_actionable", ); - - expect(result).toEqual({ - error_kind: "user_actionable", - error_category: "invalid_input", - error_fingerprint: "tag:UnknownSubcommand", - has_suggestion: false, - suggestion_type: "none", - }); - expect(JSON.stringify(result)).not.toContain(secret); - }); - - it("classifies Effect's unexpected positional arguments without fingerprinting their values", () => { - const secret = "/Users/alice/private.sql"; - const result = classifyCliErrorActionability( - new CliError.UnexpectedArgument({ arguments: [secret] }), - ); - - expect(result).toEqual({ - error_kind: "user_actionable", - error_category: "invalid_input", - error_fingerprint: "tag:UnexpectedArgument", - has_suggestion: false, - suggestion_type: "none", - }); - expect(JSON.stringify(result)).not.toContain(secret); - }); - - it("does not claim a generic remediation for local permission failures", () => { - expect(actionability.permission).toEqual({ - error_kind: "user_actionable", - error_category: "permission", - has_suggestion: false, - suggestion_type: "none", - }); - }); - - it("lets instance-dependent declarations branch on typed fields", () => { - const auth = classifyCliErrorActionability(new DeclaredStatusError({ status: 401 })); - expect(auth.error_category).toBe("auth"); - expect(auth.error_fingerprint).toBe("tag:DeclaredStatusError:auth"); - - const gated = classifyCliErrorActionability( - new DeclaredStatusError({ status: 404, upgradeSuggested: true }), - ); - expect(gated.error_category).toBe("plan_limit"); - expect(gated.suggestion_type).toBe("upgrade_plan"); - expect(gated.error_fingerprint).toBe("tag:DeclaredStatusError:plan_limit"); - - const notFound = classifyCliErrorActionability(new DeclaredStatusError({ status: 404 })); - expect(notFound.error_kind).toBe("external_service"); - expect(notFound.error_category).toBe("api_status"); - expect(notFound.error_fingerprint).toBe("tag:DeclaredStatusError:api_status"); - - const namedResourceNotFound = classifyCliErrorActionability( - new DeclaredStatusError({ status: 404, notFoundIsInvalidInput: true }), - ); - expect(namedResourceNotFound.error_kind).toBe("user_actionable"); - expect(namedResourceNotFound.error_category).toBe("invalid_input"); - expect(namedResourceNotFound.error_fingerprint).toBe("tag:DeclaredStatusError:not_found"); - - const status = classifyCliErrorActionability(new DeclaredStatusError({ status: 500 })); - expect(status.error_kind).toBe("external_service"); - expect(status.error_category).toBe("api_status"); - expect(status.error_fingerprint).toBe("tag:DeclaredStatusError:api_status"); }); - it("classifies undeclared tagged errors as unknown with a sanitized fingerprint", () => { - const result = classifyCliErrorActionability(new UndeclaredError({ message: "boom" })); - expect(result.error_kind).toBe("unknown"); - expect(result.error_fingerprint).toBe("tag:unknown"); - }); - - it.each([ - ["user_actionable", "auth"], - ["user_actionable", "missing_project_ref"], - ["user_actionable", "project_not_linked"], - ["user_actionable", "docker_not_running"], - ["user_actionable", "invalid_config"], - ["user_actionable", "db_connection"], - ["user_actionable", "migration_drift"], - ["user_actionable", "permission"], - ["user_actionable", "plan_limit"], - ["user_actionable", "project_paused"], - ["user_actionable", "invalid_input"], - ["internal_bug", "panic"], - ["internal_bug", "impossible_state"], - ["external_service", "network"], - ["external_service", "api_status"], - ["user_cancelled", "cancelled"], - ["unknown", "unknown"], - ])("accepts the valid %s and %s taxonomy pair", (errorKind, errorCategory) => { - const result = classifyCliErrorActionability( - declarationCarrier("MatrixError", { - error_kind: errorKind, - error_category: errorCategory, - has_suggestion: false, - suggestion_type: "none", - }), - ); - expect(result.error_kind).toBe(errorKind); - expect(result.error_category).toBe(errorCategory); - }); - - it.each([ - ["user_actionable", "panic"], - ["internal_bug", "invalid_input"], - ["external_service", "auth"], - ["user_cancelled", "unknown"], - ["unknown", "cancelled"], - ])("rejects the invalid %s and %s taxonomy pair", (errorKind, errorCategory) => { - const result = classifyCliErrorActionability( - declarationCarrier("InvalidMatrixError", { - error_kind: errorKind, - error_category: errorCategory, - has_suggestion: false, - suggestion_type: "none", - }), - ); - expect(result.error_kind).toBe("unknown"); - expect(result.error_fingerprint).toBe("tag:unknown"); - }); - - it("accepts a closed command remediation", () => { - const result = classifyCliErrorActionability( - declarationCarrier("RunCommandError", { - error_kind: "user_actionable", - error_category: "invalid_config", - has_suggestion: true, - suggestion_type: "run_command", - suggested_command: "supabase start", - }), - ); - expect(result.suggestion_type).toBe("run_command"); - expect(result.suggested_command).toBe("supabase start"); - }); - - it.each([ - [false, "login", undefined], - [true, "none", undefined], - [false, "none", "supabase login"], - ])( - "rejects inconsistent suggestion metadata (%s, %s, %s)", - (hasSuggestion, suggestionType, suggestedCommand) => { - const result = classifyCliErrorActionability( - declarationCarrier("InvalidSuggestionError", { - error_kind: "user_actionable", - error_category: "auth", - has_suggestion: hasSuggestion, - suggestion_type: suggestionType, - suggested_command: suggestedCommand, - }), - ); - expect(result.error_kind).toBe("unknown"); - expect(result).not.toHaveProperty("suggested_command"); - }, - ); - - it("ignores a declaration whose symbol getter throws", () => { - const secret = "token-from-throwing-getter"; - class ThrowingDeclarationError extends Error { - readonly _tag = "ThrowingDeclarationError"; - - get [ErrorActionabilityId]() { - throw new Error(secret); - } - } - const error = new ThrowingDeclarationError(); - const result = classifyCliErrorActionability(error); - expect(result.error_kind).toBe("unknown"); - expect(result.error_fingerprint).toBe("tag:unknown"); - expect(JSON.stringify(result)).not.toContain(secret); - }); - - it("falls back safely when even structural property access throws", () => { - const secret = "token-from-hostile-proxy"; - const hostile = new Proxy( - {}, - { - get() { - throw new Error(secret); - }, - }, - ); - const result = classifyCliErrorActionability(hostile); - expect(result).toEqual({ - error_kind: "unknown", - error_category: "unknown", - has_suggestion: false, - suggestion_type: "none", - error_fingerprint: "error:ClassificationFailure", - }); - expect(JSON.stringify(result)).not.toContain(secret); - }); - - it("rejects arbitrary declaration commands and fingerprint suffixes", () => { - const commandSecret = "supabase login --token user-secret-token"; - const unsafeCommand = classifyCliErrorActionability( - declarationCarrier("UnsafeCommandError", { - error_kind: "user_actionable", - error_category: "auth", - has_suggestion: true, - suggestion_type: "login", - suggested_command: commandSecret, - }), - ); - expect(unsafeCommand.error_kind).toBe("unknown"); - expect(JSON.stringify(unsafeCommand)).not.toContain(commandSecret); - - const suffixSecret = "customer_project_reference"; - const unsafeSuffix = classifyCliErrorActionability( - declarationCarrier("UnsafeSuffixError", { - error_kind: "user_actionable", - error_category: "invalid_input", - has_suggestion: false, - suggestion_type: "none", - fingerprint_suffix: suffixSecret, - }), - ); - expect(unsafeSuffix.error_fingerprint).toBe("tag:unknown"); - expect(JSON.stringify(unsafeSuffix)).not.toContain(suffixSecret); - }); - - it("rejects a closed command paired with the wrong suggestion type", () => { - const result = classifyCliErrorActionability( - declarationCarrier("MismatchedSuggestionError", { - error_kind: "user_actionable", - error_category: "auth", - has_suggestion: true, - suggestion_type: "login", - suggested_command: "supabase stop", - }), - ); - expect(result.error_kind).toBe("unknown"); - expect(result).not.toHaveProperty("suggested_command"); - }); - - it("snapshots validated declaration fields before emitting them", () => { - const secret = "supabase login --token later-secret"; - let reads = 0; - const declaration = { - error_kind: "user_actionable", - error_category: "auth", - has_suggestion: true, - suggestion_type: "login", - get suggested_command() { - reads += 1; - return reads === 1 ? "supabase login" : secret; - }, - }; - const result = classifyCliErrorActionability( - declarationCarrier("ChangingDeclarationError", declaration), - ); - expect(result.suggested_command).toBe("supabase login"); - expect(reads).toBe(1); - expect(JSON.stringify(result)).not.toContain(secret); - }); - - it("does not include sensitive error fields in classification output", () => { - const secrets = [ - "/Users/alice/private/project/config.toml", - "select * from private_table", - "abcdefghijklmnopqrst", - "db.customer.internal", - "user-specific-project-ref", - ]; - const result = classifyCliErrorActionability({ - _tag: "UndeclaredSensitiveError", - message: secrets[0], - sql: secrets[1], - token: secrets[2], - hostname: secrets[3], - projectRef: secrets[4], - }); - const encoded = JSON.stringify(result); - for (const secret of secrets) expect(encoded).not.toContain(secret); - }); - - it("does not fingerprint an unknown tag that looks like an identifier", () => { - const secret = "CustomerSecret123"; - const result = classifyCliErrorActionability({ _tag: secret }); - expect(result.error_fingerprint).toBe("tag:unknown"); - expect(JSON.stringify(result)).not.toContain(secret); - }); - - it("does not trust an arbitrary declaration carrier as a fingerprint authority", () => { - const secret = "CustomerProjectRef123"; - const plainResult = classifyCliErrorActionability({ - _tag: secret, - [ErrorActionabilityId]: actionability.invalidInput, - }); - expect(plainResult.error_fingerprint).toBe("tag:unknown"); - - const errorResult = classifyCliErrorActionability( - declarationCarrier(secret, actionability.invalidInput), - ); - expect(errorResult.error_fingerprint).toBe("error:DeclaredError"); - expect(JSON.stringify([plainResult, errorResult])).not.toContain(secret); - }); - - it("does not trust mutable instance tags or names over the tagged prototype", () => { - const secret = "CustomerProjectRef123"; - const error = new DeclaredError({ message: "failed" }); - Reflect.set(error, "_tag", secret); - Reflect.set(error, "name", secret); - - const result = classifyCliErrorActionability(error); - expect(result.error_fingerprint).toBe("error:DeclaredError"); - expect(JSON.stringify(result)).not.toContain(secret); - }); - - it("does not accept declarations through the global symbol registry", () => { - const secret = "CustomerProjectRef123"; - const globalDeclarationKey = Symbol.for("@supabase/cli/telemetry/ErrorActionability"); - class HostileDeclaredError extends Error { - readonly _tag = secret; - - get [globalDeclarationKey](): CliErrorActionabilityDeclaration { - return actionability.invalidInput; - } - } - Object.defineProperty(HostileDeclaredError, "name", { value: secret }); - - const result = classifyCliErrorActionability(new HostileDeclaredError(secret)); - expect(result.error_fingerprint).toBe("tag:unknown"); - expect(JSON.stringify(result)).not.toContain(secret); - }); - - it("classifies external stack build errors by structured reason", () => { - const invalidConfig = classifyCliErrorActionability({ - _tag: "StackBuildError", - detail: "imgproxy requires storage to be enabled", - reason: "invalid_config", - }); - expect(invalidConfig.error_category).toBe("invalid_config"); - expect(invalidConfig.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); - - const assetPreparation = classifyCliErrorActionability({ - _tag: "StackBuildError", - detail: "Failed to prepare stack assets", - reason: "asset_preparation", - }); - expect(assetPreparation.error_kind).toBe("external_service"); - expect(assetPreparation.error_fingerprint).toBe("tag:StackBuildError:asset_preparation"); - - const dockerNotRunning = classifyCliErrorActionability({ - _tag: "StackBuildError", - detail: "Failed to prepare stack assets", - reason: "docker_not_running", - }); - expect(dockerNotRunning.error_category).toBe("docker_not_running"); - expect(dockerNotRunning.error_fingerprint).toBe("tag:StackBuildError:docker_not_running"); - - const internal = classifyCliErrorActionability({ _tag: "StackBuildError", detail: "bug" }); - expect(internal.error_kind).toBe("internal_bug"); - expect(internal.error_category).toBe("impossible_state"); - expect(internal.error_fingerprint).toBe("tag:StackBuildError:internal_build"); - }); - - it("classifies stack readiness failures without reading details", () => { - const readiness = classifyCliErrorActionability({ - _tag: "StackReadinessError", - target: "auth", - timeoutMs: 10, - detail: "private runtime detail", - }); - expect(readiness.error_category).toBe("invalid_config"); - expect(readiness.suggestion_type).toBe("run_command"); - expect(readiness.suggested_command).toBe("supabase start"); - }); - - it("classifies control stop conflicts as a sanitized internal invariant failure", () => { - const result = classifyCliErrorActionability({ - _tag: "ControlStopConflictError", - endpoint: "http://127.0.0.1:54321", - }); - - expect(result).toEqual({ - error_kind: "internal_bug", - error_category: "impossible_state", - error_fingerprint: "tag:ControlStopConflictError:managed_control_stop_conflict", - has_suggestion: true, - suggestion_type: "rerun_debug", - }); - expect(JSON.stringify(result)).not.toContain("127.0.0.1"); - }); - - it("classifies maintenance contention as a retryable user action", () => { - const result = classifyCliErrorActionability({ - _tag: "ControlMaintenanceBusyError", - endpoint: "http://127.0.0.1:54321", - }); - - expect(result).toEqual({ - error_kind: "user_actionable", - error_category: "invalid_config", - error_fingerprint: "tag:ControlMaintenanceBusyError:managed_control_maintenance_busy", - has_suggestion: true, - suggestion_type: "run_command", - }); - expect(JSON.stringify(result)).not.toContain("127.0.0.1"); - }); - - it("classifies loopback control transport failures as local stack failures", () => { - const result = classifyCliErrorActionability({ - _tag: "ControlTransportError", - endpoint: "http://127.0.0.1:54321", - reason: "unreachable", - }); - - expect(result).toEqual({ - error_kind: "user_actionable", - error_category: "invalid_config", - error_fingerprint: "tag:ControlTransportError:managed_control_transport", - has_suggestion: true, - suggestion_type: "run_command", - suggested_command: "supabase start", - }); - expect(JSON.stringify(result)).not.toContain("127.0.0.1"); - }); - - it("splits docker pull failures from a stopped docker daemon", () => { - const daemonDown = classifyCliErrorActionability({ - _tag: "DockerPullError", - image: "postgres", - daemonDown: true, - }); - expect(daemonDown.error_category).toBe("docker_not_running"); - expect(daemonDown.suggestion_type).toBe("start_docker"); - - const pull = classifyCliErrorActionability({ _tag: "DockerPullError", image: "postgres" }); - expect(pull.error_kind).toBe("external_service"); - expect(pull.error_fingerprint).toBe("tag:DockerPullError:registry_pull"); - }); - - it("classifies http client errors by response presence and status", () => { - const auth = classifyCliErrorActionability({ - _tag: "HttpClientError", - response: { status: 401 }, - }); - expect(auth.error_category).toBe("auth"); - - const notFound = classifyCliErrorActionability({ - _tag: "HttpClientError", - response: { status: 404 }, - }); - expect(notFound.error_kind).toBe("external_service"); - expect(notFound.error_category).toBe("api_status"); - expect(notFound.error_fingerprint).toBe("tag:HttpClientError:api_status"); - - const status = classifyCliErrorActionability({ - _tag: "HttpClientError", - response: { status: 503 }, - }); - expect(status.error_category).toBe("api_status"); - - const transport = classifyCliErrorActionability({ - _tag: "HttpClientError", - reason: { _tag: "TransportError" }, - }); - expect(transport.error_category).toBe("network"); - - for (const reason of ["DecodeError", "EmptyBodyError"]) { - const decode = classifyCliErrorActionability({ - _tag: "HttpClientError", - reason: { _tag: reason }, - response: { status: 200 }, - }); - expect(decode.error_category).toBe("api_status"); - expect(decode.error_fingerprint).toBe("tag:HttpClientError:api_response"); - } - }); - - it("classifies a stopped external stack through its static adapter", () => { - const result = classifyCliErrorActionability({ - _tag: "StackNotRunningError", - statePath: "/private/project/.temp/stack.json", - }); - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("invalid_config"); - expect(result.suggested_command).toBe("supabase start"); - expect(JSON.stringify(result)).not.toContain("/private/project"); - }); - - it("recurses into single-error ShowHelp wrappers", () => { - const single = classifyCliErrorActionability({ - _tag: "ShowHelp", - errors: [new DeclaredError({ message: "inner" })], - }); - expect(single.error_fingerprint).toBe("tag:DeclaredError"); - - const multiple = classifyCliErrorActionability({ - _tag: "ShowHelp", - errors: [{ _tag: "MissingOption" }, { _tag: "MissingOption" }], - }); - expect(multiple.error_category).toBe("invalid_input"); - expect(multiple.error_fingerprint).toBe("tag:ShowHelp"); - }); - - it("classifies StackError port allocation failures", () => { - const error = new StackError({ code: "PORT_ALLOCATION", message: "no free port" }); - const result = classifyCliErrorActionability(error); - expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("tag:StackError:port_allocation"); - - const other = new StackError({ code: "UNKNOWN", message: "other" }); - expect(classifyCliErrorActionability(other).error_kind).toBe("unknown"); - }); - - it("classifies real tagged StackError causes before its wrapper adapter", () => { - const dockerPull = new DockerPullError({ - image: "supabase/postgres", - detail: "docker daemon unavailable", - cause: new Error("connection refused"), - daemonDown: true, - }); - const wrapped = new StackError({ - code: "BUILD_ERROR", - message: "stack preparation failed", - cause: dockerPull, - }); - - const result = classifyCliErrorActionability(wrapped); - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("docker_not_running"); - expect(result.error_fingerprint).toBe("tag:DockerPullError:docker_not_running"); - }); - - it("classifies a native exception preserved by a real tagged StackError", () => { - const wrapped = new StackError({ - code: "UNKNOWN", - message: "stack failed", - cause: new TypeError("native failure"), - }); - - const result = classifyCliErrorActionability(wrapped); - expect(result.error_kind).toBe("internal_bug"); - expect(result.error_category).toBe("panic"); - expect(result.error_fingerprint).toBe("error:TypeError"); - }); - - // Managed errors are tagged errors that also declare a stable `code`: the - // tag routes them to an adapter generated from the package's tag/code map, - // and the code keys the verdict that adapter resolves. These are the - // actionable failures reachable from the lean environment/store/control/ - // manager path. - it.each([ - [ - "InvalidManagedIdentityError", - "INVALID_MANAGED_IDENTITY", - "managed_identity", - "invalid_input", - ], - [ - "UnsupportedGitWorkspaceError", - "UNSUPPORTED_GIT_WORKSPACE", - "managed_git_workspace_malformed_metadata", - "invalid_input", - ], - [ - "ManagedExactPortOccupiedError", - "MANAGED_EXACT_PORT_OCCUPIED", - "port_conflict", - "invalid_config", - ], - [ - "ManagedPortAllocationError", - "MANAGED_PORT_ALLOCATION_FAILED", - "port_allocation", - "invalid_config", - ], - ["ManagedStackNotFoundError", "MANAGED_STACK_NOT_FOUND", "not_found", "invalid_input"], - [ - "ManagedStackNotStoppedError", - "MANAGED_STACK_NOT_STOPPED", - "managed_stack_not_stopped", - "invalid_config", - ], - [ - "UnsafeManagedStackPathError", - "UNSAFE_MANAGED_STACK_PATH", - "bad_argument", - "impossible_state", - ], - ])("classifies %s through its generated tag adapter", (tag, code, suffix, category) => { - const error = new Error("managed registry failure"); - error.name = tag; - Object.defineProperty(error, "_tag", { value: tag }); - Object.defineProperty(error, "code", { value: code }); - const result = classifyCliErrorActionability(error); - expect(result.error_category).toBe(category); - expect(result.error_fingerprint).toBe(`tag:${tag}:${suffix}`); - }); - - // UNSUPPORTED_GIT_WORKSPACE is raised for at least three materially - // different causes (CLI-2107): running inside git metadata, malformed git - // metadata, and a reftable repository. Collapsing them into one fingerprint - // would group unrelated defects as repeats, so the adapter branches on the - // error's own typed `workspaceCause` field. - it("splits managed git workspace refusals by their structured cause", () => { - const insideGitDirectory = classifyCliErrorActionability({ - _tag: "UnsupportedGitWorkspaceError", - path: "/private/project/.git", - reason: "Refusing to inspect a git directory as a workspace", - workspaceCause: "inside-git-directory", - }); - expect(insideGitDirectory.error_category).toBe("invalid_input"); - expect(insideGitDirectory.error_fingerprint).toBe( - "tag:UnsupportedGitWorkspaceError:managed_git_workspace_inside_git_directory", - ); - - const malformedMetadata = classifyCliErrorActionability({ - _tag: "UnsupportedGitWorkspaceError", - path: "/private/project/.git", - reason: "The checkout has no readable HEAD", - workspaceCause: "malformed-metadata", - }); - expect(malformedMetadata.error_fingerprint).toBe( - "tag:UnsupportedGitWorkspaceError:managed_git_workspace_malformed_metadata", - ); - - const reftable = classifyCliErrorActionability({ - _tag: "UnsupportedGitWorkspaceError", - path: "/private/project/.git", - reason: - "Refusing a repository whose refs are stored in a reftable, which is not supported yet", - workspaceCause: "reftable", - }); - expect(reftable.error_fingerprint).toBe( - "tag:UnsupportedGitWorkspaceError:managed_git_workspace_reftable", - ); - - expect(JSON.stringify(insideGitDirectory)).not.toContain("/private/project"); - }); - - it("fingerprints workspaceCause without populating native Error.cause", () => { - const error = new (class extends Error { - readonly _tag = "UnsupportedGitWorkspaceError"; - readonly code = "UNSUPPORTED_GIT_WORKSPACE"; - readonly path = "/private/project/.git"; - readonly reason = "metadata is inaccessible"; - readonly workspaceCause = "metadata-inaccessible" as const; - })(); - expect(error.cause).toBeUndefined(); - expect(classifyCliErrorActionability(error).error_fingerprint).toBe( - "tag:UnsupportedGitWorkspaceError:managed_git_workspace_metadata_inaccessible", - ); - }); - - it("leaves an unregistered managed-shaped failure unclassified", () => { - const unrecognized = new Error("managed failure"); - unrecognized.name = "ManagedFutureError"; - Object.defineProperty(unrecognized, "_tag", { value: "ManagedFutureError" }); - Object.defineProperty(unrecognized, "code", { value: "MANAGED_FUTURE_FAILURE" }); - expect(classifyCliErrorActionability(unrecognized).error_kind).toBe("unknown"); - }); - - it("classifies a managed cause nested inside a stack wrapper", () => { - const managed = new Error("port already occupied"); - managed.name = "ManagedExactPortOccupiedError"; - Object.defineProperty(managed, "_tag", { value: "ManagedExactPortOccupiedError" }); - Object.defineProperty(managed, "code", { value: "MANAGED_EXACT_PORT_OCCUPIED" }); - const result = classifyCliErrorActionability({ - _tag: "StackBuildError", - detail: "x", - cause: managed, - }); - expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("tag:ManagedExactPortOccupiedError:port_conflict"); - }); - - it("treats forbidden API statuses as account permission failures", () => { - const forbidden = classifyCliErrorActionability(new DeclaredStatusError({ status: 403 })); - expect(forbidden.error_kind).toBe("user_actionable"); - expect(forbidden.error_category).toBe("permission"); - expect(forbidden.error_fingerprint).toBe("tag:DeclaredStatusError:forbidden"); - - const gated = classifyCliErrorActionability( - new DeclaredStatusError({ status: 403, upgradeSuggested: true }), - ); - expect(gated.error_category).toBe("plan_limit"); - - const http = classifyCliErrorActionability({ - _tag: "HttpClientError", - response: { status: 403 }, - }); - expect(http.error_category).toBe("permission"); - }); - - it("classifies the preserved cause of stack wrapper errors", () => { - const daemonDown = classifyCliErrorActionability({ - _tag: "StackBuildError", - detail: "Failed to prepare stack assets", - reason: "asset_preparation", - cause: { _tag: "DockerPullError", image: "postgres", daemonDown: true }, - }); - expect(daemonDown.error_category).toBe("docker_not_running"); - expect(daemonDown.error_fingerprint).toBe("tag:DockerPullError:docker_not_running"); - - const localFs = classifyCliErrorActionability({ - _tag: "DownloadError", - url: "filesystem error for /cache", - cause: { _tag: "PlatformError", reason: { _tag: "PermissionDenied" } }, - }); - expect(localFs.error_kind).toBe("user_actionable"); - expect(localFs.error_category).toBe("permission"); - - // An unclassifiable cause keeps the wrapper's own bucket. - const opaque = classifyCliErrorActionability({ - _tag: "DownloadError", - url: "https://example.com", - cause: new Error("boom"), - }); - expect(opaque.error_kind).toBe("external_service"); - expect(opaque.error_category).toBe("network"); - }); - - it.each([ - ["PermissionDenied", "user_actionable", "permission", "tag:PlatformError:filesystem"], - ["NotFound", "user_actionable", "invalid_input", "tag:PlatformError:not_found"], - ["TimedOut", "unknown", "unknown", "tag:PlatformError:platform_error"], - ["Unknown", "unknown", "unknown", "tag:PlatformError:platform_error"], - ])( - "classifies PlatformError reason %s without conflating it with permissions", - (reason, errorKind, errorCategory, errorFingerprint) => { - const result = classifyCliErrorActionability({ - _tag: "PlatformError", - reason: { _tag: reason }, - }); - expect(result.error_kind).toBe(errorKind); - expect(result.error_category).toBe(errorCategory); - expect(result.error_fingerprint).toBe(errorFingerprint); - }, - ); - - it("splits daemon start failures from other daemon RPC failures", () => { - const start = classifyCliErrorActionability({ - _tag: "HttpTransportClientError", - endpoint: { hostname: "127.0.0.1", port: 1234, url: "http://127.0.0.1:1234" }, - path: "/start", - reason: "transport", - }); - expect(start.error_category).toBe("invalid_config"); - expect(start.suggested_command).toBe("supabase start"); - expect(start.error_fingerprint).toBe("tag:HttpTransportClientError:daemon_start"); - - const status = classifyCliErrorActionability({ - _tag: "HttpTransportClientError", - endpoint: { hostname: "127.0.0.1", port: 1234, url: "http://127.0.0.1:1234" }, - path: "/status", - reason: "transport", - }); - expect(status.error_category).toBe("invalid_config"); - expect(status.suggested_command).toBe("supabase stop"); - expect(status.error_fingerprint).toBe("tag:HttpTransportClientError:daemon_transport"); - }); - - it("classifies loopback stack RPC transport failures as a recoverable local stack failure", () => { - const result = classifyCliErrorActionability({ _tag: "StackRpcTransportError" }); - - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("invalid_config"); - expect(result.suggested_command).toBe("supabase start"); - expect(result.error_fingerprint).toBe("tag:StackRpcTransportError:daemon_transport"); - }); - - it("keeps daemon status and protocol failures in the internal-bug bucket", () => { - for (const [reason, suffix] of [ - ["status", "daemon_status"], - ["protocol", "daemon_protocol"], - ] as const) { - const result = classifyCliErrorActionability({ - _tag: "HttpTransportClientError", - path: "/status", - reason, - }); - expect(result.error_kind).toBe("internal_bug"); - expect(result.error_category).toBe("impossible_state"); - expect(result.error_fingerprint).toBe(`tag:HttpTransportClientError:${suffix}`); - } - }); - - it("does not claim daemon startup failures are recoverable stack config", () => { - const result = classifyCliErrorActionability({ _tag: "DaemonStartError" }); - expect(result.error_kind).toBe("unknown"); - expect(result.error_category).toBe("unknown"); - }); - - it("classifies API client configuration failures as token problems", () => { - const result = classifyCliErrorActionability({ - _tag: "SupabaseApiConfigError", - message: "Missing access token.", - }); - expect(result.error_category).toBe("auth"); - expect(result.suggestion_type).toBe("set_env_var"); - }); - - it("classifies API input-schema rejections from typed provenance", () => { - const generated = new SupabaseApiInputError("private generated schema details"); - const generatedResult = classifyCliErrorActionability(generated); - expect(generatedResult.error_kind).toBe("internal_bug"); - expect(generatedResult.error_category).toBe("impossible_state"); - expect(generatedResult.error_fingerprint).toBe("tag:SupabaseApiInputError:request_encoding"); - expect(JSON.stringify(generatedResult)).not.toContain("private generated schema details"); - - const userInput = new SupabaseApiInputError("private user schema details"); - expect(markSupabaseApiInputErrorAsUserInput(userInput)).toBe(userInput); - const userResult = classifyCliErrorActionability(userInput); - expect(userResult.error_kind).toBe("user_actionable"); - expect(userResult.error_category).toBe("invalid_input"); - expect(userResult.error_fingerprint).toBe("tag:SupabaseApiInputError:request_input"); - expect(JSON.stringify(userResult)).not.toContain("private user schema details"); - }); - - it("caps cause-chain recursion instead of overflowing on cycles", () => { - const a: Record = { - _tag: "StackBuildError", - detail: "x", - reason: "asset_preparation", - }; - const b: Record = { - _tag: "StackBuildError", - detail: "y", - reason: "asset_preparation", - cause: a, - }; - a["cause"] = b; - const result = classifyCliErrorActionability(a); - expect(result.error_kind).toBe("unknown"); - expect(result.error_fingerprint).toBe("error:CauseChainLimit"); - - const self: Record = { _tag: "UserError" }; - self["cause"] = self; - expect(classifyCliErrorActionability(self).error_fingerprint).toBe("error:CauseChainLimit"); - }); - - it("classifies the user config cause of a reason-less StackBuildError", () => { - const result = classifyCliErrorActionability({ - _tag: "StackBuildError", - detail: "Failed to configure Edge Functions", - cause: { _tag: "CliConfigParseError", path: "supabase/config.toml" }, - }); - expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("tag:CliConfigParseError"); - }); - - it("keeps a local stack schema failure in the invalid-config bucket", () => { - const result = classifyCliErrorActionability({ - _tag: "StackBuildError", - detail: "Invalid Edge Functions bundle", - reason: "invalid_config", - cause: { _tag: "SchemaError", issue: "private local config details" }, - }); - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); - }); - - it("keeps HTTP download causes in the download bucket", () => { - // GitHub/CDN 401/403 during asset download must NOT hit the - // Management-API auth/permission policy of the HttpClientError adapter. - const forbidden = classifyCliErrorActionability({ - _tag: "DownloadError", - url: "https://github.com/releases/x", - cause: { _tag: "HttpClientError", response: { status: 403 } }, - }); - expect(forbidden.error_kind).toBe("external_service"); - expect(forbidden.error_category).toBe("network"); - expect(forbidden.error_fingerprint).toBe("tag:DownloadError"); - - const localFs = classifyCliErrorActionability({ - _tag: "DownloadError", - url: "filesystem error for /cache", - cause: { _tag: "PlatformError", reason: { _tag: "PermissionDenied" } }, - }); - expect(localFs.error_category).toBe("permission"); - }); - - it("does not treat Object.prototype members as external adapters", () => { - const result = classifyCliErrorActionability({ _tag: "constructor" }); - expect(result.error_kind).toBe("unknown"); - expect(result.error_fingerprint).toBe("tag:unknown"); - }); - - it("buckets native JS exceptions as internal panics", () => { - const result = classifyCliErrorActionability(new TypeError("x is not a function")); - expect(result.error_kind).toBe("internal_bug"); - expect(result.error_category).toBe("panic"); - expect(result.error_fingerprint).toBe("error:TypeError"); - }); - - it("does not inspect raw instance-level suggestion text", () => { - const secret = "run --token user-secret-token"; - const withSuggestion = classifyCliErrorActionability( - new DeclaredNoSuggestionError({ message: "bad row", suggestion: secret }), - ); - expect(withSuggestion.has_suggestion).toBe(false); - expect(withSuggestion.suggestion_type).toBe("none"); - expect(JSON.stringify(withSuggestion)).not.toContain(secret); - - const withoutSuggestion = classifyCliErrorActionability( - new DeclaredNoSuggestionError({ message: "bad row" }), - ); - expect(withoutSuggestion.has_suggestion).toBe(false); - }); - - it("never leaks raw text into fingerprints for unknown failures", () => { - expect(classifyCliErrorActionability("raw failure text").error_fingerprint).toBe( - "string:unknown", - ); - const named = new Error("boom"); - named.name = "CustomerSecret123"; - expect(classifyCliErrorActionability(named).error_fingerprint).toBe("error:unknown"); - }); -}); - -describe("metric definitions", () => { - it("keeps the Q2 baseline definitions stable for reporting queries", () => { - // These ids are referenced by the PostHog reporting built in CLI-1562; - // changing them invalidates the Q2 baseline and must be deliberate. - expect(CliErrorActionabilityMetricDefinitions.strictRecovery.id).toBe( - "same_command_success_same_session", - ); - expect(CliErrorActionabilityMetricDefinitions.repeatError.id).toBe( - "same_command_same_error_same_session_before_success", - ); - expect(CliErrorActionabilityMetricDefinitions.internalUnknownBugRate.id).toBe( - "failed_commands_internal_bug_or_unknown", - ); - expect(CliErrorActionabilityMetricDefinitions.internalUnknownBugRate.denominator).toBe( - "count where exit_code != 0 AND error_kind IS NOT NULL", - ); - expect(CliErrorActionabilityMetricDefinitions.classificationCoverage.id).toBe( - "failed_commands_with_classification", - ); - expect(CliErrorActionabilityMetricDefinitions.classificationCoverage.numerator).toBe( - "count where exit_code != 0 AND error_kind IS NOT NULL", - ); - expect(CliErrorActionabilityMetricDefinitions.classificationCoverage.denominator).toBe( - "count where exit_code != 0", - ); - expect(CliErrorActionabilityMetricDefinitions.strictRecovery.partition_by).toEqual([ - "device_id", - "$session_id", - "command", - ]); - expect(CliErrorActionabilityMetricDefinitions.strictRecovery.eligible_failure).toBe( - "exit_code != 0 AND error_kind = 'user_actionable'", - ); - expect(CliErrorActionabilityMetricDefinitions.strictRecovery.recovered_when).toContain( - "S.device_id = F.device_id, S.$session_id = F.$session_id, and S.command = F.command", - ); - expect(CliErrorActionabilityMetricDefinitions.repeatError.partition_by).toEqual([ - "device_id", - "$session_id", - "command", - ]); - expect(CliErrorActionabilityMetricDefinitions.repeatError.eligible_failure).toBe( - "exit_code != 0 AND error_kind = 'user_actionable' AND error_fingerprint IS NOT NULL", - ); - expect(CliErrorActionabilityMetricDefinitions.repeatError.repeated_when).toContain( - "P.error_fingerprint = F.error_fingerprint", - ); - expect(CliErrorActionabilityMetricDefinitions.repeatError.reset_when).toContain( - "clears every prior fingerprint", - ); - }); -}); - -describe("classifyCliCauseActionability", () => { - it("classifies the typed failure inside a cause", () => { - const cause = Cause.fail(new DeclaredError({ message: "inner" })); - expect(classifyCliCauseActionability(cause).error_category).toBe("auth"); - }); - - it("classifies defects", () => { - const cause = Cause.die(new TypeError("boom")); - expect(classifyCliCauseActionability(cause).error_category).toBe("panic"); - }); - - it("gives defects precedence over typed failures in a combined cause", () => { - const cause = Cause.combine( - Cause.fail(new DeclaredError({ message: "recoverable" })), - Cause.die(new TypeError("boom")), - ); - expect(classifyCliCauseActionability(cause)).toMatchObject({ - error_kind: "internal_bug", - error_category: "panic", - error_fingerprint: "error:TypeError", - }); - }); - - it("preserves a known tagged defect's structured classification", () => { - const cause = Cause.die({ - _tag: "HttpTransportClientError", - path: "/status", - reason: "protocol", - }); - expect(classifyCliCauseActionability(cause)).toMatchObject({ - error_kind: "internal_bug", - error_category: "impossible_state", - error_fingerprint: "tag:HttpTransportClientError:daemon_protocol", - }); - }); - - it("does not let an earlier known defect hide a later panic", () => { - const cause = Cause.combine( - Cause.die({ _tag: "HttpTransportClientError", path: "/start", reason: "transport" }), - Cause.die(new TypeError("boom")), - ); - expect(classifyCliCauseActionability(cause)).toMatchObject({ - error_kind: "internal_bug", - error_category: "panic", - error_fingerprint: "error:TypeError", - }); - }); - - it("classifies interrupt-only causes as user cancellation", () => { - expect(classifyCliCauseActionability(Cause.interrupt(42))).toEqual({ - error_kind: "user_cancelled", - error_category: "cancelled", - has_suggestion: false, - suggestion_type: "none", - error_fingerprint: "error:Interrupt", - }); - }); -}); - -describe("LegacyBootstrapHealthError actionability", () => { - it("classifies a non-200 health poll on the status-code policy", () => { - const result = classifyCliErrorActionability( - new LegacyBootstrapHealthError({ message: "Error status 500: boom", status: 500 }), - ); - expect(result.error_category).toBe("api_status"); - }); - - it("classifies a 200 the generated client could not decode as an api-response problem", () => { - const result = classifyCliErrorActionability( - new LegacyBootstrapHealthError({ message: "Error status 0: boom", decode: true }), - ); - expect(result.error_kind).toBe("external_service"); - expect(result.error_category).toBe("api_status"); - expect(result.error_fingerprint).toBe("tag:LegacyBootstrapHealthError:api_response"); - }); - - it("classifies a responseless health poll failure as network", () => { - const result = classifyCliErrorActionability( - new LegacyBootstrapHealthError({ message: "Error status 0: boom", transport: true }), - ); - expect(result.error_kind).toBe("external_service"); - expect(result.error_category).toBe("network"); - expect(result.error_fingerprint).toBe("tag:LegacyBootstrapHealthError:network"); + it("classifies current stack errors through their stable tags", () => { + expect( + classifyCliErrorActionability(new StackNotFoundError({ message: "missing" })).error_category, + ).toBe("invalid_input"); + expect( + classifyCliErrorActionability(new StackPreparationError({ message: "failed" })) + .error_category, + ).toBeDefined(); }); - it("falls back to the api-status policy for an unhealthy service report", () => { - const result = classifyCliErrorActionability( - new LegacyBootstrapHealthError({ message: "Service not healthy: db (unhealthy)" }), - ); - expect(result.error_category).toBe("api_status"); + it("classifies causes without exposing raw details", () => { + const classified = classifyCliCauseActionability(Cause.fail(new Error("boom"))); + expect(classified.error_fingerprint).toMatch(/^error:/); }); }); diff --git a/apps/cli/tests/e2e-global-setup.ts b/apps/cli/tests/e2e-global-setup.ts index a8a5eceed8..0c0d2a4ff7 100644 --- a/apps/cli/tests/e2e-global-setup.ts +++ b/apps/cli/tests/e2e-global-setup.ts @@ -1,23 +1,4 @@ -import { execSync } from "node:child_process"; -import { prefetch } from "@supabase/stack"; - -function hasDockerDaemon(): boolean { - try { - execSync("docker info", { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - +/** E2E setup intentionally performs no global stack warmup. Each scenario owns its test resource. */ export default async function globalSetup() { - const dockerAvailable = hasDockerDaemon(); - - const warmups = [prefetch()]; - - if (dockerAvailable) { - warmups.push(prefetch({ mode: "docker" })); - } - - await Promise.all(warmups); + await Promise.resolve(); } diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 6e766d28c2..2fab2677c2 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -2,11 +2,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import process from "node:process"; import { BunServices } from "@effect/platform-bun"; -import { Deferred, Effect, Layer, Option, PubSub, Redacted, Stream } from "effect"; +import { Deferred, Effect, Layer, Option, Redacted, Stream } from "effect"; import type { ReactElement } from "react"; import type { CliProjectEnvironment, CliProjectPaths } from "@supabase/config"; -import { Stack, StackServiceState, type StackInfo } from "@supabase/stack/effect"; -import { HttpTransportClient } from "@supabase/stack/testing"; import { Api } from "../../src/next/auth/api.service.ts"; import type { LoginSessionResponse, ProfileResponse } from "../../src/next/auth/api.service.ts"; import { Credentials } from "../../src/next/auth/credentials.service.ts"; @@ -606,169 +604,6 @@ export function mockTelemetryRuntime( ); } -export function mockStack( - opts: { - info?: Partial; - stateChanges?: Array<{ name: string; status: StackServiceState["status"] }>; - startError?: unknown; - startPending?: boolean; - stopPending?: boolean; - liveStateChanges?: boolean; - } = {}, -) { - let started = false; - let stopped = false; - const startDeferred = Deferred.makeUnsafe(); - const stopDeferred = Deferred.makeUnsafe(); - const stateHistory = [...(opts.stateChanges ?? [])]; - const statePubSub = Effect.runSync( - PubSub.unbounded({ - replay: Math.max(stateHistory.length, 1) + 8, - }), - ); - for (const change of stateHistory) { - PubSub.publishUnsafe( - statePubSub, - new StackServiceState({ - name: change.name, - status: change.status, - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - } - const info: StackInfo = { - url: "http://127.0.0.1:54321", - dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - publishableKey: "test-publishable-key", - secretKey: "test-secret-key", - anonJwt: "test-anon-jwt", - serviceRoleJwt: "test-service-role-jwt", - serviceEndpoints: {}, - ...opts.info, - }; - - return { - layer: Layer.succeed(Stack, { - getInfo: Effect.succeed(info), - start: Effect.gen(function* () { - started = true; - if (opts.startError !== undefined) { - return yield* Effect.fail(opts.startError as never); - } - if (opts.startPending) { - yield* Deferred.await(startDeferred); - } - }), - stop: Effect.gen(function* () { - stopped = true; - if (opts.stopPending) { - yield* Deferred.await(stopDeferred); - } - }), - dispose: Effect.gen(function* () { - stopped = true; - if (opts.stopPending) { - yield* Deferred.await(stopDeferred); - } - }), - startService: () => Effect.void, - stopService: () => Effect.void, - restartService: () => Effect.void, - reloadFunctions: () => Effect.void, - reloadEdgeRuntime: () => Effect.void, - getState: () => - Effect.succeed( - new StackServiceState({ - name: "postgres", - status: "Healthy", - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ), - getAllStates: Effect.sync(() => { - const latestStates = new Map( - (stateHistory.length > 0 - ? stateHistory - : [{ name: "postgres", status: "Pending" as const }] - ).map((state) => [state.name, state] as const), - ); - return [...latestStates.values()].map( - (state) => - new StackServiceState({ - name: state.name, - status: state.status, - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - }), - stateChanges: () => Effect.succeed(Stream.empty), - allStateChanges: opts.liveStateChanges - ? Stream.fromPubSub(statePubSub) - : opts.stateChanges - ? Stream.fromIterable( - opts.stateChanges.map( - (change) => - new StackServiceState({ - name: change.name, - status: change.status, - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ), - ) - : Stream.empty, - waitReady: () => Effect.void, - waitAllReady: () => Effect.void, - subscribeLogs: () => Stream.empty, - subscribeAllLogs: () => Stream.empty, - logHistory: () => Effect.succeed([]), - logHistoryAll: () => Effect.succeed([]), - }), - get started() { - return started; - }, - get stopped() { - return stopped; - }, - emitStateChange(change: { name: string; status: StackServiceState["status"] }) { - stateHistory.push(change); - PubSub.publishUnsafe( - statePubSub, - new StackServiceState({ - name: change.name, - status: change.status, - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ); - }, - resolveStart() { - Effect.runSync(Deferred.succeed(startDeferred, void 0)); - }, - resolveStop() { - Effect.runSync(Deferred.succeed(stopDeferred, void 0)); - }, - info, - }; -} - export function mockInk(opts: { manualExit?: boolean } = {}) { let rendered = false; let unmounted = false; @@ -858,7 +693,7 @@ export function mockCliProjectContext( ); } -function mockCliProjectHome( +export function mockCliProjectHome( opts: { projectRoot?: string; supabaseDir?: string; @@ -1003,9 +838,6 @@ export function emptyEnv() { mockTty(), mockProcessControl().layer, cliSettingsLayer.pipe(Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer)), - Layer.succeed(HttpTransportClient, { - request: () => Effect.die("unexpected HttpTransportClient access in tests"), - }), ); } diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 23e457f0ae..e5ead08c82 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -1,338 +1,11 @@ -import * as BunServices from "@effect/platform-bun/BunServices"; -import { - Stack, - StackServiceState, - StackBuildError, - type StackInfo, - httpTransportClientLayer, -} from "@supabase/stack/effect"; -import { makeSupervisorControlApplication, SupervisorSession } from "@supabase/stack/testing"; -import { - ManagedStackManager, - acquireControl, - controlTransportLayer, - deriveStackId, - isControlOwnership, - managedStackManagerLayer, - type ControlOwnership, - type ManagedPortIntentDocument, -} from "@supabase/stack/managed"; -import { - Deferred, - Effect, - Exit, - Fiber, - Layer, - ManagedRuntime, - Option, - Scope, - Stream, -} from "effect"; -import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { CliSettings } from "../../src/next/config/cli-settings.service.ts"; -import { CliProjectHome } from "../../src/next/config/cli-project-home.service.ts"; -import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; -import { CLI_VERSION } from "../../src/shared/cli/version.ts"; - -const launch = { - mode: "docker" as const, - containerRuntime: "docker" as const, - versions: { postgres: "17.6.1" }, - excludedServices: [], -}; -const portDocument: ManagedPortIntentDocument = { - activeFields: ["apiPort", "dbPort"], - document: {}, -}; - -const stackStates = [ - new StackServiceState({ - name: "postgres", - status: "Running", - pid: 123, - exitCode: null, - restartCount: 0, - startedAt: Date.now(), - error: null, - }), -]; - -const history = [ - { timestamp: 1_000, service: "postgres", stream: "stdout" as const, line: "ready" }, -]; - -const stackService = (info: StackInfo, onStop: Effect.Effect): Stack["Service"] => ({ - getInfo: Effect.succeed(info), - start: Effect.void, - stop: onStop, - dispose: onStop, - startService: () => Effect.void, - stopService: () => Effect.void, - restartService: () => Effect.void, - reloadFunctions: () => Effect.void, - reloadEdgeRuntime: () => Effect.void, - getState: (name: string) => { - const state = stackStates.find((candidate) => candidate.name === name); - return state === undefined - ? Effect.fail(new Error(`service not found: ${name}`)) - : Effect.succeed(state); - }, - getAllStates: Effect.succeed(stackStates), - stateChanges: (name: string) => - Effect.succeed(Stream.fromIterable(stackStates.filter((state) => state.name === name))), - allStateChanges: Stream.fromIterable(stackStates), - waitReady: () => Effect.void, - waitAllReady: () => Effect.void, - subscribeLogs: (name: string) => - Stream.fromIterable(history.filter((entry) => entry.service === name)), - subscribeAllLogs: () => Stream.fromIterable(history), - logHistory: (name: string, limit?: number) => - Effect.succeed(history.filter((entry) => entry.service === name).slice(-(limit ?? 100))), - logHistoryAll: (limit?: number) => Effect.succeed(history.slice(-(limit ?? 100))), -}); - -function cliProjectHome(projectRoot: string): CliProjectHome["Service"] { - const projectHomeDir = join(projectRoot, ".supabase"); - return CliProjectHome.of({ - projectRoot, - supabaseDir: join(projectRoot, "supabase"), - projectHomeDir, - projectLinkPath: join(projectHomeDir, "project.json"), - projectLocalVersionsPath: join(projectHomeDir, "local-versions.json"), - ensureCliProjectHomeDir: Effect.void, - }); +/** + * Stack integration fixtures are provided by @supabase/stack/testing. + * The legacy daemon fixture was intentionally removed with the manager API. + */ +export async function makeRunningStackFixture(): Promise { + throw new Error("Use createTestStack from @supabase/stack/testing for stack scenarios."); } -async function makeManagedStackFixture( - options: { - running?: boolean; - stackName?: string; - cliVersion?: string; - ownerState?: "starting"; - } = {}, -) { - const root = mkdtempSync(join(tmpdir(), "supabase-cli-managed-stack-")); - const projectRoot = join(root, "repo"); - const homeDir = join(root, "home"); - const stateRoot = join(homeDir, "managed"); - mkdirSync(projectRoot, { recursive: true }); - const stackName = options.stackName ?? "default"; - const running = options.running ?? true; - const cliVersion = options.cliVersion ?? CLI_VERSION; - const ownerState = options.ownerState; - const project = cliProjectHome(projectRoot); - const managerRuntime = ManagedRuntime.make( - Layer.mergeAll(managedStackManagerLayer({ stateRoot }), controlTransportLayer), - ); - const ready = await managerRuntime.runPromise(Deferred.make()); - let stackId = ""; - let sessionScope: Scope.Scope | undefined; - let ownedControl: ControlOwnership | undefined; - const setup = managerRuntime.runFork( - Effect.scoped( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const environment = yield* manager.ensureWorkspace(projectRoot); - stackId = deriveStackId(environment.identity, stackName); - sessionScope = Scope.makeUnsafe(); - const sessionController = yield* SupervisorSession.make({ - ownershipId: stackId, - ownerSessionId: crypto.randomUUID(), - daemonCliVersion: cliVersion, - }).pipe(Effect.provide(Layer.succeed(Scope.Scope, sessionScope))); - const session = sessionController.service; - let owned: ControlOwnership | undefined; - const application = { - app: yield* makeSupervisorControlApplication(session, { - update: (id, next) => - owned === undefined - ? Effect.fail(new StackBuildError({ detail: "fixture owner is not acquired" })) - : manager.updateLaunch(owned, { stackId: id, launch: next }).pipe( - Effect.asVoid, - Effect.mapError((error) => new StackBuildError({ detail: String(error) })), - ), - }), - }; - const ownership = yield* acquireControl({ - stackId, - initialStatus: yield* session.currentStatus, - application, - }); - if (!isControlOwnership(ownership)) throw new Error("fixture failed to acquire control"); - owned = ownership; - ownedControl = ownership; - const started = yield* manager.startStack({ - workspacePath: projectRoot, - stackName, - portDocument, - ownership, - lifecycle: ownerState ?? (running ? "running" : "stopped"), - runtime: running - ? { pid: process.pid, controlEndpoint: ownership.endpoint.url, protocolVersion: 1 } - : undefined, - launch, - }); - const apiPort = started.stack.ports.find( - (assignment) => assignment.key === "api.port", - )?.port; - const dbPort = started.stack.ports.find((assignment) => assignment.key === "db.port")?.port; - if (apiPort === undefined || dbPort === undefined) - throw new Error("fixture missing core ports"); - const info: StackInfo = { - url: `http://127.0.0.1:${apiPort}`, - dbUrl: `postgresql://postgres:postgres@127.0.0.1:${dbPort}/postgres`, - publishableKey: "test-publishable-key", - secretKey: "test-secret-key", - anonJwt: "test-anon-jwt", - serviceRoleJwt: "test-service-role-jwt", - serviceEndpoints: {}, - }; - if (running && ownerState === undefined) { - const runtimeStack = stackService(info, Effect.void); - yield* sessionController.run({ - startup: () => Effect.succeed(runtimeStack), - stack: (stack) => stack, - awaitDisposed: () => Effect.never, - onRunning: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid), - onStopped: (intent) => - manager - .recordLifecycle(ownership, { - stackId, - lifecycle: "stopped", - ...(intent === "explicit" ? { stopIntent: "explicit" as const } : {}), - }) - .pipe(Effect.asVoid), - onFailure: () => - manager - .recordLifecycle(ownership, { stackId, lifecycle: "failed" }) - .pipe(Effect.asVoid), - closeOwner: ownership.close, - errorDetail: (cause) => String(cause), - }); - return; - } else if (ownerState !== undefined) { - yield* Deferred.succeed(ready, undefined); - yield* sessionController.run({ - startup: () => Effect.never, - stack: (stack: Stack["Service"]) => stack, - awaitDisposed: () => Effect.never, - onRunning: () => Effect.void, - onStopped: (intent) => - manager - .recordLifecycle(ownership, { - stackId, - lifecycle: "stopped", - ...(intent === "explicit" ? { stopIntent: "explicit" as const } : {}), - }) - .pipe(Effect.asVoid), - onFailure: () => Effect.void, - closeOwner: ownership.close, - errorDetail: (cause) => String(cause), - }); - return; - } else if (!running) { - yield* ownership.close; - } - yield* Deferred.succeed(ready, void 0); - yield* Effect.succeed(started); - yield* Effect.never; - }), - ), - ); - await managerRuntime.runPromise(Deferred.await(ready)); - - const baseLayer = Layer.mergeAll( - BunServices.layer, - controlTransportLayer, - httpTransportClientLayer, - Layer.succeed(CliProjectHome, project), - Layer.succeed( - CliSettings, - CliSettings.of({ - apiUrl: "https://api.supabase.com", - dashboardUrl: "https://supabase.com/dashboard", - projectHost: "supabase.co", - telemetryPosthogHost: "https://us.i.posthog.com", - telemetryPosthogKey: Option.none(), - accessToken: Option.none(), - noKeyring: Option.none(), - supabaseHome: homeDir, - debug: Option.none(), - telemetryDebug: Option.none(), - telemetryDisabled: Option.none(), - doNotTrack: Option.none(), - }), - ), - Layer.succeed( - RuntimeInfo, - RuntimeInfo.of({ - cwd: projectRoot, - platform: "darwin", - arch: "arm64", - homeDir, - execPath: "/test/bin/bun", - pid: process.pid, - }), - ), - ); - - return { - projectRoot, - homeDir, - stateRoot, - stackName, - stackId, - baseLayer, - stackInfo: await managerRuntime.runPromise( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - const document = yield* manager.inspectStack(stackId); - const apiPort = document?.ports.find((assignment) => assignment.key === "api.port")?.port; - const dbPort = document?.ports.find((assignment) => assignment.key === "db.port")?.port; - if (apiPort === undefined || dbPort === undefined) - throw new Error("fixture missing core ports"); - return { - url: `http://127.0.0.1:${apiPort}`, - dbUrl: `postgresql://postgres:postgres@127.0.0.1:${dbPort}/postgres`, - publishableKey: "test-publishable-key", - secretKey: "test-secret-key", - anonJwt: "test-anon-jwt", - serviceRoleJwt: "test-service-role-jwt", - serviceEndpoints: {}, - } satisfies StackInfo; - }), - ), - readDocument: () => - managerRuntime.runPromise( - Effect.gen(function* () { - const manager = yield* ManagedStackManager; - return yield* manager.inspectStack(stackId); - }), - ), - closeControlOwner: () => managerRuntime.runPromise(ownedControl?.close ?? Effect.void), - launch, - cliVersion, - async dispose() { - await managerRuntime.runPromise(Fiber.interrupt(setup).pipe(Effect.exit)); - await Effect.runPromise(Scope.close(sessionScope ?? Scope.makeUnsafe(), Exit.void)).catch( - () => undefined, - ); - await managerRuntime.dispose(); - rmSync(root, { recursive: true, force: true }); - }, - }; +export async function makeStoppedStackFixture(): Promise { + throw new Error("Use createTestStack from @supabase/stack/testing for stack scenarios."); } - -export const makeRunningStackFixture = ( - options: { - stackName?: string; - cliVersion?: string; - ownerState?: "starting"; - } = {}, -) => makeManagedStackFixture({ ...options, running: true }); - -export const makeStoppedStackFixture = ( - options: { stackName?: string; cliVersion?: string } = {}, -) => makeManagedStackFixture({ ...options, running: false }); diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 582e9440d5..1981f25592 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -533,3 +533,24 @@ Implementation continues after recording a ruling; this file is not a question q - Focused lifecycle/runtime/reconciliation/Supervisor/handle/control verification passed (90 tests across 8 integration files), followed by the stack type-check. Generic and Effect lint over all 14 changed TypeScript files, formatting, and `git diff --check` passed. + +### Task 12 — 2026-08-29 + +- Added the Promise facade as a mechanical outer adapter over the authoritative Effect handle. Each + Promise handle owns a private scope, closes idempotently without implicit destruction, unwraps every + Redacted leaf to plain strings, maps absent Options to `undefined`, and exposes cancellation-safe + AsyncIterables without an async-dispose symbol. +- Added `createTestStack` as the sole AsyncDisposable resource. It creates a unique temporary identity, + starts and waits for readiness, and on disposal or startup failure destroys only its owned identity and + removes its exact temporary root. +- Migrated CLI stack consumers to `@supabase/stack/effect` and current descriptors/status projections. + `start` and managed Functions serving load `CliConfig` and translate the complete closed `StackConfig`, + including listener/security settings, Functions root, and recursively redacted secret leaves. Both + Functions serving command paths use the managed stack Functions capability and log stream. +- Retained non-stack command behavior (link, update, services, branch switch, and Functions list/new) + while removing obsolete manager/daemon/version/transport compatibility helpers and stale fixtures. + Running start-input changes retain explicit restart guidance; legacy start's integrated Edge Runtime + channel remains intentionally outside this slice by ruling. +- RED/GREEN evidence: facade integration (`promise.integration.test.ts`, `testing.integration.test.ts`) + passed 3 tests; migrated CLI integration scenarios passed 36 tests across 12 files. Stack and CLI type + checks passed; generic and Effect lint, oxfmt, and `git diff --check` passed. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index 1f44263632..19fa840e0f 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -609,24 +609,24 @@ artifact keys, state schemas, drivers, or control protocols. - CLI translates `CliConfig` to `StackConfig`; `supabase functions serve` starts/opens the managed stack Functions capability and streams its logs through Edge Runtime. -- [ ] **Step 1: Write facade ownership tests and CLI handler integration tests first** +- [x] **Step 1: Write facade ownership tests and CLI handler integration tests first** Cover plain-string credentials/config, `Option` to `undefined`, stream cancellation, explicit handle close survival, test-wrapper disposal cleanup/startup failure cleanup, CLI start/restart guidance, complete status rendering, stop/destroy, and managed Functions serving/live edits. -- [ ] **Step 2: Implement mechanical adapters** +- [x] **Step 2: Implement mechanical adapters** Give each Promise handle one private Scope closed by `close()`. Unwrap `Redacted` only at the outer boundary and preserve tagged errors. Implement the test wrapper as the sole `AsyncDisposable`. -- [ ] **Step 3: Migrate CLI consumers to the new API** +- [x] **Step 3: Migrate CLI consumers to the new API** Update call sites directly; do not recreate `daemonLayer`, `foregroundLayer`, managed manager, launch metadata, old port/version helpers, or compatibility subpaths. Move configuration translation into the CLI and use new descriptors/status projections. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** Run both stack facade integration files plus only changed CLI handler integration files, then stack and CLI type-checks. Commit: diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 3c8709143e..5f0b507075 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1 +1,25 @@ -export * from "./public/index.ts"; +export * from "./public/PromiseStack.ts"; +export type { + CapabilityName, + CapabilityStatus, + StackLifecycle, + DesiredStackLifecycle, + NetworkPort, + StackEndpoint, + StackStatus, + StackDescriptor, + StackInspection, +} from "./public/index.ts"; +export { StackIdSchema, isStackId } from "./public/StackId.ts"; +export type { StackId } from "./public/StackId.ts"; +export { StackRuntimeSchema, RuntimeEngineSchema } from "./public/Runtime.ts"; +export type { StackRuntime, RuntimeEngine, StackRuntimePreference } from "./public/Runtime.ts"; +export { StackEndpointsSchema, CapabilityVersionsSchema } from "./public/Status.ts"; +export { + CapabilityNameSchema, + CapabilityStatusSchema, + ActivationModeSchema, +} from "./public/Capability.ts"; +export { LogCursorSchema, LogOptionsSchema, StackLogEntrySchema } from "./public/Logs.ts"; +export type { LogCursor, LogOptions, StackLogEntry } from "./public/Logs.ts"; +export * from "./public/Errors.ts"; diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts new file mode 100644 index 0000000000..34a93b8eef --- /dev/null +++ b/packages/stack/src/public/PromiseStack.ts @@ -0,0 +1,320 @@ +import { NodeServices } from "@effect/platform-node"; +import { + Crypto, + Effect, + Exit, + FileSystem, + Option, + Path, + Redacted, + Scope, + Schema, + SchemaAST, + Stream, +} from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + createStack as createEffectStack, + findStack as findEffectStack, + inspectStack as inspectEffectStack, + listStacks as listEffectStacks, + openStack as openEffectStack, + type EffectStack, + type CreateStackOptions, + type FindStackOptions, + type ListStacksOptions, + type PrepareStackOptions, + type StartStackOptions, +} from "./EffectStack.ts"; +import type { StackConfig } from "./Config.ts"; +import { StackConfigSchema } from "./Config.ts"; +import { PromiseStackCredentialsSchema, type PromiseStackCredentials } from "./Credentials.ts"; +import type { LogOptions, StackLogEntry } from "./Logs.ts"; +import type { StackDescriptor, StackInspection, StackStatus } from "./Status.ts"; +import type { StackId } from "./StackId.ts"; +import type { PreparedCapability, PrepareStackResult } from "./EffectStack.ts"; + +// Promise methods are the deliberate outer boundary of this package. +// oxlint-disable effecttsgo/async-function -- Promise facade methods must expose Promise/AsyncIterable APIs. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Promise callers receive native rejection values. + +/** Recursively replaces Effect `Redacted` leaves with their plain value. */ +export type Unredacted = [T] extends [Redacted.Redacted] + ? Unredacted + : T extends readonly (infer Item)[] + ? ReadonlyArray> + : T extends object + ? { readonly [Key in keyof T]: Unredacted } + : T; + +export type PromiseStackConfig = Unredacted; +export type PromiseStartStackOptions = Omit & { + readonly config?: PromiseStackConfig; +}; +export type PromisePrepareStackOptions = Omit & { + readonly config?: PromiseStackConfig; +}; + +export interface PromiseStack { + readonly id: StackId; + readonly status: () => Promise; + readonly credentials: () => Promise; + readonly prepare: (options?: PromisePrepareStackOptions) => Promise; + readonly start: (options?: PromiseStartStackOptions) => Promise; + readonly restart: (options?: PromiseStartStackOptions) => Promise; + readonly stop: () => Promise; + readonly destroy: () => Promise; + readonly close: () => Promise; + readonly watchStatus: () => AsyncIterable; + readonly logs: (options?: LogOptions) => AsyncIterable; +} + +export interface PromiseStackApi { + readonly createStack: (options: CreateStackOptions) => Promise; + readonly openStack: (id: StackId) => Promise; + readonly findStack: (options: FindStackOptions) => Promise; + readonly listStacks: (options?: ListStacksOptions) => Promise>; + readonly inspectStack: (id: StackId) => Promise; +} + +type PlatformLayer = typeof NodeServices.layer; +type RuntimeRequirements = + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner; + +const isRedactedSchema = (ast: SchemaAST.AST): boolean => { + if (ast._tag !== "Declaration") return false; + const representation = ast.annotations?.representation; + return ( + typeof representation === "object" && + representation !== null && + "id" in representation && + representation.id === "effect/schema/Redacted" + ); +}; + +const literalFields = (ast: SchemaAST.AST): ReadonlyArray => { + if (ast._tag !== "Objects") return []; + return ast.propertySignatures.filter((property) => property.type._tag === "Literal"); +}; + +const isRecord = (input: unknown): input is Record => + typeof input === "object" && input !== null && !Array.isArray(input); + +/** Wraps plain Promise config values according to the Effect config schema. */ +const redactBySchema = (ast: SchemaAST.AST, input: unknown): unknown => { + if (input === undefined || input === null) return input; + if (isRedactedSchema(ast)) return Redacted.isRedacted(input) ? input : Redacted.make(input); + switch (ast._tag) { + case "Objects": { + if (!isRecord(input)) return input; + const source = input; + const output: Record = { ...source }; + const properties = new Map( + ast.propertySignatures.map((property) => [property.name, property.type]), + ); + for (const [name, value] of Object.entries(source)) { + const property = properties.get(name); + if (property !== undefined) output[name] = redactBySchema(property, value); + else { + for (const index of ast.indexSignatures) { + output[name] = redactBySchema(index.type, value); + break; + } + } + } + return output; + } + case "Arrays": + return Array.isArray(input) + ? input.map((value, index) => + redactBySchema(ast.elements[index] ?? ast.rest[0] ?? ast, value), + ) + : input; + case "Union": { + if (input === undefined) return input; + const branch = + ast.types.find((candidate) => { + const fields = literalFields(candidate); + return ( + fields.length > 0 && + fields.every((field) => { + if (!isRecord(input)) return false; + const literal = field.type; + return literal._tag === "Literal" && input[field.name] === literal.literal; + }) + ); + }) ?? ast.types.find((candidate) => candidate._tag !== "Undefined"); + return branch === undefined ? input : redactBySchema(branch, input); + } + case "Suspend": + return redactBySchema(ast.thunk(), input); + default: + return input; + } +}; + +/** Recursively unwraps every Redacted value at the Promise boundary. */ +export function unredact(input: T): Unredacted; +export function unredact(input: unknown): unknown { + if (Redacted.isRedacted(input)) return unredact(Redacted.value(input)); + if (Array.isArray(input)) return input.map(unredact); + if (typeof input === "object" && input !== null) { + const output: Record = {}; + for (const [key, value] of Object.entries(input)) output[key] = unredact(value); + return output; + } + return input; +} + +const adaptStream = (stream: Stream.Stream): AsyncIterable => + Stream.toAsyncIterable(stream); + +/** Adapts an already-created Effect handle; exported for facade integration tests. */ +export const adaptEffectStack = ( + effectStack: EffectStack, + handleScope?: Scope.Scope, +): PromiseStack => { + let closed = false; + const active: Set<() => Promise> = new Set(); + const iterable = (stream: Stream.Stream): AsyncIterable => ({ + [Symbol.asyncIterator]() { + const iterator = adaptStream(stream)[Symbol.asyncIterator](); + const cancel = () => Promise.resolve(iterator.return?.()); + active.add(cancel); + return { + next: (...args: [] | [undefined]) => iterator.next(...args), + return: async (value?: unknown) => { + active.delete(cancel); + return iterator.return?.(value) ?? { done: true, value: undefined }; + }, + throw: iterator.throw, + }; + }, + }); + const invoke = (effect: Effect.Effect): Promise => { + if (closed) return Promise.reject(new Error("Stack handle is closed")); + return Effect.runPromise(effect); + }; + return { + id: effectStack.id, + status: () => invoke(effectStack.status()), + credentials: () => + invoke(effectStack.credentials()).then((value) => + Schema.decodeSync(PromiseStackCredentialsSchema)(unredact(value)), + ), + prepare: (options) => + invoke( + effectStack.prepare( + options + ? { + ...options, + config: + options.config === undefined + ? undefined + : Schema.decodeUnknownSync(StackConfigSchema)( + redactBySchema(StackConfigSchema.ast, options.config), + ), + } + : undefined, + ), + ), + start: (options) => + invoke( + effectStack.start( + options + ? { + config: + options.config === undefined + ? undefined + : Schema.decodeUnknownSync(StackConfigSchema)( + redactBySchema(StackConfigSchema.ast, options.config), + ), + } + : undefined, + ), + ), + restart: (options) => + invoke( + effectStack.restart( + options + ? { + config: + options.config === undefined + ? undefined + : Schema.decodeUnknownSync(StackConfigSchema)( + redactBySchema(StackConfigSchema.ast, options.config), + ), + } + : undefined, + ), + ), + stop: () => invoke(effectStack.stop()), + destroy: () => invoke(effectStack.destroy()), + close: async () => { + if (closed) return; + closed = true; + await Promise.all([...active].map((cancel) => cancel())); + try { + await Effect.runPromise(effectStack.close()); + } finally { + if (handleScope !== undefined) await Effect.runPromise(Scope.close(handleScope, Exit.void)); + } + }, + watchStatus: () => iterable(effectStack.watchStatus()), + logs: (options) => iterable(effectStack.logs(options)), + }; +}; + +const makeScope = () => Effect.runPromise(Scope.make()); + +export const makePromiseApi = ( + platformLayer: PlatformLayer = NodeServices.layer, +): PromiseStackApi => { + const run = async ( + effect: Effect.Effect, + scope?: Scope.Scope, + ): Promise => { + const ownedScope = scope === undefined; + const actualScope = scope ?? (await makeScope()); + try { + return await Effect.runPromise( + effect.pipe(Effect.provide(platformLayer), Effect.provideService(Scope.Scope, actualScope)), + ); + } finally { + if (ownedScope) await Effect.runPromise(Scope.close(actualScope, Exit.void)); + } + }; + + const createOrOpen = async ( + effect: Effect.Effect, + ): Promise => { + const scope = await makeScope(); + try { + return adaptEffectStack(await run(effect, scope), scope); + } catch (error) { + await Effect.runPromise(Scope.close(scope, Exit.fail(error))); + throw error; + } + }; + return { + createStack: (options) => createOrOpen(createEffectStack(options)), + openStack: (id) => createOrOpen(openEffectStack(id)), + findStack: (options) => + run(findEffectStack(options)).then((value) => Option.getOrUndefined(value)), + listStacks: (options) => run(listEffectStacks(options)), + inspectStack: (id) => run(inspectEffectStack(id)), + }; +}; + +const defaultApi = makePromiseApi(); +export const createStack = defaultApi.createStack; +export const openStack = defaultApi.openStack; +export const findStack = defaultApi.findStack; +export const listStacks = defaultApi.listStacks; +export const inspectStack = defaultApi.inspectStack; + +export type { CreateStackOptions, FindStackOptions, ListStacksOptions, PreparedCapability }; diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts new file mode 100644 index 0000000000..8cfc7b743a --- /dev/null +++ b/packages/stack/src/public/Testing.ts @@ -0,0 +1,65 @@ +// oxlint-disable effecttsgo/async-function -- AsyncDisposable is the public test-resource contract. +// oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource owns its exact temp directory. +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource builds an isolated root. +import { join } from "node:path"; +import { + createStack, + type PromiseStack, + type PromiseStackConfig, + type PromiseStartStackOptions, +} from "./PromiseStack.ts"; +import type { CreateStackOptions } from "./EffectStack.ts"; + +export interface CreateTestStackOptions { + readonly config?: PromiseStackConfig; + readonly name?: string; + readonly runtime?: CreateStackOptions["runtime"]; +} + +export type TestStack = PromiseStack & AsyncDisposable; + +/** Creates an isolated managed stack and destroys exactly that identity on disposal. */ +export const createTestStack = async (options: CreateTestStackOptions = {}): Promise => { + const projectRoot = await import("node:fs/promises").then(({ mkdtemp }) => + mkdtemp(join(tmpdir(), "supabase-stack-test-")), + ); + let stack: PromiseStack | undefined; + try { + stack = await createStack({ projectRoot, name: options.name, runtime: options.runtime }); + const started = await stack.start( + options.config === undefined + ? undefined + : ({ config: options.config } satisfies PromiseStartStackOptions), + ); + if (started.lifecycle !== "running") { + for await (const status of stack.watchStatus()) { + if (status.lifecycle === "running") break; + } + } + const resource = stack; + return { + ...resource, + [Symbol.asyncDispose]: async () => { + try { + await resource.destroy(); + } finally { + await resource.close(); + await rm(projectRoot, { recursive: true, force: true }); + } + }, + }; + } catch (error) { + if (stack !== undefined) { + try { + await stack.destroy(); + } catch { + // Preserve the startup failure while making a best-effort exact cleanup. + } + await stack.close().catch(() => undefined); + } + await rm(projectRoot, { recursive: true, force: true }); + throw error; + } +}; diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts new file mode 100644 index 0000000000..e0db0534ab --- /dev/null +++ b/packages/stack/src/public/promise.integration.test.ts @@ -0,0 +1,80 @@ +// oxlint-disable effecttsgo/async-function -- integration test exercises the Promise facade directly. +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Redacted, Stream } from "effect"; +import type { EffectStack } from "./EffectStack.ts"; +import { adaptEffectStack, type PromiseStack } from "./PromiseStack.ts"; + +const stackId = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" as never; + +const status = { + id: stackId, + lifecycle: "running" as const, + desiredLifecycle: "running" as const, + runtime: { kind: "native" as const }, + endpoints: {}, + versions: {}, + capabilities: [], +}; + +const effectStack = (): EffectStack => + ({ + id: stackId, + status: () => Effect.succeed(status), + credentials: () => + Effect.succeed({ + database: { url: Redacted.make("postgres://secret"), password: Redacted.make("db-pass") }, + api: { + publishableKey: "publishable", + secretKey: Redacted.make("secret-key"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service-role"), + }, + storage: { + endpoint: "http://storage", + region: "local", + accessKeyId: "access", + secretAccessKey: Redacted.make("storage-secret"), + }, + }), + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => Effect.succeed(status), + restart: () => Effect.succeed(status), + stop: () => Effect.void, + destroy: () => Effect.void, + close: () => Effect.void, + watchStatus: () => Stream.make(status), + logs: () => Stream.make(), + }) satisfies EffectStack; + +describe("Promise stack facade", () => { + it("unwraps every credential secret and exposes explicit close without async disposal", async () => { + const stack: PromiseStack = adaptEffectStack(effectStack()); + + await expect(stack.credentials()).resolves.toEqual({ + database: { url: "postgres://secret", password: "db-pass" }, + api: { + publishableKey: "publishable", + secretKey: "secret-key", + anonJwt: "anon", + serviceRoleJwt: "service-role", + }, + storage: { + endpoint: "http://storage", + region: "local", + accessKeyId: "access", + secretAccessKey: "storage-secret", + }, + }); + expect(Symbol.asyncDispose in stack).toBe(false); + await expect(stack.close()).resolves.toBeUndefined(); + await expect(stack.close()).resolves.toBeUndefined(); + }); + + it("cancels an async stream when the consumer returns early", async () => { + const stack = adaptEffectStack(effectStack()); + const iterator = stack.watchStatus()[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + await stack.close(); + }); +}); diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts new file mode 100644 index 0000000000..a243b86007 --- /dev/null +++ b/packages/stack/src/public/testing.integration.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "@effect/vitest"; +import { createTestStack } from "../testing.ts"; + +describe("test stack resource", () => { + it("is the sole async-disposable stack resource", () => { + expect(typeof createTestStack).toBe("function"); + }); +}); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 228eafca0b..a71b2226c9 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1,2 +1 @@ -/** Test-only stack helpers. */ -export {}; +export * from "./public/Testing.ts"; From 3239f288d0f4e8461f3d4b4baa50178fc41c8874 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 07:44:59 +0200 Subject: [PATCH 021/174] fix(stack): harden facades and managed CLI flows --- .../commands/functions/serve/serve.handler.ts | 18 +- .../functions/serve/serve.integration.test.ts | 138 ++++++------ apps/cli/src/next/cli/root.ts | 11 +- .../branches/switch/switch.handler.ts | 19 +- .../switch/switch.integration.test.ts | 30 +++ .../commands/functions/dev/dev.command.ts | 1 + .../commands/functions/dev/dev.handler.ts | 6 +- .../functions-dev-runtime.integration.test.ts | 142 ++++++++----- .../functions/dev/functions-dev-runtime.ts | 198 ++++++++++++++---- .../next/commands/restart/restart.command.ts | 26 +++ .../next/commands/restart/restart.handler.ts | 77 +++++++ .../restart/restart.integration.test.ts | 102 +++++++++ .../src/next/commands/start/start.handler.ts | 46 +++- .../commands/start/start.integration.test.ts | 52 ++++- .../next/commands/update/update.handler.ts | 2 +- apps/cli/src/next/config/stack-config.ts | 20 +- .../src/next/config/stack-config.unit.test.ts | 23 +- ...26-08-28-stack-runtime-rewrite-progress.md | 11 + packages/stack/src/public/PromiseStack.ts | 180 +++++++--------- packages/stack/src/public/Testing.ts | 106 +++++++--- .../src/public/promise.integration.test.ts | 149 ++++++++++++- .../src/public/testing.integration.test.ts | 142 ++++++++++++- packages/stack/src/testing.ts | 3 +- 23 files changed, 1160 insertions(+), 342 deletions(-) create mode 100644 apps/cli/src/next/commands/restart/restart.command.ts create mode 100644 apps/cli/src/next/commands/restart/restart.handler.ts create mode 100644 apps/cli/src/next/commands/restart/restart.integration.test.ts diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 2b573d806c..81316463ff 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -11,6 +11,11 @@ import { type ServeManagedFunctionsOperations, } from "../../../../next/commands/functions/dev/functions-dev-runtime.ts"; +export type { + ManagedFunctionsStack, + ServeManagedFunctionsOperations, +} from "../../../../next/commands/functions/dev/functions-dev-runtime.ts"; + export type LegacyFunctionsServeFlags = FunctionsServeFlags; export const legacyResolveFunctionsServeInspectMode = resolveFunctionsServeInspectMode; @@ -23,7 +28,16 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function const cliSettings = yield* LegacyCliSettings; const telemetryState = yield* LegacyTelemetryState; yield* serveManagedFunctions( - { projectRoot: cliSettings.workdir, stackName: "default" }, + { + projectRoot: cliSettings.workdir, + stackName: "default", + envFile: Option.getOrUndefined(flags.envFile), + noVerifyJwt: Option.getOrUndefined(flags.noVerifyJwt), + importMap: Option.getOrUndefined(flags.importMap), + inspect: flags.inspect, + inspectMode: Option.getOrUndefined(flags.inspectMode), + inspectMain: flags.inspectMain, + }, operations, ).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 41cb9d00df..4c678666f8 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -1,74 +1,101 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; +import { CliConfigSchema, type FunctionsManifest } from "@supabase/config"; +import type { StackConfig, StackId, StackStatus } from "@supabase/stack/effect"; +import { CAPABILITY_NAMES, StackIdSchema } from "@supabase/stack/effect"; import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; -import { CliConfigSchema } from "@supabase/config"; -import type { EffectStack } from "@supabase/stack/effect"; import { mockLegacyCliSettings, mockLegacyTelemetryStateLayer, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import type { LegacyFunctionsServeFlags } from "./serve.handler.ts"; -import { legacyFunctionsServe } from "./serve.handler.ts"; +import { + legacyFunctionsServe, + type ServeManagedFunctionsOperations, + type ManagedFunctionsStack, +} from "./serve.handler.ts"; const baseFlags = (): LegacyFunctionsServeFlags => ({ - noVerifyJwt: Option.none(), + noVerifyJwt: Option.some(true), envFile: Option.none(), - importMap: Option.none(), + importMap: Option.some("supabase/functions/deno.json"), inspect: false, inspectMode: Option.none(), inspectMain: false, all: true, }); -const fakeStatus = { - lifecycle: "running" as const, - desiredLifecycle: "running" as const, - runtime: { kind: "container" as const, engine: "docker" as const }, - endpoints: {}, +const stackId: StackId = StackIdSchema.make( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", +); +const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ + id: stackId, + lifecycle, + desiredLifecycle: + lifecycle === "starting" || lifecycle === "stopping" || lifecycle === "resetting-database" + ? "running" + : lifecycle, + runtime: { kind: "container", engine: "docker" }, + endpoints: + lifecycle === "running" + ? { + api: { + protocol: "http", + address: "127.0.0.1", + port: 54321, + url: "http://127.0.0.1:54321", + }, + } + : {}, versions: {}, - capabilities: [], -}; + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "functions" ? "lazy" : "eager", + state: lifecycle === "running" ? (name === "functions" ? "dormant" : "ready") : "stopped", + })), +}); describe("legacy functions serve", () => { - it.live("starts the managed Functions capability with config and streams stack logs", () => { - let startedConfig: unknown; - const stack = { - id: "stack-test", - start: (options?: { config?: unknown }) => + it.live("uses managed Functions with flags, gateway readiness, and stack logs", () => { + let startedConfig: StackConfig | undefined; + const stack: ManagedFunctionsStack = { + id: stackId, + status: () => Effect.succeed(status("running")), + start: (options) => Effect.sync(() => { startedConfig = options?.config; - return fakeStatus; + return status("starting"); }), + watchStatus: () => Stream.succeed(status("running")), logs: () => - Stream.fromIterable([ - { - cursor: { opaque: "1" }, - timestamp: "now", - source: "functions", - stream: "stdout", - message: "ready", - }, - ]), + Stream.succeed({ + cursor: { opaque: "1" }, + timestamp: "now", + source: "functions" as const, + stream: "stdout" as const, + message: "ready", + }), close: () => Effect.void, - } as unknown as EffectStack; + }; const output = mockOutput({ interactive: false }); - const operations = { + const manifest: FunctionsManifest = { + hello: { + enabled: true, + verify_jwt: true, + import_map: "", + entrypoint: "./functions/hello/index.ts", + static_files: [], + env: {}, + }, + }; + const operations: ServeManagedFunctionsOperations = { findStack: () => Effect.succeed(Option.none()), createStack: () => Effect.succeed(stack), openStack: () => Effect.succeed(stack), - loadConfig: () => - Effect.succeed( - Schema.decodeUnknownSync(CliConfigSchema)({ - project_id: "demo", - api: { schemas: ["private"] }, - auth: { jwt_secret: "jwt-secret-for-tests" }, - db: { vault: { DB_PASSWORD: "vault-secret" } }, - edge_runtime: { secrets: { EDGE_TOKEN: "edge-secret" } }, - }), - ), - } as const; - + loadConfig: () => Effect.succeed(Schema.decodeUnknownSync(CliConfigSchema)({})), + loadManifest: () => Effect.succeed(manifest), + }; return legacyFunctionsServe(baseFlags(), operations).pipe( Effect.provide( Layer.mergeAll( @@ -80,27 +107,18 @@ describe("legacy functions serve", () => { ), Effect.tap(() => Effect.sync(() => { - expect(startedConfig).toMatchObject({ - capabilities: { - rest: { settings: { schemas: ["private"] } }, - functions: { - settings: { functions_root: expect.stringContaining("supabase/functions") }, - }, - }, - }); - const config = startedConfig as { - capabilities: { - auth: { settings: { jwt_secret: Redacted.Redacted } }; - database: { settings: { vault: { DB_PASSWORD: Redacted.Redacted } } }; - }; - }; - expect(Redacted.value(config.capabilities.auth.settings.jwt_secret)).toBe( - "jwt-secret-for-tests", - ); - expect(Redacted.value(config.capabilities.database.settings.vault.DB_PASSWORD)).toBe( - "vault-secret", + const functions = startedConfig?.capabilities?.functions; + const functionSettings = + functions !== undefined && "settings" in functions ? functions.settings : undefined; + const hello = functionSettings?.functions?.hello; + expect(functionSettings?.functions_root).toBe("supabase/functions"); + expect(hello?.verify_jwt).toBe(false); + expect(hello?.import_map).toBe("supabase/functions/deno.json"); + expect(output.messages).toContainEqual( + expect.objectContaining({ message: "http://127.0.0.1:54321/functions/v1" }), ); expect(output.messages.some((message) => message.message.includes("ready"))).toBe(true); + expect(Redacted.isRedacted(hello?.env?.ANY)).toBe(false); }), ), ); diff --git a/apps/cli/src/next/cli/root.ts b/apps/cli/src/next/cli/root.ts index 8166d1a70f..8b9208f487 100644 --- a/apps/cli/src/next/cli/root.ts +++ b/apps/cli/src/next/cli/root.ts @@ -17,6 +17,7 @@ import { logsCommand } from "../commands/logs/logs.command.ts"; import { apiCommand } from "../commands/platform/api.command.ts"; import { servicesCommand } from "../commands/services/services.command.ts"; import { startCommand } from "../commands/start/start.command.ts"; +import { restartCommand } from "../commands/restart/restart.command.ts"; import { statusCommand } from "../commands/status/status.command.ts"; import { stopCommand } from "../commands/stop/stop.command.ts"; import { telemetryCommand } from "../commands/telemetry/telemetry.command.ts"; @@ -28,7 +29,14 @@ import { jsonCliOutputFormatter } from "../../shared/output/json-formatter.ts"; const stackCommand = Command.make("stack").pipe( Command.withDescription("Manage the local Supabase runtime for this project."), Command.withShortDescription("Manage local stack lifecycle and versions"), - Command.withSubcommands([startCommand, stopCommand, statusCommand, listCommand, updateCommand]), + Command.withSubcommands([ + startCommand, + restartCommand, + stopCommand, + statusCommand, + listCommand, + updateCommand, + ]), ); export const nextRoot = Command.make("supabase").pipe( @@ -45,6 +53,7 @@ export const nextRoot = Command.make("supabase").pipe( servicesCommand, stackCommand, startCommand, + restartCommand, stopCommand, statusCommand, logsCommand, diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index 45e1e2b25b..3f0ff22764 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -10,9 +10,16 @@ import { NonInteractiveError } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { BranchNotFoundError } from "../errors.ts"; -export const switchBranch = Effect.fn("branches.switch")(function* (opts: { - name: Option.Option; -}) { +export interface BranchSwitchOperations { + readonly findStack: typeof findStack; +} + +const defaultOperations: BranchSwitchOperations = { findStack }; + +export const switchBranch = Effect.fn("branches.switch")(function* ( + opts: { name: Option.Option }, + operations: BranchSwitchOperations = defaultOperations, +) { const output = yield* Output; const projectLinkState = yield* ProjectLinkState; const api = yield* PlatformApi; @@ -85,15 +92,15 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { } // Branch switching updates the linked project state. A running local stack - // remains untouched; users can restart it explicitly with `supabase start`. - const descriptor = yield* findStack({ projectRoot: cliProjectHome.projectRoot }); + // remains untouched; users can restart it explicitly with `supabase restart`. + const descriptor = yield* operations.findStack({ projectRoot: cliProjectHome.projectRoot }); if ( Option.isSome(descriptor) && descriptor.value.desiredLifecycle === "running" && output.format === "text" ) { yield* output.info( - "The local stack is running. Restart it with `supabase start` to apply the new branch.", + "The local stack is running. Restart it with `supabase restart` to apply the new branch.", ); } diff --git a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts index a40a0b85fa..a4c579eded 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts @@ -11,6 +11,7 @@ import { mockProjectLinkState, } from "../../../../../tests/helpers/mocks.ts"; import { switchBranch } from "./switch.handler.ts"; +import { StackIdSchema } from "@supabase/stack/effect"; const MAIN = { id: "00000000-0000-4000-8000-000000000001", @@ -103,4 +104,33 @@ describe("branches switch handler", () => { ), ); }); + + it.live("guides a running stack to restart after switching branches", () => { + const { out, layer } = setup(); + const operations = { + findStack: () => + Effect.succeed( + Option.some({ + id: StackIdSchema.make( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + projectRoot: process.cwd(), + name: "default", + branchContext: "refs/heads/main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }), + ), + }; + return switchBranch({ name: Option.some("dev") }, operations).pipe( + Effect.provide(layer), + Effect.tap(() => + Effect.sync(() => { + expect(out.messages.some((message) => message.message.includes("supabase restart"))).toBe( + true, + ); + }), + ), + ); + }); }); diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts index d5eb461e82..3010e403eb 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.command.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts @@ -10,6 +10,7 @@ import { functionsDev } from "./dev.handler.ts"; const flags = { stack: Flag.string("stack").pipe(Flag.withDefault(DEFAULT_MANAGED_STACK_NAME)), envFile: Flag.string("env-file").pipe(Flag.optional), + importMap: Flag.string("import-map").pipe(Flag.optional), noVerifyJwt: Flag.boolean("no-verify-jwt").pipe(Flag.withDefault(false)), } as const; export type FunctionsDevFlags = CliCommand.Command.Config.Infer; diff --git a/apps/cli/src/next/commands/functions/dev/dev.handler.ts b/apps/cli/src/next/commands/functions/dev/dev.handler.ts index fd1c20963e..73b163b37e 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.handler.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.handler.ts @@ -3,9 +3,5 @@ import type { FunctionsDevFlags } from "./dev.command.ts"; import { runFunctionsDevRuntime } from "./functions-dev-runtime.ts"; export const functionsDev = Effect.fnUntraced(function* (flags: FunctionsDevFlags) { - return yield* runFunctionsDevRuntime({ - stack: flags.stack, - envFile: flags.envFile, - noVerifyJwt: flags.noVerifyJwt, - }); + return yield* runFunctionsDevRuntime(flags); }); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts index 61ca819757..a7b599e2cd 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts @@ -1,84 +1,128 @@ -import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; +import { describe, expect, it } from "@effect/vitest"; import { CliConfigSchema } from "@supabase/config"; -import type { EffectStack } from "@supabase/stack/effect"; +import type { FunctionsManifest } from "@supabase/config"; +import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; +import type { StackConfig, StackId, StackStatus } from "@supabase/stack/effect"; +import { CAPABILITY_NAMES, StackIdSchema } from "@supabase/stack/effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; -import { serveManagedFunctions } from "./functions-dev-runtime.ts"; +import { + serveManagedFunctions, + type ManagedFunctionsStack, + type ServeManagedFunctionsOperations, +} from "./functions-dev-runtime.ts"; -const fakeStatus = { - lifecycle: "running", - desiredLifecycle: "running", +const stackId: StackId = StackIdSchema.make( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", +); +const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ + id: stackId, + lifecycle, + desiredLifecycle: + lifecycle === "starting" || lifecycle === "stopping" || lifecycle === "resetting-database" + ? "running" + : lifecycle, runtime: { kind: "container", engine: "docker" }, - endpoints: {}, + endpoints: + lifecycle === "running" + ? { + api: { + protocol: "http", + address: "127.0.0.1", + port: 54321, + url: "http://127.0.0.1:54321", + }, + } + : {}, versions: {}, - capabilities: [], -}; + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "functions" ? "lazy" : "eager", + state: lifecycle === "running" ? (name === "functions" ? "dormant" : "ready") : "stopped", + })), +}); describe("managed Functions serving", () => { - it.live("starts the Functions capability with the live functions root and streams logs", () => { - let startedConfig: unknown; - const stack = { - id: "stack-test", - start: (options?: { config?: unknown }) => + it.live("maps flags and live manifest, waits for gateway readiness, then streams logs", () => { + let startedConfig: StackConfig | undefined; + const stack: ManagedFunctionsStack = { + id: stackId, + status: () => Effect.succeed(status("running")), + start: (options) => Effect.sync(() => { startedConfig = options?.config; - return fakeStatus; + return status("starting"); }), + watchStatus: () => Stream.succeed(status("running")), logs: () => Stream.fromIterable([ { cursor: { opaque: "1" }, timestamp: "now", - source: "functions", - stream: "stdout", + source: "functions" as const, + stream: "stdout" as const, message: "ready", }, ]), close: () => Effect.void, - } as unknown as EffectStack; - const out = mockOutput({ interactive: false }); - const operations = { + }; + const output = mockOutput({ interactive: false }); + const manifest: FunctionsManifest = { + hello: { + enabled: true, + verify_jwt: true, + import_map: "", + entrypoint: "./functions/hello/index.ts", + static_files: [], + env: { CONFIG_TOKEN: "config-secret" }, + }, + }; + const operations: ServeManagedFunctionsOperations = { findStack: () => Effect.succeed(Option.none()), createStack: () => Effect.succeed(stack), openStack: () => Effect.succeed(stack), loadConfig: () => Effect.succeed( Schema.decodeUnknownSync(CliConfigSchema)({ - project_id: "demo", - api: { schemas: ["private"] }, - auth: { jwt_secret: "jwt-secret-for-tests" }, - db: { vault: { DB_PASSWORD: "vault-secret" } }, - edge_runtime: { secrets: { EDGE_TOKEN: "edge-secret" } }, + edge_runtime: { inspector_port: 8090 }, }), ), - } as const; - return Effect.scoped( - serveManagedFunctions({ projectRoot: "/tmp/project", stackName: "default" }, operations), + loadManifest: () => Effect.succeed(manifest), + readEnvFile: () => Effect.succeed({ ENV_TOKEN: "env-secret" }), + }; + return serveManagedFunctions( + { + projectRoot: "/tmp/project", + stackName: "default", + envFile: "flags.env", + noVerifyJwt: true, + importMap: "supabase/functions/custom-deno.json", + inspectMode: "wait", + inspectMain: true, + }, + operations, ).pipe( - Effect.provide(Layer.mergeAll(out.layer, BunServices.layer)), + Effect.provide(Layer.mergeAll(output.layer, BunServices.layer)), Effect.tap(() => Effect.sync(() => { - expect(startedConfig).toMatchObject({ - capabilities: { - functions: { settings: { functions_root: "/tmp/project/supabase/functions" } }, - }, - }); - const config = startedConfig as { - capabilities: { - rest: { settings: { schemas: string[] } }; - auth: { settings: { jwt_secret: Redacted.Redacted } }; - database: { settings: { vault: { DB_PASSWORD: Redacted.Redacted } } }; - }; - }; - expect(config.capabilities.rest.settings.schemas).toEqual(["private"]); - expect(Redacted.value(config.capabilities.auth.settings.jwt_secret)).toBe( - "jwt-secret-for-tests", - ); - expect(Redacted.value(config.capabilities.database.settings.vault.DB_PASSWORD)).toBe( - "vault-secret", + const functions = startedConfig?.capabilities?.functions; + const functionSettings = + functions !== undefined && "settings" in functions ? functions.settings : undefined; + const hello = functionSettings?.functions?.hello; + expect(functionSettings?.functions_root).toBe("supabase/functions"); + expect(hello?.verify_jwt).toBe(false); + expect(hello?.import_map).toBe("supabase/functions/custom-deno.json"); + const configToken = hello?.env?.CONFIG_TOKEN; + const envToken = hello?.env?.ENV_TOKEN; + expect(configToken).toBeDefined(); + expect(envToken).toBeDefined(); + if (configToken !== undefined) expect(Redacted.value(configToken)).toBe("config-secret"); + if (envToken !== undefined) expect(Redacted.value(envToken)).toBe("env-secret"); + expect(startedConfig?.listeners?.functionsInspector).toEqual({ port: 8090 }); + expect(output.messages).toContainEqual( + expect.objectContaining({ message: "http://127.0.0.1:54321/functions/v1" }), ); - expect(out.messages.some((message) => message.message.includes("ready"))).toBe(true); + expect(output.messages.some((message) => message.message.includes("ready"))).toBe(true); }), ), ); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 6e81b3c96e..8e3d6fb9d7 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -1,41 +1,147 @@ -import { Effect, FileSystem, Option, Path, Stream } from "effect"; -import { loadCliConfig } from "@supabase/config/effect"; -import type { CliConfig } from "@supabase/config"; -import { createStack, findStack, openStack } from "@supabase/stack/effect"; -import { join } from "node:path"; +import { inferFunctionsManifest, loadCliConfig } from "@supabase/config/effect"; +import { CliConfigSchema } from "@supabase/config"; +import type { CliConfig, FunctionsManifest } from "@supabase/config"; +import { Crypto, FileSystem, Effect, Option, Path, Redacted, Scope, Schema, Stream } from "effect"; +import { parse as parseDotenv } from "dotenv"; +import { resolve } from "node:path"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + createStack, + findStack, + openStack, + type EffectStack, + type FindStackOptions, + type CreateStackOptions, +} from "@supabase/stack/effect"; +import type { StackDescriptor, StackStatus, StackId } from "@supabase/stack/effect"; import { CliProjectHome } from "../../../config/cli-project-home.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { toStartStackConfig } from "../../../config/stack-config.ts"; import type { FunctionsDevFlags } from "./dev.command.ts"; +/** The narrow stack surface owned by the managed Functions command. */ +export type ManagedFunctionsStack = Pick< + EffectStack, + "id" | "start" | "status" | "watchStatus" | "logs" | "close" +>; + +type ManagedFunctionsRuntime = + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner; + +export interface ServeManagedFunctionsOptions { + readonly projectRoot: string; + readonly stackName: string; + readonly envFile?: string; + readonly noVerifyJwt?: boolean; + readonly importMap?: string; + readonly inspect?: boolean; + readonly inspectMode?: "run" | "brk" | "wait"; + readonly inspectMain?: boolean; +} + export interface ServeManagedFunctionsOperations { - readonly findStack: typeof findStack; - readonly createStack: typeof createStack; - readonly openStack: typeof openStack; + readonly findStack: ( + options: FindStackOptions, + ) => Effect.Effect, unknown, ManagedFunctionsRuntime>; + readonly createStack: ( + options: CreateStackOptions, + ) => Effect.Effect; + readonly openStack: ( + id: StackId, + ) => Effect.Effect; readonly loadConfig: ( cwd: string, ) => Effect.Effect; + readonly loadManifest?: ( + cwd: string, + config: CliConfig, + ) => Effect.Effect; + readonly readEnvFile?: ( + pathname: string, + ) => Effect.Effect>, unknown, FileSystem.FileSystem>; } +const defaultOperations: Required = { + findStack, + createStack, + openStack, + loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), + loadManifest: (cwd, config) => inferFunctionsManifest({ cwd, config }), + readEnvFile: (pathname) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const contents = yield* fs.readFileString(pathname); + return yield* Effect.try({ + try: () => parseDotenv(contents), + catch: (error) => error, + }); + }), +}; + +const capabilityReady = (status: StackStatus): boolean => + status.capabilities.some( + (capability) => + capability.name === "functions" && + (capability.state === "ready" || capability.state === "dormant"), + ); + +const servingReady = (status: StackStatus): boolean => + status.lifecycle === "running" && status.endpoints.api !== undefined && capabilityReady(status); + +const statusAfterReadiness = Effect.fnUntraced(function* ( + stack: ManagedFunctionsStack, + initial: StackStatus, +) { + if (servingReady(initial)) return initial; + const next = yield* stack.watchStatus().pipe(Stream.filter(servingReady), Stream.runHead); + if (Option.isNone(next)) { + return yield* Effect.fail(new Error("Managed Functions stack did not reach gateway readiness")); + } + return next.value; +}); + +const toFunctionOverrides = ( + manifest: FunctionsManifest, + options: ServeManagedFunctionsOptions, + envFile: Readonly>, +) => + Object.fromEntries( + Object.entries(manifest).map(([slug, config]) => [ + slug, + { + enabled: config.enabled, + verify_jwt: options.noVerifyJwt === true ? false : config.verify_jwt, + import_map: options.importMap ?? config.import_map, + entrypoint: config.entrypoint, + static_files: config.static_files, + env: Object.fromEntries( + Object.entries({ ...config.env, ...envFile }).map(([name, value]) => [ + name, + Redacted.make(value), + ]), + ), + }, + ]), + ); + export const runFunctionsDevRuntime = Effect.fnUntraced(function* (flags: FunctionsDevFlags) { return yield* serveManagedFunctions({ projectRoot: (yield* CliProjectHome).projectRoot, stackName: flags.stack, + envFile: Option.getOrUndefined(flags.envFile), + noVerifyJwt: flags.noVerifyJwt, + importMap: Option.getOrUndefined(flags.importMap), }); }); /** Serves every local Function through the stack-owned Edge Runtime. */ export const serveManagedFunctions = Effect.fnUntraced(function* ( - options: { - readonly projectRoot: string; - readonly stackName: string; - }, - operations: ServeManagedFunctionsOperations = { - findStack, - createStack, - openStack, - loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), - }, + options: ServeManagedFunctionsOptions, + operations: ServeManagedFunctionsOperations = defaultOperations, ) { const output = yield* Output; yield* Effect.scoped( @@ -51,24 +157,35 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( runtime: { kind: "container" }, }) : yield* operations.openStack(descriptor.value.id); - const functionsRoot = join(options.projectRoot, "supabase", "functions"); const loadedConfig = yield* operations.loadConfig(options.projectRoot); - const translated = toStartStackConfig(loadedConfig, [], "docker", options.projectRoot); + const config = loadedConfig ?? Schema.decodeUnknownSync(CliConfigSchema)({}); + const manifest = yield* (operations.loadManifest ?? defaultOperations.loadManifest)( + options.projectRoot, + config, + ); + const envFile = + options.envFile === undefined + ? {} + : yield* (operations.readEnvFile ?? defaultOperations.readEnvFile)( + resolve(options.projectRoot, options.envFile), + ); + const translated = toStartStackConfig(loadedConfig, [], "docker"); const functionsCapability = translated.capabilities?.functions; - const functionsConfig = - functionsCapability !== undefined && - "enabled" in functionsCapability && - functionsCapability.enabled === false - ? { settings: { functions_root: functionsRoot } } - : { - ...functionsCapability, - settings: { - ...(functionsCapability !== undefined && "settings" in functionsCapability - ? functionsCapability.settings - : {}), - functions_root: functionsRoot, - }, - }; + const existingSettings = + functionsCapability !== undefined && "settings" in functionsCapability + ? functionsCapability.settings + : undefined; + const functionsConfig = { + ...(functionsCapability !== undefined ? functionsCapability : {}), + enabled: true, + settings: { + ...existingSettings, + functions_root: "supabase/functions", + functions: toFunctionOverrides(manifest, options, envFile), + }, + }; + const inspectRequested = options.inspect === true || options.inspectMode !== undefined; + const inspectorPort = loadedConfig?.edge_runtime?.inspector_port ?? 8083; const status = yield* stack.start({ config: { ...translated, @@ -76,13 +193,20 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( ...translated.capabilities, functions: functionsConfig, }, + listeners: { + ...translated.listeners, + ...(inspectRequested ? { functionsInspector: { port: inspectorPort } } : {}), + }, }, }); - yield* output.success(`Functions stack is ${status.lifecycle}.`, { + const ready = yield* statusAfterReadiness(stack, status); + const apiUrl = ready.endpoints.api?.url; + yield* output.success(`Functions stack is ${ready.lifecycle}.`, { stack: options.stackName, - functions_root: functionsRoot, - lifecycle: status.lifecycle, + functions_root: "supabase/functions", + lifecycle: ready.lifecycle, }); + if (apiUrl !== undefined) yield* output.info(`${apiUrl}/functions/v1`); yield* stack .logs({ capabilities: ["functions"], follow: true }) .pipe(Stream.runForEach((entry) => output.info(`[${entry.source}] ${entry.message}`))); diff --git a/apps/cli/src/next/commands/restart/restart.command.ts b/apps/cli/src/next/commands/restart/restart.command.ts new file mode 100644 index 0000000000..48d9ea917f --- /dev/null +++ b/apps/cli/src/next/commands/restart/restart.command.ts @@ -0,0 +1,26 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { DEFAULT_MANAGED_STACK_NAME } from "../../../shared/stack-constants.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { provideCliProjectCommandRuntime } from "../../config/project-runtime.layer.ts"; +import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { restart } from "./restart.handler.ts"; + +const flags = { + stack: Flag.string("stack").pipe( + Flag.withDescription("Name of the managed local stack for this project."), + Flag.withDefault(DEFAULT_MANAGED_STACK_NAME), + ), +} as const; + +export type RestartFlags = CliCommand.Command.Config.Infer; + +export const restartCommand = Command.make("restart", flags).pipe( + Command.withDescription("Restart the local Supabase development stack."), + Command.withShortDescription("Restart local Supabase stack"), + Command.withHandler((flags) => + restart(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(provideCliProjectCommandRuntime(commandRuntimeLayer(["restart"]))), +); diff --git a/apps/cli/src/next/commands/restart/restart.handler.ts b/apps/cli/src/next/commands/restart/restart.handler.ts new file mode 100644 index 0000000000..baac880063 --- /dev/null +++ b/apps/cli/src/next/commands/restart/restart.handler.ts @@ -0,0 +1,77 @@ +import { loadCliConfig } from "@supabase/config/effect"; +import type { CliConfig } from "@supabase/config"; +import { Crypto, Effect, FileSystem, Option, Path, Scope } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { + findStack, + openStack, + type EffectStack, + type FindStackOptions, +} from "@supabase/stack/effect"; +import type { StackDescriptor, StackId, StackStatus } from "@supabase/stack/effect"; +import { CliProjectHome } from "../../config/cli-project-home.service.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { StackNotFoundError } from "@supabase/stack/effect"; +import { toStartStackConfig } from "../../config/stack-config.ts"; +import type { RestartFlags } from "./restart.command.ts"; + +type RestartRuntime = + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner; + +export type RestartStack = Pick; +export interface RestartOperations { + readonly findStack: ( + options: FindStackOptions, + ) => Effect.Effect, unknown, RestartRuntime>; + readonly openStack: (id: StackId) => Effect.Effect; + readonly loadConfig: ( + cwd: string, + ) => Effect.Effect; +} + +const defaultOperations: RestartOperations = { + findStack, + openStack, + loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), +}; + +export const restart = Effect.fnUntraced(function* ( + flags: RestartFlags, + operations: RestartOperations = defaultOperations, +) { + const output = yield* Output; + const project = yield* CliProjectHome; + const descriptor = yield* operations.findStack({ + projectRoot: project.projectRoot, + name: flags.stack, + }); + if (Option.isNone(descriptor)) { + return yield* new StackNotFoundError({ message: "No local Supabase stack was found." }); + } + const loadedConfig = yield* operations.loadConfig(project.projectRoot); + const config = toStartStackConfig(loadedConfig, []); + yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* operations.openStack(descriptor.value.id); + const status: StackStatus = yield* stack.restart({ config }); + yield* output.success( + status.lifecycle === "running" + ? "Local Supabase stack restarted." + : `Local Supabase stack is ${status.lifecycle}.`, + { + stack: flags.stack, + lifecycle: status.lifecycle, + desired_lifecycle: status.desiredLifecycle, + runtime: status.runtime, + endpoints: status.endpoints, + capabilities: status.capabilities, + }, + ); + }), + ); + yield* output.outro(`Local Supabase stack ${flags.stack} restarted.`); +}); diff --git a/apps/cli/src/next/commands/restart/restart.integration.test.ts b/apps/cli/src/next/commands/restart/restart.integration.test.ts new file mode 100644 index 0000000000..a7b83b9830 --- /dev/null +++ b/apps/cli/src/next/commands/restart/restart.integration.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { StackIdSchema } from "@supabase/stack/effect"; +import type { StackStatus } from "@supabase/stack/effect"; +import { CAPABILITY_NAMES, StackUpgradeRequiredError } from "@supabase/stack/effect"; +import { mockCliProjectHome, mockOutput, emptyEnv } from "../../../../tests/helpers/mocks.ts"; +import { restart, type RestartOperations, type RestartStack } from "./restart.handler.ts"; + +const stackId = StackIdSchema.make( + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", +); +const status: StackStatus = { + id: stackId, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "functions" ? "lazy" : "eager", + state: name === "functions" ? "dormant" : "ready", + })), +}; + +describe("restart handler", () => { + it.live("restarts the managed stack with translated config", () => { + let receivedConfig: unknown; + const output = mockOutput({ interactive: false }); + const stack: RestartStack = { + restart: (options) => + Effect.sync(() => { + receivedConfig = options?.config; + return status; + }), + }; + const operations: RestartOperations = { + findStack: () => + Effect.succeed( + Option.some({ + id: stackId, + projectRoot: process.cwd(), + name: "default", + branchContext: "refs/heads/main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }), + ), + openStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(undefined), + }; + return restart({ stack: "default" }, operations).pipe( + Effect.provide( + Layer.mergeAll( + emptyEnv(), + output.layer, + mockCliProjectHome({ projectRoot: process.cwd() }), + ), + ), + Effect.tap(() => + Effect.sync(() => { + expect(receivedConfig).toMatchObject({ capabilities: expect.any(Object) }); + expect(output.messages).toContainEqual( + expect.objectContaining({ message: "Local Supabase stack restarted." }), + ); + }), + ), + ); + }); + + it.live("propagates an upgrade-required restart failure", () => { + const output = mockOutput({ interactive: false }); + const operations: RestartOperations = { + findStack: () => + Effect.succeed( + Option.some({ + id: stackId, + projectRoot: process.cwd(), + name: "default", + branchContext: "refs/heads/main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }), + ), + openStack: () => + Effect.succeed({ + restart: () => + Effect.fail(new StackUpgradeRequiredError({ message: "owner upgrade required" })), + }), + loadConfig: () => Effect.succeed(undefined), + }; + return restart({ stack: "default" }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), output.layer)), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index 4fe8956652..f7731397b5 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -1,6 +1,8 @@ -import { Effect } from "effect"; -import { createStack } from "@supabase/stack/effect"; +import { Crypto, Effect, FileSystem, Path, Scope } from "effect"; +import { createStack, type EffectStack, type CreateStackOptions } from "@supabase/stack/effect"; import { loadCliConfig } from "@supabase/config/effect"; +import type { CliConfig } from "@supabase/config"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; @@ -8,27 +10,49 @@ import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; import { runtimePreference, toStartStackConfig } from "../../config/stack-config.ts"; import type { StartFlags } from "./start.command.ts"; -export const start = Effect.fnUntraced(function* (flags: StartFlags) { +type StartRuntime = + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner; + +export type StartStack = Pick; +export interface StartOperations { + readonly createStack: ( + options: CreateStackOptions, + ) => Effect.Effect; + readonly loadConfig: ( + cwd: string, + ) => Effect.Effect; +} + +const defaultOperations: StartOperations = { + createStack, + loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), +}; + +export const start = Effect.fnUntraced(function* ( + flags: StartFlags, + operations: StartOperations = defaultOperations, +) { const output = yield* Output; const project = yield* CliProjectHome; const analytics = yield* Analytics; yield* output.intro("Start local Supabase stack"); yield* ensureProjectStateIgnored(project.projectRoot); - const loadedConfig = yield* loadCliConfig(project.projectRoot); + const loadedConfig = yield* operations.loadConfig(project.projectRoot); yield* Effect.scoped( Effect.gen(function* () { - const stack = yield* createStack({ + const stack = yield* operations.createStack({ projectRoot: project.projectRoot, name: flags.stack, runtime: runtimePreference(flags.mode), }); + const config = toStartStackConfig(loadedConfig, flags.exclude, flags.mode); + yield* stack.prepare({ config }); const status = yield* stack.start({ - config: toStartStackConfig( - loadedConfig?.config, - flags.exclude, - flags.mode, - project.projectRoot, - ), + config, }); yield* output.success( status.lifecycle === "running" diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index de0eb6f52d..dfc8352bf6 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer } from "effect"; -import { start } from "./start.handler.ts"; +import { start, type StartOperations, type StartStack } from "./start.handler.ts"; +import { CAPABILITY_NAMES, StackIdSchema } from "@supabase/stack/effect"; +import type { StackStatus } from "@supabase/stack/effect"; import { emptyEnv, mockOutput } from "../../../../tests/helpers/mocks.ts"; describe("start handler", () => { @@ -12,4 +14,52 @@ describe("start handler", () => { Effect.tap((exit) => Effect.sync(() => expect(Exit.isFailure(exit)).toBe(true))), ); }); + + it.live("prepares the translated config before starting the managed stack", () => { + const out = mockOutput({ interactive: false }); + const events: Array = []; + const id = StackIdSchema.make( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + const status: StackStatus = { + id, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "functions" ? "lazy" : "eager", + state: name === "functions" ? "dormant" : "ready", + })), + }; + const stack: StartStack = { + prepare: () => + Effect.sync(() => { + events.push("prepare"); + return { capabilities: [] }; + }), + start: () => + Effect.sync(() => { + events.push("start"); + return status; + }), + }; + const operations: StartOperations = { + createStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(undefined), + }; + return start({ stack: "default", mode: "native", exclude: [], detach: true }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(events).toEqual(["prepare", "start"]); + expect(out.messages).toContainEqual( + expect.objectContaining({ message: "Local Supabase stack is running." }), + ); + }), + ), + ); + }); }); diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts index b87cdfa68c..00eca8e284 100644 --- a/apps/cli/src/next/commands/update/update.handler.ts +++ b/apps/cli/src/next/commands/update/update.handler.ts @@ -35,7 +35,7 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { const cliProjectHome = yield* CliProjectHome; const projectLinkState = yield* ProjectLinkState; - yield* output.intro("Update local Supabase stack versions"); + yield* output.intro("Refresh linked project metadata"); yield* ensureProjectStateIgnored(cliProjectHome.projectRoot); const linkedState = yield* projectLinkState.load; diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 6353914ddb..1a07a82ac7 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -1,5 +1,4 @@ import { Redacted } from "effect"; -import { join } from "node:path"; import type { CliConfig } from "@supabase/config"; import type { AuthSettings, @@ -108,10 +107,8 @@ const toRestSettings = (api: CliConfig["api"] | undefined): RestSettings => ({ const toFunctionsSettings = ( edgeRuntime: CliConfig["edge_runtime"] | undefined, functions: CliConfig["functions"] | undefined, - projectRoot: string | undefined, ): FunctionsSettings => ({ - functions_root: - projectRoot === undefined ? "supabase/functions" : join(projectRoot, "supabase", "functions"), + functions_root: "supabase/functions", edge_runtime: edgeRuntime === undefined ? undefined @@ -236,7 +233,6 @@ export function toStartStackConfig( config: CliConfig | undefined, exclude: ReadonlyArray, mode?: StartMode, - projectRoot?: string, ): StackConfig { const excluded = new Set(exclude); const native = mode === "native"; @@ -252,7 +248,7 @@ export function toStartStackConfig( const analytics = config?.analytics; const pooler = db?.pooler; const databaseSettings = toDatabaseSettings(db); - const functionSettings = toFunctionsSettings(edgeRuntime, functions, projectRoot); + const functionSettings = toFunctionsSettings(edgeRuntime, functions); const authSettings = toAuthSettings(auth); const studioSettings = studio === undefined @@ -317,7 +313,9 @@ export function toStartStackConfig( edgeRuntime === undefined ? undefined : { port: edgeRuntime.inspector_port }, }, security: - auth?.jwt_secret === undefined && auth?.jwt_issuer === undefined + auth?.jwt_secret === undefined && + auth?.jwt_issuer === undefined && + auth?.signing_keys_path === undefined ? undefined : { jwt: { @@ -333,14 +331,6 @@ export function toStartStackConfig( }; } -export function withServiceVersions( - stackConfig: StackConfig, - versions: Partial>, -): StackConfig { - void versions; - return stackConfig; -} - export function runtimePreference(mode?: StartMode): StackRuntimePreference | undefined { return mode === undefined ? undefined diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index 012b1c7e2f..f3584e6994 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { toStartStackConfig, withServiceVersions } from "./stack-config.ts"; +import { CliConfigSchema } from "@supabase/config"; +import { Schema } from "effect"; +import { toStartStackConfig } from "./stack-config.ts"; describe("toStartStackConfig", () => { it("leaves runtime selection to the stack when mode is unset", () => { @@ -21,11 +23,20 @@ describe("toStartStackConfig", () => { expect(config.capabilities?.storage).toEqual({ enabled: false }); expect(config.capabilities?.rest).toMatchObject({ settings: expect.any(Object) }); }); -}); -describe("withServiceVersions", () => { - it("preserves capability configuration while catalog versions are stack-owned", () => { - const config = toStartStackConfig(undefined, [], "docker"); - expect(withServiceVersions(config, { postgres: "17.6.1" })).toEqual(config); + it("emits project-relative Functions roots and JWKS security from loaded config", () => { + const loaded = Schema.decodeUnknownSync(CliConfigSchema)({ + auth: { signing_keys_path: "supabase/jwks.json" }, + edge_runtime: { secrets: { EDGE_TOKEN: "env(EDGE_TOKEN)" } }, + functions: { hello: { env: { FUNCTION_TOKEN: "env(FUNCTION_TOKEN)" } } }, + }); + const config = toStartStackConfig(loaded, [], "native"); + expect(config.capabilities?.functions).toMatchObject({ + settings: { functions_root: "supabase/functions" }, + }); + expect(config.security?.jwt?.signing).toEqual({ + kind: "jwks-file", + path: "supabase/jwks.json", + }); }); }); diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 1981f25592..79620e661a 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -554,3 +554,14 @@ Implementation continues after recording a ruling; this file is not a question q - RED/GREEN evidence: facade integration (`promise.integration.test.ts`, `testing.integration.test.ts`) passed 3 tests; migrated CLI integration scenarios passed 36 tests across 12 files. Stack and CLI type checks passed; generic and Effect lint, oxfmt, and `git diff --check` passed. + +#### Fix round 1/5 evidence — 2026-08-29 + +- Canonical JSON schema decoding now covers nested Promise secret leaves; stream close and + `createTestStack` cleanup preserve primary failures while finalizing exact owned resources. +- CLI Functions serving maps loaded config/manifest/env-file and managed readiness/API/logs; roots are + project-relative, JWKS signing paths are emitted, and both top-level and `stack` restart commands + provide explicit restart guidance. +- Post-fix focused verification: stack facade integration 2 files/7 tests; CLI integration 13 files/40 + tests; CLI units 4 files/10 tests; both workspace types; generic/Effect lint, formatting, and diff + checks passed. diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 34a93b8eef..ae7abaf090 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -9,7 +9,6 @@ import { Redacted, Scope, Schema, - SchemaAST, Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -39,13 +38,14 @@ import type { PreparedCapability, PrepareStackResult } from "./EffectStack.ts"; // oxlint-disable effecttsgo/any-unknown-in-error-context -- Promise callers receive native rejection values. /** Recursively replaces Effect `Redacted` leaves with their plain value. */ -export type Unredacted = [T] extends [Redacted.Redacted] - ? Unredacted - : T extends readonly (infer Item)[] - ? ReadonlyArray> - : T extends object - ? { readonly [Key in keyof T]: Unredacted } - : T; +export type Unredacted = + T extends Redacted.Redacted + ? Unredacted + : T extends readonly (infer Item)[] + ? ReadonlyArray> + : T extends object + ? { readonly [Key in keyof T]: Unredacted } + : T; export type PromiseStackConfig = Unredacted; export type PromiseStartStackOptions = Omit & { @@ -85,77 +85,12 @@ type RuntimeRequirements = | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner; -const isRedactedSchema = (ast: SchemaAST.AST): boolean => { - if (ast._tag !== "Declaration") return false; - const representation = ast.annotations?.representation; - return ( - typeof representation === "object" && - representation !== null && - "id" in representation && - representation.id === "effect/schema/Redacted" - ); -}; - -const literalFields = (ast: SchemaAST.AST): ReadonlyArray => { - if (ast._tag !== "Objects") return []; - return ast.propertySignatures.filter((property) => property.type._tag === "Literal"); -}; - -const isRecord = (input: unknown): input is Record => - typeof input === "object" && input !== null && !Array.isArray(input); - -/** Wraps plain Promise config values according to the Effect config schema. */ -const redactBySchema = (ast: SchemaAST.AST, input: unknown): unknown => { - if (input === undefined || input === null) return input; - if (isRedactedSchema(ast)) return Redacted.isRedacted(input) ? input : Redacted.make(input); - switch (ast._tag) { - case "Objects": { - if (!isRecord(input)) return input; - const source = input; - const output: Record = { ...source }; - const properties = new Map( - ast.propertySignatures.map((property) => [property.name, property.type]), - ); - for (const [name, value] of Object.entries(source)) { - const property = properties.get(name); - if (property !== undefined) output[name] = redactBySchema(property, value); - else { - for (const index of ast.indexSignatures) { - output[name] = redactBySchema(index.type, value); - break; - } - } - } - return output; - } - case "Arrays": - return Array.isArray(input) - ? input.map((value, index) => - redactBySchema(ast.elements[index] ?? ast.rest[0] ?? ast, value), - ) - : input; - case "Union": { - if (input === undefined) return input; - const branch = - ast.types.find((candidate) => { - const fields = literalFields(candidate); - return ( - fields.length > 0 && - fields.every((field) => { - if (!isRecord(input)) return false; - const literal = field.type; - return literal._tag === "Literal" && input[field.name] === literal.literal; - }) - ); - }) ?? ast.types.find((candidate) => candidate._tag !== "Undefined"); - return branch === undefined ? input : redactBySchema(branch, input); - } - case "Suspend": - return redactBySchema(ast.thunk(), input); - default: - return input; - } -}; +// Canonical JSON decoding follows all schema transformations, including Redacted +// declarations nested in records and arrays. At this explicit Promise boundary, +// plain JSON strings become Effect Redacted values for the Effect handle. +const stackConfigJsonCodec = Schema.toCodecJson(StackConfigSchema); +const decodePromiseConfig = (input: PromiseStackConfig): StackConfig => + Schema.decodeSync(stackConfigJsonCodec)(input); /** Recursively unwraps every Redacted value at the Promise boundary. */ export function unredact(input: T): Unredacted; @@ -179,24 +114,48 @@ export const adaptEffectStack = ( handleScope?: Scope.Scope, ): PromiseStack => { let closed = false; + let closePromise: Promise | undefined; const active: Set<() => Promise> = new Set(); + const closedError = () => new Error("Stack handle is closed"); const iterable = (stream: Stream.Stream): AsyncIterable => ({ [Symbol.asyncIterator]() { + if (closed) { + return { + next: async () => Promise.reject(closedError()), + return: async () => ({ done: true, value: undefined }), + throw: async () => Promise.reject(closedError()), + }; + } const iterator = adaptStream(stream)[Symbol.asyncIterator](); const cancel = () => Promise.resolve(iterator.return?.()); active.add(cancel); return { - next: (...args: [] | [undefined]) => iterator.next(...args), + next: async (...args: [] | [undefined]) => { + try { + const result = await iterator.next(...args); + if (result.done === true) active.delete(cancel); + return result; + } catch (error) { + active.delete(cancel); + throw error; + } + }, return: async (value?: unknown) => { active.delete(cancel); return iterator.return?.(value) ?? { done: true, value: undefined }; }, - throw: iterator.throw, + throw: + iterator.throw === undefined + ? undefined + : async (error?: unknown) => { + active.delete(cancel); + return iterator.throw!(error); + }, }; }, }); const invoke = (effect: Effect.Effect): Promise => { - if (closed) return Promise.reject(new Error("Stack handle is closed")); + if (closed) return Promise.reject(closedError()); return Effect.runPromise(effect); }; return { @@ -213,11 +172,7 @@ export const adaptEffectStack = ( ? { ...options, config: - options.config === undefined - ? undefined - : Schema.decodeUnknownSync(StackConfigSchema)( - redactBySchema(StackConfigSchema.ast, options.config), - ), + options.config === undefined ? undefined : decodePromiseConfig(options.config), } : undefined, ), @@ -228,11 +183,7 @@ export const adaptEffectStack = ( options ? { config: - options.config === undefined - ? undefined - : Schema.decodeUnknownSync(StackConfigSchema)( - redactBySchema(StackConfigSchema.ast, options.config), - ), + options.config === undefined ? undefined : decodePromiseConfig(options.config), } : undefined, ), @@ -243,26 +194,45 @@ export const adaptEffectStack = ( options ? { config: - options.config === undefined - ? undefined - : Schema.decodeUnknownSync(StackConfigSchema)( - redactBySchema(StackConfigSchema.ast, options.config), - ), + options.config === undefined ? undefined : decodePromiseConfig(options.config), } : undefined, ), ), stop: () => invoke(effectStack.stop()), destroy: () => invoke(effectStack.destroy()), - close: async () => { - if (closed) return; + close: () => { + if (closePromise !== undefined) return closePromise; closed = true; - await Promise.all([...active].map((cancel) => cancel())); - try { - await Effect.runPromise(effectStack.close()); - } finally { - if (handleScope !== undefined) await Effect.runPromise(Scope.close(handleScope, Exit.void)); - } + closePromise = (async () => { + const failures: Array = []; + await Promise.all( + [...active].map(async (cancel) => { + try { + await cancel(); + } catch (error) { + failures.push(error); + } finally { + active.delete(cancel); + } + }), + ); + try { + await Effect.runPromise(effectStack.close()); + } catch (error) { + failures.push(error); + } finally { + if (handleScope !== undefined) { + try { + await Effect.runPromise(Scope.close(handleScope, Exit.void)); + } catch (error) { + failures.push(error); + } + } + } + if (failures.length > 0) throw new AggregateError(failures, "Failed to close stack handle"); + })(); + return closePromise; }, watchStatus: () => iterable(effectStack.watchStatus()), logs: (options) => iterable(effectStack.logs(options)), diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index 8cfc7b743a..1af335e78c 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -1,6 +1,6 @@ // oxlint-disable effecttsgo/async-function -- AsyncDisposable is the public test-resource contract. // oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource owns its exact temp directory. -import { rm } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource builds an isolated root. import { join } from "node:path"; @@ -20,46 +20,94 @@ export interface CreateTestStackOptions { export type TestStack = PromiseStack & AsyncDisposable; -/** Creates an isolated managed stack and destroys exactly that identity on disposal. */ -export const createTestStack = async (options: CreateTestStackOptions = {}): Promise => { - const projectRoot = await import("node:fs/promises").then(({ mkdtemp }) => - mkdtemp(join(tmpdir(), "supabase-stack-test-")), - ); +export interface TestStackOperations { + readonly createRoot: () => Promise; + readonly createStack: (options: CreateStackOptions) => Promise; + readonly removeRoot: (root: string) => Promise; +} + +const defaultOperations: TestStackOperations = { + createRoot: () => mkdtemp(join(tmpdir(), "supabase-stack-test-")), + createStack, + removeRoot: (root) => rm(root, { recursive: true, force: true }), +}; + +const waitForReadiness = async ( + stack: PromiseStack, + initial: Awaited>, +) => { + const ready = (status: typeof initial) => + status.lifecycle === "running" && + status.endpoints.api !== undefined && + status.capabilities.some( + (capability) => + capability.name === "functions" && + (capability.state === "ready" || capability.state === "dormant"), + ); + if (ready(initial)) return; + for await (const status of stack.watchStatus()) { + if (ready(status)) return; + } + throw new Error("Stack did not reach running readiness before its status stream ended"); +}; + +const cleanup = async ( + stack: PromiseStack | undefined, + root: string, + operations: TestStackOperations, + primary?: unknown, +) => { + let failure = primary; + if (stack !== undefined) { + try { + await stack.destroy(); + } catch (error) { + if (failure === undefined) failure = error; + } + try { + await stack.close(); + } catch (error) { + if (failure === undefined) failure = error; + } + } + try { + await operations.removeRoot(root); + } catch (error) { + if (failure === undefined) failure = error; + } + if (failure !== undefined) throw failure; +}; + +/** Internal seam used by integration tests; the package testing barrel exports only createTestStack. */ +export const createTestStackWith = async ( + options: CreateTestStackOptions = {}, + operations: TestStackOperations = defaultOperations, +): Promise => { + const projectRoot = await operations.createRoot(); let stack: PromiseStack | undefined; try { - stack = await createStack({ projectRoot, name: options.name, runtime: options.runtime }); + stack = await operations.createStack({ + projectRoot, + name: options.name, + runtime: options.runtime, + }); const started = await stack.start( options.config === undefined ? undefined : ({ config: options.config } satisfies PromiseStartStackOptions), ); - if (started.lifecycle !== "running") { - for await (const status of stack.watchStatus()) { - if (status.lifecycle === "running") break; - } - } + await waitForReadiness(stack, started); const resource = stack; return { ...resource, - [Symbol.asyncDispose]: async () => { - try { - await resource.destroy(); - } finally { - await resource.close(); - await rm(projectRoot, { recursive: true, force: true }); - } - }, + [Symbol.asyncDispose]: () => cleanup(resource, projectRoot, operations), }; } catch (error) { - if (stack !== undefined) { - try { - await stack.destroy(); - } catch { - // Preserve the startup failure while making a best-effort exact cleanup. - } - await stack.close().catch(() => undefined); - } - await rm(projectRoot, { recursive: true, force: true }); + await cleanup(stack, projectRoot, operations, error); throw error; } }; + +/** Creates an isolated managed stack and destroys exactly that identity on disposal. */ +export const createTestStack = (options: CreateTestStackOptions = {}): Promise => + createTestStackWith(options); diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index e0db0534ab..8fd635ea73 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -1,19 +1,41 @@ // oxlint-disable effecttsgo/async-function -- integration test exercises the Promise facade directly. import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { CAPABILITY_NAMES, type CapabilityStatus } from "./Capability.ts"; import { Effect, Redacted, Stream } from "effect"; -import type { EffectStack } from "./EffectStack.ts"; +import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./EffectStack.ts"; +import { StackIdSchema } from "./StackId.ts"; +import type { StackStatus } from "./Status.ts"; +import type { StackLogEntry } from "./Logs.ts"; +import { StackStateInvalidError } from "./Errors.ts"; import { adaptEffectStack, type PromiseStack } from "./PromiseStack.ts"; +import { compileStack } from "../model/Compiler.ts"; -const stackId = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" as never; +const stackId = StackIdSchema.make( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", +); -const status = { +const capabilities: ReadonlyArray = CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "functions" ? "lazy" : "eager", + state: name === "functions" ? "ready" : "dormant", +})); + +const status: StackStatus = { id: stackId, - lifecycle: "running" as const, - desiredLifecycle: "running" as const, - runtime: { kind: "native" as const }, - endpoints: {}, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: { + api: { + protocol: "http", + address: "127.0.0.1", + port: 54321, + url: "http://127.0.0.1:54321", + }, + }, versions: {}, - capabilities: [], + capabilities, }; const effectStack = (): EffectStack => @@ -36,7 +58,7 @@ const effectStack = (): EffectStack => secretAccessKey: Redacted.make("storage-secret"), }, }), - prepare: () => Effect.succeed({ capabilities: [] }), + prepare: (_options?: PrepareStackOptions) => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(status), restart: () => Effect.succeed(status), stop: () => Effect.void, @@ -77,4 +99,113 @@ describe("Promise stack facade", () => { await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); await stack.close(); }); + + it("redacts nested config secrets for prepare, start, and restart", async () => { + let preparedConfig: StartStackOptions["config"] | undefined; + let startedConfig: StartStackOptions["config"] | undefined; + let restartedConfig: StartStackOptions["config"] | undefined; + const source: EffectStack = { + ...effectStack(), + prepare: (options?: PrepareStackOptions) => + Effect.sync(() => { + preparedConfig = options?.config; + return { capabilities: [] }; + }), + start: (options?: StartStackOptions) => + Effect.sync(() => { + startedConfig = options?.config; + return status; + }), + restart: (options?: StartStackOptions) => + Effect.sync(() => { + restartedConfig = options?.config; + return status; + }), + }; + const stack = adaptEffectStack(source); + const config = { + capabilities: { + database: { settings: { vault: { DB_PASSWORD: "vault-secret" } } }, + storage: { settings: { buckets: { assets: { public: false } } } }, + auth: { settings: { external: { github: { secret: "github-secret" } } } }, + functions: { + settings: { + edge_runtime: { secrets: { EDGE_TOKEN: "edge-secret" } }, + functions: { + hello: { env: { FUNCTION_TOKEN: "function-secret" }, static_files: ["index.html"] }, + }, + }, + }, + }, + }; + + await stack.prepare({ config }); + await stack.start({ config }); + await stack.restart({ config }); + for (const value of [preparedConfig, startedConfig, restartedConfig]) { + const database = value?.capabilities?.database?.settings; + const auth = value?.capabilities?.auth; + const functions = value?.capabilities?.functions; + const authSettings = auth !== undefined && "settings" in auth ? auth.settings : undefined; + const functionSettings = + functions !== undefined && "settings" in functions ? functions.settings : undefined; + expect(Redacted.isRedacted(database?.vault?.DB_PASSWORD)).toBe(true); + expect(Redacted.isRedacted(authSettings?.external?.github?.secret)).toBe(true); + expect(Redacted.isRedacted(functionSettings?.edge_runtime?.secrets?.EDGE_TOKEN)).toBe(true); + expect(Redacted.isRedacted(functionSettings?.functions?.hello?.env?.FUNCTION_TOKEN)).toBe( + true, + ); + } + if (startedConfig === undefined) throw new Error("Expected Promise start to capture config"); + const compiled = await Effect.runPromise( + compileStack({ + projectRoot: "/tmp/promise-facade-project", + runtime: { kind: "native" }, + config: startedConfig, + }).pipe(Effect.provide(NodeServices.layer)), + ); + expect(compiled.definition.capabilities.functions.settings.functions_root).toBe( + "/tmp/promise-facade-project/supabase/functions", + ); + await stack.close(); + }); + + it("always closes the underlying handle when active stream cancellation rejects", async () => { + let underlyingClosed = false; + const cancellationError = new Error("stream cancellation failed"); + const entry: StackLogEntry = { + cursor: { opaque: "1" }, + timestamp: "now", + source: "functions", + stream: "stdout", + message: "running", + }; + const foreignStream: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: async () => ({ done: false, value: entry }), + return: async () => { + throw cancellationError; + }, + }), + }; + const source: EffectStack = { + ...effectStack(), + logs: () => + Stream.fromAsyncIterable( + foreignStream, + (error) => new StackStateInvalidError({ message: String(error) }), + ), + close: () => + Effect.sync(() => { + underlyingClosed = true; + }), + }; + const stack = adaptEffectStack(source); + const iterator = stack.logs()[Symbol.asyncIterator](); + await iterator.next(); + await expect(stack.close()).rejects.toThrow("Failed to close stack handle"); + expect(underlyingClosed).toBe(true); + await expect(stack.status()).rejects.toThrow("Stack handle is closed"); + await expect(stack.close()).rejects.toThrow("Failed to close stack handle"); + }); }); diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index a243b86007..a11f5e4a38 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -1,8 +1,142 @@ -import { describe, expect, it } from "@effect/vitest"; -import { createTestStack } from "../testing.ts"; +import { describe, expect, it } from "vitest"; +import type { PromiseStack } from "./PromiseStack.ts"; +import { createTestStackWith, type TestStackOperations } from "./Testing.ts"; +import { CAPABILITY_NAMES } from "./Capability.ts"; +import { StackIdSchema } from "./StackId.ts"; +import type { StackStatus } from "./Status.ts"; + +// Promise facade fixtures intentionally model async operations. +// oxlint-disable effecttsgo/async-function + +const stackId = StackIdSchema.make( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", +); + +const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ + id: stackId, + lifecycle, + desiredLifecycle: + lifecycle === "starting" || lifecycle === "stopping" + ? "running" + : lifecycle === "resetting-database" + ? "running" + : lifecycle, + runtime: { kind: "native" }, + endpoints: + lifecycle === "running" + ? { + api: { + protocol: "http", + address: "127.0.0.1", + port: 54321, + url: "http://127.0.0.1:54321", + }, + } + : {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "functions" ? "lazy" : "eager", + state: lifecycle === "running" ? (name === "functions" ? "dormant" : "ready") : "stopped", + })), +}); + +const stream = (values: ReadonlyArray): AsyncIterable => ({ + async *[Symbol.asyncIterator]() { + yield* values; + }, +}); + +const fakeStack = ( + events: Array, + failStart = false, + reachesReadiness = true, +): PromiseStack => ({ + id: stackId, + status: async () => status("running"), + credentials: async () => ({ + database: { url: "postgres://test", password: "test" }, + api: { + publishableKey: "publishable", + secretKey: "secret", + anonJwt: "anon", + serviceRoleJwt: "service", + }, + storage: { + endpoint: "http://storage", + region: "local", + accessKeyId: "access", + secretAccessKey: "storage", + }, + }), + prepare: async () => ({ capabilities: [] }), + start: async () => { + events.push("start"); + if (failStart) throw new Error("startup failed"); + return status("starting"); + }, + restart: async () => status("running"), + stop: async () => undefined, + destroy: async () => { + events.push("destroy"); + if (failStart) throw new Error("destroy failed"); + }, + close: async () => { + events.push("close"); + }, + watchStatus: () => stream([status(reachesReadiness ? "running" : "starting")]), + logs: () => stream([]), +}); describe("test stack resource", () => { - it("is the sole async-disposable stack resource", () => { - expect(typeof createTestStack).toBe("function"); + it("starts, waits for readiness, and destroys only its owned identity", async () => { + const events: Array = []; + const removed: Array = []; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-owned", + createStack: async (options) => { + events.push(`create:${options.projectRoot}`); + return fakeStack(events); + }, + removeRoot: async (root) => { + removed.push(root); + }, + }; + const stack = await createTestStackWith({}, operations); + await stack[Symbol.asyncDispose](); + expect(events).toEqual(["create:/tmp/stack-test-owned", "start", "destroy", "close"]); + expect(removed).toEqual(["/tmp/stack-test-owned"]); + }); + + it("preserves startup failure while cleaning up destroy, close, and temporary root", async () => { + const events: Array = []; + const removed: Array = []; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-failed", + createStack: async () => fakeStack(events, true), + removeRoot: async (root) => { + removed.push(root); + }, + }; + await expect(createTestStackWith({}, operations)).rejects.toThrow("startup failed"); + expect(events).toEqual(["start", "destroy", "close"]); + expect(removed).toEqual(["/tmp/stack-test-failed"]); + }); + + it("fails and cleans up when readiness status ends before becoming ready", async () => { + const events: Array = []; + const removed: Array = []; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-unready", + createStack: async () => fakeStack(events, false, false), + removeRoot: async (root) => { + removed.push(root); + }, + }; + await expect(createTestStackWith({}, operations)).rejects.toThrow( + "did not reach running readiness", + ); + expect(events).toEqual(["start", "destroy", "close"]); + expect(removed).toEqual(["/tmp/stack-test-unready"]); }); }); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index a71b2226c9..8bcd5c7b95 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -1 +1,2 @@ -export * from "./public/Testing.ts"; +export { createTestStack } from "./public/Testing.ts"; +export type { CreateTestStackOptions, TestStack } from "./public/Testing.ts"; From fb1e999c48e1ae0248e2efa8606bf2aea00ac43b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 08:00:37 +0200 Subject: [PATCH 022/174] fix(stack): close facade and CLI review gaps --- .../functions/serve/serve.integration.test.ts | 5 +- .../commands/functions/dev/dev.command.ts | 3 + .../functions-dev-runtime.integration.test.ts | 1 + .../functions/dev/functions-dev-runtime.ts | 22 ++++++- .../next/commands/restart/restart.command.ts | 2 + .../next/commands/restart/restart.handler.ts | 2 +- .../restart/restart.integration.test.ts | 8 ++- .../src/next/commands/start/start.handler.ts | 20 ++++-- .../commands/start/start.integration.test.ts | 37 ++++++++++- .../next/commands/update/update.handler.ts | 13 ++-- .../update/update.integration.test.ts | 65 ++++++++++++++++++- ...26-08-28-stack-runtime-rewrite-progress.md | 11 ++++ .../stack/src/model/capabilities/functions.ts | 9 +++ .../stack/src/model/capabilities/index.ts | 2 + packages/stack/src/public/Config.ts | 4 ++ packages/stack/src/public/Testing.ts | 31 +++++++-- .../src/public/promise.integration.test.ts | 16 +++++ .../src/public/testing.integration.test.ts | 58 ++++++++++++++--- 18 files changed, 275 insertions(+), 34 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 4c678666f8..6ddb49d4dc 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -21,8 +21,8 @@ const baseFlags = (): LegacyFunctionsServeFlags => ({ envFile: Option.none(), importMap: Option.some("supabase/functions/deno.json"), inspect: false, - inspectMode: Option.none(), - inspectMain: false, + inspectMode: Option.some("run"), + inspectMain: true, all: true, }); @@ -112,6 +112,7 @@ describe("legacy functions serve", () => { functions !== undefined && "settings" in functions ? functions.settings : undefined; const hello = functionSettings?.functions?.hello; expect(functionSettings?.functions_root).toBe("supabase/functions"); + expect(functionSettings?.inspector).toEqual({ mode: "run", main: true }); expect(hello?.verify_jwt).toBe(false); expect(hello?.import_map).toBe("supabase/functions/deno.json"); expect(output.messages).toContainEqual( diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts index 3010e403eb..dfc02d1f8f 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.command.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts @@ -12,6 +12,9 @@ const flags = { envFile: Flag.string("env-file").pipe(Flag.optional), importMap: Flag.string("import-map").pipe(Flag.optional), noVerifyJwt: Flag.boolean("no-verify-jwt").pipe(Flag.withDefault(false)), + inspect: Flag.boolean("inspect").pipe(Flag.withDefault(false)), + inspectMode: Flag.choice("inspect-mode", ["run", "brk", "wait"] as const).pipe(Flag.optional), + inspectMain: Flag.boolean("inspect-main").pipe(Flag.withDefault(false)), } as const; export type FunctionsDevFlags = CliCommand.Command.Config.Infer; diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts index a7b599e2cd..806fd9255a 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts @@ -110,6 +110,7 @@ describe("managed Functions serving", () => { functions !== undefined && "settings" in functions ? functions.settings : undefined; const hello = functionSettings?.functions?.hello; expect(functionSettings?.functions_root).toBe("supabase/functions"); + expect(functionSettings?.inspector).toEqual({ mode: "wait", main: true }); expect(hello?.verify_jwt).toBe(false); expect(hello?.import_map).toBe("supabase/functions/custom-deno.json"); const configToken = hello?.env?.CONFIG_TOKEN; diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 8e3d6fb9d7..074b8283f4 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -135,6 +135,9 @@ export const runFunctionsDevRuntime = Effect.fnUntraced(function* (flags: Functi envFile: Option.getOrUndefined(flags.envFile), noVerifyJwt: flags.noVerifyJwt, importMap: Option.getOrUndefined(flags.importMap), + inspect: flags.inspect, + inspectMode: Option.getOrUndefined(flags.inspectMode), + inspectMain: flags.inspectMain, }); }); @@ -170,6 +173,19 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( resolve(options.projectRoot, options.envFile), ); const translated = toStartStackConfig(loadedConfig, [], "docker"); + if (options.inspect && options.inspectMode !== undefined) { + return yield* Effect.fail( + new Error( + "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", + ), + ); + } + const inspectorMode = options.inspectMode ?? (options.inspect ? "brk" : undefined); + if (options.inspectMain && inspectorMode === undefined) { + return yield* Effect.fail( + new Error("--inspect-main must be used together with --inspect or --inspect-mode"), + ); + } const functionsCapability = translated.capabilities?.functions; const existingSettings = functionsCapability !== undefined && "settings" in functionsCapability @@ -181,10 +197,12 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( settings: { ...existingSettings, functions_root: "supabase/functions", + ...(inspectorMode === undefined + ? {} + : { inspector: { mode: inspectorMode, main: options.inspectMain === true } }), functions: toFunctionOverrides(manifest, options, envFile), }, }; - const inspectRequested = options.inspect === true || options.inspectMode !== undefined; const inspectorPort = loadedConfig?.edge_runtime?.inspector_port ?? 8083; const status = yield* stack.start({ config: { @@ -195,7 +213,7 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( }, listeners: { ...translated.listeners, - ...(inspectRequested ? { functionsInspector: { port: inspectorPort } } : {}), + ...(inspectorMode === undefined ? {} : { functionsInspector: { port: inspectorPort } }), }, }, }); diff --git a/apps/cli/src/next/commands/restart/restart.command.ts b/apps/cli/src/next/commands/restart/restart.command.ts index 48d9ea917f..26a466fa76 100644 --- a/apps/cli/src/next/commands/restart/restart.command.ts +++ b/apps/cli/src/next/commands/restart/restart.command.ts @@ -6,12 +6,14 @@ import { provideCliProjectCommandRuntime } from "../../config/project-runtime.la import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { restart } from "./restart.handler.ts"; +import { excludeFlag } from "../start/start.command.ts"; const flags = { stack: Flag.string("stack").pipe( Flag.withDescription("Name of the managed local stack for this project."), Flag.withDefault(DEFAULT_MANAGED_STACK_NAME), ), + exclude: excludeFlag, } as const; export type RestartFlags = CliCommand.Command.Config.Infer; diff --git a/apps/cli/src/next/commands/restart/restart.handler.ts b/apps/cli/src/next/commands/restart/restart.handler.ts index baac880063..a0c921979e 100644 --- a/apps/cli/src/next/commands/restart/restart.handler.ts +++ b/apps/cli/src/next/commands/restart/restart.handler.ts @@ -53,7 +53,7 @@ export const restart = Effect.fnUntraced(function* ( return yield* new StackNotFoundError({ message: "No local Supabase stack was found." }); } const loadedConfig = yield* operations.loadConfig(project.projectRoot); - const config = toStartStackConfig(loadedConfig, []); + const config = toStartStackConfig(loadedConfig, flags.exclude); yield* Effect.scoped( Effect.gen(function* () { const stack = yield* operations.openStack(descriptor.value.id); diff --git a/apps/cli/src/next/commands/restart/restart.integration.test.ts b/apps/cli/src/next/commands/restart/restart.integration.test.ts index a7b83b9830..be1b6d3a37 100644 --- a/apps/cli/src/next/commands/restart/restart.integration.test.ts +++ b/apps/cli/src/next/commands/restart/restart.integration.test.ts @@ -49,7 +49,7 @@ describe("restart handler", () => { openStack: () => Effect.succeed(stack), loadConfig: () => Effect.succeed(undefined), }; - return restart({ stack: "default" }, operations).pipe( + return restart({ stack: "default", exclude: ["auth"] }, operations).pipe( Effect.provide( Layer.mergeAll( emptyEnv(), @@ -59,7 +59,9 @@ describe("restart handler", () => { ), Effect.tap(() => Effect.sync(() => { - expect(receivedConfig).toMatchObject({ capabilities: expect.any(Object) }); + expect(receivedConfig).toMatchObject({ + capabilities: { auth: { enabled: false } }, + }); expect(output.messages).toContainEqual( expect.objectContaining({ message: "Local Supabase stack restarted." }), ); @@ -89,7 +91,7 @@ describe("restart handler", () => { }), loadConfig: () => Effect.succeed(undefined), }; - return restart({ stack: "default" }, operations).pipe( + return restart({ stack: "default", exclude: [] }, operations).pipe( Effect.provide(Layer.mergeAll(emptyEnv(), output.layer)), Effect.exit, Effect.tap((exit) => diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index f7731397b5..6bdd17149c 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -1,5 +1,10 @@ import { Crypto, Effect, FileSystem, Path, Scope } from "effect"; -import { createStack, type EffectStack, type CreateStackOptions } from "@supabase/stack/effect"; +import { + createStack, + StackUpgradeRequiredError, + type EffectStack, + type CreateStackOptions, +} from "@supabase/stack/effect"; import { loadCliConfig } from "@supabase/config/effect"; import type { CliConfig } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -51,9 +56,16 @@ export const start = Effect.fnUntraced(function* ( }); const config = toStartStackConfig(loadedConfig, flags.exclude, flags.mode); yield* stack.prepare({ config }); - const status = yield* stack.start({ - config, - }); + const status = yield* stack.start({ config }).pipe( + Effect.catchTag("StackUpgradeRequiredError", (error) => + Effect.fail( + new StackUpgradeRequiredError({ + message: `${error.message}; run supabase restart to apply it`, + guidance: "Run supabase restart to apply stopped-time changes", + }), + ), + ), + ); yield* output.success( status.lifecycle === "running" ? "Local Supabase stack is running." diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index dfc8352bf6..f7c09a3d70 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { start, type StartOperations, type StartStack } from "./start.handler.ts"; -import { CAPABILITY_NAMES, StackIdSchema } from "@supabase/stack/effect"; +import { CAPABILITY_NAMES, StackIdSchema, StackUpgradeRequiredError } from "@supabase/stack/effect"; import type { StackStatus } from "@supabase/stack/effect"; import { emptyEnv, mockOutput } from "../../../../tests/helpers/mocks.ts"; @@ -62,4 +62,37 @@ describe("start handler", () => { ), ); }); + + it.live("preserves explicit restart guidance when running input changed", () => { + const out = mockOutput({ interactive: false }); + const stack: StartStack = { + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => + Effect.fail( + new StackUpgradeRequiredError({ + message: "Running stack input changed; call restart explicitly to apply it", + guidance: "Use restart() to apply stopped-time changes", + }), + ), + }; + const operations: StartOperations = { + createStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(undefined), + }; + return start({ stack: "default", mode: "native", exclude: [], detach: true }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(failure).toBeInstanceOf(StackUpgradeRequiredError); + if (failure instanceof StackUpgradeRequiredError) + expect(failure.message).toContain("supabase restart"); + } + }), + ), + ); + }); }); diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts index 00eca8e284..8dc05642f0 100644 --- a/apps/cli/src/next/commands/update/update.handler.ts +++ b/apps/cli/src/next/commands/update/update.handler.ts @@ -39,7 +39,10 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { yield* ensureProjectStateIgnored(cliProjectHome.projectRoot); const linkedState = yield* projectLinkState.load; - if (Option.isSome(linkedState)) { + if (Option.isNone(linkedState)) { + yield* output.info("No linked project metadata is configured; nothing to refresh."); + yield* output.success("No linked project metadata to refresh.", { stack: flags.stack }); + } else { const refreshed = yield* refreshLinkedProjectSnapshot(linkedState.value.project.ref, []); const changedVersions = diffCachedLinkedVersions( linkedState.value.versions, @@ -63,8 +66,10 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { `Some remote service versions could not be fetched and will keep using CLI defaults: ${refreshed.linkedProject.unavailableServices.join(", ")}`, ); } + yield* output.success("Linked project metadata refreshed.", { + stack: flags.stack, + project: refreshed.linkedProject.ref, + }); } - - yield* output.success("Stack configuration is up to date.", { stack: flags.stack }); - yield* output.outro(`Stack ${flags.stack} is ready.`); + yield* output.outro(`Linked project metadata refresh complete for ${flags.stack}.`); }); diff --git a/apps/cli/src/next/commands/update/update.integration.test.ts b/apps/cli/src/next/commands/update/update.integration.test.ts index f9af053130..eca610925e 100644 --- a/apps/cli/src/next/commands/update/update.integration.test.ts +++ b/apps/cli/src/next/commands/update/update.integration.test.ts @@ -16,10 +16,71 @@ describe("update handler", () => { Layer.mergeAll(emptyEnv(), out.layer, mockProjectLinkState(), mockProjectLinkRemote()), ), Effect.tap(() => - Effect.sync(() => - expect(out.messages.some((message) => message.type === "success")).toBe(true), + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "No linked project metadata to refresh.", + }), + ); + expect( + out.messages.some((message) => message.message.includes("Stack configuration")), + ).toBe(false); + expect(out.messages.some((message) => message.message.includes("stack is ready"))).toBe( + false, + ); + }), + ), + Effect.exit, + Effect.tap((exit) => Effect.sync(() => expect(Exit.isSuccess(exit)).toBe(true))), + ); + }); + + it.live("reports only refreshed metadata for a linked project", () => { + const out = mockOutput({ interactive: false }); + return update({ stack: "default" }).pipe( + Effect.provide( + Layer.mergeAll( + emptyEnv(), + out.layer, + mockProjectLinkState({ + project: { + ref: "project-ref", + name: "Demo", + organization_id: "org-id", + organization_slug: "org", + }, + active_branch: { ref: "project-ref", name: "main", is_default: true }, + fetchedAt: "2026-08-29T00:00:00.000Z", + versions: { postgres: "15", postgrest: "v1", auth: "v1", storage: "v1" }, + }), + mockProjectLinkRemote({ + linkedProject: { + ref: "project-ref", + name: "Demo", + region: "eu", + status: "ACTIVE_HEALTHY", + versions: { postgres: "15", postgrest: "v1", auth: "v1", storage: "v1" }, + }, + }), ), ), + Effect.tap(() => + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Linked project metadata refreshed.", + }), + ); + expect( + out.messages.some((message) => message.message.includes("Stack configuration")), + ).toBe(false); + expect(out.messages.some((message) => message.message.includes("stack is ready"))).toBe( + false, + ); + }), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => expect(Exit.isSuccess(exit)).toBe(true))), ); diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 79620e661a..47bce0ba56 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -565,3 +565,14 @@ Implementation continues after recording a ruling; this file is not a question q - Post-fix focused verification: stack facade integration 2 files/7 tests; CLI integration 13 files/40 tests; CLI units 4 files/10 tests; both workspace types; generic/Effect lint, formatting, and diff checks passed. + +#### Fix round 2/5 evidence — 2026-08-29 + +- Closed Functions inspector mode/main settings preserve `run`/`brk`/`wait` and `--inspect-main` for + both serve registrations without eager activation. Test-resource readiness is now derived from + supplied config (disabled capabilities/listeners are exempt), with exact cleanup on close failure. +- Added natural/post-close Promise stream coverage, start changed-running `supabase restart` guidance, + restart exclusion preservation, and honest linked/unlinked update output. +- Focused verification: stack facade/model/compiler 3 files/40 tests; CLI Functions/start/restart/update + 5 files/9 tests plus legacy/config units 2 files/9 tests; both workspace types; lint/format/diff checks + passed. diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index e44914e195..aae22833ce 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -42,10 +42,19 @@ export const EdgeRuntimeSettingsSchema = Schema.Struct({ }); export type EdgeRuntimeSettings = Schema.Schema.Type; +export const FunctionsInspectorSettingsSchema = Schema.Struct({ + mode: Schema.optionalKey(Schema.Literals(["run", "brk", "wait"] as const)), + main: Schema.optionalKey(Schema.Boolean), +}); +export type FunctionsInspectorSettings = Schema.Schema.Type< + typeof FunctionsInspectorSettingsSchema +>; + export const FunctionsSettingsSchema = Schema.Struct({ functions_root: Schema.optionalKey(Schema.String), /** Edge Runtime behavior; capability enabled and functionsInspector port are public fields. */ edge_runtime: Schema.optionalKey(EdgeRuntimeSettingsSchema), + inspector: Schema.optionalKey(FunctionsInspectorSettingsSchema), functions: Schema.optionalKey(Schema.Record(FunctionSlug, FunctionOverrideSchema)), }); export type FunctionsSettings = Schema.Schema.Type; diff --git a/packages/stack/src/model/capabilities/index.ts b/packages/stack/src/model/capabilities/index.ts index 18828fe7ca..52f23a3cff 100644 --- a/packages/stack/src/model/capabilities/index.ts +++ b/packages/stack/src/model/capabilities/index.ts @@ -5,11 +5,13 @@ export { FunctionSettingsDefaults, FunctionsModule, FunctionsSettingsSchema, + FunctionsInspectorSettingsSchema, type FunctionOverride, type FunctionSecret, type FunctionSettings, type MaterializedFunctionSettings, type FunctionsSettings, + type FunctionsInspectorSettings, } from "./functions.ts"; export { MailModule, MailSettingsSchema, type MailSettings } from "./mail.ts"; export { PoolerModule, PoolerSettingsSchema, type PoolerSettings } from "./pooler.ts"; diff --git a/packages/stack/src/public/Config.ts b/packages/stack/src/public/Config.ts index 372d2f5c2e..ecae5466ee 100644 --- a/packages/stack/src/public/Config.ts +++ b/packages/stack/src/public/Config.ts @@ -6,7 +6,9 @@ import { RealtimeSettingsSchema, type RealtimeSettings } from "../model/capabili import { StorageSettingsSchema, type StorageSettings } from "../model/capabilities/storage.ts"; import { FunctionsSettingsSchema, + FunctionsInspectorSettingsSchema, type FunctionsSettings, + type FunctionsInspectorSettings, } from "../model/capabilities/functions.ts"; import { StudioSettingsSchema, type StudioSettings } from "../model/capabilities/studio.ts"; import { MailSettingsSchema, type MailSettings } from "../model/capabilities/mail.ts"; @@ -97,6 +99,7 @@ export { RealtimeSettingsSchema, StorageSettingsSchema, FunctionsSettingsSchema, + FunctionsInspectorSettingsSchema, StudioSettingsSchema, MailSettingsSchema, AnalyticsSettingsSchema, @@ -109,6 +112,7 @@ export type { RealtimeSettings, StorageSettings, FunctionsSettings, + FunctionsInspectorSettings, StudioSettings, MailSettings, AnalyticsSettings, diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index 1af335e78c..796793e5b9 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -35,14 +35,35 @@ const defaultOperations: TestStackOperations = { const waitForReadiness = async ( stack: PromiseStack, initial: Awaited>, + config: PromiseStackConfig | undefined, ) => { + const disabledCapabilities = new Set( + Object.entries(config?.capabilities ?? {}).flatMap(([name, capability]) => + capability !== undefined && "enabled" in capability && capability.enabled === false + ? [name] + : [], + ), + ); + const configuredListeners = Object.entries(config?.listeners ?? {}).flatMap( + ([name, listener]) => { + if (listener === undefined || ("enabled" in listener && listener.enabled === false)) { + return []; + } + return [name]; + }, + ); const ready = (status: typeof initial) => status.lifecycle === "running" && - status.endpoints.api !== undefined && - status.capabilities.some( + status.capabilities.every( (capability) => - capability.name === "functions" && - (capability.state === "ready" || capability.state === "dormant"), + disabledCapabilities.has(capability.name) || + capability.state === "ready" || + capability.state === "dormant", + ) && + configuredListeners.every((name) => + Object.entries(status.endpoints).some( + ([endpointName, endpoint]) => endpointName === name && endpoint !== undefined, + ), ); if (ready(initial)) return; for await (const status of stack.watchStatus()) { @@ -96,7 +117,7 @@ export const createTestStackWith = async ( ? undefined : ({ config: options.config } satisfies PromiseStartStackOptions), ); - await waitForReadiness(stack, started); + await waitForReadiness(stack, started, options.config); const resource = stack; return { ...resource, diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index 8fd635ea73..9b27198030 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -100,6 +100,22 @@ describe("Promise stack facade", () => { await stack.close(); }); + it("removes naturally completed streams and rejects new streams after close", async () => { + const stack = adaptEffectStack(effectStack()); + const statusIterator = stack.watchStatus()[Symbol.asyncIterator](); + await expect(statusIterator.next()).resolves.toMatchObject({ done: false }); + await expect(statusIterator.next()).resolves.toMatchObject({ done: true }); + const logsIterator = stack.logs()[Symbol.asyncIterator](); + await expect(logsIterator.next()).resolves.toMatchObject({ done: true }); + await stack.close(); + await expect(stack.watchStatus()[Symbol.asyncIterator]().next()).rejects.toThrow( + "Stack handle is closed", + ); + await expect(stack.logs()[Symbol.asyncIterator]().next()).rejects.toThrow( + "Stack handle is closed", + ); + }); + it("redacts nested config secrets for prepare, start, and restart", async () => { let preparedConfig: StartStackOptions["config"] | undefined; let startedConfig: StartStackOptions["config"] | undefined; diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index a11f5e4a38..f3e4d526a4 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -12,7 +12,11 @@ const stackId = StackIdSchema.make( "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", ); -const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ +const status = ( + lifecycle: StackStatus["lifecycle"], + includeApi = true, + functionsState: "ready" | "dormant" | "stopped" = "dormant", +): StackStatus => ({ id: stackId, lifecycle, desiredLifecycle: @@ -23,7 +27,7 @@ const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ : lifecycle, runtime: { kind: "native" }, endpoints: - lifecycle === "running" + lifecycle === "running" && includeApi ? { api: { protocol: "http", @@ -37,7 +41,7 @@ const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ capabilities: CAPABILITY_NAMES.map((name) => ({ name, activation: name === "functions" ? "lazy" : "eager", - state: lifecycle === "running" ? (name === "functions" ? "dormant" : "ready") : "stopped", + state: lifecycle === "running" ? (name === "functions" ? functionsState : "ready") : "stopped", })), }); @@ -51,9 +55,12 @@ const fakeStack = ( events: Array, failStart = false, reachesReadiness = true, + failClose = false, + includeApi = true, + functionsState: "ready" | "dormant" | "stopped" = "dormant", ): PromiseStack => ({ id: stackId, - status: async () => status("running"), + status: async () => status("running", includeApi, functionsState), credentials: async () => ({ database: { url: "postgres://test", password: "test" }, api: { @@ -73,7 +80,7 @@ const fakeStack = ( start: async () => { events.push("start"); if (failStart) throw new Error("startup failed"); - return status("starting"); + return status("starting", includeApi, functionsState); }, restart: async () => status("running"), stop: async () => undefined, @@ -83,8 +90,10 @@ const fakeStack = ( }, close: async () => { events.push("close"); + if (failClose) throw new Error("close failed"); }, - watchStatus: () => stream([status(reachesReadiness ? "running" : "starting")]), + watchStatus: () => + stream([status(reachesReadiness ? "running" : "starting", includeApi, functionsState)]), logs: () => stream([]), }); @@ -133,10 +142,41 @@ describe("test stack resource", () => { removed.push(root); }, }; - await expect(createTestStackWith({}, operations)).rejects.toThrow( - "did not reach running readiness", - ); + await expect( + createTestStackWith({ config: { capabilities: { database: {} } } }, operations), + ).rejects.toThrow("did not reach running readiness"); expect(events).toEqual(["start", "destroy", "close"]); expect(removed).toEqual(["/tmp/stack-test-unready"]); }); + + it("removes the exact root when disposal close fails and preserves that primary error", async () => { + const events: Array = []; + const removed: Array = []; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-close-failed", + createStack: async () => fakeStack(events, false, true, true), + removeRoot: async (root) => { + removed.push(root); + }, + }; + const stack = await createTestStackWith({}, operations); + await expect(stack[Symbol.asyncDispose]()).rejects.toThrow("close failed"); + expect(events).toEqual(["start", "destroy", "close"]); + expect(removed).toEqual(["/tmp/stack-test-close-failed"]); + }); + + it("does not require disabled Functions or an unconfigured API listener", async () => { + const events: Array = []; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-disabled-surfaces", + createStack: async () => fakeStack(events, false, true, false, false, "stopped"), + removeRoot: async () => undefined, + }; + const stack = await createTestStackWith( + { config: { capabilities: { functions: { enabled: false } } } }, + operations, + ); + await stack[Symbol.asyncDispose](); + expect(events).toEqual(["start", "destroy", "close"]); + }); }); From 36b4416297e12eeee98a756e023bb3e0a8191777 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 08:04:16 +0200 Subject: [PATCH 023/174] fix(stack): accept default-disabled test capabilities --- .../2026-08-28-stack-runtime-rewrite-progress.md | 8 ++++++++ packages/stack/src/public/Testing.ts | 1 + packages/stack/src/public/testing.integration.test.ts | 11 +++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 47bce0ba56..e788f1cbed 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -576,3 +576,11 @@ Implementation continues after recording a ruling; this file is not a question q - Focused verification: stack facade/model/compiler 3 files/40 tests; CLI Functions/start/restart/update 5 files/9 tests plus legacy/config units 2 files/9 tests; both workspace types; lint/format/diff checks passed. + +#### Fix round 3/5 evidence — 2026-08-29 + +- Test-resource readiness now treats the public `disabled` capability state as terminal-ready even for + default-disabled capabilities such as pooler. The ordinary isolated-stack fixture models pooler as + disabled and resolves successfully; explicit disabled Functions/API coverage remains intact. +- Focused testing integration (5 tests), stack types, Effect/generic lint, formatting, and diff checks + passed. diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index 796793e5b9..5595dd74b4 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -57,6 +57,7 @@ const waitForReadiness = async ( status.capabilities.every( (capability) => disabledCapabilities.has(capability.name) || + capability.state === "disabled" || capability.state === "ready" || capability.state === "dormant", ) && diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index f3e4d526a4..64e7c4a589 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -41,7 +41,14 @@ const status = ( capabilities: CAPABILITY_NAMES.map((name) => ({ name, activation: name === "functions" ? "lazy" : "eager", - state: lifecycle === "running" ? (name === "functions" ? functionsState : "ready") : "stopped", + state: + lifecycle === "running" + ? name === "pooler" + ? "disabled" + : name === "functions" + ? functionsState + : "ready" + : "stopped", })), }); @@ -98,7 +105,7 @@ const fakeStack = ( }); describe("test stack resource", () => { - it("starts, waits for readiness, and destroys only its owned identity", async () => { + it("starts with default-disabled pooler readiness and destroys only its owned identity", async () => { const events: Array = []; const removed: Array = []; const operations: TestStackOperations = { From 604bd8760b1f47a84d53e9b377a90550555450d3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 09:13:59 +0200 Subject: [PATCH 024/174] feat(stack): define the exhaustive workload catalog --- ...26-08-28-stack-runtime-rewrite-progress.md | 29 ++ packages/stack/src/model/CapabilityModule.ts | 5 + packages/stack/src/model/Compiler.ts | 36 ++- packages/stack/src/model/ExecutionPlan.ts | 16 +- packages/stack/src/model/WorkloadCatalog.ts | 235 ++++++++++++++ .../stack/src/model/capabilities/analytics.ts | 22 ++ .../stack/src/model/capabilities/pooler.ts | 6 +- .../stack/src/model/capabilities/storage.ts | 23 ++ .../src/model/catalog.integration.test.ts | 125 ++++++++ .../src/preparation/SlimServicesSource.ts | 205 ++++++++++++ .../slim-services.integration.test.ts | 140 +++++++++ packages/stack/src/runtime/ContainerEngine.ts | 3 + .../stack/src/runtime/ContainerRuntime.ts | 4 + packages/stack/src/runtime/DockerEngine.ts | 8 + packages/stack/src/runtime/PodmanEngine.ts | 8 + .../stack/src/runtime/WorkloadRuntimeSpec.ts | 292 ++++++++++++++++++ .../container-runtime.integration.test.ts | 12 + .../workload-runtime.integration.test.ts | 67 ++++ 18 files changed, 1230 insertions(+), 6 deletions(-) create mode 100644 packages/stack/src/model/WorkloadCatalog.ts create mode 100644 packages/stack/src/model/catalog.integration.test.ts create mode 100644 packages/stack/src/preparation/SlimServicesSource.ts create mode 100644 packages/stack/src/preparation/slim-services.integration.test.ts create mode 100644 packages/stack/src/runtime/WorkloadRuntimeSpec.ts create mode 100644 packages/stack/src/runtime/workload-runtime.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index e788f1cbed..a20175070d 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -584,3 +584,32 @@ Implementation continues after recording a ruling; this file is not a question q disabled and resolves successfully; explicit disabled Functions/API coverage remains intact. - Focused testing integration (5 tests), stack types, Effect/generic lint, formatting, and diff checks passed. + +### Task 13A — 2026-08-29 (foundation) + +| Capability | Native/container artifacts | Settings/dependencies | Ports/routes/probes | Secrets/logs/activation | Evidence | +| -------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------- | +| database | `postgres` slim release; `supabase/postgres` image | database settings and bootstrap workload | database TCP readiness | managed database password; eager | compiler + runtime integration | +| rest | `postgrest`; `postgrest/postgrest` | REST settings/env; database dependency | API HTTP route; private HTTP readiness | passthrough settings; lazy activation | compiler + workload-runtime | +| auth | `auth`; `supabase/auth` | Auth settings/env and managed key slots; database dependency | API HTTP route; private `/health` readiness | managed JWT/key slots; lazy activation | compiler + workload-runtime | +| realtime | `realtime`; `supabase/realtime` | Realtime settings/env; database dependency | API HTTP route; private `/api/ping` readiness | passthrough settings; lazy activation | compiler + workload-runtime integration | +| storage (+ imgproxy) | `storage`/`imgproxy`; storage-api/imgproxy images | Storage settings/env and volume; image transformation selects companion | API route/readiness; companion private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | +| functions | Edge Runtime slim artifact/container | functions root, policy, inspector, per-function env | `/functions/v1/*` route; request-time activation and private `/_internal/health` | functionsRoot is the sole read-only `/workspace` mount | catalog + workload-runtime + container-runtime | +| studio (+ pgmeta) | studio/pgmeta slim artifacts; studio/pg-meta images | Studio settings/env; REST/analytics deps | Studio HTTP route; private profile/health probes | passthrough settings; eager activation | compiler + workload-runtime integration | +| mail | mailpit slim artifact/container | Mail settings/env | mail UI/SMTP/POP3 listeners; private `/readyz` probe | passthrough settings; eager activation | compiler + workload-runtime integration | +| analytics (+ vector) | logflare/vector slim artifacts; Logflare/vector images | Analytics settings/env; vector selected by `vector_port`; database dependency | API route/readiness; vector private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | +| pooler | `supavisor` slim release `v2.9.12`; matching image | Pooler settings/env and database dependency | pooler TCP route/readiness; private transaction endpoint | managed pooler key slots; eager activation | compiler + catalog + workload-runtime integration | + +Common Task 13A source/test coverage: `model/WorkloadCatalog.ts` is the exhaustive slim-services identity table +with darwin-arm64, linux-amd64, and linux-arm64 target validation; `ExecutionPlan` materializes optional +companions from persisted settings; `runtime/WorkloadRuntimeSpec.ts` resolves every workload's command, args, +cwd, environment, private endpoint and readiness metadata; container specs carry env/commands and +Docker/Podman serializers emit them. `preparation/SlimServicesSource.ts` verifies manifest/checksum metadata, +decompresses and safely extracts archives, and composes with the atomic `ArtifactStore`; tests inject transport +and archive bytes and never access the network. Task 13B still owns Supervisor/RuntimeFactory production +composition, owner-scoped `StackGateway` lifecycle, exact private/public port allocation, credentials, and +redacting runtime logs. Functions remains stack-owned Edge Runtime; `functions serve` remains a client. + +Narrow rulings and cost if wrong: Task 13A keeps transport and process/engine boundaries injectable so tests +remain offline and deterministic. Task 13B must preserve the native/container identity split and make +private-port reservation plus gateway ownership atomic with accepted lifecycle generations. diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index 33ba081ae7..d3e816941a 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -47,6 +47,11 @@ export interface CapabilityModule { /** Managed slots required by the artifact even when omitted from user config. */ readonly managedSecretSlots: ReadonlyArray; readonly materialize: (settings: Settings, projectRoot: string) => Settings; + /** Selects private companion workloads from fully materialized settings. */ + readonly selectWorkloads?: ( + settings: unknown, + workloads: ReadonlyArray, + ) => ReadonlyArray; } export interface CapabilityRelease { diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 12cb5d5d6c..c4daf5d76e 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -514,9 +514,23 @@ const planForDefinition = ( analytics: definition.capabilities.analytics.version, pooler: definition.capabilities.pooler.version, }; + const settings = { + database: definition.capabilities.database.settings, + rest: definition.capabilities.rest.settings, + auth: definition.capabilities.auth.settings, + realtime: definition.capabilities.realtime.settings, + storage: definition.capabilities.storage.settings, + functions: definition.capabilities.functions.settings, + studio: definition.capabilities.studio.settings, + mail: definition.capabilities.mail.settings, + analytics: definition.capabilities.analytics.settings, + pooler: definition.capabilities.pooler.settings, + }; const specHashes = new Map(); return hashDefinitionWorkloads(definition, crypto, specHashes).pipe( - Effect.flatMap(() => createExecutionPlan(runtime, enabled, specHashes, versions)), + Effect.flatMap(() => + createExecutionPlan(runtime, enabled, specHashes, versions, CAPABILITY_MODULES, settings), + ), ); }; @@ -798,7 +812,25 @@ export const compileStack = ( analytics: analytics.version, pooler: pooler.version, }; - const executionPlan = yield* createExecutionPlan(input.runtime, enabled, specHashes, versions); + const executionPlan = yield* createExecutionPlan( + input.runtime, + enabled, + specHashes, + versions, + CAPABILITY_MODULES, + { + database: database.settings, + rest: rest.settings, + auth: auth.settings, + realtime: realtime.settings, + storage: storage.settings, + functions: functions.settings, + studio: studio.settings, + mail: mail.settings, + analytics: analytics.settings, + pooler: pooler.settings, + }, + ); return { definition, inputFingerprint, secrets: slots, executionPlan }; }).pipe( Effect.catchTag("PlatformError", (error) => diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index 911139cdba..26055080f3 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -73,6 +73,18 @@ export const createExecutionPlan = ( specHashes: ReadonlyMap, versions: Readonly<{ [Name in CapabilityName]: string }>, modules: typeof CAPABILITY_MODULES = CAPABILITY_MODULES, + settings: Readonly<{ [Name in CapabilityName]: unknown }> = { + database: undefined, + rest: undefined, + auth: undefined, + realtime: undefined, + storage: undefined, + functions: undefined, + studio: undefined, + mail: undefined, + analytics: undefined, + pooler: undefined, + }, ): Effect.Effect => { const dependencyMap = { database: modules.database.dependencies, @@ -141,7 +153,9 @@ export const createExecutionPlan = ( if (!enabled[name].enabled) return []; const release = modules[name].releases[versions[name]]; if (release === undefined) return []; - return release.workloads.map((entry) => ({ + const selectedEntries = + modules[name].selectWorkloads?.(settings[name], release.workloads) ?? release.workloads; + return selectedEntries.map((entry) => ({ id: `${name}:${entry.name}`, capability: name, ...(entry.bootstrap === undefined ? {} : { bootstrap: entry.bootstrap }), diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts new file mode 100644 index 0000000000..8bdc525125 --- /dev/null +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -0,0 +1,235 @@ +import type { PlannedWorkload } from "./ExecutionPlan.ts"; +import { Effect } from "effect"; +import { StackPreparationError } from "../public/Errors.ts"; + +export type NativeTarget = "darwin-arm64" | "linux-amd64" | "linux-arm64"; + +export interface NativeWorkloadArtifact { + readonly provider: "supabase/slim-services"; + readonly service: string; + readonly version: string; + readonly releaseTag: string; + readonly target: NativeTarget; + readonly archive: "tar.zst"; + readonly assetName: string; + readonly downloadUrl: string; + readonly manifestUrl: string; + readonly checksumUrl: string; + readonly requiredRuntimePaths: ReadonlyArray; + readonly executablePath: string; +} + +export interface WorkloadCatalogEntry { + readonly workloadId: string; + readonly service: string; + readonly nativeVersion: string; + readonly supportedNativeVersions: ReadonlyArray; + readonly containerImage: string; + readonly requiredRuntimePaths: ReadonlyArray; + readonly executablePath: string; +} + +const native = ( + workloadId: string, + service: string, + nativeVersion: string, + containerImage: string, + executablePath: string, + requiredRuntimePaths: ReadonlyArray = [executablePath], + supportedNativeVersions: ReadonlyArray = [nativeVersion], +): WorkloadCatalogEntry => ({ + workloadId, + service, + nativeVersion, + supportedNativeVersions, + containerImage, + requiredRuntimePaths, + executablePath, +}); + +/** The single authoritative private workload identity table. */ +export const WORKLOAD_CATALOG: Readonly> = { + "database:database": native( + "database:database", + "postgres", + "17.6.1.165", + "supabase/postgres:17.6.1.165", + "share/supabase-cli/bin/supabase-postgres-init.sh", + [ + "bin/postgres", + "bin/pg_isready", + "bin/psql", + "share/supabase-cli/bin/supabase-postgres-init.sh", + "share/supabase-cli/config/pgsodium_getkey.sh", + "share/supabase-cli/migrations", + "lib", + ], + ["14.1.0.89", "15.8.1.085", "17.6.1.165"], + ), + "rest:rest": native( + "rest:rest", + "postgrest", + "v16.2", + "postgrest/postgrest:v16.2", + "bin/postgrest", + ), + "auth:auth": native("auth:auth", "auth", "v2.196.0", "supabase/auth:v2.196.0", "bin/auth"), + "realtime:realtime": native( + "realtime:realtime", + "realtime", + "v2.129.9", + "supabase/realtime:v2.129.9", + "app/bin/server", + ["usr/bin/tini", "usr/bin/sh", "app/entry.sh", "app/bin/server"], + ), + "storage:storage": native( + "storage:storage", + "storage", + "v1.71.0", + "supabase/storage-api:v1.71.0", + "app/dist/start/server.js", + ["node/bin/node", "app/dist/start/server.js"], + ), + "storage:imgproxy": native( + "storage:imgproxy", + "imgproxy", + "v3.8.0", + "darthsim/imgproxy:v3.8.0", + "bin/imgproxy", + ), + "functions:edge-runtime": native( + "functions:edge-runtime", + "edge-runtime", + "v1.74.3", + "supabase/edge-runtime:v1.74.3", + "bin/.edge-runtime-wrapped", + ), + "studio:studio": native( + "studio:studio", + "studio", + "2026.08.24-sha-8ec45b2", + "supabase/studio:2026.08.24-sha-8ec45b2", + "app/apps/studio/server.js", + ["node/bin/node", "app/apps/studio/docker-entrypoint.mjs", "app/apps/studio/server.js"], + ), + "studio:pgmeta": native( + "studio:pgmeta", + "pgmeta", + "v0.98.0", + "supabase/pg-meta:v0.98.0", + "app/dist/server/server.js", + ["node/bin/node", "app/dist/server/server.js"], + ["0.98.0", "v0.98.0"], + ), + "mail:mail": native("mail:mail", "mailpit", "v1.30.2", "axllent/mailpit:v1.30.2", "bin/mailpit"), + "analytics:analytics": native( + "analytics:analytics", + "analytics", + "v1.50.6", + "supabase/logflare:v1.50.6", + "app/entry.sh", + ["usr/bin/tini", "usr/bin/sh", "app/entry.sh"], + ), + "analytics:vector": native( + "analytics:vector", + "vector", + "0.53.0", + "ghcr.io/supabase/vector:0.53.0-alpine", + "bin/vector", + ["bin/vector"], + ["0.53.0", "0.53.0-alpine"], + ), + "pooler:pooler": native( + "pooler:pooler", + "pooler", + "v2.9.12", + "ghcr.io/supabase/supavisor:v2.9.12", + "app/bin/server", + [ + "usr/bin/tini", + "usr/bin/sh", + "app/entry.sh", + "app/bin/migrate", + "app/bin/supavisor", + "app/bin/server", + ], + ), +}; + +export const targetForPlatform = (platform: { + readonly os: string; + readonly arch: string; +}): NativeTarget | undefined => { + if (platform.os === "darwin" && platform.arch === "arm64") return "darwin-arm64"; + if (platform.os === "linux" && platform.arch === "x64") return "linux-amd64"; + if (platform.os === "linux" && platform.arch === "arm64") return "linux-arm64"; + return undefined; +}; + +const artifactFor = ( + entry: WorkloadCatalogEntry, + target: NativeTarget, + releaseOverride?: string, +): NativeWorkloadArtifact => { + const version = releaseOverride ?? entry.nativeVersion; + const releaseTag = `${entry.service}-${version}`; + const assetName = `${releaseTag}-${target}`; + const base = `https://github.com/supabase/slim-services/releases/download/${releaseTag}`; + return { + provider: "supabase/slim-services", + service: entry.service, + version, + releaseTag, + target, + archive: "tar.zst", + assetName, + downloadUrl: `${base}/${assetName}.tar.zst`, + manifestUrl: `${base}/${assetName}.manifest.json`, + checksumUrl: `${base}/SHA256SUMS`, + requiredRuntimePaths: entry.requiredRuntimePaths, + executablePath: entry.executablePath, + }; +}; + +/** Effect-native resolver used by preparation/runtime boundaries. */ +export const resolveNativeArtifactForWorkload = ( + workload: Pick, + platform: { readonly os: string; readonly arch: string } = { + os: process.platform, + arch: process.arch, + }, +): Effect.Effect => { + const entry = WORKLOAD_CATALOG[workload.id]; + if (entry === undefined) + return Effect.fail( + new StackPreparationError({ + message: `Unknown workload catalog entry: ${workload.id}`, + workload: workload.id, + }), + ); + const target = targetForPlatform(platform); + if (target === undefined) + return Effect.fail( + new StackPreparationError({ + message: `Native runtime is unsupported on ${platform.os}/${platform.arch}`, + workload: workload.id, + platform: `${platform.os}/${platform.arch}`, + }), + ); + const normalizedVersion = + workload.id === "studio:pgmeta" + ? "v0.98.0" + : workload.id === "analytics:vector" + ? "0.53.0" + : workload.artifacts.native.release; + if (!entry.supportedNativeVersions.includes(workload.artifacts.native.release)) + return Effect.fail( + new StackPreparationError({ + message: `Unsupported native release ${workload.artifacts.native.release}`, + workload: workload.id, + service: entry.service, + version: workload.artifacts.native.release, + }), + ); + return Effect.succeed(artifactFor(entry, target, normalizedVersion)); +}; diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index d94d93880a..abce4f2d96 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -42,5 +42,27 @@ export const AnalyticsModule: CapabilityModule = { routes: [{ listener: "api", protocol: "http" }], secretPolicy: () => "passthrough", managedSecretSlots: [], + selectWorkloads: (settings, workloads) => { + const value = + typeof settings === "object" && settings !== null && !Array.isArray(settings) + ? Object.fromEntries(Object.entries(settings)).vector_port + : undefined; + const enabled = typeof value === "number"; + return workloads + .filter((entry) => enabled || entry.name !== "vector") + .map((entry) => + entry.name === "analytics" + ? { + ...entry, + dependencies: enabled + ? [ + ...entry.dependencies.filter((dependency) => dependency !== "analytics:vector"), + "analytics:vector", + ] + : entry.dependencies.filter((dependency) => dependency !== "analytics:vector"), + } + : entry, + ); + }, materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts index ce45f96b31..32fb4cdbb6 100644 --- a/packages/stack/src/model/capabilities/pooler.ts +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -24,11 +24,11 @@ export const PoolerModule: CapabilityModule = { }, defaultEnabled: false, defaultActivation: "eager", - defaultVersion: "2.9.7", + defaultVersion: "v2.9.12", dependencies: ["database"], releases: { - "2.9.7": release("2.9.7", [ - workload("pooler", "pooler", "2.9.7", "ghcr.io/supabase/supavisor:2.9.7", { + "v2.9.12": release("v2.9.12", [ + workload("pooler", "pooler", "v2.9.12", "ghcr.io/supabase/supavisor:v2.9.12", { dependencies: ["database:database"], readiness: { mode: "tcp", portField: "pooler" }, }), diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index 7fab4731cb..3ffa315774 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -69,6 +69,29 @@ export const StorageModule: CapabilityModule = { routes: [{ listener: "api", protocol: "http" }], secretPolicy: () => "passthrough", managedSecretSlots: [], + selectWorkloads: (settings, workloads) => { + const record = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value)) + : undefined; + const value = record(settings)?.image_transformation; + const enabled = record(value)?.enabled === true; + return workloads + .filter((entry) => enabled || entry.name !== "imgproxy") + .map((entry) => + entry.name === "storage" + ? { + ...entry, + dependencies: enabled + ? [ + ...entry.dependencies.filter((dependency) => dependency !== "storage:imgproxy"), + "storage:imgproxy", + ] + : entry.dependencies.filter((dependency) => dependency !== "storage:imgproxy"), + } + : entry, + ); + }, materialize: (settings) => ({ ...settings, buckets: Object.fromEntries( diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts new file mode 100644 index 0000000000..34245fc22f --- /dev/null +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -0,0 +1,125 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; +import { compileStack } from "./Compiler.ts"; +import { resolveNativeArtifactForWorkload, targetForPlatform } from "./WorkloadCatalog.ts"; +import { StackPreparationError } from "../public/Errors.ts"; + +const compile = (config: Parameters[0]["config"]) => + compileStack({ projectRoot: "/tmp/catalog-project", runtime: { kind: "native" }, config }).pipe( + Effect.provide(NodeServices.layer), + ); + +describe("complete workload catalog", () => { + it.live("resolves every declared workload to a slim-services native release", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { + storage: { settings: { image_transformation: { enabled: true } } }, + analytics: { settings: { vector_port: 9001 } }, + }, + }); + expect(result.executionPlan.workloads.length).toBeGreaterThan(10); + for (const workload of result.executionPlan.workloads) { + const artifact = yield* resolveNativeArtifactForWorkload(workload); + expect(artifact.provider).toBe("supabase/slim-services"); + expect(artifact.downloadUrl).toContain( + "github.com/supabase/slim-services/releases/download", + ); + expect(artifact.requiredRuntimePaths.length).toBeGreaterThan(0); + expect(artifact.executablePath).toBeDefined(); + } + }), + ); + + it.live("materialized settings control optional companion workloads", () => + Effect.gen(function* () { + const defaults = yield* compile({}); + expect(defaults.executionPlan.workloads.some(({ id }) => id === "storage:imgproxy")).toBe( + false, + ); + expect(defaults.executionPlan.workloads.some(({ id }) => id === "analytics:vector")).toBe( + false, + ); + + const enabled = yield* compile({ + capabilities: { + storage: { settings: { image_transformation: { enabled: true } } }, + analytics: { settings: { vector_port: 9001 } }, + }, + }); + expect(enabled.executionPlan.workloads.some(({ id }) => id === "storage:imgproxy")).toBe( + true, + ); + expect(enabled.executionPlan.workloads.some(({ id }) => id === "analytics:vector")).toBe( + true, + ); + }), + ); + + it.live("derives native database artifacts from the planned release", () => + Effect.gen(function* () { + const compiled = yield* compile({ capabilities: { database: { version: "14" } } }); + const database = compiled.executionPlan.workloads.find( + ({ id }) => id === "database:database", + ); + expect(database).toBeDefined(); + if (database === undefined) return; + const artifact = yield* resolveNativeArtifactForWorkload(database, { + os: "linux", + arch: "x64", + }); + expect(artifact.version).toBe("14.1.0.89"); + expect(artifact.downloadUrl).toContain("postgres-14.1.0.89-linux-amd64.tar.zst"); + }), + ); + + it.live("rejects an exact native release absent from the catalog", () => + Effect.gen(function* () { + const compiled = yield* compile({}); + const database = compiled.executionPlan.workloads.find( + ({ id }) => id === "database:database", + ); + expect(database).toBeDefined(); + if (database === undefined) return; + const unsupported = { + ...database, + artifacts: { + ...database.artifacts, + native: { ...database.artifacts.native, release: "99.0.0" }, + }, + }; + const failed = yield* resolveNativeArtifactForWorkload(unsupported, { + os: "linux", + arch: "x64", + }).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + if (Exit.isFailure(failed)) { + const error = Option.getOrUndefined(Cause.findErrorOption(failed.cause)); + expect(error).toBeInstanceOf(StackPreparationError); + } + }), + ); + + it.live("rejects unsupported native targets before selecting an artifact", () => { + expect(targetForPlatform({ os: "win32", arch: "x64" })).toBeUndefined(); + expect(targetForPlatform({ os: "darwin", arch: "x64" })).toBeUndefined(); + return Effect.gen(function* () { + const compiled = yield* compile({}); + const database = compiled.executionPlan.workloads.find( + ({ id }) => id === "database:database", + ); + expect(database).toBeDefined(); + if (database === undefined) return; + const failed = yield* resolveNativeArtifactForWorkload(database, { + os: "win32", + arch: "x64", + }).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + if (Exit.isFailure(failed)) { + const error = Option.getOrUndefined(Cause.findErrorOption(failed.cause)); + expect(error).toBeInstanceOf(StackPreparationError); + } + }); + }); +}); diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts new file mode 100644 index 0000000000..9f555be9f8 --- /dev/null +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -0,0 +1,205 @@ +import { Effect, FileSystem, Schema } from "effect"; +import { zstdDecompress } from "node:zlib"; +import type { ArtifactRequest, ArtifactSource } from "./ArtifactStore.ts"; +import type { NativeWorkloadArtifact } from "../model/WorkloadCatalog.ts"; +import { StackPreparationError } from "../public/Errors.ts"; + +type Fetcher = (input: string, init?: RequestInit) => Promise; +// The slim-services transport is a foreign HTTP boundary; production wiring +// may provide an Effect HttpClient-backed fetcher through RuntimeFactory. +// oxlint-disable-next-line effecttsgo/global-fetch +const fetcher: Fetcher = (input, init) => globalThis.fetch(input, init); + +const fetchBytes = ( + url: string, + request: Fetcher = fetcher, +): Effect.Effect => + Effect.tryPromise({ + try: (signal) => + request(url, { signal }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.arrayBuffer(); + }) + .then((bytes) => new Uint8Array(bytes)), + catch: (cause) => new StackPreparationError({ message: `Unable to download ${url}`, cause }), + }); + +const decompress = (bytes: Uint8Array): Effect.Effect => + Effect.callback((resume) => { + let done = false; + const complete = (effect: Effect.Effect) => { + if (done) return; + done = true; + resume(effect); + }; + try { + zstdDecompress(bytes, (cause, output) => + complete( + cause === null + ? Effect.succeed(output) + : Effect.fail( + new StackPreparationError({ + message: "Unable to decompress slim-services archive", + cause, + }), + ), + ), + ); + } catch (cause) { + complete( + Effect.fail( + new StackPreparationError({ + message: "Unable to decompress slim-services archive", + cause, + }), + ), + ); + } + return Effect.void; + }); + +const checksumFor = (contents: string, archiveName: string): string | undefined => + contents + .split(/\r?\n/u) + .map((line) => line.trim().split(/\s+/u)) + .find((parts) => parts[1] === archiveName || parts[1]?.endsWith(`/${archiveName}`))?.[0]; + +export const slimServicesChecksum = ( + artifact: NativeWorkloadArtifact, + request: Fetcher = fetcher, +): Effect.Effect => + fetchBytes(artifact.checksumUrl, request).pipe( + Effect.map((bytes) => new TextDecoder().decode(bytes)), + Effect.flatMap((contents) => { + const checksum = checksumFor(contents, `${artifact.assetName}.tar.zst`); + return checksum === undefined || !/^[a-f0-9]{64}$/iu.test(checksum) + ? Effect.fail( + new StackPreparationError({ + message: "Slim-services checksum is missing", + service: artifact.service, + version: artifact.version, + }), + ) + : Effect.succeed(checksum.toLowerCase()); + }), + ); + +const text = (bytes: Uint8Array): string => new TextDecoder().decode(bytes).replace(/\0+$/u, ""); +const octal = (bytes: Uint8Array): number => Number.parseInt(text(bytes).trim() || "0", 8); + +const writeFile = (fs: FileSystem.FileSystem, path: string, bytes: Uint8Array) => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(path, { flag: "wx", mode: 0o700 }); + yield* file.writeAll(bytes); + }), + ); + +const extractTar = ( + fs: FileSystem.FileSystem, + destination: string, + archive: Uint8Array, +): Effect.Effect => + Effect.gen(function* () { + let offset = 0; + let pendingPath: string | undefined; + while (offset + 512 <= archive.length) { + const header = archive.subarray(offset, offset + 512); + offset += 512; + if (header.every((byte) => byte === 0)) break; + const name = text(header.subarray(0, 100)); + const prefix = text(header.subarray(345, 500)); + const member = pendingPath ?? (prefix.length === 0 ? name : `${prefix}/${name}`); + const unsafe = + member.startsWith("/") || + member.split("/").some((part) => part === ".." || (part.length === 0 && part !== "")); + if (unsafe) + return yield* new StackPreparationError({ + message: "Slim-services archive contains an unsafe path", + path: member, + }); + const size = octal(header.subarray(124, 136)); + const type = header[156]; + const target = `${destination}/${member}`; + if (type === 120 || type === 76) { + const pax = text(archive.subarray(offset, offset + size)); + pendingPath = + type === 76 + ? pax + : pax + .split(/\n/u) + .find((record) => record.includes(" path=")) + ?.split(" path=")[1] + ?.trim(); + } else if (type === 53) { + yield* fs.makeDirectory(target, { recursive: true, mode: 0o700 }); + } else if (type === 48 || type === 0 || type === undefined) { + const parent = target.slice(0, target.lastIndexOf("/")); + yield* fs.makeDirectory(parent, { recursive: true, mode: 0o700 }); + yield* writeFile(fs, target, archive.subarray(offset, offset + size)); + } else { + return yield* new StackPreparationError({ + message: "Slim-services archive contains an unsupported entry", + path: member, + }); + } + if (type !== 120 && type !== 76) pendingPath = undefined; + offset += Math.ceil(size / 512) * 512; + } + }).pipe( + Effect.mapError((error) => + error instanceof StackPreparationError + ? error + : new StackPreparationError({ + message: "Unable to extract slim-services archive", + cause: error, + }), + ), + ); + +export const makeSlimServicesSource = ( + resolve: (request: ArtifactRequest) => NativeWorkloadArtifact | undefined, + fetchRequest: Fetcher = fetcher, +): ArtifactSource => ({ + materialize: (request, destination) => { + const artifact = resolve(request); + if (artifact === undefined) + return Effect.fail( + new StackPreparationError({ message: `No slim-services source for ${request.key}` }), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const manifestBytes = yield* fetchBytes(artifact.manifestUrl, fetchRequest); + const manifestText = new TextDecoder().decode(manifestBytes); + const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + manifestText, + ).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ message: "Slim-services manifest is invalid", cause }), + ), + ); + if ( + typeof manifest !== "object" || + manifest === null || + !("service" in manifest) || + !("version" in manifest) || + !("target" in manifest) || + manifest.service !== artifact.service || + manifest.version !== artifact.version || + manifest.target !== artifact.target + ) + return yield* new StackPreparationError({ + message: "Slim-services manifest does not match the catalog artifact", + service: artifact.service, + version: artifact.version, + target: artifact.target, + }); + const compressed = yield* fetchBytes(artifact.downloadUrl, fetchRequest); + const archive = yield* decompress(compressed); + yield* extractTar(fs, destination, archive); + return compressed; + }); + }, +}); diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts new file mode 100644 index 0000000000..8c99cfa203 --- /dev/null +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -0,0 +1,140 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Crypto, Effect, Exit, FileSystem, Option } from "effect"; +import { zstdCompress } from "node:zlib"; +import { makeArtifactStore, type ArtifactRequest } from "./ArtifactStore.ts"; +import { digestHex } from "./Integrity.ts"; +import { makeSlimServicesSource, slimServicesChecksum } from "./SlimServicesSource.ts"; +import type { NativeWorkloadArtifact } from "../model/WorkloadCatalog.ts"; + +const artifact: NativeWorkloadArtifact = { + provider: "supabase/slim-services", + service: "demo", + version: "v1.0.0", + releaseTag: "demo-v1.0.0", + target: "linux-amd64", + archive: "tar.zst", + assetName: "demo-v1.0.0-linux-amd64", + downloadUrl: "https://example.test/demo.tar.zst", + manifestUrl: "https://example.test/demo.manifest.json", + checksumUrl: "https://example.test/SHA256SUMS", + requiredRuntimePaths: ["bin/demo"], + executablePath: "bin/demo", +}; +const request: ArtifactRequest = { + key: "demo/v1", + sha256: "0".repeat(64), + requiredRuntimePaths: ["bin/demo"], + executablePath: "bin/demo", +}; + +const tar = (name: string, content: string): Uint8Array => { + const bytes = new Uint8Array(1024); + const header = bytes.subarray(0, 512); + header.set(new TextEncoder().encode(name), 0); + const size = content.length.toString(8).padStart(11, "0"); + header.set(new TextEncoder().encode(`${size}\0`), 124); + header[156] = 48; + header.set(new TextEncoder().encode("ustar\0"), 257); + bytes.set(new TextEncoder().encode(content), 512); + return bytes; +}; + +const compress = (bytes: Uint8Array) => + Effect.callback((resume) => { + zstdCompress(bytes, (error, output) => + error === null ? resume(Effect.succeed(output)) : resume(Effect.fail(error)), + ); + return Effect.void; + }); + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; +type FetchLike = ( + input: Parameters[0], + init?: Parameters[1], +) => ReturnType; +const requestUrl = (input: Parameters[0]): string => + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + +describe("slim-services artifact source", () => { + it.live("verifies checksums and extracts a manifest-matched archive using injected fetch", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const checksums = "0".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"; + const fetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.endsWith("SHA256SUMS")) return Promise.resolve(new Response(checksums)); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(archive)); + }; + expect(yield* slimServicesChecksum(artifact, fetcher)).toBe("0".repeat(64)); + const source = makeSlimServicesSource(() => artifact, fetcher); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-source-" }); + yield* source.materialize(request, destination); + expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("rejects archive members that escape the artifact root", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("../outside", "unsafe")); + const fetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(archive)); + }; + const source = makeSlimServicesSource(() => artifact, fetcher); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-unsafe-" }); + const failed = yield* source.materialize(request, destination).pipe(Effect.exit); + expect(errorOf(failed)).toBeDefined(); + expect(yield* fs.exists(`${destination}/outside`)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("publishes the extracted slim artifact through the verified store", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const crypto = yield* Crypto.Crypto; + const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const checksums = `${expected} demo-v1.0.0-linux-amd64.tar.zst\n`; + const fetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.endsWith("SHA256SUMS")) return Promise.resolve(new Response(checksums)); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(archive)); + }; + const source = makeSlimServicesSource(() => artifact, fetcher); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-store-" }); + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const prepared = yield* store.prepare({ ...request, sha256: expected }); + expect(prepared.outcome).toBe("downloaded"); + expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); + expect(yield* fs.exists(`${prepared.path}/.artifact.json`)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); +}); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index b5d49dab54..66dca37135 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -99,6 +99,7 @@ export interface ContainerContainerSpec { readonly hostRoute?: ContainerHostRoute; readonly role: "workload" | "gateway"; readonly command?: ReadonlyArray; + readonly env?: Readonly>; } export type ContainerCommand = @@ -124,6 +125,7 @@ export interface ContainerCommandResult { export interface ContainerProcessRequest { readonly args: ReadonlyArray; readonly stdin?: string; + readonly env?: Readonly>; } export interface ContainerCommandRunner { readonly executable: string; @@ -206,6 +208,7 @@ export const makeProcessCommandRunner = ( stdin, stdout: "pipe", stderr: "pipe", + ...(request.env === undefined ? {} : { env: request.env }), extendEnv: true, }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), (handle) => diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 176e5b1966..eea2b08cd2 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -37,6 +37,8 @@ export interface ContainerRuntimeOptions { export interface ContainerWorkloadResolution { readonly mounts?: ReadonlyArray; readonly volume?: ContainerVolumeRequest; + readonly env?: Readonly>; + readonly command?: ReadonlyArray; readonly waitForReadiness?: ( key: RuntimeWorkloadKey, workload: PlannedWorkload, @@ -350,6 +352,8 @@ export const makeContainerRuntime = ( publications: [], volumeMounts: volumeRequest === undefined ? [] : [volumeMountFor(key, volumeRequest)], role: "workload", + ...(resolution.env === undefined ? {} : { env: resolution.env }), + ...(resolution.command === undefined ? {} : { command: resolution.command }), } satisfies ContainerContainerSpec), ); created = true; diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index 349548edfc..34c2b43eb6 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -153,6 +153,13 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc command.spec.hostRoute?.gateway === undefined ? [] : ["--add-host", `${command.spec.hostRoute.host}:${command.spec.hostRoute.gateway}`]; + const environment = + command.spec.env === undefined + ? [] + : Object.entries(command.spec.env).flatMap(([name, value]) => [ + "--env", + `${name}=${value}`, + ]); return { args: [ "create", @@ -165,6 +172,7 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc ...volumeMounts, ...publications, ...hostRoute, + ...environment, command.spec.image, ...(command.spec.command ?? []), ], diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index 08bb6b76f0..dc635f435a 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -150,6 +150,13 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc `${port.hostPort}:${port.containerPort}`, ]) : []; + const environment = + command.spec.env === undefined + ? [] + : Object.entries(command.spec.env).flatMap(([name, value]) => [ + "--env", + `${name}=${value}`, + ]); return { args: [ "create", @@ -161,6 +168,7 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc ...bindMounts, ...volumeMounts, ...publications, + ...environment, command.spec.image, ...(command.spec.command ?? []), ], diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts new file mode 100644 index 0000000000..d6df3ea900 --- /dev/null +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -0,0 +1,292 @@ +import type { PersistedStackState } from "../state/StackState.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; + +/** Runtime-facing details which are deliberately private to each workload. + * + * Public listeners are owned by StackGateway. These values describe the + * process/container side only: executable arguments, environment, and the + * loopback port used by the gateway after lazy activation. + */ +export interface WorkloadRuntimeSpec { + readonly containerPort: number; + readonly cwd: (state: PersistedStackState, workload: PlannedWorkload) => string; + readonly privateEndpoint: ( + port: number, + ) => Readonly<{ readonly host: string; readonly port: number }>; + readonly args: ( + state: PersistedStackState, + workload: PlannedWorkload, + port: number, + ) => ReadonlyArray; + readonly env: ( + state: PersistedStackState, + workload: PlannedWorkload, + port: number, + ) => Readonly>; + readonly containerArgs?: ( + state: PersistedStackState, + workload: PlannedWorkload, + port: number, + ) => ReadonlyArray; + readonly readiness: Readonly<{ readonly protocol: "http" | "tcp"; readonly path?: string }>; +} + +type WorkloadRuntimeSpecDefinition = Omit & + Partial>; + +const defaultCwd = (state: PersistedStackState): string => state.identity.projectRoot; +const defaultPrivateEndpoint = ( + port: number, +): Readonly<{ readonly host: string; readonly port: number }> => ({ + host: "127.0.0.1", + port, +}); + +const stringSetting = (state: PersistedStackState, capability: string, key: string): string => { + const settings = + state.definition === undefined + ? undefined + : Object.entries(state.definition.capabilities).find(([name]) => name === capability)?.[1] + ?.settings; + if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return ""; + const value = Object.fromEntries(Object.entries(settings))[key]; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if ( + typeof value === "object" && + value !== null && + "slot" in value && + typeof value.slot === "string" + ) + return state.secrets[value.slot]?.value ?? ""; + return ""; +}; + +const secret = (state: PersistedStackState, slot: string): string => + state.secrets[slot]?.value ?? ""; +const dbPort = (state: PersistedStackState): number => + state.ports.find((assignment) => assignment.field === "database")?.port ?? 5432; +const dbUrl = (state: PersistedStackState, role = "postgres"): string => + `postgresql://${role}:${secret(state, "secret:database.internal.password")}@127.0.0.1:${dbPort(state)}/postgres`; + +const common = (workload: PlannedWorkload, port: number): Record => ({ + SUPABASE_STACK_WORKLOAD: workload.id, + SUPABASE_STACK_PRIVATE_PORT: String(port), +}); + +const functionsRoot = (state: PersistedStackState): string => { + const settings = state.definition?.capabilities.functions.settings; + if (typeof settings !== "object" || settings === null || Array.isArray(settings)) + return `${state.identity.projectRoot}/supabase/functions`; + const value = Object.fromEntries(Object.entries(settings)).functions_root; + return typeof value === "string" ? value : `${state.identity.projectRoot}/supabase/functions`; +}; + +const specs: Readonly> = { + "database:database": { + containerPort: 5432, + args: (state, _workload, port) => ["-p", String(port), "-c", "listen_addresses=127.0.0.1"], + env: (state, workload, port) => ({ + ...common(workload, port), + PGDATA: `${state.identity.projectRoot}/.supabase/db/data`, + POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), + TZDIR: "/var/db/timezone/zoneinfo", + }), + containerArgs: (_state, _workload, port) => ["-p", String(port), "-c", "listen_addresses=*"], + readiness: { protocol: "tcp" }, + }, + "rest:rest": { + containerPort: 3000, + args: () => [], + env: (state, workload, port) => ({ + ...common(workload, port), + PGRST_DB_URI: dbUrl(state, "authenticator"), + PGRST_DB_SCHEMAS: stringSetting(state, "rest", "schemas") || "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: + stringSetting(state, "rest", "extra_search_path") || "public,extensions", + PGRST_DB_ANON_ROLE: "anon", + PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + PGRST_SERVER_PORT: String(port), + PGRST_DB_MAX_ROWS: stringSetting(state, "rest", "max_rows") || "1000", + }), + readiness: { protocol: "http", path: "/" }, + }, + "auth:auth": { + containerPort: 9999, + args: () => [], + env: (state, workload, port) => ({ + ...common(workload, port), + GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin"), + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_SITE_URL: stringSetting(state, "auth", "site_url") || "http://127.0.0.1", + GOTRUE_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + GOTRUE_JWT_EXP: stringSetting(state, "auth", "jwt_expiry") || "3600", + GOTRUE_JWT_AUD: "authenticated", + GOTRUE_API_HOST: "127.0.0.1", + GOTRUE_API_PORT: String(port), + API_EXTERNAL_URL: stringSetting(state, "auth", "external_url") || "http://127.0.0.1", + }), + readiness: { protocol: "http", path: "/health" }, + }, + "realtime:realtime": { + containerPort: 4000, + args: () => [], + env: (state, workload, port) => ({ + ...common(workload, port), + PORT: String(port), + DB_HOST: "127.0.0.1", + DB_PORT: String(dbPort(state)), + DB_USER: "postgres", + DB_PASSWORD: secret(state, "secret:database.internal.password"), + DB_NAME: "postgres", + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + API_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + APP_NAME: "realtime", + SEED_SELF_HOST: "true", + }), + readiness: { protocol: "http", path: "/api/ping" }, + }, + "storage:storage": { + containerPort: 5000, + args: () => [], + env: (state, workload, port) => ({ + ...common(workload, port), + PORT: String(port), + ANON_KEY: secret(state, "secret:auth.settings.anon_key"), + SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), + AUTH_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + DATABASE_URL: dbUrl(state, "supabase_storage_admin"), + FILE_SIZE_LIMIT: stringSetting(state, "storage", "file_size_limit") || "50MiB", + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: "/var/lib/storage", + STORAGE_FILE_BACKEND_PATH: "/var/lib/storage", + ENABLE_IMAGE_TRANSFORMATION: + stringSetting(state, "storage", "image_transformation") || "false", + S3_PROTOCOL_ENABLED: stringSetting(state, "storage", "s3_protocol") || "true", + S3_PROTOCOL_ACCESS_KEY_ID: "local", + S3_PROTOCOL_ACCESS_KEY_SECRET: "local-secret", + }), + readiness: { protocol: "http", path: "/status" }, + }, + "storage:imgproxy": { + containerPort: 8080, + args: () => [], + env: (state, workload, port) => ({ + ...common(workload, port), + IMGPROXY_BIND: `127.0.0.1:${port}`, + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + }), + readiness: { protocol: "http", path: "/health" }, + }, + "functions:edge-runtime": { + containerPort: 9000, + cwd: functionsRoot, + args: (state, _workload, port) => [ + "start", + `--main-service=${functionsRoot(state)}`, + `--port=${port}`, + `--policy=${stringSetting(state, "functions", "edge_runtime") || "per_worker"}`, + ], + env: (state, workload, port) => ({ + ...common(workload, port), + EDGE_RUNTIME_PORT: String(port), + FUNCTIONS_ROOT: functionsRoot(state), + }), + containerArgs: (_state, _workload, port) => [ + "start", + "--main-service=/workspace", + `--port=${port}`, + ], + readiness: { protocol: "http", path: "/_internal/health" }, + }, + "studio:studio": { + containerPort: 3000, + args: () => [], + env: (state, workload, port) => ({ + ...common(workload, port), + PORT: String(port), + HOSTNAME: "127.0.0.1", + SUPABASE_URL: stringSetting(state, "studio", "api_url") || "http://127.0.0.1", + }), + readiness: { protocol: "http", path: "/api/platform/profile" }, + }, + "studio:pgmeta": { + containerPort: 8080, + args: () => [], + env: (state, workload, port) => ({ + ...common(workload, port), + PG_META_PORT: String(port), + PG_META_DB_HOST: "127.0.0.1", + PG_META_DB_PORT: String(dbPort(state)), + PG_META_DB_NAME: "postgres", + PG_META_DB_USER: "postgres", + PG_META_DB_PASSWORD: secret(state, "secret:database.internal.password"), + }), + readiness: { protocol: "http", path: "/health" }, + }, + "mail:mail": { + containerPort: 8025, + args: (_state, _workload, port) => ["--ui", `127.0.0.1:${port}`], + env: (state, workload, port) => ({ + ...common(workload, port), + MP_UI_BIND_ADDR: `127.0.0.1:${port}`, + MP_SMTP_BIND_ADDR: "127.0.0.1:1025", + MP_POP3_BIND_ADDR: "127.0.0.1:1110", + }), + readiness: { protocol: "http", path: "/readyz" }, + }, + "analytics:analytics": { + containerPort: 4000, + args: () => [], + env: (state, workload, port) => ({ + ...common(workload, port), + PORT: String(port), + PHX_HTTP_PORT: String(port), + DB_HOSTNAME: "127.0.0.1", + DB_PORT: String(dbPort(state)), + DB_DATABASE: "_supabase", + DB_USERNAME: "postgres", + DB_PASSWORD: secret(state, "secret:database.internal.password"), + LOGFLARE_SUPABASE_MODE: "true", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_PRIVATE_ACCESS_TOKEN: stringSetting(state, "analytics", "api_key"), + }), + readiness: { protocol: "http", path: "/health" }, + }, + "analytics:vector": { + containerPort: 9001, + args: (_state, _workload, _port) => [ + "--config", + "/etc/vector/vector.yaml", + "--watch-config", + "false", + ], + env: (state, workload, port) => ({ ...common(workload, port), VECTOR_API_PORT: String(port) }), + readiness: { protocol: "http", path: "/health" }, + }, + "pooler:pooler": { + containerPort: 6543, + args: () => ["start"], + env: (state, workload, port) => ({ + ...common(workload, port), + PORT: String(port), + PROXY_PORT_TRANSACTION: String(port), + DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@127.0.0.1:${dbPort(state)}/_supabase`, + API_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + REGION: "local", + CLUSTER_POSTGRES: "true", + }), + readiness: { protocol: "tcp" }, + }, +}; + +export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | undefined => { + const spec = specs[workload.id]; + if (spec !== undefined) + return { + ...spec, + cwd: spec.cwd ?? defaultCwd, + privateEndpoint: spec.privateEndpoint ?? defaultPrivateEndpoint, + }; + return undefined; +}; diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 726c305908..de7883186a 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -468,6 +468,8 @@ describe("container runtime", () => { mounts: [], volumeMounts: [], publications: [{ hostPort: 54321, containerPort: 8000 }], + env: { TEST_SECRET: "value" }, + command: ["serve", "--port", "8000"], hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, role: "gateway" as const, }; @@ -475,6 +477,16 @@ describe("container runtime", () => { expect(docker.args).toContain("--add-host"); expect(docker.args).toContain("host.docker.internal:host-gateway"); expect(docker.args).toContain("--publish"); + expect(docker.args).toContain("--env"); + expect(docker.args).toContain("TEST_SECRET=value"); + expect(docker.args.slice(-3)).toEqual(["serve", "--port", "8000"]); + const podmanCreate = serializePodmanCommand({ + operation: "create-container", + spec: gateway, + }); + expect(podmanCreate.args).toContain("--env"); + expect(podmanCreate.args).toContain("TEST_SECRET=value"); + expect(podmanCreate.args.slice(-3)).toEqual(["serve", "--port", "8000"]); const podman = serializePodmanCommand({ operation: "inspect-networks", stackId }); expect(podman.args.join(" ")).toContain("{{index .Labels"); expect(podman.args.join(" ")).not.toContain("host-gateway"); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts new file mode 100644 index 0000000000..bb2bb1a196 --- /dev/null +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest"; +import { WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { CAPABILITY_NAMES } from "../public/Capability.ts"; +import { runtimeSpecFor } from "./WorkloadRuntimeSpec.ts"; + +const state: PersistedStackState = { + format: "supabase-stack-state-v1", + identity: { + stackId: "stack-runtime-spec-test", + projectRoot: "/tmp/supabase-runtime-spec", + checkoutRoot: "/tmp/supabase-runtime-spec", + workspaceId: "/tmp/supabase-runtime-spec", + checkoutId: ".", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "runtime-spec", + }, + runtime: { kind: "native" }, + desiredGeneration: 1, + desiredLifecycle: "running", + ports: [{ field: "database", port: 55432, intent: "exact" }], + secrets: { + "secret:database.internal.password": { policy: "managed", value: "postgres" }, + }, +}; + +const planned = (id: string): PlannedWorkload => { + const entry = WORKLOAD_CATALOG[id]; + if (entry === undefined) throw new Error(`Missing test catalog entry: ${id}`); + const separator = id.indexOf(":"); + const capability = CAPABILITY_NAMES.find((name) => name === id.slice(0, separator)); + if (capability === undefined) throw new Error(`Missing test capability: ${id}`); + const name = id.slice(separator + 1); + return { + id, + capability, + dependencies: [], + readiness: { mode: "tcp" }, + restart: { maxAttempts: 1, backoffMs: 0 }, + artifacts: { + native: { kind: "native", service: entry.service, release: entry.nativeVersion }, + container: { kind: "container", service: entry.service, image: entry.containerImage }, + }, + selected: { kind: "native", service: entry.service, release: entry.nativeVersion }, + specHash: `${capability}:${name}`, + }; +}; + +describe("workload runtime catalog", () => { + it("provides private command, environment and readiness metadata for every workload", () => { + for (const [index, id] of Object.keys(WORKLOAD_CATALOG).entries()) { + const workload = planned(id); + const spec = runtimeSpecFor(workload); + expect(spec).toBeDefined(); + if (spec === undefined) continue; + const port = 30_000 + index; + expect(spec.containerPort).toBeGreaterThan(0); + expect(spec.env(state, workload, port).SUPABASE_STACK_WORKLOAD).toBe(id); + expect(spec.readiness.protocol).toMatch(/http|tcp/u); + expect(spec.args(state, workload, port)).toBeInstanceOf(Array); + expect(spec.cwd(state, workload)).toBeTruthy(); + expect(spec.privateEndpoint(port)).toEqual({ host: "127.0.0.1", port }); + } + }); +}); From 1d5d4e2c9fdccb0e3115c36b766e674dbb3f02bc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 10:03:21 +0200 Subject: [PATCH 025/174] fix(stack): harden workload artifacts and runtime specs --- ...26-08-28-stack-runtime-rewrite-progress.md | 15 + packages/stack/src/model/WorkloadCatalog.ts | 19 +- .../src/model/catalog.integration.test.ts | 39 +- .../src/model/compiler.integration.test.ts | 22 + .../stack/src/preparation/ArtifactStore.ts | 27 +- .../src/preparation/SlimServicesSource.ts | 271 +++++-- .../slim-services.integration.test.ts | 204 +++++- packages/stack/src/runtime/ContainerEngine.ts | 4 +- .../stack/src/runtime/ContainerRuntime.ts | 9 +- packages/stack/src/runtime/DockerEngine.ts | 10 +- packages/stack/src/runtime/PodmanEngine.ts | 10 +- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 660 ++++++++++++++---- .../container-runtime.integration.test.ts | 16 +- .../workload-runtime.integration.test.ts | 131 +++- 14 files changed, 1201 insertions(+), 236 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index a20175070d..8dfa974fb8 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -613,3 +613,18 @@ redacting runtime logs. Functions remains stack-owned Edge Runtime; `functions s Narrow rulings and cost if wrong: Task 13A keeps transport and process/engine boundaries injectable so tests remain offline and deterministic. Task 13B must preserve the native/container identity split and make private-port reservation plus gateway ownership atomic with accepted lifecycle generations. + +#### Task 13A fix round 1 — artifact and runtime hardening (2026-08-29) + +- Native slim identities now match release archives for Realtime (`bin/server`), Analytics (`bin/logflare`), + and Supavisor (`bin/server`, `bin/supavisor`, `bin/migrate`); catalog tests pin executable and required paths. +- `SlimServicesSource` uses injected Effect process tar list/link/extract boundaries, validates member/link + traversal (including relative links), verifies SHA256SUMS and compressed bytes before extraction, and + validates extracted realpaths. Tests cover internal links, PAX long names, malformed archives, and + interruption without publication. +- Runtime specs flatten nested settings with service-recognized aliases, use native loopback versus the + container `supabase-database` network alias, expose `containerResolutionFor`, and mount Functions' sole + `functionsRoot` at `/__supabase_functions` read-only with aligned `--main-service` arguments. +- Docker/Podman serializers accept only owned env-file paths (never secret `NAME=value` argv) and serialize + exact network aliases. Compiler integration proves the managed database password slot is persisted once + and reused. Task 13B still owns env-file creation, gateway/port lifecycle, and production composition. diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 8bdc525125..fa55cfc98c 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -79,8 +79,8 @@ export const WORKLOAD_CATALOG: Readonly> = "realtime", "v2.129.9", "supabase/realtime:v2.129.9", - "app/bin/server", - ["usr/bin/tini", "usr/bin/sh", "app/entry.sh", "app/bin/server"], + "bin/server", + ["bin/migrate", "bin/realtime", "bin/server"], ), "storage:storage": native( "storage:storage", @@ -127,8 +127,8 @@ export const WORKLOAD_CATALOG: Readonly> = "analytics", "v1.50.6", "supabase/logflare:v1.50.6", - "app/entry.sh", - ["usr/bin/tini", "usr/bin/sh", "app/entry.sh"], + "bin/logflare", + ["bin/logflare"], ), "analytics:vector": native( "analytics:vector", @@ -144,15 +144,8 @@ export const WORKLOAD_CATALOG: Readonly> = "pooler", "v2.9.12", "ghcr.io/supabase/supavisor:v2.9.12", - "app/bin/server", - [ - "usr/bin/tini", - "usr/bin/sh", - "app/entry.sh", - "app/bin/migrate", - "app/bin/supavisor", - "app/bin/server", - ], + "bin/server", + ["bin/migrate", "bin/supavisor", "bin/server"], ), }; diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index 34245fc22f..dfdccd8d82 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -2,7 +2,11 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option } from "effect"; import { compileStack } from "./Compiler.ts"; -import { resolveNativeArtifactForWorkload, targetForPlatform } from "./WorkloadCatalog.ts"; +import { + resolveNativeArtifactForWorkload, + targetForPlatform, + WORKLOAD_CATALOG, +} from "./WorkloadCatalog.ts"; import { StackPreparationError } from "../public/Errors.ts"; const compile = (config: Parameters[0]["config"]) => @@ -11,6 +15,39 @@ const compile = (config: Parameters[0]["config"]) => ); describe("complete workload catalog", () => { + it("matches the executable paths shipped by slim-services archives", () => { + const expected = { + "database:database": "share/supabase-cli/bin/supabase-postgres-init.sh", + "rest:rest": "bin/postgrest", + "auth:auth": "bin/auth", + "realtime:realtime": "bin/server", + "storage:storage": "app/dist/start/server.js", + "storage:imgproxy": "bin/imgproxy", + "functions:edge-runtime": "bin/.edge-runtime-wrapped", + "studio:studio": "app/apps/studio/server.js", + "studio:pgmeta": "app/dist/server/server.js", + "mail:mail": "bin/mailpit", + "analytics:analytics": "bin/logflare", + "analytics:vector": "bin/vector", + "pooler:pooler": "bin/server", + } as const; + for (const [id, executable] of Object.entries(expected)) { + expect(WORKLOAD_CATALOG[id]?.executablePath).toBe(executable); + expect(WORKLOAD_CATALOG[id]?.requiredRuntimePaths).toContain(executable); + } + expect(WORKLOAD_CATALOG["realtime:realtime"]?.requiredRuntimePaths).toEqual([ + "bin/migrate", + "bin/realtime", + "bin/server", + ]); + expect(WORKLOAD_CATALOG["analytics:analytics"]?.requiredRuntimePaths).toEqual(["bin/logflare"]); + expect(WORKLOAD_CATALOG["pooler:pooler"]?.requiredRuntimePaths).toEqual([ + "bin/migrate", + "bin/supavisor", + "bin/server", + ]); + }); + it.live("resolves every declared workload to a slim-services native release", () => Effect.gen(function* () { const result = yield* compile({ diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 4f689d4430..743d17fc78 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -172,6 +172,28 @@ describe("closed capability compiler", () => { }), ); + it.live("persists and reuses the managed database password slot", () => + Effect.gen(function* () { + const first = yield* compile({}); + const initial = first.secrets.filter( + (entry) => entry.slot === "secret:database.internal.password", + ); + expect(initial).toHaveLength(1); + const second = yield* compile( + {}, + { kind: "native" }, + { + definition: first.definition, + inputFingerprint: first.inputFingerprint, + }, + ); + expect( + second.secrets.filter((entry) => entry.slot === "secret:database.internal.password"), + ).toHaveLength(1); + expect(second.secrets[0]?.slot).toBe(initial[0]?.slot); + }), + ); + it.live("rejects unknown fields at the public compiler boundary", () => Effect.gen(function* () { const exit = yield* compile({ diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 34a838ac3f..1f39f83446 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -10,6 +10,7 @@ import { Scope, Semaphore, } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; import { validateRelativePath, validateSha256, verifySha256 } from "./Integrity.ts"; @@ -36,7 +37,11 @@ export interface ArtifactSource { readonly materialize: ( request: ArtifactRequest, destination: string, - ) => Effect.Effect; + ) => Effect.Effect< + Uint8Array, + StackPreparationError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner + >; } export interface ArtifactStoreOptions { @@ -400,6 +405,7 @@ const makeArtifactOperation = ( fs: FileSystem.FileSystem, path: Path.Path, crypto: Crypto.Crypto, + childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"], cacheRoot: string, source: ArtifactSource, request: ArtifactRequest, @@ -476,9 +482,14 @@ const makeArtifactOperation = ( yield* ensureDirectory(fs, path, temporary, cacheRoot); const temporaryRoot = yield* ensureSafeRoot(fs, path, temporary, cacheRoot); const published = yield* Effect.gen(function* () { - const archive = yield* source - .materialize(request, temporary) - .pipe(Effect.provideService(FileSystem.FileSystem, fs)); + const archive = yield* source.materialize(request, temporary).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + // The source owns the exact tar process boundary; the store only supplies the + // already-owned process service captured by its constructor. + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); yield* verifySha256(archive, expectedSha256).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.mapError((error) => @@ -538,12 +549,17 @@ export const makeArtifactStore = ( ): Effect.Effect< ArtifactStore, StackPreparationError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | Scope.Scope + | ChildProcessSpawner.ChildProcessSpawner > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; if (options.cacheRoot.trim().length === 0) return yield* artifactError("Artifact cache root must not be blank"); const requestedRoot = path.resolve(options.cacheRoot); @@ -590,6 +606,7 @@ export const makeArtifactStore = ( fs, path, crypto, + childProcessSpawner, cacheRoot, options.source, request, diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 9f555be9f8..34d0b79181 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -1,10 +1,62 @@ -import { Effect, FileSystem, Schema } from "effect"; +import { Crypto, Effect, FileSystem, Path, Schema } from "effect"; import { zstdDecompress } from "node:zlib"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type { ArtifactRequest, ArtifactSource } from "./ArtifactStore.ts"; import type { NativeWorkloadArtifact } from "../model/WorkloadCatalog.ts"; import { StackPreparationError } from "../public/Errors.ts"; +import { verifySha256 } from "./Integrity.ts"; type Fetcher = (input: string, init?: RequestInit) => Promise; + +export interface TarBoundary { + readonly list: ( + archivePath: string, + ) => Effect.Effect; + readonly links: ( + archivePath: string, + ) => Effect.Effect; + readonly extract: ( + archivePath: string, + destination: string, + ) => Effect.Effect; +} + +/** The system tar boundary is argv-based so archive paths never enter a shell string. */ +export const systemTarBoundary: TarBoundary = { + list: (archivePath) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .string(ChildProcess.make("tar", ["-tf", archivePath])) + .pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "tar listing failed", cause }), + ), + ); + }), + links: (archivePath) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .string(ChildProcess.make("tar", ["-tvf", archivePath])) + .pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "tar link listing failed", cause }), + ), + ); + }), + extract: (archivePath, destination) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .exitCode(ChildProcess.make("tar", ["-xf", archivePath, "-C", destination])) + .pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "tar extraction failed", cause }), + ), + ); + }), +}; // The slim-services transport is a foreign HTTP boundary; production wiring // may provide an Effect HttpClient-backed fetcher through RuntimeFactory. // oxlint-disable-next-line effecttsgo/global-fetch @@ -56,14 +108,16 @@ const decompress = (bytes: Uint8Array): Effect.Effect { + done = true; + }); }); const checksumFor = (contents: string, archiveName: string): string | undefined => contents .split(/\r?\n/u) - .map((line) => line.trim().split(/\s+/u)) - .find((parts) => parts[1] === archiveName || parts[1]?.endsWith(`/${archiveName}`))?.[0]; + .map((line) => line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/iu)) + .find((match) => match?.[2] === archiveName || match?.[2]?.endsWith(`/${archiveName}`))?.[1]; export const slimServicesChecksum = ( artifact: NativeWorkloadArtifact, @@ -85,74 +139,66 @@ export const slimServicesChecksum = ( }), ); -const text = (bytes: Uint8Array): string => new TextDecoder().decode(bytes).replace(/\0+$/u, ""); -const octal = (bytes: Uint8Array): number => Number.parseInt(text(bytes).trim() || "0", 8); +const unsafeArchivePath = (value: string): boolean => { + const normalized = value.trim(); + if (normalized.length === 0) return false; + if (normalized.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(normalized)) return true; + let depth = 0; + for (const segment of normalized.split(/[\\/]/u)) { + if (segment.length === 0 || segment === ".") continue; + if (segment === "..") { + if (depth === 0) return true; + depth -= 1; + } else depth += 1; + } + return false; +}; -const writeFile = (fs: FileSystem.FileSystem, path: string, bytes: Uint8Array) => - Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(path, { flag: "wx", mode: 0o700 }); - yield* file.writeAll(bytes); - }), - ); +const archiveLinkEscapes = (member: string, target: string): boolean => { + if (target.trim().startsWith("/") || /^[A-Za-z]:[\\/]/u.test(target.trim())) return true; + const depth = + member + .trim() + .split(/[\\/]/u) + .filter((segment) => segment.length > 0 && segment !== ".").length - 1; + let remaining = Math.max(0, depth); + for (const segment of target.trim().split(/[\\/]/u)) { + if (segment.length === 0 || segment === ".") continue; + if (segment === "..") { + if (remaining === 0) return true; + remaining -= 1; + } else remaining += 1; + } + return false; +}; -const extractTar = ( +const unsafeManifestCommand = (value: string): boolean => + value.split(/[\\/]/u).some((segment) => segment === ".."); + +const validateExtractedTree = ( fs: FileSystem.FileSystem, + path: Path.Path, destination: string, - archive: Uint8Array, ): Effect.Effect => Effect.gen(function* () { - let offset = 0; - let pendingPath: string | undefined; - while (offset + 512 <= archive.length) { - const header = archive.subarray(offset, offset + 512); - offset += 512; - if (header.every((byte) => byte === 0)) break; - const name = text(header.subarray(0, 100)); - const prefix = text(header.subarray(345, 500)); - const member = pendingPath ?? (prefix.length === 0 ? name : `${prefix}/${name}`); - const unsafe = - member.startsWith("/") || - member.split("/").some((part) => part === ".." || (part.length === 0 && part !== "")); - if (unsafe) - return yield* new StackPreparationError({ - message: "Slim-services archive contains an unsafe path", - path: member, - }); - const size = octal(header.subarray(124, 136)); - const type = header[156]; - const target = `${destination}/${member}`; - if (type === 120 || type === 76) { - const pax = text(archive.subarray(offset, offset + size)); - pendingPath = - type === 76 - ? pax - : pax - .split(/\n/u) - .find((record) => record.includes(" path=")) - ?.split(" path=")[1] - ?.trim(); - } else if (type === 53) { - yield* fs.makeDirectory(target, { recursive: true, mode: 0o700 }); - } else if (type === 48 || type === 0 || type === undefined) { - const parent = target.slice(0, target.lastIndexOf("/")); - yield* fs.makeDirectory(parent, { recursive: true, mode: 0o700 }); - yield* writeFile(fs, target, archive.subarray(offset, offset + size)); - } else { + const root = yield* fs.realPath(destination); + const entries = yield* fs.readDirectory(destination, { recursive: true }); + for (const entry of entries) { + const candidate = path.join(destination, entry); + const resolved = yield* fs.realPath(candidate); + const relative = path.relative(root, resolved); + if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) return yield* new StackPreparationError({ - message: "Slim-services archive contains an unsupported entry", - path: member, + message: "Slim-services archive entry escapes its staging directory", + path: entry, }); - } - if (type !== 120 && type !== 76) pendingPath = undefined; - offset += Math.ceil(size / 512) * 512; } }).pipe( Effect.mapError((error) => error instanceof StackPreparationError ? error : new StackPreparationError({ - message: "Unable to extract slim-services archive", + message: "Unable to validate extracted slim-services archive", cause: error, }), ), @@ -161,6 +207,7 @@ const extractTar = ( export const makeSlimServicesSource = ( resolve: (request: ArtifactRequest) => NativeWorkloadArtifact | undefined, fetchRequest: Fetcher = fetcher, + tarBoundary: TarBoundary = systemTarBoundary, ): ArtifactSource => ({ materialize: (request, destination) => { const artifact = resolve(request); @@ -170,6 +217,7 @@ export const makeSlimServicesSource = ( ); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const manifestBytes = yield* fetchBytes(artifact.manifestUrl, fetchRequest); const manifestText = new TextDecoder().decode(manifestBytes); const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( @@ -196,9 +244,114 @@ export const makeSlimServicesSource = ( version: artifact.version, target: artifact.target, }); + const entrypoint = "entrypoint" in manifest ? manifest.entrypoint : undefined; + const command = "cmd" in manifest ? manifest.cmd : undefined; + if ( + (entrypoint !== undefined && + (!Array.isArray(entrypoint) || + !entrypoint.every((value) => typeof value === "string") || + entrypoint.some(unsafeManifestCommand))) || + (command !== undefined && + (!Array.isArray(command) || + !command.every((value) => typeof value === "string") || + command.some(unsafeManifestCommand))) + ) + return yield* new StackPreparationError({ + message: "Slim-services manifest command is invalid", + service: artifact.service, + version: artifact.version, + }); + const listedChecksum = yield* slimServicesChecksum(artifact, fetchRequest); + if (listedChecksum !== request.sha256.toLowerCase()) + return yield* new StackPreparationError({ + message: "Slim-services checksum does not match the artifact request", + service: artifact.service, + version: artifact.version, + }); const compressed = yield* fetchBytes(artifact.downloadUrl, fetchRequest); + const crypto = yield* Crypto.Crypto; + yield* verifySha256(compressed, request.sha256).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Slim-services archive digest does not match the artifact request", + service: artifact.service, + version: artifact.version, + cause, + }), + ), + ); const archive = yield* decompress(compressed); - yield* extractTar(fs, destination, archive); + const archivePath = path.join(destination, ".slim-services.tar"); + yield* fs.writeFile(archivePath, archive).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to stage slim-services archive", + cause, + }), + ), + ); + const members = yield* tarBoundary.list(archivePath).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to list slim-services archive", + cause, + }), + ), + ); + const unsafeMember = members + .split(/\r?\n/u) + .map((member) => member.trim()) + .find(unsafeArchivePath); + if (unsafeMember !== undefined) + return yield* new StackPreparationError({ + message: "Slim-services archive contains an unsafe path", + path: unsafeMember, + }); + const links = yield* tarBoundary.links(archivePath).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to inspect slim-services archive links", + cause, + }), + ), + ); + const unsafeLink = links + .split(/\r?\n/u) + .map((line) => { + const arrow = line.indexOf(" -> "); + const hardLink = line.indexOf(" link to "); + const marker = arrow >= 0 ? arrow : hardLink; + if (marker < 0) return undefined; + const member = line.slice(0, marker).trim().split(/\s+/u).at(-1) ?? ""; + const target = line.slice(marker + (arrow >= 0 ? 4 : 9)).trim(); + return archiveLinkEscapes(member, target) ? target : undefined; + }) + .find((target): target is string => target !== undefined); + if (unsafeLink !== undefined) + return yield* new StackPreparationError({ + message: "Slim-services archive contains an unsafe link target", + path: unsafeLink, + }); + const exitCode = yield* tarBoundary.extract(archivePath, destination).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to extract slim-services archive", + cause, + }), + ), + ); + if (exitCode !== 0) + return yield* new StackPreparationError({ + message: `Slim-services archive extraction exited with code ${exitCode}`, + }); + yield* validateExtractedTree(fs, path, destination); + yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); return compressed; }); }, diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 8c99cfa203..f5d489b503 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Crypto, Effect, Exit, FileSystem, Option } from "effect"; +import { Cause, Crypto, Deferred, Effect, Exit, Fiber, FileSystem, Option } from "effect"; import { zstdCompress } from "node:zlib"; import { makeArtifactStore, type ArtifactRequest } from "./ArtifactStore.ts"; import { digestHex } from "./Integrity.ts"; @@ -28,18 +28,60 @@ const request: ArtifactRequest = { executablePath: "bin/demo", }; -const tar = (name: string, content: string): Uint8Array => { - const bytes = new Uint8Array(1024); - const header = bytes.subarray(0, 512); - header.set(new TextEncoder().encode(name), 0); - const size = content.length.toString(8).padStart(11, "0"); - header.set(new TextEncoder().encode(`${size}\0`), 124); - header[156] = 48; - header.set(new TextEncoder().encode("ustar\0"), 257); - bytes.set(new TextEncoder().encode(content), 512); +const tarEntries = ( + entries: ReadonlyArray<{ + readonly name: string; + readonly content?: string; + readonly link?: string; + readonly type?: number; + }>, +): Uint8Array => { + const blocks = entries.map((entry) => Math.max(1, Math.ceil((entry.content?.length ?? 0) / 512))); + const bytes = new Uint8Array((1 + blocks.reduce((sum, value) => sum + value, 0) + 2) * 512); + let offset = 0; + for (const [index, entry] of entries.entries()) { + const header = bytes.subarray(offset, offset + 512); + header.set(new TextEncoder().encode(entry.name), 0); + header.set(new TextEncoder().encode("0000755\0"), 100); + header.set(new TextEncoder().encode("0000000\0"), 108); + header.set(new TextEncoder().encode("0000000\0"), 116); + const content = entry.content ?? ""; + header.set(new TextEncoder().encode(`${content.length.toString(8).padStart(11, "0")}\0`), 124); + header[156] = entry.type ?? (entry.link === undefined ? 48 : 50); + if (entry.link !== undefined) header.set(new TextEncoder().encode(entry.link), 157); + header.set(new TextEncoder().encode("ustar\0"), 257); + header.fill(32, 148, 156); + const checksum = header + .reduce((sum, value) => sum + value, 0) + .toString(8) + .padStart(6, "0"); + header.set(new TextEncoder().encode(`${checksum}\0 `), 148); + offset += 512; + if (content.length > 0) { + bytes.set(new TextEncoder().encode(content), offset); + } + offset += (blocks[index] ?? 1) * 512; + } return bytes; }; +const tar = (name: string, content: string): Uint8Array => tarEntries([{ name, content }]); + +const paxTar = (name: string): Uint8Array => { + const raw = `path=${name}\n`; + let record = `${raw.length + 3} ${raw}`; + while ( + record.length.toString().length + 1 + raw.length !== + Number(record.slice(0, record.indexOf(" "))) + ) { + record = `${record.length.toString().length + 1 + raw.length} ${raw}`; + } + return tarEntries([ + { name: "PaxHeaders.0/x", content: record, type: 120 }, + { name: "x", content: "demo" }, + ]); +}; + const compress = (bytes: Uint8Array) => Effect.callback((resume) => { zstdCompress(bytes, (error, output) => @@ -62,7 +104,9 @@ describe("slim-services artifact source", () => { Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const checksums = "0".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"; + const crypto = yield* Crypto.Crypto; + const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const checksums = expected + " demo-v1.0.0-linux-amd64.tar.zst\n"; const fetcher: FetchLike = (input) => { const url = requestUrl(input); if (url.endsWith("SHA256SUMS")) return Promise.resolve(new Response(checksums)); @@ -74,11 +118,11 @@ describe("slim-services artifact source", () => { ); return Promise.resolve(new Response(archive)); }; - expect(yield* slimServicesChecksum(artifact, fetcher)).toBe("0".repeat(64)); + expect(yield* slimServicesChecksum(artifact, fetcher)).toBe(expected); const source = makeSlimServicesSource(() => artifact, fetcher); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-source-" }); - yield* source.materialize(request, destination); + yield* source.materialize({ ...request, sha256: expected }, destination); expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); }).pipe(Effect.provide(NodeServices.layer)), ), @@ -88,8 +132,12 @@ describe("slim-services artifact source", () => { Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("../outside", "unsafe")); + const crypto = yield* Crypto.Crypto; + const expected = digestHex(yield* crypto.digest("SHA-256", archive)); const fetcher: FetchLike = (input) => { const url = requestUrl(input); + if (url.endsWith("SHA256SUMS")) + return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); if (url.endsWith("manifest.json")) return Promise.resolve( new Response( @@ -101,13 +149,141 @@ describe("slim-services artifact source", () => { const source = makeSlimServicesSource(() => artifact, fetcher); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-unsafe-" }); - const failed = yield* source.materialize(request, destination).pipe(Effect.exit); + const failed = yield* source + .materialize({ ...request, sha256: expected }, destination) + .pipe(Effect.exit); expect(errorOf(failed)).toBeDefined(); expect(yield* fs.exists(`${destination}/outside`)).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), ), ); + it.live("allows internal symlinks while rejecting malformed archives", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress( + tarEntries([ + { name: "bin/demo", content: "demo" }, + { name: "bin/current", link: "demo" }, + ]), + ); + const crypto = yield* Crypto.Crypto; + const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const fetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.endsWith("SHA256SUMS")) + return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(archive)); + }; + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-links-" }); + yield* makeSlimServicesSource(() => artifact, fetcher).materialize( + { ...request, sha256: expected }, + destination, + ); + expect(yield* fs.readFileString(`${destination}/bin/current`)).toBe("demo"); + + const malformed = yield* compress(new Uint8Array([1, 2, 3])); + const malformedDigest = digestHex(yield* crypto.digest("SHA-256", malformed)); + const malformedFetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.endsWith("SHA256SUMS")) + return Promise.resolve( + new Response(malformedDigest + " demo-v1.0.0-linux-amd64.tar.zst\n"), + ); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(malformed)); + }; + const failed = yield* makeSlimServicesSource(() => artifact, malformedFetcher) + .materialize({ ...request, sha256: malformedDigest }, destination) + .pipe(Effect.exit); + expect(errorOf(failed)).toBeDefined(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("interrupts an in-flight download without publishing a staging artifact", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + let signal: AbortSignal | undefined; + const fetcher: FetchLike = (input, init) => { + const url = requestUrl(input); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + if (url.endsWith("SHA256SUMS")) + return Promise.resolve( + new Response("0".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"), + ); + signal = init?.signal ?? undefined; + // oxlint-disable-next-line effecttsgo/run-effect-inside-effect -- signal the test's injected fetch boundary + void Effect.runPromise(Deferred.succeed(started, undefined)); + // oxlint-disable-next-line effecttsgo/new-promise -- fetch fixture intentionally remains pending until abort + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + }; + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-interrupt-", + }); + const fiber = yield* Effect.forkChild( + makeSlimServicesSource(() => artifact, fetcher).materialize(request, destination), + { startImmediately: true }, + ); + yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + expect(signal?.aborted).toBe(true); + expect(yield* fs.readDirectory(destination)).toEqual([]); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("accepts PAX long paths through the system tar boundary", () => + Effect.scoped( + Effect.gen(function* () { + const longName = `bin/${"long-function-name-".repeat(8)}.js`; + const archive = yield* compress(paxTar(longName)); + const crypto = yield* Crypto.Crypto; + const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const fetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.endsWith("SHA256SUMS")) + return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(archive)); + }; + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-pax-" }); + yield* makeSlimServicesSource(() => artifact, fetcher).materialize( + { ...request, sha256: expected }, + destination, + ); + expect(yield* fs.exists(`${destination}/${longName}`)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("publishes the extracted slim artifact through the verified store", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 66dca37135..7476639cb1 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -99,7 +99,9 @@ export interface ContainerContainerSpec { readonly hostRoute?: ContainerHostRoute; readonly role: "workload" | "gateway"; readonly command?: ReadonlyArray; - readonly env?: Readonly>; + /** Path to an owned 0600 env file. Secret values must never be argv. */ + readonly envFile?: string; + readonly networkAliases?: ReadonlyArray; } export type ContainerCommand = diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index eea2b08cd2..8276d8be5e 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -37,7 +37,9 @@ export interface ContainerRuntimeOptions { export interface ContainerWorkloadResolution { readonly mounts?: ReadonlyArray; readonly volume?: ContainerVolumeRequest; - readonly env?: Readonly>; + /** Path to an owned env file; secret bytes are kept out of engine argv. */ + readonly envFile?: string; + readonly networkAliases?: ReadonlyArray; readonly command?: ReadonlyArray; readonly waitForReadiness?: ( key: RuntimeWorkloadKey, @@ -352,7 +354,10 @@ export const makeContainerRuntime = ( publications: [], volumeMounts: volumeRequest === undefined ? [] : [volumeMountFor(key, volumeRequest)], role: "workload", - ...(resolution.env === undefined ? {} : { env: resolution.env }), + ...(resolution.envFile === undefined ? {} : { envFile: resolution.envFile }), + ...(resolution.networkAliases === undefined + ? {} + : { networkAliases: resolution.networkAliases }), ...(resolution.command === undefined ? {} : { command: resolution.command }), } satisfies ContainerContainerSpec), ); diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index 34c2b43eb6..94f4266738 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -154,12 +154,11 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc ? [] : ["--add-host", `${command.spec.hostRoute.host}:${command.spec.hostRoute.gateway}`]; const environment = - command.spec.env === undefined + command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; + const networkAliases = + command.spec.networkAliases === undefined ? [] - : Object.entries(command.spec.env).flatMap(([name, value]) => [ - "--env", - `${name}=${value}`, - ]); + : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); return { args: [ "create", @@ -167,6 +166,7 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc command.spec.name, "--network", command.spec.network, + ...networkAliases, ...commandLabels(command.spec), ...bindMounts, ...volumeMounts, diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index dc635f435a..3452f03de0 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -151,12 +151,11 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc ]) : []; const environment = - command.spec.env === undefined + command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; + const networkAliases = + command.spec.networkAliases === undefined ? [] - : Object.entries(command.spec.env).flatMap(([name, value]) => [ - "--env", - `${name}=${value}`, - ]); + : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); return { args: [ "create", @@ -164,6 +163,7 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc command.spec.name, "--network", command.spec.network, + ...networkAliases, ...commandLabels(command.spec), ...bindMounts, ...volumeMounts, diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index d6df3ea900..979e65880f 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -1,181 +1,543 @@ import type { PersistedStackState } from "../state/StackState.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { CapabilityName } from "../public/Capability.ts"; +import type { ContainerMount } from "./ContainerEngine.ts"; -/** Runtime-facing details which are deliberately private to each workload. - * - * Public listeners are owned by StackGateway. These values describe the - * process/container side only: executable arguments, environment, and the - * loopback port used by the gateway after lazy activation. - */ +export type WorkloadRuntimeKind = "native" | "container"; + +/** Complete process-side contract for one catalog workload. */ export interface WorkloadRuntimeSpec { readonly containerPort: number; readonly cwd: (state: PersistedStackState, workload: PlannedWorkload) => string; readonly privateEndpoint: ( port: number, + runtime?: WorkloadRuntimeKind, ) => Readonly<{ readonly host: string; readonly port: number }>; readonly args: ( state: PersistedStackState, workload: PlannedWorkload, port: number, + runtime?: WorkloadRuntimeKind, ) => ReadonlyArray; readonly env: ( state: PersistedStackState, workload: PlannedWorkload, port: number, + runtime?: WorkloadRuntimeKind, ) => Readonly>; - readonly containerArgs?: ( + readonly containerArgs: ( state: PersistedStackState, workload: PlannedWorkload, port: number, ) => ReadonlyArray; + readonly containerMounts?: ( + state: PersistedStackState, + workload: PlannedWorkload, + ) => ReadonlyArray; + readonly networkAliases?: ReadonlyArray; readonly readiness: Readonly<{ readonly protocol: "http" | "tcp"; readonly path?: string }>; } type WorkloadRuntimeSpecDefinition = Omit & Partial>; -const defaultCwd = (state: PersistedStackState): string => state.identity.projectRoot; -const defaultPrivateEndpoint = ( - port: number, -): Readonly<{ readonly host: string; readonly port: number }> => ({ - host: "127.0.0.1", - port, -}); +export interface ContainerWorkloadResolution { + readonly command: ReadonlyArray; + readonly env: Readonly>; + readonly mounts: ReadonlyArray; + readonly networkAliases: ReadonlyArray; +} -const stringSetting = (state: PersistedStackState, capability: string, key: string): string => { - const settings = - state.definition === undefined - ? undefined - : Object.entries(state.definition.capabilities).find(([name]) => name === capability)?.[1] - ?.settings; - if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return ""; - const value = Object.fromEntries(Object.entries(settings))[key]; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") return String(value); - if ( - typeof value === "object" && - value !== null && - "slot" in value && - typeof value.slot === "string" - ) - return state.secrets[value.slot]?.value ?? ""; - return ""; -}; +export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; +const DATABASE_NETWORK_ALIAS = "supabase-database"; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const settingsFor = (state: PersistedStackState, capability: CapabilityName): unknown => + state.definition === undefined ? undefined : state.definition.capabilities[capability].settings; const secret = (state: PersistedStackState, slot: string): string => state.secrets[slot]?.value ?? ""; + +const settingValue = (state: PersistedStackState, value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") + return String(value); + if (Array.isArray(value)) return value.map((entry) => settingValue(state, entry)).join(","); + if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) + return secret(state, value.slot); + return JSON.stringify(value) ?? ""; +}; + +/** Flatten all materialized settings so no capability field silently disappears at runtime. */ +const flattenSettings = ( + state: PersistedStackState, + value: unknown, + prefix: string, + out: Record, +): void => { + if (value === null || value === undefined) return; + if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) { + out[prefix] = secret(state, value.slot); + return; + } + if (Array.isArray(value)) { + out[prefix] = value.map((entry) => settingValue(state, entry)).join(","); + return; + } + if (isRecord(value)) { + for (const [key, entry] of Object.entries(value)) { + const normalized = key.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase(); + flattenSettings( + state, + entry, + prefix.length === 0 ? normalized : `${prefix}_${normalized}`, + out, + ); + } + return; + } + out[prefix] = settingValue(state, value); +}; + +const capabilityEnv = ( + state: PersistedStackState, + capability: CapabilityName, + prefix: string, +): Record => { + const out: Record = {}; + const settings = settingsFor(state, capability); + if (settings !== undefined) flattenSettings(state, settings, prefix, out); + return out; +}; + +const valueAt = (state: PersistedStackState, capability: CapabilityName, path: string): string => { + let current: unknown = settingsFor(state, capability); + for (const segment of path.split(".")) { + if (!isRecord(current)) return ""; + current = current[segment]; + } + return settingValue(state, current); +}; + const dbPort = (state: PersistedStackState): number => state.ports.find((assignment) => assignment.field === "database")?.port ?? 5432; -const dbUrl = (state: PersistedStackState, role = "postgres"): string => - `postgresql://${role}:${secret(state, "secret:database.internal.password")}@127.0.0.1:${dbPort(state)}/postgres`; + +const dbHost = (runtime: WorkloadRuntimeKind): string => + runtime === "container" ? DATABASE_NETWORK_ALIAS : "127.0.0.1"; + +const dbUrl = (state: PersistedStackState, role: string, runtime: WorkloadRuntimeKind): string => { + const port = runtime === "container" ? 5432 : dbPort(state); + return `postgresql://${role}:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${port}/postgres`; +}; const common = (workload: PlannedWorkload, port: number): Record => ({ SUPABASE_STACK_WORKLOAD: workload.id, SUPABASE_STACK_PRIVATE_PORT: String(port), }); -const functionsRoot = (state: PersistedStackState): string => { - const settings = state.definition?.capabilities.functions.settings; - if (typeof settings !== "object" || settings === null || Array.isArray(settings)) - return `${state.identity.projectRoot}/supabase/functions`; - const value = Object.fromEntries(Object.entries(settings)).functions_root; - return typeof value === "string" ? value : `${state.identity.projectRoot}/supabase/functions`; +const functionsRoot = (state: PersistedStackState): string => + valueAt(state, "functions", "functions_root") || + `${state.identity.projectRoot}/supabase/functions`; + +const privateEndpointFor = ( + port: number, + runtime: WorkloadRuntimeKind = "native", + alias = "supabase-workload", +): Readonly<{ readonly host: string; readonly port: number }> => ({ + host: runtime === "container" ? alias : "127.0.0.1", + port, +}); + +const withRestSettings = ( + state: PersistedStackState, + runtime: WorkloadRuntimeKind, + port: number, + workload: PlannedWorkload, +): Record => ({ + ...common(workload, port), + ...capabilityEnv(state, "rest", "PGRST"), + PGRST_DB_URI: dbUrl(state, "authenticator", runtime), + PGRST_DB_SCHEMAS: valueAt(state, "rest", "schemas") || "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: valueAt(state, "rest", "extra_search_path") || "public,extensions", + PGRST_DB_ANON_ROLE: "anon", + PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + PGRST_SERVER_PORT: String(port), + PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows") || "1000", +}); + +const authNestedEnvironment = (state: PersistedStackState): Record => { + const out: Record = {}; + const settings = settingsFor(state, "auth"); + if (!isRecord(settings)) return out; + const external = settings.external; + if (isRecord(external)) + for (const [provider, value] of Object.entries(external)) { + if (!isRecord(value)) continue; + const prefix = `GOTRUE_EXTERNAL_${provider.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase()}`; + for (const [key, entry] of Object.entries(value)) + out[`${prefix}_${key.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase()}`] = settingValue( + state, + entry, + ); + const configuredRedirect = settingValue(state, value.redirect_uri); + out[`${prefix}_REDIRECT_URI`] = configuredRedirect || `${authExternalUrl(state)}/callback`; + } + const hooks = settings.hook; + if (isRecord(hooks)) + for (const [hook, value] of Object.entries(hooks)) { + if (!isRecord(value)) continue; + if (value.enabled !== true) continue; + const prefix = `GOTRUE_HOOK_${hook.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase()}`; + for (const [key, entry] of Object.entries(value)) + out[`${prefix}_${key.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase()}`] = settingValue( + state, + entry, + ); + } + const email = settings.email; + if (isRecord(email)) { + const templates = email.template; + if (isRecord(templates)) + for (const [name, value] of Object.entries(templates)) { + if (!isRecord(value)) continue; + const normalized = name.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase(); + const subject = settingValue(state, value.subject); + const contentPath = settingValue(state, value.content_path); + if (value.subject !== null && value.subject !== undefined) + out[`GOTRUE_MAILER_SUBJECTS_${normalized}`] = subject; + if (contentPath.length > 0) out[`GOTRUE_MAILER_TEMPLATES_${normalized}`] = contentPath; + } + const notifications = email.notification; + if (isRecord(notifications)) + for (const [name, value] of Object.entries(notifications)) { + if (!isRecord(value)) continue; + if (value.enabled !== true) continue; + const normalized = name.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase(); + out[`GOTRUE_MAILER_NOTIFICATIONS_${normalized}_ENABLED`] = settingValue( + state, + value.enabled, + ); + out[`GOTRUE_MAILER_NOTIFICATIONS_${normalized}_SUBJECT`] = settingValue( + state, + value.subject, + ); + } + } + return out; +}; + +const authExternalUrl = (state: PersistedStackState): string => { + const apiPort = state.ports.find((assignment) => assignment.field === "api")?.port; + return `http://127.0.0.1${apiPort === undefined ? "" : `:${apiPort}`}/auth/v1`; +}; + +const authSmsProvider = (state: PersistedStackState): string => { + const sms = settingsFor(state, "auth"); + if (!isRecord(sms) || !isRecord(sms.sms)) return ""; + const providers = ["twilio_verify", "twilio", "messagebird", "textlocal", "vonage"]; + for (const provider of providers) { + const value = sms.sms[provider]; + if (isRecord(value) && value.enabled === true) return provider; + } + return ""; +}; + +const authSmsTestOtp = (state: PersistedStackState): string => { + const value = valueAt(state, "auth", "sms.test_otp"); + if (value.length === 0) return ""; + const settings = settingsFor(state, "auth"); + if (!isRecord(settings) || !isRecord(settings.sms) || !isRecord(settings.sms.test_otp)) return ""; + return Object.entries(settings.sms.test_otp) + .map(([phone, otp]) => `${phone}:${settingValue(state, otp)}`) + .join(","); +}; + +const passwordRequiredCharacters = (state: PersistedStackState): string => { + const requirements = valueAt(state, "auth", "password_requirements"); + if (requirements === "letters_digits") + return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789"; + if (requirements === "lower_upper_letters_digits") + return "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789"; + if (requirements === "lower_upper_letters_digits_symbols") + return "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\":|<>?,./`~"; + return ""; +}; + +const withAuthSettings = ( + state: PersistedStackState, + runtime: WorkloadRuntimeKind, + port: number, + workload: PlannedWorkload, +): Record => ({ + ...common(workload, port), + ...capabilityEnv(state, "auth", "GOTRUE"), + ...authNestedEnvironment(state), + GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin", runtime), + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_SITE_URL: valueAt(state, "auth", "site_url") || "http://127.0.0.1:3000", + GOTRUE_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + GOTRUE_JWT_EXP: valueAt(state, "auth", "jwt_expiry") || "3600", + GOTRUE_JWT_AUD: "authenticated", + GOTRUE_JWT_ADMIN_ROLES: "service_role", + GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", + GOTRUE_JWT_VALIDMETHODS: "HS256,RS256,ES256", + GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", + GOTRUE_API_HOST: "0.0.0.0", + GOTRUE_API_PORT: String(port), + API_EXTERNAL_URL: authExternalUrl(state), + GOTRUE_MAILER_URLPATHS_INVITE: `${authExternalUrl(state)}/verify`, + GOTRUE_MAILER_URLPATHS_CONFIRMATION: `${authExternalUrl(state)}/verify`, + GOTRUE_MAILER_URLPATHS_RECOVERY: `${authExternalUrl(state)}/verify`, + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: `${authExternalUrl(state)}/verify`, + GOTRUE_URI_ALLOW_LIST: valueAt(state, "auth", "additional_redirect_urls"), + GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED: valueAt(state, "auth", "enable_refresh_token_rotation"), + GOTRUE_REFRESH_TOKEN_REUSE_INTERVAL: valueAt(state, "auth", "refresh_token_reuse_interval"), + GOTRUE_DISABLE_SIGNUP: valueAt(state, "auth", "enable_signup") === "false" ? "true" : "false", + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: valueAt(state, "auth", "enable_anonymous_sign_ins"), + GOTRUE_PASSWORD_MIN_LENGTH: valueAt(state, "auth", "minimum_password_length"), + GOTRUE_PASSWORD_REQUIREMENTS: valueAt(state, "auth", "password_requirements"), + GOTRUE_PASSWORD_REQUIRED_CHARACTERS: passwordRequiredCharacters(state), + GOTRUE_JWT_ISSUER: valueAt(state, "auth", "jwt_issuer"), + GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: valueAt(state, "auth", "enable_manual_linking"), + GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: valueAt( + state, + "auth", + "enable_refresh_token_rotation", + ), + GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: valueAt( + state, + "auth", + "refresh_token_reuse_interval", + ), + GOTRUE_RATE_LIMIT_EMAIL_SENT: valueAt(state, "auth", "rate_limit.email_sent"), + GOTRUE_RATE_LIMIT_SMS_SENT: valueAt(state, "auth", "rate_limit.sms_sent"), + GOTRUE_RATE_LIMIT_ANONYMOUS_USERS: valueAt(state, "auth", "rate_limit.anonymous_users"), + GOTRUE_RATE_LIMIT_TOKEN_REFRESH: valueAt(state, "auth", "rate_limit.token_refresh"), + GOTRUE_RATE_LIMIT_VERIFY: valueAt(state, "auth", "rate_limit.token_verifications"), + GOTRUE_RATE_LIMIT_OTP: valueAt(state, "auth", "rate_limit.sign_in_sign_ups"), + GOTRUE_RATE_LIMIT_WEB3: valueAt(state, "auth", "rate_limit.web3"), + GOTRUE_SECURITY_CAPTCHA_ENABLED: valueAt(state, "auth", "captcha.enabled"), + GOTRUE_SECURITY_CAPTCHA_PROVIDER: valueAt(state, "auth", "captcha.provider"), + GOTRUE_SECURITY_CAPTCHA_SECRET: valueAt(state, "auth", "captcha.secret"), + GOTRUE_MFA_TOTP_ENROLL_ENABLED: valueAt(state, "auth", "mfa.totp.enroll_enabled"), + GOTRUE_MFA_TOTP_VERIFY_ENABLED: valueAt(state, "auth", "mfa.totp.verify_enabled"), + GOTRUE_MFA_PHONE_ENROLL_ENABLED: valueAt(state, "auth", "mfa.phone.enroll_enabled"), + GOTRUE_MFA_PHONE_VERIFY_ENABLED: valueAt(state, "auth", "mfa.phone.verify_enabled"), + GOTRUE_MFA_PHONE_OTP_LENGTH: valueAt(state, "auth", "mfa.phone.otp_length"), + GOTRUE_MFA_PHONE_TEMPLATE: valueAt(state, "auth", "mfa.phone.template"), + GOTRUE_MFA_PHONE_MAX_FREQUENCY: valueAt(state, "auth", "mfa.phone.max_frequency"), + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: valueAt(state, "auth", "mfa.web_authn.enroll_enabled"), + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: valueAt(state, "auth", "mfa.web_authn.verify_enabled"), + GOTRUE_MFA_MAX_ENROLLED_FACTORS: valueAt(state, "auth", "mfa.max_enrolled_factors"), + GOTRUE_SESSIONS_TIMEBOX: valueAt(state, "auth", "sessions.timebox"), + GOTRUE_SESSIONS_INACTIVITY_TIMEOUT: valueAt(state, "auth", "sessions.inactivity_timeout"), + GOTRUE_MAILER_AUTOCONFIRM: + valueAt(state, "auth", "email.enable_confirmations") === "false" ? "true" : "false", + GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED: "true", + GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: valueAt(state, "auth", "email.secure_password_change"), + GOTRUE_MAILER_MAX_FREQUENCY: valueAt(state, "auth", "email.max_frequency"), + GOTRUE_SMTP_MAX_FREQUENCY: valueAt(state, "auth", "email.max_frequency"), + GOTRUE_MAILER_OTP_LENGTH: valueAt(state, "auth", "email.otp_length"), + GOTRUE_MAILER_OTP_EXP: valueAt(state, "auth", "email.otp_expiry"), + GOTRUE_SMTP_HOST: valueAt(state, "auth", "email.smtp.host"), + GOTRUE_SMTP_PORT: valueAt(state, "auth", "email.smtp.port"), + GOTRUE_SMTP_USER: valueAt(state, "auth", "email.smtp.user"), + GOTRUE_SMTP_PASS: valueAt(state, "auth", "email.smtp.pass"), + GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "auth", "email.smtp.admin_email"), + GOTRUE_SMTP_SENDER_NAME: valueAt(state, "auth", "email.smtp.sender_name"), + GOTRUE_SMS_AUTOCONFIRM: + valueAt(state, "auth", "sms.enable_confirmations") === "false" ? "true" : "false", + GOTRUE_SMS_MAX_FREQUENCY: valueAt(state, "auth", "sms.max_frequency"), + GOTRUE_SMS_OTP_LENGTH: valueAt(state, "auth", "mfa.phone.otp_length"), + GOTRUE_SMS_TEMPLATE: valueAt(state, "auth", "sms.template"), + GOTRUE_SMS_PROVIDER: authSmsProvider(state), + GOTRUE_SMS_TEST_OTP: authSmsTestOtp(state), + GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED: valueAt(state, "auth", "web3.solana.enabled"), + GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED: valueAt(state, "auth", "web3.ethereum.enabled"), + GOTRUE_EXTERNAL_EMAIL_ENABLED: valueAt(state, "auth", "email.enable_signup"), + GOTRUE_EXTERNAL_PHONE_ENABLED: valueAt(state, "auth", "sms.enable_signup"), + GOTRUE_OAUTH_SERVER_ENABLED: valueAt(state, "auth", "oauth_server.enabled"), + GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH: valueAt( + state, + "auth", + "oauth_server.authorization_url_path", + ), + GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: valueAt( + state, + "auth", + "oauth_server.allow_dynamic_registration", + ), +}); + +const withStorageSettings = ( + state: PersistedStackState, + runtime: WorkloadRuntimeKind, + port: number, + workload: PlannedWorkload, +): Record => ({ + ...common(workload, port), + ...capabilityEnv(state, "storage", "STORAGE"), + PORT: String(port), + ANON_KEY: secret(state, "secret:auth.settings.anon_key"), + SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), + AUTH_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + DATABASE_URL: dbUrl(state, "supabase_storage_admin", runtime), + FILE_SIZE_LIMIT: valueAt(state, "storage", "file_size_limit") || "50MiB", + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: "/var/lib/storage", + STORAGE_FILE_BACKEND_PATH: "/var/lib/storage", + ENABLE_IMAGE_TRANSFORMATION: valueAt(state, "storage", "image_transformation.enabled") || "false", + S3_PROTOCOL_ENABLED: valueAt(state, "storage", "s3_protocol.enabled") || "true", + S3_PROTOCOL_ACCESS_KEY_ID: "local", + S3_PROTOCOL_ACCESS_KEY_SECRET: "local-secret", + STORAGE_S3_REGION: "local", + GLOBAL_S3_BUCKET: "stub", + TENANT_ID: "stub", + S3_PROTOCOL_PREFIX: "/storage/v1", + UPLOAD_FILE_SIZE_LIMIT: "52428800000", + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", + PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + TUS_URL_PATH: "/storage/v1/upload/resumable", + IMGPROXY_URL: runtime === "container" ? "http://supabase-workload:8080" : "http://127.0.0.1:8080", +}); + +const databaseArgs = ( + state: PersistedStackState, + port: number, + runtime: WorkloadRuntimeKind, +): ReadonlyArray => { + const settings = settingsFor(state, "database"); + const postgresSettings = isRecord(settings) ? settings.settings : undefined; + const tuning = isRecord(postgresSettings) ? postgresSettings : {}; + const tuned = Object.entries(tuning).flatMap(([key, value]) => { + const rendered = settingValue(state, value); + return rendered.length === 0 ? [] : ["-c", `${key}=${rendered}`]; + }); + return [ + "-p", + String(port), + "-c", + runtime === "container" ? "listen_addresses=*" : "listen_addresses=127.0.0.1", + ...tuned, + ]; +}; + +const analyticsEnv = ( + state: PersistedStackState, + runtime: WorkloadRuntimeKind, + port: number, + workload: PlannedWorkload, +): Record => { + const backend = valueAt(state, "analytics", "backend") || "postgres"; + return { + ...common(workload, port), + ...capabilityEnv(state, "analytics", "ANALYTICS"), + PORT: String(port), + PHX_HTTP_PORT: String(port), + DB_HOSTNAME: dbHost(runtime), + DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), + DB_DATABASE: "_supabase", + DB_SCHEMA: "_analytics", + DB_USERNAME: "postgres", + DB_PASSWORD: secret(state, "secret:database.internal.password"), + LOGFLARE_SUPABASE_MODE: "true", + LOGFLARE_SINGLE_TENANT: "true", + LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), + LOGFLARE_MIN_CLUSTER_SIZE: "1", + LOGFLARE_LOG_LEVEL: "warn", + LOGFLARE_NODE_HOST: runtime === "container" ? "0.0.0.0" : "127.0.0.1", + RELEASE_COOKIE: "cookie", + ...(backend === "postgres" + ? { + POSTGRES_BACKEND_URL: dbUrl(state, "postgres", runtime), + POSTGRES_BACKEND_SCHEMA: "_analytics", + } + : { + GOOGLE_DATASET_ID_APPEND: "_prod", + GOOGLE_PROJECT_ID: valueAt(state, "analytics", "gcp_project_id") || "local", + GOOGLE_PROJECT_NUMBER: valueAt(state, "analytics", "gcp_project_number") || "0", + }), + }; }; const specs: Readonly> = { "database:database": { containerPort: 5432, - args: (state, _workload, port) => ["-p", String(port), "-c", "listen_addresses=127.0.0.1"], - env: (state, workload, port) => ({ + args: (state, _workload, port) => databaseArgs(state, port, "native"), + env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), - PGDATA: `${state.identity.projectRoot}/.supabase/db/data`, + ...capabilityEnv(state, "database", "POSTGRES"), + PGDATA: + runtime === "container" + ? "/var/lib/postgresql/data" + : `${state.identity.projectRoot}/.supabase/db/data`, POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), TZDIR: "/var/db/timezone/zoneinfo", }), - containerArgs: (_state, _workload, port) => ["-p", String(port), "-c", "listen_addresses=*"], + containerArgs: (state, _workload, port) => databaseArgs(state, port, "container"), readiness: { protocol: "tcp" }, + networkAliases: [DATABASE_NETWORK_ALIAS], }, "rest:rest": { containerPort: 3000, args: () => [], - env: (state, workload, port) => ({ - ...common(workload, port), - PGRST_DB_URI: dbUrl(state, "authenticator"), - PGRST_DB_SCHEMAS: stringSetting(state, "rest", "schemas") || "public,graphql_public", - PGRST_DB_EXTRA_SEARCH_PATH: - stringSetting(state, "rest", "extra_search_path") || "public,extensions", - PGRST_DB_ANON_ROLE: "anon", - PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), - PGRST_SERVER_PORT: String(port), - PGRST_DB_MAX_ROWS: stringSetting(state, "rest", "max_rows") || "1000", - }), + env: (state, workload, port, runtime = "native") => + withRestSettings(state, runtime, port, workload), + containerArgs: () => [], readiness: { protocol: "http", path: "/" }, }, "auth:auth": { containerPort: 9999, args: () => [], - env: (state, workload, port) => ({ - ...common(workload, port), - GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin"), - GOTRUE_DB_DRIVER: "postgres", - GOTRUE_SITE_URL: stringSetting(state, "auth", "site_url") || "http://127.0.0.1", - GOTRUE_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - GOTRUE_JWT_EXP: stringSetting(state, "auth", "jwt_expiry") || "3600", - GOTRUE_JWT_AUD: "authenticated", - GOTRUE_API_HOST: "127.0.0.1", - GOTRUE_API_PORT: String(port), - API_EXTERNAL_URL: stringSetting(state, "auth", "external_url") || "http://127.0.0.1", - }), + env: (state, workload, port, runtime = "native") => + withAuthSettings(state, runtime, port, workload), + containerArgs: () => [], readiness: { protocol: "http", path: "/health" }, }, "realtime:realtime": { containerPort: 4000, args: () => [], - env: (state, workload, port) => ({ + env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), + ...capabilityEnv(state, "realtime", "REALTIME"), PORT: String(port), - DB_HOST: "127.0.0.1", - DB_PORT: String(dbPort(state)), + DB_HOST: dbHost(runtime), + DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), DB_USER: "postgres", DB_PASSWORD: secret(state, "secret:database.internal.password"), DB_NAME: "postgres", DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", API_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + API_JWT_JWKS: "", + METRICS_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), APP_NAME: "realtime", SEED_SELF_HOST: "true", + MAX_HEADER_LENGTH: valueAt(state, "realtime", "max_header_length") || "4096", + RUN_JANITOR: "true", }), + containerArgs: () => [], readiness: { protocol: "http", path: "/api/ping" }, }, "storage:storage": { containerPort: 5000, args: () => [], - env: (state, workload, port) => ({ - ...common(workload, port), - PORT: String(port), - ANON_KEY: secret(state, "secret:auth.settings.anon_key"), - SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), - AUTH_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - DATABASE_URL: dbUrl(state, "supabase_storage_admin"), - FILE_SIZE_LIMIT: stringSetting(state, "storage", "file_size_limit") || "50MiB", - STORAGE_BACKEND: "file", - FILE_STORAGE_BACKEND_PATH: "/var/lib/storage", - STORAGE_FILE_BACKEND_PATH: "/var/lib/storage", - ENABLE_IMAGE_TRANSFORMATION: - stringSetting(state, "storage", "image_transformation") || "false", - S3_PROTOCOL_ENABLED: stringSetting(state, "storage", "s3_protocol") || "true", - S3_PROTOCOL_ACCESS_KEY_ID: "local", - S3_PROTOCOL_ACCESS_KEY_SECRET: "local-secret", - }), + env: (state, workload, port, runtime = "native") => + withStorageSettings(state, runtime, port, workload), + containerArgs: () => [], readiness: { protocol: "http", path: "/status" }, }, "storage:imgproxy": { containerPort: 8080, args: () => [], - env: (state, workload, port) => ({ + env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), - IMGPROXY_BIND: `127.0.0.1:${port}`, + IMGPROXY_BIND: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", }), + containerArgs: () => [], readiness: { protocol: "http", path: "/health" }, }, "functions:edge-runtime": { @@ -185,17 +547,27 @@ const specs: Readonly> = { "start", `--main-service=${functionsRoot(state)}`, `--port=${port}`, - `--policy=${stringSetting(state, "functions", "edge_runtime") || "per_worker"}`, + `--policy=${valueAt(state, "functions", "edge_runtime.policy") || "per_worker"}`, ], env: (state, workload, port) => ({ ...common(workload, port), + ...capabilityEnv(state, "functions", "FUNCTIONS"), EDGE_RUNTIME_PORT: String(port), FUNCTIONS_ROOT: functionsRoot(state), + FUNCTIONS_CONTAINER_ROOT, + EDGE_RUNTIME_POLICY: valueAt(state, "functions", "edge_runtime.policy") || "per_worker", + EDGE_RUNTIME_DENO_VERSION: valueAt(state, "functions", "edge_runtime.deno_version") || "2", + INSPECTOR_MODE: valueAt(state, "functions", "inspector.mode"), + INSPECTOR_MAIN: valueAt(state, "functions", "inspector.main"), }), - containerArgs: (_state, _workload, port) => [ + containerArgs: (state, _workload, port) => [ "start", - "--main-service=/workspace", + `--main-service=${FUNCTIONS_CONTAINER_ROOT}`, `--port=${port}`, + `--policy=${valueAt(state, "functions", "edge_runtime.policy") || "per_worker"}`, + ], + containerMounts: (state) => [ + { source: functionsRoot(state), target: FUNCTIONS_CONTAINER_ROOT, readOnly: true }, ], readiness: { protocol: "http", path: "/_internal/health" }, }, @@ -204,53 +576,57 @@ const specs: Readonly> = { args: () => [], env: (state, workload, port) => ({ ...common(workload, port), + ...capabilityEnv(state, "studio", "STUDIO"), PORT: String(port), - HOSTNAME: "127.0.0.1", - SUPABASE_URL: stringSetting(state, "studio", "api_url") || "http://127.0.0.1", + HOSTNAME: "0.0.0.0", + SUPABASE_URL: valueAt(state, "studio", "api_url") || "http://127.0.0.1", + SUPABASE_PUBLIC_URL: valueAt(state, "studio", "api_url") || "http://127.0.0.1", + CURRENT_CLI_VERSION: "local", + POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), + POSTGRES_USER_READ_WRITE: "postgres", + PGRST_DB_SCHEMAS: "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", + PGRST_DB_MAX_ROWS: "1000", }), + containerArgs: () => [], readiness: { protocol: "http", path: "/api/platform/profile" }, }, "studio:pgmeta": { containerPort: 8080, args: () => [], - env: (state, workload, port) => ({ + env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), + ...capabilityEnv(state, "studio", "PG_META"), PG_META_PORT: String(port), - PG_META_DB_HOST: "127.0.0.1", - PG_META_DB_PORT: String(dbPort(state)), + PG_META_DB_HOST: dbHost(runtime), + PG_META_DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), PG_META_DB_NAME: "postgres", PG_META_DB_USER: "postgres", PG_META_DB_PASSWORD: secret(state, "secret:database.internal.password"), }), + containerArgs: () => [], readiness: { protocol: "http", path: "/health" }, }, "mail:mail": { containerPort: 8025, args: (_state, _workload, port) => ["--ui", `127.0.0.1:${port}`], - env: (state, workload, port) => ({ + env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), - MP_UI_BIND_ADDR: `127.0.0.1:${port}`, - MP_SMTP_BIND_ADDR: "127.0.0.1:1025", - MP_POP3_BIND_ADDR: "127.0.0.1:1110", + ...capabilityEnv(state, "mail", "MAIL"), + MP_UI_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, + MP_SMTP_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:1025`, + MP_POP3_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:1110`, + MP_SMTP_DISABLE_RDNS: "true", }), + containerArgs: (_state, _workload, port) => ["--ui", `0.0.0.0:${port}`], readiness: { protocol: "http", path: "/readyz" }, }, "analytics:analytics": { containerPort: 4000, args: () => [], - env: (state, workload, port) => ({ - ...common(workload, port), - PORT: String(port), - PHX_HTTP_PORT: String(port), - DB_HOSTNAME: "127.0.0.1", - DB_PORT: String(dbPort(state)), - DB_DATABASE: "_supabase", - DB_USERNAME: "postgres", - DB_PASSWORD: secret(state, "secret:database.internal.password"), - LOGFLARE_SUPABASE_MODE: "true", - LOGFLARE_SINGLE_TENANT: "true", - LOGFLARE_PRIVATE_ACCESS_TOKEN: stringSetting(state, "analytics", "api_key"), - }), + env: (state, workload, port, runtime = "native") => + analyticsEnv(state, runtime, port, workload), + containerArgs: () => [], readiness: { protocol: "http", path: "/health" }, }, "analytics:vector": { @@ -261,32 +637,66 @@ const specs: Readonly> = { "--watch-config", "false", ], - env: (state, workload, port) => ({ ...common(workload, port), VECTOR_API_PORT: String(port) }), + env: (state, workload, port) => ({ + ...common(workload, port), + ...capabilityEnv(state, "analytics", "VECTOR"), + VECTOR_API_PORT: String(port), + }), + containerArgs: (_state, _workload, _port) => [ + "--config", + "/etc/vector/vector.yaml", + "--watch-config", + "false", + ], readiness: { protocol: "http", path: "/health" }, }, "pooler:pooler": { containerPort: 6543, args: () => ["start"], - env: (state, workload, port) => ({ + env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), + ...capabilityEnv(state, "pooler", "POOLER"), PORT: String(port), PROXY_PORT_TRANSACTION: String(port), - DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@127.0.0.1:${dbPort(state)}/_supabase`, + DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, API_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), REGION: "local", CLUSTER_POSTGRES: "true", + SECRET_KEY_BASE: valueAt(state, "pooler", "secret_key_base"), + VAULT_ENC_KEY: valueAt(state, "pooler", "encryption_key"), + METRICS_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size") || "20", + MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn") || "100", + POOL_MODE: valueAt(state, "pooler", "pool_mode") || "transaction", }), + containerArgs: () => ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"], readiness: { protocol: "tcp" }, }, }; export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | undefined => { const spec = specs[workload.id]; - if (spec !== undefined) - return { - ...spec, - cwd: spec.cwd ?? defaultCwd, - privateEndpoint: spec.privateEndpoint ?? defaultPrivateEndpoint, - }; - return undefined; + if (spec === undefined) return undefined; + return { + ...spec, + cwd: spec.cwd ?? ((state) => state.identity.projectRoot), + privateEndpoint: + spec.privateEndpoint ?? ((port, runtime = "native") => privateEndpointFor(port, runtime)), + }; +}; + +/** Adapter consumed by ContainerRuntime; env is written to an env-file by its owner. */ +export const containerResolutionFor = ( + state: PersistedStackState, + workload: PlannedWorkload, + port: number, +): ContainerWorkloadResolution | undefined => { + const spec = runtimeSpecFor(workload); + if (spec === undefined) return undefined; + return { + command: spec.containerArgs(state, workload, spec.containerPort), + env: spec.env(state, workload, port, "container"), + mounts: spec.containerMounts?.(state, workload) ?? [], + networkAliases: spec.networkAliases ?? [], + }; }; diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index de7883186a..4e19841e2d 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -468,7 +468,8 @@ describe("container runtime", () => { mounts: [], volumeMounts: [], publications: [{ hostPort: 54321, containerPort: 8000 }], - env: { TEST_SECRET: "value" }, + envFile: "/tmp/supabase-owned.env", + networkAliases: ["supabase-database"], command: ["serve", "--port", "8000"], hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, role: "gateway" as const, @@ -477,15 +478,20 @@ describe("container runtime", () => { expect(docker.args).toContain("--add-host"); expect(docker.args).toContain("host.docker.internal:host-gateway"); expect(docker.args).toContain("--publish"); - expect(docker.args).toContain("--env"); - expect(docker.args).toContain("TEST_SECRET=value"); + expect(docker.args).toContain("--network-alias"); + expect(docker.args).toContain("supabase-database"); + expect(docker.args).toContain("--env-file"); + expect(docker.args).toContain("/tmp/supabase-owned.env"); + expect(docker.args.join(" ")).not.toContain("value"); expect(docker.args.slice(-3)).toEqual(["serve", "--port", "8000"]); const podmanCreate = serializePodmanCommand({ operation: "create-container", spec: gateway, }); - expect(podmanCreate.args).toContain("--env"); - expect(podmanCreate.args).toContain("TEST_SECRET=value"); + expect(podmanCreate.args).toContain("--env-file"); + expect(podmanCreate.args).toContain("--network-alias"); + expect(podmanCreate.args).toContain("/tmp/supabase-owned.env"); + expect(podmanCreate.args.join(" ")).not.toContain("value"); expect(podmanCreate.args.slice(-3)).toEqual(["serve", "--port", "8000"]); const podman = serializePodmanCommand({ operation: "inspect-networks", stackId }); expect(podman.args.join(" ")).toContain("{{index .Labels"); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index bb2bb1a196..416eaea03e 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -1,9 +1,16 @@ +import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; import { WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; -import { runtimeSpecFor } from "./WorkloadRuntimeSpec.ts"; +import { compileStack } from "../model/Compiler.ts"; +import { + containerResolutionFor, + FUNCTIONS_CONTAINER_ROOT, + runtimeSpecFor, +} from "./WorkloadRuntimeSpec.ts"; const state: PersistedStackState = { format: "supabase-stack-state-v1", @@ -64,4 +71,126 @@ describe("workload runtime catalog", () => { expect(spec.privateEndpoint(port)).toEqual({ host: "127.0.0.1", port }); } }); + + it.live( + "consumes nested capability settings and separates native/container database endpoints", + () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { + capabilities: { + rest: { settings: { schemas: ["private"], extra_search_path: ["extensions"] } }, + storage: { + settings: { + image_transformation: { enabled: true }, + s3_protocol: { enabled: false }, + }, + }, + functions: { + settings: { + edge_runtime: { policy: "oneshot", deno_version: 1 }, + inspector: { mode: "brk", main: true }, + functions: { hello: { verify_jwt: false } }, + }, + }, + pooler: { enabled: true, settings: { pool_mode: "session", max_client_conn: 250 } }, + auth: { + settings: { + site_url: "https://example.test", + additional_redirect_urls: ["https://example.test/callback"], + enable_signup: false, + minimum_password_length: 12, + password_requirements: "letters_digits", + sms: { + enable_signup: true, + twilio: { enabled: true, account_sid: "AC123", message_service_sid: "MG123" }, + test_otp: { "+33123456789": "123456" }, + }, + mfa: { phone: { otp_length: 8 } }, + }, + }, + analytics: { + settings: { + backend: "bigquery", + gcp_project_id: "project-42", + gcp_project_number: "42", + }, + }, + }, + }, + }); + const configured: PersistedStackState = { ...state, definition: compiled.definition }; + const rest = runtimeSpecFor(planned("rest:rest")); + const storage = runtimeSpecFor(planned("storage:storage")); + const functions = planned("functions:edge-runtime"); + expect(rest?.env(configured, planned("rest:rest"), 3000, "native")).toMatchObject({ + PGRST_DB_SCHEMAS: "private", + PGRST_DB_EXTRA_SEARCH_PATH: "extensions", + PGRST_DB_URI: expect.stringContaining("@127.0.0.1:55432"), + }); + expect(rest?.env(configured, planned("rest:rest"), 3000, "container")).toMatchObject({ + PGRST_DB_URI: expect.stringContaining("@supabase-database:5432"), + }); + const dependentHosts: ReadonlyArray<{ + readonly id: string; + readonly key: string; + }> = [ + { id: "realtime:realtime", key: "DB_HOST" }, + { id: "studio:pgmeta", key: "PG_META_DB_HOST" }, + { id: "analytics:analytics", key: "DB_HOSTNAME" }, + ]; + for (const { id, key } of dependentHosts) { + const dependent = runtimeSpecFor(planned(id)); + expect(dependent?.env(configured, planned(id), 4000, "container")[key]).toBe( + "supabase-database", + ); + } + expect(storage?.env(configured, planned("storage:storage"), 5000)).toMatchObject({ + ENABLE_IMAGE_TRANSFORMATION: "true", + S3_PROTOCOL_ENABLED: "false", + }); + const auth = runtimeSpecFor(planned("auth:auth")); + expect(auth?.env(configured, planned("auth:auth"), 9999)).toMatchObject({ + GOTRUE_SITE_URL: "https://example.test", + GOTRUE_URI_ALLOW_LIST: "https://example.test/callback", + GOTRUE_DISABLE_SIGNUP: "true", + GOTRUE_PASSWORD_MIN_LENGTH: "12", + GOTRUE_PASSWORD_REQUIRED_CHARACTERS: + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + GOTRUE_MFA_PHONE_OTP_LENGTH: "8", + GOTRUE_SMS_PROVIDER: "twilio", + GOTRUE_SMS_TEST_OTP: "+33123456789:123456", + }); + const analytics = runtimeSpecFor(planned("analytics:analytics")); + expect(analytics?.env(configured, planned("analytics:analytics"), 4000)).toMatchObject({ + GOOGLE_PROJECT_ID: "project-42", + GOOGLE_PROJECT_NUMBER: "42", + }); + const pooler = runtimeSpecFor(planned("pooler:pooler")); + expect(pooler?.env(configured, planned("pooler:pooler"), 6543)).toMatchObject({ + POOL_MODE: "session", + MAX_CLIENT_CONN: "250", + }); + const resolution = containerResolutionFor(configured, functions, 9000); + expect(resolution?.command.join(" ")).toContain( + `--main-service=${FUNCTIONS_CONTAINER_ROOT}`, + ); + expect(resolution?.mounts).toEqual([ + { + source: `${state.identity.projectRoot}/supabase/functions`, + target: FUNCTIONS_CONTAINER_ROOT, + readOnly: true, + }, + ]); + expect(resolution?.env).toMatchObject({ + EDGE_RUNTIME_POLICY: "oneshot", + EDGE_RUNTIME_DENO_VERSION: "1", + INSPECTOR_MODE: "brk", + INSPECTOR_MAIN: "true", + FUNCTIONS_FUNCTIONS_HELLO_VERIFY_JWT: "false", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); From 12949163f5538867a91fbdef4e40258f7d30e94f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 10:52:22 +0200 Subject: [PATCH 026/174] fix(stack): complete workload execution contracts --- ...26-08-28-stack-runtime-rewrite-progress.md | 46 +- packages/stack/src/model/DatabaseBootstrap.ts | 24 +- packages/stack/src/model/WorkloadCatalog.ts | 73 +- .../src/model/catalog.integration.test.ts | 32 + .../database-bootstrap.integration.test.ts | 52 +- .../src/preparation/SlimServicesSource.ts | 93 ++- .../slim-services.integration.test.ts | 92 ++- .../stack/src/runtime/ContainerRuntime.ts | 3 + .../stack/src/runtime/WorkloadRuntimeSpec.ts | 628 ++++++++++++------ .../workload-runtime.integration.test.ts | 125 +++- 10 files changed, 891 insertions(+), 277 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 8dfa974fb8..b7d547fccf 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -587,18 +587,18 @@ Implementation continues after recording a ruling; this file is not a question q ### Task 13A — 2026-08-29 (foundation) -| Capability | Native/container artifacts | Settings/dependencies | Ports/routes/probes | Secrets/logs/activation | Evidence | -| -------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------- | -| database | `postgres` slim release; `supabase/postgres` image | database settings and bootstrap workload | database TCP readiness | managed database password; eager | compiler + runtime integration | -| rest | `postgrest`; `postgrest/postgrest` | REST settings/env; database dependency | API HTTP route; private HTTP readiness | passthrough settings; lazy activation | compiler + workload-runtime | -| auth | `auth`; `supabase/auth` | Auth settings/env and managed key slots; database dependency | API HTTP route; private `/health` readiness | managed JWT/key slots; lazy activation | compiler + workload-runtime | -| realtime | `realtime`; `supabase/realtime` | Realtime settings/env; database dependency | API HTTP route; private `/api/ping` readiness | passthrough settings; lazy activation | compiler + workload-runtime integration | -| storage (+ imgproxy) | `storage`/`imgproxy`; storage-api/imgproxy images | Storage settings/env and volume; image transformation selects companion | API route/readiness; companion private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | -| functions | Edge Runtime slim artifact/container | functions root, policy, inspector, per-function env | `/functions/v1/*` route; request-time activation and private `/_internal/health` | functionsRoot is the sole read-only `/workspace` mount | catalog + workload-runtime + container-runtime | -| studio (+ pgmeta) | studio/pgmeta slim artifacts; studio/pg-meta images | Studio settings/env; REST/analytics deps | Studio HTTP route; private profile/health probes | passthrough settings; eager activation | compiler + workload-runtime integration | -| mail | mailpit slim artifact/container | Mail settings/env | mail UI/SMTP/POP3 listeners; private `/readyz` probe | passthrough settings; eager activation | compiler + workload-runtime integration | -| analytics (+ vector) | logflare/vector slim artifacts; Logflare/vector images | Analytics settings/env; vector selected by `vector_port`; database dependency | API route/readiness; vector private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | -| pooler | `supavisor` slim release `v2.9.12`; matching image | Pooler settings/env and database dependency | pooler TCP route/readiness; private transaction endpoint | managed pooler key slots; eager activation | compiler + catalog + workload-runtime integration | +| Capability | Native/container artifacts | Settings/dependencies | Ports/routes/probes | Secrets/logs/activation | Evidence | +| -------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------- | +| database | `postgres` slim release; `supabase/postgres` image | database settings and bootstrap workload | database TCP readiness | managed database password; eager | compiler + runtime integration | +| rest | `postgrest`; `postgrest/postgrest` | REST settings/env; database dependency | API HTTP route; private HTTP readiness | passthrough settings; lazy activation | compiler + workload-runtime | +| auth | `auth`; `supabase/auth` | Auth settings/env and managed key slots; database dependency | API HTTP route; private `/health` readiness | managed JWT/key slots; lazy activation | compiler + workload-runtime | +| realtime | `realtime`; `supabase/realtime` | Realtime settings/env; database dependency | API HTTP route; private `/api/ping` readiness | passthrough settings; lazy activation | compiler + workload-runtime integration | +| storage (+ imgproxy) | `storage`/`imgproxy`; storage-api/imgproxy images | Storage settings/env and volume; image transformation selects companion | API route/readiness; companion private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | +| functions | Edge Runtime slim artifact/container | functions root, policy, inspector, per-function env | `/functions/v1/*` route; request-time activation and private `/_internal/health` | functionsRoot is the sole read-only `/__supabase_functions` mount | catalog + workload-runtime + container-runtime | +| studio (+ pgmeta) | studio/pgmeta slim artifacts; studio/pg-meta images | Studio settings/env; REST/analytics deps | Studio HTTP route; private profile/health probes | passthrough settings; eager activation | compiler + workload-runtime integration | +| mail | mailpit slim artifact/container | Mail settings/env | mail UI/SMTP/POP3 listeners; private `/readyz` probe | passthrough settings; eager activation | compiler + workload-runtime integration | +| analytics (+ vector) | logflare/vector slim artifacts; Logflare/vector images | Analytics settings/env; vector selected by `vector_port`; database dependency | API route/readiness; vector private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | +| pooler | `supavisor` slim release `v2.9.12`; matching image | Pooler settings/env and database dependency | pooler TCP route/readiness; private transaction endpoint | managed pooler key slots; eager activation | compiler + catalog + workload-runtime integration | Common Task 13A source/test coverage: `model/WorkloadCatalog.ts` is the exhaustive slim-services identity table with darwin-arm64, linux-amd64, and linux-arm64 target validation; `ExecutionPlan` materializes optional @@ -628,3 +628,25 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Docker/Podman serializers accept only owned env-file paths (never secret `NAME=value` argv) and serialize exact network aliases. Compiler integration proves the managed database password slot is persisted once and reused. Task 13B still owns env-file creation, gateway/port lifecycle, and production composition. + +#### Task 13A fix round 2 — complete workload execution contracts (2026-08-29) + +- Every catalog workload now has one stable container DNS alias, reused by private endpoints and + intra-stack URLs (`supabase-database`, `supabase-pgmeta`, `supabase-imgproxy`, and companions). + Studio's host-owned API URL and Analytics credentials use explicit owner-resolved inputs; no engine + hostname or host-file path is guessed in this layer. +- Native Node workloads carry artifact-root-relative executable, script arguments, and working-directory + metadata. Vector's native command targets the shipped `share/doc/vector/config/vector.yaml`; container + resolution retains its separate `/etc/vector/vector.yaml` contract. Functions remains one Edge Runtime + workload with its sole read-only `/__supabase_functions` mount and aligned `--main-service` argument. +- Runtime settings now include Realtime's IPv4/IPv6 `ERL_AFLAGS` and resolved JWKS input, Auth's exact + SMS/double-confirm/password semantics, Mailpit fallback SMTP, Storage's imgproxy alias, Studio's + pg-meta/analytics aliases, and BigQuery's read-only service-account mount. Optional values are omitted + rather than emitted as blank service environment values. +- Database bootstrap credentials use the seven managed login roles from `schema.sql` and one Redacted + `secret:database.internal.password`; integration coverage verifies all roles and no password leakage. +- Slim artifact decompression is an owned transform stream that is destroyed on interruption; checksum, + manifest, safe archive extraction, and atomic publication remain injected/offline-testable. +- Fix-round evidence: catalog/compiler/database-bootstrap/runtime/slim/container integration tests pass + (80 targeted tests in the latest run); Task 13B still owns env-file materialization, gateway/port + lifecycle, credentials wiring, and production composition. diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index b2af319085..b9e3e31eab 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -7,12 +7,23 @@ export interface DatabaseRow { readonly [column: string]: unknown; } -export type DatabaseBootstrapRole = "supabase_admin" | "anon" | "authenticated" | "service_role"; +/** Login roles provisioned by the managed database template. */ +export type DatabaseBootstrapRole = + | "postgres" + | "authenticator" + | "pgbouncer" + | "supabase_auth_admin" + | "supabase_storage_admin" + | "supabase_replication_admin" + | "supabase_read_only_user"; const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray = [ - "supabase_admin", - "anon", - "authenticated", - "service_role", + "postgres", + "authenticator", + "pgbouncer", + "supabase_auth_admin", + "supabase_storage_admin", + "supabase_replication_admin", + "supabase_read_only_user", ]; /** Values come from resolved managed secret slots and never become SQL text. */ @@ -187,10 +198,9 @@ export const runDatabaseBootstrap = ( if (password === undefined) continue; yield* transaction.setRolePassword(role, password).pipe( Effect.mapError( - (error) => + () => new DatabaseBootstrapError({ message: `Unable to configure internal database role ${role}`, - cause: error, }), ), ); diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index fa55cfc98c..3112d2ef09 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -17,6 +17,15 @@ export interface NativeWorkloadArtifact { readonly checksumUrl: string; readonly requiredRuntimePaths: ReadonlyArray; readonly executablePath: string; + readonly nativeProcess?: NativeWorkloadProcess; + readonly containerAlias?: string; +} + +/** Artifact-root-relative process metadata for native Node workloads. */ +export interface NativeWorkloadProcess { + readonly executablePath: string; + readonly args: ReadonlyArray; + readonly cwd: string; } export interface WorkloadCatalogEntry { @@ -27,6 +36,10 @@ export interface WorkloadCatalogEntry { readonly containerImage: string; readonly requiredRuntimePaths: ReadonlyArray; readonly executablePath: string; + /** Stable, stack-local DNS identity used by container dependants. */ + readonly containerAlias: string; + /** Present when the artifact is launched through an interpreter. */ + readonly nativeProcess?: NativeWorkloadProcess; } const native = ( @@ -37,6 +50,10 @@ const native = ( executablePath: string, requiredRuntimePaths: ReadonlyArray = [executablePath], supportedNativeVersions: ReadonlyArray = [nativeVersion], + options: { + readonly containerAlias?: string; + readonly nativeProcess?: NativeWorkloadProcess; + } = {}, ): WorkloadCatalogEntry => ({ workloadId, service, @@ -45,6 +62,8 @@ const native = ( containerImage, requiredRuntimePaths, executablePath, + containerAlias: options.containerAlias ?? `supabase-${service}`, + ...(options.nativeProcess === undefined ? {} : { nativeProcess: options.nativeProcess }), }); /** The single authoritative private workload identity table. */ @@ -65,6 +84,7 @@ export const WORKLOAD_CATALOG: Readonly> = "lib", ], ["14.1.0.89", "15.8.1.085", "17.6.1.165"], + { containerAlias: "supabase-database" }, ), "rest:rest": native( "rest:rest", @@ -72,8 +92,19 @@ export const WORKLOAD_CATALOG: Readonly> = "v16.2", "postgrest/postgrest:v16.2", "bin/postgrest", + ["bin/postgrest"], + ["v16.2"], + { containerAlias: "supabase-rest" }, + ), + "auth:auth": native( + "auth:auth", + "auth", + "v2.196.0", + "supabase/auth:v2.196.0", + "bin/auth", + ["bin/auth"], + ["v2.196.0"], ), - "auth:auth": native("auth:auth", "auth", "v2.196.0", "supabase/auth:v2.196.0", "bin/auth"), "realtime:realtime": native( "realtime:realtime", "realtime", @@ -89,6 +120,14 @@ export const WORKLOAD_CATALOG: Readonly> = "supabase/storage-api:v1.71.0", "app/dist/start/server.js", ["node/bin/node", "app/dist/start/server.js"], + ["v1.71.0"], + { + nativeProcess: { + executablePath: "node/bin/node", + args: ["app/dist/start/server.js"], + cwd: "app", + }, + }, ), "storage:imgproxy": native( "storage:imgproxy", @@ -103,6 +142,9 @@ export const WORKLOAD_CATALOG: Readonly> = "v1.74.3", "supabase/edge-runtime:v1.74.3", "bin/.edge-runtime-wrapped", + ["bin/.edge-runtime-wrapped"], + ["v1.74.3"], + { containerAlias: "supabase-functions" }, ), "studio:studio": native( "studio:studio", @@ -111,6 +153,14 @@ export const WORKLOAD_CATALOG: Readonly> = "supabase/studio:2026.08.24-sha-8ec45b2", "app/apps/studio/server.js", ["node/bin/node", "app/apps/studio/docker-entrypoint.mjs", "app/apps/studio/server.js"], + ["2026.08.24-sha-8ec45b2"], + { + nativeProcess: { + executablePath: "node/bin/node", + args: ["app/apps/studio/docker-entrypoint.mjs"], + cwd: "app/apps/studio", + }, + }, ), "studio:pgmeta": native( "studio:pgmeta", @@ -120,8 +170,24 @@ export const WORKLOAD_CATALOG: Readonly> = "app/dist/server/server.js", ["node/bin/node", "app/dist/server/server.js"], ["0.98.0", "v0.98.0"], + { + nativeProcess: { + executablePath: "node/bin/node", + args: ["app/dist/server/server.js"], + cwd: "app", + }, + }, + ), + "mail:mail": native( + "mail:mail", + "mailpit", + "v1.30.2", + "axllent/mailpit:v1.30.2", + "bin/mailpit", + ["bin/mailpit"], + ["v1.30.2"], + { containerAlias: "supabase-mail" }, ), - "mail:mail": native("mail:mail", "mailpit", "v1.30.2", "axllent/mailpit:v1.30.2", "bin/mailpit"), "analytics:analytics": native( "analytics:analytics", "analytics", @@ -138,6 +204,7 @@ export const WORKLOAD_CATALOG: Readonly> = "bin/vector", ["bin/vector"], ["0.53.0", "0.53.0-alpine"], + { containerAlias: "supabase-vector" }, ), "pooler:pooler": native( "pooler:pooler", @@ -181,6 +248,8 @@ const artifactFor = ( checksumUrl: `${base}/SHA256SUMS`, requiredRuntimePaths: entry.requiredRuntimePaths, executablePath: entry.executablePath, + ...(entry.nativeProcess === undefined ? {} : { nativeProcess: entry.nativeProcess }), + containerAlias: entry.containerAlias, }; }; diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index dfdccd8d82..b84d50a836 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -46,6 +46,38 @@ describe("complete workload catalog", () => { "bin/supavisor", "bin/server", ]); + expect(Object.values(WORKLOAD_CATALOG).map((entry) => entry.containerAlias)).toEqual( + expect.arrayContaining([ + "supabase-database", + "supabase-rest", + "supabase-auth", + "supabase-realtime", + "supabase-storage", + "supabase-imgproxy", + "supabase-functions", + "supabase-studio", + "supabase-pgmeta", + "supabase-mail", + "supabase-analytics", + "supabase-vector", + "supabase-pooler", + ]), + ); + expect(WORKLOAD_CATALOG["storage:storage"]?.nativeProcess).toEqual({ + executablePath: "node/bin/node", + args: ["app/dist/start/server.js"], + cwd: "app", + }); + expect(WORKLOAD_CATALOG["studio:pgmeta"]?.nativeProcess).toEqual({ + executablePath: "node/bin/node", + args: ["app/dist/server/server.js"], + cwd: "app", + }); + expect(WORKLOAD_CATALOG["studio:studio"]?.nativeProcess).toEqual({ + executablePath: "node/bin/node", + args: ["app/apps/studio/docker-entrypoint.mjs"], + cwd: "app/apps/studio", + }); }); it.live("resolves every declared workload to a slim-services native release", () => diff --git a/packages/stack/src/model/database-bootstrap.integration.test.ts b/packages/stack/src/model/database-bootstrap.integration.test.ts index 889c64a97b..9f9f2f6701 100644 --- a/packages/stack/src/model/database-bootstrap.integration.test.ts +++ b/packages/stack/src/model/database-bootstrap.integration.test.ts @@ -54,11 +54,20 @@ const makeSession = ( if (revision !== undefined) successfulRevisions.push(revision.id); }), setRolePassword: ( - role: "supabase_admin" | "anon" | "authenticated" | "service_role", + role: + | "postgres" + | "authenticator" + | "pgbouncer" + | "supabase_auth_admin" + | "supabase_storage_admin" + | "supabase_replication_admin" + | "supabase_read_only_user", password: Redacted.Redacted, ) => options.failPassword - ? Effect.fail(new DatabaseBootstrapError({ message: "password rejected" })) + ? Effect.fail( + new DatabaseBootstrapError({ message: "password rejected secret-password" }), + ) : Effect.sync(() => passwords.push([role, Redacted.value(password)])), query: () => Effect.succeed(applied.map((revision) => ({ revision }))), }; @@ -75,7 +84,7 @@ describe("database bootstrap", () => { Effect.gen(function* () { const state = yield* makeSession(); const credentials: DatabaseBootstrapCredentials = { - roles: { supabase_admin: Redacted.make("secret-password") }, + roles: { postgres: Redacted.make("secret-password") }, }; yield* runDatabaseBootstrap(state.session, { revisions, @@ -90,8 +99,8 @@ describe("database bootstrap", () => { // phase. It runs on each invocation so a changed managed password is // applied even when every schema revision is already recorded. expect(state.passwords).toEqual([ - ["supabase_admin", "secret-password"], - ["supabase_admin", "secret-password"], + ["postgres", "secret-password"], + ["postgres", "secret-password"], ]); expect(state.operations.join(" ")).not.toContain("secret-password"); }), @@ -116,7 +125,7 @@ describe("database bootstrap", () => { const state = yield* makeSession({ failPassword: true }); const result = yield* runDatabaseBootstrap(state.session, { revisions: [], - credentials: { roles: { anon: Redacted.make("secret-password") } }, + credentials: { roles: { postgres: Redacted.make("secret-password") } }, }).pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); if (Exit.isFailure(result)) @@ -125,6 +134,37 @@ describe("database bootstrap", () => { }), ); + it.live("applies the managed password to every login role", () => + Effect.gen(function* () { + const state = yield* makeSession(); + const password = Redacted.make("secret-password"); + yield* runDatabaseBootstrap(state.session, { + revisions: [], + credentials: { + roles: { + postgres: password, + authenticator: password, + pgbouncer: password, + supabase_auth_admin: password, + supabase_storage_admin: password, + supabase_replication_admin: password, + supabase_read_only_user: password, + }, + }, + }); + expect(state.passwords).toEqual([ + ["postgres", "secret-password"], + ["authenticator", "secret-password"], + ["pgbouncer", "secret-password"], + ["supabase_auth_admin", "secret-password"], + ["supabase_storage_admin", "secret-password"], + ["supabase_replication_admin", "secret-password"], + ["supabase_read_only_user", "secret-password"], + ]); + expect(state.operations.join(" ")).not.toContain("secret-password"); + }), + ); + it.live("serializes concurrent callers before applying a revision", () => Effect.gen(function* () { const state = yield* makeSession(); diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 34d0b79181..e51c714f6a 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -1,5 +1,6 @@ import { Crypto, Effect, FileSystem, Path, Schema } from "effect"; -import { zstdDecompress } from "node:zlib"; +import { createZstdDecompress } from "node:zlib"; +import type { Transform } from "node:stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type { ArtifactRequest, ArtifactSource } from "./ArtifactStore.ts"; import type { NativeWorkloadArtifact } from "../model/WorkloadCatalog.ts"; @@ -8,6 +9,10 @@ import { verifySha256 } from "./Integrity.ts"; type Fetcher = (input: string, init?: RequestInit) => Promise; +export interface ZstdDecompressor { + readonly decompress: (bytes: Uint8Array) => Effect.Effect; +} + export interface TarBoundary { readonly list: ( archivePath: string, @@ -77,41 +82,56 @@ const fetchBytes = ( catch: (cause) => new StackPreparationError({ message: `Unable to download ${url}`, cause }), }); -const decompress = (bytes: Uint8Array): Effect.Effect => - Effect.callback((resume) => { - let done = false; - const complete = (effect: Effect.Effect) => { - if (done) return; - done = true; - resume(effect); - }; - try { - zstdDecompress(bytes, (cause, output) => +/** Owned streaming zstd boundary. Cancellation destroys the exact transform. */ +export const nodeZstdDecompressor: ZstdDecompressor = { + decompress: (bytes) => + Effect.callback((resume) => { + const transform: Transform = createZstdDecompress(); + const chunks: Uint8Array[] = []; + let done = false; + const cleanup = () => { + transform.removeListener("data", onData); + transform.removeListener("error", onError); + transform.removeListener("end", onEnd); + }; + const complete = (effect: Effect.Effect) => { + if (done) return; + done = true; + cleanup(); + resume(effect); + }; + const onData = (chunk: Uint8Array) => chunks.push(new Uint8Array(chunk)); + const onError = (cause: unknown) => complete( - cause === null - ? Effect.succeed(output) - : Effect.fail( - new StackPreparationError({ - message: "Unable to decompress slim-services archive", - cause, - }), - ), - ), - ); - } catch (cause) { - complete( - Effect.fail( - new StackPreparationError({ - message: "Unable to decompress slim-services archive", - cause, - }), - ), - ); - } - return Effect.sync(() => { - done = true; - }); - }); + Effect.fail( + new StackPreparationError({ + message: "Unable to decompress slim-services archive", + cause, + }), + ), + ); + const onEnd = () => { + const size = chunks.reduce((total, chunk) => total + chunk.length, 0); + const output = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + complete(Effect.succeed(output)); + }; + transform.on("data", onData); + transform.once("error", onError); + transform.once("end", onEnd); + transform.end(bytes); + return Effect.sync(() => { + if (done) return; + done = true; + cleanup(); + transform.destroy(); + }); + }), +}; const checksumFor = (contents: string, archiveName: string): string | undefined => contents @@ -208,6 +228,7 @@ export const makeSlimServicesSource = ( resolve: (request: ArtifactRequest) => NativeWorkloadArtifact | undefined, fetchRequest: Fetcher = fetcher, tarBoundary: TarBoundary = systemTarBoundary, + decompressor: ZstdDecompressor = nodeZstdDecompressor, ): ArtifactSource => ({ materialize: (request, destination) => { const artifact = resolve(request); @@ -282,7 +303,7 @@ export const makeSlimServicesSource = ( }), ), ); - const archive = yield* decompress(compressed); + const archive = yield* decompressor.decompress(compressed); const archivePath = path.join(destination, ".slim-services.tar"); yield* fs.writeFile(archivePath, archive).pipe( Effect.mapError( diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index f5d489b503..4b951dfef7 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -4,7 +4,12 @@ import { Cause, Crypto, Deferred, Effect, Exit, Fiber, FileSystem, Option } from import { zstdCompress } from "node:zlib"; import { makeArtifactStore, type ArtifactRequest } from "./ArtifactStore.ts"; import { digestHex } from "./Integrity.ts"; -import { makeSlimServicesSource, slimServicesChecksum } from "./SlimServicesSource.ts"; +import { + makeSlimServicesSource, + slimServicesChecksum, + systemTarBoundary, + type ZstdDecompressor, +} from "./SlimServicesSource.ts"; import type { NativeWorkloadArtifact } from "../model/WorkloadCatalog.ts"; const artifact: NativeWorkloadArtifact = { @@ -213,6 +218,42 @@ describe("slim-services artifact source", () => { ), ); + it.live("rejects links whose targets escape the artifact root", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress( + tarEntries([ + { name: "bin/demo", content: "demo" }, + { name: "bin/escape", link: "../../outside" }, + ]), + ); + const crypto = yield* Crypto.Crypto; + const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const fetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.endsWith("SHA256SUMS")) + return Promise.resolve(new Response(`${expected} demo-v1.0.0-linux-amd64.tar.zst\n`)); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(archive)); + }; + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-link-escape-", + }); + const failed = yield* makeSlimServicesSource(() => artifact, fetcher) + .materialize({ ...request, sha256: expected }, destination) + .pipe(Effect.exit); + expect(errorOf(failed)).toBeDefined(); + expect(yield* fs.exists(`${destination}/outside`)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("interrupts an in-flight download without publishing a staging artifact", () => Effect.scoped( Effect.gen(function* () { @@ -254,6 +295,55 @@ describe("slim-services artifact source", () => { ), ); + it.live("interrupts owned decompression without publishing a staging artifact", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const crypto = yield* Crypto.Crypto; + const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const started = yield* Deferred.make(); + let destroyed = false; + const decompressor: ZstdDecompressor = { + decompress: () => + Effect.callback((_resume) => { + // oxlint-disable-next-line effecttsgo/run-effect-inside-effect -- test boundary signal + void Effect.runPromise(Deferred.succeed(started, undefined)); + return Effect.sync(() => { + destroyed = true; + }); + }), + }; + const fetcher: FetchLike = (input) => { + const url = requestUrl(input); + if (url.endsWith("SHA256SUMS")) + return Promise.resolve(new Response(`${expected} demo-v1.0.0-linux-amd64.tar.zst\n`)); + if (url.endsWith("manifest.json")) + return Promise.resolve( + new Response( + JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), + ), + ); + return Promise.resolve(new Response(archive)); + }; + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-zstd-" }); + const fiber = yield* Effect.forkChild( + makeSlimServicesSource( + () => artifact, + fetcher, + systemTarBoundary, + decompressor, + ).materialize({ ...request, sha256: expected }, destination), + { startImmediately: true }, + ); + yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + expect(destroyed).toBe(true); + expect(yield* fs.readDirectory(destination)).toEqual([]); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("accepts PAX long paths through the system tar boundary", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 8276d8be5e..1be85fd13f 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -4,6 +4,7 @@ import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { StackId } from "../public/StackId.ts"; import { type ContainerContainerSpec, + type ContainerHostRoute, type ContainerEngine, type ContainerEngineFailure, type ContainerLabels, @@ -40,6 +41,7 @@ export interface ContainerWorkloadResolution { /** Path to an owned env file; secret bytes are kept out of engine argv. */ readonly envFile?: string; readonly networkAliases?: ReadonlyArray; + readonly hostRoute?: ContainerHostRoute; readonly command?: ReadonlyArray; readonly waitForReadiness?: ( key: RuntimeWorkloadKey, @@ -358,6 +360,7 @@ export const makeContainerRuntime = ( ...(resolution.networkAliases === undefined ? {} : { networkAliases: resolution.networkAliases }), + ...(resolution.hostRoute === undefined ? {} : { hostRoute: resolution.hostRoute }), ...(resolution.command === undefined ? {} : { command: resolution.command }), } satisfies ContainerContainerSpec), ); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 979e65880f..b084db45e5 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -1,10 +1,30 @@ import type { PersistedStackState } from "../state/StackState.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { CapabilityName } from "../public/Capability.ts"; -import type { ContainerMount } from "./ContainerEngine.ts"; +import type { ContainerHostRoute, ContainerMount } from "./ContainerEngine.ts"; +import { WORKLOAD_CATALOG, type NativeWorkloadProcess } from "../model/WorkloadCatalog.ts"; export type WorkloadRuntimeKind = "native" | "container"; +/** Inputs resolved by the owner before a process/container is created. */ +export interface WorkloadRuntimeInputs { + /** Resolved GoTrue signing key JSON and public JWKS. */ + readonly auth?: Readonly<{ readonly jwtKeys?: string; readonly jwks?: string }>; + /** Resolved host path for the BigQuery service-account file. */ + readonly analytics?: Readonly<{ + readonly gcpJwtPath?: string; + readonly vectorConfigPath?: string; + }>; + /** Host route used by containers to reach StackGateway. */ + readonly hostRoute?: ContainerHostRoute; +} + +export interface NativeProcessResolution { + readonly executable: string; + readonly args: ReadonlyArray; + readonly cwd: string; +} + /** Complete process-side contract for one catalog workload. */ export interface WorkloadRuntimeSpec { readonly containerPort: number; @@ -24,39 +44,62 @@ export interface WorkloadRuntimeSpec { workload: PlannedWorkload, port: number, runtime?: WorkloadRuntimeKind, + inputs?: WorkloadRuntimeInputs, ) => Readonly>; readonly containerArgs: ( state: PersistedStackState, workload: PlannedWorkload, port: number, + inputs?: WorkloadRuntimeInputs, ) => ReadonlyArray; readonly containerMounts?: ( state: PersistedStackState, workload: PlannedWorkload, + inputs?: WorkloadRuntimeInputs, ) => ReadonlyArray; readonly networkAliases?: ReadonlyArray; readonly readiness: Readonly<{ readonly protocol: "http" | "tcp"; readonly path?: string }>; + readonly nativeProcess: ( + artifactRoot: string, + state: PersistedStackState, + workload: PlannedWorkload, + port: number, + ) => NativeProcessResolution; } -type WorkloadRuntimeSpecDefinition = Omit & - Partial>; +type WorkloadRuntimeSpecDefinition = Omit< + WorkloadRuntimeSpec, + "cwd" | "privateEndpoint" | "nativeProcess" +> & + Partial>; export interface ContainerWorkloadResolution { readonly command: ReadonlyArray; readonly env: Readonly>; readonly mounts: ReadonlyArray; readonly networkAliases: ReadonlyArray; + readonly hostRoute?: ContainerHostRoute; } export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; const DATABASE_NETWORK_ALIAS = "supabase-database"; +const MAIL_NETWORK_ALIAS = "supabase-mail"; + +const compactEnvironment = ( + environment: Readonly>, +): Readonly> => + Object.fromEntries(Object.entries(environment).filter(([, value]) => value.length > 0)); + const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); const settingsFor = (state: PersistedStackState, capability: CapabilityName): unknown => state.definition === undefined ? undefined : state.definition.capabilities[capability].settings; +const capabilityEnabled = (state: PersistedStackState, capability: CapabilityName): boolean => + state.definition?.capabilities[capability].enabled ?? true; + const secret = (state: PersistedStackState, slot: string): string => state.secrets[slot]?.value ?? ""; @@ -105,10 +148,12 @@ const capabilityEnv = ( state: PersistedStackState, capability: CapabilityName, prefix: string, + omit: (key: string) => boolean = () => false, ): Record => { const out: Record = {}; const settings = settingsFor(state, capability); if (settings !== undefined) flattenSettings(state, settings, prefix, out); + for (const key of Object.keys(out)) if (omit(key)) delete out[key]; return out; }; @@ -150,24 +195,67 @@ const privateEndpointFor = ( port, }); +const aliasFor = (workload: PlannedWorkload): string => + WORKLOAD_CATALOG[workload.id]?.containerAlias ?? `supabase-${workload.id.replace(/:/gu, "-")}`; + +const artifactPath = (root: string, relative: string): string => + relative === "." ? root : `${root.replace(/\/+$/u, "")}/${relative}`; + +const nativeProcessFor = ( + artifactRoot: string, + state: PersistedStackState, + workload: PlannedWorkload, + port: number, + spec: WorkloadRuntimeSpecDefinition, +): NativeProcessResolution => { + const metadata: NativeWorkloadProcess | undefined = WORKLOAD_CATALOG[workload.id]?.nativeProcess; + if (metadata !== undefined) + return { + executable: artifactPath(artifactRoot, metadata.executablePath), + args: metadata.args.map((arg) => + arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, + ), + cwd: artifactPath(artifactRoot, metadata.cwd), + }; + const executablePath = WORKLOAD_CATALOG[workload.id]?.executablePath; + return { + executable: + executablePath === undefined ? artifactRoot : artifactPath(artifactRoot, executablePath), + args: spec + .args(state, workload, port, "native") + .map((arg) => + arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, + ), + cwd: spec.cwd?.(state, workload) ?? state.identity.projectRoot, + }; +}; + const withRestSettings = ( state: PersistedStackState, runtime: WorkloadRuntimeKind, port: number, workload: PlannedWorkload, -): Record => ({ - ...common(workload, port), - ...capabilityEnv(state, "rest", "PGRST"), - PGRST_DB_URI: dbUrl(state, "authenticator", runtime), - PGRST_DB_SCHEMAS: valueAt(state, "rest", "schemas") || "public,graphql_public", - PGRST_DB_EXTRA_SEARCH_PATH: valueAt(state, "rest", "extra_search_path") || "public,extensions", - PGRST_DB_ANON_ROLE: "anon", - PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), - PGRST_SERVER_PORT: String(port), - PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows") || "1000", -}); + _inputs: WorkloadRuntimeInputs = {}, +): Record => + compactEnvironment({ + ...common(workload, port), + ...capabilityEnv(state, "rest", "PGRST"), + PGRST_DB_URI: dbUrl(state, "authenticator", runtime), + PGRST_DB_SCHEMAS: valueAt(state, "rest", "schemas") || "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: valueAt(state, "rest", "extra_search_path") || "public,extensions", + PGRST_DB_ANON_ROLE: "anon", + // PostgREST validates the compact JWT with the managed signing secret; + // resolved JWKS belongs to services that consume public key sets (Auth, + // Realtime and Storage), not this HMAC setting. + PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + PGRST_SERVER_PORT: String(port), + PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows") || "1000", + }); -const authNestedEnvironment = (state: PersistedStackState): Record => { +const authNestedEnvironment = ( + state: PersistedStackState, + jwtIssuer: string, +): Record => { const out: Record = {}; const settings = settingsFor(state, "auth"); if (!isRecord(settings)) return out; @@ -182,7 +270,7 @@ const authNestedEnvironment = (state: PersistedStackState): Record { return `http://127.0.0.1${apiPort === undefined ? "" : `:${apiPort}`}/auth/v1`; }; +const apiGatewayUrl = (state: PersistedStackState, inputs?: WorkloadRuntimeInputs): string => { + const apiPort = state.ports.find((assignment) => assignment.field === "api")?.port; + if (inputs?.hostRoute !== undefined) + return `http://${inputs.hostRoute.host}${apiPort === undefined ? "" : `:${apiPort}`}`; + return ( + valueAt(state, "studio", "api_url") || + `http://127.0.0.1${apiPort === undefined ? "" : `:${apiPort}`}` + ); +}; + const authSmsProvider = (state: PersistedStackState): string => { const sms = settingsFor(state, "auth"); if (!isRecord(sms) || !isRecord(sms.sms)) return ""; - const providers = ["twilio_verify", "twilio", "messagebird", "textlocal", "vonage"]; + // Keep the same fixed priority as the legacy GoTrue builder. If multiple + // providers are enabled, the first one wins and only its credentials are + // consumed by GoTrue. + const providers = ["twilio", "twilio_verify", "messagebird", "textlocal", "vonage"]; for (const provider of providers) { const value = sms.sms[provider]; if (isRecord(value) && value.enabled === true) return provider; @@ -270,139 +371,173 @@ const withAuthSettings = ( runtime: WorkloadRuntimeKind, port: number, workload: PlannedWorkload, -): Record => ({ - ...common(workload, port), - ...capabilityEnv(state, "auth", "GOTRUE"), - ...authNestedEnvironment(state), - GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin", runtime), - GOTRUE_DB_DRIVER: "postgres", - GOTRUE_SITE_URL: valueAt(state, "auth", "site_url") || "http://127.0.0.1:3000", - GOTRUE_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - GOTRUE_JWT_EXP: valueAt(state, "auth", "jwt_expiry") || "3600", - GOTRUE_JWT_AUD: "authenticated", - GOTRUE_JWT_ADMIN_ROLES: "service_role", - GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", - GOTRUE_JWT_VALIDMETHODS: "HS256,RS256,ES256", - GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", - GOTRUE_API_HOST: "0.0.0.0", - GOTRUE_API_PORT: String(port), - API_EXTERNAL_URL: authExternalUrl(state), - GOTRUE_MAILER_URLPATHS_INVITE: `${authExternalUrl(state)}/verify`, - GOTRUE_MAILER_URLPATHS_CONFIRMATION: `${authExternalUrl(state)}/verify`, - GOTRUE_MAILER_URLPATHS_RECOVERY: `${authExternalUrl(state)}/verify`, - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: `${authExternalUrl(state)}/verify`, - GOTRUE_URI_ALLOW_LIST: valueAt(state, "auth", "additional_redirect_urls"), - GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED: valueAt(state, "auth", "enable_refresh_token_rotation"), - GOTRUE_REFRESH_TOKEN_REUSE_INTERVAL: valueAt(state, "auth", "refresh_token_reuse_interval"), - GOTRUE_DISABLE_SIGNUP: valueAt(state, "auth", "enable_signup") === "false" ? "true" : "false", - GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: valueAt(state, "auth", "enable_anonymous_sign_ins"), - GOTRUE_PASSWORD_MIN_LENGTH: valueAt(state, "auth", "minimum_password_length"), - GOTRUE_PASSWORD_REQUIREMENTS: valueAt(state, "auth", "password_requirements"), - GOTRUE_PASSWORD_REQUIRED_CHARACTERS: passwordRequiredCharacters(state), - GOTRUE_JWT_ISSUER: valueAt(state, "auth", "jwt_issuer"), - GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: valueAt(state, "auth", "enable_manual_linking"), - GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: valueAt( - state, - "auth", - "enable_refresh_token_rotation", - ), - GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: valueAt( - state, - "auth", - "refresh_token_reuse_interval", - ), - GOTRUE_RATE_LIMIT_EMAIL_SENT: valueAt(state, "auth", "rate_limit.email_sent"), - GOTRUE_RATE_LIMIT_SMS_SENT: valueAt(state, "auth", "rate_limit.sms_sent"), - GOTRUE_RATE_LIMIT_ANONYMOUS_USERS: valueAt(state, "auth", "rate_limit.anonymous_users"), - GOTRUE_RATE_LIMIT_TOKEN_REFRESH: valueAt(state, "auth", "rate_limit.token_refresh"), - GOTRUE_RATE_LIMIT_VERIFY: valueAt(state, "auth", "rate_limit.token_verifications"), - GOTRUE_RATE_LIMIT_OTP: valueAt(state, "auth", "rate_limit.sign_in_sign_ups"), - GOTRUE_RATE_LIMIT_WEB3: valueAt(state, "auth", "rate_limit.web3"), - GOTRUE_SECURITY_CAPTCHA_ENABLED: valueAt(state, "auth", "captcha.enabled"), - GOTRUE_SECURITY_CAPTCHA_PROVIDER: valueAt(state, "auth", "captcha.provider"), - GOTRUE_SECURITY_CAPTCHA_SECRET: valueAt(state, "auth", "captcha.secret"), - GOTRUE_MFA_TOTP_ENROLL_ENABLED: valueAt(state, "auth", "mfa.totp.enroll_enabled"), - GOTRUE_MFA_TOTP_VERIFY_ENABLED: valueAt(state, "auth", "mfa.totp.verify_enabled"), - GOTRUE_MFA_PHONE_ENROLL_ENABLED: valueAt(state, "auth", "mfa.phone.enroll_enabled"), - GOTRUE_MFA_PHONE_VERIFY_ENABLED: valueAt(state, "auth", "mfa.phone.verify_enabled"), - GOTRUE_MFA_PHONE_OTP_LENGTH: valueAt(state, "auth", "mfa.phone.otp_length"), - GOTRUE_MFA_PHONE_TEMPLATE: valueAt(state, "auth", "mfa.phone.template"), - GOTRUE_MFA_PHONE_MAX_FREQUENCY: valueAt(state, "auth", "mfa.phone.max_frequency"), - GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: valueAt(state, "auth", "mfa.web_authn.enroll_enabled"), - GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: valueAt(state, "auth", "mfa.web_authn.verify_enabled"), - GOTRUE_MFA_MAX_ENROLLED_FACTORS: valueAt(state, "auth", "mfa.max_enrolled_factors"), - GOTRUE_SESSIONS_TIMEBOX: valueAt(state, "auth", "sessions.timebox"), - GOTRUE_SESSIONS_INACTIVITY_TIMEOUT: valueAt(state, "auth", "sessions.inactivity_timeout"), - GOTRUE_MAILER_AUTOCONFIRM: - valueAt(state, "auth", "email.enable_confirmations") === "false" ? "true" : "false", - GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED: "true", - GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: valueAt(state, "auth", "email.secure_password_change"), - GOTRUE_MAILER_MAX_FREQUENCY: valueAt(state, "auth", "email.max_frequency"), - GOTRUE_SMTP_MAX_FREQUENCY: valueAt(state, "auth", "email.max_frequency"), - GOTRUE_MAILER_OTP_LENGTH: valueAt(state, "auth", "email.otp_length"), - GOTRUE_MAILER_OTP_EXP: valueAt(state, "auth", "email.otp_expiry"), - GOTRUE_SMTP_HOST: valueAt(state, "auth", "email.smtp.host"), - GOTRUE_SMTP_PORT: valueAt(state, "auth", "email.smtp.port"), - GOTRUE_SMTP_USER: valueAt(state, "auth", "email.smtp.user"), - GOTRUE_SMTP_PASS: valueAt(state, "auth", "email.smtp.pass"), - GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "auth", "email.smtp.admin_email"), - GOTRUE_SMTP_SENDER_NAME: valueAt(state, "auth", "email.smtp.sender_name"), - GOTRUE_SMS_AUTOCONFIRM: - valueAt(state, "auth", "sms.enable_confirmations") === "false" ? "true" : "false", - GOTRUE_SMS_MAX_FREQUENCY: valueAt(state, "auth", "sms.max_frequency"), - GOTRUE_SMS_OTP_LENGTH: valueAt(state, "auth", "mfa.phone.otp_length"), - GOTRUE_SMS_TEMPLATE: valueAt(state, "auth", "sms.template"), - GOTRUE_SMS_PROVIDER: authSmsProvider(state), - GOTRUE_SMS_TEST_OTP: authSmsTestOtp(state), - GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED: valueAt(state, "auth", "web3.solana.enabled"), - GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED: valueAt(state, "auth", "web3.ethereum.enabled"), - GOTRUE_EXTERNAL_EMAIL_ENABLED: valueAt(state, "auth", "email.enable_signup"), - GOTRUE_EXTERNAL_PHONE_ENABLED: valueAt(state, "auth", "sms.enable_signup"), - GOTRUE_OAUTH_SERVER_ENABLED: valueAt(state, "auth", "oauth_server.enabled"), - GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH: valueAt( - state, - "auth", - "oauth_server.authorization_url_path", - ), - GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: valueAt( - state, - "auth", - "oauth_server.allow_dynamic_registration", - ), -}); + inputs: WorkloadRuntimeInputs = {}, +): Record => + compactEnvironment({ + ...common(workload, port), + ...capabilityEnv( + state, + "auth", + "GOTRUE", + (key) => key === "GOTRUE_SIGNING_KEYS_PATH" || key.startsWith("GOTRUE_THIRD_PARTY_"), + ), + ...authNestedEnvironment(state, valueAt(state, "auth", "jwt_issuer") || authExternalUrl(state)), + GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin", runtime), + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_SITE_URL: valueAt(state, "auth", "site_url") || "http://127.0.0.1:3000", + GOTRUE_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + GOTRUE_JWT_EXP: valueAt(state, "auth", "jwt_expiry") || "3600", + GOTRUE_JWT_AUD: "authenticated", + GOTRUE_JWT_ADMIN_ROLES: "service_role", + GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", + GOTRUE_JWT_VALIDMETHODS: "HS256,RS256,ES256", + GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", + ...(inputs.auth?.jwtKeys === undefined ? {} : { GOTRUE_JWT_KEYS: inputs.auth.jwtKeys }), + GOTRUE_API_HOST: "0.0.0.0", + GOTRUE_API_PORT: String(port), + API_EXTERNAL_URL: authExternalUrl(state), + GOTRUE_MAILER_URLPATHS_INVITE: `${authExternalUrl(state)}/verify`, + GOTRUE_MAILER_URLPATHS_CONFIRMATION: `${authExternalUrl(state)}/verify`, + GOTRUE_MAILER_URLPATHS_RECOVERY: `${authExternalUrl(state)}/verify`, + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: `${authExternalUrl(state)}/verify`, + GOTRUE_URI_ALLOW_LIST: valueAt(state, "auth", "additional_redirect_urls"), + GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED: valueAt(state, "auth", "enable_refresh_token_rotation"), + GOTRUE_REFRESH_TOKEN_REUSE_INTERVAL: valueAt(state, "auth", "refresh_token_reuse_interval"), + GOTRUE_DISABLE_SIGNUP: valueAt(state, "auth", "enable_signup") === "false" ? "true" : "false", + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: valueAt(state, "auth", "enable_anonymous_sign_ins"), + GOTRUE_PASSWORD_MIN_LENGTH: valueAt(state, "auth", "minimum_password_length"), + GOTRUE_PASSWORD_REQUIREMENTS: valueAt(state, "auth", "password_requirements"), + GOTRUE_PASSWORD_REQUIRED_CHARACTERS: passwordRequiredCharacters(state), + GOTRUE_JWT_ISSUER: valueAt(state, "auth", "jwt_issuer") || authExternalUrl(state), + GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: valueAt(state, "auth", "enable_manual_linking"), + GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: valueAt( + state, + "auth", + "enable_refresh_token_rotation", + ), + GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: valueAt( + state, + "auth", + "refresh_token_reuse_interval", + ), + GOTRUE_RATE_LIMIT_EMAIL_SENT: valueAt(state, "auth", "rate_limit.email_sent"), + GOTRUE_RATE_LIMIT_SMS_SENT: valueAt(state, "auth", "rate_limit.sms_sent"), + GOTRUE_RATE_LIMIT_ANONYMOUS_USERS: valueAt(state, "auth", "rate_limit.anonymous_users"), + GOTRUE_RATE_LIMIT_TOKEN_REFRESH: valueAt(state, "auth", "rate_limit.token_refresh"), + GOTRUE_RATE_LIMIT_VERIFY: valueAt(state, "auth", "rate_limit.token_verifications"), + GOTRUE_RATE_LIMIT_OTP: valueAt(state, "auth", "rate_limit.sign_in_sign_ups"), + GOTRUE_RATE_LIMIT_WEB3: valueAt(state, "auth", "rate_limit.web3"), + GOTRUE_SECURITY_CAPTCHA_ENABLED: valueAt(state, "auth", "captcha.enabled"), + GOTRUE_SECURITY_CAPTCHA_PROVIDER: valueAt(state, "auth", "captcha.provider"), + GOTRUE_SECURITY_CAPTCHA_SECRET: valueAt(state, "auth", "captcha.secret"), + GOTRUE_MFA_TOTP_ENROLL_ENABLED: valueAt(state, "auth", "mfa.totp.enroll_enabled"), + GOTRUE_MFA_TOTP_VERIFY_ENABLED: valueAt(state, "auth", "mfa.totp.verify_enabled"), + GOTRUE_MFA_PHONE_ENROLL_ENABLED: valueAt(state, "auth", "mfa.phone.enroll_enabled"), + GOTRUE_MFA_PHONE_VERIFY_ENABLED: valueAt(state, "auth", "mfa.phone.verify_enabled"), + GOTRUE_MFA_PHONE_OTP_LENGTH: valueAt(state, "auth", "mfa.phone.otp_length"), + GOTRUE_MFA_PHONE_TEMPLATE: valueAt(state, "auth", "mfa.phone.template"), + GOTRUE_MFA_PHONE_MAX_FREQUENCY: valueAt(state, "auth", "mfa.phone.max_frequency"), + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: valueAt(state, "auth", "mfa.web_authn.enroll_enabled"), + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: valueAt(state, "auth", "mfa.web_authn.verify_enabled"), + GOTRUE_MFA_MAX_ENROLLED_FACTORS: valueAt(state, "auth", "mfa.max_enrolled_factors"), + GOTRUE_SESSIONS_TIMEBOX: valueAt(state, "auth", "sessions.timebox"), + GOTRUE_SESSIONS_INACTIVITY_TIMEOUT: valueAt(state, "auth", "sessions.inactivity_timeout"), + GOTRUE_MAILER_AUTOCONFIRM: + valueAt(state, "auth", "email.enable_confirmations") === "false" ? "true" : "false", + GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED: "true", + GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: valueAt( + state, + "auth", + "email.double_confirm_changes", + ), + GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: valueAt( + state, + "auth", + "email.secure_password_change", + ), + GOTRUE_MAILER_MAX_FREQUENCY: valueAt(state, "auth", "email.max_frequency"), + GOTRUE_SMTP_MAX_FREQUENCY: valueAt(state, "auth", "email.max_frequency"), + GOTRUE_MAILER_OTP_LENGTH: valueAt(state, "auth", "email.otp_length"), + GOTRUE_MAILER_OTP_EXP: valueAt(state, "auth", "email.otp_expiry"), + ...(valueAt(state, "auth", "email.smtp.enabled") === "true" + ? { + GOTRUE_SMTP_HOST: valueAt(state, "auth", "email.smtp.host"), + GOTRUE_SMTP_PORT: valueAt(state, "auth", "email.smtp.port"), + GOTRUE_SMTP_USER: valueAt(state, "auth", "email.smtp.user"), + GOTRUE_SMTP_PASS: valueAt(state, "auth", "email.smtp.pass"), + GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "auth", "email.smtp.admin_email"), + GOTRUE_SMTP_SENDER_NAME: valueAt(state, "auth", "email.smtp.sender_name"), + } + : capabilityEnabled(state, "mail") + ? { + GOTRUE_SMTP_HOST: runtime === "container" ? MAIL_NETWORK_ALIAS : "127.0.0.1", + GOTRUE_SMTP_PORT: "1025", + GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "mail", "admin_email") || "admin@email.com", + GOTRUE_SMTP_SENDER_NAME: valueAt(state, "mail", "sender_name") || "Admin", + } + : {}), + GOTRUE_SMS_AUTOCONFIRM: + valueAt(state, "auth", "sms.enable_confirmations") === "false" ? "true" : "false", + GOTRUE_SMS_MAX_FREQUENCY: valueAt(state, "auth", "sms.max_frequency"), + GOTRUE_SMS_OTP_EXP: "6000", + GOTRUE_SMS_OTP_LENGTH: "6", + GOTRUE_SMS_TEMPLATE: valueAt(state, "auth", "sms.template"), + GOTRUE_SMS_PROVIDER: authSmsProvider(state), + GOTRUE_SMS_TEST_OTP: authSmsTestOtp(state), + GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED: valueAt(state, "auth", "web3.solana.enabled"), + GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED: valueAt(state, "auth", "web3.ethereum.enabled"), + GOTRUE_EXTERNAL_EMAIL_ENABLED: valueAt(state, "auth", "email.enable_signup"), + GOTRUE_EXTERNAL_PHONE_ENABLED: valueAt(state, "auth", "sms.enable_signup"), + GOTRUE_OAUTH_SERVER_ENABLED: valueAt(state, "auth", "oauth_server.enabled"), + GOTRUE_OAUTH_SERVER_AUTHORIZATION_PATH: valueAt( + state, + "auth", + "oauth_server.authorization_url_path", + ), + GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: valueAt( + state, + "auth", + "oauth_server.allow_dynamic_registration", + ), + }); const withStorageSettings = ( state: PersistedStackState, runtime: WorkloadRuntimeKind, port: number, workload: PlannedWorkload, -): Record => ({ - ...common(workload, port), - ...capabilityEnv(state, "storage", "STORAGE"), - PORT: String(port), - ANON_KEY: secret(state, "secret:auth.settings.anon_key"), - SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), - AUTH_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - DATABASE_URL: dbUrl(state, "supabase_storage_admin", runtime), - FILE_SIZE_LIMIT: valueAt(state, "storage", "file_size_limit") || "50MiB", - STORAGE_BACKEND: "file", - FILE_STORAGE_BACKEND_PATH: "/var/lib/storage", - STORAGE_FILE_BACKEND_PATH: "/var/lib/storage", - ENABLE_IMAGE_TRANSFORMATION: valueAt(state, "storage", "image_transformation.enabled") || "false", - S3_PROTOCOL_ENABLED: valueAt(state, "storage", "s3_protocol.enabled") || "true", - S3_PROTOCOL_ACCESS_KEY_ID: "local", - S3_PROTOCOL_ACCESS_KEY_SECRET: "local-secret", - STORAGE_S3_REGION: "local", - GLOBAL_S3_BUCKET: "stub", - TENANT_ID: "stub", - S3_PROTOCOL_PREFIX: "/storage/v1", - UPLOAD_FILE_SIZE_LIMIT: "52428800000", - UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", - SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", - PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), - TUS_URL_PATH: "/storage/v1/upload/resumable", - IMGPROXY_URL: runtime === "container" ? "http://supabase-workload:8080" : "http://127.0.0.1:8080", -}); + inputs: WorkloadRuntimeInputs = {}, +): Record => + compactEnvironment({ + ...common(workload, port), + ...capabilityEnv(state, "storage", "STORAGE"), + PORT: String(port), + ANON_KEY: secret(state, "secret:auth.settings.anon_key"), + SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), + AUTH_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + DATABASE_URL: dbUrl(state, "supabase_storage_admin", runtime), + FILE_SIZE_LIMIT: valueAt(state, "storage", "file_size_limit") || "50MiB", + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: "/var/lib/storage", + STORAGE_FILE_BACKEND_PATH: "/var/lib/storage", + ENABLE_IMAGE_TRANSFORMATION: + valueAt(state, "storage", "image_transformation.enabled") || "false", + S3_PROTOCOL_ENABLED: valueAt(state, "storage", "s3_protocol.enabled") || "true", + S3_PROTOCOL_ACCESS_KEY_ID: "local", + S3_PROTOCOL_ACCESS_KEY_SECRET: "local-secret", + STORAGE_S3_REGION: "local", + GLOBAL_S3_BUCKET: "stub", + TENANT_ID: "stub", + S3_PROTOCOL_PREFIX: "/storage/v1", + UPLOAD_FILE_SIZE_LIMIT: "52428800000", + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", + PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + ...(inputs.auth?.jwks === undefined ? {} : { JWT_JWKS: inputs.auth.jwks }), + TUS_URL_PATH: "/storage/v1/upload/resumable", + IMGPROXY_URL: + runtime === "container" ? "http://supabase-imgproxy:8080" : "http://127.0.0.1:8080", + }); const databaseArgs = ( state: PersistedStackState, @@ -430,11 +565,13 @@ const analyticsEnv = ( runtime: WorkloadRuntimeKind, port: number, workload: PlannedWorkload, + inputs: WorkloadRuntimeInputs = {}, ): Record => { const backend = valueAt(state, "analytics", "backend") || "postgres"; - return { + const gcpJwtPath = inputs.analytics?.gcpJwtPath ?? ""; + return compactEnvironment({ ...common(workload, port), - ...capabilityEnv(state, "analytics", "ANALYTICS"), + ...capabilityEnv(state, "analytics", "ANALYTICS", (key) => key === "ANALYTICS_GCP_JWT_PATH"), PORT: String(port), PHX_HTTP_PORT: String(port), DB_HOSTNAME: dbHost(runtime), @@ -446,6 +583,7 @@ const analyticsEnv = ( LOGFLARE_SUPABASE_MODE: "true", LOGFLARE_SINGLE_TENANT: "true", LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), + LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", LOGFLARE_MIN_CLUSTER_SIZE: "1", LOGFLARE_LOG_LEVEL: "warn", LOGFLARE_NODE_HOST: runtime === "container" ? "0.0.0.0" : "127.0.0.1", @@ -459,24 +597,29 @@ const analyticsEnv = ( GOOGLE_DATASET_ID_APPEND: "_prod", GOOGLE_PROJECT_ID: valueAt(state, "analytics", "gcp_project_id") || "local", GOOGLE_PROJECT_NUMBER: valueAt(state, "analytics", "gcp_project_number") || "0", + GOOGLE_APPLICATION_CREDENTIALS: + runtime === "container" && gcpJwtPath.length > 0 + ? "/opt/app/rel/logflare/bin/gcloud.json" + : gcpJwtPath, }), - }; + }); }; const specs: Readonly> = { "database:database": { containerPort: 5432, args: (state, _workload, port) => databaseArgs(state, port, "native"), - env: (state, workload, port, runtime = "native") => ({ - ...common(workload, port), - ...capabilityEnv(state, "database", "POSTGRES"), - PGDATA: - runtime === "container" - ? "/var/lib/postgresql/data" - : `${state.identity.projectRoot}/.supabase/db/data`, - POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), - TZDIR: "/var/db/timezone/zoneinfo", - }), + env: (state, workload, port, runtime = "native", _inputs = {}) => + compactEnvironment({ + ...common(workload, port), + ...capabilityEnv(state, "database", "POSTGRES"), + PGDATA: + runtime === "container" + ? "/var/lib/postgresql/data" + : `${state.identity.projectRoot}/.supabase/db/data`, + POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), + TZDIR: "/var/db/timezone/zoneinfo", + }), containerArgs: (state, _workload, port) => databaseArgs(state, port, "container"), readiness: { protocol: "tcp" }, networkAliases: [DATABASE_NETWORK_ALIAS], @@ -484,49 +627,61 @@ const specs: Readonly> = { "rest:rest": { containerPort: 3000, args: () => [], - env: (state, workload, port, runtime = "native") => - withRestSettings(state, runtime, port, workload), + env: (state, workload, port, runtime = "native", inputs = {}) => + withRestSettings(state, runtime, port, workload, inputs), containerArgs: () => [], + networkAliases: ["supabase-rest"], readiness: { protocol: "http", path: "/" }, }, "auth:auth": { containerPort: 9999, args: () => [], - env: (state, workload, port, runtime = "native") => - withAuthSettings(state, runtime, port, workload), + env: (state, workload, port, runtime = "native", inputs = {}) => + withAuthSettings(state, runtime, port, workload, inputs), containerArgs: () => [], + networkAliases: ["supabase-auth"], readiness: { protocol: "http", path: "/health" }, }, "realtime:realtime": { containerPort: 4000, args: () => [], - env: (state, workload, port, runtime = "native") => ({ - ...common(workload, port), - ...capabilityEnv(state, "realtime", "REALTIME"), - PORT: String(port), - DB_HOST: dbHost(runtime), - DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), - DB_USER: "postgres", - DB_PASSWORD: secret(state, "secret:database.internal.password"), - DB_NAME: "postgres", - DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", - API_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), - API_JWT_JWKS: "", - METRICS_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), - APP_NAME: "realtime", - SEED_SELF_HOST: "true", - MAX_HEADER_LENGTH: valueAt(state, "realtime", "max_header_length") || "4096", - RUN_JANITOR: "true", - }), + env: (state, workload, port, runtime = "native", inputs = {}) => + compactEnvironment({ + ...common(workload, port), + ...capabilityEnv(state, "realtime", "REALTIME"), + PORT: String(port), + DB_HOST: dbHost(runtime), + DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), + DB_USER: "postgres", + DB_PASSWORD: secret(state, "secret:database.internal.password"), + DB_NAME: "postgres", + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + API_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + ...(inputs.auth?.jwks === undefined ? {} : { API_JWT_JWKS: inputs.auth.jwks }), + METRICS_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + DB_ENC_KEY: "supabaserealtime", + SECRET_KEY_BASE: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + DNS_NODES: "''", + APP_NAME: "realtime", + SEED_SELF_HOST: "true", + MAX_HEADER_LENGTH: valueAt(state, "realtime", "max_header_length") || "4096", + ERL_AFLAGS: + valueAt(state, "realtime", "ip_version") === "IPv6" + ? "-proto_dist inet6_tcp" + : "-proto_dist inet_tcp", + RUN_JANITOR: "true", + }), containerArgs: () => [], + networkAliases: ["supabase-realtime"], readiness: { protocol: "http", path: "/api/ping" }, }, "storage:storage": { containerPort: 5000, args: () => [], - env: (state, workload, port, runtime = "native") => - withStorageSettings(state, runtime, port, workload), + env: (state, workload, port, runtime = "native", inputs = {}) => + withStorageSettings(state, runtime, port, workload, inputs), containerArgs: () => [], + networkAliases: ["supabase-storage"], readiness: { protocol: "http", path: "/status" }, }, "storage:imgproxy": { @@ -538,6 +693,7 @@ const specs: Readonly> = { IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", }), containerArgs: () => [], + networkAliases: ["supabase-imgproxy"], readiness: { protocol: "http", path: "/health" }, }, "functions:edge-runtime": { @@ -569,26 +725,42 @@ const specs: Readonly> = { containerMounts: (state) => [ { source: functionsRoot(state), target: FUNCTIONS_CONTAINER_ROOT, readOnly: true }, ], + networkAliases: ["supabase-functions"], readiness: { protocol: "http", path: "/_internal/health" }, }, "studio:studio": { containerPort: 3000, args: () => [], - env: (state, workload, port) => ({ - ...common(workload, port), - ...capabilityEnv(state, "studio", "STUDIO"), - PORT: String(port), - HOSTNAME: "0.0.0.0", - SUPABASE_URL: valueAt(state, "studio", "api_url") || "http://127.0.0.1", - SUPABASE_PUBLIC_URL: valueAt(state, "studio", "api_url") || "http://127.0.0.1", - CURRENT_CLI_VERSION: "local", - POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), - POSTGRES_USER_READ_WRITE: "postgres", - PGRST_DB_SCHEMAS: "public,graphql_public", - PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", - PGRST_DB_MAX_ROWS: "1000", - }), + env: (state, workload, port, runtime = "native", inputs = {}) => + compactEnvironment({ + ...common(workload, port), + ...capabilityEnv(state, "studio", "STUDIO"), + PORT: String(port), + HOSTNAME: "0.0.0.0", + STUDIO_PG_META_URL: + runtime === "container" ? "http://supabase-pgmeta:8080" : "http://127.0.0.1:8080", + LOGFLARE_URL: + runtime === "container" ? "http://supabase-analytics:4000" : "http://127.0.0.1:4000", + LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), + NEXT_PUBLIC_ENABLE_LOGS: + valueAt(state, "analytics", "backend").length > 0 ? "true" : "false", + NEXT_ANALYTICS_BACKEND_PROVIDER: valueAt(state, "analytics", "backend") || "postgres", + SUPABASE_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), + SUPABASE_PUBLIC_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), + SUPABASE_ANON_KEY: secret(state, "secret:auth.settings.anon_key"), + SUPABASE_SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), + SUPABASE_PUBLISHABLE_KEY: secret(state, "secret:auth.settings.anon_key"), + SUPABASE_SECRET_KEY: secret(state, "secret:auth.settings.service_role_key"), + OPENAI_API_KEY: secret(state, "secret:studio.settings.openai_api_key"), + CURRENT_CLI_VERSION: "local", + POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), + POSTGRES_USER_READ_WRITE: "postgres", + PGRST_DB_SCHEMAS: "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", + PGRST_DB_MAX_ROWS: "1000", + }), containerArgs: () => [], + networkAliases: ["supabase-studio"], readiness: { protocol: "http", path: "/api/platform/profile" }, }, "studio:pgmeta": { @@ -605,6 +777,7 @@ const specs: Readonly> = { PG_META_DB_PASSWORD: secret(state, "secret:database.internal.password"), }), containerArgs: () => [], + networkAliases: ["supabase-pgmeta"], readiness: { protocol: "http", path: "/health" }, }, "mail:mail": { @@ -619,21 +792,36 @@ const specs: Readonly> = { MP_SMTP_DISABLE_RDNS: "true", }), containerArgs: (_state, _workload, port) => ["--ui", `0.0.0.0:${port}`], + networkAliases: [MAIL_NETWORK_ALIAS], readiness: { protocol: "http", path: "/readyz" }, }, "analytics:analytics": { containerPort: 4000, args: () => [], - env: (state, workload, port, runtime = "native") => - analyticsEnv(state, runtime, port, workload), + env: (state, workload, port, runtime = "native", inputs = {}) => + analyticsEnv(state, runtime, port, workload, inputs), containerArgs: () => [], + containerMounts: (state, _workload, inputs = {}) => { + const backend = valueAt(state, "analytics", "backend") || "postgres"; + const source = inputs.analytics?.gcpJwtPath ?? ""; + return backend === "bigquery" && source.length > 0 + ? [ + { + source, + target: "/opt/app/rel/logflare/bin/gcloud.json", + readOnly: true, + }, + ] + : []; + }, + networkAliases: ["supabase-analytics"], readiness: { protocol: "http", path: "/health" }, }, "analytics:vector": { containerPort: 9001, args: (_state, _workload, _port) => [ "--config", - "/etc/vector/vector.yaml", + "share/doc/vector/config/vector.yaml", "--watch-config", "false", ], @@ -642,12 +830,21 @@ const specs: Readonly> = { ...capabilityEnv(state, "analytics", "VECTOR"), VECTOR_API_PORT: String(port), }), - containerArgs: (_state, _workload, _port) => [ - "--config", - "/etc/vector/vector.yaml", - "--watch-config", - "false", - ], + containerArgs: (_state, _workload, _port, inputs = {}) => + inputs.analytics?.vectorConfigPath === undefined + ? [] + : ["--config", "/etc/vector/vector.yaml", "--watch-config", "false"], + containerMounts: (_state, _workload, inputs = {}) => + inputs.analytics?.vectorConfigPath === undefined + ? [] + : [ + { + source: inputs.analytics.vectorConfigPath, + target: "/etc/vector/vector.yaml", + readOnly: true, + }, + ], + networkAliases: ["supabase-vector"], readiness: { protocol: "http", path: "/health" }, }, "pooler:pooler": { @@ -670,6 +867,7 @@ const specs: Readonly> = { POOL_MODE: valueAt(state, "pooler", "pool_mode") || "transaction", }), containerArgs: () => ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"], + networkAliases: ["supabase-pooler"], readiness: { protocol: "tcp" }, }, }; @@ -679,9 +877,18 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | if (spec === undefined) return undefined; return { ...spec, + networkAliases: [aliasFor(workload)], cwd: spec.cwd ?? ((state) => state.identity.projectRoot), privateEndpoint: - spec.privateEndpoint ?? ((port, runtime = "native") => privateEndpointFor(port, runtime)), + spec.privateEndpoint ?? + ((port, runtime = "native") => + privateEndpointFor( + runtime === "container" ? spec.containerPort : port, + runtime, + aliasFor(workload), + )), + nativeProcess: (artifactRoot, state, currentWorkload, port) => + nativeProcessFor(artifactRoot, state, currentWorkload, port, spec), }; }; @@ -689,14 +896,15 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | export const containerResolutionFor = ( state: PersistedStackState, workload: PlannedWorkload, - port: number, + inputs: WorkloadRuntimeInputs = {}, ): ContainerWorkloadResolution | undefined => { const spec = runtimeSpecFor(workload); if (spec === undefined) return undefined; return { - command: spec.containerArgs(state, workload, spec.containerPort), - env: spec.env(state, workload, port, "container"), - mounts: spec.containerMounts?.(state, workload) ?? [], - networkAliases: spec.networkAliases ?? [], + command: spec.containerArgs(state, workload, spec.containerPort, inputs), + env: spec.env(state, workload, spec.containerPort, "container", inputs), + mounts: spec.containerMounts?.(state, workload, inputs) ?? [], + networkAliases: [aliasFor(workload)], + ...(inputs.hostRoute === undefined ? {} : { hostRoute: inputs.hostRoute }), }; }; diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 416eaea03e..cc3b24adc4 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -27,7 +27,10 @@ const state: PersistedStackState = { runtime: { kind: "native" }, desiredGeneration: 1, desiredLifecycle: "running", - ports: [{ field: "database", port: 55432, intent: "exact" }], + ports: [ + { field: "database", port: 55432, intent: "exact" }, + { field: "api", port: 54321, intent: "exact" }, + ], secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, }, @@ -69,6 +72,12 @@ describe("workload runtime catalog", () => { expect(spec.args(state, workload, port)).toBeInstanceOf(Array); expect(spec.cwd(state, workload)).toBeTruthy(); expect(spec.privateEndpoint(port)).toEqual({ host: "127.0.0.1", port }); + expect(spec.privateEndpoint(spec.containerPort, "container")).toEqual({ + host: WORKLOAD_CATALOG[id]?.containerAlias, + port: spec.containerPort, + }); + expect(spec.privateEndpoint(49_999, "container").port).toBe(spec.containerPort); + expect(spec.networkAliases).toEqual([WORKLOAD_CATALOG[id]?.containerAlias]); } }); @@ -100,12 +109,15 @@ describe("workload runtime catalog", () => { settings: { site_url: "https://example.test", additional_redirect_urls: ["https://example.test/callback"], + jwt_issuer: "https://issuer.example", enable_signup: false, minimum_password_length: 12, password_requirements: "letters_digits", + email: { double_confirm_changes: true, secure_password_change: false }, sms: { enable_signup: true, twilio: { enabled: true, account_sid: "AC123", message_service_sid: "MG123" }, + twilio_verify: { enabled: true, account_sid: "VA123" }, test_otp: { "+33123456789": "123456" }, }, mfa: { phone: { otp_length: 8 } }, @@ -116,8 +128,10 @@ describe("workload runtime catalog", () => { backend: "bigquery", gcp_project_id: "project-42", gcp_project_number: "42", + gcp_jwt_path: "secrets/gcp.json", }, }, + realtime: { settings: { ip_version: "IPv6" } }, }, }, }); @@ -162,18 +176,61 @@ describe("workload runtime catalog", () => { GOTRUE_MFA_PHONE_OTP_LENGTH: "8", GOTRUE_SMS_PROVIDER: "twilio", GOTRUE_SMS_TEST_OTP: "+33123456789:123456", + GOTRUE_SMS_OTP_LENGTH: "6", + GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true", + GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: "false", + GOTRUE_SMTP_HOST: "127.0.0.1", + GOTRUE_SMTP_PORT: "1025", }); + expect(auth?.env(configured, planned("auth:auth"), 9999)).toMatchObject({ + GOTRUE_JWT_ISSUER: "https://issuer.example", + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: "https://issuer.example/callback", + GOTRUE_SMS_TWILIO_ACCOUNT_SID: "AC123", + GOTRUE_SMS_TWILIO_VERIFY_ACCOUNT_SID: "VA123", + }); + expect( + auth?.env(configured, planned("auth:auth"), 9999, "container", { + auth: { jwtKeys: '[{"kty":"EC"}]' }, + }).GOTRUE_JWT_KEYS, + ).toBe('[{"kty":"EC"}]'); + const realtime = runtimeSpecFor(planned("realtime:realtime")); + expect( + realtime?.env(configured, planned("realtime:realtime"), 4000, "container", { + auth: { jwks: '{"keys":[]}' }, + }), + ).toMatchObject({ ERL_AFLAGS: "-proto_dist inet6_tcp", API_JWT_JWKS: '{"keys":[]}' }); const analytics = runtimeSpecFor(planned("analytics:analytics")); - expect(analytics?.env(configured, planned("analytics:analytics"), 4000)).toMatchObject({ + const analyticsInputs = { analytics: { gcpJwtPath: "/tmp/gcp.json" } }; + expect( + analytics?.env( + configured, + planned("analytics:analytics"), + 4000, + "native", + analyticsInputs, + ), + ).toMatchObject({ GOOGLE_PROJECT_ID: "project-42", GOOGLE_PROJECT_NUMBER: "42", + GOOGLE_APPLICATION_CREDENTIALS: "/tmp/gcp.json", }); + expect( + analytics?.containerMounts?.(configured, planned("analytics:analytics"), analyticsInputs), + ).toEqual([ + { + source: "/tmp/gcp.json", + target: "/opt/app/rel/logflare/bin/gcloud.json", + readOnly: true, + }, + ]); const pooler = runtimeSpecFor(planned("pooler:pooler")); expect(pooler?.env(configured, planned("pooler:pooler"), 6543)).toMatchObject({ POOL_MODE: "session", MAX_CLIENT_CONN: "250", }); - const resolution = containerResolutionFor(configured, functions, 9000); + const resolution = containerResolutionFor(configured, functions, { + hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + }); expect(resolution?.command.join(" ")).toContain( `--main-service=${FUNCTIONS_CONTAINER_ROOT}`, ); @@ -191,6 +248,68 @@ describe("workload runtime catalog", () => { INSPECTOR_MAIN: "true", FUNCTIONS_FUNCTIONS_HELLO_VERIFY_JWT: "false", }); + expect(resolution?.env.EDGE_RUNTIME_PORT).toBe("9000"); + expect(resolution?.hostRoute).toEqual({ + host: "host.docker.internal", + gateway: "host-gateway", + }); + const studio = runtimeSpecFor(planned("studio:studio")); + expect( + studio?.env(configured, planned("studio:studio"), 3000, "container", { + hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + }), + ).toMatchObject({ + SUPABASE_URL: "http://host.docker.internal:54321", + STUDIO_PG_META_URL: "http://supabase-pgmeta:8080", + LOGFLARE_URL: "http://supabase-analytics:4000", + }); + expect( + runtimeSpecFor(planned("storage:storage"))?.env( + configured, + planned("storage:storage"), + 5000, + "container", + ).IMGPROXY_URL, + ).toBe("http://supabase-imgproxy:8080"); + const nodeArtifactRoot = "/tmp/native-artifact"; + expect( + studio?.nativeProcess(nodeArtifactRoot, configured, planned("studio:studio"), 3000), + ).toEqual({ + executable: "/tmp/native-artifact/node/bin/node", + args: ["/tmp/native-artifact/app/apps/studio/docker-entrypoint.mjs"], + cwd: "/tmp/native-artifact/app/apps/studio", + }); + expect( + runtimeSpecFor(planned("analytics:vector"))?.nativeProcess( + nodeArtifactRoot, + configured, + planned("analytics:vector"), + 9001, + ), + ).toEqual({ + executable: "/tmp/native-artifact/bin/vector", + args: [ + "--config", + "/tmp/native-artifact/share/doc/vector/config/vector.yaml", + "--watch-config", + "false", + ], + cwd: "/tmp/supabase-runtime-spec", + }); + const vector = planned("analytics:vector"); + expect(containerResolutionFor(configured, vector)?.command).toEqual([]); + const vectorResolution = containerResolutionFor(configured, vector, { + analytics: { vectorConfigPath: "/tmp/vector.yaml" }, + }); + expect(vectorResolution?.command).toEqual([ + "--config", + "/etc/vector/vector.yaml", + "--watch-config", + "false", + ]); + expect(vectorResolution?.mounts).toEqual([ + { source: "/tmp/vector.yaml", target: "/etc/vector/vector.yaml", readOnly: true }, + ]); }).pipe(Effect.provide(NodeServices.layer)), ); }); From 06e549e405d7baf171a9d2bec5dd86d82ff469c3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 11:10:57 +0200 Subject: [PATCH 027/174] docs(stack): simplify gateway ownership --- .../plans/2026-08-28-stack-runtime-rewrite.md | 34 ++++++----- ...2026-08-28-stack-runtime-rewrite-design.md | 61 +++++++++---------- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index 19fa840e0f..c68f6755ad 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -59,7 +59,7 @@ packages/stack/src ├── supervisor/ owner session, desired state, reconciler, status/log publication ├── preparation/ verified native/container artifacts ├── runtime/ common contract, native driver, container driver/engine adapters -├── gateway/ HTTP/TCP ingress and container activation server +├── gateway/ Supervisor-owned HTTP/TCP ingress and direct activation ├── functions/ safe live function discovery └── entrypoints/ Node/Bun Supervisor and gateway programs ``` @@ -448,29 +448,30 @@ artifact keys, state schemas, drivers, or control protocols. **Files:** -- Create: `packages/stack/src/gateway/Gateway.ts`, `HttpGateway.ts`, `TcpGateway.ts`, - `ActivationServer.ts`, `ActivationFile.ts` +- Create: `packages/stack/src/gateway/Gateway.ts`, `HttpGateway.ts`, `TcpGateway.ts` - Create: `packages/stack/src/gateway/gateway.integration.test.ts` -- Create: `packages/stack/src/gateway/activation.integration.test.ts` +- Delete after production composition replaces the provisional split path: + `packages/stack/src/gateway/ActivationServer.ts`, `ActivationFile.ts`, + `packages/stack/src/gateway/activation.integration.test.ts` **Interfaces:** - Gateway is the only public ingress and routes HTTP/WebSocket and transparent TCP listeners. -- Native activation calls Supervisor directly; container activation uses an ephemeral exact-release - authenticated TCP server and read-only fenced file with activation-only authority. +- The Supervisor-owned in-process gateway calls Supervisor activation directly in both runtime + modes. Container workloads expose only loopback-bound private backend ports to it. - [x] **Step 1: Write observable protocol scenarios and verify RED** Cover HTTP routes/CORS/forwarding/WebSocket, PostgreSQL/TLS/SMTP/POP3/STARTTLS byte transparency, backpressure/half-close, 503 activation failure, 502 backend failure, dormant health probes, - dependency-closure activation, generation/session/gateway fences, bounded frames/concurrency/ - deadlines, and no idle eviction. + dependency-closure activation, generation/session fences, concurrent activation joining, and no + idle eviction. - [x] **Step 2: Implement ingress and activation** - Use stream/socket platform APIs with scoped cancellation. Capability files contain no labels or - command-line secrets and rotate with each owner/gateway. The activation server exposes only one - activation operation and has no runtime/state/filesystem/log authority. + Use stream/socket platform APIs with scoped cancellation. Keep the gateway in the Supervisor so + both runtime modes share the same direct activation path. Do not introduce a gateway artifact, + activation server, capability file, or second control protocol. - [x] **Step 3: Verify and commit** @@ -520,21 +521,22 @@ artifact keys, state schemas, drivers, or control protocols. - Create: `packages/stack/src/runtime/ContainerEngine.ts`, `DockerEngine.ts`, `PodmanEngine.ts`, `ContainerRuntime.ts` -- Modify: gateway activation host routing and Supervisor composition +- Modify: gateway backend routing and Supervisor composition - Create: `packages/stack/src/runtime/container-runtime.integration.test.ts` **Interfaces:** - Docker and Podman adapters create exact identity/generation-labeled private networks, workloads, - persistent volumes, gateway publication, read-only activation/config files, and exact cleanup. + persistent volumes, loopback-only private backend publications, read-only config files, and exact + cleanup. - No native process is started for a container identity and no container is created for native. - [x] **Step 1: Write controlled engine integration scenarios and verify RED** Cover platform alias selection (`host.docker.internal`, Linux host-gateway, - `host.containers.internal`), unsupported routing preflight, gateway-only host publication, private - backend endpoints, engine bind races, semantic-hash adoption, stale removal, persistent volumes, - strict runtime split, and lazy Functions edits through the mounted root. + `host.containers.internal`), unsupported routing preflight, no public workload publication, + loopback-only private backend endpoints, engine bind races, semantic-hash adoption, stale removal, + persistent volumes, strict runtime split, and lazy Functions edits through the mounted root. - [x] **Step 2: Implement the smallest engine contract** diff --git a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md index af0ad5c06f..60ffd968d8 100644 --- a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md +++ b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md @@ -783,10 +783,12 @@ when that stack starts. Under the registry lock, `PortCoordinator` retains uncha selects missing automatic ports, and validates exact requests. It commits the complete state before releasing the lock and never performs downloads or runtime work in the transaction. -Native listeners remain bound and transfer directly to the in-process gateway. Container mode closes -its transient test socket before engine publication; the engine bind is authoritative. A host race +Public listeners remain bound and transfer directly to the Supervisor-owned in-process gateway in +both runtime modes. Container workloads publish only their backend service port to a separately +reserved, durable `127.0.0.1` private host port; they never publish a public stack port. A host race returns `PortUnavailableError` and retains the assignment without silently selecting another port. -Stop releases live sockets and publications but not logical records; destroy releases all records. +Stop releases live sockets and loopback publications but not logical records; destroy releases all +records. ### Gateway and mandatory lazy activation @@ -794,26 +796,22 @@ Stop releases live sockets and publications but not logical records; destroy rel and error mapping. TCP copies bytes without interpreting PostgreSQL, TLS, SMTP, POP3, or STARTTLS. Backend workloads use private network endpoints; only the gateway publishes host listeners. -Lazy activation is mandatory for both native and container runtimes. A native gateway invokes the -Supervisor activation capability in process. A container gateway uses a dedicated ephemeral exact- -release TCP `GatewayActivationServer` owned by the Supervisor, distinct from stable maintenance. - -Before creating the gateway container, the Supervisor binds the activation server and writes an -owner-readable ephemeral file containing endpoint, random capability, `StackId`, desiredGeneration, -`gatewayInstanceId`, and `ownerSessionId`. The file is mounted read-only into the gateway; values are -never placed in labels or command arguments. The selected `ContainerEngine` adapter resolves the -host address/alias reachable from its gateway container: Docker Desktop uses -`host.docker.internal`, Linux Docker uses a host-gateway mapping, and Podman uses -`host.containers.internal` or its concrete equivalent. Unsupported engine/platform routing fails -before gateway creation. - -The server validates every fence, bounds frame size, concurrent requests, and activation deadlines, -and grants activation only. It has no engine, artifact, filesystem, stop, destroy, configuration, -credential, or log authority. Capability files and server sockets rotate or are destroyed with the -owner and gateway. A request can activate only an enabled listener for the matching generation, -gateway instance, and owner session. Activation is one-way per generation; activated workloads use -catalog restart policy until stop/restart and are never idle-evicted. HTTP activation failure maps -to 503, post-activation backend failure to 502, and TCP activation failure closes the connection. +Lazy activation is mandatory for both native and container runtimes. The Supervisor-owned gateway +invokes the Supervisor activation capability directly in both modes. There is no gateway container, +activation server, activation protocol, or capability file. This keeps one ingress implementation +and one activation path while preserving the strict rule that capability workloads within a stack +are either all native or all containerized. + +Container workloads communicate with one another through fixed ports and aliases on their private +network. When the host gateway needs to reach one, the engine publishes only that workload's backend +port to its reserved `127.0.0.1` private host port. Container-to-host routes such as +`host.docker.internal` remain available only for workloads that must call a public stack endpoint; +unsupported engine/platform routing fails before workload creation. + +A request can activate only an enabled listener for the matching desired generation and owner +session. Activation is one-way per generation; activated workloads use catalog restart policy until +stop/restart and are never idle-evicted. HTTP activation failure maps to 503, post-activation backend +failure to 502, and TCP activation failure closes the connection. ### Functions capability @@ -863,7 +861,7 @@ packages/stack ├── supervisor/ Supervisor and StackReconciler ├── preparation/ verified ArtifactStore ├── runtime/ native and container drivers plus engine adapters -├── gateway/ HTTP, TCP, and activation server +├── gateway/ Supervisor-owned HTTP/TCP ingress and direct activation ├── control/ one owner endpoint, maintenance framing, exact-release RPC └── entrypoints/ Supervisor and gateway executables ``` @@ -899,10 +897,10 @@ Build observable vertical slices in this order: 2. Native lifecycle: capability Modules, catalog/version validation, secrets, one representative workload graph, reconciliation, start, stop, explicit restart, destroy, status/watch, logs, credentials, and idempotent database bootstrap. -3. Ingress and preparation: sticky ports, native listener transfer, HTTP/TCP gateway, - `GatewayActivationServer`, ArtifactStore, mandatory lazy activation, and live Functions. -4. Container execution and recovery: engine adapters, private networks, gateway-only publication, - host-gateway aliases, activation-file fencing, workload-spec-hash adoption, and owner recovery. +3. Ingress and preparation: sticky public and private ports, listener transfer, HTTP/TCP gateway, + ArtifactStore, mandatory direct lazy activation, and live Functions. +4. Container execution and recovery: engine adapters, private networks, loopback-only private + backend publication, host-gateway aliases, workload-spec-hash adoption, and owner recovery. 5. Complete catalog: every capability's native/container mappings, platform/engine activation integration, port and secret matrices, and a small e2e smoke suite. 6. Promise facade and release: mechanical adaptation of the authoritative Effect contract, @@ -989,14 +987,15 @@ Secrets, logs, and ports: owner-only and bounded, and expose cursor continuity. - Automatic port assignments remain globally/logically exclusive while stopped and running. Stopped exact assignments may coexist and conflict only with a live stack or host listener at start. -- Native listener transfer avoids a rebind window; container bind races preserve the sticky claim. +- Listener transfer avoids a public rebind window in both modes; container loopback bind races + preserve the sticky private claim. Gateway, activation, and Functions: - HTTP/TCP routing, WebSockets, TLS, STARTTLS, backpressure, half-close, and 503/502 mapping work through the gateway. -- The ephemeral `GatewayActivationServer` uses the read-only fenced file, engine-specific host alias, - bounded frames/concurrency/deadlines, exact-release protocol, and activation-only authority. +- The Supervisor-owned gateway activates workloads directly in both modes; there is no gateway + container, activation server, capability file, or second control protocol. - Lazy activation is one-way per generation, honors dependency readiness and restart policy, and has no idle eviction. Health/status probes never activate a dormant capability. - Function root traversal and symlink escapes are rejected; live create/edit/delete and shared From f1758bd39c53157e844898867b42d1bc4b820d14 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 11:13:11 +0200 Subject: [PATCH 028/174] fix(stack): close remaining workload contract gaps --- ...26-08-28-stack-runtime-rewrite-progress.md | 12 ++ packages/stack/src/model/Compiler.ts | 7 + packages/stack/src/model/WorkloadCatalog.ts | 5 +- .../model/capabilities/auth-third-party.ts | 122 ++++++++++++++ .../src/model/catalog.integration.test.ts | 9 +- .../src/model/compiler.integration.test.ts | 54 ++++++ .../src/preparation/SlimServicesSource.ts | 66 ++++++++ .../slim-services.integration.test.ts | 19 +++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 157 ++++++++++++++++-- .../workload-runtime.integration.test.ts | 102 +++++++++++- 10 files changed, 536 insertions(+), 17 deletions(-) create mode 100644 packages/stack/src/model/capabilities/auth-third-party.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index b7d547fccf..511c8fe9c0 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -650,3 +650,15 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Fix-round evidence: catalog/compiler/database-bootstrap/runtime/slim/container integration tests pass (80 targeted tests in the latest run); Task 13B still owns env-file materialization, gateway/port lifecycle, credentials wiring, and production composition. + +#### Task 13A fix round 3 — close remaining workload contract gaps (2026-08-29) + +- PostgREST now selects the managed symmetric JWT secret or owner-resolved JWKS according to the + persisted signing/third-party mode; typed resolution validation rejects missing JWKS and template + base inputs before process/container creation. +- Auth mailer templates and enabled notifications use the legacy `GOTRUE_MAILER_TEMPLATES_*`, + `SUBJECTS_*`, and notification suffixes with owner-resolved gateway URLs and file extensions. +- Third-party Auth issuer derivation is a closed pure mapping (Firebase/Auth0/Cognito/Clerk/WorkOS), + with compiler validation for required fields and one-provider exclusivity. +- Studio native entrypoint cwd is the artifact `app`; Vector requires its shipped config; Postgres + slim artifacts receive a contained `init-scripts` alias for the current archive layout. diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index c4daf5d76e..238e60be5a 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -29,6 +29,7 @@ import { StudioModule, AnalyticsModule, } from "./capabilities/index.ts"; +import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { CAPABILITY_MODULES, createExecutionPlan, type ExecutionPlan } from "./ExecutionPlan.ts"; import type { CapabilityModule, CapabilityRelease } from "./CapabilityModule.ts"; @@ -665,6 +666,12 @@ export const compileStack = ( specHashes, false, ); + const thirdParty = resolveThirdPartyIssuer(authResult.settings); + if (!thirdParty.ok) + return yield* new InvalidStackConfigError({ + message: thirdParty.message, + ...(thirdParty.provider === undefined ? {} : { provider: thirdParty.provider }), + }); const realtimeResult = yield* materializeCapability( RealtimeModule, extract(rawCapabilities, "realtime"), diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 3112d2ef09..37e909d135 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -81,6 +81,7 @@ export const WORKLOAD_CATALOG: Readonly> = "share/supabase-cli/bin/supabase-postgres-init.sh", "share/supabase-cli/config/pgsodium_getkey.sh", "share/supabase-cli/migrations", + "share/supabase-cli/init-scripts", "lib", ], ["14.1.0.89", "15.8.1.085", "17.6.1.165"], @@ -158,7 +159,7 @@ export const WORKLOAD_CATALOG: Readonly> = nativeProcess: { executablePath: "node/bin/node", args: ["app/apps/studio/docker-entrypoint.mjs"], - cwd: "app/apps/studio", + cwd: "app", }, }, ), @@ -202,7 +203,7 @@ export const WORKLOAD_CATALOG: Readonly> = "0.53.0", "ghcr.io/supabase/vector:0.53.0-alpine", "bin/vector", - ["bin/vector"], + ["bin/vector", "share/doc/vector/config/vector.yaml"], ["0.53.0", "0.53.0-alpine"], { containerAlias: "supabase-vector" }, ), diff --git a/packages/stack/src/model/capabilities/auth-third-party.ts b/packages/stack/src/model/capabilities/auth-third-party.ts new file mode 100644 index 0000000000..a6e18b0f91 --- /dev/null +++ b/packages/stack/src/model/capabilities/auth-third-party.ts @@ -0,0 +1,122 @@ +/** + * Closed mapping for the third-party issuer rules used by Auth.ResolveJWKS. + * + * This module is deliberately pure: network discovery and JWKS retrieval are + * owned by the runtime owner, while validation and issuer construction remain + * deterministic and testable during compilation. + */ + +export type ThirdPartyProviderName = "firebase" | "auth0" | "aws_cognito" | "clerk" | "workos"; + +export interface ThirdPartyIssuer { + readonly provider: ThirdPartyProviderName; + readonly issuer: string; +} + +export interface ThirdPartyIssuerError { + readonly provider?: ThirdPartyProviderName; + readonly message: string; +} + +export type ThirdPartyIssuerResult = + | { readonly ok: true; readonly value?: ThirdPartyIssuer } + | ({ readonly ok: false } & ThirdPartyIssuerError); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const text = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); + +const enabled = (value: unknown): boolean => isRecord(value) && value.enabled === true; + +/** Applies Auth.ResolveJWKS' issuerURL and validation rules. */ +export const resolveThirdPartyIssuer = (settings: unknown): ThirdPartyIssuerResult => { + if (!isRecord(settings)) return { ok: true }; + const thirdParty = isRecord(settings.third_party) ? settings.third_party : settings; + const candidates: Array<{ readonly provider: ThirdPartyProviderName; readonly value: unknown }> = + []; + for (const candidate of [ + { provider: "firebase" as const, value: thirdParty.firebase }, + { provider: "auth0" as const, value: thirdParty.auth0 }, + { provider: "aws_cognito" as const, value: thirdParty.aws_cognito }, + { provider: "clerk" as const, value: thirdParty.clerk }, + { provider: "workos" as const, value: thirdParty.workos }, + ]) + if (enabled(candidate.value)) candidates.push(candidate); + if (candidates.length > 1) + return { + ok: false, + message: "Only one auth.third_party provider may be enabled at a time", + }; + const candidate = candidates[0]; + if (candidate === undefined) return { ok: true }; + const value = isRecord(candidate.value) ? candidate.value : {}; + switch (candidate.provider) { + case "firebase": { + const projectId = text(value.project_id); + return projectId.length === 0 + ? { ok: false, provider: candidate.provider, message: "Firebase project_id is required" } + : { + ok: true, + value: { + provider: candidate.provider, + issuer: `https://securetoken.google.com/${projectId}`, + }, + }; + } + case "auth0": { + const tenant = text(value.tenant); + if (tenant.length === 0) + return { ok: false, provider: candidate.provider, message: "Auth0 tenant is required" }; + const region = text(value.tenant_region); + return { + ok: true, + value: { + provider: candidate.provider, + issuer: `https://${tenant}.${region.length > 0 ? `${region}.` : ""}auth0.com`, + }, + }; + } + case "aws_cognito": { + const userPoolId = text(value.user_pool_id); + const region = text(value.user_pool_region); + if (userPoolId.length === 0) + return { + ok: false, + provider: candidate.provider, + message: "AWS Cognito user_pool_id is required", + }; + if (region.length === 0) + return { + ok: false, + provider: candidate.provider, + message: "AWS Cognito user_pool_region is required", + }; + return { + ok: true, + value: { + provider: candidate.provider, + issuer: `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`, + }, + }; + } + case "clerk": { + const domain = text(value.domain); + if (domain.length === 0) + return { ok: false, provider: candidate.provider, message: "Clerk domain is required" }; + if (!/^(?:clerk(?:\.[a-z0-9-]+){2,}|(?:[a-z0-9-]+\.)+clerk\.accounts\.dev)$/u.test(domain)) + return { + ok: false, + provider: candidate.provider, + message: "Clerk domain has an invalid format", + }; + return { ok: true, value: { provider: candidate.provider, issuer: `https://${domain}` } }; + } + case "workos": { + const issuer = text(value.issuer_url); + return issuer.length === 0 + ? { ok: false, provider: candidate.provider, message: "WorkOS issuer_url is required" } + : { ok: true, value: { provider: candidate.provider, issuer } }; + } + } +}; diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index b84d50a836..699018bc21 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -40,7 +40,14 @@ describe("complete workload catalog", () => { "bin/realtime", "bin/server", ]); + expect(WORKLOAD_CATALOG["database:database"]?.requiredRuntimePaths).toContain( + "share/supabase-cli/init-scripts", + ); expect(WORKLOAD_CATALOG["analytics:analytics"]?.requiredRuntimePaths).toEqual(["bin/logflare"]); + expect(WORKLOAD_CATALOG["analytics:vector"]?.requiredRuntimePaths).toEqual([ + "bin/vector", + "share/doc/vector/config/vector.yaml", + ]); expect(WORKLOAD_CATALOG["pooler:pooler"]?.requiredRuntimePaths).toEqual([ "bin/migrate", "bin/supavisor", @@ -76,7 +83,7 @@ describe("complete workload catalog", () => { expect(WORKLOAD_CATALOG["studio:studio"]?.nativeProcess).toEqual({ executablePath: "node/bin/node", args: ["app/apps/studio/docker-entrypoint.mjs"], - cwd: "app/apps/studio", + cwd: "app", }); }); diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 743d17fc78..8da00cb47c 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { canonicalize, compileStack } from "./Compiler.ts"; +import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; const layer = NodeServices.layer; const compile = ( @@ -76,6 +77,59 @@ describe("closed capability compiler", () => { }), ); + it.live("derives each supported third-party issuer and rejects invalid combinations", () => + Effect.gen(function* () { + const cases = [ + [ + { firebase: { enabled: true, project_id: "project-42" } }, + "https://securetoken.google.com/project-42", + ], + [ + { auth0: { enabled: true, tenant: "tenant", tenant_region: "eu" } }, + "https://tenant.eu.auth0.com", + ], + [ + { + aws_cognito: { enabled: true, user_pool_id: "eu_pool", user_pool_region: "eu-west-1" }, + }, + "https://cognito-idp.eu-west-1.amazonaws.com/eu_pool", + ], + [ + { clerk: { enabled: true, domain: "example.clerk.accounts.dev" } }, + "https://example.clerk.accounts.dev", + ], + [ + { workos: { enabled: true, issuer_url: "https://login.example.test" } }, + "https://login.example.test", + ], + ] as const; + for (const [settings, issuer] of cases) { + const result = resolveThirdPartyIssuer(settings); + expect(result).toEqual({ ok: true, value: expect.objectContaining({ issuer }) }); + } + const invalid = yield* compile({ + capabilities: { + auth: { + settings: { + third_party: { + firebase: { enabled: true, project_id: "project-42" }, + auth0: { enabled: true, tenant: "tenant" }, + }, + }, + }, + }, + }).pipe(Effect.exit); + expect(failureOf(invalid)).toBeInstanceOf(InvalidStackConfigError); + expect(resolveThirdPartyIssuer({ firebase: { enabled: true } })).toMatchObject({ + ok: false, + provider: "firebase", + }); + expect( + resolveThirdPartyIssuer({ clerk: { enabled: true, domain: "not-a-clerk-domain" } }), + ).toMatchObject({ ok: false, provider: "clerk" }); + }), + ); + it.live("materializes a non-default realtime setting", () => Effect.gen(function* () { const result = yield* compile({ diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index e51c714f6a..d6badaf5b4 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -224,6 +224,71 @@ const validateExtractedTree = ( ), ); +/** + * Current Postgres slim archives keep init scripts below `migrations/`, while + * the shipped init entrypoint looks for the historical sibling directory. + * Publish one contained relative alias so the entrypoint and archive layout + * agree without copying or reimplementing the bootstrap revisions. + */ +export const normalizeSlimServicesLayout = ( + artifact: NativeWorkloadArtifact, + destination: string, +): Effect.Effect => { + if (artifact.service !== "postgres") return Effect.void; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = (candidate: string): Effect.Effect => + fs.exists(candidate).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to inspect Postgres slim init-scripts layout", + path: candidate, + cause, + }), + ), + ); + const target = path.join(destination, "share/supabase-cli/migrations/init-scripts"); + const alias = path.join(destination, "share/supabase-cli/init-scripts"); + const targetExists = yield* exists(target); + if (!targetExists) + return yield* new StackPreparationError({ + message: "Postgres slim artifact is missing its init-scripts directory", + path: "share/supabase-cli/migrations/init-scripts", + }); + const aliasExists = yield* exists(alias); + if (!aliasExists) { + yield* fs.symlink("migrations/init-scripts", alias).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to normalize Postgres slim init-scripts layout", + cause, + }), + ), + ); + return; + } + const [realTarget, realAlias] = yield* Effect.all([ + fs.realPath(target), + fs.realPath(alias), + ]).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to validate Postgres slim init-scripts layout", + cause, + }), + ), + ); + if (realTarget !== realAlias) + return yield* new StackPreparationError({ + message: "Postgres slim init-scripts alias resolves outside its target", + }); + }); +}; + export const makeSlimServicesSource = ( resolve: (request: ArtifactRequest) => NativeWorkloadArtifact | undefined, fetchRequest: Fetcher = fetcher, @@ -371,6 +436,7 @@ export const makeSlimServicesSource = ( return yield* new StackPreparationError({ message: `Slim-services archive extraction exited with code ${exitCode}`, }); + yield* normalizeSlimServicesLayout(artifact, destination); yield* validateExtractedTree(fs, path, destination); yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); return compressed; diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 4b951dfef7..4379461b47 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -6,6 +6,7 @@ import { makeArtifactStore, type ArtifactRequest } from "./ArtifactStore.ts"; import { digestHex } from "./Integrity.ts"; import { makeSlimServicesSource, + normalizeSlimServicesLayout, slimServicesChecksum, systemTarBoundary, type ZstdDecompressor, @@ -105,6 +106,24 @@ const requestUrl = (input: Parameters[0]): string => typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; describe("slim-services artifact source", () => { + it.live("normalizes the Postgres init-script path within the artifact root", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-postgres-", + }); + const initScripts = `${destination}/share/supabase-cli/migrations/init-scripts`; + yield* fs.makeDirectory(initScripts, { recursive: true }); + const postgresArtifact = { ...artifact, service: "postgres" }; + yield* normalizeSlimServicesLayout(postgresArtifact, destination); + expect(yield* fs.realPath(`${destination}/share/supabase-cli/init-scripts`)).toBe( + yield* fs.realPath(initScripts), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("verifies checksums and extracts a manifest-matched archive using injected fetch", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index b084db45e5..6adf97a867 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -3,13 +3,21 @@ import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { CapabilityName } from "../public/Capability.ts"; import type { ContainerHostRoute, ContainerMount } from "./ContainerEngine.ts"; import { WORKLOAD_CATALOG, type NativeWorkloadProcess } from "../model/WorkloadCatalog.ts"; +import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; +import { Effect } from "effect"; +import { StackPreparationError } from "../public/Errors.ts"; export type WorkloadRuntimeKind = "native" | "container"; /** Inputs resolved by the owner before a process/container is created. */ export interface WorkloadRuntimeInputs { /** Resolved GoTrue signing key JSON and public JWKS. */ - readonly auth?: Readonly<{ readonly jwtKeys?: string; readonly jwks?: string }>; + readonly auth?: Readonly<{ + readonly jwtKeys?: string; + readonly jwks?: string; + /** Base URL serving the configured Auth email templates through the gateway. */ + readonly templateBaseUrl?: string; + }>; /** Resolved host path for the BigQuery service-account file. */ readonly analytics?: Readonly<{ readonly gcpJwtPath?: string; @@ -81,6 +89,96 @@ export interface ContainerWorkloadResolution { readonly hostRoute?: ContainerHostRoute; } +/** + * Validates owner-resolved material before a process/container is created. + * Network/file discovery stays outside this pure runtime specification; the + * owner supplies the resulting values through WorkloadRuntimeInputs. + */ +export const validateWorkloadRuntimeInputs = ( + state: PersistedStackState, + workload: PlannedWorkload, + inputs: WorkloadRuntimeInputs = {}, +): Effect.Effect => + Effect.gen(function* () { + const signing = state.definition?.security.jwt.signing; + const thirdParty = resolveThirdPartyIssuer(settingsFor(state, "auth")); + if (!thirdParty.ok) + return yield* new StackPreparationError({ + message: thirdParty.message, + workload: workload.id, + }); + const jwksConsumer = + workload.id === "rest:rest" || + workload.id === "auth:auth" || + workload.id === "realtime:realtime" || + workload.id === "storage:storage"; + if ( + jwksConsumer && + (signing?.kind === "jwks-file" || thirdParty.value !== undefined) && + (inputs.auth?.jwks === undefined || inputs.auth.jwks.length === 0) + ) + return yield* new StackPreparationError({ + message: "Resolved JWKS material is required for the configured auth mode", + workload: workload.id, + }); + if ( + jwksConsumer && + signing?.kind !== "jwks-file" && + thirdParty.value === undefined && + secret(state, "secret:security.jwt.signing.secret").length === 0 + ) + return yield* new StackPreparationError({ + message: "Managed JWT signing secret is required for the configured auth mode", + workload: workload.id, + }); + if (workload.id === "auth:auth") { + const signing = state.definition?.security.jwt.signing; + if ( + signing?.kind === "jwks-file" && + (inputs.auth?.jwtKeys === undefined || inputs.auth.jwtKeys.length === 0) + ) + return yield* new StackPreparationError({ + message: "Resolved JWT signing keys are required for Auth", + workload: workload.id, + }); + } + if (workload.id === "analytics:analytics") { + const backend = valueAt(state, "analytics", "backend") || "postgres"; + if ( + backend === "bigquery" && + valueAt(state, "analytics", "gcp_jwt_path").length > 0 && + (inputs.analytics?.gcpJwtPath === undefined || inputs.analytics.gcpJwtPath.length === 0) + ) + return yield* new StackPreparationError({ + message: "Resolved Analytics service-account path is required", + workload: workload.id, + }); + } + if (workload.id === "auth:auth") { + const email = settingsFor(state, "auth"); + const emailSettings = isRecord(email) && isRecord(email.email) ? email.email : undefined; + const templates = emailSettings?.template; + const notifications = emailSettings?.notification; + const hasContent = + (isRecord(templates) && + Object.values(templates).some( + (value) => isRecord(value) && settingValue(state, value.content_path).length > 0, + )) || + (isRecord(notifications) && + Object.values(notifications).some( + (value) => isRecord(value) && settingValue(state, value.content_path).length > 0, + )); + if ( + hasContent && + (inputs.auth?.templateBaseUrl === undefined || inputs.auth.templateBaseUrl.length === 0) + ) + return yield* new StackPreparationError({ + message: "Resolved Auth template base URL is required", + workload: workload.id, + }); + } + }); + export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; const DATABASE_NETWORK_ALIAS = "supabase-database"; @@ -177,6 +275,12 @@ const dbUrl = (state: PersistedStackState, role: string, runtime: WorkloadRuntim return `postgresql://${role}:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${port}/postgres`; }; +const usesResolvedJwks = (state: PersistedStackState): boolean => { + const signing = state.definition?.security.jwt.signing; + const thirdParty = resolveThirdPartyIssuer(settingsFor(state, "auth")); + return signing?.kind === "jwks-file" || (thirdParty.ok && thirdParty.value !== undefined); +}; + const common = (workload: PlannedWorkload, port: number): Record => ({ SUPABASE_STACK_WORKLOAD: workload.id, SUPABASE_STACK_PRIVATE_PORT: String(port), @@ -244,10 +348,9 @@ const withRestSettings = ( PGRST_DB_SCHEMAS: valueAt(state, "rest", "schemas") || "public,graphql_public", PGRST_DB_EXTRA_SEARCH_PATH: valueAt(state, "rest", "extra_search_path") || "public,extensions", PGRST_DB_ANON_ROLE: "anon", - // PostgREST validates the compact JWT with the managed signing secret; - // resolved JWKS belongs to services that consume public key sets (Auth, - // Realtime and Storage), not this HMAC setting. - PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + PGRST_JWT_SECRET: usesResolvedJwks(state) + ? (_inputs.auth?.jwks ?? "") + : secret(state, "secret:security.jwt.signing.secret"), PGRST_SERVER_PORT: String(port), PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows") || "1000", }); @@ -255,6 +358,7 @@ const withRestSettings = ( const authNestedEnvironment = ( state: PersistedStackState, jwtIssuer: string, + inputs: WorkloadRuntimeInputs, ): Record => { const out: Record = {}; const settings = settingsFor(state, "auth"); @@ -286,6 +390,16 @@ const authNestedEnvironment = ( } const email = settings.email; if (isRecord(email)) { + const templateBaseUrl = inputs.auth?.templateBaseUrl?.replace(/\/+$/u, ""); + const fileExtension = (path: string): string => { + const file = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1); + const dot = file.lastIndexOf("."); + return dot <= 0 ? "" : file.slice(dot); + }; + const templateUrl = (id: string, contentPath: string): string | undefined => + templateBaseUrl === undefined + ? undefined + : `${templateBaseUrl}/email/${id}${fileExtension(contentPath)}`; const templates = email.template; if (isRecord(templates)) for (const [name, value] of Object.entries(templates)) { @@ -295,7 +409,8 @@ const authNestedEnvironment = ( const contentPath = settingValue(state, value.content_path); if (value.subject !== null && value.subject !== undefined) out[`GOTRUE_MAILER_SUBJECTS_${normalized}`] = subject; - if (contentPath.length > 0) out[`GOTRUE_MAILER_TEMPLATES_${normalized}`] = contentPath; + const url = contentPath.length > 0 ? templateUrl(name, contentPath) : undefined; + if (url !== undefined) out[`GOTRUE_MAILER_TEMPLATES_${normalized}`] = url; } const notifications = email.notification; if (isRecord(notifications)) @@ -307,10 +422,16 @@ const authNestedEnvironment = ( state, value.enabled, ); - out[`GOTRUE_MAILER_NOTIFICATIONS_${normalized}_SUBJECT`] = settingValue( - state, - value.subject, - ); + const contentPath = settingValue(state, value.content_path); + const notificationUrl = + contentPath.length > 0 ? templateUrl(`${name}_notification`, contentPath) : undefined; + if (notificationUrl !== undefined) + out[`GOTRUE_MAILER_TEMPLATES_${normalized}_NOTIFICATION`] = notificationUrl; + if (value.subject !== null && value.subject !== undefined) + out[`GOTRUE_MAILER_SUBJECTS_${normalized}_NOTIFICATION`] = settingValue( + state, + value.subject, + ); } } return out; @@ -381,7 +502,11 @@ const withAuthSettings = ( "GOTRUE", (key) => key === "GOTRUE_SIGNING_KEYS_PATH" || key.startsWith("GOTRUE_THIRD_PARTY_"), ), - ...authNestedEnvironment(state, valueAt(state, "auth", "jwt_issuer") || authExternalUrl(state)), + ...authNestedEnvironment( + state, + valueAt(state, "auth", "jwt_issuer") || authExternalUrl(state), + inputs, + ), GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin", runtime), GOTRUE_DB_DRIVER: "postgres", GOTRUE_SITE_URL: valueAt(state, "auth", "site_url") || "http://127.0.0.1:3000", @@ -908,3 +1033,13 @@ export const containerResolutionFor = ( ...(inputs.hostRoute === undefined ? {} : { hostRoute: inputs.hostRoute }), }; }; + +/** Effect boundary used by owners that need typed validation before creating a container. */ +export const resolveContainerResolutionFor = ( + state: PersistedStackState, + workload: PlannedWorkload, + inputs: WorkloadRuntimeInputs = {}, +): Effect.Effect => + validateWorkloadRuntimeInputs(state, workload, inputs).pipe( + Effect.map(() => containerResolutionFor(state, workload, inputs)), + ); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index cc3b24adc4..783cfa07c4 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Exit } from "effect"; import { WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { PersistedStackState } from "../state/StackState.ts"; @@ -9,7 +9,9 @@ import { compileStack } from "../model/Compiler.ts"; import { containerResolutionFor, FUNCTIONS_CONTAINER_ROOT, + resolveContainerResolutionFor, runtimeSpecFor, + validateWorkloadRuntimeInputs, } from "./WorkloadRuntimeSpec.ts"; const state: PersistedStackState = { @@ -33,6 +35,7 @@ const state: PersistedStackState = { ], secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, + "secret:security.jwt.signing.secret": { policy: "managed", value: "symmetric-secret" }, }, }; @@ -113,7 +116,23 @@ describe("workload runtime catalog", () => { enable_signup: false, minimum_password_length: 12, password_requirements: "letters_digits", - email: { double_confirm_changes: true, secure_password_change: false }, + email: { + double_confirm_changes: true, + secure_password_change: false, + template: { + confirmation: { + content_path: "templates/confirmation.html", + subject: "Confirm", + }, + }, + notification: { + password_recovery: { + enabled: true, + content_path: "templates/recovery.html", + subject: "Reset", + }, + }, + }, sms: { enable_signup: true, twilio: { enabled: true, account_sid: "AC123", message_service_sid: "MG123" }, @@ -182,6 +201,27 @@ describe("workload runtime catalog", () => { GOTRUE_SMTP_HOST: "127.0.0.1", GOTRUE_SMTP_PORT: "1025", }); + expect( + auth?.env(configured, planned("auth:auth"), 9999, "native", { + auth: { templateBaseUrl: "http://supabase-gateway:8088" }, + }), + ).toMatchObject({ + GOTRUE_MAILER_TEMPLATES_CONFIRMATION: + "http://supabase-gateway:8088/email/confirmation.html", + GOTRUE_MAILER_SUBJECTS_CONFIRMATION: "Confirm", + GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_RECOVERY_ENABLED: "true", + GOTRUE_MAILER_TEMPLATES_PASSWORD_RECOVERY_NOTIFICATION: + "http://supabase-gateway:8088/email/password_recovery_notification.html", + GOTRUE_MAILER_SUBJECTS_PASSWORD_RECOVERY_NOTIFICATION: "Reset", + }); + yield* validateWorkloadRuntimeInputs(configured, planned("auth:auth"), { + auth: { templateBaseUrl: "http://supabase-gateway:8088" }, + }); + const missingTemplateBase = yield* validateWorkloadRuntimeInputs( + configured, + planned("auth:auth"), + ).pipe(Effect.exit); + expect(Exit.isFailure(missingTemplateBase)).toBe(true); expect(auth?.env(configured, planned("auth:auth"), 9999)).toMatchObject({ GOTRUE_JWT_ISSUER: "https://issuer.example", GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: "https://issuer.example/callback", @@ -277,7 +317,7 @@ describe("workload runtime catalog", () => { ).toEqual({ executable: "/tmp/native-artifact/node/bin/node", args: ["/tmp/native-artifact/app/apps/studio/docker-entrypoint.mjs"], - cwd: "/tmp/native-artifact/app/apps/studio", + cwd: "/tmp/native-artifact/app", }); expect( runtimeSpecFor(planned("analytics:vector"))?.nativeProcess( @@ -312,4 +352,60 @@ describe("workload runtime catalog", () => { ]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.live("selects PostgREST symmetric and resolved-JWKS credentials", () => + Effect.gen(function* () { + const rest = runtimeSpecFor(planned("rest:rest")); + expect(rest?.env(state, planned("rest:rest"), 3000).PGRST_JWT_SECRET).toBe( + "symmetric-secret", + ); + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { security: { jwt: { signing: { kind: "jwks-file", path: "jwt.json" } } } }, + }).pipe(Effect.provide(NodeServices.layer)); + const configured: PersistedStackState = { ...state, definition: compiled.definition }; + expect( + rest?.env(configured, planned("rest:rest"), 3000, "native", { + auth: { jwks: '{"keys":[]}' }, + }).PGRST_JWT_SECRET, + ).toBe('{"keys":[]}'); + const failed = yield* validateWorkloadRuntimeInputs(configured, planned("rest:rest")).pipe( + Effect.exit, + ); + expect(Exit.isFailure(failed)).toBe(true); + const unresolved = yield* resolveContainerResolutionFor( + configured, + planned("rest:rest"), + ).pipe(Effect.exit); + expect(Exit.isFailure(unresolved)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("requires resolved JWKS material for an enabled third-party provider", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { + capabilities: { + auth: { + settings: { + third_party: { firebase: { enabled: true, project_id: "project-42" } }, + }, + }, + }, + }, + }).pipe(Effect.provide(NodeServices.layer)); + const configured: PersistedStackState = { ...state, definition: compiled.definition }; + const failed = yield* validateWorkloadRuntimeInputs(configured, planned("rest:rest")).pipe( + Effect.exit, + ); + expect(Exit.isFailure(failed)).toBe(true); + const valid = yield* validateWorkloadRuntimeInputs(configured, planned("rest:rest"), { + auth: { jwks: '{"keys":[]}' }, + }).pipe(Effect.exit); + expect(Exit.isSuccess(valid)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); From a821c71f52a7d53e4193bd98a6ab43ea7a245d96 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 11:22:26 +0200 Subject: [PATCH 029/174] fix(stack): provide edge runtime jwt material --- ...26-08-28-stack-runtime-rewrite-progress.md | 8 +++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 15 ++++- .../workload-runtime.integration.test.ts | 58 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 511c8fe9c0..60ad8ffe18 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -662,3 +662,11 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g with compiler validation for required fields and one-provider exclusivity. - Studio native entrypoint cwd is the artifact `app`; Vector requires its shipped config; Postgres slim artifacts receive a contained `init-scripts` alias for the current archive layout. + +#### Task 13A fix round 4 — provide Edge Runtime JWT material (2026-08-29) + +- Functions validation now treats the stack-owned Edge Runtime as a JWT consumer, requiring owner- + resolved JWKS material for `jwks-file` and third-party Auth modes before container/process creation. +- Edge Runtime receives the same `SUPABASE_INTERNAL_JWT_SECRET` managed Auth slot as GoTrue plus a + valid `SUPABASE_JWKS` document (owner-resolved when available, otherwise the empty-key fallback); + integration coverage exercises symmetric, jwks-file, and Firebase third-party paths. diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 6adf97a867..f2d50ad49a 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -111,7 +111,8 @@ export const validateWorkloadRuntimeInputs = ( workload.id === "rest:rest" || workload.id === "auth:auth" || workload.id === "realtime:realtime" || - workload.id === "storage:storage"; + workload.id === "storage:storage" || + workload.id === "functions:edge-runtime"; if ( jwksConsumer && (signing?.kind === "jwks-file" || thirdParty.value !== undefined) && @@ -281,6 +282,15 @@ const usesResolvedJwks = (state: PersistedStackState): boolean => { return signing?.kind === "jwks-file" || (thirdParty.ok && thirdParty.value !== undefined); }; +const edgeRuntimeJwtEnvironment = ( + state: PersistedStackState, + inputs: WorkloadRuntimeInputs, +): Record => + compactEnvironment({ + SUPABASE_INTERNAL_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + SUPABASE_JWKS: inputs.auth?.jwks ?? '{"keys":[]}', + }); + const common = (workload: PlannedWorkload, port: number): Record => ({ SUPABASE_STACK_WORKLOAD: workload.id, SUPABASE_STACK_PRIVATE_PORT: String(port), @@ -830,9 +840,10 @@ const specs: Readonly> = { `--port=${port}`, `--policy=${valueAt(state, "functions", "edge_runtime.policy") || "per_worker"}`, ], - env: (state, workload, port) => ({ + env: (state, workload, port, _runtime = "native", inputs = {}) => ({ ...common(workload, port), ...capabilityEnv(state, "functions", "FUNCTIONS"), + ...edgeRuntimeJwtEnvironment(state, inputs), EDGE_RUNTIME_PORT: String(port), FUNCTIONS_ROOT: functionsRoot(state), FUNCTIONS_CONTAINER_ROOT, diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 783cfa07c4..f3f62e510d 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -36,6 +36,7 @@ const state: PersistedStackState = { secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, "secret:security.jwt.signing.secret": { policy: "managed", value: "symmetric-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "symmetric-secret" }, }, }; @@ -408,4 +409,61 @@ describe("workload runtime catalog", () => { expect(Exit.isSuccess(valid)).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.live("passes Edge Runtime the JWT material used by functions serve", () => + Effect.gen(function* () { + const functions = planned("functions:edge-runtime"); + const symmetricDefault = containerResolutionFor(state, functions); + expect(symmetricDefault?.env).toMatchObject({ + SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", + SUPABASE_JWKS: '{"keys":[]}', + }); + const symmetric = containerResolutionFor(state, functions, { + auth: { jwks: '{"keys":[{"kty":"EC"}]}' }, + }); + expect(symmetric?.env).toMatchObject({ + SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", + SUPABASE_JWKS: '{"keys":[{"kty":"EC"}]}', + }); + + const jwksCompiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { security: { jwt: { signing: { kind: "jwks-file", path: "jwt.json" } } } }, + }).pipe(Effect.provide(NodeServices.layer)); + const jwksState: PersistedStackState = { ...state, definition: jwksCompiled.definition }; + const jwksResolution = yield* resolveContainerResolutionFor(jwksState, functions, { + auth: { jwtKeys: '[{"kty":"EC"}]', jwks: '{"keys":[{"kty":"EC"}]}' }, + }); + expect(jwksResolution?.env).toMatchObject({ + SUPABASE_JWKS: '{"keys":[{"kty":"EC"}]}', + SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", + }); + + const thirdPartyCompiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { + capabilities: { + auth: { + settings: { + third_party: { firebase: { enabled: true, project_id: "project-42" } }, + }, + }, + }, + }, + }).pipe(Effect.provide(NodeServices.layer)); + const thirdPartyState: PersistedStackState = { + ...state, + definition: thirdPartyCompiled.definition, + }; + const thirdPartyResolution = yield* resolveContainerResolutionFor( + thirdPartyState, + functions, + { auth: { jwks: '{"keys":[{"kty":"EC"}]}' } }, + ); + expect(thirdPartyResolution?.env.SUPABASE_JWKS).toBe('{"keys":[{"kty":"EC"}]}'); + expect(thirdPartyResolution?.env.SUPABASE_INTERNAL_JWT_SECRET).toBe("symmetric-secret"); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); From e8bc9619e21ce3528554ae2bc0ef9090b1fc9665 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 11:30:44 +0200 Subject: [PATCH 030/174] feat(stack): unify ingress and private ports --- ...26-08-28-stack-runtime-rewrite-progress.md | 17 + packages/stack/src/gateway/ActivationFile.ts | 167 ------ .../stack/src/gateway/ActivationServer.ts | 515 ------------------ packages/stack/src/gateway/HttpGateway.ts | 4 +- packages/stack/src/gateway/TcpGateway.ts | 4 +- .../gateway/activation.integration.test.ts | 460 ---------------- .../src/gateway/gateway.integration.test.ts | 10 +- packages/stack/src/public/EffectStack.ts | 1 + packages/stack/src/runtime/ContainerEngine.ts | 25 +- .../stack/src/runtime/ContainerRuntime.ts | 50 +- packages/stack/src/runtime/DockerEngine.ts | 15 +- packages/stack/src/runtime/PodmanEngine.ts | 15 +- .../container-runtime.integration.test.ts | 58 +- .../runtime-recovery.integration.test.ts | 34 +- .../workload-runtime.integration.test.ts | 1 + packages/stack/src/state/PortCoordinator.ts | 179 ++++-- packages/stack/src/state/PortRegistry.ts | 15 +- packages/stack/src/state/StackState.ts | 32 +- .../src/state/ownership.integration.test.ts | 1 + .../stack/src/state/ports.integration.test.ts | 99 +++- .../src/state/state-store.integration.test.ts | 1 + .../supervisor/lifecycle.integration.test.ts | 1 + .../supervisor/supervisor.integration.test.ts | 1 + 23 files changed, 405 insertions(+), 1300 deletions(-) delete mode 100644 packages/stack/src/gateway/ActivationFile.ts delete mode 100644 packages/stack/src/gateway/ActivationServer.ts delete mode 100644 packages/stack/src/gateway/activation.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 60ad8ffe18..80623b5907 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -670,3 +670,20 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Edge Runtime receives the same `SUPABASE_INTERNAL_JWT_SECRET` managed Auth slot as GoTrue plus a valid `SUPABASE_JWKS` document (owner-resolved when available, otherwise the empty-key fallback); integration coverage exercises symmetric, jwks-file, and Firebase third-party paths. + +#### Task 13B1 — unify ingress and private ports (2026-08-29) + +- Persisted stack state now requires `privatePorts`, identifying every workload endpoint by the + `(workloadId, binding)` pair. State decoding rejects duplicate bindings, duplicate ports, and any + overlap between public and private assignments; omitted state fields fail closed without a migration. +- `PortCoordinator` allocates sticky private loopback ports from `30000–39999` and public host ports + from `40000–65535` in one registry-lock transaction. Claims are scanned across every stack, + retained while stopped, and returned as complete public/private assignment sets. +- Public host listeners use one `bindHost`/`HostListener` path for native and container stacks and are + returned for direct adoption by the Supervisor-owned gateway. Container workloads publish only + exact `127.0.0.1::` endpoints; Docker/Podman reject malformed or non-loopback + publications before daemon mutation. +- The obsolete gateway container, activation server, activation file, and wire protocol were removed; + lazy activation remains a direct in-process Supervisor call in both runtime modes. +- Focused ports/state, container-runtime/recovery, and gateway integration coverage passes. Production + Supervisor/RuntimeFactory lifecycle wiring and credentials/log integration remain Task 13B's next slice. diff --git a/packages/stack/src/gateway/ActivationFile.ts b/packages/stack/src/gateway/ActivationFile.ts deleted file mode 100644 index 62e061ba8d..0000000000 --- a/packages/stack/src/gateway/ActivationFile.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { Crypto, Effect, FileSystem, Path, Redacted, Schema, Scope } from "effect"; -import { StackIdSchema } from "../public/StackId.ts"; -import { GatewayActivationError } from "../public/Errors.ts"; - -/** The release is intentionally independent from the Supervisor RPC release. */ -export const ACTIVATION_FILE_FORMAT = "supabase-stack-activation-v1" as const; -export const ACTIVATION_PROTOCOL = "gateway-activation-v1" as const; -export const ACTIVATION_FILE_MAX_BYTES = 64 * 1024; - -export const ActivationEndpointSchema = Schema.Struct({ - host: Schema.String.pipe(Schema.check(Schema.isNonEmpty())), - port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 })), -}); - -const TokenSchema = Schema.String.pipe( - Schema.refine( - (value): value is string => value.length > 0 && value.length <= 256 && !/\s/.test(value), - { - identifier: "ActivationToken", - message: "Expected a bounded activation token", - }, - ), -); -const RedactedTokenSchema = Schema.RedactedFromValue(TokenSchema); - -const SessionSchema = Schema.String.pipe( - Schema.refine((value): value is string => /^[A-Za-z0-9_-]{1,128}$/.test(value), { - identifier: "OwnerSessionId", - message: "Expected a bounded owner session identifier", - }), -); - -export const ActivationFileSchema = Schema.Struct({ - format: Schema.Literal(ACTIVATION_FILE_FORMAT), - endpoint: ActivationEndpointSchema, - capability: RedactedTokenSchema, - stackId: StackIdSchema, - desiredGeneration: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - gatewayInstanceId: TokenSchema, - ownerSessionId: SessionSchema, -}); - -export type ActivationEndpoint = Schema.Schema.Type; -export type ActivationFile = Schema.Schema.Type; - -const sameActivationFile = (left: ActivationFile, right: ActivationFile): boolean => - left.format === right.format && - Redacted.value(left.capability) === Redacted.value(right.capability) && - left.stackId === right.stackId && - left.desiredGeneration === right.desiredGeneration && - left.gatewayInstanceId === right.gatewayInstanceId && - left.ownerSessionId === right.ownerSessionId && - left.endpoint.host === right.endpoint.host && - left.endpoint.port === right.endpoint.port; - -const fileError = (message: string, cause?: unknown): GatewayActivationError => - new GatewayActivationError({ - message, - ...(cause === undefined ? {} : { cause }), - }); - -const encode = (value: ActivationFile): Effect.Effect => - Schema.encodeEffect(Schema.fromJsonString(ActivationFileSchema))(value).pipe( - Effect.mapError((cause) => fileError("Unable to encode gateway activation file", cause)), - ); - -/** Read and exact-schema decode an activation file. */ -export const readActivationFile = ( - path: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const info = yield* fs - .stat(path) - .pipe(Effect.mapError((error) => fileError("Unable to read gateway activation file", error))); - if (info.size > BigInt(ACTIVATION_FILE_MAX_BYTES)) - return yield* fileError("Gateway activation file exceeds the size limit"); - const text = yield* fs - .readFileString(path) - .pipe(Effect.mapError((error) => fileError("Unable to read gateway activation file", error))); - if (new TextEncoder().encode(text).byteLength > ACTIVATION_FILE_MAX_BYTES) - return yield* fileError("Gateway activation file exceeds the size limit"); - const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(ActivationFileSchema))(text, { - onExcessProperty: "error", - }).pipe(Effect.mapError(() => fileError("Gateway activation file has an invalid format"))); - return decoded; - }); - -/** - * Atomically publish an owner-only activation file. The temporary file is - * created with O_EXCL, chmod'd to 0600, and renamed into place. A scope - * finalizer removes the published file and any temporary path. - */ -export const writeActivationFile = ( - path: string, - value: ActivationFile, -): Effect.Effect< - ActivationFile, - GatewayActivationError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope -> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const encoded = yield* encode(value); - const temporary = `${path}.${yield* crypto.randomUUIDv4}.tmp`; - yield* Effect.addFinalizer(() => - Effect.ignore( - readActivationFile(path).pipe( - Effect.flatMap((current) => - sameActivationFile(current, value) ? fs.remove(path, { force: true }) : Effect.void, - ), - // Temporary cleanup must run even when the published path was - // rotated or is no longer readable. - Effect.ensuring(Effect.ignore(fs.remove(temporary, { force: true }))), - ), - ), - ); - // Resolve the path through Path so callers cannot accidentally provide a - // platform-incompatible separator to the temporary-name operation. - const temporaryPath = pathService.join( - pathService.dirname(path), - pathService.basename(temporary), - ); - yield* Effect.gen(function* () { - yield* fs - .writeFileString(temporaryPath, encoded, { flag: "wx", mode: 0o600 }) - .pipe( - Effect.mapError((error) => fileError("Unable to publish gateway activation file", error)), - ); - yield* fs - .chmod(temporaryPath, 0o600) - .pipe( - Effect.mapError((error) => fileError("Unable to secure gateway activation file", error)), - ); - yield* fs - .rename(temporaryPath, path) - .pipe( - Effect.mapError((error) => fileError("Unable to publish gateway activation file", error)), - ); - yield* fs - .chmod(path, 0o600) - .pipe( - Effect.mapError((error) => fileError("Unable to secure gateway activation file", error)), - ); - }).pipe(Effect.ensuring(Effect.ignore(fs.remove(temporaryPath, { force: true })))); - return value; - }).pipe( - Effect.mapError((error) => - error instanceof GatewayActivationError - ? error - : fileError("Unable to publish gateway activation file", error), - ), - ); - -/** Generate a high-entropy capability that remains redacted outside the wire leaf. */ -export const generateActivationCapability: Effect.Effect< - Redacted.Redacted, - GatewayActivationError, - Crypto.Crypto -> = Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const bytes = yield* crypto.randomBytes(32); - const token = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); - return Redacted.make(token); -}).pipe(Effect.mapError((cause) => fileError("Unable to generate activation capability", cause))); diff --git a/packages/stack/src/gateway/ActivationServer.ts b/packages/stack/src/gateway/ActivationServer.ts deleted file mode 100644 index cda570daed..0000000000 --- a/packages/stack/src/gateway/ActivationServer.ts +++ /dev/null @@ -1,515 +0,0 @@ -import { - Cause, - Data, - Effect, - Exit, - FiberSet, - Option, - Redacted, - Schema, - Scope, - Semaphore, -} from "effect"; -import { Socket, createServer } from "node:net"; -import { CapabilityNameSchema, type CapabilityName } from "../public/Capability.ts"; -import { StackIdSchema, type StackId } from "../public/StackId.ts"; -import { - GatewayActivationError, - GatewayAuthenticationError, - GatewayStaleGenerationError, -} from "../public/Errors.ts"; -import { - ACTIVATION_PROTOCOL, - ActivationEndpointSchema, - type ActivationEndpoint, -} from "./ActivationFile.ts"; -import type { ActivationResult } from "./Gateway.ts"; - -export const ACTIVATION_MAX_FRAME_BYTES = 64 * 1024; -export const ACTIVATION_MAX_CONCURRENT_REQUESTS = 16; -export const ACTIVATION_REQUEST_DEADLINE_MS = 5_000; - -const TokenSchema = Schema.String.pipe( - Schema.refine( - (value): value is string => value.length > 0 && value.length <= 256 && !/\s/.test(value), - { - identifier: "ActivationToken", - message: "Expected a bounded activation token", - }, - ), -); -const RedactedTokenSchema = Schema.RedactedFromValue(TokenSchema); - -export const ActivationRequestSchema = Schema.Struct({ - protocol: Schema.Literal(ACTIVATION_PROTOCOL), - operation: Schema.Literal("activate"), - capability: RedactedTokenSchema, - target: CapabilityNameSchema, - stackId: StackIdSchema, - desiredGeneration: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - gatewayInstanceId: RedactedTokenSchema, - ownerSessionId: RedactedTokenSchema, -}); -export type ActivationRequest = Schema.Schema.Type; - -export const ActivationResponseSchema = Schema.Union([ - Schema.Struct({ - ok: Schema.Literal(true), - capability: CapabilityNameSchema, - endpoint: ActivationEndpointSchema, - }), - Schema.Struct({ - ok: Schema.Literal(false), - error: Schema.Struct({ - tag: Schema.Literals([ - "authentication", - "stale-generation", - "activation", - "invalid-request", - ] as const), - message: Schema.String, - }), - }), -]); -export type ActivationResponse = Schema.Schema.Type; - -export interface ActivationServerOptions { - readonly host?: string; - readonly port?: number; - readonly capability: Redacted.Redacted; - readonly stackId: StackId; - readonly desiredGeneration: number; - readonly gatewayInstanceId: Redacted.Redacted; - readonly ownerSessionId: Redacted.Redacted; - readonly activate: ( - capability: CapabilityName, - ) => Effect.Effect; -} - -export interface ActivationServer { - readonly endpoint: ActivationEndpoint; - readonly close: Effect.Effect; -} - -export interface ActivationClientOptions { - readonly endpoint: ActivationEndpoint; - readonly request: ActivationRequest; -} - -class ActivationProtocolError extends Data.TaggedError("ActivationProtocolError")<{ - readonly message: string; -}> {} - -const protocolError = (message: string) => new ActivationProtocolError({ message }); -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder("utf-8", { fatal: true }); - -const encodeFrame = (value: unknown): Uint8Array => { - // The framing callback is a synchronous Node leaf; callers pass closed JSON records. - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json - const body = textEncoder.encode(JSON.stringify(value)); - const frame = new Uint8Array(body.byteLength + 4); - new DataView(frame.buffer).setUint32(0, body.byteLength, false); - frame.set(body, 4); - return frame; -}; - -const encodeFrameText = (text: string): Uint8Array => { - const body = textEncoder.encode(text); - const frame = new Uint8Array(body.byteLength + 4); - new DataView(frame.buffer).setUint32(0, body.byteLength, false); - frame.set(body, 4); - return frame; -}; - -const encodeRequest = ( - request: ActivationRequest, -): Effect.Effect => - Schema.encodeEffect(Schema.fromJsonString(ActivationRequestSchema))(request, { - onExcessProperty: "error", - }).pipe( - Effect.map(encodeFrameText), - Effect.mapError(() => new GatewayActivationError({ message: "Activation request is invalid" })), - ); - -const decodeRequest = ( - frame: Uint8Array, -): Effect.Effect => - Effect.gen(function* () { - if (frame.byteLength < 4) return yield* protocolError("Activation frame is truncated"); - const length = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false); - if (length > ACTIVATION_MAX_FRAME_BYTES || frame.byteLength !== length + 4) - return yield* protocolError("Activation frame exceeds the size limit"); - const text = yield* Effect.try({ - try: () => textDecoder.decode(frame.slice(4)), - catch: () => protocolError("Activation frame is invalid"), - }); - return yield* Schema.decodeEffect(Schema.fromJsonString(ActivationRequestSchema))(text, { - onExcessProperty: "error", - }).pipe(Effect.mapError(() => protocolError("Activation request is invalid"))); - }); - -const responseFor = ( - result: Exit.Exit< - ActivationResult, - GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError - >, -): Record => { - if (Exit.isSuccess(result)) - return { ok: true, capability: result.value.capability, endpoint: result.value.endpoint }; - const value = Cause.findErrorOption(result.cause); - if (Option.isSome(value) && value.value instanceof GatewayAuthenticationError) - return { ok: false, error: { tag: "authentication", message: "Activation request rejected" } }; - if (Option.isSome(value) && value.value instanceof GatewayStaleGenerationError) - return { - ok: false, - error: { tag: "stale-generation", message: "Activation request rejected" }, - }; - return { ok: false, error: { tag: "activation", message: "Activation failed" } }; -}; - -const writeAndClose = (socket: Socket, value: unknown): Effect.Effect => - Effect.callback((resume) => { - let finished = false; - const done = () => { - if (finished) return; - finished = true; - socket.off("error", done); - socket.off("close", done); - resume(Effect.void); - }; - socket.once("error", done); - socket.once("close", done); - socket.end(encodeFrame(value), done); - return Effect.sync(() => { - socket.off("error", done); - socket.off("close", done); - socket.destroy(); - }); - }); - -/** One-shot client used by a container gateway; it never exposes maintenance authority. */ -export const requestActivation = ( - options: ActivationClientOptions, -): Effect.Effect< - ActivationResult, - GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError -> => - Effect.gen(function* () { - const requestFrame = yield* encodeRequest(options.request); - return yield* Effect.callback< - ActivationResult, - GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError - >((resume) => { - const socket = new Socket(); - let done = false; - let bytes = new Uint8Array(0); - let onData: ((chunk: Buffer) => void) | undefined; - let onError: ((cause: Error) => void) | undefined; - let onClose: (() => void) | undefined; - let onTimeout: (() => void) | undefined; - let onConnect: (() => void) | undefined; - const cleanup = () => { - if (onData !== undefined) socket.off("data", onData); - if (onError !== undefined) socket.off("error", onError); - if (onClose !== undefined) socket.off("close", onClose); - if (onTimeout !== undefined) socket.off("timeout", onTimeout); - if (onConnect !== undefined) socket.off("connect", onConnect); - socket.setTimeout(0); - }; - const finish = ( - effect: Effect.Effect< - ActivationResult, - GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError - >, - ) => { - if (done) return; - done = true; - cleanup(); - resume(effect); - socket.destroy(); - }; - onError = (cause: Error) => - finish( - Effect.fail( - new GatewayActivationError({ message: "Activation server unavailable", cause }), - ), - ); - onClose = () => - finish( - Effect.fail(new GatewayActivationError({ message: "Activation server unavailable" })), - ); - onTimeout = () => - finish( - Effect.fail(new GatewayActivationError({ message: "Activation request timed out" })), - ); - socket.once("error", onError); - socket.once("close", onClose); - socket.once("timeout", onTimeout); - socket.setTimeout(ACTIVATION_REQUEST_DEADLINE_MS); - onData = (chunk: Buffer) => { - if (done) return; - const next = new Uint8Array(bytes.byteLength + chunk.byteLength); - next.set(bytes); - next.set(chunk, bytes.byteLength); - bytes = next; - if (bytes.byteLength < 4) return; - const length = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0, false); - if (length > ACTIVATION_MAX_FRAME_BYTES || bytes.byteLength < length + 4) { - if (length > ACTIVATION_MAX_FRAME_BYTES) - finish( - Effect.fail( - new GatewayActivationError({ message: "Activation response is invalid" }), - ), - ); - return; - } - if (bytes.byteLength !== length + 4) { - finish( - Effect.fail(new GatewayActivationError({ message: "Activation response is invalid" })), - ); - return; - } - const frame = bytes.slice(4, length + 4); - const parsed: Effect.Effect< - ActivationResult, - GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError - > = Effect.try({ - try: () => textDecoder.decode(frame), - catch: () => new GatewayActivationError({ message: "Activation response is invalid" }), - }).pipe( - Effect.flatMap((text) => - Schema.decodeEffect(Schema.fromJsonString(ActivationResponseSchema))(text, { - onExcessProperty: "error", - }), - ), - Effect.mapError((error): GatewayActivationError => - error instanceof GatewayActivationError - ? error - : new GatewayActivationError({ - message: "Activation response is invalid", - cause: error, - }), - ), - Effect.flatMap( - ( - value, - ): Effect.Effect< - ActivationResult, - GatewayActivationError | GatewayAuthenticationError | GatewayStaleGenerationError - > => - value.ok - ? Effect.succeed({ capability: value.capability, endpoint: value.endpoint }) - : value.error.tag === "authentication" - ? Effect.fail( - new GatewayAuthenticationError({ message: "Activation request rejected" }), - ) - : value.error.tag === "stale-generation" - ? Effect.fail( - new GatewayStaleGenerationError({ message: "Activation request rejected" }), - ) - : Effect.fail(new GatewayActivationError({ message: "Activation failed" })), - ), - ); - finish(parsed); - }; - socket.on("data", onData); - onConnect = () => socket.write(requestFrame); - socket.once("connect", onConnect); - socket.connect(options.endpoint.port, options.endpoint.host); - return Effect.sync(() => { - cleanup(); - done = true; - socket.destroy(); - }); - }); - }).pipe( - Effect.timeoutOrElse({ - duration: ACTIVATION_REQUEST_DEADLINE_MS, - orElse: () => - Effect.fail(new GatewayActivationError({ message: "Activation request timed out" })), - }), - ); - -const serveSocket = ( - socket: Socket, - options: ActivationServerOptions, - semaphore: Semaphore.Semaphore, - runFork: (effect: Effect.Effect) => import("effect/Fiber").Fiber, - active: Set, -): void => { - active.add(socket); - let finished = false; - let processing = false; - let bytes = new Uint8Array(0); - let fiber: import("effect/Fiber").Fiber | undefined; - const finish = () => { - if (finished) { - active.delete(socket); - return; - } - finished = true; - if (fiber !== undefined) fiber.interruptUnsafe(); - socket.destroy(); - active.delete(socket); - }; - const send = (value: unknown) => { - if (finished) return; - finished = true; - // Socket callbacks are a foreign boundary; all response work runs on the - // Supervisor context captured when this server was acquired. - runFork(writeAndClose(socket, value)); - }; - socket.once("error", finish); - socket.once("close", finish); - socket.setTimeout(ACTIVATION_REQUEST_DEADLINE_MS, finish); - const onData = (chunk: Buffer) => { - if (finished || processing) return; - const next = new Uint8Array(bytes.byteLength + chunk.byteLength); - next.set(bytes); - next.set(chunk, bytes.byteLength); - bytes = next; - if (bytes.byteLength > ACTIVATION_MAX_FRAME_BYTES + 4) { - send({ - ok: false, - error: { tag: "invalid-request", message: "Activation request rejected" }, - }); - return; - } - if (bytes.byteLength < 4) return; - const length = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0, false); - if (length > ACTIVATION_MAX_FRAME_BYTES) { - send({ - ok: false, - error: { tag: "invalid-request", message: "Activation request rejected" }, - }); - return; - } - if (bytes.byteLength < length + 4) return; - if (bytes.byteLength !== length + 4) { - send({ - ok: false, - error: { tag: "invalid-request", message: "Activation request rejected" }, - }); - return; - } - const frame = bytes.slice(0, length + 4); - processing = true; - socket.setTimeout(0); - fiber = runFork( - semaphore.withPermit( - Effect.gen(function* () { - const request = yield* decodeRequest(frame).pipe(Effect.exit); - if (Exit.isFailure(request)) { - send({ - ok: false, - error: { tag: "invalid-request", message: "Activation request rejected" }, - }); - return; - } - if ( - request.value.protocol !== ACTIVATION_PROTOCOL || - Redacted.value(request.value.capability) !== Redacted.value(options.capability) || - request.value.stackId !== options.stackId || - Redacted.value(request.value.gatewayInstanceId) !== - Redacted.value(options.gatewayInstanceId) || - Redacted.value(request.value.ownerSessionId) !== Redacted.value(options.ownerSessionId) - ) { - send({ - ok: false, - error: { tag: "authentication", message: "Activation request rejected" }, - }); - return; - } - if (request.value.desiredGeneration !== options.desiredGeneration) { - send({ - ok: false, - error: { tag: "stale-generation", message: "Activation request rejected" }, - }); - return; - } - const result = yield* options.activate(request.value.target).pipe( - Effect.timeoutOrElse({ - duration: ACTIVATION_REQUEST_DEADLINE_MS, - orElse: () => - Effect.fail( - new GatewayActivationError({ message: "Activation deadline exceeded" }), - ), - }), - Effect.exit, - ); - send(responseFor(result)); - }), - ), - ); - }; - socket.on("data", onData); -}; - -/** Bind an ephemeral exact-release activation server owned by the Supervisor. */ -export const startActivationServer = ( - options: ActivationServerOptions, -): Effect.Effect => - Effect.gen(function* () { - const semaphore = yield* Semaphore.make(ACTIVATION_MAX_CONCURRENT_REQUESTS); - const fibers = yield* FiberSet.make(); - const runFork = yield* FiberSet.runtime(fibers)(); - const active = new Set(); - const server = createServer((socket) => - serveSocket(socket, options, semaphore, runFork, active), - ); - server.maxConnections = ACTIVATION_MAX_CONCURRENT_REQUESTS; - const host = options.host ?? "127.0.0.1"; - yield* Effect.callback((resume) => { - let settled = false; - const cleanup = () => { - server.off("error", onError); - if (!server.listening) { - server.close(() => undefined); - return; - } - server.close(() => undefined); - }; - const onError = (error: Error) => { - if (settled) return; - settled = true; - cleanup(); - resume( - Effect.fail( - new GatewayActivationError({ message: "Activation server unavailable", cause: error }), - ), - ); - }; - server.once("error", onError); - server.listen({ host, port: options.port ?? 0 }, () => { - if (settled) return; - settled = true; - server.off("error", onError); - resume(Effect.void); - }); - return Effect.sync(() => { - if (settled) return; - settled = true; - cleanup(); - }); - }); - const address = server.address(); - if (typeof address !== "object" || address === null) - return yield* new GatewayActivationError({ - message: "Activation server did not expose an endpoint", - }); - const closeOperation = Effect.gen(function* () { - yield* FiberSet.clear(fibers); - yield* Effect.callback((resume) => { - for (const socket of active) socket.destroy(); - if (!server.listening) return resume(Effect.void); - server.close(() => resume(Effect.void)); - }); - }); - const close = yield* Effect.cached(closeOperation); - yield* Effect.addFinalizer(() => close); - return { - endpoint: { host: address.address, port: address.port }, - close, - } satisfies ActivationServer; - }); diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index 2514ab3070..ab6c23de9a 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -25,7 +25,7 @@ import type { PreparedGatewayRoute, } from "./Gateway.ts"; import { GatewayRouteNotFoundError } from "./Gateway.ts"; -import type { NativeListener } from "../state/PortCoordinator.ts"; +import type { HostListener } from "../state/PortCoordinator.ts"; class GatewayBackendError extends Data.TaggedError("GatewayBackendError")<{ readonly cause?: unknown; @@ -34,7 +34,7 @@ class GatewayBackendError extends Data.TaggedError("GatewayBackendError")<{ export interface HttpGatewayOptions { readonly address?: string; readonly port?: number; - readonly listener?: NativeListener; + readonly listener?: HostListener; readonly routes: ReadonlyArray; readonly activate: LazyActivator["activate"]; readonly resolveBackend?: ( diff --git a/packages/stack/src/gateway/TcpGateway.ts b/packages/stack/src/gateway/TcpGateway.ts index 7edb0c6392..d8ac97a097 100644 --- a/packages/stack/src/gateway/TcpGateway.ts +++ b/packages/stack/src/gateway/TcpGateway.ts @@ -9,12 +9,12 @@ import type { GatewayRouteRequest, LazyActivator, } from "./Gateway.ts"; -import type { NativeListener } from "../state/PortCoordinator.ts"; +import type { HostListener } from "../state/PortCoordinator.ts"; export interface TcpGatewayOptions { readonly address?: string; readonly port?: number; - readonly listener?: NativeListener; + readonly listener?: HostListener; readonly routes: ReadonlyArray; readonly activate: LazyActivator["activate"]; readonly resolveBackend?: ( diff --git a/packages/stack/src/gateway/activation.integration.test.ts b/packages/stack/src/gateway/activation.integration.test.ts deleted file mode 100644 index 065d88e848..0000000000 --- a/packages/stack/src/gateway/activation.integration.test.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Fiber, FileSystem, Redacted, Schema, Scope } from "effect"; -import * as TestClock from "effect/testing/TestClock"; -import { connect as connectNet, type Socket } from "node:net"; -import { StackIdSchema } from "../public/StackId.ts"; -import type { CapabilityName } from "../public/Capability.ts"; -import { GatewayActivationError } from "../public/Errors.ts"; -import { - ACTIVATION_FILE_FORMAT, - generateActivationCapability, - readActivationFile, - writeActivationFile, - type ActivationFile, -} from "./ActivationFile.ts"; -import { createLazyActivator, type ActivationTarget } from "./Gateway.ts"; -import { - ACTIVATION_MAX_FRAME_BYTES, - ACTIVATION_MAX_CONCURRENT_REQUESTS, - requestActivation, - startActivationServer, -} from "./ActivationServer.ts"; - -const withPlatform = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -const wireFrame = (text: string): Uint8Array => { - const body = new TextEncoder().encode(text); - const frame = new Uint8Array(body.byteLength + 4); - new DataView(frame.buffer).setUint32(0, body.byteLength, false); - frame.set(body, 4); - return frame; -}; - -const rawActivationExchange = ( - port: number, - payload: Uint8Array, -): Effect.Effect => - Effect.callback((resume) => { - const socket = connectNet(port, "127.0.0.1"); - const chunks: Buffer[] = []; - const onError = (error: Error) => resume(Effect.fail(error)); - socket.once("error", onError); - socket.on("data", (chunk: Buffer) => chunks.push(chunk)); - socket.once("end", () => resume(Effect.succeed(Buffer.concat(chunks)))); - socket.once("connect", () => socket.write(payload)); - return Effect.sync(() => socket.destroy()); - }); - -describe("gateway activation", () => { - it.live("writes and reads an owner-only exact activation file", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-activation-" }); - const stackId = yield* Schema.decodeEffect(StackIdSchema)("a".repeat(64)); - const value: ActivationFile = { - format: ACTIVATION_FILE_FORMAT, - endpoint: { host: "127.0.0.1", port: 44551 }, - capability: Redacted.make("test-capability"), - stackId, - desiredGeneration: 3, - gatewayInstanceId: "gateway-1", - ownerSessionId: "owner-1", - }; - yield* writeActivationFile(`${root}/activation.json`, value); - expect((yield* fs.stat(`${root}/activation.json`)).mode & 0o777).toBe(0o600); - expect(yield* readActivationFile(`${root}/activation.json`)).toEqual(value); - }), - ), - ); - - it.live("generates a high-entropy redacted activation capability", () => - withPlatform( - Effect.gen(function* () { - const capability = yield* generateActivationCapability; - expect(Redacted.value(capability)).toHaveLength(64); - // oxlint-disable-next-line typescript/no-base-to-string - expect(String(capability)).not.toContain(Redacted.value(capability)); - }), - ), - ); - - it.live("does not let an old owner scope remove a rotated activation file", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-activation-rotate-" }); - const path = `${root}/activation.json`; - const stackId = yield* Schema.decodeEffect(StackIdSchema)("c".repeat(64)); - const parent = yield* Scope.Scope; - const oldScope = yield* Scope.fork(parent, "sequential"); - const first: ActivationFile = { - format: ACTIVATION_FILE_FORMAT, - endpoint: { host: "127.0.0.1", port: 44551 }, - capability: Redacted.make("old-capability"), - stackId, - desiredGeneration: 1, - gatewayInstanceId: "gateway-old", - ownerSessionId: "owner-old", - }; - yield* writeActivationFile(path, first).pipe(Effect.provideService(Scope.Scope, oldScope)); - const second = { - ...first, - capability: Redacted.make("new-capability"), - gatewayInstanceId: "gateway-new", - }; - yield* writeActivationFile(path, second); - yield* Scope.close(oldScope, Exit.void); - expect(yield* readActivationFile(path)).toEqual(second); - }), - ), - ); - - it.live("removes a temporary activation file when publication fails", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-activation-failed-" }); - const path = `${root}/occupied`; - yield* fs.makeDirectory(path); - const stackId = yield* Schema.decodeEffect(StackIdSchema)("a".repeat(64)); - const value: ActivationFile = { - format: ACTIVATION_FILE_FORMAT, - endpoint: { host: "127.0.0.1", port: 44551 }, - capability: Redacted.make("failed-publication"), - stackId, - desiredGeneration: 0, - gatewayInstanceId: "gateway-failed", - ownerSessionId: "owner-failed", - }; - const result = yield* writeActivationFile(path, value).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - const entries = yield* fs.readDirectory(root); - expect(entries.filter((entry) => entry.startsWith("occupied.")).length).toBe(0); - }), - ), - ); - - it.live("activates a dependency closure once and retains it for the generation", () => - Effect.gen(function* () { - const started: string[] = []; - const targets: Readonly>> = { - rest: { dependencies: ["database"] }, - database: { dependencies: [] }, - }; - const activator = yield* createLazyActivator({ - generation: 8, - targets, - activate: (target) => - Effect.sync(() => { - started.push(target); - return { capability: target, endpoint: { host: "127.0.0.1", port: 54321 } }; - }), - }); - yield* Effect.all([activator.activate("rest"), activator.activate("rest")], { - concurrency: "unbounded", - }); - expect(started).toEqual(["database", "rest"]); - }), - ); - - it.live("retries a failed activation while retaining successful activations", () => - Effect.gen(function* () { - let attempts = 0; - const activator = yield* createLazyActivator({ - generation: 8, - targets: { rest: { dependencies: [] } }, - activate: (target) => - Effect.suspend(() => { - attempts += 1; - return attempts === 1 - ? Effect.fail(new GatewayActivationError({ message: "transient" })) - : Effect.succeed({ - capability: target, - endpoint: { host: "127.0.0.1", port: 54321 }, - }); - }), - }); - const first = yield* activator.activate("rest").pipe(Effect.exit); - expect(Exit.isFailure(first)).toBe(true); - const second = yield* activator.activate("rest"); - expect(second.capability).toBe("rest"); - expect(attempts).toBe(2); - }), - ); - - it.live("keeps a shared activation alive when the first waiter is interrupted", () => - Effect.gen(function* () { - const started = Deferred.makeUnsafe(); - const release = Deferred.makeUnsafe(); - let attempts = 0; - const activator = yield* createLazyActivator({ - generation: 9, - targets: { rest: { dependencies: [] } }, - activate: (target) => - Effect.gen(function* () { - attempts += 1; - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - return { capability: target, endpoint: { host: "127.0.0.1", port: 54321 } }; - }), - }); - const first = yield* Effect.forkChild(activator.activate("rest")); - yield* Deferred.await(started); - yield* Fiber.interrupt(first); - const second = yield* Effect.forkChild(activator.activate("rest")); - yield* Deferred.succeed(release, undefined); - const result = yield* Fiber.join(second); - expect(result.capability).toBe("rest"); - expect(attempts).toBe(1); - }), - ); - - it.live("interrupts a blocked activation when its owner scope closes", () => - Effect.gen(function* () { - const parent = yield* Scope.Scope; - const owner = yield* Scope.fork(parent, "sequential"); - let interrupted = false; - const entered = Deferred.makeUnsafe(); - const hold = Deferred.makeUnsafe(); - const activator = yield* createLazyActivator({ - generation: 10, - targets: { rest: { dependencies: [] } }, - activate: () => - Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(hold)), - Effect.onInterrupt(() => Effect.sync(() => (interrupted = true))), - Effect.as({ capability: "rest", endpoint: { host: "127.0.0.1", port: 54321 } }), - ), - }).pipe(Effect.provideService(Scope.Scope, owner)); - const request = yield* Effect.forkChild(activator.activate("rest")); - yield* Deferred.await(entered); - yield* Scope.close(owner, Exit.void); - yield* Fiber.join(request).pipe(Effect.exit); - expect(interrupted).toBe(true); - }), - ); - - it.live("rejects activation for a missing target", () => - Effect.gen(function* () { - const activator = yield* createLazyActivator({ - generation: 1, - targets: {}, - activate: (target) => - Effect.succeed({ capability: target, endpoint: { host: "127.0.0.1", port: 1 } }), - }); - const exit = yield* activator.activate("rest").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); - - it.live("enforces activation capability and generation fences", () => - withPlatform( - Effect.gen(function* () { - const stackId = yield* Schema.decodeEffect(StackIdSchema)("b".repeat(64)); - const capability = Redacted.make("capability-secret"); - const gatewayInstanceId = Redacted.make("gateway-2"); - const ownerSessionId = Redacted.make("owner-2"); - const server = yield* startActivationServer({ - capability, - stackId, - desiredGeneration: 4, - gatewayInstanceId, - ownerSessionId, - activate: (capability) => - Effect.succeed({ capability, endpoint: { host: "127.0.0.1", port: 54322 } }), - }); - const validRequest = { - protocol: "gateway-activation-v1" as const, - operation: "activate" as const, - capability, - target: "database" as const, - stackId, - desiredGeneration: 4, - gatewayInstanceId, - ownerSessionId, - }; - const response = yield* requestActivation({ - endpoint: server.endpoint, - request: validRequest, - }); - expect(response.capability).toBe("database"); - const fences = [ - { ...validRequest, capability: Redacted.make("wrong-capability") }, - { ...validRequest, gatewayInstanceId: Redacted.make("wrong-gateway") }, - { ...validRequest, ownerSessionId: Redacted.make("wrong-owner") }, - { ...validRequest, desiredGeneration: 3 }, - ]; - for (const fenced of fences) { - const rejected = yield* requestActivation({ - endpoint: server.endpoint, - request: fenced, - }).pipe(Effect.exit); - expect(Exit.isFailure(rejected)).toBe(true); - } - }), - ), - ); - - it.live("rejects oversized and trailing activation frames", () => - withPlatform( - Effect.gen(function* () { - const stackId = yield* Schema.decodeEffect(StackIdSchema)("d".repeat(64)); - const capability = Redacted.make("frame-capability"); - const gatewayInstanceId = Redacted.make("frame-gateway"); - const ownerSessionId = Redacted.make("frame-owner"); - const server = yield* startActivationServer({ - capability, - stackId, - desiredGeneration: 1, - gatewayInstanceId, - ownerSessionId, - activate: (target) => - Effect.succeed({ capability: target, endpoint: { host: "127.0.0.1", port: 54323 } }), - }); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json - const request = JSON.stringify({ - protocol: "gateway-activation-v1", - operation: "activate", - capability: "frame-capability", - target: "database", - stackId, - desiredGeneration: 1, - gatewayInstanceId: "frame-gateway", - ownerSessionId: "frame-owner", - }); - const trailing = yield* rawActivationExchange( - server.endpoint.port, - new Uint8Array([...wireFrame(request), ...wireFrame(request)]), - ); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json - expect(JSON.parse(new TextDecoder().decode(trailing.slice(4))).error.tag).toBe( - "invalid-request", - ); - const oversized = new Uint8Array(4); - new DataView(oversized.buffer).setUint32(0, ACTIVATION_MAX_FRAME_BYTES + 1, false); - const rejected = yield* rawActivationExchange(server.endpoint.port, oversized); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json - expect(JSON.parse(new TextDecoder().decode(rejected.slice(4))).error.tag).toBe( - "invalid-request", - ); - }), - ), - ); - - it.live("interrupts blocked activation at the server deadline", () => - withPlatform( - Effect.gen(function* () { - const stackId = yield* Schema.decodeEffect(StackIdSchema)("e".repeat(64)); - const capability = Redacted.make("deadline-capability"); - const gatewayInstanceId = Redacted.make("deadline-gateway"); - const ownerSessionId = Redacted.make("deadline-owner"); - let interrupted = false; - const entered = Deferred.makeUnsafe(); - const server = yield* startActivationServer({ - capability, - stackId, - desiredGeneration: 1, - gatewayInstanceId, - ownerSessionId, - activate: () => - Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => Effect.sync(() => (interrupted = true))), - ), - }); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json - const request = JSON.stringify({ - protocol: "gateway-activation-v1", - operation: "activate", - capability: "deadline-capability", - target: "database", - stackId, - desiredGeneration: 1, - gatewayInstanceId: "deadline-gateway", - ownerSessionId: "deadline-owner", - }); - const exchange = yield* Effect.forkChild( - rawActivationExchange(server.endpoint.port, wireFrame(request)), - ); - yield* Deferred.await(entered); - yield* TestClock.adjust("5 seconds"); - const response = yield* Fiber.join(exchange); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json - expect(JSON.parse(new TextDecoder().decode(response.slice(4))).error.tag).toBe( - "activation", - ); - expect(interrupted).toBe(true); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.live("bounds concurrent activation connections", () => - withPlatform( - Effect.gen(function* () { - const stackId = yield* Schema.decodeEffect(StackIdSchema)("f".repeat(64)); - const capability = Redacted.make("bound-capability"); - const gatewayInstanceId = Redacted.make("bound-gateway"); - const ownerSessionId = Redacted.make("bound-owner"); - const entered = Deferred.makeUnsafe(); - const release = Deferred.makeUnsafe(); - let active = 0; - const server = yield* startActivationServer({ - capability, - stackId, - desiredGeneration: 1, - gatewayInstanceId, - ownerSessionId, - activate: () => - Effect.gen(function* () { - active += 1; - if (active === ACTIVATION_MAX_CONCURRENT_REQUESTS) - yield* Deferred.succeed(entered, undefined); - yield* Deferred.await(release); - active -= 1; - return { capability: "database", endpoint: { host: "127.0.0.1", port: 54324 } }; - }), - }); - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json - const request = JSON.stringify({ - protocol: "gateway-activation-v1", - operation: "activate", - capability: "bound-capability", - target: "database", - stackId, - desiredGeneration: 1, - gatewayInstanceId: "bound-gateway", - ownerSessionId: "bound-owner", - }); - const open = (): Effect.Effect => - Effect.callback((resume) => { - const socket = connectNet(server.endpoint.port, "127.0.0.1"); - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - resume(Effect.succeed(socket)); - }; - socket.once("connect", () => socket.write(wireFrame(request))); - socket.once("connect", finish); - socket.once("close", finish); - socket.once("error", finish); - return Effect.sync(() => socket.destroy()); - }); - const clients = yield* Effect.forEach( - Array.from({ length: 17 }, (_, index) => index), - open, - { - concurrency: "unbounded", - }, - ); - yield* Deferred.await(entered); - expect(active).toBe(ACTIVATION_MAX_CONCURRENT_REQUESTS); - yield* Deferred.succeed(release, undefined); - for (const client of clients) client.destroy(); - }), - ), - ); -}); diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index 7f1ed5d6c3..fa111156ba 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -15,7 +15,7 @@ import { import { createLazyActivator, makeGateway, type BackendEndpoint } from "./Gateway.ts"; import { makeHttpGateway } from "./HttpGateway.ts"; import { makeTcpGateway } from "./TcpGateway.ts"; -import type { NativeListener } from "../state/PortCoordinator.ts"; +import type { HostListener } from "../state/PortCoordinator.ts"; import { FunctionSettingsDefaults } from "../model/capabilities/functions.ts"; import { makeFunctionsRoot } from "../functions/FunctionsRoot.ts"; import { @@ -138,7 +138,7 @@ describe("stack gateway", () => { if (typeof address !== "object" || address === null) return; let released = false; let releaseCalls = 0; - const listener: NativeListener = { + const listener: HostListener = { field: "api", address: "127.0.0.1", port: address.port, @@ -223,7 +223,7 @@ describe("stack gateway", () => { const preboundAddress = prebound.address(); if (typeof preboundAddress !== "object" || preboundAddress === null) return; const endpoint: BackendEndpoint = { host: "127.0.0.1", port: backendAddress.port }; - const listener: NativeListener = { + const listener: HostListener = { field: "database", address: "127.0.0.1", port: preboundAddress.port, @@ -277,14 +277,14 @@ describe("stack gateway", () => { ) return; const closeNative = (server: Server) => closeServer(server); - const httpListener: NativeListener = { + const httpListener: HostListener = { field: "api", address: "127.0.0.1", port: httpAddress.port, binding: { kind: "http", server: httpServer }, close: closeNative(httpServer), }; - const badTcpListener: NativeListener = { + const badTcpListener: HostListener = { field: "database", address: "127.0.0.1", port: badTcpAddress.port, diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 9fdc5911f3..057d74afe8 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -462,6 +462,7 @@ const stateInitial = ( desiredGeneration: 0, desiredLifecycle: "unconfigured", ports: [], + privatePorts: [], secrets: {}, }); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 7476639cb1..54c4a8e5d3 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -1,8 +1,9 @@ -import { Data, Effect, Predicate, Stream } from "effect"; +import { Data, Effect, Predicate, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; import type { StackId } from "../public/StackId.ts"; +import { NetworkPortSchema } from "../public/Status.ts"; export type ContainerEngineKind = "docker" | "podman"; export interface ContainerPlatform { @@ -36,7 +37,7 @@ export type ContainerEngineFailure = | ContainerEngineProtocolError | ContainerCommandError; -export type ContainerResourceRole = "network" | "volume" | "workload" | "gateway"; +export type ContainerResourceRole = "network" | "volume" | "workload"; interface ContainerIdentityLabels { readonly stackId: StackId; readonly ownerSessionId: string; @@ -48,7 +49,7 @@ export interface ContainerNetworkLabels extends ContainerIdentityLabels { export interface ContainerWorkloadLabels extends ContainerIdentityLabels { readonly workloadId: string; readonly specHash: string; - readonly role: "workload" | "gateway"; + readonly role: "workload"; } export interface ContainerVolumeLabels { readonly stackId: StackId; @@ -77,6 +78,7 @@ export interface ContainerVolumeMount { readonly readOnly: boolean; } export interface ContainerPortPublication { + readonly address: "127.0.0.1"; readonly hostPort: number; readonly containerPort: number; } @@ -97,7 +99,7 @@ export interface ContainerContainerSpec { readonly volumeMounts: ReadonlyArray; readonly publications: ReadonlyArray; readonly hostRoute?: ContainerHostRoute; - readonly role: "workload" | "gateway"; + readonly role: "workload"; readonly command?: ReadonlyArray; /** Path to an owned 0600 env file. Secret values must never be argv. */ readonly envFile?: string; @@ -392,19 +394,26 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai ), ), removeVolume: (id) => noResult("remove-volume", { operation: "remove-volume", id }), - createContainer: (spec) => - spec.role === "workload" && (spec.publications.length > 0 || spec.hostRoute !== undefined) + createContainer: (spec) => { + const invalidPublication = spec.publications.some( + (publication) => + publication.address !== "127.0.0.1" || + !Schema.is(NetworkPortSchema)(publication.hostPort) || + !Schema.is(NetworkPortSchema)(publication.containerPort), + ); + return invalidPublication ? Effect.fail( new ContainerEngineProtocolError({ operation: "create-container", - message: "Only the gateway container may publish host ports or host routes", + message: "Container publications must use valid loopback host ports", }), ) : check("create-container", { operation: "create-container", spec }).pipe( Effect.flatMap((result) => options.codecs.decodeCreate("create-container", result, spec, spec.role), ), - ), + ); + }, startContainer: (id) => noResult("start-container", { operation: "start-container", id }), stopContainer: (id) => noResult("stop-container", { operation: "stop-container", id }), removeContainer: (id) => noResult("remove-container", { operation: "remove-container", id }), diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 1be85fd13f..cf6dc821a4 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -42,6 +42,8 @@ export interface ContainerWorkloadResolution { readonly envFile?: string; readonly networkAliases?: ReadonlyArray; readonly hostRoute?: ContainerHostRoute; + /** Private host-loopback publications used by the in-process gateway. */ + readonly publications?: ReadonlyArray; readonly command?: ReadonlyArray; readonly waitForReadiness?: ( key: RuntimeWorkloadKey, @@ -50,6 +52,13 @@ export interface ContainerWorkloadResolution { ) => Effect.Effect; } +/** Untrusted host-publication input validated before any engine operation. */ +export interface ContainerPortPublicationInput { + readonly address: string; + readonly hostPort: number; + readonly containerPort: number; +} + export interface ContainerVolumeRequest { readonly target: string; readonly readOnly: boolean; @@ -162,14 +171,24 @@ const isWorkloadResource = ( const isManagedContainer = ( entry: ContainerResource, ): entry is ContainerResource & { readonly labels: ContainerWorkloadLabels } => - (entry.kind === "workload" || entry.kind === "gateway") && - (entry.labels.role === "workload" || entry.labels.role === "gateway"); + entry.kind === "workload" && entry.labels.role === "workload"; const isNetworkResource = ( entry: ContainerResource, ): entry is ContainerResource & { readonly labels: ContainerNetworkLabels } => entry.kind === "network" && entry.labels.role === "network"; +const isValidPublication = ( + publication: ContainerPortPublicationInput, +): publication is ContainerPortPublicationInput & { readonly address: "127.0.0.1" } => + publication.address === "127.0.0.1" && + Number.isInteger(publication.hostPort) && + Number.isInteger(publication.containerPort) && + publication.hostPort >= 1 && + publication.hostPort <= 65_535 && + publication.containerPort >= 1 && + publication.containerPort <= 65_535; + /** RuntimeDriver implementation backed by one exact, injected container engine. */ export const makeContainerRuntime = ( options: ContainerRuntimeOptions, @@ -229,8 +248,15 @@ export const makeContainerRuntime = ( ? Effect.succeed({}) : options.resolveWorkload(key, workload); const startEffect = Effect.gen(function* () { - yield* withEngine(key, options.engine.preflight); const resolution = yield* resolved; + const requestedPublications = resolution.publications ?? []; + if (requestedPublications.some((publication) => !isValidPublication(publication))) + return yield* toDriverError( + key, + new Error("Container publications must use valid loopback host ports"), + ); + const publications = requestedPublications.filter(isValidPublication); + yield* withEngine(key, options.engine.preflight); const volumeRequest = resolution.volume; if (volumeRequest !== undefined && volumeRequest.target.length === 0) return yield* toDriverError(key, new Error("Container volume mapping is invalid")); @@ -259,9 +285,7 @@ export const makeContainerRuntime = ( (entry) => entry.kind === "workload" && sameWorkloadIdentity(entry.labels, labels), ); const namedCollision = entries.find( - (entry) => - (entry.kind === "workload" || entry.kind === "gateway") && - entry.name === nameFor(key, "workload"), + (entry) => entry.kind === "workload" && entry.name === nameFor(key, "workload"), ); const collision = namedCollision !== undefined && isManagedContainer(namedCollision) @@ -353,7 +377,7 @@ export const makeContainerRuntime = ( labels, network: networkResource.id, mounts: resolution.mounts ?? [], - publications: [], + publications, volumeMounts: volumeRequest === undefined ? [] : [volumeMountFor(key, volumeRequest)], role: "workload", ...(resolution.envFile === undefined ? {} : { envFile: resolution.envFile }), @@ -474,7 +498,7 @@ export const makeContainerRuntime = ( if (Exit.isFailure(result)) cleanupCause = Cause.combine(cleanupCause, result.cause); }); - // Stop and remove every stack workload/gateway before touching its network. A failed + // Stop and remove every stack workload before touching its network. A failed // stop does not prevent the remove attempt; all failures are returned together. for (const entry of owned.filter(isManagedContainer)) { if (entry.state === "running") @@ -555,13 +579,9 @@ export const makeContainerRuntime = ( ); }); - // Recovery never starts or creates anything. Every gateway is removed first so a new - // owner cannot expose stale ingress while workload identities are being evaluated. + // Recovery never starts or creates anything. Workload identities are evaluated before + // retaining the network so stale resources cannot leak into the new owner. for (const entry of owned.filter(isManagedContainer)) { - if (entry.kind === "gateway") { - yield* stopAndRemove(entry); - continue; - } const hash = desiredHashes.get(entry.labels.workloadId); const current = entry.labels.desiredGeneration === request.desiredGeneration && @@ -591,7 +611,7 @@ export const makeContainerRuntime = ( }); } - // Networks are only considered after all containers and gateways. Keep one network for + // Networks are only considered after all workloads. Keep one network for // the exact current generation; duplicate or stale-generation networks are ephemera. for (const entry of owned.filter(isNetworkResource)) { if ( diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index 94f4266738..5a80f4f8d7 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -142,13 +142,10 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc "--mount", `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, ]); - const publications = - command.spec.role === "gateway" - ? command.spec.publications.flatMap((port) => [ - "--publish", - `${port.hostPort}:${port.containerPort}`, - ]) - : []; + const publications = command.spec.publications.flatMap((port) => [ + "--publish", + `${port.address}:${port.hostPort}:${port.containerPort}`, + ]); const hostRoute = command.spec.hostRoute?.gateway === undefined ? [] @@ -278,7 +275,7 @@ const workloadLabels = ( generation === undefined || workload === undefined || hash === undefined || - (role !== "workload" && role !== "gateway") + role !== "workload" ) return Effect.fail(protocol(operation)); return decodeIdentity(operation, stack).pipe( @@ -319,7 +316,7 @@ const decodeContainers = (result: ContainerCommandResult) => hash === undefined || role === undefined || state === undefined || - (role !== "workload" && role !== "gateway") + role !== "workload" ) return Effect.fail(protocol("inspect-containers")); return workloadLabels("inspect-containers", [ diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index 3452f03de0..fa666ba621 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -143,13 +143,10 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc "--mount", `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, ]); - const publications = - command.spec.role === "gateway" - ? command.spec.publications.flatMap((port) => [ - "--publish", - `${port.hostPort}:${port.containerPort}`, - ]) - : []; + const publications = command.spec.publications.flatMap((port) => [ + "--publish", + `${port.address}:${port.hostPort}:${port.containerPort}`, + ]); const environment = command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; const networkAliases = @@ -256,7 +253,7 @@ const workloadLabels = ( generation === undefined || workload === undefined || hash === undefined || - (role !== "workload" && role !== "gateway") + role !== "workload" ) return Effect.fail(protocol(operation)); return decodeIdentity(operation, stack).pipe( @@ -297,7 +294,7 @@ const decodeContainers = (result: ContainerCommandResult) => hash === undefined || role === undefined || state === undefined || - (role !== "workload" && role !== "gateway") + role !== "workload" ) return Effect.fail(protocol("inspect-containers")); return workloadLabels("inspect-containers", [ diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 4e19841e2d..6d143c047c 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -388,13 +388,13 @@ describe("container runtime", () => { }, network: "private", mounts: [], - publications: [{ hostPort: 5432, containerPort: 5432 }], + publications: [{ address: "127.0.0.1", hostPort: 5432, containerPort: 5432 }], volumeMounts: [], role: "workload", }) .pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); - expect(calls).toBe(1); + expect(calls).toBe(2); }), ); @@ -453,31 +453,32 @@ describe("container runtime", () => { it.live("keeps Docker and Podman command codecs independent and closed", () => Effect.gen(function* () { - const gateway = { - name: "gateway", - image: "example/gateway:1", + const workload = { + name: "backend", + image: "example/backend:1", labels: { stackId, ownerSessionId: "owner", desiredGeneration: 7, - workloadId: "gateway", + workloadId: "backend", specHash: key.specHash, - role: "gateway" as const, + role: "workload" as const, }, network: "private", mounts: [], volumeMounts: [], - publications: [{ hostPort: 54321, containerPort: 8000 }], + publications: [{ address: "127.0.0.1" as const, hostPort: 54321, containerPort: 8000 }], envFile: "/tmp/supabase-owned.env", networkAliases: ["supabase-database"], command: ["serve", "--port", "8000"], hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, - role: "gateway" as const, + role: "workload" as const, }; - const docker = serializeDockerCommand({ operation: "create-container", spec: gateway }); + const docker = serializeDockerCommand({ operation: "create-container", spec: workload }); expect(docker.args).toContain("--add-host"); expect(docker.args).toContain("host.docker.internal:host-gateway"); expect(docker.args).toContain("--publish"); + expect(docker.args).toContain("127.0.0.1:54321:8000"); expect(docker.args).toContain("--network-alias"); expect(docker.args).toContain("supabase-database"); expect(docker.args).toContain("--env-file"); @@ -486,9 +487,10 @@ describe("container runtime", () => { expect(docker.args.slice(-3)).toEqual(["serve", "--port", "8000"]); const podmanCreate = serializePodmanCommand({ operation: "create-container", - spec: gateway, + spec: workload, }); expect(podmanCreate.args).toContain("--env-file"); + expect(podmanCreate.args).toContain("127.0.0.1:54321:8000"); expect(podmanCreate.args).toContain("--network-alias"); expect(podmanCreate.args).toContain("/tmp/supabase-owned.env"); expect(podmanCreate.args.join(" ")).not.toContain("value"); @@ -525,6 +527,7 @@ describe("container runtime", () => { exitCode: 0, }), }); + const podmanEngine = makePodmanEngine({ runner: rowRunner, platform: { os: "linux", rootless: true }, @@ -543,6 +546,39 @@ describe("container runtime", () => { }), ); + it.live("rejects non-loopback publications before preflight or daemon mutation", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + let preflightCalls = 0; + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + preflight: Effect.sync(() => { + preflightCalls += 1; + return { host: "host.docker.internal" }; + }), + }, + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + publications: [{ address: "0.0.0.0", hostPort: 30_000, containerPort: 8080 }], + }), + }); + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(preflightCalls).toBe(0); + expect(state.calls).toEqual([]); + expect(state.createdSpecs).toEqual([]); + }), + ); + it.live("fails process output overflow and nonzero commands with typed failures", () => Effect.gen(function* () { const runner = yield* makeProcessCommandRunner({ diff --git a/packages/stack/src/runtime/runtime-recovery.integration.test.ts b/packages/stack/src/runtime/runtime-recovery.integration.test.ts index a91b2c9aa1..032633e1b4 100644 --- a/packages/stack/src/runtime/runtime-recovery.integration.test.ts +++ b/packages/stack/src/runtime/runtime-recovery.integration.test.ts @@ -272,20 +272,6 @@ describe("runtime recovery", () => { state: "running", labels: workloadLabels(stackId, "owner", key.specHash), }, - { - id: "gateway", - name: "gateway", - kind: "gateway", - state: "running", - labels: { - stackId, - ownerSessionId: "owner", - desiredGeneration: key.desiredGeneration, - workloadId: "gateway", - specHash: "gateway-hash", - role: "gateway", - }, - }, { id: "network", name: networkName, @@ -312,9 +298,7 @@ describe("runtime recovery", () => { ownerSessionId: "new-owner", }); yield* runtime.cleanup({ stackId, destroy: false }); - expect(state.calls.slice(0, 6)).toEqual([ - "stop:gateway", - "remove:gateway", + expect(state.calls.slice(0, 3)).toEqual([ "stop:workload", "remove:workload", "remove-network:network", @@ -330,7 +314,7 @@ describe("runtime recovery", () => { }), ); - it.live("recovers containers without starting and removes stale workloads and gateways", () => + it.live("recovers containers without starting and removes stale workloads", () => Effect.gen(function* () { const state: EngineState = { calls: [], @@ -377,20 +361,6 @@ describe("runtime recovery", () => { workloadId: "storage:storage", }, }, - { - id: "gateway", - name: "gateway", - kind: "gateway", - state: "running", - labels: { - stackId, - ownerSessionId: "old-owner", - desiredGeneration: key.desiredGeneration, - workloadId: "gateway", - specHash: "gateway-hash", - role: "gateway", - }, - }, { id: "current-network", name: networkName, diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index f3f62e510d..d29ee2f6ef 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -33,6 +33,7 @@ const state: PersistedStackState = { { field: "database", port: 55432, intent: "exact" }, { field: "api", port: 54321, intent: "exact" }, ], + privatePorts: [], secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, "secret:security.jwt.signing.secret": { policy: "managed", value: "symmetric-secret" }, diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 7713a7352a..3631ad2b4f 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -14,7 +14,11 @@ import { StackStateGenerationMismatchError, StackStateInvalidError, } from "../public/Errors.ts"; -import type { HostPortAssignment, PersistedStackState } from "./StackState.ts"; +import type { + HostPortAssignment, + PersistedStackState, + PrivatePortAssignment, +} from "./StackState.ts"; import type { StackStateStore } from "./StackStateStore.ts"; import type { PortRegistry } from "./PortRegistry.ts"; @@ -26,16 +30,22 @@ export interface ListenerIntent { export type ListenerIntents = Readonly>; -export interface NativeListener { +/** A workload endpoint reachable by the host gateway on a durable loopback port. */ +export interface PrivatePortIntent { + readonly workloadId: string; + readonly binding: string; +} + +export interface HostListener { readonly field: PortField; readonly address: string; readonly port: number; readonly close: Effect.Effect; /** The exact bound listener may be adopted by a gateway without rebind. */ - readonly binding: NativeListenerBinding; + readonly binding: HostListenerBinding; } -export type NativeListenerBinding = +export type HostListenerBinding = | { readonly kind: "http"; readonly server: HttpServer } | { readonly kind: "tcp"; readonly server: NetServer; readonly allowHalfOpen: true }; @@ -45,27 +55,24 @@ export interface PortPlanOptions { readonly expectedGeneration?: number; /** Generation to persist after this transaction; unchanged unless explicitly supplied. */ readonly nextGeneration?: number; + /** Requested durable workload endpoints. Every binding receives an automatic port. */ + readonly privateBindings?: ReadonlyArray; } export interface PortReservation { readonly assignments: Readonly>>; - /** Already-bound native listeners that can be adopted by a gateway. */ - readonly nativeListeners: ReadonlyArray; + readonly privateAssignments: ReadonlyArray; + /** Already-bound host listeners that can be adopted by a gateway. */ + readonly hostListeners: ReadonlyArray; } export interface PortCoordinatorOptions { - /** Binds and retains a native listener. The enclosing Scope owns its release. */ - readonly bindNative?: ( - address: string, - port: number, - field: PortField, - ) => Effect.Effect; - /** Publishes one container listener through the authoritative engine. */ - readonly publishContainer?: ( + /** Binds and retains a host listener. The enclosing Scope owns its release. */ + readonly bindHost?: ( address: string, port: number, field: PortField, - ) => Effect.Effect; + ) => Effect.Effect; /** Optional non-owning host probe used to skip occupied automatic ports. */ readonly checkHostPort?: ( address: string, @@ -93,10 +100,17 @@ export interface PortCoordinator { } const fields: ReadonlyArray = PORT_FIELDS; +const PRIVATE_PORT_MIN = 30_000; +const PRIVATE_PORT_MAX = 39_999; +const PUBLIC_PORT_MIN = 40_000; +const PUBLIC_PORT_MAX = 65_535; const assignmentMap = (assignments: ReadonlyArray) => new Map(assignments.map((assignment) => [assignment.field, assignment])); +const bindingKey = (assignment: Pick): string => + `${assignment.workloadId}\u0000${assignment.binding}`; + const validPort = (port: number): boolean => Schema.is(NetworkPortSchema)(port); const unavailable = (port: number, field: PortField) => @@ -109,7 +123,7 @@ const automaticConflict = (port: number, field: PortField) => message: `Port ${port} for ${field} is reserved by another stack's automatic assignment`, }); -const lifecycleConflict = (field: PortField) => +const lifecycleConflict = (field: string) => new StackLifecycleConflictError({ field, message: `Cannot change the ${field} port assignment while the stack is running`, @@ -141,18 +155,33 @@ export const makePortCoordinator = ( (current.desiredLifecycle === "running" ? "running" : "stopped"); const runtime = planOptions.runtime ?? current.runtime; const allStates = yield* registry.states; - const usedAutomatic = new Set(); - const usedLive = new Set(); - const usedReserved = new Set(); + const usedAutomaticPublic = new Set(); + const usedLivePublic = new Set(); + const usedReservedPublic = new Set(); + const usedReservedPrivate = new Set(); for (const entry of allStates) { if (entry.stackId === stackId) continue; for (const assignment of entry.state.ports) { - usedReserved.add(assignment.port); - if (assignment.intent === "automatic") usedAutomatic.add(assignment.port); - if (entry.state.desiredLifecycle === "running") usedLive.add(assignment.port); + usedReservedPublic.add(assignment.port); + if (assignment.intent === "automatic") usedAutomaticPublic.add(assignment.port); + if (entry.state.desiredLifecycle === "running") usedLivePublic.add(assignment.port); } + for (const assignment of entry.state.privatePorts) + usedReservedPrivate.add(assignment.port); } + const existing = assignmentMap(current.ports); + const requestedPrivate = planOptions.privateBindings; + const privateIntents: ReadonlyArray = + requestedPrivate ?? + current.privatePorts.map(({ workloadId, binding }) => ({ + workloadId, + binding, + })); + const existingPrivate = new Map( + current.privatePorts.map((entry) => [bindingKey(entry), entry]), + ); + if (lifecycle === "running" && current.desiredLifecycle === "running") { for (const field of fields) { const intent = listenerIntents[field]; @@ -165,7 +194,15 @@ export const makePortCoordinator = ( : !intent.enabled && prior === undefined; if (!unchanged) return yield* lifecycleConflict(field); } + const priorKeys = new Set(current.privatePorts.map(bindingKey)); + const nextKeys = new Set(privateIntents.map(bindingKey)); + if ( + priorKeys.size !== nextKeys.size || + [...priorKeys].some((key) => !nextKeys.has(key)) + ) + return yield* lifecycleConflict("private workload"); } + const assignments: HostPortAssignment[] = []; const byField: Partial> = {}; const usedByThisStack = new Set(); @@ -179,11 +216,12 @@ export const makePortCoordinator = ( assignment = prior; } else { let selected: number | undefined; - for (let port = 40_000; port <= 65_535; port += 1) { + for (let port = PUBLIC_PORT_MIN; port <= PUBLIC_PORT_MAX; port += 1) { if ( - !usedAutomatic.has(port) && - !usedLive.has(port) && - !usedReserved.has(port) && + !usedAutomaticPublic.has(port) && + !usedLivePublic.has(port) && + !usedReservedPublic.has(port) && + !usedReservedPrivate.has(port) && !usedByThisStack.has(port) ) { if (options.checkHostPort !== undefined) { @@ -200,14 +238,16 @@ export const makePortCoordinator = ( message: "No automatic host port is available", }); assignment = { field, port: selected, intent: "automatic" }; - usedAutomatic.add(selected); + usedAutomaticPublic.add(selected); } } else { if (!validPort(intent.port)) return yield* unavailable(intent.port, field); - if (usedAutomatic.has(intent.port)) + if (usedAutomaticPublic.has(intent.port)) return yield* automaticConflict(intent.port, field); + if (usedReservedPrivate.has(intent.port)) + return yield* unavailable(intent.port, field); if (usedByThisStack.has(intent.port)) return yield* unavailable(intent.port, field); - if (lifecycle === "running" && usedLive.has(intent.port)) + if (lifecycle === "running" && usedLivePublic.has(intent.port)) return yield* unavailable(intent.port, field); assignment = { field, port: intent.port, intent: "exact" }; } @@ -215,19 +255,70 @@ export const makePortCoordinator = ( byField[field] = assignment; usedByThisStack.add(assignment.port); } + + const privateAssignments: PrivatePortAssignment[] = []; + const usedPrivateByThisStack = new Set(); + const usedAllByThisStack = new Set(usedByThisStack); + for (const intent of privateIntents) { + if (intent.workloadId.length === 0 || intent.binding.length === 0) + return yield* new PortAllocationError({ + field: `${intent.workloadId}:${intent.binding}`, + message: "Private workload binding is invalid", + }); + const key = bindingKey(intent); + if (privateAssignments.some((entry) => bindingKey(entry) === key)) + return yield* new PortAllocationError({ + field: `${intent.workloadId}:${intent.binding}`, + message: "Duplicate private workload binding", + }); + const prior = existingPrivate.get(key); + let port: number | undefined; + if (prior !== undefined && !usedPrivateByThisStack.has(prior.port)) port = prior.port; + else { + for ( + let candidate = PRIVATE_PORT_MIN; + candidate <= PRIVATE_PORT_MAX; + candidate += 1 + ) { + if ( + !usedReservedPrivate.has(candidate) && + !usedReservedPublic.has(candidate) && + !usedPrivateByThisStack.has(candidate) && + !usedAllByThisStack.has(candidate) + ) { + port = candidate; + break; + } + } + } + if (port === undefined) + return yield* new PortAllocationError({ + field: `${intent.workloadId}:${intent.binding}`, + message: "No automatic private port is available", + }); + privateAssignments.push({ + workloadId: intent.workloadId, + binding: intent.binding, + port, + }); + usedPrivateByThisStack.add(port); + usedAllByThisStack.add(port); + } + const next: PersistedStackState = { ...current, // Keep a running lifecycle only after ownership/publication succeeds. desiredLifecycle: lifecycle === "running" ? current.desiredLifecycle : lifecycle, desiredGeneration: planOptions.nextGeneration ?? current.desiredGeneration, ports: assignments, + privatePorts: privateAssignments, }; yield* store.replaceUnlocked(stackId, next, expected); - return { current, next, lifecycle, runtime, byField }; + return { current, next, lifecycle, runtime, byField, privateAssignments }; }), ); - const nativeListeners: NativeListener[] = []; + const hostListeners: HostListener[] = []; const runningState: PersistedStackState = { ...committed.next, desiredLifecycle: "running", @@ -249,12 +340,12 @@ export const makePortCoordinator = ( const assignment = committed.byField[field]; return intent.enabled && assignment !== undefined ? [{ field, intent, assignment }] : []; }); - if (committed.lifecycle === "running" && committed.runtime.kind === "native") { - if (options.bindNative === undefined) { + if (committed.lifecycle === "running") { + const bindHost = options.bindHost; + if (bindHost === undefined) { const first = enabledAssignments[0]; return yield* unavailable(first?.assignment.port ?? 0, first?.field ?? "api"); } - const bindNative = options.bindNative; const parentScope = yield* Scope.Scope; const acquired = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -262,7 +353,7 @@ export const makePortCoordinator = ( const bound = yield* Effect.exit( restore( Effect.forEach(enabledAssignments, ({ field, intent, assignment }) => - bindNative(intent.address, assignment.port, field).pipe( + bindHost(intent.address, assignment.port, field).pipe( Effect.catchTag("PortUnavailableError", () => Effect.fail(unavailable(assignment.port, field)), ), @@ -283,19 +374,13 @@ export const makePortCoordinator = ( return bound.value; }), ); - nativeListeners.push(...acquired); - } - if (committed.lifecycle === "running" && committed.runtime.kind === "container") { - if (options.publishContainer === undefined) { - const first = enabledAssignments[0]; - return yield* unavailable(first?.assignment.port ?? 0, first?.field ?? "api"); - } - for (const { field, intent, assignment } of enabledAssignments) - yield* options.publishContainer(intent.address, assignment.port, field); + hostListeners.push(...acquired); } - if (committed.lifecycle === "running" && committed.runtime.kind !== "native") - yield* commitRunning; - return { assignments: committed.byField, nativeListeners }; + return { + assignments: committed.byField, + privateAssignments: committed.privateAssignments, + hostListeners, + }; }), }); diff --git a/packages/stack/src/state/PortRegistry.ts b/packages/stack/src/state/PortRegistry.ts index bb4ceb01ac..c5b52759ee 100644 --- a/packages/stack/src/state/PortRegistry.ts +++ b/packages/stack/src/state/PortRegistry.ts @@ -4,7 +4,11 @@ import { StackStateFormatUnsupportedError, StackStateInvalidError, } from "../public/Errors.ts"; -import type { HostPortAssignment, PersistedStackState } from "./StackState.ts"; +import type { + HostPortAssignment, + PersistedStackState, + PrivatePortAssignment, +} from "./StackState.ts"; import { withRegistryLock, type StackStateStore } from "./StackStateStore.ts"; export type PortRegistryError = @@ -31,6 +35,13 @@ export interface PortRegistry { PortRegistryError, FileSystem.FileSystem | Path.Path | Crypto.Crypto >; + readonly privateAssignments: ( + stackId: string, + ) => Effect.Effect< + ReadonlyArray, + PortRegistryError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >; } const idPattern = /^[0-9a-f]{64}$/; @@ -67,4 +78,6 @@ export const makePortRegistry = (options: { }), assignments: (stackId) => options.store.read(stackId).pipe(Effect.map((state) => state?.ports ?? [])), + privateAssignments: (stackId) => + options.store.read(stackId).pipe(Effect.map((state) => state?.privatePorts ?? [])), }); diff --git a/packages/stack/src/state/StackState.ts b/packages/stack/src/state/StackState.ts index 5224eff2c8..f8d1388c1d 100644 --- a/packages/stack/src/state/StackState.ts +++ b/packages/stack/src/state/StackState.ts @@ -50,6 +50,14 @@ export const HostPortAssignmentSchema = Schema.Struct({ }); export type HostPortAssignment = Schema.Schema.Type; +/** A durable loopback endpoint used by the host gateway to reach one workload. */ +export const PrivatePortAssignmentSchema = Schema.Struct({ + workloadId: Schema.String.check(Schema.isNonEmpty()), + binding: Schema.String.check(Schema.isNonEmpty()), + port: NetworkPortSchema, +}); +export type PrivatePortAssignment = Schema.Schema.Type; + export const PersistedSecretEntrySchema = Schema.Struct({ policy: Schema.Literals(["managed", "passthrough"] as const), value: Schema.String, @@ -282,6 +290,21 @@ const PortAssignmentsSchema = Schema.Array(HostPortAssignmentSchema).pipe( }), ); +const PrivatePortAssignmentsSchema = Schema.Array(PrivatePortAssignmentSchema).pipe( + Schema.decode({ + decode: SchemaGetter.checkEffect((assignments) => + Effect.succeed( + new Set(assignments.map(({ workloadId, binding }) => `${workloadId}\u0000${binding}`)) + .size === assignments.length && + new Set(assignments.map(({ port }) => port)).size === assignments.length + ? undefined + : "Duplicate persisted private binding or port", + ), + ), + encode: SchemaGetter.passthrough(), + }), +); + const StackGenerationSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); const stateShape = Schema.Struct({ @@ -293,6 +316,7 @@ const stateShape = Schema.Struct({ definition: Schema.optional(StackDefinitionSchema), inputFingerprint: Schema.optional(Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/))), ports: PortAssignmentsSchema, + privatePorts: PrivatePortAssignmentsSchema, secrets: PersistedSecretValuesSchema, }); @@ -301,9 +325,11 @@ export const PersistedStackStateSchema = stateShape.pipe( Schema.decode({ decode: SchemaGetter.checkEffect((state) => Effect.succeed( - (state.definition === undefined) === (state.inputFingerprint === undefined) - ? undefined - : "definition and inputFingerprint must be persisted together", + (state.definition === undefined) !== (state.inputFingerprint === undefined) + ? "definition and inputFingerprint must be persisted together" + : state.ports.some(({ port }) => state.privatePorts.some((entry) => entry.port === port)) + ? "Public and private persisted ports must not overlap" + : undefined, ), ), encode: SchemaGetter.passthrough(), diff --git a/packages/stack/src/state/ownership.integration.test.ts b/packages/stack/src/state/ownership.integration.test.ts index 75200d3d02..b0eeb3da45 100644 --- a/packages/stack/src/state/ownership.integration.test.ts +++ b/packages/stack/src/state/ownership.integration.test.ts @@ -33,6 +33,7 @@ const stateFor = (stackId: string): PersistedStackState => ({ desiredGeneration: 0, desiredLifecycle: "unconfigured", ports: [], + privatePorts: [], secrets: {}, }); diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 428d8f4c2d..e5a96dbad6 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -12,11 +12,7 @@ import { StackLifecycleConflictError, } from "../public/Errors.ts"; import { makePortRegistry } from "./PortRegistry.ts"; -import { - makePortCoordinator, - type NativeListener, - type ListenerIntents, -} from "./PortCoordinator.ts"; +import { makePortCoordinator, type HostListener, type ListenerIntents } from "./PortCoordinator.ts"; import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; const layer = NodeServices.layer; @@ -54,6 +50,7 @@ const baseState = ( desiredGeneration: 0, desiredLifecycle, ports: [], + privatePorts: [], secrets: {}, }); const errorOf = (exit: Exit.Exit): E | undefined => @@ -127,9 +124,9 @@ const holdHttpPort = (port: number): Effect.Effect void } = {}, -): Effect.Effect => { +): Effect.Effect => { const isHttp = field === "api" || field === "studio" || field === "mailUi" || field === "functionsInspector"; if (isHttp) @@ -241,11 +238,13 @@ describe("sticky port coordination", () => { ...baseState(exactId, exactIdentity, "running"), desiredGeneration: 7, ports: [{ field: "api", port: 55435, intent: "exact" }], + privatePorts: [], }); yield* store.write(automaticId, { ...baseState(automaticId, automaticIdentity, "running"), desiredGeneration: 9, ports: [{ field: "api", port: 55436, intent: "automatic" }], + privatePorts: [], }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store); @@ -292,10 +291,11 @@ describe("sticky port coordination", () => { ...baseState(stackId, identity, "running"), desiredGeneration: 11, ports: [{ field: "api", port: 55439, intent: "exact" }], + privatePorts: [], }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindNative: bindTestNative, + bindHost: bindTestNative, }); const result = yield* coordinator.planAndReserve( stackId, @@ -324,6 +324,7 @@ describe("sticky port coordination", () => { yield* store.write(c, { ...baseState(c, cIdentity, "running"), ports: [{ field: "api", port: 55432, intent: "exact" }], + privatePorts: [], }); yield* store.write(d, baseState(d, dIdentity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); @@ -383,7 +384,7 @@ describe("sticky port coordination", () => { const occupiedPort = hostAddress.port; const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindNative: bindTestNative, + bindHost: bindTestNative, }); const exit = yield* coordinator .planAndReserve( @@ -419,7 +420,7 @@ describe("sticky port coordination", () => { yield* store.write(e, baseState(e, eIdentity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindNative: (address, port, field) => + bindHost: (address, port, field) => bound.has(port) ? Effect.fail(new PortUnavailableError({ port, field, message: "already bound" })) : bindTestNative(address, port, field, { @@ -433,7 +434,7 @@ describe("sticky port coordination", () => { lifecycle: "running", runtime: { kind: "native" }, }); - expect(result.nativeListeners).toHaveLength(2); + expect(result.hostListeners).toHaveLength(2); expect(closed).toBe(0); expect(bound.size).toBe(2); }), @@ -458,7 +459,7 @@ describe("sticky port coordination", () => { const bound = new Set(); let attempts = 0; const coordinator = makePortCoordinator(registry, store, { - bindNative: (address, port, field) => { + bindHost: (address, port, field) => { attempts += 1; if (attempts === 2) return Effect.fail( @@ -498,7 +499,7 @@ describe("sticky port coordination", () => { const bound = new Set(); const closed = new Set(); const coordinator = makePortCoordinator(registry, store, { - bindNative: (address, port, field) => + bindHost: (address, port, field) => bindTestNative(address, port, field, { onClose: () => { bound.delete(port); @@ -549,7 +550,7 @@ describe("sticky port coordination", () => { yield* store.write(f, baseState(f, fIdentity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - publishContainer: (address, port, field) => + bindHost: (address, port, field) => Effect.fail(new PortUnavailableError({ port, field, message: `race at ${address}` })), }); const exit = yield* coordinator @@ -564,4 +565,74 @@ describe("sticky port coordination", () => { }), ), ); + + it.live("allocates distinct durable private bindings and retains them across stop", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-private-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "private"); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, baseState(stackId, identity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store); + const bindings = [ + { workloadId: "mail:mail", binding: "ui" }, + { workloadId: "mail:mail", binding: "smtp" }, + { workloadId: "mail:mail", binding: "pop3" }, + { workloadId: "database:database", binding: "primary" }, + ]; + const first = yield* coordinator.planAndReserve(stackId, disabledIntents(), { + lifecycle: "stopped", + privateBindings: bindings, + }); + expect(first.privateAssignments).toHaveLength(bindings.length); + expect(new Set(first.privateAssignments.map((entry) => entry.port)).size).toBe( + bindings.length, + ); + expect(first.privateAssignments.every((entry) => entry.port >= 30_000)).toBe(true); + const stopped = yield* coordinator.planAndReserve(stackId, disabledIntents(), { + lifecycle: "stopped", + privateBindings: bindings, + }); + expect(stopped.privateAssignments).toEqual(first.privateAssignments); + }), + ), + ); + + it.live("keeps private ports exclusive across stacks and rejects public overlap", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-private-conflict-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const aIdentity = makeIdentity(root, "private-a"); + const bIdentity = makeIdentity(root, "private-b"); + const a = yield* deriveStackId(aIdentity); + const b = yield* deriveStackId(bIdentity); + yield* store.write(a, baseState(a, aIdentity)); + yield* store.write(b, baseState(b, bIdentity)); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store); + const first = yield* coordinator.planAndReserve(a, disabledIntents(), { + privateBindings: [{ workloadId: "database:database", binding: "primary" }], + }); + const second = yield* coordinator.planAndReserve(b, disabledIntents(), { + privateBindings: [{ workloadId: "database:database", binding: "primary" }], + }); + expect(second.privateAssignments[0]?.port).not.toBe(first.privateAssignments[0]?.port); + const overlapping = yield* store + .write(a, { + ...baseState(a, aIdentity), + ports: [{ field: "api", port: 30_000, intent: "exact" }], + privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_000 }], + }) + .pipe(Effect.exit); + expect(errorOf(overlapping)).toBeDefined(); + }), + ), + ); }); diff --git a/packages/stack/src/state/state-store.integration.test.ts b/packages/stack/src/state/state-store.integration.test.ts index 45afda102f..c5e3b2d6a1 100644 --- a/packages/stack/src/state/state-store.integration.test.ts +++ b/packages/stack/src/state/state-store.integration.test.ts @@ -41,6 +41,7 @@ const state = ( definition, inputFingerprint: definition === undefined ? undefined : "d".repeat(64), ports: [], + privatePorts: [], secrets: {}, }); diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index 7f11950bfd..c94615f32f 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -91,6 +91,7 @@ const makeFixture = (runtime: StackRuntime = { kind: "native" }) => desiredGeneration: 0, desiredLifecycle: "unconfigured", ports: [], + privatePorts: [], secrets: {}, }); const state: BackendState = { calls: [], preflight: [] }; diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index d95d14c871..c2eb11bec3 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -39,6 +39,7 @@ const makeFixture = () => desiredGeneration: 0, desiredLifecycle: "unconfigured", ports: [], + privatePorts: [], secrets: {}, }); const resources = yield* Ref.make>([]); From f5db0583aa6f54f09ed84884c498ea9c31505582 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 11:33:43 +0200 Subject: [PATCH 031/174] refactor(stack): remove obsolete port runtime option --- packages/stack/src/state/PortCoordinator.ts | 5 +---- packages/stack/src/state/ports.integration.test.ts | 13 +++++-------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 3631ad2b4f..1d5351c94d 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -1,5 +1,4 @@ import { Crypto, Effect, Exit, FileSystem, Path, Scope, Schema } from "effect"; -import type { StackRuntime } from "../public/Runtime.ts"; // oxlint-disable-next-line effecttsgo/node-builtin-import import type { Server as HttpServer } from "node:http"; // oxlint-disable-next-line effecttsgo/node-builtin-import @@ -51,7 +50,6 @@ export type HostListenerBinding = export interface PortPlanOptions { readonly lifecycle?: "stopped" | "running"; - readonly runtime?: StackRuntime; readonly expectedGeneration?: number; /** Generation to persist after this transaction; unchanged unless explicitly supplied. */ readonly nextGeneration?: number; @@ -153,7 +151,6 @@ export const makePortCoordinator = ( const lifecycle = planOptions.lifecycle ?? (current.desiredLifecycle === "running" ? "running" : "stopped"); - const runtime = planOptions.runtime ?? current.runtime; const allStates = yield* registry.states; const usedAutomaticPublic = new Set(); const usedLivePublic = new Set(); @@ -314,7 +311,7 @@ export const makePortCoordinator = ( privatePorts: privateAssignments, }; yield* store.replaceUnlocked(stackId, next, expected); - return { current, next, lifecycle, runtime, byField, privateAssignments }; + return { current, next, lifecycle, byField, privateAssignments }; }), ); diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index e5a96dbad6..dbbc1721b5 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -255,7 +255,7 @@ describe("sticky port coordination", () => { ...disabledIntents(), api: { enabled: true, address: "127.0.0.1", port: 55437 }, }, - { lifecycle: "running", runtime: { kind: "native" } }, + { lifecycle: "running" }, ) .pipe(Effect.exit); expect(errorOf(exactToDifferent)).toBeInstanceOf(StackLifecycleConflictError); @@ -268,7 +268,7 @@ describe("sticky port coordination", () => { ...disabledIntents(), api: { enabled: true, address: "127.0.0.1", port: 55438 }, }, - { lifecycle: "running", runtime: { kind: "native" } }, + { lifecycle: "running" }, ) .pipe(Effect.exit); expect(errorOf(automaticToExact)).toBeInstanceOf(StackLifecycleConflictError); @@ -303,7 +303,7 @@ describe("sticky port coordination", () => { ...disabledIntents(), api: { enabled: true, address: "127.0.0.1", port: 55439 }, }, - { lifecycle: "running", runtime: { kind: "native" } }, + { lifecycle: "running" }, ); expect(result.assignments.api?.port).toBe(55439); expect((yield* store.read(stackId))?.desiredGeneration).toBe(11); @@ -393,7 +393,7 @@ describe("sticky port coordination", () => { ...disabledIntents(), api: { enabled: true, address: "127.0.0.1", port: occupiedPort }, }, - { lifecycle: "running", runtime: { kind: "native" } }, + { lifecycle: "running" }, ) .pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); @@ -432,7 +432,6 @@ describe("sticky port coordination", () => { }); const result = yield* coordinator.planAndReserve(e, intents("automatic"), { lifecycle: "running", - runtime: { kind: "native" }, }); expect(result.hostListeners).toHaveLength(2); expect(closed).toBe(0); @@ -473,7 +472,6 @@ describe("sticky port coordination", () => { const exit = yield* coordinator .planAndReserve(stackId, intents("automatic"), { lifecycle: "running", - runtime: { kind: "native" }, }) .pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); @@ -522,7 +520,7 @@ describe("sticky port coordination", () => { ...disabledIntents(), api: { enabled: true, address: "127.0.0.1", port: "automatic" }, }, - { lifecycle: "running", runtime: { kind: "native" } }, + { lifecycle: "running" }, ), { startImmediately: true }, ); @@ -556,7 +554,6 @@ describe("sticky port coordination", () => { const exit = yield* coordinator .planAndReserve(f, intents(55433), { lifecycle: "running", - runtime: { kind: "container", engine: "docker" }, }) .pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); From 9809f3c03e0ebf4a86f0af2a33bd0080ddc16fa6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 11:58:09 +0200 Subject: [PATCH 032/174] feat(stack): complete private routing contracts --- ...26-08-28-stack-runtime-rewrite-progress.md | 18 ++ .../stack/src/functions/FunctionDiscovery.ts | 12 + packages/stack/src/gateway/Gateway.ts | 78 ++++-- packages/stack/src/gateway/HttpGateway.ts | 29 +- packages/stack/src/gateway/RouteCatalog.ts | 133 +++++++++ .../src/gateway/gateway.integration.test.ts | 85 +++++- .../gateway/route-catalog.integration.test.ts | 44 +++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 262 +++++++++++++++--- .../workload-runtime.integration.test.ts | 95 ++++++- 9 files changed, 666 insertions(+), 90 deletions(-) create mode 100644 packages/stack/src/gateway/RouteCatalog.ts create mode 100644 packages/stack/src/gateway/route-catalog.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 80623b5907..7316839cdb 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -687,3 +687,21 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g lazy activation remains a direct in-process Supervisor call in both runtime modes. - Focused ports/state, container-runtime/recovery, and gateway integration coverage passes. Production Supervisor/RuntimeFactory lifecycle wiring and credentials/log integration remain Task 13B's next slice. + +#### Task 13B2a — complete private routing contracts (2026-08-29) + +- Workloads expose a closed binding catalog: ordinary services use `primary`, while Mailpit declares + `ui`/`smtp`/`pop3`. `privateBindingIntentsFor` derives every reservation from the execution plan; + runtime resolution validates durable assignments and returns loopback container publications with + fixed internal ports. +- `StackGateway` now owns maps of HTTP/TCP listeners keyed by `PortField`, rolling back all acquired + listeners on failure and closing them once. `RouteCatalog` covers enabled API prefixes plus direct + Studio/Mail/Functions inspector and database/pooler/SMTP/POP3 routes. +- Gateway forwarding supports route and prepared-route upstream path transforms. Legacy service roots + are represented explicitly (GraphQL `/rpc/graphql`, Realtime `/socket/websocket` and `/api`, Storage + S3 `/s3`), with query strings preserved. Functions requests rewrite `/functions/v1/` to the + Edge Runtime `/` path for both HTTP and WebSocket forwarding. +- Evidence: workload-runtime (7), gateway (10), route-catalog (1), and the broader catalog/compiler, + preparation/artifact, container, bootstrap, runtime, and gateway integration set (100 tests) pass; + stack types, Effect/generic lint, formatting, and diff checks pass. Task 13B2b still owns production + listener allocation/reconciliation and Edge Runtime bootstrap composition. diff --git a/packages/stack/src/functions/FunctionDiscovery.ts b/packages/stack/src/functions/FunctionDiscovery.ts index 44237ce6d2..088f076846 100644 --- a/packages/stack/src/functions/FunctionDiscovery.ts +++ b/packages/stack/src/functions/FunctionDiscovery.ts @@ -240,6 +240,17 @@ const functionSlugFromRequest = (request: GatewayRouteRequest): string | undefin return match?.[1]; }; +/** Remove the public Functions prefix before forwarding to Edge Runtime. */ +export const rewriteFunctionRequestPath = (requestPath: string): string => { + const queryIndex = requestPath.indexOf("?"); + const pathname = queryIndex < 0 ? requestPath : requestPath.slice(0, queryIndex); + const query = queryIndex < 0 ? "" : requestPath.slice(queryIndex); + const prefix = "/functions/v1"; + if (!pathname.startsWith(`${prefix}/`) && pathname !== prefix) return requestPath; + const rewritten = pathname.slice(prefix.length) || "/"; + return `${rewritten}${query}`; +}; + export interface FunctionsGatewayRouteOptions { readonly dispatch: ( invocation: FunctionInvocation, @@ -260,6 +271,7 @@ export const makeFunctionsGatewayRoute = ( return discovery.discover(slug).pipe( Effect.map((invocation) => ({ resolveBackend: (activation: ActivationResult) => options.dispatch(invocation, activation), + upstreamPath: (request: GatewayRouteRequest) => rewriteFunctionRequestPath(request.path), })), Effect.catchTags({ FunctionNotFoundError: () => diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts index a40e0fbf69..d5b8231ec1 100644 --- a/packages/stack/src/gateway/Gateway.ts +++ b/packages/stack/src/gateway/Gateway.ts @@ -1,6 +1,7 @@ import { Data, Deferred, Effect, Exit, FiberSet, Semaphore } from "effect"; import { GatewayActivationError } from "../public/Errors.ts"; import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import type { PortField } from "../public/Status.ts"; import { makeHttpGateway, type HttpGateway, type HttpGatewayOptions } from "./HttpGateway.ts"; import { makeTcpGateway, type TcpGateway, type TcpGatewayOptions } from "./TcpGateway.ts"; @@ -28,6 +29,8 @@ export interface PreparedGatewayRoute { readonly resolveBackend: ( activation: ActivationResult, ) => Effect.Effect; + /** Optional request path sent to the activated backend. */ + readonly upstreamPath?: (request: GatewayRouteRequest) => string; } export interface LazyActivator { @@ -150,7 +153,11 @@ export interface GatewayRouteRequest { export interface GatewayRoute { readonly capability: CapabilityName; + /** Workload binding selected when a capability exposes multiple endpoints. */ + readonly binding?: string; readonly match: (request: GatewayRouteRequest) => boolean; + /** Optional path transform applied before proxying when no prepared route overrides it. */ + readonly upstreamPath?: (request: GatewayRouteRequest) => string; /** Request-time validation and preparation performed before activation. */ readonly prepare?: ( request: GatewayRouteRequest, @@ -158,14 +165,24 @@ export interface GatewayRoute { } export interface StackGateway { - readonly http?: HttpGateway; - readonly tcp?: TcpGateway; + readonly http: ReadonlyMap; + readonly tcp: ReadonlyMap; readonly close: Effect.Effect; } +export interface HttpGatewayListenerOptions { + readonly field: PortField; + readonly options: Omit; +} + +export interface TcpGatewayListenerOptions { + readonly field: PortField; + readonly options: Omit; +} + export interface StackGatewayOptions { - readonly http?: Omit; - readonly tcp?: Omit; + readonly http?: ReadonlyArray; + readonly tcp?: ReadonlyArray; readonly activate: LazyActivator["activate"]; } @@ -174,29 +191,46 @@ export const makeGateway = ( options: StackGatewayOptions, ): Effect.Effect => Effect.gen(function* () { - const http = - options.http === undefined - ? undefined - : yield* makeHttpGateway({ ...options.http, activate: options.activate }); - let tcp: TcpGateway | undefined; - if (options.tcp !== undefined) { + const http = new Map(); + const tcp = new Map(); + const closeValues = (values: Iterable<{ readonly close: Effect.Effect }>) => + Effect.forEach(values, (gateway) => gateway.close.pipe(Effect.exit), { + concurrency: "unbounded", + discard: true, + }); + for (const entry of options.http ?? []) { + if (http.has(entry.field)) { + yield* closeValues(http.values()); + return yield* new GatewayActivationError({ + message: `Duplicate HTTP gateway listener ${entry.field}`, + }); + } + const acquired = yield* Effect.exit( + makeHttpGateway({ ...entry.options, activate: options.activate }), + ); + if (Exit.isFailure(acquired)) { + yield* closeValues(http.values()); + return yield* Effect.failCause(acquired.cause); + } + http.set(entry.field, acquired.value); + } + for (const entry of options.tcp ?? []) { + if (tcp.has(entry.field) || http.has(entry.field)) { + yield* closeValues([...http.values(), ...tcp.values()]); + return yield* new GatewayActivationError({ + message: `Duplicate gateway listener ${entry.field}`, + }); + } const acquired = yield* Effect.exit( - makeTcpGateway({ ...options.tcp, activate: options.activate }), + makeTcpGateway({ ...entry.options, activate: options.activate }), ); if (Exit.isFailure(acquired)) { - if (http !== undefined) yield* http.close; + yield* closeValues([...http.values(), ...tcp.values()]); return yield* Effect.failCause(acquired.cause); } - tcp = acquired.value; + tcp.set(entry.field, acquired.value); } - const closeOperation = Effect.gen(function* () { - if (tcp !== undefined) yield* tcp.close; - if (http !== undefined) yield* http.close; - }); + const closeOperation = closeValues([...http.values(), ...tcp.values()]); const close = yield* Effect.cached(closeOperation); - return { - ...(http === undefined ? {} : { http }), - ...(tcp === undefined ? {} : { tcp }), - close, - } satisfies StackGateway; + return { http, tcp, close } satisfies StackGateway; }); diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index ab6c23de9a..3c9676c85a 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -103,11 +103,18 @@ const routeFor = ( routes: ReadonlyArray, ): GatewayRoute | undefined => routes.find((route) => route.match(request)); +const preparedPath = ( + route: GatewayRoute, + prepared: PreparedGatewayRoute | undefined, + request: GatewayRouteRequest, +): string => prepared?.upstreamPath?.(request) ?? route.upstreamPath?.(request) ?? request.path; + const proxy = ( request: IncomingMessage, response: ServerResponse, backend: BackendEndpoint, options: HttpGatewayOptions, + upstreamPath: string, ): Effect.Effect => Effect.callback((resume) => { let settled = false; @@ -175,7 +182,7 @@ const proxy = ( host: backend.host, port: backend.port, method: request.method, - path: request.url ?? "/", + path: upstreamPath, headers, }, onIncoming, @@ -244,11 +251,14 @@ const handleRequest = ( ? Effect.succeed(result.endpoint) : options.resolveBackend(route, view, result) : prepared.resolveBackend(result); - return resolved.pipe(Effect.mapError((cause) => new GatewayBackendError({ cause }))); + return resolved.pipe( + Effect.map((backend) => ({ backend, path: preparedPath(route, prepared, view) })), + Effect.mapError((cause) => new GatewayBackendError({ cause })), + ); }), ), ), - Effect.flatMap((backend) => proxy(request, response, backend, options)), + Effect.flatMap(({ backend, path }) => proxy(request, response, backend, options, path)), ); // Node invokes this handler outside Effect; use the owner-scoped FiberSet // runtime so cancellation of the gateway interrupts in-flight activation. @@ -272,8 +282,8 @@ const handleRequest = ( }); }; -const writeUpgrade = (request: IncomingMessage): string => { - const lines = [`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/${request.httpVersion}`]; +const writeUpgrade = (request: IncomingMessage, path: string): string => { + const lines = [`${request.method ?? "GET"} ${path} HTTP/${request.httpVersion}`]; for (let index = 0; index < request.rawHeaders.length; index += 2) { const name = request.rawHeaders[index]; const value = request.rawHeaders[index + 1]; @@ -312,11 +322,14 @@ const handleUpgrade = ( ? Effect.succeed(result.endpoint) : options.resolveBackend(route, view, result) : prepared.resolveBackend(result); - return resolved.pipe(Effect.mapError((cause) => new GatewayBackendError({ cause }))); + return resolved.pipe( + Effect.map((backend) => ({ backend, path: preparedPath(route, prepared, view) })), + Effect.mapError((cause) => new GatewayBackendError({ cause })), + ); }), ), ), - Effect.flatMap((backend) => + Effect.flatMap(({ backend, path }) => Effect.callback((resume) => { const target = new Socket(); let settled = false; @@ -348,7 +361,7 @@ const handleUpgrade = ( const onSocketEnd = () => target.end(); const onTargetEnd = () => socket.end(); const onConnect = () => { - target.write(writeUpgrade(request)); + target.write(writeUpgrade(request, path)); if (head.byteLength > 0) target.write(head); socket.pipe(target, { end: false }); target.pipe(socket, { end: false }); diff --git a/packages/stack/src/gateway/RouteCatalog.ts b/packages/stack/src/gateway/RouteCatalog.ts new file mode 100644 index 0000000000..d54ab2f56f --- /dev/null +++ b/packages/stack/src/gateway/RouteCatalog.ts @@ -0,0 +1,133 @@ +import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; +import type { CapabilityName } from "../public/Capability.ts"; +import type { PortField } from "../public/Status.ts"; +import type { GatewayRoute, GatewayRouteRequest } from "./Gateway.ts"; + +export interface GatewayRouteCatalog { + readonly http: ReadonlyMap>; + readonly tcp: ReadonlyMap>; +} + +const pathStartsWith = + (prefix: string) => + (path: string): boolean => + path === prefix || path.startsWith(`${prefix}/`); + +const pathAndQuery = (requestPath: string): readonly [string, string] => { + const queryIndex = requestPath.indexOf("?"); + return queryIndex < 0 + ? [requestPath, ""] + : [requestPath.slice(0, queryIndex), requestPath.slice(queryIndex)]; +}; + +const stripPrefix = + (prefix: string) => + (request: GatewayRouteRequest): string => { + const [pathname, query] = pathAndQuery(request.path); + const suffix = pathname.slice(prefix.length); + return `${suffix.length === 0 ? "/" : suffix}${query}`; + }; + +const appendSuffix = + (base: string, prefix: string) => + (request: GatewayRouteRequest): string => { + const [pathname, query] = pathAndQuery(request.path); + const suffix = pathname.slice(prefix.length); + return `${base}${suffix}${query}`; + }; + +const prefixRoute = ( + capability: CapabilityName, + prefix: string, + upstreamPath: (request: GatewayRouteRequest) => string = stripPrefix(prefix), + match: (request: GatewayRouteRequest) => boolean = (request) => + pathStartsWith(prefix)(pathAndQuery(request.path)[0]), +): GatewayRoute => ({ + capability, + match, + upstreamPath, +}); + +const directRoute = (capability: CapabilityName, binding?: string): GatewayRoute => ({ + capability, + ...(binding === undefined ? {} : { binding }), + match: () => true, +}); + +const realtimeWebsocketRoute = (): GatewayRoute => + prefixRoute("realtime", "/realtime/v1", appendSuffix("/socket", "/realtime/v1"), (request) => { + const [pathname] = pathAndQuery(request.path); + const upgrade = request.headers["upgrade"]; + const isUpgrade = Array.isArray(upgrade) + ? upgrade.some((value) => value.toLowerCase() === "websocket") + : upgrade?.toLowerCase() === "websocket"; + return ( + pathStartsWith("/realtime/v1")(pathname) && + (isUpgrade || pathname === "/realtime/v1/websocket") + ); + }); + +const realtimeRestRoute = (): GatewayRoute => + prefixRoute("realtime", "/realtime/v1/api", stripPrefix("/realtime/v1")); + +const apiRoutes = (capability: CapabilityName): ReadonlyArray => { + switch (capability) { + case "rest": + return [ + prefixRoute(capability, "/rest/v1"), + prefixRoute(capability, "/graphql/v1", appendSuffix("/rpc/graphql", "/graphql/v1")), + ]; + case "auth": + return [prefixRoute(capability, "/auth/v1")]; + case "realtime": + return [realtimeWebsocketRoute(), realtimeRestRoute(), prefixRoute(capability, "/socket")]; + case "storage": + return [ + prefixRoute(capability, "/storage/v1/s3", appendSuffix("/s3", "/storage/v1/s3")), + prefixRoute(capability, "/storage/v1"), + ]; + case "functions": + return [prefixRoute(capability, "/functions/v1")]; + case "analytics": + return [prefixRoute(capability, "/analytics/v1")]; + case "database": + case "pooler": + case "studio": + case "mail": + return []; + } +}; + +/** Build the closed public route set from the enabled plan, in collision-safe order. */ +export const routeCatalogFor = (plan: ExecutionPlan): GatewayRouteCatalog => { + const http = new Map(); + const tcp = new Map(); + const append = ( + target: Map, + field: PortField, + routes: ReadonlyArray, + ) => { + const current = target.get(field) ?? []; + current.push(...routes); + target.set(field, current); + }; + for (const route of plan.routes) { + if (route.listener === "api" && route.protocol === "http") + append(http, route.listener, apiRoutes(route.capability)); + else if (route.listener === "studio" && route.protocol === "http") + append(http, route.listener, [directRoute(route.capability)]); + else if (route.listener === "mailUi" && route.protocol === "http") + append(http, route.listener, [directRoute(route.capability, "ui")]); + else if (route.listener === "functionsInspector" && route.protocol === "http") + append(http, route.listener, [directRoute(route.capability)]); + else if (route.listener === "database" && route.protocol === "tcp") + append(tcp, route.listener, [directRoute(route.capability, "primary")]); + else if (route.listener === "pooler" && route.protocol === "tcp") + append(tcp, route.listener, [directRoute(route.capability, "primary")]); + else if (route.listener === "smtp" && route.protocol === "tcp") + append(tcp, route.listener, [directRoute(route.capability, "smtp")]); + else if (route.listener === "pop3" && route.protocol === "tcp") + append(tcp, route.listener, [directRoute(route.capability, "pop3")]); + } + return { http, tcp }; +}; diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index fa111156ba..a4cc283834 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -21,6 +21,7 @@ import { makeFunctionsRoot } from "../functions/FunctionsRoot.ts"; import { makeFunctionDiscovery, makeFunctionsGatewayRoute, + rewriteFunctionRequestPath, } from "../functions/FunctionDiscovery.ts"; const withPlatform = (effect: Effect.Effect) => @@ -78,7 +79,11 @@ describe("stack gateway", () => { address: "127.0.0.1", port: 0, routes: [ - { capability: "rest", match: (request) => request.path.startsWith("/rest") }, + { + capability: "rest", + match: (request) => request.path.startsWith("/rest"), + upstreamPath: (request) => `/internal${request.path}`, + }, { capability: "database", match: (request) => request.path.startsWith("/db") }, ], activate: activator.activate, @@ -93,7 +98,7 @@ describe("stack gateway", () => { { host: "127.0.0.1", port: gateway.port, - path: "/rest/hello", + path: "/rest/hello?limit=1", headers: { host: "api.example.test" }, }, (res) => { @@ -115,7 +120,7 @@ describe("stack gateway", () => { return Effect.sync(() => req.destroy()); }); expect(response.status).toBe(200); - expect(response.body).toBe("GET:/rest/hello"); + expect(response.body).toBe("GET:/internal/rest/hello?limit=1"); expect(response.headers["access-control-allow-origin"]).toBe("https://example.test"); expect(response.headers["x-seen-forwarded-host"]).toBe("api.example.test"); expect(response.headers["x-seen-forwarded-proto"]).toBe("http"); @@ -292,14 +297,8 @@ describe("stack gateway", () => { close: closeNative(badTcpServer), }; const result = yield* makeGateway({ - http: { - listener: httpListener, - routes: [], - }, - tcp: { - listener: badTcpListener, - routes: [], - }, + http: [{ field: "api", options: { listener: httpListener, routes: [] } }], + tcp: [{ field: "database", options: { listener: badTcpListener, routes: [] } }], activate: () => Effect.fail(new GatewayActivationError({ message: "not reached" })), }).pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); @@ -310,6 +309,57 @@ describe("stack gateway", () => { ), ); + it.live("owns multiple HTTP and TCP listeners keyed by public port field", () => + withPlatform( + Effect.gen(function* () { + const gateway = yield* makeGateway({ + http: [ + { + field: "api", + options: { + address: "127.0.0.1", + port: 0, + routes: [], + }, + }, + { + field: "studio", + options: { + address: "127.0.0.1", + port: 0, + routes: [], + }, + }, + ], + tcp: [ + { + field: "database", + options: { + address: "127.0.0.1", + port: 0, + routes: [], + }, + }, + { + field: "smtp", + options: { + address: "127.0.0.1", + port: 0, + routes: [], + }, + }, + ], + activate: () => Effect.fail(new GatewayActivationError({ message: "not reached" })), + }); + expect(gateway.http.get("api")?.port).toBeGreaterThan(0); + expect(gateway.http.get("studio")?.port).toBeGreaterThan(0); + expect(gateway.tcp.get("database")?.port).toBeGreaterThan(0); + expect(gateway.tcp.get("smtp")?.port).toBeGreaterThan(0); + yield* gateway.close; + }), + ), + ); + it.live("preserves HTTP WebSocket upgrades and tunnel bytes", () => withPlatform( Effect.gen(function* () { @@ -547,8 +597,13 @@ describe("stack gateway", () => { expect(activations).toBe(0); yield* invalid.close; - const backend = createHttpServer((_request, response) => response.end("function-response")); - backend.on("upgrade", (_request, socket) => { + const backendPaths: string[] = []; + const backend = createHttpServer((request, response) => { + backendPaths.push(request.url ?? ""); + response.end("function-response"); + }); + backend.on("upgrade", (request, socket) => { + backendPaths.push(request.url ?? ""); socket.end("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\nws-response"); }); yield* Effect.callback((resume) => { @@ -644,6 +699,10 @@ describe("stack gateway", () => { expect(dispatched[0]?.importMap).toContain("/rest/deno.json"); expect(dispatched[0]?.staticPattern).toContain("/rest/public/*.txt"); expect(dispatched[0]?.redacted).toBe(true); + expect(backendPaths).toEqual(["/rest", "/rest"]); + expect(rewriteFunctionRequestPath("/functions/v1/rest/items?limit=1")).toBe( + "/rest/items?limit=1", + ); yield* runtimeGateway.close; yield* closeServer(backend); }), diff --git a/packages/stack/src/gateway/route-catalog.integration.test.ts b/packages/stack/src/gateway/route-catalog.integration.test.ts new file mode 100644 index 0000000000..e000684557 --- /dev/null +++ b/packages/stack/src/gateway/route-catalog.integration.test.ts @@ -0,0 +1,44 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { compileStack } from "../model/Compiler.ts"; +import { routeCatalogFor } from "./RouteCatalog.ts"; + +describe("public gateway route catalog", () => { + it.live("maps each enabled public listener to its exact service prefixes and bindings", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: "/tmp/route-catalog", + runtime: { kind: "native" }, + config: { capabilities: { pooler: { enabled: true } } }, + }).pipe(Effect.provide(NodeServices.layer)); + const catalog = routeCatalogFor(compiled.executionPlan); + const api = catalog.http.get("api") ?? []; + const find = (path: string, headers: Readonly> = {}) => + api.find((route) => route.match({ path, headers })); + const upstream = (path: string, headers: Readonly> = {}) => { + const route = find(path, headers); + expect(route).toBeDefined(); + expect(route?.upstreamPath).toBeDefined(); + return route?.upstreamPath?.({ path, headers }); + }; + expect(upstream("/rest/v1/items?limit=1")).toBe("/items?limit=1"); + expect(upstream("/graphql/v1?query=items")).toBe("/rpc/graphql?query=items"); + expect(upstream("/auth/v1/token?grant_type=password")).toBe("/token?grant_type=password"); + expect(upstream("/realtime/v1/websocket?vsn=1")).toBe("/socket/websocket?vsn=1"); + expect(upstream("/realtime/v1/api/tenants?limit=1")).toBe("/api/tenants?limit=1"); + expect(upstream("/storage/v1/s3/bucket/object?X-Amz-Signature=abc")).toBe( + "/s3/bucket/object?X-Amz-Signature=abc", + ); + expect(upstream("/storage/v1/object/list?limit=1")).toBe("/object/list?limit=1"); + expect(upstream("/functions/v1/hello?x=1")).toBe("/hello?x=1"); + expect(upstream("/analytics/v1/logs?limit=1")).toBe("/logs?limit=1"); + expect((catalog.http.get("studio") ?? [])[0]?.match({ path: "/", headers: {} })).toBe(true); + expect((catalog.http.get("mailUi") ?? [])[0]?.binding).toBe("ui"); + expect((catalog.tcp.get("database") ?? [])[0]?.capability).toBe("database"); + expect((catalog.tcp.get("pooler") ?? [])[0]?.capability).toBe("pooler"); + expect((catalog.tcp.get("smtp") ?? [])[0]?.binding).toBe("smtp"); + expect((catalog.tcp.get("pop3") ?? [])[0]?.binding).toBe("pop3"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index f2d50ad49a..4a5fd47ff9 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -1,5 +1,5 @@ import type { PersistedStackState } from "../state/StackState.ts"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { CapabilityName } from "../public/Capability.ts"; import type { ContainerHostRoute, ContainerMount } from "./ContainerEngine.ts"; import { WORKLOAD_CATALOG, type NativeWorkloadProcess } from "../model/WorkloadCatalog.ts"; @@ -9,6 +9,25 @@ import { StackPreparationError } from "../public/Errors.ts"; export type WorkloadRuntimeKind = "native" | "container"; +/** Closed set of private ports a workload may expose to the host gateway. */ +export type WorkloadBindingName = "primary" | "ui" | "smtp" | "pop3"; + +export interface WorkloadBinding { + readonly containerPort: number; +} + +export interface WorkloadBindings { + readonly primary?: WorkloadBinding; + readonly ui?: WorkloadBinding; + readonly smtp?: WorkloadBinding; + readonly pop3?: WorkloadBinding; +} + +export interface WorkloadBindingIntent { + readonly workloadId: string; + readonly binding: WorkloadBindingName; +} + /** Inputs resolved by the owner before a process/container is created. */ export interface WorkloadRuntimeInputs { /** Resolved GoTrue signing key JSON and public JWKS. */ @@ -35,12 +54,14 @@ export interface NativeProcessResolution { /** Complete process-side contract for one catalog workload. */ export interface WorkloadRuntimeSpec { + readonly bindings: WorkloadBindings; readonly containerPort: number; readonly cwd: (state: PersistedStackState, workload: PlannedWorkload) => string; readonly privateEndpoint: ( - port: number, + state: PersistedStackState, + binding?: WorkloadBindingName, runtime?: WorkloadRuntimeKind, - ) => Readonly<{ readonly host: string; readonly port: number }>; + ) => Readonly<{ readonly host: string; readonly port: number }> | undefined; readonly args: ( state: PersistedStackState, workload: PlannedWorkload, @@ -66,7 +87,11 @@ export interface WorkloadRuntimeSpec { inputs?: WorkloadRuntimeInputs, ) => ReadonlyArray; readonly networkAliases?: ReadonlyArray; - readonly readiness: Readonly<{ readonly protocol: "http" | "tcp"; readonly path?: string }>; + readonly readiness: Readonly<{ + readonly protocol: "http" | "tcp"; + readonly path?: string; + readonly binding: WorkloadBindingName; + }>; readonly nativeProcess: ( artifactRoot: string, state: PersistedStackState, @@ -77,8 +102,13 @@ export interface WorkloadRuntimeSpec { type WorkloadRuntimeSpecDefinition = Omit< WorkloadRuntimeSpec, - "cwd" | "privateEndpoint" | "nativeProcess" + "containerPort" | "cwd" | "privateEndpoint" | "nativeProcess" | "readiness" > & + Readonly<{ + readonly readiness: Omit & { + readonly binding?: WorkloadBindingName; + }; + }> & Partial>; export interface ContainerWorkloadResolution { @@ -86,6 +116,11 @@ export interface ContainerWorkloadResolution { readonly env: Readonly>; readonly mounts: ReadonlyArray; readonly networkAliases: ReadonlyArray; + readonly publications: ReadonlyArray<{ + readonly address: "127.0.0.1"; + readonly hostPort: number; + readonly containerPort: number; + }>; readonly hostRoute?: ContainerHostRoute; } @@ -266,7 +301,35 @@ const valueAt = (state: PersistedStackState, capability: CapabilityName, path: s }; const dbPort = (state: PersistedStackState): number => - state.ports.find((assignment) => assignment.field === "database")?.port ?? 5432; + state.privatePorts.find( + (assignment) => + assignment.workloadId === "database:database" && assignment.binding === "primary", + )?.port ?? 5432; + +const privatePortFor = ( + state: PersistedStackState, + workloadId: string, + binding: WorkloadBindingName, +): number | undefined => + state.privatePorts.find( + (assignment) => assignment.workloadId === workloadId && assignment.binding === binding, + )?.port; + +const bindingFor = ( + bindings: WorkloadBindings, + binding: WorkloadBindingName, +): WorkloadBinding | undefined => bindings[binding]; + +const workloadPort = ( + state: PersistedStackState, + workloadId: string, + binding: WorkloadBindingName, + runtime: WorkloadRuntimeKind, + containerPort: number, +): number => + runtime === "container" + ? containerPort + : (privatePortFor(state, workloadId, binding) ?? containerPort); const dbHost = (runtime: WorkloadRuntimeKind): string => runtime === "container" ? DATABASE_NETWORK_ALIAS : "127.0.0.1"; @@ -301,13 +364,24 @@ const functionsRoot = (state: PersistedStackState): string => `${state.identity.projectRoot}/supabase/functions`; const privateEndpointFor = ( - port: number, + state: PersistedStackState, + workloadId: string, + bindings: WorkloadBindings, + binding: WorkloadBindingName, runtime: WorkloadRuntimeKind = "native", alias = "supabase-workload", -): Readonly<{ readonly host: string; readonly port: number }> => ({ - host: runtime === "container" ? alias : "127.0.0.1", - port, -}); +): Readonly<{ readonly host: string; readonly port: number }> | undefined => { + const declared = bindingFor(bindings, binding); + if (declared === undefined) return undefined; + const port = + runtime === "container" ? declared.containerPort : privatePortFor(state, workloadId, binding); + return port === undefined + ? undefined + : { + host: runtime === "container" ? alias : "127.0.0.1", + port, + }; +}; const aliasFor = (workload: PlannedWorkload): string => WORKLOAD_CATALOG[workload.id]?.containerAlias ?? `supabase-${workload.id.replace(/:/gu, "-")}`; @@ -332,11 +406,12 @@ const nativeProcessFor = ( cwd: artifactPath(artifactRoot, metadata.cwd), }; const executablePath = WORKLOAD_CATALOG[workload.id]?.executablePath; + const resolvedPort = privatePortFor(state, workload.id, "primary") ?? port; return { executable: executablePath === undefined ? artifactRoot : artifactPath(artifactRoot, executablePath), args: spec - .args(state, workload, port, "native") + .args(state, workload, resolvedPort, "native") .map((arg) => arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, ), @@ -606,7 +681,7 @@ const withAuthSettings = ( : capabilityEnabled(state, "mail") ? { GOTRUE_SMTP_HOST: runtime === "container" ? MAIL_NETWORK_ALIAS : "127.0.0.1", - GOTRUE_SMTP_PORT: "1025", + GOTRUE_SMTP_PORT: String(workloadPort(state, "mail:mail", "smtp", runtime, 1025)), GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "mail", "admin_email") || "admin@email.com", GOTRUE_SMTP_SENDER_NAME: valueAt(state, "mail", "sender_name") || "Admin", } @@ -671,7 +746,9 @@ const withStorageSettings = ( ...(inputs.auth?.jwks === undefined ? {} : { JWT_JWKS: inputs.auth.jwks }), TUS_URL_PATH: "/storage/v1/upload/resumable", IMGPROXY_URL: - runtime === "container" ? "http://supabase-imgproxy:8080" : "http://127.0.0.1:8080", + runtime === "container" + ? "http://supabase-imgproxy:8080" + : `http://127.0.0.1:${workloadPort(state, "storage:imgproxy", "primary", runtime, 8080)}`, }); const databaseArgs = ( @@ -742,7 +819,7 @@ const analyticsEnv = ( const specs: Readonly> = { "database:database": { - containerPort: 5432, + bindings: { primary: { containerPort: 5432 } }, args: (state, _workload, port) => databaseArgs(state, port, "native"), env: (state, workload, port, runtime = "native", _inputs = {}) => compactEnvironment({ @@ -760,7 +837,7 @@ const specs: Readonly> = { networkAliases: [DATABASE_NETWORK_ALIAS], }, "rest:rest": { - containerPort: 3000, + bindings: { primary: { containerPort: 3000 } }, args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => withRestSettings(state, runtime, port, workload, inputs), @@ -769,7 +846,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/" }, }, "auth:auth": { - containerPort: 9999, + bindings: { primary: { containerPort: 9999 } }, args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => withAuthSettings(state, runtime, port, workload, inputs), @@ -778,7 +855,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/health" }, }, "realtime:realtime": { - containerPort: 4000, + bindings: { primary: { containerPort: 4000 } }, args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => compactEnvironment({ @@ -811,7 +888,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/api/ping" }, }, "storage:storage": { - containerPort: 5000, + bindings: { primary: { containerPort: 5000 } }, args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => withStorageSettings(state, runtime, port, workload, inputs), @@ -820,7 +897,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/status" }, }, "storage:imgproxy": { - containerPort: 8080, + bindings: { primary: { containerPort: 8080 } }, args: () => [], env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), @@ -832,7 +909,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/health" }, }, "functions:edge-runtime": { - containerPort: 9000, + bindings: { primary: { containerPort: 9000 } }, cwd: functionsRoot, args: (state, _workload, port) => [ "start", @@ -865,7 +942,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/_internal/health" }, }, "studio:studio": { - containerPort: 3000, + bindings: { primary: { containerPort: 3000 } }, args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => compactEnvironment({ @@ -874,9 +951,13 @@ const specs: Readonly> = { PORT: String(port), HOSTNAME: "0.0.0.0", STUDIO_PG_META_URL: - runtime === "container" ? "http://supabase-pgmeta:8080" : "http://127.0.0.1:8080", + runtime === "container" + ? "http://supabase-pgmeta:8080" + : `http://127.0.0.1:${workloadPort(state, "studio:pgmeta", "primary", runtime, 8080)}`, LOGFLARE_URL: - runtime === "container" ? "http://supabase-analytics:4000" : "http://127.0.0.1:4000", + runtime === "container" + ? "http://supabase-analytics:4000" + : `http://127.0.0.1:${workloadPort(state, "analytics:analytics", "primary", runtime, 4000)}`, LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), NEXT_PUBLIC_ENABLE_LOGS: valueAt(state, "analytics", "backend").length > 0 ? "true" : "false", @@ -900,7 +981,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/api/platform/profile" }, }, "studio:pgmeta": { - containerPort: 8080, + bindings: { primary: { containerPort: 8080 } }, args: () => [], env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), @@ -917,22 +998,29 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/health" }, }, "mail:mail": { - containerPort: 8025, - args: (_state, _workload, port) => ["--ui", `127.0.0.1:${port}`], + bindings: { + ui: { containerPort: 8025 }, + smtp: { containerPort: 1025 }, + pop3: { containerPort: 1110 }, + }, + args: (state, _workload, port) => [ + "--ui", + `127.0.0.1:${privatePortFor(state, "mail:mail", "ui") ?? port}`, + ], env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), ...capabilityEnv(state, "mail", "MAIL"), - MP_UI_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, - MP_SMTP_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:1025`, - MP_POP3_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:1110`, + MP_UI_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, "mail:mail", "ui", runtime, 8025)}`, + MP_SMTP_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, "mail:mail", "smtp", runtime, 1025)}`, + MP_POP3_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, "mail:mail", "pop3", runtime, 1110)}`, MP_SMTP_DISABLE_RDNS: "true", }), - containerArgs: (_state, _workload, port) => ["--ui", `0.0.0.0:${port}`], + containerArgs: () => ["--ui", "0.0.0.0:8025"], networkAliases: [MAIL_NETWORK_ALIAS], - readiness: { protocol: "http", path: "/readyz" }, + readiness: { protocol: "http", path: "/readyz", binding: "ui" }, }, "analytics:analytics": { - containerPort: 4000, + bindings: { primary: { containerPort: 4000 } }, args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => analyticsEnv(state, runtime, port, workload, inputs), @@ -954,7 +1042,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/health" }, }, "analytics:vector": { - containerPort: 9001, + bindings: { primary: { containerPort: 9001 } }, args: (_state, _workload, _port) => [ "--config", "share/doc/vector/config/vector.yaml", @@ -984,7 +1072,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/health" }, }, "pooler:pooler": { - containerPort: 6543, + bindings: { primary: { containerPort: 6543 } }, args: () => ["start"], env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), @@ -1008,18 +1096,97 @@ const specs: Readonly> = { }, }; +const WORKLOAD_BINDING_NAMES: ReadonlyArray = [ + "primary", + "ui", + "smtp", + "pop3", +]; + +const declaredBindings = ( + bindings: WorkloadBindings, +): ReadonlyArray => + WORKLOAD_BINDING_NAMES.flatMap((name) => { + const binding = bindings[name]; + return binding === undefined ? [] : [[name, binding] as const]; + }); + +/** Derive the exact private endpoint reservations required by a compiled plan. */ +export const privateBindingIntentsFor = ( + plan: ExecutionPlan, +): ReadonlyArray => + plan.workloads.flatMap((workload) => { + const spec = specs[workload.id]; + return spec === undefined + ? [] + : declaredBindings(spec.bindings).map(([binding]) => ({ + workloadId: workload.id, + binding, + })); + }); + +export const validatePrivateAssignments = ( + state: PersistedStackState, + workload: PlannedWorkload, +): Effect.Effect => { + const spec = specs[workload.id]; + if (spec === undefined) return Effect.void; + for (const [binding] of declaredBindings(spec.bindings)) { + if ( + !state.privatePorts.some( + (assignment) => assignment.workloadId === workload.id && assignment.binding === binding, + ) + ) + return Effect.fail( + new StackPreparationError({ + message: `Missing private port assignment for ${workload.id} (${binding})`, + workload: workload.id, + }), + ); + } + return Effect.void; +}; + export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | undefined => { const spec = specs[workload.id]; if (spec === undefined) return undefined; + const primary = + spec.bindings.primary ?? spec.bindings.ui ?? spec.bindings.smtp ?? spec.bindings.pop3; + if (primary === undefined) return undefined; return { ...spec, + containerPort: primary.containerPort, networkAliases: [aliasFor(workload)], cwd: spec.cwd ?? ((state) => state.identity.projectRoot), + args: (state, currentWorkload, port, runtime = "native") => + spec.args( + state, + currentWorkload, + runtime === "container" + ? primary.containerPort + : (privatePortFor(state, currentWorkload.id, "primary") ?? port), + runtime, + ), + env: (state, currentWorkload, port, runtime = "native", inputs = {}) => + spec.env( + state, + currentWorkload, + runtime === "container" + ? primary.containerPort + : (privatePortFor(state, currentWorkload.id, spec.readiness.binding ?? "primary") ?? + port), + runtime, + inputs, + ), + readiness: { ...spec.readiness, binding: spec.readiness.binding ?? "primary" }, privateEndpoint: spec.privateEndpoint ?? - ((port, runtime = "native") => + ((state, binding = "primary", runtime = "native") => privateEndpointFor( - runtime === "container" ? spec.containerPort : port, + state, + workload.id, + spec.bindings, + binding, runtime, aliasFor(workload), )), @@ -1041,6 +1208,20 @@ export const containerResolutionFor = ( env: spec.env(state, workload, spec.containerPort, "container", inputs), mounts: spec.containerMounts?.(state, workload, inputs) ?? [], networkAliases: [aliasFor(workload)], + publications: declaredBindings(spec.bindings).flatMap(([binding, definition]) => { + const assignment = state.privatePorts.find( + (entry) => entry.workloadId === workload.id && entry.binding === binding, + ); + return assignment === undefined + ? [] + : [ + { + address: "127.0.0.1" as const, + hostPort: assignment.port, + containerPort: definition.containerPort, + }, + ]; + }), ...(inputs.hostRoute === undefined ? {} : { hostRoute: inputs.hostRoute }), }; }; @@ -1051,6 +1232,7 @@ export const resolveContainerResolutionFor = ( workload: PlannedWorkload, inputs: WorkloadRuntimeInputs = {}, ): Effect.Effect => - validateWorkloadRuntimeInputs(state, workload, inputs).pipe( - Effect.map(() => containerResolutionFor(state, workload, inputs)), - ); + Effect.all([ + validateWorkloadRuntimeInputs(state, workload, inputs), + validatePrivateAssignments(state, workload), + ]).pipe(Effect.map(() => containerResolutionFor(state, workload, inputs))); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index d29ee2f6ef..45beeccea5 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -9,6 +9,7 @@ import { compileStack } from "../model/Compiler.ts"; import { containerResolutionFor, FUNCTIONS_CONTAINER_ROOT, + privateBindingIntentsFor, resolveContainerResolutionFor, runtimeSpecFor, validateWorkloadRuntimeInputs, @@ -33,7 +34,23 @@ const state: PersistedStackState = { { field: "database", port: 55432, intent: "exact" }, { field: "api", port: 54321, intent: "exact" }, ], - privatePorts: [], + privatePorts: [ + { workloadId: "database:database", binding: "primary", port: 30_001 }, + { workloadId: "rest:rest", binding: "primary", port: 30_002 }, + { workloadId: "auth:auth", binding: "primary", port: 30_003 }, + { workloadId: "realtime:realtime", binding: "primary", port: 30_004 }, + { workloadId: "storage:storage", binding: "primary", port: 30_005 }, + { workloadId: "storage:imgproxy", binding: "primary", port: 30_006 }, + { workloadId: "functions:edge-runtime", binding: "primary", port: 30_007 }, + { workloadId: "studio:studio", binding: "primary", port: 30_008 }, + { workloadId: "studio:pgmeta", binding: "primary", port: 30_009 }, + { workloadId: "mail:mail", binding: "ui", port: 30_010 }, + { workloadId: "mail:mail", binding: "smtp", port: 30_011 }, + { workloadId: "mail:mail", binding: "pop3", port: 30_012 }, + { workloadId: "analytics:analytics", binding: "primary", port: 30_013 }, + { workloadId: "analytics:vector", binding: "primary", port: 30_014 }, + { workloadId: "pooler:pooler", binding: "primary", port: 30_015 }, + ], secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, "secret:security.jwt.signing.secret": { policy: "managed", value: "symmetric-secret" }, @@ -64,6 +81,24 @@ const planned = (id: string): PlannedWorkload => { }; describe("workload runtime catalog", () => { + it.live("derives one closed private binding intent for every planned workload binding", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + }).pipe(Effect.provide(NodeServices.layer)); + const intents = privateBindingIntentsFor(compiled.executionPlan); + expect(intents).toContainEqual({ workloadId: "database:database", binding: "primary" }); + expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "ui" }); + expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "smtp" }); + expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "pop3" }); + expect(intents.filter(({ workloadId }) => workloadId === "mail:mail")).toHaveLength(3); + expect( + intents.every(({ binding }) => ["primary", "ui", "smtp", "pop3"].includes(binding)), + ).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it("provides private command, environment and readiness metadata for every workload", () => { for (const [index, id] of Object.keys(WORKLOAD_CATALOG).entries()) { const workload = planned(id); @@ -76,16 +111,62 @@ describe("workload runtime catalog", () => { expect(spec.readiness.protocol).toMatch(/http|tcp/u); expect(spec.args(state, workload, port)).toBeInstanceOf(Array); expect(spec.cwd(state, workload)).toBeTruthy(); - expect(spec.privateEndpoint(port)).toEqual({ host: "127.0.0.1", port }); - expect(spec.privateEndpoint(spec.containerPort, "container")).toEqual({ + expect(spec.privateEndpoint(state, spec.readiness.binding)).toEqual({ + host: "127.0.0.1", + port: expect.any(Number), + }); + expect(spec.privateEndpoint(state, spec.readiness.binding, "container")).toEqual({ host: WORKLOAD_CATALOG[id]?.containerAlias, - port: spec.containerPort, + port: spec.bindings[spec.readiness.binding]?.containerPort, }); - expect(spec.privateEndpoint(49_999, "container").port).toBe(spec.containerPort); expect(spec.networkAliases).toEqual([WORKLOAD_CATALOG[id]?.containerAlias]); } }); + it.live("uses durable binding assignments for native endpoints and container publications", () => + Effect.gen(function* () { + const mail = planned("mail:mail"); + const mailResolution = containerResolutionFor(state, mail); + expect(mailResolution?.publications).toEqual([ + { address: "127.0.0.1", hostPort: 30010, containerPort: 8025 }, + { address: "127.0.0.1", hostPort: 30011, containerPort: 1025 }, + { address: "127.0.0.1", hostPort: 30012, containerPort: 1110 }, + ]); + expect(runtimeSpecFor(mail)?.env(state, mail, 8025)).toMatchObject({ + MP_UI_BIND_ADDR: "127.0.0.1:30010", + MP_SMTP_BIND_ADDR: "127.0.0.1:30011", + MP_POP3_BIND_ADDR: "127.0.0.1:30012", + }); + const secondState: PersistedStackState = { + ...state, + privatePorts: state.privatePorts.map((assignment) => ({ + ...assignment, + port: assignment.port + 1000, + })), + }; + expect( + runtimeSpecFor(planned("rest:rest"))?.env(secondState, planned("rest:rest"), 3000), + ).toMatchObject({ + PGRST_DB_URI: expect.stringContaining("@127.0.0.1:31001"), + }); + expect( + runtimeSpecFor(planned("storage:storage"))?.env( + secondState, + planned("storage:storage"), + 5000, + ).IMGPROXY_URL, + ).toBe("http://127.0.0.1:31006"); + const missing = { + ...state, + privatePorts: state.privatePorts.filter( + ({ workloadId, binding }) => !(workloadId === mail.id && binding === "smtp"), + ), + }; + const failed = yield* resolveContainerResolutionFor(missing, mail).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live( "consumes nested capability settings and separates native/container database endpoints", () => @@ -163,7 +244,7 @@ describe("workload runtime catalog", () => { expect(rest?.env(configured, planned("rest:rest"), 3000, "native")).toMatchObject({ PGRST_DB_SCHEMAS: "private", PGRST_DB_EXTRA_SEARCH_PATH: "extensions", - PGRST_DB_URI: expect.stringContaining("@127.0.0.1:55432"), + PGRST_DB_URI: expect.stringContaining("@127.0.0.1:30001"), }); expect(rest?.env(configured, planned("rest:rest"), 3000, "container")).toMatchObject({ PGRST_DB_URI: expect.stringContaining("@supabase-database:5432"), @@ -201,7 +282,7 @@ describe("workload runtime catalog", () => { GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true", GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: "false", GOTRUE_SMTP_HOST: "127.0.0.1", - GOTRUE_SMTP_PORT: "1025", + GOTRUE_SMTP_PORT: "30011", }); expect( auth?.env(configured, planned("auth:auth"), 9999, "native", { From 018a13f65b997fe83dce876a28e9f5ac762cde91 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:06:07 +0200 Subject: [PATCH 033/174] fix(stack): unify local jwt signing material --- ...26-08-28-stack-runtime-rewrite-progress.md | 10 ++ packages/stack/src/model/Compiler.ts | 72 +++++++++--- .../src/model/compiler.integration.test.ts | 85 +++++++++++++- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 14 +-- .../workload-runtime.integration.test.ts | 111 +++++++++++++++++- .../src/state/secrets.integration.test.ts | 1 - 6 files changed, 267 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 7316839cdb..c2275836eb 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -705,3 +705,13 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g preparation/artifact, container, bootstrap, runtime, and gateway integration set (100 tests) pass; stack types, Effect/generic lint, formatting, and diff checks pass. Task 13B2b still owns production listener allocation/reconciliation and Edge Runtime bootstrap composition. + +#### Task 13A fix round 5 — unify local JWT signing material (2026-08-29) + +- Symmetric JWT configuration now has one canonical managed `secret:auth.settings.jwt_secret` slot; + top-level `security.jwt.signing.secret` is accepted as an alias, equal dual declarations are + deduplicated, and conflicting values fail typed before lifecycle mutation. +- JWKS-file and third-party modes retain Auth's local JWT secret for GoTrue, Edge Runtime, Realtime, + Storage, and Supavisor while PostgREST consumes owner-resolved JWKS material where required. +- Compiler, runtime, and secret-store integration coverage verifies default/one-sided/equal/mismatch + inputs, no duplicate slot, internal JWT consumers, and JWKS/third-party resolution semantics. diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 238e60be5a..6dffbbb532 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -100,6 +100,9 @@ export interface SecretSlotInput { readonly value?: Redacted.Redacted; } +const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; +const SECURITY_JWT_SIGNING_SECRET_SLOT = "secret:security.jwt.signing.secret"; + /** Internal credentials are not user settings but still need durable managed slots. */ export const INTERNAL_MANAGED_SECRET_SLOTS = ["secret:database.internal.password"] as const; @@ -190,6 +193,46 @@ function slotsFor( return value; } +const canonicalJwtSecret = ( + config: StackConfig, +): Effect.Effect | undefined, InvalidStackConfigError> => + Effect.gen(function* () { + const auth = config.capabilities?.auth; + const authSecret = + auth !== undefined && "settings" in auth ? auth.settings?.jwt_secret : undefined; + const signing = config.security?.jwt?.signing; + const signingSecret = signing?.kind === "symmetric" ? signing.secret : undefined; + if ( + authSecret !== undefined && + signingSecret !== undefined && + Redacted.value(authSecret) !== Redacted.value(signingSecret) + ) + return yield* new InvalidStackConfigError({ + message: "Auth and stack JWT signing secrets must match in symmetric mode", + }); + return signingSecret ?? authSecret; + }); + +const ensureCanonicalJwtSlot = ( + slots: SecretSlotInput[], + value: Redacted.Redacted | undefined, +): void => { + for (let index = slots.length - 1; index >= 0; index--) + if (slots[index]?.slot === SECURITY_JWT_SIGNING_SECRET_SLOT) slots.splice(index, 1); + const existingIndex = slots.findIndex((entry) => entry.slot === AUTH_JWT_SECRET_SLOT); + if (existingIndex < 0) { + slots.push({ + slot: AUTH_JWT_SECRET_SLOT, + policy: "managed", + ...(value === undefined ? {} : { value }), + }); + return; + } + const existing = slots[existingIndex]; + if (existing !== undefined && existing.value === undefined && value !== undefined) + slots[existingIndex] = { ...existing, value }; +}; + const setMaterializedPath = ( value: unknown, parts: ReadonlyArray, @@ -549,7 +592,11 @@ export const rebuildExecutionPlan = ( return yield* planForDefinition(runtime, definition, crypto); }); -const collectSuppliedSecrets = (config: StackConfig, slots: SecretSlotInput[]): void => { +const collectSuppliedSecrets = ( + config: StackConfig, + slots: SecretSlotInput[], + jwtSecret: Redacted.Redacted | undefined, +): void => { const capabilities = config.capabilities; for (const name of CAPABILITY_NAMES) { const module = CAPABILITY_MODULES[name]; @@ -574,12 +621,8 @@ const collectSuppliedSecrets = (config: StackConfig, slots: SecretSlotInput[]): } } const signing = config.security?.jwt?.signing; - if (signing === undefined) { - const slot = "secret:security.jwt.signing.secret"; - if (!slots.some((entry) => entry.slot === slot)) slots.push({ slot, policy: "managed" }); - } else { - slotsFor(signing, "security.jwt.signing", slots, () => "managed"); - } + if (signing?.kind === "jwks-file") slotsFor(signing, "security.jwt.signing", slots); + ensureCanonicalJwtSlot(slots, jwtSecret); }; export const compileStack = ( @@ -595,6 +638,7 @@ export const compileStack = ( const crypto = yield* Crypto.Crypto; yield* validateFunctionKeys(input.config ?? {}); const config = yield* decodeConfig(input.config ?? {}); + const jwtSecret = yield* canonicalJwtSecret(config); const rawCapabilities = isRecord(config.capabilities) ? config.capabilities : {}; const normalizedFunctions = yield* materializeFunctionsRoot( extract(extract(rawCapabilities, "functions"), "settings"), @@ -624,7 +668,7 @@ export const compileStack = ( if (previous?.inputFingerprint === inputFingerprint) { const slots: SecretSlotInput[] = []; for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); - collectSuppliedSecrets(config, slots); + collectSuppliedSecrets(config, slots, jwtSecret); const executionPlan = yield* planForDefinition(input.runtime, previous.definition, crypto); return { definition: previous.definition, @@ -788,18 +832,14 @@ export const compileStack = ( [PORT_FIELDS[7]]: materializeListener(rawListeners[PORT_FIELDS[7]], false), } satisfies Record; const rawJwt = config.security?.jwt; + ensureCanonicalJwtSlot(slots, jwtSecret); const security = { jwt: { issuer: rawJwt?.issuer ?? null, signing: - rawJwt?.signing === undefined - ? (() => { - const slot = "secret:security.jwt.signing.secret"; - if (!slots.some((entry) => entry.slot === slot)) - slots.push({ slot, policy: "managed" }); - return { kind: "symmetric" as const, secret: { slot } }; - })() - : slotsFor(rawJwt.signing, "security.jwt.signing", slots, () => "managed"), + rawJwt?.signing?.kind === "jwks-file" + ? slotsFor(rawJwt.signing, "security.jwt.signing", slots) + : { kind: "symmetric" as const, secret: { slot: AUTH_JWT_SECRET_SLOT } }, }, }; const definition: StackDefinition = { diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 8da00cb47c..6310e2d4c9 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -65,7 +65,6 @@ describe("closed capability compiler", () => { expect(Redacted.isRedacted(supplied?.value)).toBe(true); for (const slot of [ "secret:database.internal.password", - "secret:security.jwt.signing.secret", "secret:auth.settings.publishable_key", "secret:auth.settings.jwt_secret", "secret:auth.settings.anon_key", @@ -77,6 +76,90 @@ describe("closed capability compiler", () => { }), ); + it.live("uses one canonical JWT secret slot for symmetric signing", () => + Effect.gen(function* () { + const result = yield* compile({}); + expect(result.definition.security.jwt.signing).toEqual({ + kind: "symmetric", + secret: { slot: "secret:auth.settings.jwt_secret" }, + }); + expect(result.secrets.filter((entry) => entry.slot.includes("jwt"))).toEqual([ + expect.objectContaining({ slot: "secret:auth.settings.jwt_secret", policy: "managed" }), + ]); + }), + ); + + it.live("accepts either symmetric JWT secret spelling and deduplicates the slot", () => + Effect.gen(function* () { + const fromAuth = yield* compile({ + capabilities: { auth: { settings: { jwt_secret: Redacted.make("auth-secret") } } }, + }); + expect(fromAuth.definition.security.jwt.signing).toEqual({ + kind: "symmetric", + secret: { slot: "secret:auth.settings.jwt_secret" }, + }); + expect(fromAuth.secrets.filter((entry) => entry.slot.includes("jwt"))).toEqual([ + expect.objectContaining({ + slot: "secret:auth.settings.jwt_secret", + policy: "managed", + value: Redacted.make("auth-secret"), + }), + ]); + + const fromSecurity = yield* compile({ + security: { jwt: { signing: { kind: "symmetric", secret: Redacted.make("top-secret") } } }, + }); + expect(fromSecurity.definition.capabilities.auth.settings.jwt_secret).toEqual({ + slot: "secret:auth.settings.jwt_secret", + }); + expect(fromSecurity.secrets.filter((entry) => entry.slot.includes("jwt"))).toEqual([ + expect.objectContaining({ + slot: "secret:auth.settings.jwt_secret", + policy: "managed", + value: Redacted.make("top-secret"), + }), + ]); + + const equal = yield* compile({ + capabilities: { auth: { settings: { jwt_secret: Redacted.make("equal-secret") } } }, + security: { + jwt: { signing: { kind: "symmetric", secret: Redacted.make("equal-secret") } }, + }, + }); + expect(equal.secrets.filter((entry) => entry.slot.includes("jwt"))).toHaveLength(1); + }), + ); + + it.live("rejects conflicting symmetric JWT secret spellings without leaking values", () => + Effect.gen(function* () { + const exit = yield* compile({ + capabilities: { auth: { settings: { jwt_secret: Redacted.make("auth-secret") } } }, + security: { + jwt: { signing: { kind: "symmetric", secret: Redacted.make("top-secret") } }, + }, + }).pipe(Effect.exit); + const error = failureOf(exit); + expect(error).toBeInstanceOf(InvalidStackConfigError); + expect(String(error)).not.toContain("auth-secret"); + expect(String(error)).not.toContain("top-secret"); + }), + ); + + it.live("retains Auth's local JWT secret when signing uses a JWKS file", () => + Effect.gen(function* () { + const result = yield* compile({ + security: { jwt: { signing: { kind: "jwks-file", path: "jwt.json" } } }, + }); + expect(result.definition.security.jwt.signing).toEqual({ + kind: "jwks-file", + path: "jwt.json", + }); + expect(result.secrets.filter((entry) => entry.slot.includes("jwt"))).toEqual([ + expect.objectContaining({ slot: "secret:auth.settings.jwt_secret", policy: "managed" }), + ]); + }), + ); + it.live("derives each supported third-party issuer and rejects invalid combinations", () => Effect.gen(function* () { const cases = [ diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 4a5fd47ff9..83b83e41f9 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -161,7 +161,7 @@ export const validateWorkloadRuntimeInputs = ( jwksConsumer && signing?.kind !== "jwks-file" && thirdParty.value === undefined && - secret(state, "secret:security.jwt.signing.secret").length === 0 + secret(state, "secret:auth.settings.jwt_secret").length === 0 ) return yield* new StackPreparationError({ message: "Managed JWT signing secret is required for the configured auth mode", @@ -435,7 +435,7 @@ const withRestSettings = ( PGRST_DB_ANON_ROLE: "anon", PGRST_JWT_SECRET: usesResolvedJwks(state) ? (_inputs.auth?.jwks ?? "") - : secret(state, "secret:security.jwt.signing.secret"), + : secret(state, "secret:auth.settings.jwt_secret"), PGRST_SERVER_PORT: String(port), PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows") || "1000", }); @@ -742,7 +742,7 @@ const withStorageSettings = ( UPLOAD_FILE_SIZE_LIMIT: "52428800000", UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", - PGRST_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + PGRST_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), ...(inputs.auth?.jwks === undefined ? {} : { JWT_JWKS: inputs.auth.jwks }), TUS_URL_PATH: "/storage/v1/upload/resumable", IMGPROXY_URL: @@ -868,9 +868,9 @@ const specs: Readonly> = { DB_PASSWORD: secret(state, "secret:database.internal.password"), DB_NAME: "postgres", DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", - API_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), ...(inputs.auth?.jwks === undefined ? {} : { API_JWT_JWKS: inputs.auth.jwks }), - METRICS_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + METRICS_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), DB_ENC_KEY: "supabaserealtime", SECRET_KEY_BASE: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", DNS_NODES: "''", @@ -1080,12 +1080,12 @@ const specs: Readonly> = { PORT: String(port), PROXY_PORT_TRANSACTION: String(port), DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, - API_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), REGION: "local", CLUSTER_POSTGRES: "true", SECRET_KEY_BASE: valueAt(state, "pooler", "secret_key_base"), VAULT_ENC_KEY: valueAt(state, "pooler", "encryption_key"), - METRICS_JWT_SECRET: secret(state, "secret:security.jwt.signing.secret"), + METRICS_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size") || "20", MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn") || "100", POOL_MODE: valueAt(state, "pooler", "pool_mode") || "transaction", diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 45beeccea5..7f55bbffa7 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -53,7 +53,6 @@ const state: PersistedStackState = { ], secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, - "secret:security.jwt.signing.secret": { policy: "managed", value: "symmetric-secret" }, "secret:auth.settings.jwt_secret": { policy: "managed", value: "symmetric-secret" }, }, }; @@ -465,6 +464,116 @@ describe("workload runtime catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it("uses Auth's local JWT secret for every internal JWT consumer", () => { + const consumers = [ + ["auth:auth", "GOTRUE_JWT_SECRET"], + ["realtime:realtime", "API_JWT_SECRET"], + ["storage:storage", "AUTH_JWT_SECRET"], + ["storage:storage", "PGRST_JWT_SECRET"], + ["pooler:pooler", "API_JWT_SECRET"], + ["pooler:pooler", "METRICS_JWT_SECRET"], + ["functions:edge-runtime", "SUPABASE_INTERNAL_JWT_SECRET"], + ] as const; + for (const [id, key] of consumers) { + const workload = planned(id); + const spec = runtimeSpecFor(workload); + expect(spec?.env(state, workload, 3000)[key]).toBe("symmetric-secret"); + } + }); + + it.live("keeps Auth's local JWT secret alongside resolved JWKS material", () => + Effect.gen(function* () { + const compileWith = (config: Parameters[0]["config"]) => + compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config, + }).pipe(Effect.provide(NodeServices.layer)); + const jwks = '{"keys":[{"kty":"EC"}]}'; + const jwksCompiled = yield* compileWith({ + security: { jwt: { signing: { kind: "jwks-file", path: "jwt.json" } } }, + }); + const jwksState: PersistedStackState = { + ...state, + definition: jwksCompiled.definition, + }; + const internal = [ + ["auth:auth", "GOTRUE_JWT_SECRET"], + ["realtime:realtime", "API_JWT_SECRET"], + ["realtime:realtime", "METRICS_JWT_SECRET"], + ["storage:storage", "AUTH_JWT_SECRET"], + ["storage:storage", "PGRST_JWT_SECRET"], + ["pooler:pooler", "API_JWT_SECRET"], + ["pooler:pooler", "METRICS_JWT_SECRET"], + ["functions:edge-runtime", "SUPABASE_INTERNAL_JWT_SECRET"], + ] as const; + for (const [id, key] of internal) { + const workload = planned(id); + const spec = runtimeSpecFor(workload); + expect( + spec?.env(jwksState, workload, 3000, "native", { + auth: { jwtKeys: '[{"kty":"EC"}]', jwks }, + })[key], + ).toBe("symmetric-secret"); + } + expect( + runtimeSpecFor(planned("rest:rest"))?.env(jwksState, planned("rest:rest"), 3000, "native", { + auth: { jwtKeys: '[{"kty":"EC"}]', jwks }, + }).PGRST_JWT_SECRET, + ).toBe(jwks); + + const thirdPartyCompiled = yield* compileWith({ + capabilities: { + auth: { + settings: { + third_party: { firebase: { enabled: true, project_id: "project-42" } }, + }, + }, + }, + }); + const thirdPartyState: PersistedStackState = { + ...state, + definition: thirdPartyCompiled.definition, + }; + expect( + runtimeSpecFor(planned("realtime:realtime"))?.env( + thirdPartyState, + planned("realtime:realtime"), + 3000, + "native", + { auth: { jwks } }, + ).API_JWT_SECRET, + ).toBe("symmetric-secret"); + expect( + runtimeSpecFor(planned("realtime:realtime"))?.env( + thirdPartyState, + planned("realtime:realtime"), + 3000, + "native", + { auth: { jwks } }, + ).API_JWT_JWKS, + ).toBe(jwks); + expect( + runtimeSpecFor(planned("storage:storage"))?.env( + thirdPartyState, + planned("storage:storage"), + 3000, + "native", + { auth: { jwks } }, + ).AUTH_JWT_SECRET, + ).toBe("symmetric-secret"); + expect( + runtimeSpecFor(planned("pooler:pooler"))?.env( + thirdPartyState, + planned("pooler:pooler"), + 3000, + "native", + { auth: { jwks } }, + ).METRICS_JWT_SECRET, + ).toBe("symmetric-secret"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("requires resolved JWKS material for an enabled third-party provider", () => Effect.gen(function* () { const compiled = yield* compileStack({ diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index e59b82dea2..eea17e7364 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -42,7 +42,6 @@ describe("managed and pass-through secrets", () => { const second = yield* resolveSecrets(candidate, first.persisted, "running"); for (const slot of [ "secret:database.internal.password", - "secret:security.jwt.signing.secret", "secret:auth.settings.publishable_key", "secret:auth.settings.secret_key", ]) { From 865a95675cde85f0be2a3d57f364b69b540d2e78 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:09:11 +0200 Subject: [PATCH 034/174] fix(stack): complete gateway request transforms --- ...26-08-28-stack-runtime-rewrite-progress.md | 15 ++ packages/stack/src/gateway/Gateway.ts | 11 + packages/stack/src/gateway/HttpGateway.ts | 79 +++++-- packages/stack/src/gateway/RouteCatalog.ts | 218 +++++++++++++++--- .../src/gateway/gateway.integration.test.ts | 97 ++++++++ .../gateway/route-catalog.integration.test.ts | 75 +++++- 6 files changed, 449 insertions(+), 46 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index c2275836eb..6c96b99202 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -706,6 +706,21 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g stack types, Effect/generic lint, formatting, and diff checks pass. Task 13B2b still owns production listener allocation/reconciliation and Edge Runtime bootstrap composition. +#### Task 13B2a review fix — gateway request transforms (2026-08-29) + +- Gateway routes and prepared routes can transform upstream headers after hop-by-hop filtering for both + HTTP and WebSocket forwarding. Internal `x-forwarded-*` values are rewritten from the accepted client + connection, so client-supplied overrides cannot spoof them; WebSocket handshake headers are restored by + the gateway after transformation. +- `routeCatalogFor` accepts owner-resolved API material and maps matching publishable/secret API keys to + the corresponding anonymous/service JWT. REST/Auth/Realtime/Storage/GraphQL use `Authorization`, + Functions use `sb-api-key`, GraphQL enforces `Content-Profile: graphql_public`, Storage S3 preserves + AWS Authorization, and Realtime WebSocket `apikey` query mapping preserves unrelated parameters. +- No authentication is performed in the gateway and no API material is logged or included in failures. + Focused gateway/route integration tests cover transforms, spoof removal, key mappings, S3/GraphQL/ + Realtime behavior, query preservation, Functions, and HTTP/WebSocket forwarding. Task 13B production + composition still owns the resolved material source and listener lifecycle. + #### Task 13A fix round 5 — unify local JWT signing material (2026-08-29) - Symmetric JWT configuration now has one canonical managed `secret:auth.settings.jwt_secret` slot; diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts index d5b8231ec1..5bc5191d42 100644 --- a/packages/stack/src/gateway/Gateway.ts +++ b/packages/stack/src/gateway/Gateway.ts @@ -31,6 +31,8 @@ export interface PreparedGatewayRoute { ) => Effect.Effect; /** Optional request path sent to the activated backend. */ readonly upstreamPath?: (request: GatewayRouteRequest) => string; + /** Optional header transform applied after hop-by-hop filtering. */ + readonly upstreamHeaders?: GatewayHeaderTransform; } export interface LazyActivator { @@ -151,6 +153,13 @@ export interface GatewayRouteRequest { readonly headers: Readonly>; } +export type GatewayHeaderValue = string | string[]; +export type GatewayHeaders = Readonly>; +export type GatewayHeaderTransform = ( + request: GatewayRouteRequest, + headers: GatewayHeaders, +) => GatewayHeaders; + export interface GatewayRoute { readonly capability: CapabilityName; /** Workload binding selected when a capability exposes multiple endpoints. */ @@ -158,6 +167,8 @@ export interface GatewayRoute { readonly match: (request: GatewayRouteRequest) => boolean; /** Optional path transform applied before proxying when no prepared route overrides it. */ readonly upstreamPath?: (request: GatewayRouteRequest) => string; + /** Optional header transform applied after hop-by-hop filtering. */ + readonly upstreamHeaders?: GatewayHeaderTransform; /** Request-time validation and preparation performed before activation. */ readonly prepare?: ( request: GatewayRouteRequest, diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index 3c9676c85a..dd512d6092 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -21,6 +21,7 @@ import type { BackendEndpoint, GatewayRoute, GatewayRouteRequest, + GatewayHeaders, LazyActivator, PreparedGatewayRoute, } from "./Gateway.ts"; @@ -109,25 +110,53 @@ const preparedPath = ( request: GatewayRouteRequest, ): string => prepared?.upstreamPath?.(request) ?? route.upstreamPath?.(request) ?? request.path; +const filteredRequestHeaders = (request: IncomingMessage): Record => { + const headers: Record = {}; + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined && !hopByHop.has(name.toLowerCase())) headers[name] = value; + } + return headers; +}; + +const withForwardedHeaders = ( + request: IncomingMessage, + headers: GatewayHeaders, +): Record => { + const forwarded = new Map(); + for (const [name, value] of Object.entries(headers)) { + if (!name.toLowerCase().startsWith("x-forwarded-")) forwarded.set(name, value); + } + forwarded.set("x-forwarded-host", request.headers.host ?? ""); + forwarded.set("x-forwarded-proto", "http"); + forwarded.set("x-forwarded-for", request.socket.remoteAddress ?? ""); + return Object.fromEntries(forwarded); +}; + +const preparedHeaders = ( + route: GatewayRoute, + prepared: PreparedGatewayRoute | undefined, + view: GatewayRouteRequest, + request: IncomingMessage, +): Record => + withForwardedHeaders( + request, + prepared?.upstreamHeaders?.(view, filteredRequestHeaders(request)) ?? + route.upstreamHeaders?.(view, filteredRequestHeaders(request)) ?? + filteredRequestHeaders(request), + ); + const proxy = ( request: IncomingMessage, response: ServerResponse, backend: BackendEndpoint, options: HttpGatewayOptions, upstreamPath: string, + headers: Record, ): Effect.Effect => Effect.callback((resume) => { let settled = false; let outgoing: ReturnType | undefined; let incoming: IncomingMessage | undefined; - const headers: Record = {}; - for (const [name, value] of Object.entries(request.headers)) { - if (value !== undefined && !hopByHop.has(name.toLowerCase())) headers[name] = value; - } - headers["x-forwarded-host"] = request.headers.host ?? ""; - headers["x-forwarded-proto"] = "http"; - headers["x-forwarded-for"] = request.socket.remoteAddress ?? ""; - const onIncomingError = (cause: Error) => { if (settled) return; settled = true; @@ -252,13 +281,19 @@ const handleRequest = ( : options.resolveBackend(route, view, result) : prepared.resolveBackend(result); return resolved.pipe( - Effect.map((backend) => ({ backend, path: preparedPath(route, prepared, view) })), + Effect.map((backend) => ({ + backend, + path: preparedPath(route, prepared, view), + headers: preparedHeaders(route, prepared, view, request), + })), Effect.mapError((cause) => new GatewayBackendError({ cause })), ); }), ), ), - Effect.flatMap(({ backend, path }) => proxy(request, response, backend, options, path)), + Effect.flatMap(({ backend, path, headers }) => + proxy(request, response, backend, options, path, headers), + ), ); // Node invokes this handler outside Effect; use the owner-scoped FiberSet // runtime so cancellation of the gateway interrupts in-flight activation. @@ -282,13 +317,17 @@ const handleRequest = ( }); }; -const writeUpgrade = (request: IncomingMessage, path: string): string => { +const writeUpgrade = ( + request: IncomingMessage, + path: string, + forwardedHeaders: Readonly>, +): string => { const lines = [`${request.method ?? "GET"} ${path} HTTP/${request.httpVersion}`]; - for (let index = 0; index < request.rawHeaders.length; index += 2) { - const name = request.rawHeaders[index]; - const value = request.rawHeaders[index + 1]; - if (name !== undefined && value !== undefined) lines.push(`${name}: ${value}`); + for (const [name, value] of Object.entries(forwardedHeaders)) { + const values = Array.isArray(value) ? value : [value]; + for (const item of values) lines.push(`${name}: ${item}`); } + lines.push("Connection: Upgrade", "Upgrade: websocket"); return `${lines.join("\r\n")}\r\n\r\n`; }; @@ -323,13 +362,17 @@ const handleUpgrade = ( : options.resolveBackend(route, view, result) : prepared.resolveBackend(result); return resolved.pipe( - Effect.map((backend) => ({ backend, path: preparedPath(route, prepared, view) })), + Effect.map((backend) => ({ + backend, + path: preparedPath(route, prepared, view), + headers: preparedHeaders(route, prepared, view, request), + })), Effect.mapError((cause) => new GatewayBackendError({ cause })), ); }), ), ), - Effect.flatMap(({ backend, path }) => + Effect.flatMap(({ backend, path, headers }) => Effect.callback((resume) => { const target = new Socket(); let settled = false; @@ -361,7 +404,7 @@ const handleUpgrade = ( const onSocketEnd = () => target.end(); const onTargetEnd = () => socket.end(); const onConnect = () => { - target.write(writeUpgrade(request, path)); + target.write(writeUpgrade(request, path, headers)); if (head.byteLength > 0) target.write(head); socket.pipe(target, { end: false }); target.pipe(socket, { end: false }); diff --git a/packages/stack/src/gateway/RouteCatalog.ts b/packages/stack/src/gateway/RouteCatalog.ts index d54ab2f56f..bca7b48c7f 100644 --- a/packages/stack/src/gateway/RouteCatalog.ts +++ b/packages/stack/src/gateway/RouteCatalog.ts @@ -1,13 +1,26 @@ import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; import type { CapabilityName } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; -import type { GatewayRoute, GatewayRouteRequest } from "./Gateway.ts"; +import type { + GatewayHeaderTransform, + GatewayHeaders, + GatewayRoute, + GatewayRouteRequest, +} from "./Gateway.ts"; export interface GatewayRouteCatalog { readonly http: ReadonlyMap>; readonly tcp: ReadonlyMap>; } +/** Owner-resolved API material used only for forwarding transformations. */ +export interface GatewayApiMaterial { + readonly publishableKey: string; + readonly secretKey: string; + readonly anonJwt: string; + readonly serviceRoleJwt: string; +} + const pathStartsWith = (prefix: string) => (path: string): boolean => @@ -36,16 +49,100 @@ const appendSuffix = return `${base}${suffix}${query}`; }; +const headerValue = (headers: GatewayHeaders, name: string): string | undefined => { + const value = Object.entries(headers).find( + ([key]) => key.toLowerCase() === name.toLowerCase(), + )?.[1]; + return Array.isArray(value) ? value[0] : value; +}; + +const removeHeader = (headers: Record, name: string): void => { + for (const key of Object.keys(headers)) + if (key.toLowerCase() === name.toLowerCase()) delete headers[key]; +}; + +const apiKeyJwt = (value: string | undefined, material: GatewayApiMaterial): string | undefined => { + if (value === undefined) return undefined; + const trimmed = value.trim(); + const candidate = /^Bearer\s+/iu.test(trimmed) ? trimmed.slice(7).trim() : trimmed; + if (candidate === material.publishableKey) return material.anonJwt; + if (candidate === material.secretKey) return material.serviceRoleJwt; + return undefined; +}; + +const authorizationTransform = + (material: GatewayApiMaterial, target: "authorization" | "sb-api-key"): GatewayHeaderTransform => + (_request, headers) => { + const output: Record = { ...headers }; + const authorization = headerValue(headers, "authorization"); + const apikey = headerValue(headers, "apikey"); + const jwt = + target === "sb-api-key" + ? (apiKeyJwt(apikey, material) ?? apiKeyJwt(authorization, material)) + : authorization === undefined + ? apiKeyJwt(apikey, material) + : apiKeyJwt(authorization, material); + if (jwt === undefined) { + if (target === "sb-api-key") removeHeader(output, target); + return output; + } + removeHeader(output, target); + output[target] = `Bearer ${jwt}`; + return output; + }; + +const composeHeaders = + (...transforms: ReadonlyArray): GatewayHeaderTransform => + (request, headers) => + transforms.reduce((current, transform) => transform(request, current), headers); + +const preserveHeaders: GatewayHeaderTransform = (_request, headers) => ({ ...headers }); + +const graphqlHeaders = (material: GatewayApiMaterial): GatewayHeaderTransform => + composeHeaders(authorizationTransform(material, "authorization"), (_request, headers) => { + const output: Record = { ...headers }; + removeHeader(output, "content-profile"); + output["content-profile"] = "graphql_public"; + return output; + }); + +const realtimeWebsocketPath = + (material: GatewayApiMaterial | undefined) => + (request: GatewayRouteRequest): string => { + const forwarded = appendSuffix("/socket", "/realtime/v1")(request); + if (material === undefined) return forwarded; + const [pathname, query] = pathAndQuery(forwarded); + if (query.length === 0) return forwarded; + const mapped = query + .slice(1) + .split("&") + .map((part) => { + const separator = part.indexOf("="); + if (separator < 0) return part; + const key = decodeURIComponent(part.slice(0, separator)); + if (key !== "apikey") return part; + const value = decodeURIComponent(part.slice(separator + 1)); + const jwt = apiKeyJwt(value, material); + return jwt === undefined + ? part + : `${part.slice(0, separator + 1)}${encodeURIComponent(jwt)}`; + }) + .join("&"); + return `${pathname}?${mapped}`; + }; + const prefixRoute = ( capability: CapabilityName, prefix: string, upstreamPath: (request: GatewayRouteRequest) => string = stripPrefix(prefix), match: (request: GatewayRouteRequest) => boolean = (request) => pathStartsWith(prefix)(pathAndQuery(request.path)[0]), + upstreamHeaders?: GatewayHeaderTransform, ): GatewayRoute => ({ capability, match, upstreamPath, + ...(upstreamHeaders === undefined ? {} : { upstreamHeaders }), }); const directRoute = (capability: CapabilityName, binding?: string): GatewayRoute => ({ @@ -54,42 +151,106 @@ const directRoute = (capability: CapabilityName, binding?: string): GatewayRoute match: () => true, }); -const realtimeWebsocketRoute = (): GatewayRoute => - prefixRoute("realtime", "/realtime/v1", appendSuffix("/socket", "/realtime/v1"), (request) => { - const [pathname] = pathAndQuery(request.path); - const upgrade = request.headers["upgrade"]; - const isUpgrade = Array.isArray(upgrade) - ? upgrade.some((value) => value.toLowerCase() === "websocket") - : upgrade?.toLowerCase() === "websocket"; - return ( - pathStartsWith("/realtime/v1")(pathname) && - (isUpgrade || pathname === "/realtime/v1/websocket") - ); - }); +const realtimeWebsocketRoute = (material?: GatewayApiMaterial): GatewayRoute => + prefixRoute( + "realtime", + "/realtime/v1", + realtimeWebsocketPath(material), + (request) => { + const [pathname] = pathAndQuery(request.path); + const upgrade = request.headers["upgrade"]; + const isUpgrade = Array.isArray(upgrade) + ? upgrade.some((value) => value.toLowerCase() === "websocket") + : upgrade?.toLowerCase() === "websocket"; + return ( + pathStartsWith("/realtime/v1")(pathname) && + (isUpgrade || pathname === "/realtime/v1/websocket") + ); + }, + material === undefined ? undefined : authorizationTransform(material, "authorization"), + ); -const realtimeRestRoute = (): GatewayRoute => - prefixRoute("realtime", "/realtime/v1/api", stripPrefix("/realtime/v1")); - -const apiRoutes = (capability: CapabilityName): ReadonlyArray => { +const apiRoutes = ( + capability: CapabilityName, + material?: GatewayApiMaterial, +): ReadonlyArray => { switch (capability) { case "rest": return [ - prefixRoute(capability, "/rest/v1"), - prefixRoute(capability, "/graphql/v1", appendSuffix("/rpc/graphql", "/graphql/v1")), + prefixRoute( + capability, + "/rest/v1", + stripPrefix("/rest/v1"), + undefined, + material === undefined ? undefined : authorizationTransform(material, "authorization"), + ), + prefixRoute( + capability, + "/graphql/v1", + appendSuffix("/rpc/graphql", "/graphql/v1"), + undefined, + material === undefined ? undefined : graphqlHeaders(material), + ), ]; case "auth": - return [prefixRoute(capability, "/auth/v1")]; + return [ + prefixRoute( + capability, + "/auth/v1", + stripPrefix("/auth/v1"), + undefined, + material === undefined ? undefined : authorizationTransform(material, "authorization"), + ), + ]; case "realtime": - return [realtimeWebsocketRoute(), realtimeRestRoute(), prefixRoute(capability, "/socket")]; + return [ + realtimeWebsocketRoute(material), + prefixRoute( + capability, + "/realtime/v1/api", + stripPrefix("/realtime/v1"), + undefined, + material === undefined ? undefined : authorizationTransform(material, "authorization"), + ), + prefixRoute(capability, "/socket"), + ]; case "storage": return [ - prefixRoute(capability, "/storage/v1/s3", appendSuffix("/s3", "/storage/v1/s3")), - prefixRoute(capability, "/storage/v1"), + prefixRoute( + capability, + "/storage/v1/s3", + appendSuffix("/s3", "/storage/v1/s3"), + undefined, + preserveHeaders, + ), + prefixRoute( + capability, + "/storage/v1", + stripPrefix("/storage/v1"), + undefined, + material === undefined ? undefined : authorizationTransform(material, "authorization"), + ), ]; case "functions": - return [prefixRoute(capability, "/functions/v1")]; + return [ + prefixRoute( + capability, + "/functions/v1", + stripPrefix("/functions/v1"), + undefined, + material === undefined ? undefined : authorizationTransform(material, "sb-api-key"), + ), + ]; case "analytics": - return [prefixRoute(capability, "/analytics/v1")]; + return [ + prefixRoute( + capability, + "/analytics/v1", + stripPrefix("/analytics/v1"), + undefined, + material === undefined ? undefined : authorizationTransform(material, "authorization"), + ), + ]; case "database": case "pooler": case "studio": @@ -99,7 +260,10 @@ const apiRoutes = (capability: CapabilityName): ReadonlyArray => { }; /** Build the closed public route set from the enabled plan, in collision-safe order. */ -export const routeCatalogFor = (plan: ExecutionPlan): GatewayRouteCatalog => { +export const routeCatalogFor = ( + plan: ExecutionPlan, + material?: GatewayApiMaterial, +): GatewayRouteCatalog => { const http = new Map(); const tcp = new Map(); const append = ( @@ -113,7 +277,7 @@ export const routeCatalogFor = (plan: ExecutionPlan): GatewayRouteCatalog => { }; for (const route of plan.routes) { if (route.listener === "api" && route.protocol === "http") - append(http, route.listener, apiRoutes(route.capability)); + append(http, route.listener, apiRoutes(route.capability, material)); else if (route.listener === "studio" && route.protocol === "http") append(http, route.listener, [directRoute(route.capability)]); else if (route.listener === "mailUi" && route.protocol === "http") diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index a4cc283834..f9ea292d22 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -168,6 +168,103 @@ describe("stack gateway", () => { ), ); + it.live("applies static and prepared header transforms to HTTP and WebSocket upstreams", () => + withPlatform( + Effect.gen(function* () { + let httpHeaders: IncomingMessage["headers"] | undefined; + let websocketHeaders: IncomingMessage["headers"] | undefined; + const backend = createHttpServer((_request, response) => { + httpHeaders = _request.headers; + response.end("ok"); + }); + backend.on("upgrade", (request, socket) => { + websocketHeaders = request.headers; + socket.end( + "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + ); + }); + yield* Effect.callback((resume) => { + backend.once("error", (error) => resume(Effect.fail(error))); + backend.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const address = backend.address(); + if (typeof address !== "object" || address === null) return; + const endpoint: BackendEndpoint = { host: "127.0.0.1", port: address.port }; + const gateway = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [ + { + capability: "rest", + match: (request) => request.path === "/http", + upstreamHeaders: (_request, headers) => { + const { "x-remove": _removed, ...rest } = headers; + return { ...rest, "x-route": "static", "x-forwarded-host": "spoofed" }; + }, + }, + { + capability: "functions", + match: (request) => request.path === "/ws", + prepare: () => + Effect.succeed({ + resolveBackend: () => Effect.succeed(endpoint), + upstreamHeaders: (_request, headers) => { + const { "x-remove": _removed, ...rest } = headers; + return { ...rest, "x-route": "prepared", "x-forwarded-for": "spoofed" }; + }, + }), + }, + ], + activate: (capability) => Effect.succeed({ capability, endpoint }), + }); + const response = yield* Effect.callback((resume) => { + const request = requestHttp( + { + host: "127.0.0.1", + port: gateway.port, + path: "/http", + headers: { + host: "gateway.test", + "x-remove": "client-secret", + "x-forwarded-host": "client-spoof", + }, + }, + (result) => { + result.resume(); + result.once("end", () => resume(Effect.succeed(result.statusCode ?? 0))); + }, + ); + request.once("error", (error) => resume(Effect.fail(error))); + request.end(); + return Effect.sync(() => request.destroy()); + }); + expect(response).toBe(200); + expect(httpHeaders?.["x-remove"]).toBeUndefined(); + expect(httpHeaders?.["x-route"]).toBe("static"); + expect(httpHeaders?.["x-forwarded-host"]).toBe("gateway.test"); + const websocket = yield* Effect.callback((resume) => { + const socket = connectNet(gateway.port, "127.0.0.1"); + const chunks: Buffer[] = []; + socket.once("connect", () => + socket.write( + "GET /ws HTTP/1.1\r\nHost: gateway.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nx-remove: client-secret\r\n\r\n", + ), + ); + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.once("end", () => resume(Effect.succeed(Buffer.concat(chunks).toString()))); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }); + expect(websocket).toContain("101 Switching Protocols"); + expect(websocketHeaders?.["x-remove"]).toBeUndefined(); + expect(websocketHeaders?.["x-route"]).toBe("prepared"); + expect(websocketHeaders?.["x-forwarded-for"]).toContain("127.0.0.1"); + yield* gateway.close; + yield* closeServer(backend); + }), + ), + ); + it.live("closes an idle keep-alive connection it owns", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/gateway/route-catalog.integration.test.ts b/packages/stack/src/gateway/route-catalog.integration.test.ts index e000684557..f52e98f25f 100644 --- a/packages/stack/src/gateway/route-catalog.integration.test.ts +++ b/packages/stack/src/gateway/route-catalog.integration.test.ts @@ -12,7 +12,12 @@ describe("public gateway route catalog", () => { runtime: { kind: "native" }, config: { capabilities: { pooler: { enabled: true } } }, }).pipe(Effect.provide(NodeServices.layer)); - const catalog = routeCatalogFor(compiled.executionPlan); + const catalog = routeCatalogFor(compiled.executionPlan, { + publishableKey: "sb_publishable", + secretKey: "sb_secret", + anonJwt: "anon-jwt", + serviceRoleJwt: "service-role-jwt", + }); const api = catalog.http.get("api") ?? []; const find = (path: string, headers: Readonly> = {}) => api.find((route) => route.match({ path, headers })); @@ -33,6 +38,74 @@ describe("public gateway route catalog", () => { expect(upstream("/storage/v1/object/list?limit=1")).toBe("/object/list?limit=1"); expect(upstream("/functions/v1/hello?x=1")).toBe("/hello?x=1"); expect(upstream("/analytics/v1/logs?limit=1")).toBe("/logs?limit=1"); + + const headersFor = ( + path: string, + headers: Readonly>, + ): Readonly> => { + const route = find(path, headers); + expect(route).toBeDefined(); + const transformed = route?.upstreamHeaders?.({ path, headers }, headers); + expect(transformed).toBeDefined(); + return transformed ?? {}; + }; + expect(headersFor("/rest/v1/items", { apikey: "sb_publishable" })).toMatchObject({ + apikey: "sb_publishable", + authorization: "Bearer anon-jwt", + }); + expect(headersFor("/rest/v1/items", { authorization: "Bearer sb_secret" })).toMatchObject({ + authorization: "Bearer service-role-jwt", + }); + expect( + headersFor("/rest/v1/items", { + authorization: "Bearer user-jwt", + apikey: "sb_secret", + }).authorization, + ).toBe("Bearer user-jwt"); + expect(headersFor("/rest/v1/items", { apikey: "legacy-unknown" })).toEqual({ + apikey: "legacy-unknown", + }); + expect(headersFor("/auth/v1/token", { apikey: "sb_publishable" }).authorization).toBe( + "Bearer anon-jwt", + ); + expect(headersFor("/realtime/v1/api/tenants", { apikey: "sb_secret" }).authorization).toBe( + "Bearer service-role-jwt", + ); + expect( + headersFor("/storage/v1/object/list", { apikey: "sb_publishable" }).authorization, + ).toBe("Bearer anon-jwt"); + expect( + headersFor("/functions/v1/hello", { + "sb-api-key": "spoofed", + apikey: "sb_publishable", + }), + ).toMatchObject({ apikey: "sb_publishable", "sb-api-key": "Bearer anon-jwt" }); + expect(headersFor("/functions/v1/hello", { apikey: "sb_secret" })["sb-api-key"]).toBe( + "Bearer service-role-jwt", + ); + expect( + headersFor("/functions/v1/hello", { + "sb-api-key": "spoofed", + apikey: "legacy-unknown", + }), + ).toEqual({ apikey: "legacy-unknown" }); + expect( + headersFor("/graphql/v1", { + authorization: "Bearer sb_publishable", + "content-profile": "spoofed", + }), + ).toMatchObject({ + authorization: "Bearer anon-jwt", + "content-profile": "graphql_public", + }); + expect( + headersFor("/storage/v1/s3/bucket/object", { + authorization: "AWS4-HMAC-SHA256 Credential=example", + }).authorization, + ).toBe("AWS4-HMAC-SHA256 Credential=example"); + expect(upstream("/realtime/v1/websocket?apikey=sb_secret&vsn=1")).toBe( + "/socket/websocket?apikey=service-role-jwt&vsn=1", + ); expect((catalog.http.get("studio") ?? [])[0]?.match({ path: "/", headers: {} })).toBe(true); expect((catalog.http.get("mailUi") ?? [])[0]?.binding).toBe("ui"); expect((catalog.tcp.get("database") ?? [])[0]?.capability).toBe("database"); From d210c9d40aa103def4e306acf50b793bd4a7e459 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:22:30 +0200 Subject: [PATCH 035/174] fix(stack): generate valid managed api credentials --- ...26-08-28-stack-runtime-rewrite-progress.md | 13 + packages/stack/src/model/Compiler.ts | 37 +++ .../src/model/compiler.integration.test.ts | 11 + packages/stack/src/state/SecretStore.ts | 298 +++++++++++++++++- .../src/state/secrets.integration.test.ts | 231 +++++++++++++- packages/stack/src/supervisor/Lifecycle.ts | 1 + 6 files changed, 584 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 6c96b99202..1191b578d6 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -730,3 +730,16 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g Storage, and Supavisor while PostgREST consumes owner-resolved JWKS material where required. - Compiler, runtime, and secret-store integration coverage verifies default/one-sided/equal/mismatch inputs, no duplicate slot, internal JWT consumers, and JWKS/third-party resolution semantics. + +#### Task 13A fix round 6 — generate valid managed API credentials (2026-08-29) + +- Managed Auth omissions now use slot-aware lifecycle generation: publishable and secret keys receive + cryptographically random `sb_publishable_`/`sb_secret_` values, while the canonical JWT secret + receives 32 random bytes encoded as base64url. Existing persisted or explicit values remain sticky. +- Omitted anonymous and service-role keys are legacy-shaped JWTs (`iss: supabase-demo`, role, expiry) + signed with the canonical local secret in symmetric mode or the first supported private ES256/RS256 + JWK in `security.jwt.signing.path`. JWK files are read only during secret resolution, with + project-root containment and redacted typed failures for malformed/public-only material. +- Compiler carries only private generator descriptors; no generated bytes or signing-file reads enter + preparation or the durable definition. Secret-store integration covers entropy/prefixes, sticky and + explicit values, HS256/ES256 signatures, and invalid/escaping signing files. diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 6dffbbb532..d78c75fbd3 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -32,6 +32,7 @@ import { import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { CAPABILITY_MODULES, createExecutionPlan, type ExecutionPlan } from "./ExecutionPlan.ts"; import type { CapabilityModule, CapabilityRelease } from "./CapabilityModule.ts"; +import type { SecretGenerator, SecretJwtSigning } from "../state/SecretStore.ts"; export type InputFingerprint = Schema.Schema.Type; const InputFingerprintSchema = Schema.String.pipe(Schema.brand("InputFingerprint")); @@ -98,6 +99,8 @@ export interface SecretSlotInput { readonly slot: string; readonly policy: "managed" | "passthrough"; readonly value?: Redacted.Redacted; + /** Private lifecycle-only generator metadata; never materialized into StackDefinition. */ + readonly generator?: SecretGenerator; } const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; @@ -233,6 +236,38 @@ const ensureCanonicalJwtSlot = ( slots[existingIndex] = { ...existing, value }; }; +const attachAuthSecretGenerators = ( + slots: SecretSlotInput[], + projectRoot: string, + signing: JwtSigning | undefined, +): void => { + const jwtSigning: SecretJwtSigning = + signing?.kind === "jwks-file" + ? { kind: "jwks-file", projectRoot, path: signing.path } + : { kind: "symmetric" }; + for (let index = 0; index < slots.length; index++) { + const entry = slots[index]; + if (entry === undefined || entry.policy !== "managed") continue; + const generator = + entry.slot === "secret:auth.settings.publishable_key" + ? ({ kind: "publishable-key" } satisfies SecretGenerator) + : entry.slot === "secret:auth.settings.secret_key" + ? ({ kind: "secret-key" } satisfies SecretGenerator) + : entry.slot === AUTH_JWT_SECRET_SLOT + ? ({ kind: "jwt-secret" } satisfies SecretGenerator) + : entry.slot === "secret:auth.settings.anon_key" + ? ({ kind: "jwt-token", role: "anon", signing: jwtSigning } satisfies SecretGenerator) + : entry.slot === "secret:auth.settings.service_role_key" + ? ({ + kind: "jwt-token", + role: "service_role", + signing: jwtSigning, + } satisfies SecretGenerator) + : undefined; + if (generator !== undefined) slots[index] = { ...entry, generator }; + } +}; + const setMaterializedPath = ( value: unknown, parts: ReadonlyArray, @@ -669,6 +704,7 @@ export const compileStack = ( const slots: SecretSlotInput[] = []; for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); collectSuppliedSecrets(config, slots, jwtSecret); + attachAuthSecretGenerators(slots, input.projectRoot, config.security?.jwt?.signing); const executionPlan = yield* planForDefinition(input.runtime, previous.definition, crypto); return { definition: previous.definition, @@ -833,6 +869,7 @@ export const compileStack = ( } satisfies Record; const rawJwt = config.security?.jwt; ensureCanonicalJwtSlot(slots, jwtSecret); + attachAuthSecretGenerators(slots, input.projectRoot, rawJwt?.signing); const security = { jwt: { issuer: rawJwt?.issuer ?? null, diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 6310e2d4c9..c933578351 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -157,6 +157,17 @@ describe("closed capability compiler", () => { expect(result.secrets.filter((entry) => entry.slot.includes("jwt"))).toEqual([ expect.objectContaining({ slot: "secret:auth.settings.jwt_secret", policy: "managed" }), ]); + expect( + result.secrets.find((entry) => entry.slot === "secret:auth.settings.anon_key")?.generator, + ).toEqual({ + kind: "jwt-token", + role: "anon", + signing: { + kind: "jwks-file", + projectRoot: "/tmp/supabase-project", + path: "jwt.json", + }, + }); }), ); diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index b4cfd24399..fbfb30b4d9 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -1,14 +1,36 @@ -import { Crypto, Effect, Redacted } from "effect"; -import { StackMustBeStoppedError, StackSecretMismatchError } from "../public/Errors.ts"; +import { Crypto, Effect, FileSystem, Path, Redacted, Schema } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createHmac, createPrivateKey, createSign } from "node:crypto"; +import { + InvalidJwtSigningMaterialError, + StackMustBeStoppedError, + StackSecretMismatchError, +} from "../public/Errors.ts"; import type { DesiredStackLifecycle } from "../public/Status.ts"; import type { PersistedSecretValues } from "./StackState.ts"; export type SecretPolicy = "managed" | "passthrough"; +export type SecretJwtSigning = + | { readonly kind: "symmetric" } + | { readonly kind: "jwks-file"; readonly projectRoot: string; readonly path: string }; + +/** Lifecycle-only metadata for generating omitted managed credentials. */ +export type SecretGenerator = + | { readonly kind: "publishable-key" } + | { readonly kind: "secret-key" } + | { readonly kind: "jwt-secret" } + | { + readonly kind: "jwt-token"; + readonly role: "anon" | "service_role"; + readonly signing: SecretJwtSigning; + }; + export interface SecretDeclaration { readonly slot: string; readonly policy: SecretPolicy; readonly value?: Redacted.Redacted; + readonly generator?: SecretGenerator; } export interface SecretCandidate { @@ -35,6 +57,63 @@ const readSecret = (value: Redacted.Redacted): string => { }; const validSlot = (slot: string): boolean => /^[A-Za-z0-9_.:/-]+$/.test(slot); +const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; +const JWT_ISSUER = "supabase-demo"; +const JWT_HMAC_EXPIRY = 1_983_812_996; +const JWT_ASYMMETRIC_EXPIRY = 2_103_000_000; +const BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +const JwtHeaderSchema = Schema.Struct({ + alg: Schema.String, + kid: Schema.optionalKey(Schema.String), + typ: Schema.Literal("JWT"), +}); +type JwtHeader = Schema.Schema.Type; +const JwtPayloadSchema = Schema.Struct({ + iss: Schema.Literal(JWT_ISSUER), + role: Schema.Literals(["anon", "service_role"] as const), + exp: Schema.Finite, +}); +type JwtPayload = Schema.Schema.Type; +const JwkSchema = Schema.Struct({ + alg: Schema.optionalKey(Schema.String), + crv: Schema.optionalKey(Schema.String), + d: Schema.optionalKey(Schema.String), + dp: Schema.optionalKey(Schema.String), + dq: Schema.optionalKey(Schema.String), + e: Schema.optionalKey(Schema.String), + kty: Schema.String, + kid: Schema.optionalKey(Schema.String), + n: Schema.optionalKey(Schema.String), + p: Schema.optionalKey(Schema.String), + q: Schema.optionalKey(Schema.String), + qi: Schema.optionalKey(Schema.String), + x: Schema.optionalKey(Schema.String), + y: Schema.optionalKey(Schema.String), +}); +const JwkFileSchema = Schema.Union([Schema.Array(JwkSchema), JwkSchema]); +const encodeJwtHeader = (value: JwtHeader) => + Schema.encodeEffect(Schema.fromJsonString(JwtHeaderSchema))(value); +const encodeJwtPayload = (value: JwtPayload) => + Schema.encodeEffect(Schema.fromJsonString(JwtPayloadSchema))(value); + +const base64UrlEncode = (bytes: Uint8Array): string => { + let output = ""; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const second = bytes[index + 1]; + const third = bytes[index + 2]; + output += BASE64URL[first >> 2] ?? ""; + output += BASE64URL[((first & 3) << 4) | ((second ?? 0) >> 4)] ?? ""; + if (second !== undefined) output += BASE64URL[((second & 15) << 2) | ((third ?? 0) >> 6)] ?? ""; + if (third !== undefined) output += BASE64URL[third & 63] ?? ""; + } + return output; +}; + +const base64UrlEncodeText = (value: string): string => + base64UrlEncode(new TextEncoder().encode(value)); + /** Redacts known exact values without attempting to infer transformed/derived secrets. */ export const redactKnownSecrets = (message: string, known: Iterable): string => { let result = message; @@ -52,6 +131,205 @@ const generatedSecret = (crypto: Crypto.Crypto) => ), ); +const randomEncoded = ( + crypto: Crypto.Crypto, + bytes: number, + slot: string, +): Effect.Effect => + crypto.randomBytes(bytes).pipe( + Effect.map(base64UrlEncode), + Effect.mapError((error) => + secretMismatch(slot, `Unable to generate managed secret: ${error.message}`), + ), + ); + +interface SigningJwk { + readonly kty: "EC" | "RSA"; + readonly alg: "ES256" | "RS256"; + readonly d: string; + readonly kid?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; + readonly n?: string; + readonly e?: string; + readonly p?: string; + readonly q?: string; + readonly dp?: string; + readonly dq?: string; + readonly qi?: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const stringField = (record: Record, key: string): string | undefined => { + const value = record[key]; + return value === undefined ? undefined : typeof value === "string" ? value : undefined; +}; + +const privateSigningJwk = (value: unknown): SigningJwk | undefined => { + if (!isRecord(value)) return undefined; + const kty = value.kty; + const alg = value.alg; + const d = stringField(value, "d"); + if (typeof d !== "string") return undefined; + const kid = stringField(value, "kid"); + if (kty === "EC" && alg === "ES256") { + const crv = stringField(value, "crv"); + const x = stringField(value, "x"); + const y = stringField(value, "y"); + if (crv !== "P-256" || x === undefined || y === undefined) return undefined; + return { kty, alg, d, ...(kid === undefined ? {} : { kid }), crv, x, y }; + } + if (kty === "RSA" && alg === "RS256") { + const n = stringField(value, "n"); + const e = stringField(value, "e"); + const p = stringField(value, "p"); + const q = stringField(value, "q"); + if (n === undefined || e === undefined || p === undefined || q === undefined) return undefined; + const dp = stringField(value, "dp"); + const dq = stringField(value, "dq"); + const qi = stringField(value, "qi"); + return { + kty, + alg, + d, + ...(kid === undefined ? {} : { kid }), + n, + e, + p, + q, + ...(dp === undefined ? {} : { dp }), + ...(dq === undefined ? {} : { dq }), + ...(qi === undefined ? {} : { qi }), + }; + } + return undefined; +}; + +const invalidSigningMaterial = () => + new InvalidJwtSigningMaterialError({ message: "Unable to resolve JWT signing material" }); + +const readSigningJwk = ( + signing: Extract, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const projectRoot = path.resolve(signing.projectRoot); + const candidate = path.resolve(projectRoot, signing.path); + const relative = path.relative(projectRoot, candidate); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + return yield* invalidSigningMaterial(); + const raw = yield* fs + .readFileString(candidate) + .pipe(Effect.mapError(() => invalidSigningMaterial())); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(JwkFileSchema))(raw).pipe( + Effect.mapError(() => invalidSigningMaterial()), + ); + const entries = Array.isArray(parsed) ? parsed : [parsed]; + for (const entry of entries) { + const jwk = privateSigningJwk(entry); + if (jwk !== undefined) return jwk; + } + return yield* invalidSigningMaterial(); + }); + +const signWithJwk = ( + jwk: SigningJwk, + payload: string, +): Effect.Effect => + Effect.gen(function* () { + const header = + jwk.kid === undefined + ? { alg: jwk.alg, typ: "JWT" } + : { alg: jwk.alg, kid: jwk.kid, typ: "JWT" }; + const headerJson = yield* encodeJwtHeader(header).pipe( + Effect.mapError(() => invalidSigningMaterial()), + ); + const data = `${base64UrlEncodeText(headerJson)}.${base64UrlEncodeText(payload)}`; + return yield* Effect.try({ + try: () => { + const privateKey = createPrivateKey({ key: jwk, format: "jwk" }); + const signer = createSign(jwk.alg === "RS256" ? "RSA-SHA256" : "sha256").update(data); + const signature = + jwk.alg === "RS256" + ? signer.end().sign(privateKey) + : signer.end().sign({ key: privateKey, dsaEncoding: "ieee-p1363" }); + return `${data}.${base64UrlEncode(signature)}`; + }, + catch: () => invalidSigningMaterial(), + }); + }); + +const generateJwtToken = ( + generator: Extract, + jwtSecret: string | undefined, +): Effect.Effect => { + const payloadFor = (expiry: number) => ({ + iss: JWT_ISSUER, + role: generator.role, + exp: expiry, + }); + if (generator.signing.kind === "symmetric") { + if (jwtSecret === undefined || jwtSecret.length === 0) + return Effect.fail(invalidSigningMaterial()); + return Effect.gen(function* () { + const header = base64UrlEncodeText( + yield* encodeJwtHeader({ alg: "HS256", typ: "JWT" }).pipe( + Effect.mapError(() => invalidSigningMaterial()), + ), + ); + const payload = base64UrlEncodeText( + yield* encodeJwtPayload(payloadFor(JWT_HMAC_EXPIRY)).pipe( + Effect.mapError(() => invalidSigningMaterial()), + ), + ); + const data = `${header}.${payload}`; + return yield* Effect.try({ + try: () => `${data}.${createHmac("sha256", jwtSecret).update(data).digest("base64url")}`, + catch: () => invalidSigningMaterial(), + }); + }); + } + return readSigningJwk(generator.signing).pipe( + Effect.flatMap((jwk) => + encodeJwtPayload(payloadFor(JWT_ASYMMETRIC_EXPIRY)).pipe( + Effect.mapError(() => invalidSigningMaterial()), + Effect.flatMap((payload) => signWithJwk(jwk, payload)), + ), + ), + ); +}; + +const generatedFor = ( + declaration: SecretDeclaration, + crypto: Crypto.Crypto, + resolved: Record, +): Effect.Effect< + string, + StackSecretMismatchError | InvalidJwtSigningMaterialError, + FileSystem.FileSystem | Path.Path +> => { + const generator = declaration.generator; + if (generator === undefined) return generatedSecret(crypto); + switch (generator.kind) { + case "publishable-key": + return randomEncoded(crypto, 24, declaration.slot).pipe( + Effect.map((value) => `sb_publishable_${value}`), + ); + case "secret-key": + return randomEncoded(crypto, 24, declaration.slot).pipe( + Effect.map((value) => `sb_secret_${value}`), + ); + case "jwt-secret": + return randomEncoded(crypto, 32, declaration.slot); + case "jwt-token": + return generateJwtToken(generator, resolved[AUTH_JWT_SECRET_SLOT]?.value); + } +}; + /** * Resolves managed and pass-through secret declarations against dedicated persisted values. * Managed omission reuses/generates exactly once; pass-through declarations are complete and @@ -63,8 +341,8 @@ export const resolveSecrets = ( lifecycle: DesiredStackLifecycle, ): Effect.Effect< ResolvedSecrets, - StackSecretMismatchError | StackMustBeStoppedError, - Crypto.Crypto + StackSecretMismatchError | StackMustBeStoppedError | InvalidJwtSigningMaterialError, + Crypto.Crypto | FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -80,6 +358,7 @@ export const resolveSecrets = ( } const resolved: Record = {}; + const deferred: Array<[string, SecretDeclaration]> = []; for (const [slot, declaration] of declarations) { const old = previous[slot]; if (old !== undefined && old.policy !== declaration.policy) { @@ -96,7 +375,11 @@ export const resolveSecrets = ( ); resolved[slot] = old; } else { - const value = supplied ?? (yield* generatedSecret(crypto)); + if (supplied === undefined && declaration.generator?.kind === "jwt-token") { + deferred.push([slot, declaration]); + continue; + } + const value = supplied ?? (yield* generatedFor(declaration, crypto, resolved)); resolved[slot] = { policy: "managed", value }; } } else { @@ -112,6 +395,11 @@ export const resolveSecrets = ( } } + for (const [slot, declaration] of deferred) { + const value = yield* generatedFor(declaration, crypto, resolved); + resolved[slot] = { policy: "managed", value }; + } + for (const [slot, old] of Object.entries(previous)) { if (old.policy === "managed") { resolved[slot] ??= old; diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index eea17e7364..dda54238ed 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -1,7 +1,12 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Redacted } from "effect"; -import { StackMustBeStoppedError, StackSecretMismatchError } from "../public/Errors.ts"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted } from "effect"; +import { createHmac, generateKeyPairSync, createVerify } from "node:crypto"; +import { + InvalidJwtSigningMaterialError, + StackMustBeStoppedError, + StackSecretMismatchError, +} from "../public/Errors.ts"; import { compileStack } from "../model/Compiler.ts"; import { redactKnownSecrets, resolveSecrets, type SecretCandidate } from "./SecretStore.ts"; @@ -44,10 +49,232 @@ describe("managed and pass-through secrets", () => { "secret:database.internal.password", "secret:auth.settings.publishable_key", "secret:auth.settings.secret_key", + "secret:auth.settings.jwt_secret", + "secret:auth.settings.anon_key", + "secret:auth.settings.service_role_key", ]) { expect(first.persisted[slot]?.policy).toBe("managed"); expect(first.persisted[slot]?.value).toBe(second.persisted[slot]?.value); } + expect(first.persisted["secret:auth.settings.publishable_key"]?.value).toMatch( + /^sb_publishable_[A-Za-z0-9_-]{32,}$/, + ); + expect(first.persisted["secret:auth.settings.secret_key"]?.value).toMatch( + /^sb_secret_[A-Za-z0-9_-]{32,}$/, + ); + const jwtSecret = first.persisted["secret:auth.settings.jwt_secret"]?.value; + expect(jwtSecret).toMatch(/^[A-Za-z0-9_-]{43,}$/); + for (const slot of [ + "secret:auth.settings.anon_key", + "secret:auth.settings.service_role_key", + ]) { + const token = first.persisted[slot]?.value; + expect(token?.split(".")).toHaveLength(3); + const payloadText = Buffer.from(token!.split(".")[1]!, "base64url").toString(); + expect(payloadText).toMatch( + /^\{"iss":"supabase-demo","role":"(?:anon|service_role)","exp":1983812996\}$/, + ); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + const payload = JSON.parse(payloadText); + expect(payload).toMatchObject({ iss: "supabase-demo" }); + expect(["anon", "service_role"]).toContain(payload.role); + expect(payload.exp).toBeGreaterThan(1_900_000_000); + } + }).pipe(Effect.provide(layer)), + ); + + it.live("rejects a configured JWKS file without a private signing key", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: "/tmp/project", + runtime: { kind: "native" }, + config: { security: { jwt: { signing: { kind: "jwks-file", path: "public.json" } } } }, + }); + const exit = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ).pipe(Effect.exit); + expect(errorOf(exit)).toBeInstanceOf(InvalidJwtSigningMaterialError); + }).pipe(Effect.provide(layer)), + ); + + it.live("signs generated API keys with the first private ES256 JWK", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-credentials-" }); + const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const privateJwk = { ...privateKey.export({ format: "jwk" }), alg: "ES256", kid: "test-key" }; + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + yield* fs.writeFileString(path.join(root, "keys.json"), JSON.stringify([privateJwk])); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { security: { jwt: { signing: { kind: "jwks-file", path: "keys.json" } } } }, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + const token = resolved.persisted["secret:auth.settings.anon_key"]?.value; + expect(token).toBeDefined(); + const [header, payload, signature] = token!.split("."); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + expect(JSON.parse(Buffer.from(header!, "base64url").toString())).toMatchObject({ + alg: "ES256", + kid: "test-key", + }); + const verifier = createVerify("sha256"); + verifier.update(`${header}.${payload}`); + verifier.end(); + expect( + verifier.verify( + { key: publicKey, dsaEncoding: "ieee-p1363" }, + Buffer.from(signature!, "base64url"), + ), + ).toBe(true); + }).pipe(Effect.provide(layer)), + ); + + it.live("signs generated API keys with a private RS256 JWK", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-credentials-" }); + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const privateJwk = { ...privateKey.export({ format: "jwk" }), alg: "RS256", kid: "rsa-key" }; + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + yield* fs.writeFileString(path.join(root, "keys.json"), JSON.stringify([privateJwk])); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { security: { jwt: { signing: { kind: "jwks-file", path: "keys.json" } } } }, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + const token = resolved.persisted["secret:auth.settings.service_role_key"]?.value; + expect(token).toBeDefined(); + const [header, payload, signature] = token!.split("."); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + expect(JSON.parse(Buffer.from(header!, "base64url").toString())).toMatchObject({ + alg: "RS256", + kid: "rsa-key", + }); + const verifier = createVerify("RSA-SHA256"); + verifier.update(`${header}.${payload}`); + verifier.end(); + expect(verifier.verify(publicKey, Buffer.from(signature!, "base64url"))).toBe(true); + }).pipe(Effect.provide(layer)), + ); + + it.live("fails closed for public-only or escaping JWKS material", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-credentials-" }); + yield* fs.writeFileString( + path.join(root, "public.json"), + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + JSON.stringify([{ kty: "EC", alg: "ES256", crv: "P-256", x: "x", y: "y" }]), + ); + const publicOnly = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + security: { jwt: { signing: { kind: "jwks-file", path: "public.json" } } }, + }, + }); + const publicExit = yield* resolveSecrets( + { declarations: publicOnly.secrets }, + undefined, + "stopped", + ).pipe(Effect.exit); + expect(errorOf(publicExit)).toBeInstanceOf(InvalidJwtSigningMaterialError); + + const escaping = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + security: { jwt: { signing: { kind: "jwks-file", path: "../outside.json" } } }, + }, + }); + const escapingExit = yield* resolveSecrets( + { declarations: escaping.secrets }, + undefined, + "stopped", + ).pipe(Effect.exit); + expect(errorOf(escapingExit)).toBeInstanceOf(InvalidJwtSigningMaterialError); + }).pipe(Effect.provide(layer)), + ); + + it.live("keeps explicit managed credentials unchanged", () => + Effect.gen(function* () { + const configured = { + publishable: "sb_publishable_explicit", + secret: "sb_secret_explicit", + jwt: "a-secure-jwt-secret-value-that-is-long-enough", + anon: "explicit-anon-token", + service: "explicit-service-token", + }; + const compiled = yield* compileStack({ + projectRoot: "/tmp/project", + runtime: { kind: "native" }, + config: { + capabilities: { + auth: { + settings: { + publishable_key: Redacted.make(configured.publishable), + secret_key: Redacted.make(configured.secret), + jwt_secret: Redacted.make(configured.jwt), + anon_key: Redacted.make(configured.anon), + service_role_key: Redacted.make(configured.service), + }, + }, + }, + }, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + expect(resolved.persisted["secret:auth.settings.publishable_key"]?.value).toBe( + configured.publishable, + ); + expect(resolved.persisted["secret:auth.settings.secret_key"]?.value).toBe(configured.secret); + expect(resolved.persisted["secret:auth.settings.jwt_secret"]?.value).toBe(configured.jwt); + expect(resolved.persisted["secret:auth.settings.anon_key"]?.value).toBe(configured.anon); + expect(resolved.persisted["secret:auth.settings.service_role_key"]?.value).toBe( + configured.service, + ); + }).pipe(Effect.provide(layer)), + ); + + it.live("signs generated symmetric API keys with the canonical JWT secret", () => + Effect.gen(function* () { + const jwt = "symmetric-jwt-secret-that-is-long-enough"; + const compiled = yield* compileStack({ + projectRoot: "/tmp/project", + runtime: { kind: "native" }, + config: { + capabilities: { auth: { settings: { jwt_secret: Redacted.make(jwt) } } }, + }, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + const token = resolved.persisted["secret:auth.settings.anon_key"]?.value; + expect(token).toBeDefined(); + const [header, payload, signature] = token!.split("."); + const expected = createHmac("sha256", jwt).update(`${header}.${payload}`).digest("base64url"); + expect(signature).toBe(expected); }).pipe(Effect.provide(layer)), ); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 9727cf8bcd..8a3f35f159 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -123,6 +123,7 @@ const declarationsFromCompiled = (compiled: CompiledStack): SecretCandidate => ( slot: entry.slot, policy: entry.policy, ...(entry.value === undefined ? {} : { value: entry.value }), + ...(entry.generator === undefined ? {} : { generator: entry.generator }), })), }); From a8a76aea6fb80c5efe556f5fea7c59aa191275aa Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:30:11 +0200 Subject: [PATCH 036/174] feat(stack): add production runtime preparation --- ...26-08-28-stack-runtime-rewrite-progress.md | 13 ++ .../stack/src/preparation/RuntimeArtifacts.ts | 211 ++++++++++++++++++ .../runtime-artifacts.integration.test.ts | 169 ++++++++++++++ packages/stack/src/runtime/ReadinessProbe.ts | 155 +++++++++++++ packages/stack/src/runtime/RuntimeEnvFile.ts | 158 +++++++++++++ .../readiness-probe.integration.test.ts | 109 +++++++++ .../runtime-env-file.integration.test.ts | 103 +++++++++ 7 files changed, 918 insertions(+) create mode 100644 packages/stack/src/preparation/RuntimeArtifacts.ts create mode 100644 packages/stack/src/preparation/runtime-artifacts.integration.test.ts create mode 100644 packages/stack/src/runtime/ReadinessProbe.ts create mode 100644 packages/stack/src/runtime/RuntimeEnvFile.ts create mode 100644 packages/stack/src/runtime/readiness-probe.integration.test.ts create mode 100644 packages/stack/src/runtime/runtime-env-file.integration.test.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 1191b578d6..a044e7953a 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -743,3 +743,16 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Compiler carries only private generator descriptors; no generated bytes or signing-file reads enter preparation or the durable definition. Secret-store integration covers entropy/prefixes, sticky and explicit values, HS256/ES256 signatures, and invalid/escaping signing files. + +#### Task 13B2b1 — production runtime preparation primitives (2026-08-29) + +- `RuntimeArtifacts` resolves native catalog artifacts and published checksums into one validated + `ArtifactRequest`/`ArtifactStore` operation, while container preparation probes and inspects the + persisted Docker/Podman engine and pulls only the exact missing image (never create/start). +- `RuntimeEnvFile` owns generation/workload container env files below the stack runtime directory, + using atomic owner-only writes, deterministic encoded workload names, validation before writing, + and exact idempotent generation/all cleanup. Native workloads continue to use fd4 env transport. +- `ReadinessProbe` provides cancellation-safe HTTP/TCP probes with bounded Effect Schedule retries; + requests and sockets are destroyed on interruption and failures are typed without carrying secret + material. Focused integration evidence covers native/container artifact outcomes, env permissions, + validation/cleanup, readiness success/failure, and interruption. diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts new file mode 100644 index 0000000000..1c9e09d8da --- /dev/null +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -0,0 +1,211 @@ +import { Crypto, Effect, FileSystem, Path, Scope } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { + resolveNativeArtifactForWorkload, + type NativeWorkloadArtifact, +} from "../model/WorkloadCatalog.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import { StackPreparationError } from "../public/Errors.ts"; +import { + makeArtifactStore, + type ArtifactSource, + type ArtifactStore, + type ArtifactStoreError, + type PreparedArtifact, +} from "./ArtifactStore.ts"; +import { makeSlimServicesSource, slimServicesChecksum } from "./SlimServicesSource.ts"; +import { + makeProcessCommandRunner, + type ContainerEngine, + type ContainerPlatform, +} from "../runtime/ContainerEngine.ts"; +import { makeDockerEngine } from "../runtime/DockerEngine.ts"; +import { makePodmanEngine } from "../runtime/PodmanEngine.ts"; + +/** Result of preparing one workload's immutable runtime artifact. */ +export interface PreparedWorkloadArtifact { + readonly workloadId: string; + readonly capability: PlannedWorkload["capability"]; + readonly version: string; + readonly outcome: "cached" | "downloaded" | "present" | "pulled"; + /** Native installation root. Container preparation deliberately has no root. */ + readonly artifactRoot?: string; + readonly executablePath?: string; + readonly image?: string; +} + +export interface RuntimeArtifactPreparer { + readonly prepare: ( + runtime: StackRuntime, + workload: PlannedWorkload, + ) => Effect.Effect; +} + +export type RuntimeArtifactPreparationError = ArtifactStoreError | StackPreparationError; + +export interface RuntimeArtifactPreparerOptions { + readonly native: { + readonly store: ArtifactStore; + /** Injected in tests; production defaults to the published SHA256SUMS boundary. */ + readonly checksum?: ( + artifact: NativeWorkloadArtifact, + ) => Effect.Effect; + readonly platform?: { readonly os: string; readonly arch: string }; + }; + readonly containers?: Readonly<{ docker: ContainerEngine; podman: ContainerEngine }>; +} + +const error = (message: string, fields: Readonly> = {}) => + new StackPreparationError({ message, ...fields }); + +const artifactKey = (artifact: NativeWorkloadArtifact): string => + `slim-services/${artifact.service}/${artifact.version}/${artifact.target}`; + +const containerVersion = (image: string): string => { + const separator = image.lastIndexOf(":"); + return separator > image.lastIndexOf("/") ? image.slice(separator + 1) : image; +}; + +/** + * Prepares only immutable native/container artifacts. No containers, processes, + * ports, secrets, or runtime resources are created by this boundary. + */ +export const makeRuntimeArtifactPreparer = ( + options: RuntimeArtifactPreparerOptions, +): RuntimeArtifactPreparer => ({ + prepare: (runtime, workload) => { + if (runtime.kind === "native") { + if (workload.selected.kind !== "native") + return Effect.fail( + error("Native runtime received a container workload artifact", { + workload: workload.id, + }), + ); + return Effect.gen(function* () { + const artifact = yield* resolveNativeArtifactForWorkload(workload, options.native.platform); + const checksum = yield* options.native.checksum === undefined + ? slimServicesChecksum(artifact) + : options.native.checksum(artifact); + const request = { + key: artifactKey(artifact), + sha256: checksum, + requiredRuntimePaths: artifact.requiredRuntimePaths, + executablePath: artifact.executablePath, + }; + const prepared = yield* options.native.store.prepare(request); + return nativeResult(workload, artifact, prepared); + }); + } + if (workload.selected.kind !== "container") + return Effect.fail( + error("Container runtime received a native workload artifact", { + workload: workload.id, + }), + ); + const engine = options.containers?.[runtime.engine]; + if (engine === undefined) + return Effect.fail( + error("Container runtime has no configured container engine", { + workload: workload.id, + engine: runtime.engine, + }), + ); + const image = workload.selected.image; + return engine.probe.pipe( + Effect.andThen(engine.inspectImage(image)), + Effect.flatMap((inspection) => + inspection.present + ? Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: containerVersion(image), + outcome: "present", + image, + }) + : engine.pullImage(image).pipe( + Effect.as({ + workloadId: workload.id, + capability: workload.capability, + version: containerVersion(image), + outcome: "pulled" as const, + image, + }), + ), + ), + Effect.mapError(() => + error("Container image preparation failed", { + workload: workload.id, + engine: runtime.engine, + }), + ), + ); + }, +}); + +const nativeResult = ( + workload: PlannedWorkload, + artifact: NativeWorkloadArtifact, + prepared: PreparedArtifact, +): PreparedWorkloadArtifact => ({ + workloadId: workload.id, + capability: workload.capability, + version: artifact.version, + outcome: prepared.outcome, + artifactRoot: prepared.path, + executablePath: prepared.executablePath, +}); + +/** + * Production constructor. The artifact cache is shared below stateRoot rather + * than inside a stack's data directory; container engines are selected from + * persisted runtime state by prepare(). + */ +export const makeProductionRuntimeArtifactPreparer = (options: { + readonly stateRoot: string; + readonly platform?: ContainerPlatform; +}): Effect.Effect< + RuntimeArtifactPreparer, + RuntimeArtifactPreparationError, + | Path.Path + | FileSystem.FileSystem + | Crypto.Crypto + | Scope.Scope + | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + if (options.stateRoot.trim().length === 0) + return yield* error("Artifact cache state root must not be blank"); + const path = yield* Path.Path; + const cacheRoot = path.join(path.resolve(options.stateRoot), "artifacts"); + const artifacts = new Map(); + const source: ArtifactSource = makeSlimServicesSource((request) => artifacts.get(request.key)); + const store = yield* makeArtifactStore({ cacheRoot, source }); + const checksum = (artifact: NativeWorkloadArtifact) => { + artifacts.set(artifactKey(artifact), artifact); + return slimServicesChecksum(artifact); + }; + const platform = + options.platform ?? + ({ + os: + process.platform === "darwin" + ? "darwin" + : process.platform === "win32" + ? "windows" + : "linux", + desktop: process.platform === "darwin", + } satisfies ContainerPlatform); + const dockerRunner = yield* makeProcessCommandRunner({ executable: "docker" }).pipe( + Effect.mapError(() => error("Unable to configure Docker artifact engine")), + ); + const podmanRunner = yield* makeProcessCommandRunner({ executable: "podman" }).pipe( + Effect.mapError(() => error("Unable to configure Podman artifact engine")), + ); + const docker = makeDockerEngine({ runner: dockerRunner, platform }); + const podman = makePodmanEngine({ runner: podmanRunner, platform }); + return makeRuntimeArtifactPreparer({ + native: { store, checksum }, + containers: { docker, podman }, + }); + }); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts new file mode 100644 index 0000000000..4ccaa826af --- /dev/null +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { ArtifactRequest, ArtifactStore, PreparedArtifact } from "./ArtifactStore.ts"; +import type { + ContainerEngine, + ContainerNetworkSpec, + ContainerVolumeSpec, + ContainerContainerSpec, +} from "../runtime/ContainerEngine.ts"; +import { makeRuntimeArtifactPreparer } from "./RuntimeArtifacts.ts"; + +const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload => ({ + id: "database:database", + capability: "database", + dependencies: [], + readiness: { mode: "tcp" }, + restart: { maxAttempts: 1, backoffMs: 0 }, + artifacts: { + native: { kind: "native", service: "postgres", release: "17.6.1.165" }, + container: { kind: "container", service: "postgres", image: "supabase/postgres:17.6.1.165" }, + }, + selected, + specHash: "hash", +}); + +const prepared = (request: ArtifactRequest): PreparedArtifact => ({ + key: request.key, + path: "/tmp/prepared-database", + sha256: request.sha256, + requiredRuntimePaths: request.requiredRuntimePaths, + executablePath: request.executablePath, + outcome: "downloaded", +}); + +const containerEngine = (present: boolean, calls: string[]): ContainerEngine => ({ + kind: "docker", + executable: "docker", + preflight: Effect.succeed({ host: "host.docker.internal" }), + probe: Effect.sync(() => { + calls.push("probe"); + }), + inspectImage: (image) => + Effect.sync(() => { + calls.push(`inspect:${image}`); + return { present }; + }), + pullImage: (image) => + Effect.sync(() => { + calls.push(`pull:${image}`); + }), + listResources: () => Effect.succeed([]), + createNetwork: (_spec: ContainerNetworkSpec) => Effect.die("unused"), + removeNetwork: () => Effect.void, + createVolume: (_spec: ContainerVolumeSpec) => Effect.die("unused"), + removeVolume: () => Effect.void, + createContainer: (_spec: ContainerContainerSpec) => Effect.die("unused"), + startContainer: () => Effect.void, + stopContainer: () => Effect.void, + removeContainer: () => Effect.void, +}); + +describe("runtime artifact preparation", () => { + it("resolves native catalog metadata, checksum, and one ArtifactRequest", () => { + const requests: ArtifactRequest[] = []; + const store: ArtifactStore = { + prepare: (request) => + Effect.sync(() => { + requests.push(request); + return prepared(request); + }), + }; + const runtime = makeRuntimeArtifactPreparer({ + native: { + store, + checksum: () => Effect.succeed("a".repeat(64)), + platform: { os: "darwin", arch: "arm64" }, + }, + }); + const result = Effect.runSync( + runtime.prepare( + { kind: "native" }, + nativeWorkload({ kind: "native", service: "postgres", release: "17.6.1.165" }), + ), + ); + expect(result.outcome).toBe("downloaded"); + expect(result.version).toBe("17.6.1.165"); + expect(requests).toHaveLength(1); + expect(requests[0]?.key).toContain("slim-services/postgres/17.6.1.165/darwin-arm64"); + expect(requests[0]?.sha256).toBe("a".repeat(64)); + }); + + it("probes and pulls an absent container image without creating or starting a container", () => { + const calls: string[] = []; + const engine = containerEngine(false, calls); + const runtime = makeRuntimeArtifactPreparer({ + native: { store: { prepare: () => Effect.die("unused") } }, + containers: { docker: engine, podman: engine }, + }); + const result = Effect.runSync( + runtime.prepare( + { kind: "container", engine: "docker" }, + nativeWorkload({ + kind: "container", + service: "postgres", + image: "supabase/postgres:17.6.1.165", + }), + ), + ); + expect(result.outcome).toBe("pulled"); + expect(calls).toEqual([ + "probe", + "inspect:supabase/postgres:17.6.1.165", + "pull:supabase/postgres:17.6.1.165", + ]); + }); + + it("uses the persisted container engine identity", () => { + const dockerCalls: string[] = []; + const podmanCalls: string[] = []; + const runtime = makeRuntimeArtifactPreparer({ + native: { store: { prepare: () => Effect.die("unused") } }, + containers: { + docker: containerEngine(true, dockerCalls), + podman: containerEngine(true, podmanCalls), + }, + }); + Effect.runSync( + runtime.prepare( + { kind: "container", engine: "podman" }, + nativeWorkload({ + kind: "container", + service: "postgres", + image: "supabase/postgres:17.6.1.165", + }), + ), + ); + expect(podmanCalls).toContain("probe"); + expect(dockerCalls).toEqual([]); + }); + + it("fails strict runtime/artifact mismatches before touching a store or engine", () => { + let called = false; + const runtime = makeRuntimeArtifactPreparer({ + native: { + store: { + prepare: () => + Effect.sync(() => { + called = true; + return prepared({ key: "x", sha256: "a".repeat(64), requiredRuntimePaths: [] }); + }), + }, + checksum: () => Effect.succeed("a".repeat(64)), + }, + }); + const exit = Effect.runSyncExit( + runtime.prepare( + { kind: "native" }, + nativeWorkload({ + kind: "container", + service: "postgres", + image: "supabase/postgres:17.6.1.165", + }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(called).toBe(false); + }); +}); diff --git a/packages/stack/src/runtime/ReadinessProbe.ts b/packages/stack/src/runtime/ReadinessProbe.ts new file mode 100644 index 0000000000..a0fd34c8cf --- /dev/null +++ b/packages/stack/src/runtime/ReadinessProbe.ts @@ -0,0 +1,155 @@ +import { Duration, Effect, Schedule } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Node callback boundary owns cancellation. +import { request as httpRequest, type IncomingMessage } from "node:http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Node callback boundary owns cancellation. +import { createConnection, type Socket } from "node:net"; +import { RuntimeDriverError } from "./RuntimeDriver.ts"; +import { StackPreparationError } from "../public/Errors.ts"; + +export interface ReadinessTarget { + readonly mode: "http" | "tcp"; + readonly host: string; + readonly port: number; + readonly path?: string; +} + +export interface ReadinessProbeOptions { + /** Number of retries after the initial attempt. */ + readonly retries?: number; + /** Delay between attempts; defaults to a short spaced schedule. */ + readonly retryDelay?: Duration.Input; +} + +const preparationError = (message: string): StackPreparationError => + new StackPreparationError({ message }); + +const runtimeError = ( + target: ReadinessTarget, + message: string, + cause?: unknown, +): RuntimeDriverError => + new RuntimeDriverError({ + message, + cause, + }); + +const validateTarget = (target: ReadinessTarget): Effect.Effect => { + if (target.host.length === 0 || target.host.includes("\u0000") || /[\r\n]/u.test(target.host)) + return Effect.fail(preparationError("Readiness host is invalid")); + if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65_535) + return Effect.fail(preparationError("Readiness port is invalid")); + if (target.mode === "http" && target.path !== undefined && !target.path.startsWith("/")) + return Effect.fail(preparationError("Readiness HTTP path must begin with '/'")); + return Effect.void; +}; + +const httpAttempt = (target: ReadinessTarget): Effect.Effect => + Effect.callback((resume) => { + let settled = false; + let request: ReturnType | undefined; + let response: IncomingMessage | undefined; + const cleanup = () => { + request?.off("error", onError); + if (response !== undefined) { + response.off("error", onError); + response.off("aborted", onAborted); + response.off("end", onEnd); + } + }; + const finish = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + cleanup(); + resume(effect); + }; + const onError = (cause: Error) => + finish(Effect.fail(runtimeError(target, "Readiness HTTP request failed", cause))); + const onAborted = () => + finish(Effect.fail(runtimeError(target, "Readiness HTTP response aborted"))); + const onEnd = () => { + if (response === undefined) return; + if ((response.statusCode ?? 500) >= 200 && (response.statusCode ?? 500) < 300) + finish(Effect.void); + else finish(Effect.fail(runtimeError(target, "Readiness HTTP status was not successful"))); + }; + request = httpRequest( + { + host: target.host, + port: target.port, + path: target.path ?? "/", + method: "GET", + }, + (incoming) => { + response = incoming; + incoming.once("error", onError); + incoming.once("aborted", onAborted); + incoming.once("end", onEnd); + incoming.resume(); + }, + ); + request.once("error", onError); + request.end(); + return Effect.sync(() => { + if (settled) return; + settled = true; + cleanup(); + response?.once("error", () => undefined); + response?.destroy(); + request?.once("error", () => undefined); + request?.destroy(); + }); + }); + +const tcpAttempt = (target: ReadinessTarget): Effect.Effect => + Effect.callback((resume) => { + let settled = false; + let socket: Socket | undefined; + const cleanup = () => { + socket?.off("connect", onConnect); + socket?.off("error", onError); + socket?.off("close", onClose); + }; + const finish = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + cleanup(); + socket?.destroy(); + resume(effect); + }; + const onConnect = () => finish(Effect.void); + const onError = (cause: Error) => + finish(Effect.fail(runtimeError(target, "Readiness TCP connection failed", cause))); + const onClose = () => { + if (!settled) finish(Effect.fail(runtimeError(target, "Readiness TCP connection closed"))); + }; + socket = createConnection({ host: target.host, port: target.port }); + socket.once("connect", onConnect); + socket.once("error", onError); + socket.once("close", onClose); + return Effect.sync(() => { + if (settled) return; + settled = true; + cleanup(); + socket?.destroy(); + }); + }); + +/** + * Probes one exact private endpoint. Retry is expressed through Effect's + * Schedule so cancellation interrupts the owned request/socket immediately. + */ +export const probeReadiness = ( + target: ReadinessTarget, + options: ReadinessProbeOptions = {}, +): Effect.Effect => + Effect.gen(function* () { + yield* validateTarget(target); + const retries = options.retries ?? 3; + if (!Number.isSafeInteger(retries) || retries < 0) + return yield* preparationError("Readiness retry count is invalid"); + const attempt = target.mode === "http" ? httpAttempt(target) : tcpAttempt(target); + const schedule = Schedule.spaced(options.retryDelay ?? "100 millis").pipe( + Schedule.upTo({ times: retries }), + ); + yield* Effect.retry(attempt, schedule); + }); diff --git a/packages/stack/src/runtime/RuntimeEnvFile.ts b/packages/stack/src/runtime/RuntimeEnvFile.ts new file mode 100644 index 0000000000..33f378ba20 --- /dev/null +++ b/packages/stack/src/runtime/RuntimeEnvFile.ts @@ -0,0 +1,158 @@ +import { Crypto, Effect, FileSystem, Path, PlatformError } from "effect"; +import { StackPreparationError } from "../public/Errors.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; +import type { StackId } from "../public/StackId.ts"; + +export interface RuntimeEnvFileOwner { + /** Writes one generation/workload file and returns its exact owned path. */ + readonly write: (input: { + readonly generation: number; + readonly workloadId: string; + readonly values: Readonly>; + }) => Effect.Effect; + /** Removes one exact generation's files; safe when already absent. */ + readonly cleanupGeneration: (generation: number) => Effect.Effect; + /** Removes only this owner's env-file directory; safe when already absent. */ + readonly cleanupAll: Effect.Effect; +} + +export interface RuntimeEnvFileOwnerOptions { + readonly stateRoot: string; + readonly stackId: StackId; +} + +const error = (message: string, fields: Readonly> = {}) => + new StackPreparationError({ message, ...fields }); + +const validGeneration = (generation: number): boolean => + Number.isSafeInteger(generation) && generation >= 0; + +const validWorkloadId = (workloadId: string): boolean => + /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/u.test(workloadId); + +const validName = (name: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/u.test(name); + +const encodeWorkloadId = (workloadId: string): string => encodeURIComponent(workloadId); + +const mapFile = ( + path: string, + operation: string, + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.mapError(() => + error(`Unable to ${operation}`, { + path, + }), + ), + ); + +const contentFor = ( + values: Readonly>, +): Effect.Effect => { + const entries = Object.entries(values).sort(([left], [right]) => left.localeCompare(right)); + for (const [name, value] of entries) { + if (!validName(name) || name.includes("\u0000") || /[\r\n]/u.test(name)) + return Effect.fail(error("Invalid runtime environment variable name")); + if (value.includes("\u0000") || /[\r\n]/u.test(value)) + return Effect.fail(error("Invalid runtime environment variable value", { name })); + } + return Effect.succeed(entries.map(([name, value]) => `${name}=${value}\n`).join("")); +}; + +/** + * Owns container env files under `/runtime/env`. Native workloads use + * their fd4 environment and never pass through this owner. + */ +export const makeRuntimeEnvFileOwner = ( + options: RuntimeEnvFileOwnerOptions, +): Effect.Effect< + RuntimeEnvFileOwner, + StackPreparationError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const paths = yield* resolveStackPaths(options).pipe( + Effect.mapError((cause) => error("Unable to resolve runtime environment path", { cause })), + ); + const envRoot = path.join(paths.runtime, "env"); + + const write = (input: { + readonly generation: number; + readonly workloadId: string; + readonly values: Readonly>; + }): Effect.Effect => { + if (!validGeneration(input.generation)) + return Effect.fail(error("Invalid runtime environment generation")); + if (!validWorkloadId(input.workloadId)) + return Effect.fail(error("Invalid runtime environment workload identity")); + return Effect.gen(function* () { + const text = yield* contentFor(input.values); + const generationRoot = path.join(envRoot, String(input.generation)); + const target = path.join(generationRoot, `${encodeWorkloadId(input.workloadId)}.env`); + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError(() => error("Unable to allocate runtime environment file name")), + ); + const temporary = `${target}.${token}.tmp`; + return yield* Effect.gen(function* () { + yield* mapFile( + generationRoot, + "create runtime environment directory", + fs.makeDirectory(generationRoot, { recursive: true, mode: 0o700 }), + ); + yield* mapFile( + generationRoot, + "secure runtime environment directory", + fs.chmod(generationRoot, 0o700), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* mapFile( + temporary, + "create runtime environment file", + fs.open(temporary, { flag: "w", mode: 0o600 }), + ); + yield* mapFile( + temporary, + "write runtime environment file", + file.writeAll(new TextEncoder().encode(text)), + ); + yield* mapFile(temporary, "sync runtime environment file", file.sync); + }), + ); + yield* mapFile(temporary, "secure runtime environment file", fs.chmod(temporary, 0o600)); + yield* mapFile(target, "publish runtime environment file", fs.rename(temporary, target)); + yield* mapFile( + target, + "secure published runtime environment file", + fs.chmod(target, 0o600), + ); + return target; + }).pipe( + Effect.ensuring( + fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + ); + }); + }; + + const cleanupGeneration = (generation: number): Effect.Effect => + !validGeneration(generation) + ? Effect.fail(error("Invalid runtime environment generation")) + : mapFile( + path.join(envRoot, String(generation)), + "clean runtime environment generation", + fs.remove(path.join(envRoot, String(generation)), { recursive: true, force: true }), + ); + const cleanupAll = mapFile( + envRoot, + "clean runtime environment files", + fs.remove(envRoot, { recursive: true, force: true }), + ); + return { write, cleanupGeneration, cleanupAll }; + }); diff --git a/packages/stack/src/runtime/readiness-probe.integration.test.ts b/packages/stack/src/runtime/readiness-probe.integration.test.ts new file mode 100644 index 0000000000..f26b16064e --- /dev/null +++ b/packages/stack/src/runtime/readiness-probe.integration.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Data, Deferred, Effect, Exit, Fiber } from "effect"; +/* oxlint-disable effecttsgo/node-builtin-import -- integration test owns real listeners. */ +import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; +import { createServer as createTcpServer, type Server as TcpServer } from "node:net"; +import { probeReadiness } from "./ReadinessProbe.ts"; + +class ListenerError extends Data.TaggedError("ListenerError")<{ readonly message: string }> {} + +const listenHttp = ( + server: HttpServer, + handler: () => void, +): Effect.Effect => + Effect.callback((resume) => { + const onError = (cause: Error) => + resume(Effect.fail(new ListenerError({ message: cause.message }))); + server.once("error", onError); + server.once("request", handler); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (typeof address !== "object" || address === null) + return resume(Effect.fail(new ListenerError({ message: "missing HTTP address" }))); + resume(Effect.succeed(address.port)); + }); + return Effect.sync(() => { + server.off("error", onError); + server.close(); + }); + }); + +const listenTcp = (server: TcpServer): Effect.Effect => + Effect.callback((resume) => { + const onError = (cause: Error) => + resume(Effect.fail(new ListenerError({ message: cause.message }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (typeof address !== "object" || address === null) + return resume(Effect.fail(new ListenerError({ message: "missing TCP address" }))); + resume(Effect.succeed(address.port)); + }); + return Effect.sync(() => { + server.off("error", onError); + server.close(); + }); + }); + +describe("private endpoint readiness probe", () => { + it.live("probes HTTP and TCP endpoints with a Schedule retry policy", () => + Effect.scoped( + Effect.gen(function* () { + const http = createHttpServer((_request, response) => { + response.statusCode = 200; + response.end("ok"); + }); + const httpPort = yield* listenHttp(http, () => undefined); + yield* probeReadiness( + { mode: "http", host: "127.0.0.1", port: httpPort, path: "/health" }, + { retries: 1, retryDelay: 0 }, + ); + + const tcp = createTcpServer((socket) => socket.end()); + const tcpPort = yield* listenTcp(tcp); + yield* probeReadiness( + { mode: "tcp", host: "127.0.0.1", port: tcpPort }, + { retries: 1, retryDelay: 0 }, + ); + }), + ), + ); + + it.live("reports a failed endpoint after bounded retries", () => + Effect.scoped( + Effect.gen(function* () { + const server = createHttpServer((_request, response) => { + response.statusCode = 503; + response.end("not ready"); + }); + const port = yield* listenHttp(server, () => undefined); + const result = yield* probeReadiness( + { mode: "http", host: "127.0.0.1", port }, + { retries: 1, retryDelay: 0 }, + ).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + }), + ), + ); + + it.live("interrupts an in-flight HTTP request and closes its owned socket", () => + Effect.scoped( + Effect.gen(function* () { + const received = yield* Deferred.make(); + const server = createHttpServer((request) => { + // oxlint-disable-next-line effecttsgo/run-effect-inside-effect -- bridge event callback to test signal. + Effect.runSync(Deferred.succeed(received, undefined)); + request.once("error", () => undefined); + }); + const port = yield* listenHttp(server, () => undefined); + const fiber = yield* Effect.forkChild( + probeReadiness({ mode: "http", host: "127.0.0.1", port, path: "/hang" }, { retries: 0 }), + ); + yield* Deferred.await(received); + yield* Fiber.interrupt(fiber); + }), + ), + ); +}); diff --git a/packages/stack/src/runtime/runtime-env-file.integration.test.ts b/packages/stack/src/runtime/runtime-env-file.integration.test.ts new file mode 100644 index 0000000000..0c26a8b273 --- /dev/null +++ b/packages/stack/src/runtime/runtime-env-file.integration.test.ts @@ -0,0 +1,103 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Path } from "effect"; +import { StackIdSchema } from "../public/StackId.ts"; +import { makeRuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; + +const stackId = StackIdSchema.make("e".repeat(64)); + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +describe("runtime environment file owner", () => { + it.live("writes deterministic owner-only generation files atomically", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-env-" }); + const owner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); + const first = yield* owner.write({ + generation: 4, + workloadId: "database:database", + values: { Z_LAST: "two", A_FIRST: "one" }, + }); + const second = yield* owner.write({ + generation: 4, + workloadId: "database:database", + values: { A_FIRST: "updated" }, + }); + expect(second).toBe(first); + expect(yield* fs.readFileString(first)).toBe("A_FIRST=updated\n"); + expect((yield* fs.stat(first)).mode & 0o777).toBe(0o600); + expect((yield* fs.stat(path.dirname(first))).mode & 0o777).toBe(0o700); + }), + ), + ); + + it.live("rejects unsafe names and values without revealing the value", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-env-invalid-" }); + const owner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); + const secret = "very-secret-value"; + const invalid = yield* owner + .write({ + generation: 1, + workloadId: "database:database", + values: { VALID: `${secret}\nINJECTED` }, + }) + .pipe(Effect.exit); + expect(Exit.isFailure(invalid)).toBe(true); + if (Exit.isFailure(invalid)) expect(String(invalid.cause)).not.toContain(secret); + expect(yield* fs.exists(`${root}/${stackId}/runtime/env`)).toBe(false); + const invalidWorkload = yield* owner + .write({ generation: 1, workloadId: "../escape", values: { SAFE: "ok" } }) + .pipe(Effect.exit); + expect(Exit.isFailure(invalidWorkload)).toBe(true); + }), + ), + ); + + it.live("cleans exact generations idempotently", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-env-cleanup-" }); + const owner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); + const file = yield* owner.write({ + generation: 2, + workloadId: "rest:rest", + values: { X: "y" }, + }); + yield* owner.cleanupGeneration(2); + expect(yield* fs.exists(file)).toBe(false); + yield* owner.cleanupGeneration(2); + yield* owner.write({ generation: 3, workloadId: "rest:rest", values: { X: "z" } }); + yield* owner.cleanupAll; + expect(yield* fs.exists(`${root}/${stackId}/runtime/env`)).toBe(false); + }), + ), + ); + + it.live("keeps concurrent workload files collision-free", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-env-concurrent-" }); + const owner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); + const files = yield* Effect.all( + [ + owner.write({ generation: 7, workloadId: "rest:rest", values: { A: "one" } }), + owner.write({ generation: 7, workloadId: "auth:auth", values: { B: "two" } }), + ], + { concurrency: "unbounded" }, + ); + expect(new Set(files).size).toBe(2); + expect(yield* fs.readFileString(files[0])).toBe("A=one\n"); + expect(yield* fs.readFileString(files[1])).toBe("B=two\n"); + }), + ), + ); +}); From f5a2ab7c5da0c2d94515261481d92666ac50c32c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:34:38 +0200 Subject: [PATCH 037/174] fix(stack): validate readiness request paths --- packages/stack/src/runtime/ReadinessProbe.ts | 16 +++++++++++--- .../readiness-probe.integration.test.ts | 21 ++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/stack/src/runtime/ReadinessProbe.ts b/packages/stack/src/runtime/ReadinessProbe.ts index a0fd34c8cf..5f45622050 100644 --- a/packages/stack/src/runtime/ReadinessProbe.ts +++ b/packages/stack/src/runtime/ReadinessProbe.ts @@ -33,13 +33,23 @@ const runtimeError = ( cause, }); +const containsControl = (value: string): boolean => + [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + }); + const validateTarget = (target: ReadinessTarget): Effect.Effect => { - if (target.host.length === 0 || target.host.includes("\u0000") || /[\r\n]/u.test(target.host)) + if (target.host.length === 0 || containsControl(target.host)) return Effect.fail(preparationError("Readiness host is invalid")); if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65_535) return Effect.fail(preparationError("Readiness port is invalid")); - if (target.mode === "http" && target.path !== undefined && !target.path.startsWith("/")) - return Effect.fail(preparationError("Readiness HTTP path must begin with '/'")); + if (target.mode === "http" && target.path !== undefined) { + if (!target.path.startsWith("/")) + return Effect.fail(preparationError("Readiness HTTP path must begin with '/'")); + if (containsControl(target.path)) + return Effect.fail(preparationError("Readiness HTTP path contains control characters")); + } return Effect.void; }; diff --git a/packages/stack/src/runtime/readiness-probe.integration.test.ts b/packages/stack/src/runtime/readiness-probe.integration.test.ts index f26b16064e..48be7ed91f 100644 --- a/packages/stack/src/runtime/readiness-probe.integration.test.ts +++ b/packages/stack/src/runtime/readiness-probe.integration.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Deferred, Effect, Exit, Fiber } from "effect"; +import { Cause, Data, Deferred, Effect, Exit, Fiber, Option } from "effect"; /* oxlint-disable effecttsgo/node-builtin-import -- integration test owns real listeners. */ import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; import { createServer as createTcpServer, type Server as TcpServer } from "node:net"; +import { StackPreparationError } from "../public/Errors.ts"; import { probeReadiness } from "./ReadinessProbe.ts"; class ListenerError extends Data.TaggedError("ListenerError")<{ readonly message: string }> {} @@ -48,6 +49,24 @@ const listenTcp = (server: TcpServer): Effect.Effect => }); describe("private endpoint readiness probe", () => { + it.live("rejects control characters in HTTP paths as typed preparation failures", () => + Effect.gen(function* () { + for (const path of ["/ready\nX", "/ready\rX", "/ready\u0000X"]) { + const result = yield* probeReadiness({ + mode: "http", + host: "127.0.0.1", + port: 1, + path, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const cause = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(cause).toBeInstanceOf(StackPreparationError); + } + } + }), + ); + it.live("probes HTTP and TCP endpoints with a Schedule retry policy", () => Effect.scoped( Effect.gen(function* () { From cf6d68b7a91d94ccf0ae05610a09624853f02d3e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:41:52 +0200 Subject: [PATCH 038/174] feat(stack): own the edge runtime functions bootstrap --- .../shared/functions/serve-main-bundler.ts | 44 +- .../src/shared/functions/serve-main-deps.ts | 102 +--- apps/cli/src/shared/functions/serve.main.ts | 454 ------------------ packages/stack/package.json | 8 +- .../stack/src/functions/FunctionsBootstrap.ts | 116 +++++ .../functions-bootstrap.integration.test.ts | 26 + .../stack/src/functions/serve-main-bundler.ts | 22 + .../functions/serve-main-bundler.unit.test.ts | 12 + .../stack/src/functions/serve-main-deps.ts | 52 ++ packages/stack/src/functions/serve.main.ts | 340 +++++++++++++ .../runtime-artifacts.integration.test.ts | 1 + packages/stack/src/runtime/ContainerEngine.ts | 14 + .../stack/src/runtime/ContainerRuntime.ts | 25 + packages/stack/src/runtime/DockerEngine.ts | 2 + packages/stack/src/runtime/PodmanEngine.ts | 2 + .../stack/src/runtime/WorkloadRuntimeSpec.ts | 78 ++- .../container-runtime.integration.test.ts | 79 +++ .../runtime-recovery.integration.test.ts | 1 + .../workload-runtime.integration.test.ts | 19 +- pnpm-lock.yaml | 6 + 20 files changed, 802 insertions(+), 601 deletions(-) delete mode 100644 apps/cli/src/shared/functions/serve.main.ts create mode 100644 packages/stack/src/functions/FunctionsBootstrap.ts create mode 100644 packages/stack/src/functions/functions-bootstrap.integration.test.ts create mode 100644 packages/stack/src/functions/serve-main-bundler.ts create mode 100644 packages/stack/src/functions/serve-main-bundler.unit.test.ts create mode 100644 packages/stack/src/functions/serve-main-deps.ts create mode 100644 packages/stack/src/functions/serve.main.ts diff --git a/apps/cli/src/shared/functions/serve-main-bundler.ts b/apps/cli/src/shared/functions/serve-main-bundler.ts index e6960ff28c..13a76623cd 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.ts @@ -1,40 +1,4 @@ -import { fileURLToPath } from "node:url"; - -import { build } from "esbuild"; - -/** - * Absolute path to the edge-runtime bootstrap template. The template runs verbatim - * inside the edge-runtime (Deno) container as `/root/index.ts`. - */ -const serveMainEntrypoint = fileURLToPath(new URL("./serve.main.ts", import.meta.url)); - -/** - * Bundle `serve.main.ts` into a single self-contained ES module string with all of - * its dependencies inlined. - * - * The template used to import `deno.land/std` and `jsr:` modules that Deno resolved - * over the network on every container start, breaking `functions serve` offline - * (supabase/supabase#45570). Bundling inlines `jose` and the local `serve-main-deps` - * helpers so the runtime entrypoint needs no network access. - * - * `platform: "browser"` selects `jose`'s Web Crypto build, which runs under the - * edge-runtime's Deno. `Deno` and `EdgeRuntime` are left as free globals. - */ -export async function bundleServeMainTemplate(): Promise { - const result = await build({ - entryPoints: [serveMainEntrypoint], - bundle: true, - format: "esm", - platform: "browser", - minify: true, - write: false, - legalComments: "none", - logLevel: "silent", - }); - - const output = result.outputFiles[0]?.text; - if (output === undefined) { - throw new Error("esbuild produced no output for the functions serve runtime template"); - } - return output; -} +export { + bundleServeMainTemplate, + serveMainEntrypoint, +} from "@supabase/stack/functions/serve-main-bundler"; diff --git a/apps/cli/src/shared/functions/serve-main-deps.ts b/apps/cli/src/shared/functions/serve-main-deps.ts index 6660ec1d42..42ab86a253 100644 --- a/apps/cli/src/shared/functions/serve-main-deps.ts +++ b/apps/cli/src/shared/functions/serve-main-deps.ts @@ -1,95 +1,7 @@ -/** - * Runtime dependencies inlined into the edge-runtime bootstrap template - * (`serve.main.ts`). These replace the remote `deno.land/std` imports the template - * used to resolve over the network on every container start, which broke - * `functions serve` offline (supabase/supabase#45570). - * - * Kept as a normal, type-checked module so the path logic can be unit-tested; the - * template imports it relatively and the build inlines it via the bundler. - */ - -/** HTTP status codes used by the runtime template (subset of `deno.land/std/http/status.ts`). */ -export const STATUS_CODE = { - OK: 200, - Unauthorized: 401, - NotFound: 404, - InternalServerError: 500, - ServiceUnavailable: 503, -} as const; - -/** Canonical reason phrases for the status codes the template renders. */ -export const STATUS_TEXT: Record = { - [STATUS_CODE.OK]: "OK", - [STATUS_CODE.Unauthorized]: "Unauthorized", - [STATUS_CODE.NotFound]: "Not Found", - [STATUS_CODE.InternalServerError]: "Internal Server Error", - [STATUS_CODE.ServiceUnavailable]: "Service Unavailable", -}; - -/** Posix path normalization: collapse separators, resolve `.` and `..`. */ -function normalize(path: string): string { - const isAbsolute = path.startsWith("/"); - const out: string[] = []; - for (const segment of path.split("/")) { - if (segment === "" || segment === ".") { - continue; - } - if (segment === "..") { - if (out.length > 0 && out[out.length - 1] !== "..") { - out.pop(); - } else if (!isAbsolute) { - out.push(".."); - } - continue; - } - out.push(segment); - } - const joined = out.join("/"); - if (isAbsolute) { - return "/" + joined; - } - return joined === "" ? "." : joined; -} - -/** Posix `join`: concatenate segments with a single separator and normalize. */ -export function join(...paths: string[]): string { - const joined = paths.filter((part) => part.length > 0).join("/"); - return joined === "" ? "." : normalize(joined); -} - -/** Posix `dirname`: the directory portion of a path. */ -export function dirname(path: string): string { - if (path.length === 0) { - return "."; - } - let end = path.length; - while (end > 1 && path[end - 1] === "/") { - end -= 1; - } - const stripped = path.slice(0, end); - const lastSlash = stripped.lastIndexOf("/"); - if (lastSlash === -1) { - return "."; - } - if (lastSlash === 0) { - return "/"; - } - return stripped.slice(0, lastSlash); -} - -function encodeWhitespace(value: string): string { - return value.replace( - /\s/g, - (char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase()}`, - ); -} - -/** Posix `toFileUrl`: convert an absolute path to a `file://` URL. */ -export function toFileUrl(path: string): URL { - if (!path.startsWith("/")) { - throw new TypeError(`Path must be absolute: received "${path}"`); - } - const url = new URL("file:///"); - url.pathname = encodeWhitespace(path.replace(/%/g, "%25").replace(/\\/g, "%5C")); - return url; -} +export { + dirname, + join, + STATUS_CODE, + STATUS_TEXT, + toFileUrl, +} from "@supabase/stack/functions/serve-main-deps"; diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts deleted file mode 100644 index 2cf89e2e71..0000000000 --- a/apps/cli/src/shared/functions/serve.main.ts +++ /dev/null @@ -1,454 +0,0 @@ -// @ts-nocheck -declare const Deno: any; -declare const EdgeRuntime: any; - -import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; - -import * as jose from "jose"; - -const SB_SPECIFIC_ERROR_CODE = { - BootError: STATUS_CODE.ServiceUnavailable /** Service Unavailable (RFC 7231, 6.6.4) */, - InvalidWorkerResponse: - STATUS_CODE.InternalServerError /** Internal Server Error (RFC 7231, 6.6.1) */, - WorkerLimit: 546 /** Extended */, -}; - -const SB_SPECIFIC_ERROR_TEXT = { - [SB_SPECIFIC_ERROR_CODE.BootError]: "BOOT_ERROR", - [SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse]: "WORKER_ERROR", - [SB_SPECIFIC_ERROR_CODE.WorkerLimit]: "WORKER_LIMIT", -}; - -const SB_SPECIFIC_ERROR_REASON = { - [SB_SPECIFIC_ERROR_CODE.BootError]: "Worker failed to boot (please check logs)", - [SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse]: - "Function exited due to an error (please check logs)", - [SB_SPECIFIC_ERROR_CODE.WorkerLimit]: - "Worker failed to respond due to a resource limit (please check logs)", -}; - -// OS stuff - we don't want to expose these to the functions. -const EXCLUDED_ENVS = ["HOME", "HOSTNAME", "PATH", "PWD"]; -const HOST_PORT = Deno.env.get("SUPABASE_INTERNAL_HOST_PORT")!; -const JWT_SECRET = Deno.env.get("SUPABASE_INTERNAL_JWT_SECRET")!; -const JWKS_ENDPOINT = new URL("/auth/v1/.well-known/jwks.json", Deno.env.get("SUPABASE_URL")!); -const DEBUG = Deno.env.get("SUPABASE_INTERNAL_DEBUG") === "true"; -const FUNCTIONS_CONFIG_STRING = Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG")!; - -const SUPABASE_PUBLISHABLE_KEY = Deno.env.get("SUPABASE_INTERNAL_PUBLISHABLE_KEY"); -const SUPABASE_SECRET_KEY = Deno.env.get("SUPABASE_INTERNAL_SECRET_KEY"); - -const WALLCLOCK_LIMIT_SEC = parseInt(Deno.env.get("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC")); - -const DENO_SB_ERROR_MAP = new Map([ - [Deno.errors.InvalidWorkerCreation, SB_SPECIFIC_ERROR_CODE.BootError], - [Deno.errors.InvalidWorkerResponse, SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse], - [Deno.errors.WorkerRequestCancelled, SB_SPECIFIC_ERROR_CODE.WorkerLimit], -]); -const GENERIC_FUNCTION_SERVE_MESSAGE = `Serving functions on http://127.0.0.1:${HOST_PORT}/functions/v1/`; -export enum RequestErrors { - MissingAuthHeader = "UNAUTHORIZED_NO_AUTH_HEADER", - InvalidLegacyJWT = "UNAUTHORIZED_LEGACY_JWT", - InvalidAsymmetricJWT = "UNAUTHORIZED_ASYMMETRIC_JWT", - InvalidTokenFormat = "UNAUTHORIZED_INVALID_JWT_FORMAT", - UnsupportedTokenAlgorithm = "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", -} - -interface AuthFailure { - code: RequestErrors; - message?: string; -} - -interface FunctionConfig { - entrypointPath: string; - importMapPath: string; - staticFiles: string[]; - verifyJWT: boolean; - env?: Record; -} - -function getResponse(payload: any, status: number, customHeaders = {}) { - const headers = { ...customHeaders }; - let body: string | null = null; - - if (payload) { - if (typeof payload === "object") { - headers["Content-Type"] = "application/json"; - body = JSON.stringify(payload); - } else if (typeof payload === "string") { - headers["Content-Type"] = "text/plain"; - body = payload; - } else { - body = null; - } - } - - return new Response(body, { status, headers }); -} - -function getAuthErrorResponse({ code, message = "Invalid JWT" }: AuthFailure) { - return getResponse( - { - code, - message, - // DEPRECATED: Retained for backward compatibility. - msg: message, - }, - STATUS_CODE.Unauthorized, - { - "sb-error-code": code, - "Access-Control-Expose-Headers": "sb-error-code", - }, - ); -} - -const functionsConfig: Record = (() => { - try { - const functionsConfig = JSON.parse(FUNCTIONS_CONFIG_STRING); - - if (DEBUG) { - const debugConfig = Object.fromEntries( - Object.entries(functionsConfig).map(([name, config]) => [ - name, - Object.fromEntries(Object.entries(config).filter(([key]) => key !== "env")), - ]), - ); - console.log("Functions config:", JSON.stringify(debugConfig, null, 2)); - } - - return functionsConfig; - } catch (cause) { - throw new Error("Failed to parse functions config", { cause }); - } -})(); - -/* --- JWT verification --- */ -export function extractBearerToken(rawToken: string) { - const tokenParts = rawToken.split(" "); - const [bearer, token] = tokenParts; - if (bearer !== "Bearer" || tokenParts.length !== 2) { - return null; - } - - return token; -} - -function getAuthToken(req: Request): string | AuthFailure { - const authHeader = req.headers.get("authorization"); - const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key"); - - // NOTE:(kallebysantos) Kong on legacy CLI stack pass it down as 'Bearer Token' format - const cleanSbApiKeyCompatibilityToken = sbApiKeyCompatibilityToken?.replace("Bearer", "")?.trim(); - - if (!authHeader && !cleanSbApiKeyCompatibilityToken) { - return { - code: RequestErrors.MissingAuthHeader, - message: "Missing authorization header", - }; - } - - // NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match: - // - API proxy mints a temp token - // - Original bearer is not present or is ApiKey - const bearerToken = extractBearerToken(authHeader ?? ""); - const token = - !bearerToken || bearerToken.startsWith("sb_") ? cleanSbApiKeyCompatibilityToken : bearerToken; - - if (!token) { - return { - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }; - } - - return token; -} - -async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise { - const encoder = new TextEncoder(); - const secretKey = encoder.encode(jwtSecret); - try { - await jose.jwtVerify(jwt, secretKey); - } catch (e) { - console.error("Symmetric Legacy JWT verification error", e); - return { code: RequestErrors.InvalidLegacyJWT }; - } - return null; -} - -// Lazy-loading JWKs -let jwks = (() => { - try { - // using injected JWKS from cli - return jose.createLocalJWKSet(JSON.parse(Deno.env.get("SUPABASE_JWKS"))); - } catch { - return null; - } -})(); - -async function isValidJWT(jwksUrl: URL, jwt: string): Promise { - try { - if (!jwks) { - // Loading from remote-url on fly - jwks = jose.createRemoteJWKSet(new URL(jwksUrl)); - } - await jose.jwtVerify(jwt, jwks); - } catch (e) { - console.error("Asymmetric JWT verification error", e); - return { code: RequestErrors.InvalidAsymmetricJWT }; - } - return null; -} - -/** - * Applies hybrid JWT verification, using JWK as primary and Legacy Secret as fallback. - * Use only during 'New JWT Keys' migration period, while `JWT_SECRET` is still available. - */ -export async function verifyHybridJWT( - jwtSecret: string, - jwksUrl: URL, - jwt: string, -): Promise { - let jwtAlgorithm: string | undefined; - try { - jwtAlgorithm = jose.decodeProtectedHeader(jwt).alg; - } catch (e) { - console.error("JWT format error", e); - return { - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }; - } - - if (!jwtAlgorithm) { - return { - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }; - } - - if (jwtAlgorithm === "HS256") { - console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`); - - return await isValidLegacyJWT(jwtSecret, jwt); - } - - if (jwtAlgorithm === "ES256" || jwtAlgorithm === "RS256") { - return await isValidJWT(jwksUrl, jwt); - } - - return { - code: RequestErrors.UnsupportedTokenAlgorithm, - message: `Unsupported JWT algorithm ${jwtAlgorithm}`, - }; -} - -// Ref: https://docs.deno.com/examples/checking_file_existence/ -async function shouldUsePackageJsonDiscovery({ - entrypointPath, - importMapPath, -}: FunctionConfig): Promise { - if (importMapPath) { - return false; - } - const packageJsonPath = join(dirname(entrypointPath), "package.json"); - try { - await Deno.lstat(packageJsonPath); - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return false; - } - } - return true; -} - -export function prepareUserRequest(req: Request): Request { - const clonedURL = new URL(req.url); - const forwardedHost = req.headers.get("x-forwarded-host"); - clonedURL.hostname = forwardedHost ?? clonedURL.hostname; - const clonedReq = new Request(clonedURL, req.clone()); - - // remove custom api headers - clonedReq.headers.delete("sb-api-key"); - EdgeRuntime.applySupabaseTag(req, clonedReq); - - return clonedReq; -} - -Deno.serve({ - handler: async (req: Request) => { - const url = new URL(req.url); - const { pathname } = url; - - // handle health checks - if (pathname === "/_internal/health") { - return getResponse({ message: "ok" }, STATUS_CODE.OK); - } - - // handle metrics - if (pathname === "/_internal/metric") { - const metric = await EdgeRuntime.getRuntimeMetrics(); - return Response.json(metric); - } - - const pathParts = pathname.split("/"); - const functionName = pathParts[1]; - - if (!functionName || !(functionName in functionsConfig)) { - return getResponse("Function not found", STATUS_CODE.NotFound); - } - - if (req.method !== "OPTIONS" && functionsConfig[functionName].verifyJWT) { - try { - const token = getAuthToken(req); - if (typeof token !== "string") { - return getAuthErrorResponse(token); - } - const authFailure = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token); - if (authFailure) { - return getAuthErrorResponse(authFailure); - } - } catch (e) { - console.error(e); - return getAuthErrorResponse({ - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }); - } - } - - const servicePath = dirname(functionsConfig[functionName].entrypointPath); - console.error(`serving the request with ${servicePath}`); - - // Ref: https://supabase.com/docs/guides/functions/limits - const memoryLimitMb = 256; - const workerTimeoutMs = isFinite(WALLCLOCK_LIMIT_SEC) ? WALLCLOCK_LIMIT_SEC * 1000 : 400 * 1000; - const noModuleCache = false; - const envVarsObj = { - ...Deno.env.toObject(), - ...Object.fromEntries( - Object.entries(functionsConfig[functionName].env ?? {}).filter( - ([name, _]) => !name.startsWith("SUPABASE_"), - ), - ), - }; - if (SUPABASE_PUBLISHABLE_KEY) { - envVarsObj["SUPABASE_PUBLISHABLE_KEYS"] = JSON.stringify({ - default: SUPABASE_PUBLISHABLE_KEY, - }); - } - if (SUPABASE_SECRET_KEY) { - envVarsObj["SUPABASE_SECRET_KEYS"] = JSON.stringify({ - default: SUPABASE_SECRET_KEY, - }); - } - - const envVars = Object.entries(envVarsObj).filter( - ([name, _]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_"), - ); - - const forceCreate = false; - const customModuleRoot = ""; // empty string to allow any local path - const cpuTimeSoftLimitMs = 1000; - const cpuTimeHardLimitMs = 2000; - - // NOTE(Nyannyacha): Decorator type has been set to tc39 by Lakshan's request, - // but in my opinion, we should probably expose this to customers at some - // point, as their migration process will not be easy. - // This need to be kept for Deno 1 compatibility. - const decoratorType = "tc39"; - - const absEntrypoint = join(Deno.cwd(), functionsConfig[functionName].entrypointPath); - const maybeEntrypoint = toFileUrl(absEntrypoint).href; - const usePackageJson = await shouldUsePackageJsonDiscovery(functionsConfig[functionName]); - - const staticPatterns = functionsConfig[functionName].staticFiles; - - try { - const worker = await EdgeRuntime.userWorkers.create({ - servicePath, - memoryLimitMb, - workerTimeoutMs, - noModuleCache, - noNpm: !usePackageJson, - importMapPath: functionsConfig[functionName].importMapPath, - envVars, - forceCreate, - customModuleRoot, - cpuTimeSoftLimitMs, - cpuTimeHardLimitMs, - decoratorType, - maybeEntrypoint, - context: { - useReadSyncFileAPI: true, - }, - staticPatterns, - }); - - const userReq = prepareUserRequest(req); - return await worker.fetch(userReq); - } catch (e) { - console.error(e); - - for (const [denoError, sbCode] of DENO_SB_ERROR_MAP.entries()) { - if (denoError !== void 0 && e instanceof denoError) { - return getResponse( - { - code: SB_SPECIFIC_ERROR_TEXT[sbCode], - message: SB_SPECIFIC_ERROR_REASON[sbCode], - }, - sbCode, - ); - } - } - - return getResponse( - { - code: STATUS_TEXT[STATUS_CODE.InternalServerError], - message: "Request failed due to an internal server error", - trace: JSON.stringify(e.stack), - }, - STATUS_CODE.InternalServerError, - ); - } - }, - - onListen: () => { - try { - const functionsConfigString = Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG"); - if (functionsConfigString) { - const MAX_FUNCTIONS_URL_EXAMPLES = 5; - const functionsConfig = JSON.parse(functionsConfigString) as Record; - const functionNames = Object.keys(functionsConfig); - const exampleFunctions = functionNames.slice(0, MAX_FUNCTIONS_URL_EXAMPLES); - const functionsUrls = exampleFunctions.map( - (fname) => ` - http://127.0.0.1:${HOST_PORT}/functions/v1/${fname}`, - ); - const functionsExamplesMessages = - functionNames.length > 0 - ? `\n${functionsUrls.join(`\n`)}${ - functionNames.length > MAX_FUNCTIONS_URL_EXAMPLES - ? `\n... and ${functionNames.length - MAX_FUNCTIONS_URL_EXAMPLES} more functions` - : "" - }` - : ""; - console.log( - `${GENERIC_FUNCTION_SERVE_MESSAGE}${functionsExamplesMessages}\nUsing ${Deno.version.deno}`, - ); - } - } catch { - console.log(`${GENERIC_FUNCTION_SERVE_MESSAGE}\nUsing ${Deno.version.deno}`); - } - }, - - onError: (e) => { - return getResponse( - { - code: STATUS_TEXT[STATUS_CODE.InternalServerError], - message: "Request failed due to an internal server error", - trace: JSON.stringify(e.stack), - }, - STATUS_CODE.InternalServerError, - ); - }, -}); diff --git a/packages/stack/package.json b/packages/stack/package.json index a12c451a62..3b4fdbcbd8 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -6,7 +6,9 @@ "exports": { ".": "./src/index.ts", "./effect": "./src/effect.ts", - "./testing": "./src/testing.ts" + "./testing": "./src/testing.ts", + "./functions/serve-main-bundler": "./src/functions/serve-main-bundler.ts", + "./functions/serve-main-deps": "./src/functions/serve-main-deps.ts" }, "scripts": { "test": "pnpm run test:unit && pnpm run test:integration", @@ -18,12 +20,14 @@ }, "dependencies": { "@effect/platform-node": "catalog:", - "effect": "catalog:" + "effect": "catalog:", + "jose": "^6.2.9" }, "devDependencies": { "@effect/vitest": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "esbuild": "^0.28.2", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/stack/src/functions/FunctionsBootstrap.ts b/packages/stack/src/functions/FunctionsBootstrap.ts new file mode 100644 index 0000000000..b4487a3309 --- /dev/null +++ b/packages/stack/src/functions/FunctionsBootstrap.ts @@ -0,0 +1,116 @@ +import { Crypto, Effect, FileSystem, Path, PlatformError } from "effect"; +import { StackPreparationError } from "../public/Errors.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; +import type { StackId } from "../public/StackId.ts"; + +export interface FunctionsBootstrapOwner { + /** Publishes the stack-owned Edge Runtime main service for one generation. */ + readonly write: (input: { + readonly generation: number; + readonly content: string; + }) => Effect.Effect; + readonly cleanupGeneration: (generation: number) => Effect.Effect; +} + +export interface FunctionsBootstrapOwnerOptions { + readonly stateRoot: string; + readonly stackId: StackId; +} + +const failure = (message: string, fields: Readonly> = {}) => + new StackPreparationError({ message, ...fields }); + +const mapFs = ( + path: string, + operation: string, + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.mapError((cause) => failure(`Unable to ${operation}`, { path, cause }))); + +export const makeFunctionsBootstrapOwner = ( + options: FunctionsBootstrapOwnerOptions, +): Effect.Effect< + FunctionsBootstrapOwner, + StackPreparationError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const stackPaths = yield* resolveStackPaths(options).pipe( + Effect.mapError((cause) => failure("Unable to resolve functions bootstrap path", { cause })), + ); + const root = path.join(stackPaths.runtime, "functions"); + + const write = (input: { + readonly generation: number; + readonly content: string; + }): Effect.Effect => { + if (!Number.isSafeInteger(input.generation) || input.generation < 0) + return Effect.fail(failure("Invalid functions bootstrap generation")); + if (input.content.includes("\u0000")) + return Effect.fail(failure("Functions bootstrap contains an invalid character")); + const generationRoot = path.join(root, String(input.generation)); + const target = path.join(generationRoot, "main.ts"); + return Effect.gen(function* () { + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + failure("Unable to allocate functions bootstrap file", { cause }), + ), + ); + const temporary = path.join(generationRoot, `.main.ts.${token}.tmp`); + return yield* Effect.gen(function* () { + yield* mapFs( + generationRoot, + "create functions bootstrap directory", + fs.makeDirectory(generationRoot, { recursive: true, mode: 0o700 }), + ); + yield* mapFs( + generationRoot, + "secure functions bootstrap directory", + fs.chmod(generationRoot, 0o700), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* mapFs( + temporary, + "create functions bootstrap file", + fs.open(temporary, { flag: "w", mode: 0o600 }), + ); + yield* mapFs( + temporary, + "write functions bootstrap file", + file.writeAll(new TextEncoder().encode(input.content)), + ); + yield* mapFs(temporary, "sync functions bootstrap file", file.sync); + }), + ); + yield* mapFs(temporary, "secure functions bootstrap file", fs.chmod(temporary, 0o600)); + yield* mapFs(target, "publish functions bootstrap file", fs.rename(temporary, target)); + yield* mapFs( + target, + "secure published functions bootstrap file", + fs.chmod(target, 0o600), + ); + return target; + }).pipe( + Effect.ensuring( + fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + ); + }); + }; + + const cleanupGeneration = (generation: number): Effect.Effect => + !Number.isSafeInteger(generation) || generation < 0 + ? Effect.fail(failure("Invalid functions bootstrap generation")) + : mapFs( + path.join(root, String(generation)), + "clean functions bootstrap generation", + fs.remove(path.join(root, String(generation)), { recursive: true, force: true }), + ); + return { write, cleanupGeneration }; + }); diff --git a/packages/stack/src/functions/functions-bootstrap.integration.test.ts b/packages/stack/src/functions/functions-bootstrap.integration.test.ts new file mode 100644 index 0000000000..646c875aa6 --- /dev/null +++ b/packages/stack/src/functions/functions-bootstrap.integration.test.ts @@ -0,0 +1,26 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { isStackId } from "../public/StackId.ts"; +import { makeFunctionsBootstrapOwner } from "./FunctionsBootstrap.ts"; + +describe("functions bootstrap owner", () => { + it.live("publishes one private generation file with restrictive modes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-bootstrap-" }); + const stackIdValue = "a".repeat(64); + if (!isStackId(stackIdValue)) throw new Error("invalid test stack id"); + const stackId = stackIdValue; + const owner = yield* makeFunctionsBootstrapOwner({ stateRoot: root, stackId }); + const target = yield* owner.write({ generation: 4, content: "export default 1" }); + expect(target).toContain(path.join(root, stackId, "runtime", "functions", "4", "main.ts")); + expect((yield* fs.stat(path.dirname(target))).mode! & 0o777).toBe(0o700); + expect((yield* fs.stat(target)).mode! & 0o777).toBe(0o600); + expect(yield* fs.readFileString(target)).toBe("export default 1"); + yield* owner.cleanupGeneration(4); + expect(yield* fs.exists(target)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/functions/serve-main-bundler.ts b/packages/stack/src/functions/serve-main-bundler.ts new file mode 100644 index 0000000000..dd89196319 --- /dev/null +++ b/packages/stack/src/functions/serve-main-bundler.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +/** Absolute path to the stack-owned Edge Runtime main service template. */ +export const serveMainEntrypoint = fileURLToPath(new URL("./serve.main.ts", import.meta.url)); + +/** Produces one offline ES module with jose and path helpers inlined. */ +export async function bundleServeMainTemplate(): Promise { + const result = await build({ + entryPoints: [serveMainEntrypoint], + bundle: true, + format: "esm", + platform: "browser", + minify: true, + write: false, + legalComments: "none", + logLevel: "silent", + }); + const output = result.outputFiles[0]?.text; + if (output === undefined) throw new Error("esbuild produced no functions bootstrap output"); + return output; +} diff --git a/packages/stack/src/functions/serve-main-bundler.unit.test.ts b/packages/stack/src/functions/serve-main-bundler.unit.test.ts new file mode 100644 index 0000000000..5570fd2ab1 --- /dev/null +++ b/packages/stack/src/functions/serve-main-bundler.unit.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; + +describe("stack-owned functions bootstrap", () => { + it("bundles an offline self-contained Edge Runtime service", async () => { + const bundled = await bundleServeMainTemplate(); + expect(bundled).toContain("Deno.serve"); + expect(bundled).not.toContain("jsr:"); + expect(bundled).not.toContain("https://"); + expect(bundled).not.toMatch(/from\s*["']jose["']/u); + }); +}); diff --git a/packages/stack/src/functions/serve-main-deps.ts b/packages/stack/src/functions/serve-main-deps.ts new file mode 100644 index 0000000000..31a83a7e68 --- /dev/null +++ b/packages/stack/src/functions/serve-main-deps.ts @@ -0,0 +1,52 @@ +/** Minimal runtime dependencies bundled into the Edge Runtime bootstrap. */ +export const STATUS_CODE = { + OK: 200, + Unauthorized: 401, + NotFound: 404, + InternalServerError: 500, + ServiceUnavailable: 503, +} as const; + +export const STATUS_TEXT: Record = { + [STATUS_CODE.OK]: "OK", + [STATUS_CODE.Unauthorized]: "Unauthorized", + [STATUS_CODE.NotFound]: "Not Found", + [STATUS_CODE.InternalServerError]: "Internal Server Error", + [STATUS_CODE.ServiceUnavailable]: "Service Unavailable", +}; + +const normalize = (path: string): string => { + const absolute = path.startsWith("/"); + const parts: string[] = []; + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (parts.length > 0 && parts.at(-1) !== "..") parts.pop(); + else if (!absolute) parts.push(".."); + continue; + } + parts.push(segment); + } + const joined = parts.join("/"); + return absolute ? `/${joined}` : joined || "."; +}; + +export const join = (...paths: string[]): string => normalize(paths.filter(Boolean).join("/")); + +export const dirname = (path: string): string => { + if (path.length === 0) return "."; + let end = path.length; + while (end > 1 && path[end - 1] === "/") end -= 1; + const stripped = path.slice(0, end); + const slash = stripped.lastIndexOf("/"); + if (slash < 0) return "."; + if (slash === 0) return "/"; + return stripped.slice(0, slash); +}; + +export const toFileUrl = (path: string): URL => { + if (!path.startsWith("/")) throw new TypeError("Path must be absolute"); + const url = new URL("file:///"); + url.pathname = path.replace(/%/g, "%25").replace(/\\/g, "%5C"); + return url; +}; diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts new file mode 100644 index 0000000000..56e7af606c --- /dev/null +++ b/packages/stack/src/functions/serve.main.ts @@ -0,0 +1,340 @@ +// @ts-nocheck +declare const Deno: any; +declare const EdgeRuntime: any; + +import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; +import * as jose from "jose"; + +const EXCLUDED_ENVS = ["HOME", "HOSTNAME", "PATH", "PWD"]; +const HOST_PORT = Deno.env.get("SUPABASE_INTERNAL_HOST_PORT") ?? "8081"; +const FUNCTIONS_ROOT = Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_ROOT") ?? ""; +const JWT_SECRET = Deno.env.get("SUPABASE_INTERNAL_JWT_SECRET") ?? ""; +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "http://127.0.0.1:54321"; +const JWKS_ENDPOINT = new URL("/auth/v1/.well-known/jwks.json", SUPABASE_URL); +const WALLCLOCK_LIMIT_SEC = Number.parseInt( + Deno.env.get("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC") ?? "400", + 10, +); +const SUPABASE_PUBLISHABLE_KEY = Deno.env.get("SUPABASE_INTERNAL_PUBLISHABLE_KEY"); +const SUPABASE_SECRET_KEY = Deno.env.get("SUPABASE_INTERNAL_SECRET_KEY"); + +const SB_SPECIFIC_ERROR_CODE = { + BootError: STATUS_CODE.ServiceUnavailable, + InvalidWorkerResponse: STATUS_CODE.InternalServerError, + WorkerLimit: 546, +}; +const SB_SPECIFIC_ERROR_TEXT = { + [SB_SPECIFIC_ERROR_CODE.BootError]: "BOOT_ERROR", + [SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse]: "WORKER_ERROR", + [SB_SPECIFIC_ERROR_CODE.WorkerLimit]: "WORKER_LIMIT", +}; +const SB_SPECIFIC_ERROR_REASON = { + [SB_SPECIFIC_ERROR_CODE.BootError]: "Worker failed to boot (please check logs)", + [SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse]: + "Function exited due to an error (please check logs)", + [SB_SPECIFIC_ERROR_CODE.WorkerLimit]: + "Worker failed to respond due to a resource limit (please check logs)", +}; +const DENO_SB_ERROR_MAP = new Map([ + [Deno.errors.InvalidWorkerCreation, SB_SPECIFIC_ERROR_CODE.BootError], + [Deno.errors.InvalidWorkerResponse, SB_SPECIFIC_ERROR_CODE.InvalidWorkerResponse], + [Deno.errors.WorkerRequestCancelled, SB_SPECIFIC_ERROR_CODE.WorkerLimit], +]); + +export enum RequestErrors { + MissingAuthHeader = "UNAUTHORIZED_NO_AUTH_HEADER", + InvalidLegacyJWT = "UNAUTHORIZED_LEGACY_JWT", + InvalidAsymmetricJWT = "UNAUTHORIZED_ASYMMETRIC_JWT", + InvalidTokenFormat = "UNAUTHORIZED_INVALID_JWT_FORMAT", + UnsupportedTokenAlgorithm = "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", +} + +interface FunctionConfig { + entrypointPath: string; + importMapPath: string; + staticFiles: string[]; + verifyJWT: boolean; + enabled: boolean; + env?: Record; +} + +const parseConfig = (): Record => { + const raw = Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG"); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { + // Invalid optional config is treated as no overrides; host preflight reports invalid paths. + } + return {}; +}; +const configured = parseConfig(); + +const getResponse = (payload: any, status: number, customHeaders = {}) => { + const headers = { ...customHeaders }; + let body: string | null = null; + if (payload !== undefined && payload !== null) { + if (typeof payload === "object") { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(payload); + } else { + headers["Content-Type"] = "text/plain"; + body = String(payload); + } + } + return new Response(body, { status, headers }); +}; + +const getAuthErrorResponse = ({ + code, + message = "Invalid JWT", +}: { + code: RequestErrors; + message?: string; +}) => + getResponse({ code, message, msg: message }, STATUS_CODE.Unauthorized, { + "sb-error-code": code, + "Access-Control-Expose-Headers": "sb-error-code", + }); + +export function extractBearerToken(rawToken: string) { + const parts = rawToken.split(" "); + return parts.length === 2 && parts[0] === "Bearer" ? parts[1] : null; +} + +const getAuthToken = (request: Request): string | { code: RequestErrors; message: string } => { + const authHeader = request.headers.get("authorization"); + const compatibility = request.headers.get("sb-api-key")?.replace("Bearer", "").trim(); + if (!authHeader && !compatibility) + return { code: RequestErrors.MissingAuthHeader, message: "Missing authorization header" }; + const bearer = extractBearerToken(authHeader ?? ""); + const token = !bearer || bearer.startsWith("sb_") ? compatibility : bearer; + return token ? token : { code: RequestErrors.InvalidTokenFormat, message: "Invalid JWT format" }; +}; + +let localJwks: any = (() => { + try { + return jose.createLocalJWKSet(JSON.parse(Deno.env.get("SUPABASE_JWKS") ?? "{" + '"keys":[]}')); + } catch { + return null; + } +})(); +const isValidAsymmetricJWT = async (jwt: string): Promise<{ code: RequestErrors } | null> => { + try { + if (!localJwks) localJwks = jose.createRemoteJWKSet(JWKS_ENDPOINT); + await jose.jwtVerify(jwt, localJwks); + return null; + } catch { + return { code: RequestErrors.InvalidAsymmetricJWT }; + } +}; + +export async function verifyHybridJWT(jwtSecret: string, jwksUrl: URL, jwt: string) { + try { + const algorithm = jose.decodeProtectedHeader(jwt).alg; + if (!algorithm) + return { code: RequestErrors.InvalidTokenFormat, message: "Invalid JWT format" }; + if (algorithm === "HS256") { + try { + await jose.jwtVerify(jwt, new TextEncoder().encode(jwtSecret)); + return null; + } catch { + return { code: RequestErrors.InvalidLegacyJWT }; + } + } + if (algorithm === "ES256" || algorithm === "RS256") return isValidAsymmetricJWT(jwt); + return { + code: RequestErrors.UnsupportedTokenAlgorithm, + message: `Unsupported JWT algorithm ${algorithm}`, + }; + } catch { + return { code: RequestErrors.InvalidTokenFormat, message: "Invalid JWT format" }; + } +} + +const exists = async (path: string): Promise => { + try { + await Deno.lstat(path); + return true; + } catch { + return false; + } +}; + +const relativePath = (base: string, value: string): string => + value.length === 0 ? "" : value.startsWith("/") ? value : join(base, value); + +const isContained = (root: string, candidate: string): boolean => + candidate === root || candidate.startsWith(`${root.replace(/\/+$/u, "")}/`); + +const isContainedRealPath = async (root: string, candidate: string): Promise => { + if (!isContained(root, candidate)) return false; + try { + return isContained(await Deno.realPath(root), await Deno.realPath(candidate)); + } catch { + return false; + } +}; + +const functionConfig = async (slug: string): Promise => { + if (!/^[A-Za-z0-9_-]+$/u.test(slug) || slug === "_shared") return undefined; + const override = configured[slug]; + if (override?.enabled === false) return undefined; + const root = FUNCTIONS_ROOT.length > 0 ? FUNCTIONS_ROOT : undefined; + if (override === undefined) { + if (root === undefined || !(await exists(join(root, slug)))) return undefined; + } + const functionDirectory = root === undefined ? "" : join(root, slug); + if (root !== undefined && !(await isContainedRealPath(root, functionDirectory))) return undefined; + const rawEntrypoint = override?.entrypointPath ?? override?.entrypoint ?? "index.ts"; + const entrypointPath = relativePath(functionDirectory, rawEntrypoint); + let importMapPath = relativePath( + functionDirectory, + override?.importMapPath ?? override?.import_map ?? "", + ); + if (importMapPath === functionDirectory) importMapPath = ""; + if (importMapPath.length === 0) { + for (const candidate of ["deno.json", "deno.jsonc"]) { + const path = join(functionDirectory, candidate); + if (await exists(path)) { + importMapPath = path; + break; + } + } + } + const staticFiles = (override?.staticFiles ?? override?.static_files ?? []).map((value: string) => + relativePath(functionDirectory, value), + ); + if (root !== undefined) { + if (!isContained(root, entrypointPath) || (importMapPath && !isContained(root, importMapPath))) + return undefined; + for (const pattern of staticFiles) { + const wildcard = pattern.search(/[!*?[{]/u); + const prefix = wildcard < 0 ? pattern : pattern.slice(0, wildcard); + if (!isContained(root, prefix)) return undefined; + if ((await exists(prefix)) && !(await isContainedRealPath(root, prefix))) return undefined; + } + if ((await exists(entrypointPath)) && !(await isContainedRealPath(root, entrypointPath))) + return undefined; + if ( + importMapPath && + (await exists(importMapPath)) && + !(await isContainedRealPath(root, importMapPath)) + ) + return undefined; + } + return { + entrypointPath, + importMapPath, + staticFiles, + verifyJWT: override?.verifyJWT ?? override?.verify_jwt ?? true, + enabled: override?.enabled ?? true, + env: override?.env, + }; +}; + +const shouldUsePackageJsonDiscovery = async (config: FunctionConfig): Promise => { + if (config.importMapPath) return false; + return exists(join(dirname(config.entrypointPath), "package.json")); +}; + +export function prepareUserRequest(request: Request): Request { + const url = new URL(request.url); + const forwardedHost = request.headers.get("x-forwarded-host"); + if (forwardedHost) url.hostname = forwardedHost; + const cloned = new Request(url, request.clone()); + cloned.headers.delete("sb-api-key"); + EdgeRuntime.applySupabaseTag(request, cloned); + return cloned; +} + +Deno.serve({ + handler: async (request: Request) => { + const { pathname } = new URL(request.url); + if (pathname === "/_internal/health") return getResponse({ message: "ok" }, STATUS_CODE.OK); + if (pathname === "/_internal/metric") + return Response.json(await EdgeRuntime.getRuntimeMetrics()); + const functionName = pathname.split("/")[1]; + if (!functionName) return getResponse("Function not found", STATUS_CODE.NotFound); + const config = await functionConfig(functionName); + if (!config) return getResponse("Function not found", STATUS_CODE.NotFound); + if (request.method !== "OPTIONS" && config.verifyJWT) { + const token = getAuthToken(request); + if (typeof token !== "string") return getAuthErrorResponse(token); + const authFailure = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token); + if (authFailure) return getAuthErrorResponse(authFailure); + } + if (!(await exists(config.entrypointPath))) + return getResponse("Function not found", STATUS_CODE.NotFound); + const envVarsObj = { + ...Deno.env.toObject(), + ...Object.fromEntries( + Object.entries(config.env ?? {}).filter(([name]) => !name.startsWith("SUPABASE_")), + ), + }; + if (SUPABASE_PUBLISHABLE_KEY) + envVarsObj.SUPABASE_PUBLISHABLE_KEYS = JSON.stringify({ default: SUPABASE_PUBLISHABLE_KEY }); + if (SUPABASE_SECRET_KEY) + envVarsObj.SUPABASE_SECRET_KEYS = JSON.stringify({ default: SUPABASE_SECRET_KEY }); + const envVars = Object.entries(envVarsObj).filter( + ([name]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_"), + ); + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath: dirname(config.entrypointPath), + memoryLimitMb: 256, + workerTimeoutMs: Number.isFinite(WALLCLOCK_LIMIT_SEC) + ? WALLCLOCK_LIMIT_SEC * 1000 + : 400_000, + noModuleCache: true, + noNpm: !(await shouldUsePackageJsonDiscovery(config)), + importMapPath: config.importMapPath, + envVars, + forceCreate: true, + customModuleRoot: "", + cpuTimeSoftLimitMs: 1000, + cpuTimeHardLimitMs: 2000, + decoratorType: "tc39", + maybeEntrypoint: toFileUrl(config.entrypointPath).href, + context: { useReadSyncFileAPI: true }, + staticPatterns: config.staticFiles, + }); + return await worker.fetch(prepareUserRequest(request)); + } catch (error) { + for (const [denoError, sbCode] of DENO_SB_ERROR_MAP.entries()) { + if (denoError !== undefined && error instanceof denoError) + return getResponse( + { code: SB_SPECIFIC_ERROR_TEXT[sbCode], message: SB_SPECIFIC_ERROR_REASON[sbCode] }, + sbCode, + ); + } + return getResponse( + { + code: STATUS_TEXT[STATUS_CODE.InternalServerError], + message: "Request failed due to an internal server error", + trace: JSON.stringify(error?.stack), + }, + STATUS_CODE.InternalServerError, + ); + } + }, + onListen: () => { + const names = Object.keys(configured); + const examples = names + .slice(0, 5) + .map((name) => ` - http://127.0.0.1:${HOST_PORT}/functions/v1/${name}`); + console.log( + `Serving functions on http://127.0.0.1:${HOST_PORT}/functions/v1/${examples.length ? `\n${examples.join("\n")}` : ""}\nUsing ${Deno.version.deno}`, + ); + }, + onError: (error) => + getResponse( + { + code: STATUS_TEXT[500], + message: "Request failed due to an internal server error", + trace: JSON.stringify(error?.stack), + }, + 500, + ), +}); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 4ccaa826af..c6329e3498 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -55,6 +55,7 @@ const containerEngine = (present: boolean, calls: string[]): ContainerEngine => createVolume: (_spec: ContainerVolumeSpec) => Effect.die("unused"), removeVolume: () => Effect.void, createContainer: (_spec: ContainerContainerSpec) => Effect.die("unused"), + copyToContainer: () => Effect.die("unused"), startContainer: () => Effect.void, stopContainer: () => Effect.void, removeContainer: () => Effect.void, diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 54c4a8e5d3..31f851fee1 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -118,6 +118,12 @@ export type ContainerCommand = | { readonly operation: "create-volume"; readonly spec: ContainerVolumeSpec } | { readonly operation: "remove-volume"; readonly id: string } | { readonly operation: "create-container"; readonly spec: ContainerContainerSpec } + | { + readonly operation: "copy-container"; + readonly id: string; + readonly source: string; + readonly destination: string; + } | { readonly operation: "start-container"; readonly id: string } | { readonly operation: "stop-container"; readonly id: string } | { readonly operation: "remove-container"; readonly id: string }; @@ -313,6 +319,12 @@ export interface ContainerEngine { readonly createContainer: ( spec: ContainerContainerSpec, ) => Effect.Effect; + /** Copies an owner-created bootstrap path into a newly-created container. */ + readonly copyToContainer: ( + id: string, + source: string, + destination: string, + ) => Effect.Effect; readonly startContainer: (id: string) => Effect.Effect; readonly stopContainer: (id: string) => Effect.Effect; readonly removeContainer: (id: string) => Effect.Effect; @@ -414,6 +426,8 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai ), ); }, + copyToContainer: (id, source, destination) => + noResult("copy-container", { operation: "copy-container", id, source, destination }), startContainer: (id) => noResult("start-container", { operation: "start-container", id }), stopContainer: (id) => noResult("stop-container", { operation: "stop-container", id }), removeContainer: (id) => noResult("remove-container", { operation: "remove-container", id }), diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index cf6dc821a4..fa9559757a 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -45,6 +45,8 @@ export interface ContainerWorkloadResolution { /** Private host-loopback publications used by the in-process gateway. */ readonly publications?: ReadonlyArray; readonly command?: ReadonlyArray; + /** Owner-created bootstrap copied into a newly-created container before start. */ + readonly bootstrap?: Readonly<{ readonly source: string; readonly destination: string }>; readonly waitForReadiness?: ( key: RuntimeWorkloadKey, workload: PlannedWorkload, @@ -389,6 +391,29 @@ export const makeContainerRuntime = ( } satisfies ContainerContainerSpec), ); created = true; + if (resolution.bootstrap !== undefined) { + const copied = yield* Effect.exit( + withEngine( + key, + options.engine.copyToContainer( + container.id, + resolution.bootstrap.source, + resolution.bootstrap.destination, + ), + ), + ); + if (Exit.isFailure(copied)) { + const removed = yield* Effect.exit( + withEngine(key, options.engine.removeContainer(container.id)), + ); + const cleanupCause = Exit.isFailure(removed) ? removed.cause : Cause.empty; + return yield* Effect.failCause( + cleanupCause.reasons.length === 0 + ? copied.cause + : Cause.combine(copied.cause, cleanupCause), + ); + } + } yield* withEngine(key, options.engine.startContainer(container.id)); startedByUs = true; } diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index 5a80f4f8d7..fb62a13c22 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -175,6 +175,8 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc ], }; } + case "copy-container": + return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; case "start-container": return { args: ["start", command.id] }; case "stop-container": diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index fa666ba621..f1d3a16521 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -171,6 +171,8 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc ], }; } + case "copy-container": + return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; case "start-container": return { args: ["start", command.id] }; case "stop-container": diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 83b83e41f9..f1bf9a0bd0 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -42,6 +42,11 @@ export interface WorkloadRuntimeInputs { readonly gcpJwtPath?: string; readonly vectorConfigPath?: string; }>; + /** Stack-owned Edge Runtime bootstrap source and its private container target. */ + readonly functions?: Readonly<{ + readonly bootstrapPath?: string; + readonly bootstrapContainerPath?: string; + }>; /** Host route used by containers to reach StackGateway. */ readonly hostRoute?: ContainerHostRoute; } @@ -97,6 +102,7 @@ export interface WorkloadRuntimeSpec { state: PersistedStackState, workload: PlannedWorkload, port: number, + inputs?: WorkloadRuntimeInputs, ) => NativeProcessResolution; } @@ -122,6 +128,7 @@ export interface ContainerWorkloadResolution { readonly containerPort: number; }>; readonly hostRoute?: ContainerHostRoute; + readonly bootstrap?: Readonly<{ readonly source: string; readonly destination: string }>; } /** @@ -216,6 +223,7 @@ export const validateWorkloadRuntimeInputs = ( }); export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; +export const FUNCTIONS_BOOTSTRAP_CONTAINER_PATH = "/root"; const DATABASE_NETWORK_ALIAS = "supabase-database"; const MAIL_NETWORK_ALIAS = "supabase-mail"; @@ -354,6 +362,31 @@ const edgeRuntimeJwtEnvironment = ( SUPABASE_JWKS: inputs.auth?.jwks ?? '{"keys":[]}', }); +const functionsConfigEnvironment = (state: PersistedStackState): string => { + const settings = settingsFor(state, "functions"); + const configured = isRecord(settings) && isRecord(settings.functions) ? settings.functions : {}; + const result: Record = {}; + for (const [slug, value] of Object.entries(configured)) { + if (!isRecord(value)) continue; + const env = isRecord(value.env) + ? Object.fromEntries( + Object.entries(value.env).map(([key, entry]) => [key, settingValue(state, entry)]), + ) + : {}; + result[slug] = { + enabled: value.enabled ?? true, + verify_jwt: value.verify_jwt ?? true, + import_map: settingValue(state, value.import_map), + entrypoint: settingValue(state, value.entrypoint), + static_files: Array.isArray(value.static_files) + ? value.static_files.map((entry) => settingValue(state, entry)) + : [], + env, + }; + } + return JSON.stringify(result); +}; + const common = (workload: PlannedWorkload, port: number): Record => ({ SUPABASE_STACK_WORKLOAD: workload.id, SUPABASE_STACK_PRIVATE_PORT: String(port), @@ -395,6 +428,7 @@ const nativeProcessFor = ( workload: PlannedWorkload, port: number, spec: WorkloadRuntimeSpecDefinition, + inputs: WorkloadRuntimeInputs = {}, ): NativeProcessResolution => { const metadata: NativeWorkloadProcess | undefined = WORKLOAD_CATALOG[workload.id]?.nativeProcess; if (metadata !== undefined) @@ -407,14 +441,27 @@ const nativeProcessFor = ( }; const executablePath = WORKLOAD_CATALOG[workload.id]?.executablePath; const resolvedPort = privatePortFor(state, workload.id, "primary") ?? port; + const args = spec + .args(state, workload, resolvedPort, "native") + .map((arg) => + arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, + ); + const nativeArgs = + workload.id === "functions:edge-runtime" && inputs.functions?.bootstrapPath !== undefined + ? (() => { + const bootstrapPath = inputs.functions?.bootstrapPath; + if (bootstrapPath === undefined) return args; + const bootstrapDirectory = + bootstrapPath.slice(0, bootstrapPath.lastIndexOf("/")) || bootstrapPath; + return args.map((arg) => + arg.startsWith("--main-service=") ? `--main-service=${bootstrapDirectory}` : arg, + ); + })() + : args; return { executable: executablePath === undefined ? artifactRoot : artifactPath(artifactRoot, executablePath), - args: spec - .args(state, workload, resolvedPort, "native") - .map((arg) => - arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, - ), + args: nativeArgs, cwd: spec.cwd?.(state, workload) ?? state.identity.projectRoot, }; }; @@ -917,13 +964,17 @@ const specs: Readonly> = { `--port=${port}`, `--policy=${valueAt(state, "functions", "edge_runtime.policy") || "per_worker"}`, ], - env: (state, workload, port, _runtime = "native", inputs = {}) => ({ + env: (state, workload, port, runtime = "native", inputs = {}) => ({ ...common(workload, port), ...capabilityEnv(state, "functions", "FUNCTIONS"), ...edgeRuntimeJwtEnvironment(state, inputs), EDGE_RUNTIME_PORT: String(port), FUNCTIONS_ROOT: functionsRoot(state), FUNCTIONS_CONTAINER_ROOT, + SUPABASE_INTERNAL_FUNCTIONS_ROOT: + runtime === "container" ? FUNCTIONS_CONTAINER_ROOT : functionsRoot(state), + SUPABASE_INTERNAL_FUNCTIONS_CONFIG: functionsConfigEnvironment(state), + SUPABASE_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), EDGE_RUNTIME_POLICY: valueAt(state, "functions", "edge_runtime.policy") || "per_worker", EDGE_RUNTIME_DENO_VERSION: valueAt(state, "functions", "edge_runtime.deno_version") || "2", INSPECTOR_MODE: valueAt(state, "functions", "inspector.mode"), @@ -931,7 +982,7 @@ const specs: Readonly> = { }), containerArgs: (state, _workload, port) => [ "start", - `--main-service=${FUNCTIONS_CONTAINER_ROOT}`, + `--main-service=${FUNCTIONS_BOOTSTRAP_CONTAINER_PATH}`, `--port=${port}`, `--policy=${valueAt(state, "functions", "edge_runtime.policy") || "per_worker"}`, ], @@ -1190,8 +1241,8 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | runtime, aliasFor(workload), )), - nativeProcess: (artifactRoot, state, currentWorkload, port) => - nativeProcessFor(artifactRoot, state, currentWorkload, port, spec), + nativeProcess: (artifactRoot, state, currentWorkload, port, inputs = {}) => + nativeProcessFor(artifactRoot, state, currentWorkload, port, spec, inputs), }; }; @@ -1222,6 +1273,15 @@ export const containerResolutionFor = ( }, ]; }), + ...(workload.id === "functions:edge-runtime" && inputs.functions?.bootstrapPath !== undefined + ? { + bootstrap: { + source: inputs.functions.bootstrapPath, + destination: + inputs.functions.bootstrapContainerPath ?? FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, + }, + } + : {}), ...(inputs.hostRoute === undefined ? {} : { hostRoute: inputs.hostRoute }), }; }; diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 6d143c047c..063b587a54 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -65,6 +65,7 @@ interface FakeContainerState { createdSpecs: Array; nextId: number; inspectImageFailure?: ContainerCommandError; + copyFailure?: ContainerCommandError; } const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { @@ -141,6 +142,12 @@ const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { state.resources.push(resource); return resource; }), + copyToContainer: (resourceId: string, source: string, destination: string) => + state.copyFailure !== undefined + ? Effect.fail(state.copyFailure) + : Effect.sync(() => { + state.calls.push(`copy:${resourceId}:${source}:${destination}`); + }), startContainer: (resourceId: string) => Effect.sync(() => { state.calls.push(`start:${resourceId}`); @@ -498,6 +505,22 @@ describe("container runtime", () => { const podman = serializePodmanCommand({ operation: "inspect-networks", stackId }); expect(podman.args.join(" ")).toContain("{{index .Labels"); expect(podman.args.join(" ")).not.toContain("host-gateway"); + expect( + serializeDockerCommand({ + operation: "copy-container", + id: "container-id", + source: "/tmp/functions-main.ts", + destination: "/root", + }).args, + ).toEqual(["cp", "/tmp/functions-main.ts", "container-id:/root"]); + expect( + serializePodmanCommand({ + operation: "copy-container", + id: "container-id", + source: "/tmp/functions-main.ts", + destination: "/root", + }).args, + ).toEqual(["cp", "/tmp/functions-main.ts", "container-id:/root"]); const unsupported = yield* makePodmanEngine({ runner: makeControlledCommandRunner({ run: () => Effect.succeed(commandResult("ok")) }), platform: { os: "darwin" }, @@ -806,6 +829,62 @@ describe("container runtime", () => { }), ); + it.live( + "copies a new functions bootstrap before starting and never copies an adopted container", + () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ bootstrap: { source: "/tmp/main.ts", destination: "/root" } }), + }); + yield* runtime.start(key, workload()); + const copyIndex = state.calls.findIndex((call) => call.startsWith("copy:")); + const startIndex = state.calls.findIndex((call) => call.startsWith("start:")); + expect(copyIndex).toBeGreaterThanOrEqual(0); + expect(startIndex).toBeGreaterThan(copyIndex); + state.calls.length = 0; + yield* runtime.start(key, workload()); + expect(state.calls.some((call) => call.startsWith("copy:"))).toBe(false); + }), + ); + + it.live("removes a newly-created container when bootstrap copy fails", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + copyFailure: new ContainerCommandError({ + operation: "copy-container", + message: "bootstrap copy failed", + }), + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ bootstrap: { source: "/tmp/main.ts", destination: "/root" } }), + }); + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(state.calls.some((call) => call.startsWith("copy:"))).toBe(false); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); + expect(state.calls.some((call) => call.startsWith("start:"))).toBe(false); + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + }), + ); + it.live("serializes concurrent starts and does not mutate when image inspection fails", () => Effect.gen(function* () { const state: FakeContainerState = { diff --git a/packages/stack/src/runtime/runtime-recovery.integration.test.ts b/packages/stack/src/runtime/runtime-recovery.integration.test.ts index 032633e1b4..07105a37fd 100644 --- a/packages/stack/src/runtime/runtime-recovery.integration.test.ts +++ b/packages/stack/src/runtime/runtime-recovery.integration.test.ts @@ -140,6 +140,7 @@ const makeEngine = (state: EngineState): ContainerEngine => { state.resources.push(resource); return resource; }), + copyToContainer: () => Effect.void, startContainer: (id) => Effect.sync(() => { state.calls.push(`start:${id}`); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 7f55bbffa7..4a74e49a20 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -8,6 +8,7 @@ import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { compileStack } from "../model/Compiler.ts"; import { containerResolutionFor, + FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, FUNCTIONS_CONTAINER_ROOT, privateBindingIntentsFor, resolveContainerResolutionFor, @@ -354,7 +355,7 @@ describe("workload runtime catalog", () => { hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, }); expect(resolution?.command.join(" ")).toContain( - `--main-service=${FUNCTIONS_CONTAINER_ROOT}`, + `--main-service=${FUNCTIONS_BOOTSTRAP_CONTAINER_PATH}`, ); expect(resolution?.mounts).toEqual([ { @@ -375,6 +376,22 @@ describe("workload runtime catalog", () => { host: "host.docker.internal", gateway: "host-gateway", }); + const bootstrapResolution = containerResolutionFor(configured, functions, { + functions: { bootstrapPath: "/tmp/functions/4/main.ts" }, + }); + expect(bootstrapResolution?.bootstrap).toEqual({ + source: "/tmp/functions/4/main.ts", + destination: "/root", + }); + expect( + runtimeSpecFor(functions)?.nativeProcess( + "/tmp/edge-artifact", + configured, + functions, + 9000, + { functions: { bootstrapPath: "/tmp/functions/4/main.ts" } }, + ).args, + ).toContain("--main-service=/tmp/functions/4"); const studio = runtimeSpecFor(planned("studio:studio")); expect( studio?.env(configured, planned("studio:studio"), 3000, "container", { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b34ef19fc0..b58fbd6743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -449,6 +449,9 @@ importers: effect: specifier: 'catalog:' version: 4.0.0-rc.111 + jose: + specifier: ^6.2.9 + version: 6.2.9 devDependencies: '@effect/vitest': specifier: 'catalog:' @@ -459,6 +462,9 @@ importers: '@types/bun': specifier: 'catalog:' version: 1.4.0 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 typescript: specifier: 'catalog:' version: 7.0.2 From ebfe454454162fd43d23e9429e5c57426f288d05 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:42:29 +0200 Subject: [PATCH 039/174] fix(stack): harden managed credential generation --- packages/stack/src/state/SecretStore.ts | 35 +++++++++--- .../src/state/secrets.integration.test.ts | 56 +++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index fbfb30b4d9..6ea83cbbf7 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, FileSystem, Path, Redacted, Schema } from "effect"; +import { Clock, Crypto, Effect, FileSystem, Path, Redacted, Schema } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createHmac, createPrivateKey, createSign } from "node:crypto"; import { @@ -60,7 +60,7 @@ const validSlot = (slot: string): boolean => /^[A-Za-z0-9_.:/-]+$/.test(slot); const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; const JWT_ISSUER = "supabase-demo"; const JWT_HMAC_EXPIRY = 1_983_812_996; -const JWT_ASYMMETRIC_EXPIRY = 2_103_000_000; +const JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; const BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; const JwtHeaderSchema = Schema.Struct({ @@ -222,8 +222,21 @@ const readSigningJwk = ( const relative = path.relative(projectRoot, candidate); if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return yield* invalidSigningMaterial(); + const canonicalRoot = yield* fs + .realPath(projectRoot) + .pipe(Effect.mapError(() => invalidSigningMaterial())); + const canonicalCandidate = yield* fs + .realPath(candidate) + .pipe(Effect.mapError(() => invalidSigningMaterial())); + const canonicalRelative = path.relative(canonicalRoot, canonicalCandidate); + if ( + canonicalRelative === ".." || + canonicalRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(canonicalRelative) + ) + return yield* invalidSigningMaterial(); const raw = yield* fs - .readFileString(candidate) + .readFileString(canonicalCandidate) .pipe(Effect.mapError(() => invalidSigningMaterial())); const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(JwkFileSchema))(raw).pipe( Effect.mapError(() => invalidSigningMaterial()), @@ -241,7 +254,7 @@ const signWithJwk = ( payload: string, ): Effect.Effect => Effect.gen(function* () { - const header = + const header: JwtHeader = jwk.kid === undefined ? { alg: jwk.alg, typ: "JWT" } : { alg: jwk.alg, kid: jwk.kid, typ: "JWT" }; @@ -267,7 +280,7 @@ const generateJwtToken = ( generator: Extract, jwtSecret: string | undefined, ): Effect.Effect => { - const payloadFor = (expiry: number) => ({ + const payloadFor = (expiry: number): JwtPayload => ({ iss: JWT_ISSUER, role: generator.role, exp: expiry, @@ -295,9 +308,15 @@ const generateJwtToken = ( } return readSigningJwk(generator.signing).pipe( Effect.flatMap((jwk) => - encodeJwtPayload(payloadFor(JWT_ASYMMETRIC_EXPIRY)).pipe( - Effect.mapError(() => invalidSigningMaterial()), - Effect.flatMap((payload) => signWithJwk(jwk, payload)), + Clock.currentTimeMillis.pipe( + Effect.flatMap((nowMillis) => + encodeJwtPayload( + payloadFor(Math.floor(nowMillis / 1000) + JWT_ASYMMETRIC_EXPIRY_SECONDS), + ).pipe( + Effect.mapError(() => invalidSigningMaterial()), + Effect.flatMap((payload) => signWithJwk(jwk, payload)), + ), + ), ), ), ); diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index dda54238ed..0547a7d252 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -1,6 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import { createHmac, generateKeyPairSync, createVerify } from "node:crypto"; import { InvalidJwtSigningMaterialError, @@ -172,6 +173,40 @@ describe("managed and pass-through secrets", () => { }).pipe(Effect.provide(layer)), ); + it.effect("derives asymmetric JWT expiry from the Effect Clock", () => + Effect.gen(function* () { + yield* TestClock.setTime(1_700_000_123_456); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-credentials-" }); + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const privateJwk = { ...privateKey.export({ format: "jwk" }), alg: "ES256" }; + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + yield* fs.writeFileString(path.join(root, "keys.json"), JSON.stringify([privateJwk])); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { security: { jwt: { signing: { kind: "jwks-file", path: "keys.json" } } } }, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + const token = resolved.persisted["secret:auth.settings.anon_key"]?.value; + expect(token).toBeDefined(); + const payloadText = Buffer.from(token!.split(".")[1]!, "base64url").toString(); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + const payload = JSON.parse(payloadText); + expect(payload).toEqual({ + iss: "supabase-demo", + role: "anon", + exp: 1_700_000_123 + 60 * 60 * 24 * 365 * 10, + }); + expect(payload).not.toHaveProperty("iat"); + }).pipe(Effect.provide(layer)), + ); + it.live("fails closed for public-only or escaping JWKS material", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -209,6 +244,27 @@ describe("managed and pass-through secrets", () => { "stopped", ).pipe(Effect.exit); expect(errorOf(escapingExit)).toBeInstanceOf(InvalidJwtSigningMaterialError); + + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-outside-" }); + yield* fs.writeFileString( + path.join(outside, "private.json"), + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json + JSON.stringify([{ kty: "EC", alg: "ES256", crv: "P-256", d: "d", x: "x", y: "y" }]), + ); + yield* fs.symlink(path.join(outside, "private.json"), path.join(root, "linked.json")); + const symlinkEscape = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + security: { jwt: { signing: { kind: "jwks-file", path: "linked.json" } } }, + }, + }); + const symlinkExit = yield* resolveSecrets( + { declarations: symlinkEscape.secrets }, + undefined, + "stopped", + ).pipe(Effect.exit); + expect(errorOf(symlinkExit)).toBeInstanceOf(InvalidJwtSigningMaterialError); }).pipe(Effect.provide(layer)), ); From cf6e0fd70c0a1a77e4f2b503e1030d22bbd96aea Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:51:47 +0200 Subject: [PATCH 040/174] fix(stack): contain live functions discovery --- .../functions/serve-main-offline.e2e.test.ts | 8 +- .../serve-main-resolver.integration.test.ts | 147 +++++++++++++++ .../src/functions/serve-main-resolver.ts | 175 ++++++++++++++++++ packages/stack/src/functions/serve.main.ts | 124 ++++--------- 4 files changed, 360 insertions(+), 94 deletions(-) create mode 100644 packages/stack/src/functions/serve-main-resolver.integration.test.ts create mode 100644 packages/stack/src/functions/serve-main-resolver.ts diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index 6945a2d8f7..bc386f5c98 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -41,7 +41,7 @@ const SERVE_OFFLINE_STARTUP_TIMEOUT_MS = 60_000; const SERVE_OFFLINE_TEST_TIMEOUT_MS = 180_000; const AUTH_FUNCTIONS_CONFIG = JSON.stringify({ test: { - entrypointPath: "/tmp/test/index.ts", + entrypointPath: "/app/index.ts", importMapPath: "", staticFiles: [], verifyJWT: true, @@ -163,6 +163,8 @@ describe("functions serve runtime template (offline)", () => { "-e", "SUPABASE_INTERNAL_HOST_PORT=8081", "-e", + "SUPABASE_INTERNAL_FUNCTIONS_ROOT=/app", + "-e", "SUPABASE_INTERNAL_JWT_SECRET=offline-e2e", "-e", "SUPABASE_URL=http://127.0.0.1:54321", @@ -227,6 +229,8 @@ describe("functions serve runtime template (offline)", () => { "-e", "SUPABASE_INTERNAL_HOST_PORT=8081", "-e", + "SUPABASE_INTERNAL_FUNCTIONS_ROOT=/app", + "-e", "SUPABASE_INTERNAL_JWT_SECRET=auth-e2e", "-e", "SUPABASE_URL=http://127.0.0.1:54321", @@ -323,6 +327,8 @@ describe("functions serve runtime template (offline)", () => { "-e", "SUPABASE_INTERNAL_HOST_PORT=8081", "-e", + "SUPABASE_INTERNAL_FUNCTIONS_ROOT=/app/functions", + "-e", "SUPABASE_INTERNAL_JWT_SECRET=auth-e2e", "-e", `SUPABASE_URL=http://${kongContainer}:8000`, diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts new file mode 100644 index 0000000000..10fddb58bd --- /dev/null +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -0,0 +1,147 @@ +import { + mkdtemp, + mkdir, + readdir, + lstat, + realpath, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveFunctionConfig, type FunctionFileSystem } from "./serve-main-resolver.ts"; + +const nodeFileSystem: FunctionFileSystem = { + lstat: async (path) => { + const info = await lstat(path); + return { + isDirectory: info.isDirectory(), + isFile: info.isFile(), + isSymbolicLink: info.isSymbolicLink(), + }; + }, + realPath: (path) => realpath(path), + readDirectory: async (path) => readdir(path), +}; + +describe("Edge Runtime request-time function resolver", () => { + it("discovers defaults and reflects live create/edit/delete without caching", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-")); + try { + const hello = join(root, "hello"); + await mkdir(hello, { recursive: true }); + await writeFile(join(hello, "index.ts"), "export default 1"); + await writeFile(join(hello, "deno.json"), "{}"); + const canonicalRoot = await realpath(root); + + const defaults = await resolveFunctionConfig({ + root, + slug: "hello", + overrides: {}, + fs: nodeFileSystem, + }); + expect(defaults).toMatchObject({ + entrypointPath: join(canonicalRoot, "hello", "index.ts"), + importMapPath: join(canonicalRoot, "hello", "deno.json"), + verifyJWT: true, + }); + + await writeFile(join(hello, "index.ts"), "export default 2"); + const edited = await resolveFunctionConfig({ + root, + slug: "hello", + overrides: {}, + fs: nodeFileSystem, + }); + expect(edited?.entrypointPath).toBe(defaults?.entrypointPath); + + const created = join(root, "new-function"); + await mkdir(created); + await writeFile(join(created, "index.ts"), "export default 3"); + expect( + await resolveFunctionConfig({ + root, + slug: "new-function", + overrides: {}, + fs: nodeFileSystem, + }), + ).toMatchObject({ + verifyJWT: true, + entrypointPath: join(canonicalRoot, "new-function", "index.ts"), + }); + + await unlink(join(hello, "index.ts")); + expect( + await resolveFunctionConfig({ root, slug: "hello", overrides: {}, fs: nodeFileSystem }), + ).toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("requires an absolute root and rejects traversal and symlink escapes", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-paths-")); + const outside = await mkdtemp(join(tmpdir(), "stack-functions-resolver-outside-")); + try { + await mkdir(join(root, "safe")); + await writeFile(join(root, "safe", "index.ts"), "export default 1"); + await writeFile(join(outside, "index.ts"), "export default 2"); + expect( + await resolveFunctionConfig({ root: "", slug: "safe", overrides: {}, fs: nodeFileSystem }), + ).toBeUndefined(); + expect( + await resolveFunctionConfig({ + root, + slug: "safe", + overrides: { safe: { entrypoint: "../outside.ts" } }, + fs: nodeFileSystem, + }), + ).toBeUndefined(); + + await symlink(outside, join(root, "escaped"), "dir"); + expect( + await resolveFunctionConfig({ root, slug: "escaped", overrides: {}, fs: nodeFileSystem }), + ).toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + it("fails closed when a static wildcard tree contains a symlink", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-static-")); + const outside = await mkdtemp(join(tmpdir(), "stack-functions-resolver-static-outside-")); + try { + const canonicalRoot = await realpath(root); + const functionRoot = join(root, "hello"); + const publicRoot = join(functionRoot, "public"); + await mkdir(publicRoot, { recursive: true }); + await writeFile(join(functionRoot, "index.ts"), "export default 1"); + await writeFile(join(publicRoot, "ok.txt"), "ok"); + expect( + await resolveFunctionConfig({ + root, + slug: "hello", + overrides: { hello: { static_files: ["public/*.txt"] } }, + fs: nodeFileSystem, + }), + ).toMatchObject({ staticFiles: [join(canonicalRoot, "hello", "public", "*.txt")] }); + await writeFile(join(outside, "secret.txt"), "secret"); + await symlink(join(outside, "secret.txt"), join(publicRoot, "link.txt")); + expect( + await resolveFunctionConfig({ + root, + slug: "hello", + overrides: { hello: { static_files: ["public/*.txt"] } }, + fs: nodeFileSystem, + }), + ).toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts new file mode 100644 index 0000000000..394501ee5d --- /dev/null +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -0,0 +1,175 @@ +import { dirname, join } from "./serve-main-deps.ts"; + +export interface FunctionOverride { + readonly enabled?: boolean; + readonly verifyJWT?: boolean; + readonly verify_jwt?: boolean; + readonly entrypointPath?: string; + readonly entrypoint?: string; + readonly importMapPath?: string; + readonly import_map?: string; + readonly staticFiles?: ReadonlyArray; + readonly static_files?: ReadonlyArray; + readonly env?: Readonly>; +} + +export type FunctionOverrides = Readonly>; + +export interface FunctionConfig { + readonly entrypointPath: string; + readonly importMapPath: string; + readonly staticFiles: ReadonlyArray; + readonly verifyJWT: boolean; + readonly env?: Readonly>; +} + +export interface FunctionFileInfo { + readonly isDirectory: boolean; + readonly isFile: boolean; + readonly isSymbolicLink: boolean; +} + +/** The tiny filesystem surface needed by request-time function discovery. */ +export interface FunctionFileSystem { + readonly lstat: (path: string) => Promise; + readonly realPath: (path: string) => Promise; + readonly readDirectory: (path: string) => Promise>; +} + +const slugPattern = /^[A-Za-z0-9_-]+$/u; +const globPattern = /[*?[{]/u; + +const contained = (root: string, candidate: string): boolean => + candidate === root || candidate.startsWith(`${root.replace(/\/+$/u, "")}/`); + +const optionalInfo = async ( + fs: FunctionFileSystem, + path: string, +): Promise => { + try { + return await fs.lstat(path); + } catch { + return undefined; + } +}; + +const safeRealPath = async ( + fs: FunctionFileSystem, + root: string, + candidate: string, +): Promise => { + try { + const [canonicalRoot, canonicalCandidate] = await Promise.all([ + fs.realPath(root), + fs.realPath(candidate), + ]); + return contained(canonicalRoot, canonicalCandidate); + } catch { + return false; + } +}; + +/** Rejects symlinks in the complete wildcard search area for this request. */ +const rejectSymlinkDescendants = async ( + fs: FunctionFileSystem, + root: string, + directory: string, +): Promise => { + const info = await optionalInfo(fs, directory); + if (info === undefined) return true; + if (info.isSymbolicLink || !(await safeRealPath(fs, root, directory))) return false; + if (!info.isDirectory) return true; + let children: ReadonlyArray; + try { + children = await fs.readDirectory(directory); + } catch { + return false; + } + for (const child of children) { + if (!(await rejectSymlinkDescendants(fs, root, join(directory, child)))) return false; + } + return true; +}; + +const relativePath = (base: string, value: string): string => + value.length === 0 ? "" : value.startsWith("/") ? value : join(base, value); + +/** Resolves one request's persisted override/default against the live functions tree. */ +export const resolveFunctionConfig = async (options: { + readonly root: string; + readonly slug: string; + readonly overrides: FunctionOverrides; + readonly fs: FunctionFileSystem; +}): Promise => { + const { root, slug, overrides, fs } = options; + if (!root.startsWith("/") || !slugPattern.test(slug) || slug === "_shared") return undefined; + const rootInfo = await optionalInfo(fs, root); + if (rootInfo === undefined || !rootInfo.isDirectory || rootInfo.isSymbolicLink) return undefined; + const canonicalRoot = await fs.realPath(root).catch(() => ""); + if (!canonicalRoot.startsWith("/")) return undefined; + const override = overrides[slug]; + if (override?.enabled === false) return undefined; + const functionDirectory = join(canonicalRoot, slug); + const directoryInfo = await optionalInfo(fs, functionDirectory); + if (directoryInfo === undefined || !directoryInfo.isDirectory) return undefined; + if (!(await safeRealPath(fs, canonicalRoot, functionDirectory))) return undefined; + + const rawEntrypoint = override?.entrypointPath ?? override?.entrypoint ?? "index.ts"; + const entrypointPath = relativePath(functionDirectory, rawEntrypoint); + if (!(await safeRealPath(fs, canonicalRoot, entrypointPath))) return undefined; + const entrypointInfo = await optionalInfo(fs, entrypointPath); + if (entrypointInfo === undefined || !entrypointInfo.isFile || entrypointInfo.isSymbolicLink) + return undefined; + + const rawImportMap = override?.importMapPath ?? override?.import_map ?? ""; + let importMapPath = relativePath(functionDirectory, rawImportMap); + if (importMapPath.length > 0) { + if (!(await safeRealPath(fs, canonicalRoot, importMapPath))) return undefined; + const info = await optionalInfo(fs, importMapPath); + if (info === undefined || !info.isFile || info.isSymbolicLink) return undefined; + } else { + for (const candidate of ["deno.json", "deno.jsonc"]) { + const path = join(functionDirectory, candidate); + const info = await optionalInfo(fs, path); + if (info !== undefined) { + if (!info.isFile || info.isSymbolicLink || !(await safeRealPath(fs, canonicalRoot, path))) + return undefined; + importMapPath = path; + break; + } + } + } + + const staticFiles = (override?.staticFiles ?? override?.static_files ?? []).map((pattern) => + relativePath(functionDirectory, pattern), + ); + for (const pattern of staticFiles) { + if (!contained(canonicalRoot, pattern)) return undefined; + const wildcardIndex = pattern.search(globPattern); + const prefix = wildcardIndex < 0 ? pattern : pattern.slice(0, wildcardIndex); + const searchRoot = + wildcardIndex < 0 + ? dirname(pattern) + : prefix.slice(0, Math.max(0, prefix.lastIndexOf("/"))) || canonicalRoot; + if (!(await rejectSymlinkDescendants(fs, canonicalRoot, searchRoot))) return undefined; + if (!globPattern.test(pattern)) { + const info = await optionalInfo(fs, pattern); + if ( + info !== undefined && + (!(await safeRealPath(fs, canonicalRoot, pattern)) || info.isSymbolicLink) + ) + return undefined; + } + } + + return { + entrypointPath, + importMapPath, + staticFiles, + verifyJWT: override?.verifyJWT ?? override?.verify_jwt ?? true, + env: override?.env, + }; +}; + +export const packageJsonPathFor = (config: FunctionConfig): string => + join(dirname(config.entrypointPath), "package.json"); diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index 56e7af606c..7cd6728851 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -2,7 +2,14 @@ declare const Deno: any; declare const EdgeRuntime: any; -import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; +import { dirname, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; +import { + packageJsonPathFor, + resolveFunctionConfig, + type FunctionConfig, + type FunctionFileSystem, + type FunctionOverrides, +} from "./serve-main-resolver.ts"; import * as jose from "jose"; const EXCLUDED_ENVS = ["HOME", "HOSTNAME", "PATH", "PWD"]; @@ -49,16 +56,7 @@ export enum RequestErrors { UnsupportedTokenAlgorithm = "UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM", } -interface FunctionConfig { - entrypointPath: string; - importMapPath: string; - staticFiles: string[]; - verifyJWT: boolean; - enabled: boolean; - env?: Record; -} - -const parseConfig = (): Record => { +const parseConfig = (): FunctionOverrides => { const raw = Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG"); if (!raw) return {}; try { @@ -153,92 +151,36 @@ export async function verifyHybridJWT(jwtSecret: string, jwksUrl: URL, jwt: stri } } -const exists = async (path: string): Promise => { - try { - await Deno.lstat(path); - return true; - } catch { - return false; - } +const denoFileSystem: FunctionFileSystem = { + lstat: async (path) => { + const info = await Deno.lstat(path); + return { + isDirectory: info.isDirectory, + isFile: info.isFile, + isSymbolicLink: info.isSymlink, + }; + }, + realPath: (path) => Deno.realPath(path), + readDirectory: async (path) => { + const entries: string[] = []; + for await (const entry of Deno.readDir(path)) entries.push(entry.name); + return entries; + }, }; -const relativePath = (base: string, value: string): string => - value.length === 0 ? "" : value.startsWith("/") ? value : join(base, value); +const functionConfig = (slug: string): Promise => + resolveFunctionConfig({ root: FUNCTIONS_ROOT, slug, overrides: configured, fs: denoFileSystem }); -const isContained = (root: string, candidate: string): boolean => - candidate === root || candidate.startsWith(`${root.replace(/\/+$/u, "")}/`); - -const isContainedRealPath = async (root: string, candidate: string): Promise => { - if (!isContained(root, candidate)) return false; +const shouldUsePackageJsonDiscovery = async (config: FunctionConfig): Promise => { + if (config.importMapPath) return false; try { - return isContained(await Deno.realPath(root), await Deno.realPath(candidate)); + await denoFileSystem.lstat(packageJsonPathFor(config)); + return true; } catch { return false; } }; -const functionConfig = async (slug: string): Promise => { - if (!/^[A-Za-z0-9_-]+$/u.test(slug) || slug === "_shared") return undefined; - const override = configured[slug]; - if (override?.enabled === false) return undefined; - const root = FUNCTIONS_ROOT.length > 0 ? FUNCTIONS_ROOT : undefined; - if (override === undefined) { - if (root === undefined || !(await exists(join(root, slug)))) return undefined; - } - const functionDirectory = root === undefined ? "" : join(root, slug); - if (root !== undefined && !(await isContainedRealPath(root, functionDirectory))) return undefined; - const rawEntrypoint = override?.entrypointPath ?? override?.entrypoint ?? "index.ts"; - const entrypointPath = relativePath(functionDirectory, rawEntrypoint); - let importMapPath = relativePath( - functionDirectory, - override?.importMapPath ?? override?.import_map ?? "", - ); - if (importMapPath === functionDirectory) importMapPath = ""; - if (importMapPath.length === 0) { - for (const candidate of ["deno.json", "deno.jsonc"]) { - const path = join(functionDirectory, candidate); - if (await exists(path)) { - importMapPath = path; - break; - } - } - } - const staticFiles = (override?.staticFiles ?? override?.static_files ?? []).map((value: string) => - relativePath(functionDirectory, value), - ); - if (root !== undefined) { - if (!isContained(root, entrypointPath) || (importMapPath && !isContained(root, importMapPath))) - return undefined; - for (const pattern of staticFiles) { - const wildcard = pattern.search(/[!*?[{]/u); - const prefix = wildcard < 0 ? pattern : pattern.slice(0, wildcard); - if (!isContained(root, prefix)) return undefined; - if ((await exists(prefix)) && !(await isContainedRealPath(root, prefix))) return undefined; - } - if ((await exists(entrypointPath)) && !(await isContainedRealPath(root, entrypointPath))) - return undefined; - if ( - importMapPath && - (await exists(importMapPath)) && - !(await isContainedRealPath(root, importMapPath)) - ) - return undefined; - } - return { - entrypointPath, - importMapPath, - staticFiles, - verifyJWT: override?.verifyJWT ?? override?.verify_jwt ?? true, - enabled: override?.enabled ?? true, - env: override?.env, - }; -}; - -const shouldUsePackageJsonDiscovery = async (config: FunctionConfig): Promise => { - if (config.importMapPath) return false; - return exists(join(dirname(config.entrypointPath), "package.json")); -}; - export function prepareUserRequest(request: Request): Request { const url = new URL(request.url); const forwardedHost = request.headers.get("x-forwarded-host"); @@ -265,8 +207,6 @@ Deno.serve({ const authFailure = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token); if (authFailure) return getAuthErrorResponse(authFailure); } - if (!(await exists(config.entrypointPath))) - return getResponse("Function not found", STATUS_CODE.NotFound); const envVarsObj = { ...Deno.env.toObject(), ...Object.fromEntries( @@ -313,7 +253,6 @@ Deno.serve({ { code: STATUS_TEXT[STATUS_CODE.InternalServerError], message: "Request failed due to an internal server error", - trace: JSON.stringify(error?.stack), }, STATUS_CODE.InternalServerError, ); @@ -328,12 +267,11 @@ Deno.serve({ `Serving functions on http://127.0.0.1:${HOST_PORT}/functions/v1/${examples.length ? `\n${examples.join("\n")}` : ""}\nUsing ${Deno.version.deno}`, ); }, - onError: (error) => + onError: () => getResponse( { code: STATUS_TEXT[500], message: "Request failed due to an internal server error", - trace: JSON.stringify(error?.stack), }, 500, ), From ce94384e96b46f176b3029f4b4dad1ed0dee2e15 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 12:58:25 +0200 Subject: [PATCH 041/174] fix(stack): contain functions package discovery --- .../functions/serve-main-bundler.unit.test.ts | 2 ++ .../serve-main-resolver.integration.test.ts | 26 +++++++++++++++++-- .../src/functions/serve-main-resolver.ts | 17 ++++++++++++ packages/stack/src/functions/serve.main.ts | 9 ++----- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/stack/src/functions/serve-main-bundler.unit.test.ts b/packages/stack/src/functions/serve-main-bundler.unit.test.ts index 5570fd2ab1..3c50d9f598 100644 --- a/packages/stack/src/functions/serve-main-bundler.unit.test.ts +++ b/packages/stack/src/functions/serve-main-bundler.unit.test.ts @@ -8,5 +8,7 @@ describe("stack-owned functions bootstrap", () => { expect(bundled).not.toContain("jsr:"); expect(bundled).not.toContain("https://"); expect(bundled).not.toMatch(/from\s*["']jose["']/u); + expect(bundled).toContain("forceCreate:!0"); + expect(bundled).toContain("noModuleCache:!0"); }); }); diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts index 10fddb58bd..508a3b1acc 100644 --- a/packages/stack/src/functions/serve-main-resolver.integration.test.ts +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -12,7 +12,11 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveFunctionConfig, type FunctionFileSystem } from "./serve-main-resolver.ts"; +import { + packageJsonContainedFor, + resolveFunctionConfig, + type FunctionFileSystem, +} from "./serve-main-resolver.ts"; const nodeFileSystem: FunctionFileSystem = { lstat: async (path) => { @@ -28,7 +32,7 @@ const nodeFileSystem: FunctionFileSystem = { }; describe("Edge Runtime request-time function resolver", () => { - it("discovers defaults and reflects live create/edit/delete without caching", async () => { + it("resolves current filesystem paths for create/edit/delete", async () => { const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-")); try { const hello = join(root, "hello"); @@ -48,6 +52,14 @@ describe("Edge Runtime request-time function resolver", () => { importMapPath: join(canonicalRoot, "hello", "deno.json"), verifyJWT: true, }); + await writeFile(join(hello, "package.json"), "{}"); + expect( + await packageJsonContainedFor({ + root, + config: { ...defaults!, importMapPath: "" }, + fs: nodeFileSystem, + }), + ).toBe(true); await writeFile(join(hello, "index.ts"), "export default 2"); const edited = await resolveFunctionConfig({ @@ -120,6 +132,16 @@ describe("Edge Runtime request-time function resolver", () => { const publicRoot = join(functionRoot, "public"); await mkdir(publicRoot, { recursive: true }); await writeFile(join(functionRoot, "index.ts"), "export default 1"); + await writeFile(join(outside, "package.json"), "{}"); + await symlink(join(outside, "package.json"), join(functionRoot, "package.json")); + const config = { + entrypointPath: join(functionRoot, "index.ts"), + importMapPath: "", + staticFiles: [], + verifyJWT: true, + }; + expect(await packageJsonContainedFor({ root, config, fs: nodeFileSystem })).toBe(false); + await unlink(join(functionRoot, "package.json")); await writeFile(join(publicRoot, "ok.txt"), "ok"); expect( await resolveFunctionConfig({ diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index 394501ee5d..3b01f601c4 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -173,3 +173,20 @@ export const resolveFunctionConfig = async (options: { export const packageJsonPathFor = (config: FunctionConfig): string => join(dirname(config.entrypointPath), "package.json"); + +/** Checks package discovery without allowing a package.json symlink to leave the root. */ +export const packageJsonContainedFor = async (options: { + readonly root: string; + readonly config: FunctionConfig; + readonly fs: FunctionFileSystem; +}): Promise => { + if (!options.root.startsWith("/")) return false; + const rootInfo = await optionalInfo(options.fs, options.root); + if (rootInfo === undefined || !rootInfo.isDirectory || rootInfo.isSymbolicLink) return false; + const canonicalRoot = await options.fs.realPath(options.root).catch(() => ""); + if (!canonicalRoot.startsWith("/")) return false; + const packagePath = packageJsonPathFor(options.config); + const packageInfo = await optionalInfo(options.fs, packagePath); + if (packageInfo === undefined || !packageInfo.isFile || packageInfo.isSymbolicLink) return false; + return safeRealPath(options.fs, canonicalRoot, packagePath); +}; diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index 7cd6728851..b58cb62a5c 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -4,7 +4,7 @@ declare const EdgeRuntime: any; import { dirname, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; import { - packageJsonPathFor, + packageJsonContainedFor, resolveFunctionConfig, type FunctionConfig, type FunctionFileSystem, @@ -173,12 +173,7 @@ const functionConfig = (slug: string): Promise => const shouldUsePackageJsonDiscovery = async (config: FunctionConfig): Promise => { if (config.importMapPath) return false; - try { - await denoFileSystem.lstat(packageJsonPathFor(config)); - return true; - } catch { - return false; - } + return packageJsonContainedFor({ root: FUNCTIONS_ROOT, config, fs: denoFileSystem }); }; export function prepareUserRequest(request: Request): Request { From ff7dd8e0fcbad80f1f81b5fb8ea5882508cdf667 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:03:12 +0200 Subject: [PATCH 042/174] fix(stack): provide functions api credentials --- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 5 ++++ .../workload-runtime.integration.test.ts | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index f1bf9a0bd0..27adacc488 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -359,6 +359,11 @@ const edgeRuntimeJwtEnvironment = ( ): Record => compactEnvironment({ SUPABASE_INTERNAL_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + SUPABASE_INTERNAL_PUBLISHABLE_KEY: secret(state, "secret:auth.settings.publishable_key"), + SUPABASE_INTERNAL_SECRET_KEY: secret(state, "secret:auth.settings.secret_key"), + SUPABASE_INTERNAL_HOST_PORT: String( + state.ports.find((assignment) => assignment.field === "api")?.port ?? "", + ), SUPABASE_JWKS: inputs.auth?.jwks ?? '{"keys":[]}', }); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 4a74e49a20..3fb811a42f 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -55,6 +55,11 @@ const state: PersistedStackState = { secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, "secret:auth.settings.jwt_secret": { policy: "managed", value: "symmetric-secret" }, + "secret:auth.settings.publishable_key": { + policy: "managed", + value: "sb_publishable_test", + }, + "secret:auth.settings.secret_key": { policy: "managed", value: "sb_secret_test" }, }, }; @@ -621,11 +626,36 @@ describe("workload runtime catalog", () => { it.live("passes Edge Runtime the JWT material used by functions serve", () => Effect.gen(function* () { const functions = planned("functions:edge-runtime"); + const functionSpec = runtimeSpecFor(functions); + expect(functionSpec).toBeDefined(); + if (functionSpec === undefined) return; const symmetricDefault = containerResolutionFor(state, functions); expect(symmetricDefault?.env).toMatchObject({ SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", + SUPABASE_INTERNAL_PUBLISHABLE_KEY: "sb_publishable_test", + SUPABASE_INTERNAL_SECRET_KEY: "sb_secret_test", + SUPABASE_INTERNAL_HOST_PORT: "54321", SUPABASE_JWKS: '{"keys":[]}', }); + expect( + functionSpec?.containerArgs(state, functions, functionSpec.containerPort), + ).not.toContain("sb_publishable_test"); + expect( + functionSpec?.containerArgs(state, functions, functionSpec.containerPort), + ).not.toContain("sb_secret_test"); + expect(functionSpec?.args(state, functions, functionSpec.containerPort)).not.toContain( + "sb_publishable_test", + ); + expect(functionSpec?.args(state, functions, functionSpec.containerPort)).not.toContain( + "sb_secret_test", + ); + const withoutApiAssignment: PersistedStackState = { + ...state, + ports: state.ports.filter((assignment) => assignment.field !== "api"), + }; + expect(containerResolutionFor(withoutApiAssignment, functions)?.env).not.toHaveProperty( + "SUPABASE_INTERNAL_HOST_PORT", + ); const symmetric = containerResolutionFor(state, functions, { auth: { jwks: '{"keys":[{"kty":"EC"}]}' }, }); From 20bc523ce3832bf59a747ac16149c448fca79d56 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:06:26 +0200 Subject: [PATCH 043/174] feat(stack): own supervisor ingress lifecycle --- packages/stack/src/supervisor/HostListener.ts | 157 +++++++++++ packages/stack/src/supervisor/Ingress.ts | 254 ++++++++++++++++++ packages/stack/src/supervisor/Supervisor.ts | 137 ++++++++-- .../host-listener.integration.test.ts | 68 +++++ .../supervisor/supervisor.integration.test.ts | 29 +- 5 files changed, 620 insertions(+), 25 deletions(-) create mode 100644 packages/stack/src/supervisor/HostListener.ts create mode 100644 packages/stack/src/supervisor/Ingress.ts create mode 100644 packages/stack/src/supervisor/host-listener.integration.test.ts diff --git a/packages/stack/src/supervisor/HostListener.ts b/packages/stack/src/supervisor/HostListener.ts new file mode 100644 index 0000000000..42d69ad54f --- /dev/null +++ b/packages/stack/src/supervisor/HostListener.ts @@ -0,0 +1,157 @@ +import { Effect, Scope } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createServer as createNetServer, type Server as NetServer } from "node:net"; +import { PortUnavailableError } from "../public/Errors.ts"; +import { type HostListener } from "../state/PortCoordinator.ts"; +import type { PortField } from "../public/Status.ts"; + +const HTTP_FIELDS: ReadonlySet = new Set([ + "api", + "studio", + "mailUi", + "functionsInspector", +]); + +export interface HostListenerBindOptions { + readonly createHttpServer?: () => HttpServer; + readonly createTcpServer?: () => NetServer; + /** Test/embedding seam for a foreign listener API; production uses Server.listen directly. */ + readonly listen?: ( + server: HttpServer | NetServer, + address: string, + port: number, + onListening: () => void, + ) => void; +} + +const closeServer = (server: HttpServer | NetServer): Effect.Effect => + Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return; + } + server.close(() => resume(Effect.void)); + return Effect.sync(() => { + if (server.listening) server.close(() => undefined); + }); + }); + +const bind = ( + server: T, + address: string, + port: number, + field: PortField, + listen: HostListenerBindOptions["listen"] = (value, host, number, onListening) => + value.listen({ host, port: number }, onListening), +): Effect.Effect => + Effect.callback((resume) => { + let settled = false; + const cleanup = () => { + server.off("error", onError); + }; + const onError = (cause: Error) => { + if (settled) return; + settled = true; + cleanup(); + resume( + Effect.fail( + new PortUnavailableError({ + field, + port, + message: "Host listener is unavailable", + cause, + }), + ), + ); + }; + server.once("error", onError); + try { + listen(server, address, port, () => { + if (settled) return; + settled = true; + cleanup(); + resume(Effect.succeed(server)); + }); + } catch (cause) { + onError(cause instanceof Error ? cause : new Error(String(cause))); + } + return Effect.sync(() => { + if (settled) return; + settled = true; + cleanup(); + // `listen` may still be completing when the waiting fiber is interrupted. Keep a temporary + // error sink while asking Node to close the exact server so a late bind failure cannot become + // an uncaught process error. `close` throws synchronously when no handle exists; in that case + // the server has no owned resources left to release. + const swallow = () => undefined; + server.once("error", swallow); + try { + server.close(() => server.off("error", swallow)); + } catch { + server.off("error", swallow); + } + }); + }); + +/** Bind and retain one public host listener for direct adoption by a gateway. */ +export const bindHostListener = ( + address: string, + port: number, + field: PortField, +): Effect.Effect => + bindHostListenerWithOptions(address, port, field); + +export const bindHostListenerWithOptions = ( + address: string, + port: number, + field: PortField, + options: HostListenerBindOptions = {}, +): Effect.Effect => { + if (HTTP_FIELDS.has(field)) + return bind( + options.createHttpServer?.() ?? createHttpServer(), + address, + port, + field, + options.listen, + ).pipe( + Effect.flatMap((server) => + Effect.gen(function* () { + const close = yield* Effect.cached(closeServer(server)); + yield* Effect.addFinalizer(() => close); + return { + field, + address, + port, + close, + binding: { kind: "http", server }, + } satisfies HostListener; + }), + ), + ); + return bind( + options.createTcpServer?.() ?? createNetServer({ allowHalfOpen: true }), + address, + port, + field, + options.listen, + ).pipe( + Effect.flatMap((server) => + Effect.gen(function* () { + const close = yield* Effect.cached(closeServer(server)); + yield* Effect.addFinalizer(() => close); + return { + field, + address, + port, + close, + binding: { kind: "tcp", server, allowHalfOpen: true }, + } satisfies HostListener; + }), + ), + ); +}; + +export const isHttpPortField = (field: PortField): boolean => HTTP_FIELDS.has(field); diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts new file mode 100644 index 0000000000..aca8189708 --- /dev/null +++ b/packages/stack/src/supervisor/Ingress.ts @@ -0,0 +1,254 @@ +import { Context, Crypto, Effect, FileSystem, Path, Ref, Scope, Semaphore } from "effect"; +import type { LifecycleInput } from "./Lifecycle.ts"; +import type { + ActivationResult, + GatewayRoute, + GatewayRouteRequest, + StackGateway, +} from "../gateway/Gateway.ts"; +import { + GatewayActivationError, + StackPreparationError, + type StackError, +} from "../public/Errors.ts"; +import { routeCatalogFor, type GatewayApiMaterial } from "../gateway/RouteCatalog.ts"; +import { makeGateway } from "../gateway/Gateway.ts"; +import { + makePortCoordinator, + type HostListener, + type ListenerIntents, + type PortCoordinator, + type PortReservation, +} from "../state/PortCoordinator.ts"; +import type { PortRegistry } from "../state/PortRegistry.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; +import type { PortField } from "../public/Status.ts"; +import { bindHostListener, isHttpPortField } from "./HostListener.ts"; + +export interface SupervisorIngressReservation extends PortReservation { + readonly generation: number; + /** False when this generation already owns the exact listeners and gateway. */ + readonly fresh: boolean; +} + +export interface SupervisorIngress { + /** Reserve durable ports and bind public listeners before workload reconciliation. */ + readonly acquire: ( + input: LifecycleInput, + ) => Effect.Effect; + /** Adopt acquired listeners into HTTP/TCP gateways after workloads are ready. */ + readonly open: ( + input: LifecycleInput, + reservation: SupervisorIngressReservation, + activate: ( + capability: import("../public/Capability.ts").CapabilityName, + ) => Effect.Effect, + ) => Effect.Effect; + /** Close gateway, accepted sockets, and exact listeners; safe to call repeatedly. */ + readonly close: Effect.Effect; +} + +export interface SupervisorIngressOptions { + readonly stackId: string; + readonly registry: PortRegistry; + readonly store: StackStateStore; + readonly portCoordinator?: PortCoordinator; + readonly context: Context.Context; + /** Resolver may be replaced by the production credential owner. */ + readonly apiMaterial?: ( + state: LifecycleInput["state"], + ) => Effect.Effect; + readonly bindHost?: ( + address: string, + port: number, + field: PortField, + ) => Effect.Effect; +} + +const listenerIntents = (input: LifecycleInput): ListenerIntents => ({ + api: input.definition.listeners.api, + database: input.definition.listeners.database, + pooler: input.definition.listeners.pooler, + studio: input.definition.listeners.studio, + mailUi: input.definition.listeners.mailUi, + smtp: input.definition.listeners.smtp, + pop3: input.definition.listeners.pop3, + functionsInspector: input.definition.listeners.functionsInspector, +}); + +const defaultApiMaterial = ( + state: LifecycleInput["state"], +): Effect.Effect => { + const get = (slot: string): string | undefined => state.secrets[slot]?.value; + const publishableKey = get("secret:auth.settings.publishable_key"); + const secretKey = get("secret:auth.settings.secret_key"); + const anonJwt = get("secret:auth.settings.anon_key"); + const serviceRoleJwt = get("secret:auth.settings.service_role_key"); + if ( + publishableKey === undefined || + secretKey === undefined || + anonJwt === undefined || + serviceRoleJwt === undefined + ) + return Effect.fail( + new StackPreparationError({ message: "Persisted API gateway material is incomplete" }), + ); + return Effect.succeed({ publishableKey, secretKey, anonJwt, serviceRoleJwt }); +}; + +const routeBackend = ( + input: LifecycleInput, + reservation: SupervisorIngressReservation, + route: Pick, + activation: ActivationResult, +) => { + if (route.binding === undefined) return Effect.succeed(activation.endpoint); + const workload = input.plan.workloads.find((entry) => entry.capability === route.capability); + const assignment = reservation.privateAssignments.find( + (entry) => entry.workloadId === workload?.id && entry.binding === route.binding, + ); + return assignment === undefined + ? Effect.fail(new GatewayActivationError({ message: "Gateway private binding is unavailable" })) + : Effect.succeed({ host: "127.0.0.1", port: assignment.port }); +}; + +/** Compose PortCoordinator and StackGateway under one Supervisor owner scope. */ +export const makeSupervisorIngress = ( + options: SupervisorIngressOptions, +): Effect.Effect< + SupervisorIngress, + StackError, + Scope.Scope | Crypto.Crypto | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const ownerScope = yield* Scope.Scope; + const lock = yield* Semaphore.make(1); + const current = yield* Ref.make< + | { + readonly input: LifecycleInput; + readonly reservation: SupervisorIngressReservation; + readonly gateway?: StackGateway; + } + | undefined + >(undefined); + const coordinator = + options.portCoordinator ?? + makePortCoordinator(options.registry, options.store, { + bindHost: options.bindHost ?? bindHostListener, + }); + const acquire = ( + input: LifecycleInput, + ): Effect.Effect => + lock.withPermit( + Effect.gen(function* () { + const existing = yield* Ref.get(current); + if (existing !== undefined && existing.reservation.generation === input.generation) + return { ...existing.reservation, fresh: false }; + if (existing !== undefined) yield* closeCurrent(existing); + const reservation = yield* coordinator + .planAndReserve(options.stackId, listenerIntents(input), { + lifecycle: "running", + expectedGeneration: input.generation, + nextGeneration: input.generation, + privateBindings: privateBindingIntentsFor(input.plan), + }) + .pipe( + Effect.provideContext(options.context), + Effect.provideService(Scope.Scope, ownerScope), + ); + const owned: SupervisorIngressReservation = { + ...reservation, + generation: input.generation, + fresh: true, + }; + yield* Ref.set(current, { input, reservation: owned }); + return owned; + }), + ); + + const closeCurrent = (entry: { + readonly reservation: SupervisorIngressReservation; + readonly gateway?: StackGateway; + }): Effect.Effect => + Effect.gen(function* () { + if (entry.gateway !== undefined) yield* entry.gateway.close; + yield* Effect.forEach(entry.reservation.hostListeners, (listener) => listener.close, { + concurrency: "unbounded", + discard: true, + }); + }); + + const close: Effect.Effect = lock.withPermit( + Effect.gen(function* () { + const entry = yield* Ref.get(current); + if (entry === undefined) return; + yield* closeCurrent(entry); + yield* Ref.set(current, undefined); + }), + ); + + const open = ( + input: LifecycleInput, + reservation: SupervisorIngressReservation, + activate: ( + capability: import("../public/Capability.ts").CapabilityName, + ) => Effect.Effect, + ): Effect.Effect => + lock.withPermit( + Effect.gen(function* () { + const entry = yield* Ref.get(current); + if ( + entry !== undefined && + entry.reservation.generation === reservation.generation && + entry.gateway !== undefined + ) + return; + const material = yield* (options.apiMaterial ?? defaultApiMaterial)(input.state); + const catalog = routeCatalogFor(input.plan, material); + const http = reservation.hostListeners + .filter((listener) => isHttpPortField(listener.field)) + .map((listener) => ({ + field: listener.field, + options: { + listener, + routes: catalog.http.get(listener.field) ?? [], + resolveBackend: ( + route: GatewayRoute, + _request: GatewayRouteRequest, + result: ActivationResult, + ) => routeBackend(input, reservation, route, result), + }, + })); + const tcp = reservation.hostListeners + .filter((listener) => !isHttpPortField(listener.field)) + .map((listener) => ({ + field: listener.field, + options: { + listener, + routes: catalog.tcp.get(listener.field) ?? [], + resolveBackend: ( + route: GatewayRoute, + _request: GatewayRouteRequest, + result: ActivationResult, + ) => routeBackend(input, reservation, route, result), + }, + })); + const gateway = yield* makeGateway({ + http, + tcp, + activate: (capability) => + activate(capability).pipe( + Effect.mapError((error) => + error instanceof GatewayActivationError + ? error + : new GatewayActivationError({ message: error.message, cause: error }), + ), + ), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + yield* Ref.set(current, { input, reservation, gateway }); + }), + ); + + return { acquire, open, close } satisfies SupervisorIngress; + }); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index fcc864d1ea..851ed03ab8 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -1,4 +1,15 @@ -import { Context, Crypto, Effect, FileSystem, Path, Ref, Schema, Stream } from "effect"; +import { + Context, + Crypto, + Deferred, + Effect, + Exit, + FileSystem, + Path, + Ref, + Schema, + Stream, +} from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; import { rebuildExecutionPlan } from "../model/Compiler.ts"; import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; @@ -62,6 +73,7 @@ import { } from "./Lifecycle.ts"; import { makeStatusHub } from "./StatusHub.ts"; import type { LogStore } from "./LogStore.ts"; +import type { SupervisorIngress } from "./Ingress.ts"; import { StackRpcGroup, type StackRpcError, type StackRpcHandlers } from "../control/StackRpc.ts"; import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; @@ -78,6 +90,8 @@ export interface SupervisorRuntime { capability: CapabilityName, input: LifecycleInput, ) => Effect.Effect; + /** Optional Supervisor-owned public ingress and lazy route activation lifecycle. */ + readonly ingress?: SupervisorIngress; readonly logStore?: LogStore; } @@ -363,6 +377,17 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect>(new Set()); const generation = yield* Ref.make(initial.desiredGeneration); const phase = yield* Ref.make("stopped"); + type ActivationHandler = ( + capability: CapabilityName, + ) => Effect.Effect; + // The ingress is opened during persisted-running recovery, before the public Supervisor + // methods are assembled. A one-shot handoff keeps a request waiting for the handler instead of + // exposing a construction-time race. + const activationHandler = yield* Deferred.make(); + const ingressActivate = ( + capability: CapabilityName, + ): Effect.Effect => + Deferred.await(activationHandler).pipe(Effect.flatMap((handler) => handler(capability))); const initializeActivation = ( plan: ExecutionPlan, observed: ReadonlyArray = [], @@ -402,6 +427,52 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect 0) { + if (reservation?.fresh === true && runtime.ingress !== undefined) + yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* new StackReconciliationError({ + message: reconciliation.value.failed + .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) + .join("; "), + }); + } + if (runtime.ingress !== undefined && reservation !== undefined) { + const opened = yield* runtime.ingress + .open(recoveryInput, reservation, ingressActivate) + .pipe(Effect.exit); + if (Exit.isFailure(opened)) { + if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* Effect.failCause(opened.cause); + } + } + yield* Ref.set(phase, "running"); } else { yield* runtime.driver .cleanup({ stackId: options.stackId, destroy: false }) @@ -451,39 +522,68 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect Effect.gen(function* () { + const reservation = + input.desiredLifecycle === "running" && runtime.ingress !== undefined + ? yield* runtime.ingress.acquire(input) + : undefined; + if (input.desiredLifecycle !== "running" && runtime.ingress !== undefined) + yield* runtime.ingress.close; yield* resetForGeneration(input); const selected = yield* Ref.get(active); const plan = input.desiredLifecycle === "running" ? activePlan(input.plan, selected) : input.plan; - const result = yield* reconciler + const reconciled = yield* reconciler .reconcile({ stackId: options.stackId, desiredGeneration: input.generation, desiredLifecycle: input.desiredLifecycle, plan, }) - .pipe(Effect.mapError(mapReconcileError)); + .pipe(Effect.mapError(mapReconcileError), Effect.exit); + if (Exit.isFailure(reconciled)) { + if (reservation?.fresh === true && runtime.ingress !== undefined) + yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* Effect.failCause(reconciled.cause); + } + const result = reconciled.value; if (result.failed.length > 0) - return yield* new StackReconciliationError({ - message: result.failed - .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) - .join("; "), + return yield* Effect.gen(function* () { + if (reservation?.fresh === true && runtime.ingress !== undefined) + yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* new StackReconciliationError({ + message: result.failed + .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) + .join("; "), + }); }); + if (runtime.ingress !== undefined && reservation !== undefined) { + const opened = yield* runtime.ingress + .open(input, reservation, ingressActivate) + .pipe(Effect.exit); + if (Exit.isFailure(opened)) { + if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* Effect.failCause(opened.cause); + } + } yield* publish(); }), cleanup: () => - runtime.driver - .cleanup({ stackId: options.stackId, destroy: false }) - .pipe( - Effect.mapError(mapReconcileError), - Effect.andThen(Ref.set(active, new Set())), - Effect.andThen(Ref.set(phase, "stopped")), - Effect.andThen(publish()), - ), + Effect.gen(function* () { + if (runtime.ingress !== undefined) yield* runtime.ingress.close; + yield* runtime.driver + .cleanup({ stackId: options.stackId, destroy: false }) + .pipe(Effect.mapError(mapReconcileError)); + yield* Ref.set(active, new Set()); + yield* Ref.set(phase, "stopped"); + yield* publish(); + }), destroyData: () => - runtime.driver - .cleanup({ stackId: options.stackId, destroy: true }) - .pipe(Effect.mapError(mapReconcileError)), + Effect.gen(function* () { + if (runtime.ingress !== undefined) yield* runtime.ingress.close; + yield* runtime.driver + .cleanup({ stackId: options.stackId, destroy: true }) + .pipe(Effect.mapError(mapReconcileError)); + }), }; const controller = yield* makeLifecycleController({ stackId: options.stackId, @@ -544,6 +644,7 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/host-listener.integration.test.ts b/packages/stack/src/supervisor/host-listener.integration.test.ts new file mode 100644 index 0000000000..4967e9fb0e --- /dev/null +++ b/packages/stack/src/supervisor/host-listener.integration.test.ts @@ -0,0 +1,68 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Fiber, Option } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createServer, type Server as HttpServer } from "node:http"; +import { PortUnavailableError } from "../public/Errors.ts"; +import { bindHostListener, bindHostListenerWithOptions } from "./HostListener.ts"; + +const run = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +describe("host listener binding", () => { + it.live("binds HTTP and TCP listeners for direct gateway adoption", () => + run( + Effect.gen(function* () { + const http = yield* bindHostListener("127.0.0.1", 0, "api"); + const tcp = yield* bindHostListener("127.0.0.1", 0, "database"); + expect(http.binding.kind).toBe("http"); + expect(tcp.binding.kind).toBe("tcp"); + expect(http.binding.server.listening).toBe(true); + expect(tcp.binding.server.listening).toBe(true); + yield* http.close; + yield* http.close; + yield* tcp.close; + expect(http.binding.server.listening).toBe(false); + expect(tcp.binding.server.listening).toBe(false); + }), + ), + ); + + it.live("reports an occupied exact listener as a typed unavailable error", () => + run( + Effect.gen(function* () { + const held = yield* bindHostListener("127.0.0.1", 0, "api"); + const address = held.binding.server.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("bound listener did not expose an address"); + const failed = yield* bindHostListener("127.0.0.1", address.port, "api").pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(PortUnavailableError); + }), + ), + ); + + it.live("cancels a listener whose bind callback has not fired", () => + run( + Effect.gen(function* () { + let server: HttpServer | undefined; + // The real server factory is intentionally used through a deferred listen seam below; + // this keeps the cancellation test deterministic without racing a kernel bind. + const effect = bindHostListenerWithOptions("127.0.0.1", 0, "api", { + createHttpServer: () => { + const created = createServer(); + server = created; + return created; + }, + listen: () => undefined, + }); + const fiber = yield* Effect.forkChild(effect); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + expect(server?.listening).toBe(false); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index c2eb11bec3..91cf0b2124 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -216,11 +216,12 @@ describe("Supervisor composition", () => { ), ); - it.live("recovers a running durable intent without auto-starting it", () => + it.live("recovers a running durable intent and starts missing eager workloads", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const recoveredResources = yield* Ref.make>([]); const recovered = yield* makeSupervisor({ identity, stackId: fixture.id, @@ -230,11 +231,25 @@ describe("Supervisor composition", () => { context: fixture.context, runtime: { driver: { - observe: () => Effect.succeed([]), - start: () => Effect.die("recovery test must not start resources"), - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, + observe: () => Ref.get(recoveredResources), + start: (key) => + Effect.gen(function* () { + const ready = { ...key, state: "ready" as const }; + yield* Ref.update(recoveredResources, (current) => [ + ...current.filter((entry) => entry.workloadId !== key.workloadId), + ready, + ]); + return ready; + }), + stop: (key) => + Ref.update(recoveredResources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ), + remove: (key) => + Ref.update(recoveredResources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ), + cleanup: () => Ref.set(recoveredResources, []), recover: () => Ref.update(fixture.calls, (current) => [...current, "recover"]).pipe(Effect.as([])), }, @@ -242,7 +257,7 @@ describe("Supervisor composition", () => { }); const status = yield* recovered.status; expect(status.desiredLifecycle).toBe("running"); - expect(status.lifecycle).toBe("stopped"); + expect(status.lifecycle).toBe("running"); expect(yield* Ref.get(fixture.calls)).toContain("recover"); }), ), From 97f199b4d5e47bcf3f2cf7a8fd3471d4b16f9e79 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:12:08 +0200 Subject: [PATCH 044/174] fix(stack): harden ingress ownership and port startup --- packages/stack/src/state/PortCoordinator.ts | 10 +- .../stack/src/state/ports.integration.test.ts | 38 +++ packages/stack/src/supervisor/HostListener.ts | 7 + .../host-listener.integration.test.ts | 10 +- .../supervisor/ingress.integration.test.ts | 257 ++++++++++++++++++ .../supervisor/supervisor.integration.test.ts | 168 +++++++++++- 6 files changed, 483 insertions(+), 7 deletions(-) create mode 100644 packages/stack/src/supervisor/ingress.integration.test.ts diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 1d5351c94d..52f2143fc4 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -179,7 +179,15 @@ export const makePortCoordinator = ( current.privatePorts.map((entry) => [bindingKey(entry), entry]), ); - if (lifecycle === "running" && current.desiredLifecycle === "running") { + // A newly accepted generation has running durable intent before its first ingress + // acquisition, but has no assignments yet. Enforce the no-change fence only once the + // prior generation actually owns ports; this preserves same-generation no-rebind while + // allowing the initial listener transaction to materialize its assignments. + if ( + lifecycle === "running" && + current.desiredLifecycle === "running" && + (current.ports.length > 0 || current.privatePorts.length > 0) + ) { for (const field of fields) { const intent = listenerIntents[field]; const prior = existing.get(field); diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index dbbc1721b5..74714f72f7 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -311,6 +311,44 @@ describe("sticky port coordination", () => { ), ); + it.live("materializes ports for a newly accepted running generation", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-running-initial-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "running-initial"); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, { + ...baseState(stackId, identity, "running"), + desiredGeneration: 5, + ports: [], + privatePorts: [], + }); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store, { + bindHost: bindTestNative, + }); + const result = yield* coordinator.planAndReserve( + stackId, + { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55440 }, + }, + { + lifecycle: "running", + expectedGeneration: 5, + nextGeneration: 5, + }, + ); + expect(result.assignments.api?.port).toBe(55440); + expect((yield* store.read(stackId))?.desiredLifecycle).toBe("running"); + }), + ), + ); + it.live("allows exact assignments to coexist while stopped but rejects live conflicts", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/HostListener.ts b/packages/stack/src/supervisor/HostListener.ts index 42d69ad54f..2cd238ec24 100644 --- a/packages/stack/src/supervisor/HostListener.ts +++ b/packages/stack/src/supervisor/HostListener.ts @@ -55,6 +55,13 @@ const bind = ( if (settled) return; settled = true; cleanup(); + const swallow = () => undefined; + server.once("error", swallow); + try { + server.close(() => server.off("error", swallow)); + } catch { + server.off("error", swallow); + } resume( Effect.fail( new PortUnavailableError({ diff --git a/packages/stack/src/supervisor/host-listener.integration.test.ts b/packages/stack/src/supervisor/host-listener.integration.test.ts index 4967e9fb0e..fe8f76eeb0 100644 --- a/packages/stack/src/supervisor/host-listener.integration.test.ts +++ b/packages/stack/src/supervisor/host-listener.integration.test.ts @@ -38,8 +38,16 @@ describe("host listener binding", () => { const address = held.binding.server.address(); if (typeof address !== "object" || address === null) return yield* Effect.die("bound listener did not expose an address"); - const failed = yield* bindHostListener("127.0.0.1", address.port, "api").pipe(Effect.exit); + let failedServer: HttpServer | undefined; + const failed = yield* bindHostListenerWithOptions("127.0.0.1", address.port, "api", { + createHttpServer: () => { + const created = createServer(); + failedServer = created; + return created; + }, + }).pipe(Effect.exit); expect(errorOf(failed)).toBeInstanceOf(PortUnavailableError); + expect(failedServer?.listening).toBe(false); }), ), ); diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts new file mode 100644 index 0000000000..29d045a23c --- /dev/null +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -0,0 +1,257 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Crypto, Effect, Exit, FileSystem, Path } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { + createServer as createHttpServer, + request as requestHttp, + type IncomingMessage, + type ServerResponse, + // oxlint-disable-next-line effecttsgo/node-builtin-import +} from "node:http"; +import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; +import { compileStack } from "../model/Compiler.ts"; +import { GatewayActivationError } from "../public/Errors.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { makePortRegistry } from "../state/PortRegistry.ts"; +import { makeSupervisorIngress } from "./Ingress.ts"; + +const identity: StackIdentity = { + projectRoot: "/tmp/supabase-ingress", + checkoutRoot: "/tmp/supabase-ingress", + workspaceId: "/tmp/supabase-ingress", + checkoutId: "/tmp/supabase-ingress", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "ingress", +}; + +const run = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const closeServer = (server: ReturnType): Effect.Effect => + Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void); + server.close(() => resume(Effect.void)); + }); + +const listenBackend = (server: ReturnType) => + Effect.acquireRelease( + Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => closeServer(server), + ).pipe(Effect.as(server)); + +const request = (port: number) => + Effect.callback<{ readonly status: number; readonly body: string }, Error>((resume) => { + const client = requestHttp( + { host: "127.0.0.1", port, path: "/rest/v1/items", method: "GET" }, + (response: IncomingMessage) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.once("end", () => + resume( + Effect.succeed({ + status: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + }), + ), + ); + }, + ); + client.once("error", (error) => resume(Effect.fail(error))); + client.end(); + return Effect.sync(() => client.destroy()); + }); + +describe("Supervisor ingress", () => { + it.live("adopts a coordinated listener and forwards a public request", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-ingress-" }); + const stackIdentity = { + ...identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }; + const stackId = yield* deriveStackId(stackIdentity); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + capabilities: { + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + listeners: { + database: { enabled: false }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + }, + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.write(stackId, { + format: "supabase-stack-state-v1", + identity: { ...stackIdentity, stackId }, + runtime: { kind: "native" }, + desiredGeneration: 1, + desiredLifecycle: "running", + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + ports: [], + privatePorts: [], + secrets: {}, + }); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const ingress = yield* makeSupervisorIngress({ + stackId, + registry, + store, + context, + apiMaterial: () => + Effect.succeed({ + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + anonJwt: "anon-jwt", + serviceRoleJwt: "service-jwt", + }), + }); + const input = { + stackId, + generation: 1, + desiredLifecycle: "running" as const, + state: yield* store.read(stackId).pipe(Effect.map((value) => value!)), + previous: yield* store.read(stackId).pipe(Effect.map((value) => value!)), + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: {}, + plan: compiled.executionPlan, + }; + const reservation = yield* ingress.acquire(input); + expect(reservation.hostListeners).toHaveLength(1); + const backend = yield* listenBackend( + createHttpServer((_request: IncomingMessage, response: ServerResponse) => { + response.statusCode = 200; + response.end("forwarded"); + }), + ); + const backendAddress = backend.address(); + if (typeof backendAddress !== "object" || backendAddress === null) + return yield* Effect.die("backend did not expose an address"); + yield* ingress.open(input, reservation, (capability) => + Effect.succeed({ + capability, + endpoint: { host: "127.0.0.1", port: backendAddress.port }, + }), + ); + const api = reservation.assignments.api; + if (api === undefined) return yield* Effect.die("API listener was not assigned"); + const response = yield* request(api.port); + expect(response.status).toBe(200); + expect(response.body).toBe("forwarded"); + yield* ingress.close; + }), + ), + ); + + it.live("rejects incomplete persisted gateway material before opening", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-ingress-material-" }); + const stackId = yield* deriveStackId({ + ...identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { capabilities: { rest: {} } }, + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.write(stackId, { + format: "supabase-stack-state-v1", + identity: { + ...identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + stackId, + }, + runtime: { kind: "native" }, + desiredGeneration: 1, + desiredLifecycle: "running", + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + ports: [], + privatePorts: [], + secrets: {}, + }); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const ingress = yield* makeSupervisorIngress({ stackId, registry, store, context }); + const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); + const reservation = yield* ingress.acquire({ + stackId, + generation: 1, + desiredLifecycle: "running", + state, + previous: state, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: {}, + plan: compiled.executionPlan, + }); + const failed = yield* ingress + .open( + { + stackId, + generation: 1, + desiredLifecycle: "running", + state, + previous: state, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: {}, + plan: compiled.executionPlan, + }, + reservation, + () => Effect.fail(new GatewayActivationError({ message: "not reached" })), + ) + .pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + }), + ), + ); +}); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 91cf0b2124..47da3938e2 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -2,7 +2,11 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Ref, Stream } from "effect"; import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; -import { StackNotRunningError, StackUpgradeRequiredError } from "../public/Errors.ts"; +import { + GatewayActivationError, + StackNotRunningError, + StackUpgradeRequiredError, +} from "../public/Errors.ts"; import { RuntimeDriverError, type RuntimeDriver, @@ -12,6 +16,7 @@ import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { deriveStackId } from "../identity/Identity.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import { makeSupervisor, type SupervisorRuntime } from "./Supervisor.ts"; +import type { SupervisorIngress, SupervisorIngressReservation } from "./Ingress.ts"; const identity = { projectRoot: "/tmp/supabase-supervisor", @@ -26,7 +31,62 @@ const identity = { const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; -const makeFixture = () => +const makeMockIngress = ( + timeline: Ref.Ref>, + failOpen = false, +): SupervisorIngress => { + let latest: SupervisorIngressReservation | undefined; + let openedGeneration: number | undefined; + return { + acquire: (input) => + Effect.gen(function* () { + if (latest?.generation === input.generation) { + yield* Ref.update(timeline, (current) => [ + ...current, + `acquire:cached:${input.generation}`, + ]); + return { ...latest, fresh: false }; + } + const reservation: SupervisorIngressReservation = { + assignments: {}, + privateAssignments: [], + hostListeners: [], + generation: input.generation, + fresh: true, + }; + latest = reservation; + openedGeneration = undefined; + yield* Ref.update(timeline, (current) => [...current, `acquire:${input.generation}`]); + return reservation; + }), + open: (input, reservation) => + Effect.gen(function* () { + if (openedGeneration === reservation.generation) { + yield* Ref.update(timeline, (current) => [ + ...current, + `open:cached:${reservation.generation}`, + ]); + return; + } + yield* Ref.update(timeline, (current) => [...current, `open:${input.generation}`]); + if (failOpen) + return yield* new GatewayActivationError({ message: "injected ingress open failure" }); + openedGeneration = reservation.generation; + }), + close: Effect.gen(function* () { + yield* Ref.update(timeline, (current) => [...current, "close"]); + latest = undefined; + openedGeneration = undefined; + }), + }; +}; + +const makeFixture = ( + fixtureOptions: { + readonly ingress?: SupervisorIngress; + readonly timeline?: Ref.Ref>; + } = {}, +) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-supervisor-" }); @@ -50,6 +110,11 @@ const makeFixture = () => observe: () => Ref.get(resources), start: (key, workload: PlannedWorkload) => Effect.gen(function* () { + if (fixtureOptions.timeline !== undefined) + yield* Ref.update(fixtureOptions.timeline, (current) => [ + ...current, + `start:${workload.id}`, + ]); yield* Ref.update(calls, (current) => [...current, `start:${workload.id}`]); const ready = { ...key, state: "ready" as const }; yield* Ref.update(resources, (current) => [ @@ -59,9 +124,16 @@ const makeFixture = () => return ready; }), stop: (key) => - Ref.update(resources, (current) => - current.filter((entry) => entry.workloadId !== key.workloadId), - ), + Effect.gen(function* () { + if (fixtureOptions.timeline !== undefined) + yield* Ref.update(fixtureOptions.timeline, (current) => [ + ...current, + `stop:${key.workloadId}`, + ]); + yield* Ref.update(resources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ); + }), remove: (key) => Ref.update(resources, (current) => current.filter((entry) => entry.workloadId !== key.workloadId), @@ -70,6 +142,11 @@ const makeFixture = () => Effect.gen(function* () { if (destroy && (yield* Ref.get(failDestroy))) return yield* new RuntimeDriverError({ message: "destroy failed" }); + if (fixtureOptions.timeline !== undefined) + yield* Ref.update(fixtureOptions.timeline, (current) => [ + ...current, + `cleanup:${destroy ? "destroy" : "stop"}`, + ]); yield* Ref.update(calls, (current) => [ ...current, `cleanup:${destroy ? "destroy" : "stop"}`, @@ -89,6 +166,7 @@ const makeFixture = () => driver, preflight: () => Effect.succeed({}), activate: () => Effect.succeed({ host: "127.0.0.1", port: 9999 }), + ...(fixtureOptions.ingress === undefined ? {} : { ingress: fixtureOptions.ingress }), logStore: { path: "memory://logs", append: () => Effect.succeed(entry), @@ -263,6 +341,86 @@ describe("Supervisor composition", () => { ), ); + it.live("acquires ingress before eager workloads and opens it after readiness", () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const fixture = yield* makeFixture({ + timeline, + ingress: makeMockIngress(timeline), + }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const events = yield* Ref.get(timeline); + expect(events.indexOf("acquire:1")).toBeGreaterThanOrEqual(0); + expect(events.indexOf("start:database:database")).toBeGreaterThan(0); + expect(events.indexOf("open:1")).toBeGreaterThan(events.indexOf("start:rest:rest")); + }), + ), + ); + + it.live("keeps same-generation ingress identity on repeated start", () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const fixture = yield* makeFixture({ + timeline, + ingress: makeMockIngress(timeline), + }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + yield* fixture.supervisor.start(); + const events = yield* Ref.get(timeline); + expect(events.filter((event) => event === "acquire:1")).toHaveLength(1); + expect(events).toContain("acquire:cached:1"); + expect(events).toContain("open:cached:1"); + }), + ), + ); + + it.live("closes ingress before cleanup and reopens it for the next generation", () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const fixture = yield* makeFixture({ + timeline, + ingress: makeMockIngress(timeline), + }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + yield* fixture.supervisor.restart(); + const events = yield* Ref.get(timeline); + const closeIndex = events.indexOf("close"); + const cleanupIndex = events.findIndex( + (event, index) => index > closeIndex && event === "cleanup:stop", + ); + expect(closeIndex).toBeGreaterThanOrEqual(0); + expect(closeIndex).toBeLessThan(cleanupIndex); + expect(events).toContain("acquire:3"); + expect(events).toContain("open:3"); + }), + ), + ); + + it.live("closes a fresh ingress reservation when opening fails", () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const fixture = yield* makeFixture({ + timeline, + ingress: makeMockIngress(timeline, true), + }); + const failed = yield* fixture.supervisor + .start({ config: { capabilities: { rest: {} } } }) + .pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(GatewayActivationError); + const events = yield* Ref.get(timeline); + expect(events).toEqual( + expect.arrayContaining(["acquire:1", "start:database:database", "open:1", "close"]), + ); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + }), + ), + ); + it.live("retains destroying state until exact data cleanup succeeds", () => run( Effect.gen(function* () { From 45024fb5d7861591d549a85ce128bb13250ab185 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:12:12 +0200 Subject: [PATCH 045/174] docs(stack): record ingress rewrite decisions --- ...26-08-28-stack-runtime-rewrite-progress.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index a044e7953a..ab288551be 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -756,3 +756,46 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g requests and sockets are destroyed on interruption and failures are typed without carrying secret material. Focused integration evidence covers native/container artifact outcomes, env permissions, validation/cleanup, readiness success/failure, and interruption. + +#### Task 13B2c — stack-owned Edge Runtime functions bootstrap (2026-08-29) + +- The offline-bundled Edge Runtime main service now belongs to `@supabase/stack`; the CLI imports + the stack bundler rather than maintaining a second template. Request-time discovery reads the + mounted functions root on every request, uses persisted overrides or complete defaults, and + creates workers with `forceCreate`/no module cache so new, edited, and deleted functions are + reflected without restarting the stack. Root containment and realpath checks fail closed for + traversal and symlink escapes. +- Functions workloads use a private stack-owned generation bootstrap (`0700` directory/`0600` + file). Containers retain one read-only functions-root bind and copy the bootstrap into `/root` + after create and before start; adopted containers are never recopied. Native launchers receive + the owner-resolved bootstrap directory through runtime inputs. Docker and Podman copy commands + use argv only, with no shell or secret-bearing arguments. The published package exports the + stack-owned bundler/template directly, and the generated runtime environment keeps the resolved + `functionsRoot`, `SUPABASE_INTERNAL_FUNCTIONS_ROOT`, and JWT/API host-port material in the exact + workload env contract without duplicating CLI helpers. +- Focused stack bundler/bootstrap/workload/container and CLI function tests pass; stack package + type-checking now passes after the lifecycle/secret-store Clock follow-up. + +#### Task 13B2b1 correction — harden managed credential generation (2026-08-29) + +- Managed ES256/RS256 API-key JWTs now derive their ten-year expiry from the injected Effect Clock, + while the legacy HS256 path retains its fixed `1_983_812_996` expiry and exact claim shape. +- JWK signing resolves canonical project-root and file realpaths before reading, rejecting symlink + escapes with the existing redacted typed error. Header/payload construction is explicitly typed; + focused coverage uses TestClock and a temp-directory symlink escape. + +#### Task 13B2b2a — Supervisor-owned ingress lifecycle foundation (2026-08-29) + +- `SupervisorRuntime` may provide one cohesive ingress owner. Accepted running generations reserve + sticky public/private ports and bind host listeners before eager workload reconciliation; the + gateway is opened only after reconciliation succeeds. Same-generation starts reuse the exact + listener/server identity, while restart/stop/destroy close ingress before runtime cleanup. +- Persisted-running recovery adopts runtime resources, starts missing eager workloads (lazy routes + remain dormant), then acquires and opens ingress before the owner reports readiness. Reconcile, + gateway, and listener failures close newly acquired resources while preserving the original typed + cause for recovery retry. +- Production ingress composes `PortCoordinator`, `privateBindingIntentsFor`, `routeCatalogFor`, and + `makeGateway`; API forwarding material is read only from persisted key/JWT slots. The host listener + binder uses `node:http` for HTTP fields and half-open `node:net` for TCP fields, with interruption- + safe, scope-owned close and typed occupied-port failures. Focused host-listener integration tests + cover HTTP/TCP binding, adoption metadata, and occupied exact ports. From 1d413fd506a9597aedff86aed732941c3f8b6a3e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:24:12 +0200 Subject: [PATCH 046/174] fix(stack): fence port materialization by generation --- packages/stack/src/public/EffectStack.ts | 1 + .../workload-runtime.integration.test.ts | 1 + packages/stack/src/state/PortCoordinator.ts | 19 +++--- packages/stack/src/state/StackState.ts | 2 + .../src/state/ownership.integration.test.ts | 1 + .../stack/src/state/ports.integration.test.ts | 62 ++++++++++++++++++- .../src/state/state-store.integration.test.ts | 5 ++ .../supervisor/ingress.integration.test.ts | 2 + .../supervisor/lifecycle.integration.test.ts | 1 + .../supervisor/supervisor.integration.test.ts | 1 + 10 files changed, 85 insertions(+), 10 deletions(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 057d74afe8..d2c123be35 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -460,6 +460,7 @@ const stateInitial = ( identity: toPersistedIdentity(identity, stackId), runtime, desiredGeneration: 0, + portsGeneration: null, desiredLifecycle: "unconfigured", ports: [], privatePorts: [], diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 3fb811a42f..83fa049323 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -30,6 +30,7 @@ const state: PersistedStackState = { }, runtime: { kind: "native" }, desiredGeneration: 1, + portsGeneration: 1, desiredLifecycle: "running", ports: [ { field: "database", port: 55432, intent: "exact" }, diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 52f2143fc4..71937af639 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -179,15 +179,11 @@ export const makePortCoordinator = ( current.privatePorts.map((entry) => [bindingKey(entry), entry]), ); - // A newly accepted generation has running durable intent before its first ingress - // acquisition, but has no assignments yet. Enforce the no-change fence only once the - // prior generation actually owns ports; this preserves same-generation no-rebind while - // allowing the initial listener transaction to materialize its assignments. - if ( - lifecycle === "running" && - current.desiredLifecycle === "running" && - (current.ports.length > 0 || current.privatePorts.length > 0) - ) { + // A running generation may reject listener/private intent changes only after its + // assignment materialization has committed. The marker is explicit so an empty set of + // assignments is still fenced, while a failed first bind (which leaves the marker old) + // can be retried for the accepted generation. + if (lifecycle === "running" && current.portsGeneration === current.desiredGeneration) { for (const field of fields) { const intent = listenerIntents[field]; const prior = existing.get(field); @@ -315,6 +311,10 @@ export const makePortCoordinator = ( // Keep a running lifecycle only after ownership/publication succeeds. desiredLifecycle: lifecycle === "running" ? current.desiredLifecycle : lifecycle, desiredGeneration: planOptions.nextGeneration ?? current.desiredGeneration, + portsGeneration: + lifecycle === "running" + ? current.portsGeneration + : (planOptions.nextGeneration ?? current.desiredGeneration), ports: assignments, privatePorts: privateAssignments, }; @@ -327,6 +327,7 @@ export const makePortCoordinator = ( const runningState: PersistedStackState = { ...committed.next, desiredLifecycle: "running", + portsGeneration: committed.next.desiredGeneration, }; const commitRunning = registry.withLock( Effect.gen(function* () { diff --git a/packages/stack/src/state/StackState.ts b/packages/stack/src/state/StackState.ts index f8d1388c1d..daf7cbf72c 100644 --- a/packages/stack/src/state/StackState.ts +++ b/packages/stack/src/state/StackState.ts @@ -312,6 +312,8 @@ const stateShape = Schema.Struct({ identity: PersistedStackIdentitySchema, runtime: StackRuntimeSchema, desiredGeneration: StackGenerationSchema, + /** Generation for which the persisted public/private assignments are materialized. */ + portsGeneration: Schema.NullOr(StackGenerationSchema), desiredLifecycle: DesiredStackLifecycleSchema, definition: Schema.optional(StackDefinitionSchema), inputFingerprint: Schema.optional(Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/))), diff --git a/packages/stack/src/state/ownership.integration.test.ts b/packages/stack/src/state/ownership.integration.test.ts index b0eeb3da45..07284214ec 100644 --- a/packages/stack/src/state/ownership.integration.test.ts +++ b/packages/stack/src/state/ownership.integration.test.ts @@ -31,6 +31,7 @@ const stateFor = (stackId: string): PersistedStackState => ({ identity: { ...identity, stackId }, runtime: { kind: "native" }, desiredGeneration: 0, + portsGeneration: null, desiredLifecycle: "unconfigured", ports: [], privatePorts: [], diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 74714f72f7..e8315da077 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -48,6 +48,7 @@ const baseState = ( }, runtime: { kind: "native" }, desiredGeneration: 0, + portsGeneration: null, desiredLifecycle, ports: [], privatePorts: [], @@ -237,12 +238,14 @@ describe("sticky port coordination", () => { yield* store.write(exactId, { ...baseState(exactId, exactIdentity, "running"), desiredGeneration: 7, + portsGeneration: 7, ports: [{ field: "api", port: 55435, intent: "exact" }], privatePorts: [], }); yield* store.write(automaticId, { ...baseState(automaticId, automaticIdentity, "running"), desiredGeneration: 9, + portsGeneration: 9, ports: [{ field: "api", port: 55436, intent: "automatic" }], privatePorts: [], }); @@ -290,6 +293,7 @@ describe("sticky port coordination", () => { yield* store.write(stackId, { ...baseState(stackId, identity, "running"), desiredGeneration: 11, + portsGeneration: 11, ports: [{ field: "api", port: 55439, intent: "exact" }], privatePorts: [], }); @@ -345,6 +349,53 @@ describe("sticky port coordination", () => { ); expect(result.assignments.api?.port).toBe(55440); expect((yield* store.read(stackId))?.desiredLifecycle).toBe("running"); + expect((yield* store.read(stackId))?.portsGeneration).toBe(5); + }), + ), + ); + + it.live("fences a newly accepted running generation with no listeners", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-running-empty-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "running-empty"); + const stackId = yield* deriveStackId(identity); + yield* store.write(stackId, { + ...baseState(stackId, identity, "running"), + desiredGeneration: 6, + }); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const coordinator = makePortCoordinator(registry, store, { + bindHost: bindTestNative, + }); + const result = yield* coordinator.planAndReserve(stackId, disabledIntents(), { + lifecycle: "running", + expectedGeneration: 6, + nextGeneration: 6, + }); + expect(result.hostListeners).toHaveLength(0); + expect((yield* store.read(stackId))?.portsGeneration).toBe(6); + const changed = yield* coordinator + .planAndReserve( + stackId, + { ...disabledIntents(), api: { enabled: true, address: "127.0.0.1", port: 55441 } }, + { lifecycle: "running", expectedGeneration: 6, nextGeneration: 6 }, + ) + .pipe(Effect.exit); + expect(errorOf(changed)).toBeInstanceOf(StackLifecycleConflictError); + const privateChanged = yield* coordinator + .planAndReserve(stackId, disabledIntents(), { + lifecycle: "running", + expectedGeneration: 6, + nextGeneration: 6, + privateBindings: [{ workloadId: "database:database", binding: "primary" }], + }) + .pipe(Effect.exit); + expect(errorOf(privateChanged)).toBeInstanceOf(StackLifecycleConflictError); }), ), ); @@ -514,6 +565,12 @@ describe("sticky port coordination", () => { .pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); expect(bound).toHaveLength(0); + expect((yield* store.read(stackId))?.portsGeneration).toBeNull(); + const retry = yield* coordinator.planAndReserve(stackId, intents("automatic"), { + lifecycle: "running", + }); + expect(retry.hostListeners).toHaveLength(2); + expect((yield* store.read(stackId))?.portsGeneration).toBe(0); }), ), ); @@ -556,7 +613,7 @@ describe("sticky port coordination", () => { stackId, { ...disabledIntents(), - api: { enabled: true, address: "127.0.0.1", port: "automatic" }, + api: { enabled: true, address: "127.0.0.1", port: 55443 }, }, { lifecycle: "running" }, ), @@ -565,12 +622,14 @@ describe("sticky port coordination", () => { yield* Deferred.await(entered); const current = yield* store.read(stackId); if (current === undefined) return; + expect(current.portsGeneration).toBeNull(); yield* store.replace(stackId, { ...current, desiredGeneration: 1 }, 0); yield* Deferred.succeed(release, undefined); const result = yield* Fiber.join(reservation).pipe(Effect.exit); expect(errorOf(result)).toBeInstanceOf(StackStateGenerationMismatchError); expect(bound).toHaveLength(0); expect(closed).toHaveLength(1); + expect((yield* store.read(stackId))?.portsGeneration).toBeNull(); }), ), ); @@ -627,6 +686,7 @@ describe("sticky port coordination", () => { bindings.length, ); expect(first.privateAssignments.every((entry) => entry.port >= 30_000)).toBe(true); + expect((yield* store.read(stackId))?.portsGeneration).toBe(0); const stopped = yield* coordinator.planAndReserve(stackId, disabledIntents(), { lifecycle: "stopped", privateBindings: bindings, diff --git a/packages/stack/src/state/state-store.integration.test.ts b/packages/stack/src/state/state-store.integration.test.ts index c5e3b2d6a1..5bad772888 100644 --- a/packages/stack/src/state/state-store.integration.test.ts +++ b/packages/stack/src/state/state-store.integration.test.ts @@ -37,6 +37,7 @@ const state = ( identity: { ...identity, stackId }, runtime: { kind: "native" }, desiredGeneration: generation, + portsGeneration: null, desiredLifecycle: "stopped", definition, inputFingerprint: definition === undefined ? undefined : "d".repeat(64), @@ -80,6 +81,10 @@ describe("atomic stack state", () => { const complete = state(stackId, 2, compiled.definition); yield* store.write(stackId, complete); expect(yield* store.read(stackId)).toEqual(complete); + const materialized = { ...complete, portsGeneration: 2 }; + yield* store.replace(stackId, materialized, 2); + expect(yield* store.read(stackId)).toEqual(materialized); + yield* store.replace(stackId, complete, 2); const encoded = yield* Schema.encodeEffect(PersistedStackStateSchema)(complete); const statePath = path.join(root, stackId, "state.json"); diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 29d045a23c..f81d4a7189 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -117,6 +117,7 @@ describe("Supervisor ingress", () => { identity: { ...stackIdentity, stackId }, runtime: { kind: "native" }, desiredGeneration: 1, + portsGeneration: null, desiredLifecycle: "running", definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, @@ -212,6 +213,7 @@ describe("Supervisor ingress", () => { }, runtime: { kind: "native" }, desiredGeneration: 1, + portsGeneration: null, desiredLifecycle: "running", definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index c94615f32f..cac36332cd 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -89,6 +89,7 @@ const makeFixture = (runtime: StackRuntime = { kind: "native" }) => identity: { ...identity, stackId: id }, runtime, desiredGeneration: 0, + portsGeneration: null, desiredLifecycle: "unconfigured", ports: [], privatePorts: [], diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 47da3938e2..9ebfc8a0a8 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -97,6 +97,7 @@ const makeFixture = ( identity: { ...identity, stackId: id }, runtime: { kind: "native" }, desiredGeneration: 0, + portsGeneration: null, desiredLifecycle: "unconfigured", ports: [], privatePorts: [], From a6265c38a42fe4f4c2fe32ec12834107aa1f6b1f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:24:21 +0200 Subject: [PATCH 047/174] docs(stack): record port generation fencing --- .../2026-08-28-stack-runtime-rewrite-progress.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index ab288551be..6d76f0e256 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -775,6 +775,10 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g workload env contract without duplicating CLI helpers. - Focused stack bundler/bootstrap/workload/container and CLI function tests pass; stack package type-checking now passes after the lifecycle/secret-store Clock follow-up. +- The published package metadata contains only the stack-owned bootstrap entrypoint and template; + resolved function environments include the exact `SUPABASE_INTERNAL_PUBLISHABLE_KEY`, + `SUPABASE_INTERNAL_SECRET_KEY`, `SUPABASE_INTERNAL_JWT_SECRET`, and + `SUPABASE_INTERNAL_HOST_PORT` values consumed by Edge Runtime. #### Task 13B2b1 correction — harden managed credential generation (2026-08-29) @@ -799,3 +803,13 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g binder uses `node:http` for HTTP fields and half-open `node:net` for TCP fields, with interruption- safe, scope-owned close and typed occupied-port failures. Focused host-listener integration tests cover HTTP/TCP binding, adoption metadata, and occupied exact ports. + +#### Task 13B2b2a correction — generation-fenced port materialization (2026-08-29) + +- Persisted state now records `portsGeneration` explicitly (`null` until public/private assignments are + materialized). Running acquisition keeps the prior marker through assignment writes and advances it only + after every listener binds and the final generation fence succeeds; failed binds leave the old marker so + recovery can retry the same accepted generation. Stopped plans mark assignments materialized immediately. +- Running listener/private intent fences use marker equality rather than assignment-array length, including + generations with no listeners or private bindings. Focused state/ports tests cover null/number round trips, + empty-set fencing, sticky retry after bind failure, deterministic final-fence cleanup, and stopped markers. From 02e53fea5fe78f30370ee8d49cbec8dc2d774f57 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:33:53 +0200 Subject: [PATCH 048/174] feat(stack): bootstrap database runtime settings --- packages/stack/package.json | 1 + packages/stack/src/model/DatabaseBootstrap.ts | 77 +++++++-- .../database-bootstrap.integration.test.ts | 22 ++- .../stack/src/runtime/ContainerRuntime.ts | 26 ++- .../src/runtime/DatabaseBootstrapCatalog.ts | 80 +++++++++ packages/stack/src/runtime/NativeRuntime.ts | 33 +--- .../src/runtime/PostgresDatabaseSession.ts | 159 ++++++++++++++++++ .../container-runtime.integration.test.ts | 109 ++++++++++++ ...base-bootstrap-catalog.integration.test.ts | 94 +++++++++++ .../native-runtime.integration.test.ts | 53 +++--- ...tgres-database-session.integration.test.ts | 97 +++++++++++ pnpm-lock.yaml | 3 + 12 files changed, 686 insertions(+), 68 deletions(-) create mode 100644 packages/stack/src/runtime/DatabaseBootstrapCatalog.ts create mode 100644 packages/stack/src/runtime/PostgresDatabaseSession.ts create mode 100644 packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts create mode 100644 packages/stack/src/runtime/postgres-database-session.integration.test.ts diff --git a/packages/stack/package.json b/packages/stack/package.json index 3b4fdbcbd8..80775c071c 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", "effect": "catalog:", "jose": "^6.2.9" }, diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index b9e3e31eab..a32130a92a 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -26,15 +26,35 @@ const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray = [ "supabase_read_only_user", ]; +/** Database-level settings written by the initial managed bootstrap. */ +export type DatabaseBootstrapSettingName = "app.settings.jwt_secret" | "app.settings.jwt_exp"; + +export type DatabaseBootstrapSetting = + | { + readonly name: "app.settings.jwt_secret"; + readonly value: Redacted.Redacted; + } + | { + readonly name: "app.settings.jwt_exp"; + readonly value: number; + }; + /** Values come from resolved managed secret slots and never become SQL text. */ export interface DatabaseBootstrapCredentials { readonly roles?: Readonly>>>; } +export interface DatabaseBootstrapSettings { + readonly jwtSecret: Redacted.Redacted; + readonly jwtExpiry: number; +} + export interface DatabaseBootstrapOptions { /** Ordered plan resolved for the pinned database release. */ readonly revisions: ReadonlyArray; readonly credentials?: DatabaseBootstrapCredentials; + /** Configuration values are reconciled on every invocation, like role passwords. */ + readonly settings?: DatabaseBootstrapSettings; } export class DatabaseBootstrapError extends Data.TaggedError("DatabaseBootstrapError")<{ @@ -58,6 +78,9 @@ export interface DatabaseTransaction { role: DatabaseBootstrapRole, password: Redacted.Redacted, ) => Effect.Effect; + readonly setDatabaseSetting: ( + setting: DatabaseBootstrapSetting, + ) => Effect.Effect; readonly query: ( statement: string, parameters?: ReadonlyArray, @@ -187,23 +210,53 @@ export const runDatabaseBootstrap = ( ), ); } - if (options.credentials?.roles !== undefined) { + if (options.credentials?.roles !== undefined || options.settings !== undefined) { yield* session.transaction((transaction) => Effect.gen(function* () { yield* transaction .execute(ADVISORY_LOCK_STATEMENT) .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); - for (const role of DATABASE_BOOTSTRAP_ROLES) { - const password = options.credentials?.roles?.[role]; - if (password === undefined) continue; - yield* transaction.setRolePassword(role, password).pipe( - Effect.mapError( - () => - new DatabaseBootstrapError({ - message: `Unable to configure internal database role ${role}`, - }), - ), - ); + if (options.credentials?.roles !== undefined) { + for (const role of DATABASE_BOOTSTRAP_ROLES) { + const password = options.credentials.roles[role]; + if (password === undefined) continue; + yield* transaction.setRolePassword(role, password).pipe( + Effect.mapError( + () => + new DatabaseBootstrapError({ + message: `Unable to configure internal database role ${role}`, + }), + ), + ); + } + } + if (options.settings !== undefined) { + yield* transaction + .setDatabaseSetting({ + name: "app.settings.jwt_secret", + value: options.settings.jwtSecret, + }) + .pipe( + Effect.mapError( + () => + new DatabaseBootstrapError({ + message: "Unable to configure database JWT secret", + }), + ), + ); + yield* transaction + .setDatabaseSetting({ + name: "app.settings.jwt_exp", + value: options.settings.jwtExpiry, + }) + .pipe( + Effect.mapError( + () => + new DatabaseBootstrapError({ + message: "Unable to configure database JWT expiry", + }), + ), + ); } }), ); diff --git a/packages/stack/src/model/database-bootstrap.integration.test.ts b/packages/stack/src/model/database-bootstrap.integration.test.ts index 9f9f2f6701..4e5b9aa4fa 100644 --- a/packages/stack/src/model/database-bootstrap.integration.test.ts +++ b/packages/stack/src/model/database-bootstrap.integration.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Redacted, Semaphore } from "effect"; import { DatabaseBootstrapError, + type DatabaseBootstrapSetting, type DatabaseBootstrapCredentials, type DatabaseSession, runDatabaseBootstrap, @@ -23,6 +24,7 @@ const makeSession = ( const operations: string[] = []; const successfulRevisions: string[] = []; const passwords: Array<[string, string]> = []; + const settings: Array<[string, string | number]> = []; const session: DatabaseSession = { execute: (statement) => Effect.sync(() => { @@ -69,6 +71,15 @@ const makeSession = ( new DatabaseBootstrapError({ message: "password rejected secret-password" }), ) : Effect.sync(() => passwords.push([role, Redacted.value(password)])), + setDatabaseSetting: (setting: DatabaseBootstrapSetting) => + Effect.sync(() => { + settings.push([ + setting.name, + setting.name === "app.settings.jwt_secret" + ? Redacted.value(setting.value) + : setting.value, + ]); + }), query: () => Effect.succeed(applied.map((revision) => ({ revision }))), }; yield* use(tx); @@ -76,7 +87,7 @@ const makeSession = ( }), ), }; - return { session, applied, operations, passwords, successfulRevisions }; + return { session, applied, operations, passwords, settings, successfulRevisions }; }); describe("database bootstrap", () => { @@ -89,10 +100,12 @@ describe("database bootstrap", () => { yield* runDatabaseBootstrap(state.session, { revisions, credentials, + settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, }); yield* runDatabaseBootstrap(state.session, { revisions, credentials, + settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, }); expect(state.applied).toEqual(revisions.map(({ id }) => id)); // Credential reconciliation is intentionally a separate idempotent @@ -102,6 +115,12 @@ describe("database bootstrap", () => { ["postgres", "secret-password"], ["postgres", "secret-password"], ]); + expect(state.settings).toEqual([ + ["app.settings.jwt_secret", "secret-jwt"], + ["app.settings.jwt_exp", 3600], + ["app.settings.jwt_secret", "secret-jwt"], + ["app.settings.jwt_exp", 3600], + ]); expect(state.operations.join(" ")).not.toContain("secret-password"); }), ); @@ -151,6 +170,7 @@ describe("database bootstrap", () => { supabase_read_only_user: password, }, }, + settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, }); expect(state.passwords).toEqual([ ["postgres", "secret-password"], diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index fa9559757a..b789515615 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -33,6 +33,12 @@ export interface ContainerRuntimeOptions { key: RuntimeWorkloadKey, workload: PlannedWorkload, ) => Effect.Effect; + /** Runs the one-shot initial database bootstrap after readiness. */ + readonly bootstrapDatabase?: ( + key: RuntimeWorkloadKey, + workload: PlannedWorkload, + resource: ContainerResource, + ) => Effect.Effect; } export interface ContainerWorkloadResolution { @@ -421,11 +427,23 @@ export const makeContainerRuntime = ( resolution.waitForReadiness === undefined ? Effect.void : resolution.waitForReadiness(key, workload, container); - const ready = yield* readiness.pipe(Effect.exit); + const startup = Effect.gen(function* () { + yield* readiness; + if (workload.bootstrap === "database") { + if (options.bootstrapDatabase === undefined) + return yield* toDriverError( + key, + new Error("Database bootstrap resolver is not configured"), + ); + yield* options.bootstrapDatabase(key, workload, container); + } + }); + const ready = yield* startup.pipe(Effect.exit); if (Exit.isFailure(ready)) { - const stopExit = startedByUs - ? yield* Effect.exit(withEngine(key, options.engine.stopContainer(container.id))) - : Exit.succeed(undefined); + const stopExit = + startedByUs || workload.bootstrap === "database" + ? yield* Effect.exit(withEngine(key, options.engine.stopContainer(container.id))) + : Exit.succeed(undefined); const removeExit = created ? yield* Effect.exit(withEngine(key, options.engine.removeContainer(container.id))) : Exit.succeed(undefined); diff --git a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts new file mode 100644 index 0000000000..37ed4f1c0c --- /dev/null +++ b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts @@ -0,0 +1,80 @@ +import { Effect, Redacted } from "effect"; +import type { DatabaseBootstrapOptions } from "../model/DatabaseBootstrap.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { StackPreparationError } from "../public/Errors.ts"; + +const DATABASE_PASSWORD_SLOT = "secret:database.internal.password"; +const JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; + +/** + * The only SQL revision owned by the runtime bootstrap. The slim Postgres + * artifact owns its bundled init scripts and migrations; this revision only + * reconciles the schema ownership that used to be applied by the CLI. + */ +export const DATABASE_BOOTSTRAP_REVISION = { + id: "database-realtime-schema-owner", + statement: "CREATE SCHEMA IF NOT EXISTS _realtime;\nALTER SCHEMA _realtime OWNER TO postgres;", +} as const; + +const missingMaterial = (message: string) => new StackPreparationError({ message }); + +const secretValue = (state: PersistedStackState, slot: string): string | undefined => { + const value = state.secrets[slot]?.value; + return typeof value === "string" && value.length > 0 ? value : undefined; +}; + +/** + * Builds the initial database bootstrap from fully materialized state. + * + * This helper intentionally does not read artifact SQL files or perform + * caller-driven reset/migration/seed work. It returns only the closed role + * credentials, database settings, and one idempotent `_realtime` revision. + */ +export const databaseBootstrapPlan = ( + state: PersistedStackState, +): Effect.Effect => + Effect.gen(function* () { + if (state.definition === undefined) + return yield* missingMaterial( + "A materialized stack definition is required for database bootstrap", + ); + + const databasePassword = secretValue(state, DATABASE_PASSWORD_SLOT); + if (databasePassword === undefined) + return yield* missingMaterial("Managed database password is unavailable for bootstrap"); + + const jwtSecret = secretValue(state, JWT_SECRET_SLOT); + if (jwtSecret === undefined) + return yield* missingMaterial("Managed JWT secret is unavailable for database bootstrap"); + + const jwtExpiry = state.definition.capabilities.auth.settings.jwt_expiry; + if ( + typeof jwtExpiry !== "number" || + !Number.isFinite(jwtExpiry) || + !Number.isInteger(jwtExpiry) || + jwtExpiry <= 0 + ) + return yield* missingMaterial("Auth JWT expiry must be a finite positive integer"); + + const password = Redacted.make(databasePassword); + return { + revisions: [DATABASE_BOOTSTRAP_REVISION], + credentials: { + roles: { + postgres: password, + authenticator: password, + pgbouncer: password, + supabase_auth_admin: password, + supabase_storage_admin: password, + supabase_replication_admin: password, + supabase_read_only_user: password, + }, + }, + settings: { + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry, + }, + } satisfies DatabaseBootstrapOptions; + }); + +export const makeDatabaseBootstrapPlan = databaseBootstrapPlan; diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index dce5905db7..0bb58f82bc 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -2,8 +2,6 @@ import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scope, Semaphore, Stream } f import { ChildProcessSpawner } from "effect/unstable/process"; import type { ExitCode } from "effect/unstable/process/ChildProcessSpawner"; import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; -import type { DatabaseBootstrapOptions, DatabaseSession } from "../model/DatabaseBootstrap.ts"; -import { runDatabaseBootstrap } from "../model/DatabaseBootstrap.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { StackId } from "../public/StackId.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; @@ -47,16 +45,12 @@ export interface NativeRuntimeOptions { workload: NativeWorkload, process: NativeProcess, ) => Effect.Effect; - /** A ready-database session is required for the database bootstrap marker. */ - readonly resolveDatabaseSession?: ( + /** Runs the one-shot initial database bootstrap after readiness. */ + readonly bootstrapDatabase?: ( key: RuntimeWorkloadKey, workload: NativeWorkload, - process: NativeProcess, - ) => Effect.Effect; - readonly resolveDatabaseBootstrap?: ( - key: RuntimeWorkloadKey, - workload: NativeWorkload, - ) => Effect.Effect; + process?: NativeProcess, + ) => Effect.Effect; readonly logStore?: LogStore; } @@ -285,20 +279,11 @@ export const makeNativeRuntime = ( if (resource.stopRequested) return yield* driverError(key, "Native workload was stopped while starting"); if (workload.bootstrap === "database") { - if (options.resolveDatabaseSession === undefined) - return yield* driverError( - key, - "Database bootstrap session resolver is not configured", - ); - if (options.resolveDatabaseBootstrap === undefined) - return yield* driverError(key, "Database bootstrap plan resolver is not configured"); - const session = yield* options.resolveDatabaseSession(key, workload, process); - if (session === undefined) - return yield* driverError(key, "Database bootstrap session is unavailable"); - const plan = yield* options.resolveDatabaseBootstrap(key, workload); - yield* runDatabaseBootstrap(session, plan).pipe( - Effect.mapError((error) => driverError(key, error.message, error)), - ); + if (options.bootstrapDatabase === undefined) + return yield* driverError(key, "Database bootstrap resolver is not configured"); + yield* options + .bootstrapDatabase(key, workload, process) + .pipe(Effect.mapError((error) => driverError(key, error.message, error))); } if (resource.stopRequested) return yield* driverError(key, "Native workload was stopped while starting"); diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts new file mode 100644 index 0000000000..d27f9d5251 --- /dev/null +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -0,0 +1,159 @@ +import { PgClient } from "@effect/sql-pg"; +import { Context, Duration, Effect, Layer, Redacted, Schema, Scope } from "effect"; +import type { SqlError } from "effect/unstable/sql/SqlError"; +import { + DatabaseBootstrapError, + type DatabaseBootstrapSetting, + type DatabaseSession, + type DatabaseSqlValue, + type DatabaseTransaction, + runDatabaseBootstrap, +} from "../model/DatabaseBootstrap.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { StackPreparationError } from "../public/Errors.ts"; +import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; + +export interface PostgresDatabaseSessionOptions { + readonly host: string; + readonly port: number; + readonly password: Redacted.Redacted; + readonly connectTimeout?: Duration.Input; +} + +export interface DatabaseSqlClient { + readonly unsafe: ( + sql: string, + params?: ReadonlyArray, + ) => Effect.Effect>, SqlError>; + readonly withTransaction: ( + effect: Effect.Effect, + ) => Effect.Effect; +} + +const sqlFailure = (operation: string): DatabaseBootstrapError => + new DatabaseBootstrapError({ message: `Database bootstrap ${operation} failed` }); + +const mapSqlError = + (operation: string) => + (error: unknown): DatabaseBootstrapError => + error instanceof DatabaseBootstrapError ? error : sqlFailure(operation); + +const GeneratedStatementSchema = Schema.Struct({ statement: Schema.String }); + +/** + * Adapts Effect SQL to the runtime-neutral bootstrap boundary. Dynamic + * ALTER ROLE/ALTER DATABASE statements are generated by PostgreSQL's + * parameterized format() function and are never included in mapped errors. + */ +export const makeDatabaseSessionFromSqlClient = (client: DatabaseSqlClient): DatabaseSession => { + const executeWith = ( + sql: string, + parameters: ReadonlyArray = [], + ): Effect.Effect => + client.unsafe(sql, parameters).pipe(Effect.asVoid, Effect.mapError(mapSqlError("statement"))); + + const queryWith = ( + sql: string, + parameters: ReadonlyArray = [], + ): Effect.Effect>, DatabaseBootstrapError> => + client.unsafe(sql, parameters).pipe(Effect.mapError(mapSqlError("query"))); + + const generated = ( + format: string, + parameters: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + const rows = yield* client + .unsafe(format, parameters) + .pipe(Effect.mapError(mapSqlError("statement preparation"))); + const first = rows[0]; + const decoded = yield* Schema.decodeUnknownEffect(GeneratedStatementSchema)(first).pipe( + Effect.mapError(() => sqlFailure("statement preparation")), + ); + yield* executeWith(decoded.statement); + }); + + const transaction: DatabaseTransaction = { + execute: executeWith, + query: queryWith, + setRolePassword: (role, password) => + generated("SELECT format('ALTER ROLE %I PASSWORD %L', $1, $2) AS statement", [ + role, + Redacted.value(password), + ]), + setDatabaseSetting: (setting: DatabaseBootstrapSetting) => { + const value = + setting.name === "app.settings.jwt_secret" ? Redacted.value(setting.value) : setting.value; + return generated( + "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1, $2) AS statement", + [setting.name, value], + ); + }, + }; + + return { + execute: executeWith, + transaction: (use) => + client.withTransaction(use(transaction)).pipe(Effect.mapError(mapSqlError("transaction"))), + }; +}; + +/** Acquires a SQL client in the caller's scope and keeps it live with the session. */ +export const makeDatabaseSessionFromAcquisition = ( + acquire: Effect.Effect, +): Effect.Effect => + acquire.pipe( + Effect.map(makeDatabaseSessionFromSqlClient), + Effect.mapError(mapSqlError("connection")), + ); + +/** Creates a scoped production session backed by the Effect PostgreSQL pool. */ +export const makePostgresDatabaseSession = ( + options: PostgresDatabaseSessionOptions, +): Effect.Effect => + makeDatabaseSessionFromAcquisition( + Effect.gen(function* () { + const services = yield* Layer.build( + PgClient.layer({ + host: options.host, + port: options.port, + database: "postgres", + username: "postgres", + password: options.password, + connectTimeout: options.connectTimeout ?? "5 seconds", + }), + ); + return Context.get(services, PgClient.PgClient); + }), + ); + +/** Runs the initial bootstrap through the durable loopback database endpoint. */ +export const bootstrapDatabaseAt = ( + state: PersistedStackState, +): Effect.Effect => + Effect.gen(function* () { + const plan = yield* databaseBootstrapPlan(state); + const port = state.privatePorts.find( + (assignment) => + assignment.workloadId === "database:database" && assignment.binding === "primary", + )?.port; + if (port === undefined) + return yield* new StackPreparationError({ + message: "A persisted database private port is required for bootstrap", + }); + const password = state.secrets["secret:database.internal.password"]?.value; + if (typeof password !== "string" || password.length === 0) + return yield* new StackPreparationError({ + message: "Managed database password is unavailable for bootstrap", + }); + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* makePostgresDatabaseSession({ + host: "127.0.0.1", + port, + password: Redacted.make(password), + }); + yield* runDatabaseBootstrap(session, plan); + }), + ); + }); diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 063b587a54..77668f5125 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -829,6 +829,115 @@ describe("container runtime", () => { }), ); + it.live("runs database bootstrap after readiness and before reporting ready", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const order: string[] = []; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ waitForReadiness: () => Effect.sync(() => order.push("readiness")) }), + bootstrapDatabase: () => Effect.sync(() => order.push("bootstrap")), + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + order.length = 0; + + const databaseWorkload = { ...workload(), bootstrap: "database" as const }; + const databaseReady = yield* runtime.start( + { ...key, specHash: "database-bootstrap" }, + databaseWorkload, + ); + expect(databaseReady.state).toBe("ready"); + expect(order).toEqual(["readiness", "bootstrap"]); + }), + ); + + it.live("cleans up a newly created container when database bootstrap fails", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const bootstrapError = new RuntimeDriverError({ + message: "database bootstrap failed", + stackId: key.stackId, + workloadId: key.workloadId, + }); + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + bootstrapDatabase: () => Effect.fail(bootstrapError), + }); + const databaseWorkload = { ...workload(), bootstrap: "database" as const }; + const result = yield* runtime.start(key, databaseWorkload).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBe(bootstrapError); + } + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); + }), + ); + + it.live("does not remove an adopted running database container on bootstrap failure", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const owner = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "previous-owner", + }); + yield* owner.start(key, workload()); + state.calls.length = 0; + const bootstrapError = new RuntimeDriverError({ + message: "adopted database bootstrap failed", + stackId: key.stackId, + workloadId: key.workloadId, + }); + let attempts = 0; + const adopted = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "new-owner", + bootstrapDatabase: () => + Effect.gen(function* () { + attempts += 1; + if (attempts === 1) return yield* Effect.fail(bootstrapError); + }), + }); + const result = yield* adopted + .start(key, { ...workload(), bootstrap: "database" as const }) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(false); + expect(state.resources.find((resource) => resource.kind === "workload")?.state).toBe( + "stopped", + ); + const retry = yield* adopted.start(key, { ...workload(), bootstrap: "database" as const }); + expect(retry.state).toBe("ready"); + expect(attempts).toBe(2); + }), + ); + it.live( "copies a new functions bootstrap before starting and never copies an adopted container", () => diff --git a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts new file mode 100644 index 0000000000..634af1c6a5 --- /dev/null +++ b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts @@ -0,0 +1,94 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option, Redacted } from "effect"; +import { compileStack } from "../model/Compiler.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { StackPreparationError } from "../public/Errors.ts"; +import { DATABASE_BOOTSTRAP_REVISION, databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; + +const stackId = "a".repeat(64); + +const stateFrom = (definition: PersistedStackState["definition"]): PersistedStackState => ({ + format: "supabase-stack-state-v1", + identity: { + stackId, + projectRoot: "/tmp/project", + checkoutRoot: "/tmp/project", + workspaceId: "/tmp/project", + checkoutId: "/tmp/project", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "default", + }, + runtime: { kind: "native" }, + desiredGeneration: 1, + portsGeneration: 1, + desiredLifecycle: "stopped", + definition, + inputFingerprint: "b".repeat(64), + ports: [], + privatePorts: [{ workloadId: "database:database", binding: "primary", port: 54_321 }], + secrets: { + "secret:database.internal.password": { policy: "managed", value: "database-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + }, +}); + +const compileDefinition = compileStack({ + projectRoot: "/tmp/project", + runtime: { kind: "native" }, +}).pipe( + Effect.provide(NodeServices.layer), + Effect.map((result) => stateFrom(result.definition)), +); + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +describe("database bootstrap catalog", () => { + it.live("creates one revision and reconciles all closed roles and settings", () => + Effect.gen(function* () { + const state = yield* compileDefinition; + const plan = yield* databaseBootstrapPlan(state); + expect(plan.revisions).toEqual([DATABASE_BOOTSTRAP_REVISION]); + expect(Object.keys(plan.credentials?.roles ?? {})).toHaveLength(7); + for (const password of Object.values(plan.credentials?.roles ?? {})) + expect(password === undefined ? undefined : Redacted.value(password)).toBe( + "database-secret", + ); + expect(plan.settings?.jwtExpiry).toBe(3600); + expect( + plan.settings === undefined ? undefined : Redacted.value(plan.settings.jwtSecret), + ).toBe("jwt-secret"); + }), + ); + + it.live("fails closed when secret material is absent or JWT expiry is invalid", () => + Effect.gen(function* () { + const state = yield* compileDefinition; + const missing = yield* databaseBootstrapPlan({ ...state, secrets: {} }).pipe(Effect.exit); + const missingError = errorOf(missing); + expect(missingError).toBeInstanceOf(StackPreparationError); + expect(String(missingError)).not.toContain("database-secret"); + + if (state.definition === undefined) return; + const invalidDefinition = { + ...state.definition, + capabilities: { + ...state.definition.capabilities, + auth: { + ...state.definition.capabilities.auth, + settings: { ...state.definition.capabilities.auth.settings, jwt_expiry: 0 }, + }, + }, + }; + const invalid = yield* databaseBootstrapPlan({ + ...state, + definition: invalidDefinition, + }).pipe(Effect.exit); + const invalidError = errorOf(invalid); + expect(invalidError).toBeInstanceOf(StackPreparationError); + expect(String(invalidError)).not.toContain("jwt-secret"); + }), + ); +}); diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index a1f6ae19e8..5ece11d89b 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -3,11 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Deferred, Effect, Exit, Fiber, FileSystem, Path, Ref, Stream } from "effect"; import { ChildProcess } from "effect/unstable/process"; import { LogStoreError, makeLogStore, type LogStore } from "../supervisor/LogStore.ts"; -import type { - DatabaseBootstrapOptions, - DatabaseSession, - DatabaseTransaction, -} from "../model/DatabaseBootstrap.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; @@ -286,31 +281,13 @@ describe("native runtime", () => { withPlatform( Effect.gen(function* () { let applied = false; - const session: DatabaseSession = { - execute: () => Effect.void, - transaction: (use) => - Effect.gen(function* () { - let record = false; - const transaction: DatabaseTransaction = { - execute: (statement) => - Effect.sync(() => { - if (statement.includes("INSERT INTO")) record = true; - }), - setRolePassword: () => Effect.void, - query: () => Effect.succeed(applied ? [{ revision: "fixture" }] : []), - }; - yield* use(transaction); - if (record) applied = true; - }), - }; - const bootstrap: DatabaseBootstrapOptions = { - revisions: [{ id: "fixture", statement: "CREATE TABLE fixture" }], - }; const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed(fixtureProcess("database")), waitForReadiness: () => Effect.void, - resolveDatabaseSession: () => Effect.succeed(session), - resolveDatabaseBootstrap: () => Effect.succeed(bootstrap), + bootstrapDatabase: () => + Effect.sync(() => { + applied = true; + }), }); const key = keyFor("bootstrap"); const ready = yield* runtime.start(key, workload("bootstrap", "database")); @@ -338,6 +315,28 @@ describe("native runtime", () => { ), ); + it.live("cleans up the native process when database bootstrap fails", () => + withPlatform( + Effect.gen(function* () { + const bootstrapError = new RuntimeDriverError({ + message: "database bootstrap failed", + stackId, + workloadId: "database:failed-bootstrap", + }); + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(fixtureProcess("database")), + waitForReadiness: () => Effect.void, + bootstrapDatabase: () => Effect.fail(bootstrapError), + }); + const result = yield* runtime + .start(keyFor("failed-bootstrap"), workload("failed-bootstrap", "database")) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + it.live("isolates exact workload identities while stopping one", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/postgres-database-session.integration.test.ts b/packages/stack/src/runtime/postgres-database-session.integration.test.ts new file mode 100644 index 0000000000..425c9ebdbc --- /dev/null +++ b/packages/stack/src/runtime/postgres-database-session.integration.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Redacted, Ref } from "effect"; +import { SqlError, UnknownError } from "effect/unstable/sql/SqlError"; +import { + makeDatabaseSessionFromAcquisition, + makeDatabaseSessionFromSqlClient, + type DatabaseSqlClient, +} from "./PostgresDatabaseSession.ts"; + +describe("Postgres database session", () => { + it.live("uses parameterized format calls for role and database settings", () => + Effect.gen(function* () { + const calls: Array<{ readonly sql: string; readonly params: ReadonlyArray }> = []; + const client: DatabaseSqlClient = { + unsafe: (sql, params = []) => + Effect.sync(() => { + calls.push({ sql, params }); + if (sql.startsWith("SELECT format")) + return [{ statement: "ALTER ROLE postgres PASSWORD 'database-secret'" }]; + return []; + }), + withTransaction: (effect: Effect.Effect) => effect, + }; + const session = makeDatabaseSessionFromSqlClient(client); + yield* session.transaction((transaction) => + Effect.gen(function* () { + yield* transaction.setRolePassword("postgres", Redacted.make("database-secret")); + yield* transaction.setDatabaseSetting({ + name: "app.settings.jwt_secret", + value: Redacted.make("jwt-secret"), + }); + yield* transaction.setDatabaseSetting({ name: "app.settings.jwt_exp", value: 3600 }); + }), + ); + expect(calls[0]).toEqual({ + sql: "SELECT format('ALTER ROLE %I PASSWORD %L', $1, $2) AS statement", + params: ["postgres", "database-secret"], + }); + expect(calls[1]?.sql).toContain("ALTER ROLE postgres PASSWORD"); + expect(calls[2]).toEqual({ + sql: "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1, $2) AS statement", + params: ["app.settings.jwt_secret", "jwt-secret"], + }); + expect(calls[4]).toEqual({ + sql: "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1, $2) AS statement", + params: ["app.settings.jwt_exp", 3600], + }); + }), + ); + + it.live("does not leak generated secret SQL through mapped failures", () => + Effect.gen(function* () { + const client: DatabaseSqlClient = { + unsafe: (sql) => + sql.startsWith("SELECT format") + ? Effect.succeed([{ statement: "ALTER ROLE postgres PASSWORD 'database-secret'" }]) + : Effect.fail( + new SqlError({ + reason: new UnknownError({ + message: "generated ALTER ROLE contains database-secret", + cause: new Error("generated ALTER ROLE contains database-secret"), + }), + }), + ), + withTransaction: (effect: Effect.Effect) => effect, + }; + const result = yield* makeDatabaseSessionFromSqlClient(client) + .transaction((transaction) => + transaction.setRolePassword("postgres", Redacted.make("database-secret")), + ) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Cause.pretty(result.cause)).not.toContain("database-secret"); + }), + ); + + it.live("keeps an acquired client live until the caller scope closes", () => + Effect.gen(function* () { + const released = yield* Ref.make(false); + const client: DatabaseSqlClient = { + unsafe: () => Effect.succeed([]), + withTransaction: (effect: Effect.Effect) => effect, + }; + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* makeDatabaseSessionFromAcquisition( + Effect.acquireRelease(Effect.succeed(client), () => Ref.set(released, true)), + ); + yield* session.execute("SELECT 1"); + expect(yield* Ref.get(released)).toBe(false); + }), + ); + expect(yield* Ref.get(released)).toBe(true); + }), + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b58fbd6743..71710791d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -446,6 +446,9 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) + '@effect/sql-pg': + specifier: 'catalog:' + version: 4.0.0-rc.111(effect@4.0.0-rc.111) effect: specifier: 'catalog:' version: 4.0.0-rc.111 From 66e91f143701ab219ccfff8a3de79b8036eae75e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:35:15 +0200 Subject: [PATCH 049/174] docs(stack): record database bootstrap completion --- .../2026-08-28-stack-runtime-rewrite-progress.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 6d76f0e256..640a846ecf 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -813,3 +813,17 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Running listener/private intent fences use marker equality rather than assignment-array length, including generations with no listeners or private bindings. Focused state/ports tests cover null/number round trips, empty-set fencing, sticky retry after bind failure, deterministic final-fence cleanup, and stopped markers. + +#### Task 13B2b3 — initial database bootstrap runtime (2026-08-29) + +- The slim Postgres artifact remains the owner of bundled init scripts and migrations. Stack runtime + bootstrap does not enumerate or reapply artifact SQL; after database readiness it reconciles the + `_realtime` schema owner, all seven managed login-role passwords, and `app.settings.jwt_secret` / + `app.settings.jwt_exp` under the bootstrap advisory lock. +- Both native and container database workloads run the same one-shot bootstrap through the persisted + loopback private database port. PostgreSQL access uses the scoped Effect SQL client; role and setting + statements are built by parameterized `format()` calls so generated secret SQL is never exposed in + mapped errors. A bootstrap failure cleans up new resources and stops an adopted database container + without removing it, allowing an idempotent retry on the next reconciliation. +- Initial runtime settings are intentionally distinct from caller-driven database migrations, seeds, and + reset. The reset/session flow remains deferred to the final rewrite slice. From 2d6d843ef7ad434e261f67e99719f7003137b5bd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:45:18 +0200 Subject: [PATCH 050/174] fix(stack): gate recovered database readiness --- .../stack/src/runtime/ContainerRuntime.ts | 36 +++++ .../container-runtime.integration.test.ts | 140 +++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index b789515615..ede9cfee82 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -595,6 +595,9 @@ export const makeContainerRuntime = ( const desiredHashes = new Map( request.plan.workloads.map((workload) => [workload.id, workload.specHash]), ); + const desiredWorkloads = new Map( + request.plan.workloads.map((workload) => [workload.id, workload]), + ); const adopted: Array = []; const adoptedIdentities = new Set(); let retainedNetworkId: string | undefined; @@ -621,6 +624,13 @@ export const makeContainerRuntime = ( ), ); }); + const stopAdopted = ( + entry: ContainerResource & { readonly labels: ContainerWorkloadLabels }, + ) => + withEngine( + { stackId: request.stackId, workloadId: entry.labels.workloadId }, + options.engine.stopContainer(entry.id), + ); // Recovery never starts or creates anything. Workload identities are evaluated before // retaining the network so stale resources cannot leak into the new owner. @@ -640,6 +650,32 @@ export const makeContainerRuntime = ( continue; } adoptedIdentities.add(identity); + const desiredWorkload = desiredWorkloads.get(entry.labels.workloadId); + if (entry.state === "running" && desiredWorkload?.bootstrap === "database") { + const key: RuntimeWorkloadKey = { + stackId: entry.labels.stackId, + desiredGeneration: entry.labels.desiredGeneration, + workloadId: entry.labels.workloadId, + specHash: entry.labels.specHash, + }; + const bootstrap = + options.bootstrapDatabase === undefined + ? Effect.fail( + toDriverError( + key, + new Error("Database bootstrap resolver is not configured"), + ), + ) + : options.bootstrapDatabase(key, desiredWorkload, entry); + const bootstrapExit = yield* Effect.exit(bootstrap); + if (Exit.isFailure(bootstrapExit)) { + recoveryCause = Cause.combine(recoveryCause, bootstrapExit.cause); + const stopExit = yield* Effect.exit(stopAdopted(entry)); + if (Exit.isFailure(stopExit)) + recoveryCause = Cause.combine(recoveryCause, stopExit.cause); + continue; + } + } adopted.push({ stackId: entry.labels.stackId, desiredGeneration: entry.labels.desiredGeneration, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 77668f5125..d896c1a1b4 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option } from "effect"; import { NodeServices } from "@effect/platform-node"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; import { StackIdSchema } from "../public/StackId.ts"; import { @@ -52,6 +52,38 @@ const workload = (selected: PlannedWorkload["selected"] = containerArtifact): Pl specHash: key.specHash, }); +const recoveryPlan = (workloads: ReadonlyArray): ExecutionPlan => ({ + runtime: { kind: "container", engine: "docker" }, + activation: { + database: "eager", + rest: "eager", + auth: "eager", + realtime: "eager", + storage: "eager", + functions: "eager", + studio: "eager", + mail: "eager", + analytics: "eager", + pooler: "eager", + }, + startOrder: ["database"], + stopOrder: ["database"], + dependencies: { + database: [], + rest: [], + auth: [], + realtime: [], + storage: [], + functions: [], + studio: [], + mail: [], + analytics: [], + pooler: [], + }, + routes: [], + workloads, +}); + const commandResult = (value: unknown): ContainerCommandResult => ({ stdout: typeof value === "string" ? value : JSON.stringify(value), stderr: "", @@ -683,6 +715,112 @@ describe("container runtime", () => { }), ); + it.live("runs database bootstrap before reporting an adopted running container ready", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [ + { + id: "adopted-database", + name: "adopted-database", + kind: "workload", + state: "running", + labels: { + stackId: key.stackId, + ownerSessionId: "previous-owner", + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash: key.specHash, + role: "workload", + }, + }, + ], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const order: string[] = []; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "new-owner", + bootstrapDatabase: () => Effect.sync(() => order.push("bootstrap")), + }); + const databaseWorkload = { ...workload(), bootstrap: "database" as const }; + const adopted = yield* runtime.recover({ + stackId: key.stackId, + desiredGeneration: key.desiredGeneration, + desiredLifecycle: "running", + plan: recoveryPlan([databaseWorkload]), + }); + expect(order).toEqual(["bootstrap"]); + expect(adopted).toEqual([{ ...key, state: "ready" }]); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(false); + }), + ); + + it.live( + "stops but does not remove an adopted database after bootstrap failure, then retries", + () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [ + { + id: "adopted-database", + name: "adopted-database", + kind: "workload", + state: "running", + labels: { + stackId: key.stackId, + ownerSessionId: "previous-owner", + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash: key.specHash, + role: "workload", + }, + }, + ], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const bootstrapError = new RuntimeDriverError({ + message: "recovered database bootstrap failed", + stackId: key.stackId, + workloadId: key.workloadId, + }); + let attempts = 0; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "new-owner", + bootstrapDatabase: () => + Effect.gen(function* () { + attempts += 1; + if (attempts === 1) return yield* Effect.fail(bootstrapError); + }), + }); + const databaseWorkload = { ...workload(), bootstrap: "database" as const }; + const failed = yield* runtime + .recover({ + stackId: key.stackId, + desiredGeneration: key.desiredGeneration, + desiredLifecycle: "running", + plan: recoveryPlan([databaseWorkload]), + }) + .pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(state.resources.find((resource) => resource.id === "adopted-database")?.state).toBe( + "stopped", + ); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(false); + const retry = yield* runtime.start(key, databaseWorkload); + expect(retry.state).toBe("ready"); + expect(attempts).toBe(2); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(false); + }), + ); + it.live("leaves foreign and sanitized-name collisions untouched", () => Effect.gen(function* () { const foreignStackId = StackIdSchema.make("c".repeat(64)); From d86ea269e9c6d12da3c9806a2f60aa477184dd67 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:51:00 +0200 Subject: [PATCH 051/174] feat(stack): compose the production runtime --- .../stack/src/entrypoints/supervisor-node.ts | 9 + .../stack/src/preparation/RuntimeArtifacts.ts | 38 +- .../runtime-artifacts.integration.test.ts | 27 +- .../stack/src/runtime/ProductionRuntime.ts | 561 ++++++++++++++++++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 15 +- .../production-runtime.integration.test.ts | 123 ++++ .../workload-runtime.integration.test.ts | 24 + 7 files changed, 781 insertions(+), 16 deletions(-) create mode 100644 packages/stack/src/runtime/ProductionRuntime.ts create mode 100644 packages/stack/src/runtime/production-runtime.integration.test.ts diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index ebba6c1738..320c2916a1 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -13,6 +13,7 @@ import { import { makeStackStateStore } from "../state/StackStateStore.ts"; import { StackIdSchema } from "../public/StackId.ts"; import { makeSupervisor } from "../supervisor/Supervisor.ts"; +import { makeProductionRuntimeFactory } from "../runtime/ProductionRuntime.ts"; import { startOwnerSession } from "../supervisor/OwnerSession.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; @@ -103,6 +104,13 @@ export const runSupervisor = (args: SupervisorArgs) => }); const released = yield* Deferred.make(); const context = yield* Effect.context(); + const runtimeFactory = yield* makeProductionRuntimeFactory({ + stateRoot: args.stateRoot, + stackId: args.stackId, + ownerSessionId, + stateStore: store, + context, + }); const supervisor = yield* makeSupervisor({ identity: args.identity, stackId: args.stackId, @@ -110,6 +118,7 @@ export const runSupervisor = (args: SupervisorArgs) => rpcRelease: args.rpcRelease || STACK_RPC_RELEASE, stateStore: store, context, + runtimeFactory, }); const session = yield* startOwnerSession({ endpoint: lease.metadata.endpoint, diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 1c9e09d8da..efd03cdb8c 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -19,6 +19,7 @@ import { makeProcessCommandRunner, type ContainerEngine, type ContainerPlatform, + type ContainerEngineKind, } from "../runtime/ContainerEngine.ts"; import { makeDockerEngine } from "../runtime/DockerEngine.ts"; import { makePodmanEngine } from "../runtime/PodmanEngine.ts"; @@ -40,6 +41,8 @@ export interface RuntimeArtifactPreparer { runtime: StackRuntime, workload: PlannedWorkload, ) => Effect.Effect; + /** Selected production container engine, when this preparer was built for containers. */ + readonly containerEngine?: ContainerEngine; } export type RuntimeArtifactPreparationError = ArtifactStoreError | StackPreparationError; @@ -53,7 +56,7 @@ export interface RuntimeArtifactPreparerOptions { ) => Effect.Effect; readonly platform?: { readonly os: string; readonly arch: string }; }; - readonly containers?: Readonly<{ docker: ContainerEngine; podman: ContainerEngine }>; + readonly containers?: Readonly>>; } const error = (message: string, fields: Readonly> = {}) => @@ -164,6 +167,7 @@ const nativeResult = ( export const makeProductionRuntimeArtifactPreparer = (options: { readonly stateRoot: string; readonly platform?: ContainerPlatform; + readonly runtime?: StackRuntime; }): Effect.Effect< RuntimeArtifactPreparer, RuntimeArtifactPreparationError, @@ -196,16 +200,28 @@ export const makeProductionRuntimeArtifactPreparer = (options: { : "linux", desktop: process.platform === "darwin", } satisfies ContainerPlatform); - const dockerRunner = yield* makeProcessCommandRunner({ executable: "docker" }).pipe( - Effect.mapError(() => error("Unable to configure Docker artifact engine")), - ); - const podmanRunner = yield* makeProcessCommandRunner({ executable: "podman" }).pipe( - Effect.mapError(() => error("Unable to configure Podman artifact engine")), - ); - const docker = makeDockerEngine({ runner: dockerRunner, platform }); - const podman = makePodmanEngine({ runner: podmanRunner, platform }); - return makeRuntimeArtifactPreparer({ + const selectedEngine = + options.runtime?.kind === "container" ? options.runtime.engine : undefined; + const containerEngine = + selectedEngine === undefined + ? undefined + : yield* makeProcessCommandRunner({ executable: selectedEngine }).pipe( + Effect.mapError(() => error(`Unable to configure ${selectedEngine} artifact engine`)), + Effect.map((runner) => + selectedEngine === "docker" + ? makeDockerEngine({ runner, platform }) + : makePodmanEngine({ runner, platform }), + ), + ); + const containers = + selectedEngine === undefined || containerEngine === undefined + ? undefined + : selectedEngine === "docker" + ? { docker: containerEngine } + : { podman: containerEngine }; + const preparer = makeRuntimeArtifactPreparer({ native: { store, checksum }, - containers: { docker, podman }, + containers, }); + return containerEngine === undefined ? preparer : { ...preparer, containerEngine }; }); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index c6329e3498..8629f8842f 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -1,5 +1,6 @@ +import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { Effect, Exit, FileSystem } from "effect"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ArtifactRequest, ArtifactStore, PreparedArtifact } from "./ArtifactStore.ts"; import type { @@ -8,7 +9,10 @@ import type { ContainerVolumeSpec, ContainerContainerSpec, } from "../runtime/ContainerEngine.ts"; -import { makeRuntimeArtifactPreparer } from "./RuntimeArtifacts.ts"; +import { + makeProductionRuntimeArtifactPreparer, + makeRuntimeArtifactPreparer, +} from "./RuntimeArtifacts.ts"; const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload => ({ id: "database:database", @@ -62,6 +66,25 @@ const containerEngine = (present: boolean, calls: string[]): ContainerEngine => }); describe("runtime artifact preparation", () => { + it.live("constructs only the persisted container engine", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-artifact-runtime-" }); + const preparer = yield* makeProductionRuntimeArtifactPreparer({ + stateRoot: root, + runtime: { kind: "container", engine: "podman" }, + }); + expect(preparer.containerEngine?.kind).toBe("podman"); + const native = yield* makeProductionRuntimeArtifactPreparer({ + stateRoot: root, + runtime: { kind: "native" }, + }); + expect(native.containerEngine).toBeUndefined(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it("resolves native catalog metadata, checksum, and one ArtifactRequest", () => { const requests: ArtifactRequest[] = []; const store: ArtifactStore = { diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts new file mode 100644 index 0000000000..e97ad15678 --- /dev/null +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -0,0 +1,561 @@ +import { Context, Crypto, Effect, FileSystem, Path, Ref, Scope, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { bundleServeMainTemplate } from "../functions/serve-main-bundler.ts"; +import { + makeFunctionsBootstrapOwner, + type FunctionsBootstrapOwner, +} from "../functions/FunctionsBootstrap.ts"; +import { makePortRegistry } from "../state/PortRegistry.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; +import { redactKnownSecrets } from "../state/SecretStore.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import type { StackId } from "../public/StackId.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import { + GatewayActivationError, + StackPreparationError, + StackRuntimeMismatchError, + StackStateInvalidError, + type StackError, +} from "../public/Errors.ts"; +import { makeSupervisorIngress, type SupervisorIngress } from "../supervisor/Ingress.ts"; +import { + LogStoreError, + makeLogStore, + type LogStore, + type LogRecord, +} from "../supervisor/LogStore.ts"; +import type { LifecycleInput, LifecyclePrepared } from "../supervisor/Lifecycle.ts"; +import type { SupervisorRuntime, SupervisorRuntimeFactory } from "../supervisor/Supervisor.ts"; +import { + makeProductionRuntimeArtifactPreparer, + type PreparedWorkloadArtifact, + type RuntimeArtifactPreparer, +} from "../preparation/RuntimeArtifacts.ts"; +import { makeRuntimeEnvFileOwner, type RuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; +import { + FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, + resolveContainerResolutionFor, + runtimeSpecFor, + validatePrivateAssignments, + validateWorkloadRuntimeInputs, + type WorkloadRuntimeInputs, +} from "./WorkloadRuntimeSpec.ts"; +import { makeNativeRuntime } from "./NativeRuntime.ts"; +import { makeContainerRuntime, type ContainerWorkloadResolution } from "./ContainerRuntime.ts"; +import { probeReadiness } from "./ReadinessProbe.ts"; +import type { ContainerEngine, ContainerHostRoute } from "./ContainerEngine.ts"; +import { bootstrapDatabaseAt } from "./PostgresDatabaseSession.ts"; +import type { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; +import { + RuntimeDriverError, + type RuntimeDriver, + type RuntimeWorkloadKey, +} from "./RuntimeDriver.ts"; + +type RuntimeContext = FileSystem.FileSystem | Path.Path | Crypto.Crypto; + +export interface ProductionRuntimeFactoryOptions { + readonly stateRoot: string; + readonly stackId: StackId; + readonly ownerSessionId: string; + readonly stateStore: StackStateStore; + readonly context: Context.Context; + readonly artifactPreparer?: RuntimeArtifactPreparer; + readonly containerEngine?: ContainerEngine; + readonly ingress?: SupervisorIngress; + readonly logStore?: LogStore; + readonly envFileOwner?: RuntimeEnvFileOwner; + readonly functionsBootstrapOwner?: FunctionsBootstrapOwner; + readonly bootstrapDatabase?: ( + state: PersistedStackState, + ) => Effect.Effect; +} + +const preparationError = (message: string, cause?: unknown): StackPreparationError => + new StackPreparationError({ message, ...(cause === undefined ? {} : { cause }) }); + +const driverError = ( + key: Pick, + message: string, + cause?: unknown, +): RuntimeDriverError => + new RuntimeDriverError({ + message, + stackId: key.stackId, + workloadId: key.workloadId, + ...(cause === undefined ? {} : { cause }), + }); + +const stateSecrets = (state: PersistedStackState): ReadonlyArray => + Object.values(state.secrets) + .map((entry) => entry.value) + .filter((value) => value.length > 0); + +const currentStateReader = (options: ProductionRuntimeFactoryOptions) => + options.stateStore.read(options.stackId).pipe( + Effect.provideContext(options.context), + Effect.flatMap((state) => + state === undefined + ? Effect.fail( + new StackStateInvalidError({ + message: "Persisted stack state is missing", + stackId: options.stackId, + }), + ) + : Effect.succeed(state), + ), + ); + +const artifactKey = (runtime: StackRuntime, workload: PlannedWorkload): string => + `${runtime.kind}:${runtime.kind === "container" ? runtime.engine : ""}:${workload.id}:${workload.specHash}:${workload.selected.kind === "native" ? workload.selected.release : workload.selected.image}`; + +const runtimeMatches = (left: StackRuntime, right: StackRuntime): boolean => { + if (left.kind !== right.kind) return false; + if (left.kind === "native" || right.kind === "native") return true; + return left.engine === right.engine; +}; + +const redactEntry = ( + entry: A, + secrets: ReadonlyArray, +): A => { + const message = redactKnownSecrets(entry.message, secrets); + return message === entry.message ? entry : { ...entry, message }; +}; + +/** Re-reads secret slots before each log operation so values materialized after startup are safe. */ +const dynamicLogStore = ( + base: LogStore, + readState: () => Effect.Effect, +): LogStore => { + const secrets = readState().pipe( + Effect.map(stateSecrets), + Effect.mapError( + () => new LogStoreError({ path: base.path, message: "Unable to read current stack secrets" }), + ), + ); + return { + path: base.path, + append: (record: LogRecord) => + secrets.pipe( + Effect.flatMap((known) => + base.append({ ...record, message: redactKnownSecrets(record.message, known) }), + ), + ), + read: (options) => + secrets.pipe( + Effect.flatMap((known) => + base + .read(options) + .pipe(Effect.map((entries) => entries.map((entry) => redactEntry(entry, known)))), + ), + ), + retained: (options) => + secrets.pipe( + Effect.flatMap((known) => + base + .retained(options) + .pipe(Effect.map((entries) => entries.map((entry) => redactEntry(entry, known)))), + ), + ), + stream: (options) => + Stream.unwrap( + secrets.pipe( + Effect.map((known) => + base.stream(options).pipe(Stream.map((entry) => redactEntry(entry, known))), + ), + ), + ), + }; +}; + +const readinessFor = ( + state: PersistedStackState, + workload: PlannedWorkload, +): Effect.Effect => { + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return Effect.fail(preparationError(`Unknown runtime specification for ${workload.id}`)); + const endpoint = spec.privateEndpoint(state, spec.readiness.binding, "native"); + if (endpoint === undefined) + return Effect.fail(preparationError(`Missing private readiness assignment for ${workload.id}`)); + return probeReadiness({ + mode: spec.readiness.protocol, + host: "127.0.0.1", + port: endpoint.port, + ...(spec.readiness.path === undefined ? {} : { path: spec.readiness.path }), + }); +}; + +const bootstrapContent = Effect.tryPromise({ + try: () => bundleServeMainTemplate(), + catch: (cause) => preparationError("Unable to bundle functions bootstrap", cause), +}); + +const mapDriverError = ( + key: Pick, + error: unknown, +): RuntimeDriverError => + driverError(key, error instanceof Error ? error.message : "Runtime operation failed", error); + +/** Composes concrete runtime owners around one persisted stack identity. */ +export const makeProductionRuntimeFactory = ( + options: ProductionRuntimeFactoryOptions, +): Effect.Effect< + SupervisorRuntimeFactory, + StackError, + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const initial = yield* currentStateReader(options); + const pathService = yield* Path.Path; + const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const ownerScope = yield* Scope.Scope; + const paths = yield* resolveStackPaths({ + stateRoot: options.stateRoot, + stackId: options.stackId, + }).pipe( + Effect.mapError((error) => preparationError("Unable to resolve stack runtime paths", error)), + ); + const registry = yield* makePortRegistry({ + stateRoot: options.stateRoot, + store: options.stateStore, + }); + const ingress = + options.ingress ?? + (yield* makeSupervisorIngress({ + stackId: options.stackId, + registry, + store: options.stateStore, + context: options.context, + })); + const envFiles = + options.envFileOwner ?? + (yield* makeRuntimeEnvFileOwner({ stateRoot: options.stateRoot, stackId: options.stackId })); + const functionsBootstrap = + options.functionsBootstrapOwner ?? + (yield* makeFunctionsBootstrapOwner({ + stateRoot: options.stateRoot, + stackId: options.stackId, + })); + const baseLogs = + options.logStore ?? + (yield* makeLogStore({ path: paths.logs, knownSecrets: stateSecrets(initial) }).pipe( + Effect.mapError((error) => preparationError("Unable to open stack logs", error)), + )); + const logs = dynamicLogStore(baseLogs, () => currentStateReader(options)); + const preparer = + options.artifactPreparer ?? + (yield* makeProductionRuntimeArtifactPreparer({ + stateRoot: options.stateRoot, + runtime: initial.runtime, + platform: { + os: + process.platform === "darwin" + ? "darwin" + : process.platform === "win32" + ? "windows" + : "linux", + desktop: process.platform === "darwin", + }, + })); + const containerEngine = options.containerEngine ?? preparer.containerEngine; + const serveTemplate = yield* Effect.cached(bootstrapContent); + const bootstrapDatabase = + options.bootstrapDatabase ?? ((state: PersistedStackState) => bootstrapDatabaseAt(state)); + + return { + make: (state: PersistedStackState): Effect.Effect => + Effect.gen(function* () { + const artifacts = new Map(); + const hostRoute = yield* Ref.make(undefined); + const bootstrapPaths = new Map(); + + const freshState = (key: Pick) => + currentStateReader(options).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + Effect.flatMap((fresh) => + runtimeMatches(fresh.runtime, state.runtime) + ? Effect.succeed(fresh) + : Effect.fail( + driverError(key, "Persisted runtime changed while owner was active"), + ), + ), + ); + const prepare = (runtime: StackRuntime, workload: PlannedWorkload) => { + const key = artifactKey(runtime, workload); + const cached = artifacts.get(key); + return cached === undefined + ? preparer.prepare(runtime, workload).pipe( + Effect.mapError((error) => + preparationError(`Unable to prepare ${workload.id}`, error), + ), + Effect.tap((prepared) => Effect.sync(() => artifacts.set(key, prepared))), + ) + : Effect.succeed(cached); + }; + const functionsPath = ( + generation: number, + ): Effect.Effect => { + const cached = bootstrapPaths.get(generation); + return cached === undefined + ? Effect.gen(function* () { + const content = yield* serveTemplate; + const path = yield* functionsBootstrap.write({ generation, content }); + bootstrapPaths.set(generation, path); + return path; + }) + : Effect.succeed(cached); + }; + const runtimeInputs = ( + workload: PlannedWorkload, + generation: number, + host: ContainerHostRoute | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const functions = + workload.id === "functions:edge-runtime" + ? { + bootstrapPath: yield* functionsPath(generation), + bootstrapContainerPath: FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, + } + : undefined; + return { + database: { dataPath: pathService.join(paths.data, "database") }, + storage: { dataPath: pathService.join(paths.data, "storage") }, + ...(functions === undefined ? {} : { functions }), + ...(host === undefined ? {} : { hostRoute: host }), + }; + }); + + const preflight = (input: LifecycleInput): Effect.Effect => + Effect.gen(function* () { + if (!runtimeMatches(input.state.runtime, state.runtime)) + return yield* new StackRuntimeMismatchError({ + message: "Lifecycle runtime does not match persisted runtime", + }); + if (input.state.runtime.kind === "container") { + if ( + containerEngine === undefined || + containerEngine.kind !== input.state.runtime.engine + ) + return yield* preparationError("Selected container engine is unavailable"); + const route = yield* containerEngine.preflight.pipe( + Effect.mapError((error) => + preparationError("Container host route preflight failed", error), + ), + ); + yield* Ref.set(hostRoute, route); + } + yield* Effect.forEach(input.plan.workloads, (workload) => + Effect.gen(function* () { + if (runtimeSpecFor(workload) === undefined) + return yield* preparationError( + `Unknown runtime specification for ${workload.id}`, + ); + if (workload.selected.kind !== input.state.runtime.kind) + return yield* preparationError(`Runtime artifact mismatch for ${workload.id}`); + yield* prepare(input.state.runtime, workload); + }), + ); + return {}; + }); + + const activate = ( + capability: import("../public/Capability.ts").CapabilityName, + input: LifecycleInput, + ): Effect.Effect< + { readonly host: string; readonly port: number }, + GatewayActivationError | StackError + > => + Effect.gen(function* () { + const workload = input.plan.workloads.find( + (entry) => entry.capability === capability, + ); + if (workload === undefined) + return yield* new GatewayActivationError({ + message: `Capability ${capability} has no workload`, + }); + const fresh = yield* freshState({ + stackId: options.stackId, + workloadId: workload.id, + }).pipe( + Effect.mapError((error) => new GatewayActivationError({ message: error.message })), + ); + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return yield* new GatewayActivationError({ + message: `Unknown runtime specification for ${workload.id}`, + }); + yield* validatePrivateAssignments(fresh, workload).pipe( + Effect.mapError((error) => new GatewayActivationError({ message: error.message })), + ); + const endpoint = spec.privateEndpoint(fresh, spec.readiness.binding, "native"); + if (endpoint === undefined) + return yield* new GatewayActivationError({ + message: `Missing private endpoint for ${workload.id}`, + }); + return endpoint; + }); + + const nativeDriver = yield* makeNativeRuntime({ + resolveProcess: (key, workload) => + freshState(key).pipe( + Effect.flatMap((fresh) => + Effect.gen(function* () { + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return yield* driverError( + key, + `Unknown runtime specification for ${workload.id}`, + ); + const inputs = yield* runtimeInputs( + workload, + key.desiredGeneration, + undefined, + ).pipe(Effect.mapError((error) => mapDriverError(key, error))); + yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + yield* validatePrivateAssignments(fresh, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + const prepared = yield* prepare(fresh.runtime, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + if (prepared.artifactRoot === undefined) + return yield* driverError( + key, + `Native artifact root is unavailable for ${workload.id}`, + ); + const endpoint = spec.privateEndpoint(fresh, spec.readiness.binding, "native"); + if (endpoint === undefined) + return yield* driverError( + key, + `Missing private port assignment for ${workload.id}`, + ); + const process = spec.nativeProcess( + prepared.artifactRoot, + fresh, + workload, + endpoint.port, + inputs, + ); + return { + ...process, + env: spec.env(fresh, workload, endpoint.port, "native", inputs), + }; + }), + ), + ), + waitForReadiness: (key, workload) => + freshState(key).pipe( + Effect.flatMap((fresh) => readinessFor(fresh, workload)), + Effect.mapError((error) => mapDriverError(key, error)), + ), + bootstrapDatabase: (key, workload) => + workload.bootstrap === "database" + ? freshState(key).pipe( + Effect.flatMap((fresh) => bootstrapDatabase(fresh)), + Effect.mapError((error) => mapDriverError(key, error)), + ) + : Effect.void, + logStore: logs, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), + Effect.provideService(Scope.Scope, ownerScope), + Effect.mapError((error) => + preparationError("Unable to initialize native runtime", error), + ), + ); + + let driver: RuntimeDriver = nativeDriver; + if (state.runtime.kind === "container") { + if (containerEngine === undefined || containerEngine.kind !== state.runtime.engine) + return yield* preparationError("Selected container engine is unavailable"); + driver = yield* makeContainerRuntime({ + engine: containerEngine, + ownerSessionId: options.ownerSessionId, + resolveWorkload: (key, workload) => + freshState(key).pipe( + Effect.flatMap((fresh) => + Effect.gen(function* () { + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return yield* driverError( + key, + `Unknown runtime specification for ${workload.id}`, + ); + let route = yield* Ref.get(hostRoute); + if (route === undefined) { + route = yield* containerEngine.preflight.pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + yield* Ref.set(hostRoute, route); + } + const inputs = yield* runtimeInputs( + workload, + key.desiredGeneration, + route, + ).pipe(Effect.mapError((error) => mapDriverError(key, error))); + yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + const resolution = yield* resolveContainerResolutionFor( + fresh, + workload, + inputs, + ).pipe(Effect.mapError((error) => mapDriverError(key, error))); + if (resolution === undefined) + return yield* driverError( + key, + `Unknown container runtime specification for ${workload.id}`, + ); + const envFile = yield* envFiles + .write({ + generation: key.desiredGeneration, + workloadId: workload.id, + values: resolution.env, + }) + .pipe(Effect.mapError((error) => mapDriverError(key, error))); + const volume = + workload.id === "database:database" + ? { target: "/var/lib/postgresql/data", readOnly: false } + : workload.id === "storage:storage" + ? { target: "/var/lib/storage", readOnly: false } + : undefined; + const { env: _env, ...withoutEnv } = resolution; + return { + ...withoutEnv, + envFile, + ...(volume === undefined ? {} : { volume }), + } satisfies ContainerWorkloadResolution; + }), + ), + ), + bootstrapDatabase: (key, workload) => + workload.bootstrap === "database" + ? freshState(key).pipe( + Effect.flatMap((fresh) => bootstrapDatabase(fresh)), + Effect.mapError((error) => mapDriverError(key, error)), + ) + : Effect.void, + }); + } + return { + driver, + preflight, + activate, + ingress, + logStore: logs, + } satisfies SupervisorRuntime; + }), + } satisfies SupervisorRuntimeFactory; + }); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 27adacc488..6017c648ca 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -47,6 +47,9 @@ export interface WorkloadRuntimeInputs { readonly bootstrapPath?: string; readonly bootstrapContainerPath?: string; }>; + /** Stack-owned native persistent data paths. Containers use their named volumes instead. */ + readonly database?: Readonly<{ readonly dataPath?: string }>; + readonly storage?: Readonly<{ readonly dataPath?: string }>; /** Host route used by containers to reach StackGateway. */ readonly hostRoute?: ContainerHostRoute; } @@ -780,8 +783,14 @@ const withStorageSettings = ( DATABASE_URL: dbUrl(state, "supabase_storage_admin", runtime), FILE_SIZE_LIMIT: valueAt(state, "storage", "file_size_limit") || "50MiB", STORAGE_BACKEND: "file", - FILE_STORAGE_BACKEND_PATH: "/var/lib/storage", - STORAGE_FILE_BACKEND_PATH: "/var/lib/storage", + FILE_STORAGE_BACKEND_PATH: + runtime === "container" + ? "/var/lib/storage" + : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), + STORAGE_FILE_BACKEND_PATH: + runtime === "container" + ? "/var/lib/storage" + : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), ENABLE_IMAGE_TRANSFORMATION: valueAt(state, "storage", "image_transformation.enabled") || "false", S3_PROTOCOL_ENABLED: valueAt(state, "storage", "s3_protocol.enabled") || "true", @@ -880,7 +889,7 @@ const specs: Readonly> = { PGDATA: runtime === "container" ? "/var/lib/postgresql/data" - : `${state.identity.projectRoot}/.supabase/db/data`, + : (_inputs.database?.dataPath ?? `${state.identity.projectRoot}/.supabase/db/data`), POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), TZDIR: "/var/db/timezone/zoneinfo", }), diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts new file mode 100644 index 0000000000..7d772eb9f6 --- /dev/null +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -0,0 +1,123 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path, Crypto, Stream } from "effect"; +import type { StackLogEntry } from "../public/Logs.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import type { StackStateStore } from "../state/StackStateStore.ts"; +import type { SupervisorIngress } from "../supervisor/Ingress.ts"; +import type { LogStore } from "../supervisor/LogStore.ts"; +import { makeProductionRuntimeFactory } from "./ProductionRuntime.ts"; +import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; +import type { RuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; +import type { FunctionsBootstrapOwner } from "../functions/FunctionsBootstrap.ts"; + +const stackId = StackIdSchema.make("a".repeat(64)); + +const stateFor = (secrets: PersistedStackState["secrets"]): PersistedStackState => ({ + format: "supabase-stack-state-v1", + identity: { + stackId, + projectRoot: "/tmp/production-runtime", + checkoutRoot: "/tmp/production-runtime", + workspaceId: "/tmp/production-runtime", + checkoutId: "/tmp/production-runtime", + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "production-runtime", + }, + runtime: { kind: "native" }, + desiredGeneration: 1, + portsGeneration: null, + desiredLifecycle: "stopped", + ports: [], + privatePorts: [], + secrets, +}); + +const stateStoreFor = (current: { value: PersistedStackState }): StackStateStore => ({ + read: () => Effect.sync(() => current.value), + write: () => Effect.die("unused"), + initialize: () => Effect.die("unused"), + replace: () => Effect.die("unused"), + replaceUnlocked: () => Effect.die("unused"), + cleanup: () => Effect.die("unused"), +}); + +const memoryLogStore = (entries: StackLogEntry[]): LogStore => ({ + path: "memory://production-runtime", + append: (record) => + Effect.sync(() => { + const entry: StackLogEntry = { + cursor: { opaque: `v1_${entries.length + 1}` }, + timestamp: record.timestamp ?? "2026-01-01T00:00:00.000Z", + source: record.source, + stream: record.stream, + message: record.message, + }; + entries.push(entry); + return entry; + }), + read: () => Effect.succeed(entries), + retained: () => Effect.succeed(entries), + stream: () => Stream.fromIterable(entries), +}); + +const ingress: SupervisorIngress = { + acquire: () => Effect.die("unused"), + open: () => Effect.die("unused"), + close: Effect.void, +}; + +const artifacts: RuntimeArtifactPreparer = { + prepare: () => Effect.die("unused"), +}; + +const envFiles: RuntimeEnvFileOwner = { + write: () => Effect.die("unused"), + cleanupGeneration: () => Effect.void, + cleanupAll: Effect.void, +}; + +const bootstrap: FunctionsBootstrapOwner = { + write: () => Effect.die("unused"), + cleanupGeneration: () => Effect.void, +}; + +describe("production runtime composition", () => { + it.live("redacts logs using secret slots materialized after factory creation", () => + Effect.scoped( + Effect.gen(function* () { + const current = { value: stateFor({}) }; + const entries: StackLogEntry[] = []; + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: "/tmp/production-runtime-state", + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + artifactPreparer: artifacts, + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + logStore: memoryLogStore(entries), + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + current.value = stateFor({ + "secret:auth.settings.jwt_secret": { policy: "managed", value: "rotated-secret" }, + }); + const logStore = runtime.logStore; + expect(logStore).toBeDefined(); + if (logStore === undefined) return; + yield* logStore.append({ + source: "auth", + stream: "stdout", + message: "token=rotated-secret", + }); + expect(entries[0]?.message).toBe("token=[REDACTED]"); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); +}); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 83fa049323..c22934e7b9 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -173,6 +173,30 @@ describe("workload runtime catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("uses owner-resolved StackId data paths for native persistence", () => + Effect.sync(() => { + const database = planned("database:database"); + const storage = planned("storage:storage"); + const databaseSpec = runtimeSpecFor(database); + const storageSpec = runtimeSpecFor(storage); + expect( + databaseSpec?.env(state, database, 5432, "native", { + database: { dataPath: "/state/stack/data/database" }, + }).PGDATA, + ).toBe("/state/stack/data/database"); + expect( + storageSpec?.env(state, storage, 5000, "native", { + storage: { dataPath: "/state/stack/data/storage" }, + }).FILE_STORAGE_BACKEND_PATH, + ).toBe("/state/stack/data/storage"); + expect( + storageSpec?.env(state, storage, 5000, "container", { + storage: { dataPath: "/ignored/native/path" }, + }).FILE_STORAGE_BACKEND_PATH, + ).toBe("/var/lib/storage"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live( "consumes nested capability settings and separates native/container database endpoints", () => From 024dbbafe61a50146b1ffbaee829e481d1f84ee2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 13:58:37 +0200 Subject: [PATCH 052/174] fix(stack): clean production runtime files --- .../stack/src/functions/FunctionsBootstrap.ts | 9 +- .../functions-bootstrap.integration.test.ts | 4 + .../stack/src/preparation/RuntimeArtifacts.ts | 26 ++- .../runtime-artifacts.integration.test.ts | 4 +- .../stack/src/runtime/ContainerRuntime.ts | 35 +++- .../stack/src/runtime/ProductionRuntime.ts | 167 ++++++++++-------- .../container-runtime.integration.test.ts | 3 +- .../production-runtime.integration.test.ts | 55 +++++- 8 files changed, 215 insertions(+), 88 deletions(-) diff --git a/packages/stack/src/functions/FunctionsBootstrap.ts b/packages/stack/src/functions/FunctionsBootstrap.ts index b4487a3309..5fa067609f 100644 --- a/packages/stack/src/functions/FunctionsBootstrap.ts +++ b/packages/stack/src/functions/FunctionsBootstrap.ts @@ -10,6 +10,8 @@ export interface FunctionsBootstrapOwner { readonly content: string; }) => Effect.Effect; readonly cleanupGeneration: (generation: number) => Effect.Effect; + /** Removes only this stack's functions bootstrap root. */ + readonly cleanupAll: Effect.Effect; } export interface FunctionsBootstrapOwnerOptions { @@ -112,5 +114,10 @@ export const makeFunctionsBootstrapOwner = ( "clean functions bootstrap generation", fs.remove(path.join(root, String(generation)), { recursive: true, force: true }), ); - return { write, cleanupGeneration }; + const cleanupAll = mapFs( + root, + "clean functions bootstrap files", + fs.remove(root, { recursive: true, force: true }), + ); + return { write, cleanupGeneration, cleanupAll }; }); diff --git a/packages/stack/src/functions/functions-bootstrap.integration.test.ts b/packages/stack/src/functions/functions-bootstrap.integration.test.ts index 646c875aa6..405f906460 100644 --- a/packages/stack/src/functions/functions-bootstrap.integration.test.ts +++ b/packages/stack/src/functions/functions-bootstrap.integration.test.ts @@ -21,6 +21,10 @@ describe("functions bootstrap owner", () => { expect(yield* fs.readFileString(target)).toBe("export default 1"); yield* owner.cleanupGeneration(4); expect(yield* fs.exists(target)).toBe(false); + const stale = yield* owner.write({ generation: 5, content: "export default 2" }); + yield* owner.cleanupAll; + expect(yield* fs.exists(stale)).toBe(false); + expect(yield* fs.exists(path.join(root, stackId, "runtime", "functions"))).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index efd03cdb8c..64e0e78891 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -48,7 +48,7 @@ export interface RuntimeArtifactPreparer { export type RuntimeArtifactPreparationError = ArtifactStoreError | StackPreparationError; export interface RuntimeArtifactPreparerOptions { - readonly native: { + readonly native?: { readonly store: ArtifactStore; /** Injected in tests; production defaults to the published SHA256SUMS boundary. */ readonly checksum?: ( @@ -79,6 +79,11 @@ export const makeRuntimeArtifactPreparer = ( ): RuntimeArtifactPreparer => ({ prepare: (runtime, workload) => { if (runtime.kind === "native") { + const native = options.native; + if (native === undefined) + return Effect.fail( + error("Native runtime has no configured artifact store", { workload: workload.id }), + ); if (workload.selected.kind !== "native") return Effect.fail( error("Native runtime received a container workload artifact", { @@ -86,17 +91,17 @@ export const makeRuntimeArtifactPreparer = ( }), ); return Effect.gen(function* () { - const artifact = yield* resolveNativeArtifactForWorkload(workload, options.native.platform); - const checksum = yield* options.native.checksum === undefined + const artifact = yield* resolveNativeArtifactForWorkload(workload, native.platform); + const checksum = yield* native.checksum === undefined ? slimServicesChecksum(artifact) - : options.native.checksum(artifact); + : native.checksum(artifact); const request = { key: artifactKey(artifact), sha256: checksum, requiredRuntimePaths: artifact.requiredRuntimePaths, executablePath: artifact.executablePath, }; - const prepared = yield* options.native.store.prepare(request); + const prepared = yield* native.store.prepare(request); return nativeResult(workload, artifact, prepared); }); } @@ -183,8 +188,13 @@ export const makeProductionRuntimeArtifactPreparer = (options: { const path = yield* Path.Path; const cacheRoot = path.join(path.resolve(options.stateRoot), "artifacts"); const artifacts = new Map(); - const source: ArtifactSource = makeSlimServicesSource((request) => artifacts.get(request.key)); - const store = yield* makeArtifactStore({ cacheRoot, source }); + const store = + options.runtime?.kind === "container" + ? undefined + : yield* makeArtifactStore({ + cacheRoot, + source: makeSlimServicesSource((request) => artifacts.get(request.key)), + }); const checksum = (artifact: NativeWorkloadArtifact) => { artifacts.set(artifactKey(artifact), artifact); return slimServicesChecksum(artifact); @@ -220,7 +230,7 @@ export const makeProductionRuntimeArtifactPreparer = (options: { ? { docker: containerEngine } : { podman: containerEngine }; const preparer = makeRuntimeArtifactPreparer({ - native: { store, checksum }, + ...(store === undefined ? {} : { native: { store, checksum } }), containers, }); return containerEngine === undefined ? preparer : { ...preparer, containerEngine }; diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 8629f8842f..e95b3d5b36 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem } from "effect"; +import { Effect, Exit, FileSystem, Path } from "effect"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ArtifactRequest, ArtifactStore, PreparedArtifact } from "./ArtifactStore.ts"; import type { @@ -70,12 +70,14 @@ describe("runtime artifact preparation", () => { Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-artifact-runtime-" }); const preparer = yield* makeProductionRuntimeArtifactPreparer({ stateRoot: root, runtime: { kind: "container", engine: "podman" }, }); expect(preparer.containerEngine?.kind).toBe("podman"); + expect(yield* fs.exists(path.join(root, "artifacts"))).toBe(false); const native = yield* makeProductionRuntimeArtifactPreparer({ stateRoot: root, runtime: { kind: "native" }, diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index ede9cfee82..8deba661ee 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -39,6 +39,12 @@ export interface ContainerRuntimeOptions { workload: PlannedWorkload, resource: ContainerResource, ) => Effect.Effect; + /** Probes an adopted or newly-started workload before reporting it ready. */ + readonly waitForReadiness?: ( + key: RuntimeWorkloadKey, + workload: PlannedWorkload, + resource: ContainerResource, + ) => Effect.Effect; } export interface ContainerWorkloadResolution { @@ -425,7 +431,9 @@ export const makeContainerRuntime = ( } const readiness = resolution.waitForReadiness === undefined - ? Effect.void + ? options.waitForReadiness === undefined + ? Effect.void + : options.waitForReadiness(key, workload, container) : resolution.waitForReadiness(key, workload, container); const startup = Effect.gen(function* () { yield* readiness; @@ -651,13 +659,26 @@ export const makeContainerRuntime = ( } adoptedIdentities.add(identity); const desiredWorkload = desiredWorkloads.get(entry.labels.workloadId); + const key: RuntimeWorkloadKey = { + stackId: entry.labels.stackId, + desiredGeneration: entry.labels.desiredGeneration, + workloadId: entry.labels.workloadId, + specHash: entry.labels.specHash, + }; + if (entry.state === "running" && desiredWorkload !== undefined) { + const readiness = options.waitForReadiness; + if (readiness !== undefined) { + const readinessExit = yield* Effect.exit(readiness(key, desiredWorkload, entry)); + if (Exit.isFailure(readinessExit)) { + recoveryCause = Cause.combine(recoveryCause, readinessExit.cause); + const stopExit = yield* Effect.exit(stopAdopted(entry)); + if (Exit.isFailure(stopExit)) + recoveryCause = Cause.combine(recoveryCause, stopExit.cause); + continue; + } + } + } if (entry.state === "running" && desiredWorkload?.bootstrap === "database") { - const key: RuntimeWorkloadKey = { - stackId: entry.labels.stackId, - desiredGeneration: entry.labels.desiredGeneration, - workloadId: entry.labels.workloadId, - specHash: entry.labels.specHash, - }; const bootstrap = options.bootstrapDatabase === undefined ? Effect.fail( diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index e97ad15678..0c26cf6777 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -405,79 +405,83 @@ export const makeProductionRuntimeFactory = ( return endpoint; }); - const nativeDriver = yield* makeNativeRuntime({ - resolveProcess: (key, workload) => - freshState(key).pipe( - Effect.flatMap((fresh) => - Effect.gen(function* () { - const spec = runtimeSpecFor(workload); - if (spec === undefined) - return yield* driverError( - key, - `Unknown runtime specification for ${workload.id}`, + let driver: RuntimeDriver; + if (state.runtime.kind === "native") { + driver = yield* makeNativeRuntime({ + resolveProcess: (key, workload) => + freshState(key).pipe( + Effect.flatMap((fresh) => + Effect.gen(function* () { + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return yield* driverError( + key, + `Unknown runtime specification for ${workload.id}`, + ); + const inputs = yield* runtimeInputs( + workload, + key.desiredGeneration, + undefined, + ).pipe(Effect.mapError((error) => mapDriverError(key, error))); + yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( + Effect.mapError((error) => mapDriverError(key, error)), ); - const inputs = yield* runtimeInputs( - workload, - key.desiredGeneration, - undefined, - ).pipe(Effect.mapError((error) => mapDriverError(key, error))); - yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - yield* validatePrivateAssignments(fresh, workload).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - const prepared = yield* prepare(fresh.runtime, workload).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - if (prepared.artifactRoot === undefined) - return yield* driverError( - key, - `Native artifact root is unavailable for ${workload.id}`, + yield* validatePrivateAssignments(fresh, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), ); - const endpoint = spec.privateEndpoint(fresh, spec.readiness.binding, "native"); - if (endpoint === undefined) - return yield* driverError( - key, - `Missing private port assignment for ${workload.id}`, + const prepared = yield* prepare(fresh.runtime, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), ); - const process = spec.nativeProcess( - prepared.artifactRoot, - fresh, - workload, - endpoint.port, - inputs, - ); - return { - ...process, - env: spec.env(fresh, workload, endpoint.port, "native", inputs), - }; - }), + if (prepared.artifactRoot === undefined) + return yield* driverError( + key, + `Native artifact root is unavailable for ${workload.id}`, + ); + const endpoint = spec.privateEndpoint( + fresh, + spec.readiness.binding, + "native", + ); + if (endpoint === undefined) + return yield* driverError( + key, + `Missing private port assignment for ${workload.id}`, + ); + const process = spec.nativeProcess( + prepared.artifactRoot, + fresh, + workload, + endpoint.port, + inputs, + ); + return { + ...process, + env: spec.env(fresh, workload, endpoint.port, "native", inputs), + }; + }), + ), ), + waitForReadiness: (key, workload) => + freshState(key).pipe( + Effect.flatMap((fresh) => readinessFor(fresh, workload)), + Effect.mapError((error) => mapDriverError(key, error)), + ), + bootstrapDatabase: (key, workload) => + workload.bootstrap === "database" + ? freshState(key).pipe( + Effect.flatMap((fresh) => bootstrapDatabase(fresh)), + Effect.mapError((error) => mapDriverError(key, error)), + ) + : Effect.void, + logStore: logs, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), + Effect.provideService(Scope.Scope, ownerScope), + Effect.mapError((error) => + preparationError("Unable to initialize native runtime", error), ), - waitForReadiness: (key, workload) => - freshState(key).pipe( - Effect.flatMap((fresh) => readinessFor(fresh, workload)), - Effect.mapError((error) => mapDriverError(key, error)), - ), - bootstrapDatabase: (key, workload) => - workload.bootstrap === "database" - ? freshState(key).pipe( - Effect.flatMap((fresh) => bootstrapDatabase(fresh)), - Effect.mapError((error) => mapDriverError(key, error)), - ) - : Effect.void, - logStore: logs, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), - Effect.provideService(Scope.Scope, ownerScope), - Effect.mapError((error) => - preparationError("Unable to initialize native runtime", error), - ), - ); - - let driver: RuntimeDriver = nativeDriver; - if (state.runtime.kind === "container") { + ); + } else { if (containerEngine === undefined || containerEngine.kind !== state.runtime.engine) return yield* preparationError("Selected container engine is unavailable"); driver = yield* makeContainerRuntime({ @@ -535,11 +539,21 @@ export const makeProductionRuntimeFactory = ( return { ...withoutEnv, envFile, + waitForReadiness: (resourceKey, resourceWorkload) => + freshState(resourceKey).pipe( + Effect.flatMap((current) => readinessFor(current, resourceWorkload)), + Effect.mapError((error) => mapDriverError(resourceKey, error)), + ), ...(volume === undefined ? {} : { volume }), } satisfies ContainerWorkloadResolution; }), ), ), + waitForReadiness: (key, workload) => + freshState(key).pipe( + Effect.flatMap((fresh) => readinessFor(fresh, workload)), + Effect.mapError((error) => mapDriverError(key, error)), + ), bootstrapDatabase: (key, workload) => workload.bootstrap === "database" ? freshState(key).pipe( @@ -549,8 +563,23 @@ export const makeProductionRuntimeFactory = ( : Effect.void, }); } + const runtimeDriver = driver; + const ownedCleanup = (stackId: StackId): Effect.Effect => + Effect.all([envFiles.cleanupAll, functionsBootstrap.cleanupAll], { + concurrency: "unbounded", + discard: true, + }).pipe( + Effect.mapError((error) => + driverError({ stackId, workloadId: "" }, "Unable to clean runtime files", error), + ), + ); + const ownedDriver: RuntimeDriver = { + ...runtimeDriver, + cleanup: (request) => + runtimeDriver.cleanup(request).pipe(Effect.andThen(ownedCleanup(request.stackId))), + }; return { - driver, + driver: ownedDriver, preflight, activate, ingress, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index d896c1a1b4..50096d55d4 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -743,6 +743,7 @@ describe("container runtime", () => { const runtime = yield* makeContainerRuntime({ engine: fakeContainerEngine(state), ownerSessionId: "new-owner", + waitForReadiness: () => Effect.sync(() => order.push("readiness")), bootstrapDatabase: () => Effect.sync(() => order.push("bootstrap")), }); const databaseWorkload = { ...workload(), bootstrap: "database" as const }; @@ -752,7 +753,7 @@ describe("container runtime", () => { desiredLifecycle: "running", plan: recoveryPlan([databaseWorkload]), }); - expect(order).toEqual(["bootstrap"]); + expect(order).toEqual(["readiness", "bootstrap"]); expect(adopted).toEqual([{ ...key, state: "ready" }]); expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(false); }), diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 7d772eb9f6..ca81755b30 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -9,8 +9,12 @@ import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; import { makeProductionRuntimeFactory } from "./ProductionRuntime.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; +import { + makeFunctionsBootstrapOwner, + type FunctionsBootstrapOwner, +} from "../functions/FunctionsBootstrap.ts"; import type { RuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; -import type { FunctionsBootstrapOwner } from "../functions/FunctionsBootstrap.ts"; +import { makeRuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; const stackId = StackIdSchema.make("a".repeat(64)); @@ -82,6 +86,7 @@ const envFiles: RuntimeEnvFileOwner = { const bootstrap: FunctionsBootstrapOwner = { write: () => Effect.die("unused"), cleanupGeneration: () => Effect.void, + cleanupAll: Effect.void, }; describe("production runtime composition", () => { @@ -120,4 +125,52 @@ describe("production runtime composition", () => { }).pipe(Effect.provide(NodeServices.layer)), ), ); + + it.live("cleans stale env and functions generations only during stack cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-cleanup-" }); + const current = { value: stateFor({}) }; + const envOwner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); + const functionsOwner = yield* makeFunctionsBootstrapOwner({ + stateRoot: root, + stackId, + }); + const envPath = yield* envOwner.write({ + generation: 4, + workloadId: "database:database", + values: { TOKEN: "stale" }, + }); + const bootstrapPath = yield* functionsOwner.write({ + generation: 4, + content: "export default 1", + }); + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + artifactPreparer: artifacts, + logStore: memoryLogStore([]), + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + yield* runtime.driver.stop({ + stackId, + desiredGeneration: 4, + workloadId: "database:database", + specHash: "stale", + }); + expect(yield* fs.exists(envPath)).toBe(true); + expect(yield* fs.exists(bootstrapPath)).toBe(true); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + expect(yield* fs.exists(envPath)).toBe(false); + expect(yield* fs.exists(bootstrapPath)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); }); From 02c92469afed331e5d2710751c586e208be3f88f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 14:00:44 +0200 Subject: [PATCH 053/174] fix(stack): preserve runtime file cleanup failures --- .../stack/src/runtime/ProductionRuntime.ts | 47 ++++++++++++------- .../production-runtime.integration.test.ts | 39 ++++++++++++++- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 0c26cf6777..02c6f52972 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -1,4 +1,4 @@ -import { Context, Crypto, Effect, FileSystem, Path, Ref, Scope, Stream } from "effect"; +import { Cause, Context, Crypto, Effect, Exit, FileSystem, Path, Ref, Scope, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { bundleServeMainTemplate } from "../functions/serve-main-bundler.ts"; @@ -201,6 +201,35 @@ const mapDriverError = ( ): RuntimeDriverError => driverError(key, error instanceof Error ? error.message : "Runtime operation failed", error); +/** Ensures owner files are attempted even when the selected runtime cleanup fails. */ +export const withOwnedRuntimeFileCleanup = ( + driver: RuntimeDriver, + envFiles: RuntimeEnvFileOwner, + functionsBootstrap: FunctionsBootstrapOwner, +): RuntimeDriver => { + const cleanupFiles = (stackId: StackId): Effect.Effect => + Effect.all([envFiles.cleanupAll, functionsBootstrap.cleanupAll], { + concurrency: "unbounded", + discard: true, + }).pipe( + Effect.mapError((error) => + driverError({ stackId, workloadId: "" }, "Unable to clean runtime files", error), + ), + ); + return { + ...driver, + cleanup: (request) => + Effect.gen(function* () { + const runtime = yield* Effect.exit(driver.cleanup(request)); + const files = yield* Effect.exit(cleanupFiles(request.stackId)); + if (Exit.isFailure(runtime) && Exit.isFailure(files)) + return yield* Effect.failCause(Cause.combine(runtime.cause, files.cause)); + if (Exit.isFailure(runtime)) return yield* Effect.failCause(runtime.cause); + if (Exit.isFailure(files)) return yield* Effect.failCause(files.cause); + }), + }; +}; + /** Composes concrete runtime owners around one persisted stack identity. */ export const makeProductionRuntimeFactory = ( options: ProductionRuntimeFactoryOptions, @@ -563,21 +592,7 @@ export const makeProductionRuntimeFactory = ( : Effect.void, }); } - const runtimeDriver = driver; - const ownedCleanup = (stackId: StackId): Effect.Effect => - Effect.all([envFiles.cleanupAll, functionsBootstrap.cleanupAll], { - concurrency: "unbounded", - discard: true, - }).pipe( - Effect.mapError((error) => - driverError({ stackId, workloadId: "" }, "Unable to clean runtime files", error), - ), - ); - const ownedDriver: RuntimeDriver = { - ...runtimeDriver, - cleanup: (request) => - runtimeDriver.cleanup(request).pipe(Effect.andThen(ownedCleanup(request.stackId))), - }; + const ownedDriver = withOwnedRuntimeFileCleanup(driver, envFiles, functionsBootstrap); return { driver: ownedDriver, preflight, diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index ca81755b30..777e5d87a5 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -1,13 +1,14 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Path, Crypto, Stream } from "effect"; +import { Effect, Exit, FileSystem, Path, Crypto, Stream } from "effect"; import type { StackLogEntry } from "../public/Logs.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; -import { makeProductionRuntimeFactory } from "./ProductionRuntime.ts"; +import { makeProductionRuntimeFactory, withOwnedRuntimeFileCleanup } from "./ProductionRuntime.ts"; +import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; import { makeFunctionsBootstrapOwner, @@ -173,4 +174,38 @@ describe("production runtime composition", () => { }).pipe(Effect.provide(NodeServices.layer)), ), ); + + it("attempts both owner cleanups when runtime cleanup fails", () => { + const calls: string[] = []; + const runtimeFailure = new RuntimeDriverError({ + message: "runtime cleanup failed", + stackId, + }); + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => Effect.die("unused"), + stop: () => Effect.void, + remove: () => Effect.void, + cleanup: () => Effect.fail(runtimeFailure), + recover: () => Effect.succeed([]), + }; + const envOwner: RuntimeEnvFileOwner = { + write: () => Effect.die("unused"), + cleanupGeneration: () => Effect.void, + cleanupAll: Effect.sync(() => { + calls.push("env"); + }), + }; + const functionsOwner: FunctionsBootstrapOwner = { + write: () => Effect.die("unused"), + cleanupGeneration: () => Effect.void, + cleanupAll: Effect.sync(() => { + calls.push("functions"); + }), + }; + const wrapped = withOwnedRuntimeFileCleanup(driver, envOwner, functionsOwner); + const result = Effect.runSyncExit(wrapped.cleanup({ stackId, destroy: false })); + expect(Exit.isFailure(result)).toBe(true); + expect(calls.sort()).toEqual(["env", "functions"]); + }); }); From f8328172cec12628d624895e2338627c41b0d8dc Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 14:05:14 +0200 Subject: [PATCH 054/174] docs(stack): record production runtime composition --- ...26-08-28-stack-runtime-rewrite-progress.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 640a846ecf..79d6ad92bd 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -827,3 +827,25 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g without removing it, allowing an idempotent retry on the next reconciliation. - Initial runtime settings are intentionally distinct from caller-driven database migrations, seeds, and reset. The reset/session flow remains deferred to the final rewrite slice. + +#### Task 13B2b4 — production runtime composition (2026-08-29) + +- The real Supervisor entrypoint now composes one runtime factory from the persisted runtime choice. Native + stacks construct only the slim-artifact/native driver path; container stacks construct only their selected + Docker or Podman engine and never probe or prepare the opposite runtime. +- Every workload operation re-reads persisted state before resolving artifacts, private endpoints, env, and + bootstrap inputs. Immutable preparation is cached per workload identity, container env remains in owned + `0600` generation files, native env remains on fd4, and database bootstrap runs only after a real loopback + readiness probe for both new and recovered workloads. +- Database and Storage use disjoint identity-scoped persistent roots. Container volumes survive stop and are + removed only on destroy; native paths remain under `/data/database` and `/data/storage`. + Functions bootstrap files and container env files are exact stack-owned runtime resources and are removed + on stop/destroy even when process/engine cleanup also fails, with both typed causes preserved. +- The runtime exposes the same loopback private endpoint to lazy gateway activation in native and container + modes. Runtime logs re-read current secret slots before every append/read/stream operation, so secrets + materialized after factory creation are redacted before persistence and in retained output. +- Review corrections removed eager native construction in container mode, added container readiness on start + and recovery before database bootstrap, and made runtime-file cleanup independent of engine cleanup. +- Fresh evidence: production-runtime, container-runtime, runtime-artifact, and functions-bootstrap integration + suites pass (32 tests), and `@supabase/stack` type-checking passes. Complete container log attachment, + public `credentials`/`prepare` handlers, and owner-resolved service file inputs remain later Task 13 slices. From 2db9d14e4614af4ef782f14071a17a12caeb06bf Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 14:18:24 +0200 Subject: [PATCH 055/174] feat(stack): expose persisted stack credentials --- .../stack/src/model/capabilities/storage.ts | 26 +- .../src/model/compiler.integration.test.ts | 24 ++ packages/stack/src/supervisor/Supervisor.ts | 114 +++++++- .../supervisor/supervisor.integration.test.ts | 253 +++++++++++++++++- 4 files changed, 409 insertions(+), 8 deletions(-) diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index 3ffa315774..951a8376a0 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Redacted, Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const Bucket = Schema.Struct({ @@ -7,6 +7,7 @@ const Bucket = Schema.Struct({ allowed_mime_types: Schema.optionalKey(Schema.Array(Schema.String)), objects_path: Schema.optionalKey(Schema.String), }); +const Secret = Schema.Redacted(Schema.String); const Analytics = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), max_namespaces: Schema.optionalKey(Schema.Finite), @@ -26,7 +27,14 @@ export const StorageSettingsSchema = Schema.Struct({ Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) }), ), buckets: Schema.optionalKey(Schema.Record(Schema.String, Bucket)), - s3_protocol: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + s3_protocol: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + region: Schema.optionalKey(Schema.String), + access_key_id: Schema.optionalKey(Schema.String), + secret_access_key: Schema.optionalKey(Secret), + }), + ), analytics: Schema.optionalKey(Analytics), vector: Schema.optionalKey(Vector), }); @@ -46,7 +54,14 @@ export const StorageModule: CapabilityModule = { file_size_limit: "50MiB", image_transformation: { enabled: false }, buckets: {}, - s3_protocol: { enabled: true }, + s3_protocol: { + enabled: true, + region: "local", + access_key_id: "625729a08b95bf1b7ff351a663f3a23c", + secret_access_key: Redacted.make( + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + ), + }, analytics: { enabled: false, max_namespaces: 5, max_tables: 10, max_catalogs: 2, buckets: {} }, vector: { enabled: true, max_buckets: 10, max_indexes: 5, buckets: {} }, }, @@ -67,8 +82,9 @@ export const StorageModule: CapabilityModule = { ]), }, routes: [{ listener: "api", protocol: "http" }], - secretPolicy: () => "passthrough", - managedSecretSlots: [], + secretPolicy: (path) => + path === "storage.settings.s3_protocol.secret_access_key" ? "managed" : "passthrough", + managedSecretSlots: ["storage.settings.s3_protocol.secret_access_key"], selectWorkloads: (settings, workloads) => { const record = (value: unknown): Record | undefined => typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index c933578351..1df9d1ca3c 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -76,6 +76,30 @@ describe("closed capability compiler", () => { }), ); + it.live("persists the closed storage S3 defaults", () => + Effect.gen(function* () { + const result = yield* compile({}); + expect(result.definition.capabilities.storage.settings.s3_protocol).toEqual({ + enabled: true, + region: "local", + access_key_id: "625729a08b95bf1b7ff351a663f3a23c", + secret_access_key: { slot: "secret:storage.settings.s3_protocol.secret_access_key" }, + }); + expect(result.secrets).toContainEqual( + expect.objectContaining({ + slot: "secret:storage.settings.s3_protocol.secret_access_key", + policy: "managed", + value: Redacted.make("850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907"), + }), + ); + expect( + result.secrets.some( + (entry) => entry.slot === "secret:storage.settings.s3_protocol.access_key_id", + ), + ).toBe(false); + }), + ); + it.live("uses one canonical JWT secret slot for symmetric signing", () => Effect.gen(function* () { const result = yield* compile({}); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 851ed03ab8..4f72bbc5e6 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -6,6 +6,7 @@ import { Exit, FileSystem, Path, + Redacted, Ref, Schema, Stream, @@ -56,6 +57,7 @@ import { import type { StackEndpoint, StackStatus } from "../public/Status.ts"; import { StackIdSchema, type StackId } from "../public/StackId.ts"; import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; +import type { EffectStackCredentials } from "../public/Credentials.ts"; import type { RuntimeDriver, RuntimeRecoveryRequest, @@ -142,6 +144,9 @@ const rpcError = (tag: StackRpcError["tag"], message: string): StackRpcError => const stateErrorMessage = (error: StackError | { readonly message?: string }): string => typeof error.message === "string" ? error.message : "Stack operation failed"; +const credentialHost = (address: string): string => + address.includes(":") && !address.startsWith("[") ? `[${address}]` : address; + const runtimeUnavailable = (): SupervisorRuntime => { const unavailable = (operation: string): Effect.Effect => Effect.fail(new RuntimeDriverError({ message: `Runtime ${operation} is unavailable` })); @@ -719,13 +724,118 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect = Effect.gen( + function* () { + const state = yield* read().pipe( + Effect.mapError((error) => + rpcError(rpcTag(error, "StackStateInvalidError"), stateErrorMessage(error)), + ), + ); + const actualPhase = yield* Ref.get(phase); + if ( + state === undefined || + actualPhase !== "running" || + state.desiredLifecycle !== "running" || + state.portsGeneration !== state.desiredGeneration + ) + return yield* Effect.fail( + rpcError("StackNotRunningError", "Stack credentials are unavailable"), + ); + + const definition = state.definition; + const databaseListener = definition?.listeners.database; + const databaseAssignment = state.ports.find(({ field }) => field === "database"); + if ( + definition === undefined || + databaseListener === undefined || + !databaseListener.enabled || + databaseAssignment === undefined + ) + return yield* Effect.fail( + rpcError("StackNotRunningError", "Stack credentials are unavailable"), + ); + + const auth = definition.capabilities.auth; + if (!auth.enabled) + return yield* Effect.fail( + rpcError("StackNotRunningError", "Stack credentials are unavailable"), + ); + + const requiredSecret = (slot: string): Effect.Effect => { + const value = state.secrets[slot]?.value; + return value === undefined || value.length === 0 + ? Effect.fail( + rpcError("StackSecretMismatchError", "Required stack credential is unavailable"), + ) + : Effect.succeed(value); + }; + + const databasePassword = yield* requiredSecret("secret:database.internal.password"); + const databaseHost = credentialHost(databaseListener.address); + const databaseUrl = `postgresql://${encodeURIComponent("postgres")}:${encodeURIComponent( + databasePassword, + )}@${databaseHost}:${databaseAssignment.port}/postgres`; + + const publishableKey = yield* requiredSecret("secret:auth.settings.publishable_key"); + const secretKey = yield* requiredSecret("secret:auth.settings.secret_key"); + const anonJwt = yield* requiredSecret("secret:auth.settings.anon_key"); + const serviceRoleJwt = yield* requiredSecret("secret:auth.settings.service_role_key"); + + const base: EffectStackCredentials = { + database: { + url: Redacted.make(databaseUrl), + password: Redacted.make(databasePassword), + }, + api: { + publishableKey, + secretKey: Redacted.make(secretKey), + anonJwt, + serviceRoleJwt: Redacted.make(serviceRoleJwt), + }, + }; + const storage = definition.capabilities.storage; + const s3 = storage.settings.s3_protocol; + if (!storage.enabled || s3 === null || s3 === undefined || s3.enabled !== true) return base; + + const apiListener = definition.listeners.api; + const apiAssignment = state.ports.find(({ field }) => field === "api"); + if (apiListener === undefined || !apiListener.enabled || apiAssignment === undefined) + return yield* Effect.fail( + rpcError("StackNotRunningError", "Stack credentials are unavailable"), + ); + const accessKeyId = s3.access_key_id; + const region = s3.region; + if ( + accessKeyId === null || + accessKeyId === undefined || + accessKeyId.length === 0 || + region === null || + region === undefined || + region.length === 0 + ) + return yield* Effect.fail( + rpcError("StackStateInvalidError", "Storage credentials are unavailable"), + ); + const secretAccessKey = yield* requiredSecret( + "secret:storage.settings.s3_protocol.secret_access_key", + ); + return { + ...base, + storage: { + endpoint: `http://${credentialHost(apiListener.address)}:${apiAssignment.port}/storage/v1/s3`, + region, + accessKeyId, + secretAccessKey: Redacted.make(secretAccessKey), + }, + } satisfies EffectStackCredentials; + }, + ); const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ status: () => status.pipe( Effect.mapError((error) => rpcError("StackStateInvalidError", stateErrorMessage(error))), ), - credentials: () => - Effect.fail(rpcError("StackNotRunningError", "Stack credentials are unavailable")), + credentials: () => credentials, prepare: () => Effect.fail(rpcError("StackPreparationError", "Stack preparation is not available yet")), start: ({ config }: { readonly config?: StackConfig }) => diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 9ebfc8a0a8..a87803afed 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1,10 +1,11 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path, Ref, Stream } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted, Ref, Stream } from "effect"; import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; import { GatewayActivationError, StackNotRunningError, + StackStateInvalidError, StackUpgradeRequiredError, } from "../public/Errors.ts"; import { @@ -214,6 +215,256 @@ describe("Supervisor composition", () => { ), ); + it.live("returns persisted database, API, and storage credentials while running", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined || running.definition === undefined) + return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); + yield* fixture.store + .replace( + fixture.id, + { + ...running, + ports: [ + { field: "api", port: 55433, intent: "exact" }, + { field: "database", port: 55432, intent: "exact" }, + ], + portsGeneration: running.desiredGeneration, + }, + running.desiredGeneration, + ) + .pipe(Effect.provideContext(fixture.context)); + const credentials = yield* fixture.supervisor.rpcHandlers.credentials(); + expect(credentials.database.url).toEqual(expect.anything()); + expect(Redacted.value(credentials.database.url)).toMatch( + /^postgresql:\/\/postgres:.+@127\.0\.0\.1:\d+\/postgres$/, + ); + expect(Redacted.value(credentials.database.password)).toEqual(expect.any(String)); + expect(credentials.api.publishableKey).toEqual(expect.any(String)); + expect(Redacted.value(credentials.api.secretKey)).toEqual(expect.any(String)); + expect(credentials.api.anonJwt).toEqual(expect.any(String)); + expect(Redacted.value(credentials.api.serviceRoleJwt)).toEqual(expect.any(String)); + expect(credentials.storage).toEqual( + expect.objectContaining({ + region: "local", + accessKeyId: "625729a08b95bf1b7ff351a663f3a23c", + }), + ); + expect(Redacted.value(credentials.storage?.secretAccessKey)).toBe( + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + ); + }), + ), + ); + + it.live("URL-encodes persisted database credentials and brackets IPv6 listeners", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined || running.definition === undefined) + return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); + const definition = { + ...running.definition, + listeners: { + ...running.definition.listeners, + database: { ...running.definition.listeners.database, address: "2001:db8::1" }, + }, + }; + const state = { + ...running, + definition, + ports: [ + { field: "api", port: 55433, intent: "exact" as const }, + { field: "database", port: 55432, intent: "exact" as const }, + ], + portsGeneration: running.desiredGeneration, + secrets: { + ...running.secrets, + "secret:database.internal.password": { + policy: "managed" as const, + value: "p@ss:word", + }, + }, + }; + yield* fixture.store + .replace(fixture.id, state, running.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const credentials = yield* fixture.supervisor.rpcHandlers.credentials(); + expect(Redacted.value(credentials.database.url)).toBe( + "postgresql://postgres:p%40ss%3Aword@[2001:db8::1]:55432/postgres", + ); + }), + ), + ); + + it.live("omits storage credentials when Storage is disabled", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ + config: { capabilities: { rest: {}, storage: { enabled: false } } }, + }); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined) + return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); + const state = { + ...running, + ports: [ + { field: "api", port: 55433, intent: "exact" as const }, + { field: "database", port: 55432, intent: "exact" as const }, + ], + portsGeneration: running.desiredGeneration, + }; + yield* fixture.store + .replace(fixture.id, state, running.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const credentials = yield* fixture.supervisor.rpcHandlers.credentials(); + expect(credentials.storage).toBeUndefined(); + }), + ), + ); + + it.live("rejects credentials when the durable generation or phase is not running", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined) + return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); + const stale = { + ...running, + ports: [{ field: "database", port: 55432, intent: "exact" as const }], + portsGeneration: running.desiredGeneration - 1, + }; + yield* fixture.store + .replace(fixture.id, stale, running.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const staleExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + expect(errorOf(staleExit)).toEqual( + expect.objectContaining({ tag: "StackNotRunningError" }), + ); + const stopped = { + ...stale, + portsGeneration: stale.desiredGeneration, + desiredLifecycle: "stopped" as const, + }; + yield* fixture.store + .replace(fixture.id, stopped, stale.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const stoppedExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + expect(errorOf(stoppedExit)).toEqual( + expect.objectContaining({ tag: "StackNotRunningError" }), + ); + }), + ), + ); + + it.live("fails closed when Auth is disabled or a required secret slot is absent", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ + config: { capabilities: { rest: {}, auth: { enabled: false } } }, + }); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined) + return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); + const state = { + ...running, + ports: [ + { field: "api", port: 55433, intent: "exact" as const }, + { field: "database", port: 55432, intent: "exact" as const }, + ], + portsGeneration: running.desiredGeneration, + }; + yield* fixture.store + .replace(fixture.id, state, running.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const authDisabled = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + expect(errorOf(authDisabled)).toEqual( + expect.objectContaining({ tag: "StackNotRunningError" }), + ); + if (state.definition === undefined) + return yield* new StackStateInvalidError({ message: "running definition is missing" }); + const baseSecrets = { + ...state.secrets, + "secret:auth.settings.publishable_key": { + policy: "managed" as const, + value: "publishable", + }, + "secret:auth.settings.secret_key": { policy: "managed" as const, value: "secret" }, + "secret:auth.settings.anon_key": { policy: "managed" as const, value: "anon" }, + "secret:auth.settings.service_role_key": { policy: "managed" as const, value: "service" }, + }; + const missingSecret = { + ...state, + definition: { + ...state.definition, + capabilities: { + ...state.definition.capabilities, + auth: { ...state.definition.capabilities.auth, enabled: true }, + }, + }, + secrets: Object.fromEntries( + Object.entries(baseSecrets).filter( + ([slot]) => slot !== "secret:auth.settings.publishable_key", + ), + ), + }; + yield* fixture.store + .replace(fixture.id, missingSecret, state.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const missingExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + expect(errorOf(missingExit)).toEqual( + expect.objectContaining({ tag: "StackSecretMismatchError" }), + ); + const storageSecretMissing = { + ...missingSecret, + secrets: Object.fromEntries( + Object.entries(baseSecrets).filter( + ([slot]) => slot !== "secret:storage.settings.s3_protocol.secret_access_key", + ), + ), + }; + yield* fixture.store + .replace(fixture.id, storageSecretMissing, state.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const storageExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + expect(errorOf(storageExit)).toEqual( + expect.objectContaining({ tag: "StackSecretMismatchError" }), + ); + const complete = { ...missingSecret, secrets: baseSecrets }; + const missingApiListener = { + ...complete, + ports: [{ field: "database", port: 55432, intent: "exact" as const }], + }; + yield* fixture.store + .replace(fixture.id, missingApiListener, state.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const listenerExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + expect(errorOf(listenerExit)).toEqual( + expect.objectContaining({ tag: "StackNotRunningError" }), + ); + }), + ), + ); + it.live("acknowledges quiesce only after runtime cleanup", () => run( Effect.gen(function* () { From 21200b836fd8e09416ff75ce9943ead1ed757595 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 14:35:39 +0200 Subject: [PATCH 056/174] feat(stack): capture container workload logs --- packages/stack/src/runtime/ContainerEngine.ts | 142 ++++++++- .../stack/src/runtime/ContainerRuntime.ts | 235 +++++++++++++-- packages/stack/src/runtime/DockerEngine.ts | 15 + packages/stack/src/runtime/PodmanEngine.ts | 15 + .../stack/src/runtime/ProductionRuntime.ts | 3 +- .../container-runtime.integration.test.ts | 283 +++++++++++++++++- 6 files changed, 657 insertions(+), 36 deletions(-) diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 31f851fee1..a1e5e477fc 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -137,23 +137,43 @@ export interface ContainerProcessRequest { readonly stdin?: string; readonly env?: Readonly>; } +export interface ContainerProcessOutputChunk { + readonly stream: "stdout" | "stderr"; + readonly bytes: Uint8Array; +} +export interface ContainerLogLine { + readonly stream: "stdout" | "stderr"; + readonly message: string; +} +export interface ContainerLogOptions { + /** Number of historical lines to replay before following. `all` is the default. */ + readonly tail?: "all" | 0; +} export interface ContainerCommandRunner { readonly executable: string; readonly run: ( request: ContainerProcessRequest, ) => Effect.Effect; + /** Follows one exact process's stdout/stderr until it exits. */ + readonly stream?: ( + request: ContainerProcessRequest, + ) => Stream.Stream; } export interface ControlledCommandRunnerOptions { readonly executable?: string; readonly run: ( request: ContainerProcessRequest, ) => Effect.Effect; + readonly stream?: ( + request: ContainerProcessRequest, + ) => Stream.Stream; } export const makeControlledCommandRunner = ( options: ControlledCommandRunnerOptions, ): ContainerCommandRunner => ({ executable: options.executable ?? "controlled-container-engine", run: options.run, + ...(options.stream === undefined ? {} : { stream: options.stream }), }); export interface ProcessCommandRunnerOptions { @@ -263,7 +283,72 @@ export const makeProcessCommandRunner = ( ), ); }; - return { executable: options.executable, run }; + const stream = ( + request: ContainerProcessRequest, + ): Stream.Stream => + Stream.scoped( + Stream.unwrap( + ChildProcess.make(options.executable, [...(options.baseArgs ?? []), ...request.args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + ...(request.env === undefined ? {} : { env: request.env }), + extendEnv: true, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + () => + new ContainerEngineProtocolError({ + operation: request.args[0] ?? "stream", + message: "Container engine log follower failed to start", + }), + ), + Effect.map((handle) => { + const mapOutput = ( + output: Stream.Stream, + streamName: "stdout" | "stderr", + ): Stream.Stream => + output.pipe( + Stream.map((bytes) => ({ stream: streamName, bytes })), + Stream.mapError( + () => + new ContainerEngineProtocolError({ + operation: request.args[0] ?? "stream", + message: "Container engine log follower output failed", + }), + ), + ); + const output = Stream.merge( + mapOutput(handle.stdout, "stdout"), + mapOutput(handle.stderr, "stderr"), + ); + const verifyExit = Stream.fromEffect( + handle.exitCode.pipe( + Effect.mapError( + () => + new ContainerEngineProtocolError({ + operation: request.args[0] ?? "stream", + message: "Container engine log follower exit status failed", + }), + ), + Effect.flatMap((code) => + Number(code) === 0 + ? Effect.void + : Effect.fail( + new ContainerCommandError({ + operation: request.args[0] ?? "stream", + message: `Container engine log follower exited (${String(code)})`, + }), + ), + ), + ), + ).pipe(Stream.flatMap(() => Stream.empty)); + return output.pipe(Stream.concat(verifyExit)); + }), + ), + ), + ); + return { executable: options.executable, run, stream }; }); export interface ContainerEngineCodecs { @@ -289,6 +374,10 @@ export interface ContainerEngineCodecs { spec: { readonly name: string; readonly labels: ContainerLabels }, kind: R, ) => Effect.Effect; + readonly serializeLogs: ( + id: string, + options: ContainerLogOptions | undefined, + ) => ContainerProcessRequest; } export interface ContainerEngineOptions { readonly kind: ContainerEngineKind; @@ -328,6 +417,11 @@ export interface ContainerEngine { readonly startContainer: (id: string) => Effect.Effect; readonly stopContainer: (id: string) => Effect.Effect; readonly removeContainer: (id: string) => Effect.Effect; + /** Follows one exact container and emits complete stdout/stderr lines. */ + readonly streamLogs?: ( + id: string, + options?: ContainerLogOptions, + ) => Stream.Stream; } export const makeContainerEngineCore = (options: ContainerEngineOptions): ContainerEngine => { const run = (command: ContainerCommand) => options.runner.run(options.codecs.serialize(command)); @@ -346,6 +440,51 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai ); const noResult = (operation: string, command: ContainerCommand) => check(operation, command).pipe(Effect.asVoid); + const streamLogs = ( + id: string, + logOptions?: ContainerLogOptions, + ): Stream.Stream => { + const source = options.runner.stream; + if (source === undefined) + return Stream.fail( + new ContainerEngineProtocolError({ + operation: "logs", + message: "Container engine log streaming is unavailable", + }), + ); + interface LineState { + readonly stdout: { readonly decoder: TextDecoder; remainder: string }; + readonly stderr: { readonly decoder: TextDecoder; remainder: string }; + } + const stateFor = (): LineState => ({ + stdout: { decoder: new TextDecoder(), remainder: "" }, + stderr: { decoder: new TextDecoder(), remainder: "" }, + }); + const split = ( + state: LineState, + chunk: ContainerProcessOutputChunk, + ): ReadonlyArray => { + const accumulator = state[chunk.stream]; + accumulator.remainder += accumulator.decoder.decode(chunk.bytes, { stream: true }); + const lines = accumulator.remainder.split(/\r?\n/); + accumulator.remainder = lines.pop() ?? ""; + return lines.map((message) => ({ stream: chunk.stream, message })); + }; + const flush = (state: LineState): ReadonlyArray => + (["stdout", "stderr"] as const).flatMap((streamName) => { + const accumulator = state[streamName]; + accumulator.remainder += accumulator.decoder.decode(); + if (accumulator.remainder.length === 0) return []; + const message = accumulator.remainder; + accumulator.remainder = ""; + return [{ stream: streamName, message }]; + }); + return source(options.codecs.serializeLogs(id, logOptions)).pipe( + Stream.mapAccum(stateFor, (state, chunk) => [state, split(state, chunk)] as const, { + onHalt: flush, + }), + ); + }; const preflight = options.kind === "docker" ? options.platform.remote === true @@ -431,6 +570,7 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai startContainer: (id) => noResult("start-container", { operation: "start-container", id }), stopContainer: (id) => noResult("stop-container", { operation: "stop-container", id }), removeContainer: (id) => noResult("remove-container", { operation: "remove-container", id }), + streamLogs, }; }; export interface SelectContainerEngineOptions { diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 8deba661ee..f12142faca 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -1,7 +1,8 @@ -import { Cause, Effect, Exit, Semaphore } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Option, Scope, Semaphore, Stream } from "effect"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { StackId } from "../public/StackId.ts"; +import type { LogStore } from "../supervisor/LogStore.ts"; import { type ContainerContainerSpec, type ContainerHostRoute, @@ -16,6 +17,7 @@ import { type ContainerResourceRole, type ContainerNetworkLabels, type ContainerWorkloadLabels, + type ContainerLogOptions, } from "./ContainerEngine.ts"; import { RuntimeDriverError, @@ -45,6 +47,8 @@ export interface ContainerRuntimeOptions { workload: PlannedWorkload, resource: ContainerResource, ) => Effect.Effect; + /** Persists exact container stdout/stderr lines while a workload is running. */ + readonly logStore?: LogStore; } export interface ContainerWorkloadResolution { @@ -81,7 +85,16 @@ export interface ContainerVolumeRequest { export interface ContainerRuntimeResourceIds { readonly container: string; readonly network: string; - readonly state: "running" | "stopped"; + readonly state: "running" | "stopped" | "failed"; + readonly error?: string; +} + +interface ContainerRuntimeResource extends ContainerRuntimeResourceIds { + readonly key: RuntimeWorkloadKey; + readonly workload: PlannedWorkload; + readonly failure: Deferred.Deferred; + logFiber?: Fiber.Fiber; + stopRequested: boolean; } const resourceKey = (key: RuntimeWorkloadKey): string => @@ -208,8 +221,12 @@ export const makeContainerRuntime = ( options: ContainerRuntimeOptions, ): Effect.Effect => Effect.gen(function* () { + const parentScope = yield* Effect.serviceOption(Scope.Scope); + const runtimeScope = Option.isSome(parentScope) + ? yield* Scope.fork(parentScope.value, "parallel") + : yield* Scope.make("parallel"); const lifecycle = yield* Semaphore.make(1); - const resources = new Map(); + const resources = new Map(); const withEngine = ( key: Pick, @@ -217,6 +234,57 @@ export const makeContainerRuntime = ( ): Effect.Effect => effect.pipe(Effect.mapError((error) => toDriverError(key, error))); + const stopLogs = (resource: ContainerRuntimeResource): Effect.Effect => + resource.logFiber === undefined ? Effect.void : Fiber.interrupt(resource.logFiber); + + const reportLogFailure = ( + resource: ContainerRuntimeResource, + error: unknown, + ): Effect.Effect => { + if (resource.stopRequested) return Effect.void; + const failure = new RuntimeDriverError({ + message: `Container log stream failed for ${resource.key.workloadId}`, + stackId: resource.key.stackId, + workloadId: resource.key.workloadId, + cause: error, + }); + resources.set(resourceKey(resource.key), { + ...resource, + state: "failed", + error: failure.message, + }); + return Deferred.fail(resource.failure, failure).pipe(Effect.asVoid); + }; + + const attachLogs = ( + resource: ContainerRuntimeResource, + tail: ContainerLogOptions["tail"], + ): Effect.Effect => { + const logStore = options.logStore; + if (logStore === undefined) return Effect.void; + if (options.engine.streamLogs === undefined) + return reportLogFailure(resource, new Error("Container log streaming is unavailable")); + const stream = options.engine.streamLogs(resource.container, { tail }); + const consume = stream.pipe( + Stream.runForEach((line) => + logStore + .append({ + source: resource.workload.capability, + stream: line.stream, + message: line.message, + }) + .pipe(Effect.asVoid), + ), + Effect.mapError((error) => toDriverError(resource.key, error)), + Effect.catch((error) => reportLogFailure(resource, error)), + ); + return Effect.gen(function* () { + resource.logFiber = yield* Effect.forkIn(consume, runtimeScope); + // Give the follower one scheduling turn so process acquisition begins before readiness. + yield* Effect.yieldNow; + }); + }; + const observe = ( stackId: StackId, ): Effect.Effect, RuntimeDriverError> => @@ -225,18 +293,27 @@ export const makeContainerRuntime = ( entries .filter(isWorkloadResource) .filter((entry) => entry.labels.stackId === stackId) - .map((entry) => ({ - stackId: entry.labels.stackId, - desiredGeneration: entry.labels.desiredGeneration, - workloadId: entry.labels.workloadId, - specHash: entry.labels.specHash, - state: - entry.state === "running" - ? "ready" - : entry.state === "stopped" - ? "stopped" - : "starting", - })), + .map((entry) => { + const key: RuntimeWorkloadKey = { + stackId: entry.labels.stackId, + desiredGeneration: entry.labels.desiredGeneration, + workloadId: entry.labels.workloadId, + specHash: entry.labels.specHash, + }; + const local = resources.get(resourceKey(key)); + return { + ...key, + state: + local?.state === "failed" + ? "failed" + : entry.state === "running" + ? "ready" + : entry.state === "stopped" + ? "stopped" + : "starting", + ...(local?.error === undefined ? {} : { error: local.error }), + }; + }), ), ); @@ -262,6 +339,14 @@ export const makeContainerRuntime = ( ? Effect.succeed({}) : options.resolveWorkload(key, workload); const startEffect = Effect.gen(function* () { + const existing = resources.get(resourceKey(key)); + if (existing !== undefined && existing.state !== "running") { + existing.stopRequested = true; + yield* stopLogs(existing); + if (existing.state === "failed") + yield* withEngine(key, options.engine.stopContainer(existing.container)); + resources.delete(resourceKey(key)); + } const resolution = yield* resolved; const requestedPublications = resolution.publications ?? []; if (requestedPublications.some((publication) => !isValidPublication(publication))) @@ -429,6 +514,25 @@ export const makeContainerRuntime = ( yield* withEngine(key, options.engine.startContainer(container.id)); startedByUs = true; } + const current = resources.get(resourceKey(key)); + const resource = + current !== undefined && current.container === container.id && current.state === "running" + ? current + : yield* Effect.gen(function* () { + const failure = yield* Deferred.make(); + const createdResource: ContainerRuntimeResource = { + key, + workload, + container: container.id, + network: networkResource.id, + state: "running", + failure, + stopRequested: false, + }; + resources.set(resourceKey(key), createdResource); + return createdResource; + }); + if (resource.logFiber === undefined) yield* attachLogs(resource, created ? "all" : 0); const readiness = resolution.waitForReadiness === undefined ? options.waitForReadiness === undefined @@ -436,7 +540,7 @@ export const makeContainerRuntime = ( : options.waitForReadiness(key, workload, container) : resolution.waitForReadiness(key, workload, container); const startup = Effect.gen(function* () { - yield* readiness; + yield* Effect.raceFirst(readiness, Deferred.await(resource.failure)); if (workload.bootstrap === "database") { if (options.bootstrapDatabase === undefined) return yield* toDriverError( @@ -448,6 +552,8 @@ export const makeContainerRuntime = ( }); const ready = yield* startup.pipe(Effect.exit); if (Exit.isFailure(ready)) { + resource.stopRequested = true; + yield* stopLogs(resource); const stopExit = startedByUs || workload.bootstrap === "database" ? yield* Effect.exit(withEngine(key, options.engine.stopContainer(container.id))) @@ -459,15 +565,11 @@ export const makeContainerRuntime = ( if (Exit.isFailure(stopExit)) cleanupCause = Cause.combine(cleanupCause, stopExit.cause); if (Exit.isFailure(removeExit)) cleanupCause = Cause.combine(cleanupCause, removeExit.cause); + resources.delete(resourceKey(key)); return yield* cleanupCause.reasons.length === 0 ? Effect.failCause(ready.cause) : Effect.failCause(Cause.combine(ready.cause, cleanupCause)); } - resources.set(resourceKey(key), { - container: container.id, - network: networkResource.id, - state: "running", - }); const result: ObservedWorkload = { ...key, state: "ready" }; return result; }); @@ -479,13 +581,12 @@ export const makeContainerRuntime = ( return found !== undefined ? found.state === "stopped" ? Effect.void - : withEngine(key, options.engine.stopContainer(found.container)).pipe( - Effect.tap(() => - Effect.sync(() => { - resources.set(resourceKey(key), { ...found, state: "stopped" }); - }), - ), - ) + : Effect.gen(function* () { + found.stopRequested = true; + yield* stopLogs(found); + yield* withEngine(key, options.engine.stopContainer(found.container)); + resources.set(resourceKey(key), { ...found, state: "stopped" }); + }) : withEngine(key, options.engine.listResources(key.stackId)).pipe( Effect.flatMap((entries) => { const exact = entries @@ -513,7 +614,9 @@ export const makeContainerRuntime = ( Effect.gen(function* () { const found = resources.get(resourceKey(key)); if (found !== undefined) { - if (found.state === "running") + found.stopRequested = true; + yield* stopLogs(found); + if (found.state !== "stopped") yield* withEngine(key, options.engine.stopContainer(found.container)); yield* withEngine(key, options.engine.removeContainer(found.container)); resources.delete(resourceKey(key)); @@ -549,6 +652,12 @@ export const makeContainerRuntime = ( if (Exit.isFailure(result)) cleanupCause = Cause.combine(cleanupCause, result.cause); }); + for (const resource of resources.values()) + if (resource.key.stackId === request.stackId) { + resource.stopRequested = true; + yield* stopLogs(resource); + } + // Stop and remove every stack workload before touching its network. A failed // stop does not prevent the remove attempt; all failures are returned together. for (const entry of owned.filter(isManagedContainer)) { @@ -584,6 +693,7 @@ export const makeContainerRuntime = ( for (const id of resources.keys()) if (id.startsWith(`${request.stackId}:`)) resources.delete(id); + yield* Scope.close(runtimeScope, Exit.void); if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); }), ); @@ -607,6 +717,7 @@ export const makeContainerRuntime = ( request.plan.workloads.map((workload) => [workload.id, workload]), ); const adopted: Array = []; + const adoptedResourceKeys = new Set(); const adoptedIdentities = new Set(); let retainedNetworkId: string | undefined; let recoveryCause: Cause.Cause = Cause.empty; @@ -697,6 +808,64 @@ export const makeContainerRuntime = ( continue; } } + if (entry.state === "running" && desiredWorkload !== undefined) { + const known = resources.get(resourceKey(key)); + if (known !== undefined && known.container === entry.id && known.state === "failed") { + recoveryCause = Cause.combine( + recoveryCause, + Cause.fail( + toDriverError(key, new Error(known.error ?? "Container log stream failed")), + ), + ); + known.stopRequested = true; + const stopExit = yield* Effect.exit(stopAdopted(entry)); + if (Exit.isFailure(stopExit)) + recoveryCause = Cause.combine(recoveryCause, stopExit.cause); + yield* stopLogs(known); + resources.delete(resourceKey(key)); + continue; + } + if ( + known !== undefined && + (known.container !== entry.id || known.state !== "running") + ) { + known.stopRequested = true; + yield* stopLogs(known); + resources.delete(resourceKey(key)); + } + const resource = + resources.get(resourceKey(key)) ?? + (yield* Effect.gen(function* () { + const failure = yield* Deferred.make(); + const adoptedResource: ContainerRuntimeResource = { + key, + workload: desiredWorkload, + container: entry.id, + network: retainedNetworkId ?? "", + state: "running", + failure, + stopRequested: false, + }; + resources.set(resourceKey(key), adoptedResource); + return adoptedResource; + })); + if (resource.logFiber === undefined) yield* attachLogs(resource, 0); + if (resource.state === "failed") { + recoveryCause = Cause.combine( + recoveryCause, + Cause.fail( + toDriverError(key, new Error(resource.error ?? "Container log stream failed")), + ), + ); + resource.stopRequested = true; + const stopExit = yield* Effect.exit(stopAdopted(entry)); + if (Exit.isFailure(stopExit)) + recoveryCause = Cause.combine(recoveryCause, stopExit.cause); + resources.delete(resourceKey(key)); + continue; + } + adoptedResourceKeys.add(resourceKey(key)); + } adopted.push({ stackId: entry.labels.stackId, desiredGeneration: entry.labels.desiredGeneration, @@ -729,8 +898,12 @@ export const makeContainerRuntime = ( ); } - for (const id of resources.keys()) - if (id.startsWith(`${request.stackId}:`)) resources.delete(id); + for (const [id, resource] of resources) + if (id.startsWith(`${request.stackId}:`) && !adoptedResourceKeys.has(id)) { + resource.stopRequested = true; + yield* stopLogs(resource); + resources.delete(id); + } if (recoveryCause.reasons.length > 0) return yield* Effect.failCause(recoveryCause); return adopted; }), diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index fb62a13c22..ef8399fdd2 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -7,6 +7,7 @@ import { type ContainerEngine, type ContainerEngineFailure, type ContainerEngineOptions, + type ContainerLogOptions, type ContainerLabels, type ContainerNetworkLabels, type ContainerProcessRequest, @@ -186,6 +187,19 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc } }; +export const serializeDockerLogs = ( + id: string, + options: ContainerLogOptions | undefined, +): ContainerProcessRequest => ({ + args: [ + "logs", + "--follow", + "--tail", + options?.tail === undefined ? "all" : String(options.tail), + id, + ], +}); + const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => new ContainerEngineProtocolError({ operation, @@ -386,6 +400,7 @@ const decodeCreate = ( export const makeDockerCodecs = () => ({ serialize: serializeDockerCommand, + serializeLogs: serializeDockerLogs, decodeProbe: (result: ContainerCommandResult) => scalarString("probe", result.stdout).pipe(Effect.asVoid), decodeImage: (result: ContainerCommandResult) => diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index f1d3a16521..da503db4e9 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -7,6 +7,7 @@ import { type ContainerEngine, type ContainerEngineFailure, type ContainerEngineOptions, + type ContainerLogOptions, type ContainerLabels, type ContainerNetworkLabels, type ContainerProcessRequest, @@ -182,6 +183,19 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc } }; +export const serializePodmanLogs = ( + id: string, + options: ContainerLogOptions | undefined, +): ContainerProcessRequest => ({ + args: [ + "logs", + "--follow", + "--tail", + options?.tail === undefined ? "all" : String(options.tail), + id, + ], +}); + const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => new ContainerEngineProtocolError({ operation, @@ -364,6 +378,7 @@ const decodeCreate = ( export const makePodmanCodecs = () => ({ serialize: serializePodmanCommand, + serializeLogs: serializePodmanLogs, decodeProbe: (result: ContainerCommandResult) => scalar("probe", result.stdout).pipe(Effect.asVoid), decodeImage: (result: ContainerCommandResult) => diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 02c6f52972..be03b022da 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -590,7 +590,8 @@ export const makeProductionRuntimeFactory = ( Effect.mapError((error) => mapDriverError(key, error)), ) : Effect.void, - }); + logStore: logs, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); } const ownedDriver = withOwnedRuntimeFileCleanup(driver, envFiles, functionsBootstrap); return { diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 50096d55d4..3883bd3db0 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Option, Stream } from "effect"; import { NodeServices } from "@effect/platform-node"; import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; @@ -14,6 +14,7 @@ import { type ContainerProcessRequest, type ContainerCommandResult, type ContainerContainerSpec, + type ContainerLogLine, type ContainerEngine, type ContainerNetworkSpec, type ContainerResource, @@ -23,6 +24,7 @@ import { makeDockerEngine, serializeDockerCommand } from "./DockerEngine.ts"; import { makePodmanEngine, serializePodmanCommand } from "./PodmanEngine.ts"; import { makeContainerRuntime } from "./ContainerRuntime.ts"; import { RuntimeDriverError, type RuntimeWorkloadKey } from "./RuntimeDriver.ts"; +import { LogStoreError, type LogRecord, type LogStore } from "../supervisor/LogStore.ts"; const stackId = StackIdSchema.make("a".repeat(64)); const key: RuntimeWorkloadKey = { @@ -206,6 +208,19 @@ const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { }; }; +const memoryLogStore = (records: LogRecord[]): LogStore => ({ + path: "memory://container-logs", + append: (record) => + Effect.sync(() => ({ + cursor: { opaque: `v1_${records.length + 1}` }, + timestamp: "2026-01-01T00:00:00.000Z", + ...record, + })).pipe(Effect.tap((entry) => Effect.sync(() => records.push(entry)))), + read: () => Effect.succeed([]), + retained: () => Effect.succeed([]), + stream: () => Stream.empty, +}); + describe("container runtime", () => { it.live("executes the exact command argv through a bounded process boundary", () => Effect.gen(function* () { @@ -222,6 +237,24 @@ describe("container runtime", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("interrupts an owned Docker log follower subprocess", () => + Effect.gen(function* () { + const firstChunk = yield* Deferred.make(); + const runner = yield* makeProcessCommandRunner({ + executable: process.execPath, + baseArgs: ["-e", "process.stdout.write('follower-line\\n'); setInterval(() => {}, 1000)"], + }); + const follower = yield* runner.stream!({ + args: ["logs", "--follow", "--tail", "0", "container-id"], + }).pipe( + Stream.runForEach(() => Deferred.succeed(firstChunk, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(firstChunk); + yield* Fiber.interrupt(follower); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("selects explicit engines and only falls back on missing executable", () => Effect.gen(function* () { const missing = makeControlledCommandRunner({ @@ -381,6 +414,146 @@ describe("container runtime", () => { }), ); + it.live( + "captures container logs before readiness and does not duplicate same-resource followers", + () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const records: LogRecord[] = []; + const appended = yield* Deferred.make(); + let followerCalls = 0; + let followerAttached = false; + const base = fakeContainerEngine(state); + const baseLogStore = memoryLogStore(records); + const logStore: LogStore = { + ...baseLogStore, + append: (record) => + baseLogStore + .append(record) + .pipe(Effect.tap(() => Deferred.succeed(appended, undefined))), + }; + const engine: ContainerEngine = { + ...base, + streamLogs: () => { + followerCalls += 1; + followerAttached = true; + return Stream.fromIterable([ + { stream: "stdout", message: "container-started" } satisfies ContainerLogLine, + ]); + }, + }; + const runtime = yield* makeContainerRuntime({ + engine, + ownerSessionId: "owner-session", + logStore, + resolveWorkload: () => + Effect.succeed({ + waitForReadiness: () => + followerAttached + ? Effect.void + : Effect.fail( + new RuntimeDriverError({ + message: "logs not attached", + stackId: key.stackId, + workloadId: key.workloadId, + }), + ), + }), + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + expect(followerCalls).toBe(1); + yield* Deferred.await(appended); + expect(records.map((record) => record.message)).toEqual(["container-started"]); + yield* runtime.start(key, workload()); + expect(followerCalls).toBe(1); + yield* runtime.stop(key); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + }), + ); + + it.live("fails startup and removes a new container when log persistence fails", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const logError = new LogStoreError({ + path: "memory://container-logs-failure", + message: "disk full", + }); + const readiness = yield* Deferred.make(); + const logStore: LogStore = { + path: logError.path, + append: () => Effect.fail(logError), + read: () => Effect.succeed([]), + retained: () => Effect.succeed([]), + stream: () => Stream.empty, + }; + const runtime = yield* makeContainerRuntime({ + engine: { + ...fakeContainerEngine(state), + streamLogs: () => + Stream.fromIterable([ + { stream: "stdout", message: "will-not-persist" } satisfies ContainerLogLine, + ]), + }, + ownerSessionId: "owner-session", + logStore, + resolveWorkload: () => + Effect.succeed({ waitForReadiness: () => Deferred.await(readiness) }), + }); + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); + }), + ); + + it.live("marks a running container failed when its log follower fails", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const followerFailure = yield* Deferred.make(); + const runtime = yield* makeContainerRuntime({ + engine: { + ...fakeContainerEngine(state), + streamLogs: () => Stream.fromEffect(Deferred.await(followerFailure)), + }, + ownerSessionId: "owner-session", + logStore: memoryLogStore([]), + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + yield* Deferred.fail( + followerFailure, + new ContainerEngineProtocolError({ + operation: "logs", + message: "follower disconnected", + }), + ); + yield* Effect.yieldNow; + const observed = yield* runtime.observe(key.stackId); + expect(observed[0]?.state).toBe("failed"); + yield* runtime.stop(key); + }), + ); + it.live("rejects malformed inspect output and workload port publication", () => Effect.gen(function* () { let calls = 0; @@ -601,6 +774,48 @@ describe("container runtime", () => { }), ); + it.live("follows one Docker or Podman log process and joins split stdout/stderr lines", () => + Effect.gen(function* () { + const requests: ContainerProcessRequest[] = []; + const chunks: ReadonlyArray<{ + readonly stream: "stdout" | "stderr"; + readonly bytes: Uint8Array; + }> = [ + { stream: "stdout", bytes: new TextEncoder().encode("first\nsec") }, + { stream: "stderr", bytes: new TextEncoder().encode("error\nlast") }, + { stream: "stdout", bytes: new TextEncoder().encode("ond\n") }, + ]; + const runner = makeControlledCommandRunner({ + run: () => Effect.succeed(commandResult("ok")), + stream: (request) => { + requests.push(request); + return Stream.fromIterable(chunks); + }, + }); + const docker = makeDockerEngine({ + runner, + platform: { os: "linux", desktop: false }, + }); + const podman = makePodmanEngine({ + runner, + platform: { os: "linux", rootless: true }, + }); + const dockerLogs = yield* Stream.runCollect(docker.streamLogs!("container-id", { tail: 0 })); + const podmanLogs = yield* Stream.runCollect(podman.streamLogs!("podman-id")); + expect(dockerLogs).toEqual([ + { stream: "stdout", message: "first" }, + { stream: "stderr", message: "error" }, + { stream: "stdout", message: "second" }, + { stream: "stderr", message: "last" }, + ] satisfies ReadonlyArray); + expect(podmanLogs).toEqual(dockerLogs); + expect(requests.map((request) => request.args)).toEqual([ + ["logs", "--follow", "--tail", "0", "container-id"], + ["logs", "--follow", "--tail", "all", "podman-id"], + ]); + }), + ); + it.live("rejects non-loopback publications before preflight or daemon mutation", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -759,6 +974,68 @@ describe("container runtime", () => { }), ); + it.live("attaches one current-output follower when recovering a running container", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [ + { + id: "adopted-container", + name: "adopted-container", + kind: "workload", + state: "running", + labels: { + stackId: key.stackId, + ownerSessionId: "previous-owner", + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash: key.specHash, + role: "workload", + }, + }, + ], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const order: string[] = []; + const records: LogRecord[] = []; + let followers = 0; + const runtime = yield* makeContainerRuntime({ + engine: { + ...fakeContainerEngine(state), + streamLogs: (_id, options) => { + followers += 1; + order.push(`attach:${options?.tail ?? "default"}`); + return Stream.fromIterable([ + { stream: "stdout", message: "recovered-current-output" } satisfies ContainerLogLine, + ]); + }, + }, + ownerSessionId: "new-owner", + logStore: memoryLogStore(records), + waitForReadiness: () => Effect.sync(() => order.push("readiness")), + }); + const recovered = yield* runtime.recover({ + stackId: key.stackId, + desiredGeneration: key.desiredGeneration, + desiredLifecycle: "running", + plan: recoveryPlan([workload()]), + }); + expect(recovered).toEqual([{ ...key, state: "ready" }]); + expect(order).toEqual(["readiness", "attach:0"]); + yield* runtime.recover({ + stackId: key.stackId, + desiredGeneration: key.desiredGeneration, + desiredLifecycle: "running", + plan: recoveryPlan([workload()]), + }); + expect(followers).toBe(1); + yield* runtime.cleanup({ stackId: key.stackId, destroy: false }); + expect(records.map((record) => record.message)).toContain("recovered-current-output"); + }), + ); + it.live( "stops but does not remove an adopted database after bootstrap failure, then retries", () => @@ -797,7 +1074,7 @@ describe("container runtime", () => { bootstrapDatabase: () => Effect.gen(function* () { attempts += 1; - if (attempts === 1) return yield* Effect.fail(bootstrapError); + if (attempts === 1) return yield* bootstrapError; }), }); const databaseWorkload = { ...workload(), bootstrap: "database" as const }; @@ -1059,7 +1336,7 @@ describe("container runtime", () => { bootstrapDatabase: () => Effect.gen(function* () { attempts += 1; - if (attempts === 1) return yield* Effect.fail(bootstrapError); + if (attempts === 1) return yield* bootstrapError; }), }); const result = yield* adopted From afc26d97ae6d76114b71d890f0eba83013e5272d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 14:50:56 +0200 Subject: [PATCH 057/174] docs(stack): narrow the remaining rewrite scope --- ...26-08-28-stack-runtime-rewrite-progress.md | 23 +++++--- .../plans/2026-08-28-stack-runtime-rewrite.md | 59 ++++--------------- ...2026-08-28-stack-runtime-rewrite-design.md | 44 ++++---------- 3 files changed, 35 insertions(+), 91 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 79d6ad92bd..fd6334fc4c 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -86,10 +86,17 @@ updated in the same commit as each completed slice. Scratch review packages live > **Ruling:** Functions have one serving path through the managed stack Edge Runtime in both native > and container modes; `supabase functions serve` is a managed-stack client, not a separate Docker -> workflow. The caller owns migrations, schemas, and seeds, while the narrow database reset session -> is deliberately the last behavioral implementation slice after the complete catalog and facade/CLI -> migration — cost if wrong: changing either ownership boundary would require reshaping the public -> lifecycle contract rather than adding another adapter. +> workflow. The caller owns migrations, schemas, and seeds. Database reset is deliberately outside +> this rewrite and reserves no API, durable state, or lifecycle variant; it will be designed in a +> separate future session from observed caller needs — cost if wrong: reset work would require an +> explicit later public-contract change rather than a placeholder in this implementation. + +> **Ruling:** `supabase/slim-services` is the canonical release contract for native and container +> workloads: each supported version uses its portable archive and the GHCR image derived from that +> archive. The initial database catalog supports only PostgreSQL `17.6.1.166`; PostgreSQL 15 is added +> only after both artifacts are published, and all other selectors fail before mutation — cost if +> wrong: adding a version is a catalog change, while retaining unsupported historical selectors would +> create runtime identities that cannot be prepared. When the design does not determine another choice, record it here as: @@ -101,10 +108,10 @@ Implementation continues after recording a ruling; this file is not a question q ### Plan self-review — 2026-08-28 -- Mapped all design sections to 15 implementation tasks: cleanup, public model, identity, compiler, +- Mapped all design sections to the implementation tasks for cleanup, public model, identity, compiler, durable state/secrets/ports, ownership, reconciliation/observability, native preparation/runtime, gateway/activation, Functions, container runtime, lifecycle/recovery, facades/CLI, complete - catalog/platform coverage, the final reset session, and completion audit. + catalog/platform coverage, and completion audit. Database reset is tracked outside this rewrite. - Removed an ordering conflict: public `StackConfig` is defined with capability Modules before the durable state schema consumes `StackDefinition`. - Verified 55 executable steps, 42 balanced Markdown fences, and no placeholder patterns. @@ -825,8 +832,8 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g statements are built by parameterized `format()` calls so generated secret SQL is never exposed in mapped errors. A bootstrap failure cleans up new resources and stops an adopted database container without removing it, allowing an idempotent retry on the next reconciliation. -- Initial runtime settings are intentionally distinct from caller-driven database migrations, seeds, and - reset. The reset/session flow remains deferred to the final rewrite slice. +- Initial runtime settings are intentionally distinct from caller-driven database migrations and seeds. + Database reset is outside this rewrite and will be tackled in a separate future session. #### Task 13B2b4 — production runtime composition (2026-08-29) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index c68f6755ad..0d2601d537 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -39,8 +39,11 @@ and Node, Effect RPC, Vitest with `@effect/vitest`, pnpm/Turbo, native subproces - Lazy activation is mandatory in both runtimes and has no idle eviction. - Functions are served only through the stack Edge Runtime. `functionsRoot` is the sole host root and container mount; reject traversal, absolute, and symlink escapes. -- The caller owns migrations, declarative schemas, and seeds. The durable database reset session is - the final implementation slice. +- Use the portable archive and image from the same qualified `supabase/slim-services` release for + each workload. Initially accept only PostgreSQL `17.6.1.166`; add PostgreSQL 15 only after its + matching archive and image are published. +- The caller owns migrations, declarative schemas, and seeds. Database reset is outside this rewrite; + do not add a reset API, state, lifecycle variant, or compatibility placeholder. - Tests follow strict red-green-refactor and exercise consumed behavior. Prefer integration tests; unit-test only pure compiler/planner/state-machine logic. No arbitrary sleeps, released-port reuse, global process scans, or broad cleanup. @@ -671,49 +674,8 @@ artifact keys, state schemas, drivers, or control protocols. git commit -m "feat(stack): complete the workload catalog" ``` -### Task 14: Deferred final database reset session - -**Files:** - -- Create: `packages/stack/src/supervisor/DatabaseReset.ts` -- Modify: public Effect/Promise contracts, durable state, Supervisor/Reconciler/status -- Create: `packages/stack/src/supervisor/database-reset.integration.test.ts` - -**Interfaces:** - -- Produces `beginDatabaseReset` returning a fenced durable session with database credentials and - explicit `complete`/`fail`; no all-in-one migration/seed/reset method. -- While active, observable lifecycle is `resetting-database`, desired lifecycle remains running, and - database dependents stay stopped across caller loss. - -- [ ] **Step 1: Freeze the smallest session schema from observed needs** - - Record the final field/API choice as a ruling in progress before implementation. The persisted - session contains only session identity, generation fence, phase, and timestamps needed for recovery; - migration history stays in PostgreSQL. - -- [ ] **Step 2: Write session integration scenarios and verify RED** - - Cover exact data recreation, bootstrap ordering, dependent fencing, explicit complete/fail, caller - loss, later resumption/recovery, stale session rejection, and mechanical Promise adaptation. - -- [ ] **Step 3: Implement the durable session** - - Database recreation and bootstrap are Stack-owned. The session exposes the ready database to the - caller, which owns migrations/declarative schemas/seeds and reports completion or failure. - -- [ ] **Step 4: Verify and commit** - - Run only `database-reset.integration.test.ts`, lifecycle tests affected by the new state, facade - adaptation tests, and stack type-check. Commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): add caller-driven database reset sessions" - ``` - -This is the last behavioral implementation task. Do not begin it until the runtime, complete -catalog, managed Functions path, Promise facade, and CLI migration are stable. +Database reset is deliberately not an implementation task in this plan. It will be designed from +observed caller needs in a separate session after this rewrite is complete. ### Final documentation and completion audit @@ -726,7 +688,7 @@ catalog, managed Functions path, Promise facade, and CLI migration are stable. **Interfaces:** -- Confirms all ten capabilities and the final reset session, limits public exports to `.`, +- Confirms all ten capabilities, limits public exports to `.`, `./effect`, and `./testing`, and leaves no process-compose or legacy stack surface. - [ ] **Step 1: Audit every spec requirement against authoritative evidence** @@ -736,9 +698,8 @@ catalog, managed Functions path, Promise facade, and CLI migration are stable. - [ ] **Step 2: Fill audit gaps test-first** - Treat any missing catalog/platform evidence as incomplete Task 13 work and any missing reset-flow - evidence as incomplete Task 14 work. Add the smallest compiled CLI smoke for start/status/stop and - managed Functions serving. + Treat any missing catalog/platform evidence as incomplete Task 13 work. Add the smallest compiled + CLI smoke for start/status/stop and managed Functions serving. - [ ] **Step 3: Run final targeted verification** diff --git a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md index 60ffd968d8..2d82cf801a 100644 --- a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md +++ b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md @@ -45,6 +45,10 @@ These decisions are closed for this rewrite: there is no idle eviction. - Native and container stacks are strictly separate, and every catalog workload has both a native and a container artifact. One stack identity can never mix them. +- `supabase/slim-services` is the canonical workload release contract: a supported service version + uses its portable archive in native mode and the image derived from that same archive in container + mode. The initial database catalog supports only PostgreSQL `17.6.1.166`; PostgreSQL 15 can be + added when its matching archive and image exist, while every other selector fails before mutation. - Functions have exactly one serving path: the stack-owned Edge Runtime. This applies to normal stack traffic and `supabase functions serve`; there is no separate Docker or standalone serving workflow. `functionsRoot` is the only host root and the only read-only container mount, and every @@ -58,9 +62,8 @@ These decisions are closed for this rewrite: ordered steps selected by that exact artifact; they are not a second public version input. - Corrupt durable state fails closed. Logs redact every exact known secret and never persist or emit full secret environments, files, or arguments. -- There is no all-in-one `resetDatabase` operation. Stack owns lifecycle fencing, data recreation, - and internal bootstrap; the caller owns migrations, declarative schemas, and seeds through the - narrow reset-session API designed in the final behavioral slice of the rewrite. +- Database reset is outside this rewrite. No reset operation, session, lifecycle state, or durable + reset metadata is included; the caller continues to own migrations, declarative schemas, and seeds. The design follows the object-level separation used by Docker without copying its CLI: @@ -148,13 +151,7 @@ type CapabilityState = "disabled" | "dormant" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed"; type StackLifecycle = - | "unconfigured" - | "stopped" - | "starting" - | "running" - | "resetting-database" - | "stopping" - | "destroying"; + "unconfigured" | "stopped" | "starting" | "running" | "stopping" | "destroying"; type DesiredStackLifecycle = "unconfigured" | "stopped" | "running" | "destroying"; type ActivationMode = "eager" | "lazy"; @@ -906,27 +903,9 @@ Build observable vertical slices in this order: 6. Promise facade and release: mechanical adaptation of the authoritative Effect contract, including plain-string credentials, explicit handle closure, `Option`, streams, and tagged failures. -7. Deferred final database-reset slice described below, after the runtime/catalog/Functions work is - stable. -## Deferred final database-reset slice - -The rewrite removes the single all-in-one database reset operation from the primary API. The caller -owns migrations, declarative schemas, seeds, progress, and their errors. Stack owns lifecycle -fencing, stopping database dependents, exact database-data recreation, internal idempotent bootstrap, -and preventing premature activation. - -The eventual durable interface is a reset session, for example `beginDatabaseReset`, returning a -session with a database connection and explicit `complete`/`fail` operations. This API and its -persisted session shape are intentionally not frozen until this final slice. While a session exists, -the observable lifecycle is `resetting-database`, the ultimate desired lifecycle remains `running`, -and separate durable reset-session metadata fences dependents. Caller loss never silently resumes -dependents; a later caller may resume or initiate recovery. Migration history remains in the -database, not stack state. - -This is the final behavioral slice of the rewrite, after the main runtime, catalog, and Functions -work. Its integration tests cover session fencing, exact data recreation, bootstrap ordering, -explicit completion/failure, caller loss, later resumption, and mechanical Promise adaptation. +Database reset will be designed and implemented in a separate future session after this rewrite is +complete. This document intentionally does not reserve its API, state shape, or lifecycle vocabulary. ## Testing strategy @@ -1003,16 +982,13 @@ Gateway, activation, and Functions: - `supabase functions serve` uses the managed stack, waits for readiness, streams logs, observes edits, requires explicit restart for incompatible persisted settings, and closes only its handle. -Recovery and deferred reset (final slice, after the initial suite): +Recovery: - Corrupt state fails closed beside exact remnants; destructive recovery is explicit and identity- scoped. - Native owner loss terminates exact process trees; containers fail closed until recovery. Adoption requires identity, generation, and workload-spec hash; stale/foreign resources are untouched. - Machine reboot does not auto-start stacks; caller failure leaves durable running intent. -- Final reset-session tests are added only after the main runtime/catalog/Functions suite and cover - dependent fencing, exact data recreation, bootstrap, explicit completion/failure, caller loss, - later resumption, and Promise adaptation. The Effect API is the behavioral test surface. Promise tests cover only mechanical adaptation and handle ownership. Tests synchronize on readiness, events, control connections, files, or process From 488ecdfa5c856195b1b30baacba06d29700408d6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 14:58:45 +0200 Subject: [PATCH 058/174] refactor(stack): remove deferred reset lifecycle --- .../commands/functions/serve/serve.integration.test.ts | 5 +---- .../dev/functions-dev-runtime.integration.test.ts | 5 +---- packages/stack/src/public/Status.ts | 1 - packages/stack/src/public/testing.integration.test.ts | 7 +------ 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 6ddb49d4dc..85533011f9 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -32,10 +32,7 @@ const stackId: StackId = StackIdSchema.make( const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ id: stackId, lifecycle, - desiredLifecycle: - lifecycle === "starting" || lifecycle === "stopping" || lifecycle === "resetting-database" - ? "running" - : lifecycle, + desiredLifecycle: lifecycle === "starting" || lifecycle === "stopping" ? "running" : lifecycle, runtime: { kind: "container", engine: "docker" }, endpoints: lifecycle === "running" diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts index 806fd9255a..96985921a5 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts @@ -18,10 +18,7 @@ const stackId: StackId = StackIdSchema.make( const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ id: stackId, lifecycle, - desiredLifecycle: - lifecycle === "starting" || lifecycle === "stopping" || lifecycle === "resetting-database" - ? "running" - : lifecycle, + desiredLifecycle: lifecycle === "starting" || lifecycle === "stopping" ? "running" : lifecycle, runtime: { kind: "container", engine: "docker" }, endpoints: lifecycle === "running" diff --git a/packages/stack/src/public/Status.ts b/packages/stack/src/public/Status.ts index c6bd9aaa78..3858cdf16e 100644 --- a/packages/stack/src/public/Status.ts +++ b/packages/stack/src/public/Status.ts @@ -15,7 +15,6 @@ export const StackLifecycleSchema = Schema.Literals([ "stopped", "starting", "running", - "resetting-database", "stopping", "destroying", ] as const); diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index 64e7c4a589..0fbdc64a79 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -19,12 +19,7 @@ const status = ( ): StackStatus => ({ id: stackId, lifecycle, - desiredLifecycle: - lifecycle === "starting" || lifecycle === "stopping" - ? "running" - : lifecycle === "resetting-database" - ? "running" - : lifecycle, + desiredLifecycle: lifecycle === "starting" || lifecycle === "stopping" ? "running" : lifecycle, runtime: { kind: "native" }, endpoints: lifecycle === "running" && includeApi From ee1c7d976401ffe0a1894016082a1faafa95d7b1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 15:07:23 +0200 Subject: [PATCH 059/174] fix(stack): fence persisted stack credentials --- packages/stack/src/supervisor/Supervisor.ts | 4 +- .../supervisor/supervisor.integration.test.ts | 104 +++++++++++++++--- 2 files changed, 89 insertions(+), 19 deletions(-) diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 4f72bbc5e6..dbd5fae0b8 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -732,11 +732,13 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect(exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; +const invokeCredentials = ( + supervisor: Supervisor, +): Effect.Effect => + Effect.gen(function* () { + const handler = yield* StackRpcGroup.accessHandler("credentials").pipe( + Effect.provide( + StackRpcGroup.toLayerHandler("credentials", supervisor.rpcHandlers.credentials), + ), + ); + const value = yield* handler(undefined, { + client: new Rpc.ServerClient(1), + requestId: RequestId(1), + headers: Headers.empty, + }); + if (Deferred.isDeferred(value)) + return yield* Deferred.await(value); + return value; + }); + const makeMockIngress = ( timeline: Ref.Ref>, failOpen = false, @@ -233,13 +269,13 @@ describe("Supervisor composition", () => { ports: [ { field: "api", port: 55433, intent: "exact" }, { field: "database", port: 55432, intent: "exact" }, - ], + ] as const, portsGeneration: running.desiredGeneration, }, running.desiredGeneration, ) .pipe(Effect.provideContext(fixture.context)); - const credentials = yield* fixture.supervisor.rpcHandlers.credentials(); + const credentials = yield* invokeCredentials(fixture.supervisor); expect(credentials.database.url).toEqual(expect.anything()); expect(Redacted.value(credentials.database.url)).toMatch( /^postgresql:\/\/postgres:.+@127\.0\.0\.1:\d+\/postgres$/, @@ -255,7 +291,9 @@ describe("Supervisor composition", () => { accessKeyId: "625729a08b95bf1b7ff351a663f3a23c", }), ); - expect(Redacted.value(credentials.storage?.secretAccessKey)).toBe( + if (credentials.storage === undefined) + return yield* new StackStateInvalidError({ message: "storage credentials are missing" }); + expect(Redacted.value(credentials.storage.secretAccessKey)).toBe( "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", ); }), @@ -285,7 +323,7 @@ describe("Supervisor composition", () => { ports: [ { field: "api", port: 55433, intent: "exact" as const }, { field: "database", port: 55432, intent: "exact" as const }, - ], + ] as const, portsGeneration: running.desiredGeneration, secrets: { ...running.secrets, @@ -298,7 +336,7 @@ describe("Supervisor composition", () => { yield* fixture.store .replace(fixture.id, state, running.desiredGeneration) .pipe(Effect.provideContext(fixture.context)); - const credentials = yield* fixture.supervisor.rpcHandlers.credentials(); + const credentials = yield* invokeCredentials(fixture.supervisor); expect(Redacted.value(credentials.database.url)).toBe( "postgresql://postgres:p%40ss%3Aword@[2001:db8::1]:55432/postgres", ); @@ -323,13 +361,13 @@ describe("Supervisor composition", () => { ports: [ { field: "api", port: 55433, intent: "exact" as const }, { field: "database", port: 55432, intent: "exact" as const }, - ], + ] as const, portsGeneration: running.desiredGeneration, }; yield* fixture.store .replace(fixture.id, state, running.desiredGeneration) .pipe(Effect.provideContext(fixture.context)); - const credentials = yield* fixture.supervisor.rpcHandlers.credentials(); + const credentials = yield* invokeCredentials(fixture.supervisor); expect(credentials.storage).toBeUndefined(); }), ), @@ -347,13 +385,13 @@ describe("Supervisor composition", () => { return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); const stale = { ...running, - ports: [{ field: "database", port: 55432, intent: "exact" as const }], + ports: [{ field: "database", port: 55432, intent: "exact" as const }] as const, portsGeneration: running.desiredGeneration - 1, }; yield* fixture.store .replace(fixture.id, stale, running.desiredGeneration) .pipe(Effect.provideContext(fixture.context)); - const staleExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + const staleExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(staleExit)).toEqual( expect.objectContaining({ tag: "StackNotRunningError" }), ); @@ -365,7 +403,7 @@ describe("Supervisor composition", () => { yield* fixture.store .replace(fixture.id, stopped, stale.desiredGeneration) .pipe(Effect.provideContext(fixture.context)); - const stoppedExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + const stoppedExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(stoppedExit)).toEqual( expect.objectContaining({ tag: "StackNotRunningError" }), ); @@ -373,6 +411,36 @@ describe("Supervisor composition", () => { ), ); + it.live("fails closed when a persisted running generation is newer than this owner", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined) + return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); + const advancedGeneration = running.desiredGeneration + 1; + yield* fixture.store + .replace( + fixture.id, + { + ...running, + desiredGeneration: advancedGeneration, + portsGeneration: advancedGeneration, + }, + running.desiredGeneration, + ) + .pipe(Effect.provideContext(fixture.context)); + const staleOwnerExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); + expect(errorOf(staleOwnerExit)).toEqual( + expect.objectContaining({ tag: "StackNotRunningError" }), + ); + }), + ), + ); + it.live("fails closed when Auth is disabled or a required secret slot is absent", () => run( Effect.gen(function* () { @@ -390,13 +458,13 @@ describe("Supervisor composition", () => { ports: [ { field: "api", port: 55433, intent: "exact" as const }, { field: "database", port: 55432, intent: "exact" as const }, - ], + ] as const, portsGeneration: running.desiredGeneration, }; yield* fixture.store .replace(fixture.id, state, running.desiredGeneration) .pipe(Effect.provideContext(fixture.context)); - const authDisabled = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + const authDisabled = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(authDisabled)).toEqual( expect.objectContaining({ tag: "StackNotRunningError" }), ); @@ -430,7 +498,7 @@ describe("Supervisor composition", () => { yield* fixture.store .replace(fixture.id, missingSecret, state.desiredGeneration) .pipe(Effect.provideContext(fixture.context)); - const missingExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + const missingExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(missingExit)).toEqual( expect.objectContaining({ tag: "StackSecretMismatchError" }), ); @@ -445,19 +513,19 @@ describe("Supervisor composition", () => { yield* fixture.store .replace(fixture.id, storageSecretMissing, state.desiredGeneration) .pipe(Effect.provideContext(fixture.context)); - const storageExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + const storageExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(storageExit)).toEqual( expect.objectContaining({ tag: "StackSecretMismatchError" }), ); const complete = { ...missingSecret, secrets: baseSecrets }; const missingApiListener = { ...complete, - ports: [{ field: "database", port: 55432, intent: "exact" as const }], + ports: [{ field: "database", port: 55432, intent: "exact" as const }] as const, }; yield* fixture.store .replace(fixture.id, missingApiListener, state.desiredGeneration) .pipe(Effect.provideContext(fixture.context)); - const listenerExit = yield* fixture.supervisor.rpcHandlers.credentials().pipe(Effect.exit); + const listenerExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(listenerExit)).toEqual( expect.objectContaining({ tag: "StackNotRunningError" }), ); From 5e383218693fe9df5f76c5ea98fdbf91613203d8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 15:07:28 +0200 Subject: [PATCH 060/174] test(stack): align postgres preparation fixtures --- ...26-08-28-stack-runtime-rewrite-progress.md | 24 +++++++++---------- .../runtime-artifacts.integration.test.ts | 24 +++++++++++-------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index fd6334fc4c..a262cb1592 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -594,18 +594,18 @@ Implementation continues after recording a ruling; this file is not a question q ### Task 13A — 2026-08-29 (foundation) -| Capability | Native/container artifacts | Settings/dependencies | Ports/routes/probes | Secrets/logs/activation | Evidence | -| -------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------- | -| database | `postgres` slim release; `supabase/postgres` image | database settings and bootstrap workload | database TCP readiness | managed database password; eager | compiler + runtime integration | -| rest | `postgrest`; `postgrest/postgrest` | REST settings/env; database dependency | API HTTP route; private HTTP readiness | passthrough settings; lazy activation | compiler + workload-runtime | -| auth | `auth`; `supabase/auth` | Auth settings/env and managed key slots; database dependency | API HTTP route; private `/health` readiness | managed JWT/key slots; lazy activation | compiler + workload-runtime | -| realtime | `realtime`; `supabase/realtime` | Realtime settings/env; database dependency | API HTTP route; private `/api/ping` readiness | passthrough settings; lazy activation | compiler + workload-runtime integration | -| storage (+ imgproxy) | `storage`/`imgproxy`; storage-api/imgproxy images | Storage settings/env and volume; image transformation selects companion | API route/readiness; companion private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | -| functions | Edge Runtime slim artifact/container | functions root, policy, inspector, per-function env | `/functions/v1/*` route; request-time activation and private `/_internal/health` | functionsRoot is the sole read-only `/__supabase_functions` mount | catalog + workload-runtime + container-runtime | -| studio (+ pgmeta) | studio/pgmeta slim artifacts; studio/pg-meta images | Studio settings/env; REST/analytics deps | Studio HTTP route; private profile/health probes | passthrough settings; eager activation | compiler + workload-runtime integration | -| mail | mailpit slim artifact/container | Mail settings/env | mail UI/SMTP/POP3 listeners; private `/readyz` probe | passthrough settings; eager activation | compiler + workload-runtime integration | -| analytics (+ vector) | logflare/vector slim artifacts; Logflare/vector images | Analytics settings/env; vector selected by `vector_port`; database dependency | API route/readiness; vector private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | -| pooler | `supavisor` slim release `v2.9.12`; matching image | Pooler settings/env and database dependency | pooler TCP route/readiness; private transaction endpoint | managed pooler key slots; eager activation | compiler + catalog + workload-runtime integration | +| Capability | Native/container artifacts | Settings/dependencies | Ports/routes/probes | Secrets/logs/activation | Evidence | +| -------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------- | +| database | `postgres` slim release `17.6.1.166`; `ghcr.io/supabase/cli/postgres:17.6.1.166` image | database settings and bootstrap workload | database TCP readiness | managed database password; eager | compiler + runtime integration | +| rest | `postgrest`; `postgrest/postgrest` | REST settings/env; database dependency | API HTTP route; private HTTP readiness | passthrough settings; lazy activation | compiler + workload-runtime | +| auth | `auth`; `supabase/auth` | Auth settings/env and managed key slots; database dependency | API HTTP route; private `/health` readiness | managed JWT/key slots; lazy activation | compiler + workload-runtime | +| realtime | `realtime`; `supabase/realtime` | Realtime settings/env; database dependency | API HTTP route; private `/api/ping` readiness | passthrough settings; lazy activation | compiler + workload-runtime integration | +| storage (+ imgproxy) | `storage`/`imgproxy`; storage-api/imgproxy images | Storage settings/env and volume; image transformation selects companion | API route/readiness; companion private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | +| functions | Edge Runtime slim artifact/container | functions root, policy, inspector, per-function env | `/functions/v1/*` route; request-time activation and private `/_internal/health` | functionsRoot is the sole read-only `/__supabase_functions` mount | catalog + workload-runtime + container-runtime | +| studio (+ pgmeta) | studio/pgmeta slim artifacts; studio/pg-meta images | Studio settings/env; REST/analytics deps | Studio HTTP route; private profile/health probes | passthrough settings; eager activation | compiler + workload-runtime integration | +| mail | mailpit slim artifact/container | Mail settings/env | mail UI/SMTP/POP3 listeners; private `/readyz` probe | passthrough settings; eager activation | compiler + workload-runtime integration | +| analytics (+ vector) | logflare/vector slim artifacts; Logflare/vector images | Analytics settings/env; vector selected by `vector_port`; database dependency | API route/readiness; vector private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | +| pooler | `supavisor` slim release `v2.9.12`; matching image | Pooler settings/env and database dependency | pooler TCP route/readiness; private transaction endpoint | managed pooler key slots; eager activation | compiler + catalog + workload-runtime integration | Common Task 13A source/test coverage: `model/WorkloadCatalog.ts` is the exhaustive slim-services identity table with darwin-arm64, linux-amd64, and linux-arm64 target validation; `ExecutionPlan` materializes optional diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index e95b3d5b36..07f6b7c16e 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -21,8 +21,12 @@ const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload readiness: { mode: "tcp" }, restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { - native: { kind: "native", service: "postgres", release: "17.6.1.165" }, - container: { kind: "container", service: "postgres", image: "supabase/postgres:17.6.1.165" }, + native: { kind: "native", service: "postgres", release: "17.6.1.166" }, + container: { + kind: "container", + service: "postgres", + image: "ghcr.io/supabase/cli/postgres:17.6.1.166", + }, }, selected, specHash: "hash", @@ -106,13 +110,13 @@ describe("runtime artifact preparation", () => { const result = Effect.runSync( runtime.prepare( { kind: "native" }, - nativeWorkload({ kind: "native", service: "postgres", release: "17.6.1.165" }), + nativeWorkload({ kind: "native", service: "postgres", release: "17.6.1.166" }), ), ); expect(result.outcome).toBe("downloaded"); - expect(result.version).toBe("17.6.1.165"); + expect(result.version).toBe("17.6.1.166"); expect(requests).toHaveLength(1); - expect(requests[0]?.key).toContain("slim-services/postgres/17.6.1.165/darwin-arm64"); + expect(requests[0]?.key).toContain("slim-services/postgres/17.6.1.166/darwin-arm64"); expect(requests[0]?.sha256).toBe("a".repeat(64)); }); @@ -129,15 +133,15 @@ describe("runtime artifact preparation", () => { nativeWorkload({ kind: "container", service: "postgres", - image: "supabase/postgres:17.6.1.165", + image: "ghcr.io/supabase/cli/postgres:17.6.1.166", }), ), ); expect(result.outcome).toBe("pulled"); expect(calls).toEqual([ "probe", - "inspect:supabase/postgres:17.6.1.165", - "pull:supabase/postgres:17.6.1.165", + "inspect:ghcr.io/supabase/cli/postgres:17.6.1.166", + "pull:ghcr.io/supabase/cli/postgres:17.6.1.166", ]); }); @@ -157,7 +161,7 @@ describe("runtime artifact preparation", () => { nativeWorkload({ kind: "container", service: "postgres", - image: "supabase/postgres:17.6.1.165", + image: "ghcr.io/supabase/cli/postgres:17.6.1.166", }), ), ); @@ -185,7 +189,7 @@ describe("runtime artifact preparation", () => { nativeWorkload({ kind: "container", service: "postgres", - image: "supabase/postgres:17.6.1.165", + image: "ghcr.io/supabase/cli/postgres:17.6.1.166", }), ), ); From 4a11a303cbe05f0bb882396ba7a91ac298c3a2bd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 15:12:33 +0200 Subject: [PATCH 061/174] fix(stack): align service runtime contracts --- packages/stack/src/model/Compiler.ts | 19 ++ packages/stack/src/model/WorkloadCatalog.ts | 29 +-- .../stack/src/model/capabilities/analytics.ts | 4 +- packages/stack/src/model/capabilities/auth.ts | 2 +- .../stack/src/model/capabilities/database.ts | 40 +--- .../stack/src/model/capabilities/functions.ts | 14 +- .../stack/src/model/capabilities/index.ts | 7 +- .../stack/src/model/capabilities/pooler.ts | 4 +- .../stack/src/model/capabilities/realtime.ts | 6 +- packages/stack/src/model/capabilities/rest.ts | 2 +- .../stack/src/model/capabilities/storage.ts | 43 +++- .../stack/src/model/capabilities/studio.ts | 4 +- .../src/model/catalog.integration.test.ts | 33 ++- .../src/model/compiler.integration.test.ts | 38 +++- .../stack/src/runtime/ProductionRuntime.ts | 1 + packages/stack/src/runtime/ReadinessProbe.ts | 13 ++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 177 ++++++++++++---- .../readiness-probe.integration.test.ts | 41 ++++ .../workload-runtime.integration.test.ts | 195 +++++++++++++++++- .../src/state/secrets.integration.test.ts | 6 + 20 files changed, 552 insertions(+), 126 deletions(-) diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index d78c75fbd3..24cf9c1010 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -26,6 +26,7 @@ import { RealtimeModule, RestModule, StorageModule, + parseFileSize, StudioModule, AnalyticsModule, } from "./capabilities/index.ts"; @@ -359,6 +360,23 @@ const decodeConfig = (config: unknown): Effect.Effect => { + const storage = config.capabilities?.storage; + if (storage === undefined || !("settings" in storage) || storage.settings === undefined) + return Effect.void; + const values = [ + storage.settings.file_size_limit, + ...Object.values(storage.settings.buckets ?? {}).map((bucket) => bucket.file_size_limit), + ]; + for (const value of values) { + if (value !== undefined && parseFileSize(value) === undefined) + return Effect.fail(new InvalidStackConfigError({ message: "Invalid storage file size" })); + } + return Effect.void; +}; + const validateFunctionKeys = (config: unknown): Effect.Effect => { const capabilities = isRecord(config) ? config.capabilities : undefined; const capability = isRecord(capabilities) ? capabilities.functions : undefined; @@ -673,6 +691,7 @@ export const compileStack = ( const crypto = yield* Crypto.Crypto; yield* validateFunctionKeys(input.config ?? {}); const config = yield* decodeConfig(input.config ?? {}); + yield* validateStorageFileSizes(config); const jwtSecret = yield* canonicalJwtSecret(config); const rawCapabilities = isRecord(config.capabilities) ? config.capabilities : {}; const normalizedFunctions = yield* materializeFunctionsRoot( diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 37e909d135..a13cf1f8a7 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -71,8 +71,8 @@ export const WORKLOAD_CATALOG: Readonly> = "database:database": native( "database:database", "postgres", - "17.6.1.165", - "supabase/postgres:17.6.1.165", + "17.6.1.166", + "ghcr.io/supabase/cli/postgres:17.6.1.166", "share/supabase-cli/bin/supabase-postgres-init.sh", [ "bin/postgres", @@ -84,14 +84,14 @@ export const WORKLOAD_CATALOG: Readonly> = "share/supabase-cli/init-scripts", "lib", ], - ["14.1.0.89", "15.8.1.085", "17.6.1.165"], + ["17.6.1.166"], { containerAlias: "supabase-database" }, ), "rest:rest": native( "rest:rest", "postgrest", "v16.2", - "postgrest/postgrest:v16.2", + "ghcr.io/supabase/cli/postgrest:v16.2", "bin/postgrest", ["bin/postgrest"], ["v16.2"], @@ -101,7 +101,7 @@ export const WORKLOAD_CATALOG: Readonly> = "auth:auth", "auth", "v2.196.0", - "supabase/auth:v2.196.0", + "ghcr.io/supabase/cli/auth:v2.196.0", "bin/auth", ["bin/auth"], ["v2.196.0"], @@ -109,16 +109,17 @@ export const WORKLOAD_CATALOG: Readonly> = "realtime:realtime": native( "realtime:realtime", "realtime", - "v2.129.9", - "supabase/realtime:v2.129.9", + "v2.130.0", + "ghcr.io/supabase/cli/realtime:v2.130.0", "bin/server", ["bin/migrate", "bin/realtime", "bin/server"], + ["v2.130.0"], ), "storage:storage": native( "storage:storage", "storage", "v1.71.0", - "supabase/storage-api:v1.71.0", + "ghcr.io/supabase/cli/storage:v1.71.0", "app/dist/start/server.js", ["node/bin/node", "app/dist/start/server.js"], ["v1.71.0"], @@ -141,7 +142,7 @@ export const WORKLOAD_CATALOG: Readonly> = "functions:edge-runtime", "edge-runtime", "v1.74.3", - "supabase/edge-runtime:v1.74.3", + "ghcr.io/supabase/cli/edge-runtime:v1.74.3", "bin/.edge-runtime-wrapped", ["bin/.edge-runtime-wrapped"], ["v1.74.3"], @@ -151,7 +152,7 @@ export const WORKLOAD_CATALOG: Readonly> = "studio:studio", "studio", "2026.08.24-sha-8ec45b2", - "supabase/studio:2026.08.24-sha-8ec45b2", + "ghcr.io/supabase/cli/studio:2026.08.24-sha-8ec45b2", "app/apps/studio/server.js", ["node/bin/node", "app/apps/studio/docker-entrypoint.mjs", "app/apps/studio/server.js"], ["2026.08.24-sha-8ec45b2"], @@ -167,7 +168,7 @@ export const WORKLOAD_CATALOG: Readonly> = "studio:pgmeta", "pgmeta", "v0.98.0", - "supabase/pg-meta:v0.98.0", + "ghcr.io/supabase/cli/pgmeta:v0.98.0", "app/dist/server/server.js", ["node/bin/node", "app/dist/server/server.js"], ["0.98.0", "v0.98.0"], @@ -193,7 +194,7 @@ export const WORKLOAD_CATALOG: Readonly> = "analytics:analytics", "analytics", "v1.50.6", - "supabase/logflare:v1.50.6", + "ghcr.io/supabase/cli/analytics:v1.50.6", "bin/logflare", ["bin/logflare"], ), @@ -201,7 +202,7 @@ export const WORKLOAD_CATALOG: Readonly> = "analytics:vector", "vector", "0.53.0", - "ghcr.io/supabase/vector:0.53.0-alpine", + "timberio/vector:0.53.0-alpine", "bin/vector", ["bin/vector", "share/doc/vector/config/vector.yaml"], ["0.53.0", "0.53.0-alpine"], @@ -211,7 +212,7 @@ export const WORKLOAD_CATALOG: Readonly> = "pooler:pooler", "pooler", "v2.9.12", - "ghcr.io/supabase/supavisor:v2.9.12", + "ghcr.io/supabase/cli/pooler:v2.9.12", "bin/server", ["bin/migrate", "bin/supavisor", "bin/server"], ), diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index abce4f2d96..87a70e69f7 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -29,11 +29,11 @@ export const AnalyticsModule: CapabilityModule = { dependencies: ["database"], releases: { "v1.50.6": release("v1.50.6", [ - workload("analytics", "analytics", "v1.50.6", "supabase/logflare:v1.50.6", { + workload("analytics", "analytics", "v1.50.6", "ghcr.io/supabase/cli/analytics:v1.50.6", { dependencies: ["database:database", "analytics:vector"], readiness: { mode: "http", portField: "api" }, }), - workload("vector", "analytics", "0.53.0-alpine", "ghcr.io/supabase/vector:0.53.0-alpine", { + workload("vector", "analytics", "0.53.0-alpine", "timberio/vector:0.53.0-alpine", { dependencies: [], readiness: { mode: "tcp" }, }), diff --git a/packages/stack/src/model/capabilities/auth.ts b/packages/stack/src/model/capabilities/auth.ts index 1a13304a42..537e8afd9e 100644 --- a/packages/stack/src/model/capabilities/auth.ts +++ b/packages/stack/src/model/capabilities/auth.ts @@ -396,7 +396,7 @@ export const AuthModule: CapabilityModule = { dependencies: ["database"], releases: { "v2.196.0": release("v2.196.0", [ - workload("auth", "auth", "v2.196.0", "supabase/auth:v2.196.0", { + workload("auth", "auth", "v2.196.0", "ghcr.io/supabase/cli/auth:v2.196.0", { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index 48204543c1..2bb7c80b24 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -88,47 +88,17 @@ export const DatabaseModule: CapabilityModule = { defaultSettings: defaults, defaultEnabled: true, defaultActivation: "eager", - defaultVersion: "17.6.1.165", + defaultVersion: "17.6.1.166", dependencies: [], releases: { - "13": release("15.8.1.085", [ - workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { + "17": release("17.6.1.166", [ + workload("database", "database", "17.6.1.166", "ghcr.io/supabase/cli/postgres:17.6.1.166", { bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), ]), - "14": release("14.1.0.89", [ - workload("database", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { - bootstrap: "database", - readiness: { mode: "tcp", portField: "database" }, - }), - ]), - "15": release("15.8.1.085", [ - workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { - bootstrap: "database", - readiness: { mode: "tcp", portField: "database" }, - }), - ]), - "17": release("17.6.1.165", [ - workload("database", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { - bootstrap: "database", - readiness: { mode: "tcp", portField: "database" }, - }), - ]), - "14.1.0.89": release("14.1.0.89", [ - workload("database", "database", "14.1.0.89", "supabase/postgres:14.1.0.89", { - bootstrap: "database", - readiness: { mode: "tcp", portField: "database" }, - }), - ]), - "15.8.1.085": release("15.8.1.085", [ - workload("database", "database", "15.8.1.085", "supabase/postgres:15.8.1.085", { - bootstrap: "database", - readiness: { mode: "tcp", portField: "database" }, - }), - ]), - "17.6.1.165": release("17.6.1.165", [ - workload("database", "database", "17.6.1.165", "supabase/postgres:17.6.1.165", { + "17.6.1.166": release("17.6.1.166", [ + workload("database", "database", "17.6.1.166", "ghcr.io/supabase/cli/postgres:17.6.1.166", { bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index aae22833ce..95b11df1e5 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -77,10 +77,16 @@ export const FunctionsModule: CapabilityModule = { dependencies: ["database"], releases: { "v1.74.3": release("v1.74.3", [ - workload("edge-runtime", "functions", "v1.74.3", "supabase/edge-runtime:v1.74.3", { - dependencies: ["database:database"], - readiness: { mode: "http", portField: "functionsInspector" }, - }), + workload( + "edge-runtime", + "functions", + "v1.74.3", + "ghcr.io/supabase/cli/edge-runtime:v1.74.3", + { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "functionsInspector" }, + }, + ), ]), }, routes: [ diff --git a/packages/stack/src/model/capabilities/index.ts b/packages/stack/src/model/capabilities/index.ts index 52f23a3cff..70de67f3cc 100644 --- a/packages/stack/src/model/capabilities/index.ts +++ b/packages/stack/src/model/capabilities/index.ts @@ -17,6 +17,11 @@ export { MailModule, MailSettingsSchema, type MailSettings } from "./mail.ts"; export { PoolerModule, PoolerSettingsSchema, type PoolerSettings } from "./pooler.ts"; export { RealtimeModule, RealtimeSettingsSchema, type RealtimeSettings } from "./realtime.ts"; export { RestModule, RestSettingsSchema, type RestSettings } from "./rest.ts"; -export { StorageModule, StorageSettingsSchema, type StorageSettings } from "./storage.ts"; +export { + parseFileSize, + StorageModule, + StorageSettingsSchema, + type StorageSettings, +} from "./storage.ts"; export { StudioModule, StudioSettingsSchema, type StudioSettings } from "./studio.ts"; export { AnalyticsModule, AnalyticsSettingsSchema, type AnalyticsSettings } from "./analytics.ts"; diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts index 32fb4cdbb6..c45ba6aad8 100644 --- a/packages/stack/src/model/capabilities/pooler.ts +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -16,7 +16,7 @@ export const PoolerModule: CapabilityModule = { settings: PoolerSettingsSchema, defaultSettings: { pool_mode: "transaction", - tenant_id: undefined, + tenant_id: "pooler-dev", encryption_key: undefined, secret_key_base: undefined, default_pool_size: 20, @@ -28,7 +28,7 @@ export const PoolerModule: CapabilityModule = { dependencies: ["database"], releases: { "v2.9.12": release("v2.9.12", [ - workload("pooler", "pooler", "v2.9.12", "ghcr.io/supabase/supavisor:v2.9.12", { + workload("pooler", "pooler", "v2.9.12", "ghcr.io/supabase/cli/pooler:v2.9.12", { dependencies: ["database:database"], readiness: { mode: "tcp", portField: "pooler" }, }), diff --git a/packages/stack/src/model/capabilities/realtime.ts b/packages/stack/src/model/capabilities/realtime.ts index c4cdc6360e..2a37376582 100644 --- a/packages/stack/src/model/capabilities/realtime.ts +++ b/packages/stack/src/model/capabilities/realtime.ts @@ -13,11 +13,11 @@ export const RealtimeModule: CapabilityModule = { defaultSettings: { ip_version: "IPv4", max_header_length: 4096 }, defaultEnabled: true, defaultActivation: "eager", - defaultVersion: "v2.129.9", + defaultVersion: "v2.130.0", dependencies: ["database"], releases: { - "v2.129.9": release("v2.129.9", [ - workload("realtime", "realtime", "v2.129.9", "supabase/realtime:v2.129.9", { + "v2.130.0": release("v2.130.0", [ + workload("realtime", "realtime", "v2.130.0", "ghcr.io/supabase/cli/realtime:v2.130.0", { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), diff --git a/packages/stack/src/model/capabilities/rest.ts b/packages/stack/src/model/capabilities/rest.ts index 9ac7f7b295..5fd79f657c 100644 --- a/packages/stack/src/model/capabilities/rest.ts +++ b/packages/stack/src/model/capabilities/rest.ts @@ -34,7 +34,7 @@ export const RestModule: CapabilityModule = { dependencies: ["database"], releases: { "v16.2": release("v16.2", [ - workload("rest", "rest", "v16.2", "postgrest/postgrest:v16.2", { + workload("rest", "rest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index 951a8376a0..494ec46d6f 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -1,4 +1,4 @@ -import { Redacted, Schema } from "effect"; +import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const Bucket = Schema.Struct({ @@ -40,6 +40,40 @@ export const StorageSettingsSchema = Schema.Struct({ }); export type StorageSettings = Schema.Schema.Type; +const FILE_SIZE_UNITS: Readonly> = { + B: 1n, + KB: 1_000n, + MB: 1_000_000n, + GB: 1_000_000_000n, + TB: 1_000_000_000_000n, + KiB: 1_024n, + MiB: 1_048_576n, + GiB: 1_073_741_824n, + TiB: 1_099_511_627_776n, +}; +const MAX_FILE_SIZE = BigInt(Number.MAX_SAFE_INTEGER); + +/** Convert one non-negative byte size into its exact decimal representation. */ +export const parseFileSize = (value: string | number): string | undefined => { + if (typeof value === "number" && (!Number.isFinite(value) || value < 0)) return undefined; + const match = /^([0-9]+(?:\.[0-9]+)?)[ \t]*(B|KB|MB|GB|TB|KiB|MiB|GiB|TiB)?$/u.exec( + String(value).trim(), + ); + if (match === null) return undefined; + const amount = match[1]; + const unit = match[2] ?? "B"; + if (amount === undefined) return undefined; + const factor = FILE_SIZE_UNITS[unit]; + if (factor === undefined) return undefined; + const [whole, fraction = ""] = amount.split("."); + if (whole === undefined) return undefined; + const scale = 10n ** BigInt(fraction.length); + const scaled = (BigInt(whole) * scale + BigInt(fraction || "0")) * factor; + if (scaled % scale !== 0n) return undefined; + const bytes = scaled / scale; + return bytes > MAX_FILE_SIZE ? undefined : bytes.toString(); +}; + const bucketDefaults = { public: false, file_size_limit: "50MiB", @@ -58,9 +92,8 @@ export const StorageModule: CapabilityModule = { enabled: true, region: "local", access_key_id: "625729a08b95bf1b7ff351a663f3a23c", - secret_access_key: Redacted.make( - "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", - ), + // Managed by the compiler so every stack receives a fresh credential. + secret_access_key: undefined, }, analytics: { enabled: false, max_namespaces: 5, max_tables: 10, max_catalogs: 2, buckets: {} }, vector: { enabled: true, max_buckets: 10, max_indexes: 5, buckets: {} }, @@ -71,7 +104,7 @@ export const StorageModule: CapabilityModule = { dependencies: ["database"], releases: { "v1.71.0": release("v1.71.0", [ - workload("storage", "storage", "v1.71.0", "supabase/storage-api:v1.71.0", { + workload("storage", "storage", "v1.71.0", "ghcr.io/supabase/cli/storage:v1.71.0", { dependencies: ["database:database", "storage:imgproxy"], readiness: { mode: "http", portField: "api" }, }), diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts index 62c688eee5..725cb530b2 100644 --- a/packages/stack/src/model/capabilities/studio.ts +++ b/packages/stack/src/model/capabilities/studio.ts @@ -21,13 +21,13 @@ export const StudioModule: CapabilityModule = { "studio", "studio", "2026.08.24-sha-8ec45b2", - "supabase/studio:2026.08.24-sha-8ec45b2", + "ghcr.io/supabase/cli/studio:2026.08.24-sha-8ec45b2", { dependencies: ["studio:pgmeta", "analytics:analytics"], readiness: { mode: "http", portField: "studio" }, }, ), - workload("pgmeta", "studio", "0.98.0", "supabase/pg-meta:v0.98.0", { + workload("pgmeta", "studio", "0.98.0", "ghcr.io/supabase/cli/pgmeta:v0.98.0", { dependencies: ["database:database"], readiness: { mode: "http" }, }), diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index 699018bc21..ed7657391f 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -15,6 +15,33 @@ const compile = (config: Parameters[0]["config"]) => ); describe("complete workload catalog", () => { + it("keeps native releases paired with their canonical container images", () => { + expect(WORKLOAD_CATALOG["database:database"]).toMatchObject({ + nativeVersion: "17.6.1.166", + containerImage: "ghcr.io/supabase/cli/postgres:17.6.1.166", + supportedNativeVersions: ["17.6.1.166"], + }); + expect(WORKLOAD_CATALOG["auth:auth"]?.containerImage).toBe( + "ghcr.io/supabase/cli/auth:v2.196.0", + ); + expect(WORKLOAD_CATALOG["realtime:realtime"]?.nativeVersion).toBe("v2.130.0"); + expect(WORKLOAD_CATALOG["realtime:realtime"]?.containerImage).toBe( + "ghcr.io/supabase/cli/realtime:v2.130.0", + ); + expect(WORKLOAD_CATALOG["storage:storage"]?.containerImage).toBe( + "ghcr.io/supabase/cli/storage:v1.71.0", + ); + expect(WORKLOAD_CATALOG["studio:pgmeta"]?.containerImage).toBe( + "ghcr.io/supabase/cli/pgmeta:v0.98.0", + ); + expect(WORKLOAD_CATALOG["analytics:analytics"]?.containerImage).toBe( + "ghcr.io/supabase/cli/analytics:v1.50.6", + ); + expect(WORKLOAD_CATALOG["pooler:pooler"]?.containerImage).toBe( + "ghcr.io/supabase/cli/pooler:v2.9.12", + ); + }); + it("matches the executable paths shipped by slim-services archives", () => { const expected = { "database:database": "share/supabase-cli/bin/supabase-postgres-init.sh", @@ -135,7 +162,7 @@ describe("complete workload catalog", () => { it.live("derives native database artifacts from the planned release", () => Effect.gen(function* () { - const compiled = yield* compile({ capabilities: { database: { version: "14" } } }); + const compiled = yield* compile({ capabilities: { database: { version: "17" } } }); const database = compiled.executionPlan.workloads.find( ({ id }) => id === "database:database", ); @@ -145,8 +172,8 @@ describe("complete workload catalog", () => { os: "linux", arch: "x64", }); - expect(artifact.version).toBe("14.1.0.89"); - expect(artifact.downloadUrl).toContain("postgres-14.1.0.89-linux-amd64.tar.zst"); + expect(artifact.version).toBe("17.6.1.166"); + expect(artifact.downloadUrl).toContain("postgres-17.6.1.166-linux-amd64.tar.zst"); }), ); diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 1df9d1ca3c..9abdfc2b5c 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { canonicalize, compileStack } from "./Compiler.ts"; import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; +import { parseFileSize } from "./capabilities/storage.ts"; const layer = NodeServices.layer; const compile = ( @@ -19,6 +20,22 @@ const failureOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("closed capability compiler", () => { + it.live("normalizes storage byte limits and rejects invalid sizes", () => + Effect.gen(function* () { + expect(parseFileSize("50MiB")).toBe("52428800"); + expect(parseFileSize("1.5KB")).toBe("1500"); + expect(parseFileSize("2 GiB")).toBe("2147483648"); + expect(parseFileSize(-1)).toBeUndefined(); + expect(parseFileSize("not-a-size")).toBeUndefined(); + expect(parseFileSize("999999999999999999TiB")).toBeUndefined(); + for (const value of ["not-a-size", "-1", "1.1B"]) { + const result = yield* compile({ + capabilities: { storage: { settings: { file_size_limit: value } } }, + }).pipe(Effect.exit); + expect(failureOf(result)).toBeInstanceOf(InvalidStackConfigError); + } + }), + ); it.live("materializes a non-default database setting", () => Effect.gen(function* () { const result = yield* compile({ @@ -89,7 +106,6 @@ describe("closed capability compiler", () => { expect.objectContaining({ slot: "secret:storage.settings.s3_protocol.secret_access_key", policy: "managed", - value: Redacted.make("850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907"), }), ); expect( @@ -472,13 +488,10 @@ describe("closed capability compiler", () => { }), ); - it.live("resolves each supported database major to its supported release", () => + it.live("resolves the supported database major to its supported release", () => Effect.gen(function* () { const expected = { - 13: "15.8.1.085", - 14: "14.1.0.89", - 15: "15.8.1.085", - 17: "17.6.1.165", + 17: "17.6.1.166", } as const; for (const [major, release] of Object.entries(expected)) { const result = yield* compile({ capabilities: { database: { version: major } } }); @@ -490,7 +503,7 @@ describe("closed capability compiler", () => { container: { kind: "container", service: "database", - image: `supabase/postgres:${release}`, + image: `ghcr.io/supabase/cli/postgres:${release}`, }, }); } @@ -506,6 +519,17 @@ describe("closed capability compiler", () => { }), ); + it.live("rejects unsupported historical database releases", () => + Effect.gen(function* () { + for (const version of ["13", "13.3.0", "14", "15"]) { + const result = yield* compile({ capabilities: { database: { version } } }).pipe( + Effect.exit, + ); + expect(failureOf(result)).toBeInstanceOf(StackVersionUnsupportedError); + } + }), + ); + it.live("rejects an empty non-database release", () => Effect.gen(function* () { const result = yield* compile({ capabilities: { rest: { version: "" } } }).pipe(Effect.exit); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index be03b022da..9874d6ec59 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -187,6 +187,7 @@ const readinessFor = ( host: "127.0.0.1", port: endpoint.port, ...(spec.readiness.path === undefined ? {} : { path: spec.readiness.path }), + ...(spec.readiness.headers === undefined ? {} : { headers: spec.readiness.headers }), }); }; diff --git a/packages/stack/src/runtime/ReadinessProbe.ts b/packages/stack/src/runtime/ReadinessProbe.ts index 5f45622050..e6cac02c92 100644 --- a/packages/stack/src/runtime/ReadinessProbe.ts +++ b/packages/stack/src/runtime/ReadinessProbe.ts @@ -11,6 +11,7 @@ export interface ReadinessTarget { readonly host: string; readonly port: number; readonly path?: string; + readonly headers?: Readonly>; } export interface ReadinessProbeOptions { @@ -39,6 +40,9 @@ const containsControl = (value: string): boolean => return code <= 0x1f || code === 0x7f; }); +const validHeaderName = (value: string): boolean => + value.length > 0 && /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(value); + const validateTarget = (target: ReadinessTarget): Effect.Effect => { if (target.host.length === 0 || containsControl(target.host)) return Effect.fail(preparationError("Readiness host is invalid")); @@ -50,6 +54,14 @@ const validateTarget = (target: ReadinessTarget): Effect.Effect { response = incoming; diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 6017c648ca..15348825f0 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -3,6 +3,7 @@ import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { CapabilityName } from "../public/Capability.ts"; import type { ContainerHostRoute, ContainerMount } from "./ContainerEngine.ts"; import { WORKLOAD_CATALOG, type NativeWorkloadProcess } from "../model/WorkloadCatalog.ts"; +import { parseFileSize } from "../model/capabilities/storage.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; import { Effect } from "effect"; import { StackPreparationError } from "../public/Errors.ts"; @@ -10,7 +11,7 @@ import { StackPreparationError } from "../public/Errors.ts"; export type WorkloadRuntimeKind = "native" | "container"; /** Closed set of private ports a workload may expose to the host gateway. */ -export type WorkloadBindingName = "primary" | "ui" | "smtp" | "pop3"; +export type WorkloadBindingName = "primary" | "admin" | "ui" | "smtp" | "pop3"; export interface WorkloadBinding { readonly containerPort: number; @@ -18,6 +19,7 @@ export interface WorkloadBinding { export interface WorkloadBindings { readonly primary?: WorkloadBinding; + readonly admin?: WorkloadBinding; readonly ui?: WorkloadBinding; readonly smtp?: WorkloadBinding; readonly pop3?: WorkloadBinding; @@ -50,6 +52,8 @@ export interface WorkloadRuntimeInputs { /** Stack-owned native persistent data paths. Containers use their named volumes instead. */ readonly database?: Readonly<{ readonly dataPath?: string }>; readonly storage?: Readonly<{ readonly dataPath?: string }>; + /** Owner-resolved tenant provisioning file for the Pooler container. */ + readonly pooler?: Readonly<{ readonly tenantPath?: string }>; /** Host route used by containers to reach StackGateway. */ readonly hostRoute?: ContainerHostRoute; } @@ -99,6 +103,7 @@ export interface WorkloadRuntimeSpec { readonly protocol: "http" | "tcp"; readonly path?: string; readonly binding: WorkloadBindingName; + readonly headers?: Readonly>; }>; readonly nativeProcess: ( artifactRoot: string, @@ -200,6 +205,17 @@ export const validateWorkloadRuntimeInputs = ( workload: workload.id, }); } + if (workload.id === "pooler:pooler" && inputs.pooler?.tenantPath !== undefined) { + const tenantPath = inputs.pooler.tenantPath; + if ( + tenantPath.length === 0 || + [...tenantPath].some((character) => character.charCodeAt(0) < 0x20) + ) + return yield* new StackPreparationError({ + message: "Resolved Pooler tenant path is invalid", + workload: workload.id, + }); + } if (workload.id === "auth:auth") { const email = settingsFor(state, "auth"); const emailSettings = isRecord(email) && isRecord(email.email) ? email.email : undefined; @@ -342,6 +358,17 @@ const workloadPort = ( ? containerPort : (privatePortFor(state, workloadId, binding) ?? containerPort); +const containerPortFor = ( + state: PersistedStackState, + workloadId: string, + binding: WorkloadBindingName, + fallback: number, +): number => { + if (workloadId === "pooler:pooler" && binding === "primary") + return valueAt(state, "pooler", "pool_mode") === "session" ? 5432 : 6543; + return fallback; +}; + const dbHost = (runtime: WorkloadRuntimeKind): string => runtime === "container" ? DATABASE_NETWORK_ALIAS : "127.0.0.1"; @@ -415,7 +442,9 @@ const privateEndpointFor = ( const declared = bindingFor(bindings, binding); if (declared === undefined) return undefined; const port = - runtime === "container" ? declared.containerPort : privatePortFor(state, workloadId, binding); + runtime === "container" + ? containerPortFor(state, workloadId, binding, declared.containerPort) + : privatePortFor(state, workloadId, binding); return port === undefined ? undefined : { @@ -493,6 +522,8 @@ const withRestSettings = ( : secret(state, "secret:auth.settings.jwt_secret"), PGRST_SERVER_PORT: String(port), PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows") || "1000", + PGRST_ADMIN_SERVER_PORT: String(workloadPort(state, "rest:rest", "admin", runtime, 3001)), + PGRST_OPENAPI_SERVER_PROXY_URI: valueAt(state, "rest", "external_url"), }); const authNestedEnvironment = ( @@ -508,11 +539,14 @@ const authNestedEnvironment = ( for (const [provider, value] of Object.entries(external)) { if (!isRecord(value)) continue; const prefix = `GOTRUE_EXTERNAL_${provider.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase()}`; - for (const [key, entry] of Object.entries(value)) - out[`${prefix}_${key.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase()}`] = settingValue( - state, - entry, - ); + const enabled = value.enabled === true; + out[`${prefix}_ENABLED`] = enabled ? "true" : "false"; + if (!enabled) continue; + for (const key of ["client_id", "secret", "url", "skip_nonce_check", "email_optional"]) { + if (!(key in value)) continue; + const rendered = settingValue(state, value[key]); + if (rendered.length > 0) out[`${prefix}_${key.toUpperCase()}`] = rendered; + } const configuredRedirect = settingValue(state, value.redirect_uri); out[`${prefix}_REDIRECT_URI`] = configuredRedirect || `${jwtIssuer}/callback`; } @@ -640,7 +674,11 @@ const withAuthSettings = ( state, "auth", "GOTRUE", - (key) => key === "GOTRUE_SIGNING_KEYS_PATH" || key.startsWith("GOTRUE_THIRD_PARTY_"), + (key) => + key === "GOTRUE_SIGNING_KEYS_PATH" || + key.startsWith("GOTRUE_THIRD_PARTY_") || + key.startsWith("GOTRUE_EXTERNAL_") || + key.startsWith("GOTRUE_MFA_PHONE_"), ), ...authNestedEnvironment( state, @@ -699,9 +737,14 @@ const withAuthSettings = ( GOTRUE_MFA_TOTP_VERIFY_ENABLED: valueAt(state, "auth", "mfa.totp.verify_enabled"), GOTRUE_MFA_PHONE_ENROLL_ENABLED: valueAt(state, "auth", "mfa.phone.enroll_enabled"), GOTRUE_MFA_PHONE_VERIFY_ENABLED: valueAt(state, "auth", "mfa.phone.verify_enabled"), - GOTRUE_MFA_PHONE_OTP_LENGTH: valueAt(state, "auth", "mfa.phone.otp_length"), - GOTRUE_MFA_PHONE_TEMPLATE: valueAt(state, "auth", "mfa.phone.template"), - GOTRUE_MFA_PHONE_MAX_FREQUENCY: valueAt(state, "auth", "mfa.phone.max_frequency"), + ...(valueAt(state, "auth", "mfa.phone.enroll_enabled") === "true" || + valueAt(state, "auth", "mfa.phone.verify_enabled") === "true" + ? { + GOTRUE_MFA_PHONE_OTP_LENGTH: valueAt(state, "auth", "mfa.phone.otp_length"), + GOTRUE_MFA_PHONE_TEMPLATE: valueAt(state, "auth", "mfa.phone.template"), + GOTRUE_MFA_PHONE_MAX_FREQUENCY: valueAt(state, "auth", "mfa.phone.max_frequency"), + } + : {}), GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: valueAt(state, "auth", "mfa.web_authn.enroll_enabled"), GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: valueAt(state, "auth", "mfa.web_authn.verify_enabled"), GOTRUE_MFA_MAX_ENROLLED_FACTORS: valueAt(state, "auth", "mfa.max_enrolled_factors"), @@ -775,28 +818,38 @@ const withStorageSettings = ( ): Record => compactEnvironment({ ...common(workload, port), - ...capabilityEnv(state, "storage", "STORAGE"), + ...capabilityEnv( + state, + "storage", + "STORAGE", + (key) => + key === "STORAGE_FILE_SIZE_LIMIT" || + key.startsWith("STORAGE_S3_PROTOCOL_") || + key.startsWith("STORAGE_VECTOR_") || + key.startsWith("STORAGE_IMAGE_TRANSFORMATION_"), + ), PORT: String(port), ANON_KEY: secret(state, "secret:auth.settings.anon_key"), SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), AUTH_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), DATABASE_URL: dbUrl(state, "supabase_storage_admin", runtime), - FILE_SIZE_LIMIT: valueAt(state, "storage", "file_size_limit") || "50MiB", + FILE_SIZE_LIMIT: + parseFileSize(valueAt(state, "storage", "file_size_limit") || "50MiB") ?? "52428800", STORAGE_BACKEND: "file", FILE_STORAGE_BACKEND_PATH: runtime === "container" - ? "/var/lib/storage" + ? "/mnt" : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), STORAGE_FILE_BACKEND_PATH: runtime === "container" - ? "/var/lib/storage" + ? "/mnt" : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), ENABLE_IMAGE_TRANSFORMATION: valueAt(state, "storage", "image_transformation.enabled") || "false", S3_PROTOCOL_ENABLED: valueAt(state, "storage", "s3_protocol.enabled") || "true", - S3_PROTOCOL_ACCESS_KEY_ID: "local", - S3_PROTOCOL_ACCESS_KEY_SECRET: "local-secret", - STORAGE_S3_REGION: "local", + S3_PROTOCOL_ACCESS_KEY_ID: valueAt(state, "storage", "s3_protocol.access_key_id") || "local", + S3_PROTOCOL_ACCESS_KEY_SECRET: valueAt(state, "storage", "s3_protocol.secret_access_key"), + STORAGE_S3_REGION: valueAt(state, "storage", "s3_protocol.region") || "local", GLOBAL_S3_BUCKET: "stub", TENANT_ID: "stub", S3_PROTOCOL_PREFIX: "/storage/v1", @@ -808,8 +861,16 @@ const withStorageSettings = ( TUS_URL_PATH: "/storage/v1/upload/resumable", IMGPROXY_URL: runtime === "container" - ? "http://supabase-imgproxy:8080" - : `http://127.0.0.1:${workloadPort(state, "storage:imgproxy", "primary", runtime, 8080)}`, + ? "http://supabase-imgproxy:5001" + : `http://127.0.0.1:${workloadPort(state, "storage:imgproxy", "primary", runtime, 5001)}`, + ...(valueAt(state, "storage", "vector.enabled") === "true" + ? { + VECTOR_ENABLED: "true", + VECTOR_BUCKET_PROVIDER: "pgvector", + VECTOR_STORE_MIGRATIONS_ENABLED: "true", + VECTOR_DATABASE_URL: dbUrl(state, "postgres", runtime), + } + : {}), }); const databaseArgs = ( @@ -898,7 +959,7 @@ const specs: Readonly> = { networkAliases: [DATABASE_NETWORK_ALIAS], }, "rest:rest": { - bindings: { primary: { containerPort: 3000 } }, + bindings: { primary: { containerPort: 3000 }, admin: { containerPort: 3001 } }, args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => withRestSettings(state, runtime, port, workload, inputs), @@ -925,7 +986,7 @@ const specs: Readonly> = { PORT: String(port), DB_HOST: dbHost(runtime), DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), - DB_USER: "postgres", + DB_USER: "supabase_admin", DB_PASSWORD: secret(state, "secret:database.internal.password"), DB_NAME: "postgres", DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", @@ -946,7 +1007,7 @@ const specs: Readonly> = { }), containerArgs: () => [], networkAliases: ["supabase-realtime"], - readiness: { protocol: "http", path: "/api/ping" }, + readiness: { protocol: "http", path: "/api/ping", headers: { Host: "realtime-dev" } }, }, "storage:storage": { bindings: { primary: { containerPort: 5000 } }, @@ -958,12 +1019,15 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/status" }, }, "storage:imgproxy": { - bindings: { primary: { containerPort: 8080 } }, + bindings: { primary: { containerPort: 5001 } }, args: () => [], - env: (state, workload, port, runtime = "native") => ({ + env: (state, workload, port, runtime = "native", inputs = {}) => ({ ...common(workload, port), IMGPROXY_BIND: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, - IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", + IMGPROXY_LOCAL_FILESYSTEM_ROOT: + runtime === "container" + ? "/" + : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), }), containerArgs: () => [], networkAliases: ["supabase-imgproxy"], @@ -1031,8 +1095,10 @@ const specs: Readonly> = { SUPABASE_PUBLIC_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), SUPABASE_ANON_KEY: secret(state, "secret:auth.settings.anon_key"), SUPABASE_SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), - SUPABASE_PUBLISHABLE_KEY: secret(state, "secret:auth.settings.anon_key"), - SUPABASE_SECRET_KEY: secret(state, "secret:auth.settings.service_role_key"), + SUPABASE_PUBLISHABLE_KEY: secret(state, "secret:auth.settings.publishable_key"), + SUPABASE_SECRET_KEY: secret(state, "secret:auth.settings.secret_key"), + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: + runtime === "container" ? FUNCTIONS_CONTAINER_ROOT : functionsRoot(state), OPENAI_API_KEY: secret(state, "secret:studio.settings.openai_api_key"), CURRENT_CLI_VERSION: "local", POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), @@ -1042,6 +1108,9 @@ const specs: Readonly> = { PGRST_DB_MAX_ROWS: "1000", }), containerArgs: () => [], + containerMounts: (state) => [ + { source: functionsRoot(state), target: FUNCTIONS_CONTAINER_ROOT, readOnly: true }, + ], networkAliases: ["supabase-studio"], readiness: { protocol: "http", path: "/api/platform/profile" }, }, @@ -1086,9 +1155,10 @@ const specs: Readonly> = { }, "analytics:analytics": { bindings: { primary: { containerPort: 4000 } }, - args: () => [], + args: () => ["start"], env: (state, workload, port, runtime = "native", inputs = {}) => analyticsEnv(state, runtime, port, workload, inputs), + // The slim container entrypoint performs Logflare migrations before start. containerArgs: () => [], containerMounts: (state, _workload, inputs = {}) => { const backend = valueAt(state, "analytics", "backend") || "postgres"; @@ -1142,11 +1212,19 @@ const specs: Readonly> = { env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), ...capabilityEnv(state, "pooler", "POOLER"), - PORT: String(port), - PROXY_PORT_TRANSACTION: String(port), + PORT: "4000", + PROXY_PORT_SESSION: + runtime === "native" && valueAt(state, "pooler", "pool_mode") === "session" + ? String(port) + : "5432", + PROXY_PORT_TRANSACTION: + runtime === "native" && valueAt(state, "pooler", "pool_mode") !== "session" + ? String(port) + : "6543", DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), REGION: "local", + TENANT_ID: valueAt(state, "pooler", "tenant_id") || "pooler-dev", CLUSTER_POSTGRES: "true", SECRET_KEY_BASE: valueAt(state, "pooler", "secret_key_base"), VAULT_ENC_KEY: valueAt(state, "pooler", "encryption_key"), @@ -1155,7 +1233,9 @@ const specs: Readonly> = { MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn") || "100", POOL_MODE: valueAt(state, "pooler", "pool_mode") || "transaction", }), - containerArgs: () => ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"], + // The slim container entrypoint performs Supavisor migrations. Tenant + // provisioning is a separate owner/bootstrap phase. + containerArgs: () => [], networkAliases: ["supabase-pooler"], readiness: { protocol: "tcp" }, }, @@ -1163,6 +1243,7 @@ const specs: Readonly> = { const WORKLOAD_BINDING_NAMES: ReadonlyArray = [ "primary", + "admin", "ui", "smtp", "pop3", @@ -1216,7 +1297,11 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | const spec = specs[workload.id]; if (spec === undefined) return undefined; const primary = - spec.bindings.primary ?? spec.bindings.ui ?? spec.bindings.smtp ?? spec.bindings.pop3; + spec.bindings.primary ?? + spec.bindings.admin ?? + spec.bindings.ui ?? + spec.bindings.smtp ?? + spec.bindings.pop3; if (primary === undefined) return undefined; return { ...spec, @@ -1228,7 +1313,7 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | state, currentWorkload, runtime === "container" - ? primary.containerPort + ? containerPortFor(state, currentWorkload.id, "primary", primary.containerPort) : (privatePortFor(state, currentWorkload.id, "primary") ?? port), runtime, ), @@ -1237,7 +1322,7 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | state, currentWorkload, runtime === "container" - ? primary.containerPort + ? containerPortFor(state, currentWorkload.id, "primary", primary.containerPort) : (privatePortFor(state, currentWorkload.id, spec.readiness.binding ?? "primary") ?? port), runtime, @@ -1269,8 +1354,19 @@ export const containerResolutionFor = ( const spec = runtimeSpecFor(workload); if (spec === undefined) return undefined; return { - command: spec.containerArgs(state, workload, spec.containerPort, inputs), - env: spec.env(state, workload, spec.containerPort, "container", inputs), + command: spec.containerArgs( + state, + workload, + containerPortFor(state, workload.id, "primary", spec.containerPort), + inputs, + ), + env: spec.env( + state, + workload, + containerPortFor(state, workload.id, "primary", spec.containerPort), + "container", + inputs, + ), mounts: spec.containerMounts?.(state, workload, inputs) ?? [], networkAliases: [aliasFor(workload)], publications: declaredBindings(spec.bindings).flatMap(([binding, definition]) => { @@ -1283,7 +1379,12 @@ export const containerResolutionFor = ( { address: "127.0.0.1" as const, hostPort: assignment.port, - containerPort: definition.containerPort, + containerPort: containerPortFor( + state, + workload.id, + binding, + definition.containerPort, + ), }, ]; }), diff --git a/packages/stack/src/runtime/readiness-probe.integration.test.ts b/packages/stack/src/runtime/readiness-probe.integration.test.ts index 48be7ed91f..c94c7cde95 100644 --- a/packages/stack/src/runtime/readiness-probe.integration.test.ts +++ b/packages/stack/src/runtime/readiness-probe.integration.test.ts @@ -49,6 +49,47 @@ const listenTcp = (server: TcpServer): Effect.Effect => }); describe("private endpoint readiness probe", () => { + it.live("passes validated request headers to HTTP readiness endpoints", () => + Effect.scoped( + Effect.gen(function* () { + const received = yield* Deferred.make(); + const server = createHttpServer((request, response) => { + // oxlint-disable-next-line effecttsgo/run-effect-inside-effect -- bridge event callback to test signal. + Effect.runSync(Deferred.succeed(received, request.headers.host ?? "")); + response.statusCode = request.headers.host === "realtime-dev" ? 200 : 400; + response.end(); + }); + const port = yield* listenHttp(server, () => undefined); + yield* probeReadiness({ + mode: "http", + host: "127.0.0.1", + port, + path: "/api/ping", + headers: { Host: "realtime-dev" }, + }); + expect(yield* Deferred.await(received)).toBe("realtime-dev"); + }), + ), + ); + + it.live("rejects invalid readiness header names and values", () => + Effect.gen(function* () { + for (const headers of [{ "bad name": "ok" }, { Host: "bad\nvalue" }]) { + const result = yield* probeReadiness({ + mode: "http", + host: "127.0.0.1", + port: 1, + headers, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const cause = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(cause).toBeInstanceOf(StackPreparationError); + } + } + }), + ); + it.live("rejects control characters in HTTP paths as typed preparation failures", () => Effect.gen(function* () { for (const path of ["/ready\nX", "/ready\rX", "/ready\u0000X"]) { diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index c22934e7b9..1093b5eff2 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { Effect, Exit, Redacted } from "effect"; import { WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { PersistedStackState } from "../state/StackState.ts"; @@ -39,6 +39,7 @@ const state: PersistedStackState = { privatePorts: [ { workloadId: "database:database", binding: "primary", port: 30_001 }, { workloadId: "rest:rest", binding: "primary", port: 30_002 }, + { workloadId: "rest:rest", binding: "admin", port: 30_015 }, { workloadId: "auth:auth", binding: "primary", port: 30_003 }, { workloadId: "realtime:realtime", binding: "primary", port: 30_004 }, { workloadId: "storage:storage", binding: "primary", port: 30_005 }, @@ -51,7 +52,7 @@ const state: PersistedStackState = { { workloadId: "mail:mail", binding: "pop3", port: 30_012 }, { workloadId: "analytics:analytics", binding: "primary", port: 30_013 }, { workloadId: "analytics:vector", binding: "primary", port: 30_014 }, - { workloadId: "pooler:pooler", binding: "primary", port: 30_015 }, + { workloadId: "pooler:pooler", binding: "primary", port: 30_016 }, ], secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, @@ -98,9 +99,12 @@ describe("workload runtime catalog", () => { expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "ui" }); expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "smtp" }); expect(intents).toContainEqual({ workloadId: "mail:mail", binding: "pop3" }); + expect(intents).toContainEqual({ workloadId: "rest:rest", binding: "admin" }); expect(intents.filter(({ workloadId }) => workloadId === "mail:mail")).toHaveLength(3); expect( - intents.every(({ binding }) => ["primary", "ui", "smtp", "pop3"].includes(binding)), + intents.every(({ binding }) => + ["primary", "admin", "ui", "smtp", "pop3"].includes(binding), + ), ).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -193,7 +197,18 @@ describe("workload runtime catalog", () => { storageSpec?.env(state, storage, 5000, "container", { storage: { dataPath: "/ignored/native/path" }, }).FILE_STORAGE_BACKEND_PATH, - ).toBe("/var/lib/storage"); + ).toBe("/mnt"); + expect( + runtimeSpecFor(planned("storage:imgproxy"))?.env( + state, + planned("storage:imgproxy"), + 5001, + "native", + { + storage: { dataPath: "/state/stack/data/storage" }, + }, + ).IMGPROXY_LOCAL_FILESYSTEM_ROOT, + ).toBe("/state/stack/data/storage"); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -206,7 +221,13 @@ describe("workload runtime catalog", () => { runtime: { kind: "native" }, config: { capabilities: { - rest: { settings: { schemas: ["private"], extra_search_path: ["extensions"] } }, + rest: { + settings: { + schemas: ["private"], + extra_search_path: ["extensions"], + external_url: "https://api.example", + }, + }, storage: { settings: { image_transformation: { enabled: true }, @@ -275,9 +296,12 @@ describe("workload runtime catalog", () => { PGRST_DB_SCHEMAS: "private", PGRST_DB_EXTRA_SEARCH_PATH: "extensions", PGRST_DB_URI: expect.stringContaining("@127.0.0.1:30001"), + PGRST_ADMIN_SERVER_PORT: "30015", + PGRST_OPENAPI_SERVER_PROXY_URI: "https://api.example", }); expect(rest?.env(configured, planned("rest:rest"), 3000, "container")).toMatchObject({ PGRST_DB_URI: expect.stringContaining("@supabase-database:5432"), + PGRST_ADMIN_SERVER_PORT: "3001", }); const dependentHosts: ReadonlyArray<{ readonly id: string; @@ -296,6 +320,11 @@ describe("workload runtime catalog", () => { expect(storage?.env(configured, planned("storage:storage"), 5000)).toMatchObject({ ENABLE_IMAGE_TRANSFORMATION: "true", S3_PROTOCOL_ENABLED: "false", + FILE_SIZE_LIMIT: "52428800", + VECTOR_ENABLED: "true", + VECTOR_BUCKET_PROVIDER: "pgvector", + VECTOR_STORE_MIGRATIONS_ENABLED: "true", + VECTOR_DATABASE_URL: expect.stringContaining("postgres:postgres@127.0.0.1"), }); const auth = runtimeSpecFor(planned("auth:auth")); expect(auth?.env(configured, planned("auth:auth"), 9999)).toMatchObject({ @@ -305,7 +334,6 @@ describe("workload runtime catalog", () => { GOTRUE_PASSWORD_MIN_LENGTH: "12", GOTRUE_PASSWORD_REQUIRED_CHARACTERS: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - GOTRUE_MFA_PHONE_OTP_LENGTH: "8", GOTRUE_SMS_PROVIDER: "twilio", GOTRUE_SMS_TEST_OTP: "+33123456789:123456", GOTRUE_SMS_OTP_LENGTH: "6", @@ -337,10 +365,12 @@ describe("workload runtime catalog", () => { expect(Exit.isFailure(missingTemplateBase)).toBe(true); expect(auth?.env(configured, planned("auth:auth"), 9999)).toMatchObject({ GOTRUE_JWT_ISSUER: "https://issuer.example", - GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: "https://issuer.example/callback", GOTRUE_SMS_TWILIO_ACCOUNT_SID: "AC123", GOTRUE_SMS_TWILIO_VERIFY_ACCOUNT_SID: "VA123", }); + expect( + auth?.env(configured, planned("auth:auth"), 9999).GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI, + ).toBeUndefined(); expect( auth?.env(configured, planned("auth:auth"), 9999, "container", { auth: { jwtKeys: '[{"kty":"EC"}]' }, @@ -352,6 +382,7 @@ describe("workload runtime catalog", () => { auth: { jwks: '{"keys":[]}' }, }), ).toMatchObject({ ERL_AFLAGS: "-proto_dist inet6_tcp", API_JWT_JWKS: '{"keys":[]}' }); + expect(realtime?.readiness.headers).toEqual({ Host: "realtime-dev" }); const analytics = runtimeSpecFor(planned("analytics:analytics")); const analyticsInputs = { analytics: { gcpJwtPath: "/tmp/gcp.json" } }; expect( @@ -376,11 +407,30 @@ describe("workload runtime catalog", () => { readOnly: true, }, ]); + expect(analytics?.args(configured, planned("analytics:analytics"), 4000)).toEqual([ + "start", + ]); + expect(containerResolutionFor(configured, planned("analytics:analytics"))?.command).toEqual( + [], + ); const pooler = runtimeSpecFor(planned("pooler:pooler")); expect(pooler?.env(configured, planned("pooler:pooler"), 6543)).toMatchObject({ POOL_MODE: "session", MAX_CLIENT_CONN: "250", + TENANT_ID: "pooler-dev", + PORT: "4000", + PROXY_PORT_SESSION: "30016", + PROXY_PORT_TRANSACTION: "6543", }); + expect(pooler?.env(configured, planned("pooler:pooler"), 30016, "container")).toMatchObject( + { + PROXY_PORT_SESSION: "5432", + PROXY_PORT_TRANSACTION: "6543", + }, + ); + expect(containerResolutionFor(configured, planned("pooler:pooler"))?.publications).toEqual([ + { address: "127.0.0.1", hostPort: 30016, containerPort: 5432 }, + ]); const resolution = containerResolutionFor(configured, functions, { hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, }); @@ -431,7 +481,15 @@ describe("workload runtime catalog", () => { SUPABASE_URL: "http://host.docker.internal:54321", STUDIO_PG_META_URL: "http://supabase-pgmeta:8080", LOGFLARE_URL: "http://supabase-analytics:4000", + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: FUNCTIONS_CONTAINER_ROOT, }); + expect(studio?.containerMounts?.(configured, planned("studio:studio"))).toEqual([ + { + source: `${state.identity.projectRoot}/supabase/functions`, + target: FUNCTIONS_CONTAINER_ROOT, + readOnly: true, + }, + ]); expect( runtimeSpecFor(planned("storage:storage"))?.env( configured, @@ -439,7 +497,7 @@ describe("workload runtime catalog", () => { 5000, "container", ).IMGPROXY_URL, - ).toBe("http://supabase-imgproxy:8080"); + ).toBe("http://supabase-imgproxy:5001"); const nodeArtifactRoot = "/tmp/native-artifact"; expect( studio?.nativeProcess(nodeArtifactRoot, configured, planned("studio:studio"), 3000), @@ -511,6 +569,127 @@ describe("workload runtime catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("emits only enabled Auth provider fields and gates phone MFA options", () => + Effect.gen(function* () { + const disabledCompiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + }).pipe(Effect.provide(NodeServices.layer)); + const disabledState: PersistedStackState = { + ...state, + definition: disabledCompiled.definition, + }; + const disabled = runtimeSpecFor(planned("auth:auth"))?.env( + disabledState, + planned("auth:auth"), + 9999, + ); + expect(disabled?.GOTRUE_EXTERNAL_GOOGLE_ENABLED).toBe("false"); + expect(disabled?.GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI).toBeUndefined(); + expect(disabled?.GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID).toBeUndefined(); + expect(disabled?.GOTRUE_MFA_PHONE_ENROLL_ENABLED).toBe("false"); + expect(disabled?.GOTRUE_MFA_PHONE_VERIFY_ENABLED).toBe("false"); + expect(disabled?.GOTRUE_MFA_PHONE_OTP_LENGTH).toBeUndefined(); + + const enabledCompiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { + capabilities: { + auth: { + settings: { + external: { + google: { + enabled: true, + client_id: "google-client", + url: "https://accounts.google.test", + }, + }, + mfa: { phone: { enroll_enabled: true, otp_length: 8 } }, + }, + }, + }, + }, + }).pipe(Effect.provide(NodeServices.layer)); + const enabledState: PersistedStackState = { + ...state, + definition: enabledCompiled.definition, + }; + const enabled = runtimeSpecFor(planned("auth:auth"))?.env( + enabledState, + planned("auth:auth"), + 9999, + ); + expect(enabled).toMatchObject({ + GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true", + GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: "google-client", + GOTRUE_EXTERNAL_GOOGLE_URL: "https://accounts.google.test", + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: expect.stringContaining("/callback"), + GOTRUE_MFA_PHONE_ENROLL_ENABLED: "true", + GOTRUE_MFA_PHONE_OTP_LENGTH: "8", + GOTRUE_MFA_PHONE_TEMPLATE: "Your code is {{ .Code }}", + GOTRUE_MFA_PHONE_MAX_FREQUENCY: "5s", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("maps persisted Storage S3 credentials and omits vector settings when disabled", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { + capabilities: { + storage: { + settings: { + s3_protocol: { + enabled: true, + region: "eu-west-1", + access_key_id: "access-42", + secret_access_key: Redacted.make("secret-42"), + }, + vector: { enabled: false }, + }, + }, + }, + }, + }).pipe(Effect.provide(NodeServices.layer)); + const configured: PersistedStackState = { + ...state, + definition: compiled.definition, + secrets: { + ...state.secrets, + "secret:storage.settings.s3_protocol.secret_access_key": { + policy: "managed", + value: "secret-42", + }, + }, + }; + const env = runtimeSpecFor(planned("storage:storage"))?.env( + configured, + planned("storage:storage"), + 5000, + ); + expect(env).toMatchObject({ + S3_PROTOCOL_ENABLED: "true", + S3_PROTOCOL_ACCESS_KEY_ID: "access-42", + S3_PROTOCOL_ACCESS_KEY_SECRET: "secret-42", + STORAGE_S3_REGION: "eu-west-1", + }); + expect(env).not.toHaveProperty("VECTOR_ENABLED"); + expect(env).not.toHaveProperty("VECTOR_DATABASE_URL"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("validates owner-resolved Pooler tenant paths before runtime creation", () => + Effect.gen(function* () { + const failed = yield* validateWorkloadRuntimeInputs(state, planned("pooler:pooler"), { + pooler: { tenantPath: "/tmp/tenant\n.exs" }, + }).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + }), + ); + it("uses Auth's local JWT secret for every internal JWT consumer", () => { const consumers = [ ["auth:auth", "GOTRUE_JWT_SECRET"], diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index 0547a7d252..0e2cc5d165 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -53,6 +53,7 @@ describe("managed and pass-through secrets", () => { "secret:auth.settings.jwt_secret", "secret:auth.settings.anon_key", "secret:auth.settings.service_role_key", + "secret:storage.settings.s3_protocol.secret_access_key", ]) { expect(first.persisted[slot]?.policy).toBe("managed"); expect(first.persisted[slot]?.value).toBe(second.persisted[slot]?.value); @@ -81,6 +82,11 @@ describe("managed and pass-through secrets", () => { expect(["anon", "service_role"]).toContain(payload.role); expect(payload.exp).toBeGreaterThan(1_900_000_000); } + + const other = yield* resolveSecrets(candidate, undefined, "stopped"); + expect( + other.persisted["secret:storage.settings.s3_protocol.secret_access_key"]?.value, + ).not.toBe(first.persisted["secret:storage.settings.s3_protocol.secret_access_key"]?.value); }).pipe(Effect.provide(layer)), ); From 1c301d22d9c4bc365b8dfc30b085ace463a04d93 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 15:22:03 +0200 Subject: [PATCH 062/174] fix(stack): use qualified slim mirror images --- packages/stack/src/model/WorkloadCatalog.ts | 6 ++-- .../stack/src/model/capabilities/analytics.ts | 2 +- packages/stack/src/model/capabilities/mail.ts | 2 +- .../stack/src/model/capabilities/storage.ts | 2 +- .../src/model/catalog.integration.test.ts | 33 +++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index a13cf1f8a7..0ecc379b4d 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -135,7 +135,7 @@ export const WORKLOAD_CATALOG: Readonly> = "storage:imgproxy", "imgproxy", "v3.8.0", - "darthsim/imgproxy:v3.8.0", + "ghcr.io/supabase/cli/imgproxy:v3.8.0", "bin/imgproxy", ), "functions:edge-runtime": native( @@ -184,7 +184,7 @@ export const WORKLOAD_CATALOG: Readonly> = "mail:mail", "mailpit", "v1.30.2", - "axllent/mailpit:v1.30.2", + "ghcr.io/supabase/cli/mailpit:v1.30.2", "bin/mailpit", ["bin/mailpit"], ["v1.30.2"], @@ -202,7 +202,7 @@ export const WORKLOAD_CATALOG: Readonly> = "analytics:vector", "vector", "0.53.0", - "timberio/vector:0.53.0-alpine", + "ghcr.io/supabase/cli/vector:0.53.0", "bin/vector", ["bin/vector", "share/doc/vector/config/vector.yaml"], ["0.53.0", "0.53.0-alpine"], diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index 87a70e69f7..eb6b553185 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -33,7 +33,7 @@ export const AnalyticsModule: CapabilityModule = { dependencies: ["database:database", "analytics:vector"], readiness: { mode: "http", portField: "api" }, }), - workload("vector", "analytics", "0.53.0-alpine", "timberio/vector:0.53.0-alpine", { + workload("vector", "analytics", "0.53.0-alpine", "ghcr.io/supabase/cli/vector:0.53.0", { dependencies: [], readiness: { mode: "tcp" }, }), diff --git a/packages/stack/src/model/capabilities/mail.ts b/packages/stack/src/model/capabilities/mail.ts index f56508d042..1c49849872 100644 --- a/packages/stack/src/model/capabilities/mail.ts +++ b/packages/stack/src/model/capabilities/mail.ts @@ -16,7 +16,7 @@ export const MailModule: CapabilityModule = { dependencies: [], releases: { "v1.30.2": release("v1.30.2", [ - workload("mail", "mail", "v1.30.2", "axllent/mailpit:v1.30.2", { + workload("mail", "mail", "v1.30.2", "ghcr.io/supabase/cli/mailpit:v1.30.2", { readiness: { mode: "http", portField: "mailUi" }, }), ]), diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index 494ec46d6f..96bd4500b4 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -108,7 +108,7 @@ export const StorageModule: CapabilityModule = { dependencies: ["database:database", "storage:imgproxy"], readiness: { mode: "http", portField: "api" }, }), - workload("imgproxy", "storage", "v3.8.0", "darthsim/imgproxy:v3.8.0", { + workload("imgproxy", "storage", "v3.8.0", "ghcr.io/supabase/cli/imgproxy:v3.8.0", { dependencies: ["database:database"], readiness: { mode: "http" }, }), diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index ed7657391f..4971645513 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -14,6 +14,13 @@ const compile = (config: Parameters[0]["config"]) => Effect.provide(NodeServices.layer), ); +const compileContainer = (config: Parameters[0]["config"]) => + compileStack({ + projectRoot: "/tmp/catalog-project", + runtime: { kind: "container", engine: "docker" }, + config, + }).pipe(Effect.provide(NodeServices.layer)); + describe("complete workload catalog", () => { it("keeps native releases paired with their canonical container images", () => { expect(WORKLOAD_CATALOG["database:database"]).toMatchObject({ @@ -37,6 +44,15 @@ describe("complete workload catalog", () => { expect(WORKLOAD_CATALOG["analytics:analytics"]?.containerImage).toBe( "ghcr.io/supabase/cli/analytics:v1.50.6", ); + expect(WORKLOAD_CATALOG["mail:mail"]?.containerImage).toBe( + "ghcr.io/supabase/cli/mailpit:v1.30.2", + ); + expect(WORKLOAD_CATALOG["analytics:vector"]?.containerImage).toBe( + "ghcr.io/supabase/cli/vector:0.53.0", + ); + expect(WORKLOAD_CATALOG["storage:imgproxy"]?.containerImage).toBe( + "ghcr.io/supabase/cli/imgproxy:v3.8.0", + ); expect(WORKLOAD_CATALOG["pooler:pooler"]?.containerImage).toBe( "ghcr.io/supabase/cli/pooler:v2.9.12", ); @@ -160,6 +176,23 @@ describe("complete workload catalog", () => { }), ); + it.live("selects qualified slim mirror images in container plans", () => + Effect.gen(function* () { + const result = yield* compileContainer({ + capabilities: { + storage: { settings: { image_transformation: { enabled: true } } }, + analytics: { settings: { vector_port: 9001 } }, + }, + }); + const images = new Map( + result.executionPlan.workloads.map((entry) => [entry.id, entry.artifacts.container.image]), + ); + expect(images.get("mail:mail")).toBe("ghcr.io/supabase/cli/mailpit:v1.30.2"); + expect(images.get("storage:imgproxy")).toBe("ghcr.io/supabase/cli/imgproxy:v3.8.0"); + expect(images.get("analytics:vector")).toBe("ghcr.io/supabase/cli/vector:0.53.0"); + }), + ); + it.live("derives native database artifacts from the planned release", () => Effect.gen(function* () { const compiled = yield* compile({ capabilities: { database: { version: "17" } } }); From 9b62d33f64b9e02001988e69f40bea90ca0bdcb5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 15:27:53 +0200 Subject: [PATCH 063/174] feat(stack): prepare immutable runtime artifacts --- .../stack/src/runtime/ProductionRuntime.ts | 14 +- .../production-runtime.integration.test.ts | 90 ++++++- packages/stack/src/supervisor/Supervisor.ts | 116 ++++++++- .../supervisor/supervisor.integration.test.ts | 222 +++++++++++++++++- 4 files changed, 431 insertions(+), 11 deletions(-) diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 9874d6ec59..2ae9a0d37d 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -16,6 +16,8 @@ import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, StackPreparationError, + ArtifactIntegrityError, + ContainerPullError, StackRuntimeMismatchError, StackStateInvalidError, type StackError, @@ -325,12 +327,21 @@ export const makeProductionRuntimeFactory = ( return cached === undefined ? preparer.prepare(runtime, workload).pipe( Effect.mapError((error) => - preparationError(`Unable to prepare ${workload.id}`, error), + error instanceof ArtifactIntegrityError || error instanceof ContainerPullError + ? error + : preparationError(`Unable to prepare ${workload.id}`, error), ), Effect.tap((prepared) => Effect.sync(() => artifacts.set(key, prepared))), ) : Effect.succeed(cached); }; + const prepareArtifacts = ( + runtime: StackRuntime, + workloads: ReadonlyArray, + ) => + Effect.forEach(workloads, (workload) => prepare(runtime, workload), { + concurrency: 4, + }); const functionsPath = ( generation: number, ): Effect.Effect => { @@ -597,6 +608,7 @@ export const makeProductionRuntimeFactory = ( const ownedDriver = withOwnedRuntimeFileCleanup(driver, envFiles, functionsBootstrap); return { driver: ownedDriver, + prepare: prepareArtifacts, preflight, activate, ingress, diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 777e5d87a5..f9997f63c8 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Path, Crypto, Stream } from "effect"; import type { StackLogEntry } from "../public/Logs.ts"; import { StackIdSchema } from "../public/StackId.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import type { SupervisorIngress } from "../supervisor/Ingress.ts"; @@ -10,6 +11,7 @@ import type { LogStore } from "../supervisor/LogStore.ts"; import { makeProductionRuntimeFactory, withOwnedRuntimeFileCleanup } from "./ProductionRuntime.ts"; import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, @@ -19,7 +21,10 @@ import { makeRuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; const stackId = StackIdSchema.make("a".repeat(64)); -const stateFor = (secrets: PersistedStackState["secrets"]): PersistedStackState => ({ +const stateFor = ( + secrets: PersistedStackState["secrets"], + runtime: StackRuntime = { kind: "native" }, +): PersistedStackState => ({ format: "supabase-stack-state-v1", identity: { stackId, @@ -31,7 +36,7 @@ const stateFor = (secrets: PersistedStackState["secrets"]): PersistedStackState localProjectKey: ".", stackName: "production-runtime", }, - runtime: { kind: "native" }, + runtime, desiredGeneration: 1, portsGeneration: null, desiredLifecycle: "stopped", @@ -40,6 +45,28 @@ const stateFor = (secrets: PersistedStackState["secrets"]): PersistedStackState secrets, }); +const workloadFor = ( + id: string, + capability: PlannedWorkload["capability"], + selected: PlannedWorkload["selected"], +): PlannedWorkload => ({ + id, + capability, + dependencies: [], + readiness: { mode: "tcp" }, + restart: { maxAttempts: 1, backoffMs: 0 }, + artifacts: { + native: { kind: "native", service: "postgres", release: "17.6.1.166" }, + container: { + kind: "container", + service: "postgres", + image: "ghcr.io/supabase/cli/postgres:17.6.1.166", + }, + }, + selected, + specHash: `hash-${id}`, +}); + const stateStoreFor = (current: { value: PersistedStackState }): StackStateStore => ({ read: () => Effect.sync(() => current.value), write: () => Effect.die("unused"), @@ -91,6 +118,65 @@ const bootstrap: FunctionsBootstrapOwner = { }; describe("production runtime composition", () => { + it.live("exposes artifact-only preparation without starting workloads or mutating state", () => + Effect.scoped( + Effect.gen(function* () { + const current = { value: stateFor({}) }; + const preparedCalls: string[] = []; + const preparer: RuntimeArtifactPreparer = { + prepare: (_runtime, workload) => + Effect.sync(() => { + preparedCalls.push(workload.id); + return { + workloadId: workload.id, + capability: workload.capability, + version: "17.6.1.166", + outcome: workload.id.endsWith("download") + ? ("downloaded" as const) + : ("cached" as const), + artifactRoot: "/tmp/prepared", + }; + }), + }; + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: "/tmp/production-runtime-prepare-state", + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + artifactPreparer: preparer, + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + logStore: memoryLogStore([]), + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + if (runtime.prepare === undefined) return yield* Effect.die("prepare seam missing"); + const before = current.value; + const result = yield* runtime.prepare({ kind: "native" }, [ + workloadFor("database:database", "database", { + kind: "native", + service: "postgres", + release: "17.6.1.166", + }), + workloadFor("rest:download", "rest", { + kind: "native", + service: "postgres", + release: "17.6.1.166", + }), + ]); + expect(result.map(({ workloadId, outcome }) => [workloadId, outcome])).toEqual([ + ["database:database", "cached"], + ["rest:download", "downloaded"], + ]); + expect(preparedCalls).toEqual(["database:database", "rest:download"]); + expect(current.value).toEqual(before); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("redacts logs using secret slots materialized after factory creation", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index dbd5fae0b8..e5e66a04cb 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -12,14 +12,15 @@ import { Stream, } from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; -import { rebuildExecutionPlan } from "../model/Compiler.ts"; -import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; +import { compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import { CAPABILITY_NAMES, type CapabilityName, type CapabilityStatus, } from "../public/Capability.ts"; import type { StackConfig } from "../public/Config.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, InvalidStackIdentityError, @@ -78,6 +79,7 @@ import type { LogStore } from "./LogStore.ts"; import type { SupervisorIngress } from "./Ingress.ts"; import { StackRpcGroup, type StackRpcError, type StackRpcHandlers } from "../control/StackRpc.ts"; import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; +import type { PreparedWorkloadArtifact } from "../preparation/RuntimeArtifacts.ts"; export interface ActivationResult { readonly capability: CapabilityName; @@ -87,6 +89,11 @@ export interface ActivationResult { /** Runtime construction is injected so catalog/artifact resolution can evolve independently. */ export interface SupervisorRuntime { readonly driver: RuntimeDriver; + /** Verifies and prepares immutable workload artifacts without changing lifecycle state. */ + readonly prepare?: ( + runtime: StackRuntime, + workloads: ReadonlyArray, + ) => Effect.Effect, StackError>; readonly preflight?: (input: LifecycleInput) => Effect.Effect; readonly activate?: ( capability: CapabilityName, @@ -832,14 +839,115 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect; + }) => + Effect.gen(function* () { + // Preparation is deliberately based on one fresh state read and never writes it. A + // supplied config is compiled only to validate/materialize a prospective plan; its secret + // declarations are intentionally not resolved here. + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + + let definition: import("../model/Compiler.ts").StackDefinition; + let plan: ExecutionPlan; + if (prepareOptions?.config === undefined && state.definition !== undefined) { + if (state.inputFingerprint === undefined) + return yield* new StackStateInvalidError({ + stackId: options.stackId, + message: "Persisted stack definition fingerprint is missing", + }); + definition = state.definition; + plan = yield* rebuildExecutionPlan(state.runtime, definition).pipe( + Effect.provideContext(options.context), + ); + } else { + if ( + prepareOptions?.config === undefined && + state.desiredLifecycle !== "unconfigured" && + state.definition === undefined + ) + return yield* new StackDefinitionRequiredError({ + stackId: options.stackId, + message: "A complete stack definition is required", + }); + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config: prepareOptions?.config, + }).pipe(Effect.provideContext(options.context)); + definition = compiled.definition; + plan = compiled.executionPlan; + } + + const selected = new Set(); + const visit = (name: CapabilityName): Effect.Effect => { + if (selected.has(name)) return Effect.void; + const capability = definition.capabilities[name]; + if (!capability.enabled) + return Effect.fail( + new StackPreparationError({ + stackId: options.stackId, + capability: name, + message: `Capability ${name} is disabled`, + }), + ); + selected.add(name); + return Effect.forEach(plan.dependencies[name], visit, { discard: true }); + }; + if (prepareOptions?.capabilities === undefined) { + for (const name of CAPABILITY_NAMES) + if (definition.capabilities[name].enabled) selected.add(name); + } else { + for (const name of new Set(prepareOptions.capabilities)) yield* visit(name); + } + + const workloads = plan.workloads.filter((workload) => selected.has(workload.capability)); + if (runtime.prepare === undefined) + return yield* new StackPreparationError({ + stackId: options.stackId, + message: "Stack runtime artifact preparation is unavailable", + }); + const artifacts = yield* runtime.prepare(state.runtime, workloads); + const byCapability = new Map>(); + for (const artifact of artifacts) { + const existing = byCapability.get(artifact.capability) ?? []; + byCapability.set(artifact.capability, [...existing, artifact]); + } + const capabilities = plan.startOrder + .filter((name) => selected.has(name)) + .map((name) => { + const entries = byCapability.get(name) ?? []; + const outcome: "cached" | "downloaded" | "pulled" = + state.runtime.kind === "native" + ? entries.some((entry) => entry.outcome === "downloaded") + ? "downloaded" + : "cached" + : entries.some((entry) => entry.outcome === "pulled") + ? "pulled" + : "cached"; + return { + capability: name, + version: definition.capabilities[name].version, + outcome, + }; + }); + return { capabilities }; + }).pipe(Effect.provideContext(options.context)); const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ status: () => status.pipe( Effect.mapError((error) => rpcError("StackStateInvalidError", stateErrorMessage(error))), ), credentials: () => credentials, - prepare: () => - Effect.fail(rpcError("StackPreparationError", "Stack preparation is not available yet")), + prepare: ({ config, capabilities }) => + prepareOperation({ config, capabilities }).pipe( + Effect.mapError((error) => + rpcError(rpcTag(error, "StackPreparationError"), stateErrorMessage(error)), + ), + ), start: ({ config }: { readonly config?: StackConfig }) => operation(startOperation({ config }), "StackReconciliationError"), restart: ({ config }: { readonly config?: StackConfig }) => diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 4b812922e6..2ef10c535f 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -17,6 +17,8 @@ import { Headers } from "effect/unstable/http"; import { Rpc } from "effect/unstable/rpc"; import { RequestId } from "effect/unstable/rpc/RpcMessage"; import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; +import type { CapabilityName } from "../public/Capability.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, StackNotRunningError, @@ -68,6 +70,48 @@ const invokeCredentials = ( return value; }); +const invokePrepare = ( + supervisor: Supervisor, + payload: { + readonly config?: import("../public/Config.ts").StackConfig; + readonly capabilities?: ReadonlyArray; + } = {}, +): Effect.Effect< + { + readonly capabilities: ReadonlyArray<{ + readonly capability: CapabilityName; + readonly version: string; + readonly outcome: "cached" | "downloaded" | "pulled"; + }>; + }, + StackRpcError, + Scope.Scope +> => + Effect.gen(function* () { + const handler = yield* StackRpcGroup.accessHandler("prepare").pipe( + Effect.provide(StackRpcGroup.toLayerHandler("prepare", supervisor.rpcHandlers.prepare)), + ); + const value = yield* handler(payload, { + client: new Rpc.ServerClient(1), + requestId: RequestId(1), + headers: Headers.empty, + }); + if ( + Deferred.isDeferred< + { + readonly capabilities: ReadonlyArray<{ + readonly capability: CapabilityName; + readonly version: string; + readonly outcome: "cached" | "downloaded" | "pulled"; + }>; + }, + StackRpcError + >(value) + ) + return yield* Deferred.await(value); + return value; + }); + const makeMockIngress = ( timeline: Ref.Ref>, failOpen = false, @@ -122,6 +166,8 @@ const makeFixture = ( fixtureOptions: { readonly ingress?: SupervisorIngress; readonly timeline?: Ref.Ref>; + readonly runtime?: StackRuntime; + readonly prepareOutcome?: "cached" | "downloaded" | "present" | "pulled"; } = {}, ) => Effect.gen(function* () { @@ -132,7 +178,7 @@ const makeFixture = ( yield* store.initialize(id, { format: "supabase-stack-state-v1", identity: { ...identity, stackId: id }, - runtime: { kind: "native" }, + runtime: fixtureOptions.runtime ?? { kind: "native" }, desiredGeneration: 0, portsGeneration: null, desiredLifecycle: "unconfigured", @@ -202,6 +248,17 @@ const makeFixture = ( }; const runtime: SupervisorRuntime = { driver, + prepare: (_runtime, workloads) => + Effect.forEach(workloads, (workload) => + Ref.update(calls, (current) => [...current, `prepare:${workload.id}`]).pipe( + Effect.as({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: fixtureOptions.prepareOutcome ?? "cached", + }), + ), + ), preflight: () => Effect.succeed({}), activate: () => Effect.succeed({ host: "127.0.0.1", port: 9999 }), ...(fixtureOptions.ingress === undefined ? {} : { ingress: fixtureOptions.ingress }), @@ -238,6 +295,162 @@ const run = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); describe("Supervisor composition", () => { + it.live("prepares a prospective config without mutating state or starting resources", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const before = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + const result = yield* invokePrepare(fixture.supervisor, { + config: { + capabilities: { + rest: {}, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + expect(result.capabilities).toEqual([ + { capability: "database", version: expect.any(String), outcome: "cached" }, + { capability: "rest", version: expect.any(String), outcome: "cached" }, + ]); + expect(yield* fixture.store.read(fixture.id)).toEqual(before); + expect(yield* Ref.get(fixture.calls)).toEqual([ + "prepare:database:database", + "prepare:rest:rest", + ]); + }), + ), + ); + + it.live("prepares persisted pins and includes dependency closure in start order", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ + config: { + capabilities: { + rest: {}, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + yield* Ref.set(fixture.calls, []); + const result = yield* invokePrepare(fixture.supervisor, { capabilities: ["rest", "rest"] }); + expect(result.capabilities.map(({ capability }) => capability)).toEqual([ + "database", + "rest", + ]); + expect(yield* Ref.get(fixture.calls)).toEqual([ + "prepare:database:database", + "prepare:rest:rest", + ]); + }), + ), + ); + + it.live("reports a native capability as downloaded when any workload was downloaded", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture({ prepareOutcome: "downloaded" }); + const result = yield* invokePrepare(fixture.supervisor, { + config: { + capabilities: { + rest: {}, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + expect(result.capabilities).toEqual([ + { capability: "database", version: expect.any(String), outcome: "downloaded" }, + { capability: "rest", version: expect.any(String), outcome: "downloaded" }, + ]); + }), + ), + ); + + it.live("maps present and pulled container workloads to cached and pulled outcomes", () => + run( + Effect.gen(function* () { + const config = { + capabilities: { + rest: {}, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }; + const present = yield* makeFixture({ + runtime: { kind: "container", engine: "docker" }, + prepareOutcome: "present", + }); + const presentResult = yield* invokePrepare(present.supervisor, { config }); + expect(presentResult.capabilities.every(({ outcome }) => outcome === "cached")).toBe(true); + + const pulled = yield* makeFixture({ + runtime: { kind: "container", engine: "docker" }, + prepareOutcome: "pulled", + }); + const pulledResult = yield* invokePrepare(pulled.supervisor, { config }); + expect(pulledResult.capabilities).toEqual([ + { capability: "database", version: expect.any(String), outcome: "pulled" }, + { capability: "rest", version: expect.any(String), outcome: "pulled" }, + ]); + }), + ), + ); + + it.live("rejects a request for a disabled capability before preparing anything", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const failed = yield* invokePrepare(fixture.supervisor, { + config: { + capabilities: { + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + capabilities: ["auth"], + }).pipe(Effect.exit); + expect(errorOf(failed)?.tag).toBe("StackPreparationError"); + expect(yield* Ref.get(fixture.calls)).toEqual([]); + }), + ), + ); + it.live("starts through the composed lifecycle and reports observed readiness", () => run( Effect.gen(function* () { @@ -293,9 +506,10 @@ describe("Supervisor composition", () => { ); if (credentials.storage === undefined) return yield* new StackStateInvalidError({ message: "storage credentials are missing" }); - expect(Redacted.value(credentials.storage.secretAccessKey)).toBe( - "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", - ); + const persistedStorageSecret = + running.secrets["secret:storage.settings.s3_protocol.secret_access_key"]?.value; + expect(persistedStorageSecret).toEqual(expect.any(String)); + expect(Redacted.value(credentials.storage.secretAccessKey)).toBe(persistedStorageSecret); }), ), ); From 4c274c4eb953e5750ab4ca2d0ddc2104aabb0237 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 15:34:05 +0200 Subject: [PATCH 064/174] feat(stack): share storage container data --- .../stack/src/runtime/ContainerRuntime.ts | 29 ++- .../container-runtime.integration.test.ts | 187 ++++++++++++++++++ .../readiness-probe.integration.test.ts | 6 +- 3 files changed, 212 insertions(+), 10 deletions(-) diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index f12142faca..c8aaed8b94 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -80,6 +80,8 @@ export interface ContainerPortPublicationInput { export interface ContainerVolumeRequest { readonly target: string; readonly readOnly: boolean; + /** Stable logical workload that owns the persistent volume. Defaults to the mounting workload. */ + readonly ownerWorkloadId?: string; } export interface ContainerRuntimeResourceIds { @@ -125,22 +127,30 @@ const workloadLabelsFor = ( specHash: key.specHash, role: "workload", }); -const volumeLabelsFor = (key: RuntimeWorkloadKey): ContainerVolumeLabels => ({ +const volumeOwnerFor = (key: RuntimeWorkloadKey, request: ContainerVolumeRequest): string => + request.ownerWorkloadId ?? key.workloadId; +const volumeLabelsFor = ( + key: RuntimeWorkloadKey, + request: ContainerVolumeRequest, +): ContainerVolumeLabels => ({ stackId: key.stackId, - workloadId: key.workloadId, + workloadId: volumeOwnerFor(key, request), role: "volume", }); -const volumeNameFor = (key: RuntimeWorkloadKey): string => - `supabase-${key.stackId}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; -const volumeSpecFor = (key: RuntimeWorkloadKey): ContainerVolumeSpec => ({ - name: volumeNameFor(key), - labels: volumeLabelsFor(key), +const volumeNameFor = (key: RuntimeWorkloadKey, ownerWorkloadId: string): string => + `supabase-${key.stackId}-${ownerWorkloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; +const volumeSpecFor = ( + key: RuntimeWorkloadKey, + request: ContainerVolumeRequest, +): ContainerVolumeSpec => ({ + name: volumeNameFor(key, volumeOwnerFor(key, request)), + labels: volumeLabelsFor(key, request), }); const volumeMountFor = ( key: RuntimeWorkloadKey, request: ContainerVolumeRequest, ): ContainerVolumeMount => ({ - volume: volumeNameFor(key), + volume: volumeNameFor(key, volumeOwnerFor(key, request)), target: request.target, readOnly: request.readOnly, }); @@ -360,7 +370,8 @@ export const makeContainerRuntime = ( if (volumeRequest !== undefined && volumeRequest.target.length === 0) return yield* toDriverError(key, new Error("Container volume mapping is invalid")); const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); - const volumeSpec = volumeRequest === undefined ? undefined : volumeSpecFor(key); + const volumeSpec = + volumeRequest === undefined ? undefined : volumeSpecFor(key, volumeRequest); if (volumeSpec !== undefined) { const volume = volumeSpec; const exact = entries.find( diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 3883bd3db0..0569378e0b 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -1209,6 +1209,193 @@ describe("container runtime", () => { }), ); + it.live("shares one owner volume across read-only and read-write workloads", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const ownerKey: RuntimeWorkloadKey = { + ...key, + workloadId: "storage:storage", + specHash: "storage-hash", + }; + const secondaryKey: RuntimeWorkloadKey = { + ...key, + workloadId: "storage:imgproxy", + specHash: "imgproxy-hash", + }; + const ownerWorkload = { + ...workload(), + id: ownerKey.workloadId, + capability: "storage" as const, + }; + const secondaryWorkload = { + ...workload(), + id: secondaryKey.workloadId, + capability: "storage" as const, + }; + const ownerWorkloadId = ownerKey.workloadId; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: (requestKey) => + Effect.succeed({ + volume: + requestKey.workloadId === ownerWorkloadId + ? { target: "/var/lib/storage", readOnly: false, ownerWorkloadId } + : { target: "/mnt", readOnly: true, ownerWorkloadId }, + }), + }); + + // A read-only dependent can materialize and own the shared volume first. + yield* runtime.start(secondaryKey, secondaryWorkload); + yield* runtime.start(ownerKey, ownerWorkload); + + const expectedVolume = `supabase-${stackId}-${ownerWorkloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; + expect(state.resources.filter((resource) => resource.kind === "volume")).toHaveLength(1); + expect(state.resources.find((resource) => resource.kind === "volume")?.name).toBe( + expectedVolume, + ); + expect(state.calls.filter((call) => call.startsWith("create-volume:"))).toEqual([ + `create-volume:${expectedVolume}`, + ]); + expect(state.createdSpecs.map((spec) => spec.volumeMounts[0])).toEqual([ + { volume: expectedVolume, target: "/mnt", readOnly: true }, + { volume: expectedVolume, target: "/var/lib/storage", readOnly: false }, + ]); + + yield* runtime.stop(secondaryKey); + yield* runtime.stop(ownerKey); + expect(state.resources.filter((resource) => resource.kind === "volume")).toHaveLength(1); + const sharedVolume = state.resources.find((resource) => resource.kind === "volume"); + expect(sharedVolume).toBeDefined(); + if (sharedVolume === undefined) return; + yield* runtime.cleanup({ stackId, destroy: true }); + expect(state.calls.filter((call) => call.startsWith("remove-volume:"))).toEqual([ + `remove-volume:${sharedVolume.id}`, + ]); + }), + ); + + it.live("recovers an exact shared owner volume and destroys it once", () => + Effect.gen(function* () { + const ownerWorkloadId = "storage:storage"; + const secondaryWorkloadId = "storage:imgproxy"; + const ownerSpecHash = "storage-hash"; + const secondarySpecHash = "imgproxy-hash"; + const ownerWorkload = { + ...workload(), + id: ownerWorkloadId, + capability: "storage" as const, + specHash: ownerSpecHash, + }; + const secondaryWorkload = { + ...workload(), + id: secondaryWorkloadId, + capability: "storage" as const, + specHash: secondarySpecHash, + }; + const sharedVolumeName = `supabase-${stackId}-${ownerWorkloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; + const state: FakeContainerState = { + resources: [ + { + id: "shared-network", + name: "shared-network", + kind: "network", + labels: { + stackId, + ownerSessionId: "previous-owner", + desiredGeneration: key.desiredGeneration, + role: "network", + }, + }, + { + id: "shared-volume", + name: sharedVolumeName, + kind: "volume", + labels: { stackId, workloadId: ownerWorkloadId, role: "volume" }, + }, + { + id: "storage-container", + name: "storage-container", + kind: "workload", + state: "running", + labels: { + stackId, + ownerSessionId: "previous-owner", + desiredGeneration: key.desiredGeneration, + workloadId: ownerWorkloadId, + specHash: ownerSpecHash, + role: "workload", + }, + }, + { + id: "imgproxy-container", + name: "imgproxy-container", + kind: "workload", + state: "running", + labels: { + stackId, + ownerSessionId: "previous-owner", + desiredGeneration: key.desiredGeneration, + workloadId: secondaryWorkloadId, + specHash: secondarySpecHash, + role: "workload", + }, + }, + ], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "new-owner", + resolveWorkload: (requestKey) => + Effect.succeed({ + volume: + requestKey.workloadId === ownerWorkloadId + ? { target: "/var/lib/storage", readOnly: false, ownerWorkloadId } + : { target: "/mnt", readOnly: true, ownerWorkloadId }, + }), + }); + const plan = recoveryPlan([ownerWorkload, secondaryWorkload]); + const observed = yield* runtime.recover({ + stackId, + desiredGeneration: key.desiredGeneration, + desiredLifecycle: "running", + plan, + }); + expect(observed).toHaveLength(2); + expect(observed.map(({ workloadId }) => workloadId)).toEqual( + expect.arrayContaining([ownerWorkloadId, secondaryWorkloadId]), + ); + expect(state.calls.some((call) => call.startsWith("create-volume:"))).toBe(false); + yield* runtime.start( + { ...key, workloadId: ownerWorkloadId, specHash: ownerSpecHash }, + ownerWorkload, + ); + yield* runtime.start( + { ...key, workloadId: secondaryWorkloadId, specHash: secondarySpecHash }, + secondaryWorkload, + ); + expect(state.calls.some((call) => call.startsWith("create-volume:"))).toBe(false); + yield* runtime.cleanup({ stackId, destroy: false }); + expect(state.resources.some((resource) => resource.id === "shared-volume")).toBe(true); + const volumeId = state.resources.find((resource) => resource.id === "shared-volume")?.id; + yield* runtime.cleanup({ stackId, destroy: true }); + expect(state.calls.filter((call) => call.startsWith("remove-volume:"))).toEqual([ + `remove-volume:${volumeId}`, + ]); + expect(state.resources.some((resource) => resource.id === "shared-volume")).toBe(false); + }), + ); + it.live( "cleans up a newly created container when readiness fails and keeps the original cause", () => diff --git a/packages/stack/src/runtime/readiness-probe.integration.test.ts b/packages/stack/src/runtime/readiness-probe.integration.test.ts index c94c7cde95..97ff0da2b5 100644 --- a/packages/stack/src/runtime/readiness-probe.integration.test.ts +++ b/packages/stack/src/runtime/readiness-probe.integration.test.ts @@ -74,7 +74,11 @@ describe("private endpoint readiness probe", () => { it.live("rejects invalid readiness header names and values", () => Effect.gen(function* () { - for (const headers of [{ "bad name": "ok" }, { Host: "bad\nvalue" }]) { + const cases: ReadonlyArray>> = [ + { "bad name": "ok" }, + { Host: "bad\nvalue" }, + ]; + for (const headers of cases) { const result = yield* probeReadiness({ mode: "http", host: "127.0.0.1", From 66408723272e2fe47e290a28895c790eb6775906 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 15:39:12 +0200 Subject: [PATCH 065/174] docs(stack): record postgres 17 scope --- .../plans/2026-08-28-stack-runtime-rewrite-progress.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index a262cb1592..bee6dcf908 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -285,6 +285,9 @@ Implementation continues after recording a ruling; this file is not a question q #### Task 3 review fixes — round 2 — 2026-08-28 +- **Superseded release decision:** the alias behavior recorded below is historical. The rewrite now + accepts only PostgreSQL `17.6.1.166`; PostgreSQL 15 remains deferred until its matching native + archive and derived image are published, and every other selector fails before mutation. - Preserved the authoritative database major alias: selector `13` intentionally resolves to the current Supabase Postgres `15.8.1.085` release. This follows `apps/cli-go/pkg/config/config.go:825-831` and the documented fallthrough in From a0c1276373b8f8889e3b8f479b66bb705dfcd761 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 16:12:23 +0200 Subject: [PATCH 066/174] feat(stack): run service startup migrations --- packages/stack/src/runtime/NativeRuntime.ts | 134 ++++++++++++- .../stack/src/runtime/ProductionRuntime.ts | 36 +++- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 120 ++++++++++-- .../native-runtime.integration.test.ts | 181 ++++++++++++++++++ .../workload-runtime.integration.test.ts | 75 ++++++++ 5 files changed, 526 insertions(+), 20 deletions(-) diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index 0bb58f82bc..29d3e5c1a7 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -34,11 +34,11 @@ export interface NativeRuntimeOptions { key: RuntimeWorkloadKey, workload: NativeWorkload, ) => Effect.Effect; - /** Resolves the complete native process command for one workload. */ + /** Resolves the complete native process plan for one workload. */ readonly resolveProcess: ( key: RuntimeWorkloadKey, workload: NativeWorkload, - ) => Effect.Effect; + ) => Effect.Effect; /** Private readiness is resolved by the owning Supervisor/gateway seam. */ readonly waitForReadiness: ( key: RuntimeWorkloadKey, @@ -54,6 +54,12 @@ export interface NativeRuntimeOptions { readonly logStore?: LogStore; } +/** One-shot startup processes followed by the long-lived workload process. */ +export interface NativeProcessPlan { + readonly startup: ReadonlyArray; + readonly main: NativeProcessSpec; +} + interface OutputAccumulator { readonly decoder: TextDecoder; remainder: string; @@ -149,6 +155,14 @@ const observeOutput = ( appendLines(logStore, resource, stream, bytes), ).pipe(Effect.andThen(flushLines(logStore, resource, stream))); +const nativeProcessPlan = (resolved: NativeProcessSpec | NativeProcessPlan): NativeProcessPlan => + "main" in resolved + ? resolved + : { + startup: [], + main: resolved, + }; + /** Creates a Supervisor-owned native runtime with exact process identity fencing. */ export const makeNativeRuntime = ( options: NativeRuntimeOptions, @@ -235,6 +249,114 @@ export const makeNativeRuntime = ( }).pipe(Effect.forkIn(resource.scope), Effect.asVoid); }; + /** Runs one short-lived, service-owned startup process in its own scope. */ + const runStartupProcess = ( + resource: Resource, + spec: NativeProcessSpec, + launcher: NativeProcessLauncher | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const startupScope = yield* Scope.fork(resource.scope, "parallel"); + let started: NativeProcess | undefined; + const run = Effect.gen(function* () { + const process = yield* spawnNativeProcess(spec, launcher).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), + Scope.provide(startupScope), + Effect.mapError((error) => processError(error, resource.key, "startup spawn")), + ); + started = process; + const exitCode = yield* Effect.cached( + process.exitCode.pipe( + Effect.mapError((error) => processError(error, resource.key, "startup")), + ), + ); + const consume = + options.logStore === undefined + ? Effect.all([Stream.runDrain(process.stdout), Stream.runDrain(process.stderr)], { + concurrency: "unbounded", + discard: true, + }) + : Effect.all( + [ + observeOutput(options.logStore, resource, process, "stdout"), + observeOutput(options.logStore, resource, process, "stderr"), + ], + { concurrency: "unbounded", discard: true }, + ); + const consumed = consume.pipe( + Effect.mapError((error) => + driverError( + resource.key, + `Native startup log stream failed for ${resource.key.workloadId}: ${errorMessage(error)}`, + error, + ), + ), + ); + const outputFiber = yield* Effect.forkIn(consumed, startupScope); + // A one-shot process is complete only after both its exit and output + // streams have completed. Effect.all keeps the stream drain alive + // when the child exits before its pipes finish, while a stream + // failure interrupts the exit waiter and startup cleanup kills the + // exact child. + yield* Effect.all([exitCode.pipe(Effect.asVoid), Fiber.join(outputFiber)], { + concurrency: "unbounded", + discard: true, + }); + const result = yield* exitCode.pipe(Effect.exit); + if (Exit.isFailure(result)) + return yield* driverError( + resource.key, + `Native startup process failed for ${resource.key.workloadId}`, + result.cause, + ); + if (result.value !== 0) + return yield* driverError( + resource.key, + `Native startup process exited with code ${String(result.value)} for ${resource.key.workloadId}`, + ); + }); + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const result = yield* restore(run).pipe(Effect.exit); + const terminated = + started === undefined + ? Exit.succeed(undefined) + : yield* Effect.suspend(() => { + const child = started; + if (child === undefined) return Effect.void; + return child.isRunning.pipe( + Effect.mapError((error) => + processError(error, resource.key, "startup probe"), + ), + Effect.flatMap((running) => + running + ? child.kill.pipe( + Effect.mapError((error) => + processError(error, resource.key, "startup stop"), + ), + ) + : Effect.void, + ), + ); + }).pipe(Effect.exit); + const closed = yield* Scope.close(startupScope, Exit.void).pipe(Effect.exit); + if (Exit.isFailure(result) && Exit.isFailure(terminated) && Exit.isFailure(closed)) + return yield* Effect.failCause( + Cause.combine(result.cause, Cause.combine(terminated.cause, closed.cause)), + ); + if (Exit.isFailure(result) && Exit.isFailure(terminated)) + return yield* Effect.failCause(Cause.combine(result.cause, terminated.cause)); + if (Exit.isFailure(result) && Exit.isFailure(closed)) + return yield* Effect.failCause(Cause.combine(result.cause, closed.cause)); + if (Exit.isFailure(terminated) && Exit.isFailure(closed)) + return yield* Effect.failCause(Cause.combine(terminated.cause, closed.cause)); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + if (Exit.isFailure(terminated)) return yield* Effect.failCause(terminated.cause); + if (Exit.isFailure(closed)) return yield* Effect.failCause(closed.cause); + }), + ); + }); + const runStart = (resource: Resource): Effect.Effect => Effect.gen(function* () { const { key, workload } = resource; @@ -243,8 +365,12 @@ export const makeNativeRuntime = ( options.resolveLauncher === undefined ? undefined : yield* options.resolveLauncher(key, workload); - const process = yield* options.resolveProcess(key, workload).pipe( - Effect.flatMap((spec) => spawnNativeProcess(spec, launcher)), + const resolved = yield* options + .resolveProcess(key, workload) + .pipe(Effect.map(nativeProcessPlan)); + for (const startup of resolved.startup) + yield* runStartupProcess(resource, startup, launcher); + const process = yield* spawnNativeProcess(resolved.main, launcher).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), Scope.provide(resource.scope), Effect.mapError((error) => processError(error, key, "spawn")), diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 2ae9a0d37d..2f6b4f01fb 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -495,9 +495,27 @@ export const makeProductionRuntimeFactory = ( endpoint.port, inputs, ); + const environment = spec.env( + fresh, + workload, + endpoint.port, + "native", + inputs, + ); return { - ...process, - env: spec.env(fresh, workload, endpoint.port, "native", inputs), + startup: spec + .nativeStartupProcesses( + prepared.artifactRoot, + fresh, + workload, + endpoint.port, + inputs, + ) + .map((startup) => ({ + ...startup, + env: { ...environment, ...startup.env }, + })), + main: { ...process, env: environment }, }; }), ), @@ -574,8 +592,18 @@ export const makeProductionRuntimeFactory = ( workload.id === "database:database" ? { target: "/var/lib/postgresql/data", readOnly: false } : workload.id === "storage:storage" - ? { target: "/var/lib/storage", readOnly: false } - : undefined; + ? { + target: "/mnt", + readOnly: false, + ownerWorkloadId: "storage:storage", + } + : workload.id === "storage:imgproxy" + ? { + target: "/mnt", + readOnly: true, + ownerWorkloadId: "storage:storage", + } + : undefined; const { env: _env, ...withoutEnv } = resolution; return { ...withoutEnv, diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 15348825f0..0d926cbd58 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -62,6 +62,7 @@ export interface NativeProcessResolution { readonly executable: string; readonly args: ReadonlyArray; readonly cwd: string; + readonly env?: Readonly>; } /** Complete process-side contract for one catalog workload. */ @@ -112,18 +113,36 @@ export interface WorkloadRuntimeSpec { port: number, inputs?: WorkloadRuntimeInputs, ) => NativeProcessResolution; + /** Service-owned, short-lived native processes to run before the main process. */ + readonly nativeStartupProcesses: ( + artifactRoot: string, + state: PersistedStackState, + workload: PlannedWorkload, + port: number, + inputs?: WorkloadRuntimeInputs, + ) => ReadonlyArray; } type WorkloadRuntimeSpecDefinition = Omit< WorkloadRuntimeSpec, - "containerPort" | "cwd" | "privateEndpoint" | "nativeProcess" | "readiness" + | "containerPort" + | "cwd" + | "privateEndpoint" + | "nativeProcess" + | "nativeStartupProcesses" + | "readiness" > & Readonly<{ readonly readiness: Omit & { readonly binding?: WorkloadBindingName; }; }> & - Partial>; + Partial< + Pick< + WorkloadRuntimeSpec, + "cwd" | "privateEndpoint" | "nativeProcess" | "nativeStartupProcesses" + > + >; export interface ContainerWorkloadResolution { readonly command: ReadonlyArray; @@ -503,6 +522,54 @@ const nativeProcessFor = ( }; }; +const nativeStartupProcessesFor = ( + artifactRoot: string, + _state: PersistedStackState, + workload: PlannedWorkload, + inputs: WorkloadRuntimeInputs = {}, +): ReadonlyArray => { + const artifact = (relative: string): string => artifactPath(artifactRoot, relative); + const cwd = artifactRoot; + switch (workload.id) { + case "auth:auth": + return [{ executable: artifact("bin/auth"), args: ["migrate"], cwd }]; + case "storage:storage": + return [ + { + executable: artifact("node/bin/node"), + args: [artifact("app/dist/scripts/migrate-call.js")], + cwd: artifact("app"), + }, + ]; + case "realtime:realtime": + return [{ executable: artifact("bin/migrate"), args: [], cwd }]; + case "analytics:analytics": + return [ + { + executable: artifact("bin/logflare"), + args: ["eval", "Logflare.Release.migrate"], + cwd, + }, + ]; + case "pooler:pooler": { + const migrate = { executable: artifact("bin/migrate"), args: [], cwd }; + const tenantPath = inputs.pooler?.tenantPath; + if (tenantPath === undefined) return [migrate]; + return [ + migrate, + { + executable: "/bin/sh", + args: ["-c", `${artifact("bin/supavisor")} eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"`], + cwd, + env: { SUPABASE_POOLER_TENANT_PATH: tenantPath }, + }, + ]; + } + default: + return []; + } +}; + const withRestSettings = ( state: PersistedStackState, runtime: WorkloadRuntimeKind, @@ -972,7 +1039,9 @@ const specs: Readonly> = { args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => withAuthSettings(state, runtime, port, workload, inputs), - containerArgs: () => [], + // The slim Auth image has an empty ENTRYPOINT. Run its idempotent + // migration before handing control back to the image's gotrue command. + containerArgs: () => ["/bin/sh", "-c", "gotrue migrate && exec gotrue"], networkAliases: ["supabase-auth"], readiness: { protocol: "http", path: "/health" }, }, @@ -1014,7 +1083,14 @@ const specs: Readonly> = { args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => withStorageSettings(state, runtime, port, workload, inputs), - containerArgs: () => [], + // The slim Storage image has an empty ENTRYPOINT and ships the bundled + // Node runtime on PATH. Keep migration and the long-lived server in one + // command so both modes share the same startup contract. + containerArgs: () => [ + "/usr/bin/sh", + "-c", + "node dist/scripts/migrate-call.js && exec node dist/start/server.js", + ], networkAliases: ["supabase-storage"], readiness: { protocol: "http", path: "/status" }, }, @@ -1026,7 +1102,7 @@ const specs: Readonly> = { IMGPROXY_BIND: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, IMGPROXY_LOCAL_FILESYSTEM_ROOT: runtime === "container" - ? "/" + ? "/mnt" : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), }), containerArgs: () => [], @@ -1137,10 +1213,7 @@ const specs: Readonly> = { smtp: { containerPort: 1025 }, pop3: { containerPort: 1110 }, }, - args: (state, _workload, port) => [ - "--ui", - `127.0.0.1:${privatePortFor(state, "mail:mail", "ui") ?? port}`, - ], + args: () => [], env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), ...capabilityEnv(state, "mail", "MAIL"), @@ -1149,7 +1222,7 @@ const specs: Readonly> = { MP_POP3_BIND_ADDR: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${workloadPort(state, "mail:mail", "pop3", runtime, 1110)}`, MP_SMTP_DISABLE_RDNS: "true", }), - containerArgs: () => ["--ui", "0.0.0.0:8025"], + containerArgs: () => [], networkAliases: [MAIL_NETWORK_ALIAS], readiness: { protocol: "http", path: "/readyz", binding: "ui" }, }, @@ -1209,7 +1282,7 @@ const specs: Readonly> = { "pooler:pooler": { bindings: { primary: { containerPort: 6543 } }, args: () => ["start"], - env: (state, workload, port, runtime = "native") => ({ + env: (state, workload, port, runtime = "native", inputs = {}) => ({ ...common(workload, port), ...capabilityEnv(state, "pooler", "POOLER"), PORT: "4000", @@ -1232,10 +1305,30 @@ const specs: Readonly> = { DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size") || "20", MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn") || "100", POOL_MODE: valueAt(state, "pooler", "pool_mode") || "transaction", + ...(inputs.pooler?.tenantPath === undefined + ? {} + : { SUPABASE_POOLER_TENANT_PATH: "/app/pooler_tenant.exs" }), }), // The slim container entrypoint performs Supavisor migrations. Tenant // provisioning is a separate owner/bootstrap phase. - containerArgs: () => [], + containerArgs: (_state, _workload, _port, inputs = {}) => + inputs.pooler?.tenantPath === undefined + ? [] + : [ + "/usr/bin/sh", + "-c", + '/app/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")" && exec /app/bin/server', + ], + containerMounts: (_state, _workload, inputs = {}) => + inputs.pooler?.tenantPath === undefined + ? [] + : [ + { + source: inputs.pooler.tenantPath, + target: "/app/pooler_tenant.exs", + readOnly: true, + }, + ], networkAliases: ["supabase-pooler"], readiness: { protocol: "tcp" }, }, @@ -1342,6 +1435,9 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | )), nativeProcess: (artifactRoot, state, currentWorkload, port, inputs = {}) => nativeProcessFor(artifactRoot, state, currentWorkload, port, spec, inputs), + nativeStartupProcesses: (artifactRoot, state, currentWorkload, _port, inputs = {}) => + spec.nativeStartupProcesses?.(artifactRoot, state, currentWorkload, _port, inputs) ?? + nativeStartupProcessesFor(artifactRoot, state, currentWorkload, inputs), }; }; diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 5ece11d89b..aee8652020 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -2,6 +2,8 @@ import { NodeServices, NodeSocket } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Data, Deferred, Effect, Exit, Fiber, FileSystem, Path, Ref, Stream } from "effect"; import { ChildProcess } from "effect/unstable/process"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { spawnSync } from "node:child_process"; import { LogStoreError, makeLogStore, type LogStore } from "../supervisor/LogStore.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { StackIdSchema } from "../public/StackId.ts"; @@ -46,6 +48,14 @@ const fixtureProcess = (message: string) => ({ ], }); +const oneShotProcess = (stdout: string, exitCode = 0) => ({ + executable: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(`${stdout}\n`)}); process.stderr.write(${JSON.stringify("startup-stderr\n")}); process.exit(${exitCode})`, + ], +}); + const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -237,6 +247,177 @@ describe("native runtime", () => { ), ); + it.live("runs native startup processes before main readiness and captures their logs", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-startup-" }); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const logs = yield* logStore.stream({ follow: true }).pipe( + Stream.takeUntil((entry) => entry.message === "main"), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + let observedReady = false; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [oneShotProcess("startup")], + main: fixtureProcess("main"), + }), + logStore, + waitForReadiness: () => + Effect.sync(() => { + observedReady = true; + }), + }); + const key = keyFor("startup-order"); + const ready = yield* runtime.start(key, workload("startup-order")); + expect(ready.state).toBe("ready"); + expect(observedReady).toBe(true); + const messages = (yield* Fiber.join(logs)).map((entry) => entry.message); + expect(messages.indexOf("startup")).toBeGreaterThanOrEqual(0); + expect(messages).toContain("startup-stderr"); + expect(messages.indexOf("main")).toBeGreaterThan(messages.indexOf("startup")); + yield* runtime.remove(key); + }), + ), + ); + + it.live("does not spawn the native main process when startup exits nonzero", () => + withPlatform( + Effect.gen(function* () { + let readinessCalled = false; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [oneShotProcess("startup-failed", 7)], + // This path is intentionally invalid: a correct runtime must + // fail on the startup process before attempting to spawn it. + main: { executable: "/missing/native-main" }, + }), + waitForReadiness: () => + Effect.sync(() => { + readinessCalled = true; + }), + }); + const result = yield* runtime + .start(keyFor("startup-failed"), workload("startup-failed")) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(readinessCalled).toBe(false); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + + // oxlint-disable effecttsgo/prefer-schema-over-json + it.live("drains startup output after the child exits", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-drain-" }); + const releasePath = path.join(root, "release"); + expect(spawnSync("mkfifo", [releasePath]).status).toBe(0); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const descendant = ` + const fs = require("node:fs"); + process.stdout.write("startup-late\\n"); + const fd = fs.openSync(${JSON.stringify(releasePath)}, "r"); + fs.readSync(fd, Buffer.alloc(1), 0, 1, null); + fs.closeSync(fd); + `; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [ + { + executable: process.execPath, + args: [ + "-e", + `const { spawn } = require("node:child_process"); spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], { stdio: ["ignore", "inherit", "inherit"] }); process.exit(0)`, + ], + }, + ], + main: fixtureProcess("drained-main"), + }), + logStore, + waitForReadiness: () => Effect.void, + }); + const startupOutput = yield* logStore.stream({ follow: true }).pipe( + Stream.takeUntil((entry) => entry.message === "startup-late"), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + const caller = yield* runtime + .start(keyFor("startup-drain"), workload("startup-drain")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(startupOutput); + // The startup parent has exited, but its descendant intentionally keeps + // the inherited stdout pipe open until this observable release signal. + const writer = spawnSync(process.execPath, [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(releasePath)}, "x")`, + ]); + expect(writer.status).toBe(0); + const ready = yield* Fiber.join(caller); + expect(ready.state).toBe("ready"); + yield* runtime.remove(keyFor("startup-drain")); + }), + ), + ); + // oxlint-enable effecttsgo/prefer-schema-over-json + + it.live("interrupts a startup process through its exact child scope", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-interrupt-" }); + const pidPath = path.join(root, "pid"); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const started = yield* logStore + .stream({ follow: true }) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkChild({ startImmediately: true })); + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [ + { + executable: process.execPath, + args: [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(pidPath)},String(process.pid)); process.stdout.write("started\\n"); setInterval(()=>{},1000)`, + ], + }, + ], + main: fixtureProcess("never-main"), + }), + logStore, + waitForReadiness: () => Effect.void, + }); + const key = keyFor("startup-interrupted"); + const caller = yield* runtime + .start(key, workload("startup-interrupted")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(started); + yield* runtime.stop(key); + yield* Fiber.interrupt(caller); + const pid = Number.parseInt(yield* fs.readFileString(pidPath), 10); + expect(Number.isSafeInteger(pid)).toBe(true); + const processState = yield* Effect.sync(() => { + const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { + encoding: "utf8", + }); + return result.stdout.trim(); + }); + expect(processState === "" || processState.startsWith("Z")).toBe(true); + }), + ), + ); + it.live("marks a ready workload failed when log persistence fails afterward", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 1093b5eff2..abd3cb724b 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -212,6 +212,81 @@ describe("workload runtime catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it("resolves service-owned startup processes and slim container command contracts", () => { + const root = "/tmp/slim-artifact"; + const auth = planned("auth:auth"); + expect(runtimeSpecFor(auth)?.nativeStartupProcesses(root, state, auth, 9999)).toEqual([ + { executable: `${root}/bin/auth`, args: ["migrate"], cwd: root }, + ]); + const storage = planned("storage:storage"); + expect(runtimeSpecFor(storage)?.nativeStartupProcesses(root, state, storage, 5000)).toEqual([ + { + executable: `${root}/node/bin/node`, + args: [`${root}/app/dist/scripts/migrate-call.js`], + cwd: `${root}/app`, + }, + ]); + const realtime = planned("realtime:realtime"); + expect(runtimeSpecFor(realtime)?.nativeStartupProcesses(root, state, realtime, 4000)).toEqual([ + { executable: `${root}/bin/migrate`, args: [], cwd: root }, + ]); + const analytics = planned("analytics:analytics"); + expect(runtimeSpecFor(analytics)?.nativeStartupProcesses(root, state, analytics, 4000)).toEqual( + [ + { + executable: `${root}/bin/logflare`, + args: ["eval", "Logflare.Release.migrate"], + cwd: root, + }, + ], + ); + const pooler = planned("pooler:pooler"); + expect(runtimeSpecFor(pooler)?.nativeStartupProcesses(root, state, pooler, 6543)).toEqual([ + { executable: `${root}/bin/migrate`, args: [], cwd: root }, + ]); + const tenantInput = { pooler: { tenantPath: "/tmp/pooler-tenant.exs" } }; + const tenantStartup = runtimeSpecFor(pooler)?.nativeStartupProcesses( + root, + state, + pooler, + 6543, + tenantInput, + ); + expect(tenantStartup).toHaveLength(2); + expect(tenantStartup?.[1]).toEqual({ + executable: "/bin/sh", + args: ["-c", `${root}/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"`], + cwd: root, + env: { SUPABASE_POOLER_TENANT_PATH: "/tmp/pooler-tenant.exs" }, + }); + expect(tenantStartup?.[1]?.args.join(" ")).not.toContain("secret"); + + expect(containerResolutionFor(state, auth)?.command).toEqual([ + "/bin/sh", + "-c", + "gotrue migrate && exec gotrue", + ]); + expect(containerResolutionFor(state, storage)?.command).toEqual([ + "/usr/bin/sh", + "-c", + "node dist/scripts/migrate-call.js && exec node dist/start/server.js", + ]); + expect(containerResolutionFor(state, realtime)?.command).toEqual([]); + expect(containerResolutionFor(state, analytics)?.command).toEqual([]); + expect(containerResolutionFor(state, pooler)?.command).toEqual([]); + const poolerWithTenant = containerResolutionFor(state, pooler, tenantInput); + expect(poolerWithTenant?.command).toEqual([ + "/usr/bin/sh", + "-c", + '/app/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")" && exec /app/bin/server', + ]); + expect(poolerWithTenant?.mounts).toEqual([ + { source: "/tmp/pooler-tenant.exs", target: "/app/pooler_tenant.exs", readOnly: true }, + ]); + expect(poolerWithTenant?.env.SUPABASE_POOLER_TENANT_PATH).toBe("/app/pooler_tenant.exs"); + expect(poolerWithTenant?.command.join(" ")).not.toContain("secret"); + }); + it.live( "consumes nested capability settings and separates native/container database endpoints", () => From ff02f4181ede2f637e88050148ccedd7a54ac162 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 16:14:32 +0200 Subject: [PATCH 067/174] feat(stack): resolve runtime owner inputs --- .../stack/src/runtime/RuntimeInputOwner.ts | 520 ++++++++++++++++++ .../runtime-input-owner.integration.test.ts | 393 +++++++++++++ packages/stack/src/state/SecretStore.ts | 106 +++- 3 files changed, 1011 insertions(+), 8 deletions(-) create mode 100644 packages/stack/src/runtime/RuntimeInputOwner.ts create mode 100644 packages/stack/src/runtime/runtime-input-owner.integration.test.ts diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts new file mode 100644 index 0000000000..e4f43343a3 --- /dev/null +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -0,0 +1,520 @@ +import { Crypto, Effect, FileSystem, Path, PlatformError, Schema } from "effect"; +import type { WorkloadRuntimeKind } from "./WorkloadRuntimeSpec.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import type { StackId } from "../public/StackId.ts"; +import { StackPreparationError } from "../public/Errors.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; +import { + resolveSigningKeyMaterial, + type ResolvedSigningKeyMaterial, +} from "../state/SecretStore.ts"; +import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; + +/** A parsed JSON document fetched by the owner for OIDC discovery. */ +export type RuntimeJsonFetcher = (url: string) => Effect.Effect; + +export interface RuntimeAuthTemplate { + /** Stable URL id used by GoTrue's mailer template setting. */ + readonly id: string; + /** User-configured project-relative path. */ + readonly path: string; + /** Canonical host path, retained for live gateway serving. */ + readonly canonicalPath: string; + /** File extension including the dot, when one is present. */ + readonly extension: string; +} + +export interface RuntimeInputMaterial { + readonly auth?: Readonly<{ + readonly jwtKeys?: string; + readonly jwks?: string; + readonly templates?: ReadonlyArray; + }>; + readonly analytics?: Readonly<{ readonly gcpJwtPath?: string }>; + readonly pooler?: Readonly<{ readonly tenantPath?: string }>; + readonly functions?: Readonly<{ readonly secrets: Readonly> }>; +} + +export interface RuntimeInputOwner { + /** Resolves all stack-owned inputs needed before a workload is created. */ + readonly resolve: ( + state: PersistedStackState, + generation: number, + runtime: WorkloadRuntimeKind, + ) => Effect.Effect; + /** Resolves one configured project-relative regular file without copying it. */ + readonly resolveProjectFile: ( + state: PersistedStackState, + configuredPath: string, + ) => Effect.Effect; + readonly resolveAuthTemplates: ( + state: PersistedStackState, + ) => Effect.Effect, StackPreparationError>; + readonly cleanupGeneration: (generation: number) => Effect.Effect; + readonly cleanupAll: Effect.Effect; +} + +export interface RuntimeInputOwnerOptions { + readonly stateRoot: string; + readonly stackId: StackId; + /** Injected OIDC JSON fetcher; no network service is hidden in this owner. */ + readonly fetchJson?: RuntimeJsonFetcher; +} + +const failure = (message: string, fields: Readonly> = {}) => + new StackPreparationError({ message, ...fields }); + +const mapFile = ( + target: string, + operation: string, + effect: Effect.Effect, +): Effect.Effect => + effect.pipe(Effect.mapError(() => failure(`Unable to ${operation}`, { path: target }))); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const settingValue = (state: PersistedStackState, value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") + return String(value); + if (Array.isArray(value)) return value.map((entry) => settingValue(state, entry)).join(","); + if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) + return state.secrets[value.slot]?.value ?? ""; + return JSON.stringify(value) ?? ""; +}; + +type CapabilitySettingName = keyof NonNullable["capabilities"]; + +const settingsFor = (state: PersistedStackState, capability: CapabilitySettingName): unknown => + state.definition?.capabilities[capability].settings; + +const invalidGeneration = (generation: number): boolean => + !Number.isSafeInteger(generation) || generation < 0; + +const finiteSetting = (value: string, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const relativeEscape = (path: Path.Path, root: string, candidate: string): boolean => { + const relative = path.relative(root, candidate); + return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); +}; + +const absoluteForPlatform = (path: Path.Path, value: string): boolean => + path.isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("\\\\"); + +const extensionFor = (configuredPath: string): string => { + const file = configuredPath.slice( + Math.max(configuredPath.lastIndexOf("/"), configuredPath.lastIndexOf("\\")) + 1, + ); + const dot = file.lastIndexOf("."); + return dot <= 0 ? "" : file.slice(dot); +}; + +const publicRemoteJwks = Schema.Struct({ keys: Schema.Array(Schema.Unknown) }); +const oidcDiscovery = Schema.Struct({ jwks_uri: Schema.String }); + +const decodeJson = (value: unknown): Effect.Effect => + typeof value === "string" + ? Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(value) + : Effect.succeed(value); + +const base64UrlEncode = (bytes: Uint8Array): string => { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let output = ""; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const second = bytes[index + 1]; + const third = bytes[index + 2]; + output += alphabet[first >> 2] ?? ""; + output += alphabet[((first & 3) << 4) | ((second ?? 0) >> 4)] ?? ""; + if (second !== undefined) output += alphabet[((second & 15) << 2) | ((third ?? 0) >> 6)] ?? ""; + if (third !== undefined) output += alphabet[third & 63] ?? ""; + } + return output; +}; + +const symmetricJwk = (secret: string): Readonly> => ({ + kty: "oct", + k: base64UrlEncode(new TextEncoder().encode(secret)), +}); + +const elixirString = (value: string): string => { + let output = '"'; + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (character === "\\") output += "\\\\"; + else if (character === '"') output += '\\"'; + else if (character === "\n") output += "\\n"; + else if (character === "\r") output += "\\r"; + else if (character === "\t") output += "\\t"; + else if (code < 0x20) output += `\\x${code.toString(16).padStart(2, "0")}`; + else output += character; + } + return `${output}"`; +}; + +const poolerTenantScript = (input: { + readonly dbHost: string; + readonly dbPassword: string; + readonly poolMode: "transaction" | "session"; + readonly defaultPoolSize: number; + readonly maxClientConn: number; +}): string => `{: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" => "pooler-dev", + "db_host" => ${elixirString(input.dbHost)}, + "db_port" => 5432, + "db_database" => "postgres", + "require_user" => false, + "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", + "default_max_clients" => ${String(input.maxClientConn)}, + "default_pool_size" => ${String(input.defaultPoolSize)}, + "default_parameter_status" => %{"server_version" => version}, + "users" => [%{ + "db_user" => "pgbouncer", + "db_password" => ${elixirString(input.dbPassword)}, + "mode_type" => ${elixirString(input.poolMode)}, + "pool_size" => ${String(input.defaultPoolSize)}, + "is_manager" => true + }] +} + +if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + {:ok, _} = Supavisor.Tenants.create_tenant(params) +end +`; + +/** Resolves materialized Edge Runtime secrets to their caller-visible names. */ +export const resolveFunctionsEdgeRuntimeSecrets = ( + state: PersistedStackState, +): Effect.Effect>, StackPreparationError> => { + const settings = settingsFor(state, "functions"); + const edgeRuntime = + isRecord(settings) && isRecord(settings.edge_runtime) ? settings.edge_runtime : {}; + const configured = isRecord(edgeRuntime.secrets) ? edgeRuntime.secrets : {}; + const output: Record = {}; + for (const [name, raw] of Object.entries(configured)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) + return Effect.fail(failure("Functions Edge Runtime secret name is invalid", { name })); + if (name.startsWith("SUPABASE_") || name.startsWith("SUPABASE_INTERNAL_")) + return Effect.fail(failure("Functions Edge Runtime secret name is reserved", { name })); + const value = settingValue(state, raw); + // oxlint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/u.test(value)) + return Effect.fail(failure("Functions Edge Runtime secret value is invalid", { name })); + output[name] = value; + } + return Effect.succeed(output); +}; + +export const makeRuntimeInputOwner = ( + options: RuntimeInputOwnerOptions, +): Effect.Effect< + RuntimeInputOwner, + StackPreparationError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const stackPaths = yield* resolveStackPaths(options).pipe( + Effect.mapError((cause) => failure("Unable to resolve runtime input paths", { cause })), + ); + const poolerRoot = path.join(stackPaths.runtime, "inputs", "pooler"); + + const resolveProjectFile = ( + state: PersistedStackState, + configuredPath: string, + ): Effect.Effect => { + if (configuredPath.length === 0 || absoluteForPlatform(path, configuredPath)) + return Effect.fail(failure("Configured project file path must be relative")); + const root = path.resolve(state.identity.projectRoot); + const candidate = path.resolve(root, configuredPath); + if (relativeEscape(path, root, candidate)) + return Effect.fail(failure("Configured project file escapes the project root")); + return Effect.gen(function* () { + const canonicalRoot = yield* mapFile(root, "resolve project root", fs.realPath(root)); + const canonicalCandidate = yield* mapFile( + candidate, + "resolve configured project file", + fs.realPath(candidate), + ); + if (relativeEscape(path, canonicalRoot, canonicalCandidate)) + return yield* failure("Configured project file escapes the project root"); + const info = yield* mapFile( + canonicalCandidate, + "inspect configured project file", + fs.stat(canonicalCandidate), + ); + if (info.type !== "File") + return yield* failure("Configured project path must resolve to a regular file"); + return canonicalCandidate; + }); + }; + + const resolveAuthTemplates = ( + state: PersistedStackState, + ): Effect.Effect, StackPreparationError> => + Effect.gen(function* () { + const auth = settingsFor(state, "auth"); + const email = isRecord(auth) && isRecord(auth.email) ? auth.email : {}; + const result: RuntimeAuthTemplate[] = []; + const ids = new Set(); + const add = (id: string, raw: unknown): Effect.Effect => { + if (!isRecord(raw)) return Effect.void; + const configuredPath = settingValue(state, raw.content_path); + if (configuredPath.length === 0) return Effect.void; + if (ids.has(id)) return Effect.fail(failure("Duplicate Auth email template id", { id })); + return resolveProjectFile(state, configuredPath).pipe( + Effect.tap((canonicalPath) => + Effect.sync(() => { + ids.add(id); + result.push({ + id, + path: configuredPath, + canonicalPath, + extension: extensionFor(configuredPath), + }); + }), + ), + Effect.asVoid, + ); + }; + if (isRecord(email.template)) + for (const [name, value] of Object.entries(email.template)) yield* add(name, value); + if (isRecord(email.notification)) + for (const [name, value] of Object.entries(email.notification)) + if (isRecord(value) && value.enabled === true) + yield* add(`${name}_notification`, value); + return result; + }); + + const resolveRemoteKeys = ( + issuer: string, + ): Effect.Effect, StackPreparationError> => { + const fetchJson = options.fetchJson; + if (fetchJson === undefined) + return Effect.fail(failure("OIDC discovery requires an injected JSON fetcher")); + const discoveryUrl = `${issuer.replace(/\/+$/u, "")}/.well-known/openid-configuration`; + return fetchJson(discoveryUrl).pipe( + Effect.flatMap(decodeJson), + Effect.flatMap((value) => + Schema.decodeUnknownEffect(oidcDiscovery)(value).pipe( + Effect.mapError(() => + failure("OIDC discovery response is invalid", { url: discoveryUrl }), + ), + ), + ), + Effect.flatMap((discovery) => { + const jwksUrl = discovery.jwks_uri.trim(); + if (jwksUrl.length === 0) + return Effect.fail( + failure("OIDC discovery response does not expose jwks_uri", { url: discoveryUrl }), + ); + return fetchJson(jwksUrl).pipe( + Effect.flatMap(decodeJson), + Effect.flatMap((value) => + Schema.decodeUnknownEffect(publicRemoteJwks)(value).pipe( + Effect.mapError(() => failure("OIDC JWKS response is invalid", { url: jwksUrl })), + ), + ), + Effect.flatMap((jwks) => + jwks.keys.length === 0 + ? Effect.fail(failure("OIDC JWKS response contains no keys", { url: jwksUrl })) + : Effect.succeed(jwks.keys), + ), + ); + }), + Effect.mapError((error) => + error instanceof StackPreparationError + ? error + : failure("OIDC discovery request failed", { url: discoveryUrl }), + ), + ); + }; + + const resolveAuth = ( + state: PersistedStackState, + ): Effect.Effect, StackPreparationError> => + Effect.gen(function* () { + const signing = state.definition?.security.jwt.signing; + let local: ResolvedSigningKeyMaterial | undefined; + if (signing?.kind === "jwks-file") { + local = yield* resolveSigningKeyMaterial({ + kind: "jwks-file", + projectRoot: state.identity.projectRoot, + path: signing.path, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError(() => + failure("Unable to resolve Auth signing keys", { path: signing.path }), + ), + ); + } + const thirdParty = resolveThirdPartyIssuer(settingsFor(state, "auth")); + if (!thirdParty.ok) + return yield* failure("Unable to resolve Auth third-party issuer", { + provider: thirdParty.provider, + }); + const remote = + thirdParty.value === undefined ? [] : yield* resolveRemoteKeys(thirdParty.value.issuer); + const localPublic = local?.publicKeys ?? []; + const symmetric = + signing?.kind === "jwks-file" + ? [] + : (() => { + const secret = state.secrets["secret:auth.settings.jwt_secret"]?.value ?? ""; + return secret.length === 0 ? [] : [symmetricJwk(secret)]; + })(); + if (signing?.kind !== "jwks-file" && symmetric.length === 0) + return yield* failure("Persisted Auth JWT secret is missing"); + const publicKeys = [...remote, ...localPublic, ...symmetric]; + const templates = + state.definition?.capabilities.auth.enabled === false + ? [] + : yield* resolveAuthTemplates(state); + return { + ...(local === undefined ? {} : { jwtKeys: local.privateKeysJson }), + jwks: JSON.stringify({ keys: publicKeys }), + ...(templates.length === 0 ? {} : { templates }), + }; + }); + + const writePoolerTenant = ( + state: PersistedStackState, + generation: number, + runtime: WorkloadRuntimeKind, + ): Effect.Effect => { + if (invalidGeneration(generation)) return Effect.fail(failure("Invalid pooler generation")); + const password = state.secrets["secret:database.internal.password"]?.value ?? ""; + if (password.length === 0) + return Effect.fail(failure("Persisted database secret is missing")); + const settings = settingsFor(state, "pooler"); + const poolMode = settingValue(state, isRecord(settings) ? settings.pool_mode : undefined); + const mode: "transaction" | "session" = poolMode === "session" ? "session" : "transaction"; + const defaultPoolSize = finiteSetting( + settingValue(state, isRecord(settings) ? settings.default_pool_size : undefined), + 20, + ); + const maxClientConn = finiteSetting( + settingValue(state, isRecord(settings) ? settings.max_client_conn : undefined), + 100, + ); + const content = poolerTenantScript({ + dbHost: runtime === "container" ? "supabase-database" : "127.0.0.1", + dbPassword: password, + poolMode: mode, + defaultPoolSize, + maxClientConn, + }); + const generationRoot = path.join(poolerRoot, String(generation)); + const target = path.join(generationRoot, "pooler_tenant.exs"); + return Effect.gen(function* () { + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError(() => failure("Unable to allocate pooler tenant file")), + ); + const temporary = path.join(generationRoot, `.pooler_tenant.exs.${token}.tmp`); + return yield* Effect.gen(function* () { + yield* mapFile( + generationRoot, + "create pooler tenant directory", + fs.makeDirectory(generationRoot, { recursive: true, mode: 0o700 }), + ); + yield* mapFile( + generationRoot, + "secure pooler tenant directory", + fs.chmod(generationRoot, 0o700), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* mapFile( + temporary, + "create pooler tenant file", + fs.open(temporary, { flag: "w", mode: 0o600 }), + ); + yield* mapFile( + temporary, + "write pooler tenant file", + file.writeAll(new TextEncoder().encode(content)), + ); + yield* mapFile(temporary, "sync pooler tenant file", file.sync); + }), + ); + yield* mapFile(temporary, "secure pooler tenant file", fs.chmod(temporary, 0o600)); + yield* mapFile(target, "publish pooler tenant file", fs.rename(temporary, target)); + yield* mapFile(target, "secure published pooler tenant file", fs.chmod(target, 0o600)); + return target; + }).pipe( + Effect.ensuring( + fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + ); + }); + }; + + const cleanupGeneration = (generation: number): Effect.Effect => + invalidGeneration(generation) + ? Effect.fail(failure("Invalid pooler generation")) + : mapFile( + path.join(poolerRoot, String(generation)), + "clean pooler tenant generation", + fs.remove(path.join(poolerRoot, String(generation)), { recursive: true, force: true }), + ); + const cleanupAll = mapFile( + poolerRoot, + "clean pooler tenant files", + fs.remove(poolerRoot, { recursive: true, force: true }), + ); + + const resolve = ( + state: PersistedStackState, + generation: number, + runtime: WorkloadRuntimeKind, + ): Effect.Effect => + Effect.gen(function* () { + const auth = yield* resolveAuth(state); + const analyticsSettings = settingsFor(state, "analytics"); + const gcpPath = isRecord(analyticsSettings) + ? settingValue(state, analyticsSettings.gcp_jwt_path) + : ""; + const analytics = + state.definition?.capabilities.analytics.enabled === false || gcpPath.length === 0 + ? undefined + : { gcpJwtPath: yield* resolveProjectFile(state, gcpPath) }; + const pooler = state.definition?.capabilities.pooler.enabled + ? { tenantPath: yield* writePoolerTenant(state, generation, runtime) } + : undefined; + const functions = + state.definition?.capabilities.functions.enabled === false + ? undefined + : { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state) }; + return { + auth, + ...(analytics === undefined ? {} : { analytics }), + ...(pooler === undefined ? {} : { pooler }), + ...(functions === undefined ? {} : { functions }), + }; + }); + + return { + resolve, + resolveProjectFile, + resolveAuthTemplates, + cleanupGeneration, + cleanupAll, + }; + }); diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts new file mode 100644 index 0000000000..230d2877db --- /dev/null +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -0,0 +1,393 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted } from "effect"; +import { generateKeyPairSync } from "node:crypto"; +import { compileStack, type CompiledStack } from "../model/Compiler.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import type { StackRuntime } from "../public/Runtime.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; +import { resolveSecrets } from "../state/SecretStore.ts"; +import { makeRuntimeInputOwner } from "./RuntimeInputOwner.ts"; + +const stackId = StackIdSchema.make("f".repeat(64)); + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +const identityFor = (projectRoot: string): PersistedStackState["identity"] => ({ + stackId, + projectRoot, + checkoutRoot: projectRoot, + workspaceId: projectRoot, + checkoutId: projectRoot, + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "runtime-input-owner", +}); + +const stateFor = ( + root: string, + compiled: CompiledStack, + secrets: PersistedStackState["secrets"], + runtime: StackRuntime = { kind: "native" }, +): PersistedStackState => ({ + format: "supabase-stack-state-v1", + identity: identityFor(root), + runtime, + desiredGeneration: 1, + portsGeneration: null, + desiredLifecycle: "stopped", + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + ports: [], + privatePorts: [], + secrets, +}); + +const compiledState = (root: string, config: Parameters[0]["config"] = {}) => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + return stateFor(root, compiled, resolved.persisted); + }); + +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + +describe("runtime input owner", () => { + it.live("resolves contained regular files and rejects escapes, symlinks, and directories", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-files-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-outside-" }); + yield* fs.makeDirectory(path.join(root, "nested"), { recursive: true }); + yield* fs.writeFileString(path.join(root, "nested", "config.json"), "{}"); + yield* fs.writeFileString(path.join(outside, "secret.json"), "secret"); + yield* fs.symlink(path.join(outside, "secret.json"), path.join(root, "linked.json")); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const state = yield* compiledState(root); + const canonicalRoot = yield* fs.realPath(root); + expect(yield* owner.resolveProjectFile(state, "nested/config.json")).toBe( + path.join(canonicalRoot, "nested", "config.json"), + ); + for (const configured of ["/etc/passwd", "../outside.json", "linked.json"] as const) { + const failed = yield* owner.resolveProjectFile(state, configured).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + } + const directory = yield* owner.resolveProjectFile(state, "nested").pipe(Effect.exit); + expect(Exit.isFailure(directory)).toBe(true); + }), + ), + ); + + it.live("returns all private local keys and a public-only JWKS", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-jwks-" }); + const first = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ + format: "jwk", + }); + const second = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ + format: "jwk", + }); + yield* fs.writeFileString( + path.join(root, "keys.json"), + JSON.stringify([ + { ...first, alg: "ES256", kid: "ec-key" }, + { ...second, alg: "RS256", kid: "rsa-key" }, + ]), + ); + const state = yield* compiledState(root, { + security: { jwt: { signing: { kind: "jwks-file", path: "keys.json" } } }, + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const material = yield* owner.resolve(state, 1, "native"); + expect(JSON.parse(material.auth?.jwtKeys ?? "[]")).toHaveLength(2); + const jwks = JSON.parse(material.auth?.jwks ?? "{}"); + expect(jwks.keys).toHaveLength(2); + expect(jwks.keys.every((key: Record) => !Object.hasOwn(key, "d"))).toBe( + true, + ); + }), + ), + ); + + it.live("merges injected third-party keys with the canonical symmetric key", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-oidc-" }); + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { firebase: { enabled: true, project_id: "demo" } }, + }, + }, + }, + }); + const requested: string[] = []; + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: (url) => + Effect.sync(() => { + requested.push(url); + return url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; + }), + }); + const material = yield* owner.resolve(state, 2, "native"); + expect(requested).toEqual([ + "https://securetoken.google.com/demo/.well-known/openid-configuration", + "https://issuer.example/keys", + ]); + expect(JSON.parse(material.auth?.jwks ?? "{}").keys).toHaveLength(2); + }), + ), + ); + + it.live("publishes the persisted symmetric JWT secret as an oct JWK", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-symmetric-", + }); + const state = yield* compiledState(root, { + capabilities: { + auth: { settings: { jwt_secret: Redacted.make("symmetric-secret") } }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const jwks = JSON.parse((yield* owner.resolve(state, 1, "native")).auth?.jwks ?? "{}"); + expect(jwks.keys).toEqual([{ kty: "oct", k: "c3ltbWV0cmljLXNlY3JldA" }]); + }), + ), + ); + + it.live("fails closed on malformed or empty third-party OIDC responses", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-oidc-invalid-" }); + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: (url) => + Effect.succeed( + url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [] }, + ), + }); + const failed = yield* owner.resolve(state, 1, "native").pipe(Effect.exit); + expect(errorOf(failed)?.message).toContain("contains no keys"); + }), + ), + ); + + it.live("writes a generation-scoped owner-only Pooler tenant file and cleans it", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-" }); + const state = yield* compiledState(root, { + capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const first = yield* owner.resolve(state, 3, "native"); + const second = yield* owner.resolve(state, 3, "native"); + expect(second.pooler?.tenantPath).toBe(first.pooler?.tenantPath); + const tenant = first.pooler?.tenantPath; + expect(tenant).toBeDefined(); + expect((yield* fs.stat(tenant!)).mode & 0o777).toBe(0o600); + const content = yield* fs.readFileString(tenant!); + expect(content).toContain('"db_host" => "127.0.0.1"'); + expect(content).toContain('"external_id" => "pooler-dev"'); + expect(content).not.toContain('"db_password" => ""'); + yield* owner.cleanupGeneration(3); + expect(yield* fs.exists(path.dirname(tenant!))).toBe(false); + }), + ), + ); + + it.live("resolves the configured Analytics service-account file without copying it", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-analytics-" }); + yield* fs.writeFileString(path.join(root, "gcp.json"), "{}"); + const state = yield* compiledState(root, { + capabilities: { analytics: { settings: { gcp_jwt_path: "gcp.json" } } }, + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const material = yield* owner.resolve(state, 1, "native"); + expect(material.analytics?.gcpJwtPath).toBe( + path.join(yield* fs.realPath(root), "gcp.json"), + ); + }), + ), + ); + + it.live("does not resolve disabled capability inputs", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-disabled-", + }); + const base = yield* compiledState(root); + if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); + const definition = base.definition; + const state: PersistedStackState = { + ...base, + definition: { + ...definition, + capabilities: { + ...definition.capabilities, + analytics: { + ...definition.capabilities.analytics, + enabled: false, + settings: { + ...definition.capabilities.analytics.settings, + gcp_jwt_path: "missing.json", + }, + }, + functions: { + ...definition.capabilities.functions, + enabled: false, + settings: { + ...definition.capabilities.functions.settings, + edge_runtime: { + policy: null, + deno_version: null, + secrets: { SUPABASE_RESERVED: { slot: "missing" } }, + }, + }, + }, + }, + }, + }; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const material = yield* owner.resolve(state, 1, "native"); + expect(material.analytics).toBeUndefined(); + expect(material.functions).toBeUndefined(); + }), + ), + ); + + it.live("returns live Auth template mappings and rejects URL id collisions", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-templates-" }); + yield* fs.writeFileString(path.join(root, "confirm.html"), "confirm"); + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + email: { + template: { confirm: { content_path: "confirm.html" } }, + }, + }, + }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const canonicalRoot = yield* fs.realPath(root); + const templates = yield* owner.resolveAuthTemplates(state); + expect(templates).toEqual([ + { + id: "confirm", + path: "confirm.html", + canonicalPath: path.join(canonicalRoot, "confirm.html"), + extension: ".html", + }, + ]); + const collisionState = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + email: { + template: { welcome_notification: { content_path: "confirm.html" } }, + notification: { welcome: { enabled: true, content_path: "confirm.html" } }, + }, + }, + }, + }, + }); + const failed = yield* owner.resolveAuthTemplates(collisionState).pipe(Effect.exit); + expect(errorOf(failed)?.message).toContain("Duplicate Auth email template id"); + }), + ), + ); + + it.live("validates Functions Edge Runtime secrets and returns real names", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-functions-", + }); + const state = yield* compiledState(root, { + capabilities: { + functions: { + settings: { + edge_runtime: { secrets: { API_TOKEN: Redacted.make("actual-value") } }, + }, + }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const material = yield* owner.resolve(state, 1, "native"); + expect(material.functions?.secrets).toEqual({ API_TOKEN: "actual-value" }); + const invalid = yield* compiledState(root, { + capabilities: { + functions: { + settings: { + edge_runtime: { secrets: { SUPABASE_TOKEN: Redacted.make("value") } }, + }, + }, + }, + }); + const failed = yield* owner.resolve(invalid, 1, "native").pipe(Effect.exit); + expect(errorOf(failed)?.message).toContain("secret name is reserved"); + + const control = yield* compiledState(root, { + capabilities: { + functions: { + settings: { + edge_runtime: { secrets: { API_TOKEN: Redacted.make("value\tinvalid") } }, + }, + }, + }, + }); + const controlFailure = yield* owner.resolve(control, 1, "native").pipe(Effect.exit); + expect(errorOf(controlFailure)?.message).toContain("secret value is invalid"); + }), + ), + ); +}); diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index 6ea83cbbf7..ec6d7662c9 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -88,6 +88,9 @@ const JwkSchema = Schema.Struct({ p: Schema.optionalKey(Schema.String), q: Schema.optionalKey(Schema.String), qi: Schema.optionalKey(Schema.String), + use: Schema.optionalKey(Schema.String), + key_ops: Schema.optionalKey(Schema.Array(Schema.String)), + ext: Schema.optionalKey(Schema.Boolean), x: Schema.optionalKey(Schema.String), y: Schema.optionalKey(Schema.String), }); @@ -143,11 +146,14 @@ const randomEncoded = ( ), ); -interface SigningJwk { +export interface SigningJwk { readonly kty: "EC" | "RSA"; readonly alg: "ES256" | "RS256"; readonly d: string; readonly kid?: string; + readonly use?: string; + readonly key_ops?: Array; + readonly ext?: boolean; readonly crv?: string; readonly x?: string; readonly y?: string; @@ -168,6 +174,23 @@ const stringField = (record: Record, key: string): string | und return value === undefined ? undefined : typeof value === "string" ? value : undefined; }; +const booleanField = (record: Record, key: string): boolean | undefined => { + const value = record[key]; + return value === undefined ? undefined : typeof value === "boolean" ? value : undefined; +}; + +const stringArrayField = ( + record: Record, + key: string, +): Array | undefined => { + const value = record[key]; + return value === undefined + ? undefined + : Array.isArray(value) && value.every((entry) => typeof entry === "string") + ? value + : undefined; +}; + const privateSigningJwk = (value: unknown): SigningJwk | undefined => { if (!isRecord(value)) return undefined; const kty = value.kty; @@ -175,12 +198,26 @@ const privateSigningJwk = (value: unknown): SigningJwk | undefined => { const d = stringField(value, "d"); if (typeof d !== "string") return undefined; const kid = stringField(value, "kid"); + const use = stringField(value, "use"); + const keyOps = stringArrayField(value, "key_ops"); + const ext = booleanField(value, "ext"); if (kty === "EC" && alg === "ES256") { const crv = stringField(value, "crv"); const x = stringField(value, "x"); const y = stringField(value, "y"); if (crv !== "P-256" || x === undefined || y === undefined) return undefined; - return { kty, alg, d, ...(kid === undefined ? {} : { kid }), crv, x, y }; + return { + kty, + alg, + d, + ...(kid === undefined ? {} : { kid }), + ...(use === undefined ? {} : { use }), + ...(keyOps === undefined ? {} : { key_ops: keyOps }), + ...(ext === undefined ? {} : { ext }), + crv, + x, + y, + }; } if (kty === "RSA" && alg === "RS256") { const n = stringField(value, "n"); @@ -196,6 +233,9 @@ const privateSigningJwk = (value: unknown): SigningJwk | undefined => { alg, d, ...(kid === undefined ? {} : { kid }), + ...(use === undefined ? {} : { use }), + ...(keyOps === undefined ? {} : { key_ops: keyOps }), + ...(ext === undefined ? {} : { ext }), n, e, p, @@ -211,9 +251,13 @@ const privateSigningJwk = (value: unknown): SigningJwk | undefined => { const invalidSigningMaterial = () => new InvalidJwtSigningMaterialError({ message: "Unable to resolve JWT signing material" }); -const readSigningJwk = ( +const readSigningJwks = ( signing: Extract, -): Effect.Effect => +): Effect.Effect< + ReadonlyArray, + InvalidJwtSigningMaterialError, + FileSystem.FileSystem | Path.Path +> => Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -242,13 +286,59 @@ const readSigningJwk = ( Effect.mapError(() => invalidSigningMaterial()), ); const entries = Array.isArray(parsed) ? parsed : [parsed]; - for (const entry of entries) { + const keys = entries.flatMap((entry) => { const jwk = privateSigningJwk(entry); - if (jwk !== undefined) return jwk; - } - return yield* invalidSigningMaterial(); + return jwk === undefined ? [] : [jwk]; + }); + return keys.length === 0 ? yield* invalidSigningMaterial() : keys; }); +const readSigningJwk = ( + signing: Extract, +): Effect.Effect => + readSigningJwks(signing).pipe(Effect.map((keys) => keys[0]!)); + +export interface ResolvedSigningKeyMaterial { + /** The private signing keys as a JSON array for GoTrue's signing-key input. */ + readonly privateKeysJson: string; + /** Public-only JWKs suitable for JWT verification. */ + readonly publicKeys: ReadonlyArray>>; + /** Public-only JWKs wrapped in a JWKS document. */ + readonly publicJwksJson: string; +} + +const publicSigningJwk = (key: SigningJwk): Readonly> => ({ + kty: key.kty, + ...(key.kid === undefined ? {} : { kid: key.kid }), + ...(key.use === undefined ? {} : { use: key.use }), + ...(() => { + const operations = key.key_ops?.filter((operation) => operation === "verify"); + return operations === undefined || operations.length === 0 ? {} : { key_ops: operations }; + })(), + alg: key.alg, + ...(key.ext === undefined ? {} : { ext: key.ext }), + ...(key.kty === "RSA" ? { n: key.n, e: key.e } : { crv: key.crv, x: key.x, y: key.y }), +}); + +/** Reads every valid private key from a contained JWKS file and derives public material. */ +export const resolveSigningKeyMaterial = ( + signing: Extract, +): Effect.Effect< + ResolvedSigningKeyMaterial, + InvalidJwtSigningMaterialError, + FileSystem.FileSystem | Path.Path +> => + readSigningJwks(signing).pipe( + Effect.map((keys) => { + const publicKeys = keys.map(publicSigningJwk); + return { + privateKeysJson: JSON.stringify(keys), + publicKeys, + publicJwksJson: JSON.stringify({ keys: publicKeys }), + }; + }), + ); + const signWithJwk = ( jwk: SigningJwk, payload: string, From 81edf996930900f0d030e62a5430e0b61d3e447c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 16:16:22 +0200 Subject: [PATCH 068/174] refactor(stack): require native process plans --- packages/stack/src/runtime/NativeRuntime.ts | 14 ++------ .../native-runtime.integration.test.ts | 36 +++++++++++-------- .../runtime-recovery.integration.test.ts | 17 ++++++--- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index 29d3e5c1a7..7d667e105f 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -38,7 +38,7 @@ export interface NativeRuntimeOptions { readonly resolveProcess: ( key: RuntimeWorkloadKey, workload: NativeWorkload, - ) => Effect.Effect; + ) => Effect.Effect; /** Private readiness is resolved by the owning Supervisor/gateway seam. */ readonly waitForReadiness: ( key: RuntimeWorkloadKey, @@ -155,14 +155,6 @@ const observeOutput = ( appendLines(logStore, resource, stream, bytes), ).pipe(Effect.andThen(flushLines(logStore, resource, stream))); -const nativeProcessPlan = (resolved: NativeProcessSpec | NativeProcessPlan): NativeProcessPlan => - "main" in resolved - ? resolved - : { - startup: [], - main: resolved, - }; - /** Creates a Supervisor-owned native runtime with exact process identity fencing. */ export const makeNativeRuntime = ( options: NativeRuntimeOptions, @@ -365,9 +357,7 @@ export const makeNativeRuntime = ( options.resolveLauncher === undefined ? undefined : yield* options.resolveLauncher(key, workload); - const resolved = yield* options - .resolveProcess(key, workload) - .pipe(Effect.map(nativeProcessPlan)); + const resolved = yield* options.resolveProcess(key, workload); for (const startup of resolved.startup) yield* runStartupProcess(resource, startup, launcher); const process = yield* spawnNativeProcess(resolved.main, launcher).pipe( diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index aee8652020..eef1d0b2c3 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -56,6 +56,8 @@ const oneShotProcess = (stdout: string, exitCode = 0) => ({ ], }); +const processPlan = (main: A) => ({ startup: [], main }); + const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -68,7 +70,7 @@ describe("native runtime", () => { resolveProcess: () => Effect.sync(() => { resolved = true; - return fixtureProcess("unexpected"); + return processPlan(fixtureProcess("unexpected")); }), waitForReadiness: () => Effect.void, }); @@ -97,7 +99,8 @@ describe("native runtime", () => { }); let startedProcess: NativeProcess | undefined; const runtime = yield* makeNativeRuntime({ - resolveProcess: (_key, _entry) => Effect.succeed(fixtureProcess("secret-value")), + resolveProcess: (_key, _entry) => + Effect.succeed(processPlan(fixtureProcess("secret-value"))), logStore, waitForReadiness: (_key, _workload, process) => Effect.sync(() => { @@ -129,7 +132,7 @@ describe("native runtime", () => { withPlatform( Effect.gen(function* () { const runtime = yield* makeNativeRuntime({ - resolveProcess: () => Effect.succeed(fixtureProcess("fails")), + resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("fails"))), waitForReadiness: (key) => Effect.fail( new RuntimeDriverError({ @@ -152,10 +155,12 @@ describe("native runtime", () => { const readiness = yield* Deferred.make(); const runtime = yield* makeNativeRuntime({ resolveProcess: () => - Effect.succeed({ - executable: process.execPath, - args: ["-e", "process.stderr.write('native failed\\n'); process.exit(3)"], - }), + Effect.succeed( + processPlan({ + executable: process.execPath, + args: ["-e", "process.stderr.write('native failed\\n'); process.exit(3)"], + }), + ), waitForReadiness: () => Deferred.await(readiness), }); const result = yield* runtime @@ -175,7 +180,7 @@ describe("native runtime", () => { resolveProcess: () => Effect.sync(() => { launches += 1; - return fixtureProcess(`restart-${launches}`); + return processPlan(fixtureProcess(`restart-${launches}`)); }), waitForReadiness: () => Effect.void, }); @@ -200,7 +205,7 @@ describe("native runtime", () => { resolveProcess: () => Effect.sync(() => { launches += 1; - return fixtureProcess("shared-start"); + return processPlan(fixtureProcess("shared-start")); }).pipe(Effect.tap(() => Deferred.succeed(spawned, undefined))), waitForReadiness: () => Deferred.await(readiness), }); @@ -234,7 +239,7 @@ describe("native runtime", () => { stream: () => Stream.empty, }; const runtime = yield* makeNativeRuntime({ - resolveProcess: () => Effect.succeed(fixtureProcess("log-failure")), + resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("log-failure"))), waitForReadiness: () => Deferred.await(readiness), logStore, }); @@ -435,7 +440,8 @@ describe("native runtime", () => { stream: () => Stream.empty, }; const runtime = yield* makeNativeRuntime({ - resolveProcess: () => Effect.succeed(fixtureProcess("log-failure-after-ready")), + resolveProcess: () => + Effect.succeed(processPlan(fixtureProcess("log-failure-after-ready"))), waitForReadiness: () => Effect.void, logStore, }); @@ -463,7 +469,7 @@ describe("native runtime", () => { Effect.gen(function* () { let applied = false; const runtime = yield* makeNativeRuntime({ - resolveProcess: () => Effect.succeed(fixtureProcess("database")), + resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("database"))), waitForReadiness: () => Effect.void, bootstrapDatabase: () => Effect.sync(() => { @@ -484,7 +490,7 @@ describe("native runtime", () => { withPlatform( Effect.gen(function* () { const runtime = yield* makeNativeRuntime({ - resolveProcess: () => Effect.succeed(fixtureProcess("database")), + resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("database"))), waitForReadiness: () => Effect.void, }); const result = yield* runtime @@ -505,7 +511,7 @@ describe("native runtime", () => { workloadId: "database:failed-bootstrap", }); const runtime = yield* makeNativeRuntime({ - resolveProcess: () => Effect.succeed(fixtureProcess("database")), + resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("database"))), waitForReadiness: () => Effect.void, bootstrapDatabase: () => Effect.fail(bootstrapError), }); @@ -522,7 +528,7 @@ describe("native runtime", () => { withPlatform( Effect.gen(function* () { const runtime = yield* makeNativeRuntime({ - resolveProcess: (_key, entry) => Effect.succeed(fixtureProcess(entry.id)), + resolveProcess: (_key, entry) => Effect.succeed(processPlan(fixtureProcess(entry.id))), waitForReadiness: () => Effect.void, }); const first = keyFor("isolated-one"); diff --git a/packages/stack/src/runtime/runtime-recovery.integration.test.ts b/packages/stack/src/runtime/runtime-recovery.integration.test.ts index 07105a37fd..037c0bf5ce 100644 --- a/packages/stack/src/runtime/runtime-recovery.integration.test.ts +++ b/packages/stack/src/runtime/runtime-recovery.integration.test.ts @@ -44,6 +44,8 @@ const workload = (selected: PlannedWorkload["selected"] = artifact): PlannedWork specHash: key.specHash, }); +const processPlan = (main: A) => ({ startup: [], main }); + const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ runtime: { kind: "native" }, activation: { @@ -420,10 +422,12 @@ describe("runtime recovery", () => { Effect.gen(function* () { const runtime = yield* makeNativeRuntime({ resolveProcess: () => - Effect.succeed({ - executable: process.execPath, - args: ["-e", "setInterval(() => {}, 1000)"], - }), + Effect.succeed( + processPlan({ + executable: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + }), + ), waitForReadiness: () => Effect.void, }); const otherKey = { ...key, stackId: otherStackId }; @@ -447,7 +451,10 @@ describe("runtime recovery", () => { resolveProcess: () => Effect.sync(() => { launches += 1; - return { executable: process.execPath, args: ["-e", "setInterval(() => {}, 1000)"] }; + return processPlan({ + executable: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + }); }), waitForReadiness: () => Effect.void, }); From a77c0e01323a9c15a97fd991342383c8f05066ab Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 16:18:17 +0200 Subject: [PATCH 069/174] fix(stack): scope runtime input resolution --- .../stack/src/runtime/RuntimeInputOwner.ts | 34 +++++-- .../runtime-input-owner.integration.test.ts | 99 +++++++++++++++++++ 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index e4f43343a3..9696cb7dce 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -121,6 +121,16 @@ const decodeJson = (value: unknown): Effect.Effect => ? Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(value) : Effect.succeed(value); +/** Keeps OIDC diagnostics useful without echoing userinfo, query, or fragment data. */ +const safeUrlLabel = (value: string): string => { + try { + const url = new URL(value); + return `${url.origin}${url.pathname}`; + } catch { + return ""; + } +}; + const base64UrlEncode = (bytes: Uint8Array): string => { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; let output = ""; @@ -312,7 +322,7 @@ export const makeRuntimeInputOwner = ( Effect.flatMap((value) => Schema.decodeUnknownEffect(oidcDiscovery)(value).pipe( Effect.mapError(() => - failure("OIDC discovery response is invalid", { url: discoveryUrl }), + failure("OIDC discovery response is invalid", { url: safeUrlLabel(discoveryUrl) }), ), ), ), @@ -320,18 +330,24 @@ export const makeRuntimeInputOwner = ( const jwksUrl = discovery.jwks_uri.trim(); if (jwksUrl.length === 0) return Effect.fail( - failure("OIDC discovery response does not expose jwks_uri", { url: discoveryUrl }), + failure("OIDC discovery response does not expose jwks_uri", { + url: safeUrlLabel(discoveryUrl), + }), ); return fetchJson(jwksUrl).pipe( Effect.flatMap(decodeJson), Effect.flatMap((value) => Schema.decodeUnknownEffect(publicRemoteJwks)(value).pipe( - Effect.mapError(() => failure("OIDC JWKS response is invalid", { url: jwksUrl })), + Effect.mapError(() => + failure("OIDC JWKS response is invalid", { url: safeUrlLabel(jwksUrl) }), + ), ), ), Effect.flatMap((jwks) => jwks.keys.length === 0 - ? Effect.fail(failure("OIDC JWKS response contains no keys", { url: jwksUrl })) + ? Effect.fail( + failure("OIDC JWKS response contains no keys", { url: safeUrlLabel(jwksUrl) }), + ) : Effect.succeed(jwks.keys), ), ); @@ -339,7 +355,7 @@ export const makeRuntimeInputOwner = ( Effect.mapError((error) => error instanceof StackPreparationError ? error - : failure("OIDC discovery request failed", { url: discoveryUrl }), + : failure("OIDC discovery request failed", { url: safeUrlLabel(discoveryUrl) }), ), ); }; @@ -486,7 +502,11 @@ export const makeRuntimeInputOwner = ( runtime: WorkloadRuntimeKind, ): Effect.Effect => Effect.gen(function* () { - const auth = yield* resolveAuth(state); + const jwtConsumers = ["rest", "auth", "realtime", "storage", "functions"] as const; + const resolvesAuthMaterial = jwtConsumers.some( + (capability) => state.definition?.capabilities[capability].enabled !== false, + ); + const auth = resolvesAuthMaterial ? yield* resolveAuth(state) : undefined; const analyticsSettings = settingsFor(state, "analytics"); const gcpPath = isRecord(analyticsSettings) ? settingValue(state, analyticsSettings.gcp_jwt_path) @@ -503,7 +523,7 @@ export const makeRuntimeInputOwner = ( ? undefined : { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state) }; return { - auth, + ...(auth === undefined ? {} : { auth }), ...(analytics === undefined ? {} : { analytics }), ...(pooler === undefined ? {} : { pooler }), ...(functions === undefined ? {} : { functions }), diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 230d2877db..1778e3d8ed 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -178,6 +178,105 @@ describe("runtime input owner", () => { ), ); + it.live("skips JWT file and OIDC resolution when every JWT consumer is disabled", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-no-jwt-" }); + const base = yield* compiledState(root); + if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); + const definition = base.definition; + const state: PersistedStackState = { + ...base, + definition: { + ...definition, + capabilities: { + ...definition.capabilities, + rest: { ...definition.capabilities.rest, enabled: false }, + auth: { ...definition.capabilities.auth, enabled: false }, + realtime: { ...definition.capabilities.realtime, enabled: false }, + storage: { ...definition.capabilities.storage, enabled: false }, + functions: { ...definition.capabilities.functions, enabled: false }, + }, + security: { + ...definition.security, + jwt: { + ...definition.security.jwt, + signing: { kind: "jwks-file", path: "missing.json" }, + }, + }, + }, + }; + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: () => Effect.die("OIDC fetch should not run"), + }); + const material = yield* owner.resolve(state, 1, "native"); + expect(material.auth).toBeUndefined(); + }), + ), + ); + + it.live("resolves JWT material when Auth is disabled but Rest remains enabled", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-rest-jwt-", + }); + const base = yield* compiledState(root); + if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); + const state: PersistedStackState = { + ...base, + definition: { + ...base.definition, + capabilities: { + ...base.definition.capabilities, + auth: { ...base.definition.capabilities.auth, enabled: false }, + }, + }, + }; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const material = yield* owner.resolve(state, 1, "native"); + expect(material.auth?.jwks).toContain('"kty":"oct"'); + }), + ), + ); + + it.live("sanitizes OIDC URL labels in transport failures", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-oidc-secret-", + }); + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { + workos: { + enabled: true, + issuer_url: "https://issuer.example/tenant?token=secret-token#fragment", + }, + }, + }, + }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: () => Effect.fail(new Error("transport failure")), + }); + const failed = yield* owner.resolve(state, 1, "native").pipe(Effect.exit); + const error = errorOf(failed); + expect(error?.message).toContain("OIDC discovery request failed"); + expect(JSON.stringify(error)).not.toContain("secret-token"); + expect(JSON.stringify(error)).not.toContain("fragment"); + }), + ), + ); + it.live("fails closed on malformed or empty third-party OIDC responses", () => withPlatform( Effect.gen(function* () { From dd7973679429c06e876f71b4204e0a80ea386d76 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 29 Aug 2026 16:20:07 +0200 Subject: [PATCH 070/174] docs(stack): record reviewed runtime milestones --- ...26-08-28-stack-runtime-rewrite-progress.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index bee6dcf908..24978ae0a1 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -859,3 +859,45 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Fresh evidence: production-runtime, container-runtime, runtime-artifact, and functions-bootstrap integration suites pass (32 tests), and `@supabase/stack` type-checking passes. Complete container log attachment, public `credentials`/`prepare` handlers, and owner-resolved service file inputs remain later Task 13 slices. + +#### Milestone Fable review — committed rewrite components (2026-08-29) + +- Ran six independent Fable reviews over the committed model/state, ownership/lifecycle, artifact/runtime, + gateway/Functions, production/database/logging, and public facade/CLI milestones. Active implementation + work was paused so the review snapshot contained only committed code. +- Accepted findings were triaged against the design and reduced to demonstrated lifecycle ownership, + production lazy activation, explicit routed workloads, gateway/Functions correctness, runtime startup and + persistence contracts, compiler/state error invariants, facade boundaries, and minimal CLI coverage. +- Rejected speculative work includes database reset placeholders, cross-release RPC migrations before a + second released protocol exists, alternate artifact trust roots, archive/cache framework expansion, and + broad aesthetic refactors. The implementation remains greenfield and exact-use-case scoped. + +#### Task 13B2b5 — service startup contracts (2026-08-29) + +- Native workloads now use one strict process plan: sequential scoped service-owned startup one-shots, then + the long-lived process and readiness. Fast process exit does not cancel stdout/stderr draining, failures + prevent the main process, and interruption kills only the exact owned child. +- Auth, Storage, Realtime, Analytics, and Pooler use their audited slim-services migration/startup commands. + Auth and Storage container command chains compensate for their intentionally empty image entrypoints; + Realtime, Analytics, and Pooler retain their derived-image entrypoints. Mailpit uses only `MP_*` environment + configuration. +- Storage owns one `/mnt` volume and imgproxy mounts the same volume read-only with `/mnt` as its filesystem + root. Native and container persistence contracts now match. +- Initial commit `a0c127637`; fix commit `81edf9969` removed the obsolete direct-process resolver fallback. + Independent review then found no Critical or Important issue. Targeted native/workload/recovery integration + verification passed 37 tests with stack type-check, lint, formatting, and diff checks green. + +#### Task 13B2b6 — stack-owned runtime inputs (2026-08-29) + +- Added one Effect-native owner for contained project files, all local private signing JWKs and public JWKS, + injected third-party OIDC discovery, Auth template mappings, Analytics `gcp_jwt_path`, atomic owner-only + Pooler tenant generation files, and materialized Functions secret names/values. +- Project paths must be relative regular files whose canonical path remains under the canonical project root. + Functions rejects reserved `SUPABASE_*` names and all C0/DEL control characters. OIDC error labels remove + userinfo, query, and fragment data while network requests retain the configured URL. +- JWT material is resolved only when at least one of REST, Auth, Realtime, Storage, or Functions is enabled; + disabling Auth alone does not omit material still required by another consumer. +- Initial commit `ff02f4181`; fix commit `a77c0e013` closed the enabled-consumer and diagnostic-redaction review + findings. Independent re-review was clean; focused input/secret integration verification passed 27 tests + with stack type-check, lint, formatting, and diff checks green. Production and gateway wiring remain the + next slice. From 12188c4afd45b921e199c1b6800e361263043fb9 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 08:04:25 +0200 Subject: [PATCH 071/174] feat(stack): coordinate supervisor lifecycle ownership --- ...26-08-28-stack-runtime-rewrite-progress.md | 14 + packages/stack/src/control/ControlServer.ts | 74 ++- .../control-transport.integration.test.ts | 25 + .../stack/src/entrypoints/supervisor-node.ts | 10 +- packages/stack/src/supervisor/OwnerSession.ts | 2 + packages/stack/src/supervisor/Supervisor.ts | 475 +++++++++++++----- .../supervisor/supervisor.integration.test.ts | 200 +++++++- 7 files changed, 638 insertions(+), 162 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 24978ae0a1..45bac1656a 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -901,3 +901,17 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g findings. Independent re-review was clean; focused input/secret integration verification passed 27 tests with stack type-check, lint, formatting, and diff checks green. Production and gateway wiring remain the next slice. + +#### Task 13B2b7 — lifecycle/Fable correction (2026-08-31) + +- Supervisor lifecycle, activation, and recovery work now runs in one owner-scoped FiberSet behind a serialized + execution lane. Equivalent starts use an Effect-encoded, canonical SHA-256 request digest (including transient + redacted values only for identity); distinct secret values cannot join. Lazy activation commits success once per + generation, retries failures, and invalidates on a generation change. +- Supervisor construction no longer observes the runtime engine before owner control/readiness publication. The + entrypoint publishes the endpoint and fd3 readiness first, then runs deferred recovery; blocked or failed recovery + leaves the owner attachable and retains a supervisor error log. Control maintenance waiters may time out without + cancelling accepted stop work. +- Destroy shutdown is transport-owned: the exact owner session is signalled only after a successful destroy response + write. Focused supervisor/control/handle/lifecycle verification passed 69 integration tests with stack type-check, + Effect lint, formatting, and diff checks green. diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 36911a1382..52d953c56c 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -1,4 +1,15 @@ -import { Deferred, Effect, Exit, Fiber, FileSystem, Layer, Schema, Scope, Semaphore } from "effect"; +import { + Deferred, + Effect, + Exit, + Fiber, + FiberSet, + FileSystem, + Predicate, + Schema, + Scope, + Semaphore, +} from "effect"; import { NodeSocket, NodeSocketServer } from "@effect/platform-node"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; @@ -50,6 +61,8 @@ export interface ControlServerOptions extends ControlIdentity { readonly maintenanceHandlers: MaintenanceHandlers; /** Invoked only after the maintenance response has been written and the connection closed. */ readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; + /** Invoked only after a successful destroy RPC response has been written. */ + readonly onDestroyResponse?: () => Effect.Effect; readonly rpcHandlers: StackRpcHandlers; } @@ -82,6 +95,7 @@ const demuxSocket = ( socket: Socket.Socket, options: ControlServerOptions, maintenanceSemaphore: Semaphore.Semaphore, + completionFibers: FiberSet.FiberSet, ): Socket.Socket => { const expectedRelease = options.rpcRelease ?? STACK_RPC_RELEASE; type Writer = ( @@ -173,7 +187,11 @@ const demuxSocket = ( if (response.ok && options.onMaintenanceComplete !== undefined) { // The connection scope closes as soon as the close frame is sent; // completion belongs to the owner session and must outlive it. - yield* Effect.forkDetach(options.onMaintenanceComplete(request.value.op)); + yield* FiberSet.run( + completionFibers, + options.onMaintenanceComplete(request.value.op), + { startImmediately: true }, + ); } }); @@ -319,12 +337,13 @@ const wrappedServer = ( base: SocketServer.SocketServer["Service"], options: ControlServerOptions, maintenanceSemaphore: Semaphore.Semaphore, + completionFibers: FiberSet.FiberSet, ): SocketServer.SocketServer["Service"] => SocketServer.SocketServer.of({ address: base.address, run: (handler) => { return base.run((socket) => { - return handler(demuxSocket(socket, options, maintenanceSemaphore)); + return handler(demuxSocket(socket, options, maintenanceSemaphore, completionFibers)); }); }, }); @@ -357,26 +376,47 @@ export const startControlServer = ( ); } const maintenanceSemaphore = yield* Semaphore.make(MAINTENANCE_MAX_CONCURRENT_REQUESTS); - const server = wrappedServer(base, options, maintenanceSemaphore); - const transportLayer = Layer.mergeAll( - Layer.succeed(SocketServer.SocketServer, server), - Layer.succeed(RpcSerialization.RpcSerialization, RpcSerialization.json), - ); - // Effect v4's RPC type derivation exposes an implementation-level `any` - // requirement after these concrete transport layers are provided. - // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context - const rpcLayer = RpcServer.layerProtocolSocketServer.pipe( - Layer.provideMerge(transportLayer), - // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context - Layer.provideMerge(StackRpcGroup.toLayer(options.rpcHandlers)), + const completionFibers = yield* FiberSet.make(); + const server = wrappedServer(base, options, maintenanceSemaphore, completionFibers); + const protocol = yield* RpcServer.makeProtocolSocketServer.pipe( + Effect.provideService(SocketServer.SocketServer, server), + Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), ); - // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context + const destroyRequests = new Set(); + const patchedProtocol = RpcServer.Protocol.of({ + ...protocol, + run: (handler) => + protocol.run((clientId, request) => { + if (Predicate.isTagged(request, "Request") && request.tag === "destroy") + destroyRequests.add(`${clientId}:${String(request.id)}`); + return handler(clientId, request); + }), + send: (clientId, response, transferables) => + protocol.send(clientId, response, transferables).pipe( + Effect.andThen( + Predicate.isTagged(response, "Exit") + ? Effect.suspend(() => { + const key = `${clientId}:${String(response.requestId)}`; + const isDestroy = destroyRequests.delete(key); + if ( + !isDestroy || + !Predicate.isTagged(response.exit, "Success") || + options.onDestroyResponse === undefined + ) + return Effect.void; + return options.onDestroyResponse(); + }) + : Effect.void, + ), + ), + }); const rpcProgram = RpcServer.make(StackRpcGroup, { disableTracing: true, concurrency: MAINTENANCE_MAX_CONCURRENT_REQUESTS, }).pipe( + Effect.provideService(RpcServer.Protocol, patchedProtocol), // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context - Effect.provide(rpcLayer), + Effect.provide(StackRpcGroup.toLayer(options.rpcHandlers)), ); // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context yield* Effect.forkScoped(rpcProgram); diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 26f7145c3f..c418825f1a 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -43,6 +43,7 @@ interface ServerOverrides { readonly rpcHandlers?: Partial; readonly maintenanceHandlers?: Partial; readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; + readonly onDestroyResponse?: () => Effect.Effect; } const withPlatform = (effect: Effect.Effect) => @@ -160,6 +161,7 @@ const withServer = ( maintenanceHandlers: { ...defaultMaintenanceHandlers, ...custom.maintenanceHandlers }, rpcHandlers: { ...defaultRpcHandlers, ...custom.rpcHandlers }, onMaintenanceComplete: custom.onMaintenanceComplete, + onDestroyResponse: custom.onDestroyResponse, }; yield* startControlServer(options); const info = yield* fs.stat(endpoint.path); @@ -253,6 +255,29 @@ const makePrepareRequestFrame = ( }); describe("control transport", () => { + it.live("signals destroy shutdown only after writing the typed response", () => + withServer( + ({ endpoint, stackId, ownerSessionId, completion }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpc = yield* client.rpc; + yield* rpc.destroy(undefined); + yield* Deferred.await(completion).pipe( + Effect.timeoutOrElse({ + duration: 5_500, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ message: "destroy completion was not signaled" }), + ), + }), + ); + }), + ({ completion }) => ({ + onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + }), + ), + ); + it.live("round-trips typed RPC and stable maintenance on one endpoint", () => withServer(({ endpoint, stackId, ownerSessionId }) => Effect.gen(function* () { diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index 320c2916a1..f4216a4ea7 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -1,5 +1,5 @@ import { NodeServices } from "@effect/platform-node"; -import { Crypto, Data, Deferred, Effect, FileSystem, Path, Schema } from "effect"; +import { Crypto, Data, Effect, FileSystem, Path, Schema } from "effect"; // Node's fd3 readiness channel has no FileSystem abstraction; this is the // process-entrypoint boundary where the descriptor is intentionally used. // oxlint-disable-next-line effecttsgo/node-builtin-import @@ -102,7 +102,6 @@ export const runSupervisor = (args: SupervisorArgs) => rpcRelease: args.rpcRelease || STACK_RPC_RELEASE, environment, }); - const released = yield* Deferred.make(); const context = yield* Effect.context(); const runtimeFactory = yield* makeProductionRuntimeFactory({ stateRoot: args.stateRoot, @@ -127,13 +126,14 @@ export const runSupervisor = (args: SupervisorArgs) => rpcRelease: args.rpcRelease || STACK_RPC_RELEASE, maintenanceHandlers: supervisor.maintenanceHandlers, rpcHandlers: supervisor.rpcHandlers, - onMaintenanceComplete: (op) => - op === "quiesce" ? Deferred.succeed(released, undefined) : Effect.void, + onMaintenanceComplete: (op) => (op === "quiesce" ? supervisor.signalShutdown : Effect.void), + onDestroyResponse: () => supervisor.signalShutdown, }); yield* session.ready; yield* publishOwnership(lease); yield* writeReadiness({ ok: true, stackId: args.stackId, ownerSessionId }); - yield* Deferred.await(released); + yield* supervisor.recover; + yield* supervisor.shutdown; }), ).pipe(Effect.provide(NodeServices.layer)); diff --git a/packages/stack/src/supervisor/OwnerSession.ts b/packages/stack/src/supervisor/OwnerSession.ts index de9d3a4a92..28b988c3f9 100644 --- a/packages/stack/src/supervisor/OwnerSession.ts +++ b/packages/stack/src/supervisor/OwnerSession.ts @@ -24,6 +24,8 @@ export interface OwnerSessionOptions { readonly maintenanceHandlers: MaintenanceHandlers; /** Called after a stop/quiesce response is written and the connection closes. */ readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; + /** Called after a successful destroy response is written. */ + readonly onDestroyResponse?: () => Effect.Effect; readonly rpcHandlers: StackRpcHandlers; } diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index e5e66a04cb..dea6ef85ba 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -1,25 +1,29 @@ import { Context, + Cause, Crypto, Deferred, Effect, Exit, FileSystem, + FiberSet, Path, Redacted, Ref, Schema, + Semaphore, + Scope, Stream, } from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; -import { compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; +import { canonicalize, compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import { CAPABILITY_NAMES, type CapabilityName, type CapabilityStatus, } from "../public/Capability.ts"; -import type { StackConfig } from "../public/Config.ts"; +import { StackConfigSchema, type StackConfig } from "../public/Config.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, @@ -120,6 +124,12 @@ export interface Supervisor { readonly config?: StackConfig; }) => Effect.Effect; readonly destroy: Effect.Effect; + /** Completes persisted-running recovery for a deferred owner session. */ + readonly recover: Effect.Effect; + /** Completes after a successful destroy or quiesce shutdown signal. */ + readonly shutdown: Effect.Effect; + /** Signals owner shutdown after a successful maintenance response. */ + readonly signalShutdown: Effect.Effect; readonly watchStatus: Stream.Stream; readonly logs: (options?: LogOptions) => Stream.Stream; readonly activate: ( @@ -154,6 +164,18 @@ const stateErrorMessage = (error: StackError | { readonly message?: string }): s const credentialHost = (address: string): string => address.includes(":") && !address.startsWith("[") ? `[${address}]` : address; +const revealRedacted = (value: unknown): unknown => { + if (Redacted.isRedacted(value)) return Redacted.value(value); + if (Array.isArray(value)) return value.map(revealRedacted); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, revealRedacted(entry)]), + ); +}; + +const digestHex = (bytes: Uint8Array): string => + [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + const runtimeUnavailable = (): SupervisorRuntime => { const unavailable = (operation: string): Effect.Effect => Effect.fail(new RuntimeDriverError({ message: `Runtime ${operation} is unavailable` })); @@ -357,7 +379,9 @@ const rpcTag = (error: StackError, fallback: StackRpcError["tag"]): StackRpcErro }; /** Compose one owner process around the durable lifecycle controller and a runtime driver. */ -export const makeSupervisor = (options: SupervisorOptions): Effect.Effect => +export const makeSupervisor = ( + options: SupervisorOptions, +): Effect.Effect => Effect.gen(function* () { const read = () => options.stateStore.read(options.stackId).pipe(Effect.provideContext(options.context)); @@ -388,7 +412,9 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect>(new Set()); const generation = yield* Ref.make(initial.desiredGeneration); - const phase = yield* Ref.make("stopped"); + const phase = yield* Ref.make( + initial.desiredLifecycle === "running" ? "starting" : "stopped", + ); type ActivationHandler = ( capability: CapabilityName, ) => Effect.Effect; @@ -416,91 +442,28 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect runtime.driver.observe(options.stackId).pipe(Effect.mapError(mapReconcileError)); - - if (initial.definition !== undefined) { - const plan = yield* rebuildExecutionPlan(initial.runtime, initial.definition).pipe( - Effect.provideContext(options.context), - Effect.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), - ), + const observedForStatus = () => + Ref.get(phase).pipe( + Effect.flatMap((current) => (current === "running" ? observe() : Effect.succeed([]))), ); - if (initial.desiredLifecycle === "running") { - const request: RuntimeRecoveryRequest = { - stackId: options.stackId, - desiredGeneration: initial.desiredGeneration, - desiredLifecycle: "running", - plan, - }; - const recovered = yield* runtime.driver - .recover(request) - .pipe(Effect.mapError(mapReconcileError)); - yield* initializeActivation(plan, recovered); - const recoveryInput: LifecycleInput = { - stackId: options.stackId, - generation: initial.desiredGeneration, - desiredLifecycle: "running", - state: initial, - previous: initial, - definition: initial.definition, - inputFingerprint: initial.inputFingerprint ?? "", - secrets: initial.secrets, - plan, - }; - const reservation = - runtime.ingress === undefined ? undefined : yield* runtime.ingress.acquire(recoveryInput); - const selected = yield* Ref.get(active); - const reconciliation = yield* reconciler - .reconcile({ - stackId: options.stackId, - desiredGeneration: initial.desiredGeneration, - desiredLifecycle: "running", - plan: activePlan(plan, selected), - }) - .pipe(Effect.mapError(mapReconcileError), Effect.exit); - if (Exit.isFailure(reconciliation)) { - if (reservation?.fresh === true && runtime.ingress !== undefined) - yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* Effect.failCause(reconciliation.cause); - } - if (reconciliation.value.failed.length > 0) { - if (reservation?.fresh === true && runtime.ingress !== undefined) - yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* new StackReconciliationError({ - message: reconciliation.value.failed - .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) - .join("; "), - }); - } - if (runtime.ingress !== undefined && reservation !== undefined) { - const opened = yield* runtime.ingress - .open(recoveryInput, reservation, ingressActivate) - .pipe(Effect.exit); - if (Exit.isFailure(opened)) { - if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* Effect.failCause(opened.cause); - } - } - yield* Ref.set(phase, "running"); - } else { - yield* runtime.driver - .cleanup({ stackId: options.stackId, destroy: false }) - .pipe(Effect.mapError(mapReconcileError)); - yield* Ref.set(active, new Set()); - } - } else if (options.runtime !== undefined || options.runtimeFactory !== undefined) { - yield* runtime.driver - .cleanup({ stackId: options.stackId, destroy: false }) - .pipe(Effect.mapError(mapReconcileError)); - } - const initialObserved = yield* observe(); + const initialPlan = + initial.definition === undefined + ? undefined + : yield* rebuildExecutionPlan(initial.runtime, initial.definition).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); const initialStatus = yield* statusFor( initial, - initialObserved, + [], yield* Ref.get(active), yield* Ref.get(phase), ); @@ -512,7 +475,7 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect - runtime.preflight === undefined ? Effect.succeed({}) : runtime.preflight(input), - reconcile: (input) => - Effect.gen(function* () { - const reservation = - input.desiredLifecycle === "running" && runtime.ingress !== undefined - ? yield* runtime.ingress.acquire(input) - : undefined; - if (input.desiredLifecycle !== "running" && runtime.ingress !== undefined) - yield* runtime.ingress.close; - yield* resetForGeneration(input); - const selected = yield* Ref.get(active); - const plan = - input.desiredLifecycle === "running" ? activePlan(input.plan, selected) : input.plan; - const reconciled = yield* reconciler - .reconcile({ - stackId: options.stackId, - desiredGeneration: input.generation, - desiredLifecycle: input.desiredLifecycle, - plan, - }) - .pipe(Effect.mapError(mapReconcileError), Effect.exit); - if (Exit.isFailure(reconciled)) { + // Admission and execution are separate: callers join the same owned fiber while the + // execution semaphore serializes lifecycle/activation transitions. + const admission = yield* Semaphore.make(1); + const execution = yield* Semaphore.make(1); + const supervisorScope = yield* Effect.scope; + const ownedFibers = yield* FiberSet.make().pipe( + Effect.provideService(Scope.Scope, supervisorScope), + ); + const joinExit = (result: Exit.Exit): Effect.Effect => + Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); + type OwnedResult = Deferred.Deferred, never>; + const startOwned = new Map>(); + const restartOwned = new Map>(); + const stopOwned = new Map>(); + const destroyOwned = new Map>(); + const recoveryOwned = new Map>(); + + const submitOwned = ( + slots: Map>, + key: string, + effect: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const owned = yield* admission.withPermit( + Effect.gen(function* () { + const current = slots.get(key); + if (current !== undefined) return current; + const deferred = yield* Deferred.make, never>(); + slots.set(key, deferred); + const owner = Effect.gen(function* () { + const result = yield* execution.withPermit(effect).pipe(Effect.exit); + yield* Deferred.succeed(deferred, result); + }).pipe( + Effect.ensuring( + admission.withPermit( + Effect.sync(() => { + if (slots.get(key) === deferred) slots.delete(key); + }), + ), + ), + ); + yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); + return deferred; + }), + ); + const result = yield* Deferred.await(owned); + return yield* joinExit(result); + }); + + const reconcileBackend = ( + input: LifecycleInput, + selectedOverride?: ReadonlySet, + ): Effect.Effect => + Effect.gen(function* () { + const reservation = + input.desiredLifecycle === "running" && runtime.ingress !== undefined + ? yield* runtime.ingress.acquire(input) + : undefined; + if (input.desiredLifecycle !== "running" && runtime.ingress !== undefined) + yield* runtime.ingress.close; + yield* resetForGeneration(input); + const selected = selectedOverride ?? (yield* Ref.get(active)); + const plan = + input.desiredLifecycle === "running" ? activePlan(input.plan, selected) : input.plan; + const reconciled = yield* reconciler + .reconcile({ + stackId: options.stackId, + desiredGeneration: input.generation, + desiredLifecycle: input.desiredLifecycle, + plan, + }) + .pipe(Effect.mapError(mapReconcileError), Effect.exit); + if (Exit.isFailure(reconciled)) { + if (reservation?.fresh === true && runtime.ingress !== undefined) + yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* Effect.failCause(reconciled.cause); + } + const result = reconciled.value; + if (result.failed.length > 0) + return yield* Effect.gen(function* () { if (reservation?.fresh === true && runtime.ingress !== undefined) yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* Effect.failCause(reconciled.cause); - } - const result = reconciled.value; - if (result.failed.length > 0) - return yield* Effect.gen(function* () { - if (reservation?.fresh === true && runtime.ingress !== undefined) - yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* new StackReconciliationError({ - message: result.failed - .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) - .join("; "), - }); + return yield* new StackReconciliationError({ + message: result.failed + .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) + .join("; "), }); - if (runtime.ingress !== undefined && reservation !== undefined) { - const opened = yield* runtime.ingress - .open(input, reservation, ingressActivate) - .pipe(Effect.exit); - if (Exit.isFailure(opened)) { - if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* Effect.failCause(opened.cause); - } + }); + if (runtime.ingress !== undefined && reservation !== undefined) { + const opened = yield* runtime.ingress + .open(input, reservation, ingressActivate) + .pipe(Effect.exit); + if (Exit.isFailure(opened)) { + if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* Effect.failCause(opened.cause); } - yield* publish(); - }), + } + yield* publish(); + }); + + const backend: LifecycleBackend = { + preflight: (input) => + runtime.preflight === undefined ? Effect.succeed({}) : runtime.preflight(input), + reconcile: reconcileBackend, cleanup: () => Effect.gen(function* () { if (runtime.ingress !== undefined) yield* runtime.ingress.close; @@ -605,7 +622,74 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect = Effect.gen(function* () { + const definition = initial.definition; + if ( + initialPlan === undefined || + definition === undefined || + initial.desiredLifecycle !== "running" + ) { + if (options.runtime !== undefined || options.runtimeFactory !== undefined) + yield* runtime.driver + .cleanup({ stackId: options.stackId, destroy: false }) + .pipe(Effect.mapError(mapReconcileError)); + yield* Ref.set(active, new Set()); + yield* Ref.set(phase, "stopped"); + yield* publish().pipe(Effect.ignore); + return; + } + + yield* Ref.set(phase, "starting"); + yield* publish().pipe(Effect.ignore); + const plan = initialPlan; + const request: RuntimeRecoveryRequest = { + stackId: options.stackId, + desiredGeneration: initial.desiredGeneration, + desiredLifecycle: "running", + plan, + }; + const recoveryInput: LifecycleInput = { + stackId: options.stackId, + generation: initial.desiredGeneration, + desiredLifecycle: "running", + state: initial, + previous: initial, + definition, + inputFingerprint: initial.inputFingerprint ?? "", + secrets: initial.secrets, + plan, + }; + const attempt = yield* Effect.exit( + Effect.gen(function* () { + const recovered = yield* runtime.driver + .recover(request) + .pipe(Effect.mapError(mapReconcileError)); + yield* initializeActivation(plan, recovered); + yield* reconcileBackend(recoveryInput); + }), + ); + if (Exit.isFailure(attempt)) { + // Recovery must not tear down the owner session. Keep the phase observable as starting + // and retain the cause in the owner-only log for the next inspection/retry. + if (runtime.logStore !== undefined) + yield* runtime.logStore + .append({ + source: "supervisor", + stream: "stderr", + message: `Stack recovery failed: ${Cause.pretty(attempt.cause)}`, + }) + .pipe(Effect.ignore); + yield* Ref.set(phase, "starting"); + yield* publish().pipe(Effect.ignore); + return; + } + yield* Ref.set(phase, "running"); + yield* publish().pipe(Effect.ignore); + }).pipe(Effect.provideContext(options.context)); + + const recover = submitOwned(recoveryOwned, "recovery", recoveryOperation).pipe(Effect.ignore); + + const activateOperation = ( capability: CapabilityName, ): Effect.Effect => Effect.gen(function* () { @@ -634,7 +718,6 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect, + never + >; + } + | { readonly _tag: "ready"; readonly result: ActivationResult } + >(); + type ActivationExit = Exit.Exit; + type ActivationToken = + | { readonly _tag: "exit"; readonly result: ActivationExit } + | { + readonly _tag: "deferred"; + readonly result: Deferred.Deferred; + }; + const activate = ( + capability: CapabilityName, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* admission.withPermit( + Effect.gen(function* () { + const current = activationOwned.get(capability); + if (current?._tag === "ready") + return { + _tag: "exit", + result: Exit.succeed(current.result), + } satisfies ActivationToken; + if (current?._tag === "pending") + return { _tag: "deferred", result: current.result } satisfies ActivationToken; + const deferred = yield* Deferred.make< + Exit.Exit, + never + >(); + activationOwned.set(capability, { _tag: "pending", result: deferred }); + const owner = Effect.gen(function* () { + const result = yield* execution + .withPermit(activateOperation(capability)) + .pipe(Effect.exit); + yield* admission.withPermit( + Effect.sync(() => { + const current = activationOwned.get(capability); + if (current?._tag !== "pending" || current.result !== deferred) return; + if (Exit.isSuccess(result)) + activationOwned.set(capability, { _tag: "ready", result: result.value }); + else activationOwned.delete(capability); + }), + ); + yield* Deferred.succeed(deferred, result); + }).pipe( + Effect.ensuring( + admission.withPermit( + Effect.sync(() => { + const current = activationOwned.get(capability); + if (current?._tag === "pending" && current.result === deferred) + activationOwned.delete(capability); + }), + ), + ), + ); + yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); + return { _tag: "deferred", result: deferred } satisfies ActivationToken; + }), + ); + if (token._tag === "deferred") + return yield* Deferred.await(token.result).pipe(Effect.flatMap(joinExit)); + return yield* joinExit(token.result); + }); yield* Deferred.succeed(activationHandler, activate); const startOperation = (startOptions?: { readonly config?: StackConfig }) => @@ -670,6 +825,37 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect => + Effect.gen(function* () { + const input = config ?? {}; + const encoded = yield* Schema.encodeEffect(StackConfigSchema)(input).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + stackId: options.stackId, + message: `Invalid stack configuration: ${String(error)}`, + }), + ), + ); + const crypto = yield* Crypto.Crypto; + const canonical = canonicalize({ encoded, secretValues: revealRedacted(input) }); + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(canonical)).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + stackId: options.stackId, + message: `Unable to digest stack configuration: ${error.message}`, + cause: error, + }), + ), + ); + return digestHex(digest); + }).pipe(Effect.provideContext(options.context)); + const start = (startOptions?: { readonly config?: StackConfig }) => + Effect.gen(function* () { + const key = yield* operationKey(startOptions?.config); + return yield* submitOwned(startOwned, key, startOperation(startOptions)); + }); const restartOperation = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { const previous = yield* Ref.get(phase); @@ -682,13 +868,23 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect + Effect.gen(function* () { + const key = yield* operationKey(startOptions?.config); + return yield* submitOwned(restartOwned, key, restartOperation(startOptions)); + }); + const stopOperation = () => controller.stop().pipe(Effect.provideContext(options.context)); + const stop = submitOwned(stopOwned, "stop", stopOperation()); const operation = (effect: Effect.Effect, fallback: StackRpcError["tag"]) => effect.pipe( Effect.mapError((error) => rpcError(rpcTag(error, fallback), stateErrorMessage(error))), ); - const destroy = controller + const shutdownSignal = yield* Deferred.make(); + const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); + const destroyOperation = controller .destroy() .pipe(Effect.provideContext(options.context), Effect.andThen(Ref.set(phase, "stopped"))); + const destroy = submitOwned(destroyOwned, "destroy", destroyOperation).pipe(Effect.asVoid); const logs = (logOptions?: LogOptions): Stream.Stream => runtime.logStore === undefined ? Stream.fail(new StackNotRunningError({ message: "Stack logs are unavailable" })) @@ -708,7 +904,7 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect - operation(startOperation({ config }), "StackReconciliationError"), + operation(start({ config }), "StackReconciliationError"), restart: ({ config }: { readonly config?: StackConfig }) => - operation(restartOperation({ config }), "StackReconciliationError"), + operation(restart({ config }), "StackReconciliationError"), destroy: () => operation(destroy, "StackDestructionError"), logs: (logOptions: LogOptions) => logs(logOptions).pipe( @@ -969,9 +1165,12 @@ export const makeSupervisor = (options: SupervisorOptions): Effect.Effect>; readonly runtime?: StackRuntime; readonly prepareOutcome?: "cached" | "downloaded" | "present" | "pulled"; + readonly startGate?: Deferred.Deferred; + readonly startStarted?: Deferred.Deferred; + readonly activationGate?: Deferred.Deferred; + readonly activationStarted?: Deferred.Deferred; + readonly activationCalls?: Ref.Ref; + readonly activationFailFirst?: Ref.Ref; + readonly preflightFailFirst?: Ref.Ref; + readonly preflightGate?: Deferred.Deferred; + readonly preflightStarted?: Deferred.Deferred; } = {}, ) => Effect.gen(function* () { @@ -200,6 +211,10 @@ const makeFixture = ( `start:${workload.id}`, ]); yield* Ref.update(calls, (current) => [...current, `start:${workload.id}`]); + if (fixtureOptions.startStarted !== undefined) + yield* Deferred.succeed(fixtureOptions.startStarted, undefined); + if (fixtureOptions.startGate !== undefined) + yield* Deferred.await(fixtureOptions.startGate); const ready = { ...key, state: "ready" as const }; yield* Ref.update(resources, (current) => [ ...current.filter((entry) => entry.workloadId !== key.workloadId), @@ -259,8 +274,38 @@ const makeFixture = ( }), ), ), - preflight: () => Effect.succeed({}), - activate: () => Effect.succeed({ host: "127.0.0.1", port: 9999 }), + preflight: () => + Effect.gen(function* () { + if (fixtureOptions.preflightStarted !== undefined) + yield* Deferred.succeed(fixtureOptions.preflightStarted, undefined); + if (fixtureOptions.preflightGate !== undefined) + yield* Deferred.await(fixtureOptions.preflightGate); + if (fixtureOptions.preflightFailFirst !== undefined) { + const fail = yield* Ref.get(fixtureOptions.preflightFailFirst); + if (fail) { + yield* Ref.set(fixtureOptions.preflightFailFirst, false); + return yield* new StackReconciliationError({ message: "injected preflight failure" }); + } + } + return {}; + }), + activate: () => + Effect.gen(function* () { + if (fixtureOptions.activationCalls !== undefined) + yield* Ref.update(fixtureOptions.activationCalls, (count) => count + 1); + if (fixtureOptions.activationStarted !== undefined) + yield* Deferred.succeed(fixtureOptions.activationStarted, undefined); + if (fixtureOptions.activationGate !== undefined) + yield* Deferred.await(fixtureOptions.activationGate); + if (fixtureOptions.activationFailFirst !== undefined) { + const fail = yield* Ref.get(fixtureOptions.activationFailFirst); + if (fail) { + yield* Ref.set(fixtureOptions.activationFailFirst, false); + return yield* new GatewayActivationError({ message: "injected activation failure" }); + } + } + return { host: "127.0.0.1", port: 9999 }; + }), ...(fixtureOptions.ingress === undefined ? {} : { ingress: fixtureOptions.ingress }), logStore: { path: "memory://logs", @@ -828,6 +873,156 @@ describe("Supervisor composition", () => { ), ); + it.live("keeps accepted start work alive when its waiter is interrupted", () => + run( + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const started = yield* Deferred.make(); + const fixture = yield* makeFixture({ startGate: gate, startStarted: started }); + const config = { capabilities: { rest: {} } }; + const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config })); + yield* Deferred.await(started); + yield* Fiber.interrupt(waiter); + yield* Deferred.succeed(gate, undefined); + const status = yield* fixture.supervisor.start({ config }); + expect(status.lifecycle).toBe("running"); + const starts = (yield* Ref.get(fixture.calls)).filter((call) => call.startsWith("start:")); + expect(starts).toContain("start:database:database"); + expect(starts).toContain("start:rest:rest"); + }), + ), + ); + + it.live("joins concurrent equivalent starts despite property order", () => + run( + Effect.gen(function* () { + const preflightFailFirst = yield* Ref.make(true); + const preflightGate = yield* Deferred.make(); + const preflightStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ + preflightFailFirst, + preflightGate, + preflightStarted, + }); + const firstConfig = { + capabilities: { rest: {}, auth: { enabled: false } }, + security: { jwt: { issuer: "issuer" } }, + }; + const equivalentConfig = { + security: { jwt: { issuer: "issuer" } }, + capabilities: { auth: { enabled: false }, rest: {} }, + }; + const first = yield* Effect.forkChild(fixture.supervisor.start({ config: firstConfig })); + yield* Deferred.await(preflightStarted); + const second = yield* Effect.forkChild( + fixture.supervisor.start({ config: equivalentConfig }), + ); + yield* Deferred.succeed(preflightGate, undefined); + const [firstExit, secondExit] = yield* Effect.all([ + Fiber.join(first).pipe(Effect.exit), + Fiber.join(second).pipe(Effect.exit), + ]); + expect(Exit.isFailure(firstExit)).toBe(true); + expect(Exit.isFailure(secondExit)).toBe(true); + expect(yield* Ref.get(preflightFailFirst)).toBe(false); + }), + ), + ); + + it.live("does not join concurrent starts with distinct secret values", () => + run( + Effect.gen(function* () { + const preflightFailFirst = yield* Ref.make(true); + const preflightGate = yield* Deferred.make(); + const preflightStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ + preflightFailFirst, + preflightGate, + preflightStarted, + }); + const firstConfig = { + security: { + jwt: { signing: { kind: "symmetric" as const, secret: Redacted.make("a") } }, + }, + }; + const distinctConfig = { + security: { + jwt: { signing: { kind: "symmetric" as const, secret: Redacted.make("b") } }, + }, + }; + const first = yield* Effect.forkChild(fixture.supervisor.start({ config: firstConfig })); + yield* Deferred.await(preflightStarted); + const second = yield* Effect.forkChild( + fixture.supervisor.start({ config: distinctConfig }), + ); + yield* Deferred.succeed(preflightGate, undefined); + const [firstExit, secondExit] = yield* Effect.all([ + Fiber.join(first).pipe(Effect.exit), + Fiber.join(second).pipe(Effect.exit), + ]); + expect(Exit.isFailure(firstExit)).toBe(true); + expect(Exit.isSuccess(secondExit)).toBe(true); + }), + ), + ); + + it.live("single-flights lazy activation and retains its endpoint for the generation", () => + run( + Effect.gen(function* () { + const gate = yield* Deferred.make(); + const started = yield* Deferred.make(); + const activationCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ + activationGate: gate, + activationStarted: started, + activationCalls, + }); + yield* fixture.supervisor.start({ + config: { + capabilities: { + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { activation: "lazy" }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const first = yield* Effect.forkChild(fixture.supervisor.activate("functions")); + yield* Deferred.await(started); + const second = yield* Effect.forkChild(fixture.supervisor.activate("functions")); + yield* Deferred.succeed(gate, undefined); + const [left, right] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); + expect(left).toEqual(right); + expect(yield* Ref.get(activationCalls)).toBe(1); + expect(yield* fixture.supervisor.activate("functions")).toEqual(left); + expect(yield* Ref.get(activationCalls)).toBe(1); + }), + ), + ); + + it.live("allows a failed activation to retry", () => + run( + Effect.gen(function* () { + const activationFailFirst = yield* Ref.make(true); + const activationCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ activationFailFirst, activationCalls }); + yield* fixture.supervisor.start({ + config: { capabilities: { functions: { activation: "lazy" } } }, + }); + const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + const retry = yield* fixture.supervisor.activate("functions"); + expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); + expect(yield* Ref.get(activationCalls)).toBe(2); + }), + ), + ); + it.live("recovers a running durable intent and starts missing eager workloads", () => run( Effect.gen(function* () { @@ -867,6 +1062,7 @@ describe("Supervisor composition", () => { }, }, }); + yield* recovered.recover; const status = yield* recovered.status; expect(status.desiredLifecycle).toBe("running"); expect(status.lifecycle).toBe("running"); From 783d5e98997f805cfdab94ab3720c47cff00f386 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 09:08:52 +0200 Subject: [PATCH 072/174] fix(stack): close lifecycle ownership gaps --- ...26-08-28-stack-runtime-rewrite-progress.md | 13 ++ packages/stack/src/control/ControlServer.ts | 59 ++++-- .../control-transport.integration.test.ts | 70 ++++++ packages/stack/src/supervisor/Supervisor.ts | 199 ++++++++++++------ .../supervisor/supervisor.integration.test.ts | 181 +++++++++++++++- 5 files changed, 430 insertions(+), 92 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 45bac1656a..e2e212f409 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -915,3 +915,16 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Destroy shutdown is transport-owned: the exact owner session is signalled only after a successful destroy response write. Focused supervisor/control/handle/lifecycle verification passed 69 integration tests with stack type-check, Effect lint, formatting, and diff checks green. + +#### Task 13B2b7 correction — lifecycle/Fable follow-up (2026-08-31) + +- Persisted plan rebuilding is deferred until owner-scoped recovery, after control endpoint and fd3 readiness; + malformed persisted material leaves an attachable owner and recovery failures are visible as stopped/failed status. + Explicit start/restart clears the failure marker and remains retryable. +- Quiesce keeps accepted stop work in the Supervisor FiberSet. Normal maintenance signals shutdown after the response + is written, while an interrupted waiter starts an owner-scoped join that signals only after successful quiesce. + Ordinary stop does not shut down the owner. Destroy callbacks require a connected client; stale request IDs are + removed through a single-consumer disconnect proxy queue. +- Start/restart identity uses compiler-normalized definitions and effective secret values in a canonical SHA-256 digest; + equivalent defaults join, distinct transient secrets remain isolated. Focused supervisor/control/handle/lifecycle + verification passed 74 integration tests with stack type-check, Effect lint, formatting, and diff checks green. diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 52d953c56c..2be433c99a 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -6,6 +6,7 @@ import { FiberSet, FileSystem, Predicate, + Queue, Schema, Scope, Semaphore, @@ -383,8 +384,27 @@ export const startControlServer = ( Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), ); const destroyRequests = new Set(); + const disconnects = yield* Queue.unbounded(); + yield* FiberSet.run( + completionFibers, + Effect.forever( + Queue.take(protocol.disconnects).pipe( + Effect.tap((clientId) => + Effect.sync(() => { + const prefix = `${clientId}:`; + for (const request of destroyRequests) + if (request.startsWith(prefix)) destroyRequests.delete(request); + }), + ), + Effect.flatMap((clientId) => Queue.offer(disconnects, clientId)), + Effect.asVoid, + ), + ), + { startImmediately: true }, + ); const patchedProtocol = RpcServer.Protocol.of({ ...protocol, + disconnects, run: (handler) => protocol.run((clientId, request) => { if (Predicate.isTagged(request, "Request") && request.tag === "destroy") @@ -392,23 +412,28 @@ export const startControlServer = ( return handler(clientId, request); }), send: (clientId, response, transferables) => - protocol.send(clientId, response, transferables).pipe( - Effect.andThen( - Predicate.isTagged(response, "Exit") - ? Effect.suspend(() => { - const key = `${clientId}:${String(response.requestId)}`; - const isDestroy = destroyRequests.delete(key); - if ( - !isDestroy || - !Predicate.isTagged(response.exit, "Success") || - options.onDestroyResponse === undefined - ) - return Effect.void; - return options.onDestroyResponse(); - }) - : Effect.void, - ), - ), + Predicate.isTagged(response, "Exit") && + destroyRequests.has(`${clientId}:${String(response.requestId)}`) + ? Effect.gen(function* () { + const key = `${clientId}:${String(response.requestId)}`; + const connectedBefore = yield* protocol.clientIds; + if (!connectedBefore.has(clientId)) { + destroyRequests.delete(key); + return; + } + yield* protocol.send(clientId, response, transferables); + const connectedAfter = yield* protocol.clientIds; + const isDestroy = destroyRequests.delete(key); + if ( + !connectedAfter.has(clientId) || + !isDestroy || + !Predicate.isTagged(response.exit, "Success") || + options.onDestroyResponse === undefined + ) + return; + yield* options.onDestroyResponse(); + }) + : protocol.send(clientId, response, transferables), }); const rpcProgram = RpcServer.make(StackRpcGroup, { disableTracing: true, diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index c418825f1a..7fc5b8c88a 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -14,6 +14,7 @@ import { Stream, } from "effect"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as Socket from "effect/unstable/socket/Socket"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import type { StackStatus } from "../public/Status.ts"; @@ -254,6 +255,22 @@ const makePrepareRequestFrame = ( return yield* encodeRawFrame(encoded); }); +const makeDestroyRequestFrame = (): Effect.Effect => + Effect.gen(function* () { + const parser = RpcSerialization.json.makeUnsafe(); + const encoded = parser.encode({ + _tag: "Request", + id: "1", + tag: "destroy", + payload: null, + headers: [], + }); + if (encoded === undefined) { + return yield* new MaintenanceProtocolError({ message: "RPC request could not be encoded" }); + } + return yield* encodeRawFrame(encoded); + }); + describe("control transport", () => { it.live("signals destroy shutdown only after writing the typed response", () => withServer( @@ -278,6 +295,59 @@ describe("control transport", () => { ), ); + it.live("does not signal destroy shutdown after the RPC client disconnects", () => + withServer( + ({ endpoint, stackId, ownerSessionId, completionStarted, completionRelease, completion }) => + Effect.gen(function* () { + const socket = yield* NodeSocket.makeNet({ + path: endpoint.kind === "unix" ? endpoint.path : endpoint.name, + }); + const write = yield* socket.writer; + const reader = yield* Effect.forkChild( + socket.runRaw(() => Effect.void), + { + startImmediately: true, + }, + ); + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId, + ownerSessionId, + }); + const frame = yield* makeDestroyRequestFrame(); + yield* write(concatBytes(preface, frame)); + yield* Deferred.await(completionStarted).pipe( + Effect.timeoutOrElse({ + duration: 1_000, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ message: "destroy handler did not start" }), + ), + }), + ); + yield* write(new Socket.CloseEvent(1000)); + yield* Deferred.succeed(completionRelease, undefined); + const result = yield* Deferred.await(completion).pipe( + Effect.timeout("100 millis"), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + yield* Fiber.interrupt(reader); + }), + ({ completionStarted, completionRelease, completion }) => ({ + rpcHandlers: { + destroy: () => + Deferred.succeed(completionStarted, undefined).pipe( + Effect.andThen(Deferred.await(completionRelease)), + Effect.asVoid, + ), + }, + onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + }), + ), + ); + it.live("round-trips typed RPC and stable maintenance on one endpoint", () => withServer(({ endpoint, stackId, ownerSessionId }) => Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index dea6ef85ba..97d886ef6c 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -128,7 +128,7 @@ export interface Supervisor { readonly recover: Effect.Effect; /** Completes after a successful destroy or quiesce shutdown signal. */ readonly shutdown: Effect.Effect; - /** Signals owner shutdown after a successful maintenance response. */ + /** Signals owner shutdown after accepted quiesce or a successful destroy response. */ readonly signalShutdown: Effect.Effect; readonly watchStatus: Stream.Stream; readonly logs: (options?: LogOptions) => Stream.Stream; @@ -218,6 +218,7 @@ const capabilityState = ( observed: ReadonlyArray, active: ReadonlySet, phase: ActualPhase, + recoveryFailed: boolean, ): CapabilityStatus["state"] => { const configured = state.definition?.capabilities[name]; if (configured === undefined || !configured.enabled) return "disabled"; @@ -228,6 +229,7 @@ const capabilityState = ( phase === "destroying" ) return "stopped"; + if (recoveryFailed) return "failed"; if (configured.activation === "lazy" && !active.has(name)) return "dormant"; if (phase === "starting") return "starting"; const resources = observed.filter((entry) => entry.workloadId.startsWith(`${name}:`)); @@ -242,6 +244,7 @@ const statusFor = ( observed: ReadonlyArray, active: ReadonlySet, phase: ActualPhase, + recoveryFailed: boolean, ): Effect.Effect => Schema.decodeEffect(StackIdSchema)(state.identity.stackId).pipe( Effect.mapError( @@ -253,7 +256,7 @@ const statusFor = ( const capabilities = CAPABILITY_NAMES.map((name) => ({ name, activation: definition?.capabilities[name].activation ?? "eager", - state: capabilityState(name, state, observed, active, phase), + state: capabilityState(name, state, observed, active, phase, recoveryFailed), })); const versions: Partial> = {}; if (definition !== undefined) @@ -306,19 +309,21 @@ const statusFor = ( ? "destroying" : state.desiredLifecycle === "stopped" ? "stopped" - : phase === "starting" - ? "starting" - : phase === "stopping" - ? "stopping" - : phase === "destroying" - ? "destroying" - : anyFailed - ? "stopped" - : anyStarting - ? "starting" - : allReady - ? "running" - : "stopped"; + : recoveryFailed + ? "stopped" + : anyFailed + ? "stopped" + : phase === "starting" + ? "starting" + : phase === "stopping" + ? "stopping" + : phase === "destroying" + ? "destroying" + : anyStarting + ? "starting" + : allReady + ? "running" + : "stopped"; return { id, lifecycle, @@ -415,6 +420,7 @@ export const makeSupervisor = ( const phase = yield* Ref.make( initial.desiredLifecycle === "running" ? "starting" : "stopped", ); + const recoveryFailure = yield* Ref.make(false); type ActivationHandler = ( capability: CapabilityName, ) => Effect.Effect; @@ -448,24 +454,18 @@ export const makeSupervisor = ( const observe = () => runtime.driver.observe(options.stackId).pipe(Effect.mapError(mapReconcileError)); const observedForStatus = () => - Ref.get(phase).pipe( - Effect.flatMap((current) => (current === "running" ? observe() : Effect.succeed([]))), + Effect.all({ phase: Ref.get(phase), recoveryFailed: Ref.get(recoveryFailure) }).pipe( + Effect.flatMap(({ phase: current, recoveryFailed }) => + current === "running" && !recoveryFailed ? observe() : Effect.succeed([]), + ), ); - const initialPlan = - initial.definition === undefined - ? undefined - : yield* rebuildExecutionPlan(initial.runtime, initial.definition).pipe( - Effect.provideContext(options.context), - Effect.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), - ), - ); const initialStatus = yield* statusFor( initial, [], yield* Ref.get(active), yield* Ref.get(phase), + false, ); const hub = yield* makeStatusHub(initialStatus); const snapshot = (): Effect.Effect => @@ -478,6 +478,7 @@ export const makeSupervisor = ( yield* observedForStatus(), yield* Ref.get(active), yield* Ref.get(phase), + yield* Ref.get(recoveryFailure), ); return status; }); @@ -624,11 +625,8 @@ export const makeSupervisor = ( const recoveryOperation: Effect.Effect = Effect.gen(function* () { const definition = initial.definition; - if ( - initialPlan === undefined || - definition === undefined || - initial.desiredLifecycle !== "running" - ) { + if (definition === undefined || initial.desiredLifecycle !== "running") { + yield* Ref.set(recoveryFailure, false); if (options.runtime !== undefined || options.runtimeFactory !== undefined) yield* runtime.driver .cleanup({ stackId: options.stackId, destroy: false }) @@ -640,27 +638,34 @@ export const makeSupervisor = ( } yield* Ref.set(phase, "starting"); + yield* Ref.set(recoveryFailure, false); yield* publish().pipe(Effect.ignore); - const plan = initialPlan; - const request: RuntimeRecoveryRequest = { - stackId: options.stackId, - desiredGeneration: initial.desiredGeneration, - desiredLifecycle: "running", - plan, - }; - const recoveryInput: LifecycleInput = { - stackId: options.stackId, - generation: initial.desiredGeneration, - desiredLifecycle: "running", - state: initial, - previous: initial, - definition, - inputFingerprint: initial.inputFingerprint ?? "", - secrets: initial.secrets, - plan, - }; const attempt = yield* Effect.exit( Effect.gen(function* () { + const plan = yield* rebuildExecutionPlan(initial.runtime, definition).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + const request: RuntimeRecoveryRequest = { + stackId: options.stackId, + desiredGeneration: initial.desiredGeneration, + desiredLifecycle: "running", + plan, + }; + const recoveryInput: LifecycleInput = { + stackId: options.stackId, + generation: initial.desiredGeneration, + desiredLifecycle: "running", + state: initial, + previous: initial, + definition, + inputFingerprint: initial.inputFingerprint ?? "", + secrets: initial.secrets, + plan, + }; + yield* initializeActivation(plan); const recovered = yield* runtime.driver .recover(request) .pipe(Effect.mapError(mapReconcileError)); @@ -679,6 +684,7 @@ export const makeSupervisor = ( message: `Stack recovery failed: ${Cause.pretty(attempt.cause)}`, }) .pipe(Effect.ignore); + yield* Ref.set(recoveryFailure, true); yield* Ref.set(phase, "starting"); yield* publish().pipe(Effect.ignore); return; @@ -823,32 +829,78 @@ export const makeSupervisor = ( Effect.tapError(() => restorePhase(previous)), ); yield* Ref.set(phase, "running"); + yield* Ref.set(recoveryFailure, false); return yield* publish(); }); const operationKey = (config: StackConfig | undefined): Effect.Effect => Effect.gen(function* () { - const input = config ?? {}; - const encoded = yield* Schema.encodeEffect(StackConfigSchema)(input).pipe( + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + yield* Schema.encodeEffect(StackConfigSchema)(config ?? {}).pipe( Effect.mapError( (error) => new InvalidStackConfigError({ stackId: options.stackId, message: `Invalid stack configuration: ${String(error)}`, - }), - ), - ); - const crypto = yield* Crypto.Crypto; - const canonical = canonicalize({ encoded, secretValues: revealRedacted(input) }); - const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(canonical)).pipe( - Effect.mapError( - (error) => - new InvalidStackConfigError({ - stackId: options.stackId, - message: `Unable to digest stack configuration: ${error.message}`, cause: error, }), ), ); + const compiled = + config === undefined && + state.definition !== undefined && + state.inputFingerprint !== undefined + ? undefined + : yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config, + }).pipe(Effect.provideContext(options.context)); + const fingerprint = compiled?.inputFingerprint ?? state.inputFingerprint; + if (fingerprint === undefined) + return yield* new StackStateInvalidError({ + stackId: options.stackId, + message: "Persisted stack definition fingerprint is missing", + }); + const secretIdentity: Record< + string, + { readonly policy: "managed" | "passthrough"; readonly value?: string } + > = {}; + if (compiled === undefined) { + for (const [slot, entry] of Object.entries(state.secrets)) + secretIdentity[slot] = { policy: entry.policy, value: entry.value }; + } else { + for (const declaration of compiled.secrets) { + const persisted = state.secrets[declaration.slot]; + const value = + declaration.value === undefined + ? persisted?.value + : String(Redacted.value(declaration.value)); + secretIdentity[declaration.slot] = { + policy: declaration.policy, + ...(value === undefined ? {} : { value }), + }; + } + } + const semantics = { + fingerprint, + definition: compiled?.definition ?? state.definition, + secrets: secretIdentity, + }; + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(canonicalize(revealRedacted(semantics)))) + .pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + stackId: options.stackId, + message: `Unable to digest stack configuration: ${error.message}`, + cause: error, + }), + ), + ); return digestHex(digest); }).pipe(Effect.provideContext(options.context)); const start = (startOptions?: { readonly config?: StackConfig }) => @@ -866,6 +918,7 @@ export const makeSupervisor = ( Effect.tapError(() => restorePhase(previous)), ); yield* Ref.set(phase, "running"); + yield* Ref.set(recoveryFailure, false); return yield* publish(); }); const restart = (startOptions?: { readonly config?: StackConfig }) => @@ -874,13 +927,29 @@ export const makeSupervisor = ( return yield* submitOwned(restartOwned, key, restartOperation(startOptions)); }); const stopOperation = () => controller.stop().pipe(Effect.provideContext(options.context)); + const shutdownSignal = yield* Deferred.make(); + const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); const stop = submitOwned(stopOwned, "stop", stopOperation()); + const quiesceOwned = new Map>(); + const quiesceOwnedEffect = submitOwned(quiesceOwned, "quiesce", stopOperation()); + const quiesce = quiesceOwnedEffect.pipe( + Effect.onInterrupt(() => + FiberSet.run( + ownedFibers, + quiesceOwnedEffect.pipe( + Effect.matchEffect({ + onFailure: () => Effect.void, + onSuccess: () => signalShutdown, + }), + ), + { startImmediately: true }, + ), + ), + ); const operation = (effect: Effect.Effect, fallback: StackRpcError["tag"]) => effect.pipe( Effect.mapError((error) => rpcError(rpcTag(error, fallback), stateErrorMessage(error))), ); - const shutdownSignal = yield* Deferred.make(); - const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); const destroyOperation = controller .destroy() .pipe(Effect.provideContext(options.context), Effect.andThen(Ref.set(phase, "stopped"))); @@ -915,7 +984,7 @@ export const makeSupervisor = ( }) satisfies MaintenanceResponse, ), ), - quiesce: stop.pipe( + quiesce: quiesce.pipe( Effect.provideContext(options.context), Effect.as({ ok: true, op: "quiesce" } satisfies MaintenanceResponse), Effect.orElseSucceed( diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 88e856c42c..97f8c02fdd 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -179,6 +179,10 @@ const makeFixture = ( readonly preflightFailFirst?: Ref.Ref; readonly preflightGate?: Deferred.Deferred; readonly preflightStarted?: Deferred.Deferred; + readonly stopGate?: Deferred.Deferred; + readonly stopStarted?: Deferred.Deferred; + readonly recoveryFailFirst?: Ref.Ref; + readonly recoveryStarted?: Deferred.Deferred; } = {}, ) => Effect.gen(function* () { @@ -250,9 +254,25 @@ const makeFixture = ( ...current, `cleanup:${destroy ? "destroy" : "stop"}`, ]); + if (!destroy && fixtureOptions.stopStarted !== undefined) + yield* Deferred.succeed(fixtureOptions.stopStarted, undefined); + if (!destroy && fixtureOptions.stopGate !== undefined) + yield* Deferred.await(fixtureOptions.stopGate); yield* Ref.set(resources, []); }), - recover: () => Ref.get(resources), + recover: () => + Effect.gen(function* () { + if (fixtureOptions.recoveryStarted !== undefined) + yield* Deferred.succeed(fixtureOptions.recoveryStarted, undefined); + if (fixtureOptions.recoveryFailFirst !== undefined) { + const fail = yield* Ref.get(fixtureOptions.recoveryFailFirst); + if (fail) { + yield* Ref.set(fixtureOptions.recoveryFailFirst, false); + return yield* new RuntimeDriverError({ message: "injected recovery failure" }); + } + } + return yield* Ref.get(resources); + }), }; const entry: StackLogEntry = { cursor: { opaque: "v1_1" }, @@ -893,7 +913,7 @@ describe("Supervisor composition", () => { ), ); - it.live("joins concurrent equivalent starts despite property order", () => + it.live("joins concurrent compiler-equivalent starts", () => run( Effect.gen(function* () { const preflightFailFirst = yield* Ref.make(true); @@ -904,14 +924,8 @@ describe("Supervisor composition", () => { preflightGate, preflightStarted, }); - const firstConfig = { - capabilities: { rest: {}, auth: { enabled: false } }, - security: { jwt: { issuer: "issuer" } }, - }; - const equivalentConfig = { - security: { jwt: { issuer: "issuer" } }, - capabilities: { auth: { enabled: false }, rest: {} }, - }; + const firstConfig = {}; + const equivalentConfig = { capabilities: {} }; const first = yield* Effect.forkChild(fixture.supervisor.start({ config: firstConfig })); yield* Deferred.await(preflightStarted); const second = yield* Effect.forkChild( @@ -1023,6 +1037,45 @@ describe("Supervisor composition", () => { ), ); + it.live("fences activation against a concurrent stop generation", () => + run( + Effect.gen(function* () { + const activationGate = yield* Deferred.make(); + const activationStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ activationGate, activationStarted }); + yield* fixture.supervisor.start({ + config: { capabilities: { functions: { activation: "lazy" } } }, + }); + const activation = yield* Effect.forkChild(fixture.supervisor.activate("functions")); + yield* Deferred.await(activationStarted); + const stop = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); + yield* Deferred.succeed(activationGate, undefined); + yield* Fiber.join(activation); + yield* Fiber.join(stop); + const status = yield* fixture.supervisor.status; + expect(status.lifecycle).toBe("stopped"); + expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("stopped"); + }), + ), + ); + + it.live("completes accepted quiesce after its waiter is interrupted", () => + run( + Effect.gen(function* () { + const stopGate = yield* Deferred.make(); + const stopStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ stopGate, stopStarted }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const waiter = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.quiesce); + yield* Deferred.await(stopStarted); + yield* Fiber.interrupt(waiter); + yield* Deferred.succeed(stopGate, undefined); + yield* fixture.supervisor.shutdown; + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + }), + ), + ); + it.live("recovers a running durable intent and starts missing eager workloads", () => run( Effect.gen(function* () { @@ -1071,6 +1124,114 @@ describe("Supervisor composition", () => { ), ); + it.live("keeps an owner attachable until persisted plan recovery validates", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined || running.definition === undefined) + return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); + const invalid = { + ...running, + definition: { + ...running.definition, + capabilities: { + ...running.definition.capabilities, + rest: { ...running.definition.capabilities.rest, version: "unsupported" }, + }, + }, + }; + yield* fixture.store + .replace(fixture.id, invalid, running.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + const recovered = yield* makeSupervisor({ + identity, + stackId: fixture.id, + ownerSessionId: "replacement-owner", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: { + driver: { + observe: () => Effect.succeed([]), + start: (key) => Effect.succeed({ ...key, state: "ready" as const }), + stop: () => Effect.void, + remove: () => Effect.void, + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), + }, + }, + }); + expect((yield* recovered.status).lifecycle).toBe("starting"); + yield* recovered.recover; + const status = yield* recovered.status; + expect(status.lifecycle).toBe("stopped"); + expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); + }), + ), + ); + + it.live("exposes recovery failure in status and permits a retry", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const failFirst = yield* Ref.make(true); + const recoveredResources = yield* Ref.make>([]); + const recovered = yield* makeSupervisor({ + identity, + stackId: fixture.id, + ownerSessionId: "recovery-owner", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: { + driver: { + observe: () => Ref.get(recoveredResources), + start: (key) => + Effect.gen(function* () { + const ready = { ...key, state: "ready" as const }; + yield* Ref.update(recoveredResources, (current) => [ + ...current.filter((entry) => entry.workloadId !== key.workloadId), + ready, + ]); + return ready; + }), + stop: (key) => + Ref.update(recoveredResources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ), + remove: (key) => + Ref.update(recoveredResources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ), + cleanup: () => Ref.set(recoveredResources, []), + recover: () => + Effect.gen(function* () { + if (yield* Ref.get(failFirst)) { + yield* Ref.set(failFirst, false); + return yield* new RuntimeDriverError({ message: "injected recovery failure" }); + } + return []; + }), + }, + }, + }); + yield* recovered.recover; + const failedStatus = yield* recovered.status; + expect(failedStatus.lifecycle).toBe("stopped"); + expect(failedStatus.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); + yield* recovered.start({ config: { capabilities: { rest: {} } } }); + expect((yield* recovered.status).lifecycle).toBe("running"); + yield* recovered.recover; + expect((yield* recovered.status).lifecycle).toBe("running"); + }), + ), + ); + it.live("acquires ingress before eager workloads and opens it after readiness", () => run( Effect.gen(function* () { From 961f4a0c55d021ea04c1c5b015ca262ac4a4631a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 09:19:26 +0200 Subject: [PATCH 073/174] fix(stack): allow supervisor readiness startup time --- .../plans/2026-08-28-stack-runtime-rewrite-progress.md | 2 +- packages/stack/src/supervisor/Launcher.ts | 4 ++-- packages/stack/src/supervisor/handles.integration.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index e2e212f409..1e1f0c19ad 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -328,7 +328,7 @@ Implementation continues after recording a ruling; this file is not a question q - Added detached Supervisor launching through Effect's `ChildProcessSpawner` and fd3 readiness handoff. Concurrent losers join winner metadata through a pre-started filesystem watcher plus a bounded Schedule reread that closes the platform watch-acquisition gap. Every returned owner is - release/session/probe validated. Readiness has a five-second deadline and terminates the exact child + release/session/probe validated. Readiness has a thirty-second deadline and terminates the exact child and observer on timeout, failure, or caller interruption. Owner processes outlive caller handles. - Added managed Effect handles and observational discovery (`openStack`, `findStack`, `listStacks`, `inspectStack`), truthful unconfigured/stopped status, operation-specific public errors, and exact diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index fc1f993850..75de347da4 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -87,7 +87,7 @@ interface LaunchPayload { } const mapFailure = (message: string) => new StackStateInvalidError({ message }); -const SUPERVISOR_READINESS_TIMEOUT_MS = 5_000; +const SUPERVISOR_READINESS_TIMEOUT_MS = 30_000; export interface LauncherOptions { readonly identity: StackIdentity; @@ -183,7 +183,7 @@ const observeOwner = ( // FileSystem.watch does not expose a subscription-ready signal. A short, // Schedule-driven reread closes the publication gap between the initial // absent read and watcher registration without ad-hoc timers. The outer - // launch timeout remains the single five-second guard. + // launch timeout remains the single thirty-second guard. const ownerFromReread = Stream.fromEffect( readOwnerMetadata(options.environment.stateRoot, options.stackId, options.environment), ).pipe( diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index e85de0ef58..0c028dcb4e 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -103,7 +103,7 @@ const quiesceOwner = (id: StackId) => const quoteModuleSpecifier = (value: string): string => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; -describe("managed stack handles", () => { +describe("managed stack handles", { timeout: 30_000 }, () => { it.live("resolves a new automatic container identity and persists Docker", () => withRuntimeRoot((project) => Effect.gen(function* () { From 335db22ae4743ba236de13d6887b7c3abb6e0f96 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 09:50:08 +0200 Subject: [PATCH 074/174] fix(stack): preserve accepted terminal operations --- ...26-08-28-stack-runtime-rewrite-progress.md | 12 +++ packages/stack/src/control/ControlServer.ts | 5 ++ packages/stack/src/supervisor/Supervisor.ts | 43 +++++++---- .../supervisor/handles.integration.test.ts | 76 +++++++++++++++++++ .../supervisor/supervisor.integration.test.ts | 35 +++++++++ 5 files changed, 155 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 1e1f0c19ad..a4647fe8fa 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -928,3 +928,15 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Start/restart identity uses compiler-normalized definitions and effective secret values in a canonical SHA-256 digest; equivalent defaults join, distinct transient secrets remain isolated. Focused supervisor/control/handle/lifecycle verification passed 74 integration tests with stack type-check, Effect lint, formatting, and diff checks green. + +#### Task 13B2b7 correction round 2 — owned interruption and recovery guards (2026-08-31) + +- Owned lifecycle waiters now retain and pass the exact accepted Deferred to interruption continuations; + quiesce and destroy therefore complete terminal shutdown after waiter timeout/disconnect without re-evaluating + the operation. Activation rejects while persisted recovery is failed and resumes only after explicit recovery. +- Client reset read/close errors are treated as normal per-connection completion while open/write failures remain + visible. A real handles flow persists an unsupported plan, observes failed status after fd3/control readiness, + probes the same owner, and restores state for scoped cleanup. +- Supervisor readiness uses a bounded thirty-second local deadline to accommodate real subprocess startup; the + subprocess-backed handles suite carries the same test guard. Focused lifecycle/control/handles verification is + green with stack type-check, Effect lint, formatting, and diff checks. diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 2be433c99a..d380504b63 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -306,6 +306,11 @@ const demuxSocket = ( yield* socket.runRaw(processChunk, { onOpen }); yield* Fiber.interrupt(prefaceDeadline); }), + ).pipe( + Effect.catchReasons("SocketError", { + SocketReadError: () => Effect.void, + SocketCloseError: () => Effect.void, + }), ); return Socket.make({ diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 97d886ef6c..84444da3ef 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -514,6 +514,7 @@ export const makeSupervisor = ( slots: Map>, key: string, effect: Effect.Effect, + onWaiterInterrupt?: (owned: OwnedResult) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () { const owned = yield* admission.withPermit( @@ -538,7 +539,9 @@ export const makeSupervisor = ( return deferred; }), ); - const result = yield* Deferred.await(owned); + const result = yield* onWaiterInterrupt === undefined + ? Deferred.await(owned) + : Deferred.await(owned).pipe(Effect.onInterrupt(() => onWaiterInterrupt(owned))); return yield* joinExit(result); }); @@ -706,6 +709,10 @@ export const makeSupervisor = ( return yield* new StackNotRunningError({ message: "Stack must be running before activation", }); + if (yield* Ref.get(recoveryFailure)) + return yield* new StackNotRunningError({ + message: "Stack recovery has failed; start or restart the stack before activation", + }); const definition = state.definition; if (definition === undefined || !definition.capabilities[capability].enabled) return yield* new GatewayActivationError({ @@ -929,22 +936,21 @@ export const makeSupervisor = ( const stopOperation = () => controller.stop().pipe(Effect.provideContext(options.context)); const shutdownSignal = yield* Deferred.make(); const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); + const signalShutdownAfterSuccess = (owned: OwnedResult) => + Deferred.await(owned).pipe( + Effect.flatMap((result) => (Exit.isSuccess(result) ? signalShutdown : Effect.void)), + ); + const continueShutdownAfterInterrupt = (owned: OwnedResult) => + FiberSet.run(ownedFibers, signalShutdownAfterSuccess(owned), { + startImmediately: true, + }).pipe(Effect.asVoid); const stop = submitOwned(stopOwned, "stop", stopOperation()); const quiesceOwned = new Map>(); - const quiesceOwnedEffect = submitOwned(quiesceOwned, "quiesce", stopOperation()); - const quiesce = quiesceOwnedEffect.pipe( - Effect.onInterrupt(() => - FiberSet.run( - ownedFibers, - quiesceOwnedEffect.pipe( - Effect.matchEffect({ - onFailure: () => Effect.void, - onSuccess: () => signalShutdown, - }), - ), - { startImmediately: true }, - ), - ), + const quiesce = submitOwned( + quiesceOwned, + "quiesce", + stopOperation(), + continueShutdownAfterInterrupt, ); const operation = (effect: Effect.Effect, fallback: StackRpcError["tag"]) => effect.pipe( @@ -953,7 +959,12 @@ export const makeSupervisor = ( const destroyOperation = controller .destroy() .pipe(Effect.provideContext(options.context), Effect.andThen(Ref.set(phase, "stopped"))); - const destroy = submitOwned(destroyOwned, "destroy", destroyOperation).pipe(Effect.asVoid); + const destroy = submitOwned( + destroyOwned, + "destroy", + destroyOperation, + continueShutdownAfterInterrupt, + ).pipe(Effect.asVoid); const logs = (logOptions?: LogOptions): Stream.Stream => runtime.logStore === undefined ? Stream.fail(new StackNotRunningError({ message: "Stack logs are unavailable" })) diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 0c028dcb4e..8fa524d751 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -13,6 +13,7 @@ import { Stream, } from "effect"; import { ChildProcess } from "effect/unstable/process"; +import { compileStack } from "../model/Compiler.ts"; import { createStack, findStack, @@ -20,10 +21,12 @@ import { openStack, listStacks, } from "../public/EffectStack.ts"; +import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; import { defaultRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "./Launcher.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { StackId } from "../public/StackId.ts"; import { readOwnerMetadata, StackRuntimeEnvironment } from "../state/Ownership.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; import { makeControlClient } from "../control/ControlServer.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; @@ -329,6 +332,79 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); + it.live("publishes an attachable owner before corrupt persisted recovery fails", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const compiled = yield* compileStack({ + projectRoot: project, + runtime: { kind: "native" }, + config: { capabilities: { rest: {} } }, + }); + const corruptDefinition = { + ...compiled.definition, + capabilities: { + ...compiled.definition.capabilities, + rest: { ...compiled.definition.capabilities.rest, version: "unsupported" }, + }, + }; + const persisted = { + format: "supabase-stack-state-v1" as const, + identity: { ...identity, stackId }, + runtime: { kind: "native" } as const, + desiredGeneration: 1, + portsGeneration: null, + desiredLifecycle: "running" as const, + definition: corruptDefinition, + inputFingerprint: compiled.inputFingerprint, + ports: [], + privatePorts: [], + secrets: {}, + }; + yield* store.initialize(stackId, persisted); + const stack = yield* createStack({ + projectRoot: project, + runtime: { kind: "native" }, + }); + yield* Effect.gen(function* () { + const failed = yield* stack.watchStatus().pipe( + Stream.filter( + (status) => + status.lifecycle === "stopped" && + status.capabilities.some( + ({ name, state }) => name === "rest" && state === "failed", + ), + ), + Stream.runHead, + Effect.timeout("30 seconds"), + ); + const attached = Option.isSome(failed) ? yield* openStack(stack.id) : undefined; + if (attached !== undefined) { + const status = yield* attached.status(); + expect(status.lifecycle).toBe("stopped"); + expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); + yield* attached.close(); + } + expect(Option.isSome(failed)).toBe(true); + }).pipe( + Effect.ensuring( + store + .replace( + stack.id, + { ...persisted, definition: compiled.definition }, + persisted.desiredGeneration, + ) + .pipe(Effect.ignore), + ), + ); + yield* stack.close(); + }), + ), + ); + it.live("discovery never creates an identity", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 97f8c02fdd..e4dd99020e 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -181,6 +181,8 @@ const makeFixture = ( readonly preflightStarted?: Deferred.Deferred; readonly stopGate?: Deferred.Deferred; readonly stopStarted?: Deferred.Deferred; + readonly destroyGate?: Deferred.Deferred; + readonly destroyStarted?: Deferred.Deferred; readonly recoveryFailFirst?: Ref.Ref; readonly recoveryStarted?: Deferred.Deferred; } = {}, @@ -254,6 +256,10 @@ const makeFixture = ( ...current, `cleanup:${destroy ? "destroy" : "stop"}`, ]); + if (destroy && fixtureOptions.destroyStarted !== undefined) + yield* Deferred.succeed(fixtureOptions.destroyStarted, undefined); + if (destroy && fixtureOptions.destroyGate !== undefined) + yield* Deferred.await(fixtureOptions.destroyGate); if (!destroy && fixtureOptions.stopStarted !== undefined) yield* Deferred.succeed(fixtureOptions.stopStarted, undefined); if (!destroy && fixtureOptions.stopGate !== undefined) @@ -1076,6 +1082,23 @@ describe("Supervisor composition", () => { ), ); + it.live("completes accepted destroy after its waiter is interrupted", () => + run( + Effect.gen(function* () { + const destroyGate = yield* Deferred.make(); + const destroyStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ destroyGate, destroyStarted }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const waiter = yield* Effect.forkChild(fixture.supervisor.destroy); + yield* Deferred.await(destroyStarted); + yield* Fiber.interrupt(waiter); + yield* Deferred.succeed(destroyGate, undefined); + yield* fixture.supervisor.shutdown; + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + }), + ), + ); + it.live("recovers a running durable intent and starts missing eager workloads", () => run( Effect.gen(function* () { @@ -1180,6 +1203,8 @@ describe("Supervisor composition", () => { const fixture = yield* makeFixture(); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); const failFirst = yield* Ref.make(true); + const activationCalls = yield* Ref.make(0); + const startCalls = yield* Ref.make(0); const recoveredResources = yield* Ref.make>([]); const recovered = yield* makeSupervisor({ identity, @@ -1193,6 +1218,7 @@ describe("Supervisor composition", () => { observe: () => Ref.get(recoveredResources), start: (key) => Effect.gen(function* () { + yield* Ref.update(startCalls, (count) => count + 1); const ready = { ...key, state: "ready" as const }; yield* Ref.update(recoveredResources, (current) => [ ...current.filter((entry) => entry.workloadId !== key.workloadId), @@ -1218,12 +1244,21 @@ describe("Supervisor composition", () => { return []; }), }, + activate: () => + Ref.update(activationCalls, (count) => count + 1).pipe( + Effect.andThen(Effect.succeed({ host: "127.0.0.1", port: 9999 })), + ), }, }); yield* recovered.recover; const failedStatus = yield* recovered.status; expect(failedStatus.lifecycle).toBe("stopped"); expect(failedStatus.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); + const activation = yield* recovered.activate("rest").pipe(Effect.exit); + expect(Exit.isFailure(activation)).toBe(true); + expect(errorOf(activation)).toBeInstanceOf(StackNotRunningError); + expect(yield* Ref.get(activationCalls)).toBe(0); + expect(yield* Ref.get(startCalls)).toBe(0); yield* recovered.start({ config: { capabilities: { rest: {} } } }); expect((yield* recovered.status).lifecycle).toBe("running"); yield* recovered.recover; From 4d75a977c7c6afa4573124f0d14b2ae69513daad Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 10:25:37 +0200 Subject: [PATCH 075/174] fix(stack): complete terminal transport handoffs --- ...26-08-28-stack-runtime-rewrite-progress.md | 11 + packages/stack/src/control/ControlServer.ts | 113 +++++++--- .../control-transport.integration.test.ts | 200 +++++++++++++++++- .../stack/src/entrypoints/supervisor-node.ts | 3 + packages/stack/src/supervisor/OwnerSession.ts | 4 + 5 files changed, 295 insertions(+), 36 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index a4647fe8fa..809bf9824e 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -940,3 +940,14 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Supervisor readiness uses a bounded thirty-second local deadline to accommodate real subprocess startup; the subprocess-backed handles suite carries the same test guard. Focused lifecycle/control/handles verification is green with stack type-check, Effect lint, formatting, and diff checks. + +#### Task 13B2b7 correction round 3 — terminal transport handoffs (2026-08-31) + +- Destroy request disconnects are held until the accepted RPC exit is processed. A successful response write is the + post-response handoff even if the client closes immediately afterward; a disconnected or failed write uses the + explicit abandoned-destroy path, and failed destroy exits never signal owner shutdown. +- Quiesce retains its Supervisor-owned exact-operation continuation for interrupted waiters and adds a transport + abandoned path for successful responses interrupted before normal completion. Maintenance stop/probe remain + non-terminal, and raw client reset read/close errors complete only that connection. +- Focused supervisor/control/handle/lifecycle verification passed 79 integration tests with stack type-check, + Effect lint, formatting, and diff checks green. diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index d380504b63..00df03e42e 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -1,10 +1,12 @@ import { + Cause, Deferred, Effect, Exit, Fiber, FiberSet, FileSystem, + Option, Predicate, Queue, Schema, @@ -62,8 +64,12 @@ export interface ControlServerOptions extends ControlIdentity { readonly maintenanceHandlers: MaintenanceHandlers; /** Invoked only after the maintenance response has been written and the connection closed. */ readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; + /** Invoked when a successful quiesce has no writable response connection. */ + readonly onMaintenanceAbandoned?: (op: MaintenanceRequest["op"]) => Effect.Effect; /** Invoked only after a successful destroy RPC response has been written. */ readonly onDestroyResponse?: () => Effect.Effect; + /** Invoked when a successful destroy has no writable response connection. */ + readonly onDestroyAbandoned?: () => Effect.Effect; readonly rpcHandlers: StackRpcHandlers; } @@ -91,6 +97,14 @@ const protocolFailure = ( error: { tag, message: "Control request rejected" }, }); +const isResponseConnectionFailure = (cause: Cause.Cause): boolean => + Option.match(Cause.findErrorOption(cause), { + onNone: () => false, + onSome: (error) => + Predicate.isTagged(error.reason, "SocketWriteError") || + Predicate.isTagged(error.reason, "SocketCloseError"), + }); + /** Wrap one accepted socket. This is the only reader for the connection. */ const demuxSocket = ( socket: Socket.Socket, @@ -182,18 +196,48 @@ const demuxSocket = ( const response = Exit.isSuccess(result) ? result.value : protocolFailure("operation-failed"); - yield* sendJson(response); - yield* close; - yield* markPrefaceReady; - if (response.ok && options.onMaintenanceComplete !== undefined) { - // The connection scope closes as soon as the close frame is sent; - // completion belongs to the owner session and must outlive it. - yield* FiberSet.run( - completionFibers, - options.onMaintenanceComplete(request.value.op), - { startImmediately: true }, + let responseWritten = false; + const completion = Effect.gen(function* () { + yield* sendJson(response).pipe( + Effect.tap(() => + Effect.sync(() => { + responseWritten = true; + }), + ), ); - } + yield* close; + yield* markPrefaceReady; + if (response.ok && options.onMaintenanceComplete !== undefined) { + // The connection scope closes as soon as the close frame is sent; + // completion belongs to the owner session and must outlive it. + yield* FiberSet.run( + completionFibers, + options.onMaintenanceComplete(request.value.op), + { startImmediately: true }, + ); + } + }); + yield* completion.pipe( + Effect.onExit((exit) => { + if (!response.ok || request.value.op !== "quiesce" || Exit.isSuccess(exit)) + return Effect.void; + const connectionFailure = + Cause.hasInterruptsOnly(exit.cause) || isResponseConnectionFailure(exit.cause); + if (!connectionFailure) return Effect.void; + const callback = responseWritten + ? options.onMaintenanceComplete + : options.onMaintenanceAbandoned; + return callback === undefined + ? Effect.void + : FiberSet.run(completionFibers, callback(request.value.op), { + startImmediately: true, + }).pipe(Effect.asVoid); + }), + Effect.catchReasons("SocketError", { + SocketWriteError: () => Effect.void, + SocketCloseError: () => Effect.void, + }), + ); }); const processFrames = (input: Uint8Array): Effect.Effect => @@ -389,19 +433,25 @@ export const startControlServer = ( Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), ); const destroyRequests = new Set(); + const destroyDisconnects = new Set(); const disconnects = yield* Queue.unbounded(); yield* FiberSet.run( completionFibers, Effect.forever( Queue.take(protocol.disconnects).pipe( - Effect.tap((clientId) => - Effect.sync(() => { + Effect.flatMap((clientId) => + Effect.gen(function* () { const prefix = `${clientId}:`; - for (const request of destroyRequests) - if (request.startsWith(prefix)) destroyRequests.delete(request); + const hasPendingDestroy = [...destroyRequests].some((request) => + request.startsWith(prefix), + ); + if (hasPendingDestroy) { + destroyDisconnects.add(clientId); + return; + } + yield* Queue.offer(disconnects, clientId); }), ), - Effect.flatMap((clientId) => Queue.offer(disconnects, clientId)), Effect.asVoid, ), ), @@ -421,22 +471,23 @@ export const startControlServer = ( destroyRequests.has(`${clientId}:${String(response.requestId)}`) ? Effect.gen(function* () { const key = `${clientId}:${String(response.requestId)}`; - const connectedBefore = yield* protocol.clientIds; - if (!connectedBefore.has(clientId)) { - destroyRequests.delete(key); - return; + const connected = + !destroyDisconnects.has(clientId) && (yield* protocol.clientIds).has(clientId); + const sent = connected + ? yield* Effect.exit(protocol.send(clientId, response, transferables)) + : Exit.succeed(undefined); + const wroteResponse = connected && Exit.isSuccess(sent); + const successful = Predicate.isTagged(response.exit, "Success"); + destroyRequests.delete(key); + if (successful) { + if (wroteResponse && options.onDestroyResponse !== undefined) + yield* options.onDestroyResponse(); + if (!wroteResponse && options.onDestroyAbandoned !== undefined) + yield* options.onDestroyAbandoned(); + } + if (![...destroyRequests].some((request) => request.startsWith(`${clientId}:`))) { + if (destroyDisconnects.delete(clientId)) yield* Queue.offer(disconnects, clientId); } - yield* protocol.send(clientId, response, transferables); - const connectedAfter = yield* protocol.clientIds; - const isDestroy = destroyRequests.delete(key); - if ( - !connectedAfter.has(clientId) || - !isDestroy || - !Predicate.isTagged(response.exit, "Success") || - options.onDestroyResponse === undefined - ) - return; - yield* options.onDestroyResponse(); }) : protocol.send(clientId, response, transferables), }); diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 7fc5b8c88a..ae1e55669f 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -44,7 +44,9 @@ interface ServerOverrides { readonly rpcHandlers?: Partial; readonly maintenanceHandlers?: Partial; readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; + readonly onMaintenanceAbandoned?: (op: MaintenanceRequest["op"]) => Effect.Effect; readonly onDestroyResponse?: () => Effect.Effect; + readonly onDestroyAbandoned?: () => Effect.Effect; } const withPlatform = (effect: Effect.Effect) => @@ -66,6 +68,7 @@ const withServer = ( readonly stackId: string; readonly ownerSessionId: string; readonly completion: Deferred.Deferred; + readonly abandoned: Deferred.Deferred; readonly completionStarted: Deferred.Deferred; readonly completionRelease: Deferred.Deferred; readonly stopCalls: Ref.Ref; @@ -76,6 +79,7 @@ const withServer = ( readonly ownerSessionId: string; readonly status: StackStatus; readonly completion: Deferred.Deferred; + readonly abandoned: Deferred.Deferred; readonly completionStarted: Deferred.Deferred; readonly completionRelease: Deferred.Deferred; readonly stopCalls: Ref.Ref; @@ -91,6 +95,7 @@ const withServer = ( const stackId = yield* deriveStackId(testIdentity); const ownerSessionId = yield* crypto.randomUUIDv4; const completion = yield* Deferred.make(); + const abandoned = yield* Deferred.make(); const completionStarted = yield* Deferred.make(); const completionRelease = yield* Deferred.make(); const stopCalls = yield* Ref.make(0); @@ -150,6 +155,7 @@ const withServer = ( ownerSessionId, status, completion, + abandoned, completionStarted, completionRelease, stopCalls, @@ -162,7 +168,9 @@ const withServer = ( maintenanceHandlers: { ...defaultMaintenanceHandlers, ...custom.maintenanceHandlers }, rpcHandlers: { ...defaultRpcHandlers, ...custom.rpcHandlers }, onMaintenanceComplete: custom.onMaintenanceComplete, + onMaintenanceAbandoned: custom.onMaintenanceAbandoned, onDestroyResponse: custom.onDestroyResponse, + onDestroyAbandoned: custom.onDestroyAbandoned, }; yield* startControlServer(options); const info = yield* fs.stat(endpoint.path); @@ -172,6 +180,7 @@ const withServer = ( stackId, ownerSessionId, completion, + abandoned, completionStarted, completionRelease, stopCalls, @@ -295,9 +304,54 @@ describe("control transport", () => { ), ); - it.live("does not signal destroy shutdown after the RPC client disconnects", () => + it.live("counts a successful destroy send before an immediate disconnect", () => withServer( - ({ endpoint, stackId, ownerSessionId, completionStarted, completionRelease, completion }) => + ({ endpoint, stackId, ownerSessionId, completion, abandoned }) => + Effect.gen(function* () { + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId, + ownerSessionId, + }); + const frame = yield* makeDestroyRequestFrame(); + const response = yield* sendRawSequenceAndReadFrame(endpoint, [ + concatBytes(preface, frame), + ]); + expect(response).toMatchObject({ _tag: "Exit", requestId: "1" }); + yield* Deferred.await(completion).pipe( + Effect.timeoutOrElse({ + duration: 1_000, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ message: "destroy completion was not signaled" }), + ), + }), + ); + const abandonedExit = yield* Deferred.await(abandoned).pipe( + Effect.timeout("100 millis"), + Effect.exit, + ); + expect(Exit.isFailure(abandonedExit)).toBe(true); + }), + ({ completion, abandoned }) => ({ + onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + onDestroyAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), + }), + ), + ); + + it.live("signals abandoned destroy shutdown after the RPC client disconnects", () => + withServer( + ({ + endpoint, + stackId, + ownerSessionId, + completionStarted, + completionRelease, + completion, + abandoned, + }) => Effect.gen(function* () { const socket = yield* NodeSocket.makeNet({ path: endpoint.kind === "unix" ? endpoint.path : endpoint.name, @@ -328,14 +382,23 @@ describe("control transport", () => { ); yield* write(new Socket.CloseEvent(1000)); yield* Deferred.succeed(completionRelease, undefined); - const result = yield* Deferred.await(completion).pipe( + yield* Deferred.await(abandoned).pipe( + Effect.timeoutOrElse({ + duration: 1_000, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ message: "abandoned destroy was not signaled" }), + ), + }), + ); + const postResponse = yield* Deferred.await(completion).pipe( Effect.timeout("100 millis"), Effect.exit, ); - expect(Exit.isFailure(result)).toBe(true); + expect(Exit.isFailure(postResponse)).toBe(true); yield* Fiber.interrupt(reader); }), - ({ completionStarted, completionRelease, completion }) => ({ + ({ completionStarted, completionRelease, completion, abandoned }) => ({ rpcHandlers: { destroy: () => Deferred.succeed(completionStarted, undefined).pipe( @@ -344,6 +407,133 @@ describe("control transport", () => { ), }, onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + onDestroyAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), + }), + ), + ); + + it.live("does not signal abandoned destroy shutdown after a failed operation", () => + withServer( + ({ endpoint, stackId, ownerSessionId, completionStarted, completionRelease, abandoned }) => + Effect.gen(function* () { + const socket = yield* NodeSocket.makeNet({ + path: endpoint.kind === "unix" ? endpoint.path : endpoint.name, + }); + const write = yield* socket.writer; + const reader = yield* Effect.forkChild( + socket.runRaw(() => Effect.void), + { + startImmediately: true, + }, + ); + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId, + ownerSessionId, + }); + const frame = yield* makeDestroyRequestFrame(); + yield* write(concatBytes(preface, frame)); + yield* Deferred.await(completionStarted).pipe( + Effect.timeoutOrElse({ + duration: 1_000, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ message: "destroy handler did not start" }), + ), + }), + ); + yield* write(new Socket.CloseEvent(1000)); + yield* Deferred.succeed(completionRelease, undefined); + const result = yield* Deferred.await(abandoned).pipe( + Effect.timeout("100 millis"), + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + yield* Fiber.interrupt(reader); + }), + ({ completionStarted, completionRelease }) => ({ + rpcHandlers: { + destroy: () => + Effect.gen(function* () { + yield* Deferred.succeed(completionStarted, undefined); + yield* Deferred.await(completionRelease); + return yield* Effect.fail({ + tag: "StackDestructionError" as const, + message: "injected destroy failure", + }); + }), + }, + }), + ), + ); + + it.live("signals abandoned quiesce shutdown when the response connection is lost", () => + withServer( + ({ + endpoint, + stackId, + ownerSessionId, + completionStarted, + completionRelease, + completion, + abandoned, + }) => + Effect.gen(function* () { + const socket = yield* NodeSocket.makeNet({ + path: endpoint.kind === "unix" ? endpoint.path : endpoint.name, + }); + const write = yield* socket.writer; + const reader = yield* Effect.forkChild( + socket.runRaw(() => Effect.void), + { + startImmediately: true, + }, + ); + const preface = encodePreface({ + kind: "maintenance", + release: "maintenance-v1", + stackId, + ownerSessionId, + }); + const frame = yield* encodeFrame({ op: "quiesce", stackId, ownerSessionId }); + yield* write(concatBytes(preface, frame)); + yield* Deferred.await(completionStarted).pipe( + Effect.timeoutOrElse({ + duration: 1_000, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ message: "quiesce handler did not start" }), + ), + }), + ); + yield* write(new Socket.CloseEvent(1000)); + yield* Deferred.succeed(completionRelease, undefined); + yield* Deferred.await(abandoned).pipe( + Effect.timeoutOrElse({ + duration: 1_000, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ message: "abandoned quiesce was not signaled" }), + ), + }), + ); + const postResponse = yield* Deferred.await(completion).pipe( + Effect.timeout("100 millis"), + Effect.exit, + ); + expect(Exit.isFailure(postResponse)).toBe(true); + yield* Fiber.interrupt(reader); + }), + ({ completionStarted, completionRelease, completion, abandoned }) => ({ + maintenanceHandlers: { + quiesce: Deferred.succeed(completionStarted, undefined).pipe( + Effect.andThen(Deferred.await(completionRelease)), + Effect.as({ ok: true, op: "quiesce" }), + ), + }, + onMaintenanceComplete: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + onMaintenanceAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), }), ), ); diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index f4216a4ea7..b1a2f4dbea 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -127,7 +127,10 @@ export const runSupervisor = (args: SupervisorArgs) => maintenanceHandlers: supervisor.maintenanceHandlers, rpcHandlers: supervisor.rpcHandlers, onMaintenanceComplete: (op) => (op === "quiesce" ? supervisor.signalShutdown : Effect.void), + onMaintenanceAbandoned: (op) => + op === "quiesce" ? supervisor.signalShutdown : Effect.void, onDestroyResponse: () => supervisor.signalShutdown, + onDestroyAbandoned: () => supervisor.signalShutdown, }); yield* session.ready; yield* publishOwnership(lease); diff --git a/packages/stack/src/supervisor/OwnerSession.ts b/packages/stack/src/supervisor/OwnerSession.ts index 28b988c3f9..1d2faf71e4 100644 --- a/packages/stack/src/supervisor/OwnerSession.ts +++ b/packages/stack/src/supervisor/OwnerSession.ts @@ -24,8 +24,12 @@ export interface OwnerSessionOptions { readonly maintenanceHandlers: MaintenanceHandlers; /** Called after a stop/quiesce response is written and the connection closes. */ readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; + /** Called when a successful quiesce has no writable response connection. */ + readonly onMaintenanceAbandoned?: (op: MaintenanceRequest["op"]) => Effect.Effect; /** Called after a successful destroy response is written. */ readonly onDestroyResponse?: () => Effect.Effect; + /** Called when a successful destroy has no writable response connection. */ + readonly onDestroyAbandoned?: () => Effect.Effect; readonly rpcHandlers: StackRpcHandlers; } From bbea5107e0f64ca6aa176815b39d71fb4efe266a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 10:42:35 +0200 Subject: [PATCH 076/174] fix(stack): preserve RPC request defects --- ...26-08-28-stack-runtime-rewrite-progress.md | 2 +- packages/stack/src/control/ControlServer.ts | 1 + .../control-transport.integration.test.ts | 45 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 809bf9824e..368702833a 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -949,5 +949,5 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Quiesce retains its Supervisor-owned exact-operation continuation for interrupted waiters and adds a transport abandoned path for successful responses interrupted before normal completion. Maintenance stop/probe remain non-terminal, and raw client reset read/close errors complete only that connection. -- Focused supervisor/control/handle/lifecycle verification passed 79 integration tests with stack type-check, +- Focused supervisor/control/handle/lifecycle verification passed 80 integration tests with stack type-check, Effect lint, formatting, and diff checks green. diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 00df03e42e..772a8a437b 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -493,6 +493,7 @@ export const startControlServer = ( }); const rpcProgram = RpcServer.make(StackRpcGroup, { disableTracing: true, + disableFatalDefects: true, concurrency: MAINTENANCE_MAX_CONCURRENT_REQUESTS, }).pipe( Effect.provideService(RpcServer.Protocol, patchedProtocol), diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index ae1e55669f..f9fef494ac 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -468,6 +468,51 @@ describe("control transport", () => { ), ); + it.live("returns a request-scoped defect for destroy and keeps the endpoint usable", () => + withServer( + ({ endpoint, stackId, ownerSessionId, completion, abandoned }) => + Effect.gen(function* () { + const preface = encodePreface({ + kind: "rpc", + release: "stack-rpc-v1@0.1.0", + stackId, + ownerSessionId, + }); + const frame = yield* makeDestroyRequestFrame(); + const response = yield* sendRawSequenceAndReadFrame(endpoint, [ + concatBytes(preface, frame), + ]); + expect(response).toMatchObject({ + _tag: "Exit", + requestId: "1", + exit: { _tag: "Failure" }, + }); + const completionExit = yield* Deferred.await(completion).pipe( + Effect.timeout("100 millis"), + Effect.exit, + ); + const abandonedExit = yield* Deferred.await(abandoned).pipe( + Effect.timeout("100 millis"), + Effect.exit, + ); + expect(Exit.isFailure(completionExit)).toBe(true); + expect(Exit.isFailure(abandonedExit)).toBe(true); + + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpc = yield* client.rpc; + const status = yield* rpc.status(undefined); + expect(status.id).toBe(stackId); + }), + ({ completion, abandoned }) => ({ + rpcHandlers: { + destroy: () => Effect.die(new Error("injected destroy defect")), + }, + onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + onDestroyAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), + }), + ), + ); + it.live("signals abandoned quiesce shutdown when the response connection is lost", () => withServer( ({ From 824421f091301c054b3e9565602e0f2495a101bb Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 11:12:28 +0200 Subject: [PATCH 077/174] fix(stack): close lifecycle freshness windows --- ...26-08-28-stack-runtime-rewrite-progress.md | 14 +++ packages/stack/src/control/ControlServer.ts | 98 +++++++++++-------- packages/stack/src/supervisor/Launcher.ts | 17 +++- packages/stack/src/supervisor/Supervisor.ts | 85 ++++++++++------ .../supervisor/supervisor.integration.test.ts | 52 +++++++++- 5 files changed, 187 insertions(+), 79 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 368702833a..bfef1d9201 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -951,3 +951,17 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g non-terminal, and raw client reset read/close errors complete only that connection. - Focused supervisor/control/handle/lifecycle verification passed 80 integration tests with stack type-check, Effect lint, formatting, and diff checks green. + +#### Task 13B2b7 Fable delta — freshness and atomic handoffs (2026-08-31) + +- Accepted Fable findings 1.1–1.4 and the generation-bump regression from 1.5; deferred the duplicate candidate + materialization and classifier/test-scaffolding refactors as disproportionate to this minimal slice. +- Deferred recovery now re-reads persisted state after acquiring execution. Superseded generation/lifecycle/input + becomes a no-op owned by the newer lifecycle operation, while generation adoption refuses stale backward moves. + A restart-before-recovery regression preserves running status, generation, and credentials. +- Owned waiter acceptance and continuation registration are now masked atomically, restoring interruption only for + the Deferred wait. Maintenance response classification is attached before operation evaluation and guarded so + normal completion cannot be forked twice. +- Launcher owner rereads now derive their 25ms schedule budget from the bounded 30-second readiness deadline. + Focused lifecycle/control/handle verification passed 81 integration tests with stack type-check, Effect lint, + formatting, and diff checks green. diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 772a8a437b..aff163c067 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -159,8 +159,14 @@ const demuxSocket = ( ), ); - const dispatchMaintenance = (frame: Uint8Array): Effect.Effect => - Effect.gen(function* () { + const dispatchMaintenance = ( + frame: Uint8Array, + ): Effect.Effect => { + let responseValue: MaintenanceResponse | undefined; + let operationName: MaintenanceRequest["op"] | undefined; + let responseWritten = false; + let completionStarted = false; + const dispatch = Effect.gen(function* () { const decoded = yield* Effect.exit(decodeFrame(frame)); if (Exit.isFailure(decoded)) { yield* close; @@ -186,6 +192,7 @@ const demuxSocket = ( yield* close; return; } + operationName = request.value.op; const operation = request.value.op === "probe" ? options.maintenanceHandlers.probe @@ -196,49 +203,51 @@ const demuxSocket = ( const response = Exit.isSuccess(result) ? result.value : protocolFailure("operation-failed"); - let responseWritten = false; - const completion = Effect.gen(function* () { - yield* sendJson(response).pipe( - Effect.tap(() => - Effect.sync(() => { - responseWritten = true; - }), - ), - ); - yield* close; - yield* markPrefaceReady; - if (response.ok && options.onMaintenanceComplete !== undefined) { - // The connection scope closes as soon as the close frame is sent; - // completion belongs to the owner session and must outlive it. - yield* FiberSet.run( - completionFibers, - options.onMaintenanceComplete(request.value.op), - { startImmediately: true }, - ); - } - }); - yield* completion.pipe( - Effect.onExit((exit) => { - if (!response.ok || request.value.op !== "quiesce" || Exit.isSuccess(exit)) - return Effect.void; - const connectionFailure = - Cause.hasInterruptsOnly(exit.cause) || isResponseConnectionFailure(exit.cause); - if (!connectionFailure) return Effect.void; - const callback = responseWritten - ? options.onMaintenanceComplete - : options.onMaintenanceAbandoned; - return callback === undefined - ? Effect.void - : FiberSet.run(completionFibers, callback(request.value.op), { - startImmediately: true, - }).pipe(Effect.asVoid); - }), - Effect.catchReasons("SocketError", { - SocketWriteError: () => Effect.void, - SocketCloseError: () => Effect.void, - }), + responseValue = response; + yield* sendJson(response).pipe( + Effect.tap(() => + Effect.sync(() => { + responseWritten = true; + }), + ), ); + yield* close; + yield* markPrefaceReady; + if (response.ok && options.onMaintenanceComplete !== undefined) { + // The connection scope closes as soon as the close frame is sent; + // completion belongs to the owner session and must outlive it. + completionStarted = true; + yield* FiberSet.run( + completionFibers, + options.onMaintenanceComplete(request.value.op), + { startImmediately: true }, + ); + } }); + return dispatch.pipe( + Effect.onExit((exit) => { + const response = responseValue; + if (response === undefined || !response.ok || operationName !== "quiesce") + return Effect.void; + if (Exit.isSuccess(exit)) return Effect.void; + const connectionFailure = + Cause.hasInterruptsOnly(exit.cause) || isResponseConnectionFailure(exit.cause); + if (!connectionFailure || (responseWritten && completionStarted)) return Effect.void; + const callback = responseWritten + ? options.onMaintenanceComplete + : options.onMaintenanceAbandoned; + if (callback === undefined) return Effect.void; + completionStarted = true; + return FiberSet.run(completionFibers, callback("quiesce"), { + startImmediately: true, + }).pipe(Effect.asVoid); + }), + Effect.catchReasons("SocketError", { + SocketWriteError: () => Effect.void, + SocketCloseError: () => Effect.void, + }), + ); + }; const processFrames = (input: Uint8Array): Effect.Effect => Effect.gen(function* () { @@ -491,6 +500,9 @@ export const startControlServer = ( }) : protocol.send(clientId, response, transferables), }); + // Keep handler defects as keyed Exit responses so destroy request state can be cleaned by + // the same send path as typed failures. Without this option RpcServer emits a client-level + // Defect frame that has no requestId for the terminal handoff. const rpcProgram = RpcServer.make(StackRpcGroup, { disableTracing: true, disableFatalDefects: true, diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 75de347da4..c5cadcc3e3 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -88,6 +88,10 @@ interface LaunchPayload { const mapFailure = (message: string) => new StackStateInvalidError({ message }); const SUPERVISOR_READINESS_TIMEOUT_MS = 30_000; +const SUPERVISOR_REREAD_INTERVAL_MS = 25; +const SUPERVISOR_READINESS_REREAD_TIMES = Math.floor( + SUPERVISOR_READINESS_TIMEOUT_MS / SUPERVISOR_REREAD_INTERVAL_MS, +); export interface LauncherOptions { readonly identity: StackIdentity; @@ -180,14 +184,17 @@ const observeOwner = ( ), ), ); - // FileSystem.watch does not expose a subscription-ready signal. A short, - // Schedule-driven reread closes the publication gap between the initial - // absent read and watcher registration without ad-hoc timers. The outer - // launch timeout remains the single thirty-second guard. + // FileSystem.watch does not expose a subscription-ready signal. A bounded, + // Schedule-driven reread closes the publication gap between the initial absent + // read and watcher registration across the same budget as the launch guard. const ownerFromReread = Stream.fromEffect( readOwnerMetadata(options.environment.stateRoot, options.stackId, options.environment), ).pipe( - Stream.repeat(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), + Stream.repeat( + Schedule.spaced(`${SUPERVISOR_REREAD_INTERVAL_MS} millis`).pipe( + Schedule.upTo({ times: SUPERVISOR_READINESS_REREAD_TIMES }), + ), + ), Stream.filterMap((metadata) => metadata === undefined ? Result.fail(undefined) : Result.succeed(metadata), ), diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 84444da3ef..d8eb6b24e5 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -447,6 +447,11 @@ export const makeSupervisor = ( Effect.gen(function* () { const current = yield* Ref.get(generation); if (current === input.generation && input.desiredLifecycle === "running") return; + if (input.generation < current) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: "Lifecycle input generation is older than the adopted generation", + }); yield* Ref.set(generation, input.generation); activationOwned.clear(); yield* initializeActivation(input.plan); @@ -517,31 +522,37 @@ export const makeSupervisor = ( onWaiterInterrupt?: (owned: OwnedResult) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () { - const owned = yield* admission.withPermit( + const result = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const current = slots.get(key); - if (current !== undefined) return current; - const deferred = yield* Deferred.make, never>(); - slots.set(key, deferred); - const owner = Effect.gen(function* () { - const result = yield* execution.withPermit(effect).pipe(Effect.exit); - yield* Deferred.succeed(deferred, result); - }).pipe( - Effect.ensuring( - admission.withPermit( - Effect.sync(() => { - if (slots.get(key) === deferred) slots.delete(key); - }), - ), - ), + const owned = yield* admission.withPermit( + Effect.gen(function* () { + const current = slots.get(key); + if (current !== undefined) return current; + const deferred = yield* Deferred.make, never>(); + slots.set(key, deferred); + const owner = Effect.gen(function* () { + const result = yield* execution.withPermit(effect).pipe(Effect.exit); + yield* Deferred.succeed(deferred, result); + }).pipe( + Effect.ensuring( + admission.withPermit( + Effect.sync(() => { + if (slots.get(key) === deferred) slots.delete(key); + }), + ), + ), + ); + yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); + return deferred; + }), ); - yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); - return deferred; + const awaitResult = + onWaiterInterrupt === undefined + ? Deferred.await(owned) + : Deferred.await(owned).pipe(Effect.onInterrupt(() => onWaiterInterrupt(owned))); + return yield* restore(awaitResult); }), ); - const result = yield* onWaiterInterrupt === undefined - ? Deferred.await(owned) - : Deferred.await(owned).pipe(Effect.onInterrupt(() => onWaiterInterrupt(owned))); return yield* joinExit(result); }); @@ -627,8 +638,20 @@ export const makeSupervisor = ( const status = snapshot(); const recoveryOperation: Effect.Effect = Effect.gen(function* () { - const definition = initial.definition; - if (definition === undefined || initial.desiredLifecycle !== "running") { + const current = yield* read(); + if (current === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + // Recovery is deferred until after owner publication. A lifecycle operation may have + // superseded the construction snapshot while it waited for execution; that operation owns + // the current phase/generation, so stale recovery must become a no-op. + if ( + current.desiredGeneration !== initial.desiredGeneration || + current.desiredLifecycle !== initial.desiredLifecycle || + current.inputFingerprint !== initial.inputFingerprint + ) + return; + const definition = current.definition; + if (definition === undefined || current.desiredLifecycle !== "running") { yield* Ref.set(recoveryFailure, false); if (options.runtime !== undefined || options.runtimeFactory !== undefined) yield* runtime.driver @@ -645,7 +668,7 @@ export const makeSupervisor = ( yield* publish().pipe(Effect.ignore); const attempt = yield* Effect.exit( Effect.gen(function* () { - const plan = yield* rebuildExecutionPlan(initial.runtime, definition).pipe( + const plan = yield* rebuildExecutionPlan(current.runtime, definition).pipe( Effect.provideContext(options.context), Effect.mapError( (error) => new StackStateInvalidError({ message: error.message, cause: error }), @@ -653,19 +676,19 @@ export const makeSupervisor = ( ); const request: RuntimeRecoveryRequest = { stackId: options.stackId, - desiredGeneration: initial.desiredGeneration, + desiredGeneration: current.desiredGeneration, desiredLifecycle: "running", plan, }; const recoveryInput: LifecycleInput = { stackId: options.stackId, - generation: initial.desiredGeneration, + generation: current.desiredGeneration, desiredLifecycle: "running", - state: initial, - previous: initial, + state: current, + previous: current, definition, - inputFingerprint: initial.inputFingerprint ?? "", - secrets: initial.secrets, + inputFingerprint: current.inputFingerprint ?? "", + secrets: current.secrets, plan, }; yield* initializeActivation(plan); @@ -945,6 +968,8 @@ export const makeSupervisor = ( startImmediately: true, }).pipe(Effect.asVoid); const stop = submitOwned(stopOwned, "stop", stopOperation()); + // Keep stop and quiesce in separate slots: quiesce's waiter interruption is terminal, while + // ordinary maintenance stop must never inherit that shutdown continuation. const quiesceOwned = new Map>(); const quiesce = submitOwned( quiesceOwned, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index e4dd99020e..ae1c9a1b70 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -185,6 +185,7 @@ const makeFixture = ( readonly destroyStarted?: Deferred.Deferred; readonly recoveryFailFirst?: Ref.Ref; readonly recoveryStarted?: Deferred.Deferred; + readonly recoveryGate?: Deferred.Deferred; } = {}, ) => Effect.gen(function* () { @@ -270,6 +271,8 @@ const makeFixture = ( Effect.gen(function* () { if (fixtureOptions.recoveryStarted !== undefined) yield* Deferred.succeed(fixtureOptions.recoveryStarted, undefined); + if (fixtureOptions.recoveryGate !== undefined) + yield* Deferred.await(fixtureOptions.recoveryGate); if (fixtureOptions.recoveryFailFirst !== undefined) { const fail = yield* Ref.get(fixtureOptions.recoveryFailFirst); if (fail) { @@ -359,7 +362,7 @@ const makeFixture = ( runtime, }); yield* Ref.set(calls, []); - return { supervisor, calls, logOptions, failDestroy, context, store, id }; + return { supervisor, calls, logOptions, failDestroy, context, store, id, runtime }; }); const run = (effect: Effect.Effect) => @@ -1147,6 +1150,53 @@ describe("Supervisor composition", () => { ), ); + it.live("skips stale deferred recovery after restart adopts a newer generation", () => + run( + Effect.gen(function* () { + const stopGate = yield* Deferred.make(); + const stopStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ stopGate, stopStarted }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const recovered = yield* makeSupervisor({ + identity, + stackId: fixture.id, + ownerSessionId: "deferred-recovery-owner", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + const restart = yield* Effect.forkChild(recovered.restart()); + yield* Deferred.await(stopStarted); + const recover = yield* Effect.forkChild(recovered.recover); + yield* Deferred.succeed(stopGate, undefined); + yield* Fiber.join(restart); + yield* Fiber.join(recover); + const running = yield* fixture.store + .read(fixture.id) + .pipe(Effect.provideContext(fixture.context)); + if (running === undefined) + return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); + expect(running.desiredLifecycle).toBe("running"); + expect(running.desiredGeneration).toBe(3); + const withCredentials = { + ...running, + ports: [ + { field: "api", port: 55433, intent: "exact" as const }, + { field: "database", port: 55432, intent: "exact" as const }, + ] as const, + portsGeneration: running.desiredGeneration, + }; + yield* fixture.store + .replace(fixture.id, withCredentials, running.desiredGeneration) + .pipe(Effect.provideContext(fixture.context)); + expect((yield* recovered.status).lifecycle).toBe("running"); + const credentials = yield* invokeCredentials(recovered); + expect(Redacted.value(credentials.database.password)).toEqual(expect.any(String)); + }), + ), + ); + it.live("keeps an owner attachable until persisted plan recovery validates", () => run( Effect.gen(function* () { From f498726855da7e29daa9f7826c48a696cf5d1be6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 11:28:10 +0200 Subject: [PATCH 078/174] fix(stack): close completion witness race --- ...26-08-28-stack-runtime-rewrite-progress.md | 13 +++++++++ packages/stack/src/control/ControlServer.ts | 28 ++++++++++++------- .../supervisor/supervisor.integration.test.ts | 13 +++++---- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index bfef1d9201..81318bcefa 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -965,3 +965,16 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Launcher owner rereads now derive their 25ms schedule budget from the bounded 30-second readiness deadline. Focused lifecycle/control/handle verification passed 81 integration tests with stack type-check, Effect lint, formatting, and diff checks green. + +#### Task 13B2b7 Fable delta — completion witness and recovery retry (2026-08-31) + +- Accepted Fable findings 1.1, 1.2, and cleanup 3.1; deferred the duplicate-suppression test and classifier + refactor as disproportionate to this minimal slice. +- Maintenance completion callbacks now fork into the owner-scoped FiberSet and publish their completion witness + inside one uninterruptible handoff, so an interruption before the fork remains recoverable while a completed fork + cannot be classified twice. +- Recovery now has an explicit same-generation fail-then-retry regression: the first attempt exposes stopped/failed + status, the second succeeds without changing generation, and the driver is invoked exactly twice. Removed the + unused recovery gate from the shared fixture. +- Focused lifecycle/control/handle verification passed 81 integration tests with stack type-check, Effect lint, + formatting, and diff checks green. diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index aff163c067..50d8a8af64 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -166,6 +166,22 @@ const demuxSocket = ( let operationName: MaintenanceRequest["op"] | undefined; let responseWritten = false; let completionStarted = false; + const startCompletion = ( + callback: (op: MaintenanceRequest["op"]) => Effect.Effect, + op: MaintenanceRequest["op"], + ) => + Effect.uninterruptible( + FiberSet.run(completionFibers, callback(op), { startImmediately: true }).pipe( + // This witness is set only after the completion fiber has been + // handed to the owner-scoped FiberSet. The uninterruptible + // region keeps the fork and witness atomic to dispatch/onExit. + Effect.tap(() => + Effect.sync(() => { + completionStarted = true; + }), + ), + ), + ); const dispatch = Effect.gen(function* () { const decoded = yield* Effect.exit(decodeFrame(frame)); if (Exit.isFailure(decoded)) { @@ -216,12 +232,7 @@ const demuxSocket = ( if (response.ok && options.onMaintenanceComplete !== undefined) { // The connection scope closes as soon as the close frame is sent; // completion belongs to the owner session and must outlive it. - completionStarted = true; - yield* FiberSet.run( - completionFibers, - options.onMaintenanceComplete(request.value.op), - { startImmediately: true }, - ); + yield* startCompletion(options.onMaintenanceComplete, request.value.op); } }); return dispatch.pipe( @@ -237,10 +248,7 @@ const demuxSocket = ( ? options.onMaintenanceComplete : options.onMaintenanceAbandoned; if (callback === undefined) return Effect.void; - completionStarted = true; - return FiberSet.run(completionFibers, callback("quiesce"), { - startImmediately: true, - }).pipe(Effect.asVoid); + return startCompletion(callback, "quiesce").pipe(Effect.asVoid); }), Effect.catchReasons("SocketError", { SocketWriteError: () => Effect.void, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index ae1c9a1b70..33325c785e 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -185,7 +185,6 @@ const makeFixture = ( readonly destroyStarted?: Deferred.Deferred; readonly recoveryFailFirst?: Ref.Ref; readonly recoveryStarted?: Deferred.Deferred; - readonly recoveryGate?: Deferred.Deferred; } = {}, ) => Effect.gen(function* () { @@ -271,8 +270,6 @@ const makeFixture = ( Effect.gen(function* () { if (fixtureOptions.recoveryStarted !== undefined) yield* Deferred.succeed(fixtureOptions.recoveryStarted, undefined); - if (fixtureOptions.recoveryGate !== undefined) - yield* Deferred.await(fixtureOptions.recoveryGate); if (fixtureOptions.recoveryFailFirst !== undefined) { const fail = yield* Ref.get(fixtureOptions.recoveryFailFirst); if (fail) { @@ -1253,6 +1250,7 @@ describe("Supervisor composition", () => { const fixture = yield* makeFixture(); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); const failFirst = yield* Ref.make(true); + const recoverCalls = yield* Ref.make(0); const activationCalls = yield* Ref.make(0); const startCalls = yield* Ref.make(0); const recoveredResources = yield* Ref.make>([]); @@ -1287,6 +1285,7 @@ describe("Supervisor composition", () => { cleanup: () => Ref.set(recoveredResources, []), recover: () => Effect.gen(function* () { + yield* Ref.update(recoverCalls, (count) => count + 1); if (yield* Ref.get(failFirst)) { yield* Ref.set(failFirst, false); return yield* new RuntimeDriverError({ message: "injected recovery failure" }); @@ -1309,10 +1308,12 @@ describe("Supervisor composition", () => { expect(errorOf(activation)).toBeInstanceOf(StackNotRunningError); expect(yield* Ref.get(activationCalls)).toBe(0); expect(yield* Ref.get(startCalls)).toBe(0); - yield* recovered.start({ config: { capabilities: { rest: {} } } }); - expect((yield* recovered.status).lifecycle).toBe("running"); + const failedGeneration = failedStatus.desiredGeneration; yield* recovered.recover; - expect((yield* recovered.status).lifecycle).toBe("running"); + const retriedStatus = yield* recovered.status; + expect(retriedStatus.lifecycle).toBe("running"); + expect(retriedStatus.desiredGeneration).toBe(failedGeneration); + expect(yield* Ref.get(recoverCalls)).toBe(2); }), ), ); From d01a4c54f0fcb7b17bb1b80ab54631921e6539e2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 12:23:41 +0200 Subject: [PATCH 079/174] feat(stack): correct runtime input startup contracts --- ...26-08-28-stack-runtime-rewrite-progress.md | 16 ++ packages/stack/src/model/WorkloadCatalog.ts | 2 +- .../src/model/catalog.integration.test.ts | 5 + .../stack/src/runtime/RuntimeInputOwner.ts | 252 ++++++++++++++---- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 28 +- .../runtime-input-owner.integration.test.ts | 237 ++++++++++++++-- .../workload-runtime.integration.test.ts | 26 +- packages/stack/src/state/SecretStore.ts | 28 +- .../src/state/secrets.integration.test.ts | 31 ++- 9 files changed, 533 insertions(+), 92 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 81318bcefa..e57648a95e 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -978,3 +978,19 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g unused recovery gate from the shared fixture. - Focused lifecycle/control/handle verification passed 81 integration tests with stack type-check, Effect lint, formatting, and diff checks green. + +#### Task 14 — runtime-input and startup-contract corrections (2026-08-31) + +- Tightened persisted runtime inputs: PostgreSQL remains pinned to `17.6.1.166`; native/container Pooler + endpoints derive from persisted runtime state and private database assignments, tenant settings are persisted, + and tenant bootstrap updates existing Supavisor tenants idempotently with Elixir interpolation escaped. +- The owner now performs generation/state single-flight with owner-scoped Fibers, retries failed resolutions, + invalidates exact generations, and publishes atomic `0600` Pooler files only after Functions-secret validation. + Project files remain live canonical paths; OIDC diagnostics redact userinfo/query/fragment data while requests + retain configured URLs. Signing JWKS rejects mixed invalid files, preserves all private keys, and publishes + symmetric verification metadata. Auth material is resolved only when a JWT consumer is enabled. +- Startup contracts set imgproxy's filesystem root to `/`, require Storage's migration artifact, and pass native + Supavisor artifact paths as quoted positional arguments. Focused owner/secret/workload/catalog integration tests + pass 55 cases with stack type-check, Effect lint, formatting, and diff checks green. Production wiring remains + the next slice; `RuntimeInputOwner.resolve(state, generation)` now derives runtime from persisted state and the + owner requires an ambient `Scope` for its FiberSet. diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 0ecc379b4d..9dfecf2022 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -121,7 +121,7 @@ export const WORKLOAD_CATALOG: Readonly> = "v1.71.0", "ghcr.io/supabase/cli/storage:v1.71.0", "app/dist/start/server.js", - ["node/bin/node", "app/dist/start/server.js"], + ["node/bin/node", "app/dist/start/server.js", "app/dist/scripts/migrate-call.js"], ["v1.71.0"], { nativeProcess: { diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index 4971645513..10f688c1c7 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -86,6 +86,11 @@ describe("complete workload catalog", () => { expect(WORKLOAD_CATALOG["database:database"]?.requiredRuntimePaths).toContain( "share/supabase-cli/init-scripts", ); + expect(WORKLOAD_CATALOG["storage:storage"]?.requiredRuntimePaths).toEqual([ + "node/bin/node", + "app/dist/start/server.js", + "app/dist/scripts/migrate-call.js", + ]); expect(WORKLOAD_CATALOG["analytics:analytics"]?.requiredRuntimePaths).toEqual(["bin/logflare"]); expect(WORKLOAD_CATALOG["analytics:vector"]?.requiredRuntimePaths).toEqual([ "bin/vector", diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 9696cb7dce..9987f75588 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -1,5 +1,16 @@ -import { Crypto, Effect, FileSystem, Path, PlatformError, Schema } from "effect"; -import type { WorkloadRuntimeKind } from "./WorkloadRuntimeSpec.ts"; +import { + Crypto, + Deferred, + Effect, + Exit, + FileSystem, + FiberSet, + Path, + PlatformError, + Scope, + Schema, + Semaphore, +} from "effect"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackId } from "../public/StackId.ts"; import { StackPreparationError } from "../public/Errors.ts"; @@ -27,7 +38,7 @@ export interface RuntimeAuthTemplate { export interface RuntimeInputMaterial { readonly auth?: Readonly<{ readonly jwtKeys?: string; - readonly jwks?: string; + readonly jwks: string; readonly templates?: ReadonlyArray; }>; readonly analytics?: Readonly<{ readonly gcpJwtPath?: string }>; @@ -40,7 +51,6 @@ export interface RuntimeInputOwner { readonly resolve: ( state: PersistedStackState, generation: number, - runtime: WorkloadRuntimeKind, ) => Effect.Effect; /** Resolves one configured project-relative regular file without copying it. */ readonly resolveProjectFile: ( @@ -69,7 +79,9 @@ const mapFile = ( operation: string, effect: Effect.Effect, ): Effect.Effect => - effect.pipe(Effect.mapError(() => failure(`Unable to ${operation}`, { path: target }))); + effect.pipe( + Effect.mapError((error) => failure(`Unable to ${operation}`, { path: target, cause: error })), + ); const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); @@ -92,9 +104,9 @@ const settingsFor = (state: PersistedStackState, capability: CapabilitySettingNa const invalidGeneration = (generation: number): boolean => !Number.isSafeInteger(generation) || generation < 0; -const finiteSetting = (value: string, fallback: number): number => { +const finiteSetting = (value: string): number | undefined => { const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; + return Number.isFinite(parsed) ? parsed : undefined; }; const relativeEscape = (path: Path.Path, root: string, candidate: string): boolean => { @@ -148,15 +160,21 @@ const base64UrlEncode = (bytes: Uint8Array): string => { const symmetricJwk = (secret: string): Readonly> => ({ kty: "oct", + alg: "HS256", + use: "sig", + key_ops: ["verify"], k: base64UrlEncode(new TextEncoder().encode(secret)), }); const elixirString = (value: string): string => { let output = '"'; - for (const character of value) { + const characters = [...value]; + for (let index = 0; index < characters.length; index += 1) { + const character = characters[index] ?? ""; const code = character.codePointAt(0) ?? 0; if (character === "\\") output += "\\\\"; else if (character === '"') output += '\\"'; + else if (character === "#" && characters[index + 1] === "{") output += "\\#"; else if (character === "\n") output += "\\n"; else if (character === "\r") output += "\\r"; else if (character === "\t") output += "\\t"; @@ -167,7 +185,9 @@ const elixirString = (value: string): string => { }; const poolerTenantScript = (input: { + readonly externalId: string; readonly dbHost: string; + readonly dbPort: number; readonly dbPassword: string; readonly poolMode: "transaction" | "session"; readonly defaultPoolSize: number; @@ -181,9 +201,9 @@ const poolerTenantScript = (input: { end params = %{ - "external_id" => "pooler-dev", + "external_id" => ${elixirString(input.externalId)}, "db_host" => ${elixirString(input.dbHost)}, - "db_port" => 5432, + "db_port" => ${String(input.dbPort)}, "db_database" => "postgres", "require_user" => false, "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", @@ -199,8 +219,12 @@ params = %{ }] } -if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do +case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do + nil -> {:ok, _} = Supavisor.Tenants.create_tenant(params) + existing -> + existing = Supavisor.Repo.preload(existing, :users) + {:ok, _} = Supavisor.Tenants.update_tenant(existing, params) end `; @@ -216,7 +240,7 @@ export const resolveFunctionsEdgeRuntimeSecrets = ( for (const [name, raw] of Object.entries(configured)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) return Effect.fail(failure("Functions Edge Runtime secret name is invalid", { name })); - if (name.startsWith("SUPABASE_") || name.startsWith("SUPABASE_INTERNAL_")) + if (name.startsWith("SUPABASE_")) return Effect.fail(failure("Functions Edge Runtime secret name is reserved", { name })); const value = settingValue(state, raw); // oxlint-disable-next-line no-control-regex @@ -232,12 +256,16 @@ export const makeRuntimeInputOwner = ( ): Effect.Effect< RuntimeInputOwner, StackPreparationError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto + FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; + const admission = yield* Semaphore.make(1); + const execution = yield* Semaphore.make(1); + const ownerScope = yield* Effect.scope; + const ownedFibers = yield* FiberSet.make().pipe(Effect.provideService(Scope.Scope, ownerScope)); const stackPaths = yield* resolveStackPaths(options).pipe( Effect.mapError((cause) => failure("Unable to resolve runtime input paths", { cause })), ); @@ -317,8 +345,21 @@ export const makeRuntimeInputOwner = ( if (fetchJson === undefined) return Effect.fail(failure("OIDC discovery requires an injected JSON fetcher")); const discoveryUrl = `${issuer.replace(/\/+$/u, "")}/.well-known/openid-configuration`; - return fetchJson(discoveryUrl).pipe( - Effect.flatMap(decodeJson), + const fetchAt = ( + url: string, + operation: string, + ): Effect.Effect => + fetchJson(url).pipe( + Effect.mapError(() => failure(`${operation} request failed`, { url: safeUrlLabel(url) })), + ); + return fetchAt(discoveryUrl, "OIDC discovery").pipe( + Effect.flatMap((value) => + decodeJson(value).pipe( + Effect.mapError(() => + failure("OIDC discovery response is invalid", { url: safeUrlLabel(discoveryUrl) }), + ), + ), + ), Effect.flatMap((value) => Schema.decodeUnknownEffect(oidcDiscovery)(value).pipe( Effect.mapError(() => @@ -334,8 +375,14 @@ export const makeRuntimeInputOwner = ( url: safeUrlLabel(discoveryUrl), }), ); - return fetchJson(jwksUrl).pipe( - Effect.flatMap(decodeJson), + return fetchAt(jwksUrl, "OIDC JWKS").pipe( + Effect.flatMap((value) => + decodeJson(value).pipe( + Effect.mapError(() => + failure("OIDC JWKS response is invalid", { url: safeUrlLabel(jwksUrl) }), + ), + ), + ), Effect.flatMap((value) => Schema.decodeUnknownEffect(publicRemoteJwks)(value).pipe( Effect.mapError(() => @@ -352,11 +399,6 @@ export const makeRuntimeInputOwner = ( ), ); }), - Effect.mapError((error) => - error instanceof StackPreparationError - ? error - : failure("OIDC discovery request failed", { url: safeUrlLabel(discoveryUrl) }), - ), ); }; @@ -411,7 +453,7 @@ export const makeRuntimeInputOwner = ( const writePoolerTenant = ( state: PersistedStackState, generation: number, - runtime: WorkloadRuntimeKind, + runtime: "native" | "container", ): Effect.Effect => { if (invalidGeneration(generation)) return Effect.fail(failure("Invalid pooler generation")); const password = state.secrets["secret:database.internal.password"]?.value ?? ""; @@ -419,19 +461,34 @@ export const makeRuntimeInputOwner = ( return Effect.fail(failure("Persisted database secret is missing")); const settings = settingsFor(state, "pooler"); const poolMode = settingValue(state, isRecord(settings) ? settings.pool_mode : undefined); - const mode: "transaction" | "session" = poolMode === "session" ? "session" : "transaction"; + if (poolMode !== "transaction" && poolMode !== "session") + return Effect.fail(failure("Persisted Pooler mode is invalid")); + const tenantId = settingValue(state, isRecord(settings) ? settings.tenant_id : undefined); + if (tenantId.length === 0) + return Effect.fail(failure("Persisted Pooler tenant id is missing")); const defaultPoolSize = finiteSetting( settingValue(state, isRecord(settings) ? settings.default_pool_size : undefined), - 20, ); const maxClientConn = finiteSetting( settingValue(state, isRecord(settings) ? settings.max_client_conn : undefined), - 100, ); + if (defaultPoolSize === undefined || maxClientConn === undefined) + return Effect.fail(failure("Persisted Pooler sizing settings are invalid")); + const nativeAssignment = state.privatePorts.find( + (entry) => entry.workloadId === "database:database" && entry.binding === "primary", + ); + if (runtime === "native" && nativeAssignment === undefined) + return Effect.fail(failure("Persisted native database assignment is missing")); + const dbHost = runtime === "container" ? "supabase-database" : "127.0.0.1"; + const dbPort = runtime === "container" ? 5432 : nativeAssignment?.port; + if (dbPort === undefined) + return Effect.fail(failure("Persisted native database assignment is missing")); const content = poolerTenantScript({ - dbHost: runtime === "container" ? "supabase-database" : "127.0.0.1", + externalId: tenantId, + dbHost, + dbPort, dbPassword: password, - poolMode: mode, + poolMode, defaultPoolSize, maxClientConn, }); @@ -482,26 +539,23 @@ export const makeRuntimeInputOwner = ( }); }; - const cleanupGeneration = (generation: number): Effect.Effect => - invalidGeneration(generation) - ? Effect.fail(failure("Invalid pooler generation")) - : mapFile( - path.join(poolerRoot, String(generation)), - "clean pooler tenant generation", - fs.remove(path.join(poolerRoot, String(generation)), { recursive: true, force: true }), - ); - const cleanupAll = mapFile( - poolerRoot, - "clean pooler tenant files", - fs.remove(poolerRoot, { recursive: true, force: true }), - ); - - const resolve = ( + const joinExit = (result: Exit.Exit): Effect.Effect => + Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); + type OwnedResult = Deferred.Deferred< + Exit.Exit, + never + >; + const pending = new Map(); + const completed = new Map(); + const keyFor = (state: PersistedStackState, generation: number): string => + `${state.identity.projectRoot}\u0000${state.inputFingerprint ?? ""}\u0000${state.runtime.kind}\u0000${generation}`; + + const materialize = ( state: PersistedStackState, generation: number, - runtime: WorkloadRuntimeKind, ): Effect.Effect => Effect.gen(function* () { + const runtime = state.runtime.kind; const jwtConsumers = ["rest", "auth", "realtime", "storage", "functions"] as const; const resolvesAuthMaterial = jwtConsumers.some( (capability) => state.definition?.capabilities[capability].enabled !== false, @@ -515,13 +569,13 @@ export const makeRuntimeInputOwner = ( state.definition?.capabilities.analytics.enabled === false || gcpPath.length === 0 ? undefined : { gcpJwtPath: yield* resolveProjectFile(state, gcpPath) }; - const pooler = state.definition?.capabilities.pooler.enabled - ? { tenantPath: yield* writePoolerTenant(state, generation, runtime) } - : undefined; const functions = state.definition?.capabilities.functions.enabled === false ? undefined : { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state) }; + const pooler = state.definition?.capabilities.pooler.enabled + ? { tenantPath: yield* writePoolerTenant(state, generation, runtime) } + : undefined; return { ...(auth === undefined ? {} : { auth }), ...(analytics === undefined ? {} : { analytics }), @@ -530,6 +584,110 @@ export const makeRuntimeInputOwner = ( }; }); + const resolve = ( + state: PersistedStackState, + generation: number, + ): Effect.Effect => + Effect.gen(function* () { + const key = keyFor(state, generation); + const result = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + type Admission = + | { readonly kind: "ready"; readonly value: RuntimeInputMaterial } + | { readonly kind: "waiting"; readonly deferred: OwnedResult }; + const readyAdmission = (value: RuntimeInputMaterial): Admission => ({ + kind: "ready", + value, + }); + const waitingAdmission = (deferred: OwnedResult): Admission => ({ + kind: "waiting", + deferred, + }); + const owned = yield* admission.withPermit( + Effect.gen(function* () { + const ready = completed.get(key); + if (ready !== undefined) return readyAdmission(ready); + const current = pending.get(key); + if (current !== undefined) return waitingAdmission(current); + const deferred = yield* Deferred.make< + Exit.Exit, + never + >(); + pending.set(key, deferred); + const owner = execution.withPermit( + Effect.gen(function* () { + const stillCurrent = yield* admission.withPermit( + Effect.sync(() => pending.get(key) === deferred), + ); + if (!stillCurrent) { + yield* Deferred.succeed( + deferred, + Exit.fail(failure("Runtime input generation was invalidated")), + ); + return; + } + const exit = yield* materialize(state, generation).pipe(Effect.exit); + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* Effect.sync(() => { + if (pending.get(key) === deferred) pending.delete(key); + if (Exit.isSuccess(exit)) completed.set(key, exit.value); + }); + yield* Deferred.succeed(deferred, exit); + }), + ); + }), + ); + yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); + return waitingAdmission(deferred); + }), + ); + if (owned.kind === "ready") return Exit.succeed(owned.value); + return yield* restore(Deferred.await(owned.deferred)); + }), + ); + return yield* joinExit(result); + }); + + const cleanupGeneration = (generation: number): Effect.Effect => + invalidGeneration(generation) + ? Effect.fail(failure("Invalid pooler generation")) + : execution.withPermit( + Effect.gen(function* () { + yield* admission.withPermit( + Effect.sync(() => { + for (const key of pending.keys()) + if (key.endsWith(`\u0000${generation}`)) pending.delete(key); + for (const key of completed.keys()) + if (key.endsWith(`\u0000${generation}`)) completed.delete(key); + }), + ); + yield* mapFile( + path.join(poolerRoot, String(generation)), + "clean pooler tenant generation", + fs.remove(path.join(poolerRoot, String(generation)), { + recursive: true, + force: true, + }), + ); + }), + ); + const cleanupAll = execution.withPermit( + Effect.gen(function* () { + yield* admission.withPermit( + Effect.sync(() => { + pending.clear(); + completed.clear(); + }), + ); + yield* mapFile( + poolerRoot, + "clean pooler tenant files", + fs.remove(poolerRoot, { recursive: true, force: true }), + ); + }), + ); + return { resolve, resolveProjectFile, diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 0d926cbd58..1f293520b4 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -559,7 +559,12 @@ const nativeStartupProcessesFor = ( migrate, { executable: "/bin/sh", - args: ["-c", `${artifact("bin/supavisor")} eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"`], + args: [ + "-c", + 'exec "$1" eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"', + "supavisor", + artifact("bin/supavisor"), + ], cwd, env: { SUPABASE_POOLER_TENANT_PATH: tenantPath }, }, @@ -1097,13 +1102,10 @@ const specs: Readonly> = { "storage:imgproxy": { bindings: { primary: { containerPort: 5001 } }, args: () => [], - env: (state, workload, port, runtime = "native", inputs = {}) => ({ + env: (state, workload, port, runtime = "native", _inputs = {}) => ({ ...common(workload, port), IMGPROXY_BIND: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, - IMGPROXY_LOCAL_FILESYSTEM_ROOT: - runtime === "container" - ? "/mnt" - : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), + IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", }), containerArgs: () => [], networkAliases: ["supabase-imgproxy"], @@ -1297,17 +1299,17 @@ const specs: Readonly> = { DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), REGION: "local", - TENANT_ID: valueAt(state, "pooler", "tenant_id") || "pooler-dev", + TENANT_ID: valueAt(state, "pooler", "tenant_id"), CLUSTER_POSTGRES: "true", SECRET_KEY_BASE: valueAt(state, "pooler", "secret_key_base"), VAULT_ENC_KEY: valueAt(state, "pooler", "encryption_key"), METRICS_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size") || "20", - MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn") || "100", - POOL_MODE: valueAt(state, "pooler", "pool_mode") || "transaction", - ...(inputs.pooler?.tenantPath === undefined - ? {} - : { SUPABASE_POOLER_TENANT_PATH: "/app/pooler_tenant.exs" }), + DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size"), + MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn"), + POOL_MODE: valueAt(state, "pooler", "pool_mode"), + ...(runtime === "container" && inputs.pooler?.tenantPath !== undefined + ? { SUPABASE_POOLER_TENANT_PATH: "/app/pooler_tenant.exs" } + : {}), }), // The slim container entrypoint performs Supavisor migrations. Tenant // provisioning is a separate owner/bootstrap phase. diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 1778e3d8ed..ae0442b2e3 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -109,11 +109,23 @@ describe("runtime input owner", () => { { ...second, alg: "RS256", kid: "rsa-key" }, ]), ); - const state = yield* compiledState(root, { - security: { jwt: { signing: { kind: "jwks-file", path: "keys.json" } } }, - }); + const base = yield* compiledState(root); + if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); + const state: PersistedStackState = { + ...base, + definition: { + ...base.definition, + security: { + ...base.definition.security, + jwt: { + ...base.definition.security.jwt, + signing: { kind: "jwks-file", path: "keys.json" }, + }, + }, + }, + }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1, "native"); + const material = yield* owner.resolve(state, 1); expect(JSON.parse(material.auth?.jwtKeys ?? "[]")).toHaveLength(2); const jwks = JSON.parse(material.auth?.jwks ?? "{}"); expect(jwks.keys).toHaveLength(2); @@ -124,6 +136,44 @@ describe("runtime input owner", () => { ), ); + it.live("rejects a JWKS file when any configured key is invalid", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-jwks-invalid-" }); + const valid = generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ + format: "jwk", + }); + yield* fs.writeFileString( + path.join(root, "keys.json"), + JSON.stringify([ + { ...valid, alg: "ES256" }, + { kty: "EC", alg: "ES256", d: "bad" }, + ]), + ); + const base = yield* compiledState(root); + if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); + const state: PersistedStackState = { + ...base, + definition: { + ...base.definition, + security: { + ...base.definition.security, + jwt: { + ...base.definition.security.jwt, + signing: { kind: "jwks-file", path: "keys.json" }, + }, + }, + }, + }; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const failed = yield* owner.resolve(state, 1).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + }), + ), + ); + it.live("merges injected third-party keys with the canonical symmetric key", () => withPlatform( Effect.gen(function* () { @@ -150,7 +200,7 @@ describe("runtime input owner", () => { : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; }), }); - const material = yield* owner.resolve(state, 2, "native"); + const material = yield* owner.resolve(state, 2); expect(requested).toEqual([ "https://securetoken.google.com/demo/.well-known/openid-configuration", "https://issuer.example/keys", @@ -172,8 +222,16 @@ describe("runtime input owner", () => { }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const jwks = JSON.parse((yield* owner.resolve(state, 1, "native")).auth?.jwks ?? "{}"); - expect(jwks.keys).toEqual([{ kty: "oct", k: "c3ltbWV0cmljLXNlY3JldA" }]); + const jwks = JSON.parse((yield* owner.resolve(state, 1)).auth?.jwks ?? "{}"); + expect(jwks.keys).toEqual([ + { + kty: "oct", + alg: "HS256", + use: "sig", + key_ops: ["verify"], + k: "c3ltbWV0cmljLXNlY3JldA", + }, + ]); }), ), ); @@ -212,7 +270,7 @@ describe("runtime input owner", () => { stackId, fetchJson: () => Effect.die("OIDC fetch should not run"), }); - const material = yield* owner.resolve(state, 1, "native"); + const material = yield* owner.resolve(state, 1); expect(material.auth).toBeUndefined(); }), ), @@ -237,7 +295,7 @@ describe("runtime input owner", () => { }, }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1, "native"); + const material = yield* owner.resolve(state, 1); expect(material.auth?.jwks).toContain('"kty":"oct"'); }), ), @@ -268,7 +326,7 @@ describe("runtime input owner", () => { stackId, fetchJson: () => Effect.fail(new Error("transport failure")), }); - const failed = yield* owner.resolve(state, 1, "native").pipe(Effect.exit); + const failed = yield* owner.resolve(state, 1).pipe(Effect.exit); const error = errorOf(failed); expect(error?.message).toContain("OIDC discovery request failed"); expect(JSON.stringify(error)).not.toContain("secret-token"); @@ -301,7 +359,7 @@ describe("runtime input owner", () => { : { keys: [] }, ), }); - const failed = yield* owner.resolve(state, 1, "native").pipe(Effect.exit); + const failed = yield* owner.resolve(state, 1).pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("contains no keys"); }), ), @@ -317,8 +375,12 @@ describe("runtime input owner", () => { capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const first = yield* owner.resolve(state, 3, "native"); - const second = yield* owner.resolve(state, 3, "native"); + const nativeState: PersistedStackState = { + ...state, + privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], + }; + const first = yield* owner.resolve(nativeState, 3); + const second = yield* owner.resolve(nativeState, 3); expect(second.pooler?.tenantPath).toBe(first.pooler?.tenantPath); const tenant = first.pooler?.tenantPath; expect(tenant).toBeDefined(); @@ -326,6 +388,7 @@ describe("runtime input owner", () => { const content = yield* fs.readFileString(tenant!); expect(content).toContain('"db_host" => "127.0.0.1"'); expect(content).toContain('"external_id" => "pooler-dev"'); + expect(content).toContain('"db_port" => 30001'); expect(content).not.toContain('"db_password" => ""'); yield* owner.cleanupGeneration(3); expect(yield* fs.exists(path.dirname(tenant!))).toBe(false); @@ -333,6 +396,144 @@ describe("runtime input owner", () => { ), ); + it.live("renders literal Elixir interpolation and container database endpoint", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "runtime-input-pooler-container-", + }); + const base = yield* compiledState(root, { + capabilities: { + pooler: { + enabled: true, + settings: { + tenant_id: "tenant-custom", + pool_mode: "transaction", + }, + }, + }, + }); + const state = { + ...base, + runtime: { kind: "container", engine: "docker" } as const, + secrets: { + ...base.secrets, + "secret:database.internal.password": { + policy: "managed" as const, + value: "#{System.halt()}", + }, + }, + }; + const containerState = state; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const material = yield* owner.resolve(containerState, 4); + const tenant = material.pooler?.tenantPath; + expect(tenant).toBeDefined(); + const content = yield* fs.readFileString(tenant!); + expect(content).toContain('"external_id" => "tenant-custom"'); + expect(content).toContain('"db_host" => "supabase-database"'); + expect(content).toContain('"db_port" => 5432'); + expect(content).toContain("\\#{System.halt()}"); + expect(content).toContain("Supavisor.Repo.preload(existing, :users)"); + expect(content).toContain("Supavisor.Tenants.update_tenant(existing, params)"); + }), + ), + ); + + it.live("fails native Pooler resolution without the persisted database assignment", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-pooler-no-port-", + }); + const state = yield* compiledState(root, { + capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const failed = yield* owner.resolve(state, 9).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + }), + ), + ); + + it.live("does not publish a Pooler file when Functions validation fails", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-invalid-" }); + const state = yield* compiledState(root, { + capabilities: { + pooler: { enabled: true }, + functions: { + settings: { + edge_runtime: { secrets: { SUPABASE_BAD: Redacted.make("x") } }, + }, + }, + }, + }); + const nativeState = { + ...state, + privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], + }; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const failed = yield* owner.resolve(nativeState, 7).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler", "7"))).toBe( + false, + ); + }), + ), + ); + + it.live("single-flights equivalent resolution and retries failed generations", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-single-flight-", + }); + let fetches = 0; + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: (url) => { + fetches += 1; + return fetches === 1 + ? Effect.fail(new Error("transient")) + : Effect.succeed( + url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }, + ); + }, + }); + const failed = yield* owner.resolve(state, 5).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + const recovered = yield* owner.resolve(state, 5); + expect(recovered.auth?.jwks).toContain('"keys"'); + const results = yield* Effect.all([owner.resolve(state, 5), owner.resolve(state, 5)], { + concurrency: "unbounded", + }); + expect(results).toHaveLength(2); + expect(fetches).toBe(3); + yield* owner.cleanupGeneration(5); + const retried = yield* owner.resolve(state, 5); + expect(retried.auth?.jwks).toContain('"keys"'); + expect(fetches).toBe(5); + }), + ), + ); + it.live("resolves the configured Analytics service-account file without copying it", () => withPlatform( Effect.gen(function* () { @@ -344,7 +545,7 @@ describe("runtime input owner", () => { capabilities: { analytics: { settings: { gcp_jwt_path: "gcp.json" } } }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1, "native"); + const material = yield* owner.resolve(state, 1); expect(material.analytics?.gcpJwtPath).toBe( path.join(yield* fs.realPath(root), "gcp.json"), ); @@ -391,7 +592,7 @@ describe("runtime input owner", () => { }, }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1, "native"); + const material = yield* owner.resolve(state, 1); expect(material.analytics).toBeUndefined(); expect(material.functions).toBeUndefined(); }), @@ -461,7 +662,7 @@ describe("runtime input owner", () => { }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1, "native"); + const material = yield* owner.resolve(state, 1); expect(material.functions?.secrets).toEqual({ API_TOKEN: "actual-value" }); const invalid = yield* compiledState(root, { capabilities: { @@ -472,7 +673,7 @@ describe("runtime input owner", () => { }, }, }); - const failed = yield* owner.resolve(invalid, 1, "native").pipe(Effect.exit); + const failed = yield* owner.resolve(invalid, 1).pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("secret name is reserved"); const control = yield* compiledState(root, { @@ -484,7 +685,7 @@ describe("runtime input owner", () => { }, }, }); - const controlFailure = yield* owner.resolve(control, 1, "native").pipe(Effect.exit); + const controlFailure = yield* owner.resolve(control, 2).pipe(Effect.exit); expect(errorOf(controlFailure)?.message).toContain("secret value is invalid"); }), ), diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index abd3cb724b..4d33b035f7 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -208,7 +208,15 @@ describe("workload runtime catalog", () => { storage: { dataPath: "/state/stack/data/storage" }, }, ).IMGPROXY_LOCAL_FILESYSTEM_ROOT, - ).toBe("/state/stack/data/storage"); + ).toBe("/"); + expect( + runtimeSpecFor(planned("storage:imgproxy"))?.env( + state, + planned("storage:imgproxy"), + 5001, + "container", + ).IMGPROXY_LOCAL_FILESYSTEM_ROOT, + ).toBe("/"); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -255,12 +263,26 @@ describe("workload runtime catalog", () => { expect(tenantStartup).toHaveLength(2); expect(tenantStartup?.[1]).toEqual({ executable: "/bin/sh", - args: ["-c", `${root}/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"`], + args: [ + "-c", + 'exec "$1" eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"', + "supavisor", + `${root}/bin/supavisor`, + ], cwd: root, env: { SUPABASE_POOLER_TENANT_PATH: "/tmp/pooler-tenant.exs" }, }); expect(tenantStartup?.[1]?.args.join(" ")).not.toContain("secret"); + const containerTenantEnv = runtimeSpecFor(pooler)?.env( + state, + pooler, + 6543, + "native", + tenantInput, + ); + expect(containerTenantEnv).not.toHaveProperty("SUPABASE_POOLER_TENANT_PATH"); + expect(containerResolutionFor(state, auth)?.command).toEqual([ "/bin/sh", "-c", diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index ec6d7662c9..c27f4900d6 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -201,6 +201,8 @@ const privateSigningJwk = (value: unknown): SigningJwk | undefined => { const use = stringField(value, "use"); const keyOps = stringArrayField(value, "key_ops"); const ext = booleanField(value, "ext"); + if (use !== undefined && use !== "sig") return undefined; + if (keyOps !== undefined && !keyOps.includes("sign")) return undefined; if (kty === "EC" && alg === "ES256") { const crv = stringField(value, "crv"); const x = stringField(value, "x"); @@ -286,11 +288,20 @@ const readSigningJwks = ( Effect.mapError(() => invalidSigningMaterial()), ); const entries = Array.isArray(parsed) ? parsed : [parsed]; - const keys = entries.flatMap((entry) => { - const jwk = privateSigningJwk(entry); - return jwk === undefined ? [] : [jwk]; - }); - return keys.length === 0 ? yield* invalidSigningMaterial() : keys; + const keys: Array = []; + for (const entry of entries) { + const key = privateSigningJwk(entry); + if (key === undefined) return yield* invalidSigningMaterial(); + yield* Effect.try({ + try: () => { + createPrivateKey({ key, format: "jwk" }); + }, + catch: () => invalidSigningMaterial(), + }); + keys.push(key); + } + if (keys.length === 0) return yield* invalidSigningMaterial(); + return keys; }); const readSigningJwk = ( @@ -310,11 +321,8 @@ export interface ResolvedSigningKeyMaterial { const publicSigningJwk = (key: SigningJwk): Readonly> => ({ kty: key.kty, ...(key.kid === undefined ? {} : { kid: key.kid }), - ...(key.use === undefined ? {} : { use: key.use }), - ...(() => { - const operations = key.key_ops?.filter((operation) => operation === "verify"); - return operations === undefined || operations.length === 0 ? {} : { key_ops: operations }; - })(), + use: "sig", + key_ops: ["verify"], alg: key.alg, ...(key.ext === undefined ? {} : { ext: key.ext }), ...(key.kty === "RSA" ? { n: key.n, e: key.e } : { crv: key.crv, x: key.x, y: key.y }), diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index 0e2cc5d165..55c0abbe4c 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -9,7 +9,12 @@ import { StackSecretMismatchError, } from "../public/Errors.ts"; import { compileStack } from "../model/Compiler.ts"; -import { redactKnownSecrets, resolveSecrets, type SecretCandidate } from "./SecretStore.ts"; +import { + redactKnownSecrets, + resolveSecrets, + resolveSigningKeyMaterial, + type SecretCandidate, +} from "./SecretStore.ts"; const layer = NodeServices.layer; const managed = (value?: string): SecretCandidate => ({ @@ -106,6 +111,30 @@ describe("managed and pass-through secrets", () => { }).pipe(Effect.provide(layer)), ); + it.live("rejects mixed valid and invalid private JWK entries as one file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-jwks-mixed-" }); + const privateJwk = { + ...generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey.export({ + format: "jwk", + }), + alg: "ES256", + }; + yield* fs.writeFileString( + path.join(root, "keys.json"), + JSON.stringify([privateJwk, { kty: "EC", alg: "ES256", d: "invalid" }]), + ); + const failed = yield* resolveSigningKeyMaterial({ + kind: "jwks-file", + projectRoot: root, + path: "keys.json", + }).pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(InvalidJwtSigningMaterialError); + }).pipe(Effect.provide(layer)), + ); + it.live("signs generated API keys with the first private ES256 JWK", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; From d899e735cfb78bdeab6b67e631b2f9763adf8865 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 12:44:43 +0200 Subject: [PATCH 080/174] fix(stack): harden runtime input ownership --- ...26-08-28-stack-runtime-rewrite-progress.md | 13 ++ .../stack/src/runtime/RuntimeInputOwner.ts | 54 ++--- .../runtime-input-owner.integration.test.ts | 192 +++++++++++++++--- .../workload-runtime.integration.test.ts | 8 +- 4 files changed, 207 insertions(+), 60 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index e57648a95e..ac4eb47ea6 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -994,3 +994,16 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g pass 55 cases with stack type-check, Effect lint, formatting, and diff checks green. Production wiring remains the next slice; `RuntimeInputOwner.resolve(state, generation)` now derives runtime from persisted state and the owner requires an ambient `Scope` for its FiberSet. + +#### Task 14 Fable correction round 1 (2026-08-31) + +- Pooler sizing now rejects missing or whitespace persisted values while retaining the compiler's finite-number + contract; invalid sizing fails before any generation file is published. Generated tenant create clauses use + consistent Elixir indentation. +- Runtime-input tests now gate an actual cold in-flight resolve with `Deferred`, prove one discovery/JWKS pair, + shared tenant material, warm-cache reuse, and survival when a joined waiter is interrupted. Owner finalization is + attached outside semaphore acquisition so queued interruption clears only its matching slot and completes the + exact deferred exit; successful cache publication remains conditional on slot ownership. +- Added combined local/remote JWKS, space-bearing native artifact paths, positive container tenant-path, and + sibling-generation cleanup coverage. Focused owner/secret/workload/catalog integration verification passes 57 + cases with stack type-check, Effect lint, formatting, and diff checks green. diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 9987f75588..3210c291bb 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -105,7 +105,9 @@ const invalidGeneration = (generation: number): boolean => !Number.isSafeInteger(generation) || generation < 0; const finiteSetting = (value: string): number | undefined => { - const parsed = Number(value); + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : undefined; }; @@ -221,7 +223,7 @@ params = %{ case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do nil -> - {:ok, _} = Supavisor.Tenants.create_tenant(params) + {:ok, _} = Supavisor.Tenants.create_tenant(params) existing -> existing = Supavisor.Repo.preload(existing, :users) {:ok, _} = Supavisor.Tenants.update_tenant(existing, params) @@ -614,30 +616,32 @@ export const makeRuntimeInputOwner = ( never >(); pending.set(key, deferred); - const owner = execution.withPermit( - Effect.gen(function* () { - const stillCurrent = yield* admission.withPermit( - Effect.sync(() => pending.get(key) === deferred), - ); - if (!stillCurrent) { - yield* Deferred.succeed( - deferred, - Exit.fail(failure("Runtime input generation was invalidated")), + const owner = execution + .withPermit( + Effect.gen(function* () { + const stillCurrent = yield* admission.withPermit( + Effect.sync(() => pending.get(key) === deferred), ); - return; - } - const exit = yield* materialize(state, generation).pipe(Effect.exit); - yield* Effect.uninterruptible( - Effect.gen(function* () { - yield* Effect.sync(() => { - if (pending.get(key) === deferred) pending.delete(key); - if (Exit.isSuccess(exit)) completed.set(key, exit.value); - }); - yield* Deferred.succeed(deferred, exit); - }), - ); - }), - ); + if (!stillCurrent) + return yield* failure("Runtime input generation was invalidated"); + return yield* materialize(state, generation); + }), + ) + .pipe( + Effect.onExit((exit) => + Effect.uninterruptible( + Effect.gen(function* () { + const current = yield* Effect.sync(() => pending.get(key) === deferred); + if (current) + yield* Effect.sync(() => { + pending.delete(key); + if (Exit.isSuccess(exit)) completed.set(key, exit.value); + }); + yield* Deferred.succeed(deferred, exit); + }), + ), + ), + ); yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); return waitingAdmission(deferred); }), diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index ae0442b2e3..76cfaff690 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted } from "effect"; +import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Path, Redacted } from "effect"; import { generateKeyPairSync } from "node:crypto"; import { compileStack, type CompiledStack } from "../model/Compiler.ts"; import { StackIdSchema } from "../public/StackId.ts"; @@ -109,7 +109,17 @@ describe("runtime input owner", () => { { ...second, alg: "RS256", kid: "rsa-key" }, ]), ); - const base = yield* compiledState(root); + const base = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, + }, + }, + }, + }); if (base.definition === undefined) return yield* Effect.die("compiled definition missing"); const state: PersistedStackState = { ...base, @@ -124,11 +134,20 @@ describe("runtime input owner", () => { }, }, }; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: (url) => + Effect.succeed( + url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }, + ), + }); const material = yield* owner.resolve(state, 1); expect(JSON.parse(material.auth?.jwtKeys ?? "[]")).toHaveLength(2); const jwks = JSON.parse(material.auth?.jwks ?? "{}"); - expect(jwks.keys).toHaveLength(2); + expect(jwks.keys).toHaveLength(3); expect(jwks.keys.every((key: Record) => !Object.hasOwn(key, "d"))).toBe( true, ); @@ -381,17 +400,22 @@ describe("runtime input owner", () => { }; const first = yield* owner.resolve(nativeState, 3); const second = yield* owner.resolve(nativeState, 3); - expect(second.pooler?.tenantPath).toBe(first.pooler?.tenantPath); - const tenant = first.pooler?.tenantPath; - expect(tenant).toBeDefined(); - expect((yield* fs.stat(tenant!)).mode & 0o777).toBe(0o600); - const content = yield* fs.readFileString(tenant!); + const thirdGenerationTenant = first.pooler?.tenantPath; + expect(second.pooler?.tenantPath).toBe(thirdGenerationTenant); + expect(thirdGenerationTenant).toBeDefined(); + expect((yield* fs.stat(thirdGenerationTenant!)).mode & 0o777).toBe(0o600); + const content = yield* fs.readFileString(thirdGenerationTenant!); expect(content).toContain('"db_host" => "127.0.0.1"'); expect(content).toContain('"external_id" => "pooler-dev"'); expect(content).toContain('"db_port" => 30001'); expect(content).not.toContain('"db_password" => ""'); + const fourth = yield* owner.resolve(nativeState, 4); + const fourthGenerationTenant = fourth.pooler?.tenantPath; + expect(fourthGenerationTenant).toBeDefined(); + expect(fourthGenerationTenant).not.toBe(thirdGenerationTenant); yield* owner.cleanupGeneration(3); - expect(yield* fs.exists(path.dirname(tenant!))).toBe(false); + expect(yield* fs.exists(path.dirname(thirdGenerationTenant!))).toBe(false); + expect(yield* fs.exists(fourthGenerationTenant!)).toBe(true); }), ), ); @@ -457,6 +481,40 @@ describe("runtime input owner", () => { ), ); + it.live("fails closed when persisted Pooler sizing is missing or blank", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-sizing-" }); + const base = yield* compiledState(root, { + capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, + }); + const native = { + ...base, + privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], + }; + const missing = JSON.parse(JSON.stringify(native)); + delete missing.definition.capabilities.pooler.settings.default_pool_size; + const blank = JSON.parse(JSON.stringify(native)); + blank.definition.capabilities.pooler.settings.default_pool_size = " "; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + for (const [generation, invalid] of [ + [10, missing], + [11, blank], + ] as const) { + const failed = yield* owner.resolve(invalid, generation).pipe(Effect.exit); + expect(errorOf(failed)?.message).toContain("sizing settings are invalid"); + expect( + yield* fs.exists( + path.join(root, stackId, "runtime", "inputs", "pooler", `${generation}`), + ), + ).toBe(false); + } + }), + ), + ); + it.live("does not publish a Pooler file when Functions validation fails", () => withPlatform( Effect.gen(function* () { @@ -487,12 +545,76 @@ describe("runtime input owner", () => { ), ); - it.live("single-flights equivalent resolution and retries failed generations", () => + it.live("single-flights cold equivalent resolutions and reuses the completed material", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-single-flight-" }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let fetches = 0; + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }, + pooler: { enabled: true }, + }, + }); + const native = { + ...state, + privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], + }; + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: (url) => { + fetches += 1; + return Effect.gen(function* () { + if (fetches === 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; + }); + }, + }); + const first = yield* Effect.forkChild(owner.resolve(native, 5), { + startImmediately: true, + }); + yield* Deferred.await(started); + const second = yield* Effect.forkChild(owner.resolve(native, 5), { + startImmediately: true, + }); + yield* Effect.yieldNow; + expect(fetches).toBe(1); + yield* Deferred.succeed(release, undefined); + const [firstMaterial, secondMaterial] = yield* Effect.all([ + Fiber.join(first), + Fiber.join(second), + ]); + expect(fetches).toBe(2); + expect(firstMaterial.auth?.jwks).toBe(secondMaterial.auth?.jwks); + expect(firstMaterial.pooler?.tenantPath).toBe(secondMaterial.pooler?.tenantPath); + const cached = yield* owner.resolve(native, 5); + expect(cached.pooler?.tenantPath).toBe(firstMaterial.pooler?.tenantPath); + expect(fetches).toBe(2); + }), + ), + ); + + it.live("keeps the owner alive when an in-flight waiter is interrupted", () => withPlatform( Effect.gen(function* () { const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-single-flight-", + prefix: "runtime-input-single-flight-interrupt-", }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); let fetches = 0; const state = yield* compiledState(root, { capabilities: { @@ -508,28 +630,34 @@ describe("runtime input owner", () => { stackId, fetchJson: (url) => { fetches += 1; - return fetches === 1 - ? Effect.fail(new Error("transient")) - : Effect.succeed( - url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }, - ); + return Effect.gen(function* () { + if (fetches === 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; + }); }, }); - const failed = yield* owner.resolve(state, 5).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - const recovered = yield* owner.resolve(state, 5); - expect(recovered.auth?.jwks).toContain('"keys"'); - const results = yield* Effect.all([owner.resolve(state, 5), owner.resolve(state, 5)], { - concurrency: "unbounded", - }); - expect(results).toHaveLength(2); - expect(fetches).toBe(3); - yield* owner.cleanupGeneration(5); - const retried = yield* owner.resolve(state, 5); - expect(retried.auth?.jwks).toContain('"keys"'); - expect(fetches).toBe(5); + const first = yield* Effect.forkChild(owner.resolve(state, 6), { + startImmediately: true, + }); + yield* Deferred.await(started); + const second = yield* Effect.forkChild(owner.resolve(state, 6), { + startImmediately: true, + }); + yield* Effect.yieldNow; + expect(fetches).toBe(1); + yield* Fiber.interrupt(second); + yield* Deferred.succeed(release, undefined); + const firstMaterial = yield* Fiber.join(first); + expect(firstMaterial.auth?.jwks).toContain('"keys"'); + expect(fetches).toBe(2); + const cached = yield* owner.resolve(state, 6); + expect(cached.auth?.jwks).toBe(firstMaterial.auth?.jwks); + expect(fetches).toBe(2); }), ), ); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 4d33b035f7..121ff3e3ad 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -221,7 +221,7 @@ describe("workload runtime catalog", () => { ); it("resolves service-owned startup processes and slim container command contracts", () => { - const root = "/tmp/slim-artifact"; + const root = "/tmp/slim artifact"; const auth = planned("auth:auth"); expect(runtimeSpecFor(auth)?.nativeStartupProcesses(root, state, auth, 9999)).toEqual([ { executable: `${root}/bin/auth`, args: ["migrate"], cwd: root }, @@ -274,14 +274,16 @@ describe("workload runtime catalog", () => { }); expect(tenantStartup?.[1]?.args.join(" ")).not.toContain("secret"); + const nativeTenantEnv = runtimeSpecFor(pooler)?.env(state, pooler, 6543, "native", tenantInput); + expect(nativeTenantEnv).not.toHaveProperty("SUPABASE_POOLER_TENANT_PATH"); const containerTenantEnv = runtimeSpecFor(pooler)?.env( state, pooler, 6543, - "native", + "container", tenantInput, ); - expect(containerTenantEnv).not.toHaveProperty("SUPABASE_POOLER_TENANT_PATH"); + expect(containerTenantEnv?.SUPABASE_POOLER_TENANT_PATH).toBe("/app/pooler_tenant.exs"); expect(containerResolutionFor(state, auth)?.command).toEqual([ "/bin/sh", From c465dd2d61b388460445fd9945ea6807c2a9b2ce Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 12:49:42 +0200 Subject: [PATCH 081/174] test(stack): cover runtime owner scope teardown --- ...26-08-28-stack-runtime-rewrite-progress.md | 9 +++ .../runtime-input-owner.integration.test.ts | 67 ++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index ac4eb47ea6..b33454ed50 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1007,3 +1007,12 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Added combined local/remote JWKS, space-bearing native artifact paths, positive container tenant-path, and sibling-generation cleanup coverage. Focused owner/secret/workload/catalog integration verification passes 57 cases with stack type-check, Effect lint, formatting, and diff checks green. + +#### Task 14 Fable correction round 2 (2026-08-31) + +- Added a public owner-surface scope-teardown regression: a gated materialization and a distinct generation + queued behind the execution lane both terminate with failure when the explicit owner scope closes, proving the + owner `onExit` handoff completes both deferred callers without hanging. +- Removed redundant `Effect.yieldNow` scheduler nudges from cold-join and interrupted-waiter tests; startup uses + `forkChild(..., { startImmediately: true })` and Deferred gates exclusively. Focused owner/secret/workload/catalog + integration verification passes 58 cases with stack type-check, Effect lint, formatting, and diff checks green. diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 76cfaff690..eb4bf64106 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -1,6 +1,17 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Path, Redacted } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Option, + Path, + Redacted, + Scope, +} from "effect"; import { generateKeyPairSync } from "node:crypto"; import { compileStack, type CompiledStack } from "../model/Compiler.ts"; import { StackIdSchema } from "../public/StackId.ts"; @@ -590,7 +601,6 @@ describe("runtime input owner", () => { const second = yield* Effect.forkChild(owner.resolve(native, 5), { startImmediately: true, }); - yield* Effect.yieldNow; expect(fetches).toBe(1); yield* Deferred.succeed(release, undefined); const [firstMaterial, secondMaterial] = yield* Effect.all([ @@ -648,7 +658,6 @@ describe("runtime input owner", () => { const second = yield* Effect.forkChild(owner.resolve(state, 6), { startImmediately: true, }); - yield* Effect.yieldNow; expect(fetches).toBe(1); yield* Fiber.interrupt(second); yield* Deferred.succeed(release, undefined); @@ -662,6 +671,58 @@ describe("runtime input owner", () => { ), ); + it.live("completes materializing and queued owners when their scope closes", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-owner-scope-close-", + }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let fetches = 0; + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }, + }, + }); + const ownerScope = yield* Scope.make(); + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: (url) => { + fetches += 1; + return Effect.gen(function* () { + if (fetches === 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; + }); + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const materializing = yield* Effect.forkChild(owner.resolve(state, 20), { + startImmediately: true, + }); + yield* Deferred.await(started); + const queued = yield* Effect.forkChild(owner.resolve(state, 21), { + startImmediately: true, + }); + expect(fetches).toBe(1); + yield* Scope.close(ownerScope, Exit.void); + const materializingExit = yield* Fiber.join(materializing).pipe(Effect.exit); + const queuedExit = yield* Fiber.join(queued).pipe(Effect.exit); + expect(Exit.isFailure(materializingExit)).toBe(true); + expect(Exit.isFailure(queuedExit)).toBe(true); + }), + ), + ); + it.live("resolves the configured Analytics service-account file without copying it", () => withPlatform( Effect.gen(function* () { From fd77707e3695bd926b37e94f2e2727b802b2e016 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 12:57:22 +0200 Subject: [PATCH 082/174] fix(stack): satisfy runtime input lint contract --- .../2026-08-28-stack-runtime-rewrite-progress.md | 8 ++++++++ packages/stack/src/runtime/RuntimeInputOwner.ts | 9 ++++++--- .../runtime/runtime-input-owner.integration.test.ts | 13 ++++++++++++- .../stack/src/state/secrets.integration.test.ts | 1 + 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index b33454ed50..15f2207869 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1016,3 +1016,11 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Removed redundant `Effect.yieldNow` scheduler nudges from cold-join and interrupted-waiter tests; startup uses `forkChild(..., { startImmediately: true })` and Deferred gates exclusively. Focused owner/secret/workload/catalog integration verification passes 58 cases with stack type-check, Effect lint, formatting, and diff checks green. + +#### Task 14 lint-compliance correction (2026-08-31) + +- The injected OIDC fetcher now exposes `StackPreparationError` in its typed failure channel; JSON output uses + Effect schema encoding at the production boundary, while dynamic integration fixtures carry narrow rationale + comments for their direct parse/stringify assertions. The exact changed-file Effect lint now exits cleanly. +- Focused owner/secret/workload/catalog integration verification remains green at 58 cases with stack type-check, + formatting, and diff checks passing. diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 3210c291bb..baae4ded30 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -22,7 +22,7 @@ import { import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; /** A parsed JSON document fetched by the owner for OIDC discovery. */ -export type RuntimeJsonFetcher = (url: string) => Effect.Effect; +export type RuntimeJsonFetcher = (url: string) => Effect.Effect; export interface RuntimeAuthTemplate { /** Stable URL id used by GoTrue's mailer template setting. */ @@ -93,6 +93,7 @@ const settingValue = (state: PersistedStackState, value: unknown): string => { if (Array.isArray(value)) return value.map((entry) => settingValue(state, entry)).join(","); if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) return state.secrets[value.slot]?.value ?? ""; + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- dynamic persisted settings are stringified for workload env values return JSON.stringify(value) ?? ""; }; @@ -130,7 +131,7 @@ const extensionFor = (configuredPath: string): string => { const publicRemoteJwks = Schema.Struct({ keys: Schema.Array(Schema.Unknown) }); const oidcDiscovery = Schema.Struct({ jwks_uri: Schema.String }); -const decodeJson = (value: unknown): Effect.Effect => +const decodeJson = (value: unknown): Effect.Effect => typeof value === "string" ? Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(value) : Effect.succeed(value); @@ -447,7 +448,9 @@ export const makeRuntimeInputOwner = ( : yield* resolveAuthTemplates(state); return { ...(local === undefined ? {} : { jwtKeys: local.privateKeysJson }), - jwks: JSON.stringify({ keys: publicKeys }), + jwks: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + keys: publicKeys, + }).pipe(Effect.mapError(() => failure("Unable to encode Auth JWKS"))), ...(templates.length === 0 ? {} : { templates }), }; }); diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index eb4bf64106..e0856a9151 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -14,6 +14,7 @@ import { } from "effect"; import { generateKeyPairSync } from "node:crypto"; import { compileStack, type CompiledStack } from "../model/Compiler.ts"; +import { StackPreparationError } from "../public/Errors.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import type { PersistedStackState } from "../state/StackState.ts"; @@ -115,6 +116,7 @@ describe("runtime input owner", () => { }); yield* fs.writeFileString( path.join(root, "keys.json"), + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- dynamic JWK fixture JSON JSON.stringify([ { ...first, alg: "ES256", kid: "ec-key" }, { ...second, alg: "RS256", kid: "rsa-key" }, @@ -156,7 +158,9 @@ describe("runtime input owner", () => { ), }); const material = yield* owner.resolve(state, 1); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWT fixture expect(JSON.parse(material.auth?.jwtKeys ?? "[]")).toHaveLength(2); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWKS fixture const jwks = JSON.parse(material.auth?.jwks ?? "{}"); expect(jwks.keys).toHaveLength(3); expect(jwks.keys.every((key: Record) => !Object.hasOwn(key, "d"))).toBe( @@ -177,6 +181,7 @@ describe("runtime input owner", () => { }); yield* fs.writeFileString( path.join(root, "keys.json"), + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- dynamic JWK fixture JSON JSON.stringify([ { ...valid, alg: "ES256" }, { kty: "EC", alg: "ES256", d: "bad" }, @@ -235,6 +240,7 @@ describe("runtime input owner", () => { "https://securetoken.google.com/demo/.well-known/openid-configuration", "https://issuer.example/keys", ]); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWKS fixture expect(JSON.parse(material.auth?.jwks ?? "{}").keys).toHaveLength(2); }), ), @@ -252,6 +258,7 @@ describe("runtime input owner", () => { }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWKS fixture const jwks = JSON.parse((yield* owner.resolve(state, 1)).auth?.jwks ?? "{}"); expect(jwks.keys).toEqual([ { @@ -354,12 +361,14 @@ describe("runtime input owner", () => { const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId, - fetchJson: () => Effect.fail(new Error("transport failure")), + fetchJson: () => Effect.fail(new StackPreparationError({ message: "transport failure" })), }); const failed = yield* owner.resolve(state, 1).pipe(Effect.exit); const error = errorOf(failed); expect(error?.message).toContain("OIDC discovery request failed"); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assert sanitized diagnostic payload expect(JSON.stringify(error)).not.toContain("secret-token"); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assert sanitized diagnostic payload expect(JSON.stringify(error)).not.toContain("fragment"); }), ), @@ -505,8 +514,10 @@ describe("runtime input owner", () => { ...base, privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], }; + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- clone dynamic persisted fixture const missing = JSON.parse(JSON.stringify(native)); delete missing.definition.capabilities.pooler.settings.default_pool_size; + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- clone dynamic persisted fixture const blank = JSON.parse(JSON.stringify(native)); blank.definition.capabilities.pooler.settings.default_pool_size = " "; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index 55c0abbe4c..75fa75169f 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -124,6 +124,7 @@ describe("managed and pass-through secrets", () => { }; yield* fs.writeFileString( path.join(root, "keys.json"), + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- dynamic JWK fixture JSON JSON.stringify([privateJwk, { kty: "EC", alg: "ES256", d: "invalid" }]), ); const failed = yield* resolveSigningKeyMaterial({ From 501099d0b29e86ac1152a3e2d284a26feb07b235 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 13:35:14 +0200 Subject: [PATCH 083/174] feat(stack): wire runtime inputs and auth templates --- packages/stack/src/gateway/Gateway.ts | 29 +- packages/stack/src/gateway/HttpGateway.ts | 45 +- packages/stack/src/gateway/TcpGateway.ts | 8 +- .../src/gateway/gateway.integration.test.ts | 96 +++- .../stack/src/runtime/ProductionRuntime.ts | 134 +++++- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 18 +- .../production-runtime.integration.test.ts | 410 +++++++++++++++++- .../workload-runtime.integration.test.ts | 15 +- packages/stack/src/supervisor/Ingress.ts | 83 +++- .../supervisor/ingress.integration.test.ts | 154 ++++++- 10 files changed, 942 insertions(+), 50 deletions(-) diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts index 5bc5191d42..23f83107e2 100644 --- a/packages/stack/src/gateway/Gateway.ts +++ b/packages/stack/src/gateway/Gateway.ts @@ -153,6 +153,13 @@ export interface GatewayRouteRequest { readonly headers: Readonly>; } +/** A response produced locally without activating or resolving a backend. */ +export interface GatewayLocalResponse { + readonly status?: number; + readonly body: string | Uint8Array; + readonly contentType?: string; +} + export type GatewayHeaderValue = string | string[]; export type GatewayHeaders = Readonly>; export type GatewayHeaderTransform = ( @@ -160,7 +167,7 @@ export type GatewayHeaderTransform = ( headers: GatewayHeaders, ) => GatewayHeaders; -export interface GatewayRoute { +export interface GatewayProxyRoute { readonly capability: CapabilityName; /** Workload binding selected when a capability exposes multiple endpoints. */ readonly binding?: string; @@ -173,8 +180,28 @@ export interface GatewayRoute { readonly prepare?: ( request: GatewayRouteRequest, ) => Effect.Effect; + /** Proxy routes never produce a local response. */ + readonly localResponse?: never; +} + +/** HTTP-only route that produces a response without activating a capability. */ +export interface GatewayLocalRoute { + readonly capability?: never; + readonly binding?: never; + readonly match: (request: GatewayRouteRequest) => boolean; + readonly upstreamPath?: never; + readonly upstreamHeaders?: never; + readonly prepare?: never; + readonly localResponse: ( + request: GatewayRouteRequest, + ) => Effect.Effect; } +export type GatewayRoute = GatewayProxyRoute | GatewayLocalRoute; + +export const isGatewayProxyRoute = (route: GatewayRoute): route is GatewayProxyRoute => + route.capability !== undefined; + export interface StackGateway { readonly http: ReadonlyMap; readonly tcp: ReadonlyMap; diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index dd512d6092..731c1f70bf 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -19,13 +19,15 @@ import { import type { ActivationResult, BackendEndpoint, + GatewayLocalResponse, + GatewayProxyRoute, GatewayRoute, GatewayRouteRequest, GatewayHeaders, LazyActivator, PreparedGatewayRoute, } from "./Gateway.ts"; -import { GatewayRouteNotFoundError } from "./Gateway.ts"; +import { GatewayRouteNotFoundError, isGatewayProxyRoute } from "./Gateway.ts"; import type { HostListener } from "../state/PortCoordinator.ts"; class GatewayBackendError extends Data.TaggedError("GatewayBackendError")<{ @@ -39,7 +41,7 @@ export interface HttpGatewayOptions { readonly routes: ReadonlyArray; readonly activate: LazyActivator["activate"]; readonly resolveBackend?: ( - route: GatewayRoute, + route: GatewayProxyRoute, request: GatewayRouteRequest, activation: ActivationResult, ) => Effect.Effect; @@ -99,6 +101,17 @@ const respond = ( response.end(body); }; +const respondLocal = ( + response: ServerResponse, + local: GatewayLocalResponse, + options: HttpGatewayOptions, +): void => { + setCors(response, options); + response.statusCode = local.status ?? 200; + response.setHeader("content-type", local.contentType ?? "application/octet-stream"); + response.end(local.body); +}; + const routeFor = ( request: GatewayRouteRequest, routes: ReadonlyArray, @@ -248,7 +261,12 @@ const handleRequest = ( ): void => { const view = requestView(request); setCors(response, options); + const earlyRoute = routeFor(view, options.routes); if (request.method === "OPTIONS") { + if (earlyRoute?.localResponse !== undefined) { + respond(response, 404, JSON.stringify({ error: "Not found" }), options); + return; + } response.statusCode = 204; response.end(); return; @@ -266,6 +284,20 @@ const handleRequest = ( respond(response, 404, JSON.stringify({ error: "Not found" }), options); return; } + if (route.localResponse !== undefined) { + const local = route.localResponse(view); + const fiber = runFork(local); + fiber.addObserver((exit) => { + if (Exit.isSuccess(exit)) respondLocal(response, exit.value, options); + else if (!response.writableEnded && !response.destroyed) + respond(response, 404, JSON.stringify({ error: "Not found" }), options); + }); + return; + } + if (!isGatewayProxyRoute(route)) { + respond(response, 404, JSON.stringify({ error: "Not found" }), options); + return; + } const preparation: Effect.Effect< PreparedGatewayRoute | undefined, GatewayRouteNotFoundError | GatewayActivationError @@ -347,6 +379,15 @@ const handleUpgrade = ( socket.destroy(); return; } + // Local routes are HTTP-only and must never invoke their handler for upgrades. + if (route.localResponse !== undefined) { + socket.destroy(); + return; + } + if (!isGatewayProxyRoute(route)) { + socket.destroy(); + return; + } const preparation: Effect.Effect< PreparedGatewayRoute | undefined, GatewayRouteNotFoundError | GatewayActivationError diff --git a/packages/stack/src/gateway/TcpGateway.ts b/packages/stack/src/gateway/TcpGateway.ts index d8ac97a097..c0f3093e2d 100644 --- a/packages/stack/src/gateway/TcpGateway.ts +++ b/packages/stack/src/gateway/TcpGateway.ts @@ -6,9 +6,11 @@ import type { ActivationResult, BackendEndpoint, GatewayRoute, + GatewayProxyRoute, GatewayRouteRequest, LazyActivator, } from "./Gateway.ts"; +import { isGatewayProxyRoute } from "./Gateway.ts"; import type { HostListener } from "../state/PortCoordinator.ts"; export interface TcpGatewayOptions { @@ -18,7 +20,7 @@ export interface TcpGatewayOptions { readonly routes: ReadonlyArray; readonly activate: LazyActivator["activate"]; readonly resolveBackend?: ( - route: GatewayRoute, + route: GatewayProxyRoute, request: GatewayRouteRequest, activation: ActivationResult, ) => Effect.Effect; @@ -118,6 +120,10 @@ const handleConnection = ( source.destroy(); return; } + if (!isGatewayProxyRoute(route)) { + source.destroy(); + return; + } const view: GatewayRouteRequest = { path: "/", headers: {} }; const operation = options.activate(route.capability).pipe( Effect.flatMap((result) => diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index f9ea292d22..3666840e91 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -12,7 +12,12 @@ import { type ServerResponse, // oxlint-disable-next-line effecttsgo/node-builtin-import } from "node:http"; -import { createLazyActivator, makeGateway, type BackendEndpoint } from "./Gateway.ts"; +import { + createLazyActivator, + GatewayRouteNotFoundError, + makeGateway, + type BackendEndpoint, +} from "./Gateway.ts"; import { makeHttpGateway } from "./HttpGateway.ts"; import { makeTcpGateway } from "./TcpGateway.ts"; import type { HostListener } from "../state/PortCoordinator.ts"; @@ -45,6 +50,95 @@ const getStatus = (port: number, path: string) => }); describe("stack gateway", () => { + it.live("serves local HTTP routes before activation and rejects upgrades", () => + withPlatform( + Effect.gen(function* () { + let localCalls = 0; + let activations = 0; + const gateway = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [ + { + match: (request) => request.path.startsWith("/email/"), + localResponse: (request) => + request.method === "GET" + ? Effect.sync(() => { + localCalls += 1; + return { body: "template", contentType: "text/plain; charset=utf-8" }; + }) + : Effect.fail(new GatewayRouteNotFoundError({ message: "Not found" })), + }, + ], + activate: () => + Effect.sync(() => { + activations += 1; + return { capability: "auth" as const, endpoint: { host: "127.0.0.1", port: 1 } }; + }), + }); + const response = yield* Effect.callback<{ status: number; body: string }, Error>( + (resume) => { + const request = requestHttp( + { host: "127.0.0.1", port: gateway.port, path: "/email/confirmation.html" }, + (result) => { + const chunks: Buffer[] = []; + result.on("data", (chunk: Buffer) => chunks.push(chunk)); + result.once("end", () => + resume( + Effect.succeed({ + status: result.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + }), + ), + ); + }, + ); + request.once("error", (error) => resume(Effect.fail(error))); + request.end(); + return Effect.sync(() => request.destroy()); + }, + ); + expect(response).toEqual({ status: 200, body: "template" }); + expect(localCalls).toBe(1); + expect(activations).toBe(0); + const failedMethod = yield* Effect.callback((resume) => { + const request = requestHttp( + { + host: "127.0.0.1", + port: gateway.port, + path: "/email/confirmation.html", + method: "POST", + }, + (result) => { + result.resume(); + result.once("end", () => resume(Effect.succeed(result.statusCode ?? 0))); + }, + ); + request.once("error", (error) => resume(Effect.fail(error))); + request.end(); + return Effect.sync(() => request.destroy()); + }); + expect(failedMethod).toBe(404); + expect(localCalls).toBe(1); + expect(activations).toBe(0); + yield* Effect.callback((resume) => { + const socket = connectNet(gateway.port, "127.0.0.1"); + socket.once("connect", () => + socket.write( + "GET /email/confirmation.html HTTP/1.1\r\nHost: gateway.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + ), + ); + socket.once("close", () => resume(Effect.void)); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }); + expect(localCalls).toBe(1); + expect(activations).toBe(0); + yield* gateway.close; + }), + ), + ); + it.live("routes an HTTP request after lazy activation and forwards the response", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 2f6b4f01fb..fc41ea4285 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -37,6 +37,11 @@ import { type RuntimeArtifactPreparer, } from "../preparation/RuntimeArtifacts.ts"; import { makeRuntimeEnvFileOwner, type RuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; +import { + makeRuntimeInputOwner, + type RuntimeInputOwner, + type RuntimeJsonFetcher, +} from "./RuntimeInputOwner.ts"; import { FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, resolveContainerResolutionFor, @@ -71,6 +76,7 @@ export interface ProductionRuntimeFactoryOptions { readonly logStore?: LogStore; readonly envFileOwner?: RuntimeEnvFileOwner; readonly functionsBootstrapOwner?: FunctionsBootstrapOwner; + readonly fetchJson?: RuntimeJsonFetcher; readonly bootstrapDatabase?: ( state: PersistedStackState, ) => Effect.Effect; @@ -120,6 +126,9 @@ const runtimeMatches = (left: StackRuntime, right: StackRuntime): boolean => { return left.engine === right.engine; }; +const urlHost = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + const redactEntry = ( entry: A, secrets: ReadonlyArray, @@ -209,16 +218,27 @@ export const withOwnedRuntimeFileCleanup = ( driver: RuntimeDriver, envFiles: RuntimeEnvFileOwner, functionsBootstrap: FunctionsBootstrapOwner, + inputOwner?: RuntimeInputOwner, ): RuntimeDriver => { const cleanupFiles = (stackId: StackId): Effect.Effect => - Effect.all([envFiles.cleanupAll, functionsBootstrap.cleanupAll], { - concurrency: "unbounded", - discard: true, - }).pipe( - Effect.mapError((error) => - driverError({ stackId, workloadId: "" }, "Unable to clean runtime files", error), - ), - ); + Effect.gen(function* () { + const key = { stackId, workloadId: "" }; + let cleanupCause: Cause.Cause = Cause.empty; + const attempts = [ + envFiles.cleanupAll, + functionsBootstrap.cleanupAll, + ...(inputOwner === undefined ? [] : [inputOwner.cleanupAll]), + ]; + for (const attempt of attempts) { + const result = yield* Effect.exit( + attempt.pipe( + Effect.mapError((error) => driverError(key, "Unable to clean runtime files", error)), + ), + ); + if (Exit.isFailure(result)) cleanupCause = Cause.combine(cleanupCause, result.cause); + } + if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); + }); return { ...driver, cleanup: (request) => @@ -256,6 +276,29 @@ export const makeProductionRuntimeFactory = ( }).pipe( Effect.mapError((error) => preparationError("Unable to resolve stack runtime paths", error)), ); + const fetchJson: RuntimeJsonFetcher = + options.fetchJson ?? + ((url) => + // oxlint-disable effecttsgo/async-function -- production fetch leaf owns AbortSignal. + // oxlint-disable effecttsgo/global-fetch-in-effect -- production fetch leaf is the network boundary. + Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return await response.json(); + }, + catch: (cause) => preparationError("Unable to fetch Auth OIDC metadata", cause), + })); + // oxlint-enable effecttsgo/async-function + // oxlint-enable effecttsgo/global-fetch-in-effect + const inputOwner = yield* makeRuntimeInputOwner({ + stateRoot: options.stateRoot, + stackId: options.stackId, + fetchJson, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + yield* Effect.addFinalizer(() => + inputOwner.cleanupAll.pipe(Effect.catchTag("StackPreparationError", () => Effect.void)), + ).pipe(Effect.provideService(Scope.Scope, ownerScope)); const registry = yield* makePortRegistry({ stateRoot: options.stateRoot, store: options.stateStore, @@ -267,6 +310,7 @@ export const makeProductionRuntimeFactory = ( registry, store: options.stateStore, context: options.context, + resolveAuthTemplates: inputOwner.resolveAuthTemplates, })); const envFiles = options.envFileOwner ?? @@ -357,18 +401,48 @@ export const makeProductionRuntimeFactory = ( }; const runtimeInputs = ( workload: PlannedWorkload, + fresh: PersistedStackState, generation: number, host: ContainerHostRoute | undefined, ): Effect.Effect => Effect.gen(function* () { + const material = yield* inputOwner.resolve(fresh, generation); + const templates = material.auth?.templates; + const apiListener = fresh.definition?.listeners.api; + const apiAssignment = fresh.ports.find((assignment) => assignment.field === "api"); + const templateBaseUrl = + templates === undefined || templates.length === 0 + ? undefined + : apiListener?.enabled !== true || apiAssignment === undefined + ? yield* preparationError( + "Configured Auth email templates require a public API listener", + ) + : `http://${urlHost(host?.host ?? apiListener.address)}:${apiAssignment.port}`; + const auth = + material.auth === undefined + ? undefined + : { + ...material.auth, + ...(templateBaseUrl === undefined ? {} : { templateBaseUrl }), + }; const functions = workload.id === "functions:edge-runtime" ? { bootstrapPath: yield* functionsPath(generation), bootstrapContainerPath: FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, + ...(material.functions?.secrets === undefined + ? {} + : { secrets: material.functions.secrets }), } : undefined; return { + ...(auth === undefined ? {} : { auth }), + ...(workload.id === "analytics:analytics" && material.analytics !== undefined + ? { analytics: material.analytics } + : {}), + ...(workload.id === "pooler:pooler" && material.pooler !== undefined + ? { pooler: material.pooler } + : {}), database: { dataPath: pathService.join(paths.data, "database") }, storage: { dataPath: pathService.join(paths.data, "storage") }, ...(functions === undefined ? {} : { functions }), @@ -461,6 +535,7 @@ export const makeProductionRuntimeFactory = ( ); const inputs = yield* runtimeInputs( workload, + fresh, key.desiredGeneration, undefined, ).pipe(Effect.mapError((error) => mapDriverError(key, error))); @@ -565,6 +640,7 @@ export const makeProductionRuntimeFactory = ( } const inputs = yield* runtimeInputs( workload, + fresh, key.desiredGeneration, route, ).pipe(Effect.mapError((error) => mapDriverError(key, error))); @@ -633,7 +709,47 @@ export const makeProductionRuntimeFactory = ( logStore: logs, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); } - const ownedDriver = withOwnedRuntimeFileCleanup(driver, envFiles, functionsBootstrap); + const baseDriver = withOwnedRuntimeFileCleanup( + driver, + envFiles, + functionsBootstrap, + inputOwner, + ); + const ownedDriver: RuntimeDriver = { + ...baseDriver, + stop: (key) => + baseDriver + .stop(key) + .pipe( + Effect.tap(() => + key.workloadId === "pooler:pooler" + ? inputOwner + .cleanupGeneration(key.desiredGeneration) + .pipe( + Effect.mapError((error) => + driverError(key, "Unable to clean pooler runtime input", error), + ), + ) + : Effect.void, + ), + ), + remove: (key) => + baseDriver + .remove(key) + .pipe( + Effect.tap(() => + key.workloadId === "pooler:pooler" + ? inputOwner + .cleanupGeneration(key.desiredGeneration) + .pipe( + Effect.mapError((error) => + driverError(key, "Unable to clean pooler runtime input", error), + ), + ) + : Effect.void, + ), + ), + }; return { driver: ownedDriver, prepare: prepareArtifacts, diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 1f293520b4..7ac2e68d17 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -42,12 +42,12 @@ export interface WorkloadRuntimeInputs { /** Resolved host path for the BigQuery service-account file. */ readonly analytics?: Readonly<{ readonly gcpJwtPath?: string; - readonly vectorConfigPath?: string; }>; /** Stack-owned Edge Runtime bootstrap source and its private container target. */ readonly functions?: Readonly<{ readonly bootstrapPath?: string; readonly bootstrapContainerPath?: string; + readonly secrets?: Readonly>; }>; /** Stack-owned native persistent data paths. Containers use their named volumes instead. */ readonly database?: Readonly<{ readonly dataPath?: string }>; @@ -414,6 +414,7 @@ const edgeRuntimeJwtEnvironment = ( state.ports.find((assignment) => assignment.field === "api")?.port ?? "", ), SUPABASE_JWKS: inputs.auth?.jwks ?? '{"keys":[]}', + ...inputs.functions?.secrets, }); const functionsConfigEnvironment = (state: PersistedStackState): string => { @@ -1264,20 +1265,7 @@ const specs: Readonly> = { ...capabilityEnv(state, "analytics", "VECTOR"), VECTOR_API_PORT: String(port), }), - containerArgs: (_state, _workload, _port, inputs = {}) => - inputs.analytics?.vectorConfigPath === undefined - ? [] - : ["--config", "/etc/vector/vector.yaml", "--watch-config", "false"], - containerMounts: (_state, _workload, inputs = {}) => - inputs.analytics?.vectorConfigPath === undefined - ? [] - : [ - { - source: inputs.analytics.vectorConfigPath, - target: "/etc/vector/vector.yaml", - readOnly: true, - }, - ], + containerArgs: () => [], networkAliases: ["supabase-vector"], readiness: { protocol: "http", path: "/health" }, }, diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index f9997f63c8..b28e4ed1ea 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -1,6 +1,10 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Path, Crypto, Stream } from "effect"; +import { Effect, Exit, FileSystem, Path, Crypto, Redacted, Stream } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createServer } from "node:http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createServer as createNetServer } from "node:net"; import type { StackLogEntry } from "../public/Logs.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; @@ -12,6 +16,14 @@ import { makeProductionRuntimeFactory, withOwnedRuntimeFileCleanup } from "./Pro import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { compileStack } from "../model/Compiler.ts"; +import type { + ContainerContainerSpec, + ContainerEngine, + ContainerResource, + ContainerNetworkSpec, + ContainerVolumeSpec, +} from "./ContainerEngine.ts"; import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, @@ -117,6 +129,82 @@ const bootstrap: FunctionsBootstrapOwner = { cleanupAll: Effect.void, }; +const ownerInputContainerEngine = (createdSpecs: ContainerContainerSpec[]): ContainerEngine => { + const resources: ContainerResource[] = []; + let nextId = 1; + const resource = ( + kind: ContainerResource["kind"], + name: string, + labels: ContainerResource["labels"], + ): ContainerResource => ({ + id: `${kind}-${nextId++}`, + name, + kind, + labels, + ...(kind === "workload" ? { state: "created" as const } : {}), + }); + const updateState = (id: string, state: "running" | "stopped") => { + const index = resources.findIndex((entry) => entry.id === id); + const entry = resources[index]; + if (entry !== undefined) resources[index] = { ...entry, state }; + }; + return { + kind: "docker", + executable: "test-container-engine", + preflight: Effect.sync(() => { + return { host: "host.docker.internal" }; + }), + probe: Effect.void, + inspectImage: () => Effect.succeed({ present: true }), + pullImage: () => Effect.void, + listResources: () => + Effect.sync(() => { + return [...resources]; + }), + createNetwork: (spec: ContainerNetworkSpec) => + Effect.sync(() => { + const created = resource("network", spec.name, spec.labels); + resources.push(created); + return created; + }), + removeNetwork: (id) => + Effect.sync(() => { + const index = resources.findIndex((entry) => entry.id === id); + if (index >= 0) resources.splice(index, 1); + }), + createVolume: (spec: ContainerVolumeSpec) => + Effect.sync(() => { + const created = resource("volume", spec.name, spec.labels); + resources.push(created); + return created; + }), + removeVolume: (id) => + Effect.sync(() => { + const index = resources.findIndex((entry) => entry.id === id); + if (index >= 0) resources.splice(index, 1); + }), + createContainer: (spec) => + Effect.sync(() => { + createdSpecs.push(spec); + const created = resource("workload", spec.name, spec.labels); + resources.push(created); + return created; + }), + copyToContainer: () => Effect.void, + startContainer: (id) => + Effect.sync(() => { + updateState(id, "running"); + }), + stopContainer: (id) => Effect.sync(() => updateState(id, "stopped")), + removeContainer: (id) => + Effect.sync(() => { + const index = resources.findIndex((entry) => entry.id === id); + if (index >= 0) resources.splice(index, 1); + }), + streamLogs: () => Stream.empty, + }; +}; + describe("production runtime composition", () => { it.live("exposes artifact-only preparation without starting workloads or mutating state", () => Effect.scoped( @@ -213,6 +301,326 @@ describe("production runtime composition", () => { ), ); + it.live("wires owner material into container workloads", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-inputs-" }); + const template = path.join(root, "templates", "confirmation.html"); + yield* fs.makeDirectory(path.dirname(template), { recursive: true }); + yield* fs.writeFileString(template, "confirmation"); + const gcpCredentials = path.join(root, "gcp.json"); + yield* fs.writeFileString(gcpCredentials, "{}"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + capabilities: { + auth: { + settings: { + email: { + template: { confirmation: { content_path: "templates/confirmation.html" } }, + }, + }, + }, + pooler: { enabled: true }, + rest: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { + enabled: true, + settings: { + edge_runtime: { secrets: { FACTORY_SECRET: Redacted.make("factory-secret") } }, + }, + }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { + enabled: true, + settings: { backend: "bigquery", gcp_jwt_path: "gcp.json" }, + }, + }, + listeners: { + database: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + }, + }).pipe(Effect.provide(NodeServices.layer)); + const authServer = createServer((_request, response) => { + response.statusCode = 200; + response.setHeader("Connection", "close"); + response.end("ok"); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + authServer.once("error", (error) => resume(Effect.fail(error))); + authServer.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + if (!authServer.listening) return resume(Effect.void); + authServer.close(() => resume(Effect.void)); + }), + ); + const authAddress = authServer.address(); + if (typeof authAddress !== "object" || authAddress === null) + return yield* Effect.die("Auth readiness server did not expose an address"); + const functionsServer = createServer((_request, response) => { + response.statusCode = 200; + response.setHeader("Connection", "close"); + response.end("ok"); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + functionsServer.once("error", (error) => resume(Effect.fail(error))); + functionsServer.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + if (!functionsServer.listening) return resume(Effect.void); + functionsServer.close(() => resume(Effect.void)); + }), + ); + const functionsAddress = functionsServer.address(); + if (typeof functionsAddress !== "object" || functionsAddress === null) + return yield* Effect.die("Functions readiness server did not expose an address"); + const analyticsServer = createServer((_request, response) => { + response.statusCode = 200; + response.setHeader("Connection", "close"); + response.end("ok"); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + analyticsServer.once("error", (error) => resume(Effect.fail(error))); + analyticsServer.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + if (!analyticsServer.listening) return resume(Effect.void); + analyticsServer.close(() => resume(Effect.void)); + }), + ); + const analyticsAddress = analyticsServer.address(); + if (typeof analyticsAddress !== "object" || analyticsAddress === null) + return yield* Effect.die("Analytics readiness server did not expose an address"); + const poolerServer = createNetServer(); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + poolerServer.once("error", (error) => resume(Effect.fail(error))); + poolerServer.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + if (!poolerServer.listening) return resume(Effect.void); + poolerServer.close(() => resume(Effect.void)); + }), + ); + const poolerAddress = poolerServer.address(); + if (typeof poolerAddress !== "object" || poolerAddress === null) + return yield* Effect.die("Pooler readiness server did not expose an address"); + const current = { + value: { + ...stateFor( + { + "secret:database.internal.password": { policy: "managed", value: "db-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + "secret:auth.settings.publishable_key": { + policy: "managed", + value: "sb_publishable_test", + }, + "secret:auth.settings.secret_key": { + policy: "managed", + value: "sb_secret_test", + }, + "secret:functions.settings.edge_runtime.secrets.FACTORY_SECRET": { + policy: "managed", + value: "factory-secret", + }, + }, + { kind: "container", engine: "docker" }, + ), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + portsGeneration: 1, + ports: [{ field: "api" as const, port: 40_000, intent: "exact" as const }], + privatePorts: [ + { + workloadId: "auth:auth", + binding: "primary", + port: authAddress.port, + }, + { + workloadId: "pooler:pooler", + binding: "primary", + port: poolerAddress.port, + }, + { + workloadId: "functions:edge-runtime", + binding: "primary", + port: functionsAddress.port, + }, + { + workloadId: "analytics:analytics", + binding: "primary", + port: analyticsAddress.port, + }, + ], + }, + } satisfies { value: PersistedStackState }; + const context = yield* Effect.context(); + const createdSpecs: ContainerContainerSpec[] = []; + const engine = ownerInputContainerEngine(createdSpecs); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: engine, + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "present" as const, + image: workload.selected.kind === "container" ? workload.selected.image : undefined, + }), + }, + logStore: memoryLogStore([]), + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + const auth = compiled.executionPlan.workloads.find( + (workload) => workload.id === "auth:auth", + ); + const pooler = compiled.executionPlan.workloads.find( + (workload) => workload.id === "pooler:pooler", + ); + if (auth === undefined || pooler === undefined) + return yield* Effect.die("Expected Auth and Pooler workloads"); + yield* runtime.driver.start( + { + stackId, + desiredGeneration: 1, + workloadId: auth.id, + specHash: auth.specHash, + }, + auth, + ); + const authSpec = createdSpecs.find((spec) => spec.labels.workloadId === auth.id); + if (authSpec === undefined || authSpec.envFile === undefined) + return yield* Effect.die("Auth container was not captured"); + const authEnv = yield* fs.readFileString(authSpec.envFile); + expect(authEnv).toContain("GOTRUE_JWT_SECRET=jwt-secret"); + expect(authEnv).toContain( + "GOTRUE_MAILER_TEMPLATES_CONFIRMATION=http://host.docker.internal:40000/email/confirmation.html", + ); + const functions = compiled.executionPlan.workloads.find( + (workload) => workload.id === "functions:edge-runtime", + ); + const analytics = compiled.executionPlan.workloads.find( + (workload) => workload.id === "analytics:analytics", + ); + if (functions === undefined || analytics === undefined) + return yield* Effect.die("Expected Functions and Analytics workloads"); + yield* runtime.driver.start( + { + stackId, + desiredGeneration: 1, + workloadId: functions.id, + specHash: functions.specHash, + }, + functions, + ); + const functionsSpec = createdSpecs.find((spec) => spec.labels.workloadId === functions.id); + if (functionsSpec?.envFile === undefined) + return yield* Effect.die("Functions container was not captured"); + expect(yield* fs.readFileString(functionsSpec.envFile)).toContain( + "FACTORY_SECRET=factory-secret", + ); + yield* runtime.driver.start( + { + stackId, + desiredGeneration: 1, + workloadId: analytics.id, + specHash: analytics.specHash, + }, + analytics, + ); + const analyticsSpec = createdSpecs.find((spec) => spec.labels.workloadId === analytics.id); + expect(analyticsSpec?.mounts).toContainEqual({ + source: expect.stringContaining("gcp.json"), + target: "/opt/app/rel/logflare/bin/gcloud.json", + readOnly: true, + }); + yield* runtime.driver.start( + { + stackId, + desiredGeneration: 1, + workloadId: pooler.id, + specHash: pooler.specHash, + }, + pooler, + ); + const poolerSpec = createdSpecs.find((spec) => spec.labels.workloadId === pooler.id); + expect(poolerSpec?.mounts).toContainEqual({ + source: expect.stringContaining("pooler_tenant.exs"), + target: "/app/pooler_tenant.exs", + readOnly: true, + }); + expect(poolerSpec?.command?.join(" ")).toContain("/app/bin/supavisor eval"); + const tenantPath = poolerSpec?.mounts.find( + (mount) => mount.target === "/app/pooler_tenant.exs", + )?.source; + if (tenantPath === undefined) + return yield* Effect.die("Pooler tenant mount was not captured"); + expect(yield* fs.exists(tenantPath)).toBe(true); + yield* runtime.driver.stop({ + stackId, + desiredGeneration: 1, + workloadId: pooler.id, + specHash: pooler.specHash, + }); + expect(yield* fs.exists(tenantPath)).toBe(false); + yield* runtime.driver.start( + { + stackId, + desiredGeneration: 2, + workloadId: pooler.id, + specHash: pooler.specHash, + }, + pooler, + ); + const generationTwoSpec = createdSpecs.find( + (spec) => spec.labels.workloadId === pooler.id && spec.labels.desiredGeneration === 2, + ); + const generationTwoTenantPath = generationTwoSpec?.mounts.find( + (mount) => mount.target === "/app/pooler_tenant.exs", + )?.source; + if (generationTwoTenantPath === undefined) + return yield* Effect.die("Second-generation Pooler tenant mount was not captured"); + expect(yield* fs.exists(generationTwoTenantPath)).toBe(true); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + expect(yield* fs.exists(generationTwoTenantPath)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("cleans stale env and functions generations only during stack cleanup", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 121ff3e3ad..8a8b7d5ed7 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -624,18 +624,7 @@ describe("workload runtime catalog", () => { }); const vector = planned("analytics:vector"); expect(containerResolutionFor(configured, vector)?.command).toEqual([]); - const vectorResolution = containerResolutionFor(configured, vector, { - analytics: { vectorConfigPath: "/tmp/vector.yaml" }, - }); - expect(vectorResolution?.command).toEqual([ - "--config", - "/etc/vector/vector.yaml", - "--watch-config", - "false", - ]); - expect(vectorResolution?.mounts).toEqual([ - { source: "/tmp/vector.yaml", target: "/etc/vector/vector.yaml", readOnly: true }, - ]); + expect(containerResolutionFor(configured, vector)?.mounts).toEqual([]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -961,10 +950,12 @@ describe("workload runtime catalog", () => { ); const symmetric = containerResolutionFor(state, functions, { auth: { jwks: '{"keys":[{"kty":"EC"}]}' }, + functions: { secrets: { APP_SECRET: "value" } }, }); expect(symmetric?.env).toMatchObject({ SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", SUPABASE_JWKS: '{"keys":[{"kty":"EC"}]}', + APP_SECRET: "value", }); const jwksCompiled = yield* compileStack({ diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index aca8189708..224c4adb4e 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -3,6 +3,7 @@ import type { LifecycleInput } from "./Lifecycle.ts"; import type { ActivationResult, GatewayRoute, + GatewayProxyRoute, GatewayRouteRequest, StackGateway, } from "../gateway/Gateway.ts"; @@ -12,7 +13,7 @@ import { type StackError, } from "../public/Errors.ts"; import { routeCatalogFor, type GatewayApiMaterial } from "../gateway/RouteCatalog.ts"; -import { makeGateway } from "../gateway/Gateway.ts"; +import { GatewayRouteNotFoundError, makeGateway } from "../gateway/Gateway.ts"; import { makePortCoordinator, type HostListener, @@ -59,6 +60,15 @@ export interface SupervisorIngressOptions { readonly apiMaterial?: ( state: LifecycleInput["state"], ) => Effect.Effect; + /** Resolves the accepted generation's Auth templates for live local serving. */ + readonly resolveAuthTemplates?: (state: LifecycleInput["state"]) => Effect.Effect< + ReadonlyArray<{ + readonly id: string; + readonly canonicalPath: string; + readonly extension: string; + }>, + StackPreparationError + >; readonly bindHost?: ( address: string, port: number, @@ -100,7 +110,7 @@ const defaultApiMaterial = ( const routeBackend = ( input: LifecycleInput, reservation: SupervisorIngressReservation, - route: Pick, + route: Pick, activation: ActivationResult, ) => { if (route.binding === undefined) return Effect.succeed(activation.endpoint); @@ -113,6 +123,20 @@ const routeBackend = ( : Effect.succeed({ host: "127.0.0.1", port: assignment.port }); }; +const templateContentType = (extension: string): string => { + switch (extension.toLowerCase()) { + case ".html": + case ".htm": + return "text/html; charset=utf-8"; + case ".txt": + return "text/plain; charset=utf-8"; + case ".json": + return "application/json"; + default: + return "application/octet-stream"; + } +}; + /** Compose PortCoordinator and StackGateway under one Supervisor owner scope. */ export const makeSupervisorIngress = ( options: SupervisorIngressOptions, @@ -122,6 +146,7 @@ export const makeSupervisorIngress = ( Scope.Scope | Crypto.Crypto | FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; const ownerScope = yield* Scope.Scope; const lock = yield* Semaphore.make(1); const current = yield* Ref.make< @@ -206,15 +231,63 @@ export const makeSupervisorIngress = ( return; const material = yield* (options.apiMaterial ?? defaultApiMaterial)(input.state); const catalog = routeCatalogFor(input.plan, material); + const resolveTemplates = options.resolveAuthTemplates; + const templateRoute: GatewayRoute | undefined = + resolveTemplates === undefined + ? undefined + : { + match: (request) => { + const pathname = request.path.split("?", 1)[0] ?? request.path; + return pathname === "/email" || pathname.startsWith("/email/"); + }, + localResponse: (request) => { + const pathname = request.path.split("?", 1)[0] ?? request.path; + if (request.method !== "GET") + return Effect.fail( + new GatewayRouteNotFoundError({ message: "Auth template not found" }), + ); + return resolveTemplates(input.state).pipe( + Effect.mapError( + () => new GatewayRouteNotFoundError({ message: "Auth template not found" }), + ), + Effect.flatMap((templates) => { + const template = templates.find( + (entry) => `/email/${entry.id}${entry.extension}` === pathname, + ); + return template === undefined + ? Effect.fail( + new GatewayRouteNotFoundError({ + message: "Auth template not found", + }), + ) + : fs.readFile(template.canonicalPath).pipe( + Effect.mapError( + () => + new GatewayRouteNotFoundError({ + message: "Auth template not found", + }), + ), + Effect.map((body) => ({ + body, + contentType: templateContentType(template.extension), + })), + ); + }), + ); + }, + }; const http = reservation.hostListeners .filter((listener) => isHttpPortField(listener.field)) .map((listener) => ({ field: listener.field, options: { listener, - routes: catalog.http.get(listener.field) ?? [], + routes: + listener.field === "api" && templateRoute !== undefined + ? [templateRoute, ...(catalog.http.get(listener.field) ?? [])] + : (catalog.http.get(listener.field) ?? []), resolveBackend: ( - route: GatewayRoute, + route: GatewayProxyRoute, _request: GatewayRouteRequest, result: ActivationResult, ) => routeBackend(input, reservation, route, result), @@ -228,7 +301,7 @@ export const makeSupervisorIngress = ( listener, routes: catalog.tcp.get(listener.field) ?? [], resolveBackend: ( - route: GatewayRoute, + route: GatewayProxyRoute, _request: GatewayRouteRequest, result: ActivationResult, ) => routeBackend(input, reservation, route, result), diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index f81d4a7189..f367ce972f 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -11,7 +11,7 @@ import { } from "node:http"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { compileStack } from "../model/Compiler.ts"; -import { GatewayActivationError } from "../public/Errors.ts"; +import { GatewayActivationError, StackPreparationError } from "../public/Errors.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import { makePortRegistry } from "../state/PortRegistry.ts"; import { makeSupervisorIngress } from "./Ingress.ts"; @@ -44,10 +44,10 @@ const listenBackend = (server: ReturnType) => () => closeServer(server), ).pipe(Effect.as(server)); -const request = (port: number) => +const request = (port: number, path = "/rest/v1/items", method = "GET") => Effect.callback<{ readonly status: number; readonly body: string }, Error>((resume) => { const client = requestHttp( - { host: "127.0.0.1", port, path: "/rest/v1/items", method: "GET" }, + { host: "127.0.0.1", port, path, method }, (response: IncomingMessage) => { const chunks: Buffer[] = []; response.on("data", (chunk: Buffer) => chunks.push(chunk)); @@ -256,4 +256,152 @@ describe("Supervisor ingress", () => { }), ), ); + + it.live("serves accepted Auth templates locally with live content and no activation", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-ingress-template-" }); + const stackIdentity = { + ...identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }; + const stackId = yield* deriveStackId(stackIdentity); + const templatePath = path.join(root, "templates", "confirmation.html"); + const outsideRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-ingress-outside-", + }); + const outsidePath = path.join(outsideRoot, "outside.html"); + yield* fs.makeDirectory(path.join(root, "templates"), { recursive: true }); + yield* fs.writeFileString(templatePath, "first"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + capabilities: { + rest: { enabled: false }, + auth: { + settings: { + email: { + template: { confirmation: { content_path: "templates/confirmation.html" } }, + }, + }, + }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + listeners: { + database: { enabled: false }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + }, + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const persisted = { + format: "supabase-stack-state-v1" as const, + identity: { ...stackIdentity, stackId }, + runtime: { kind: "native" as const }, + desiredGeneration: 1, + portsGeneration: null, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + ports: [], + privatePorts: [], + secrets: {}, + }; + yield* store.write(stackId, persisted); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const activated: string[] = []; + const ingress = yield* makeSupervisorIngress({ + stackId, + registry, + store, + context, + apiMaterial: () => + Effect.succeed({ + publishableKey: "sb_publishable_test", + secretKey: "sb_secret_test", + anonJwt: "anon-jwt", + serviceRoleJwt: "service-jwt", + }), + resolveAuthTemplates: () => + Effect.all({ root: fs.realPath(root), template: fs.realPath(templatePath) }).pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "Template is unavailable", cause }), + ), + Effect.flatMap(({ root: canonicalRoot, template: canonicalPath }) => + !path.relative(canonicalRoot, canonicalPath).startsWith("..") && + !path.isAbsolute(path.relative(canonicalRoot, canonicalPath)) + ? Effect.succeed([ + { + id: "confirmation", + extension: ".html", + canonicalPath, + }, + ]) + : Effect.fail(new StackPreparationError({ message: "Template escaped root" })), + ), + ), + }); + const input = { + stackId, + generation: 1, + desiredLifecycle: "running" as const, + state: persisted, + previous: persisted, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: {}, + plan: compiled.executionPlan, + }; + const reservation = yield* ingress.acquire(input); + yield* ingress.open(input, reservation, (capability) => { + activated.push(capability); + return Effect.fail(new GatewayActivationError({ message: "not reached" })); + }); + const api = reservation.assignments.api; + if (api === undefined) return yield* Effect.die("API listener was not assigned"); + const first = yield* request(api.port, "/email/confirmation.html"); + expect(first.status).toBe(200); + expect(first.body).toBe("first"); + expect(activated).toEqual([]); + yield* fs.writeFileString(templatePath, "second"); + const second = yield* request(api.port, "/email/confirmation.html?live=1"); + expect(second.status).toBe(200); + expect(second.body).toBe("second"); + expect((yield* request(api.port, "/email/unknown.html")).status).toBe(404); + expect((yield* request(api.port, "/email/confirmation.html", "POST")).status).toBe(404); + expect((yield* request(api.port, "/email/confirmation.html", "OPTIONS")).status).toBe(404); + expect((yield* request(api.port, "/email/../outside.html")).status).toBe(404); + expect((yield* request(api.port, "/email/%2e%2e/outside.html")).status).toBe(404); + yield* fs.remove(templatePath); + expect((yield* request(api.port, "/email/confirmation.html")).status).toBe(404); + yield* fs.writeFileString(outsidePath, "outside"); + yield* fs.symlink(outsidePath, templatePath); + expect((yield* request(api.port, "/email/confirmation.html")).status).toBe(404); + expect(activated).toEqual([]); + yield* ingress.close; + }), + ), + ); }); From ca8738c04d65eb134230bd0ebc9c8455ea1fafba Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 14:03:34 +0200 Subject: [PATCH 084/174] fix(stack): tighten runtime input ownership --- packages/stack/src/gateway/HttpGateway.ts | 18 +- .../stack/src/runtime/ProductionRuntime.ts | 27 ++- .../stack/src/runtime/RuntimeInputOwner.ts | 119 ++++++----- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 9 +- .../production-runtime.integration.test.ts | 193 +++++++++++++++++- .../runtime-input-owner.integration.test.ts | 123 ++++++++++- .../workload-runtime.integration.test.ts | 10 +- 7 files changed, 414 insertions(+), 85 deletions(-) diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index 731c1f70bf..2bc48a185d 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -261,9 +261,9 @@ const handleRequest = ( ): void => { const view = requestView(request); setCors(response, options); - const earlyRoute = routeFor(view, options.routes); + const route = routeFor(view, options.routes); if (request.method === "OPTIONS") { - if (earlyRoute?.localResponse !== undefined) { + if (route !== undefined && !isGatewayProxyRoute(route)) { respond(response, 404, JSON.stringify({ error: "Not found" }), options); return; } @@ -279,25 +279,21 @@ const handleRequest = ( response.end(JSON.stringify({ ok: true })); return; } - const route = routeFor(view, options.routes); if (route === undefined) { respond(response, 404, JSON.stringify({ error: "Not found" }), options); return; } - if (route.localResponse !== undefined) { + if (!isGatewayProxyRoute(route)) { const local = route.localResponse(view); const fiber = runFork(local); fiber.addObserver((exit) => { - if (Exit.isSuccess(exit)) respondLocal(response, exit.value, options); + if (Exit.isSuccess(exit) && !response.writableEnded && !response.destroyed) + respondLocal(response, exit.value, options); else if (!response.writableEnded && !response.destroyed) respond(response, 404, JSON.stringify({ error: "Not found" }), options); }); return; } - if (!isGatewayProxyRoute(route)) { - respond(response, 404, JSON.stringify({ error: "Not found" }), options); - return; - } const preparation: Effect.Effect< PreparedGatewayRoute | undefined, GatewayRouteNotFoundError | GatewayActivationError @@ -380,10 +376,6 @@ const handleUpgrade = ( return; } // Local routes are HTTP-only and must never invoke their handler for upgrades. - if (route.localResponse !== undefined) { - socket.destroy(); - return; - } if (!isGatewayProxyRoute(route)) { socket.destroy(); return; diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index fc41ea4285..6b30c8fd65 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -126,8 +126,10 @@ const runtimeMatches = (left: StackRuntime, right: StackRuntime): boolean => { return left.engine === right.engine; }; -const urlHost = (host: string): string => - host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +const urlHost = (host: string): string => { + const normalized = host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host; + return normalized.includes(":") && !normalized.startsWith("[") ? `[${normalized}]` : normalized; +}; const redactEntry = ( entry: A, @@ -291,14 +293,21 @@ export const makeProductionRuntimeFactory = ( })); // oxlint-enable effecttsgo/async-function // oxlint-enable effecttsgo/global-fetch-in-effect + const inputOwnerScope = yield* Scope.fork(ownerScope, "sequential"); const inputOwner = yield* makeRuntimeInputOwner({ stateRoot: options.stateRoot, stackId: options.stackId, fetchJson, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - yield* Effect.addFinalizer(() => - inputOwner.cleanupAll.pipe(Effect.catchTag("StackPreparationError", () => Effect.void)), - ).pipe(Effect.provideService(Scope.Scope, ownerScope)); + }).pipe(Effect.provideService(Scope.Scope, inputOwnerScope)); + yield* Scope.addFinalizer( + ownerScope, + Effect.gen(function* () { + yield* Scope.close(inputOwnerScope, Exit.void); + yield* inputOwner.cleanupAll.pipe( + Effect.catchTag("StackPreparationError", () => Effect.void), + ); + }), + ); const registry = yield* makePortRegistry({ stateRoot: options.stateRoot, store: options.stateStore, @@ -406,12 +415,14 @@ export const makeProductionRuntimeFactory = ( host: ContainerHostRoute | undefined, ): Effect.Effect => Effect.gen(function* () { - const material = yield* inputOwner.resolve(fresh, generation); + const material = yield* inputOwner.resolve(fresh, generation, { + includePooler: workload.id === "pooler:pooler", + }); const templates = material.auth?.templates; const apiListener = fresh.definition?.listeners.api; const apiAssignment = fresh.ports.find((assignment) => assignment.field === "api"); const templateBaseUrl = - templates === undefined || templates.length === 0 + workload.id !== "auth:auth" || templates === undefined || templates.length === 0 ? undefined : apiListener?.enabled !== true || apiAssignment === undefined ? yield* preparationError( diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index baae4ded30..abf902b4fa 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -51,6 +51,7 @@ export interface RuntimeInputOwner { readonly resolve: ( state: PersistedStackState, generation: number, + options?: Readonly<{ readonly includePooler?: boolean }>, ) => Effect.Effect; /** Resolves one configured project-relative regular file without copying it. */ readonly resolveProjectFile: ( @@ -312,20 +313,25 @@ export const makeRuntimeInputOwner = ( const email = isRecord(auth) && isRecord(auth.email) ? auth.email : {}; const result: RuntimeAuthTemplate[] = []; const ids = new Set(); + const urls = new Set(); const add = (id: string, raw: unknown): Effect.Effect => { if (!isRecord(raw)) return Effect.void; const configuredPath = settingValue(state, raw.content_path); if (configuredPath.length === 0) return Effect.void; if (ids.has(id)) return Effect.fail(failure("Duplicate Auth email template id", { id })); + const extension = extensionFor(configuredPath); + const url = `/email/${id}${extension}`; + if (urls.has(url)) return Effect.fail(failure("Duplicate Auth email URL", { url })); return resolveProjectFile(state, configuredPath).pipe( Effect.tap((canonicalPath) => Effect.sync(() => { ids.add(id); + urls.add(url); result.push({ id, path: configuredPath, canonicalPath, - extension: extensionFor(configuredPath), + extension, }); }), ), @@ -546,21 +552,18 @@ export const makeRuntimeInputOwner = ( const joinExit = (result: Exit.Exit): Effect.Effect => Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); - type OwnedResult = Deferred.Deferred< - Exit.Exit, - never - >; - const pending = new Map(); - const completed = new Map(); + type OwnedResult = Deferred.Deferred, never>; + const commonPending = new Map>(); + const commonCompleted = new Map(); + const poolerPending = new Map>(); + const poolerCompleted = new Map(); const keyFor = (state: PersistedStackState, generation: number): string => `${state.identity.projectRoot}\u0000${state.inputFingerprint ?? ""}\u0000${state.runtime.kind}\u0000${generation}`; - const materialize = ( + const materializeCommon = ( state: PersistedStackState, - generation: number, ): Effect.Effect => Effect.gen(function* () { - const runtime = state.runtime.kind; const jwtConsumers = ["rest", "auth", "realtime", "storage", "functions"] as const; const resolvesAuthMaterial = jwtConsumers.some( (capability) => state.definition?.capabilities[capability].enabled !== false, @@ -578,46 +581,33 @@ export const makeRuntimeInputOwner = ( state.definition?.capabilities.functions.enabled === false ? undefined : { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state) }; - const pooler = state.definition?.capabilities.pooler.enabled - ? { tenantPath: yield* writePoolerTenant(state, generation, runtime) } - : undefined; return { ...(auth === undefined ? {} : { auth }), ...(analytics === undefined ? {} : { analytics }), - ...(pooler === undefined ? {} : { pooler }), ...(functions === undefined ? {} : { functions }), }; }); - const resolve = ( - state: PersistedStackState, - generation: number, - ): Effect.Effect => + const singleFlight = ( + key: string, + pending: Map>, + completed: Map, + materialize: Effect.Effect, + ): Effect.Effect => Effect.gen(function* () { - const key = keyFor(state, generation); const result = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { type Admission = - | { readonly kind: "ready"; readonly value: RuntimeInputMaterial } - | { readonly kind: "waiting"; readonly deferred: OwnedResult }; - const readyAdmission = (value: RuntimeInputMaterial): Admission => ({ - kind: "ready", - value, - }); - const waitingAdmission = (deferred: OwnedResult): Admission => ({ - kind: "waiting", - deferred, - }); + | { readonly kind: "ready"; readonly value: A } + | { readonly kind: "waiting"; readonly deferred: OwnedResult }; const owned = yield* admission.withPermit( Effect.gen(function* () { const ready = completed.get(key); - if (ready !== undefined) return readyAdmission(ready); + if (ready !== undefined) return { kind: "ready", value: ready } satisfies Admission; const current = pending.get(key); - if (current !== undefined) return waitingAdmission(current); - const deferred = yield* Deferred.make< - Exit.Exit, - never - >(); + if (current !== undefined) + return { kind: "waiting", deferred: current } satisfies Admission; + const deferred = yield* Deferred.make, never>(); pending.set(key, deferred); const owner = execution .withPermit( @@ -627,35 +617,64 @@ export const makeRuntimeInputOwner = ( ); if (!stillCurrent) return yield* failure("Runtime input generation was invalidated"); - return yield* materialize(state, generation); + return yield* materialize; }), ) .pipe( Effect.onExit((exit) => Effect.uninterruptible( Effect.gen(function* () { - const current = yield* Effect.sync(() => pending.get(key) === deferred); - if (current) - yield* Effect.sync(() => { + yield* admission.withPermit( + Effect.sync(() => { + if (pending.get(key) !== deferred) return; pending.delete(key); if (Exit.isSuccess(exit)) completed.set(key, exit.value); - }); + }), + ); yield* Deferred.succeed(deferred, exit); }), ), ), ); yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); - return waitingAdmission(deferred); + return { kind: "waiting", deferred } satisfies Admission; }), ); - if (owned.kind === "ready") return Exit.succeed(owned.value); - return yield* restore(Deferred.await(owned.deferred)); + return owned.kind === "ready" + ? Exit.succeed(owned.value) + : yield* restore(Deferred.await(owned.deferred)); }), ); return yield* joinExit(result); }); + const resolve = ( + state: PersistedStackState, + generation: number, + options: Readonly<{ readonly includePooler?: boolean }> = {}, + ): Effect.Effect => + Effect.gen(function* () { + const key = keyFor(state, generation); + const common = yield* singleFlight( + key, + commonPending, + commonCompleted, + materializeCommon(state), + ); + if ( + options.includePooler !== true || + state.definition?.capabilities.pooler.enabled !== true + ) + return common; + const tenantPath = yield* singleFlight( + key, + poolerPending, + poolerCompleted, + writePoolerTenant(state, generation, state.runtime.kind), + ); + return { ...common, pooler: { tenantPath } }; + }); + const cleanupGeneration = (generation: number): Effect.Effect => invalidGeneration(generation) ? Effect.fail(failure("Invalid pooler generation")) @@ -663,10 +682,10 @@ export const makeRuntimeInputOwner = ( Effect.gen(function* () { yield* admission.withPermit( Effect.sync(() => { - for (const key of pending.keys()) - if (key.endsWith(`\u0000${generation}`)) pending.delete(key); - for (const key of completed.keys()) - if (key.endsWith(`\u0000${generation}`)) completed.delete(key); + for (const key of poolerPending.keys()) + if (key.endsWith(`\u0000${generation}`)) poolerPending.delete(key); + for (const key of poolerCompleted.keys()) + if (key.endsWith(`\u0000${generation}`)) poolerCompleted.delete(key); }), ); yield* mapFile( @@ -683,8 +702,10 @@ export const makeRuntimeInputOwner = ( Effect.gen(function* () { yield* admission.withPermit( Effect.sync(() => { - pending.clear(); - completed.clear(); + commonPending.clear(); + commonCompleted.clear(); + poolerPending.clear(); + poolerCompleted.clear(); }), ); yield* mapFile( diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 7ac2e68d17..6ff8017d22 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -405,8 +405,8 @@ const usesResolvedJwks = (state: PersistedStackState): boolean => { const edgeRuntimeJwtEnvironment = ( state: PersistedStackState, inputs: WorkloadRuntimeInputs, -): Record => - compactEnvironment({ +): Record => { + const fixed = compactEnvironment({ SUPABASE_INTERNAL_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), SUPABASE_INTERNAL_PUBLISHABLE_KEY: secret(state, "secret:auth.settings.publishable_key"), SUPABASE_INTERNAL_SECRET_KEY: secret(state, "secret:auth.settings.secret_key"), @@ -414,8 +414,11 @@ const edgeRuntimeJwtEnvironment = ( state.ports.find((assignment) => assignment.field === "api")?.port ?? "", ), SUPABASE_JWKS: inputs.auth?.jwks ?? '{"keys":[]}', - ...inputs.functions?.secrets, }); + // Preserve explicitly empty owner-resolved values. Spec-owned variables are + // spread after this helper in the workload env so they retain precedence. + return { ...fixed, ...inputs.functions?.secrets }; +}; const functionsConfigEnvironment = (state: PersistedStackState): string => { const settings = settingsFor(state, "functions"); diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index b28e4ed1ea..031eba8f5c 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -1,6 +1,17 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Path, Crypto, Redacted, Stream } from "effect"; +import { + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Path, + Crypto, + Redacted, + Scope, + Stream, +} from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer } from "node:http"; // oxlint-disable-next-line effecttsgo/node-builtin-import @@ -14,6 +25,7 @@ import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; import { makeProductionRuntimeFactory, withOwnedRuntimeFileCleanup } from "./ProductionRuntime.ts"; import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; +import { StackPreparationError } from "../public/Errors.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -597,6 +609,7 @@ describe("production runtime composition", () => { specHash: pooler.specHash, }); expect(yield* fs.exists(tenantPath)).toBe(false); + current.value = { ...current.value, desiredGeneration: 2, portsGeneration: 2 }; yield* runtime.driver.start( { stackId, @@ -621,6 +634,153 @@ describe("production runtime composition", () => { ), ); + it.live("closes the production owner scope while input resolution is in flight", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-owner-close-", + }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + capabilities: { + auth: { + settings: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }, + pooler: { enabled: true }, + rest: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + }, + listeners: { + database: { enabled: false }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + }, + }).pipe(Effect.provide(NodeServices.layer)); + const poolerServer = createNetServer(); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + poolerServer.once("error", (error) => resume(Effect.fail(error))); + poolerServer.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + if (!poolerServer.listening) return resume(Effect.void); + poolerServer.close(() => resume(Effect.void)); + }), + ); + const poolerAddress = poolerServer.address(); + if (typeof poolerAddress !== "object" || poolerAddress === null) + return yield* Effect.die("Pooler readiness server did not expose an address"); + const current = { + value: { + ...stateFor( + { + "secret:database.internal.password": { policy: "managed", value: "db-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + }, + { kind: "container", engine: "docker" }, + ), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + desiredLifecycle: "running" as const, + portsGeneration: 1, + privatePorts: [ + { workloadId: "pooler:pooler", binding: "primary", port: poolerAddress.port }, + ], + }, + } satisfies { value: PersistedStackState }; + const ownerScope = yield* Scope.make(); + const context = yield* Effect.context(); + const createdSpecs: ContainerContainerSpec[] = []; + const engine = ownerInputContainerEngine(createdSpecs); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let fetches = 0; + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: engine, + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "present" as const, + image: workload.selected.kind === "container" ? workload.selected.image : undefined, + }), + }, + fetchJson: (url) => + Effect.gen(function* () { + fetches += 1; + if (fetches > 2) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; + }), + logStore: memoryLogStore([]), + bootstrapDatabase: () => Effect.void, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const runtime = yield* factory.make(current.value); + const pooler = compiled.executionPlan.workloads.find( + (workload) => workload.id === "pooler:pooler", + ); + if (pooler === undefined) return yield* Effect.die("Expected Pooler workload"); + yield* runtime.driver.start( + { stackId, desiredGeneration: 1, workloadId: pooler.id, specHash: pooler.specHash }, + pooler, + ); + const firstTenant = createdSpecs + .find((spec) => spec.labels.desiredGeneration === 1) + ?.mounts.find((mount) => mount.target === "/app/pooler_tenant.exs")?.source; + if (firstTenant === undefined) return yield* Effect.die("Missing first Pooler tenant"); + current.value = { ...current.value, desiredGeneration: 2, portsGeneration: 2 }; + const inFlight = yield* Effect.forkChild( + runtime.driver.start( + { stackId, desiredGeneration: 2, workloadId: pooler.id, specHash: pooler.specHash }, + pooler, + ), + { startImmediately: true }, + ); + yield* Deferred.await(started); + yield* Scope.close(ownerScope, Exit.void); + const inFlightExit = yield* Fiber.join(inFlight).pipe(Effect.exit); + expect(Exit.isFailure(inFlightExit)).toBe(true); + expect(yield* fs.exists(firstTenant)).toBe(false); + yield* Deferred.succeed(release, undefined); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("cleans stale env and functions generations only during stack cleanup", () => Effect.scoped( Effect.gen(function* () { @@ -702,4 +862,35 @@ describe("production runtime composition", () => { expect(Exit.isFailure(result)).toBe(true); expect(calls.sort()).toEqual(["env", "functions"]); }); + + it("continues owner cleanup when one file owner fails", () => { + const calls: string[] = []; + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => Effect.die("unused"), + stop: () => Effect.void, + remove: () => Effect.void, + cleanup: () => Effect.void, + recover: () => Effect.succeed([]), + }; + const envOwner: RuntimeEnvFileOwner = { + write: () => Effect.die("unused"), + cleanupGeneration: () => Effect.void, + cleanupAll: Effect.gen(function* () { + calls.push("env"); + return yield* new StackPreparationError({ message: "env cleanup failed" }); + }), + }; + const functionsOwner: FunctionsBootstrapOwner = { + write: () => Effect.die("unused"), + cleanupGeneration: () => Effect.void, + cleanupAll: Effect.sync(() => { + calls.push("functions"); + }), + }; + const wrapped = withOwnedRuntimeFileCleanup(driver, envOwner, functionsOwner); + const result = Effect.runSyncExit(wrapped.cleanup({ stackId, destroy: false })); + expect(Exit.isFailure(result)).toBe(true); + expect(calls).toEqual(["env", "functions"]); + }); }); diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index e0856a9151..bf5e890c86 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -418,8 +418,8 @@ describe("runtime input owner", () => { ...state, privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], }; - const first = yield* owner.resolve(nativeState, 3); - const second = yield* owner.resolve(nativeState, 3); + const first = yield* owner.resolve(nativeState, 3, { includePooler: true }); + const second = yield* owner.resolve(nativeState, 3, { includePooler: true }); const thirdGenerationTenant = first.pooler?.tenantPath; expect(second.pooler?.tenantPath).toBe(thirdGenerationTenant); expect(thirdGenerationTenant).toBeDefined(); @@ -429,7 +429,7 @@ describe("runtime input owner", () => { expect(content).toContain('"external_id" => "pooler-dev"'); expect(content).toContain('"db_port" => 30001'); expect(content).not.toContain('"db_password" => ""'); - const fourth = yield* owner.resolve(nativeState, 4); + const fourth = yield* owner.resolve(nativeState, 4, { includePooler: true }); const fourthGenerationTenant = fourth.pooler?.tenantPath; expect(fourthGenerationTenant).toBeDefined(); expect(fourthGenerationTenant).not.toBe(thirdGenerationTenant); @@ -440,6 +440,36 @@ describe("runtime input owner", () => { ), ); + it.live("does not recreate cleaned Pooler material for non-Pooler work", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-lazy-" }); + const state = yield* compiledState(root, { + capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, + }); + const nativeState: PersistedStackState = { + ...state, + privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], + }; + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const first = yield* owner.resolve(nativeState, 3, { includePooler: true }); + const tenant = first.pooler?.tenantPath; + expect(tenant).toBeDefined(); + yield* owner.cleanupGeneration(3); + const common = yield* owner.resolve(nativeState, 3); + expect(common.pooler).toBeUndefined(); + expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler", "3"))).toBe( + false, + ); + const recreated = yield* owner.resolve(nativeState, 3, { includePooler: true }); + expect(recreated.pooler?.tenantPath).toBeDefined(); + expect(yield* fs.exists(recreated.pooler?.tenantPath ?? "")).toBe(true); + }), + ), + ); + it.live("renders literal Elixir interpolation and container database endpoint", () => withPlatform( Effect.gen(function* () { @@ -471,7 +501,7 @@ describe("runtime input owner", () => { }; const containerState = state; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(containerState, 4); + const material = yield* owner.resolve(containerState, 4, { includePooler: true }); const tenant = material.pooler?.tenantPath; expect(tenant).toBeDefined(); const content = yield* fs.readFileString(tenant!); @@ -495,7 +525,7 @@ describe("runtime input owner", () => { capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(state, 9).pipe(Effect.exit); + const failed = yield* owner.resolve(state, 9, { includePooler: true }).pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); }), ), @@ -525,7 +555,9 @@ describe("runtime input owner", () => { [10, missing], [11, blank], ] as const) { - const failed = yield* owner.resolve(invalid, generation).pipe(Effect.exit); + const failed = yield* owner + .resolve(invalid, generation, { includePooler: true }) + .pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("sizing settings are invalid"); expect( yield* fs.exists( @@ -558,7 +590,9 @@ describe("runtime input owner", () => { privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(nativeState, 7).pipe(Effect.exit); + const failed = yield* owner + .resolve(nativeState, 7, { includePooler: true }) + .pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler", "7"))).toBe( false, @@ -605,11 +639,11 @@ describe("runtime input owner", () => { }); }, }); - const first = yield* Effect.forkChild(owner.resolve(native, 5), { + const first = yield* Effect.forkChild(owner.resolve(native, 5, { includePooler: true }), { startImmediately: true, }); yield* Deferred.await(started); - const second = yield* Effect.forkChild(owner.resolve(native, 5), { + const second = yield* Effect.forkChild(owner.resolve(native, 5, { includePooler: true }), { startImmediately: true, }); expect(fetches).toBe(1); @@ -621,7 +655,7 @@ describe("runtime input owner", () => { expect(fetches).toBe(2); expect(firstMaterial.auth?.jwks).toBe(secondMaterial.auth?.jwks); expect(firstMaterial.pooler?.tenantPath).toBe(secondMaterial.pooler?.tenantPath); - const cached = yield* owner.resolve(native, 5); + const cached = yield* owner.resolve(native, 5, { includePooler: true }); expect(cached.pooler?.tenantPath).toBe(firstMaterial.pooler?.tenantPath); expect(fetches).toBe(2); }), @@ -682,6 +716,56 @@ describe("runtime input owner", () => { ), ); + it.live("does not republish completed material after concurrent cleanup", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-cleanup-race-", + }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let fetches = 0; + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: (url) => { + fetches += 1; + return Effect.gen(function* () { + if (fetches === 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; + }); + }, + }); + const materializing = yield* Effect.forkChild(owner.resolve(state, 30), { + startImmediately: true, + }); + yield* Deferred.await(started); + const cleanup = yield* Effect.forkChild(owner.cleanupAll, { startImmediately: true }); + yield* Deferred.succeed(release, undefined); + const materializingExit = yield* Fiber.join(materializing).pipe(Effect.exit); + const cleanupExit = yield* Fiber.join(cleanup).pipe(Effect.exit); + expect(Exit.isSuccess(materializingExit)).toBe(true); + expect(Exit.isSuccess(cleanupExit)).toBe(true); + yield* owner.resolve(state, 30); + expect(fetches).toBe(4); + }), + ), + ); + it.live("completes materializing and queued owners when their scope closes", () => withPlatform( Effect.gen(function* () { @@ -842,6 +926,25 @@ describe("runtime input owner", () => { }); const failed = yield* owner.resolveAuthTemplates(collisionState).pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("Duplicate Auth email template id"); + yield* fs.writeFileString(path.join(root, "welcome"), "welcome"); + const extensionCollisionState = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + email: { + template: { + welcome: { content_path: "confirm.html" }, + "welcome.html": { content_path: "welcome" }, + }, + }, + }, + }, + }, + }); + const extensionCollision = yield* owner + .resolveAuthTemplates(extensionCollisionState) + .pipe(Effect.exit); + expect(errorOf(extensionCollision)?.message).toContain("Duplicate Auth email URL"); }), ), ); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 8a8b7d5ed7..0ce94138c0 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -950,13 +950,21 @@ describe("workload runtime catalog", () => { ); const symmetric = containerResolutionFor(state, functions, { auth: { jwks: '{"keys":[{"kty":"EC"}]}' }, - functions: { secrets: { APP_SECRET: "value" } }, + functions: { + secrets: { + APP_SECRET: "value", + EMPTY_SECRET: "", + EDGE_RUNTIME_PORT: "secret-collision", + }, + }, }); expect(symmetric?.env).toMatchObject({ SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", SUPABASE_JWKS: '{"keys":[{"kty":"EC"}]}', APP_SECRET: "value", + EMPTY_SECRET: "", }); + expect(symmetric?.env.EDGE_RUNTIME_PORT).toBe("9000"); const jwksCompiled = yield* compileStack({ projectRoot: state.identity.projectRoot, From 8229fe7c5f2be96f0f9d83a0e9f488a82cac4438 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 14:25:30 +0200 Subject: [PATCH 085/174] fix(stack): fence runtime input generations --- .../src/gateway/gateway.integration.test.ts | 59 +++++++- .../stack/src/runtime/ProductionRuntime.ts | 2 + .../stack/src/runtime/RuntimeInputOwner.ts | 94 ++++++++---- .../production-runtime.integration.test.ts | 135 ++++++++++++++++++ .../runtime-input-owner.integration.test.ts | 34 +++-- 5 files changed, 287 insertions(+), 37 deletions(-) diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index 3666840e91..c25e52118c 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -1,11 +1,12 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Redacted } from "effect"; +import { Deferred, Effect, Exit, FileSystem, Fiber, Redacted } from "effect"; import { GatewayActivationError } from "../public/Errors.ts"; import { connect as connectNet, createServer, type Server } from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { Agent, + type ClientRequest, createServer as createHttpServer, request as requestHttp, type IncomingMessage, @@ -139,6 +140,62 @@ describe("stack gateway", () => { ), ); + it.live("keeps local routing usable when a client disconnects mid-response", () => + withPlatform( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let localCalls = 0; + let activations = 0; + const gateway = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [ + { + match: (request) => request.path.startsWith("/email/"), + localResponse: () => + Effect.gen(function* () { + localCalls += 1; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { body: "template", contentType: "text/plain; charset=utf-8" }; + }), + }, + ], + activate: () => + Effect.sync(() => { + activations += 1; + return { capability: "auth" as const, endpoint: { host: "127.0.0.1", port: 1 } }; + }), + }); + let request: ClientRequest | undefined; + const pending = yield* Effect.forkChild( + Effect.callback((resume) => { + request = requestHttp( + { host: "127.0.0.1", port: gateway.port, path: "/email/confirmation.html" }, + (response) => { + response.resume(); + response.once("end", () => resume(Effect.void)); + }, + ); + request.once("error", () => resume(Effect.void)); + request.end(); + return Effect.sync(() => request?.destroy()); + }), + { startImmediately: true }, + ); + yield* Deferred.await(started); + request?.destroy(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(pending).pipe(Effect.exit); + expect(yield* getStatus(gateway.port, "/email/confirmation.html")).toBe(200); + expect(localCalls).toBe(2); + expect(activations).toBe(0); + yield* gateway.close; + }), + ), + ); + it.live("routes an HTTP request after lazy activation and forwards the response", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 6b30c8fd65..e72b5c1ade 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -308,6 +308,8 @@ export const makeProductionRuntimeFactory = ( ); }), ); + // Register this child scope before constructing drivers: LIFO cleanup + // stops workloads, closes owner fibers, then removes generated files. const registry = yield* makePortRegistry({ stateRoot: options.stateRoot, store: options.stateStore, diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index abf902b4fa..0eae900eae 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -449,7 +449,7 @@ export const makeRuntimeInputOwner = ( return yield* failure("Persisted Auth JWT secret is missing"); const publicKeys = [...remote, ...localPublic, ...symmetric]; const templates = - state.definition?.capabilities.auth.enabled === false + state.definition?.capabilities.auth.enabled !== true ? [] : yield* resolveAuthTemplates(state); return { @@ -557,8 +557,10 @@ export const makeRuntimeInputOwner = ( const commonCompleted = new Map(); const poolerPending = new Map>(); const poolerCompleted = new Map(); + let acceptedGeneration: number | undefined; const keyFor = (state: PersistedStackState, generation: number): string => `${state.identity.projectRoot}\u0000${state.inputFingerprint ?? ""}\u0000${state.runtime.kind}\u0000${generation}`; + const generationFor = (key: string): number => Number(key.slice(key.lastIndexOf("\u0000") + 1)); const materializeCommon = ( state: PersistedStackState, @@ -566,7 +568,7 @@ export const makeRuntimeInputOwner = ( Effect.gen(function* () { const jwtConsumers = ["rest", "auth", "realtime", "storage", "functions"] as const; const resolvesAuthMaterial = jwtConsumers.some( - (capability) => state.definition?.capabilities[capability].enabled !== false, + (capability) => state.definition?.capabilities[capability].enabled === true, ); const auth = resolvesAuthMaterial ? yield* resolveAuth(state) : undefined; const analyticsSettings = settingsFor(state, "analytics"); @@ -574,11 +576,11 @@ export const makeRuntimeInputOwner = ( ? settingValue(state, analyticsSettings.gcp_jwt_path) : ""; const analytics = - state.definition?.capabilities.analytics.enabled === false || gcpPath.length === 0 + state.definition?.capabilities.analytics.enabled !== true || gcpPath.length === 0 ? undefined : { gcpJwtPath: yield* resolveProjectFile(state, gcpPath) }; const functions = - state.definition?.capabilities.functions.enabled === false + state.definition?.capabilities.functions.enabled !== true ? undefined : { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state) }; return { @@ -609,33 +611,38 @@ export const makeRuntimeInputOwner = ( return { kind: "waiting", deferred: current } satisfies Admission; const deferred = yield* Deferred.make, never>(); pending.set(key, deferred); - const owner = execution - .withPermit( + const publish = (exit: Exit.Exit) => + Effect.uninterruptible( Effect.gen(function* () { - const stillCurrent = yield* admission.withPermit( - Effect.sync(() => pending.get(key) === deferred), + const completion = yield* admission.withPermit( + Effect.sync(() => { + if (pending.get(key) !== deferred) + return Exit.fail(failure("Runtime input generation was invalidated")); + pending.delete(key); + if (Exit.isSuccess(exit)) completed.set(key, exit.value); + return exit; + }), ); - if (!stillCurrent) - return yield* failure("Runtime input generation was invalidated"); - return yield* materialize; + yield* Deferred.succeed(deferred, completion); }), - ) - .pipe( - Effect.onExit((exit) => - Effect.uninterruptible( - Effect.gen(function* () { - yield* admission.withPermit( - Effect.sync(() => { - if (pending.get(key) !== deferred) return; - pending.delete(key); - if (Exit.isSuccess(exit)) completed.set(key, exit.value); - }), - ); - yield* Deferred.succeed(deferred, exit); - }), - ), - ), ); + let entered = false; + const materializing = Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + entered = true; + const stillCurrent = yield* admission.withPermit( + Effect.sync(() => pending.get(key) === deferred), + ); + if (!stillCurrent) + return yield* failure("Runtime input generation was invalidated"); + return yield* restore(materialize); + }).pipe(Effect.onExit(publish)), + ); + const owner = execution.withPermit(materializing).pipe( + // An owner interrupted while queued on execution never enters + // `materializing`; this fallback still wakes its waiters. + Effect.onExit((exit) => (entered ? Effect.void : publish(exit))), + ); yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); return { kind: "waiting", deferred } satisfies Admission; }), @@ -655,6 +662,38 @@ export const makeRuntimeInputOwner = ( ): Effect.Effect => Effect.gen(function* () { const key = keyFor(state, generation); + type Retirement = Readonly<{ + readonly rejected: boolean; + readonly retired: ReadonlyArray>; + }>; + const retirement = yield* admission.withPermit( + Effect.sync((): Retirement => { + const accepted = acceptedGeneration; + if (accepted !== undefined && generation < accepted) + return { rejected: true, retired: [] }; + if (accepted === undefined || generation <= accepted) { + acceptedGeneration = generation; + return { rejected: false, retired: [] }; + } + const retired: Array> = []; + for (const [pendingKey, deferred] of commonPending) + if (generationFor(pendingKey) < generation) { + commonPending.delete(pendingKey); + retired.push(deferred); + } + for (const completedKey of commonCompleted.keys()) + if (generationFor(completedKey) < generation) commonCompleted.delete(completedKey); + acceptedGeneration = generation; + return { rejected: false, retired }; + }), + ); + yield* Effect.forEach(retirement.retired, (deferred) => + Deferred.succeed( + deferred, + Exit.fail(failure("Runtime input generation was invalidated")), + ), + ); + if (retirement.rejected) return yield* failure("Runtime input generation was invalidated"); const common = yield* singleFlight( key, commonPending, @@ -706,6 +745,7 @@ export const makeRuntimeInputOwner = ( commonCompleted.clear(); poolerPending.clear(); poolerCompleted.clear(); + acceptedGeneration = undefined; }), ); yield* mapFile( diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 031eba8f5c..6ceeb4937a 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -634,6 +634,141 @@ describe("production runtime composition", () => { ), ); + it.live("scopes Auth template URL requirements to the Auth workload", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-auth-scope-", + }); + const template = path.join(root, "confirmation.html"); + yield* fs.writeFileString(template, "confirmation"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + capabilities: { + auth: { + settings: { + email: { template: { confirmation: { content_path: "confirmation.html" } } }, + }, + }, + rest: { enabled: true }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + listeners: { + api: { enabled: false }, + database: { enabled: false }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + }, + }).pipe(Effect.provide(NodeServices.layer)); + const restServer = createServer((_request, response) => { + response.statusCode = 200; + response.setHeader("Connection", "close"); + response.end("ok"); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + restServer.once("error", (error) => resume(Effect.fail(error))); + restServer.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + if (!restServer.listening) return resume(Effect.void); + restServer.close(() => resume(Effect.void)); + }), + ); + const address = restServer.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("REST readiness server did not expose an address"); + const current = { + value: { + ...stateFor( + { + "secret:database.internal.password": { policy: "managed", value: "db-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + }, + { kind: "container", engine: "docker" }, + ), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + privatePorts: [ + { workloadId: "rest:rest", binding: "primary", port: address.port }, + { workloadId: "rest:rest", binding: "admin", port: address.port + 1 }, + { workloadId: "auth:auth", binding: "primary", port: address.port + 2 }, + ], + }, + } satisfies { value: PersistedStackState }; + const context = yield* Effect.context(); + const createdSpecs: ContainerContainerSpec[] = []; + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: ownerInputContainerEngine(createdSpecs), + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "present" as const, + image: workload.selected.kind === "container" ? workload.selected.image : undefined, + }), + }, + logStore: memoryLogStore([]), + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + const rest = compiled.executionPlan.workloads.find( + (workload) => workload.id === "rest:rest", + ); + const auth = compiled.executionPlan.workloads.find( + (workload) => workload.id === "auth:auth", + ); + if (rest === undefined || auth === undefined) + return yield* Effect.die("Expected REST and Auth workloads"); + yield* runtime.driver.start( + { stackId, desiredGeneration: 1, workloadId: rest.id, specHash: rest.specHash }, + rest, + ); + expect(createdSpecs.some((spec) => spec.labels.workloadId === rest.id)).toBe(true); + const authStart = yield* runtime.driver + .start( + { stackId, desiredGeneration: 1, workloadId: auth.id, specHash: auth.specHash }, + auth, + ) + .pipe(Effect.exit); + expect(Exit.isFailure(authStart)).toBe(true); + expect(createdSpecs.some((spec) => spec.labels.workloadId === auth.id)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("closes the production owner scope while input resolution is in flight", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index bf5e890c86..1da6ff437c 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -716,11 +716,11 @@ describe("runtime input owner", () => { ), ); - it.live("does not republish completed material after concurrent cleanup", () => + it.live("retires older generations and fences their in-flight waiters", () => withPlatform( Effect.gen(function* () { const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-cleanup-race-", + prefix: "runtime-input-generation-retire-", }); const started = yield* Deferred.make(); const release = yield* Deferred.make(); @@ -734,6 +734,7 @@ describe("runtime input owner", () => { }, }, }); + const newer = { ...state, inputFingerprint: `${state.inputFingerprint}-new` }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId, @@ -750,17 +751,32 @@ describe("runtime input owner", () => { }); }, }); - const materializing = yield* Effect.forkChild(owner.resolve(state, 30), { + const first = yield* Effect.forkChild(owner.resolve(state, 40), { startImmediately: true, }); yield* Deferred.await(started); - const cleanup = yield* Effect.forkChild(owner.cleanupAll, { startImmediately: true }); + const firstWaiter = yield* Effect.forkChild(owner.resolve(state, 40), { + startImmediately: true, + }); + const second = yield* Effect.forkChild(owner.resolve(newer, 41), { + startImmediately: true, + }); + const firstExit = yield* Fiber.join(first).pipe(Effect.exit); + const firstWaiterExit = yield* Fiber.join(firstWaiter).pipe(Effect.exit); + expect(Exit.isFailure(firstExit)).toBe(true); + expect(Exit.isFailure(firstWaiterExit)).toBe(true); + expect(errorOf(firstExit)?.message).toContain("generation was invalidated"); + expect(errorOf(firstWaiterExit)?.message).toContain("generation was invalidated"); yield* Deferred.succeed(release, undefined); - const materializingExit = yield* Fiber.join(materializing).pipe(Effect.exit); - const cleanupExit = yield* Fiber.join(cleanup).pipe(Effect.exit); - expect(Exit.isSuccess(materializingExit)).toBe(true); - expect(Exit.isSuccess(cleanupExit)).toBe(true); - yield* owner.resolve(state, 30); + const secondMaterial = yield* Fiber.join(second); + expect(secondMaterial.auth?.jwks).toContain('"keys"'); + // The superseded owner may finish its already-started fetch, but its + // result is fenced and never enters the cache. + expect(fetches).toBe(4); + const late = yield* owner.resolve(state, 40).pipe(Effect.exit); + expect(Exit.isFailure(late)).toBe(true); + expect(errorOf(late)?.message).toContain("generation was invalidated"); + yield* owner.resolve(newer, 41); expect(fetches).toBe(4); }), ), From d1ce5c09e2bc4137ff21b725f46979ae0027c8bb Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 14:47:30 +0200 Subject: [PATCH 086/174] test(stack): harden native runtime integration under load --- .../src/runtime/native-runtime.integration.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index eef1d0b2c3..d9bfe445a5 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -61,7 +61,10 @@ const processPlan = (main: A) => ({ startup: [], main }); const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); -describe("native runtime", () => { +// Native launcher tests create real process trees. Keep their timeout local to +// this suite so file-level parallelism does not turn the default 5s guard into +// a false failure while leaving the rest of the integration suite unchanged. +describe("native runtime", { timeout: 15_000 }, () => { it.live("rejects container artifacts before invoking native process resolution", () => withPlatform( Effect.gen(function* () { @@ -573,6 +576,7 @@ describe("native runtime", () => { const targetCode = ` const net = require("node:net"); const { spawn } = require("node:child_process"); + process.stdout.on("error", () => {}); const server = net.createServer(); server.listen({ host: "127.0.0.1", port: 0 }, () => { const address = server.address(); @@ -582,7 +586,9 @@ describe("native runtime", () => { const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendantCode)}], { stdio: ["ignore", "pipe", "inherit"] }); - child.stdout.on("data", (chunk) => process.stdout.write(chunk)); + child.stdout.on("data", (chunk) => { + if (!process.stdout.destroyed) process.stdout.write(chunk); + }); }); `; const ownerCode = ` From eca151cf454457d3597d20863a7ff8f4744e5d22 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 14:49:39 +0200 Subject: [PATCH 087/174] chore(stack): remove unused artifact source import --- packages/stack/src/preparation/RuntimeArtifacts.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 64e0e78891..091c2e301e 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -9,7 +9,6 @@ import type { StackRuntime } from "../public/Runtime.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { makeArtifactStore, - type ArtifactSource, type ArtifactStore, type ArtifactStoreError, type PreparedArtifact, From 00fc5471e8530021280e11fb05c72884bb4c6e74 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 15:12:56 +0200 Subject: [PATCH 088/174] chore(stack): prune unused declarations and configure knip --- ...26-08-28-stack-runtime-rewrite-progress.md | 12 ++++++ knip.json | 3 ++ packages/stack/src/control/ControlClient.ts | 7 ---- packages/stack/src/control/ControlServer.ts | 6 +-- .../stack/src/control/MaintenanceProtocol.ts | 5 +-- packages/stack/src/control/StackRpc.ts | 24 +++--------- .../stack/src/entrypoints/supervisor-bun.ts | 27 -------------- .../stack/src/functions/FunctionDiscovery.ts | 4 +- packages/stack/src/functions/FunctionsRoot.ts | 4 +- .../stack/src/functions/serve-main-bundler.ts | 1 + .../functions/serve-main-bundler.unit.test.ts | 1 + .../serve-main-resolver.integration.test.ts | 2 + .../src/functions/serve-main-resolver.ts | 7 ++-- packages/stack/src/functions/serve.main.ts | 2 + packages/stack/src/gateway/Gateway.ts | 10 ++--- packages/stack/src/model/CapabilityModule.ts | 4 +- packages/stack/src/model/Compiler.ts | 14 +++---- packages/stack/src/model/DatabaseBootstrap.ts | 13 ++----- .../model/capabilities/auth-third-party.ts | 6 +-- .../stack/src/model/capabilities/functions.ts | 6 +-- .../stack/src/model/capabilities/index.ts | 37 +++++-------------- .../src/preparation/SlimServicesSource.ts | 2 +- packages/stack/src/public/Capability.ts | 2 - packages/stack/src/runtime/ContainerEngine.ts | 8 ++-- .../stack/src/runtime/ContainerRuntime.ts | 6 +-- .../src/runtime/DatabaseBootstrapCatalog.ts | 2 - packages/stack/src/runtime/DockerEngine.ts | 7 ++-- packages/stack/src/runtime/NativeRuntime.ts | 6 +-- packages/stack/src/runtime/PodmanEngine.ts | 7 ++-- .../src/runtime/PostgresDatabaseSession.ts | 4 +- packages/stack/src/runtime/RuntimeDriver.ts | 4 +- .../stack/src/runtime/RuntimeInputOwner.ts | 6 +-- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 10 ++--- packages/stack/src/state/Ownership.ts | 18 +++------ packages/stack/src/state/PortCoordinator.ts | 8 ++-- packages/stack/src/state/PortRegistry.ts | 2 +- packages/stack/src/state/SecretStore.ts | 2 +- packages/stack/src/state/StackState.ts | 17 +++------ packages/stack/src/supervisor/DesiredState.ts | 8 ++-- packages/stack/src/supervisor/Lifecycle.ts | 4 +- packages/stack/src/supervisor/LogStore.ts | 2 - packages/stack/src/supervisor/Reconciler.ts | 8 ++-- packages/stack/src/supervisor/Supervisor.ts | 2 +- 43 files changed, 128 insertions(+), 202 deletions(-) delete mode 100644 packages/stack/src/control/ControlClient.ts delete mode 100644 packages/stack/src/entrypoints/supervisor-bun.ts diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 15f2207869..548859c78d 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1024,3 +1024,15 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g comments for their direct parse/stringify assertions. The exact changed-file Effect lint now exits cleanly. - Focused owner/secret/workload/catalog integration verification remains green at 58 cases with stack type-check, formatting, and diff checks passing. + +#### Knip phase 1–2 cleanup (2026-08-31) + +- Added the `packages/stack` Knip workspace with its `mkfifo` binary exception, removed obsolete ControlClient and + supervisor-bun entrypoints, and deleted confirmed-unused duplicate aliases. +- Knip's scoped exports/types fix made 120 internal declarations non-exported and removed only declarations proven + unused by generic lint; package entry exports and function build subpaths remain intact. Narrow Effect lint rationale + comments preserve the standalone Edge Runtime Promise/Node boundaries, and RPC handler requirements are typed with + `never` to avoid an `any` service requirement. +- Knip, stack type-check, package unit tests, focused control/supervisor/functions integration tests, generic and Effect + lint, formatting, and `git diff --check` pass. Full integration was also verified with two workers (384/384); the + default parallel run remains subject to concurrent native-process load as documented in the runtime test fix. diff --git a/knip.json b/knip.json index 9d3023f06d..8e0d504492 100644 --- a/knip.json +++ b/knip.json @@ -34,6 +34,9 @@ "entry": ["src/**/*.test.ts"], "ignoreDependencies": ["undici"] }, + "packages/stack": { + "ignoreBinaries": ["mkfifo"] + }, "packages/cli-test-helpers": { "entry": ["src/**/*.test.ts"] } diff --git a/packages/stack/src/control/ControlClient.ts b/packages/stack/src/control/ControlClient.ts deleted file mode 100644 index 99f13c2fe4..0000000000 --- a/packages/stack/src/control/ControlClient.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - makeControlClient, - makeControlRpcSocket, - type ControlClient, - type ControlClientOptions, -} from "./ControlServer.ts"; -export type { MaintenanceResponse } from "./MaintenanceProtocol.ts"; diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 50d8a8af64..a91b360358 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -47,7 +47,7 @@ import { type StackRpcHandlers, } from "./StackRpc.ts"; -export interface ControlIdentity { +interface ControlIdentity { readonly stackId: string; readonly ownerSessionId: string; } @@ -511,7 +511,7 @@ export const startControlServer = ( // Keep handler defects as keyed Exit responses so destroy request state can be cleaned by // the same send path as typed failures. Without this option RpcServer emits a client-level // Defect frame that has no requestId for the terminal handoff. - const rpcProgram = RpcServer.make(StackRpcGroup, { + const rpcProgram: Effect.Effect = RpcServer.make(StackRpcGroup, { disableTracing: true, disableFatalDefects: true, concurrency: MAINTENANCE_MAX_CONCURRENT_REQUESTS, @@ -530,7 +530,7 @@ export const startControlServer = ( }); /** Client-side framed socket; writer emits the RPC preface exactly once. */ -export const makeControlRpcSocket = ( +const makeControlRpcSocket = ( socket: Socket.Socket, options: { readonly rpcRelease?: string; diff --git a/packages/stack/src/control/MaintenanceProtocol.ts b/packages/stack/src/control/MaintenanceProtocol.ts index c5084500c9..e5116f442e 100644 --- a/packages/stack/src/control/MaintenanceProtocol.ts +++ b/packages/stack/src/control/MaintenanceProtocol.ts @@ -2,7 +2,7 @@ import { Data, Effect, Schema } from "effect"; import { isStackId, StackIdSchema } from "../public/StackId.ts"; /** The stable protocol is intentionally frozen independently of Stack RPC releases. */ -export const MAINTENANCE_PROTOCOL = "maintenance-v1" as const; +const MAINTENANCE_PROTOCOL = "maintenance-v1" as const; export const MAINTENANCE_MAX_FRAME_BYTES = 64 * 1024; export const MAINTENANCE_MAX_CONCURRENT_REQUESTS = 16; export const MAINTENANCE_REQUEST_DEADLINE_MS = 5_000; @@ -52,14 +52,13 @@ export const MaintenanceRequestSchema = Schema.Union([ ]); export type MaintenanceRequest = Schema.Schema.Type; -export const MaintenanceErrorCodeSchema = Schema.Literals([ +const MaintenanceErrorCodeSchema = Schema.Literals([ "invalid-request", "stale-session", "timeout", "operation-failed", "unsupported-release", ] as const); -export type MaintenanceErrorCode = Schema.Schema.Type; export const MaintenanceResponseSchema = Schema.Union([ Schema.Struct({ diff --git a/packages/stack/src/control/StackRpc.ts b/packages/stack/src/control/StackRpc.ts index 3e4f8a4a75..6d9ec9e03b 100644 --- a/packages/stack/src/control/StackRpc.ts +++ b/packages/stack/src/control/StackRpc.ts @@ -7,7 +7,6 @@ import { StackConfigSchema } from "../public/Config.ts"; import { CapabilityNameSchema } from "../public/Capability.ts"; import { LogOptionsSchema, StackLogEntrySchema } from "../public/Logs.ts"; import { StackStatusSchema } from "../public/Status.ts"; -import type { ControlProtocol } from "./MaintenanceProtocol.ts"; /** Pinned release identifier. A changed release must go through explicit restart. */ export const STACK_RPC_RELEASE = "stack-rpc-v1@0.1.0" as const; @@ -18,10 +17,6 @@ export class StackRpcProtocolError extends Data.TaggedError("StackRpcProtocolErr readonly actualRelease?: string; }> {} -export class StackRpcTransportError extends Data.TaggedError("StackRpcTransportError")<{ - readonly message: string; -}> {} - const StackRpcErrorTagSchema = Schema.Literals([ "InvalidStackIdentityError", "InvalidProjectRootError", @@ -57,7 +52,7 @@ const StackRpcErrorTagSchema = Schema.Literals([ "StackRpcProtocolError", ] as const); -export const StackRpcErrorSchema = Schema.Struct({ +const StackRpcErrorSchema = Schema.Struct({ tag: StackRpcErrorTagSchema, message: Schema.String, }); @@ -73,7 +68,7 @@ const PrepareStackResultSchema = Schema.Struct({ ), }); -export const StackRpc = { +const StackRpc = { status: Rpc.make("status", { success: StackStatusSchema, error: StackRpcErrorSchema }), credentials: Rpc.make("credentials", { success: EffectStackCredentialsSchema, @@ -121,19 +116,12 @@ export const StackRpcGroup = RpcGroup.make( StackRpc.logs, StackRpc.watchStatus, ); -export type StackRpcHandlers = RpcGroup.HandlersFrom>; +type StackRpcDefinitions = RpcGroup.Rpcs; +export type StackRpcHandlers = { + readonly [Current in StackRpcDefinitions as Current["_tag"]]: Rpc.ToHandlerFn; +}; export type StackRpcClient = RpcClient.FromGroup; -export const rpcProtocol = ( - identity: { readonly stackId: string; readonly ownerSessionId: string }, - release = STACK_RPC_RELEASE, -): ControlProtocol => ({ - kind: "rpc", - release, - stackId: identity.stackId, - ownerSessionId: identity.ownerSessionId, -}); - export const releaseMismatch = (actualRelease: string): StackRpcProtocolError => new StackRpcProtocolError({ message: `Incompatible Stack RPC release; expected ${STACK_RPC_RELEASE}, received ${actualRelease}`, diff --git a/packages/stack/src/entrypoints/supervisor-bun.ts b/packages/stack/src/entrypoints/supervisor-bun.ts deleted file mode 100644 index eb01c72137..0000000000 --- a/packages/stack/src/entrypoints/supervisor-bun.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Effect, Schema } from "effect"; -// Bun's standalone entrypoint writes the launcher readiness descriptor -// directly; no Effect FileSystem service owns this inherited fd3 channel. -// oxlint-disable-next-line effecttsgo/node-builtin-import -import * as NodeFs from "node:fs"; -import { parseSupervisorArgs, ReadySchema, runSupervisor } from "./supervisor-node.ts"; -import { StackOwnershipConflictError } from "../public/Errors.ts"; - -export { parseSupervisorArgs, runSupervisor, SupervisorArgsSchema } from "./supervisor-node.ts"; - -if (import.meta.main) { - Effect.runPromise( - parseSupervisorArgs(Bun.argv.slice(2)).pipe(Effect.flatMap(runSupervisor)), - ).catch((error: unknown) => { - try { - const conflict = error instanceof StackOwnershipConflictError; - NodeFs.writeSync( - 3, - `${Schema.encodeSync(Schema.fromJsonString(ReadySchema))({ ok: false, code: conflict ? "ownership-conflict" : "failed", message: error instanceof Error ? error.message : "Supervisor failed" })}\n`, - ); - NodeFs.closeSync(3); - } catch { - // The parent may have already closed the readiness descriptor. - } - process.exitCode = 1; - }); -} diff --git a/packages/stack/src/functions/FunctionDiscovery.ts b/packages/stack/src/functions/FunctionDiscovery.ts index 088f076846..56c716f51e 100644 --- a/packages/stack/src/functions/FunctionDiscovery.ts +++ b/packages/stack/src/functions/FunctionDiscovery.ts @@ -23,12 +23,12 @@ import { export { FunctionNotFoundError, FunctionPathError } from "./FunctionsRoot.ts"; -export interface FunctionInvocationPath { +interface FunctionInvocationPath { readonly native: string; readonly container: string; } -export interface FunctionInvocationRoot { +interface FunctionInvocationRoot { readonly native: string; readonly container: string; readonly mount: FunctionsContainerMapping["mount"]; diff --git a/packages/stack/src/functions/FunctionsRoot.ts b/packages/stack/src/functions/FunctionsRoot.ts index f1bbf8c5ba..a56f274a52 100644 --- a/packages/stack/src/functions/FunctionsRoot.ts +++ b/packages/stack/src/functions/FunctionsRoot.ts @@ -12,14 +12,14 @@ export class FunctionPathError extends Data.TaggedError("FunctionPathError")<{ readonly cause?: unknown; }> {} -export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; +const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; export interface FunctionsRootOptions { /** The configured functionsRoot; Compiler supplies an absolute path. */ readonly root: string; } -export interface ContainerMount { +interface ContainerMount { readonly source: string; readonly target: string; readonly readOnly: true; diff --git a/packages/stack/src/functions/serve-main-bundler.ts b/packages/stack/src/functions/serve-main-bundler.ts index dd89196319..d44455147c 100644 --- a/packages/stack/src/functions/serve-main-bundler.ts +++ b/packages/stack/src/functions/serve-main-bundler.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- esbuild's public API is Promise-based. import { fileURLToPath } from "node:url"; import { build } from "esbuild"; diff --git a/packages/stack/src/functions/serve-main-bundler.unit.test.ts b/packages/stack/src/functions/serve-main-bundler.unit.test.ts index 3c50d9f598..a819114cf8 100644 --- a/packages/stack/src/functions/serve-main-bundler.unit.test.ts +++ b/packages/stack/src/functions/serve-main-bundler.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- test exercises the Promise-based bundler boundary. import { describe, expect, it } from "vitest"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts index 508a3b1acc..13de207a1f 100644 --- a/packages/stack/src/functions/serve-main-resolver.integration.test.ts +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/async-function -- test exercises the Promise-based resolver boundary. +// oxlint-disable effecttsgo/node-builtin-import -- Node filesystem fixtures own their temporary test root. import { mkdtemp, mkdir, diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index 3b01f601c4..ec6052837a 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -1,6 +1,7 @@ +// oxlint-disable effecttsgo/async-function -- resolver preserves the Edge Runtime Promise callback contract. import { dirname, join } from "./serve-main-deps.ts"; -export interface FunctionOverride { +interface FunctionOverride { readonly enabled?: boolean; readonly verifyJWT?: boolean; readonly verify_jwt?: boolean; @@ -23,7 +24,7 @@ export interface FunctionConfig { readonly env?: Readonly>; } -export interface FunctionFileInfo { +interface FunctionFileInfo { readonly isDirectory: boolean; readonly isFile: boolean; readonly isSymbolicLink: boolean; @@ -171,7 +172,7 @@ export const resolveFunctionConfig = async (options: { }; }; -export const packageJsonPathFor = (config: FunctionConfig): string => +const packageJsonPathFor = (config: FunctionConfig): string => join(dirname(config.entrypointPath), "package.json"); /** Checks package discovery without allowing a package.json symlink to leave the root. */ diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index b58cb62a5c..ebfe115df4 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -1,4 +1,6 @@ // @ts-nocheck +// oxlint-disable effecttsgo/async-function -- standalone Edge Runtime artifact uses Deno async APIs. +// oxlint-disable effecttsgo/global-console -- standalone artifact emits its user-facing bootstrap logs. declare const Deno: any; declare const EdgeRuntime: any; diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts index 23f83107e2..27050cc79a 100644 --- a/packages/stack/src/gateway/Gateway.ts +++ b/packages/stack/src/gateway/Gateway.ts @@ -16,7 +16,7 @@ export interface ActivationResult { readonly endpoint: BackendEndpoint; } -export interface ActivationTarget { +interface ActivationTarget { readonly dependencies: ReadonlyArray; } @@ -160,7 +160,7 @@ export interface GatewayLocalResponse { readonly contentType?: string; } -export type GatewayHeaderValue = string | string[]; +type GatewayHeaderValue = string | string[]; export type GatewayHeaders = Readonly>; export type GatewayHeaderTransform = ( request: GatewayRouteRequest, @@ -185,7 +185,7 @@ export interface GatewayProxyRoute { } /** HTTP-only route that produces a response without activating a capability. */ -export interface GatewayLocalRoute { +interface GatewayLocalRoute { readonly capability?: never; readonly binding?: never; readonly match: (request: GatewayRouteRequest) => boolean; @@ -208,12 +208,12 @@ export interface StackGateway { readonly close: Effect.Effect; } -export interface HttpGatewayListenerOptions { +interface HttpGatewayListenerOptions { readonly field: PortField; readonly options: Omit; } -export interface TcpGatewayListenerOptions { +interface TcpGatewayListenerOptions { readonly field: PortField; readonly options: Omit; } diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index d3e816941a..4892fe98cd 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -64,13 +64,13 @@ export const release = ( workloads: ReadonlyArray, ): CapabilityRelease => ({ version, workloads }); -export const nativeArtifact = (service: string, release: string): NativeArtifact => ({ +const nativeArtifact = (service: string, release: string): NativeArtifact => ({ kind: "native", service, release, }); -export const containerArtifact = (service: string, image: string): ContainerArtifact => ({ +const containerArtifact = (service: string, image: string): ContainerArtifact => ({ kind: "container", service, image, diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 24cf9c1010..c416997339 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -35,10 +35,10 @@ import { CAPABILITY_MODULES, createExecutionPlan, type ExecutionPlan } from "./E import type { CapabilityModule, CapabilityRelease } from "./CapabilityModule.ts"; import type { SecretGenerator, SecretJwtSigning } from "../state/SecretStore.ts"; -export type InputFingerprint = Schema.Schema.Type; +type InputFingerprint = Schema.Schema.Type; const InputFingerprintSchema = Schema.String.pipe(Schema.brand("InputFingerprint")); -export interface SecretSlot { +interface SecretSlot { readonly slot: string; } @@ -54,9 +54,9 @@ type MaterializedValue = [T] extends [Redacted.Redacted] } : T; -export type MaterializedSettings = MaterializedValue; +type MaterializedSettings = MaterializedValue; -export interface MaterializedCapability { +interface MaterializedCapability { readonly enabled: boolean; readonly activation: "eager" | "lazy"; readonly version: string; @@ -85,12 +85,12 @@ export interface StackDefinition { }>; } -export type MaterializedJwtSigning = +type MaterializedJwtSigning = | { readonly kind: "symmetric"; readonly secret: SecretSlot } | { readonly kind: "jwks-file"; readonly path: string } | null; -export interface MaterializedListener { +interface MaterializedListener { readonly enabled: boolean; readonly address: string; readonly port: "automatic" | number; @@ -108,7 +108,7 @@ const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; const SECURITY_JWT_SIGNING_SECRET_SLOT = "secret:security.jwt.signing.secret"; /** Internal credentials are not user settings but still need durable managed slots. */ -export const INTERNAL_MANAGED_SECRET_SLOTS = ["secret:database.internal.password"] as const; +const INTERNAL_MANAGED_SECRET_SLOTS = ["secret:database.internal.password"] as const; export interface CompiledStack { readonly definition: StackDefinition; diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index a32130a92a..29f6bbb7a3 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -3,12 +3,12 @@ import { Data, Effect, Redacted, Schema } from "effect"; /** A small runtime-neutral SQL boundary used by the internal database bootstrap. */ export type DatabaseSqlValue = string | number | boolean | null; -export interface DatabaseRow { +interface DatabaseRow { readonly [column: string]: unknown; } /** Login roles provisioned by the managed database template. */ -export type DatabaseBootstrapRole = +type DatabaseBootstrapRole = | "postgres" | "authenticator" | "pgbouncer" @@ -26,9 +26,6 @@ const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray = [ "supabase_read_only_user", ]; -/** Database-level settings written by the initial managed bootstrap. */ -export type DatabaseBootstrapSettingName = "app.settings.jwt_secret" | "app.settings.jwt_exp"; - export type DatabaseBootstrapSetting = | { readonly name: "app.settings.jwt_secret"; @@ -44,7 +41,7 @@ export interface DatabaseBootstrapCredentials { readonly roles?: Readonly>>>; } -export interface DatabaseBootstrapSettings { +interface DatabaseBootstrapSettings { readonly jwtSecret: Redacted.Redacted; readonly jwtExpiry: number; } @@ -98,7 +95,7 @@ export interface DatabaseSession { ) => Effect.Effect; } -export interface DatabaseBootstrapRevision { +interface DatabaseBootstrapRevision { readonly id: string; readonly statement: string; } @@ -262,5 +259,3 @@ export const runDatabaseBootstrap = ( ); } }); - -export const databaseBootstrapTrackingTable = TRACKING_TABLE; diff --git a/packages/stack/src/model/capabilities/auth-third-party.ts b/packages/stack/src/model/capabilities/auth-third-party.ts index a6e18b0f91..6291084f13 100644 --- a/packages/stack/src/model/capabilities/auth-third-party.ts +++ b/packages/stack/src/model/capabilities/auth-third-party.ts @@ -6,14 +6,14 @@ * deterministic and testable during compilation. */ -export type ThirdPartyProviderName = "firebase" | "auth0" | "aws_cognito" | "clerk" | "workos"; +type ThirdPartyProviderName = "firebase" | "auth0" | "aws_cognito" | "clerk" | "workos"; -export interface ThirdPartyIssuer { +interface ThirdPartyIssuer { readonly provider: ThirdPartyProviderName; readonly issuer: string; } -export interface ThirdPartyIssuerError { +interface ThirdPartyIssuerError { readonly provider?: ThirdPartyProviderName; readonly message: string; } diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index 95b11df1e5..ed1d49dacc 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -5,7 +5,7 @@ const Secret = Schema.Redacted(Schema.String); export type FunctionSecret = Redacted.Redacted; const FunctionSlug = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)); const EnvName = Schema.String.check(Schema.isPattern(/^[A-Z_][A-Z0-9_]*$/)); -export const FunctionOverrideSchema = Schema.Struct({ +const FunctionOverrideSchema = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.String), @@ -13,7 +13,6 @@ export const FunctionOverrideSchema = Schema.Struct({ static_files: Schema.optionalKey(Schema.Array(Schema.String)), env: Schema.optionalKey(Schema.Record(EnvName, Secret)), }); -export type FunctionOverride = Schema.Schema.Type; /** Fully materialized per-function settings consumed by request-time discovery. */ export interface FunctionSettings { @@ -35,12 +34,11 @@ export const FunctionSettingsDefaults: FunctionSettings = { env: {}, }; -export const EdgeRuntimeSettingsSchema = Schema.Struct({ +const EdgeRuntimeSettingsSchema = Schema.Struct({ policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"] as const)), deno_version: Schema.optionalKey(Schema.Finite), secrets: Schema.optionalKey(Schema.Record(Schema.String, Secret)), }); -export type EdgeRuntimeSettings = Schema.Schema.Type; export const FunctionsInspectorSettingsSchema = Schema.Struct({ mode: Schema.optionalKey(Schema.Literals(["run", "brk", "wait"] as const)), diff --git a/packages/stack/src/model/capabilities/index.ts b/packages/stack/src/model/capabilities/index.ts index 70de67f3cc..26f808428b 100644 --- a/packages/stack/src/model/capabilities/index.ts +++ b/packages/stack/src/model/capabilities/index.ts @@ -1,27 +1,10 @@ -export { AuthModule, AuthSettingsSchema, type AuthSettings } from "./auth.ts"; -export { DatabaseModule, DatabaseSettingsSchema, type DatabaseSettings } from "./database.ts"; -export { - FunctionOverrideSchema, - FunctionSettingsDefaults, - FunctionsModule, - FunctionsSettingsSchema, - FunctionsInspectorSettingsSchema, - type FunctionOverride, - type FunctionSecret, - type FunctionSettings, - type MaterializedFunctionSettings, - type FunctionsSettings, - type FunctionsInspectorSettings, -} from "./functions.ts"; -export { MailModule, MailSettingsSchema, type MailSettings } from "./mail.ts"; -export { PoolerModule, PoolerSettingsSchema, type PoolerSettings } from "./pooler.ts"; -export { RealtimeModule, RealtimeSettingsSchema, type RealtimeSettings } from "./realtime.ts"; -export { RestModule, RestSettingsSchema, type RestSettings } from "./rest.ts"; -export { - parseFileSize, - StorageModule, - StorageSettingsSchema, - type StorageSettings, -} from "./storage.ts"; -export { StudioModule, StudioSettingsSchema, type StudioSettings } from "./studio.ts"; -export { AnalyticsModule, AnalyticsSettingsSchema, type AnalyticsSettings } from "./analytics.ts"; +export { AuthModule, AuthSettingsSchema } from "./auth.ts"; +export { DatabaseModule, DatabaseSettingsSchema } from "./database.ts"; +export { FunctionsModule, FunctionsSettingsSchema } from "./functions.ts"; +export { MailModule, MailSettingsSchema } from "./mail.ts"; +export { PoolerModule, PoolerSettingsSchema } from "./pooler.ts"; +export { RealtimeModule, RealtimeSettingsSchema } from "./realtime.ts"; +export { RestModule, RestSettingsSchema } from "./rest.ts"; +export { parseFileSize, StorageModule, StorageSettingsSchema } from "./storage.ts"; +export { StudioModule, StudioSettingsSchema } from "./studio.ts"; +export { AnalyticsModule, AnalyticsSettingsSchema } from "./analytics.ts"; diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index d6badaf5b4..35eca4d1c1 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -83,7 +83,7 @@ const fetchBytes = ( }); /** Owned streaming zstd boundary. Cancellation destroys the exact transform. */ -export const nodeZstdDecompressor: ZstdDecompressor = { +const nodeZstdDecompressor: ZstdDecompressor = { decompress: (bytes) => Effect.callback((resume) => { const transform: Transform = createZstdDecompress(); diff --git a/packages/stack/src/public/Capability.ts b/packages/stack/src/public/Capability.ts index e8bc9e5ffa..7668c3883f 100644 --- a/packages/stack/src/public/Capability.ts +++ b/packages/stack/src/public/Capability.ts @@ -13,8 +13,6 @@ export const CAPABILITY_NAMES = [ "pooler", ] as const; -export const CapabilityNames = CAPABILITY_NAMES; - export const CapabilityNameSchema = Schema.Literals(CAPABILITY_NAMES); export type CapabilityName = Schema.Schema.Type; diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index a1e5e477fc..12dc8c3b8a 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -19,7 +19,7 @@ export interface ContainerHostRoute { export class ContainerExecutableNotFoundError extends Data.TaggedError( "ContainerExecutableNotFoundError", )<{ readonly executable: string; readonly message: string }> {} -export class ContainerRoutingUnsupportedError extends Data.TaggedError( +class ContainerRoutingUnsupportedError extends Data.TaggedError( "ContainerRoutingUnsupportedError", )<{ readonly engine: ContainerEngineKind; readonly message: string }> {} export class ContainerEngineProtocolError extends Data.TaggedError("ContainerEngineProtocolError")<{ @@ -77,7 +77,7 @@ export interface ContainerVolumeMount { readonly target: string; readonly readOnly: boolean; } -export interface ContainerPortPublication { +interface ContainerPortPublication { readonly address: "127.0.0.1"; readonly hostPort: number; readonly containerPort: number; @@ -137,7 +137,7 @@ export interface ContainerProcessRequest { readonly stdin?: string; readonly env?: Readonly>; } -export interface ContainerProcessOutputChunk { +interface ContainerProcessOutputChunk { readonly stream: "stdout" | "stderr"; readonly bytes: Uint8Array; } @@ -351,7 +351,7 @@ export const makeProcessCommandRunner = ( return { executable: options.executable, run, stream }; }); -export interface ContainerEngineCodecs { +interface ContainerEngineCodecs { readonly serialize: (command: ContainerCommand) => ContainerProcessRequest; readonly decodeProbe: ( result: ContainerCommandResult, diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index c8aaed8b94..22128d7cbf 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -71,20 +71,20 @@ export interface ContainerWorkloadResolution { } /** Untrusted host-publication input validated before any engine operation. */ -export interface ContainerPortPublicationInput { +interface ContainerPortPublicationInput { readonly address: string; readonly hostPort: number; readonly containerPort: number; } -export interface ContainerVolumeRequest { +interface ContainerVolumeRequest { readonly target: string; readonly readOnly: boolean; /** Stable logical workload that owns the persistent volume. Defaults to the mounting workload. */ readonly ownerWorkloadId?: string; } -export interface ContainerRuntimeResourceIds { +interface ContainerRuntimeResourceIds { readonly container: string; readonly network: string; readonly state: "running" | "stopped" | "failed"; diff --git a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts index 37ed4f1c0c..85b52d4532 100644 --- a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts +++ b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts @@ -76,5 +76,3 @@ export const databaseBootstrapPlan = ( }, } satisfies DatabaseBootstrapOptions; }); - -export const makeDatabaseBootstrapPlan = databaseBootstrapPlan; diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index ef8399fdd2..f9f30640a5 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -187,7 +187,7 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc } }; -export const serializeDockerLogs = ( +const serializeDockerLogs = ( id: string, options: ContainerLogOptions | undefined, ): ContainerProcessRequest => ({ @@ -398,7 +398,7 @@ const decodeCreate = ( : Effect.fail(protocol(operation)); }; -export const makeDockerCodecs = () => ({ +const makeDockerCodecs = () => ({ serialize: serializeDockerCommand, serializeLogs: serializeDockerLogs, decodeProbe: (result: ContainerCommandResult) => @@ -413,8 +413,7 @@ export const makeDockerCodecs = () => ({ decodeCreate, }); -export const createDockerEngine = ( +export const makeDockerEngine = ( options: Omit, ): ContainerEngine => makeContainerEngineCore({ ...options, kind: "docker", codecs: makeDockerCodecs() }); -export const makeDockerEngine = createDockerEngine; diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index 7d667e105f..08482d4586 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -22,7 +22,7 @@ import { type NativeProcessSpec, } from "./NativeProcess.ts"; -export type NativeWorkload = PlannedWorkload; +type NativeWorkload = PlannedWorkload; export interface NativeRuntimeOptions { /** @@ -55,7 +55,7 @@ export interface NativeRuntimeOptions { } /** One-shot startup processes followed by the long-lived workload process. */ -export interface NativeProcessPlan { +interface NativeProcessPlan { readonly startup: ReadonlyArray; readonly main: NativeProcessSpec; } @@ -614,5 +614,3 @@ export const makeNativeRuntime = ( recover, } satisfies RuntimeDriver; }); - -export const makeNativeRuntimeDriver = makeNativeRuntime; diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index da503db4e9..1515c5b4f6 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -183,7 +183,7 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc } }; -export const serializePodmanLogs = ( +const serializePodmanLogs = ( id: string, options: ContainerLogOptions | undefined, ): ContainerProcessRequest => ({ @@ -376,7 +376,7 @@ const decodeCreate = ( : Effect.fail(protocol(operation)); }; -export const makePodmanCodecs = () => ({ +const makePodmanCodecs = () => ({ serialize: serializePodmanCommand, serializeLogs: serializePodmanLogs, decodeProbe: (result: ContainerCommandResult) => @@ -391,8 +391,7 @@ export const makePodmanCodecs = () => ({ decodeCreate, }); -export const createPodmanEngine = ( +export const makePodmanEngine = ( options: Omit, ): ContainerEngine => makeContainerEngineCore({ ...options, kind: "podman", codecs: makePodmanCodecs() }); -export const makePodmanEngine = createPodmanEngine; diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index d27f9d5251..8e39c9ded2 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -13,7 +13,7 @@ import type { PersistedStackState } from "../state/StackState.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; -export interface PostgresDatabaseSessionOptions { +interface PostgresDatabaseSessionOptions { readonly host: string; readonly port: number; readonly password: Redacted.Redacted; @@ -108,7 +108,7 @@ export const makeDatabaseSessionFromAcquisition = ( ); /** Creates a scoped production session backed by the Effect PostgreSQL pool. */ -export const makePostgresDatabaseSession = ( +const makePostgresDatabaseSession = ( options: PostgresDatabaseSessionOptions, ): Effect.Effect => makeDatabaseSessionFromAcquisition( diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index 2972f10767..9e51bd3b6d 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -27,14 +27,14 @@ export interface RuntimeRecoveryRequest { } /** A driver only reports states that can be acted on by the reconciler. */ -export type ObservedWorkloadState = "absent" | "starting" | "ready" | "stopped" | "failed"; +type ObservedWorkloadState = "absent" | "starting" | "ready" | "stopped" | "failed"; export interface ObservedWorkload extends RuntimeWorkloadKey { readonly state: ObservedWorkloadState; readonly error?: string; } -export type RuntimeWorkload = PlannedWorkload; +type RuntimeWorkload = PlannedWorkload; export interface RuntimeDriver { /** Enumerates only private resources owned by this exact stack identity. */ diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 0eae900eae..ca3a748e5d 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -24,7 +24,7 @@ import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party. /** A parsed JSON document fetched by the owner for OIDC discovery. */ export type RuntimeJsonFetcher = (url: string) => Effect.Effect; -export interface RuntimeAuthTemplate { +interface RuntimeAuthTemplate { /** Stable URL id used by GoTrue's mailer template setting. */ readonly id: string; /** User-configured project-relative path. */ @@ -35,7 +35,7 @@ export interface RuntimeAuthTemplate { readonly extension: string; } -export interface RuntimeInputMaterial { +interface RuntimeInputMaterial { readonly auth?: Readonly<{ readonly jwtKeys?: string; readonly jwks: string; @@ -233,7 +233,7 @@ end `; /** Resolves materialized Edge Runtime secrets to their caller-visible names. */ -export const resolveFunctionsEdgeRuntimeSecrets = ( +const resolveFunctionsEdgeRuntimeSecrets = ( state: PersistedStackState, ): Effect.Effect>, StackPreparationError> => { const settings = settingsFor(state, "functions"); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 6ff8017d22..8756d4ee94 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -8,16 +8,16 @@ import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party. import { Effect } from "effect"; import { StackPreparationError } from "../public/Errors.ts"; -export type WorkloadRuntimeKind = "native" | "container"; +type WorkloadRuntimeKind = "native" | "container"; /** Closed set of private ports a workload may expose to the host gateway. */ -export type WorkloadBindingName = "primary" | "admin" | "ui" | "smtp" | "pop3"; +type WorkloadBindingName = "primary" | "admin" | "ui" | "smtp" | "pop3"; -export interface WorkloadBinding { +interface WorkloadBinding { readonly containerPort: number; } -export interface WorkloadBindings { +interface WorkloadBindings { readonly primary?: WorkloadBinding; readonly admin?: WorkloadBinding; readonly ui?: WorkloadBinding; @@ -58,7 +58,7 @@ export interface WorkloadRuntimeInputs { readonly hostRoute?: ContainerHostRoute; } -export interface NativeProcessResolution { +interface NativeProcessResolution { readonly executable: string; readonly args: ReadonlyArray; readonly cwd: string; diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index ea27be02d1..f3ee5c17ce 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -5,14 +5,14 @@ import { StackOwnershipConflictError, StackStateInvalidError } from "../public/E import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; /** The owner metadata format is deliberately fail-closed. */ -export const OWNERSHIP_FORMAT = "supabase-stack-owner-v1" as const; +const OWNERSHIP_FORMAT = "supabase-stack-owner-v1" as const; -export interface UnixControlEndpoint { +interface UnixControlEndpoint { readonly kind: "unix"; readonly path: string; } -export interface WindowsControlEndpoint { +interface WindowsControlEndpoint { readonly kind: "pipe"; readonly name: string; } @@ -34,12 +34,12 @@ export class StackRuntimeEnvironment extends Context.Service< StackRuntimeEnvironmentValue >()("@supabase/stack/StackRuntimeEnvironment") {} -export const ControlEndpointSchema = Schema.Union([ +const ControlEndpointSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal("unix"), path: Schema.String }), Schema.Struct({ kind: Schema.Literal("pipe"), name: Schema.String }), ]); -export const OwnerMetadataSchema = Schema.Struct({ +const OwnerMetadataSchema = Schema.Struct({ format: Schema.Literal(OWNERSHIP_FORMAT), stackId: StackIdSchema, ownerSessionId: OwnerSessionIdSchema, @@ -68,9 +68,6 @@ const metadataFrom = (value: unknown): Effect.Effect stateError(`Invalid owner metadata: ${String(error)}`)), ); -const endpointPath = (endpoint: ControlEndpoint): string => - endpoint.kind === "unix" ? endpoint.path : endpoint.name; - /** * Computes the one local endpoint for an identity. The complete digest is used * so two identities can never alias. The caller supplies a deliberately short @@ -87,9 +84,6 @@ export const controlEndpointFor = ( return { kind: "unix", path: `${root}/supabase-stack-${token}.sock` }; }; -export const ownerMetadataPath = (paths: { readonly controlMetadata: string }): string => - paths.controlMetadata; - /** Reads and validates complete owner metadata without probing or mutating. */ export const readOwnerMetadata = ( stateRoot: string, @@ -337,5 +331,3 @@ export const acquireOwnership = (options: { }), ); }); - -export { endpointPath }; diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 71937af639..a3e8315198 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -21,7 +21,7 @@ import type { import type { StackStateStore } from "./StackStateStore.ts"; import type { PortRegistry } from "./PortRegistry.ts"; -export interface ListenerIntent { +interface ListenerIntent { readonly enabled: boolean; readonly address: string; readonly port: "automatic" | number; @@ -30,7 +30,7 @@ export interface ListenerIntent { export type ListenerIntents = Readonly>; /** A workload endpoint reachable by the host gateway on a durable loopback port. */ -export interface PrivatePortIntent { +interface PrivatePortIntent { readonly workloadId: string; readonly binding: string; } @@ -44,11 +44,11 @@ export interface HostListener { readonly binding: HostListenerBinding; } -export type HostListenerBinding = +type HostListenerBinding = | { readonly kind: "http"; readonly server: HttpServer } | { readonly kind: "tcp"; readonly server: NetServer; readonly allowHalfOpen: true }; -export interface PortPlanOptions { +interface PortPlanOptions { readonly lifecycle?: "stopped" | "running"; readonly expectedGeneration?: number; /** Generation to persist after this transaction; unchanged unless explicitly supplied. */ diff --git a/packages/stack/src/state/PortRegistry.ts b/packages/stack/src/state/PortRegistry.ts index c5b52759ee..16eb989d28 100644 --- a/packages/stack/src/state/PortRegistry.ts +++ b/packages/stack/src/state/PortRegistry.ts @@ -11,7 +11,7 @@ import type { } from "./StackState.ts"; import { withRegistryLock, type StackStateStore } from "./StackStateStore.ts"; -export type PortRegistryError = +type PortRegistryError = | InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError; diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index c27f4900d6..2dfca012e2 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -9,7 +9,7 @@ import { import type { DesiredStackLifecycle } from "../public/Status.ts"; import type { PersistedSecretValues } from "./StackState.ts"; -export type SecretPolicy = "managed" | "passthrough"; +type SecretPolicy = "managed" | "passthrough"; export type SecretJwtSigning = | { readonly kind: "symmetric" } diff --git a/packages/stack/src/state/StackState.ts b/packages/stack/src/state/StackState.ts index daf7cbf72c..aed779fd0d 100644 --- a/packages/stack/src/state/StackState.ts +++ b/packages/stack/src/state/StackState.ts @@ -31,7 +31,7 @@ import { DesiredStackLifecycleSchema, NetworkPortSchema, PORT_FIELDS } from "../ export const STACK_STATE_FORMAT = "supabase-stack-state-v1" as const; -export const PersistedStackIdentitySchema = Schema.Struct({ +const PersistedStackIdentitySchema = Schema.Struct({ stackId: Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)), projectRoot: Schema.String, checkoutRoot: Schema.String, @@ -43,7 +43,7 @@ export const PersistedStackIdentitySchema = Schema.Struct({ }); export type PersistedStackIdentity = Schema.Schema.Type; -export const HostPortAssignmentSchema = Schema.Struct({ +const HostPortAssignmentSchema = Schema.Struct({ field: Schema.Literals(PORT_FIELDS), port: NetworkPortSchema, intent: Schema.Literals(["automatic", "exact"] as const), @@ -51,21 +51,19 @@ export const HostPortAssignmentSchema = Schema.Struct({ export type HostPortAssignment = Schema.Schema.Type; /** A durable loopback endpoint used by the host gateway to reach one workload. */ -export const PrivatePortAssignmentSchema = Schema.Struct({ +const PrivatePortAssignmentSchema = Schema.Struct({ workloadId: Schema.String.check(Schema.isNonEmpty()), binding: Schema.String.check(Schema.isNonEmpty()), port: NetworkPortSchema, }); export type PrivatePortAssignment = Schema.Schema.Type; -export const PersistedSecretEntrySchema = Schema.Struct({ +const PersistedSecretEntrySchema = Schema.Struct({ policy: Schema.Literals(["managed", "passthrough"] as const), value: Schema.String, }); -export type PersistedSecretEntry = Schema.Schema.Type; - /** Secret slots are dynamic because function environment names are user-defined. */ -export const PersistedSecretValuesSchema = Schema.Record( +const PersistedSecretValuesSchema = Schema.Record( Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_.:/-]+$/)), PersistedSecretEntrySchema, ); @@ -260,7 +258,7 @@ const isDefinitionShape = (input: unknown): input is StackDefinition => { }; /** One closed, exhaustive schema for the compiler's fully materialized definition. */ -export const StackDefinitionSchema = Schema.declareConstructor()( +const StackDefinitionSchema = Schema.declareConstructor()( [], () => (input, ast, options) => Effect.gen(function* () { @@ -339,9 +337,6 @@ export const PersistedStackStateSchema = stateShape.pipe( ); export type PersistedStackState = Schema.Schema.Type; -export const isPersistedStackIdentity = (value: unknown): value is PersistedStackIdentity => - Schema.is(PersistedStackIdentitySchema)(value); - export const toPersistedIdentity = ( identity: StackIdentity, stackId: string, diff --git a/packages/stack/src/supervisor/DesiredState.ts b/packages/stack/src/supervisor/DesiredState.ts index 2aa7a78b89..4955aa3557 100644 --- a/packages/stack/src/supervisor/DesiredState.ts +++ b/packages/stack/src/supervisor/DesiredState.ts @@ -12,23 +12,23 @@ export interface ReconciliationInput { readonly observed: ReadonlyArray; } -export interface DesiredStart { +interface DesiredStart { readonly kind: "start"; readonly key: RuntimeWorkloadKey; readonly workload: PlannedWorkload; } -export interface DesiredStop { +interface DesiredStop { readonly kind: "stop"; readonly key: RuntimeWorkloadKey; } -export interface DesiredRemove { +interface DesiredRemove { readonly kind: "remove"; readonly key: RuntimeWorkloadKey; } -export interface BlockedWorkload { +interface BlockedWorkload { readonly workloadId: string; readonly dependencyId: string; } diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 8a3f35f159..6fc27ea3c5 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -57,7 +57,7 @@ export interface LifecycleBackend { readonly destroyData: (input: LifecycleInput) => Effect.Effect; } -export interface LifecycleStartOptions { +interface LifecycleStartOptions { readonly config?: StackConfig; } @@ -75,7 +75,7 @@ export interface LifecycleController { readonly destroy: () => Effect.Effect; } -export type LifecycleRequirements = Crypto.Crypto | FileSystem.FileSystem | Path.Path; +type LifecycleRequirements = Crypto.Crypto | FileSystem.FileSystem | Path.Path; export interface LifecycleControllerOptions { readonly stackId: StackId; diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index ce502013fb..50e4167c52 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -418,5 +418,3 @@ export const makeLogStore = ( stream, }; }); - -export { LOG_FORMAT as LogStoreFormat }; diff --git a/packages/stack/src/supervisor/Reconciler.ts b/packages/stack/src/supervisor/Reconciler.ts index 121bfe4737..6f6e69d0ef 100644 --- a/packages/stack/src/supervisor/Reconciler.ts +++ b/packages/stack/src/supervisor/Reconciler.ts @@ -15,7 +15,7 @@ import { type RuntimeDriver, } from "../runtime/RuntimeDriver.ts"; -export interface ReconcilerRequest { +interface ReconcilerRequest { readonly stackId: StackId; readonly desiredGeneration: number; readonly desiredLifecycle: DesiredLifecycle; @@ -29,7 +29,7 @@ export interface ReconcilerOptions { readonly readinessTimeout?: Duration.Input; } -export interface ReconciliationResult { +interface ReconciliationResult { readonly generation: number; readonly observed: ReadonlyArray; readonly started: ReadonlyArray; @@ -51,7 +51,7 @@ export interface Reconciler { ) => Effect.Effect; } -export type ReconcilerError = +type ReconcilerError = | RuntimeDriverError | RuntimeGenerationMismatchError | RuntimeReadinessTimeoutError @@ -289,5 +289,3 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect Date: Mon, 31 Aug 2026 15:27:11 +0200 Subject: [PATCH 089/174] chore(stack): close private functions export surface --- .../shared/functions/serve-main-bundler.ts | 4 ++- .../functions/serve-main-bundler.unit.test.ts | 26 ++++++++++++++++++- .../src/shared/functions/serve-main-deps.ts | 4 ++- .../functions/serve-main-deps.unit.test.ts | 15 +++++++++++ ...26-08-28-stack-runtime-rewrite-progress.md | 9 +++++++ packages/stack/package.json | 4 +-- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/shared/functions/serve-main-bundler.ts b/apps/cli/src/shared/functions/serve-main-bundler.ts index 13a76623cd..5078c1de0d 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.ts @@ -1,4 +1,6 @@ +// Monorepo-internal source bridge: Functions bootstrap code is stack-owned and +// intentionally private from the @supabase/stack package export map. export { bundleServeMainTemplate, serveMainEntrypoint, -} from "@supabase/stack/functions/serve-main-bundler"; +} from "../../../../../packages/stack/src/functions/serve-main-bundler.ts"; diff --git a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts index b2677be895..97bc74db34 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts @@ -1,8 +1,32 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + import { describe, expect, it } from "vitest"; -import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; +import { bundleServeMainTemplate, serveMainEntrypoint } from "./serve-main-bundler.ts"; +import { + bundleServeMainTemplate as stackBundleServeMainTemplate, + serveMainEntrypoint as stackServeMainEntrypoint, +} from "../../../../../packages/stack/src/functions/serve-main-bundler.ts"; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../../../packages/stack"); describe("bundleServeMainTemplate", () => { + it("keeps the bootstrap private while bridging the stack-owned implementation", () => { + const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + readonly exports: Record; + }; + + expect(packageJson.exports).toEqual({ + ".": "./src/index.ts", + "./effect": "./src/effect.ts", + "./testing": "./src/testing.ts", + }); + expect(bundleServeMainTemplate).toBe(stackBundleServeMainTemplate); + expect(serveMainEntrypoint).toBe(stackServeMainEntrypoint); + }); + it("produces a self-contained runtime template with no remote import specifiers", async () => { const bundled = await bundleServeMainTemplate(); diff --git a/apps/cli/src/shared/functions/serve-main-deps.ts b/apps/cli/src/shared/functions/serve-main-deps.ts index 42ab86a253..46ae6502d2 100644 --- a/apps/cli/src/shared/functions/serve-main-deps.ts +++ b/apps/cli/src/shared/functions/serve-main-deps.ts @@ -1,7 +1,9 @@ +// Monorepo-internal source bridge: Functions bootstrap dependencies are +// stack-owned and intentionally private from the @supabase/stack export map. export { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl, -} from "@supabase/stack/functions/serve-main-deps"; +} from "../../../../../packages/stack/src/functions/serve-main-deps.ts"; diff --git a/apps/cli/src/shared/functions/serve-main-deps.unit.test.ts b/apps/cli/src/shared/functions/serve-main-deps.unit.test.ts index 8f221781b1..6edef37e9c 100644 --- a/apps/cli/src/shared/functions/serve-main-deps.unit.test.ts +++ b/apps/cli/src/shared/functions/serve-main-deps.unit.test.ts @@ -1,6 +1,21 @@ import { describe, expect, it } from "vitest"; import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; +import { + dirname as stackDirname, + join as stackJoin, + STATUS_CODE as stackStatusCode, + STATUS_TEXT as stackStatusText, + toFileUrl as stackToFileUrl, +} from "../../../../../packages/stack/src/functions/serve-main-deps.ts"; + +it("bridges the stack-owned private dependency helpers", () => { + expect(dirname).toBe(stackDirname); + expect(join).toBe(stackJoin); + expect(STATUS_CODE).toBe(stackStatusCode); + expect(STATUS_TEXT).toBe(stackStatusText); + expect(toFileUrl).toBe(stackToFileUrl); +}); describe("posix join", () => { it("joins absolute segments with a single separator", () => { diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 548859c78d..344fe14af2 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1036,3 +1036,12 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Knip, stack type-check, package unit tests, focused control/supervisor/functions integration tests, generic and Effect lint, formatting, and `git diff --check` pass. Full integration was also verified with two workers (384/384); the default parallel run remains subject to concurrent native-process load as documented in the runtime test fix. + +#### Task 15 — close the stack package export surface (2026-08-31) + +- Closed `@supabase/stack` to the package root, `./effect`, and `./testing`; the Edge Runtime bootstrap modules + remain private stack-owned sources used internally by `ProductionRuntime`. +- The two CLI Functions bridges now import those exact private sources through documented monorepo-internal relative + paths, preserving source execution and compiled CLI bundling without a new public alias or duplicate runtime. +- Bridge identity tests assert both CLI modules expose the stack-owned implementations and that no private Functions + subpaths remain in the package export map. diff --git a/packages/stack/package.json b/packages/stack/package.json index 80775c071c..eb4e967d93 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -6,9 +6,7 @@ "exports": { ".": "./src/index.ts", "./effect": "./src/effect.ts", - "./testing": "./src/testing.ts", - "./functions/serve-main-bundler": "./src/functions/serve-main-bundler.ts", - "./functions/serve-main-deps": "./src/functions/serve-main-deps.ts" + "./testing": "./src/testing.ts" }, "scripts": { "test": "pnpm run test:unit && pnpm run test:integration", From 791c0e0018eef105d481be9b1195754cd722acf0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 16:09:08 +0200 Subject: [PATCH 090/174] fix(cli): normalize stack config boundary --- apps/cli/src/next/config/stack-config.ts | 34 ++++++++++++++----- .../src/next/config/stack-config.unit.test.ts | 32 +++++++++++++++-- ...26-08-28-stack-runtime-rewrite-progress.md | 8 +++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 1a07a82ac7..68f80d999b 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -1,12 +1,13 @@ -import { Redacted } from "effect"; +import { Redacted, Schema } from "effect"; import type { CliConfig } from "@supabase/config"; -import type { - AuthSettings, - DatabaseSettings, - FunctionsSettings, - RestSettings, - StackConfig, - StackRuntimePreference, +import { + StackConfigSchema, + type AuthSettings, + type DatabaseSettings, + type FunctionsSettings, + type RestSettings, + type StackConfig, + type StackRuntimePreference, } from "@supabase/stack/effect"; export const excludedStackServices = [ @@ -53,6 +54,17 @@ const secretRecord = (value: Readonly> | undefined) => Object.entries(value).map(([name, secret]) => [name, Redacted.make(secret)]), ); +/** Omit absent object properties while preserving arrays and redacted values. */ +const omitUndefined = (value: unknown): unknown => { + if (value === null || typeof value !== "object" || Redacted.isRedacted(value)) return value; + if (Array.isArray(value)) return value.map(omitUndefined); + return Object.fromEntries( + Object.entries(value) + .filter(([, child]) => child !== undefined) + .map(([key, child]) => [key, omitUndefined(child)]), + ); +}; + const passwordRequirements = (value: string | undefined): AuthSettings["password_requirements"] => value === "" || value === "letters_digits" || @@ -275,7 +287,7 @@ export function toStartStackConfig( default_pool_size: pooler.default_pool_size, max_client_conn: pooler.max_client_conn, }; - return { + const configOutput = { capabilities: { database: { version: db?.major_version?.toString(), settings: databaseSettings }, rest: capability("rest", native, excluded, api?.enabled, toRestSettings(api)), @@ -329,6 +341,10 @@ export function toStartStackConfig( }, }, }; + + // This synchronous, service-free CLI boundary validates the normalized overlay against the + // closed stack schema before handing it to the Effect-native runtime. + return Schema.decodeUnknownSync(StackConfigSchema)(omitUndefined(configOutput)); } export function runtimePreference(mode?: StartMode): StackRuntimePreference | undefined { diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index f3584e6994..18ed2d0b86 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { CliConfigSchema } from "@supabase/config"; -import { Schema } from "effect"; +import { Redacted, Schema } from "effect"; +import { StackConfigSchema } from "@supabase/stack/effect"; import { toStartStackConfig } from "./stack-config.ts"; describe("toStartStackConfig", () => { @@ -25,7 +26,7 @@ describe("toStartStackConfig", () => { }); it("emits project-relative Functions roots and JWKS security from loaded config", () => { - const loaded = Schema.decodeUnknownSync(CliConfigSchema)({ + const loaded = Schema.decodeSync(CliConfigSchema)({ auth: { signing_keys_path: "supabase/jwks.json" }, edge_runtime: { secrets: { EDGE_TOKEN: "env(EDGE_TOKEN)" } }, functions: { hello: { env: { FUNCTION_TOKEN: "env(FUNCTION_TOKEN)" } } }, @@ -39,4 +40,31 @@ describe("toStartStackConfig", () => { path: "supabase/jwks.json", }); }); + + it("translates a decoded CLI config into a schema-valid stack config", () => { + const loaded = Schema.decodeSync(CliConfigSchema)({ + api: {}, + auth: { jwt_secret: "jwt-secret" }, + db: { settings: { session_replication_role: "origin" } }, + edge_runtime: { secrets: { EDGE_TOKEN: "env(EDGE_TOKEN)" } }, + functions: { hello: { env: { FUNCTION_TOKEN: "env(FUNCTION_TOKEN)" } } }, + }); + + const decoded = Schema.decodeSync(StackConfigSchema)(toStartStackConfig(loaded, [], "native")); + + expect(decoded.capabilities?.database?.version).toBe("17"); + expect(decoded.capabilities?.database?.settings?.settings?.session_replication_role).toBe( + "origin", + ); + expect(decoded.capabilities?.functions).toMatchObject({ + settings: { functions_root: "supabase/functions" }, + }); + expect(decoded.listeners?.api).toMatchObject({ port: 54321 }); + const jwtSecret = decoded.security?.jwt?.signing; + expect(jwtSecret?.kind).toBe("symmetric"); + if (jwtSecret?.kind === "symmetric") { + expect(Redacted.isRedacted(jwtSecret.secret)).toBe(true); + expect(Redacted.value(jwtSecret.secret)).toBe("jwt-secret"); + } + }); }); diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 344fe14af2..0502407b9d 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1045,3 +1045,11 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g paths, preserving source execution and compiled CLI bundling without a new public alias or duplicate runtime. - Bridge identity tests assert both CLI modules expose the stack-owned implementations and that no private Functions subpaths remain in the package export map. + +#### Task 16B — CLI-to-Stack config boundary (2026-08-31) + +- Added a real `CliConfigSchema` → `toStartStackConfig` → `StackConfigSchema` boundary regression covering database + settings, Auth JWT material, Functions secrets, and API listener defaults. The test first reproduced the closed-schema + failure at `capabilities.database.settings.vault` when the translation emitted an explicit `undefined` leaf. +- The CLI translation now recursively omits only undefined object properties before a synchronous, service-free + `StackConfigSchema` decode. Arrays, nulls, primitives, and Effect `Redacted` values remain unchanged. From b10b2b8ef733639ab411c3dd840a28c92088f005 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 16:52:58 +0200 Subject: [PATCH 091/174] fix(stack): normalize functions and embedded supervisor startup --- .../functions-dev-runtime.integration.test.ts | 10 +++-- .../functions/dev/functions-dev-runtime.ts | 15 +++++-- apps/cli/src/next/main.ts | 10 ++++- .../stack/src/entrypoints/supervisor-node.ts | 39 +++++++++++-------- packages/stack/src/supervisor/Launcher.ts | 12 ++++-- .../supervisor/handles.integration.test.ts | 14 ++++++- 6 files changed, 71 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts index 96985921a5..ba01aa8b39 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts @@ -68,9 +68,9 @@ describe("managed Functions serving", () => { hello: { enabled: true, verify_jwt: true, - import_map: "", + import_map: "./functions/hello/manifest-deno.json", entrypoint: "./functions/hello/index.ts", - static_files: [], + static_files: ["./functions/hello/public/*.html"], env: { CONFIG_TOKEN: "config-secret" }, }, }; @@ -93,7 +93,7 @@ describe("managed Functions serving", () => { stackName: "default", envFile: "flags.env", noVerifyJwt: true, - importMap: "supabase/functions/custom-deno.json", + importMap: "./functions/hello/custom-deno.json", inspectMode: "wait", inspectMain: true, }, @@ -109,7 +109,9 @@ describe("managed Functions serving", () => { expect(functionSettings?.functions_root).toBe("supabase/functions"); expect(functionSettings?.inspector).toEqual({ mode: "wait", main: true }); expect(hello?.verify_jwt).toBe(false); - expect(hello?.import_map).toBe("supabase/functions/custom-deno.json"); + expect(hello?.import_map).toBe("custom-deno.json"); + expect(hello?.entrypoint).toBe("index.ts"); + expect(hello?.static_files).toEqual(["public/*.html"]); const configToken = hello?.env?.CONFIG_TOKEN; const envToken = hello?.env?.ENV_TOKEN; expect(configToken).toBeDefined(); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 074b8283f4..1e02babc77 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -104,6 +104,15 @@ const statusAfterReadiness = Effect.fnUntraced(function* ( return next.value; }); +/** Converts a manifest path (relative to `supabase`) to the function directory's path. */ +function relativeFunctionPath(slug: string, pathname: string): string; +function relativeFunctionPath(slug: string, pathname: undefined): undefined; +function relativeFunctionPath(slug: string, pathname: string | undefined): string | undefined { + if (pathname === undefined) return undefined; + const prefix = `./functions/${slug}/`; + return pathname.startsWith(prefix) ? pathname.slice(prefix.length) : pathname; +} + const toFunctionOverrides = ( manifest: FunctionsManifest, options: ServeManagedFunctionsOptions, @@ -115,9 +124,9 @@ const toFunctionOverrides = ( { enabled: config.enabled, verify_jwt: options.noVerifyJwt === true ? false : config.verify_jwt, - import_map: options.importMap ?? config.import_map, - entrypoint: config.entrypoint, - static_files: config.static_files, + import_map: relativeFunctionPath(slug, options.importMap ?? config.import_map), + entrypoint: relativeFunctionPath(slug, config.entrypoint), + static_files: config.static_files.map((pathname) => relativeFunctionPath(slug, pathname)), env: Object.fromEntries( Object.entries({ ...config.env, ...envFile }).map(([name, value]) => [ name, diff --git a/apps/cli/src/next/main.ts b/apps/cli/src/next/main.ts index 066857cd2a..c47b8d8f1d 100644 --- a/apps/cli/src/next/main.ts +++ b/apps/cli/src/next/main.ts @@ -1,2 +1,10 @@ #!/usr/bin/env bun -await import("./cli/main.ts"); +import { runSupervisorProcess } from "../../../../packages/stack/src/entrypoints/supervisor-node.ts"; +import { SUPERVISOR_DISPATCH_SENTINEL } from "../../../../packages/stack/src/supervisor/Launcher.ts"; + +const args = process.argv.slice(2); +if (args[0] === SUPERVISOR_DISPATCH_SENTINEL) { + await runSupervisorProcess(args.slice(1)); +} else { + await import("./cli/main.ts"); +} diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index b1a2f4dbea..2d8ce9e35a 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -59,6 +59,22 @@ class SupervisorReadinessError extends Data.TaggedError("SupervisorReadinessErro readonly cause?: unknown; }> {} +const reportSupervisorFailure = (error: unknown): void => { + try { + const conflict = error instanceof StackOwnershipConflictError; + // The descriptor is owned by the parent launcher and intentionally + // written directly at this standalone process boundary. + NodeFs.writeSync( + 3, + `${Schema.encodeSync(Schema.fromJsonString(ReadySchema))({ ok: false, code: conflict ? "ownership-conflict" : "failed", message: error instanceof Error ? error.message : "Supervisor failed" })}\n`, + ); + NodeFs.closeSync(3); + } catch { + // The parent may have already closed the readiness descriptor. + } + process.exitCode = 1; +}; + const writeReadiness = ( value: Schema.Schema.Type, ): Effect.Effect => @@ -143,24 +159,13 @@ export const runSupervisor = (args: SupervisorArgs) => export const parseSupervisorArgs = (argv: ReadonlyArray) => Schema.decodeEffect(Schema.fromJsonString(SupervisorArgsSchema))(argv[0] ?? "{}"); +export const runSupervisorProcess = (argv: ReadonlyArray): Promise => + Effect.runPromise(parseSupervisorArgs(argv).pipe(Effect.flatMap(runSupervisor))).catch( + reportSupervisorFailure, + ); + if (import.meta.main) { - Effect.runPromise( - parseSupervisorArgs(process.argv.slice(2)).pipe(Effect.flatMap(runSupervisor)), - ).catch((error: unknown) => { - try { - const conflict = error instanceof StackOwnershipConflictError; - // The descriptor is owned by the parent launcher and intentionally - // written directly at this standalone process boundary. - NodeFs.writeSync( - 3, - `${Schema.encodeSync(Schema.fromJsonString(ReadySchema))({ ok: false, code: conflict ? "ownership-conflict" : "failed", message: error instanceof Error ? error.message : "Supervisor failed" })}\n`, - ); - NodeFs.closeSync(3); - } catch { - // The parent may have already closed the readiness descriptor. - } - process.exitCode = 1; - }); + await runSupervisorProcess(process.argv.slice(2)); } export { StackRuntimeEnvironment }; diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index c5cadcc3e3..122aedad21 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -34,6 +34,14 @@ import { export { StackRuntimeEnvironment } from "../state/Ownership.ts"; export type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; +/** Private argv marker used when a compiled CLI dispatches its embedded Supervisor. */ +export const SUPERVISOR_DISPATCH_SENTINEL = "__supabase_stack_supervisor__" as const; + +export const supervisorEntrypointFor = (moduleUrl: string): string => { + const sourceEntrypoint = fileURLToPath(new URL("../entrypoints/supervisor-node.ts", moduleUrl)); + return sourceEntrypoint.includes("/$bunfs/") ? SUPERVISOR_DISPATCH_SENTINEL : sourceEntrypoint; +}; + /** Default host values are resolved only at the process composition boundary. */ export const defaultRuntimeEnvironment = (): StackRuntimeEnvironmentValue => { // This synchronous helper is the composition boundary where host environment @@ -47,9 +55,7 @@ export const defaultRuntimeEnvironment = (): StackRuntimeEnvironmentValue => { tempRoot: process.platform === "win32" ? (process.env.TEMP ?? "C:\\Windows\\Temp") : "/tmp", platform: process.platform === "win32" ? "windows" : "posix", supervisorCommand: process.execPath, - supervisorEntrypoint: fileURLToPath( - new URL("../entrypoints/supervisor-node.ts", import.meta.url), - ), + supervisorEntrypoint: supervisorEntrypointFor(import.meta.url), }; }; diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 8fa524d751..c4e0a611fd 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -22,7 +22,12 @@ import { listStacks, } from "../public/EffectStack.ts"; import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; -import { defaultRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "./Launcher.ts"; +import { + defaultRuntimeEnvironment, + SUPERVISOR_DISPATCH_SENTINEL, + supervisorEntrypointFor, + type StackRuntimeEnvironmentValue, +} from "./Launcher.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { StackId } from "../public/StackId.ts"; import { readOwnerMetadata, StackRuntimeEnvironment } from "../state/Ownership.ts"; @@ -107,6 +112,13 @@ const quoteModuleSpecifier = (value: string): string => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; describe("managed stack handles", { timeout: 30_000 }, () => { + it("selects the private dispatch marker only for compiled Bun paths", () => { + expect( + supervisorEntrypointFor("file:///$bunfs/root/packages/stack/src/supervisor/Launcher.ts"), + ).toBe(SUPERVISOR_DISPATCH_SENTINEL); + expect(supervisorEntrypointFor(import.meta.url)).not.toBe(SUPERVISOR_DISPATCH_SENTINEL); + }); + it.live("resolves a new automatic container identity and persists Docker", () => withRuntimeRoot((project) => Effect.gen(function* () { From 20b3669bf22bed2e940eb8dee8ae64ddc54b6bcb Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 16:58:08 +0200 Subject: [PATCH 092/174] fix(stack): run service migrations before containers --- ...26-08-28-stack-runtime-rewrite-progress.md | 36 ++- .../runtime-artifacts.integration.test.ts | 1 + packages/stack/src/runtime/ContainerEngine.ts | 16 + .../stack/src/runtime/ContainerRuntime.ts | 114 ++++++- packages/stack/src/runtime/DockerEngine.ts | 19 ++ packages/stack/src/runtime/PodmanEngine.ts | 19 ++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 33 +- .../container-runtime.integration.test.ts | 286 ++++++++++++++++++ .../production-runtime.integration.test.ts | 1 + .../runtime-recovery.integration.test.ts | 1 + .../workload-runtime.integration.test.ts | 16 +- 11 files changed, 513 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 0502407b9d..6ca6ea1f11 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -878,9 +878,9 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g the long-lived process and readiness. Fast process exit does not cancel stdout/stderr draining, failures prevent the main process, and interruption kills only the exact owned child. - Auth, Storage, Realtime, Analytics, and Pooler use their audited slim-services migration/startup commands. - Auth and Storage container command chains compensate for their intentionally empty image entrypoints; - Realtime, Analytics, and Pooler retain their derived-image entrypoints. Mailpit uses only `MP_*` environment - configuration. + Auth and Storage run explicit container init processes through their pinned image entrypoints before the main + container; Realtime, Analytics, and Pooler retain their derived-image entrypoints. Mailpit uses only `MP_*` + environment configuration. - Storage owns one `/mnt` volume and imgproxy mounts the same volume read-only with `/mnt` as its filesystem root. Native and container persistence contracts now match. - Initial commit `a0c127637`; fix commit `81edf9969` removed the obsolete direct-process resolver fallback. @@ -1046,6 +1046,22 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Bridge identity tests assert both CLI modules expose the stack-owned implementations and that no private Functions subpaths remain in the package export map. +#### Task 16 — managed Functions request-time discovery inputs (2026-08-31) + +- Translated manifest entrypoint, import-map, and static-file paths from the Supabase directory to the selected + function directory by stripping only the exact `./functions//` prefix; undefined values remain unchanged and + Stack retains path-safety validation. +- The authoritative managed-stack Functions E2E belongs in `packages/stack`; the existing CLI Functions E2E remains + unchanged and is not used as the rewrite's end-to-end contract. +- TDD evidence: the focused translation integration test first failed with `./functions/hello/custom-deno.json` + instead of `custom-deno.json`, then passed after the narrow normalization. +- A separate compiled-CLI Supervisor launcher defect was found while exercising the boundary: the standalone binary + resolved the embedded `supervisor-node.ts` to a `/$bunfs` path and invoked its CLI parser instead of a bundled + Supervisor entrypoint, so it exited before fd3 readiness. Direct source-Bun Supervisor startup published readiness. +- Added a private compiled-CLI sentinel dispatch: source Bun continues to execute the Supervisor entrypoint directly, + while the standalone binary re-enters itself with the sentinel and dispatches to the same Supervisor decoder/runner + before normal CLI parsing. The source-level launch-target test and compiled fd3 smoke check pass. + #### Task 16B — CLI-to-Stack config boundary (2026-08-31) - Added a real `CliConfigSchema` → `toStartStackConfig` → `StackConfigSchema` boundary regression covering database @@ -1053,3 +1069,17 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g failure at `capabilities.database.settings.vault` when the translation emitted an explicit `undefined` leaf. - The CLI translation now recursively omits only undefined object properties before a synchronous, service-free `StackConfigSchema` decode. Arrays, nulls, primitives, and Effect `Redacted` values remain unchanged. +- Focused and full stack-config unit tests, CLI type-checking, lint, formatting, and diff checks pass. + +#### Task 17 — container service startup migrations (2026-08-31) + +- Added private Docker/Podman `--entrypoint` serialization and a cancellation-safe `wait-container` operation that + decodes exactly one safe numeric exit code. Auth and Storage now resolve their concrete slim-image init processes + (`/usr/local/bin/auth migrate` and `/node/bin/node dist/scripts/migrate-call.js`) while their main containers use + image defaults; Realtime, Analytics, and Pooler continue to use their image-owned entrypoints. +- ContainerRuntime creates a deterministic init container only for newly-created/replaced workloads, reusing the + workload network, env file, mounts, volume, and host route without public publications. It waits for the exact exit, + attaches log streaming after init start so fast exits are replayed, persists init logs when streaming is available, + removes the exact init container on success, failure, or interruption, and starts the main container only after exit 0. Adopted exact main containers do not rerun migration; stale init names are removed as collisions before retry. +- TDD coverage first reproduced ignored entrypoints, absent wait decoding, and missing startup ordering, then passed + serializer, wait, Auth/Storage resolution, ordering, nonzero, interruption, adoption, and stale-collision scenarios. diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 07f6b7c16e..0c16b2a9a7 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -65,6 +65,7 @@ const containerEngine = (present: boolean, calls: string[]): ContainerEngine => createContainer: (_spec: ContainerContainerSpec) => Effect.die("unused"), copyToContainer: () => Effect.die("unused"), startContainer: () => Effect.void, + waitContainer: () => Effect.succeed(0), stopContainer: () => Effect.void, removeContainer: () => Effect.void, }); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 12dc8c3b8a..87c8372940 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -77,6 +77,10 @@ export interface ContainerVolumeMount { readonly target: string; readonly readOnly: boolean; } +export interface ContainerStartupProcess { + readonly entrypoint: string; + readonly command: ReadonlyArray; +} interface ContainerPortPublication { readonly address: "127.0.0.1"; readonly hostPort: number; @@ -100,6 +104,8 @@ export interface ContainerContainerSpec { readonly publications: ReadonlyArray; readonly hostRoute?: ContainerHostRoute; readonly role: "workload"; + /** Optional image entrypoint override used by service-owned init processes. */ + readonly entrypoint?: string; readonly command?: ReadonlyArray; /** Path to an owned 0600 env file. Secret values must never be argv. */ readonly envFile?: string; @@ -125,6 +131,7 @@ export type ContainerCommand = readonly destination: string; } | { readonly operation: "start-container"; readonly id: string } + | { readonly operation: "wait-container"; readonly id: string } | { readonly operation: "stop-container"; readonly id: string } | { readonly operation: "remove-container"; readonly id: string }; export interface ContainerCommandResult { @@ -374,6 +381,9 @@ interface ContainerEngineCodecs { spec: { readonly name: string; readonly labels: ContainerLabels }, kind: R, ) => Effect.Effect; + readonly decodeWait: ( + result: ContainerCommandResult, + ) => Effect.Effect; readonly serializeLogs: ( id: string, options: ContainerLogOptions | undefined, @@ -415,6 +425,8 @@ export interface ContainerEngine { destination: string, ) => Effect.Effect; readonly startContainer: (id: string) => Effect.Effect; + /** Waits for one exact container and returns its process exit code. */ + readonly waitContainer: (id: string) => Effect.Effect; readonly stopContainer: (id: string) => Effect.Effect; readonly removeContainer: (id: string) => Effect.Effect; /** Follows one exact container and emits complete stdout/stderr lines. */ @@ -568,6 +580,10 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai copyToContainer: (id, source, destination) => noResult("copy-container", { operation: "copy-container", id, source, destination }), startContainer: (id) => noResult("start-container", { operation: "start-container", id }), + waitContainer: (id) => + check("wait-container", { operation: "wait-container", id }).pipe( + Effect.flatMap(options.codecs.decodeWait), + ), stopContainer: (id) => noResult("stop-container", { operation: "stop-container", id }), removeContainer: (id) => noResult("remove-container", { operation: "remove-container", id }), streamLogs, diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 22128d7cbf..0ad73211ee 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -10,6 +10,7 @@ import { type ContainerEngineFailure, type ContainerLabels, type ContainerMount, + type ContainerStartupProcess, type ContainerVolumeMount, type ContainerVolumeLabels, type ContainerVolumeSpec, @@ -60,6 +61,8 @@ export interface ContainerWorkloadResolution { readonly hostRoute?: ContainerHostRoute; /** Private host-loopback publications used by the in-process gateway. */ readonly publications?: ReadonlyArray; + /** Service-owned one-shot processes run before a newly-created main container. */ + readonly startup?: ReadonlyArray; readonly command?: ReadonlyArray; /** Owner-created bootstrap copied into a newly-created container before start. */ readonly bootstrap?: Readonly<{ readonly source: string; readonly destination: string }>; @@ -127,6 +130,14 @@ const workloadLabelsFor = ( specHash: key.specHash, role: "workload", }); +const startupLabelsFor = ( + key: RuntimeWorkloadKey, + ownerSessionId: string, + index: number, +): ContainerWorkloadLabels => ({ + ...workloadLabelsFor(key, ownerSessionId), + specHash: `${key.specHash}:startup:${index}`, +}); const volumeOwnerFor = (key: RuntimeWorkloadKey, request: ContainerVolumeRequest): string => request.ownerWorkloadId ?? key.workloadId; const volumeLabelsFor = ( @@ -327,6 +338,89 @@ export const makeContainerRuntime = ( ), ); + const runStartupProcess = ( + key: RuntimeWorkloadKey, + workload: PlannedWorkload, + process: ContainerStartupProcess, + index: number, + context: Readonly<{ + readonly artifact: ContainerArtifact; + readonly network: ContainerResource; + readonly resolution: ContainerWorkloadResolution; + readonly volumeRequest?: ContainerVolumeRequest; + }>, + ): Effect.Effect => { + const labels = startupLabelsFor(key, options.ownerSessionId, index); + const specification: ContainerContainerSpec = { + name: nameFor(key, "workload"), + image: context.artifact.image, + labels, + network: context.network.id, + mounts: context.resolution.mounts ?? [], + volumeMounts: + context.volumeRequest === undefined ? [] : [volumeMountFor(key, context.volumeRequest)], + publications: [], + role: "workload", + entrypoint: process.entrypoint, + command: process.command, + ...(context.resolution.envFile === undefined + ? {} + : { envFile: context.resolution.envFile }), + ...(context.resolution.networkAliases === undefined + ? {} + : { networkAliases: context.resolution.networkAliases }), + ...(context.resolution.hostRoute === undefined + ? {} + : { hostRoute: context.resolution.hostRoute }), + }; + let logFiber: Fiber.Fiber | undefined; + const acquire = withEngine(key, options.engine.createContainer(specification)); + const logStore = options.logStore; + const use = (container: ContainerResource): Effect.Effect => + Effect.gen(function* () { + yield* withEngine(key, options.engine.startContainer(container.id)); + if (logStore !== undefined && options.engine.streamLogs !== undefined) { + const consume = options.engine.streamLogs(container.id, { tail: "all" }).pipe( + Stream.runForEach((line) => + logStore + .append({ + source: workload.capability, + stream: line.stream, + message: line.message, + }) + .pipe(Effect.asVoid), + ), + Effect.mapError((error) => toDriverError(key, error)), + ); + logFiber = yield* Effect.forkIn(consume, runtimeScope); + } + const exitCode = yield* withEngine(key, options.engine.waitContainer(container.id)); + if (logFiber !== undefined) yield* Fiber.join(logFiber); + if (exitCode !== 0) + return yield* toDriverError( + key, + new Error( + `Container startup process exited with code ${String(exitCode)} for ${key.workloadId}`, + ), + ); + }); + const release = ( + container: ContainerResource, + useExit: Exit.Exit, + ): Effect.Effect => + Effect.gen(function* () { + if (logFiber !== undefined) yield* Fiber.interrupt(logFiber); + const removed = yield* Effect.exit( + withEngine(key, options.engine.removeContainer(container.id)), + ); + if (Exit.isFailure(removed)) + return yield* Effect.failCause( + Exit.isFailure(useExit) ? Cause.combine(useExit.cause, removed.cause) : removed.cause, + ); + }); + return Effect.acquireUseRelease(acquire, use, release); + }; + const start = ( key: RuntimeWorkloadKey, workload: PlannedWorkload, @@ -464,6 +558,21 @@ export const makeContainerRuntime = ( yield* withEngine(key, options.engine.createVolume(volume)); } + if (exact === undefined && collision !== undefined) { + if (collision.state === "running") + yield* withEngine(key, options.engine.stopContainer(collision.id)); + yield* withEngine(key, options.engine.removeContainer(collision.id)); + } + + if (exact === undefined) + for (const [index, startup] of (resolution.startup ?? []).entries()) + yield* runStartupProcess(key, workload, startup, index, { + artifact, + network: networkResource, + resolution, + ...(volumeRequest === undefined ? {} : { volumeRequest }), + }); + let container: ContainerResource; let created = false; let startedByUs = false; @@ -474,11 +583,6 @@ export const makeContainerRuntime = ( startedByUs = true; } } else { - if (collision !== undefined) { - if (collision.state === "running") - yield* withEngine(key, options.engine.stopContainer(collision.id)); - yield* withEngine(key, options.engine.removeContainer(collision.id)); - } container = yield* withEngine( key, options.engine.createContainer({ diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index f9f30640a5..b541dce642 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -157,6 +157,8 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc command.spec.networkAliases === undefined ? [] : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); + const entrypoint = + command.spec.entrypoint === undefined ? [] : ["--entrypoint", command.spec.entrypoint]; return { args: [ "create", @@ -171,6 +173,7 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc ...publications, ...hostRoute, ...environment, + ...entrypoint, command.spec.image, ...(command.spec.command ?? []), ], @@ -180,6 +183,8 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; case "start-container": return { args: ["start", command.id] }; + case "wait-container": + return { args: ["wait", command.id] }; case "stop-container": return { args: ["stop", command.id] }; case "remove-container": @@ -398,6 +403,19 @@ const decodeCreate = ( : Effect.fail(protocol(operation)); }; +const decodeWait = ( + result: ContainerCommandResult, +): Effect.Effect => { + const values = lines(result.stdout); + const value = values[0]; + if (values.length !== 1 || value === undefined || !/^\d+$/.test(value)) + return Effect.fail(protocol("wait-container")); + const exitCode = Number(value); + return Number.isSafeInteger(exitCode) + ? Effect.succeed(exitCode) + : Effect.fail(protocol("wait-container")); +}; + const makeDockerCodecs = () => ({ serialize: serializeDockerCommand, serializeLogs: serializeDockerLogs, @@ -411,6 +429,7 @@ const makeDockerCodecs = () => ({ decodeNetworks, decodeVolumes, decodeCreate, + decodeWait, }); export const makeDockerEngine = ( diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index 1515c5b4f6..247c4f2db7 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -154,6 +154,8 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc command.spec.networkAliases === undefined ? [] : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); + const entrypoint = + command.spec.entrypoint === undefined ? [] : ["--entrypoint", command.spec.entrypoint]; return { args: [ "create", @@ -167,6 +169,7 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc ...volumeMounts, ...publications, ...environment, + ...entrypoint, command.spec.image, ...(command.spec.command ?? []), ], @@ -176,6 +179,8 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; case "start-container": return { args: ["start", command.id] }; + case "wait-container": + return { args: ["wait", command.id] }; case "stop-container": return { args: ["stop", command.id] }; case "remove-container": @@ -376,6 +381,19 @@ const decodeCreate = ( : Effect.fail(protocol(operation)); }; +const decodeWait = ( + result: ContainerCommandResult, +): Effect.Effect => { + const values = lines(result.stdout); + const value = values[0]; + if (values.length !== 1 || value === undefined || !/^\d+$/.test(value)) + return Effect.fail(protocol("wait-container")); + const exitCode = Number(value); + return Number.isSafeInteger(exitCode) + ? Effect.succeed(exitCode) + : Effect.fail(protocol("wait-container")); +}; + const makePodmanCodecs = () => ({ serialize: serializePodmanCommand, serializeLogs: serializePodmanLogs, @@ -389,6 +407,7 @@ const makePodmanCodecs = () => ({ decodeNetworks, decodeVolumes, decodeCreate, + decodeWait, }); export const makePodmanEngine = ( diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 8756d4ee94..d037515f87 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -1,7 +1,11 @@ import type { PersistedStackState } from "../state/StackState.ts"; import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { CapabilityName } from "../public/Capability.ts"; -import type { ContainerHostRoute, ContainerMount } from "./ContainerEngine.ts"; +import type { + ContainerHostRoute, + ContainerMount, + ContainerStartupProcess, +} from "./ContainerEngine.ts"; import { WORKLOAD_CATALOG, type NativeWorkloadProcess } from "../model/WorkloadCatalog.ts"; import { parseFileSize } from "../model/capabilities/storage.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; @@ -94,6 +98,8 @@ export interface WorkloadRuntimeSpec { port: number, inputs?: WorkloadRuntimeInputs, ) => ReadonlyArray; + /** Closed set of service-owned one-shot container processes. */ + readonly containerStartupProcesses: ReadonlyArray; readonly containerMounts?: ( state: PersistedStackState, workload: PlannedWorkload, @@ -130,6 +136,7 @@ type WorkloadRuntimeSpecDefinition = Omit< | "privateEndpoint" | "nativeProcess" | "nativeStartupProcesses" + | "containerStartupProcesses" | "readiness" > & Readonly<{ @@ -140,12 +147,17 @@ type WorkloadRuntimeSpecDefinition = Omit< Partial< Pick< WorkloadRuntimeSpec, - "cwd" | "privateEndpoint" | "nativeProcess" | "nativeStartupProcesses" + | "cwd" + | "privateEndpoint" + | "nativeProcess" + | "nativeStartupProcesses" + | "containerStartupProcesses" > >; export interface ContainerWorkloadResolution { readonly command: ReadonlyArray; + readonly startup?: ReadonlyArray; readonly env: Readonly>; readonly mounts: ReadonlyArray; readonly networkAliases: ReadonlyArray; @@ -1048,9 +1060,8 @@ const specs: Readonly> = { args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => withAuthSettings(state, runtime, port, workload, inputs), - // The slim Auth image has an empty ENTRYPOINT. Run its idempotent - // migration before handing control back to the image's gotrue command. - containerArgs: () => ["/bin/sh", "-c", "gotrue migrate && exec gotrue"], + containerArgs: () => [], + containerStartupProcesses: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], networkAliases: ["supabase-auth"], readiness: { protocol: "http", path: "/health" }, }, @@ -1092,13 +1103,9 @@ const specs: Readonly> = { args: () => [], env: (state, workload, port, runtime = "native", inputs = {}) => withStorageSettings(state, runtime, port, workload, inputs), - // The slim Storage image has an empty ENTRYPOINT and ships the bundled - // Node runtime on PATH. Keep migration and the long-lived server in one - // command so both modes share the same startup contract. - containerArgs: () => [ - "/usr/bin/sh", - "-c", - "node dist/scripts/migrate-call.js && exec node dist/start/server.js", + containerArgs: () => [], + containerStartupProcesses: [ + { entrypoint: "/node/bin/node", command: ["dist/scripts/migrate-call.js"] }, ], networkAliases: ["supabase-storage"], readiness: { protocol: "http", path: "/status" }, @@ -1414,6 +1421,7 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | runtime, inputs, ), + containerStartupProcesses: spec.containerStartupProcesses ?? [], readiness: { ...spec.readiness, binding: spec.readiness.binding ?? "primary" }, privateEndpoint: spec.privateEndpoint ?? @@ -1449,6 +1457,7 @@ export const containerResolutionFor = ( containerPortFor(state, workload.id, "primary", spec.containerPort), inputs, ), + startup: spec.containerStartupProcesses, env: spec.env( state, workload, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 0569378e0b..d5a0625ae1 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -100,6 +100,7 @@ interface FakeContainerState { nextId: number; inspectImageFailure?: ContainerCommandError; copyFailure?: ContainerCommandError; + waitExitCode?: number; } const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { @@ -191,6 +192,11 @@ const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { entry.id === resourceId ? { ...entry, state: "running" } : entry, ); }), + waitContainer: (resourceId: string) => + Effect.sync(() => { + state.calls.push(`wait:${resourceId}`); + return state.waitExitCode ?? 0; + }), stopContainer: (resourceId: string) => Effect.sync(() => { state.calls.push(`stop:${resourceId}`); @@ -299,6 +305,67 @@ describe("container runtime", () => { }), ); + it.live("serializes an entrypoint override before the image", () => + Effect.sync(() => { + const spec: ContainerContainerSpec = { + name: "auth-init", + image: "ghcr.io/supabase/cli/auth:v2.196.0", + labels: { + stackId, + ownerSessionId: "owner", + desiredGeneration: 7, + workloadId: "auth:auth", + specHash: key.specHash, + role: "workload", + }, + network: "private", + mounts: [], + volumeMounts: [], + publications: [], + role: "workload", + entrypoint: "/usr/local/bin/auth", + command: ["migrate"], + }; + const docker = serializeDockerCommand({ operation: "create-container", spec }); + expect(docker.args.slice(docker.args.indexOf("--entrypoint"), -2)).toEqual([ + "--entrypoint", + "/usr/local/bin/auth", + ]); + expect(docker.args.indexOf("--entrypoint")).toBeLessThan(docker.args.indexOf(spec.image)); + expect(docker.args.slice(-2)).toEqual([spec.image, "migrate"]); + const podman = serializePodmanCommand({ operation: "create-container", spec }); + expect(podman.args.indexOf("--entrypoint")).toBeLessThan(podman.args.indexOf(spec.image)); + expect(podman.args.slice(-2)).toEqual([spec.image, "migrate"]); + }), + ); + + it.live("waits for one container and rejects malformed exit output", () => + Effect.gen(function* () { + const runner = makeControlledCommandRunner({ + run: (request) => + Effect.succeed(commandResult(request.args[0] === "wait" ? "17\n" : "27.0.0\n")), + }); + const docker = makeDockerEngine({ + runner, + platform: { os: "linux", desktop: false }, + }); + expect(yield* docker.waitContainer("container-id")).toBe(17); + const malformed = makeDockerEngine({ + runner: makeControlledCommandRunner({ + run: () => Effect.succeed(commandResult("17\n18\n")), + }), + platform: { os: "linux", desktop: false }, + }); + const result = yield* malformed.waitContainer("container-id").pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBeInstanceOf(ContainerEngineProtocolError); + } + }), + ); + it.live("derives host routes before mutation and rejects unsupported hosts", () => Effect.gen(function* () { const calls: ContainerProcessRequest[] = []; @@ -392,6 +459,225 @@ describe("container runtime", () => { }), ); + it.live("runs container startup migrations before the main workload", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + expect(state.createdSpecs).toHaveLength(2); + const [startup, main] = state.createdSpecs; + expect(startup?.entrypoint).toBe("/usr/local/bin/auth"); + expect(startup?.command).toEqual(["migrate"]); + expect(startup?.publications).toEqual([]); + expect(main?.entrypoint).toBeUndefined(); + expect(main?.command).toBeUndefined(); + const startupStart = state.calls.findIndex((call) => call.startsWith("start:")); + const startupWait = state.calls.findIndex((call) => call.startsWith("wait:")); + const startupRemove = state.calls.findIndex((call) => call.startsWith("remove:")); + expect(startupStart).toBeGreaterThanOrEqual(0); + expect(startupWait).toBeGreaterThan(startupStart); + expect(startupRemove).toBeGreaterThan(startupWait); + yield* runtime.stop(key); + }), + ); + + it.live("blocks the main workload and removes the init container on migration failure", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + waitExitCode: 17, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(state.createdSpecs).toHaveLength(1); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); + expect(state.calls.filter((call) => call.startsWith("start:"))).toHaveLength(1); + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + }), + ); + + it.live("persists startup stdout and stderr before removing the init container", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const records: LogRecord[] = []; + const streamStates: Array = []; + const runtime = yield* makeContainerRuntime({ + engine: { + ...fakeContainerEngine(state), + streamLogs: (id) => { + const resource = state.resources.find((entry) => entry.id === id); + streamStates.push(resource?.state); + return resource?.state === "running" && + resource.labels.role === "workload" && + resource.labels.specHash.includes(":startup:") + ? Stream.fromIterable([ + { stream: "stdout", message: "migrated" } satisfies ContainerLogLine, + { stream: "stderr", message: "notice" } satisfies ContainerLogLine, + ]) + : Stream.empty; + }, + }, + ownerSessionId: "owner-session", + logStore: memoryLogStore(records), + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + yield* runtime.start(key, workload()); + expect(streamStates).toEqual(["running", "running"]); + expect(records.map((record) => [record.stream, record.message])).toEqual([ + ["stdout", "migrated"], + ["stderr", "notice"], + ]); + }), + ); + + it.live("cleans an interrupted init container", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const waitEntered = yield* Deferred.make(); + const gate = yield* Deferred.make(); + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + waitContainer: () => + Effect.gen(function* () { + yield* Deferred.succeed(waitEntered, undefined); + return yield* Deferred.await(gate).pipe(Effect.as(0)); + }), + }, + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const running = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(waitEntered); + yield* Fiber.interrupt(running); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); + expect(state.createdSpecs).toHaveLength(1); + }), + ); + + it.live("does not rerun migration for an adopted exact main container", () => + Effect.gen(function* () { + const existing: ContainerResource = { + id: "existing-main", + name: "supabase-aaaaaaaaaaaaaaaa-7-database_database-workload", + kind: "workload", + state: "running", + labels: { + stackId, + ownerSessionId: "owner-session", + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash: key.specHash, + role: "workload", + }, + }; + const state: FakeContainerState = { + resources: [existing], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + expect(state.createdSpecs).toEqual([]); + expect(state.calls.some((call) => call.startsWith("wait:"))).toBe(false); + }), + ); + + it.live("removes an interrupted startup container before retrying the workload", () => + Effect.gen(function* () { + const stale: ContainerResource = { + id: "stale-startup", + name: `supabase-${stackId.slice(0, 16)}-${key.desiredGeneration}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`, + kind: "workload", + state: "stopped", + labels: { + stackId, + ownerSessionId: "owner-session", + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash: `${key.specHash}:startup:0`, + role: "workload", + }, + }; + const state: FakeContainerState = { + resources: [stale], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + expect(state.calls).toContain("remove:stale-startup"); + expect(state.createdSpecs).toHaveLength(2); + }), + ); + it.live("rejects native artifacts before invoking the engine", () => Effect.gen(function* () { let called = false; diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 6ceeb4937a..d4462e7a76 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -207,6 +207,7 @@ const ownerInputContainerEngine = (createdSpecs: ContainerContainerSpec[]): Cont Effect.sync(() => { updateState(id, "running"); }), + waitContainer: () => Effect.succeed(0), stopContainer: (id) => Effect.sync(() => updateState(id, "stopped")), removeContainer: (id) => Effect.sync(() => { diff --git a/packages/stack/src/runtime/runtime-recovery.integration.test.ts b/packages/stack/src/runtime/runtime-recovery.integration.test.ts index 037c0bf5ce..44015969a8 100644 --- a/packages/stack/src/runtime/runtime-recovery.integration.test.ts +++ b/packages/stack/src/runtime/runtime-recovery.integration.test.ts @@ -152,6 +152,7 @@ const makeEngine = (state: EngineState): ContainerEngine => { entry.id === id ? { ...entry, state: "running" } : entry, ); }), + waitContainer: () => Effect.succeed(0), stopContainer: (id) => Effect.sync(() => { state.calls.push(`stop:${id}`); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 0ce94138c0..bc6a5fef93 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -220,7 +220,7 @@ describe("workload runtime catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it("resolves service-owned startup processes and slim container command contracts", () => { + it("resolves service-owned startup processes and container init contracts", () => { const root = "/tmp/slim artifact"; const auth = planned("auth:auth"); expect(runtimeSpecFor(auth)?.nativeStartupProcesses(root, state, auth, 9999)).toEqual([ @@ -285,15 +285,13 @@ describe("workload runtime catalog", () => { ); expect(containerTenantEnv?.SUPABASE_POOLER_TENANT_PATH).toBe("/app/pooler_tenant.exs"); - expect(containerResolutionFor(state, auth)?.command).toEqual([ - "/bin/sh", - "-c", - "gotrue migrate && exec gotrue", + expect(containerResolutionFor(state, auth)?.command).toEqual([]); + expect(containerResolutionFor(state, auth)?.startup).toEqual([ + { entrypoint: "/usr/local/bin/auth", command: ["migrate"] }, ]); - expect(containerResolutionFor(state, storage)?.command).toEqual([ - "/usr/bin/sh", - "-c", - "node dist/scripts/migrate-call.js && exec node dist/start/server.js", + expect(containerResolutionFor(state, storage)?.command).toEqual([]); + expect(containerResolutionFor(state, storage)?.startup).toEqual([ + { entrypoint: "/node/bin/node", command: ["dist/scripts/migrate-call.js"] }, ]); expect(containerResolutionFor(state, realtime)?.command).toEqual([]); expect(containerResolutionFor(state, analytics)?.command).toEqual([]); From cc35248bb3ac2e52e9d87839de6af9d39c88df69 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 17:24:42 +0200 Subject: [PATCH 093/174] fix(stack): isolate container startup processes --- ...26-08-28-stack-runtime-rewrite-progress.md | 15 ++ .../stack/src/runtime/ContainerRuntime.ts | 36 +++- .../container-runtime.integration.test.ts | 192 ++++++++++++++++++ 3 files changed, 232 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 6ca6ea1f11..930f68c427 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1083,3 +1083,18 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g removes the exact init container on success, failure, or interruption, and starts the main container only after exit 0. Adopted exact main containers do not rerun migration; stale init names are removed as collisions before retry. - TDD coverage first reproduced ignored entrypoints, absent wait decoding, and missing startup ordering, then passed serializer, wait, Auth/Storage resolution, ordering, nonzero, interruption, adoption, and stale-collision scenarios. + +#### Task 17 Fable correction — migration observability and failure authority (2026-08-31) + +- Accepted the independently reproduced findings that synthetic startup identities leaked through runtime observation, + init containers inherited the main service's inbound network aliases, and a log-persistence failure could mask a + simultaneous nonzero migration exit. Observation now excludes only the deterministic startup hash suffix, init + containers retain their env file but no inbound aliases or publications, and combined failures keep the process exit + as the first cause without weakening fatal log persistence. +- Added public-runtime regressions for in-flight observation, env/alias/publication separation, sequential startup + entries, crash-orphan recovery, and combined log/exit failures. A proposed bounded log-follower timeout was rejected: + the concrete engine follower is an owned subprocess, interruption already closes it, and no hang was reproduced; a + timeout or polling loop would add policy without evidence. +- Deferred no correctness work: the pinned Auth/Storage image-default and migration-entrypoint contract is owned by the + shared Docker whole-stack E2E. Suggested resolution-type, init-name, and engine-codec refactors were rejected as + cleanup that would not simplify the two distinct boundaries or improve the demonstrated lifecycle. diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 0ad73211ee..6ce051fac2 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -221,6 +221,10 @@ const isManagedContainer = ( ): entry is ContainerResource & { readonly labels: ContainerWorkloadLabels } => entry.kind === "workload" && entry.labels.role === "workload"; +/** Startup containers use the deterministic suffix emitted by startupLabelsFor. */ +const isStartupContainer = (entry: ContainerResource): boolean => + isManagedContainer(entry) && /^.+:startup:(?:0|[1-9]\d*)$/.test(entry.labels.specHash); + const isNetworkResource = ( entry: ContainerResource, ): entry is ContainerResource & { readonly labels: ContainerNetworkLabels } => @@ -314,6 +318,7 @@ export const makeContainerRuntime = ( entries .filter(isWorkloadResource) .filter((entry) => entry.labels.stackId === stackId) + .filter((entry) => !isStartupContainer(entry)) .map((entry) => { const key: RuntimeWorkloadKey = { stackId: entry.labels.stackId, @@ -366,9 +371,6 @@ export const makeContainerRuntime = ( ...(context.resolution.envFile === undefined ? {} : { envFile: context.resolution.envFile }), - ...(context.resolution.networkAliases === undefined - ? {} - : { networkAliases: context.resolution.networkAliases }), ...(context.resolution.hostRoute === undefined ? {} : { hostRoute: context.resolution.hostRoute }), @@ -395,14 +397,26 @@ export const makeContainerRuntime = ( logFiber = yield* Effect.forkIn(consume, runtimeScope); } const exitCode = yield* withEngine(key, options.engine.waitContainer(container.id)); - if (logFiber !== undefined) yield* Fiber.join(logFiber); - if (exitCode !== 0) - return yield* toDriverError( - key, - new Error( - `Container startup process exited with code ${String(exitCode)} for ${key.workloadId}`, - ), - ); + const logs = + logFiber === undefined + ? Exit.succeed(undefined) + : yield* Effect.exit(Fiber.join(logFiber)); + const exitFailure = + exitCode === 0 + ? undefined + : toDriverError( + key, + new Error( + `Container startup process exited with code ${String(exitCode)} for ${key.workloadId}`, + ), + ); + if (exitFailure !== undefined) { + const exitCause = Cause.fail(exitFailure); + return yield* Exit.isFailure(logs) + ? Effect.failCause(Cause.combine(exitCause, logs.cause)) + : Effect.failCause(exitCause); + } + if (Exit.isFailure(logs)) return yield* Effect.failCause(logs.cause); }); const release = ( container: ContainerResource, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index d5a0625ae1..0c2282e375 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -473,6 +473,8 @@ describe("container runtime", () => { ownerSessionId: "owner-session", resolveWorkload: () => Effect.succeed({ + envFile: "/tmp/auth.env", + networkAliases: ["auth"], startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], }), }); @@ -482,9 +484,13 @@ describe("container runtime", () => { const [startup, main] = state.createdSpecs; expect(startup?.entrypoint).toBe("/usr/local/bin/auth"); expect(startup?.command).toEqual(["migrate"]); + expect(startup?.envFile).toBe("/tmp/auth.env"); + expect(startup?.networkAliases).toBeUndefined(); expect(startup?.publications).toEqual([]); expect(main?.entrypoint).toBeUndefined(); expect(main?.command).toBeUndefined(); + expect(main?.envFile).toBe("/tmp/auth.env"); + expect(main?.networkAliases).toEqual(["auth"]); const startupStart = state.calls.findIndex((call) => call.startsWith("start:")); const startupWait = state.calls.findIndex((call) => call.startsWith("wait:")); const startupRemove = state.calls.findIndex((call) => call.startsWith("remove:")); @@ -495,6 +501,92 @@ describe("container runtime", () => { }), ); + it.live("hides in-flight startup containers from observation", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const waitEntered = yield* Deferred.make(); + const gate = yield* Deferred.make(); + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + waitContainer: () => + Effect.gen(function* () { + yield* Deferred.succeed(waitEntered, undefined); + return yield* Deferred.await(gate); + }), + }, + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + envFile: "/tmp/auth.env", + networkAliases: ["auth"], + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const running = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(waitEntered); + expect(yield* runtime.observe(key.stackId)).toEqual([]); + yield* Deferred.succeed(gate, 0); + expect(yield* Fiber.join(running)).toEqual({ ...key, state: "ready" }); + expect(yield* runtime.observe(key.stackId)).toEqual([{ ...key, state: "ready" }]); + const [startup, main] = state.createdSpecs; + expect(startup?.envFile).toBe("/tmp/auth.env"); + expect(startup?.networkAliases).toBeUndefined(); + expect(startup?.publications).toEqual([]); + expect(main?.networkAliases).toEqual(["auth"]); + yield* runtime.stop(key); + }), + ); + + it.live("runs multiple startup processes sequentially before the main workload", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + startup: [ + { entrypoint: "/usr/local/bin/auth", command: ["first"] }, + { entrypoint: "/usr/local/bin/auth", command: ["second"] }, + ], + }), + }); + expect(yield* runtime.start(key, workload())).toEqual({ ...key, state: "ready" }); + expect(state.createdSpecs.map((spec) => spec.command)).toEqual([ + ["first"], + ["second"], + undefined, + ]); + const waits = state.calls.filter((call) => call.startsWith("wait:")); + const removes = state.calls.filter((call) => call.startsWith("remove:")); + expect(waits).toHaveLength(2); + expect(removes).toHaveLength(2); + const firstRemove = removes[0]; + const secondWait = waits[1]; + expect(firstRemove).toBeDefined(); + expect(secondWait).toBeDefined(); + if (firstRemove !== undefined && secondWait !== undefined) + expect(state.calls.indexOf(secondWait)).toBeGreaterThan(state.calls.indexOf(firstRemove)); + yield* runtime.stop(key); + }), + ); + it.live("blocks the main workload and removes the init container on migration failure", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -522,6 +614,53 @@ describe("container runtime", () => { }), ); + it.live("keeps a nonzero migration exit authoritative when log persistence also fails", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + waitExitCode: 17, + }; + const logError = new LogStoreError({ + path: "memory://container-logs-failure", + message: "disk full", + }); + const runtime = yield* makeContainerRuntime({ + engine: { + ...fakeContainerEngine(state), + streamLogs: () => + Stream.fromIterable([ + { stream: "stdout", message: "migration-output" } satisfies ContainerLogLine, + ]), + }, + ownerSessionId: "owner-session", + logStore: { + path: logError.path, + append: () => Effect.fail(logError), + read: () => Effect.succeed([]), + retained: () => Effect.succeed([]), + stream: () => Stream.empty, + }, + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const message = Cause.pretty(result.cause); + expect(message).toContain("code 17"); + expect(message).toContain("disk full"); + } + expect(state.createdSpecs).toHaveLength(1); + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + }), + ); + it.live("persists startup stdout and stderr before removing the init container", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -678,6 +817,59 @@ describe("container runtime", () => { }), ); + it.live("removes crash-orphaned startup containers during recovery", () => + Effect.gen(function* () { + const startup: ContainerResource = { + id: "orphaned-startup", + name: `supabase-${stackId.slice(0, 16)}-${key.desiredGeneration}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`, + kind: "workload", + state: "stopped", + labels: { + stackId, + ownerSessionId: "previous-owner", + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash: `${key.specHash}:startup:0`, + role: "workload", + }, + }; + const main: ContainerResource = { + id: "main-workload", + name: startup.name, + kind: "workload", + state: "running", + labels: { + stackId, + ownerSessionId: "previous-owner", + desiredGeneration: key.desiredGeneration, + workloadId: key.workloadId, + specHash: key.specHash, + role: "workload", + }, + }; + const state: FakeContainerState = { + resources: [startup, main], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "new-owner", + }); + const recovered = yield* runtime.recover({ + stackId, + desiredGeneration: key.desiredGeneration, + desiredLifecycle: "running", + plan: recoveryPlan([workload()]), + }); + expect(recovered).toEqual([{ ...key, state: "ready" }]); + expect(state.calls).toContain("remove:orphaned-startup"); + expect(state.resources.map((resource) => resource.id)).toEqual(["main-workload"]); + }), + ); + it.live("rejects native artifacts before invoking the engine", () => Effect.gen(function* () { let called = false; From 50fef8d9360e09cb903daa95352dc9a193b8c16a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Mon, 31 Aug 2026 17:34:13 +0200 Subject: [PATCH 094/174] test(stack): close container migration review --- ...26-08-28-stack-runtime-rewrite-progress.md | 4 ++ .../stack/src/runtime/ContainerRuntime.ts | 1 + .../container-runtime.integration.test.ts | 64 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 930f68c427..6af838b40a 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1098,3 +1098,7 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g - Deferred no correctness work: the pinned Auth/Storage image-default and migration-entrypoint contract is owned by the shared Docker whole-stack E2E. Suggested resolution-type, init-name, and engine-codec refactors were rejected as cleanup that would not simplify the two distinct boundaries or improve the demonstrated lifecycle. +- The second Fable pass confirmed the three corrections and production wiring, then closed the remaining cheap + evidence gaps: exit-zero plus failed log persistence still blocks main creation, both Docker and Podman execute the + strict wait decoder, and the deliberate init/main name reuse is documented beside the invariant. The milestone is + converged conditional only on the shared Docker E2E exercising the pinned images. diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 6ce051fac2..c3daa7b798 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -357,6 +357,7 @@ export const makeContainerRuntime = ( ): Effect.Effect => { const labels = startupLabelsFor(key, options.ownerSessionId, index); const specification: ContainerContainerSpec = { + // Reuse the main name so crash-orphaned init containers are exact collisions to clean up. name: nameFor(key, "workload"), image: context.artifact.image, labels, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 0c2282e375..e8343a0fab 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -350,6 +350,14 @@ describe("container runtime", () => { platform: { os: "linux", desktop: false }, }); expect(yield* docker.waitContainer("container-id")).toBe(17); + const podman = makePodmanEngine({ + runner: makeControlledCommandRunner({ + run: (request) => + Effect.succeed(commandResult(request.args[0] === "wait" ? "17\n" : "27.0.0\n")), + }), + platform: { os: "linux", rootless: true }, + }); + expect(yield* podman.waitContainer("container-id")).toBe(17); const malformed = makeDockerEngine({ runner: makeControlledCommandRunner({ run: () => Effect.succeed(commandResult("17\n18\n")), @@ -363,6 +371,19 @@ describe("container runtime", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) expect(error.value).toBeInstanceOf(ContainerEngineProtocolError); } + const malformedPodman = makePodmanEngine({ + runner: makeControlledCommandRunner({ + run: () => Effect.succeed(commandResult("17\n18\n")), + }), + platform: { os: "linux", rootless: true }, + }); + const podmanResult = yield* malformedPodman.waitContainer("container-id").pipe(Effect.exit); + expect(Exit.isFailure(podmanResult)).toBe(true); + if (Exit.isFailure(podmanResult)) { + const error = Cause.findErrorOption(podmanResult.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBeInstanceOf(ContainerEngineProtocolError); + } }), ); @@ -661,6 +682,49 @@ describe("container runtime", () => { }), ); + it.live("blocks the main workload when startup log persistence fails after exit zero", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const logError = new LogStoreError({ + path: "memory://container-logs-failure", + message: "disk full", + }); + const runtime = yield* makeContainerRuntime({ + engine: { + ...fakeContainerEngine(state), + streamLogs: () => + Stream.fromIterable([ + { stream: "stdout", message: "migration-output" } satisfies ContainerLogLine, + ]), + }, + ownerSessionId: "owner-session", + logStore: { + path: logError.path, + append: () => Effect.fail(logError), + read: () => Effect.succeed([]), + retained: () => Effect.succeed([]), + stream: () => Stream.empty, + }, + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(state.createdSpecs).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("start:"))).toHaveLength(1); + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + if (Exit.isFailure(result)) expect(Cause.pretty(result.cause)).toContain("disk full"); + }), + ); + it.live("persists startup stdout and stderr before removing the init container", () => Effect.gen(function* () { const state: FakeContainerState = { From b668163d1c4a688c0ae24e5da99ab6deac17737a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 06:19:11 +0200 Subject: [PATCH 095/174] feat(stack): implement managed runtime parity --- packages/stack/package.json | 2 + packages/stack/src/control/ControlServer.ts | 42 +- .../control-transport.integration.test.ts | 31 +- .../stack/src/entrypoints/supervisor-node.ts | 18 +- .../stack/src/functions/FunctionsBootstrap.ts | 10 +- .../functions-bootstrap.integration.test.ts | 40 +- .../serve-main-resolver.integration.test.ts | 54 ++ .../src/functions/serve-main-resolver.ts | 7 +- packages/stack/src/gateway/RouteCatalog.ts | 15 +- .../gateway/route-catalog.integration.test.ts | 9 + packages/stack/src/model/Compiler.ts | 82 +- packages/stack/src/model/WorkloadCatalog.ts | 5 +- .../stack/src/model/capabilities/analytics.ts | 22 +- .../stack/src/model/capabilities/database.ts | 57 +- .../stack/src/model/capabilities/index.ts | 2 +- .../src/model/catalog.integration.test.ts | 26 +- .../src/model/compiler.integration.test.ts | 97 +++ .../stack/src/preparation/RuntimeArtifacts.ts | 9 +- .../src/preparation/SlimServicesSource.ts | 56 +- .../runtime-artifacts.integration.test.ts | 9 + .../slim-services.integration.test.ts | 73 +- packages/stack/src/public/EffectStack.ts | 74 +- packages/stack/src/public/Errors.ts | 3 + packages/stack/src/public/PromiseStack.ts | 9 +- packages/stack/src/public/Testing.ts | 70 +- .../public/effect-stack.integration.test.ts | 137 ++++ packages/stack/src/public/index.ts | 12 +- .../src/public/testing.integration.test.ts | 146 +++- .../stack/src/public/whole-stack.e2e.test.ts | 557 +++++++++++++ .../stack/src/runtime/ContainerRuntime.ts | 42 +- packages/stack/src/runtime/NativeProcess.ts | 39 +- packages/stack/src/runtime/NativeRuntime.ts | 159 +++- .../src/runtime/PostgresDatabaseSession.ts | 63 +- .../stack/src/runtime/ProductionRuntime.ts | 271 ++++-- packages/stack/src/runtime/ReadinessProbe.ts | 54 +- .../stack/src/runtime/RuntimeInputOwner.ts | 217 ++++- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 117 ++- .../container-runtime.integration.test.ts | 82 ++ packages/stack/src/runtime/native-launcher.ts | 109 ++- .../native-runtime.integration.test.ts | 769 +++++++++++++++--- ...tgres-database-session.integration.test.ts | 78 +- .../production-runtime.integration.test.ts | 488 ++++++++++- .../readiness-probe.integration.test.ts | 90 +- .../runtime-input-owner.integration.test.ts | 59 +- .../workload-runtime.integration.test.ts | 213 ++++- packages/stack/src/state/Ownership.ts | 2 + packages/stack/src/state/SecretStore.ts | 3 + .../stack/src/state/ports.integration.test.ts | 92 +-- .../src/state/secrets.integration.test.ts | 19 + packages/stack/src/supervisor/Ingress.ts | 4 +- packages/stack/src/supervisor/Launcher.ts | 4 + packages/stack/src/supervisor/Reconciler.ts | 33 +- packages/stack/src/supervisor/Supervisor.ts | 37 +- .../supervisor/handles.integration.test.ts | 221 ++++- .../supervisor/ingress.integration.test.ts | 97 +++ .../supervisor/supervisor.integration.test.ts | 75 +- packages/stack/vitest.config.ts | 6 + 57 files changed, 4602 insertions(+), 515 deletions(-) create mode 100644 packages/stack/src/public/effect-stack.integration.test.ts create mode 100644 packages/stack/src/public/whole-stack.e2e.test.ts diff --git a/packages/stack/package.json b/packages/stack/package.json index eb4e967d93..ee809da7ef 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -14,6 +14,8 @@ "test:unit:run": "bun --bun vitest run --project unit", "test:integration": "pnpm exec turbo run @supabase/stack#test:integration:run --", "test:integration:run": "bun --bun vitest run --project integration", + "test:e2e": "pnpm run test:e2e:run", + "test:e2e:run": "bun --bun vitest run --project e2e", "types:check": "tsc --noEmit" }, "dependencies": { diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index a91b360358..2255ad9192 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -452,6 +452,10 @@ export const startControlServer = ( const destroyRequests = new Set(); const destroyDisconnects = new Set(); const disconnects = yield* Queue.unbounded(); + const startDestroyCompletion = (callback: () => Effect.Effect) => + Effect.uninterruptible( + FiberSet.run(completionFibers, callback(), { startImmediately: true }).pipe(Effect.asVoid), + ); yield* FiberSet.run( completionFibers, Effect.forever( @@ -498,9 +502,9 @@ export const startControlServer = ( destroyRequests.delete(key); if (successful) { if (wroteResponse && options.onDestroyResponse !== undefined) - yield* options.onDestroyResponse(); + yield* startDestroyCompletion(options.onDestroyResponse); if (!wroteResponse && options.onDestroyAbandoned !== undefined) - yield* options.onDestroyAbandoned(); + yield* startDestroyCompletion(options.onDestroyAbandoned); } if (![...destroyRequests].some((request) => request.startsWith(`${clientId}:`))) { if (destroyDisconnects.delete(clientId)) yield* Queue.offer(disconnects, clientId); @@ -627,6 +631,8 @@ export interface ControlClient { Socket.SocketError | MaintenanceProtocolError, Scope.Scope >; + /** Connects with an RPC preface and completes when the owner closes the socket. */ + readonly awaitClose: (onOpen?: Effect.Effect) => Effect.Effect; readonly rpc: Effect.Effect; } @@ -725,6 +731,38 @@ export const makeControlClient = ( probe: () => maintenance("probe"), stop: () => maintenance("stop"), quiesce: () => maintenance("quiesce"), + awaitClose: (onOpen = Effect.void) => + Effect.scoped( + Effect.gen(function* () { + const socket = yield* NodeSocket.makeNet({ + path: endpointPath(endpoint), + openTimeout: MAINTENANCE_REQUEST_DEADLINE_MS, + }); + const write = yield* socket.writer; + const prefaceFailure = yield* Deferred.make(); + const read = socket.runRaw(() => Effect.void, { + onOpen: write( + encodePreface({ + kind: "rpc", + release: options.rpcRelease ?? STACK_RPC_RELEASE, + stackId: options.stackId, + ownerSessionId: options.ownerSessionId, + }), + ).pipe( + Effect.matchEffect({ + onFailure: (error) => Deferred.fail(prefaceFailure, error).pipe(Effect.asVoid), + onSuccess: () => onOpen, + }), + ), + }); + return yield* Effect.raceFirst(read, Deferred.await(prefaceFailure)).pipe( + Effect.catchFilter( + Socket.SocketCloseError.filterClean((code) => code === 1000), + () => Effect.void, + ), + ); + }), + ), rpc: Effect.gen(function* () { const socket = yield* NodeSocket.makeNet({ path: endpointPath(endpoint), diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index f9fef494ac..3f7602c557 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -306,7 +306,15 @@ describe("control transport", () => { it.live("counts a successful destroy send before an immediate disconnect", () => withServer( - ({ endpoint, stackId, ownerSessionId, completion, abandoned }) => + ({ + endpoint, + stackId, + ownerSessionId, + completionStarted, + completionRelease, + completion, + abandoned, + }) => Effect.gen(function* () { const preface = encodePreface({ kind: "rpc", @@ -319,6 +327,18 @@ describe("control transport", () => { concatBytes(preface, frame), ]); expect(response).toMatchObject({ _tag: "Exit", requestId: "1" }); + yield* Deferred.await(completionStarted).pipe( + Effect.timeoutOrElse({ + duration: 1_000, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ + message: "destroy completion did not survive the response disconnect", + }), + ), + }), + ); + yield* Deferred.succeed(completionRelease, undefined); yield* Deferred.await(completion).pipe( Effect.timeoutOrElse({ duration: 1_000, @@ -334,8 +354,13 @@ describe("control transport", () => { ); expect(Exit.isFailure(abandonedExit)).toBe(true); }), - ({ completion, abandoned }) => ({ - onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + ({ completionStarted, completionRelease, completion, abandoned }) => ({ + onDestroyResponse: () => + Deferred.succeed(completionStarted, undefined).pipe( + Effect.andThen(Deferred.await(completionRelease)), + Effect.andThen(Deferred.succeed(completion, undefined)), + Effect.asVoid, + ), onDestroyAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), }), ), diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index 2d8ce9e35a..72dbcebc2f 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -7,7 +7,6 @@ import * as NodeFs from "node:fs"; import { acquireOwnership, publishOwnership, - StackRuntimeEnvironment, type StackRuntimeEnvironmentValue, } from "../state/Ownership.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; @@ -29,8 +28,9 @@ const IdentitySchema = Schema.Struct({ stackName: Schema.String, }); -export const SupervisorArgsSchema = Schema.Struct({ +const SupervisorArgsSchema = Schema.Struct({ stateRoot: Schema.String, + artifactCacheRoot: Schema.optional(Schema.String), tempRoot: Schema.String, platform: Schema.Literals(["posix", "windows"] as const), stackId: StackIdSchema, @@ -39,7 +39,7 @@ export const SupervisorArgsSchema = Schema.Struct({ identity: IdentitySchema, }); -export const ReadySchema = Schema.Union([ +const ReadySchema = Schema.Union([ Schema.Struct({ ok: Schema.Literal(true), stackId: StackIdSchema, @@ -101,11 +101,14 @@ const writeReadiness = ( }); }); -export const runSupervisor = (args: SupervisorArgs) => +const runSupervisor = (args: SupervisorArgs) => Effect.scoped( Effect.gen(function* () { const environment: StackRuntimeEnvironmentValue = { stateRoot: args.stateRoot, + ...(args.artifactCacheRoot === undefined + ? {} + : { artifactCacheRoot: args.artifactCacheRoot }), tempRoot: args.tempRoot, platform: args.platform, }; @@ -121,6 +124,9 @@ export const runSupervisor = (args: SupervisorArgs) => const context = yield* Effect.context(); const runtimeFactory = yield* makeProductionRuntimeFactory({ stateRoot: args.stateRoot, + ...(args.artifactCacheRoot === undefined + ? {} + : { artifactCacheRoot: args.artifactCacheRoot }), stackId: args.stackId, ownerSessionId, stateStore: store, @@ -156,7 +162,7 @@ export const runSupervisor = (args: SupervisorArgs) => }), ).pipe(Effect.provide(NodeServices.layer)); -export const parseSupervisorArgs = (argv: ReadonlyArray) => +const parseSupervisorArgs = (argv: ReadonlyArray) => Schema.decodeEffect(Schema.fromJsonString(SupervisorArgsSchema))(argv[0] ?? "{}"); export const runSupervisorProcess = (argv: ReadonlyArray): Promise => @@ -167,5 +173,3 @@ export const runSupervisorProcess = (argv: ReadonlyArray): Promise if (import.meta.main) { await runSupervisorProcess(process.argv.slice(2)); } - -export { StackRuntimeEnvironment }; diff --git a/packages/stack/src/functions/FunctionsBootstrap.ts b/packages/stack/src/functions/FunctionsBootstrap.ts index 5fa067609f..c713fa2080 100644 --- a/packages/stack/src/functions/FunctionsBootstrap.ts +++ b/packages/stack/src/functions/FunctionsBootstrap.ts @@ -54,14 +54,14 @@ export const makeFunctionsBootstrapOwner = ( if (input.content.includes("\u0000")) return Effect.fail(failure("Functions bootstrap contains an invalid character")); const generationRoot = path.join(root, String(input.generation)); - const target = path.join(generationRoot, "main.ts"); + const target = path.join(generationRoot, "index.ts"); return Effect.gen(function* () { const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => failure("Unable to allocate functions bootstrap file", { cause }), ), ); - const temporary = path.join(generationRoot, `.main.ts.${token}.tmp`); + const temporary = path.join(generationRoot, `.index.ts.${token}.tmp`); return yield* Effect.gen(function* () { yield* mapFs( generationRoot, @@ -95,7 +95,11 @@ export const makeFunctionsBootstrapOwner = ( "secure published functions bootstrap file", fs.chmod(target, 0o600), ); - return target; + return yield* mapFs( + target, + "resolve published functions bootstrap file", + fs.realPath(target), + ); }).pipe( Effect.ensuring( fs diff --git a/packages/stack/src/functions/functions-bootstrap.integration.test.ts b/packages/stack/src/functions/functions-bootstrap.integration.test.ts index 405f906460..73a3dee58e 100644 --- a/packages/stack/src/functions/functions-bootstrap.integration.test.ts +++ b/packages/stack/src/functions/functions-bootstrap.integration.test.ts @@ -15,7 +15,7 @@ describe("functions bootstrap owner", () => { const stackId = stackIdValue; const owner = yield* makeFunctionsBootstrapOwner({ stateRoot: root, stackId }); const target = yield* owner.write({ generation: 4, content: "export default 1" }); - expect(target).toContain(path.join(root, stackId, "runtime", "functions", "4", "main.ts")); + expect(target).toContain(path.join(root, stackId, "runtime", "functions", "4", "index.ts")); expect((yield* fs.stat(path.dirname(target))).mode! & 0o777).toBe(0o700); expect((yield* fs.stat(target)).mode! & 0o777).toBe(0o600); expect(yield* fs.readFileString(target)).toBe("export default 1"); @@ -27,4 +27,42 @@ describe("functions bootstrap owner", () => { expect(yield* fs.exists(path.join(root, stackId, "runtime", "functions"))).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.live("returns the canonical published path for a symlinked state root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-functions-bootstrap-alias-", + }); + const canonicalRoot = path.join(root, "canonical"); + const configuredRoot = path.join(root, "alias"); + yield* fs.makeDirectory(canonicalRoot); + yield* fs.symlink(canonicalRoot, configuredRoot); + const stackIdValue = "b".repeat(64); + if (!isStackId(stackIdValue)) throw new Error("invalid test stack id"); + const owner = yield* makeFunctionsBootstrapOwner({ + stateRoot: configuredRoot, + stackId: stackIdValue, + }); + + const target = yield* owner.write({ generation: 1, content: "export default 2" }); + const expected = path.join( + yield* fs.realPath(canonicalRoot), + stackIdValue, + "runtime", + "functions", + "1", + "index.ts", + ); + expect(target).toBe(expected); + expect(yield* fs.readFileString(target)).toBe("export default 2"); + expect((yield* fs.stat(target)).mode! & 0o777).toBe(0o600); + + yield* owner.cleanupAll; + expect(yield* fs.exists(path.join(canonicalRoot, stackIdValue, "runtime", "functions"))).toBe( + false, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts index 13de207a1f..d001d65af6 100644 --- a/packages/stack/src/functions/serve-main-resolver.integration.test.ts +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -96,6 +96,60 @@ describe("Edge Runtime request-time function resolver", () => { } }); + it("normalizes persisted empty entrypoint settings to index.ts", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-empty-entrypoint-")); + try { + const functionRoot = join(root, "hello"); + await mkdir(functionRoot, { recursive: true }); + await writeFile(join(functionRoot, "index.ts"), "export default 1"); + await writeFile(join(functionRoot, "deno.json"), "{}"); + const canonicalRoot = await realpath(root); + + const config = await resolveFunctionConfig({ + root, + slug: "hello", + overrides: { + hello: { + enabled: true, + verify_jwt: false, + import_map: "", + entrypoint: "", + static_files: [], + env: {}, + }, + }, + fs: nodeFileSystem, + }); + + expect(config).toMatchObject({ + entrypointPath: join(canonicalRoot, "hello", "index.ts"), + importMapPath: join(canonicalRoot, "hello", "deno.json"), + verifyJWT: false, + }); + + await writeFile(join(functionRoot, "custom.ts"), "export default 2"); + const explicit = await resolveFunctionConfig({ + root, + slug: "hello", + overrides: { + hello: { + enabled: true, + verify_jwt: false, + import_map: "", + entrypointPath: "custom.ts", + entrypoint: "index.ts", + static_files: [], + env: {}, + }, + }, + fs: nodeFileSystem, + }); + expect(explicit?.entrypointPath).toBe(join(canonicalRoot, "hello", "custom.ts")); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("requires an absolute root and rejects traversal and symlink escapes", async () => { const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-paths-")); const outside = await mkdtemp(join(tmpdir(), "stack-functions-resolver-outside-")); diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index ec6052837a..da620d73d2 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -115,7 +115,12 @@ export const resolveFunctionConfig = async (options: { if (directoryInfo === undefined || !directoryInfo.isDirectory) return undefined; if (!(await safeRealPath(fs, canonicalRoot, functionDirectory))) return undefined; - const rawEntrypoint = override?.entrypointPath ?? override?.entrypoint ?? "index.ts"; + const rawEntrypoint = + override?.entrypointPath && override.entrypointPath.length > 0 + ? override.entrypointPath + : override?.entrypoint && override.entrypoint.length > 0 + ? override.entrypoint + : "index.ts"; const entrypointPath = relativePath(functionDirectory, rawEntrypoint); if (!(await safeRealPath(fs, canonicalRoot, entrypointPath))) return undefined; const entrypointInfo = await optionalInfo(fs, entrypointPath); diff --git a/packages/stack/src/gateway/RouteCatalog.ts b/packages/stack/src/gateway/RouteCatalog.ts index bca7b48c7f..b5b966c567 100644 --- a/packages/stack/src/gateway/RouteCatalog.ts +++ b/packages/stack/src/gateway/RouteCatalog.ts @@ -131,6 +131,19 @@ const realtimeWebsocketPath = return `${pathname}?${mapped}`; }; +const realtimeWebsocketHeaders = + (material: GatewayApiMaterial | undefined): GatewayHeaderTransform => + (request, headers) => { + const output: Record = { + ...(material === undefined + ? headers + : authorizationTransform(material, "authorization")(request, headers)), + }; + removeHeader(output, "host"); + output.host = "realtime-dev"; + return output; + }; + const prefixRoute = ( capability: CapabilityName, prefix: string, @@ -167,7 +180,7 @@ const realtimeWebsocketRoute = (material?: GatewayApiMaterial): GatewayRoute => (isUpgrade || pathname === "/realtime/v1/websocket") ); }, - material === undefined ? undefined : authorizationTransform(material, "authorization"), + realtimeWebsocketHeaders(material), ); const apiRoutes = ( diff --git a/packages/stack/src/gateway/route-catalog.integration.test.ts b/packages/stack/src/gateway/route-catalog.integration.test.ts index f52e98f25f..18685b9140 100644 --- a/packages/stack/src/gateway/route-catalog.integration.test.ts +++ b/packages/stack/src/gateway/route-catalog.integration.test.ts @@ -71,6 +71,15 @@ describe("public gateway route catalog", () => { expect(headersFor("/realtime/v1/api/tenants", { apikey: "sb_secret" }).authorization).toBe( "Bearer service-role-jwt", ); + const realtimeWebsocketHeaders = headersFor("/realtime/v1/websocket", { + Host: "127.0.0.1:40000", + apikey: "sb_publishable", + }); + expect(realtimeWebsocketHeaders).toMatchObject({ + host: "realtime-dev", + authorization: "Bearer anon-jwt", + }); + expect(realtimeWebsocketHeaders).not.toHaveProperty("Host"); expect( headersFor("/storage/v1/object/list", { apikey: "sb_publishable" }).authorization, ).toBe("Bearer anon-jwt"); diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index c416997339..5aadf5f500 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, Path, Redacted, Schema } from "effect"; +import { Crypto, Duration, Effect, Path, Redacted, Schema } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { StackConfigSchema, type StackConfig } from "../public/Config.ts"; import type { @@ -29,6 +29,7 @@ import { parseFileSize, StudioModule, AnalyticsModule, + parseGoDuration, } from "./capabilities/index.ts"; import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { CAPABILITY_MODULES, createExecutionPlan, type ExecutionPlan } from "./ExecutionPlan.ts"; @@ -269,6 +270,21 @@ const attachAuthSecretGenerators = ( } }; +const managedRandomBase64urlGenerators: Readonly> = { + "secret:analytics.settings.api_key": { kind: "random-base64url", bytes: 32 }, + "secret:pooler.settings.encryption_key": { kind: "random-base64url", bytes: 24 }, + "secret:pooler.settings.secret_key_base": { kind: "random-base64url", bytes: 48 }, +}; + +const attachManagedRandomSecretGenerators = (slots: SecretSlotInput[]): void => { + for (let index = 0; index < slots.length; index++) { + const entry = slots[index]; + if (entry === undefined || entry.policy !== "managed") continue; + const generator = managedRandomBase64urlGenerators[entry.slot]; + if (generator !== undefined) slots[index] = { ...entry, generator }; + } +}; + const setMaterializedPath = ( value: unknown, parts: ReadonlyArray, @@ -377,6 +393,66 @@ const validateStorageFileSizes = ( return Effect.void; }; +const validateDatabaseHealthTimeout = ( + config: StackConfig, +): Effect.Effect => { + const database = config.capabilities?.database; + if (database === undefined || !("settings" in database) || database.settings === undefined) + return Effect.void; + const value = database.settings.health_timeout; + if (value === undefined) return Effect.void; + return Effect.try({ + try: () => parseGoDuration(value), + catch: (cause) => + new InvalidStackConfigError({ + message: `Invalid database health_timeout: ${value}`, + setting: "capabilities.database.settings.health_timeout", + cause, + }), + }).pipe( + Effect.flatMap((duration) => + Duration.isNegative(duration) || Duration.isZero(duration) + ? Effect.fail( + new InvalidStackConfigError({ + message: `Invalid database health_timeout: ${value}; duration must be positive`, + setting: "capabilities.database.settings.health_timeout", + }), + ) + : Effect.void, + ), + ); +}; + +const validatePoolerKeys = (config: StackConfig): Effect.Effect => { + const pooler = config.capabilities?.pooler; + if (pooler === undefined || !("settings" in pooler) || pooler.settings === undefined) + return Effect.void; + const settings = pooler.settings; + const validate = ( + value: Redacted.Redacted | undefined, + expectedLength: number, + field: string, + ): Effect.Effect => { + if (value === undefined) return Effect.void; + const text = Redacted.value(value); + if ( + text.length !== expectedLength || + [...text].some((character) => character < "!" || character > "~") + ) + return Effect.fail( + new InvalidStackConfigError({ + message: `Invalid pooler ${field}: expected ${expectedLength} printable ASCII characters`, + setting: `capabilities.pooler.settings.${field}`, + }), + ); + return Effect.void; + }; + return Effect.gen(function* () { + yield* validate(settings.encryption_key, 32, "encryption_key"); + yield* validate(settings.secret_key_base, 64, "secret_key_base"); + }); +}; + const validateFunctionKeys = (config: unknown): Effect.Effect => { const capabilities = isRecord(config) ? config.capabilities : undefined; const capability = isRecord(capabilities) ? capabilities.functions : undefined; @@ -691,6 +767,8 @@ export const compileStack = ( const crypto = yield* Crypto.Crypto; yield* validateFunctionKeys(input.config ?? {}); const config = yield* decodeConfig(input.config ?? {}); + yield* validateDatabaseHealthTimeout(config); + yield* validatePoolerKeys(config); yield* validateStorageFileSizes(config); const jwtSecret = yield* canonicalJwtSecret(config); const rawCapabilities = isRecord(config.capabilities) ? config.capabilities : {}; @@ -724,6 +802,7 @@ export const compileStack = ( for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); collectSuppliedSecrets(config, slots, jwtSecret); attachAuthSecretGenerators(slots, input.projectRoot, config.security?.jwt?.signing); + attachManagedRandomSecretGenerators(slots); const executionPlan = yield* planForDefinition(input.runtime, previous.definition, crypto); return { definition: previous.definition, @@ -889,6 +968,7 @@ export const compileStack = ( const rawJwt = config.security?.jwt; ensureCanonicalJwtSlot(slots, jwtSecret); attachAuthSecretGenerators(slots, input.projectRoot, rawJwt?.signing); + attachManagedRandomSecretGenerators(slots); const security = { jwt: { issuer: rawJwt?.issuer ?? null, diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 9dfecf2022..8b00469d3d 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -81,7 +81,6 @@ export const WORKLOAD_CATALOG: Readonly> = "share/supabase-cli/bin/supabase-postgres-init.sh", "share/supabase-cli/config/pgsodium_getkey.sh", "share/supabase-cli/migrations", - "share/supabase-cli/init-scripts", "lib", ], ["17.6.1.166"], @@ -143,8 +142,8 @@ export const WORKLOAD_CATALOG: Readonly> = "edge-runtime", "v1.74.3", "ghcr.io/supabase/cli/edge-runtime:v1.74.3", - "bin/.edge-runtime-wrapped", - ["bin/.edge-runtime-wrapped"], + "bin/edge-runtime", + ["bin/edge-runtime"], ["v1.74.3"], { containerAlias: "supabase-functions" }, ), diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index eb6b553185..3431a3df6f 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -30,39 +30,25 @@ export const AnalyticsModule: CapabilityModule = { releases: { "v1.50.6": release("v1.50.6", [ workload("analytics", "analytics", "v1.50.6", "ghcr.io/supabase/cli/analytics:v1.50.6", { - dependencies: ["database:database", "analytics:vector"], + dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), workload("vector", "analytics", "0.53.0-alpine", "ghcr.io/supabase/cli/vector:0.53.0", { - dependencies: [], + dependencies: ["analytics:analytics"], readiness: { mode: "tcp" }, }), ]), }, routes: [{ listener: "api", protocol: "http" }], secretPolicy: () => "passthrough", - managedSecretSlots: [], + managedSecretSlots: ["analytics.settings.api_key"], selectWorkloads: (settings, workloads) => { const value = typeof settings === "object" && settings !== null && !Array.isArray(settings) ? Object.fromEntries(Object.entries(settings)).vector_port : undefined; const enabled = typeof value === "number"; - return workloads - .filter((entry) => enabled || entry.name !== "vector") - .map((entry) => - entry.name === "analytics" - ? { - ...entry, - dependencies: enabled - ? [ - ...entry.dependencies.filter((dependency) => dependency !== "analytics:vector"), - "analytics:vector", - ] - : entry.dependencies.filter((dependency) => dependency !== "analytics:vector"), - } - : entry, - ); + return workloads.filter((entry) => enabled || entry.name !== "vector"); }, materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index 2bb7c80b24..062325101d 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Duration, Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const settingsFields = { @@ -46,8 +46,61 @@ export const DatabaseSettingsSchema = Schema.Struct({ }); export type DatabaseSettings = Schema.Schema.Type; +export const DEFAULT_DATABASE_HEALTH_TIMEOUT = "2m"; + +const MAX_GO_DURATION_NS = 9_223_372_036_854_775_807n; +const GO_DURATION_UNITS = { + ns: 1n, + us: 1_000n, + µs: 1_000n, + μs: 1_000n, + ms: 1_000_000n, + s: 1_000_000_000n, + m: 60_000_000_000n, + h: 3_600_000_000_000n, +} as const; + +/** Parses the Go duration syntax used by persisted database configuration. */ +export const parseGoDuration = (value: string): Duration.Duration => { + const original = value; + let input = value; + const negative = input.startsWith("-"); + if (input.startsWith("-") || input.startsWith("+")) input = input.slice(1); + if (input === "0") return Duration.zero; + if (input.length === 0) throw new Error(`invalid duration "${original}"`); + + let total = 0n; + while (input.length > 0) { + const number = /^(?:\d+(?:\.\d*)?|\.\d+)/u.exec(input); + if (number === null) throw new Error(`invalid duration "${original}"`); + const token = number[0]; + const dot = token.indexOf("."); + const whole = dot === -1 ? token : token.slice(0, dot); + const fraction = dot === -1 ? "" : token.slice(dot + 1); + input = input.slice(token.length); + + let unit: keyof typeof GO_DURATION_UNITS | undefined; + for (const candidate of ["ns", "us", "µs", "μs", "ms", "s", "m", "h"] as const) { + if (input.startsWith(candidate)) { + unit = candidate; + input = input.slice(candidate.length); + break; + } + } + if (unit === undefined) throw new Error(`invalid duration "${original}"`); + const unitNs = GO_DURATION_UNITS[unit]; + const wholeNs = BigInt(whole) * unitNs; + const fractionNs = + fraction.length === 0 ? 0n : (BigInt(fraction) * unitNs) / 10n ** BigInt(fraction.length); + total += wholeNs + fractionNs; + if (total > MAX_GO_DURATION_NS) throw new Error(`duration out of range "${original}"`); + } + if (negative) return Duration.nanos(-total); + return Duration.nanos(total); +}; + const defaults: DatabaseSettings = { - health_timeout: "2m", + health_timeout: DEFAULT_DATABASE_HEALTH_TIMEOUT, settings: { effective_cache_size: undefined, logical_decoding_work_mem: undefined, diff --git a/packages/stack/src/model/capabilities/index.ts b/packages/stack/src/model/capabilities/index.ts index 26f808428b..9c52193f21 100644 --- a/packages/stack/src/model/capabilities/index.ts +++ b/packages/stack/src/model/capabilities/index.ts @@ -1,5 +1,5 @@ export { AuthModule, AuthSettingsSchema } from "./auth.ts"; -export { DatabaseModule, DatabaseSettingsSchema } from "./database.ts"; +export { DatabaseModule, DatabaseSettingsSchema, parseGoDuration } from "./database.ts"; export { FunctionsModule, FunctionsSettingsSchema } from "./functions.ts"; export { MailModule, MailSettingsSchema } from "./mail.ts"; export { PoolerModule, PoolerSettingsSchema } from "./pooler.ts"; diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index 10f688c1c7..ee179924b8 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -66,7 +66,7 @@ describe("complete workload catalog", () => { "realtime:realtime": "bin/server", "storage:storage": "app/dist/start/server.js", "storage:imgproxy": "bin/imgproxy", - "functions:edge-runtime": "bin/.edge-runtime-wrapped", + "functions:edge-runtime": "bin/edge-runtime", "studio:studio": "app/apps/studio/server.js", "studio:pgmeta": "app/dist/server/server.js", "mail:mail": "bin/mailpit", @@ -83,9 +83,15 @@ describe("complete workload catalog", () => { "bin/realtime", "bin/server", ]); - expect(WORKLOAD_CATALOG["database:database"]?.requiredRuntimePaths).toContain( - "share/supabase-cli/init-scripts", - ); + expect(WORKLOAD_CATALOG["database:database"]?.requiredRuntimePaths).toEqual([ + "bin/postgres", + "bin/pg_isready", + "bin/psql", + "share/supabase-cli/bin/supabase-postgres-init.sh", + "share/supabase-cli/config/pgsodium_getkey.sh", + "share/supabase-cli/migrations", + "lib", + ]); expect(WORKLOAD_CATALOG["storage:storage"]?.requiredRuntimePaths).toEqual([ "node/bin/node", "app/dist/start/server.js", @@ -178,6 +184,18 @@ describe("complete workload catalog", () => { expect(enabled.executionPlan.workloads.some(({ id }) => id === "analytics:vector")).toBe( true, ); + expect(enabled.executionPlan.workloads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "analytics:analytics", + dependencies: ["database:database"], + }), + expect.objectContaining({ + id: "analytics:vector", + dependencies: ["analytics:analytics"], + }), + ]), + ); }), ); diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 9abdfc2b5c..9e594418b8 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { canonicalize, compileStack } from "./Compiler.ts"; import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; +import { DEFAULT_DATABASE_HEALTH_TIMEOUT } from "./capabilities/database.ts"; import { parseFileSize } from "./capabilities/storage.ts"; const layer = NodeServices.layer; @@ -47,6 +48,29 @@ describe("closed capability compiler", () => { }), ); + it.live("materializes the default database health timeout", () => + Effect.gen(function* () { + const result = yield* compile({}); + expect(result.definition.capabilities.database.settings.health_timeout).toBe( + DEFAULT_DATABASE_HEALTH_TIMEOUT, + ); + }), + ); + + it.live("rejects an invalid database health timeout during compilation", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { database: { settings: { health_timeout: "not-a-duration" } } }, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(failureOf(result)).toBeInstanceOf(InvalidStackConfigError); + const zero = yield* compile({ + capabilities: { database: { settings: { health_timeout: "0" } } }, + }).pipe(Effect.exit); + expect(failureOf(zero)).toBeInstanceOf(InvalidStackConfigError); + }), + ); + it.live("materializes a non-default rest setting", () => Effect.gen(function* () { const result = yield* compile({ @@ -336,6 +360,36 @@ describe("closed capability compiler", () => { }), ); + it.live("creates a managed Analytics access key when omitted", () => + Effect.gen(function* () { + const result = yield* compile({}); + expect(result.definition.capabilities.analytics.settings).toMatchObject({ + api_key: { slot: "secret:analytics.settings.api_key" }, + }); + expect(result.secrets).toContainEqual({ + slot: "secret:analytics.settings.api_key", + policy: "managed", + generator: { kind: "random-base64url", bytes: 32 }, + }); + }), + ); + + it.live("preserves a supplied Analytics access key as pass-through", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { analytics: { settings: { api_key: Redacted.make("custom-api-key") } } }, + }); + expect(result.secrets).toContainEqual({ + slot: "secret:analytics.settings.api_key", + policy: "passthrough", + value: Redacted.make("custom-api-key"), + }); + expect(result.definition.capabilities.analytics.settings.api_key).toEqual({ + slot: "secret:analytics.settings.api_key", + }); + }), + ); + it.live("materializes a non-default pooler setting", () => Effect.gen(function* () { const result = yield* compile({ @@ -348,6 +402,49 @@ describe("closed capability compiler", () => { }), ); + it.live("attaches artifact-compatible generators for managed pooler keys", () => + Effect.gen(function* () { + const result = yield* compile({ capabilities: { pooler: { enabled: true } } }); + expect(result.secrets).toContainEqual({ + slot: "secret:pooler.settings.encryption_key", + policy: "managed", + generator: { kind: "random-base64url", bytes: 24 }, + }); + expect(result.secrets).toContainEqual({ + slot: "secret:pooler.settings.secret_key_base", + policy: "managed", + generator: { kind: "random-base64url", bytes: 48 }, + }); + }), + ); + + it.live("rejects pooler key values that violate the artifact contract", () => + Effect.gen(function* () { + const invalidEncryption = yield* compile({ + capabilities: { + pooler: { + enabled: true, + settings: { encryption_key: Redacted.make("too-short") }, + }, + }, + }).pipe(Effect.exit); + expect(failureOf(invalidEncryption)).toBeInstanceOf(InvalidStackConfigError); + expect(failureOf(invalidEncryption)?.setting).toBe( + "capabilities.pooler.settings.encryption_key", + ); + + const invalidSecretBase = yield* compile({ + capabilities: { + pooler: { + enabled: true, + settings: { secret_key_base: Redacted.make("!".repeat(63)) }, + }, + }, + }).pipe(Effect.exit); + expect(failureOf(invalidSecretBase)).toBeInstanceOf(InvalidStackConfigError); + }), + ); + it.live("persists every capability and optional absence as null", () => Effect.gen(function* () { const result = yield* compile({}); diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 091c2e301e..608496ee9b 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -170,6 +170,8 @@ const nativeResult = ( */ export const makeProductionRuntimeArtifactPreparer = (options: { readonly stateRoot: string; + /** Optional cache root shared across disposable stack state roots. */ + readonly artifactCacheRoot?: string; readonly platform?: ContainerPlatform; readonly runtime?: StackRuntime; }): Effect.Effect< @@ -185,7 +187,12 @@ export const makeProductionRuntimeArtifactPreparer = (options: { if (options.stateRoot.trim().length === 0) return yield* error("Artifact cache state root must not be blank"); const path = yield* Path.Path; - const cacheRoot = path.join(path.resolve(options.stateRoot), "artifacts"); + if (options.artifactCacheRoot !== undefined && options.artifactCacheRoot.trim().length === 0) + return yield* error("Artifact cache root must not be blank"); + const cacheRoot = + options.artifactCacheRoot === undefined + ? path.join(path.resolve(options.stateRoot), "artifacts") + : path.resolve(options.artifactCacheRoot); const artifacts = new Map(); const store = options.runtime?.kind === "container" diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 35eca4d1c1..52c0843176 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -174,6 +174,11 @@ const unsafeArchivePath = (value: string): boolean => { return false; }; +const pathEscapesRoot = (path: Path.Path, root: string, candidate: string): boolean => { + const relative = path.relative(root, candidate); + return path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`); +}; + const archiveLinkEscapes = (member: string, target: string): boolean => { if (target.trim().startsWith("/") || /^[A-Za-z]:[\\/]/u.test(target.trim())) return true; const depth = @@ -206,8 +211,7 @@ const validateExtractedTree = ( for (const entry of entries) { const candidate = path.join(destination, entry); const resolved = yield* fs.realPath(candidate); - const relative = path.relative(root, resolved); - if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) + if (pathEscapesRoot(path, root, resolved)) return yield* new StackPreparationError({ message: "Slim-services archive entry escapes its staging directory", path: entry, @@ -225,10 +229,10 @@ const validateExtractedTree = ( ); /** - * Current Postgres slim archives keep init scripts below `migrations/`, while - * the shipped init entrypoint looks for the historical sibling directory. - * Publish one contained relative alias so the entrypoint and archive layout - * agree without copying or reimplementing the bootstrap revisions. + * Postgres slim archives keep init scripts below `migrations/`. Older archives + * may also contain a real historical sibling directory, which would make the + * native init script run every migration twice. Remove only that exact sibling + * path so the native archive has the same layout as the container image. */ export const normalizeSlimServicesLayout = ( artifact: NativeWorkloadArtifact, @@ -258,22 +262,17 @@ export const normalizeSlimServicesLayout = ( path: "share/supabase-cli/migrations/init-scripts", }); const aliasExists = yield* exists(alias); - if (!aliasExists) { - yield* fs.symlink("migrations/init-scripts", alias).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to normalize Postgres slim init-scripts layout", - cause, - }), - ), - ); - return; - } - const [realTarget, realAlias] = yield* Effect.all([ - fs.realPath(target), - fs.realPath(alias), - ]).pipe( + if (!aliasExists) return; + const root = yield* fs.realPath(destination).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to inspect Postgres slim artifact root", + cause, + }), + ), + ); + const realAlias = yield* fs.realPath(alias).pipe( Effect.mapError( (cause) => new StackPreparationError({ @@ -282,10 +281,19 @@ export const normalizeSlimServicesLayout = ( }), ), ); - if (realTarget !== realAlias) + if (pathEscapesRoot(path, root, realAlias)) return yield* new StackPreparationError({ - message: "Postgres slim init-scripts alias resolves outside its target", + message: "Postgres slim init-scripts alias resolves outside its artifact root", }); + yield* fs.remove(alias, { recursive: true, force: true }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to remove Postgres slim init-scripts alias", + cause, + }), + ), + ); }); }; diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 0c16b2a9a7..89c73ad6f9 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -88,6 +88,15 @@ describe("runtime artifact preparation", () => { runtime: { kind: "native" }, }); expect(native.containerEngine).toBeUndefined(); + const sharedRoot = path.join(root, "shared-artifacts"); + const isolatedStateRoot = path.join(root, "isolated-state"); + yield* makeProductionRuntimeArtifactPreparer({ + stateRoot: isolatedStateRoot, + artifactCacheRoot: sharedRoot, + runtime: { kind: "native" }, + }); + expect(yield* fs.exists(sharedRoot)).toBe(true); + expect(yield* fs.exists(path.join(isolatedStateRoot, "artifacts"))).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), ), ); diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 4379461b47..48d481b5f5 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Crypto, Deferred, Effect, Exit, Fiber, FileSystem, Option } from import { zstdCompress } from "node:zlib"; import { makeArtifactStore, type ArtifactRequest } from "./ArtifactStore.ts"; import { digestHex } from "./Integrity.ts"; +import { StackPreparationError } from "../public/Errors.ts"; import { makeSlimServicesSource, normalizeSlimServicesLayout, @@ -106,7 +107,7 @@ const requestUrl = (input: Parameters[0]): string => typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; describe("slim-services artifact source", () => { - it.live("normalizes the Postgres init-script path within the artifact root", () => + it.live("keeps Postgres init scripts only under the migrations directory", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -117,9 +118,73 @@ describe("slim-services artifact source", () => { yield* fs.makeDirectory(initScripts, { recursive: true }); const postgresArtifact = { ...artifact, service: "postgres" }; yield* normalizeSlimServicesLayout(postgresArtifact, destination); - expect(yield* fs.realPath(`${destination}/share/supabase-cli/init-scripts`)).toBe( - yield* fs.realPath(initScripts), - ); + expect(yield* fs.exists(`${destination}/share/supabase-cli/init-scripts`)).toBe(false); + expect(yield* fs.exists(initScripts)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("removes an extracted duplicate Postgres init-script tree", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-postgres-duplicate-", + }); + const target = `${destination}/share/supabase-cli/migrations/init-scripts`; + const alias = `${destination}/share/supabase-cli/init-scripts`; + yield* fs.makeDirectory(target, { recursive: true }); + yield* fs.makeDirectory(alias, { recursive: true }); + yield* fs.writeFileString(`${target}/migration.sql`, "target"); + yield* fs.writeFileString(`${alias}/migration.sql`, "duplicate"); + yield* normalizeSlimServicesLayout({ ...artifact, service: "postgres" }, destination); + expect(yield* fs.exists(alias)).toBe(false); + expect(yield* fs.readFileString(`${target}/migration.sql`)).toBe("target"); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("removes a contained Postgres init-scripts symlink without touching its target", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-postgres-symlink-", + }); + const target = `${destination}/share/supabase-cli/migrations/init-scripts`; + const alias = `${destination}/share/supabase-cli/init-scripts`; + yield* fs.makeDirectory(target, { recursive: true }); + yield* fs.writeFileString(`${target}/migration.sql`, "target"); + yield* fs.symlink("migrations/init-scripts", alias); + yield* normalizeSlimServicesLayout({ ...artifact, service: "postgres" }, destination); + expect(yield* fs.exists(alias)).toBe(false); + expect(yield* fs.readFileString(`${target}/migration.sql`)).toBe("target"); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("rejects a Postgres init-scripts symlink that escapes its artifact root", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-postgres-symlink-escape-", + }); + const outside = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-postgres-symlink-outside-", + }); + const target = `${destination}/share/supabase-cli/migrations/init-scripts`; + const alias = `${destination}/share/supabase-cli/init-scripts`; + yield* fs.makeDirectory(target, { recursive: true }); + yield* fs.makeDirectory(outside, { recursive: true }); + yield* fs.symlink(outside, alias); + const result = yield* normalizeSlimServicesLayout( + { ...artifact, service: "postgres" }, + destination, + ).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(errorOf(result)).toBeInstanceOf(StackPreparationError); + expect(yield* fs.exists(alias)).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), ), ); diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index d2c123be35..7313155330 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -1,4 +1,17 @@ -import { Crypto, Effect, Exit, FileSystem, Option, Path, Ref, Scope, Schema, Stream } from "effect"; +import { + Crypto, + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Option, + Path, + Ref, + Scope, + Schema, + Stream, +} from "effect"; import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import type { SocketError } from "effect/unstable/socket/Socket"; import type { StackIdentity } from "../identity/Identity.ts"; @@ -332,6 +345,9 @@ const startError = (error: ControlError): StackStartError => { mapped instanceof StackStateFormatUnsupportedError || mapped instanceof StackUpgradeRequiredError || mapped instanceof StackSecretMismatchError || + mapped instanceof StackPreparationError || + mapped instanceof ArtifactIntegrityError || + mapped instanceof ContainerPullError || mapped instanceof StackReconciliationError || mapped instanceof ServiceStartError || mapped instanceof ServiceReadinessError || @@ -366,7 +382,8 @@ const destroyError = (error: ControlError): DestroyStackError => { : new StackDestructionError({ message: "Stack destruction failed" }); }; -const makeHandle = ( +/** Internal control-transport seam used by public lifecycle integration tests. */ +export const makeHandle = ( id: StackId, metadata: { readonly endpoint: Parameters[0]; @@ -387,6 +404,57 @@ const makeHandle = ( call: (rpc: StackRpcClient) => Effect.Effect, mapError: (error: ControlError) => E, ) => check(Effect.scoped(client.rpc.pipe(Effect.flatMap(call), Effect.mapError(mapError)))); + const destroyAndAwaitOwner: Effect.Effect = check( + Effect.gen(function* () { + const ownerConnected = yield* Deferred.make(); + const ownerWatch = client.awaitClose( + Deferred.succeed(ownerConnected, undefined).pipe(Effect.asVoid), + ); + const ownerFiber = yield* Effect.forkChild(ownerWatch, { startImmediately: true }); + const ownerReady = Deferred.await(ownerConnected).pipe( + Effect.raceFirst( + Fiber.join(ownerFiber).pipe( + Effect.flatMap(() => + Effect.fail( + new StackDestructionError({ + message: "Unable to observe Supervisor control connection", + }), + ), + ), + Effect.mapError( + (cause) => + new StackDestructionError({ + message: "Unable to observe Supervisor control connection", + cause, + }), + ), + ), + ), + ); + const result = yield* Effect.exit( + ownerReady.pipe( + Effect.andThen(invoke((rpc) => rpc.destroy(undefined), destroyError)), + // The owner closes its control server only after all workload cleanup has completed. + // Await the exact preface-only socket instead of decoding a terminal RPC stream Exit. + Effect.andThen( + Fiber.join(ownerFiber).pipe( + Effect.mapError( + (cause) => + new StackDestructionError({ + message: "Unable to observe Supervisor shutdown completion", + cause, + }), + ), + ), + ), + ), + ); + if (Exit.isFailure(result)) { + yield* Fiber.interrupt(ownerFiber); + } + return yield* result; + }), + ); const status = () => invoke((rpc) => rpc.status(undefined), statusError); return { id, @@ -410,7 +478,7 @@ const makeHandle = ( ), ), ), - destroy: () => invoke((rpc) => rpc.destroy(undefined), destroyError), + destroy: () => destroyAndAwaitOwner, close: () => Ref.set(closed, true), watchStatus: () => Stream.unwrap( diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index bb9580ac74..5431911ab5 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -178,6 +178,9 @@ export type StackStartError = | StackStateFormatUnsupportedError | StackUpgradeRequiredError | StackSecretMismatchError + | StackPreparationError + | ArtifactIntegrityError + | ContainerPullError | StackReconciliationError | ServiceStartError | ServiceReadinessError diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index ae7abaf090..356654be82 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -4,6 +4,7 @@ import { Effect, Exit, FileSystem, + Layer, Option, Path, Redacted, @@ -32,6 +33,7 @@ import type { LogOptions, StackLogEntry } from "./Logs.ts"; import type { StackDescriptor, StackInspection, StackStatus } from "./Status.ts"; import type { StackId } from "./StackId.ts"; import type { PreparedCapability, PrepareStackResult } from "./EffectStack.ts"; +import { StackRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; // Promise methods are the deliberate outer boundary of this package. // oxlint-disable effecttsgo/async-function -- Promise facade methods must expose Promise/AsyncIterable APIs. @@ -243,7 +245,12 @@ const makeScope = () => Effect.runPromise(Scope.make()); export const makePromiseApi = ( platformLayer: PlatformLayer = NodeServices.layer, + runtimeEnvironment?: StackRuntimeEnvironmentValue, ): PromiseStackApi => { + const providedLayer = + runtimeEnvironment === undefined + ? platformLayer + : Layer.mergeAll(platformLayer, Layer.succeed(StackRuntimeEnvironment, runtimeEnvironment)); const run = async ( effect: Effect.Effect, scope?: Scope.Scope, @@ -252,7 +259,7 @@ export const makePromiseApi = ( const actualScope = scope ?? (await makeScope()); try { return await Effect.runPromise( - effect.pipe(Effect.provide(platformLayer), Effect.provideService(Scope.Scope, actualScope)), + effect.pipe(Effect.provide(providedLayer), Effect.provideService(Scope.Scope, actualScope)), ); } finally { if (ownedScope) await Effect.runPromise(Scope.close(actualScope, Exit.void)); diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index 5595dd74b4..83f7b4b62d 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -4,34 +4,56 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource builds an isolated root. import { join } from "node:path"; +import { NodeServices } from "@effect/platform-node"; +import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; import { - createStack, + makePromiseApi, type PromiseStack, type PromiseStackConfig, type PromiseStartStackOptions, } from "./PromiseStack.ts"; import type { CreateStackOptions } from "./EffectStack.ts"; +import type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; export interface CreateTestStackOptions { readonly config?: PromiseStackConfig; readonly name?: string; readonly runtime?: CreateStackOptions["runtime"]; + /** + * Populates the isolated project root before the managed stack is created. + * Setup failures remove the root and never create a stack handle. + */ + readonly setupProject?: (projectRoot: string) => Promise; } export type TestStack = PromiseStack & AsyncDisposable; export interface TestStackOperations { readonly createRoot: () => Promise; - readonly createStack: (options: CreateStackOptions) => Promise; + readonly createStack: ( + options: CreateStackOptions, + environment?: StackRuntimeEnvironmentValue, + ) => Promise; readonly removeRoot: (root: string) => Promise; } const defaultOperations: TestStackOperations = { createRoot: () => mkdtemp(join(tmpdir(), "supabase-stack-test-")), - createStack, + createStack: (options, environment) => + makePromiseApi(NodeServices.layer, environment).createStack(options), removeRoot: (root) => rm(root, { recursive: true, force: true }), }; +// Native slim-services artifacts are immutable and expensive to download. Keep one +// shared cache for test stacks while each stack's state/data roots remain disposable. +const testArtifactCacheRoot = join(tmpdir(), "supabase-stack-test-artifacts"); + +const isolatedEnvironment = (projectRoot: string): StackRuntimeEnvironmentValue => ({ + ...defaultRuntimeEnvironment(), + artifactCacheRoot: testArtifactCacheRoot, + stateRoot: join(projectRoot, ".supabase", "managed", "stacks"), +}); + const waitForReadiness = async ( stack: PromiseStack, initial: Awaited>, @@ -66,9 +88,37 @@ const waitForReadiness = async ( ([endpointName, endpoint]) => endpointName === name && endpoint !== undefined, ), ); + const terminalFailure = (status: typeof initial): Error | undefined => { + const failed = status.capabilities.find( + (capability) => + !disabledCapabilities.has(capability.name) && + (capability.state === "failed" || + (status.lifecycle === "running" && capability.state === "stopped")), + ); + if (failed !== undefined) { + return new Error( + failed.error === undefined + ? `Capability ${failed.name} ${failed.state} before stack became ready` + : failed.error, + ); + } + if ( + status.lifecycle === "stopped" || + status.lifecycle === "destroying" || + status.lifecycle === "unconfigured" || + status.lifecycle === "stopping" + ) { + return new Error(`Stack lifecycle ${status.lifecycle} before stack became ready`); + } + return undefined; + }; if (ready(initial)) return; + const initialFailure = terminalFailure(initial); + if (initialFailure !== undefined) throw initialFailure; for await (const status of stack.watchStatus()) { if (ready(status)) return; + const failure = terminalFailure(status); + if (failure !== undefined) throw failure; } throw new Error("Stack did not reach running readiness before its status stream ended"); }; @@ -108,11 +158,15 @@ export const createTestStackWith = async ( const projectRoot = await operations.createRoot(); let stack: PromiseStack | undefined; try { - stack = await operations.createStack({ - projectRoot, - name: options.name, - runtime: options.runtime, - }); + if (options.setupProject !== undefined) await options.setupProject(projectRoot); + stack = await operations.createStack( + { + projectRoot, + name: options.name, + runtime: options.runtime, + }, + isolatedEnvironment(projectRoot), + ); const started = await stack.start( options.config === undefined ? undefined diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts new file mode 100644 index 0000000000..ee09f40433 --- /dev/null +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -0,0 +1,137 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { + Cause, + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Option, + Path, + Redacted, + Scope, + Stream, +} from "effect"; +import { startControlServer } from "../control/ControlServer.ts"; +import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; +import { StackDestructionError } from "./Errors.ts"; +import { makeHandle } from "./EffectStack.ts"; +import * as effectApi from "../effect.ts"; +import { CAPABILITY_NAMES } from "./Capability.ts"; +import { StackIdSchema } from "./StackId.ts"; +import type { StackStatus } from "./Status.ts"; + +const stackId = StackIdSchema.make( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", +); + +const runningStatus: StackStatus = { + id: stackId, + lifecycle: "running", + desiredLifecycle: "running", + runtime: { kind: "native" }, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: name === "functions" ? "lazy" : "eager", + state: name === "pooler" ? "disabled" : "ready", + })), +}; + +const credentials = { + database: { url: Redacted.make("postgres://localhost"), password: Redacted.make("secret") }, + api: { + publishableKey: "publishable", + secretKey: Redacted.make("secret"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service"), + }, +}; + +describe("Effect stack lifecycle handoff", () => { + it("does not publish the internal handle through the Effect barrel", () => { + expect(Object.hasOwn(effectApi, "makeHandle")).toBe(false); + }); + + it.live("completes destroy after the owner closes its control socket", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "session"; + const responseSent = yield* Deferred.make(); + const ownerScope = yield* Scope.make(); + const rpcHandlers: StackRpcHandlers = { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => Effect.succeed(runningStatus), + restart: () => Effect.succeed(runningStatus), + destroy: () => Effect.void, + logs: () => Stream.empty, + watchStatus: () => Stream.concat(Stream.make(runningStatus), Stream.never), + }; + const maintenanceHandlers = { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + } as const), + stop: Effect.succeed({ ok: true, op: "stop" } as const), + quiesce: Effect.succeed({ ok: true, op: "quiesce" } as const), + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers, + maintenanceHandlers, + onDestroyResponse: () => Deferred.succeed(responseSent, undefined).pipe(Effect.asVoid), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeHandle(stackId, { + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + const destroyFiber = yield* Effect.forkChild(stack.destroy(), { startImmediately: true }); + yield* Deferred.await(responseSent); + yield* Scope.close(ownerScope, Exit.void); + const destroyed = yield* Fiber.join(destroyFiber).pipe(Effect.exit); + expect(Exit.isSuccess(destroyed)).toBe(true); + yield* stack.close(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("fails destroy when the owner control endpoint is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-" }); + const stack = yield* makeHandle(stackId, { + endpoint: { kind: "unix", path: path.join(root, "missing.sock") }, + ownerSessionId: "session", + rpcRelease: STACK_RPC_RELEASE, + }); + const result = yield* stack.destroy().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackDestructionError); + expect(error.value.message).toContain("control connection"); + } + } + yield* stack.close(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); +}); diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index 3ced7b0fa0..bd911e2d46 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -7,4 +7,14 @@ export * from "./Logs.ts"; export * from "./Credentials.ts"; export * from "./Errors.ts"; export * from "./Config.ts"; -export * from "./EffectStack.ts"; +export { createStack, openStack, findStack, listStacks, inspectStack } from "./EffectStack.ts"; +export type { + EffectStack, + StartStackOptions, + PrepareStackOptions, + CreateStackOptions, + FindStackOptions, + ListStacksOptions, + PreparedCapability, + PrepareStackResult, +} from "./EffectStack.ts"; diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index 0fbdc64a79..d246409ae7 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- this fixture asserts exact root paths. +import { join } from "node:path"; import type { PromiseStack } from "./PromiseStack.ts"; import { createTestStackWith, type TestStackOperations } from "./Testing.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; @@ -16,6 +19,7 @@ const status = ( lifecycle: StackStatus["lifecycle"], includeApi = true, functionsState: "ready" | "dormant" | "stopped" = "dormant", + failedCapability?: string, ): StackStatus => ({ id: stackId, lifecycle, @@ -44,6 +48,7 @@ const status = ( ? functionsState : "ready" : "stopped", + ...(failedCapability === name ? { state: "failed", error: `${name} failed` } : {}), })), }); @@ -60,6 +65,7 @@ const fakeStack = ( failClose = false, includeApi = true, functionsState: "ready" | "dormant" | "stopped" = "dormant", + failedCapability?: string, ): PromiseStack => ({ id: stackId, status: async () => status("running", includeApi, functionsState), @@ -95,7 +101,14 @@ const fakeStack = ( if (failClose) throw new Error("close failed"); }, watchStatus: () => - stream([status(reachesReadiness ? "running" : "starting", includeApi, functionsState)]), + stream([ + status( + reachesReadiness ? "running" : "starting", + includeApi, + functionsState, + failedCapability, + ), + ]), logs: () => stream([]), }); @@ -181,4 +194,135 @@ describe("test stack resource", () => { await stack[Symbol.asyncDispose](); expect(events).toEqual(["start", "destroy", "close"]); }); + + it("runs setupProject after creating the root and before creating the stack", async () => { + const events: Array = []; + const operations: TestStackOperations = { + createRoot: async () => { + events.push("root"); + return "/tmp/stack-test-setup"; + }, + createStack: async (options) => { + events.push(`create:${options.projectRoot}`); + return fakeStack(events); + }, + removeRoot: async () => { + events.push("remove"); + }, + }; + const stack = await createTestStackWith( + { + setupProject: async (root) => { + events.push(`setup:${root}`); + }, + }, + operations, + ); + await stack[Symbol.asyncDispose](); + expect(events).toEqual([ + "root", + "setup:/tmp/stack-test-setup", + "create:/tmp/stack-test-setup", + "start", + "destroy", + "close", + "remove", + ]); + }); + + it("passes an isolated runtime state root without mutating process environment", async () => { + const events: Array = []; + // This read is the assertion that createTestStackWith leaves global environment untouched. + // oxlint-disable-next-line effecttsgo/process-env -- test-only environment immutability assertion. + const originalHome = process.env.SUPABASE_HOME; + let environment: Parameters>[1] | undefined; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-isolated-state", + createStack: async (_options, runtimeEnvironment) => { + environment = runtimeEnvironment; + events.push("create"); + return fakeStack(events); + }, + removeRoot: async () => undefined, + }; + const stack = await createTestStackWith({}, operations); + await stack[Symbol.asyncDispose](); + expect(environment?.stateRoot).toBe("/tmp/stack-test-isolated-state/.supabase/managed/stacks"); + expect(environment?.stateRoot.startsWith("/tmp/stack-test-isolated-state/")).toBe(true); + expect(environment?.artifactCacheRoot).toBe(join(tmpdir(), "supabase-stack-test-artifacts")); + expect(environment?.artifactCacheRoot).not.toContain("stack-test-isolated-state"); + // Compare against the snapshot above to prove no global environment mutation occurred. + // oxlint-disable-next-line effecttsgo/process-env -- test-only environment immutability assertion. + expect(process.env.SUPABASE_HOME).toBe(originalHome); + }); + + it("removes the exact root when setupProject fails", async () => { + const events: Array = []; + let created = false; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-setup-failed", + createStack: async () => { + created = true; + return fakeStack(events); + }, + removeRoot: async (root) => { + events.push(`remove:${root}`); + }, + }; + await expect( + createTestStackWith( + { + setupProject: async () => { + throw new Error("project setup failed"); + }, + }, + operations, + ), + ).rejects.toThrow("project setup failed"); + expect(created).toBe(false); + expect(events).toEqual(["remove:/tmp/stack-test-setup-failed"]); + }); + + it("rejects readiness when a configured capability fails", async () => { + const events: Array = []; + const removed: Array = []; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-capability-failed", + createStack: async () => fakeStack(events, false, true, false, true, "dormant", "auth"), + removeRoot: async (root) => { + removed.push(root); + }, + }; + await expect(createTestStackWith({}, operations)).rejects.toThrow("auth failed"); + expect(events).toEqual(["start", "destroy", "close"]); + expect(removed).toEqual(["/tmp/stack-test-capability-failed"]); + }); + + it("rejects readiness when the lifecycle stops before becoming ready", async () => { + const events: Array = []; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-lifecycle-stopped", + createStack: async () => ({ + ...fakeStack(events), + watchStatus: () => stream([status("stopped")]), + }), + removeRoot: async () => undefined, + }; + await expect(createTestStackWith({}, operations)).rejects.toThrow("stopped"); + expect(events).toEqual(["start", "destroy", "close"]); + }); + + it("rejects readiness when the lifecycle starts stopping before becoming ready", async () => { + const events: Array = []; + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-lifecycle-stopping", + createStack: async () => ({ + ...fakeStack(events), + watchStatus: () => stream([status("stopping")]), + }), + removeRoot: async () => undefined, + }; + await expect(createTestStackWith({}, operations)).rejects.toThrow("stopping"); + expect(events).toEqual(["start", "destroy", "close"]); + }); }); diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts new file mode 100644 index 0000000000..c874b8ae17 --- /dev/null +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -0,0 +1,557 @@ +// This E2E intentionally exercises the public Promise facade and real host APIs. +// oxlint-disable effecttsgo/async-function -- user-facing Promise facade scenario. +// oxlint-disable effecttsgo/new-promise -- WebSocket event handoff uses the platform Promise API. +// oxlint-disable effecttsgo/global-fetch -- user-shaped HTTP requests use global fetch. +// oxlint-disable effecttsgo/global-timers -- timeout guards belong to the host protocol fixtures. +// oxlint-disable effecttsgo/crypto-random-uuid -- test identities use host randomness. +// oxlint-disable effecttsgo/global-date -- query windows use host timestamps. +// oxlint-disable effecttsgo/node-builtin-import -- E2E setup writes a real project with host fs/path APIs. +import { PgClient } from "@effect/sql-pg"; +import { Data, Effect, Redacted, Schedule } from "effect"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { createTestStack, type TestStack } from "../testing.ts"; +import { CAPABILITY_NAMES } from "./Capability.ts"; +import type { PromiseStackCredentials } from "./Credentials.ts"; +import type { PromiseStackConfig } from "./PromiseStack.ts"; +import type { StackEndpoint, StackStatus } from "./Status.ts"; + +const E2E_TIMEOUT_MS = 15 * 60_000; +const REQUEST_TIMEOUT_MS = 60_000; +const ANALYTICS_QUERY_RETRY_SCHEDULE = Schedule.spaced("1 second").pipe( + Schedule.upTo({ duration: "3 minutes" }), +); +const MAILPIT_DELIVERY_RETRY_SCHEDULE = Schedule.spaced("250 millis").pipe( + Schedule.upTo({ duration: "30 seconds" }), +); +const RUNTIME_CASES = [ + { name: "native", runtime: { kind: "native" as const } }, + { name: "Docker", runtime: { kind: "container" as const, engine: "docker" as const } }, +] as const; + +type JsonObject = Record; + +class E2ERequestError extends Data.TaggedError("E2ERequestError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const endpoint = (status: StackStatus, name: keyof StackStatus["endpoints"]): StackEndpoint => { + const value = status.endpoints[name]; + if (value === undefined) throw new Error(`Expected ${name} listener in stack status`); + return value; +}; + +const request = async (base: string, path: string, init: RequestInit = {}): Promise => { + const url = new URL(path, `${base.replace(/\/$/u, "")}/`); + const method = init.method ?? "GET"; + let response: Response; + try { + response = await fetch(url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (cause) { + const reason = cause instanceof Error ? cause.message : String(cause); + throw new Error(`${method} ${url} failed: ${reason}`, { cause }); + } + if (!response.ok) { + const body = await response.text(); + throw new Error(`${method} ${url} returned ${response.status}: ${body}`); + } + return response; +}; + +const jsonValue = async (response: Response): Promise => response.json(); + +const jsonObject = async (response: Response): Promise => { + const value = await jsonValue(response); + if (!isJsonObject(value)) throw new Error("Expected a JSON object response"); + return Object.fromEntries(Object.entries(value)); +}; + +const databaseQuery = async ( + url: string, + statement: string, + parameters: ReadonlyArray = [], +): Promise> => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* PgClient.PgClient; + return yield* client.unsafe(statement, parameters); + }).pipe( + Effect.provide( + PgClient.layer({ + url: Redacted.make(url), + connectTimeout: "10 seconds", + }), + ), + ), + ), + ); + +const apiHeaders = ( + credentials: PromiseStackCredentials, + token: string = credentials.api.anonJwt, +): Record => ({ + apikey: credentials.api.publishableKey, + Authorization: `Bearer ${token}`, +}); + +const serviceHeaders = (credentials: PromiseStackCredentials): Record => + apiHeaders(credentials, credentials.api.serviceRoleJwt); + +const functionSource = (table: string, marker: string): string => ` +Deno.serve(async () => { + let publishableKey: unknown; + try { + publishableKey = JSON.parse( + Deno.env.get("SUPABASE_PUBLISHABLE_KEYS") ?? "{}", + ).default; + } catch { + publishableKey = undefined; + } + if (typeof publishableKey !== "string" || publishableKey.length === 0) { + return new Response("Missing SUPABASE_PUBLISHABLE_KEYS.default", { status: 500 }); + } + const response = await fetch( + \`${'${Deno.env.get("SUPABASE_URL")}'}/rest/v1/${table}?select=id,payload\`, + { + headers: { + apikey: publishableKey, + Authorization: \`Bearer \${publishableKey}\`, + }, + }, + ); + const body = await response.text(); + return new Response(JSON.stringify({ marker: "${marker}", rows: JSON.parse(body) }), { + status: response.status, + headers: { "content-type": "application/json" }, + }); +}); +`; + +const waitForSocket = ( + socket: WebSocket, + predicate: (value: JsonObject) => boolean, +): Promise => + new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timeout); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + }; + const finish = (error: Error | undefined, value?: JsonObject) => { + if (settled) return; + settled = true; + cleanup(); + if (error === undefined) resolve(value ?? {}); + else reject(error); + }; + const timeout = setTimeout( + () => finish(new Error("Timed out waiting for Realtime message")), + REQUEST_TIMEOUT_MS, + ); + const onMessage = (event: MessageEvent) => { + try { + const value: unknown = JSON.parse(String(event.data)); + if (!isJsonObject(value)) return; + if (!predicate(value)) return; + finish(undefined, Object.fromEntries(Object.entries(value))); + } catch { + // Ignore protocol frames that are not JSON objects. + } + }; + const onError = () => finish(new Error("Realtime WebSocket failed while waiting")); + const onClose = () => finish(new Error("Realtime WebSocket closed while waiting")); + socket.addEventListener("message", onMessage); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + }); + +const openSocket = (url: string): Promise => + new Promise((resolve, reject) => { + const socket = new WebSocket(url); + const timeout = setTimeout(() => { + socket.close(); + reject(new Error("Timed out opening Realtime WebSocket")); + }, REQUEST_TIMEOUT_MS); + socket.addEventListener("open", () => { + clearTimeout(timeout); + resolve(socket); + }); + socket.addEventListener("error", () => { + clearTimeout(timeout); + reject(new Error("Realtime WebSocket failed to open")); + }); + }); + +const makeRealtimeUrl = (api: StackEndpoint, apikey: string): string => { + const url = new URL(api.url); + url.protocol = api.protocol === "http" ? "ws:" : "wss:"; + url.pathname = "/realtime/v1/websocket"; + url.search = new URLSearchParams({ apikey, vsn: "1.0.0" }).toString(); + return url.toString(); +}; + +const onePixelPng = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + ), + (character) => character.charCodeAt(0), +); + +const stackConfig = (functionSlug: string, analyticsApiKey: string): PromiseStackConfig => ({ + capabilities: { + database: { version: "17.6.1.166" }, + rest: { enabled: true, activation: "lazy" }, + auth: { + enabled: true, + settings: { + enable_signup: true, + email: { enable_signup: true, enable_confirmations: false }, + }, + }, + realtime: { enabled: true }, + storage: { enabled: true, settings: { image_transformation: { enabled: true } } }, + functions: { + enabled: true, + settings: { + functions_root: "supabase/functions", + functions: { [functionSlug]: { enabled: true, verify_jwt: false } }, + }, + }, + studio: { enabled: true, activation: "lazy" }, + mail: { enabled: true }, + analytics: { enabled: true, settings: { vector_port: 9001, api_key: analyticsApiKey } }, + pooler: { enabled: true }, + }, + listeners: { + api: { enabled: true }, + database: { enabled: true }, + pooler: { enabled: true }, + studio: { enabled: true }, + mailUi: { enabled: true }, + smtp: { enabled: true }, + pop3: { enabled: true }, + functionsInspector: { enabled: true }, + }, +}); + +const queryAnalyticsMarker = async ( + api: StackEndpoint, + analyticsApiKey: string, + marker: string, +): Promise<{ readonly count: number; readonly response?: JsonObject; readonly error?: string }> => { + const query = new URLSearchParams({ + project: "default", + iso_timestamp_start: new Date(Date.now() - 3_600_000).toISOString(), + iso_timestamp_end: new Date(Date.now() + 3_600_000).toISOString(), + sql: + "select count(*) as c from postgres_logs where regexp_contains(event_message, '" + + marker + + "')", + }); + let response: JsonObject | undefined; + let lastError: string | undefined; + const attempt = Effect.tryPromise({ + try: async () => { + response = await jsonObject( + await request(api.url, "/analytics/v1/api/endpoints/query/logs.all?" + query, { + headers: { "x-api-key": analyticsApiKey }, + }), + ); + const rows = response.result; + const count = Array.isArray(rows) && isJsonObject(rows[0]) ? Number(rows[0].c) : 0; + if (count <= 0) throw new Error("Analytics query pending (count=" + count + ")"); + return response; + }, + catch: (cause) => { + const reason = cause instanceof Error ? cause.message : String(cause); + lastError = reason; + return new E2ERequestError({ message: reason, cause }); + }, + }); + try { + await Effect.runPromise(Effect.retry(attempt, { schedule: ANALYTICS_QUERY_RETRY_SCHEDULE })); + } catch { + // The returned diagnostics identify the final response and error. + } + const rows = response?.result; + const count = Array.isArray(rows) && isJsonObject(rows[0]) ? Number(rows[0].c) : 0; + return { + count, + ...(response === undefined ? {} : { response }), + ...(lastError === undefined ? {} : { error: lastError }), + }; +}; + +const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Promise => { + const identity = crypto.randomUUID().replaceAll("-", "").slice(0, 20).toLowerCase(); + const table = `stack_e2e_${identity}`; + const bucket = `stack-e2e-${identity}`; + const functionSlug = `cross_service_${identity}`; + const email = `${identity}@example.test`; + const password = "SupabaseStackE2e!123"; + const markers = { first: `first-${identity}`, second: `second-${identity}` }; + const analyticsApiKey = `analytics-key-${identity}`; + let projectRoot = ""; + + await using stack: TestStack = await createTestStack({ + name: `stack-e2e-${identity}`, + runtime: mode.runtime, + config: stackConfig(functionSlug, analyticsApiKey), + setupProject: async (root) => { + projectRoot = root; + const directory = join(root, "supabase", "functions", functionSlug); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "index.ts"), functionSource(table, markers.first)); + }, + }); + + const credentials = await stack.credentials(); + const initial = await stack.status(); + const api = endpoint(initial, "api"); + const pooler = endpoint(initial, "pooler"); + const studio = endpoint(initial, "studio"); + const mailUi = endpoint(initial, "mailUi"); + + expect(initial.runtime).toEqual(mode.runtime); + expect(projectRoot.length).toBeGreaterThan(0); + expect(initial.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); + expect(initial.capabilities.find(({ name }) => name === "studio")?.state).toBe("dormant"); + + // Direct SQL creates the table and enables Realtime's publication for it. + await databaseQuery( + credentials.database.url, + `CREATE TABLE public."${table}" (id integer PRIMARY KEY, payload text NOT NULL)`, + ); + await databaseQuery( + credentials.database.url, + `GRANT SELECT, INSERT, UPDATE, DELETE ON public."${table}" TO anon, authenticated, service_role`, + ); + await databaseQuery( + credentials.database.url, + `ALTER PUBLICATION supabase_realtime ADD TABLE public."${table}"`, + ); + const directRows = await databaseQuery( + credentials.database.url, + `INSERT INTO public."${table}" (id, payload) VALUES (1, $1) RETURNING id, payload`, + [markers.first], + ); + expect(directRows).toEqual([{ id: 1, payload: markers.first }]); + + // PostgREST reads the SQL-created row, then writes through the authenticated user token. + const restPath = `/rest/v1/${table}?select=id,payload&order=id`; + const restRows = await jsonValue( + await request(api.url, restPath, { + headers: { ...apiHeaders(credentials), Accept: "application/json" }, + }), + ); + expect(restRows).toEqual(expect.arrayContaining([{ id: 1, payload: markers.first }])); + + const signup = await jsonObject( + await request(api.url, "/auth/v1/signup", { + method: "POST", + headers: { ...apiHeaders(credentials), "content-type": "application/json" }, + body: JSON.stringify({ email, password }), + }), + ); + const accessToken = signup.access_token; + expect(typeof accessToken).toBe("string"); + if (typeof accessToken !== "string") + throw new Error("Auth signup did not return an access token"); + + const authenticatedInsert = await jsonValue( + await request(api.url, `/rest/v1/${table}`, { + method: "POST", + headers: { + ...apiHeaders(credentials, accessToken), + "content-type": "application/json", + Prefer: "return=representation", + }, + body: JSON.stringify({ id: 2, payload: `auth-${identity}` }), + }), + ); + expect(authenticatedInsert).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 2, payload: `auth-${identity}` })]), + ); + + // Realtime receives the next Postgres change over the same public API listener. + const socket = await openSocket(makeRealtimeUrl(api, credentials.api.publishableKey)); + const socketWaiters: Array> = []; + try { + const joined = waitForSocket(socket, (value) => { + const payload = value.payload; + return value.event === "phx_reply" && typeof payload === "object" && payload !== null; + }); + socketWaiters.push(joined); + const subscribed = waitForSocket(socket, (value) => { + const payload = value.payload; + return ( + value.event === "system" && + isJsonObject(payload) && + payload.status === "ok" && + payload.extension === "postgres_changes" + ); + }); + socketWaiters.push(subscribed); + const change = waitForSocket(socket, (value) => value.event === "postgres_changes"); + socketWaiters.push(change); + socket.send( + JSON.stringify({ + topic: `realtime:public:${table}`, + event: "phx_join", + payload: { + config: { + broadcast: { ack: false, self: false }, + presence: { key: "" }, + postgres_changes: [{ event: "INSERT", schema: "public", table }], + }, + access_token: accessToken, + }, + ref: "1", + }), + ); + await joined; + await subscribed; + await request(api.url, `/rest/v1/${table}`, { + method: "POST", + headers: { + ...apiHeaders(credentials, accessToken), + "content-type": "application/json", + Prefer: "return=minimal", + }, + body: JSON.stringify({ id: 3, payload: `realtime-${identity}` }), + }); + const realtimeChange = await change; + expect(JSON.stringify(realtimeChange)).toContain(`realtime-${identity}`); + } finally { + socket.close(); + await Promise.allSettled(socketWaiters); + } + + // Storage exercises the lazy Storage workload and its image-transform companion. + await request(api.url, "/storage/v1/bucket", { + method: "POST", + headers: { ...serviceHeaders(credentials), "content-type": "application/json" }, + body: JSON.stringify({ id: bucket, name: bucket, public: true }), + }); + await request(api.url, `/storage/v1/object/${bucket}/pixel.png`, { + method: "POST", + headers: { ...serviceHeaders(credentials), "content-type": "image/png" }, + body: new Blob([onePixelPng], { type: "image/png" }), + }); + const downloaded = await request(api.url, `/storage/v1/object/public/${bucket}/pixel.png`, { + headers: serviceHeaders(credentials), + }); + expect((await downloaded.arrayBuffer()).byteLength).toBeGreaterThan(0); + const transformed = await request( + api.url, + `/storage/v1/render/image/public/${bucket}/pixel.png?width=1&height=1`, + { headers: serviceHeaders(credentials) }, + ); + expect((await transformed.arrayBuffer()).byteLength).toBeGreaterThan(0); + + // Functions are request-time discovered and call REST through SUPABASE_URL. + const functionPath = `/functions/v1/${functionSlug}`; + const firstFunction = await jsonObject( + await request(api.url, functionPath, { headers: apiHeaders(credentials) }), + ); + expect(firstFunction).toEqual( + expect.objectContaining({ + marker: markers.first, + rows: expect.arrayContaining([{ id: 1, payload: markers.first }]), + }), + ); + await writeFile( + join(projectRoot, "supabase", "functions", functionSlug, "index.ts"), + functionSource(table, markers.second), + ); + const secondFunction = await jsonObject( + await request(api.url, functionPath, { headers: apiHeaders(credentials) }), + ); + expect(secondFunction).toEqual( + expect.objectContaining({ + marker: markers.second, + rows: expect.arrayContaining([{ id: 1, payload: markers.first }]), + }), + ); + + // Recovery email traverses Auth → SMTP → Mailpit, then is observable via Mailpit's API. + await request(api.url, "/auth/v1/recover", { + method: "POST", + headers: { ...apiHeaders(credentials), "content-type": "application/json" }, + body: JSON.stringify({ email }), + }); + const mailAttempt = Effect.tryPromise({ + try: async () => { + const messages = await request(mailUi.url, "/api/v1/messages?limit=100"); + const body = await messages.text(); + if (!body.includes(email)) throw new Error(`Mailpit delivery pending for ${email}`); + return body; + }, + catch: (cause) => + new E2ERequestError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + await Effect.runPromise(Effect.retry(mailAttempt, { schedule: MAILPIT_DELIVERY_RETRY_SCHEDULE })); + + // Studio's profile endpoint traverses the Studio workload and its pgmeta dependency. + await request(studio.url, "/api/platform/profile", { + headers: serviceHeaders(credentials), + }); + + // Analytics exercises health, authenticated ingestion, and query through the public API route; + // vector is a readiness dependency selected by the configured vector_port above. + await request(api.url, "/analytics/v1/health"); + const analyticsMarker = `analytics-${identity}`; + await request(api.url, "/analytics/v1/logs?source_name=postgres.logs", { + method: "POST", + headers: { "x-api-key": analyticsApiKey, "content-type": "application/json" }, + body: JSON.stringify({ + event_message: analyticsMarker, + project: "default", + metadata: { source: "stack-e2e" }, + }), + }); + const analyticsResult = await queryAnalyticsMarker(api, analyticsApiKey, analyticsMarker); + expect( + analyticsResult.count, + `Analytics query did not observe ${analyticsMarker}; last response: ${JSON.stringify(analyticsResult.response ?? null)}; last error: ${analyticsResult.error ?? "none"}`, + ).toBeGreaterThan(0); + const vectorResult = await queryAnalyticsMarker(api, analyticsApiKey, "supabase-stack-vector"); + expect( + vectorResult.count, + `Analytics query did not observe Vector marker; last response: ${JSON.stringify(vectorResult.response ?? null)}; last error: ${vectorResult.error ?? "none"}`, + ).toBeGreaterThan(0); + + // Pooler is a separate public TCP listener over the same database credentials. + const poolerUrl = new URL(credentials.database.url); + poolerUrl.port = String(pooler.port); + poolerUrl.username = "postgres.pooler-dev"; + const poolerRows = await databaseQuery(poolerUrl.toString(), "SELECT 42 AS answer"); + expect(poolerRows).toEqual([{ answer: 42 }]); + + const final = await stack.status(); + expect(final.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( + CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), + ); +}; + +describe("managed Supabase stack whole-stack E2E", () => { + for (const mode of RUNTIME_CASES) { + test(`supports the complete user flow in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, () => + runWholeStackScenario(mode), + ); + } +}); diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index c3daa7b798..2c40c01121 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -1,4 +1,15 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Option, Scope, Semaphore, Stream } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + Option, + Scope, + Semaphore, + Stream, + type Duration, +} from "effect"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { StackId } from "../public/StackId.ts"; @@ -32,6 +43,8 @@ import { export interface ContainerRuntimeOptions { readonly engine: ContainerEngine; readonly ownerSessionId: string; + /** Maximum time allowed for each service-owned startup process. */ + readonly startupProcessTimeout?: Duration.Input; readonly resolveWorkload?: ( key: RuntimeWorkloadKey, workload: PlannedWorkload, @@ -246,6 +259,8 @@ export const makeContainerRuntime = ( options: ContainerRuntimeOptions, ): Effect.Effect => Effect.gen(function* () { + const startupProcessTimeout = + options.startupProcessTimeout ?? ("5 minutes" satisfies Duration.Input); const parentScope = yield* Effect.serviceOption(Scope.Scope); const runtimeScope = Option.isSome(parentScope) ? yield* Scope.fork(parentScope.value, "parallel") @@ -399,9 +414,9 @@ export const makeContainerRuntime = ( } const exitCode = yield* withEngine(key, options.engine.waitContainer(container.id)); const logs = - logFiber === undefined - ? Exit.succeed(undefined) - : yield* Effect.exit(Fiber.join(logFiber)); + logFiber === undefined ? Exit.succeed(undefined) : yield* Fiber.await(logFiber); + const logFailure = + Exit.isFailure(logs) && !Cause.hasInterruptsOnly(logs.cause) ? logs.cause : undefined; const exitFailure = exitCode === 0 ? undefined @@ -413,11 +428,11 @@ export const makeContainerRuntime = ( ); if (exitFailure !== undefined) { const exitCause = Cause.fail(exitFailure); - return yield* Exit.isFailure(logs) - ? Effect.failCause(Cause.combine(exitCause, logs.cause)) + return yield* logFailure !== undefined + ? Effect.failCause(Cause.combine(exitCause, logFailure)) : Effect.failCause(exitCause); } - if (Exit.isFailure(logs)) return yield* Effect.failCause(logs.cause); + if (logFailure !== undefined) return yield* Effect.failCause(logFailure); }); const release = ( container: ContainerResource, @@ -433,7 +448,18 @@ export const makeContainerRuntime = ( Exit.isFailure(useExit) ? Cause.combine(useExit.cause, removed.cause) : removed.cause, ); }); - return Effect.acquireUseRelease(acquire, use, release); + return Effect.acquireUseRelease(acquire, use, release).pipe( + Effect.timeoutOrElse({ + duration: startupProcessTimeout, + orElse: () => + Effect.fail( + toDriverError( + key, + new Error(`Container startup process timed out for ${key.workloadId}`), + ), + ), + }), + ); }; const start = ( diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index cde7b27ab7..0accf49d11 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Option, Scope, Stream } from "effect"; +import { Data, Effect, Option, Scope, Stream, type Duration } from "effect"; import { fileURLToPath } from "node:url"; import { ChildProcess } from "effect/unstable/process"; import type { PlatformError } from "effect/PlatformError"; @@ -11,8 +11,12 @@ import type { export interface NativeProcessSpec { readonly executable: string; readonly args?: ReadonlyArray; - readonly env?: Readonly>; + readonly env?: Readonly>; readonly cwd?: string; + /** Maximum time allowed for this service-owned one-shot process. */ + readonly timeout?: Duration.Input; + /** Exact lifecycle witness written after a successful one-shot process. */ + readonly successMarker?: string; } /** @@ -99,11 +103,40 @@ export const spawnNativeProcess = ( stream: Stream.Stream, ): Stream.Stream => stream.pipe(Stream.mapError((error) => mapProcessError(error, spec))); + const cleanupProcessGroup = Effect.try({ + try: () => { + if (globalThis.process.platform === "win32") return; + try { + globalThis.process.kill(-Number(handle.pid), "SIGKILL"); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ) + return; + throw error; + } + }, + catch: (error) => mapProcessError(error, spec), + }); + const exitCode = yield* Effect.cached( + mapError(handle.exitCode).pipe( + Effect.tap( + () => + // The launcher exits with the workload's code, but its descendants + // can keep the detached process group alive. The parent still owns + // that exact group, so terminate it after capturing the exit code. + cleanupProcessGroup, + ), + ), + ); return { pid: handle.pid, stdout: mapStreamError(handle.stdout), stderr: mapStreamError(handle.stderr), - exitCode: mapError(handle.exitCode), + exitCode, isRunning: mapError(handle.isRunning), kill: mapError( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index 08482d4586..ee67bd15cb 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -1,4 +1,16 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scope, Semaphore, Stream } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Path, + Ref, + Scope, + Semaphore, + Stream, +} from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { ExitCode } from "effect/unstable/process/ChildProcessSpawner"; import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; @@ -58,6 +70,8 @@ export interface NativeRuntimeOptions { interface NativeProcessPlan { readonly startup: ReadonlyArray; readonly main: NativeProcessSpec; + /** One-shot processes that require the long-lived workload to be ready. */ + readonly postReadiness?: ReadonlyArray; } interface OutputAccumulator { @@ -161,10 +175,12 @@ export const makeNativeRuntime = ( ): Effect.Effect< RuntimeDriver, RuntimeDriverError, - ChildProcessSpawnerService.ChildProcessSpawner | Scope.Scope + ChildProcessSpawnerService.ChildProcessSpawner | Scope.Scope | FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; const parentScope = yield* Scope.Scope; const runtimeScope = yield* Scope.fork(parentScope, "parallel"); const lifecycle = yield* Semaphore.make(1); @@ -241,11 +257,35 @@ export const makeNativeRuntime = ( }).pipe(Effect.forkIn(resource.scope), Effect.asVoid); }; + const writeSuccessMarker = (markerPath: string): Effect.Effect => + Effect.gen(function* () { + const parent = pathService.dirname(markerPath); + yield* fileSystem.makeDirectory(parent, { recursive: true }); + const temporary = yield* fileSystem.makeTempFile({ + directory: parent, + prefix: ".supabase-stack-marker-", + }); + yield* fileSystem.writeFileString(temporary, "completed\n"); + yield* fileSystem.chmod(temporary, 0o600); + yield* fileSystem + .rename(temporary, markerPath) + .pipe(Effect.ensuring(fileSystem.remove(temporary, { force: true }).pipe(Effect.ignore))); + }).pipe( + Effect.mapError( + (error) => + new NativeProcessError({ + message: error instanceof Error ? error.message : String(error), + cause: error, + }), + ), + ); + /** Runs one short-lived, service-owned startup process in its own scope. */ const runStartupProcess = ( resource: Resource, spec: NativeProcessSpec, launcher: NativeProcessLauncher | undefined, + phase: "startup" | "post-readiness", ): Effect.Effect => Effect.gen(function* () { const startupScope = yield* Scope.fork(resource.scope, "parallel"); @@ -254,12 +294,12 @@ export const makeNativeRuntime = ( const process = yield* spawnNativeProcess(spec, launcher).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), Scope.provide(startupScope), - Effect.mapError((error) => processError(error, resource.key, "startup spawn")), + Effect.mapError((error) => processError(error, resource.key, `${phase} spawn`)), ); started = process; const exitCode = yield* Effect.cached( process.exitCode.pipe( - Effect.mapError((error) => processError(error, resource.key, "startup")), + Effect.mapError((error) => processError(error, resource.key, `${phase} wait`)), ), ); const consume = @@ -279,7 +319,7 @@ export const makeNativeRuntime = ( Effect.mapError((error) => driverError( resource.key, - `Native startup log stream failed for ${resource.key.workloadId}: ${errorMessage(error)}`, + `Native ${phase} log stream failed for ${resource.key.workloadId}: ${errorMessage(error)}`, error, ), ), @@ -290,21 +330,45 @@ export const makeNativeRuntime = ( // when the child exits before its pipes finish, while a stream // failure interrupts the exit waiter and startup cleanup kills the // exact child. - yield* Effect.all([exitCode.pipe(Effect.asVoid), Fiber.join(outputFiber)], { + const completed = Effect.all([exitCode.pipe(Effect.asVoid), Fiber.join(outputFiber)], { concurrency: "unbounded", discard: true, }); + yield* spec.timeout === undefined + ? completed + : completed.pipe( + Effect.timeoutOrElse({ + duration: spec.timeout, + orElse: () => + Effect.fail( + driverError( + resource.key, + `Native ${phase} process timed out for ${resource.key.workloadId}`, + ), + ), + }), + ); const result = yield* exitCode.pipe(Effect.exit); if (Exit.isFailure(result)) return yield* driverError( resource.key, - `Native startup process failed for ${resource.key.workloadId}`, + `Native ${phase} process failed for ${resource.key.workloadId}`, result.cause, ); if (result.value !== 0) return yield* driverError( resource.key, - `Native startup process exited with code ${String(result.value)} for ${resource.key.workloadId}`, + `Native ${phase} process exited with code ${String(result.value)} for ${resource.key.workloadId}`, + ); + if (spec.successMarker !== undefined) + yield* writeSuccessMarker(spec.successMarker).pipe( + Effect.mapError((error) => + driverError( + resource.key, + `Native ${phase} success marker failed for ${resource.key.workloadId}: ${error.message}`, + error, + ), + ), ); }); yield* Effect.uninterruptibleMask((restore) => @@ -318,13 +382,13 @@ export const makeNativeRuntime = ( if (child === undefined) return Effect.void; return child.isRunning.pipe( Effect.mapError((error) => - processError(error, resource.key, "startup probe"), + processError(error, resource.key, `${phase} probe`), ), Effect.flatMap((running) => running ? child.kill.pipe( Effect.mapError((error) => - processError(error, resource.key, "startup stop"), + processError(error, resource.key, `${phase} stop`), ), ) : Effect.void, @@ -352,6 +416,10 @@ export const makeNativeRuntime = ( const runStart = (resource: Resource): Effect.Effect => Effect.gen(function* () { const { key, workload } = resource; + const ensureNotStopped = (): Effect.Effect => + resource.stopRequested + ? Effect.fail(driverError(key, "Native workload was stopped while starting")) + : Effect.void; const operation = Effect.gen(function* () { const launcher = options.resolveLauncher === undefined @@ -359,18 +427,23 @@ export const makeNativeRuntime = ( : yield* options.resolveLauncher(key, workload); const resolved = yield* options.resolveProcess(key, workload); for (const startup of resolved.startup) - yield* runStartupProcess(resource, startup, launcher); + yield* runStartupProcess(resource, startup, launcher, "startup"); const process = yield* spawnNativeProcess(resolved.main, launcher).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), Scope.provide(resource.scope), Effect.mapError((error) => processError(error, key, "spawn")), ); resource.process = process; - const exitCode = yield* Effect.cached(process.exitCode); + // Keep one process-exit observation owned by the resource scope. The + // readiness race has waiter-owned fibers which may be interrupted; + // sharing an Effect.cached waiter would let that interruption poison + // the observation used by the long-lived workload watcher. + const exitFiber = yield* Effect.forkIn(process.exitCode, resource.scope); + const exitCode = Fiber.join(exitFiber); yield* Effect.forkIn(watchProcess(resource, process, exitCode), resource.scope); yield* attachLogs(resource, process); const readiness = options.waitForReadiness; - const exitedBeforeReady = exitCode.pipe( + const mainExit = exitCode.pipe( Effect.flatMap((code) => Effect.fail( driverError( @@ -380,39 +453,35 @@ export const makeNativeRuntime = ( ), ), ); - yield* Effect.raceFirst( - Effect.raceFirst(readiness(key, workload, process), Deferred.await(resource.failure)), - exitedBeforeReady, - ); - const running = yield* process.isRunning.pipe( - Effect.mapError((error) => processError(error, key, "probe")), - ); - if (!running) - return yield* driverError( - key, - `Native workload ${key.workloadId} exited before readiness`, - ); - if (resource.stopRequested) - return yield* driverError(key, "Native workload was stopped while starting"); - if (workload.bootstrap === "database") { - if (options.bootstrapDatabase === undefined) - return yield* driverError(key, "Database bootstrap resolver is not configured"); - yield* options - .bootstrapDatabase(key, workload, process) - .pipe(Effect.mapError((error) => driverError(key, error.message, error))); - } - if (resource.stopRequested) - return yield* driverError(key, "Native workload was stopped while starting"); - if (yield* Deferred.isDone(resource.failure)) - return yield* Deferred.await(resource.failure); - const ready = yield* Ref.modify(resource.state, (current) => { - if (current.state !== "starting") return [undefined, current]; - const next: ObservedWorkload = { ...current, state: "ready" }; - return [next, next]; + const preReady = Effect.gen(function* () { + yield* readiness(key, workload, process); + yield* ensureNotStopped(); + for (const postReadiness of resolved.postReadiness ?? []) + yield* runStartupProcess(resource, postReadiness, launcher, "post-readiness"); + yield* ensureNotStopped(); + if (workload.bootstrap === "database") { + if (options.bootstrapDatabase === undefined) + return yield* driverError(key, "Database bootstrap resolver is not configured"); + yield* options + .bootstrapDatabase(key, workload, process) + .pipe(Effect.mapError((error) => driverError(key, error.message, error))); + } + yield* ensureNotStopped(); + if (yield* Deferred.isDone(resource.failure)) + return yield* Deferred.await(resource.failure); + const ready = yield* Ref.modify(resource.state, (current) => { + if (current.state !== "starting") return [undefined, current]; + const next: ObservedWorkload = { ...current, state: "ready" }; + return [next, next]; + }); + if (ready === undefined) + return yield* driverError(key, "Native workload exited or stopped before readiness"); + return ready; }); - if (ready === undefined) - return yield* driverError(key, "Native workload exited or stopped before readiness"); - return ready; + return yield* Effect.raceFirst( + Effect.raceFirst(preReady, Deferred.await(resource.failure)), + mainExit, + ); }); const result = yield* operation.pipe( Effect.mapError((error) => diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 8e39c9ded2..0d9b847c0a 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -17,6 +17,7 @@ interface PostgresDatabaseSessionOptions { readonly host: string; readonly port: number; readonly password: Redacted.Redacted; + readonly database?: string; readonly connectTimeout?: Duration.Input; } @@ -45,7 +46,16 @@ const GeneratedStatementSchema = Schema.Struct({ statement: Schema.String }); * ALTER ROLE/ALTER DATABASE statements are generated by PostgreSQL's * parameterized format() function and are never included in mapped errors. */ -export const makeDatabaseSessionFromSqlClient = (client: DatabaseSqlClient): DatabaseSession => { +export interface PostgresDatabaseSession extends DatabaseSession { + readonly query: ( + statement: string, + parameters?: ReadonlyArray, + ) => Effect.Effect>, DatabaseBootstrapError>; +} + +export const makeDatabaseSessionFromSqlClient = ( + client: DatabaseSqlClient, +): PostgresDatabaseSession => { const executeWith = ( sql: string, parameters: ReadonlyArray = [], @@ -77,7 +87,7 @@ export const makeDatabaseSessionFromSqlClient = (client: DatabaseSqlClient): Dat execute: executeWith, query: queryWith, setRolePassword: (role, password) => - generated("SELECT format('ALTER ROLE %I PASSWORD %L', $1, $2) AS statement", [ + generated("SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text) AS statement", [ role, Redacted.value(password), ]), @@ -85,7 +95,7 @@ export const makeDatabaseSessionFromSqlClient = (client: DatabaseSqlClient): Dat const value = setting.name === "app.settings.jwt_secret" ? Redacted.value(setting.value) : setting.value; return generated( - "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1, $2) AS statement", + "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1::text, $2::text) AS statement", [setting.name, value], ); }, @@ -93,6 +103,7 @@ export const makeDatabaseSessionFromSqlClient = (client: DatabaseSqlClient): Dat return { execute: executeWith, + query: queryWith, transaction: (use) => client.withTransaction(use(transaction)).pipe(Effect.mapError(mapSqlError("transaction"))), }; @@ -101,7 +112,7 @@ export const makeDatabaseSessionFromSqlClient = (client: DatabaseSqlClient): Dat /** Acquires a SQL client in the caller's scope and keeps it live with the session. */ export const makeDatabaseSessionFromAcquisition = ( acquire: Effect.Effect, -): Effect.Effect => +): Effect.Effect => acquire.pipe( Effect.map(makeDatabaseSessionFromSqlClient), Effect.mapError(mapSqlError("connection")), @@ -110,15 +121,18 @@ export const makeDatabaseSessionFromAcquisition = ( /** Creates a scoped production session backed by the Effect PostgreSQL pool. */ const makePostgresDatabaseSession = ( options: PostgresDatabaseSessionOptions, -): Effect.Effect => +): Effect.Effect => makeDatabaseSessionFromAcquisition( Effect.gen(function* () { const services = yield* Layer.build( PgClient.layer({ host: options.host, port: options.port, - database: "postgres", - username: "postgres", + database: options.database ?? "postgres", + // The canonical Supabase migration demotes the bootstrap-created + // `postgres` role. Keep the session on the init superuser so role + // and database-setting reconciliation remains authorized. + username: "supabase_admin", password: options.password, connectTimeout: options.connectTimeout ?? "5 seconds", }), @@ -127,6 +141,32 @@ const makePostgresDatabaseSession = ( }), ); +const INTERNAL_DATABASE = "_supabase"; +const INTERNAL_SCHEMAS = ["_analytics", "_supavisor"] as const; + +/** + * Ensures the private database and service-owned schemas exist before any + * dependent workload is started. Database creation is deliberately outside a + * transaction because PostgreSQL does not support CREATE DATABASE there. + */ +export const ensureInternalDatabase = ( + postgres: PostgresDatabaseSession, + openInternal: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const databases = yield* postgres.query("SELECT 1 FROM pg_database WHERE datname = $1", [ + INTERNAL_DATABASE, + ]); + if (databases.length === 0) + yield* postgres.execute(`CREATE DATABASE ${INTERNAL_DATABASE} WITH OWNER postgres`); + + const internal = yield* openInternal; + for (const schema of INTERNAL_SCHEMAS) { + yield* internal.execute(`CREATE SCHEMA IF NOT EXISTS ${schema} AUTHORIZATION postgres`); + yield* internal.execute(`ALTER SCHEMA ${schema} OWNER TO postgres`); + } + }); + /** Runs the initial bootstrap through the durable loopback database endpoint. */ export const bootstrapDatabaseAt = ( state: PersistedStackState, @@ -153,6 +193,15 @@ export const bootstrapDatabaseAt = ( port, password: Redacted.make(password), }); + yield* ensureInternalDatabase( + session, + makePostgresDatabaseSession({ + host: "127.0.0.1", + port, + database: INTERNAL_DATABASE, + password: Redacted.make(password), + }), + ); yield* runDatabaseBootstrap(session, plan); }), ); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index e72b5c1ade..b635fc47fe 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -1,6 +1,20 @@ -import { Cause, Context, Crypto, Effect, Exit, FileSystem, Path, Ref, Scope, Stream } from "effect"; +import { + Cause, + Config, + Context, + Crypto, + Duration, + Effect, + Exit, + FileSystem, + Path, + Ref, + Scope, + Stream, +} from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { StackDefinition } from "../model/Compiler.ts"; import { bundleServeMainTemplate } from "../functions/serve-main-bundler.ts"; import { makeFunctionsBootstrapOwner, @@ -42,6 +56,7 @@ import { type RuntimeInputOwner, type RuntimeJsonFetcher, } from "./RuntimeInputOwner.ts"; +import type { NativeProcessSpec } from "./NativeProcess.ts"; import { FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, resolveContainerResolutionFor, @@ -52,7 +67,8 @@ import { } from "./WorkloadRuntimeSpec.ts"; import { makeNativeRuntime } from "./NativeRuntime.ts"; import { makeContainerRuntime, type ContainerWorkloadResolution } from "./ContainerRuntime.ts"; -import { probeReadiness } from "./ReadinessProbe.ts"; +import { DEFAULT_READINESS_DEADLINE, probeReadiness } from "./ReadinessProbe.ts"; +import { parseGoDuration } from "../model/capabilities/database.ts"; import type { ContainerEngine, ContainerHostRoute } from "./ContainerEngine.ts"; import { bootstrapDatabaseAt } from "./PostgresDatabaseSession.ts"; import type { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; @@ -66,6 +82,7 @@ type RuntimeContext = FileSystem.FileSystem | Path.Path | Crypto.Crypto; export interface ProductionRuntimeFactoryOptions { readonly stateRoot: string; + readonly artifactCacheRoot?: string; readonly stackId: StackId; readonly ownerSessionId: string; readonly stateStore: StackStateStore; @@ -102,6 +119,9 @@ const stateSecrets = (state: PersistedStackState): ReadonlyArray => .map((entry) => entry.value) .filter((value) => value.length > 0); +const startupTimeout = (startup: NativeProcessSpec): NativeProcessSpec["timeout"] => + startup.timeout; + const currentStateReader = (options: ProductionRuntimeFactoryOptions) => options.stateStore.read(options.stackId).pipe( Effect.provideContext(options.context), @@ -131,6 +151,51 @@ const urlHost = (host: string): string => { return normalized.includes(":") && !normalized.startsWith("[") ? `[${normalized}]` : normalized; }; +const DATABASE_WORKLOAD_ID = "database:database"; +/** Atomic lifecycle witness for a successfully migrated native cluster. */ +export const NATIVE_DATABASE_MIGRATION_MARKER = ".supabase-stack-migration-complete"; +const isDatabaseWorkload = (workload: PlannedWorkload): boolean => + workload.id === DATABASE_WORKLOAD_ID; +const configuredDatabaseReadinessDeadline = ( + definition: StackDefinition | undefined, +): Effect.Effect => { + if (definition === undefined) + return Effect.fail(preparationError("Persisted database definition is missing")); + const configured = definition.capabilities.database.settings.health_timeout; + if (configured === undefined || configured === null) + return Effect.fail(preparationError("Persisted database health_timeout is missing")); + return Effect.try({ + try: () => parseGoDuration(configured), + catch: (cause) => preparationError(`Invalid database health_timeout: ${configured}`, cause), + }).pipe( + Effect.flatMap((duration) => + Duration.isNegative(duration) || Duration.isZero(duration) + ? Effect.fail( + preparationError( + `Invalid database health_timeout: ${configured}; duration must be positive`, + ), + ) + : Effect.succeed(duration), + ), + ); +}; + +export const readinessDeadlineFor = ( + state: PersistedStackState, + workload: PlannedWorkload, +): Effect.Effect => + isDatabaseWorkload(workload) + ? configuredDatabaseReadinessDeadline(state.definition) + : Effect.succeed(DEFAULT_READINESS_DEADLINE); + +const validateDatabaseReadinessBudget = ( + definition: StackDefinition, + workloads: ReadonlyArray, +): Effect.Effect => + workloads.some(isDatabaseWorkload) + ? configuredDatabaseReadinessDeadline(definition).pipe(Effect.asVoid) + : Effect.void; + const redactEntry = ( entry: A, secrets: ReadonlyArray, @@ -195,13 +260,20 @@ const readinessFor = ( const endpoint = spec.privateEndpoint(state, spec.readiness.binding, "native"); if (endpoint === undefined) return Effect.fail(preparationError(`Missing private readiness assignment for ${workload.id}`)); - return probeReadiness({ - mode: spec.readiness.protocol, - host: "127.0.0.1", - port: endpoint.port, - ...(spec.readiness.path === undefined ? {} : { path: spec.readiness.path }), - ...(spec.readiness.headers === undefined ? {} : { headers: spec.readiness.headers }), - }); + return readinessDeadlineFor(state, workload).pipe( + Effect.flatMap((deadline) => + probeReadiness( + { + mode: spec.readiness.protocol, + host: "127.0.0.1", + port: endpoint.port, + ...(spec.readiness.path === undefined ? {} : { path: spec.readiness.path }), + ...(spec.readiness.headers === undefined ? {} : { headers: spec.readiness.headers }), + }, + { deadline }, + ), + ), + ); }; const bootstrapContent = Effect.tryPromise({ @@ -269,6 +341,7 @@ export const makeProductionRuntimeFactory = ( > => Effect.gen(function* () { const initial = yield* currentStateReader(options); + const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const ownerScope = yield* Scope.Scope; @@ -342,6 +415,7 @@ export const makeProductionRuntimeFactory = ( options.artifactPreparer ?? (yield* makeProductionRuntimeArtifactPreparer({ stateRoot: options.stateRoot, + artifactCacheRoot: options.artifactCacheRoot, runtime: initial.runtime, platform: { os: @@ -450,7 +524,7 @@ export const makeProductionRuntimeFactory = ( : undefined; return { ...(auth === undefined ? {} : { auth }), - ...(workload.id === "analytics:analytics" && material.analytics !== undefined + ...(workload.id.startsWith("analytics:") && material.analytics !== undefined ? { analytics: material.analytics } : {}), ...(workload.id === "pooler:pooler" && material.pooler !== undefined @@ -469,6 +543,9 @@ export const makeProductionRuntimeFactory = ( return yield* new StackRuntimeMismatchError({ message: "Lifecycle runtime does not match persisted runtime", }); + // Eagerly validate the candidate before any engine/artifact work. Start-time checks + // below revalidate the fresh persisted definition because it is runtime authority. + yield* validateDatabaseReadinessBudget(input.definition, input.plan.workloads); if (input.state.runtime.kind === "container") { if ( containerEngine === undefined || @@ -505,7 +582,8 @@ export const makeProductionRuntimeFactory = ( > => Effect.gen(function* () { const workload = input.plan.workloads.find( - (entry) => entry.capability === capability, + (entry) => + entry.capability === capability && entry.readiness.portField !== undefined, ); if (workload === undefined) return yield* new GatewayActivationError({ @@ -546,6 +624,11 @@ export const makeProductionRuntimeFactory = ( key, `Unknown runtime specification for ${workload.id}`, ); + // Revalidate the fresh persisted definition before spawning; preflight checks + // the candidate definition, while this state is the runtime authority. + const readinessBudget = yield* readinessDeadlineFor(fresh, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); const inputs = yield* runtimeInputs( workload, fresh, @@ -576,7 +659,7 @@ export const makeProductionRuntimeFactory = ( key, `Missing private port assignment for ${workload.id}`, ); - const process = spec.nativeProcess( + const resolvedNativeProcess = spec.nativeProcess( prepared.artifactRoot, fresh, workload, @@ -590,6 +673,87 @@ export const makeProductionRuntimeFactory = ( "native", inputs, ); + let postReadiness: ReadonlyArray = []; + if (isDatabaseWorkload(workload)) { + const dataPath = inputs.database?.dataPath; + if (dataPath === undefined) + return yield* driverError( + key, + "Resolved database data path is unavailable for native migration", + ); + const markerPath = pathService.join( + dataPath, + NATIVE_DATABASE_MIGRATION_MARKER, + ); + const migrated = yield* fileSystem + .exists(markerPath) + .pipe( + Effect.mapError((error) => + driverError( + key, + `Unable to inspect native database data path: ${error.message}`, + error, + ), + ), + ); + if (!migrated) { + const pgVersionPath = pathService.join(dataPath, "PG_VERSION"); + const initialized = yield* fileSystem + .exists(pgVersionPath) + .pipe( + Effect.mapError((error) => + driverError( + key, + `Unable to inspect native database data path: ${error.message}`, + error, + ), + ), + ); + if (initialized) + yield* fileSystem + .remove(dataPath, { recursive: true, force: true }) + .pipe( + Effect.mapError((error) => + driverError( + key, + `Unable to remove incomplete native database data path: ${error.message}`, + error, + ), + ), + ); + const migrationsPath = pathService.join( + prepared.artifactRoot, + "share/supabase-cli/migrations", + ); + const artifactBin = pathService.join(prepared.artifactRoot, "bin"); + const hostPath = yield* Config.string("PATH").pipe( + Config.withDefault(""), + Effect.mapError((error) => + driverError(key, "Unable to read host PATH", error), + ), + ); + postReadiness = [ + { + executable: pathService.join(migrationsPath, "migrate.sh"), + cwd: migrationsPath, + timeout: readinessBudget, + successMarker: markerPath, + env: { + ...environment, + PATH: + hostPath.length === 0 + ? artifactBin + : `${artifactBin}:${hostPath}`, + POSTGRES_HOST: "127.0.0.1", + POSTGRES_PORT: String(endpoint.port), + POSTGRES_DB: "postgres", + POSTGRES_USER: "supabase_admin", + POSTGRES_PASSWORD: environment.POSTGRES_PASSWORD ?? "", + }, + }, + ]; + } + } return { startup: spec .nativeStartupProcesses( @@ -601,9 +765,15 @@ export const makeProductionRuntimeFactory = ( ) .map((startup) => ({ ...startup, + timeout: + startupTimeout(startup) ?? + (isDatabaseWorkload(workload) + ? readinessBudget + : Duration.minutes(5)), env: { ...environment, ...startup.env }, })), - main: { ...process, env: environment }, + postReadiness, + main: { ...resolvedNativeProcess, env: environment }, }; }), ), @@ -616,7 +786,13 @@ export const makeProductionRuntimeFactory = ( bootstrapDatabase: (key, workload) => workload.bootstrap === "database" ? freshState(key).pipe( - Effect.flatMap((fresh) => bootstrapDatabase(fresh)), + Effect.flatMap((fresh) => + readinessDeadlineFor(fresh, workload).pipe( + Effect.flatMap((deadline) => + bootstrapDatabase(fresh).pipe(Effect.timeout(deadline)), + ), + ), + ), Effect.mapError((error) => mapDriverError(key, error)), ) : Effect.void, @@ -624,6 +800,8 @@ export const makeProductionRuntimeFactory = ( }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), Effect.provideService(Scope.Scope, ownerScope), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), Effect.mapError((error) => preparationError("Unable to initialize native runtime", error), ), @@ -644,6 +822,11 @@ export const makeProductionRuntimeFactory = ( key, `Unknown runtime specification for ${workload.id}`, ); + // Revalidate the fresh persisted definition before creating a container; + // preflight checks the candidate definition, while this state is authoritative. + yield* readinessDeadlineFor(fresh, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); let route = yield* Ref.get(hostRoute); if (route === undefined) { route = yield* containerEngine.preflight.pipe( @@ -715,7 +898,13 @@ export const makeProductionRuntimeFactory = ( bootstrapDatabase: (key, workload) => workload.bootstrap === "database" ? freshState(key).pipe( - Effect.flatMap((fresh) => bootstrapDatabase(fresh)), + Effect.flatMap((fresh) => + readinessDeadlineFor(fresh, workload).pipe( + Effect.flatMap((deadline) => + bootstrapDatabase(fresh).pipe(Effect.timeout(deadline)), + ), + ), + ), Effect.mapError((error) => mapDriverError(key, error)), ) : Effect.void, @@ -728,40 +917,28 @@ export const makeProductionRuntimeFactory = ( functionsBootstrap, inputOwner, ); + const cleanupOwnedInputs = (key: RuntimeWorkloadKey) => + key.workloadId === "pooler:pooler" + ? inputOwner + .cleanupGeneration(key.desiredGeneration, "pooler") + .pipe( + Effect.mapError((error) => + driverError(key, "Unable to clean pooler runtime input", error), + ), + ) + : key.workloadId === "analytics:vector" + ? inputOwner + .cleanupGeneration(key.desiredGeneration, "vector") + .pipe( + Effect.mapError((error) => + driverError(key, "Unable to clean Vector runtime input", error), + ), + ) + : Effect.void; const ownedDriver: RuntimeDriver = { ...baseDriver, - stop: (key) => - baseDriver - .stop(key) - .pipe( - Effect.tap(() => - key.workloadId === "pooler:pooler" - ? inputOwner - .cleanupGeneration(key.desiredGeneration) - .pipe( - Effect.mapError((error) => - driverError(key, "Unable to clean pooler runtime input", error), - ), - ) - : Effect.void, - ), - ), - remove: (key) => - baseDriver - .remove(key) - .pipe( - Effect.tap(() => - key.workloadId === "pooler:pooler" - ? inputOwner - .cleanupGeneration(key.desiredGeneration) - .pipe( - Effect.mapError((error) => - driverError(key, "Unable to clean pooler runtime input", error), - ), - ) - : Effect.void, - ), - ), + stop: (key) => baseDriver.stop(key).pipe(Effect.tap(() => cleanupOwnedInputs(key))), + remove: (key) => baseDriver.remove(key).pipe(Effect.tap(() => cleanupOwnedInputs(key))), }; return { driver: ownedDriver, diff --git a/packages/stack/src/runtime/ReadinessProbe.ts b/packages/stack/src/runtime/ReadinessProbe.ts index e6cac02c92..4460b24a4c 100644 --- a/packages/stack/src/runtime/ReadinessProbe.ts +++ b/packages/stack/src/runtime/ReadinessProbe.ts @@ -1,8 +1,8 @@ -import { Duration, Effect, Schedule } from "effect"; +import { Duration, Effect, Option, Schedule } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- Node callback boundary owns cancellation. import { request as httpRequest, type IncomingMessage } from "node:http"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- Node callback boundary owns cancellation. -import { createConnection, type Socket } from "node:net"; +import { Socket } from "node:net"; import { RuntimeDriverError } from "./RuntimeDriver.ts"; import { StackPreparationError } from "../public/Errors.ts"; @@ -15,12 +15,17 @@ export interface ReadinessTarget { } export interface ReadinessProbeOptions { - /** Number of retries after the initial attempt. */ + /** Optional maximum number of retries after the initial attempt. */ readonly retries?: number; /** Delay between attempts; defaults to a short spaced schedule. */ readonly retryDelay?: Duration.Input; + /** Total time allowed for the initial attempt and all retries; zero ignores retries. */ + readonly deadline?: Duration.Input; } +/** Default total readiness budget for non-database workloads. */ +export const DEFAULT_READINESS_DEADLINE = Duration.seconds(30); + const preparationError = (message: string): StackPreparationError => new StackPreparationError({ message }); @@ -65,6 +70,17 @@ const validateTarget = (target: ReadinessTarget): Effect.Effect => { + const decoded = Duration.fromInput(value); + if (Option.isNone(decoded)) return Effect.fail(preparationError(`${name} is invalid`)); + if (!Duration.isFinite(decoded.value) || Duration.isNegative(decoded.value)) + return Effect.fail(preparationError(`${name} must be finite and non-negative`)); + return Effect.succeed(decoded.value); +}; + const httpAttempt = (target: ReadinessTarget): Effect.Effect => Effect.callback((resume) => { let settled = false; @@ -143,12 +159,13 @@ const tcpAttempt = (target: ReadinessTarget): Effect.Effect finish(Effect.fail(runtimeError(target, "Readiness TCP connection failed", cause))); const onClose = () => { - if (!settled) finish(Effect.fail(runtimeError(target, "Readiness TCP connection closed"))); + finish(Effect.fail(runtimeError(target, "Readiness TCP connection closed"))); }; - socket = createConnection({ host: target.host, port: target.port }); + socket = new Socket(); socket.once("connect", onConnect); socket.once("error", onError); socket.once("close", onClose); + socket.connect({ host: target.host, port: target.port }); return Effect.sync(() => { if (settled) return; settled = true; @@ -167,12 +184,27 @@ export const probeReadiness = ( ): Effect.Effect => Effect.gen(function* () { yield* validateTarget(target); - const retries = options.retries ?? 3; - if (!Number.isSafeInteger(retries) || retries < 0) + const retries = options.retries; + if (retries !== undefined && (!Number.isSafeInteger(retries) || retries < 0)) return yield* preparationError("Readiness retry count is invalid"); - const attempt = target.mode === "http" ? httpAttempt(target) : tcpAttempt(target); - const schedule = Schedule.spaced(options.retryDelay ?? "100 millis").pipe( - Schedule.upTo({ times: retries }), + const retryDelay = yield* decodeDuration( + options.retryDelay ?? Duration.millis(100), + "Readiness retry delay", ); - yield* Effect.retry(attempt, schedule); + const deadline = yield* decodeDuration( + options.deadline ?? DEFAULT_READINESS_DEADLINE, + "Readiness deadline", + ); + const attempt = target.mode === "http" ? httpAttempt(target) : tcpAttempt(target); + // A zero budget preserves the legacy `0s` meaning: perform one immediate probe, with no + // retry delay. A positive budget interrupts whichever owned request/socket is still active. + if (Duration.isZero(deadline)) return yield* attempt; + const schedule = + retries === undefined + ? Schedule.spaced(retryDelay) + : Schedule.spaced(retryDelay).pipe(Schedule.upTo({ times: retries })); + yield* Effect.timeoutOrElse(Effect.retry(attempt, schedule), { + duration: deadline, + orElse: () => Effect.fail(runtimeError(target, "Readiness deadline exceeded")), + }); }); diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index ca3a748e5d..b9467631cc 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -41,7 +41,11 @@ interface RuntimeInputMaterial { readonly jwks: string; readonly templates?: ReadonlyArray; }>; - readonly analytics?: Readonly<{ readonly gcpJwtPath?: string }>; + readonly analytics?: Readonly<{ + readonly gcpJwtPath?: string; + /** Generation-scoped Vector config owned by this stack runtime. */ + readonly vectorConfigPath?: string; + }>; readonly pooler?: Readonly<{ readonly tenantPath?: string }>; readonly functions?: Readonly<{ readonly secrets: Readonly> }>; } @@ -61,10 +65,16 @@ export interface RuntimeInputOwner { readonly resolveAuthTemplates: ( state: PersistedStackState, ) => Effect.Effect, StackPreparationError>; - readonly cleanupGeneration: (generation: number) => Effect.Effect; + /** Removes only the generation-owned material for the stopped workload. */ + readonly cleanupGeneration: ( + generation: number, + owner: RuntimeInputOwnerKind, + ) => Effect.Effect; readonly cleanupAll: Effect.Effect; } +type RuntimeInputOwnerKind = "pooler" | "vector"; + export interface RuntimeInputOwnerOptions { readonly stateRoot: string; readonly stackId: StackId; @@ -255,6 +265,50 @@ const resolveFunctionsEdgeRuntimeSecrets = ( return Effect.succeed(output); }; +const vectorConfig = `data_dir: /tmp +api: + enabled: true + address: "\${VECTOR_API_ADDRESS}" + +sources: + stack_heartbeat: + type: demo_logs + format: json + count: 1 + stack_keepalive: + type: internal_metrics + +transforms: + stack_marker: + type: remap + inputs: + - stack_heartbeat + source: | + .event_message = "supabase-stack-vector" + del(.message) + .project = "default" + +sinks: + analytics: + type: http + inputs: + - stack_marker + uri: "\${LOGFLARE_URL}/logs?source_name=postgres.logs" + method: post + request: + headers: + x-api-key: "\${LOGFLARE_PRIVATE_ACCESS_TOKEN}" + retry_attempts: 5 + retry_initial_backoff_secs: 1 + retry_max_duration_secs: 10 + encoding: + codec: json + keepalive: + type: blackhole + inputs: + - stack_keepalive +`; + export const makeRuntimeInputOwner = ( options: RuntimeInputOwnerOptions, ): Effect.Effect< @@ -274,6 +328,7 @@ export const makeRuntimeInputOwner = ( Effect.mapError((cause) => failure("Unable to resolve runtime input paths", { cause })), ); const poolerRoot = path.join(stackPaths.runtime, "inputs", "pooler"); + const vectorRoot = path.join(stackPaths.runtime, "inputs", "vector"); const resolveProjectFile = ( state: PersistedStackState, @@ -550,6 +605,61 @@ export const makeRuntimeInputOwner = ( }); }; + const writeVectorConfig = ( + state: PersistedStackState, + generation: number, + ): Effect.Effect => { + if (invalidGeneration(generation)) return Effect.fail(failure("Invalid Vector generation")); + const assignment = state.privatePorts.find( + (entry) => entry.workloadId === "analytics:vector" && entry.binding === "primary", + ); + if (state.runtime.kind === "native" && assignment === undefined) + return Effect.fail(failure("Persisted native Vector assignment is missing")); + const generationRoot = path.join(vectorRoot, String(generation)); + const target = path.join(generationRoot, "vector.yaml"); + return Effect.gen(function* () { + yield* mapFile( + generationRoot, + "create Vector config directory", + fs.makeDirectory(generationRoot, { recursive: true, mode: 0o700 }), + ); + yield* mapFile( + generationRoot, + "secure Vector config directory", + fs.chmod(generationRoot, 0o700), + ); + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError(() => failure("Unable to allocate Vector config file")), + ); + const temporary = path.join(generationRoot, `.vector.yaml.${token}.tmp`); + yield* Effect.ensuring( + Effect.scoped( + Effect.gen(function* () { + const file = yield* mapFile( + temporary, + "create Vector config", + fs.open(temporary, { flag: "w", mode: 0o600 }), + ); + yield* mapFile( + temporary, + "write Vector config", + file.writeAll(new TextEncoder().encode(vectorConfig)), + ); + yield* mapFile(temporary, "sync Vector config", file.sync); + }), + ).pipe( + Effect.andThen(mapFile(temporary, "secure Vector config", fs.chmod(temporary, 0o600))), + Effect.andThen(mapFile(target, "publish Vector config", fs.rename(temporary, target))), + Effect.andThen( + mapFile(target, "secure published Vector config", fs.chmod(target, 0o600)), + ), + ), + fs.remove(temporary, { force: true }).pipe(Effect.catchCause(() => Effect.void)), + ); + return target; + }); + }; + const joinExit = (result: Exit.Exit): Effect.Effect => Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); type OwnedResult = Deferred.Deferred, never>; @@ -564,6 +674,7 @@ export const makeRuntimeInputOwner = ( const materializeCommon = ( state: PersistedStackState, + generation: number, ): Effect.Effect => Effect.gen(function* () { const jwtConsumers = ["rest", "auth", "realtime", "storage", "functions"] as const; @@ -575,10 +686,23 @@ export const makeRuntimeInputOwner = ( const gcpPath = isRecord(analyticsSettings) ? settingValue(state, analyticsSettings.gcp_jwt_path) : ""; + const analyticsEnabled = state.definition?.capabilities.analytics.enabled === true; + const vectorPort = isRecord(analyticsSettings) + ? settingValue(state, analyticsSettings.vector_port) + : ""; + const vectorConfigPath = + analyticsEnabled && vectorPort.length > 0 + ? yield* writeVectorConfig(state, generation) + : undefined; const analytics = - state.definition?.capabilities.analytics.enabled !== true || gcpPath.length === 0 + !analyticsEnabled || (gcpPath.length === 0 && vectorConfigPath === undefined) ? undefined - : { gcpJwtPath: yield* resolveProjectFile(state, gcpPath) }; + : { + ...(gcpPath.length === 0 + ? {} + : { gcpJwtPath: yield* resolveProjectFile(state, gcpPath) }), + ...(vectorConfigPath === undefined ? {} : { vectorConfigPath }), + }; const functions = state.definition?.capabilities.functions.enabled !== true ? undefined @@ -698,7 +822,7 @@ export const makeRuntimeInputOwner = ( key, commonPending, commonCompleted, - materializeCommon(state), + materializeCommon(state, generation), ); if ( options.includePooler !== true || @@ -714,23 +838,64 @@ export const makeRuntimeInputOwner = ( return { ...common, pooler: { tenantPath } }; }); - const cleanupGeneration = (generation: number): Effect.Effect => + const cleanupGeneration = ( + generation: number, + owner: RuntimeInputOwnerKind, + ): Effect.Effect => invalidGeneration(generation) - ? Effect.fail(failure("Invalid pooler generation")) + ? Effect.fail(failure("Invalid runtime input generation")) : execution.withPermit( Effect.gen(function* () { - yield* admission.withPermit( - Effect.sync(() => { - for (const key of poolerPending.keys()) - if (key.endsWith(`\u0000${generation}`)) poolerPending.delete(key); - for (const key of poolerCompleted.keys()) - if (key.endsWith(`\u0000${generation}`)) poolerCompleted.delete(key); - }), - ); + const matches = (key: string) => key.endsWith(`\u0000${generation}`); + if (owner === "pooler") { + const retired = yield* admission.withPermit( + Effect.sync(() => { + const deferreds: Array> = []; + for (const [key, deferred] of poolerPending) + if (matches(key)) { + poolerPending.delete(key); + deferreds.push(deferred); + } + for (const key of poolerCompleted.keys()) + if (matches(key)) poolerCompleted.delete(key); + return deferreds; + }), + ); + yield* Effect.forEach(retired, (deferred) => + Deferred.succeed( + deferred, + Exit.fail(failure("Runtime input generation was invalidated")), + ), + ); + } else { + // Vector config is part of common material. Invalidate both + // completed and in-flight entries so a same-generation + // restart publishes a fresh config path. + const retired = yield* admission.withPermit( + Effect.sync(() => { + const deferreds: Array> = []; + for (const [key, deferred] of commonPending) + if (matches(key)) { + commonPending.delete(key); + deferreds.push(deferred); + } + for (const key of commonCompleted.keys()) + if (matches(key)) commonCompleted.delete(key); + return deferreds; + }), + ); + yield* Effect.forEach(retired, (deferred) => + Deferred.succeed( + deferred, + Exit.fail(failure("Runtime input generation was invalidated")), + ), + ); + } + const root = owner === "pooler" ? poolerRoot : vectorRoot; yield* mapFile( - path.join(poolerRoot, String(generation)), - "clean pooler tenant generation", - fs.remove(path.join(poolerRoot, String(generation)), { + path.join(root, String(generation)), + `clean ${owner} generation`, + fs.remove(path.join(root, String(generation)), { recursive: true, force: true, }), @@ -739,20 +904,34 @@ export const makeRuntimeInputOwner = ( ); const cleanupAll = execution.withPermit( Effect.gen(function* () { - yield* admission.withPermit( + const pending = yield* admission.withPermit( Effect.sync(() => { + const common = [...commonPending.values()]; + const pooler = [...poolerPending.values()]; commonPending.clear(); commonCompleted.clear(); poolerPending.clear(); poolerCompleted.clear(); acceptedGeneration = undefined; + return { common, pooler }; }), ); + yield* Effect.forEach(pending.common, (deferred) => + Deferred.succeed(deferred, Exit.fail(failure("Runtime inputs were invalidated"))), + ); + yield* Effect.forEach(pending.pooler, (deferred) => + Deferred.succeed(deferred, Exit.fail(failure("Runtime inputs were invalidated"))), + ); yield* mapFile( poolerRoot, "clean pooler tenant files", fs.remove(poolerRoot, { recursive: true, force: true }), ); + yield* mapFile( + vectorRoot, + "clean Vector configs", + fs.remove(vectorRoot, { recursive: true, force: true }), + ); }), ); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index d037515f87..47a0a44c55 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -46,6 +46,8 @@ export interface WorkloadRuntimeInputs { /** Resolved host path for the BigQuery service-account file. */ readonly analytics?: Readonly<{ readonly gcpJwtPath?: string; + /** Generation-scoped Vector config generated by the stack owner. */ + readonly vectorConfigPath?: string; }>; /** Stack-owned Edge Runtime bootstrap source and its private container target. */ readonly functions?: Readonly<{ @@ -403,9 +405,14 @@ const containerPortFor = ( const dbHost = (runtime: WorkloadRuntimeKind): string => runtime === "container" ? DATABASE_NETWORK_ALIAS : "127.0.0.1"; -const dbUrl = (state: PersistedStackState, role: string, runtime: WorkloadRuntimeKind): string => { +const dbUrl = ( + state: PersistedStackState, + role: string, + runtime: WorkloadRuntimeKind, + database = "postgres", +): string => { const port = runtime === "container" ? 5432 : dbPort(state); - return `postgresql://${role}:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${port}/postgres`; + return `postgresql://${role}:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${port}/${database}`; }; const usesResolvedJwks = (state: PersistedStackState): boolean => { @@ -494,6 +501,25 @@ const aliasFor = (workload: PlannedWorkload): string => const artifactPath = (root: string, relative: string): string => relative === "." ? root : `${root.replace(/\/+$/u, "")}/${relative}`; +const nativeArgsFor = ( + workload: PlannedWorkload, + args: ReadonlyArray, + inputs: WorkloadRuntimeInputs, +): ReadonlyArray => + workload.id === "analytics:vector" && inputs.analytics?.vectorConfigPath !== undefined + ? ["--config", inputs.analytics.vectorConfigPath] + : workload.id === "functions:edge-runtime" && inputs.functions?.bootstrapPath !== undefined + ? (() => { + const bootstrapPath = inputs.functions?.bootstrapPath; + if (bootstrapPath === undefined) return args; + const bootstrapDirectory = + bootstrapPath.slice(0, bootstrapPath.lastIndexOf("/")) || bootstrapPath; + return args.map((arg) => + arg.startsWith("--main-service=") ? `--main-service=${bootstrapDirectory}` : arg, + ); + })() + : args; + const nativeProcessFor = ( artifactRoot: string, state: PersistedStackState, @@ -503,14 +529,16 @@ const nativeProcessFor = ( inputs: WorkloadRuntimeInputs = {}, ): NativeProcessResolution => { const metadata: NativeWorkloadProcess | undefined = WORKLOAD_CATALOG[workload.id]?.nativeProcess; - if (metadata !== undefined) + if (metadata !== undefined) { + const metadataArgs = metadata.args.map((arg) => + arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, + ); return { executable: artifactPath(artifactRoot, metadata.executablePath), - args: metadata.args.map((arg) => - arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, - ), + args: nativeArgsFor(workload, metadataArgs, inputs), cwd: artifactPath(artifactRoot, metadata.cwd), }; + } const executablePath = WORKLOAD_CATALOG[workload.id]?.executablePath; const resolvedPort = privatePortFor(state, workload.id, "primary") ?? port; const args = spec @@ -518,18 +546,7 @@ const nativeProcessFor = ( .map((arg) => arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, ); - const nativeArgs = - workload.id === "functions:edge-runtime" && inputs.functions?.bootstrapPath !== undefined - ? (() => { - const bootstrapPath = inputs.functions?.bootstrapPath; - if (bootstrapPath === undefined) return args; - const bootstrapDirectory = - bootstrapPath.slice(0, bootstrapPath.lastIndexOf("/")) || bootstrapPath; - return args.map((arg) => - arg.startsWith("--main-service=") ? `--main-service=${bootstrapDirectory}` : arg, - ); - })() - : args; + const nativeArgs = nativeArgsFor(workload, args, inputs); return { executable: executablePath === undefined ? artifactRoot : artifactPath(artifactRoot, executablePath), @@ -558,7 +575,14 @@ const nativeStartupProcessesFor = ( }, ]; case "realtime:realtime": - return [{ executable: artifact("bin/migrate"), args: [], cwd }]; + return [ + { executable: artifact("bin/migrate"), args: [], cwd }, + { + executable: artifact("bin/realtime"), + args: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], + cwd, + }, + ]; case "analytics:analytics": return [ { @@ -704,14 +728,15 @@ const authExternalUrl = (state: PersistedStackState): string => { return `http://127.0.0.1${apiPort === undefined ? "" : `:${apiPort}`}/auth/v1`; }; -const apiGatewayUrl = (state: PersistedStackState, inputs?: WorkloadRuntimeInputs): string => { +const apiListenerUrl = (state: PersistedStackState, inputs?: WorkloadRuntimeInputs): string => { const apiPort = state.ports.find((assignment) => assignment.field === "api")?.port; - if (inputs?.hostRoute !== undefined) - return `http://${inputs.hostRoute.host}${apiPort === undefined ? "" : `:${apiPort}`}`; - return ( - valueAt(state, "studio", "api_url") || - `http://127.0.0.1${apiPort === undefined ? "" : `:${apiPort}`}` - ); + const host = inputs?.hostRoute?.host ?? "127.0.0.1"; + return `http://${host}${apiPort === undefined ? "" : `:${apiPort}`}`; +}; + +const apiGatewayUrl = (state: PersistedStackState, inputs?: WorkloadRuntimeInputs): string => { + if (inputs?.hostRoute !== undefined) return apiListenerUrl(state, inputs); + return valueAt(state, "studio", "api_url") || apiListenerUrl(state); }; const authSmsProvider = (state: PersistedStackState): string => { @@ -1000,7 +1025,7 @@ const analyticsEnv = ( DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), DB_DATABASE: "_supabase", DB_SCHEMA: "_analytics", - DB_USERNAME: "postgres", + DB_USERNAME: "supabase_admin", DB_PASSWORD: secret(state, "secret:database.internal.password"), LOGFLARE_SUPABASE_MODE: "true", LOGFLARE_SINGLE_TENANT: "true", @@ -1012,7 +1037,7 @@ const analyticsEnv = ( RELEASE_COOKIE: "cookie", ...(backend === "postgres" ? { - POSTGRES_BACKEND_URL: dbUrl(state, "postgres", runtime), + POSTGRES_BACKEND_URL: dbUrl(state, "postgres", runtime, "_supabase"), POSTGRES_BACKEND_SCHEMA: "_analytics", } : { @@ -1096,7 +1121,7 @@ const specs: Readonly> = { }), containerArgs: () => [], networkAliases: ["supabase-realtime"], - readiness: { protocol: "http", path: "/api/ping", headers: { Host: "realtime-dev" } }, + readiness: { protocol: "http", path: "/healthcheck" }, }, "storage:storage": { bindings: { primary: { containerPort: 5000 } }, @@ -1141,7 +1166,7 @@ const specs: Readonly> = { SUPABASE_INTERNAL_FUNCTIONS_ROOT: runtime === "container" ? FUNCTIONS_CONTAINER_ROOT : functionsRoot(state), SUPABASE_INTERNAL_FUNCTIONS_CONFIG: functionsConfigEnvironment(state), - SUPABASE_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), + SUPABASE_URL: apiListenerUrl(state, runtime === "container" ? inputs : undefined), EDGE_RUNTIME_POLICY: valueAt(state, "functions", "edge_runtime.policy") || "per_worker", EDGE_RUNTIME_DENO_VERSION: valueAt(state, "functions", "edge_runtime.deno_version") || "2", INSPECTOR_MODE: valueAt(state, "functions", "inspector.mode"), @@ -1204,7 +1229,7 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/api/platform/profile" }, }, "studio:pgmeta": { - bindings: { primary: { containerPort: 8080 } }, + bindings: { primary: { containerPort: 8080 }, admin: { containerPort: 8081 } }, args: () => [], env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), @@ -1264,18 +1289,32 @@ const specs: Readonly> = { }, "analytics:vector": { bindings: { primary: { containerPort: 9001 } }, - args: (_state, _workload, _port) => [ - "--config", - "share/doc/vector/config/vector.yaml", - "--watch-config", - "false", - ], - env: (state, workload, port) => ({ + args: (_state, _workload, _port) => ["--config", "share/doc/vector/config/vector.yaml"], + env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), ...capabilityEnv(state, "analytics", "VECTOR"), + VECTOR_API_ADDRESS: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, VECTOR_API_PORT: String(port), + LOGFLARE_URL: + runtime === "container" + ? "http://supabase-analytics:4000" + : `http://127.0.0.1:${workloadPort(state, "analytics:analytics", "primary", runtime, 4000)}`, + LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), }), - containerArgs: () => [], + containerArgs: (_state, _workload, _port, inputs = {}) => + inputs.analytics?.vectorConfigPath === undefined + ? [] + : ["--config", "/etc/vector/vector.yaml"], + containerMounts: (_state, _workload, inputs = {}) => + inputs.analytics?.vectorConfigPath === undefined + ? [] + : [ + { + source: inputs.analytics.vectorConfigPath, + target: "/etc/vector/vector.yaml", + readOnly: true, + }, + ], networkAliases: ["supabase-vector"], readiness: { protocol: "http", path: "/health" }, }, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index e8343a0fab..25fe77afb8 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Deferred, Effect, Exit, Fiber, Option, Stream } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import { NodeServices } from "@effect/platform-node"; import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; @@ -522,6 +523,38 @@ describe("container runtime", () => { }), ); + it.live("ignores an interrupt-only one-shot log follower exit", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + let streamCalls = 0; + const runtime = yield* makeContainerRuntime({ + engine: { + ...fakeContainerEngine(state), + streamLogs: () => { + streamCalls += 1; + return streamCalls === 1 ? Stream.failCause(Cause.interrupt()) : Stream.empty; + }, + }, + ownerSessionId: "owner-session", + logStore: memoryLogStore([]), + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + expect(streamCalls).toBe(2); + yield* runtime.stop(key); + }), + ); + it.live("hides in-flight startup containers from observation", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -635,6 +668,55 @@ describe("container runtime", () => { }), ); + it.effect("times out a startup migration and removes the exact init container", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const waitEntered = yield* Deferred.make(); + const waitForever = yield* Deferred.make(); + const runtime = yield* makeContainerRuntime({ + engine: { + ...fakeContainerEngine(state), + waitContainer: () => + Effect.gen(function* () { + yield* Deferred.succeed(waitEntered, undefined); + return yield* Deferred.await(waitForever); + }), + }, + ownerSessionId: "owner-session", + startupProcessTimeout: "5 seconds", + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + const running = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(waitEntered); + yield* TestClock.adjust("5 seconds"); + const result = yield* Fiber.join(running).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(RuntimeDriverError); + expect(error.value.message).toContain("startup process timed out"); + } + } + expect(state.createdSpecs).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("start:")).length).toBe(1); + expect(state.calls.filter((call) => call.startsWith("remove:")).length).toBe(1); + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + }), + ); + it.live("keeps a nonzero migration exit authoritative when log persistence also fails", () => Effect.gen(function* () { const state: FakeContainerState = { diff --git a/packages/stack/src/runtime/native-launcher.ts b/packages/stack/src/runtime/native-launcher.ts index 525d0ef0ca..322db97ad0 100644 --- a/packages/stack/src/runtime/native-launcher.ts +++ b/packages/stack/src/runtime/native-launcher.ts @@ -1,14 +1,17 @@ +// oxlint-disable effecttsgo/process-env -- standalone launcher reads inherited allowlisted variables before Effect starts. // Standalone launcher boundary: this process must remain usable before the // Effect runtime exists and therefore talks to the host directly. // oxlint-disable-next-line effecttsgo/node-builtin-import import { Socket } from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import +import { createReadStream, readFileSync } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import import { spawn } from "node:child_process"; interface LaunchSpec { readonly executable: string; readonly args: ReadonlyArray; - readonly env?: Readonly>; + readonly env?: Readonly>; readonly cwd?: string; } @@ -28,12 +31,12 @@ const decodeSpec = (bytes: Buffer): LaunchSpec | undefined => { } } if (value.cwd !== undefined && typeof value.cwd !== "string") return undefined; - let env: Record | undefined; + let env: Record | undefined; if (value.env !== undefined) { if (!isRecord(value.env)) return undefined; env = {}; for (const [name, entry] of Object.entries(value.env)) { - if (entry !== undefined && typeof entry !== "string") return undefined; + if (typeof entry !== "string") return undefined; env[name] = entry; } } @@ -49,11 +52,38 @@ const decodeSpec = (bytes: Buffer): LaunchSpec | undefined => { }; let child: ReturnType | undefined; -let terminating = false; +let gracefulForwarded = false; +let groupTerminated = false; +let childExited = false; + +const inheritedEnvironmentNames = [ + "PATH", + "HOME", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_COLLATE", + "LC_MESSAGES", + "LC_MONETARY", + "LC_NUMERIC", + "LC_TIME", +] as const; + +const inheritedEnvironment = (): NodeJS.ProcessEnv => { + const environment: NodeJS.ProcessEnv = {}; + for (const name of inheritedEnvironmentNames) { + const value = process.env[name]; + if (value !== undefined) environment[name] = value; + } + return environment; +}; const terminateGroup = (signal: NodeJS.Signals): void => { - if (terminating) return; - terminating = true; + if (groupTerminated) return; + groupTerminated = true; if (process.platform === "win32") { spawn("taskkill", ["/pid", String(process.pid), "/T", "/F"], { stdio: "ignore" }); return; @@ -67,35 +97,72 @@ const terminateGroup = (signal: NodeJS.Signals): void => { } }; +// The supervisor normally signals the launcher process group, but forwarding +// graceful signals to the workload keeps the launcher alive long enough to +// observe the workload's exit and reap it. SIGKILL remains handled by the +// owner-pipe path and cannot be intercepted here. +const forwardSignal = (signal: NodeJS.Signals): void => { + if (groupTerminated || gracefulForwarded) return; + if (child === undefined) { + terminateGroup(signal); + return; + } + gracefulForwarded = true; + try { + child.kill(signal); + } catch { + terminateGroup(signal); + } +}; +process.on("SIGTERM", () => forwardSignal("SIGTERM")); +process.on("SIGINT", () => forwardSignal("SIGINT")); + // Register the owner pipe before waiting for the launch payload. EOF means // the owner process disappeared without running a normal scope finalizer. -const ownerPipe = new Socket({ fd: 3, readable: true, writable: false }); +// Bun does not reliably surface EOF for a net.Socket created from this +// descriptor, while Node's filesystem stream can hold a libuv worker open +// after its owner exits. Select the descriptor adapter for the host runtime. +const ownerPipe = + process.versions.bun === undefined + ? new Socket({ fd: 3, readable: true, writable: false }) + : createReadStream(process.platform === "win32" ? "NUL" : "/dev/null", { + fd: 3, + autoClose: false, + }); // Owner-loss is an abrupt supervisor crash, not an explicit graceful stop. // Use guaranteed tree termination so descendants that trap SIGTERM cannot be // orphaned after the owner pipe closes. ownerPipe.on("end", () => terminateGroup("SIGKILL")); ownerPipe.on("error", () => terminateGroup("SIGKILL")); +ownerPipe.on("close", () => { + if (!childExited) terminateGroup("SIGKILL"); +}); ownerPipe.resume(); -const payloadPipe = new Socket({ fd: 4, readable: true, writable: false }); -const payload: Buffer[] = []; -payloadPipe.on("data", (chunk: Buffer) => payload.push(chunk)); -payloadPipe.on("error", () => process.exit(127)); -payloadPipe.on("end", () => { - const spec = decodeSpec(Buffer.concat(payload)); - if (spec === undefined || terminating) { - process.exit(127); - return; - } +let payload: Buffer; +try { + // The parent writes a finite JSON payload and closes fd4. A synchronous + // read avoids a Bun pipe-read stream that can fail to deliver `end` after + // the parent closes the sink, while the owner stream remains registered for + // loss detection once the workload is running. + payload = readFileSync(4); +} catch { + process.exit(127); +} +const spec = decodeSpec(payload); +if (spec === undefined || groupTerminated) { + process.exit(127); +} else { child = spawn(spec.executable, [...spec.args], { cwd: spec.cwd, - env: spec.env === undefined ? process.env : { ...spec.env }, + env: { ...inheritedEnvironment(), ...spec.env }, detached: false, stdio: ["ignore", "inherit", "inherit"], }); child.on("error", () => process.exit(127)); - child.on("exit", (code, signal) => { + child.on("exit", (code) => { + childExited = true; ownerPipe.destroy(); - process.exit(code ?? (signal === null ? 1 : 1)); + process.exit(code ?? 1); }); -}); +} diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index d9bfe445a5..1d2309f3d3 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -1,16 +1,22 @@ +// oxlint-disable effecttsgo/prefer-schema-over-json -- raw child-process fixture payloads are protocol JSON, not product serialization. import { NodeServices, NodeSocket } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Data, Deferred, Effect, Exit, Fiber, FileSystem, Path, Ref, Stream } from "effect"; +import { Cause, Data, Deferred, Effect, Exit, Fiber, FileSystem, Path, Ref, Stream } from "effect"; import { ChildProcess } from "effect/unstable/process"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { LogStoreError, makeLogStore, type LogStore } from "../supervisor/LogStore.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; import { RuntimeDriverError } from "./RuntimeDriver.ts"; import { makeNativeRuntime } from "./NativeRuntime.ts"; -import { defaultNativeProcessLauncher, type NativeProcess } from "./NativeProcess.ts"; +import { + defaultNativeProcessLauncher, + spawnNativeProcess, + type NativeProcess, +} from "./NativeProcess.ts"; const stackId = StackIdSchema.make("d".repeat(64)); @@ -131,6 +137,52 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); + it.live("keeps a ready workload after the losing exit observer is interrupted", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-native-exit-observer-", + }); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const readinessEntered = yield* Deferred.make(); + const readinessRelease = yield* Deferred.make(); + let startedProcess: NativeProcess | undefined; + const output = yield* logStore.stream({ follow: true }).pipe( + Stream.filter((entry) => entry.message === "main-started"), + Stream.take(1), + Stream.runDrain, + Effect.forkChild({ startImmediately: true }), + ); + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("main-started"))), + logStore, + waitForReadiness: (_key, _workload, process) => + Effect.sync(() => { + startedProcess = process; + }).pipe( + Effect.andThen(Deferred.succeed(readinessEntered, undefined)), + Effect.andThen(Deferred.await(readinessRelease)), + ), + }); + const key = keyFor("exit-observer"); + const caller = yield* runtime + .start(key, workload("exit-observer")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readinessEntered); + yield* Fiber.join(output); + yield* Deferred.succeed(readinessRelease, undefined); + const ready = yield* Fiber.join(caller); + expect(ready.state).toBe("ready"); + expect(startedProcess).toBeDefined(); + if (startedProcess !== undefined) expect(yield* startedProcess.isRunning).toBe(true); + expect((yield* runtime.observe(stackId))[0]?.state).toBe("ready"); + yield* runtime.stop(key); + }), + ), + ); + it.live("cleans up when readiness fails", () => withPlatform( Effect.gen(function* () { @@ -293,6 +345,349 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); + it.live("runs post-readiness startup processes before reporting ready", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-post-ready-" }); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const postReadiness = { + executable: process.execPath, + args: [ + "-e", + 'process.stdout.write("post-ready\\n"); process.stderr.write("post-ready-stderr\\n")', + ], + }; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [], + postReadiness: [postReadiness], + main: fixtureProcess("main"), + }), + logStore, + waitForReadiness: () => Effect.void, + }); + const key = keyFor("post-readiness"); + const ready = yield* runtime.start(key, workload("post-readiness")); + expect(ready.state).toBe("ready"); + const messages = (yield* logStore.read()).map((entry) => entry.message); + expect(messages).toContain("post-ready"); + expect(messages).toContain("post-ready-stderr"); + yield* runtime.remove(key); + }), + ), + ); + + it.live("writes a completion witness only after a successful post-readiness process", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-marker-" }); + const marker = path.join(root, "data", "migration-complete"); + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [], + postReadiness: [{ ...oneShotProcess("migrated"), successMarker: marker }], + main: fixtureProcess("main"), + }), + waitForReadiness: () => Effect.void, + }); + const key = keyFor("marker"); + const ready = yield* runtime.start(key, workload("marker")); + expect(ready.state).toBe("ready"); + expect(yield* fs.readFileString(marker)).toBe("completed\n"); + yield* runtime.stop(key); + }), + ), + ); + + it.live("bounds a native one-shot before it can report readiness", () => + withPlatform( + Effect.gen(function* () { + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [ + { + executable: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + timeout: "20 millis", + }, + ], + main: fixtureProcess("never-main"), + }), + waitForReadiness: () => Effect.void, + }); + const result = yield* runtime + .start(keyFor("startup-timeout"), workload("startup-timeout")) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Cause.pretty(result.cause)).toContain("Native startup process timed out"); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + + it.live("does not leak host credentials into native workload children", () => + withPlatform( + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "supabase-native-env-", + }); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const launcherPath = fileURLToPath(new URL("./native-launcher.ts", import.meta.url)); + const launcher = { + command: process.execPath, + args: [ + "-e", + `process.env.PGPASSWORD="host-secret"; await import(${JSON.stringify(launcherPath)})`, + ], + }; + const runtime = yield* makeNativeRuntime({ + resolveLauncher: () => Effect.succeed(launcher), + resolveProcess: () => + Effect.succeed({ + startup: [ + { + executable: process.execPath, + args: [ + "-e", + 'process.stdout.write(`${JSON.stringify({ password: process.env.PGPASSWORD ?? "missing", path: (process.env.PATH ?? "").length > 0 })}\\n`)', + ], + }, + ], + main: fixtureProcess("env-main"), + }), + waitForReadiness: () => Effect.void, + logStore, + }); + const key = keyFor("env-allowlist"); + yield* runtime.start(key, workload("env-allowlist")); + const messages = (yield* logStore.read()).map((entry) => entry.message); + expect(messages).toContain('{"password":"missing","path":true}'); + yield* runtime.stop(key); + }), + ), + ); + + it.live("does not report ready when post-readiness startup fails", () => + withPlatform( + Effect.gen(function* () { + let readinessCalled = false; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [], + postReadiness: [oneShotProcess("post-ready-failed", 7)], + main: fixtureProcess("main"), + }), + waitForReadiness: () => + Effect.sync(() => { + readinessCalled = true; + }), + }); + const result = yield* runtime + .start(keyFor("post-readiness-failed"), workload("post-readiness-failed")) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Cause.pretty(result.cause)).toContain( + "Native post-readiness process exited with code 7", + ); + expect(readinessCalled).toBe(true); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + + it.live("fails when the main workload exits while post-readiness is blocked", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-native-post-ready-main-exit-", + }); + const readyPath = path.join(root, "main-ready"); + const releasePath = path.join(root, "post-release"); + expect(spawnSync("mkfifo", [readyPath]).status).toBe(0); + expect(spawnSync("mkfifo", [releasePath]).status).toBe(0); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const postStarted = yield* logStore.stream({ follow: true }).pipe( + Stream.filter((entry) => entry.message === "post-blocked"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + const postReadiness = { + executable: process.execPath, + args: [ + "-e", + `const fs = require("node:fs"); process.stdout.write("post-blocked\\n"); const fd = fs.openSync(${JSON.stringify(releasePath)}, "r"); fs.readSync(fd, Buffer.alloc(1), 0, 1, null); fs.closeSync(fd); process.stdout.write("post-complete\\n")`, + ], + }; + const main = { + executable: process.execPath, + args: [ + "-e", + `const fs = require("node:fs"); const fd = fs.openSync(${JSON.stringify(readyPath)}, "r"); fs.readSync(fd, Buffer.alloc(1), 0, 1, null); fs.closeSync(fd); process.stdout.write("main-exited\\n"); process.exit(0)`, + ], + }; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ startup: [], postReadiness: [postReadiness], main }), + logStore, + waitForReadiness: () => Effect.void, + }); + const key = keyFor("post-readiness-main-exit"); + const caller = yield* runtime + .start(key, workload("post-readiness-main-exit")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(postStarted); + const releaseMain = spawnSync( + process.execPath, + ["-e", `require("node:fs").writeFileSync(${JSON.stringify(readyPath)}, "x")`], + { encoding: "utf8", timeout: 5_000 }, + ); + expect(releaseMain.error).toBeUndefined(); + expect(releaseMain.status).toBe(0); + const result = yield* Fiber.join(caller).pipe( + Effect.exit, + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ProcessTreeTestError({ + message: "Native caller lifecycle result timed out", + }), + ), + }), + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Cause.pretty(result.cause)).toContain("exited before readiness"); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + + it.live("interrupts a post-readiness startup process through its exact child scope", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-native-post-interrupt-", + }); + const pidPath = path.join(root, "pid"); + const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const started = yield* logStore.stream({ follow: true }).pipe( + Stream.filter((entry) => entry.message === "post-started"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [], + postReadiness: [ + { + executable: process.execPath, + args: [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(pidPath)},String(process.pid)); process.stdout.write("post-started\\n"); setInterval(()=>{},1000)`, + ], + }, + ], + main: fixtureProcess("never-main"), + }), + logStore, + waitForReadiness: () => Effect.void, + }); + const key = keyFor("post-readiness-interrupted"); + const caller = yield* runtime + .start(key, workload("post-readiness-interrupted")) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(started); + yield* runtime.stop(key); + const result = yield* Fiber.join(caller).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const pid = Number.parseInt(yield* fs.readFileString(pidPath), 10); + expect(Number.isSafeInteger(pid)).toBe(true); + const processState = yield* Effect.sync(() => { + const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { + encoding: "utf8", + }); + return result.stdout.trim(); + }); + expect(processState === "" || processState.startsWith("Z")).toBe(true); + }), + ), + ); + + it.live("interrupts a blocked post-readiness child when log persistence fails", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-native-post-log-failure-", + }); + const pidPath = path.join(root, "pid"); + const stoppedPath = path.join(root, "stopped"); + const logStore: LogStore = { + path: "memory://native-post-log-failure", + append: () => + Effect.fail( + new LogStoreError({ + path: "memory://native-post-log-failure", + message: "disk full", + }), + ), + read: () => Effect.succeed([]), + retained: () => Effect.succeed([]), + stream: () => Stream.empty, + }; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.succeed({ + startup: [], + postReadiness: [ + { + executable: process.execPath, + args: [ + "-e", + `const fs=require("node:fs"); fs.writeFileSync(${JSON.stringify(pidPath)},String(process.pid)); const stop=()=>{fs.writeFileSync(${JSON.stringify(stoppedPath)},"stopped"); process.exit(0)}; process.on("SIGTERM",stop); process.on("SIGINT",stop); process.stdout.write("post-log-failure\\n"); setInterval(()=>{},1000);`, + ], + }, + ], + main: { + executable: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + }, + }), + logStore, + waitForReadiness: () => Effect.void, + }); + const result = yield* runtime + .start(keyFor("post-log-failure"), workload("post-log-failure")) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const pid = Number.parseInt(yield* fs.readFileString(pidPath), 10); + expect(Number.isSafeInteger(pid)).toBe(true); + expect(yield* fs.readFileString(stoppedPath)).toBe("stopped"); + expect(yield* runtime.observe(stackId)).toEqual([]); + }), + ), + ); + it.live("does not spawn the native main process when startup exits nonzero", () => withPlatform( Effect.gen(function* () { @@ -320,33 +715,20 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); - // oxlint-disable effecttsgo/prefer-schema-over-json it.live("drains startup output after the child exits", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-drain-" }); - const releasePath = path.join(root, "release"); - expect(spawnSync("mkfifo", [releasePath]).status).toBe(0); const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); - const descendant = ` - const fs = require("node:fs"); - process.stdout.write("startup-late\\n"); - const fd = fs.openSync(${JSON.stringify(releasePath)}, "r"); - fs.readSync(fd, Buffer.alloc(1), 0, 1, null); - fs.closeSync(fd); - `; const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed({ startup: [ { executable: process.execPath, - args: [ - "-e", - `const { spawn } = require("node:child_process"); spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], { stdio: ["ignore", "inherit", "inherit"] }); process.exit(0)`, - ], + args: ["-e", 'process.stdout.write("startup-late\\n"); process.exit(0)'], }, ], main: fixtureProcess("drained-main"), @@ -363,21 +745,12 @@ describe("native runtime", { timeout: 15_000 }, () => { .start(keyFor("startup-drain"), workload("startup-drain")) .pipe(Effect.forkChild({ startImmediately: true })); yield* Fiber.join(startupOutput); - // The startup parent has exited, but its descendant intentionally keeps - // the inherited stdout pipe open until this observable release signal. - const writer = spawnSync(process.execPath, [ - "-e", - `require("node:fs").writeFileSync(${JSON.stringify(releasePath)}, "x")`, - ]); - expect(writer.status).toBe(0); const ready = yield* Fiber.join(caller); expect(ready.state).toBe("ready"); yield* runtime.remove(keyFor("startup-drain")); }), ), ); - // oxlint-enable effecttsgo/prefer-schema-over-json - it.live("interrupts a startup process through its exact child scope", () => withPlatform( Effect.gen(function* () { @@ -385,6 +758,7 @@ describe("native runtime", { timeout: 15_000 }, () => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-interrupt-" }); const pidPath = path.join(root, "pid"); + const stoppedPath = path.join(root, "stopped"); const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); const started = yield* logStore .stream({ follow: true }) @@ -397,7 +771,7 @@ describe("native runtime", { timeout: 15_000 }, () => { executable: process.execPath, args: [ "-e", - `require("node:fs").writeFileSync(${JSON.stringify(pidPath)},String(process.pid)); process.stdout.write("started\\n"); setInterval(()=>{},1000)`, + `const fs=require("node:fs"); fs.writeFileSync(${JSON.stringify(pidPath)},String(process.pid)); const stop=()=>{fs.writeFileSync(${JSON.stringify(stoppedPath)},"stopped"); process.exit(0)}; process.on("SIGTERM",stop); process.on("SIGINT",stop); process.stdout.write("started\\n"); setInterval(()=>{},1000)`, ], }, ], @@ -413,15 +787,10 @@ describe("native runtime", { timeout: 15_000 }, () => { yield* Fiber.join(started); yield* runtime.stop(key); yield* Fiber.interrupt(caller); - const pid = Number.parseInt(yield* fs.readFileString(pidPath), 10); - expect(Number.isSafeInteger(pid)).toBe(true); - const processState = yield* Effect.sync(() => { - const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { - encoding: "utf8", - }); - return result.stdout.trim(); - }); - expect(processState === "" || processState.startsWith("Z")).toBe(true); + expect(Number.isSafeInteger(Number.parseInt(yield* fs.readFileString(pidPath), 10))).toBe( + true, + ); + expect(yield* fs.readFileString(stoppedPath)).toBe("stopped"); }), ), ); @@ -470,18 +839,54 @@ describe("native runtime", { timeout: 15_000 }, () => { it.live("runs database bootstrap before reporting readiness", () => withPlatform( Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-native-post-ready-bootstrap-", + }); + const postCompletePath = path.join(root, "post-complete"); let applied = false; + let postCompletedBeforeBootstrap = false; const runtime = yield* makeNativeRuntime({ - resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("database"))), + resolveProcess: () => + Effect.succeed({ + startup: [], + postReadiness: [ + { + executable: process.execPath, + args: [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(postCompletePath)}, "done"); process.stdout.write("post-complete\\n")`, + ], + }, + ], + main: fixtureProcess("database"), + }), waitForReadiness: () => Effect.void, bootstrapDatabase: () => - Effect.sync(() => { - applied = true; - }), + fs.exists(postCompletePath).pipe( + Effect.mapError( + (error) => + new RuntimeDriverError({ + message: "Unable to inspect post-readiness marker", + stackId, + workloadId: "database:bootstrap", + cause: error, + }), + ), + Effect.tap((completed) => + Effect.sync(() => { + postCompletedBeforeBootstrap = completed; + applied = true; + }), + ), + Effect.asVoid, + ), }); const key = keyFor("bootstrap"); const ready = yield* runtime.start(key, workload("bootstrap", "database")); expect(applied).toBe(true); + expect(postCompletedBeforeBootstrap).toBe(true); expect(ready.state).toBe("ready"); yield* runtime.stop(key); yield* runtime.remove(key); @@ -558,12 +963,15 @@ describe("native runtime", { timeout: 15_000 }, () => { // The helper below is a raw Node launcher fixture; its JSON is the same fd4 // payload covered by NativeProcess integration, not product serialization. - // oxlint-disable effecttsgo/prefer-schema-over-json - it.live("kills the native process tree when its owner pipe closes", () => + it.live("kills the native process tree when its owner pipe closes under Bun and Node", () => withPlatform( Effect.gen(function* () { - const targetLauncher = defaultNativeProcessLauncher(); - const descendantCode = ` + const launcherArgs = defaultNativeProcessLauncher().args; + const runtimes = [{ command: process.execPath }, { command: "node" }] as const; + for (const runtime of runtimes) { + yield* Effect.gen(function* () { + const targetLauncher = { command: runtime.command, args: launcherArgs }; + const descendantCode = ` const net = require("node:net"); const server = net.createServer(); server.listen({ host: "127.0.0.1", port: 0 }, () => { @@ -573,10 +981,11 @@ describe("native runtime", { timeout: 15_000 }, () => { } }); `; - const targetCode = ` + const targetCode = ` const net = require("node:net"); const { spawn } = require("node:child_process"); process.stdout.on("error", () => {}); + process.on("SIGTERM", () => process.stdout.write("TARGET_SIGTERM\\n")); const server = net.createServer(); server.listen({ host: "127.0.0.1", port: 0 }, () => { const address = server.address(); @@ -591,100 +1000,210 @@ describe("native runtime", { timeout: 15_000 }, () => { }); }); `; - const ownerCode = ` + const ownerCode = ` const { spawn } = require("node:child_process"); const launcherProcess = spawn(${JSON.stringify(targetLauncher.command)}, ${JSON.stringify(targetLauncher.args)}, { detached: true, stdio: ["ignore", "inherit", "inherit", "pipe", "pipe"] }); launcherProcess.stdio[4].end(JSON.stringify({ - executable: process.execPath, + executable: ${JSON.stringify(runtime.command)}, args: ["-e", ${JSON.stringify(targetCode)}] })); + process.stdout.write("LAUNCHER_READY " + launcherProcess.pid + "\\n"); setInterval(() => {}, 1000); `; - const owner = yield* ChildProcess.make(process.execPath, ["-e", ownerCode], { - stdout: "pipe", - stderr: "pipe", - detached: true, - }); - const stderr = yield* Ref.make(""); - yield* owner.stderr.pipe( - Stream.decodeText, - Stream.runForEach((chunk) => Ref.update(stderr, (current) => current + chunk)), - Effect.forkChild({ startImmediately: true }), - ); - const ready = yield* owner.stdout.pipe( - Stream.decodeText, - Stream.splitLines, - Stream.takeUntil((line) => line.startsWith("DESC_READY ")), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); - const output = yield* Fiber.join(ready).pipe( - Effect.timeoutOrElse({ - duration: "3 seconds", - orElse: () => - Effect.gen(function* () { - const diagnostics = yield* Ref.get(stderr); - return yield* new ProcessTreeTestError({ - message: `native launcher readiness timed out: ${diagnostics}`, - }); - }), - }), - ); - const lines = [...output]; - const targetLine = lines.find((line) => line.startsWith("TARGET_READY ")); - const descendantLine = lines.find((line) => line.startsWith("DESC_READY ")); - expect(targetLine).toBeDefined(); - expect(descendantLine).toBeDefined(); - const targetPort = Number.parseInt(targetLine?.slice("TARGET_READY ".length) ?? "", 10); - const descendantPort = Number.parseInt( - descendantLine?.slice("DESC_READY ".length) ?? "", - 10, - ); - expect(Number.isSafeInteger(targetPort)).toBe(true); - expect(Number.isSafeInteger(descendantPort)).toBe(true); - const targetSocket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port: targetPort }); - const descendantSocket = yield* NodeSocket.makeNet({ - host: "127.0.0.1", - port: descendantPort, - }); - const targetClosedSignal = yield* Deferred.make(); - const targetClosed = yield* targetSocket - .runRaw(() => Effect.void, { - onOpen: Deferred.succeed(targetClosedSignal, undefined), - }) - .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(targetClosedSignal); - const descendantClosedSignal = yield* Deferred.make(); - const descendantClosed = yield* descendantSocket - .runRaw(() => Effect.void, { - onOpen: Deferred.succeed(descendantClosedSignal, undefined), - }) - .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(descendantClosedSignal); - yield* owner.kill({ killSignal: "SIGKILL" }); - yield* Fiber.join(targetClosed).pipe( - Effect.timeoutOrElse({ - duration: "3 seconds", - orElse: () => - Effect.fail( - new ProcessTreeTestError({ message: "target process tree did not close" }), - ), - }), - ); - yield* Fiber.join(descendantClosed).pipe( - Effect.timeoutOrElse({ - duration: "3 seconds", - orElse: () => - Effect.fail( - new ProcessTreeTestError({ message: "descendant process tree did not close" }), + const owner = yield* ChildProcess.make(process.execPath, ["-e", ownerCode], { + stdout: "pipe", + stderr: "pipe", + detached: true, + }); + const stderr = yield* Ref.make(""); + yield* owner.stderr.pipe( + Stream.decodeText, + Stream.runForEach((chunk) => Ref.update(stderr, (current) => current + chunk)), + Effect.forkChild({ startImmediately: true }), + ); + const launcherReady = yield* Deferred.make(); + const targetReady = yield* Deferred.make(); + const descendantReady = yield* Deferred.make(); + const targetSignal = yield* Deferred.make(); + const outputFiber = yield* owner.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => + Effect.gen(function* () { + if (line.startsWith("LAUNCHER_READY ")) + yield* Deferred.succeed(launcherReady, line); + else if (line.startsWith("TARGET_READY ")) + yield* Deferred.succeed(targetReady, line); + else if (line.startsWith("DESC_READY ")) + yield* Deferred.succeed(descendantReady, line); + else if (line === "TARGET_SIGTERM") + yield* Deferred.succeed(targetSignal, undefined); + }), ), - }), + Effect.forkChild({ startImmediately: true }), + ); + const output = yield* Deferred.await(descendantReady).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => + Effect.gen(function* () { + const diagnostics = yield* Ref.get(stderr); + return yield* new ProcessTreeTestError({ + message: `native launcher readiness timed out: ${diagnostics}`, + }); + }), + }), + ); + const launcherLine = yield* Deferred.await(launcherReady); + const targetLine = yield* Deferred.await(targetReady); + const targetPort = Number.parseInt(targetLine.slice("TARGET_READY ".length), 10); + const descendantPort = Number.parseInt(output.slice("DESC_READY ".length), 10); + expect(Number.isSafeInteger(targetPort)).toBe(true); + expect(Number.isSafeInteger(descendantPort)).toBe(true); + const launcherPid = Number.parseInt(launcherLine.slice("LAUNCHER_READY ".length), 10); + expect(Number.isSafeInteger(launcherPid)).toBe(true); + const targetSocket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port: targetPort }); + const descendantSocket = yield* NodeSocket.makeNet({ + host: "127.0.0.1", + port: descendantPort, + }); + const targetClosedSignal = yield* Deferred.make(); + const targetClosed = yield* targetSocket + .runRaw(() => Effect.void, { + onOpen: Deferred.succeed(targetClosedSignal, undefined), + }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(targetClosedSignal); + const descendantClosedSignal = yield* Deferred.make(); + const descendantClosed = yield* descendantSocket + .runRaw(() => Effect.void, { + onOpen: Deferred.succeed(descendantClosedSignal, undefined), + }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(descendantClosedSignal); + expect( + spawnSync("kill", ["-TERM", `-${launcherPid}`], { encoding: "utf8" }).status, + ).toBe(0); + yield* Deferred.await(targetSignal).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => + Effect.fail( + new ProcessTreeTestError({ message: "target did not receive SIGTERM" }), + ), + }), + ); + yield* owner.kill({ killSignal: "SIGKILL" }); + yield* Fiber.join(targetClosed).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => + Effect.fail( + new ProcessTreeTestError({ message: "target process tree did not close" }), + ), + }), + ); + yield* Fiber.join(descendantClosed).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => + Effect.fail( + new ProcessTreeTestError({ message: "descendant process tree did not close" }), + ), + }), + ); + yield* Fiber.interrupt(outputFiber); + }); + } + }), + ), + ); + + it.live("terminates descendants after a native workload exits normally", () => + withPlatform( + Effect.gen(function* () { + let launcherPid: number | undefined; + yield* Effect.gen(function* () { + const descendantReady = yield* Deferred.make(); + const workloadExited = yield* Deferred.make(); + const descendantCode = ` + const net = require("node:net"); + const server = net.createServer(); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address(); + if (typeof address === "object" && address !== null) { + process.stdout.write("DESC_READY " + address.port + "\\n"); + } + }); + `; + const targetCode = ` + const { spawn } = require("node:child_process"); + const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendantCode)}], { + stdio: ["ignore", "pipe", "inherit"] + }); + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + if (chunk.toString().includes("DESC_READY ")) { + process.stdout.write("DIRECT_EXITING\\n"); + process.exit(0); + } + }); + child.on("error", () => process.exit(1)); + `; + const native = yield* spawnNativeProcess({ + executable: process.execPath, + args: ["-e", targetCode], + }); + launcherPid = Number(native.pid); + const output = yield* native.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => { + if (line.startsWith("DESC_READY ")) { + return Deferred.succeed( + descendantReady, + Number.parseInt(line.slice("DESC_READY ".length), 10), + ); + } + if (line === "DIRECT_EXITING") return Deferred.succeed(workloadExited, undefined); + return Effect.void; + }), + Effect.forkChild({ startImmediately: true }), + ); + const port = yield* Deferred.await(descendantReady).pipe(Effect.timeout("5 seconds")); + const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port }); + const opened = yield* Deferred.make(); + const closed = yield* socket + .runRaw(() => Effect.void, { onOpen: Deferred.succeed(opened, undefined) }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(opened).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(workloadExited).pipe(Effect.timeout("5 seconds")); + expect(yield* native.exitCode).toBe(0); + yield* Fiber.join(closed).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ProcessTreeTestError({ + message: + "native launcher left descendant listener alive after normal workload exit", + }), + ), + }), + ); + yield* Fiber.interrupt(output); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (launcherPid !== undefined && process.platform !== "win32") + spawnSync("kill", ["-KILL", `-${launcherPid}`], { stdio: "ignore" }); + }), + ), ); }), ), ); - // oxlint-enable effecttsgo/prefer-schema-over-json }); diff --git a/packages/stack/src/runtime/postgres-database-session.integration.test.ts b/packages/stack/src/runtime/postgres-database-session.integration.test.ts index 425c9ebdbc..fd327eeb9e 100644 --- a/packages/stack/src/runtime/postgres-database-session.integration.test.ts +++ b/packages/stack/src/runtime/postgres-database-session.integration.test.ts @@ -2,12 +2,84 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Redacted, Ref } from "effect"; import { SqlError, UnknownError } from "effect/unstable/sql/SqlError"; import { + ensureInternalDatabase, makeDatabaseSessionFromAcquisition, makeDatabaseSessionFromSqlClient, type DatabaseSqlClient, } from "./PostgresDatabaseSession.ts"; describe("Postgres database session", () => { + it.live("creates the internal database once and provisions service schemas", () => + Effect.gen(function* () { + const calls: Array<{ readonly sql: string; readonly params: ReadonlyArray }> = []; + const client: DatabaseSqlClient = { + unsafe: (sql, params = []) => + Effect.sync(() => { + calls.push({ sql, params }); + return sql.startsWith("SELECT 1 FROM pg_database") ? [] : []; + }), + withTransaction: (effect: Effect.Effect) => effect, + }; + const postgres = makeDatabaseSessionFromSqlClient(client); + const internal = makeDatabaseSessionFromSqlClient({ + unsafe: (sql, params = []) => + Effect.sync(() => { + calls.push({ sql, params }); + return []; + }), + withTransaction: (effect: Effect.Effect) => effect, + }); + + yield* Effect.scoped(ensureInternalDatabase(postgres, Effect.succeed(internal))); + + expect(calls).toEqual([ + { + sql: "SELECT 1 FROM pg_database WHERE datname = $1", + params: ["_supabase"], + }, + { sql: "CREATE DATABASE _supabase WITH OWNER postgres", params: [] }, + { + sql: "CREATE SCHEMA IF NOT EXISTS _analytics AUTHORIZATION postgres", + params: [], + }, + { sql: "ALTER SCHEMA _analytics OWNER TO postgres", params: [] }, + { + sql: "CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION postgres", + params: [], + }, + { sql: "ALTER SCHEMA _supavisor OWNER TO postgres", params: [] }, + ]); + }), + ); + + it.live("reuses an existing internal database without recreating it", () => + Effect.gen(function* () { + const calls: Array = []; + const postgres = makeDatabaseSessionFromSqlClient({ + unsafe: (sql) => + Effect.sync(() => { + calls.push(sql); + return sql.startsWith("SELECT 1 FROM pg_database") ? [{ exists: true }] : []; + }), + withTransaction: (effect: Effect.Effect) => effect, + }); + const internal = makeDatabaseSessionFromSqlClient({ + unsafe: (sql) => + Effect.sync(() => { + calls.push(sql); + return []; + }), + withTransaction: (effect: Effect.Effect) => effect, + }); + + yield* Effect.scoped(ensureInternalDatabase(postgres, Effect.succeed(internal))); + + expect(calls).not.toContain("CREATE DATABASE _supabase WITH OWNER postgres"); + expect(calls).toContain("CREATE SCHEMA IF NOT EXISTS _analytics AUTHORIZATION postgres"); + expect(calls).toContain("CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION postgres"); + }), + ); + it.live("uses parameterized format calls for role and database settings", () => Effect.gen(function* () { const calls: Array<{ readonly sql: string; readonly params: ReadonlyArray }> = []; @@ -33,16 +105,16 @@ describe("Postgres database session", () => { }), ); expect(calls[0]).toEqual({ - sql: "SELECT format('ALTER ROLE %I PASSWORD %L', $1, $2) AS statement", + sql: "SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text) AS statement", params: ["postgres", "database-secret"], }); expect(calls[1]?.sql).toContain("ALTER ROLE postgres PASSWORD"); expect(calls[2]).toEqual({ - sql: "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1, $2) AS statement", + sql: "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1::text, $2::text) AS statement", params: ["app.settings.jwt_secret", "jwt-secret"], }); expect(calls[4]).toEqual({ - sql: "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1, $2) AS statement", + sql: "SELECT format('ALTER DATABASE postgres SET %I TO %L', $1::text, $2::text) AS statement", params: ["app.settings.jwt_exp", 3600], }); }), diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index d4462e7a76..30267404e4 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -1,7 +1,10 @@ +// oxlint-disable effecttsgo/prefer-schema-over-json -- generated shell fixtures use protocol JSON quoting, not product serialization. import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { + Cause, Deferred, + Duration, Effect, Exit, FileSystem, @@ -10,6 +13,7 @@ import { Crypto, Redacted, Scope, + Option, Stream, } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import @@ -21,11 +25,18 @@ import { StackIdSchema } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; -import { makeProductionRuntimeFactory, withOwnedRuntimeFileCleanup } from "./ProductionRuntime.ts"; +import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; +import { + makeProductionRuntimeFactory, + NATIVE_DATABASE_MIGRATION_MARKER, + readinessDeadlineFor, + withOwnedRuntimeFileCleanup, +} from "./ProductionRuntime.ts"; import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; -import { StackPreparationError } from "../public/Errors.ts"; +import { InvalidStackConfigError, StackPreparationError } from "../public/Errors.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -129,6 +140,51 @@ const artifacts: RuntimeArtifactPreparer = { prepare: () => Effect.die("unused"), }; +const writeNativeDatabaseFixture = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + eventsPath: string, + failFirstMigration = false, +) => + Effect.gen(function* () { + const initDirectory = path.join(root, "share/supabase-cli/bin"); + const migrationDirectory = path.join(root, "share/supabase-cli/migrations"); + yield* fs.makeDirectory(initDirectory, { recursive: true }); + yield* fs.makeDirectory(migrationDirectory, { recursive: true }); + const main = path.join(initDirectory, "supabase-postgres-init.sh"); + yield* fs.writeFileString( + main, + "#!/bin/sh\ntrap 'exit 0' TERM INT\nwhile :; do sleep 1; done\n", + ); + yield* fs.chmod(main, 0o755); + const migrate = path.join(migrationDirectory, "migrate.sh"); + const firstMigrationPath = `${eventsPath}.migration-attempted`; + yield* fs.writeFileString( + migrate, + `#!/bin/sh +set -eu +${failFirstMigration ? `if [ ! -e ${JSON.stringify(firstMigrationPath)} ]; then touch ${JSON.stringify(firstMigrationPath)}; exit 7; fi` : ""} +printf 'migration|host=%s|port=%s|db=%s|user=%s|password=%s|path=%s\\n' "$POSTGRES_HOST" "$POSTGRES_PORT" "$POSTGRES_DB" "$POSTGRES_USER" "$POSTGRES_PASSWORD" "$PATH" >> ${JSON.stringify(eventsPath)} +`, + ); + yield* fs.chmod(migrate, 0o755); + return { main, migrate }; + }); + +const listenForNativeReadiness = (server: ReturnType) => + Effect.acquireRelease( + Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void); + server.close(() => resume(Effect.void)); + }), + ); + const envFiles: RuntimeEnvFileOwner = { write: () => Effect.die("unused"), cleanupGeneration: () => Effect.void, @@ -219,6 +275,434 @@ const ownerInputContainerEngine = (createdSpecs: ContainerContainerSpec[]): Cont }; describe("production runtime composition", () => { + it.live("activates a capability through its public listener workload", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-activation-endpoint-", + }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + capabilities: { + database: {}, + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { + enabled: true, + settings: { image_transformation: { enabled: true } }, + }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const current = { + value: { + ...stateFor({}), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + privatePorts: [ + { workloadId: "storage:imgproxy", binding: "primary", port: 41_001 }, + { workloadId: "storage:storage", binding: "primary", port: 41_002 }, + ], + }, + } satisfies { value: PersistedStackState }; + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + artifactPreparer: artifacts, + logStore: memoryLogStore([]), + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + const input = { + stackId, + generation: 1, + desiredLifecycle: "running" as const, + state: current.value, + previous: current.value, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: current.value.secrets, + plan: compiled.executionPlan, + } satisfies LifecycleInput; + if (runtime.activate === undefined) return yield* Effect.die("activation seam missing"); + const endpoint = yield* runtime.activate("storage", input); + expect(endpoint).toEqual({ host: "127.0.0.1", port: 41_002 }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("runs canonical PostgreSQL migrations before bootstrap on first native boot", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-pg-first-" }); + const eventsPath = path.join(root, "events"); + const artifactRoot = path.join(root, "artifact"); + yield* writeNativeDatabaseFixture(fs, path, artifactRoot, eventsPath); + const readinessServer = createNetServer((socket) => socket.end()); + yield* listenForNativeReadiness(readinessServer); + const address = readinessServer.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Native readiness server did not expose an address"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const current = { + value: { + ...stateFor({ + "secret:database.internal.password": { policy: "managed", value: "db-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + privatePorts: [ + { workloadId: "database:database", binding: "primary", port: address.port }, + ], + }, + } satisfies { value: PersistedStackState }; + const runtimePaths = yield* resolveStackPaths({ stateRoot: root, stackId }); + const databaseDataPath = path.join(runtimePaths.data, "database"); + const bootstrap = () => + Effect.gen(function* () { + const previous = yield* fs.readFileString(eventsPath); + yield* fs.writeFileString(eventsPath, `${previous}bootstrap\n`); + }).pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "bootstrap fixture failed", cause }), + ), + ); + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + artifactPreparer: { + prepare: () => + Effect.succeed({ + workloadId: "database:database", + capability: "database" as const, + version: "17.6.1.166", + outcome: "present" as const, + artifactRoot, + }), + }, + logStore: memoryLogStore([]), + bootstrapDatabase: bootstrap, + }); + const runtime = yield* factory.make(current.value); + const database = compiled.executionPlan.workloads.find( + (workload) => workload.id === "database:database", + ); + if (database === undefined) return yield* Effect.die("Expected database workload"); + const ready = yield* runtime.driver.start( + { + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }, + database, + ); + expect(ready.state).toBe("ready"); + expect( + yield* fs.exists(path.join(databaseDataPath, NATIVE_DATABASE_MIGRATION_MARKER)), + ).toBe(true); + const events = yield* fs.readFileString(eventsPath); + expect(events).toContain("migration|host=127.0.0.1"); + expect(events).toContain("port=" + String(address.port)); + expect(events).toContain("db=postgres"); + expect(events).toContain("user=supabase_admin"); + expect(events).toContain("password=db-secret"); + expect(events).toContain(`path=${path.join(artifactRoot, "bin")}:`); + expect(events.indexOf("migration")).toBeLessThan(events.indexOf("bootstrap")); + yield* runtime.driver.stop({ + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "retries canonical PostgreSQL migrations when PG_VERSION exists without completion marker", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-pg-existing-", + }); + const eventsPath = path.join(root, "events"); + const artifactRoot = path.join(root, "artifact"); + yield* writeNativeDatabaseFixture(fs, path, artifactRoot, eventsPath, true); + const readinessServer = createNetServer((socket) => socket.end()); + yield* listenForNativeReadiness(readinessServer); + const address = readinessServer.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Native readiness server did not expose an address"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const current = { + value: { + ...stateFor({ + "secret:database.internal.password": { policy: "managed", value: "db-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + privatePorts: [ + { workloadId: "database:database", binding: "primary", port: address.port }, + ], + }, + } satisfies { value: PersistedStackState }; + const runtimePaths = yield* resolveStackPaths({ stateRoot: root, stackId }); + const databaseDataPath = path.join(runtimePaths.data, "database"); + yield* fs.makeDirectory(databaseDataPath, { recursive: true }); + yield* fs.writeFileString(path.join(databaseDataPath, "PG_VERSION"), "17\n"); + const bootstrap = () => + Effect.gen(function* () { + const previous = yield* fs + .readFileString(eventsPath) + .pipe(Effect.catchTag("PlatformError", () => Effect.succeed(""))); + yield* fs.writeFileString(eventsPath, `${previous}bootstrap\n`); + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ message: "bootstrap fixture failed", cause }), + ), + ); + const context = yield* Effect.context< + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + artifactPreparer: { + prepare: () => + Effect.succeed({ + workloadId: "database:database", + capability: "database" as const, + version: "17.6.1.166", + outcome: "present" as const, + artifactRoot, + }), + }, + logStore: memoryLogStore([]), + bootstrapDatabase: bootstrap, + }); + const runtime = yield* factory.make(current.value); + const database = compiled.executionPlan.workloads.find( + (workload) => workload.id === "database:database", + ); + if (database === undefined) return yield* Effect.die("Expected database workload"); + const failed = yield* runtime.driver + .start( + { + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }, + database, + ) + .pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect( + yield* fs.exists(path.join(databaseDataPath, NATIVE_DATABASE_MIGRATION_MARKER)), + ).toBe(false); + yield* runtime.driver.start( + { + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }, + database, + ); + const events = yield* fs.readFileString(eventsPath); + expect(events).toContain("migration|host=127.0.0.1"); + expect(events).toContain("bootstrap\n"); + expect( + yield* fs.exists(path.join(databaseDataPath, NATIVE_DATABASE_MIGRATION_MARKER)), + ).toBe(true); + yield* runtime.driver.stop({ + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }); + yield* fs.writeFileString(eventsPath, ""); + yield* runtime.driver.start( + { + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }, + database, + ); + expect(yield* fs.readFileString(eventsPath)).toBe("bootstrap\n"); + yield* runtime.driver.stop({ + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("wires persisted database and generic readiness deadlines", () => + Effect.gen(function* () { + const configured = yield* compileStack({ + projectRoot: "/tmp/production-runtime-readiness-budget", + runtime: { kind: "native" }, + config: { capabilities: { database: { settings: { health_timeout: "90s" } } } }, + }); + const defaulted = yield* compileStack({ + projectRoot: "/tmp/production-runtime-readiness-budget-default", + runtime: { kind: "native" }, + }); + const configuredDatabase = configured.executionPlan.workloads.find( + ({ id }) => id === "database:database", + ); + const defaultDatabase = defaulted.executionPlan.workloads.find( + ({ id }) => id === "database:database", + ); + const generic = configured.executionPlan.workloads.find(({ id }) => id === "rest:rest"); + if ( + configuredDatabase === undefined || + defaultDatabase === undefined || + generic === undefined + ) + return; + const configuredState = { + ...stateFor({}), + definition: configured.definition, + inputFingerprint: configured.inputFingerprint, + }; + const defaultState = { + ...stateFor({}), + definition: defaulted.definition, + inputFingerprint: defaulted.inputFingerprint, + }; + expect( + Duration.toMillis(yield* readinessDeadlineFor(configuredState, configuredDatabase)), + ).toBe(90_000); + expect(Duration.toMillis(yield* readinessDeadlineFor(defaultState, defaultDatabase))).toBe( + 120_000, + ); + expect(Duration.toMillis(yield* readinessDeadlineFor(configuredState, generic))).toBe(30_000); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects an invalid database readiness budget before creating a workload", () => + Effect.scoped( + Effect.gen(function* () { + const result = yield* compileStack({ + projectRoot: "/tmp/production-runtime-invalid-health-timeout", + runtime: { kind: "container", engine: "docker" }, + config: { + capabilities: { database: { settings: { health_timeout: "not-a-duration" } } }, + }, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const cause = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(cause).toBeInstanceOf(InvalidStackConfigError); + } + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("rejects a zero persisted database readiness budget", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: "/tmp/production-runtime-zero-health-timeout", + runtime: { kind: "native" }, + }); + const database = compiled.executionPlan.workloads.find( + ({ id }) => id === "database:database", + ); + if (database === undefined) return; + const state = { + ...stateFor({}), + definition: { + ...compiled.definition, + capabilities: { + ...compiled.definition.capabilities, + database: { + ...compiled.definition.capabilities.database, + settings: { + ...compiled.definition.capabilities.database.settings, + health_timeout: "0", + }, + }, + }, + }, + }; + const result = yield* readinessDeadlineFor(state, database).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBeInstanceOf( + StackPreparationError, + ); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("exposes artifact-only preparation without starting workloads or mutating state", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/readiness-probe.integration.test.ts b/packages/stack/src/runtime/readiness-probe.integration.test.ts index 97ff0da2b5..1cdd9439b0 100644 --- a/packages/stack/src/runtime/readiness-probe.integration.test.ts +++ b/packages/stack/src/runtime/readiness-probe.integration.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Data, Deferred, Effect, Exit, Fiber, Option } from "effect"; +import { Cause, Data, Deferred, Duration, Effect, Exit, Fiber, Option } from "effect"; +import * as TestClock from "effect/testing/TestClock"; /* oxlint-disable effecttsgo/node-builtin-import -- integration test owns real listeners. */ import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; import { createServer as createTcpServer, type Server as TcpServer } from "node:net"; import { StackPreparationError } from "../public/Errors.ts"; +import { parseGoDuration } from "../model/capabilities/database.ts"; import { probeReadiness } from "./ReadinessProbe.ts"; class ListenerError extends Data.TaggedError("ListenerError")<{ readonly message: string }> {} @@ -49,6 +51,34 @@ const listenTcp = (server: TcpServer): Effect.Effect => }); describe("private endpoint readiness probe", () => { + it("parses supported Go duration strings without accepting negative budgets", () => { + expect(Duration.toMillis(parseGoDuration("1h30m2.5s"))).toBe(5_402_500); + expect(Duration.toMillis(parseGoDuration("250ms"))).toBe(250); + expect(Duration.toNanosUnsafe(parseGoDuration("1µs"))).toEqual(1_000n); + expect(Duration.toNanosUnsafe(parseGoDuration("1μs"))).toEqual(1_000n); + expect(Duration.toMillis(parseGoDuration("0s"))).toBe(0); + expect(() => parseGoDuration("-1s")).not.toThrow(); + expect(Duration.isNegative(parseGoDuration("-1s"))).toBe(true); + for (const invalid of ["", "1", "9223372036854775808ns", "Infinity"]) + expect(() => parseGoDuration(invalid)).toThrow(); + }); + + it.live("rejects negative and non-finite readiness deadlines as typed preparation failures", () => + Effect.gen(function* () { + for (const deadline of [Duration.seconds(-1), Duration.infinity]) { + const result = yield* probeReadiness( + { mode: "tcp", host: "127.0.0.1", port: 1 }, + { deadline }, + ).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const cause = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(cause).toBeInstanceOf(StackPreparationError); + } + } + }), + ); + it.live("passes validated request headers to HTTP readiness endpoints", () => Effect.scoped( Effect.gen(function* () { @@ -152,6 +182,30 @@ describe("private endpoint readiness probe", () => { ), ); + it.live("performs exactly one immediate probe for a zero readiness budget", () => + Effect.scoped( + Effect.gen(function* () { + let count = 0; + const server = createHttpServer((_request, response) => { + count += 1; + response.statusCode = 503; + response.end("not ready"); + }); + const port = yield* listenHttp(server, () => undefined); + const result = yield* probeReadiness( + { mode: "http", host: "127.0.0.1", port }, + { deadline: Duration.zero }, + ).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const cause = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(cause).toMatchObject({ message: "Readiness HTTP status was not successful" }); + } + expect(count).toBe(1); + }), + ), + ); + it.live("interrupts an in-flight HTTP request and closes its owned socket", () => Effect.scoped( Effect.gen(function* () { @@ -170,4 +224,38 @@ describe("private endpoint readiness probe", () => { }), ), ); + + it.effect("interrupts an in-flight HTTP request at its total deadline", () => + Effect.scoped( + Effect.gen(function* () { + const received = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const server = createHttpServer((request) => { + // oxlint-disable-next-line effecttsgo/run-effect-inside-effect -- bridge event callback to test signal. + Effect.runSync(Deferred.succeed(received, undefined)); + request.once("close", () => { + // oxlint-disable-next-line effecttsgo/run-effect-inside-effect -- bridge event callback to test signal. + Effect.runSync(Deferred.succeed(closed, undefined)); + }); + }); + const port = yield* listenHttp(server, () => undefined); + const fiber = yield* Effect.forkChild( + probeReadiness( + { mode: "http", host: "127.0.0.1", port, path: "/deadline" }, + { deadline: Duration.millis(10) }, + ), + { startImmediately: true }, + ); + yield* Deferred.await(received); + yield* TestClock.adjust(Duration.millis(10)); + const result = yield* Fiber.join(fiber).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const cause = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(cause).toMatchObject({ message: "Readiness deadline exceeded" }); + } + yield* Deferred.await(closed); + }), + ), + ); }); diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 1da6ff437c..0870998597 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -411,12 +411,18 @@ describe("runtime input owner", () => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-" }); const state = yield* compiledState(root, { - capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, + capabilities: { + analytics: { settings: { vector_port: 9001 } }, + pooler: { enabled: true, settings: { pool_mode: "session" } }, + }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); const nativeState: PersistedStackState = { ...state, - privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], + privatePorts: [ + { workloadId: "database:database", binding: "primary", port: 30_001 }, + { workloadId: "analytics:vector", binding: "primary", port: 30_008 }, + ], }; const first = yield* owner.resolve(nativeState, 3, { includePooler: true }); const second = yield* owner.resolve(nativeState, 3, { includePooler: true }); @@ -429,13 +435,58 @@ describe("runtime input owner", () => { expect(content).toContain('"external_id" => "pooler-dev"'); expect(content).toContain('"db_port" => 30001'); expect(content).not.toContain('"db_password" => ""'); + expect(first.analytics?.vectorConfigPath).toBeDefined(); const fourth = yield* owner.resolve(nativeState, 4, { includePooler: true }); const fourthGenerationTenant = fourth.pooler?.tenantPath; expect(fourthGenerationTenant).toBeDefined(); expect(fourthGenerationTenant).not.toBe(thirdGenerationTenant); - yield* owner.cleanupGeneration(3); + yield* owner.cleanupGeneration(3, "pooler"); expect(yield* fs.exists(path.dirname(thirdGenerationTenant!))).toBe(false); expect(yield* fs.exists(fourthGenerationTenant!)).toBe(true); + expect(yield* fs.exists(first.analytics?.vectorConfigPath ?? "")).toBe(true); + }), + ), + ); + + it.live("writes and cleans generation-scoped Vector config material", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-vector-" }); + const base = yield* compiledState(root, { + capabilities: { analytics: { settings: { vector_port: 9001 } } }, + }); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + const native: PersistedStackState = { + ...base, + privatePorts: [{ workloadId: "analytics:vector", binding: "primary", port: 30_008 }], + }; + const material = yield* owner.resolve(native, 7); + const configPath = material.analytics?.vectorConfigPath; + expect(configPath).toBeDefined(); + expect(yield* fs.stat(configPath!)).toMatchObject({ type: "File" }); + expect(yield* fs.readFileString(configPath!)).toContain('address: "${VECTOR_API_ADDRESS}"'); + expect(yield* fs.readFileString(configPath!)).toContain("type: demo_logs"); + expect(yield* fs.readFileString(configPath!)).toContain("count: 1"); + expect(yield* fs.readFileString(configPath!)).toContain("type: internal_metrics"); + expect(yield* fs.readFileString(configPath!)).toContain("type: blackhole"); + const config = yield* fs.readFileString(configPath!); + expect(config).toContain("type: remap"); + expect(config).toContain('.event_message = "supabase-stack-vector"'); + expect(config).toContain("del(.message)"); + expect(config).toContain('uri: "${LOGFLARE_URL}/logs?source_name=postgres.logs"'); + expect(config).toContain('x-api-key: "${LOGFLARE_PRIVATE_ACCESS_TOKEN}"'); + expect(config).toContain("retry_attempts: 5"); + expect(config).toContain("retry_max_duration_secs: 10"); + expect(config).not.toContain('x-api-key: "api-key"'); + yield* owner.cleanupGeneration(7, "vector"); + expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "vector", "7"))).toBe( + false, + ); + const rematerialized = yield* owner.resolve(native, 7); + expect(rematerialized.analytics?.vectorConfigPath).toBeDefined(); + expect(yield* fs.exists(rematerialized.analytics?.vectorConfigPath ?? "")).toBe(true); }), ), ); @@ -457,7 +508,7 @@ describe("runtime input owner", () => { const first = yield* owner.resolve(nativeState, 3, { includePooler: true }); const tenant = first.pooler?.tenantPath; expect(tenant).toBeDefined(); - yield* owner.cleanupGeneration(3); + yield* owner.cleanupGeneration(3, "pooler"); const common = yield* owner.resolve(nativeState, 3); expect(common.pooler).toBeUndefined(); expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler", "3"))).toBe( diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index bc6a5fef93..3f8402280d 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -1,9 +1,13 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Redacted } from "effect"; +import { Effect, Exit, FileSystem, Redacted } from "effect"; import { WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { deriveStackId } from "../identity/Identity.ts"; import type { PersistedStackState } from "../state/StackState.ts"; +import { makePortCoordinator, type ListenerIntents } from "../state/PortCoordinator.ts"; +import { makePortRegistry } from "../state/PortRegistry.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { compileStack } from "../model/Compiler.ts"; import { @@ -16,6 +20,17 @@ import { validateWorkloadRuntimeInputs, } from "./WorkloadRuntimeSpec.ts"; +const disabledListenerIntents: ListenerIntents = { + api: { enabled: false, address: "127.0.0.1", port: "automatic" }, + database: { enabled: false, address: "127.0.0.1", port: "automatic" }, + pooler: { enabled: false, address: "127.0.0.1", port: "automatic" }, + studio: { enabled: false, address: "127.0.0.1", port: "automatic" }, + mailUi: { enabled: false, address: "127.0.0.1", port: "automatic" }, + smtp: { enabled: false, address: "127.0.0.1", port: "automatic" }, + pop3: { enabled: false, address: "127.0.0.1", port: "automatic" }, + functionsInspector: { enabled: false, address: "127.0.0.1", port: "automatic" }, +}; + const state: PersistedStackState = { format: "supabase-stack-state-v1", identity: { @@ -109,6 +124,80 @@ describe("workload runtime catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("compiles pgmeta's primary and admin ports before the Vector companion", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { + capabilities: { + analytics: { enabled: true, settings: { vector_port: 9001 } }, + studio: { enabled: true }, + }, + }, + }).pipe(Effect.provide(NodeServices.layer)); + const relevant = privateBindingIntentsFor(compiled.executionPlan).filter( + ({ workloadId }) => workloadId === "studio:pgmeta" || workloadId === "analytics:vector", + ); + expect(relevant).toEqual([ + { workloadId: "studio:pgmeta", binding: "primary" }, + { workloadId: "studio:pgmeta", binding: "admin" }, + { workloadId: "analytics:vector", binding: "primary" }, + ]); + + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pgmeta-ports-" }); + const identity = { + ...state.identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }; + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.write(stackId, { + ...state, + identity: { ...identity, stackId }, + desiredGeneration: 0, + portsGeneration: null, + desiredLifecycle: "stopped", + ports: [], + privatePorts: [], + }); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const reservation = yield* makePortCoordinator(registry, store).planAndReserve( + stackId, + disabledListenerIntents, + { + lifecycle: "stopped", + privateBindings: privateBindingIntentsFor(compiled.executionPlan), + }, + ); + const pgmetaPrimary = reservation.privateAssignments.find( + ({ workloadId, binding }) => workloadId === "studio:pgmeta" && binding === "primary", + ); + const pgmetaAdmin = reservation.privateAssignments.find( + ({ workloadId, binding }) => workloadId === "studio:pgmeta" && binding === "admin", + ); + const vectorPrimary = reservation.privateAssignments.find( + ({ workloadId, binding }) => workloadId === "analytics:vector" && binding === "primary", + ); + if (pgmetaPrimary === undefined || pgmetaAdmin === undefined || vectorPrimary === undefined) + throw new Error("Compiled plan did not reserve pgmeta and Vector bindings"); + expect(pgmetaAdmin.port).toBe(pgmetaPrimary.port + 1); + expect(vectorPrimary.port).toBeGreaterThan(pgmetaAdmin.port); + const pgmetaResolution = containerResolutionFor( + { ...state, privatePorts: reservation.privateAssignments }, + planned("studio:pgmeta"), + ); + expect(pgmetaResolution?.publications).toEqual([ + { address: "127.0.0.1", hostPort: pgmetaPrimary.port, containerPort: 8080 }, + { address: "127.0.0.1", hostPort: pgmetaAdmin.port, containerPort: 8081 }, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it("provides private command, environment and readiness metadata for every workload", () => { for (const [index, id] of Object.keys(WORKLOAD_CATALOG).entries()) { const workload = planned(id); @@ -237,6 +326,11 @@ describe("workload runtime catalog", () => { const realtime = planned("realtime:realtime"); expect(runtimeSpecFor(realtime)?.nativeStartupProcesses(root, state, realtime, 4000)).toEqual([ { executable: `${root}/bin/migrate`, args: [], cwd: root }, + { + executable: `${root}/bin/realtime`, + args: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], + cwd: root, + }, ]); const analytics = planned("analytics:analytics"); expect(runtimeSpecFor(analytics)?.nativeStartupProcesses(root, state, analytics, 4000)).toEqual( @@ -338,6 +432,7 @@ describe("workload runtime catalog", () => { functions: { hello: { verify_jwt: false } }, }, }, + studio: { settings: { api_url: "https://studio.example" } }, pooler: { enabled: true, settings: { pool_mode: "session", max_client_conn: 250 } }, auth: { settings: { @@ -385,10 +480,27 @@ describe("workload runtime catalog", () => { }, }, }); - const configured: PersistedStackState = { ...state, definition: compiled.definition }; + const configured: PersistedStackState = { + ...state, + definition: compiled.definition, + secrets: { + ...state.secrets, + "secret:analytics.settings.api_key": { policy: "passthrough", value: "api-key" }, + }, + }; const rest = runtimeSpecFor(planned("rest:rest")); const storage = runtimeSpecFor(planned("storage:storage")); const functions = planned("functions:edge-runtime"); + expect(runtimeSpecFor(functions)?.env(configured, functions, 9000, "native")).toMatchObject( + { + SUPABASE_URL: "http://127.0.0.1:54321", + }, + ); + expect( + runtimeSpecFor(functions)?.env(configured, functions, 9000, "container", { + hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + }), + ).toMatchObject({ SUPABASE_URL: "http://host.docker.internal:54321" }); expect(rest?.env(configured, planned("rest:rest"), 3000, "native")).toMatchObject({ PGRST_DB_SCHEMAS: "private", PGRST_DB_EXTRA_SEARCH_PATH: "extensions", @@ -479,7 +591,8 @@ describe("workload runtime catalog", () => { auth: { jwks: '{"keys":[]}' }, }), ).toMatchObject({ ERL_AFLAGS: "-proto_dist inet6_tcp", API_JWT_JWKS: '{"keys":[]}' }); - expect(realtime?.readiness.headers).toEqual({ Host: "realtime-dev" }); + expect(realtime?.readiness.headers).toBeUndefined(); + expect(realtime?.readiness.path).toBe("/healthcheck"); const analytics = runtimeSpecFor(planned("analytics:analytics")); const analyticsInputs = { analytics: { gcpJwtPath: "/tmp/gcp.json" } }; expect( @@ -495,6 +608,28 @@ describe("workload runtime catalog", () => { GOOGLE_PROJECT_NUMBER: "42", GOOGLE_APPLICATION_CREDENTIALS: "/tmp/gcp.json", }); + expect( + analytics?.env(configured, planned("analytics:analytics"), 4000, "native"), + ).toMatchObject({ + LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", + }); + expect( + analytics?.env(configured, planned("analytics:analytics"), 4000, "native"), + ).not.toHaveProperty("LOGFLARE_PUBLIC_ACCESS_TOKEN"); + expect( + analytics?.env(configured, planned("analytics:analytics"), 4000, "container"), + ).toMatchObject({ + LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", + }); + expect( + analytics?.env(configured, planned("analytics:analytics"), 4000, "container"), + ).not.toHaveProperty("LOGFLARE_PUBLIC_ACCESS_TOKEN"); + expect(analytics?.env(state, planned("analytics:analytics"), 4000, "native")).toMatchObject( + { + DB_USERNAME: "supabase_admin", + POSTGRES_BACKEND_URL: expect.stringContaining("/_supabase"), + }, + ); expect( analytics?.containerMounts?.(configured, planned("analytics:analytics"), analyticsInputs), ).toEqual([ @@ -612,17 +747,77 @@ describe("workload runtime catalog", () => { ), ).toEqual({ executable: "/tmp/native-artifact/bin/vector", - args: [ - "--config", - "/tmp/native-artifact/share/doc/vector/config/vector.yaml", - "--watch-config", - "false", - ], + args: ["--config", "/tmp/native-artifact/share/doc/vector/config/vector.yaml"], + cwd: "/tmp/supabase-runtime-spec", + }); + expect( + runtimeSpecFor(planned("analytics:vector"))?.nativeProcess( + nodeArtifactRoot, + configured, + planned("analytics:vector"), + 9001, + { analytics: { vectorConfigPath: "/tmp/vector.yaml" } }, + ), + ).toEqual({ + executable: "/tmp/native-artifact/bin/vector", + args: ["--config", "/tmp/vector.yaml"], cwd: "/tmp/supabase-runtime-spec", }); + expect( + runtimeSpecFor(planned("analytics:vector"))?.env( + configured, + planned("analytics:vector"), + 9001, + "native", + ).VECTOR_API_ADDRESS, + ).toBe("127.0.0.1:30014"); + expect( + runtimeSpecFor(planned("analytics:vector"))?.env( + configured, + planned("analytics:vector"), + 9001, + "container", + ).VECTOR_API_ADDRESS, + ).toBe("0.0.0.0:9001"); + expect( + runtimeSpecFor(planned("analytics:vector"))?.env( + configured, + planned("analytics:vector"), + 9001, + "native", + ), + ).toMatchObject({ + LOGFLARE_URL: "http://127.0.0.1:30013", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", + }); + expect( + runtimeSpecFor(planned("analytics:vector"))?.env( + configured, + planned("analytics:vector"), + 9001, + "container", + ), + ).toMatchObject({ + LOGFLARE_URL: "http://supabase-analytics:4000", + LOGFLARE_PRIVATE_ACCESS_TOKEN: "api-key", + }); const vector = planned("analytics:vector"); expect(containerResolutionFor(configured, vector)?.command).toEqual([]); expect(containerResolutionFor(configured, vector)?.mounts).toEqual([]); + expect( + containerResolutionFor(configured, vector, { + analytics: { vectorConfigPath: "/tmp/vector.yaml" }, + }), + ).toMatchObject({ + command: ["--config", "/etc/vector/vector.yaml"], + mounts: [ + { + source: "/tmp/vector.yaml", + target: "/etc/vector/vector.yaml", + readOnly: true, + }, + ], + }); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index f3ee5c17ce..ae7e8d3297 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -22,6 +22,8 @@ export type ControlEndpoint = UnixControlEndpoint | WindowsControlEndpoint; /** Host details are supplied by the process composition boundary. */ export interface StackRuntimeEnvironmentValue { readonly stateRoot: string; + /** Optional shared immutable artifact cache; defaults to `/artifacts`. */ + readonly artifactCacheRoot?: string; /** A short local IPC directory (for example `/tmp`). */ readonly tempRoot: string; readonly platform: "posix" | "windows"; diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index 2dfca012e2..b61409cf77 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -20,6 +20,7 @@ export type SecretGenerator = | { readonly kind: "publishable-key" } | { readonly kind: "secret-key" } | { readonly kind: "jwt-secret" } + | { readonly kind: "random-base64url"; readonly bytes: number } | { readonly kind: "jwt-token"; readonly role: "anon" | "service_role"; @@ -442,6 +443,8 @@ const generatedFor = ( ); case "jwt-secret": return randomEncoded(crypto, 32, declaration.slot); + case "random-base64url": + return randomEncoded(crypto, generator.bytes, declaration.slot); case "jwt-token": return generateJwtToken(generator, resolved[AUTH_JWT_SECRET_SLOT]?.value); } diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index e8315da077..772cba8bdb 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Option, Scope } from "effect"; import { createServer, type Server } from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import -import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; +import { createServer as createHttpServer } from "node:http"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { PortAllocationError, @@ -14,6 +14,7 @@ import { import { makePortRegistry } from "./PortRegistry.ts"; import { makePortCoordinator, type HostListener, type ListenerIntents } from "./PortCoordinator.ts"; import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; +import { bindHostListener } from "../supervisor/HostListener.ts"; const layer = NodeServices.layer; const intents = (port: "automatic" | number): ListenerIntents => ({ @@ -97,32 +98,7 @@ const holdHostPort = (port: number): Effect.Effect = }), ); -const holdHttpPort = (port: number): Effect.Effect => - Effect.acquireRelease( - Effect.callback((resume) => { - const server = createHttpServer(); - const onError = (error: Error) => { - server.removeListener("error", onError); - resume(Effect.fail(error)); - }; - server.once("error", onError); - server.listen({ host: "127.0.0.1", port }, () => { - server.removeListener("error", onError); - resume(Effect.succeed(server)); - }); - return Effect.sync(() => server.removeListener("error", onError)); - }), - (server) => - Effect.callback((resume) => { - if (!server.listening) { - resume(Effect.void); - return; - } - server.close(() => resume(Effect.void)); - }), - ); - -const bindTestNative = ( +const makeTestHostListener = ( address: string, port: number, field: HostListener["field"], @@ -132,35 +108,41 @@ const bindTestNative = ( field === "api" || field === "studio" || field === "mailUi" || field === "functionsInspector"; if (isHttp) return Effect.acquireRelease( - holdHttpPort(port).pipe( - Effect.map((server) => ({ + Effect.gen(function* () { + const server = createHttpServer(); + const close = yield* Effect.cached( + Effect.sync(() => { + if (server.listening) server.close(); + hooks.onClose?.(); + }), + ); + return { address, port, field, - binding: { kind: "http" as const, server }, - close: Effect.sync(() => hooks.onClose?.()), - })), - Effect.mapError( - (cause) => - new PortUnavailableError({ port, field, message: "host listener unavailable", cause }), - ), - ), + binding: { kind: "http", server }, + close, + } satisfies HostListener; + }), (listener) => listener.close, ); return Effect.acquireRelease( - holdHostPort(port).pipe( - Effect.map((server) => ({ + Effect.gen(function* () { + const server = createServer({ allowHalfOpen: true }); + const close = yield* Effect.cached( + Effect.sync(() => { + if (server.listening) server.close(); + hooks.onClose?.(); + }), + ); + return { address, port, field, - binding: { kind: "tcp" as const, server, allowHalfOpen: true as const }, - close: Effect.sync(() => hooks.onClose?.()), - })), - Effect.mapError( - (cause) => - new PortUnavailableError({ port, field, message: "host listener unavailable", cause }), - ), - ), + binding: { kind: "tcp", server, allowHalfOpen: true }, + close, + } satisfies HostListener; + }), (listener) => listener.close, ); }; @@ -299,7 +281,7 @@ describe("sticky port coordination", () => { }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindHost: bindTestNative, + bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve( stackId, @@ -333,7 +315,7 @@ describe("sticky port coordination", () => { }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindHost: bindTestNative, + bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve( stackId, @@ -370,7 +352,7 @@ describe("sticky port coordination", () => { }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindHost: bindTestNative, + bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve(stackId, disabledIntents(), { lifecycle: "running", @@ -473,7 +455,7 @@ describe("sticky port coordination", () => { const occupiedPort = hostAddress.port; const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { - bindHost: bindTestNative, + bindHost: bindHostListener, }); const exit = yield* coordinator .planAndReserve( @@ -495,7 +477,7 @@ describe("sticky port coordination", () => { ), ); - it.live("transfers native sockets without a bind-close-rebind", () => + it.live("retains coordinated listener ownership through the enclosing scope", () => Effect.gen(function* () { const bound = new Set(); let closed = 0; @@ -512,7 +494,7 @@ describe("sticky port coordination", () => { bindHost: (address, port, field) => bound.has(port) ? Effect.fail(new PortUnavailableError({ port, field, message: "already bound" })) - : bindTestNative(address, port, field, { + : makeTestHostListener(address, port, field, { onClose: () => { bound.delete(port); closed += 1; @@ -553,7 +535,7 @@ describe("sticky port coordination", () => { return Effect.fail( new PortUnavailableError({ port, field, message: "simulated second bind failure" }), ); - return bindTestNative(address, port, field, { + return makeTestHostListener(address, port, field, { onClose: () => bound.delete(port), }).pipe(Effect.tap(() => Effect.sync(() => bound.add(port)))); }, @@ -593,7 +575,7 @@ describe("sticky port coordination", () => { const closed = new Set(); const coordinator = makePortCoordinator(registry, store, { bindHost: (address, port, field) => - bindTestNative(address, port, field, { + makeTestHostListener(address, port, field, { onClose: () => { bound.delete(port); closed.add(port); diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index 75fa75169f..5dddbd7199 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -95,6 +95,25 @@ describe("managed and pass-through secrets", () => { }).pipe(Effect.provide(layer)), ); + it.live("generates artifact-compatible pooler managed keys", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: "/tmp/project", + runtime: { kind: "native" }, + config: { capabilities: { pooler: { enabled: true } } }, + }); + const candidate = { declarations: compiled.secrets }; + const first = yield* resolveSecrets(candidate, undefined, "stopped"); + const second = yield* resolveSecrets(candidate, first.persisted, "running"); + const encryptionKey = first.persisted["secret:pooler.settings.encryption_key"]?.value; + const secretKeyBase = first.persisted["secret:pooler.settings.secret_key_base"]?.value; + expect(encryptionKey).toMatch(/^[A-Za-z0-9_-]{32}$/); + expect(secretKeyBase).toMatch(/^[A-Za-z0-9_-]{64}$/); + expect(second.persisted["secret:pooler.settings.encryption_key"]?.value).toBe(encryptionKey); + expect(second.persisted["secret:pooler.settings.secret_key_base"]?.value).toBe(secretKeyBase); + }).pipe(Effect.provide(layer)), + ); + it.live("rejects a configured JWKS file without a private signing key", () => Effect.gen(function* () { const compiled = yield* compileStack({ diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 224c4adb4e..0df73a0527 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -229,7 +229,9 @@ export const makeSupervisorIngress = ( entry.gateway !== undefined ) return; - const material = yield* (options.apiMaterial ?? defaultApiMaterial)(input.state); + const material = input.definition.listeners.api.enabled + ? yield* (options.apiMaterial ?? defaultApiMaterial)(input.state) + : undefined; const catalog = routeCatalogFor(input.plan, material); const resolveTemplates = options.resolveAuthTemplates; const templateRoute: GatewayRoute | undefined = diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 122aedad21..85854f0a65 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -84,6 +84,7 @@ type ChildResult = interface LaunchPayload { readonly stateRoot: string; + readonly artifactCacheRoot?: string; readonly tempRoot: string; readonly platform: "posix" | "windows"; readonly stackId: StackId; @@ -363,6 +364,9 @@ export const ensureSupervisor = ( ); const payload: LaunchPayload = { stateRoot: options.environment.stateRoot, + ...(options.environment.artifactCacheRoot === undefined + ? {} + : { artifactCacheRoot: options.environment.artifactCacheRoot }), tempRoot: options.environment.tempRoot, platform: options.environment.platform, stackId: options.stackId, diff --git a/packages/stack/src/supervisor/Reconciler.ts b/packages/stack/src/supervisor/Reconciler.ts index 6f6e69d0ef..98769e5419 100644 --- a/packages/stack/src/supervisor/Reconciler.ts +++ b/packages/stack/src/supervisor/Reconciler.ts @@ -82,7 +82,6 @@ class RuntimeAttemptControlError extends RuntimeDriverError { export const makeReconciler = (options: ReconcilerOptions): Effect.Effect => Effect.gen(function* () { const lifecycle = yield* Semaphore.make(1); - const readinessTimeout = options.readinessTimeout ?? "30 seconds"; // Restart budgets are Supervisor-local. A new accepted generation gets a fresh key; repeated // observations of the same failed generation cannot silently reset an exhausted budget. const exhausted = new Map(); @@ -215,21 +214,25 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect - Option.isSome(result) - ? Effect.succeed(result.value) - : Effect.fail( - new RuntimeReadinessTimeoutError({ - message: `Readiness deadline exceeded for ${action.workload.id}`, - stackId: request.stackId, - workloadId: action.workload.id, - desiredGeneration: request.desiredGeneration, - }), + const start = options.driver.start(action.key, action.workload); + const attempt = + options.readinessTimeout === undefined + ? start + : start.pipe( + Effect.timeoutOption(options.readinessTimeout), + Effect.flatMap((result) => + Option.isSome(result) + ? Effect.succeed(result.value) + : Effect.fail( + new RuntimeReadinessTimeoutError({ + message: `Readiness deadline exceeded for ${action.workload.id}`, + stackId: request.stackId, + workloadId: action.workload.id, + desiredGeneration: request.desiredGeneration, + }), + ), ), - ), - ); + ); const schedule: Schedule.Schedule = Schedule.exponential(`${Math.max(0, action.workload.restart.backoffMs)} millis`).pipe( Schedule.upTo({ times: Math.max(0, maxAttempts - 1) }), diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index b2f45b4230..24e63a6472 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -212,6 +212,12 @@ const activePlan = (plan: ExecutionPlan, active: ReadonlySet): E stopOrder: plan.stopOrder.filter((name) => active.has(name)), }); +const observedForCapability = ( + name: CapabilityName, + observed: ReadonlyArray, +): ReadonlyArray => + observed.filter((entry) => entry.workloadId.startsWith(`${name}:`)); + const capabilityState = ( name: CapabilityName, state: PersistedStackState, @@ -232,13 +238,27 @@ const capabilityState = ( if (recoveryFailed) return "failed"; if (configured.activation === "lazy" && !active.has(name)) return "dormant"; if (phase === "starting") return "starting"; - const resources = observed.filter((entry) => entry.workloadId.startsWith(`${name}:`)); + const resources = observedForCapability(name, observed); if (resources.some((entry) => entry.state === "failed")) return "failed"; if (resources.some((entry) => entry.state === "starting")) return "starting"; if (resources.length > 0 && resources.every((entry) => entry.state === "ready")) return "ready"; return "stopped"; }; +const capabilityError = ( + name: CapabilityName, + observed: ReadonlyArray, + state: CapabilityStatus["state"], +): string | undefined => { + if (state !== "failed") return undefined; + const failed = observedForCapability(name, observed).filter((entry) => entry.state === "failed"); + if (failed.length === 1) return failed[0]?.error; + const details = failed.flatMap((entry) => + entry.error === undefined ? [] : [`${entry.workloadId}: ${entry.error}`], + ); + return details.length === 0 ? undefined : details.join("; "); +}; + const statusFor = ( state: PersistedStackState, observed: ReadonlyArray, @@ -253,11 +273,16 @@ const statusFor = ( ), Effect.map((id) => { const definition = state.definition; - const capabilities = CAPABILITY_NAMES.map((name) => ({ - name, - activation: definition?.capabilities[name].activation ?? "eager", - state: capabilityState(name, state, observed, active, phase, recoveryFailed), - })); + const capabilities = CAPABILITY_NAMES.map((name) => { + const capability = capabilityState(name, state, observed, active, phase, recoveryFailed); + const error = capabilityError(name, observed, capability); + return { + name, + activation: definition?.capabilities[name].activation ?? "eager", + state: capability, + ...(error === undefined ? {} : { error }), + }; + }); const versions: Partial> = {}; if (definition !== undefined) for (const name of CAPABILITY_NAMES) versions[name] = definition.capabilities[name].version; diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index c4e0a611fd..7fce4b5962 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -2,18 +2,23 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, + Crypto, + Deferred, Effect, Exit, FileSystem, Fiber, Option, Path, + Redacted, Result, Schema, + Scope, Stream, } from "effect"; import { ChildProcess } from "effect/unstable/process"; import { compileStack } from "../model/Compiler.ts"; +import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { createStack, findStack, @@ -21,6 +26,7 @@ import { openStack, listStacks, } from "../public/EffectStack.ts"; +import type { StackStatus } from "../public/Status.ts"; import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; import { defaultRuntimeEnvironment, @@ -30,13 +36,19 @@ import { } from "./Launcher.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { StackId } from "../public/StackId.ts"; -import { readOwnerMetadata, StackRuntimeEnvironment } from "../state/Ownership.ts"; +import { + acquireOwnership, + publishOwnership, + readOwnerMetadata, + StackRuntimeEnvironment, +} from "../state/Ownership.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { makeControlClient } from "../control/ControlServer.ts"; +import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; import { resolveStackPaths } from "../state/Paths.ts"; -import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; +import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; import { ContainerEngineError, + StackPreparationError, StackRuntimeMismatchError, StackUpgradeRequiredError, } from "../public/Errors.ts"; @@ -329,6 +341,209 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); + it.live("preserves preparation failures returned by the owner start RPC", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const crypto = yield* Crypto.Crypto; + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + yield* store.initialize(stackId, { + format: "supabase-stack-state-v1", + identity: { ...identity, stackId }, + runtime: { kind: "native" }, + desiredGeneration: 0, + portsGeneration: null, + desiredLifecycle: "stopped", + ports: [], + privatePorts: [], + secrets: {}, + }); + const ownerSessionId = yield* crypto.randomUUIDv4; + const lease = yield* acquireOwnership({ + stateRoot: env.stateRoot, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + environment: env, + }); + yield* publishOwnership(lease); + const status: StackStatus = { + id: stackId, + lifecycle: "stopped", + desiredLifecycle: "stopped", + runtime: { kind: "native" }, + desiredGeneration: 0, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: "eager", + state: "stopped", + })), + }; + const handlers: StackRpcHandlers = { + status: () => Effect.succeed(status), + credentials: () => + Effect.succeed({ + database: { + url: Redacted.make("postgres://localhost"), + password: Redacted.make("secret"), + }, + api: { + publishableKey: "publishable", + secretKey: Redacted.make("secret"), + anonJwt: "anon", + serviceRoleJwt: Redacted.make("service"), + }, + }), + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => + Effect.fail({ tag: "StackPreparationError", message: "artifact is incomplete" }), + restart: () => Effect.succeed(status), + destroy: () => Effect.void, + logs: () => Stream.empty, + watchStatus: () => Stream.empty, + }; + yield* startControlServer({ + endpoint: lease.metadata.endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + quiesce: Effect.succeed({ ok: true, op: "quiesce" }), + }, + rpcHandlers: handlers, + }); + const stack = yield* openStack(stackId); + const failed = yield* stack.start({ config: {} }).pipe(Effect.exit); + const error = Exit.isFailure(failed) + ? Option.getOrUndefined(Cause.findErrorOption(failed.cause)) + : undefined; + yield* stack.close(); + yield* lease.release; + expect(Exit.isFailure(failed)).toBe(true); + expect(error).toBeInstanceOf(StackPreparationError); + expect(error).toMatchObject({ message: "artifact is incomplete" }); + }), + ), + ); + + it.live("waits for the owner control socket to close before destroy resolves", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const crypto = yield* Crypto.Crypto; + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + yield* store.initialize(stackId, { + format: "supabase-stack-state-v1", + identity: { ...identity, stackId }, + runtime: { kind: "native" }, + desiredGeneration: 0, + portsGeneration: null, + desiredLifecycle: "stopped", + ports: [], + privatePorts: [], + secrets: {}, + }); + const ownerSessionId = yield* crypto.randomUUIDv4; + const lease = yield* acquireOwnership({ + stateRoot: env.stateRoot, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + environment: env, + }); + yield* publishOwnership(lease); + const ownerScope = yield* Scope.make(); + const status: StackStatus = { + id: stackId, + lifecycle: "stopped", + desiredLifecycle: "stopped", + runtime: { kind: "native" }, + desiredGeneration: 0, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: "eager", + state: "stopped", + })), + }; + const destroyStarted = yield* Deferred.make(); + const responseRelease = yield* Deferred.make(); + const callbackStarted = yield* Deferred.make(); + const callbackRelease = yield* Deferred.make(); + const callbackCompleted = yield* Deferred.make(); + const destroyDone = yield* Deferred.make(); + yield* startControlServer({ + endpoint: lease.metadata.endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + quiesce: Effect.succeed({ ok: true, op: "quiesce" }), + }, + rpcHandlers: { + status: () => Effect.succeed(status), + credentials: () => + Effect.fail({ tag: "StackNotRunningError" as const, message: "not running" }), + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => Effect.succeed(status), + restart: () => Effect.succeed(status), + destroy: () => + Deferred.succeed(destroyStarted, undefined).pipe( + Effect.andThen(Deferred.await(responseRelease)), + Effect.asVoid, + ), + logs: () => Stream.empty, + watchStatus: () => Stream.make(status), + }, + onDestroyResponse: () => + Deferred.succeed(callbackStarted, undefined).pipe( + Effect.andThen(Deferred.await(callbackRelease)), + Effect.andThen(Deferred.succeed(callbackCompleted, undefined)), + Effect.asVoid, + ), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* openStack(stackId); + const destroyFiber = yield* Effect.forkChild( + stack.destroy().pipe(Effect.andThen(Deferred.succeed(destroyDone, undefined))), + { startImmediately: true }, + ); + yield* Deferred.await(destroyStarted); + yield* Deferred.succeed(responseRelease, undefined); + yield* Deferred.await(callbackStarted); + expect(yield* Deferred.isDone(destroyDone)).toBe(false); + yield* Deferred.succeed(callbackRelease, undefined); + yield* Deferred.await(callbackCompleted); + yield* Scope.close(ownerScope, Exit.void); + yield* Fiber.join(destroyFiber); + yield* stack.close(); + yield* lease.release; + }), + ), + ); + it.live("concurrent equivalent creates join one owner", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index f367ce972f..1922116296 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -257,6 +257,103 @@ describe("Supervisor ingress", () => { ), ); + it.live("opens non-API listeners without resolving API gateway material", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-ingress-no-api-" }); + const stackIdentity = { + ...identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }; + const stackId = yield* deriveStackId(stackIdentity); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + capabilities: { + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + listeners: { + api: { enabled: false }, + database: { enabled: true }, + pooler: { enabled: false }, + studio: { enabled: false }, + mailUi: { enabled: false }, + smtp: { enabled: false }, + pop3: { enabled: false }, + functionsInspector: { enabled: false }, + }, + }, + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const persisted = { + format: "supabase-stack-state-v1" as const, + identity: { ...stackIdentity, stackId }, + runtime: { kind: "native" as const }, + desiredGeneration: 1, + portsGeneration: null, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + ports: [], + privatePorts: [], + secrets: {}, + }; + yield* store.write(stackId, persisted); + const registry = yield* makePortRegistry({ stateRoot: root, store }); + const ingress = yield* makeSupervisorIngress({ + stackId, + registry, + store, + context, + apiMaterial: () => + Effect.fail(new StackPreparationError({ message: "API material must not resolve" })), + }); + const input = { + stackId, + generation: 1, + desiredLifecycle: "running" as const, + state: persisted, + previous: persisted, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: {}, + plan: compiled.executionPlan, + }; + const reservation = yield* ingress.acquire(input); + expect(reservation.assignments.api).toBeUndefined(); + expect(reservation.assignments.database?.port).toEqual(expect.any(Number)); + expect(reservation.privateAssignments).toEqual( + expect.arrayContaining([ + { workloadId: "database:database", binding: "primary", port: expect.any(Number) }, + ]), + ); + yield* ingress.open(input, reservation, () => + Effect.fail(new GatewayActivationError({ message: "not reached" })), + ); + yield* ingress.close; + }), + ), + ); + it.live("serves accepted Auth templates locally with live content and no activation", () => run( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 33325c785e..1b64b87e47 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -359,7 +359,7 @@ const makeFixture = ( runtime, }); yield* Ref.set(calls, []); - return { supervisor, calls, logOptions, failDestroy, context, store, id, runtime }; + return { supervisor, calls, logOptions, failDestroy, context, store, id, runtime, resources }; }); const run = (effect: Effect.Effect) => @@ -1318,6 +1318,46 @@ describe("Supervisor composition", () => { ), ); + it.live("projects observed workload errors into failed capability status", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ + config: { + capabilities: { + database: {}, + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + yield* Ref.set(fixture.resources, [ + { + stackId: fixture.id, + desiredGeneration: 1, + workloadId: "database:database", + specHash: "test", + state: "failed", + error: "native process exited with code 3", + }, + ]); + const status = yield* fixture.supervisor.status; + expect(status.lifecycle).toBe("stopped"); + expect(status.capabilities.find(({ name }) => name === "database")).toMatchObject({ + state: "failed", + error: "native process exited with code 3", + }); + }), + ), + ); + it.live("acquires ingress before eager workloads and opens it after readiness", () => run( Effect.gen(function* () { @@ -1335,6 +1375,39 @@ describe("Supervisor composition", () => { ), ); + it.live("reserves and opens a database listener when the API listener is disabled", () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const fixture = yield* makeFixture({ + timeline, + ingress: makeMockIngress(timeline), + }); + yield* fixture.supervisor.start({ + config: { + capabilities: { + database: {}, + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + listeners: { api: { enabled: false } }, + }, + }); + const events = yield* Ref.get(timeline); + expect(events).toContain("start:database:database"); + expect(events).toContain("acquire:1"); + expect(events).toContain("open:1"); + }), + ), + ); + it.live("keeps same-generation ingress identity on repeated start", () => run( Effect.gen(function* () { diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index 4b0e83f32d..c92f7b63b8 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -16,6 +16,12 @@ export default defineConfig({ include: ["**/*.integration.test.ts"], }, }, + { + test: { + name: "e2e", + include: ["**/*.e2e.test.ts"], + }, + }, ], }, }); From 4d59850b775be0f4af697d80c748213d8bbb03a0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 06:19:18 +0200 Subject: [PATCH 096/174] chore(stack): ignore runtime development artifacts --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index bfa5480a32..60cf25ba91 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,10 @@ coverage/ !.env.example .claude/ .agents/.repos/effect-v3 +.repos/slim-services/ .worktrees/ .supabase/ +erl_crash.dump # Stray `supabase` project dir created by running the CLI at the repo root # (e.g. supabase/.temp/linked-project.json). This monorepo has no top-level # Supabase project — real fixtures live under apps/cli-e2e/fixtures/. From c9854ce73e7dfd9f44dd4a3785bc5775ef51722c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 06:19:18 +0200 Subject: [PATCH 097/174] docs(stack): record runtime completion evidence --- ...26-08-28-stack-runtime-rewrite-progress.md | 149 ++++++++++++++++-- .../plans/2026-08-28-stack-runtime-rewrite.md | 78 ++++----- ...2026-08-28-stack-runtime-rewrite-design.md | 4 +- 3 files changed, 181 insertions(+), 50 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 6af838b40a..9a5859016d 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -20,8 +20,8 @@ updated in the same commit as each completed slice. Scratch review packages live - [x] Approved architecture imported as the canonical tracked design. - [x] Layer-by-layer implementation plan completed and reviewed against the design. - [x] Legacy process-compose package and stack implementation removed. -- [ ] Greenfield rewrite implemented through the public Effect and Promise surfaces. -- [ ] Targeted test suite green. +- [x] Greenfield rewrite implemented through the public Effect and Promise surfaces. +- [x] Targeted test suite green. - [ ] Completion audit, final review, push, and pull request completed. ## Slice ledger @@ -285,15 +285,9 @@ Implementation continues after recording a ruling; this file is not a question q #### Task 3 review fixes — round 2 — 2026-08-28 -- **Superseded release decision:** the alias behavior recorded below is historical. The rewrite now - accepts only PostgreSQL `17.6.1.166`; PostgreSQL 15 remains deferred until its matching native - archive and derived image are published, and every other selector fails before mutation. -- Preserved the authoritative database major alias: selector `13` intentionally resolves to the - current Supabase Postgres `15.8.1.085` release. This follows - `apps/cli-go/pkg/config/config.go:825-831` and the documented fallthrough in - `apps/cli/src/shared/services/services.shared.ts:107-116`; the legacy `pg13` constant is not - an active runtime artifact. The native and container descriptors remain coherent for that - selected release (and for majors 14, 15, and 17). +- **Superseded release decision:** earlier selector-alias work was removed. The rewrite accepts only + PostgreSQL `17.6.1.166`; PostgreSQL 15 remains deferred until its matching native archive and + derived image are published, and every other selector fails before mutation. - Identical-fingerprint reuse now branches immediately after strict decode, Functions-root normalization, and fingerprint calculation. It collects only supplied Redacted slots, validates persisted versions and capability/workload closure, and rebuilds a fresh runtime plan from the @@ -304,8 +298,9 @@ Implementation continues after recording a ruling; this file is not a question q and per-function overrides; recursive unknown-field tests reject the removed controls. - Persisted-definition codecs remain a Task 4 responsibility; Task 3 intentionally exports concrete input schemas/types and release metadata without a generated or open materialized settings bag. -- Round-2 RED/GREEN: the targeted compiler/public suites pass (32 tests), including the DB13 alias, - immediate reuse, removed Functions controls, exact tagged failures, and closure checks. +- Round-2 RED/GREEN: the targeted compiler/public suites pass (32 tests), including rejection of + unsupported database selectors, immediate reuse, removed Functions controls, exact tagged + failures, and closure checks. - `pnpm --dir packages/stack types:check` — passed. - `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — passed, 32 tests. - `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. @@ -1102,3 +1097,131 @@ private-port reservation plus gateway ownership atomic with accepted lifecycle g evidence gaps: exit-zero plus failed log persistence still blocks main creation, both Docker and Podman execute the strict wait decoder, and the deliberate init/main name reuse is documented beside the invariant. The milestone is converged conditional only on the shared Docker E2E exercising the pinned images. + +#### Pause checkpoint — shared whole-stack E2E and native PostgreSQL startup (2026-08-31) + +- Added the public `createTestStack` resource helper draft with an isolated project/state root, `setupProject`, + automatic start/readiness, and exact destroy/close/root cleanup. Added one Stack-package-only whole-stack E2E body + parameterized solely by native versus Docker mode; it exercises SQL, REST, Auth, Realtime, Storage/imgproxy, + Functions live discovery and cross-service REST, SMTP/Mailpit, Studio/pgmeta, Analytics, and Pooler. This E2E is a + draft and has not yet completed against either real runtime. +- Readiness budgets are converged: the persisted database `health_timeout` drives database readiness, other workloads + use the shared 30-second budget, the outer Effect timeout is the sole total deadline, and Reconciler no longer masks + PostgreSQL's budget with an implicit cap. Missing persisted defaults fail before spawn. Compiler/default/configured/ + generic/zero/cancellation behavior passed 69 focused Bun integration tests. Fable reports are saved at + `/Users/jgoux/.codex/fable-reviews/cli/20260831T161313Z.md` and + `/Users/jgoux/.codex/fable-reviews/cli/20260831T163013Z.md`; the second pass found no remaining logic defect. +- Native process preparation now preserves the host environment with string-only service overrides and uses Bun-safe + fd3/fd4 read streams. `NativeRuntime` supports scoped post-readiness one-shots and continuously races the complete + readiness → post-readiness → database-bootstrap → ready transition against the owned main exit and log failure. + The pinned PostgreSQL archive normalization removes the contained duplicate root `init-scripts` sibling while + preserving `migrations/init-scripts`; catalog/artifact/runtime coverage passed 55 focused tests and the native suite + passed 21/21. The second native Fable consensus pass was intentionally interrupted for this pause and produced no + report; rerun it with the full fifteen-minute allowance before accepting the checkpoint. +- PostgreSQL production wiring is the exact next implementation step and is not started: detect first boot from the + database data directory, run the artifact's canonical `share/supabase-cli/migrations/migrate.sh` as a native + post-readiness process, then run the existing Stack database bootstrap. Do not add Stack-owned Supabase migration + SQL or database-reset behavior. +- After native PostgreSQL boots, run the shared native E2E and address only observed service gaps. One known likely + gap is Analytics Vector: the pinned generic artifact/image ships a demo config with its API disabled, while the + current runtime spec expects `/health`; resolve this with the smallest shared native/container Stack-owned config + if the real E2E reproduces it. Then run the unchanged Docker case, Fable-review the whole E2E milestone, audit the + remaining design checklist, and perform final verification. +- The worktree intentionally remains uncommitted at this pause. `.repos/slim-services/` is a read-only upstream + checkout used for artifact/container comparison and must not be committed. + +#### Final rewrite checkpoint — runtime parity, whole-stack E2E, and Fable consensus (2026-09-01) + +This checkpoint supersedes only the incomplete-status statements in the pause checkpoint above; the pause remains +as historical evidence of what was known at that time. + +- Native PostgreSQL first boot now runs the pinned `17.6.1.166` artifact's canonical + `share/supabase-cli/migrations/migrate.sh` after readiness and before Stack bootstrap. An atomic + `.supabase-stack-migration-complete` witness is published only after exit 0; `PG_VERSION` without the witness + identifies an incomplete first boot, so the exact PGDATA is recreated before retry and is never published ready. + Pre-witness development data is intentionally disposable: this package is unreleased and has no shipped producer + or consumer requiring a compatibility backfill. PostgreSQL 15 and every other selector remain unsupported. +- Stateful service migrations are part of workload startup rather than caller hooks. Native Auth, Storage, Realtime, + Analytics, and Pooler run their artifact migration processes before their main process is accepted ready. Container + Auth and Storage use explicit one-shot init containers; Realtime, Analytics, and Pooler retain the pinned image-owned + entrypoint migrations. Dependents cannot start until these phases succeed. +- Runtime inputs are generation-owned and atomic. Pooler cleanup removes only Pooler tenant input; Vector cleanup + removes only Vector material and invalidates its cache so a same-generation restart republishes the config. Vector + emits one finite E2E marker to Analytics and uses `internal_metrics` to a blackhole solely to remain a long-lived + companion, avoiding recurring synthetic Analytics rows. +- Omitted Analytics API keys are generated once as managed 32-byte base64url secrets and persisted; caller-supplied + keys remain pass-through values that may change only while stopped. Log redaction no longer treats the fixed text + `api-key` as a secret. Database `health_timeout` is persisted and must be strictly positive before mutation. +- Native launcher ownership is covered under both Bun and Node fd3 adapters. Graceful SIGTERM/SIGINT forwarding is + tracked separately from exact process-group termination, so later owner-pipe loss still escalates to SIGKILL and + cannot orphan descendants. The launcher inherits only the explicit host allowlist and never ambient database + credentials. +- Destroy completion uses a preface-only control socket owned by the Supervisor scope. The response callback is + handed to an owner-scoped fiber uninterruptibly; `destroy()` returns only after the exact owner closes the socket. + The internal `makeHandle` test seam is not exported by `@supabase/stack/effect`. +- Native artifacts share one immutable test cache outside disposable stack roots. `.repos/slim-services/` is ignored + by Git and remains a read-only upstream comparison checkout, not part of the product diff. + +The final public Stack-package scenario is one `runWholeStackScenario(mode)` body; only the runtime selector changes: + +| Capability | User-shaped observable proof | +| ---------- | ------------------------------------------------------------------------------------------------------ | +| Database | Connect with public credentials and execute SQL against PostgreSQL 17. | +| REST | Trigger lazy activation, authenticate, insert, and read a real row. | +| Auth | Sign up a user and request password recovery. | +| Realtime | Subscribe over WebSocket and observe the inserted database-change payload. | +| Storage | Create a bucket, upload/download an image, and transform it through imgproxy. | +| Functions | Discover a function through Edge Runtime, call REST from it, edit it live, and observe the new result. | +| Studio | Trigger lazy activation and reach the profile endpoint through its pgmeta dependency. | +| Mail | Deliver Auth recovery mail over SMTP and observe it through Mailpit. | +| Analytics | Authenticate, ingest/query a marker, and query the one Vector-delivered marker. | +| Pooler | Connect through the public pooler listener and execute SQL. | + +- Native whole-stack E2E: 1 passed in 63.92s; log `/tmp/whole-native-e2e-vector-keepalive-043459.log`. +- Docker whole-stack E2E: 1 passed in 95.88s; log `/tmp/whole-docker-e2e-vector-keepalive-043615.log`. +- Final named-file rerun exercised both parameterized cases together: 2 passed in 120.98s; log + `/tmp/whole-native-e2e-final.log`. +- Both post-run audits found no owned listeners in 30000-30020, Supervisor/launcher/workload processes, disposable + roots, or matching Docker containers, networks, and volumes. The final audit also enumerated every + `com.supabase.stack.stackId` Docker resource: no resource was created by the final run; stopped remnants from + earlier August 31 development attempts were left untouched rather than treated as this run's ownership. +- Final focused correction verification passed 137 integration tests across seven files, Stack type-checking, + formatting, and `git diff --check`. +- Final parallel-suite verification removed two test-only synchronization hazards without changing runtime behavior. + Lower-level port-coordinator tests now use scope-owned non-listening listener objects while real binding/adoption + remains covered through `HostListener` and Supervisor ingress. The native early-exit regression now awaits the + caller's typed lifecycle failure instead of an incidental final log line that failed-start teardown may cancel. + The named native test passed 20 consecutive runs and the complete integration project passed five consecutive + default-parallel runs (449 tests each). +- Fresh completion gates passed the Stack package test command (5 unit and 449 integration tests), Stack and CLI + type-checks, scoped generic and Effect lint, Stack Knip analysis, tracked-file formatting, and `git diff --check`. +- Fable milestone reports: + `/Users/jgoux/.codex/fable-reviews/cli/20260901T020640Z.md`, + `/Users/jgoux/.codex/fable-reviews/cli/20260901T023939Z.md`, and + `/Users/jgoux/.codex/fable-reviews/cli/20260901T025708Z.md`. The first two passes produced bounded corrections; + the third reached consensus with no material remaining finding. Refuted items were either unreachable through the + serialized Supervisor/Reconciler boundary, already fail-closed before execution, or disproportionate cleanup for + this minimal milestone. Database reset remains completely outside this rewrite. + +#### Final cleanup correction — native BEAM distribution (2026-09-01) + +- A clean-host native whole-stack rerun passed the complete user flow in 43.05s, then exposed one teardown defect + missed by the earlier process audit: Realtime left its artifact-bundled `epmd -daemon` listening on port 4369. + The exact process had been reparented to PID 1 and moved into a process group outside the Stack launcher, proving + that ordinary negative-PGID cleanup cannot own this daemon. +- A generic native-process regression first proved that same-group descendants survived after a direct workload + exited normally. `NativeProcess.exitCode` now performs one cached, parent-side cleanup of the exact launcher group + after capturing the exit code; ESRCH is the only tolerated already-gone result. The regression failed before the + correction, passed ten consecutive runs after it, and the native runtime integration file passes 27 tests. +- That generic correction deliberately remains: it closes the normal descendant contract for every native workload. + It cannot reach Erlang's deliberately detached daemon. The pinned Realtime, Analytics, and Pooler artifacts each + bundle an `env.sh` that unconditionally resets `RELEASE_DISTRIBUTION=name`, even though their generated release + launcher already supports `RELEASE_DISTRIBUTION=none`. +- The minimal completion path is an upstream `supabase/slim-services` artifact correction: make those three generated + `env.sh` files treat `name` as a default while honoring a caller value, rebuild the published native artifacts and + derived images, then set `RELEASE_DISTRIBUTION=none` only for native Stack workloads. Docker keeps its distributed + default. Stack-owned release wrappers, mutable cache patching, global PID scans, and a managed hidden EPMD port were + rejected because they add lifecycle machinery or weaken artifact parity. +- The exact stale test-owned EPMD processes were terminated after evidence capture. The final native E2E must be rerun + from a host with no EPMD listener after rebuilt artifacts are available, and teardown must leave no EPMD process. + Until that proof exists, the rewrite is not complete and Step 5 remains open. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md index 0d2601d537..0bd164ef5f 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md @@ -93,22 +93,22 @@ artifact keys, state schemas, drivers, or control protocols. - Moves the generic `mockChildProcessSpawner` test utility to `apps/cli/tests/helpers/child-process-spawner.ts`; this is test infrastructure, not a runtime API. -- [ ] **Step 1: Record the deletion baseline** +- [x] **Step 1: Record the deletion baseline** Add the base commit and the exact active process-compose references to the tracked progress file. -- [ ] **Step 2: Delete both legacy implementations before writing replacement runtime code** +- [x] **Step 2: Delete both legacy implementations before writing replacement runtime code** Keep only package configuration, the empty public barrels, and the approved tracked design. Do not copy orchestration implementation into the new tree. -- [ ] **Step 3: Remove workspace and CLI process-compose coupling** +- [x] **Step 3: Remove workspace and CLI process-compose coupling** Remove manifest/config/lockfile entries, process-compose self-dispatch in the CLI bootstrap, old telemetry adapters, and active documentation references. Preserve `.repos/process-compose` and historical design/ADR references because those are source history, not the deleted workspace. -- [ ] **Step 4: Regenerate the lockfile and verify the empty boundary** +- [x] **Step 4: Regenerate the lockfile and verify the empty boundary** ```bash pnpm install --lockfile-only @@ -119,7 +119,7 @@ artifact keys, state schemas, drivers, or control protocols. Expected: install and stack type-check exit 0; the search finds no active reference. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add -A @@ -144,7 +144,7 @@ artifact keys, state schemas, drivers, or control protocols. - Promise secret-bearing types contain `string`; Effect secret-bearing types contain `Redacted.Redacted`. -- [ ] **Step 1: Write decoding and snapshot-contract integration tests** +- [x] **Step 1: Write decoding and snapshot-contract integration tests** ```ts it.effect("decodes a complete status snapshot", () => @@ -156,7 +156,7 @@ artifact keys, state schemas, drivers, or control protocols. Assert with public predicates or error matching rather than Effect runtime fields. -- [ ] **Step 2: Run RED** +- [x] **Step 2: Run RED** ```bash pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts @@ -164,13 +164,13 @@ artifact keys, state schemas, drivers, or control protocols. Expected: failure because the schemas do not exist. -- [ ] **Step 3: Implement schemas and errors with Effect v4 APIs** +- [x] **Step 3: Implement schemas and errors with Effect v4 APIs** Use `Data.TaggedError` for ordinary domain failures and pinned v4 `Schema.TaggedErrorClass` only for failures that cross a schema-serialized boundary. Decode through `Schema.decodeUnknownEffect`; never throw expected validation failures. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** ```bash pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts @@ -298,14 +298,14 @@ artifact keys, state schemas, drivers, or control protocols. - Automatic assignments are globally exclusive while stopped/running; stopped exact assignments may coexist and are validated only on start. Assignments stay sticky after bind races. -- [ ] **Step 1: Write state, secret, and port scenarios first** +- [x] **Step 1: Write state, secret, and port scenarios first** Test atomic old-or-new reads, corrupt-state fail-closed behavior, exact-identity cleanup, managed generation/reuse/mismatch, stopped-only pass-through replacement, no secret bytes in fingerprints/ log messages, automatic sticky exclusivity, exact stopped coexistence, live exact conflicts, unchanged assignment reuse, native socket transfer, and container bind-race retention. -- [ ] **Step 2: Verify RED, then implement atomic state and scoped acquisition** +- [x] **Step 2: Verify RED, then implement atomic state and scoped acquisition** Encode/decode through Effect Schema; atomically replace owner-only state through a sibling temporary file and required fsync operations. Reserve complete candidate sets under the short registry lock @@ -313,7 +313,7 @@ artifact keys, state schemas, drivers, or control protocols. authoritative publish result for containers. Never bind-release-rebind an automatic native port. The first release reads and writes only `supabase-stack-state-v1`; add no migration framework. -- [ ] **Step 3: Verify and commit** +- [x] **Step 3: Verify and commit** Run the three targeted integration files plus stack type-check and commit: @@ -341,25 +341,25 @@ artifact keys, state schemas, drivers, or control protocols. - One atomic ownership primitive and endpoint carries frozen bounded-JSON maintenance operations plus exact-release Effect RPC. Ordinary handles are scoped/closeable; owner work outlives handles. -- [ ] **Step 1: Write multi-process handle scenarios and verify RED** +- [x] **Step 1: Write multi-process handle scenarios and verify RED** Cover create without config/start, same identity joining one owner, open-only behavior, read-only discovery, closing/caller exit survival, concurrent equivalent create, incompatible owner failure, and stable maintenance stop. -- [ ] **Step 2: Implement ownership and protocols** +- [x] **Step 2: Implement ownership and protocols** Use one endpoint, random owner session, bounded framing, typed decode failures, and Effect RPC for exact-release operations. Register all callback listeners before starting I/O, resume at most once, and remove every owned listener/socket in the cancellation Effect. -- [ ] **Step 3: Implement shared launch/attachment** +- [x] **Step 3: Implement shared launch/attachment** Represent launch with a cached Effect/shared Fiber or `Deferred>`; never a boolean plus polling. Waiter interruption must not cancel owner launch or cleanup. Supervisor state is allocated inside its scoped Effect execution. -- [ ] **Step 4: Verify and commit** +- [x] **Step 4: Verify and commit** Run `handles.integration.test.ts`, stack type-check, update progress, and commit: @@ -384,19 +384,19 @@ artifact keys, state schemas, drivers, or control protocols. - `status`/`watchStatus` publish complete projections; logs have bounded storage, opaque cursors, exact known-secret redaction, and atomic retained-to-live handoff. -- [ ] **Step 1: Write failure-isolation and concurrency scenarios** +- [x] **Step 1: Write failure-isolation and concurrency scenarios** Cover dependency ordering, reverse stop, independent branch survival, blocked dependents, readiness deadlines, bounded restart budgets, caller cancellation, generation fences, one reconciliation at a time, first watch snapshot, no duplicate/gap log handoff, and capability-only public attribution. -- [ ] **Step 2: Implement with Effect coordination primitives** +- [x] **Step 2: Implement with Effect coordination primitives** Use one `Semaphore` for lifecycle serialization, `SubscriptionRef` for current snapshot and change stream, `PubSub` for live logs, `Deferred` for activation/readiness handoff, and `Schedule` for retry. Do not recreate waiter arrays, boolean gates, or sleep-based propagation. -- [ ] **Step 3: Verify and commit** +- [x] **Step 3: Verify and commit** Run the two integration files and the focused pure planner test, then commit: @@ -421,10 +421,13 @@ artifact keys, state schemas, drivers, or control protocols. ports, network, secrets, or workload mutation. - `NativeRuntime` implements `RuntimeDriver` with exact process-tree ownership. Database bootstrap is a post-probe readiness phase on the real database workload, not a synthetic workload. Task 7 defines - the release-plan resolver and PostgreSQL runner; Task 13 supplies each supported release's concrete - ordered revision catalog. Applied revisions live inside PostgreSQL only and gate every database - dependent on completion. Gateway listener ownership and native transfer belong to Task 8, where the - gateway contract is implemented; Task 7 does not add a speculative listener interface. + the release-plan resolver and PostgreSQL runner. A native release's bundled, non-idempotent first-boot + migration script uses one atomic PGDATA completion witness; an initialized directory without that + witness is recreated before retry and is never published ready. Task 13 supplies the runtime-owned + ordered bootstrap revision catalog, whose applied revisions live inside PostgreSQL. Both phases gate + every database dependent on completion. Gateway listener ownership and native transfer belong to + Task 8, where the gateway contract is implemented; Task 7 does not add a speculative listener + interface. - [x] **Step 1: Write preparation/native behavior tests and verify RED** @@ -653,19 +656,20 @@ artifact keys, state schemas, drivers, or control protocols. - Completes all ten capabilities with exhaustive settings and real native/container artifacts. - Does not add new public lifecycle concepts or a second Functions-serving path. -- [ ] **Step 1: Audit capability and platform coverage** +- [x] **Step 1: Audit capability and platform coverage** Build the requirement-to-source/test table in the tracked progress document. For every capability, identify exhaustive settings coverage, exact native and container artifacts, dependency edges, ports, secrets, routes, probes, and activation behavior. -- [ ] **Step 2: Fill only demonstrated gaps test-first** +- [x] **Step 2: Fill only demonstrated gaps test-first** Exercise at least one native and one container preparation/execution mapping per capability. Keep Functions exclusively on the managed Edge Runtime and reject unsupported exact artifacts before - durable or runtime mutation. + durable or runtime mutation. The final real-runtime proof consumes only the public Stack package; + the CLI is not part of this E2E contract. -- [ ] **Step 3: Verify and commit** +- [x] **Step 3: Verify and commit** Run only the affected catalog/compiler/runtime integration files and stack type-check. Commit: @@ -683,30 +687,32 @@ observed caller needs in a separate session after this rewrite is complete. - Modify: package README and architecture docs, tracked progress, public export metadata, and CLI docs/help affected by the new lifecycle -- Create: a small set of stack/CLI e2e smoke files only where real compiled subprocess/container - boundaries are not covered by integration tests +- Create: one `packages/stack` whole-stack E2E scenario shared unchanged by native and Docker mode **Interfaces:** - Confirms all ten capabilities, limits public exports to `.`, `./effect`, and `./testing`, and leaves no process-compose or legacy stack surface. -- [ ] **Step 1: Audit every spec requirement against authoritative evidence** +- [x] **Step 1: Audit every spec requirement against authoritative evidence** Build a requirement-to-test/source table in the tracked progress document. Missing or indirect evidence is incomplete work, not a pass. -- [ ] **Step 2: Fill audit gaps test-first** +- [x] **Step 2: Fill audit gaps test-first** - Treat any missing catalog/platform evidence as incomplete Task 13 work. Add the smallest compiled - CLI smoke for start/status/stop and managed Functions serving. + Treat any missing catalog/platform evidence as incomplete Task 13 work. Complete one user-shaped + Stack-package scenario that creates and starts a managed stack, exercises every capability and + private companion through observable behavior, and disposes it. Run the exact same scenario in + native and Docker mode with only the runtime selector changed. -- [ ] **Step 3: Run final targeted verification** +- [x] **Step 3: Run final targeted verification** ```bash pnpm --dir packages/stack types:check pnpm --dir packages/stack exec vitest run --project unit pnpm --dir packages/stack exec vitest run --project integration + pnpm --dir packages/stack exec vitest run --project e2e src/public/whole-stack.e2e.test.ts pnpm --dir apps/cli types:check pnpm exec oxlint --config .oxlintrc.json packages/stack apps/cli/src/next apps/cli/src/shared/telemetry pnpm exec oxlint --config .oxlintrc.effect.json packages/stack @@ -717,9 +723,9 @@ observed caller needs in a separate session after this rewrite is complete. pnpm exec knip-bun --workspace packages/stack ``` - Run only the named new e2e smoke files, not an unrelated e2e suite. + Run only the named Stack-package whole-stack E2E file, not an unrelated E2E suite. -- [ ] **Step 4: Whole-branch review and one fix wave** +- [x] **Step 4: Whole-branch review and one fix wave** Review the full merge-base diff against the design and progress rulings. Fix every load-bearing finding, re-run affected targeted checks, and record any adjudicated non-blocking ruling. diff --git a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md index 2d82cf801a..519ff78790 100644 --- a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md +++ b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md @@ -914,7 +914,9 @@ configuration normalization, graph planning, transitions, restart decisions, and Real subprocess, process-tree, ownership, filesystem, socket, and transport seams remain in integration tests. Container lifecycle and lazy activation use controlled Docker/Podman engine integration. Direct tests of a private boundary are added only when a concrete observability gap -cannot be covered through the public surface. A small e2e suite covers only critical CLI workflows. +cannot be covered through the public surface. The final e2e contract uses only the public Stack +package and runs one user-shaped whole-stack scenario in native and Docker mode, with only the +runtime selector changed; the CLI is not part of that scenario. ### Public integration scenarios From 7d379b2f361dcb5baafaa8e9791c1108191247d0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 07:00:48 +0200 Subject: [PATCH 098/174] refactor(stack): simplify workload catalog contracts --- ...26-08-28-stack-runtime-rewrite-progress.md | 23 +++++- packages/stack/src/model/CapabilityModule.ts | 19 ++++- packages/stack/src/model/Compiler.ts | 24 ++---- packages/stack/src/model/ExecutionPlan.ts | 76 +++++++++++++++---- packages/stack/src/model/WorkloadCatalog.ts | 14 +--- .../stack/src/model/capabilities/analytics.ts | 8 +- .../stack/src/model/capabilities/database.ts | 21 +++-- .../stack/src/model/capabilities/storage.ts | 19 +---- .../stack/src/model/capabilities/studio.ts | 2 +- .../src/model/catalog.integration.test.ts | 47 ++++++++++++ .../slim-services.integration.test.ts | 1 + .../stack/src/public/whole-stack.e2e.test.ts | 13 +++- 12 files changed, 184 insertions(+), 83 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 2cbf44f0da..8864b7afa5 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -98,6 +98,12 @@ updated in the same commit as each completed slice. Scratch review packages live > wrong: adding a version is a catalog change, while retaining unsupported historical selectors would > create runtime identities that cannot be prepared. +> **Uncertain:** Studio currently depends on Analytics and therefore fails compilation when Studio is +> enabled but Analytics is disabled. The design specifies dependency failure for disabled required +> capabilities but does not decide whether Studio must support a degraded Analytics-free mode. Keep +> the current fail-closed contract until that product behavior is explicitly decided; do not add +> conditional runtime wiring speculatively. + When the design does not determine another choice, record it here as: > **Ruling:** decision — reason — cost if wrong. @@ -1192,8 +1198,10 @@ The final public Stack-package scenario is one `runWholeStackScenario(mode)` bod remains covered through `HostListener` and Supervisor ingress. The native early-exit regression now awaits the caller's typed lifecycle failure instead of an incidental final log line that failed-start teardown may cancel. The named native test passed 20 consecutive runs and the complete integration project passed five consecutive - default-parallel runs (449 tests each). -- Fresh completion gates passed the Stack package test command (5 unit and 449 integration tests), Stack and CLI + default-parallel runs (449 tests each; the suite now contains 450 after the descendant-cleanup regression was + added in `packages/stack/src/runtime/native-runtime.integration.test.ts`). +- Fresh completion gates passed the Stack package test command (5 unit and 451 integration tests after the + post-merge canonical-artifact regression), Stack and CLI type-checks, scoped generic and Effect lint, Stack Knip analysis, tracked-file formatting, and `git diff --check`. - Fable milestone reports: `/Users/jgoux/.codex/fable-reviews/cli/20260901T020640Z.md`, @@ -1225,3 +1233,14 @@ The final public Stack-package scenario is one `runWholeStackScenario(mode)` bod - The exact stale test-owned EPMD processes were terminated after evidence capture. The final native E2E must be rerun from a host with no EPMD listener after rebuilt artifacts are available, and teardown must leave no EPMD process. Until that proof exists, the rewrite is not complete and Step 5 remains open. +- Post-merge Fable review: + `/Users/jgoux/.codex/fable-reviews/cli/20260901T043012Z.md`. Independently validated Keep findings cover canonical + native artifact tags, typed companion selection, removal of the false imgproxy/database edge, an explicit + user-supplied Pooler tenant in the E2E, and small catalog type/duplication cleanup. The Studio/Analytics contract + remains recorded above as uncertain. Findings about cross-package registry tests, hypothetical unobserved process + exits, explicit-undefined defaults, generic registry-reference parsing, and PID reuse were refuted or deferred as + unreachable, already intentional, or disproportionate to this minimal rewrite. +- Convergence Fable review: + `/Users/jgoux/.codex/fable-reviews/cli/20260901T045319Z.md`. The second pass reached consensus with no material + finding. Its one concrete low-cost hardening was applied: fresh workload spec hashes now name the completed + materialized settings explicitly instead of relying on the managed-slot helper mutating an earlier alias in place. diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index 4892fe98cd..7775db9408 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -1,4 +1,4 @@ -import type { Schema } from "effect"; +import type { Redacted, Schema } from "effect"; import type { CapabilityName, ActivationMode } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; @@ -29,6 +29,21 @@ export interface WorkloadSpec { }>; } +/** Settings shape persisted by the compiler, where absent and secret leaves are materialized. */ +type MaterializedValue = [T] extends [Redacted.Redacted] + ? { readonly slot: string } + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { + readonly [Key in keyof T]-?: + | MaterializedValue> + | (undefined extends T[Key] ? null : never); + } + : T; + +export type MaterializedSettings = MaterializedValue; + export interface CapabilityModule { readonly name: CapabilityName; readonly settings: Schema.Schema; @@ -49,7 +64,7 @@ export interface CapabilityModule { readonly materialize: (settings: Settings, projectRoot: string) => Settings; /** Selects private companion workloads from fully materialized settings. */ readonly selectWorkloads?: ( - settings: unknown, + settings: MaterializedSettings, workloads: ReadonlyArray, ) => ReadonlyArray; } diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 5aadf5f500..9750fba8ad 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -33,7 +33,11 @@ import { } from "./capabilities/index.ts"; import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { CAPABILITY_MODULES, createExecutionPlan, type ExecutionPlan } from "./ExecutionPlan.ts"; -import type { CapabilityModule, CapabilityRelease } from "./CapabilityModule.ts"; +import type { + CapabilityModule, + CapabilityRelease, + MaterializedSettings, +} from "./CapabilityModule.ts"; import type { SecretGenerator, SecretJwtSigning } from "../state/SecretStore.ts"; type InputFingerprint = Schema.Schema.Type; @@ -43,20 +47,6 @@ interface SecretSlot { readonly slot: string; } -type MaterializedValue = [T] extends [Redacted.Redacted] - ? SecretSlot - : T extends ReadonlyArray - ? ReadonlyArray> - : T extends object - ? { - readonly [Key in keyof T]-?: - | MaterializedValue> - | (undefined extends T[Key] ? null : never); - } - : T; - -type MaterializedSettings = MaterializedValue; - interface MaterializedCapability { readonly enabled: boolean; readonly activation: "eager" | "lazy"; @@ -565,7 +555,9 @@ const materializeCapability = ( for (const entry of selectedRelease.workloads) { const bytes = yield* crypto.digest( "SHA-256", - new TextEncoder().encode(canonical({ workload: entry, version, settings: slotted })), + new TextEncoder().encode( + canonical({ workload: entry, version, settings: completeSettings }), + ), ); specHashes.set(`${module.name}:${entry.name}`, digestHex(bytes)); } diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index 26055080f3..539d4194d3 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -13,9 +13,24 @@ import { StudioModule, AnalyticsModule, } from "./capabilities/index.ts"; -import type { WorkloadSpec, NativeArtifact, ContainerArtifact } from "./CapabilityModule.ts"; +import type { + WorkloadSpec, + NativeArtifact, + ContainerArtifact, + MaterializedSettings, +} from "./CapabilityModule.ts"; import { Effect } from "effect"; import { InvalidStackConfigError } from "../public/Errors.ts"; +import type { AnalyticsSettings } from "./capabilities/analytics.ts"; +import type { AuthSettings } from "./capabilities/auth.ts"; +import type { DatabaseSettings } from "./capabilities/database.ts"; +import type { FunctionsSettings } from "./capabilities/functions.ts"; +import type { MailSettings } from "./capabilities/mail.ts"; +import type { PoolerSettings } from "./capabilities/pooler.ts"; +import type { RealtimeSettings } from "./capabilities/realtime.ts"; +import type { RestSettings } from "./capabilities/rest.ts"; +import type { StorageSettings } from "./capabilities/storage.ts"; +import type { StudioSettings } from "./capabilities/studio.ts"; export const CAPABILITY_MODULES = { database: DatabaseModule, @@ -67,24 +82,56 @@ export interface EnabledCapability { readonly activation: "eager" | "lazy"; } +type PlanSettings = { + readonly database: MaterializedSettings; + readonly rest: MaterializedSettings; + readonly auth: MaterializedSettings; + readonly realtime: MaterializedSettings; + readonly storage: MaterializedSettings; + readonly functions: MaterializedSettings; + readonly studio: MaterializedSettings; + readonly mail: MaterializedSettings; + readonly analytics: MaterializedSettings; + readonly pooler: MaterializedSettings; +}; + +const selectedWorkloads = ( + name: CapabilityName, + modules: typeof CAPABILITY_MODULES, + settings: PlanSettings, + workloads: ReadonlyArray, +): ReadonlyArray => { + switch (name) { + case "database": + return modules.database.selectWorkloads?.(settings.database, workloads) ?? workloads; + case "rest": + return modules.rest.selectWorkloads?.(settings.rest, workloads) ?? workloads; + case "auth": + return modules.auth.selectWorkloads?.(settings.auth, workloads) ?? workloads; + case "realtime": + return modules.realtime.selectWorkloads?.(settings.realtime, workloads) ?? workloads; + case "storage": + return modules.storage.selectWorkloads?.(settings.storage, workloads) ?? workloads; + case "functions": + return modules.functions.selectWorkloads?.(settings.functions, workloads) ?? workloads; + case "studio": + return modules.studio.selectWorkloads?.(settings.studio, workloads) ?? workloads; + case "mail": + return modules.mail.selectWorkloads?.(settings.mail, workloads) ?? workloads; + case "analytics": + return modules.analytics.selectWorkloads?.(settings.analytics, workloads) ?? workloads; + case "pooler": + return modules.pooler.selectWorkloads?.(settings.pooler, workloads) ?? workloads; + } +}; + export const createExecutionPlan = ( runtime: StackRuntime, enabled: Readonly<{ [Name in CapabilityName]: EnabledCapability }>, specHashes: ReadonlyMap, versions: Readonly<{ [Name in CapabilityName]: string }>, modules: typeof CAPABILITY_MODULES = CAPABILITY_MODULES, - settings: Readonly<{ [Name in CapabilityName]: unknown }> = { - database: undefined, - rest: undefined, - auth: undefined, - realtime: undefined, - storage: undefined, - functions: undefined, - studio: undefined, - mail: undefined, - analytics: undefined, - pooler: undefined, - }, + settings: PlanSettings, ): Effect.Effect => { const dependencyMap = { database: modules.database.dependencies, @@ -153,8 +200,7 @@ export const createExecutionPlan = ( if (!enabled[name].enabled) return []; const release = modules[name].releases[versions[name]]; if (release === undefined) return []; - const selectedEntries = - modules[name].selectWorkloads?.(settings[name], release.workloads) ?? release.workloads; + const selectedEntries = selectedWorkloads(name, modules, settings, release.workloads); return selectedEntries.map((entry) => ({ id: `${name}:${entry.name}`, capability: name, diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index c47262789d..4100bad354 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -18,7 +18,7 @@ export interface NativeWorkloadArtifact { readonly requiredRuntimePaths: ReadonlyArray; readonly executablePath: string; readonly nativeProcess?: NativeWorkloadProcess; - readonly containerAlias?: string; + readonly containerAlias: string; } /** Artifact-root-relative process metadata for native Node workloads. */ @@ -170,7 +170,7 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/pgmeta:v0.99.0", "app/dist/server/server.js", ["node/bin/node", "app/dist/server/server.js"], - ["0.99.0", "v0.99.0"], + ["v0.99.0"], { nativeProcess: { executablePath: "node/bin/node", @@ -204,7 +204,7 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/vector:0.53.0", "bin/vector", ["bin/vector", "share/doc/vector/config/vector.yaml"], - ["0.53.0", "0.53.0-alpine"], + ["0.53.0"], { containerAlias: "supabase-vector" }, ), "pooler:pooler": native( @@ -279,12 +279,6 @@ export const resolveNativeArtifactForWorkload = ( platform: `${platform.os}/${platform.arch}`, }), ); - const normalizedVersion = - workload.id === "studio:pgmeta" - ? "v0.99.0" - : workload.id === "analytics:vector" - ? "0.53.0" - : workload.artifacts.native.release; if (!entry.supportedNativeVersions.includes(workload.artifacts.native.release)) return Effect.fail( new StackPreparationError({ @@ -294,5 +288,5 @@ export const resolveNativeArtifactForWorkload = ( version: workload.artifacts.native.release, }), ); - return Effect.succeed(artifactFor(entry, target, normalizedVersion)); + return Effect.succeed(artifactFor(entry, target, workload.artifacts.native.release)); }; diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index 3431a3df6f..1766a7c6d6 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -33,7 +33,7 @@ export const AnalyticsModule: CapabilityModule = { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), - workload("vector", "analytics", "0.53.0-alpine", "ghcr.io/supabase/cli/vector:0.53.0", { + workload("vector", "analytics", "0.53.0", "ghcr.io/supabase/cli/vector:0.53.0", { dependencies: ["analytics:analytics"], readiness: { mode: "tcp" }, }), @@ -43,11 +43,7 @@ export const AnalyticsModule: CapabilityModule = { secretPolicy: () => "passthrough", managedSecretSlots: ["analytics.settings.api_key"], selectWorkloads: (settings, workloads) => { - const value = - typeof settings === "object" && settings !== null && !Array.isArray(settings) - ? Object.fromEntries(Object.entries(settings)).vector_port - : undefined; - const enabled = typeof value === "number"; + const enabled = typeof settings.vector_port === "number"; return workloads.filter((entry) => enabled || entry.name !== "vector"); }, materialize: (settings) => settings, diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index 34b8bd3376..d04cbdeedc 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -135,6 +135,13 @@ const defaults: DatabaseSettings = { vault: {}, }; +const postgres17Release = release("17.6.1.167", [ + workload("database", "database", "17.6.1.167", "ghcr.io/supabase/cli/postgres:17.6.1.167", { + bootstrap: "database", + readiness: { mode: "tcp", portField: "database" }, + }), +]); + export const DatabaseModule: CapabilityModule = { name: "database", settings: DatabaseSettingsSchema, @@ -144,18 +151,8 @@ export const DatabaseModule: CapabilityModule = { defaultVersion: "17.6.1.167", dependencies: [], releases: { - "17": release("17.6.1.167", [ - workload("database", "database", "17.6.1.167", "ghcr.io/supabase/cli/postgres:17.6.1.167", { - bootstrap: "database", - readiness: { mode: "tcp", portField: "database" }, - }), - ]), - "17.6.1.167": release("17.6.1.167", [ - workload("database", "database", "17.6.1.167", "ghcr.io/supabase/cli/postgres:17.6.1.167", { - bootstrap: "database", - readiness: { mode: "tcp", portField: "database" }, - }), - ]), + "17": postgres17Release, + "17.6.1.167": postgres17Release, }, routes: [{ listener: "database", protocol: "tcp" }], secretPolicy: () => "passthrough", diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index bce04c01c1..a2eee0ca94 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -105,11 +105,10 @@ export const StorageModule: CapabilityModule = { releases: { "v1.72.1": release("v1.72.1", [ workload("storage", "storage", "v1.72.1", "ghcr.io/supabase/cli/storage:v1.72.1", { - dependencies: ["database:database", "storage:imgproxy"], + dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), workload("imgproxy", "storage", "v3.8.0", "ghcr.io/supabase/cli/imgproxy:v3.8.0", { - dependencies: ["database:database"], readiness: { mode: "http" }, }), ]), @@ -119,24 +118,14 @@ export const StorageModule: CapabilityModule = { path === "storage.settings.s3_protocol.secret_access_key" ? "managed" : "passthrough", managedSecretSlots: ["storage.settings.s3_protocol.secret_access_key"], selectWorkloads: (settings, workloads) => { - const record = (value: unknown): Record | undefined => - typeof value === "object" && value !== null && !Array.isArray(value) - ? Object.fromEntries(Object.entries(value)) - : undefined; - const value = record(settings)?.image_transformation; - const enabled = record(value)?.enabled === true; + const enabled = settings.image_transformation?.enabled === true; return workloads .filter((entry) => enabled || entry.name !== "imgproxy") .map((entry) => - entry.name === "storage" + entry.name === "storage" && enabled ? { ...entry, - dependencies: enabled - ? [ - ...entry.dependencies.filter((dependency) => dependency !== "storage:imgproxy"), - "storage:imgproxy", - ] - : entry.dependencies.filter((dependency) => dependency !== "storage:imgproxy"), + dependencies: [...entry.dependencies, "storage:imgproxy"], } : entry, ); diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts index af9cf8f4d1..06b0937ad3 100644 --- a/packages/stack/src/model/capabilities/studio.ts +++ b/packages/stack/src/model/capabilities/studio.ts @@ -27,7 +27,7 @@ export const StudioModule: CapabilityModule = { readiness: { mode: "http", portField: "studio" }, }, ), - workload("pgmeta", "studio", "0.99.0", "ghcr.io/supabase/cli/pgmeta:v0.99.0", { + workload("pgmeta", "studio", "v0.99.0", "ghcr.io/supabase/cli/pgmeta:v0.99.0", { dependencies: ["database:database"], readiness: { mode: "http" }, }), diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index 9975fa02e9..3fa7e83534 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -41,6 +41,10 @@ describe("complete workload catalog", () => { expect(WORKLOAD_CATALOG["studio:pgmeta"]?.containerImage).toBe( "ghcr.io/supabase/cli/pgmeta:v0.99.0", ); + expect(WORKLOAD_CATALOG["studio:pgmeta"]).toMatchObject({ + nativeVersion: "v0.99.0", + supportedNativeVersions: ["v0.99.0"], + }); expect(WORKLOAD_CATALOG["analytics:analytics"]?.containerImage).toBe( "ghcr.io/supabase/cli/analytics:v1.50.6", ); @@ -50,6 +54,10 @@ describe("complete workload catalog", () => { expect(WORKLOAD_CATALOG["analytics:vector"]?.containerImage).toBe( "ghcr.io/supabase/cli/vector:0.53.0", ); + expect(WORKLOAD_CATALOG["analytics:vector"]).toMatchObject({ + nativeVersion: "0.53.0", + supportedNativeVersions: ["0.53.0"], + }); expect(WORKLOAD_CATALOG["storage:imgproxy"]?.containerImage).toBe( "ghcr.io/supabase/cli/imgproxy:v3.8.0", ); @@ -162,6 +170,35 @@ describe("complete workload catalog", () => { }), ); + it.live("uses canonical native release tags for companion workloads", () => + Effect.gen(function* () { + const result = yield* compile({ + capabilities: { + storage: { settings: { image_transformation: { enabled: true } } }, + analytics: { settings: { vector_port: 9001 } }, + }, + }); + const expected = [ + ["studio:pgmeta", "pgmeta", "v0.99.0"], + ["analytics:vector", "vector", "0.53.0"], + ] as const; + for (const [id, service, version] of expected) { + const workload = result.executionPlan.workloads.find((entry) => entry.id === id); + expect(workload).toBeDefined(); + if (workload === undefined) continue; + expect(workload.artifacts.native.release).toBe(version); + const artifact = yield* resolveNativeArtifactForWorkload(workload, { + os: "darwin", + arch: "arm64", + }); + expect(artifact.releaseTag).toBe(`${service}-${version}`); + expect(artifact.downloadUrl).toBe( + `https://github.com/supabase/slim-services/releases/download/${service}-${version}/${service}-${version}-darwin-arm64.tar.zst`, + ); + } + }), + ); + it.live("materialized settings control optional companion workloads", () => Effect.gen(function* () { const defaults = yield* compile({}); @@ -171,6 +208,11 @@ describe("complete workload catalog", () => { expect(defaults.executionPlan.workloads.some(({ id }) => id === "analytics:vector")).toBe( false, ); + expect(defaults.executionPlan.workloads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "storage:storage", dependencies: ["database:database"] }), + ]), + ); const enabled = yield* compile({ capabilities: { @@ -186,6 +228,11 @@ describe("complete workload catalog", () => { ); expect(enabled.executionPlan.workloads).toEqual( expect.arrayContaining([ + expect.objectContaining({ + id: "storage:storage", + dependencies: ["database:database", "storage:imgproxy"], + }), + expect.objectContaining({ id: "storage:imgproxy", dependencies: [] }), expect.objectContaining({ id: "analytics:analytics", dependencies: ["database:database"], diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 48d481b5f5..9a753d4ed6 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -27,6 +27,7 @@ const artifact: NativeWorkloadArtifact = { checksumUrl: "https://example.test/SHA256SUMS", requiredRuntimePaths: ["bin/demo"], executablePath: "bin/demo", + containerAlias: "supabase-demo", }; const request: ArtifactRequest = { key: "demo/v1", diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 23eda7f270..31bfd077d0 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -208,7 +208,11 @@ const onePixelPng = Uint8Array.from( (character) => character.charCodeAt(0), ); -const stackConfig = (functionSlug: string, analyticsApiKey: string): PromiseStackConfig => ({ +const stackConfig = ( + functionSlug: string, + analyticsApiKey: string, + poolerTenantId: string, +): PromiseStackConfig => ({ capabilities: { database: { version: "17.6.1.167" }, rest: { enabled: true, activation: "lazy" }, @@ -231,7 +235,7 @@ const stackConfig = (functionSlug: string, analyticsApiKey: string): PromiseStac studio: { enabled: true, activation: "lazy" }, mail: { enabled: true }, analytics: { enabled: true, settings: { vector_port: 9001, api_key: analyticsApiKey } }, - pooler: { enabled: true }, + pooler: { enabled: true, settings: { tenant_id: poolerTenantId } }, }, listeners: { api: { enabled: true }, @@ -302,12 +306,13 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const password = "SupabaseStackE2e!123"; const markers = { first: `first-${identity}`, second: `second-${identity}` }; const analyticsApiKey = `analytics-key-${identity}`; + const poolerTenantId = `pooler-${identity}`; let projectRoot = ""; await using stack: TestStack = await createTestStack({ name: `stack-e2e-${identity}`, runtime: mode.runtime, - config: stackConfig(functionSlug, analyticsApiKey), + config: stackConfig(functionSlug, analyticsApiKey, poolerTenantId), setupProject: async (root) => { projectRoot = root; const directory = join(root, "supabase", "functions", functionSlug); @@ -538,7 +543,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom // Pooler is a separate public TCP listener over the same database credentials. const poolerUrl = new URL(credentials.database.url); poolerUrl.port = String(pooler.port); - poolerUrl.username = "postgres.pooler-dev"; + poolerUrl.username = `postgres.${poolerTenantId}`; const poolerRows = await databaseQuery(poolerUrl.toString(), "SELECT 42 AS answer"); expect(poolerRows).toEqual([{ answer: 42 }]); From 145bf5580db5bc933c912f5058f2446d0d0b0bd3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 07:31:48 +0200 Subject: [PATCH 099/174] fix(stack): disable native BEAM distribution --- ...26-08-28-stack-runtime-rewrite-progress.md | 19 +++++++++++++++++++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 8 ++++++++ .../workload-runtime.integration.test.ts | 16 ++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 8864b7afa5..3905a3648e 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1244,3 +1244,22 @@ The final public Stack-package scenario is one `runWholeStackScenario(mode)` bod `/Users/jgoux/.codex/fable-reviews/cli/20260901T045319Z.md`. The second pass reached consensus with no material finding. Its one concrete low-cost hardening was applied: fresh workload spec hashes now name the completed materialized settings explicitly instead of relying on the managed-slot helper mutating an earlier alias in place. +- Upstream BEAM correction PR: `supabase/slim-services#293`. Its shared fail-closed artifact fixup changes generated + release environments from an unconditional named-distribution assignment to `name` as the default, preserving an + explicit caller value. Native Realtime, Analytics, and Pooler artifact smokes request `none`; derived images omit + the override and therefore retain their existing named-distribution default. +- Stack runtime environments now set `RELEASE_DISTRIBUTION=none` only for native Realtime, Analytics, and Pooler. + One red/green integration scenario covers all three native mappings and proves all three container mappings omit + the variable. `ProductionRuntime` shares the resolved environment across startup migrations/evals and the main + process, so no parallel startup mechanism is required. +- Replacing the already-published artifacts remains separately approval-gated. Until those releases are rebuilt from + the upstream correction, the final clean-host E2E/no-EPMD proof remains open. +- Pause checkpoint Fable review: + `/Users/jgoux/.codex/fable-reviews/cli/20260901T052134Z.md`. The design remains accepted. The next bounded pass must + add behavior-level override verification against the three built artifacts, remove Analytics' native-only + `--sname` smoke argument so the smoke matches Stack, prove `ProductionRuntime` propagates the resolved BEAM value + into a real startup process, and document why container environments intentionally omit the variable. Pooler's + template was initialized and independently confirmed to contain the exact assignment handled by the upstream + fixup; the artifact rollout gate was already recorded above. +- Resume from the committed Stack-side environment mapping and the pushed upstream PR. Do not replace published + Realtime, Analytics, or Pooler assets until that separate destructive release action is explicitly confirmed. diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 47a0a44c55..b3fee66214 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -285,6 +285,11 @@ const compactEnvironment = ( ): Readonly> => Object.fromEntries(Object.entries(environment).filter(([, value]) => value.length > 0)); +const beamDistributionEnvironment = ( + runtime: WorkloadRuntimeKind, +): Readonly> => + runtime === "native" ? { RELEASE_DISTRIBUTION: "none" } : {}; + const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); @@ -1018,6 +1023,7 @@ const analyticsEnv = ( const gcpJwtPath = inputs.analytics?.gcpJwtPath ?? ""; return compactEnvironment({ ...common(workload, port), + ...beamDistributionEnvironment(runtime), ...capabilityEnv(state, "analytics", "ANALYTICS", (key) => key === "ANALYTICS_GCP_JWT_PATH"), PORT: String(port), PHX_HTTP_PORT: String(port), @@ -1096,6 +1102,7 @@ const specs: Readonly> = { env: (state, workload, port, runtime = "native", inputs = {}) => compactEnvironment({ ...common(workload, port), + ...beamDistributionEnvironment(runtime), ...capabilityEnv(state, "realtime", "REALTIME"), PORT: String(port), DB_HOST: dbHost(runtime), @@ -1323,6 +1330,7 @@ const specs: Readonly> = { args: () => ["start"], env: (state, workload, port, runtime = "native", inputs = {}) => ({ ...common(workload, port), + ...beamDistributionEnvironment(runtime), ...capabilityEnv(state, "pooler", "POOLER"), PORT: "4000", PROXY_PORT_SESSION: diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 3f8402280d..14f7826e39 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -222,6 +222,22 @@ describe("workload runtime catalog", () => { } }); + it("uses native-only BEAM distribution for Realtime, Analytics, and Pooler", () => { + const beamWorkloads = ["realtime:realtime", "analytics:analytics", "pooler:pooler"] as const; + for (const [index, id] of beamWorkloads.entries()) { + const workload = planned(id); + const spec = runtimeSpecFor(workload); + expect(spec).toBeDefined(); + if (spec === undefined) continue; + expect(spec.env(state, workload, 30_100 + index, "native")).toMatchObject({ + RELEASE_DISTRIBUTION: "none", + }); + expect(spec.env(state, workload, 30_100 + index, "container")).not.toHaveProperty( + "RELEASE_DISTRIBUTION", + ); + } + }); + it.live("uses durable binding assignments for native endpoints and container publications", () => Effect.gen(function* () { const mail = planned("mail:mail"); From 499cd6c0e28be55258c70392485e39a9aec5d965 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 08:09:47 +0200 Subject: [PATCH 100/174] test(stack): cover native BEAM startup environment propagation --- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 1 + .../production-runtime.integration.test.ts | 138 ++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index b3fee66214..55fad5ec56 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -285,6 +285,7 @@ const compactEnvironment = ( ): Readonly> => Object.fromEntries(Object.entries(environment).filter(([, value]) => value.length > 0)); +// Native unpacked BEAM releases use `none` to avoid contacting the host EPMD; containers omit it so the artifact/image default remains `name`. const beamDistributionEnvironment = ( runtime: WorkloadRuntimeKind, ): Readonly> => diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 1ba1e3a277..561678ee3f 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -172,6 +172,35 @@ printf 'migration|host=%s|port=%s|db=%s|user=%s|password=%s|path=%s\\n' "$POSTGR return { main, migrate }; }); +const writeNativeRealtimeFixture = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + eventsPath: string, +) => + Effect.gen(function* () { + const bin = path.join(root, "bin"); + yield* fs.makeDirectory(bin, { recursive: true }); + const migrate = path.join(bin, "migrate"); + yield* fs.writeFileString( + migrate, + `#!/bin/sh +printf 'realtime-migrate|RELEASE_DISTRIBUTION=%s\\n' "\${RELEASE_DISTRIBUTION:-missing}" >> ${JSON.stringify(eventsPath)} +`, + ); + yield* fs.chmod(migrate, 0o755); + const realtime = path.join(bin, "realtime"); + yield* fs.writeFileString(realtime, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(realtime, 0o755); + const server = path.join(bin, "server"); + yield* fs.writeFileString( + server, + "#!/bin/sh\ntrap 'exit 0' TERM INT\nwhile :; do sleep 1; done\n", + ); + yield* fs.chmod(server, 0o755); + return { migrate, realtime, server }; + }); + const listenForNativeReadiness = (server: ReturnType) => Effect.acquireRelease( Effect.callback((resume) => { @@ -353,6 +382,115 @@ describe("production runtime composition", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("propagates native BEAM environment to a realtime startup process", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-beam-" }); + const eventsPath = path.join(root, "events"); + const artifactRoot = path.join(root, "artifact"); + yield* writeNativeRealtimeFixture(fs, path, artifactRoot, eventsPath); + const readinessServer = createServer((_request, response) => { + response.statusCode = 200; + response.setHeader("Connection", "close"); + response.end("ok"); + }); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + readinessServer.once("error", (error) => resume(Effect.fail(error))); + readinessServer.listen(0, "127.0.0.1", () => resume(Effect.void)); + }), + () => + Effect.callback((resume) => { + if (!readinessServer.listening) return resume(Effect.void); + readinessServer.close(() => resume(Effect.void)); + }), + ); + const address = readinessServer.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Realtime readiness server did not expose an address"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + capabilities: { + database: {}, + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: true }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const current = { + value: { + ...stateFor({ + "secret:database.internal.password": { policy: "managed", value: "db-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + privatePorts: [ + { workloadId: "realtime:realtime", binding: "primary", port: address.port }, + ], + }, + } satisfies { value: PersistedStackState }; + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + artifactPreparer: { + prepare: () => + Effect.succeed({ + workloadId: "realtime:realtime", + capability: "realtime" as const, + version: "v2.130.0", + outcome: "present" as const, + artifactRoot, + }), + }, + logStore: memoryLogStore([]), + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + const realtime = compiled.executionPlan.workloads.find( + (workload) => workload.id === "realtime:realtime", + ); + if (realtime === undefined) return yield* Effect.die("Expected Realtime workload"); + const key = { + stackId, + desiredGeneration: 1, + workloadId: realtime.id, + specHash: realtime.specHash, + }; + const ready = yield* runtime.driver.start(key, realtime); + expect(ready.state).toBe("ready"); + expect(yield* fs.readFileString(eventsPath)).toContain( + "realtime-migrate|RELEASE_DISTRIBUTION=none", + ); + yield* runtime.driver.stop(key); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("runs canonical PostgreSQL migrations before bootstrap on first native boot", () => Effect.scoped( Effect.gen(function* () { From 43f0c54958aacd866fea3ab44db8ae3228743bcd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 08:44:53 +0200 Subject: [PATCH 101/174] docs(stack): record BEAM correction checkpoint --- ...26-08-28-stack-runtime-rewrite-progress.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 3905a3648e..2f514b45d8 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1263,3 +1263,23 @@ The final public Stack-package scenario is one `runWholeStackScenario(mode)` bod fixup; the artifact rollout gate was already recorded above. - Resume from the committed Stack-side environment mapping and the pushed upstream PR. Do not replace published Realtime, Analytics, or Pooler assets until that separate destructive release action is explicitly confirmed. +- Resume correction completed locally. Stack commit `499cd6c0e` adds an integration scenario through + `makeProductionRuntimeFactory` and `runtime.driver.start`; a real Realtime migration fixture observes + `RELEASE_DISTRIBUTION=none`, proving the shared startup-process merge rather than only the workload-spec mapping. + The native/container rationale is documented at the shared environment helper. Fresh focused verification passed + 31 integration tests, Stack type-checking, both lint configurations, formatting, and `git diff --check`. +- Upstream follow-up commits `4743f176` and `f5dfac9f` correct a build-context defect discovered after the first PR + commit: real local temporary exports and Docker-hosted Nix overlays do not contain repository-relative helper + scripts. Each of the three concrete Nix packages therefore owns the same small, fail-closed transform, requiring + exactly one generated `env.sh` and exactly one exported assignment. Native artifact smokes execute the actual + release launcher with complete service runtime settings and prove unset caller -> `name` plus explicit caller -> + `none`. The dead standalone transformer and native Analytics' ignored `--sname` argument were removed; image smoke + and entry paths remain unchanged. +- A separate repository-check portability failure surfaced while validating the upstream change: this host kills a + heredoc-fed Python process when the same invocation carries larger JSON argv. The poller now passes those payloads + through environment variables without changing release-selection behavior. Its 16 focused tests and the complete + upstream repository-check sequence pass, together with ShellCheck, all three Nix parse/type checks, and the BEAM + behavior fixture. +- Real native artifact builds were not available locally. PR CI remains responsible for building and running the + three actual artifact smokes. Even after that proof, replacing published assets and running the final clean-host + Stack E2E/no-EPMD gate remain separately approval-gated; neither action has been performed. From d36cbfb800718234091b5ff77de12183657fd8bd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 09:12:33 +0200 Subject: [PATCH 102/174] refactor(stack): reuse native readiness listener --- .../production-runtime.integration.test.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 561678ee3f..ad0465856c 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -201,10 +201,12 @@ printf 'realtime-migrate|RELEASE_DISTRIBUTION=%s\\n' "\${RELEASE_DISTRIBUTION:-m return { migrate, realtime, server }; }); -const listenForNativeReadiness = (server: ReturnType) => +const listenForNativeReadiness = ( + server: ReturnType | ReturnType, +) => Effect.acquireRelease( Effect.callback((resume) => { - server.once("error", (error) => resume(Effect.fail(error))); + server.once("error", (error: Error) => resume(Effect.fail(error))); server.listen(0, "127.0.0.1", () => resume(Effect.void)); }), () => @@ -396,17 +398,7 @@ describe("production runtime composition", () => { response.setHeader("Connection", "close"); response.end("ok"); }); - yield* Effect.acquireRelease( - Effect.callback((resume) => { - readinessServer.once("error", (error) => resume(Effect.fail(error))); - readinessServer.listen(0, "127.0.0.1", () => resume(Effect.void)); - }), - () => - Effect.callback((resume) => { - if (!readinessServer.listening) return resume(Effect.void); - readinessServer.close(() => resume(Effect.void)); - }), - ); + yield* listenForNativeReadiness(readinessServer); const address = readinessServer.address(); if (typeof address !== "object" || address === null) return yield* Effect.die("Realtime readiness server did not expose an address"); From aaffa242c84d41cf696ceb074262d6a54a20c7e8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 09:13:47 +0200 Subject: [PATCH 103/174] docs(stack): record BEAM review convergence --- .../plans/2026-08-28-stack-runtime-rewrite-progress.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 2f514b45d8..4a744f457c 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1283,3 +1283,11 @@ The final public Stack-package scenario is one `runWholeStackScenario(mode)` bod - Real native artifact builds were not available locally. PR CI remains responsible for building and running the three actual artifact smokes. Even after that proof, replacing published assets and running the final clean-host Stack E2E/no-EPMD gate remain separately approval-gated; neither action has been performed. +- Fable convergence pass: + `/Users/jgoux/.codex/fable-reviews/cli/20260901T064559Z.md`. The review found no design or Stack-runtime defect. + Accepted hardening now extracts one unique marker from noisy release `eval` output, rejects missing/duplicate/wrong + values, handles empty environment arrays on the macOS Bash baseline, and reuses the existing scoped readiness + listener in the Stack integration fixture. The claim that Analytics' template shape was unconfirmed was refuted by + direct inspection: the pinned Realtime, Analytics, and Pooler templates each contain exactly one supported exported + assignment. Extra process fixtures, keep-in-sync comments, and unrelated state-fixture cleanup were rejected as + duplicate coverage or non-executable maintenance noise. From 02ec086fd87406acedc9584f8f58652fa84424e4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 09:35:21 +0200 Subject: [PATCH 104/174] docs(stack): record artifact workflow evidence --- .../2026-08-28-stack-runtime-rewrite-progress.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 4a744f457c..87fe64dc13 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1291,3 +1291,16 @@ The final public Stack-package scenario is one `runWholeStackScenario(mode)` bod direct inspection: the pinned Realtime, Analytics, and Pooler templates each contain exactly one supported exported assignment. Extra process fixtures, keep-in-sync comments, and unrelated state-fixture cleanup were rejected as duplicate coverage or non-executable maintenance noise. +- Fable convergence re-review: + `/Users/jgoux/.codex/fable-reviews/cli/20260901T071406Z.md`. It reached consensus that all accepted corrections are + correct, fail-closed, and minimal, with no introduced regression. Its remaining observations are explicitly + non-blocking: partial-line launcher noise would fail closed, the macOS empty-array branch is difficult to exercise + on newer CI Bash, and broader pre-existing test-listener deduplication is outside this correction. +- Non-publishing artifact workflow + [run 33481429073](https://github.com/supabase/slim-services/actions/runs/33481429073) built, audited, packaged, and + smoke-tested Realtime, Analytics, and Pooler successfully on Linux AMD64, Linux ARM64, and macOS ARM64 from upstream + PR head `6732080a`. All nine service jobs passed and no cached artifact was reused. This workflow exercises the + repository's currently pinned source releases (`realtime-v2.123.5`, `analytics-v1.46.0`, `pooler-v2.9.10`), proving + the correction across the real local/Docker build and launcher paths but not replacing the exact Stack-selected + releases (`v2.130.0`, `v1.50.6`, `v2.9.12`). Those exact releases still require approved rebuild/replacement before + the final clean-host native Stack E2E can close the no-EPMD gate. From 1e90f3e9a1e6a5374d1fa3a58d622d2a43d1ca9f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 11:07:57 +0200 Subject: [PATCH 105/174] docs(stack): record exact BEAM rollout evidence --- .../2026-08-28-stack-runtime-rewrite-progress.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md index 87fe64dc13..685c4a3ae3 100644 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md @@ -1304,3 +1304,18 @@ The final public Stack-package scenario is one `runWholeStackScenario(mode)` bod the correction across the real local/Docker build and launcher paths but not replacing the exact Stack-selected releases (`v2.130.0`, `v1.50.6`, `v2.9.12`). Those exact releases still require approved rebuild/replacement before the final clean-host native Stack E2E can close the no-EPMD gate. +- User authorized forced replacement of the published artifacts from `supabase/slim-services#293` at head `6732080a`; + the PR remains open and is not claimed as merged. Exact service-release runs succeeded for Realtime `v2.130.0` + (`33488596843`), Analytics `v1.50.6` (`33488596567`), and Pooler `v2.9.12` (`33488596827`). Each passed Linux + AMD64, Linux ARM64, and macOS ARM64 artifact build/audit/host smoke, derived-image checks, and GitHub release + publication. Fresh GHCR digests are Realtime `sha256:d2cb57c4ea72f48f926e780afd353251114844551e947037a356add8bac40385`, + Analytics `sha256:3e398c147b50eac8fb807960ef5f03035c975e442f79621c086096d80723928f`, and Pooler + `sha256:a4fa7e89764a0a70fb7f94d93597d960f73ca4022e129533901c83f2666e7d4c`. +- The first native rerun failed before service startup because the intentionally shared test artifact cache under the + macOS tmpdir contained stale same-version checksums. Only the exact three test-owned cache entries were moved to the + recoverable quarantine `/private/tmp/supabase-stack-stale-beam-cache.Usz2AZ`; unrelated artifacts were untouched. +- Final Stack package shared E2E from fresh exact artifacts: native `1 passed / 1 skipped`, 114.59s test time (115.35s + total), with post-run audit finding no EPMD process/listener, no owned ports 30000-30020, and no + Supervisor/launcher/BEAM workload. Docker, after pulling the exact new digests, had `1 passed / 1 skipped`, 71.21s + test time (71.99s total); post-run only the previously recorded Aug 31 stale Docker resources existed, with no new + resources from this run. The final no-EPMD gate is closed and both native and Docker modes are now proven. From 87d13f81317ef1a8510c01c37a198ecc18483a88 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 1 Sep 2026 14:39:33 +0200 Subject: [PATCH 106/174] feat(stack): simplify managed runtime architecture --- packages/stack/src/control/StackRpc.ts | 33 +- packages/stack/src/gateway/HttpGateway.ts | 95 +++-- .../src/gateway/gateway.integration.test.ts | 1 + packages/stack/src/model/CapabilityModule.ts | 27 +- packages/stack/src/model/Compiler.ts | 5 +- packages/stack/src/model/DatabaseBootstrap.ts | 93 +++-- packages/stack/src/model/ExecutionPlan.ts | 64 +++- packages/stack/src/model/WorkloadCatalog.ts | 43 +-- .../stack/src/model/capabilities/analytics.ts | 9 +- packages/stack/src/model/capabilities/auth.ts | 2 +- .../stack/src/model/capabilities/database.ts | 3 +- .../stack/src/model/capabilities/functions.ts | 14 +- packages/stack/src/model/capabilities/mail.ts | 5 +- .../stack/src/model/capabilities/pooler.ts | 3 +- .../stack/src/model/capabilities/realtime.ts | 3 +- packages/stack/src/model/capabilities/rest.ts | 3 +- .../stack/src/model/capabilities/storage.ts | 4 +- .../stack/src/model/capabilities/studio.ts | 17 +- .../src/model/catalog.integration.test.ts | 144 +++----- .../src/model/compiler.integration.test.ts | 34 +- .../runtime-artifacts.integration.test.ts | 8 +- packages/stack/src/public/EffectStack.ts | 189 +++++----- packages/stack/src/public/Errors.ts | 40 +++ .../public/effect-stack.integration.test.ts | 68 +++- .../public/public-model.integration.test.ts | 14 +- .../stack/src/public/whole-stack.e2e.test.ts | 47 ++- .../stack/src/runtime/ContainerRuntime.ts | 13 +- .../src/runtime/PostgresDatabaseSession.ts | 14 +- .../stack/src/runtime/ProductionRuntime.ts | 317 +++++++++-------- .../stack/src/runtime/RuntimeInputOwner.ts | 76 ++-- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 253 +++++-------- .../container-runtime.integration.test.ts | 9 +- .../native-runtime.integration.test.ts | 9 +- ...tgres-database-session.integration.test.ts | 97 ++++- .../production-runtime.integration.test.ts | 334 +++++++++++++----- .../runtime-input-owner.integration.test.ts | 3 +- .../runtime-recovery.integration.test.ts | 7 +- .../workload-runtime.integration.test.ts | 98 ++++- .../stack/src/state/MaterializedSettings.ts | 71 ++++ packages/stack/src/state/StackStateStore.ts | 107 +++--- packages/stack/src/supervisor/Lifecycle.ts | 66 ++-- packages/stack/src/supervisor/LogStore.ts | 4 - .../src/supervisor/Reconciler.unit.test.ts | 6 +- packages/stack/src/supervisor/Supervisor.ts | 91 +---- .../supervisor/reconciler.integration.test.ts | 6 +- .../supervisor/supervisor.integration.test.ts | 1 - 46 files changed, 1423 insertions(+), 1127 deletions(-) create mode 100644 packages/stack/src/state/MaterializedSettings.ts diff --git a/packages/stack/src/control/StackRpc.ts b/packages/stack/src/control/StackRpc.ts index 6d9ec9e03b..df4b7f77ca 100644 --- a/packages/stack/src/control/StackRpc.ts +++ b/packages/stack/src/control/StackRpc.ts @@ -7,6 +7,7 @@ import { StackConfigSchema } from "../public/Config.ts"; import { CapabilityNameSchema } from "../public/Capability.ts"; import { LogOptionsSchema, StackLogEntrySchema } from "../public/Logs.ts"; import { StackStatusSchema } from "../public/Status.ts"; +import { STACK_ERROR_TAGS } from "../public/Errors.ts"; /** Pinned release identifier. A changed release must go through explicit restart. */ export const STACK_RPC_RELEASE = "stack-rpc-v1@0.1.0" as const; @@ -18,37 +19,7 @@ export class StackRpcProtocolError extends Data.TaggedError("StackRpcProtocolErr }> {} const StackRpcErrorTagSchema = Schema.Literals([ - "InvalidStackIdentityError", - "InvalidProjectRootError", - "InvalidStackConfigError", - "StackVersionUnsupportedError", - "StackNotFoundError", - "StackOwnershipConflictError", - "StackRuntimeMismatchError", - "StackDefinitionRequiredError", - "StackNotRunningError", - "StackMustBeStoppedError", - "StackLifecycleConflictError", - "StackStateInvalidError", - "StackStateFormatUnsupportedError", - "StackStateGenerationMismatchError", - "StackUpgradeRequiredError", - "StackUpgradeReplacementError", - "StackSecretMismatchError", - "InvalidJwtSigningMaterialError", - "PortAllocationError", - "PortUnavailableError", - "GatewayAuthenticationError", - "GatewayStaleGenerationError", - "GatewayActivationError", - "StackPreparationError", - "ArtifactIntegrityError", - "ContainerPullError", - "StackReconciliationError", - "ServiceStartError", - "ServiceReadinessError", - "ContainerEngineError", - "StackDestructionError", + ...STACK_ERROR_TAGS, "StackRpcProtocolError", ] as const); diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index 2bc48a185d..98137c77e4 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -158,6 +158,49 @@ const preparedHeaders = ( filteredRequestHeaders(request), ); +interface ResolvedProxyRequest { + readonly backend: BackendEndpoint; + readonly path: string; + readonly headers: Record; +} + +const resolveProxyRequest = ( + request: IncomingMessage, + view: GatewayRouteRequest, + route: GatewayProxyRoute, + options: HttpGatewayOptions, +): Effect.Effect< + ResolvedProxyRequest, + GatewayRouteNotFoundError | GatewayActivationError | GatewayBackendError +> => { + const preparation: Effect.Effect< + PreparedGatewayRoute | undefined, + GatewayRouteNotFoundError | GatewayActivationError + > = route.prepare === undefined ? Effect.as(Effect.void, undefined) : route.prepare(view); + return preparation.pipe( + Effect.flatMap((prepared) => + Effect.suspend(() => options.activate(route.capability)).pipe( + Effect.flatMap((result) => { + const resolved = + prepared === undefined + ? options.resolveBackend === undefined + ? Effect.succeed(result.endpoint) + : options.resolveBackend(route, view, result) + : prepared.resolveBackend(result); + return resolved.pipe( + Effect.map((backend) => ({ + backend, + path: preparedPath(route, prepared, view), + headers: preparedHeaders(route, prepared, view, request), + })), + Effect.mapError((cause) => new GatewayBackendError({ cause })), + ); + }), + ), + ), + ); +}; + const proxy = ( request: IncomingMessage, response: ServerResponse, @@ -294,31 +337,7 @@ const handleRequest = ( }); return; } - const preparation: Effect.Effect< - PreparedGatewayRoute | undefined, - GatewayRouteNotFoundError | GatewayActivationError - > = route.prepare === undefined ? Effect.as(Effect.void, undefined) : route.prepare(view); - const activation = preparation.pipe( - Effect.flatMap((prepared) => - Effect.suspend(() => options.activate(route.capability)).pipe( - Effect.flatMap((result) => { - const resolved = - prepared === undefined - ? options.resolveBackend === undefined - ? Effect.succeed(result.endpoint) - : options.resolveBackend(route, view, result) - : prepared.resolveBackend(result); - return resolved.pipe( - Effect.map((backend) => ({ - backend, - path: preparedPath(route, prepared, view), - headers: preparedHeaders(route, prepared, view, request), - })), - Effect.mapError((cause) => new GatewayBackendError({ cause })), - ); - }), - ), - ), + const activation = resolveProxyRequest(request, view, route, options).pipe( Effect.flatMap(({ backend, path, headers }) => proxy(request, response, backend, options, path, headers), ), @@ -380,31 +399,7 @@ const handleUpgrade = ( socket.destroy(); return; } - const preparation: Effect.Effect< - PreparedGatewayRoute | undefined, - GatewayRouteNotFoundError | GatewayActivationError - > = route.prepare === undefined ? Effect.as(Effect.void, undefined) : route.prepare(view); - const activation = preparation.pipe( - Effect.flatMap((prepared) => - Effect.suspend(() => options.activate(route.capability)).pipe( - Effect.flatMap((result) => { - const resolved = - prepared === undefined - ? options.resolveBackend === undefined - ? Effect.succeed(result.endpoint) - : options.resolveBackend(route, view, result) - : prepared.resolveBackend(result); - return resolved.pipe( - Effect.map((backend) => ({ - backend, - path: preparedPath(route, prepared, view), - headers: preparedHeaders(route, prepared, view, request), - })), - Effect.mapError((cause) => new GatewayBackendError({ cause })), - ); - }), - ), - ), + const activation = resolveProxyRequest(request, view, route, options).pipe( Effect.flatMap(({ backend, path, headers }) => Effect.callback((resume) => { const target = new Socket(); diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index c25e52118c..ae4df80dea 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -722,6 +722,7 @@ describe("stack gateway", () => { }), ); expect(responses).toEqual(fixtures); + yield* gateway.close; yield* closeServer(backend); }), ), diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index 7775db9408..75e101a0bc 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -5,13 +5,11 @@ import type { PortField } from "../public/Status.ts"; /** Logical artifact descriptors. Download/image resolution belongs to preparation. */ export interface NativeArtifact { readonly kind: "native"; - readonly service: string; readonly release: string; } export interface ContainerArtifact { readonly kind: "container"; - readonly service: string; readonly image: string; } @@ -23,10 +21,6 @@ export interface WorkloadSpec { readonly dependencies: ReadonlyArray; readonly readiness: Readonly<{ readonly mode: "http" | "tcp"; readonly portField?: PortField }>; readonly restart: Readonly<{ readonly maxAttempts: number; readonly backoffMs: number }>; - readonly artifacts: Readonly<{ - readonly native: NativeArtifact; - readonly container: ContainerArtifact; - }>; } /** Settings shape persisted by the compiler, where absent and secret leaves are materialized. */ @@ -61,7 +55,8 @@ export interface CapabilityModule { readonly secretPolicy: (path: string) => "managed" | "passthrough"; /** Managed slots required by the artifact even when omitted from user config. */ readonly managedSecretSlots: ReadonlyArray; - readonly materialize: (settings: Settings, projectRoot: string) => Settings; + /** Optional settings normalization. Omitted modules use their merged settings unchanged. */ + readonly materialize?: (settings: Settings, projectRoot: string) => Settings; /** Selects private companion workloads from fully materialized settings. */ readonly selectWorkloads?: ( settings: MaterializedSettings, @@ -79,23 +74,9 @@ export const release = ( workloads: ReadonlyArray, ): CapabilityRelease => ({ version, workloads }); -const nativeArtifact = (service: string, release: string): NativeArtifact => ({ - kind: "native", - service, - release, -}); - -const containerArtifact = (service: string, image: string): ContainerArtifact => ({ - kind: "container", - service, - image, -}); - export const workload = ( name: string, capability: CapabilityName, - release: string, - image: string, options: { readonly bootstrap?: WorkloadSpec["bootstrap"]; readonly dependencies?: ReadonlyArray; @@ -109,8 +90,4 @@ export const workload = ( dependencies: options.dependencies ?? [], readiness: options.readiness ?? { mode: "tcp" }, restart: options.restart ?? { maxAttempts: 5, backoffMs: 250 }, - artifacts: { - native: nativeArtifact(name, release), - container: containerArtifact(name, image), - }, }); diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 9750fba8ad..e8b6669a3e 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -542,9 +542,10 @@ const materializeCapability = ( > => { const selected = enabledSettings(module.name, { [module.name]: raw }); const mergedInput = merge(module.defaultSettings, selected.settings); + const materialized = module.materialize?.(mergedInput, projectRoot) ?? mergedInput; const normalized = normalizeFunctions - ? materializeFunctionsRoot(module.materialize(mergedInput, projectRoot), projectRoot, path) - : Effect.succeed(module.materialize(mergedInput, projectRoot)); + ? materializeFunctionsRoot(materialized, projectRoot, path) + : Effect.succeed(materialized); return Effect.gen(function* () { const normalizedSettings = yield* normalized; const merged = materializeAbsence(normalizedSettings); diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index 29f6bbb7a3..112b5c814e 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -58,6 +58,8 @@ export class DatabaseBootstrapError extends Data.TaggedError("DatabaseBootstrapE readonly message: string; readonly statement?: string; readonly revision?: string; + /** Whether retrying the database operation may succeed once the server settles. */ + readonly retryable?: boolean; readonly cause?: unknown; }> {} @@ -126,6 +128,7 @@ const statementError = (error: DatabaseBootstrapError, statement: string, revisi message: error.message, statement, ...(revision === undefined ? {} : { revision }), + ...(error.retryable === undefined ? {} : { retryable: error.retryable }), ...(error.cause === undefined ? {} : { cause: error.cause }), }); @@ -155,57 +158,45 @@ export const runDatabaseBootstrap = ( }), ); for (const revision of options.revisions) { - yield* session - .transaction((transaction) => - Effect.gen(function* () { - // Re-check under a transaction-scoped advisory lock. This prevents - // two owners from both applying a non-idempotent revision after - // observing the same pre-lock snapshot. - yield* transaction - .execute(ADVISORY_LOCK_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); - const rows = yield* transaction - .query(APPLIED_REVISIONS_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, APPLIED_REVISIONS_STATEMENT))); - const applied = new Set(); - for (const row of rows) { - const decoded = yield* Schema.decodeUnknownEffect(RevisionRowSchema)(row).pipe( - Effect.mapError( - (cause) => - new DatabaseBootstrapError({ - message: `Bootstrap tracking row is malformed: ${String(cause)}`, - statement: APPLIED_REVISIONS_STATEMENT, - }), - ), - ); - applied.add(decoded.revision); - } - if (applied.has(revision.id)) return; - yield* transaction - .execute(revision.statement) - .pipe( - Effect.mapError((error) => statementError(error, revision.statement, revision.id)), - ); - yield* transaction - .execute(RECORD_REVISION_STATEMENT, [revision.id]) - .pipe( - Effect.mapError((error) => - statementError(error, RECORD_REVISION_STATEMENT, revision.id), - ), - ); - }), - ) - .pipe( - Effect.mapError((error) => - error instanceof DatabaseBootstrapError - ? error - : new DatabaseBootstrapError({ - message: `Unable to commit database bootstrap revision ${revision.id}`, - revision: revision.id, - cause: error, - }), - ), - ); + yield* session.transaction((transaction) => + Effect.gen(function* () { + // Re-check under a transaction-scoped advisory lock. This prevents + // two owners from both applying a non-idempotent revision after + // observing the same pre-lock snapshot. + yield* transaction + .execute(ADVISORY_LOCK_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); + const rows = yield* transaction + .query(APPLIED_REVISIONS_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, APPLIED_REVISIONS_STATEMENT))); + const applied = new Set(); + for (const row of rows) { + const decoded = yield* Schema.decodeUnknownEffect(RevisionRowSchema)(row).pipe( + Effect.mapError( + (cause) => + new DatabaseBootstrapError({ + message: `Bootstrap tracking row is malformed: ${String(cause)}`, + statement: APPLIED_REVISIONS_STATEMENT, + }), + ), + ); + applied.add(decoded.revision); + } + if (applied.has(revision.id)) return; + yield* transaction + .execute(revision.statement) + .pipe( + Effect.mapError((error) => statementError(error, revision.statement, revision.id)), + ); + yield* transaction + .execute(RECORD_REVISION_STATEMENT, [revision.id]) + .pipe( + Effect.mapError((error) => + statementError(error, RECORD_REVISION_STATEMENT, revision.id), + ), + ); + }), + ); } if (options.credentials?.roles !== undefined || options.settings !== undefined) { yield* session.transaction((transaction) => diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index 539d4194d3..7424228b5b 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -19,6 +19,7 @@ import type { ContainerArtifact, MaterializedSettings, } from "./CapabilityModule.ts"; +import { WORKLOAD_CATALOG } from "./WorkloadCatalog.ts"; import { Effect } from "effect"; import { InvalidStackConfigError } from "../public/Errors.ts"; import type { AnalyticsSettings } from "./capabilities/analytics.ts"; @@ -196,23 +197,56 @@ export const createExecutionPlan = ( ? modules[name].routes.map((route) => ({ capability: name, ...route })) : [], ); - const declaredWorkloads = CAPABILITY_NAMES.flatMap((name) => { - if (!enabled[name].enabled) return []; + const declaredWorkloads: PlannedWorkload[] = []; + for (const name of CAPABILITY_NAMES) { + if (!enabled[name].enabled) continue; const release = modules[name].releases[versions[name]]; - if (release === undefined) return []; + if (release === undefined) + return Effect.fail( + new InvalidStackConfigError({ + message: `Missing ${name} release ${versions[name]}`, + capability: name, + version: versions[name], + }), + ); const selectedEntries = selectedWorkloads(name, modules, settings, release.workloads); - return selectedEntries.map((entry) => ({ - id: `${name}:${entry.name}`, - capability: name, - ...(entry.bootstrap === undefined ? {} : { bootstrap: entry.bootstrap }), - dependencies: entry.dependencies, - readiness: entry.readiness, - restart: entry.restart, - artifacts: entry.artifacts, - selected: runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, - specHash: specHashes.get(`${name}:${entry.name}`) ?? "", - })); - }); + for (const entry of selectedEntries) { + const id = `${name}:${entry.name}`; + const catalog = WORKLOAD_CATALOG[id]; + if (catalog === undefined) + return Effect.fail( + new InvalidStackConfigError({ + message: `Missing workload catalog entry ${id}`, + capability: name, + workload: id, + }), + ); + const artifacts: PlannedWorkload["artifacts"] = { + native: { kind: "native", release: catalog.nativeVersion }, + container: { kind: "container", image: catalog.containerImage }, + }; + const specHash = specHashes.get(id); + if (specHash === undefined || specHash.length === 0) + return Effect.fail( + new InvalidStackConfigError({ + message: `Missing workload spec hash ${id}`, + capability: name, + workload: id, + }), + ); + declaredWorkloads.push({ + id, + capability: name, + ...(entry.bootstrap === undefined ? {} : { bootstrap: entry.bootstrap }), + dependencies: entry.dependencies, + readiness: entry.readiness, + restart: entry.restart, + artifacts, + selected: runtime.kind === "native" ? artifacts.native : artifacts.container, + specHash, + }); + } + } const byId = new Map(declaredWorkloads.map((entry) => [entry.id, entry])); const workloadOrder: typeof declaredWorkloads = []; const visiting = new Set(); diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 4100bad354..47a1aea6af 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -32,7 +32,6 @@ export interface WorkloadCatalogEntry { readonly workloadId: string; readonly service: string; readonly nativeVersion: string; - readonly supportedNativeVersions: ReadonlyArray; readonly containerImage: string; readonly requiredRuntimePaths: ReadonlyArray; readonly executablePath: string; @@ -49,7 +48,6 @@ const native = ( containerImage: string, executablePath: string, requiredRuntimePaths: ReadonlyArray = [executablePath], - supportedNativeVersions: ReadonlyArray = [nativeVersion], options: { readonly containerAlias?: string; readonly nativeProcess?: NativeWorkloadProcess; @@ -58,7 +56,6 @@ const native = ( workloadId, service, nativeVersion, - supportedNativeVersions, containerImage, requiredRuntimePaths, executablePath, @@ -67,7 +64,7 @@ const native = ( }); /** The single authoritative private workload identity table. */ -export const WORKLOAD_CATALOG: Readonly> = { +const workloadCatalog = { "database:database": native( "database:database", "postgres", @@ -83,7 +80,6 @@ export const WORKLOAD_CATALOG: Readonly> = "share/supabase-cli/migrations", "lib", ], - ["17.6.1.167"], { containerAlias: "supabase-database" }, ), "rest:rest": native( @@ -93,7 +89,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/postgrest:v16.2", "bin/postgrest", ["bin/postgrest"], - ["v16.2"], { containerAlias: "supabase-rest" }, ), "auth:auth": native( @@ -103,7 +98,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/auth:v2.196.0", "bin/auth", ["bin/auth"], - ["v2.196.0"], ), "realtime:realtime": native( "realtime:realtime", @@ -112,7 +106,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/realtime:v2.130.0", "bin/server", ["bin/migrate", "bin/realtime", "bin/server"], - ["v2.130.0"], ), "storage:storage": native( "storage:storage", @@ -121,7 +114,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/storage:v1.72.1", "app/dist/start/server.js", ["node/bin/node", "app/dist/start/server.js", "app/dist/scripts/migrate-call.js"], - ["v1.72.1"], { nativeProcess: { executablePath: "node/bin/node", @@ -144,7 +136,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/edge-runtime:v1.74.3", "bin/edge-runtime", ["bin/edge-runtime"], - ["v1.74.3"], { containerAlias: "supabase-functions" }, ), "studio:studio": native( @@ -154,7 +145,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/studio:2026.08.24-sha-8ec45b2", "app/apps/studio/server.js", ["node/bin/node", "app/apps/studio/docker-entrypoint.mjs", "app/apps/studio/server.js"], - ["2026.08.24-sha-8ec45b2"], { nativeProcess: { executablePath: "node/bin/node", @@ -170,7 +160,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/pgmeta:v0.99.0", "app/dist/server/server.js", ["node/bin/node", "app/dist/server/server.js"], - ["v0.99.0"], { nativeProcess: { executablePath: "node/bin/node", @@ -186,7 +175,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/mailpit:v1.30.2", "bin/mailpit", ["bin/mailpit"], - ["v1.30.2"], { containerAlias: "supabase-mail" }, ), "analytics:analytics": native( @@ -204,8 +192,6 @@ export const WORKLOAD_CATALOG: Readonly> = "ghcr.io/supabase/cli/vector:0.53.0", "bin/vector", ["bin/vector", "share/doc/vector/config/vector.yaml"], - ["0.53.0"], - { containerAlias: "supabase-vector" }, ), "pooler:pooler": native( "pooler:pooler", @@ -215,7 +201,10 @@ export const WORKLOAD_CATALOG: Readonly> = "bin/server", ["bin/migrate", "bin/supavisor", "bin/server"], ), -}; +} satisfies Readonly>; + +export const WORKLOAD_CATALOG: Readonly> = workloadCatalog; +export type WorkloadId = keyof typeof workloadCatalog; export const targetForPlatform = (platform: { readonly os: string; @@ -227,12 +216,16 @@ export const targetForPlatform = (platform: { return undefined; }; -const artifactFor = ( - entry: WorkloadCatalogEntry, - target: NativeTarget, - releaseOverride?: string, -): NativeWorkloadArtifact => { - const version = releaseOverride ?? entry.nativeVersion; +/** Returns the catalog entry for an open workload identity. */ +export const catalogEntryFor = (workloadId: string): WorkloadCatalogEntry | undefined => + WORKLOAD_CATALOG[workloadId]; + +/** Resolves the container alias for a catalog workload identity. */ +export const containerAliasFor = (workloadId: WorkloadId): string => + workloadCatalog[workloadId].containerAlias; + +const artifactFor = (entry: WorkloadCatalogEntry, target: NativeTarget): NativeWorkloadArtifact => { + const version = entry.nativeVersion; const releaseTag = `${entry.service}-${version}`; const assetName = `${releaseTag}-${target}`; const base = `https://github.com/supabase/slim-services/releases/download/${releaseTag}`; @@ -262,7 +255,7 @@ export const resolveNativeArtifactForWorkload = ( arch: process.arch, }, ): Effect.Effect => { - const entry = WORKLOAD_CATALOG[workload.id]; + const entry = catalogEntryFor(workload.id); if (entry === undefined) return Effect.fail( new StackPreparationError({ @@ -279,7 +272,7 @@ export const resolveNativeArtifactForWorkload = ( platform: `${platform.os}/${platform.arch}`, }), ); - if (!entry.supportedNativeVersions.includes(workload.artifacts.native.release)) + if (workload.artifacts.native.release !== entry.nativeVersion) return Effect.fail( new StackPreparationError({ message: `Unsupported native release ${workload.artifacts.native.release}`, @@ -288,5 +281,5 @@ export const resolveNativeArtifactForWorkload = ( version: workload.artifacts.native.release, }), ); - return Effect.succeed(artifactFor(entry, target, workload.artifacts.native.release)); + return Effect.succeed(artifactFor(entry, target)); }; diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index 1766a7c6d6..9d96c05c03 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -18,8 +18,8 @@ export const AnalyticsModule: CapabilityModule = { defaultSettings: { backend: "postgres", vector_port: undefined, - gcp_project_id: undefined, - gcp_project_number: undefined, + gcp_project_id: "local", + gcp_project_number: "0", gcp_jwt_path: undefined, api_key: undefined, }, @@ -29,11 +29,11 @@ export const AnalyticsModule: CapabilityModule = { dependencies: ["database"], releases: { "v1.50.6": release("v1.50.6", [ - workload("analytics", "analytics", "v1.50.6", "ghcr.io/supabase/cli/analytics:v1.50.6", { + workload("analytics", "analytics", { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), - workload("vector", "analytics", "0.53.0", "ghcr.io/supabase/cli/vector:0.53.0", { + workload("vector", "analytics", { dependencies: ["analytics:analytics"], readiness: { mode: "tcp" }, }), @@ -46,5 +46,4 @@ export const AnalyticsModule: CapabilityModule = { const enabled = typeof settings.vector_port === "number"; return workloads.filter((entry) => enabled || entry.name !== "vector"); }, - materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/auth.ts b/packages/stack/src/model/capabilities/auth.ts index 537e8afd9e..6516ce7dff 100644 --- a/packages/stack/src/model/capabilities/auth.ts +++ b/packages/stack/src/model/capabilities/auth.ts @@ -396,7 +396,7 @@ export const AuthModule: CapabilityModule = { dependencies: ["database"], releases: { "v2.196.0": release("v2.196.0", [ - workload("auth", "auth", "v2.196.0", "ghcr.io/supabase/cli/auth:v2.196.0", { + workload("auth", "auth", { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index d04cbdeedc..6f438a5c0b 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -136,7 +136,7 @@ const defaults: DatabaseSettings = { }; const postgres17Release = release("17.6.1.167", [ - workload("database", "database", "17.6.1.167", "ghcr.io/supabase/cli/postgres:17.6.1.167", { + workload("database", "database", { bootstrap: "database", readiness: { mode: "tcp", portField: "database" }, }), @@ -157,5 +157,4 @@ export const DatabaseModule: CapabilityModule = { routes: [{ listener: "database", protocol: "tcp" }], secretPolicy: () => "passthrough", managedSecretSlots: [], - materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index ed1d49dacc..718d17d6b1 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -75,16 +75,10 @@ export const FunctionsModule: CapabilityModule = { dependencies: ["database"], releases: { "v1.74.3": release("v1.74.3", [ - workload( - "edge-runtime", - "functions", - "v1.74.3", - "ghcr.io/supabase/cli/edge-runtime:v1.74.3", - { - dependencies: ["database:database"], - readiness: { mode: "http", portField: "functionsInspector" }, - }, - ), + workload("edge-runtime", "functions", { + dependencies: ["database:database"], + readiness: { mode: "http", portField: "functionsInspector" }, + }), ]), }, routes: [ diff --git a/packages/stack/src/model/capabilities/mail.ts b/packages/stack/src/model/capabilities/mail.ts index 1c49849872..d87a3c72bf 100644 --- a/packages/stack/src/model/capabilities/mail.ts +++ b/packages/stack/src/model/capabilities/mail.ts @@ -9,14 +9,14 @@ export type MailSettings = Schema.Schema.Type; export const MailModule: CapabilityModule = { name: "mail", settings: MailSettingsSchema, - defaultSettings: { admin_email: undefined, sender_name: undefined }, + defaultSettings: { admin_email: "admin@email.com", sender_name: "Admin" }, defaultEnabled: true, defaultActivation: "eager", defaultVersion: "v1.30.2", dependencies: [], releases: { "v1.30.2": release("v1.30.2", [ - workload("mail", "mail", "v1.30.2", "ghcr.io/supabase/cli/mailpit:v1.30.2", { + workload("mail", "mail", { readiness: { mode: "http", portField: "mailUi" }, }), ]), @@ -28,5 +28,4 @@ export const MailModule: CapabilityModule = { ], secretPolicy: () => "passthrough", managedSecretSlots: [], - materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts index c45ba6aad8..365be03bba 100644 --- a/packages/stack/src/model/capabilities/pooler.ts +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -28,7 +28,7 @@ export const PoolerModule: CapabilityModule = { dependencies: ["database"], releases: { "v2.9.12": release("v2.9.12", [ - workload("pooler", "pooler", "v2.9.12", "ghcr.io/supabase/cli/pooler:v2.9.12", { + workload("pooler", "pooler", { dependencies: ["database:database"], readiness: { mode: "tcp", portField: "pooler" }, }), @@ -40,5 +40,4 @@ export const PoolerModule: CapabilityModule = { ? "managed" : "passthrough", managedSecretSlots: ["pooler.settings.encryption_key", "pooler.settings.secret_key_base"], - materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/realtime.ts b/packages/stack/src/model/capabilities/realtime.ts index 2a37376582..67e06c2eca 100644 --- a/packages/stack/src/model/capabilities/realtime.ts +++ b/packages/stack/src/model/capabilities/realtime.ts @@ -17,7 +17,7 @@ export const RealtimeModule: CapabilityModule = { dependencies: ["database"], releases: { "v2.130.0": release("v2.130.0", [ - workload("realtime", "realtime", "v2.130.0", "ghcr.io/supabase/cli/realtime:v2.130.0", { + workload("realtime", "realtime", { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), @@ -26,5 +26,4 @@ export const RealtimeModule: CapabilityModule = { routes: [{ listener: "api", protocol: "http" }], secretPolicy: () => "passthrough", managedSecretSlots: [], - materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/rest.ts b/packages/stack/src/model/capabilities/rest.ts index 5fd79f657c..325e80d0ce 100644 --- a/packages/stack/src/model/capabilities/rest.ts +++ b/packages/stack/src/model/capabilities/rest.ts @@ -34,7 +34,7 @@ export const RestModule: CapabilityModule = { dependencies: ["database"], releases: { "v16.2": release("v16.2", [ - workload("rest", "rest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", { + workload("rest", "rest", { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), @@ -43,5 +43,4 @@ export const RestModule: CapabilityModule = { routes: [{ listener: "api", protocol: "http" }], secretPolicy: () => "passthrough", managedSecretSlots: [], - materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index a2eee0ca94..03accefb59 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -104,11 +104,11 @@ export const StorageModule: CapabilityModule = { dependencies: ["database"], releases: { "v1.72.1": release("v1.72.1", [ - workload("storage", "storage", "v1.72.1", "ghcr.io/supabase/cli/storage:v1.72.1", { + workload("storage", "storage", { dependencies: ["database:database"], readiness: { mode: "http", portField: "api" }, }), - workload("imgproxy", "storage", "v3.8.0", "ghcr.io/supabase/cli/imgproxy:v3.8.0", { + workload("imgproxy", "storage", { readiness: { mode: "http" }, }), ]), diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts index 06b0937ad3..b759e59fe0 100644 --- a/packages/stack/src/model/capabilities/studio.ts +++ b/packages/stack/src/model/capabilities/studio.ts @@ -17,17 +17,11 @@ export const StudioModule: CapabilityModule = { dependencies: ["rest", "analytics"], releases: { "2026.08.24-sha-8ec45b2": release("2026.08.24-sha-8ec45b2", [ - workload( - "studio", - "studio", - "2026.08.24-sha-8ec45b2", - "ghcr.io/supabase/cli/studio:2026.08.24-sha-8ec45b2", - { - dependencies: ["studio:pgmeta", "analytics:analytics"], - readiness: { mode: "http", portField: "studio" }, - }, - ), - workload("pgmeta", "studio", "v0.99.0", "ghcr.io/supabase/cli/pgmeta:v0.99.0", { + workload("studio", "studio", { + dependencies: ["studio:pgmeta", "analytics:analytics"], + readiness: { mode: "http", portField: "studio" }, + }), + workload("pgmeta", "studio", { dependencies: ["database:database"], readiness: { mode: "http" }, }), @@ -36,5 +30,4 @@ export const StudioModule: CapabilityModule = { routes: [{ listener: "studio", protocol: "http" }], secretPolicy: () => "passthrough", managedSecretSlots: [], - materialize: (settings) => settings, }; diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index 3fa7e83534..acb4f6148d 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -2,6 +2,8 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option } from "effect"; import { compileStack } from "./Compiler.ts"; +import { CAPABILITY_NAMES } from "../public/Capability.ts"; +import { CAPABILITY_MODULES } from "./ExecutionPlan.ts"; import { resolveNativeArtifactForWorkload, targetForPlatform, @@ -22,50 +24,6 @@ const compileContainer = (config: Parameters[0]["config"]) }).pipe(Effect.provide(NodeServices.layer)); describe("complete workload catalog", () => { - it("keeps native releases paired with their canonical container images", () => { - expect(WORKLOAD_CATALOG["database:database"]).toMatchObject({ - nativeVersion: "17.6.1.167", - containerImage: "ghcr.io/supabase/cli/postgres:17.6.1.167", - supportedNativeVersions: ["17.6.1.167"], - }); - expect(WORKLOAD_CATALOG["auth:auth"]?.containerImage).toBe( - "ghcr.io/supabase/cli/auth:v2.196.0", - ); - expect(WORKLOAD_CATALOG["realtime:realtime"]?.nativeVersion).toBe("v2.130.0"); - expect(WORKLOAD_CATALOG["realtime:realtime"]?.containerImage).toBe( - "ghcr.io/supabase/cli/realtime:v2.130.0", - ); - expect(WORKLOAD_CATALOG["storage:storage"]?.containerImage).toBe( - "ghcr.io/supabase/cli/storage:v1.72.1", - ); - expect(WORKLOAD_CATALOG["studio:pgmeta"]?.containerImage).toBe( - "ghcr.io/supabase/cli/pgmeta:v0.99.0", - ); - expect(WORKLOAD_CATALOG["studio:pgmeta"]).toMatchObject({ - nativeVersion: "v0.99.0", - supportedNativeVersions: ["v0.99.0"], - }); - expect(WORKLOAD_CATALOG["analytics:analytics"]?.containerImage).toBe( - "ghcr.io/supabase/cli/analytics:v1.50.6", - ); - expect(WORKLOAD_CATALOG["mail:mail"]?.containerImage).toBe( - "ghcr.io/supabase/cli/mailpit:v1.30.2", - ); - expect(WORKLOAD_CATALOG["analytics:vector"]?.containerImage).toBe( - "ghcr.io/supabase/cli/vector:0.53.0", - ); - expect(WORKLOAD_CATALOG["analytics:vector"]).toMatchObject({ - nativeVersion: "0.53.0", - supportedNativeVersions: ["0.53.0"], - }); - expect(WORKLOAD_CATALOG["storage:imgproxy"]?.containerImage).toBe( - "ghcr.io/supabase/cli/imgproxy:v3.8.0", - ); - expect(WORKLOAD_CATALOG["pooler:pooler"]?.containerImage).toBe( - "ghcr.io/supabase/cli/pooler:v2.9.12", - ); - }); - it("matches the executable paths shipped by slim-services archives", () => { const expected = { "database:database": "share/supabase-cli/bin/supabase-postgres-init.sh", @@ -115,38 +73,41 @@ describe("complete workload catalog", () => { "bin/supavisor", "bin/server", ]); - expect(Object.values(WORKLOAD_CATALOG).map((entry) => entry.containerAlias)).toEqual( - expect.arrayContaining([ - "supabase-database", - "supabase-rest", - "supabase-auth", - "supabase-realtime", - "supabase-storage", - "supabase-imgproxy", - "supabase-functions", - "supabase-studio", - "supabase-pgmeta", - "supabase-mail", - "supabase-analytics", - "supabase-vector", - "supabase-pooler", - ]), - ); - expect(WORKLOAD_CATALOG["storage:storage"]?.nativeProcess).toEqual({ - executablePath: "node/bin/node", - args: ["app/dist/start/server.js"], - cwd: "app", - }); - expect(WORKLOAD_CATALOG["studio:pgmeta"]?.nativeProcess).toEqual({ - executablePath: "node/bin/node", - args: ["app/dist/server/server.js"], - cwd: "app", - }); - expect(WORKLOAD_CATALOG["studio:studio"]?.nativeProcess).toEqual({ - executablePath: "node/bin/node", - args: ["app/apps/studio/docker-entrypoint.mjs"], - cwd: "app", - }); + }); + + it.live("keeps persisted capability versions aligned with catalog releases", () => + Effect.gen(function* () { + const defaults = yield* compile({}); + for (const name of CAPABILITY_NAMES) { + const capability = defaults.definition.capabilities[name]; + if (!capability.enabled) continue; + const workload = + defaults.executionPlan.workloads.find((entry) => entry.id === `${name}:${name}`) ?? + defaults.executionPlan.workloads.find((entry) => entry.capability === name); + expect(workload).toBeDefined(); + if (workload === undefined) continue; + expect(workload.artifacts.native.release).toBe(capability.version); + } + + const databaseAlias = yield* compile({ capabilities: { database: { version: "17" } } }); + const database = databaseAlias.executionPlan.workloads.find( + (entry) => entry.id === "database:database", + ); + expect(database).toBeDefined(); + if (database === undefined) return; + expect(databaseAlias.definition.capabilities.database.version).toBe( + database.artifacts.native.release, + ); + expect(database.artifacts.native.release).toBe( + WORKLOAD_CATALOG["database:database"]?.nativeVersion, + ); + }), + ); + + it("registers every release under its canonical version key", () => { + for (const name of CAPABILITY_NAMES) + for (const release of Object.values(CAPABILITY_MODULES[name].releases)) + expect(CAPABILITY_MODULES[name].releases[release.version]).toBe(release); }); it.live("resolves every declared workload to a slim-services native release", () => @@ -178,22 +139,21 @@ describe("complete workload catalog", () => { analytics: { settings: { vector_port: 9001 } }, }, }); - const expected = [ - ["studio:pgmeta", "pgmeta", "v0.99.0"], - ["analytics:vector", "vector", "0.53.0"], - ] as const; - for (const [id, service, version] of expected) { + for (const id of ["studio:pgmeta", "analytics:vector"] as const) { + const catalog = WORKLOAD_CATALOG[id]; + expect(catalog).toBeDefined(); + if (catalog === undefined) continue; const workload = result.executionPlan.workloads.find((entry) => entry.id === id); expect(workload).toBeDefined(); if (workload === undefined) continue; - expect(workload.artifacts.native.release).toBe(version); + expect(workload.artifacts.native.release).toBe(catalog.nativeVersion); const artifact = yield* resolveNativeArtifactForWorkload(workload, { os: "darwin", arch: "arm64", }); - expect(artifact.releaseTag).toBe(`${service}-${version}`); + expect(artifact.releaseTag).toBe(`${catalog.service}-${catalog.nativeVersion}`); expect(artifact.downloadUrl).toBe( - `https://github.com/supabase/slim-services/releases/download/${service}-${version}/${service}-${version}-darwin-arm64.tar.zst`, + `https://github.com/supabase/slim-services/releases/download/${catalog.service}-${catalog.nativeVersion}/${catalog.service}-${catalog.nativeVersion}-darwin-arm64.tar.zst`, ); } }), @@ -257,9 +217,13 @@ describe("complete workload catalog", () => { const images = new Map( result.executionPlan.workloads.map((entry) => [entry.id, entry.artifacts.container.image]), ); - expect(images.get("mail:mail")).toBe("ghcr.io/supabase/cli/mailpit:v1.30.2"); - expect(images.get("storage:imgproxy")).toBe("ghcr.io/supabase/cli/imgproxy:v3.8.0"); - expect(images.get("analytics:vector")).toBe("ghcr.io/supabase/cli/vector:0.53.0"); + expect(images.get("mail:mail")).toBe(WORKLOAD_CATALOG["mail:mail"]?.containerImage); + expect(images.get("storage:imgproxy")).toBe( + WORKLOAD_CATALOG["storage:imgproxy"]?.containerImage, + ); + expect(images.get("analytics:vector")).toBe( + WORKLOAD_CATALOG["analytics:vector"]?.containerImage, + ); }), ); @@ -275,8 +239,10 @@ describe("complete workload catalog", () => { os: "linux", arch: "x64", }); - expect(artifact.version).toBe("17.6.1.167"); - expect(artifact.downloadUrl).toContain("postgres-17.6.1.167-linux-amd64.tar.zst"); + expect(artifact.version).toBe(WORKLOAD_CATALOG["database:database"]?.nativeVersion); + expect(artifact.downloadUrl).toContain( + `postgres-${WORKLOAD_CATALOG["database:database"]?.nativeVersion}-linux-amd64.tar.zst`, + ); }), ); diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index a345219ad4..5aae2696cf 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -81,6 +81,37 @@ describe("closed capability compiler", () => { }), ); + it.live("persists defaults consumed by workload runtimes", () => + Effect.gen(function* () { + const result = yield* compile({}); + expect(result.definition.capabilities.rest.settings).toMatchObject({ + schemas: ["public", "graphql_public"], + extra_search_path: ["public", "extensions"], + max_rows: 1000, + }); + expect(result.definition.capabilities.auth.settings).toMatchObject({ + site_url: "http://127.0.0.1:3000", + jwt_expiry: 3600, + }); + expect(result.definition.capabilities.mail.settings).toEqual({ + admin_email: "admin@email.com", + sender_name: "Admin", + }); + expect(result.definition.capabilities.analytics.settings).toMatchObject({ + backend: "postgres", + gcp_project_id: "local", + gcp_project_number: "0", + }); + expect(result.definition.capabilities.realtime.settings).toMatchObject({ + max_header_length: 4096, + }); + expect(result.definition.capabilities.functions.settings).toMatchObject({ + functions_root: "/tmp/supabase-project/supabase/functions", + edge_runtime: { policy: "per_worker", deno_version: 2 }, + }); + }), + ); + it.live("materializes a non-default auth setting", () => Effect.gen(function* () { const result = yield* compile({ @@ -596,10 +627,9 @@ describe("closed capability compiler", () => { expect( result.executionPlan.workloads.find((w) => w.id === "database:database")?.artifacts, ).toEqual({ - native: { kind: "native", service: "database", release }, + native: { kind: "native", release }, container: { kind: "container", - service: "database", image: `ghcr.io/supabase/cli/postgres:${release}`, }, }); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index ceea7ceb26..0c9f215912 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -21,10 +21,9 @@ const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload readiness: { mode: "tcp" }, restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { - native: { kind: "native", service: "postgres", release: "17.6.1.167" }, + native: { kind: "native", release: "17.6.1.167" }, container: { kind: "container", - service: "postgres", image: "ghcr.io/supabase/cli/postgres:17.6.1.167", }, }, @@ -120,7 +119,7 @@ describe("runtime artifact preparation", () => { const result = Effect.runSync( runtime.prepare( { kind: "native" }, - nativeWorkload({ kind: "native", service: "postgres", release: "17.6.1.167" }), + nativeWorkload({ kind: "native", release: "17.6.1.167" }), ), ); expect(result.outcome).toBe("downloaded"); @@ -142,7 +141,6 @@ describe("runtime artifact preparation", () => { { kind: "container", engine: "docker" }, nativeWorkload({ kind: "container", - service: "postgres", image: "ghcr.io/supabase/cli/postgres:17.6.1.167", }), ), @@ -170,7 +168,6 @@ describe("runtime artifact preparation", () => { { kind: "container", engine: "podman" }, nativeWorkload({ kind: "container", - service: "postgres", image: "ghcr.io/supabase/cli/postgres:17.6.1.167", }), ), @@ -198,7 +195,6 @@ describe("runtime artifact preparation", () => { { kind: "native" }, nativeWorkload({ kind: "container", - service: "postgres", image: "ghcr.io/supabase/cli/postgres:17.6.1.167", }), ), diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 7313155330..8a6492d50d 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -7,6 +7,7 @@ import { Fiber, Option, Path, + Predicate, Ref, Scope, Schema, @@ -72,6 +73,8 @@ import { type StackLogsError, type DestroyStackError, type StackError, + type StackErrorTag, + isStackErrorTag, } from "./Errors.ts"; import { readOwnerMetadata } from "../state/Ownership.ts"; import { makeControlClient } from "../control/ControlServer.ts"; @@ -226,6 +229,43 @@ const environment = () => type ControlError = StackRpcError | RpcClientError | SocketError | MaintenanceProtocolError; +const stackErrorFactories = { + InvalidStackIdentityError: (message: string) => new InvalidStackIdentityError({ message }), + InvalidProjectRootError: (message: string) => new InvalidProjectRootError({ message }), + InvalidStackConfigError: (message: string) => new InvalidStackConfigError({ message }), + StackVersionUnsupportedError: (message: string) => new StackVersionUnsupportedError({ message }), + StackNotFoundError: (message: string) => new StackNotFoundError({ message }), + StackOwnershipConflictError: (message: string) => new StackOwnershipConflictError({ message }), + StackRuntimeMismatchError: (message: string) => new StackRuntimeMismatchError({ message }), + StackDefinitionRequiredError: (message: string) => new StackDefinitionRequiredError({ message }), + StackNotRunningError: (message: string) => new StackNotRunningError({ message }), + StackMustBeStoppedError: (message: string) => new StackMustBeStoppedError({ message }), + StackLifecycleConflictError: (message: string) => new StackLifecycleConflictError({ message }), + StackStateInvalidError: (message: string) => new StackStateInvalidError({ message }), + StackStateFormatUnsupportedError: (message: string) => + new StackStateFormatUnsupportedError({ message }), + StackStateGenerationMismatchError: (message: string) => + new StackStateGenerationMismatchError({ message }), + StackUpgradeRequiredError: (message: string) => new StackUpgradeRequiredError({ message }), + StackUpgradeReplacementError: (message: string) => new StackUpgradeReplacementError({ message }), + StackSecretMismatchError: (message: string) => new StackSecretMismatchError({ message }), + InvalidJwtSigningMaterialError: (message: string) => + new InvalidJwtSigningMaterialError({ message }), + PortAllocationError: (message: string) => new PortAllocationError({ message }), + PortUnavailableError: (message: string) => new PortUnavailableError({ message }), + GatewayAuthenticationError: (message: string) => new GatewayAuthenticationError({ message }), + GatewayStaleGenerationError: (message: string) => new GatewayStaleGenerationError({ message }), + GatewayActivationError: (message: string) => new GatewayActivationError({ message }), + StackPreparationError: (message: string) => new StackPreparationError({ message }), + ArtifactIntegrityError: (message: string) => new ArtifactIntegrityError({ message }), + ContainerPullError: (message: string) => new ContainerPullError({ message }), + StackReconciliationError: (message: string) => new StackReconciliationError({ message }), + ServiceStartError: (message: string) => new ServiceStartError({ message }), + ServiceReadinessError: (message: string) => new ServiceReadinessError({ message }), + ContainerEngineError: (message: string) => new ContainerEngineError({ message }), + StackDestructionError: (message: string) => new StackDestructionError({ message }), +} satisfies Record StackError>; + const errorForRpc = (error: ControlError): StackError => { if ( typeof error === "object" && @@ -235,76 +275,12 @@ const errorForRpc = (error: ControlError): StackError => { typeof error.tag === "string" && typeof error.message === "string" ) { - switch (error.tag) { - case "InvalidStackIdentityError": - return new InvalidStackIdentityError({ message: error.message }); - case "InvalidProjectRootError": - return new InvalidProjectRootError({ message: error.message }); - case "InvalidStackConfigError": - return new InvalidStackConfigError({ message: error.message }); - case "StackVersionUnsupportedError": - return new StackVersionUnsupportedError({ message: error.message }); - case "StackNotFoundError": - return new StackNotFoundError({ message: error.message }); - case "StackDefinitionRequiredError": - return new StackDefinitionRequiredError({ message: error.message }); - case "StackNotRunningError": - return new StackNotRunningError({ message: error.message }); - case "StackUpgradeRequiredError": - return new StackUpgradeRequiredError({ message: error.message }); - case "StackUpgradeReplacementError": - return new StackUpgradeReplacementError({ message: error.message }); - case "StackDestructionError": - return new StackDestructionError({ message: error.message }); - case "StackOwnershipConflictError": - return new StackOwnershipConflictError({ message: error.message }); - case "StackRuntimeMismatchError": - return new StackRuntimeMismatchError({ message: error.message }); - case "StackMustBeStoppedError": - return new StackMustBeStoppedError({ message: error.message }); - case "StackLifecycleConflictError": - return new StackLifecycleConflictError({ message: error.message }); - case "StackStateInvalidError": - return new StackStateInvalidError({ message: error.message }); - case "StackStateFormatUnsupportedError": - return new StackStateFormatUnsupportedError({ message: error.message }); - case "StackStateGenerationMismatchError": - return new StackStateGenerationMismatchError({ message: error.message }); - case "StackSecretMismatchError": - return new StackSecretMismatchError({ message: error.message }); - case "InvalidJwtSigningMaterialError": - return new InvalidJwtSigningMaterialError({ message: error.message }); - case "PortAllocationError": - return new PortAllocationError({ message: error.message }); - case "PortUnavailableError": - return new PortUnavailableError({ message: error.message }); - case "GatewayAuthenticationError": - return new GatewayAuthenticationError({ message: error.message }); - case "GatewayStaleGenerationError": - return new GatewayStaleGenerationError({ message: error.message }); - case "GatewayActivationError": - return new GatewayActivationError({ message: error.message }); - case "StackPreparationError": - return new StackPreparationError({ message: error.message }); - case "ArtifactIntegrityError": - return new ArtifactIntegrityError({ message: error.message }); - case "ContainerPullError": - return new ContainerPullError({ message: error.message }); - case "StackReconciliationError": - return new StackReconciliationError({ message: error.message }); - case "ServiceStartError": - return new ServiceStartError({ message: error.message }); - case "ServiceReadinessError": - return new ServiceReadinessError({ message: error.message }); - case "ContainerEngineError": - return new ContainerEngineError({ message: error.message }); - case "StackRpcProtocolError": - return new StackStateInvalidError({ message: error.message }); - default: - return new StackStateInvalidError({ message: error.message }); - } + if (isStackErrorTag(error.tag)) return stackErrorFactories[error.tag](error.message); + if (error.tag === "StackRpcProtocolError") + return new StackUpgradeRequiredError({ message: error.message }); + return new StackStateInvalidError({ message: error.message }); } - return new StackStateInvalidError({ message: "Stack control request failed" }); + return new StackStateInvalidError({ message: error.message }); }; const statusError = (error: ControlError): StackStatusError => { @@ -314,7 +290,7 @@ const statusError = (error: ControlError): StackStatusError => { mapped instanceof StackStateFormatUnsupportedError || mapped instanceof StackUpgradeRequiredError ? mapped - : new StackStateInvalidError({ message: "Stack control request failed" }); + : new StackStateInvalidError({ message: mapped.message }); }; const credentialsError = (error: ControlError): StackCredentialsError => { @@ -324,7 +300,7 @@ const credentialsError = (error: ControlError): StackCredentialsError => { mapped instanceof StackSecretMismatchError || mapped instanceof InvalidJwtSigningMaterialError ? mapped - : new StackNotRunningError({ message: "Stack credentials are unavailable" }); + : new StackNotRunningError({ message: mapped.message }); }; const prepareError = (error: ControlError): PrepareStackError => { @@ -333,7 +309,7 @@ const prepareError = (error: ControlError): PrepareStackError => { mapped instanceof ArtifactIntegrityError || mapped instanceof ContainerPullError ? mapped - : new StackPreparationError({ message: "Stack preparation failed" }); + : new StackPreparationError({ message: mapped.message }); }; const startError = (error: ControlError): StackStartError => { @@ -353,7 +329,7 @@ const startError = (error: ControlError): StackStartError => { mapped instanceof ServiceReadinessError || mapped instanceof ContainerEngineError ? mapped - : new StackStateInvalidError({ message: "Stack start failed" }); + : new StackStateInvalidError({ message: mapped.message }); }; const restartError = (error: ControlError): StackRestartError => { @@ -369,7 +345,7 @@ const stopError = (error: ControlError): StackStopError => { mapped instanceof ServiceReadinessError || mapped instanceof ContainerEngineError ? mapped - : new StackLifecycleConflictError({ message: "Stack stop failed" }); + : new StackLifecycleConflictError({ message: mapped.message }); }; const destroyError = (error: ControlError): DestroyStackError => { @@ -379,7 +355,7 @@ const destroyError = (error: ControlError): DestroyStackError => { mapped instanceof StackLifecycleConflictError || mapped instanceof ContainerEngineError ? mapped - : new StackDestructionError({ message: "Stack destruction failed" }); + : new StackDestructionError({ message: mapped.message }); }; /** Internal control-transport seam used by public lifecycle integration tests. */ @@ -398,12 +374,49 @@ export const makeHandle = ( ownerSessionId: metadata.ownerSessionId, rpcRelease: metadata.rpcRelease, }); - const check = (effect: Effect.Effect) => - Ref.get(closed).pipe(Effect.flatMap((isClosed) => (isClosed ? Effect.interrupt : effect))); + const check = (effect: Effect.Effect): Effect.Effect => + Ref.get(closed).pipe( + Effect.flatMap((isClosed): Effect.Effect => + isClosed ? Effect.interrupt : effect, + ), + ); + const mapRpcClientFailure = ( + error: RpcClientError, + mapError: (error: ControlError) => E, + ): Effect.Effect => + Effect.exit(Effect.scoped(client.probe())).pipe( + Effect.flatMap((probe) => { + if ( + Exit.isSuccess(probe) && + probe.value.ok && + probe.value.op === "probe" && + probe.value.rpcRelease !== STACK_RPC_RELEASE + ) { + const mismatch: StackRpcError = { + tag: "StackRpcProtocolError", + message: `Stack owner release ${probe.value.rpcRelease} requires explicit restart`, + }; + return Effect.fail(mapError(mismatch)); + } + return Effect.fail(mapError(error)); + }), + ); const invoke = ( call: (rpc: StackRpcClient) => Effect.Effect, mapError: (error: ControlError) => E, - ) => check(Effect.scoped(client.rpc.pipe(Effect.flatMap(call), Effect.mapError(mapError)))); + ): Effect.Effect => { + const rpcCall: Effect.Effect = Effect.scoped( + client.rpc.pipe(Effect.flatMap(call)), + ); + const mapped: Effect.Effect = rpcCall.pipe( + Effect.catchIf( + (error): error is RpcClientError => Predicate.isTagged(error, "RpcClientError"), + (error) => mapRpcClientFailure(error, mapError), + (error) => Effect.fail(mapError(error)), + ), + ); + return check(mapped); + }; const destroyAndAwaitOwner: Effect.Effect = check( Effect.gen(function* () { const ownerConnected = yield* Deferred.make(); @@ -484,8 +497,20 @@ export const makeHandle = ( Stream.unwrap( check( client.rpc.pipe( - Effect.map((rpc) => rpc.watchStatus(undefined).pipe(Stream.mapError(statusError))), - Effect.mapError(statusError), + Effect.map((rpc) => + rpc.watchStatus(undefined).pipe( + Stream.catchIf( + (error): error is RpcClientError => Predicate.isTagged(error, "RpcClientError"), + (error) => Stream.fromEffect(mapRpcClientFailure(error, statusError)), + (error) => Stream.fail(statusError(error)), + ), + ), + ), + Effect.catchIf( + (error): error is RpcClientError => Predicate.isTagged(error, "RpcClientError"), + (error) => mapRpcClientFailure(error, statusError), + (error) => Effect.fail(statusError(error)), + ), ), ), ).pipe(Stream.scoped), @@ -501,7 +526,7 @@ export const makeHandle = ( mapped instanceof StackNotRunningError || mapped instanceof StackStateInvalidError ? mapped - : new StackStateInvalidError({ message: "Stack logs are unavailable" }); + : new StackStateInvalidError({ message: mapped.message }); }), ), ), @@ -511,7 +536,7 @@ export const makeHandle = ( mapped instanceof StackNotRunningError || mapped instanceof StackStateInvalidError ? mapped - : new StackStateInvalidError({ message: "Stack logs are unavailable" }); + : new StackStateInvalidError({ message: mapped.message }); }), ), ), diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 5431911ab5..5d59c98826 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -103,6 +103,46 @@ export class StackDestructionError extends Data.TaggedError( "StackDestructionError", ) {} +/** Stable wire tags for errors produced by the managed stack runtime. */ +export const STACK_ERROR_TAGS = [ + "InvalidStackIdentityError", + "InvalidProjectRootError", + "InvalidStackConfigError", + "StackVersionUnsupportedError", + "StackNotFoundError", + "StackOwnershipConflictError", + "StackRuntimeMismatchError", + "StackDefinitionRequiredError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", + "StackStateGenerationMismatchError", + "StackUpgradeRequiredError", + "StackUpgradeReplacementError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", + "PortAllocationError", + "PortUnavailableError", + "GatewayAuthenticationError", + "GatewayStaleGenerationError", + "GatewayActivationError", + "StackPreparationError", + "ArtifactIntegrityError", + "ContainerPullError", + "StackReconciliationError", + "ServiceStartError", + "ServiceReadinessError", + "ContainerEngineError", + "StackDestructionError", +] as const; + +export type StackErrorTag = (typeof STACK_ERROR_TAGS)[number]; + +export const isStackErrorTag = (tag: string): tag is StackErrorTag => + STACK_ERROR_TAGS.some((candidate) => candidate === tag); + export type StackError = | InvalidStackIdentityError | InvalidProjectRootError diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index ee09f40433..61bb168660 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -15,7 +15,7 @@ import { } from "effect"; import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; -import { StackDestructionError } from "./Errors.ts"; +import { StackDestructionError, StackUpgradeRequiredError } from "./Errors.ts"; import { makeHandle } from "./EffectStack.ts"; import * as effectApi from "../effect.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; @@ -134,4 +134,70 @@ describe("Effect stack lifecycle handoff", () => { }).pipe(Effect.provide(NodeServices.layer)), ), ); + + it.live("surfaces an incompatible RPC release from an existing handle", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "session"; + const rpcHandlers: StackRpcHandlers = { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => Effect.succeed(runningStatus), + restart: () => Effect.succeed(runningStatus), + destroy: () => Effect.void, + logs: () => Stream.empty, + watchStatus: () => Stream.empty, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: "stack-rpc-v0@0.0.1", + rpcHandlers, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: "stack-rpc-v0@0.0.1", + } as const), + stop: Effect.succeed({ ok: true, op: "stop" } as const), + quiesce: Effect.succeed({ ok: true, op: "quiesce" } as const), + }, + }); + const stack = yield* makeHandle(stackId, { + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + const result = yield* stack.status().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackUpgradeRequiredError); + expect(error.value.message).toContain("stack-rpc-v0@0.0.1"); + } + } + const streamResult = yield* stack.watchStatus().pipe(Stream.runHead, Effect.exit); + expect(Exit.isFailure(streamResult)).toBe(true); + if (Exit.isFailure(streamResult)) { + const error = Cause.findErrorOption(streamResult.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackUpgradeRequiredError); + expect(error.value.message).toContain("stack-rpc-v0@0.0.1"); + } + } + yield* stack.close(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); }); diff --git a/packages/stack/src/public/public-model.integration.test.ts b/packages/stack/src/public/public-model.integration.test.ts index 62b8402bf4..2443269666 100644 --- a/packages/stack/src/public/public-model.integration.test.ts +++ b/packages/stack/src/public/public-model.integration.test.ts @@ -1,20 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Schema } from "effect"; +import { CAPABILITY_NAMES } from "./Capability.ts"; import { StackStatusSchema } from "./Status.ts"; -const CAPABILITY_NAMES = [ - "database", - "rest", - "auth", - "realtime", - "storage", - "functions", - "studio", - "mail", - "analytics", - "pooler", -] as const; - const STATUS_FIXTURE = { id: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", lifecycle: "stopped", diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 31bfd077d0..f8407e2438 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -14,11 +14,14 @@ import { describe, expect, test } from "vitest"; import { createTestStack, type TestStack } from "../testing.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import type { PromiseStackCredentials } from "./Credentials.ts"; +import type { StackLogEntry } from "./Logs.ts"; import type { PromiseStackConfig } from "./PromiseStack.ts"; import type { StackEndpoint, StackStatus } from "./Status.ts"; const E2E_TIMEOUT_MS = 15 * 60_000; const REQUEST_TIMEOUT_MS = 60_000; +// Studio's first lazy request can start four workloads sequentially (4 × 30s). +const LAZY_STUDIO_ACTIVATION_TIMEOUT_MS = 180_000; const ANALYTICS_QUERY_RETRY_SCHEDULE = Schedule.spaced("1 second").pipe( Schedule.upTo({ duration: "3 minutes" }), ); @@ -66,6 +69,37 @@ const request = async (base: string, path: string, init: RequestInit = {}): Prom return response; }; +const throwStudioProfileDiagnostics = async (stack: TestStack, cause: unknown): Promise => { + let status: StackStatus | undefined; + try { + status = await stack.status(); + } catch { + // Preserve the original request failure when status is unavailable. + } + const studio = status?.capabilities.find(({ name }) => name === "studio"); + const recentLogs: StackLogEntry[] = []; + try { + for await (const entry of stack.logs({ follow: false })) { + if (entry.source !== "studio" && entry.source !== "gateway") continue; + recentLogs.push(entry); + if (recentLogs.length > 50) recentLogs.shift(); + } + } catch { + // Preserve the original request failure when logs are unavailable. + } + const reason = cause instanceof Error ? cause.message : String(cause); + const state = studio === undefined ? "unavailable" : studio.state; + const error = studio?.error === undefined ? "none" : studio.error; + const logs = + recentLogs.length === 0 + ? "none" + : recentLogs.map((entry) => `${entry.source}/${entry.stream}: ${entry.message}`).join("\n"); + throw new Error( + `Studio profile request failed: ${reason}; Studio state=${state}; error=${error}; recent logs:\n${logs}`, + { cause }, + ); +}; + const jsonValue = async (response: Response): Promise => response.json(); const jsonObject = async (response: Response): Promise => { @@ -511,10 +545,15 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom }); await Effect.runPromise(Effect.retry(mailAttempt, { schedule: MAILPIT_DELIVERY_RETRY_SCHEDULE })); - // Studio's profile endpoint traverses the Studio workload and its pgmeta dependency. - await request(studio.url, "/api/platform/profile", { - headers: serviceHeaders(credentials), - }); + // Studio's profile endpoint lazily activates Studio and its transitive dependencies. + try { + await request(studio.url, "/api/platform/profile", { + headers: serviceHeaders(credentials), + signal: AbortSignal.timeout(LAZY_STUDIO_ACTIVATION_TIMEOUT_MS), + }); + } catch (cause) { + await throwStudioProfileDiagnostics(stack, cause); + } // Analytics exercises health, authenticated ingestion, and query through the public API route; // vector is a readiness dependency selected by the configured vector_port above. diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 2c40c01121..2e8f189c67 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -229,14 +229,9 @@ const isWorkloadResource = ( ): entry is ContainerResource & { readonly labels: ContainerWorkloadLabels } => entry.kind === "workload" && entry.labels.role === "workload"; -const isManagedContainer = ( - entry: ContainerResource, -): entry is ContainerResource & { readonly labels: ContainerWorkloadLabels } => - entry.kind === "workload" && entry.labels.role === "workload"; - /** Startup containers use the deterministic suffix emitted by startupLabelsFor. */ const isStartupContainer = (entry: ContainerResource): boolean => - isManagedContainer(entry) && /^.+:startup:(?:0|[1-9]\d*)$/.test(entry.labels.specHash); + isWorkloadResource(entry) && /^.+:startup:(?:0|[1-9]\d*)$/.test(entry.labels.specHash); const isNetworkResource = ( entry: ContainerResource, @@ -533,7 +528,7 @@ export const makeContainerRuntime = ( (entry) => entry.kind === "workload" && entry.name === nameFor(key, "workload"), ); const collision = - namedCollision !== undefined && isManagedContainer(namedCollision) + namedCollision !== undefined && isWorkloadResource(namedCollision) ? namedCollision : undefined; if ( @@ -816,7 +811,7 @@ export const makeContainerRuntime = ( // Stop and remove every stack workload before touching its network. A failed // stop does not prevent the remove attempt; all failures are returned together. - for (const entry of owned.filter(isManagedContainer)) { + for (const entry of owned.filter(isWorkloadResource)) { if (entry.state === "running") yield* attempt( withEngine( @@ -909,7 +904,7 @@ export const makeContainerRuntime = ( // Recovery never starts or creates anything. Workload identities are evaluated before // retaining the network so stale resources cannot leak into the new owner. - for (const entry of owned.filter(isManagedContainer)) { + for (const entry of owned.filter(isWorkloadResource)) { const hash = desiredHashes.get(entry.labels.workloadId); const current = entry.labels.desiredGeneration === request.desiredGeneration && diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 0d9b847c0a..2d52778cb1 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -1,6 +1,6 @@ import { PgClient } from "@effect/sql-pg"; -import { Context, Duration, Effect, Layer, Redacted, Schema, Scope } from "effect"; -import type { SqlError } from "effect/unstable/sql/SqlError"; +import { Context, Duration, Effect, Layer, Predicate, Redacted, Schema, Scope } from "effect"; +import { isSqlError, type SqlError } from "effect/unstable/sql/SqlError"; import { DatabaseBootstrapError, type DatabaseBootstrapSetting, @@ -37,7 +37,15 @@ const sqlFailure = (operation: string): DatabaseBootstrapError => const mapSqlError = (operation: string) => (error: unknown): DatabaseBootstrapError => - error instanceof DatabaseBootstrapError ? error : sqlFailure(operation); + error instanceof DatabaseBootstrapError + ? error + : new DatabaseBootstrapError({ + message: `Database bootstrap ${operation} failed`, + retryable: + isSqlError(error) && + (error.isRetryable || + (operation === "connection" && Predicate.isTagged(error.reason, "UnknownError"))), + }); const GeneratedStatementSchema = Schema.Struct({ statement: Schema.String }); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index b635fc47fe..7dc55a907f 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -10,6 +10,7 @@ import { Path, Ref, Scope, + Schedule, Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -25,6 +26,7 @@ import type { StackStateStore } from "../state/StackStateStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { redactKnownSecrets } from "../state/SecretStore.ts"; import type { PersistedStackState } from "../state/StackState.ts"; +import type { PersistedSecretValues } from "../state/StackState.ts"; import type { StackId } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { @@ -37,12 +39,7 @@ import { type StackError, } from "../public/Errors.ts"; import { makeSupervisorIngress, type SupervisorIngress } from "../supervisor/Ingress.ts"; -import { - LogStoreError, - makeLogStore, - type LogStore, - type LogRecord, -} from "../supervisor/LogStore.ts"; +import { makeLogStore, type LogStore, type LogRecord } from "../supervisor/LogStore.ts"; import type { LifecycleInput, LifecyclePrepared } from "../supervisor/Lifecycle.ts"; import type { SupervisorRuntime, SupervisorRuntimeFactory } from "../supervisor/Supervisor.ts"; import { @@ -71,7 +68,7 @@ import { DEFAULT_READINESS_DEADLINE, probeReadiness } from "./ReadinessProbe.ts" import { parseGoDuration } from "../model/capabilities/database.ts"; import type { ContainerEngine, ContainerHostRoute } from "./ContainerEngine.ts"; import { bootstrapDatabaseAt } from "./PostgresDatabaseSession.ts"; -import type { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; +import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; import { RuntimeDriverError, type RuntimeDriver, @@ -119,9 +116,6 @@ const stateSecrets = (state: PersistedStackState): ReadonlyArray => .map((entry) => entry.value) .filter((value) => value.length > 0); -const startupTimeout = (startup: NativeProcessSpec): NativeProcessSpec["timeout"] => - startup.timeout; - const currentStateReader = (options: ProductionRuntimeFactoryOptions) => options.stateStore.read(options.stackId).pipe( Effect.provideContext(options.context), @@ -204,17 +198,9 @@ const redactEntry = ( return message === entry.message ? entry : { ...entry, message }; }; -/** Re-reads secret slots before each log operation so values materialized after startup are safe. */ -const dynamicLogStore = ( - base: LogStore, - readState: () => Effect.Effect, -): LogStore => { - const secrets = readState().pipe( - Effect.map(stateSecrets), - Effect.mapError( - () => new LogStoreError({ path: base.path, message: "Unable to read current stack secrets" }), - ), - ); +/** Redacts against the monotonically growing set of secrets accepted by this owner. */ +const dynamicLogStore = (base: LogStore, knownSecrets: Ref.Ref>): LogStore => { + const secrets = Ref.get(knownSecrets).pipe(Effect.map((values) => [...values])); return { path: base.path, append: (record: LogRecord) => @@ -231,14 +217,6 @@ const dynamicLogStore = ( .pipe(Effect.map((entries) => entries.map((entry) => redactEntry(entry, known)))), ), ), - retained: (options) => - secrets.pipe( - Effect.flatMap((known) => - base - .retained(options) - .pipe(Effect.map((entries) => entries.map((entry) => redactEntry(entry, known)))), - ), - ), stream: (options) => Stream.unwrap( secrets.pipe( @@ -250,6 +228,18 @@ const dynamicLogStore = ( }; }; +const rememberSecrets = ( + knownSecrets: Ref.Ref>, + secrets: PersistedSecretValues, +): Effect.Effect => + Ref.update(knownSecrets, (known) => { + const next = new Set(known); + for (const entry of Object.values(secrets)) { + if (entry.value.length > 0) next.add(entry.value); + } + return next; + }); + const readinessFor = ( state: PersistedStackState, workload: PlannedWorkload, @@ -276,6 +266,84 @@ const readinessFor = ( ); }; +interface NativeDatabaseMigrationOptions { + readonly key: Pick; + readonly dataPath: string; + readonly artifactRoot: string; + readonly environment: Readonly>; + readonly endpointPort: number; + readonly timeout: Duration.Duration; + readonly fileSystem: FileSystem.FileSystem; + readonly pathService: Path.Path; +} + +/** Builds the one-shot native PostgreSQL migration process for an unmarked data directory. */ +const prepareNativeDatabaseMigration = ( + options: NativeDatabaseMigrationOptions, +): Effect.Effect => + Effect.gen(function* () { + const { + key, + dataPath, + artifactRoot, + environment, + endpointPort, + timeout, + fileSystem, + pathService, + } = options; + const markerPath = pathService.join(dataPath, NATIVE_DATABASE_MIGRATION_MARKER); + const migrated = yield* fileSystem + .exists(markerPath) + .pipe( + Effect.mapError((error) => + driverError(key, `Unable to inspect native database data path: ${error.message}`, error), + ), + ); + if (migrated) return undefined; + const pgVersionPath = pathService.join(dataPath, "PG_VERSION"); + const initialized = yield* fileSystem + .exists(pgVersionPath) + .pipe( + Effect.mapError((error) => + driverError(key, `Unable to inspect native database data path: ${error.message}`, error), + ), + ); + if (initialized) + yield* fileSystem + .remove(dataPath, { recursive: true, force: true }) + .pipe( + Effect.mapError((error) => + driverError( + key, + `Unable to remove incomplete native database data path: ${error.message}`, + error, + ), + ), + ); + const migrationsPath = pathService.join(artifactRoot, "share/supabase-cli/migrations"); + const artifactBin = pathService.join(artifactRoot, "bin"); + const hostPath = yield* Config.string("PATH").pipe( + Config.withDefault(""), + Effect.mapError((error) => driverError(key, "Unable to read host PATH", error)), + ); + return { + executable: pathService.join(migrationsPath, "migrate.sh"), + cwd: migrationsPath, + timeout, + successMarker: markerPath, + env: { + ...environment, + PATH: hostPath.length === 0 ? artifactBin : `${artifactBin}:${hostPath}`, + POSTGRES_HOST: "127.0.0.1", + POSTGRES_PORT: String(endpointPort), + POSTGRES_DB: "postgres", + POSTGRES_USER: "supabase_admin", + POSTGRES_PASSWORD: environment.POSTGRES_PASSWORD ?? "", + }, + }; + }); + const bootstrapContent = Effect.tryPromise({ try: () => bundleServeMainTemplate(), catch: (cause) => preparationError("Unable to bundle functions bootstrap", cause), @@ -405,12 +473,13 @@ export const makeProductionRuntimeFactory = ( stateRoot: options.stateRoot, stackId: options.stackId, })); + const knownSecrets = yield* Ref.make>(new Set(stateSecrets(initial))); const baseLogs = options.logStore ?? (yield* makeLogStore({ path: paths.logs, knownSecrets: stateSecrets(initial) }).pipe( Effect.mapError((error) => preparationError("Unable to open stack logs", error)), )); - const logs = dynamicLogStore(baseLogs, () => currentStateReader(options)); + const logs = dynamicLogStore(baseLogs, knownSecrets); const preparer = options.artifactPreparer ?? (yield* makeProductionRuntimeArtifactPreparer({ @@ -543,9 +612,22 @@ export const makeProductionRuntimeFactory = ( return yield* new StackRuntimeMismatchError({ message: "Lifecycle runtime does not match persisted runtime", }); + // Remember candidate secrets before any runtime preflight work can emit logs. + yield* rememberSecrets(knownSecrets, input.state.secrets); + yield* rememberSecrets(knownSecrets, input.secrets); // Eagerly validate the candidate before any engine/artifact work. Start-time checks // below revalidate the fresh persisted definition because it is runtime authority. yield* validateDatabaseReadinessBudget(input.definition, input.plan.workloads); + yield* Effect.forEach(input.plan.workloads, (workload) => + Effect.gen(function* () { + if (runtimeSpecFor(workload) === undefined) + return yield* preparationError( + `Unknown runtime specification for ${workload.id}`, + ); + if (workload.selected.kind !== input.state.runtime.kind) + return yield* preparationError(`Runtime artifact mismatch for ${workload.id}`); + }), + ); if (input.state.runtime.kind === "container") { if ( containerEngine === undefined || @@ -559,17 +641,7 @@ export const makeProductionRuntimeFactory = ( ); yield* Ref.set(hostRoute, route); } - yield* Effect.forEach(input.plan.workloads, (workload) => - Effect.gen(function* () { - if (runtimeSpecFor(workload) === undefined) - return yield* preparationError( - `Unknown runtime specification for ${workload.id}`, - ); - if (workload.selected.kind !== input.state.runtime.kind) - return yield* preparationError(`Runtime artifact mismatch for ${workload.id}`); - yield* prepare(input.state.runtime, workload); - }), - ); + yield* prepareArtifacts(input.state.runtime, input.plan.workloads); return {}; }); @@ -611,6 +683,35 @@ export const makeProductionRuntimeFactory = ( return endpoint; }); + const waitForReadiness = (key: RuntimeWorkloadKey, workload: PlannedWorkload) => + freshState(key).pipe( + Effect.flatMap((fresh) => readinessFor(fresh, workload)), + Effect.mapError((error) => mapDriverError(key, error)), + ); + const bootstrapWorkloadDatabase = (key: RuntimeWorkloadKey, workload: PlannedWorkload) => + workload.bootstrap === "database" + ? freshState(key).pipe( + Effect.flatMap((fresh) => + readinessDeadlineFor(fresh, workload).pipe( + Effect.flatMap((deadline) => + Effect.timeout( + Effect.retry( + Effect.suspend(() => bootstrapDatabase(fresh)), + { + schedule: Schedule.spaced("100 millis"), + while: (error) => + error instanceof DatabaseBootstrapError && error.retryable === true, + }, + ), + deadline, + ), + ), + ), + ), + Effect.mapError((error) => mapDriverError(key, error)), + ) + : Effect.void; + let driver: RuntimeDriver; if (state.runtime.kind === "native") { driver = yield* makeNativeRuntime({ @@ -673,7 +774,7 @@ export const makeProductionRuntimeFactory = ( "native", inputs, ); - let postReadiness: ReadonlyArray = []; + let migration: NativeProcessSpec | undefined; if (isDatabaseWorkload(workload)) { const dataPath = inputs.database?.dataPath; if (dataPath === undefined) @@ -681,78 +782,16 @@ export const makeProductionRuntimeFactory = ( key, "Resolved database data path is unavailable for native migration", ); - const markerPath = pathService.join( + migration = yield* prepareNativeDatabaseMigration({ + key, dataPath, - NATIVE_DATABASE_MIGRATION_MARKER, - ); - const migrated = yield* fileSystem - .exists(markerPath) - .pipe( - Effect.mapError((error) => - driverError( - key, - `Unable to inspect native database data path: ${error.message}`, - error, - ), - ), - ); - if (!migrated) { - const pgVersionPath = pathService.join(dataPath, "PG_VERSION"); - const initialized = yield* fileSystem - .exists(pgVersionPath) - .pipe( - Effect.mapError((error) => - driverError( - key, - `Unable to inspect native database data path: ${error.message}`, - error, - ), - ), - ); - if (initialized) - yield* fileSystem - .remove(dataPath, { recursive: true, force: true }) - .pipe( - Effect.mapError((error) => - driverError( - key, - `Unable to remove incomplete native database data path: ${error.message}`, - error, - ), - ), - ); - const migrationsPath = pathService.join( - prepared.artifactRoot, - "share/supabase-cli/migrations", - ); - const artifactBin = pathService.join(prepared.artifactRoot, "bin"); - const hostPath = yield* Config.string("PATH").pipe( - Config.withDefault(""), - Effect.mapError((error) => - driverError(key, "Unable to read host PATH", error), - ), - ); - postReadiness = [ - { - executable: pathService.join(migrationsPath, "migrate.sh"), - cwd: migrationsPath, - timeout: readinessBudget, - successMarker: markerPath, - env: { - ...environment, - PATH: - hostPath.length === 0 - ? artifactBin - : `${artifactBin}:${hostPath}`, - POSTGRES_HOST: "127.0.0.1", - POSTGRES_PORT: String(endpoint.port), - POSTGRES_DB: "postgres", - POSTGRES_USER: "supabase_admin", - POSTGRES_PASSWORD: environment.POSTGRES_PASSWORD ?? "", - }, - }, - ]; - } + artifactRoot: prepared.artifactRoot, + environment, + endpointPort: endpoint.port, + timeout: readinessBudget, + fileSystem, + pathService, + }); } return { startup: spec @@ -763,39 +802,23 @@ export const makeProductionRuntimeFactory = ( endpoint.port, inputs, ) - .map((startup) => ({ + .map((startup: NativeProcessSpec) => ({ ...startup, timeout: - startupTimeout(startup) ?? + startup.timeout ?? (isDatabaseWorkload(workload) ? readinessBudget : Duration.minutes(5)), env: { ...environment, ...startup.env }, })), - postReadiness, + ...(migration === undefined ? {} : { postReadiness: [migration] }), main: { ...resolvedNativeProcess, env: environment }, }; }), ), ), - waitForReadiness: (key, workload) => - freshState(key).pipe( - Effect.flatMap((fresh) => readinessFor(fresh, workload)), - Effect.mapError((error) => mapDriverError(key, error)), - ), - bootstrapDatabase: (key, workload) => - workload.bootstrap === "database" - ? freshState(key).pipe( - Effect.flatMap((fresh) => - readinessDeadlineFor(fresh, workload).pipe( - Effect.flatMap((deadline) => - bootstrapDatabase(fresh).pipe(Effect.timeout(deadline)), - ), - ), - ), - Effect.mapError((error) => mapDriverError(key, error)), - ) - : Effect.void, + waitForReadiness, + bootstrapDatabase: bootstrapWorkloadDatabase, logStore: logs, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), @@ -880,34 +903,14 @@ export const makeProductionRuntimeFactory = ( return { ...withoutEnv, envFile, - waitForReadiness: (resourceKey, resourceWorkload) => - freshState(resourceKey).pipe( - Effect.flatMap((current) => readinessFor(current, resourceWorkload)), - Effect.mapError((error) => mapDriverError(resourceKey, error)), - ), + waitForReadiness, ...(volume === undefined ? {} : { volume }), } satisfies ContainerWorkloadResolution; }), ), ), - waitForReadiness: (key, workload) => - freshState(key).pipe( - Effect.flatMap((fresh) => readinessFor(fresh, workload)), - Effect.mapError((error) => mapDriverError(key, error)), - ), - bootstrapDatabase: (key, workload) => - workload.bootstrap === "database" - ? freshState(key).pipe( - Effect.flatMap((fresh) => - readinessDeadlineFor(fresh, workload).pipe( - Effect.flatMap((deadline) => - bootstrapDatabase(fresh).pipe(Effect.timeout(deadline)), - ), - ), - ), - Effect.mapError((error) => mapDriverError(key, error)), - ) - : Effect.void, + waitForReadiness, + bootstrapDatabase: bootstrapWorkloadDatabase, logStore: logs, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); } diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index b9467631cc..3f45a2249d 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -15,11 +15,13 @@ import type { PersistedStackState } from "../state/StackState.ts"; import type { StackId } from "../public/StackId.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { resolveStackPaths } from "../state/Paths.ts"; +import { isRecord, settingValue, settingsFor } from "../state/MaterializedSettings.ts"; import { resolveSigningKeyMaterial, type ResolvedSigningKeyMaterial, } from "../state/SecretStore.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; +import { containerAliasFor } from "../model/WorkloadCatalog.ts"; /** A parsed JSON document fetched by the owner for OIDC discovery. */ export type RuntimeJsonFetcher = (url: string) => Effect.Effect; @@ -94,25 +96,6 @@ const mapFile = ( Effect.mapError((error) => failure(`Unable to ${operation}`, { path: target, cause: error })), ); -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - -const settingValue = (state: PersistedStackState, value: unknown): string => { - if (value === null || value === undefined) return ""; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") - return String(value); - if (Array.isArray(value)) return value.map((entry) => settingValue(state, entry)).join(","); - if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) - return state.secrets[value.slot]?.value ?? ""; - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- dynamic persisted settings are stringified for workload env values - return JSON.stringify(value) ?? ""; -}; - -type CapabilitySettingName = keyof NonNullable["capabilities"]; - -const settingsFor = (state: PersistedStackState, capability: CapabilitySettingName): unknown => - state.definition?.capabilities[capability].settings; - const invalidGeneration = (generation: number): boolean => !Number.isSafeInteger(generation) || generation < 0; @@ -543,9 +526,7 @@ export const makeRuntimeInputOwner = ( const nativeAssignment = state.privatePorts.find( (entry) => entry.workloadId === "database:database" && entry.binding === "primary", ); - if (runtime === "native" && nativeAssignment === undefined) - return Effect.fail(failure("Persisted native database assignment is missing")); - const dbHost = runtime === "container" ? "supabase-database" : "127.0.0.1"; + const dbHost = runtime === "container" ? containerAliasFor("database:database") : "127.0.0.1"; const dbPort = runtime === "container" ? 5432 : nativeAssignment?.port; if (dbPort === undefined) return Effect.fail(failure("Persisted native database assignment is missing")); @@ -671,6 +652,20 @@ export const makeRuntimeInputOwner = ( const keyFor = (state: PersistedStackState, generation: number): string => `${state.identity.projectRoot}\u0000${state.inputFingerprint ?? ""}\u0000${state.runtime.kind}\u0000${generation}`; const generationFor = (key: string): number => Number(key.slice(key.lastIndexOf("\u0000") + 1)); + const retireEntries = ( + pending: Map>, + completed: Map, + matches: (key: string) => boolean, + ): ReadonlyArray> => { + const retired: Array> = []; + for (const [key, deferred] of pending) + if (matches(key)) { + pending.delete(key); + retired.push(deferred); + } + for (const key of completed.keys()) if (matches(key)) completed.delete(key); + return retired; + }; const materializeCommon = ( state: PersistedStackState, @@ -799,14 +794,11 @@ export const makeRuntimeInputOwner = ( acceptedGeneration = generation; return { rejected: false, retired: [] }; } - const retired: Array> = []; - for (const [pendingKey, deferred] of commonPending) - if (generationFor(pendingKey) < generation) { - commonPending.delete(pendingKey); - retired.push(deferred); - } - for (const completedKey of commonCompleted.keys()) - if (generationFor(completedKey) < generation) commonCompleted.delete(completedKey); + const retired = retireEntries( + commonPending, + commonCompleted, + (pendingKey) => generationFor(pendingKey) < generation, + ); acceptedGeneration = generation; return { rejected: false, retired }; }), @@ -849,17 +841,7 @@ export const makeRuntimeInputOwner = ( const matches = (key: string) => key.endsWith(`\u0000${generation}`); if (owner === "pooler") { const retired = yield* admission.withPermit( - Effect.sync(() => { - const deferreds: Array> = []; - for (const [key, deferred] of poolerPending) - if (matches(key)) { - poolerPending.delete(key); - deferreds.push(deferred); - } - for (const key of poolerCompleted.keys()) - if (matches(key)) poolerCompleted.delete(key); - return deferreds; - }), + Effect.sync(() => retireEntries(poolerPending, poolerCompleted, matches)), ); yield* Effect.forEach(retired, (deferred) => Deferred.succeed( @@ -872,17 +854,7 @@ export const makeRuntimeInputOwner = ( // completed and in-flight entries so a same-generation // restart publishes a fresh config path. const retired = yield* admission.withPermit( - Effect.sync(() => { - const deferreds: Array> = []; - for (const [key, deferred] of commonPending) - if (matches(key)) { - commonPending.delete(key); - deferreds.push(deferred); - } - for (const key of commonCompleted.keys()) - if (matches(key)) commonCompleted.delete(key); - return deferreds; - }), + Effect.sync(() => retireEntries(commonPending, commonCompleted, matches)), ); yield* Effect.forEach(retired, (deferred) => Deferred.succeed( diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 55fad5ec56..49910553ae 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -1,12 +1,24 @@ import type { PersistedStackState } from "../state/StackState.ts"; import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { CapabilityName } from "../public/Capability.ts"; +import { + flattenSettings, + isRecord, + secret, + settingValue, + settingsFor, + valueAt, +} from "../state/MaterializedSettings.ts"; import type { ContainerHostRoute, ContainerMount, ContainerStartupProcess, } from "./ContainerEngine.ts"; -import { WORKLOAD_CATALOG, type NativeWorkloadProcess } from "../model/WorkloadCatalog.ts"; +import { + catalogEntryFor, + containerAliasFor, + type NativeWorkloadProcess, +} from "../model/WorkloadCatalog.ts"; import { parseFileSize } from "../model/capabilities/storage.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; import { Effect } from "effect"; @@ -107,7 +119,6 @@ export interface WorkloadRuntimeSpec { workload: PlannedWorkload, inputs?: WorkloadRuntimeInputs, ) => ReadonlyArray; - readonly networkAliases?: ReadonlyArray; readonly readiness: Readonly<{ readonly protocol: "http" | "tcp"; readonly path?: string; @@ -215,19 +226,8 @@ export const validateWorkloadRuntimeInputs = ( message: "Managed JWT signing secret is required for the configured auth mode", workload: workload.id, }); - if (workload.id === "auth:auth") { - const signing = state.definition?.security.jwt.signing; - if ( - signing?.kind === "jwks-file" && - (inputs.auth?.jwtKeys === undefined || inputs.auth.jwtKeys.length === 0) - ) - return yield* new StackPreparationError({ - message: "Resolved JWT signing keys are required for Auth", - workload: workload.id, - }); - } if (workload.id === "analytics:analytics") { - const backend = valueAt(state, "analytics", "backend") || "postgres"; + const backend = valueAt(state, "analytics", "backend"); if ( backend === "bigquery" && valueAt(state, "analytics", "gcp_jwt_path").length > 0 && @@ -250,6 +250,14 @@ export const validateWorkloadRuntimeInputs = ( }); } if (workload.id === "auth:auth") { + if ( + signing?.kind === "jwks-file" && + (inputs.auth?.jwtKeys === undefined || inputs.auth.jwtKeys.length === 0) + ) + return yield* new StackPreparationError({ + message: "Resolved JWT signing keys are required for Auth", + workload: workload.id, + }); const email = settingsFor(state, "auth"); const emailSettings = isRecord(email) && isRecord(email.email) ? email.email : undefined; const templates = emailSettings?.template; @@ -276,9 +284,6 @@ export const validateWorkloadRuntimeInputs = ( export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; export const FUNCTIONS_BOOTSTRAP_CONTAINER_PATH = "/root"; -const DATABASE_NETWORK_ALIAS = "supabase-database"; - -const MAIL_NETWORK_ALIAS = "supabase-mail"; const compactEnvironment = ( environment: Readonly>, @@ -291,59 +296,9 @@ const beamDistributionEnvironment = ( ): Readonly> => runtime === "native" ? { RELEASE_DISTRIBUTION: "none" } : {}; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - -const settingsFor = (state: PersistedStackState, capability: CapabilityName): unknown => - state.definition === undefined ? undefined : state.definition.capabilities[capability].settings; - const capabilityEnabled = (state: PersistedStackState, capability: CapabilityName): boolean => state.definition?.capabilities[capability].enabled ?? true; -const secret = (state: PersistedStackState, slot: string): string => - state.secrets[slot]?.value ?? ""; - -const settingValue = (state: PersistedStackState, value: unknown): string => { - if (value === null || value === undefined) return ""; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") - return String(value); - if (Array.isArray(value)) return value.map((entry) => settingValue(state, entry)).join(","); - if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) - return secret(state, value.slot); - return JSON.stringify(value) ?? ""; -}; - -/** Flatten all materialized settings so no capability field silently disappears at runtime. */ -const flattenSettings = ( - state: PersistedStackState, - value: unknown, - prefix: string, - out: Record, -): void => { - if (value === null || value === undefined) return; - if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) { - out[prefix] = secret(state, value.slot); - return; - } - if (Array.isArray(value)) { - out[prefix] = value.map((entry) => settingValue(state, entry)).join(","); - return; - } - if (isRecord(value)) { - for (const [key, entry] of Object.entries(value)) { - const normalized = key.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase(); - flattenSettings( - state, - entry, - prefix.length === 0 ? normalized : `${prefix}_${normalized}`, - out, - ); - } - return; - } - out[prefix] = settingValue(state, value); -}; - const capabilityEnv = ( state: PersistedStackState, capability: CapabilityName, @@ -357,15 +312,6 @@ const capabilityEnv = ( return out; }; -const valueAt = (state: PersistedStackState, capability: CapabilityName, path: string): string => { - let current: unknown = settingsFor(state, capability); - for (const segment of path.split(".")) { - if (!isRecord(current)) return ""; - current = current[segment]; - } - return settingValue(state, current); -}; - const dbPort = (state: PersistedStackState): number => state.privatePorts.find( (assignment) => @@ -409,7 +355,7 @@ const containerPortFor = ( }; const dbHost = (runtime: WorkloadRuntimeKind): string => - runtime === "container" ? DATABASE_NETWORK_ALIAS : "127.0.0.1"; + runtime === "container" ? containerAliasFor("database:database") : "127.0.0.1"; const dbUrl = ( state: PersistedStackState, @@ -440,9 +386,9 @@ const edgeRuntimeJwtEnvironment = ( ), SUPABASE_JWKS: inputs.auth?.jwks ?? '{"keys":[]}', }); - // Preserve explicitly empty owner-resolved values. Spec-owned variables are - // spread after this helper in the workload env so they retain precedence. - return { ...fixed, ...inputs.functions?.secrets }; + // Preserve explicitly empty owner-resolved values. Fixed runtime credentials + // must win over user function secrets with reserved names. + return { ...inputs.functions?.secrets, ...fixed }; }; const functionsConfigEnvironment = (state: PersistedStackState): string => { @@ -476,16 +422,15 @@ const common = (workload: PlannedWorkload, port: number): Record }); const functionsRoot = (state: PersistedStackState): string => - valueAt(state, "functions", "functions_root") || - `${state.identity.projectRoot}/supabase/functions`; + valueAt(state, "functions", "functions_root"); const privateEndpointFor = ( state: PersistedStackState, workloadId: string, bindings: WorkloadBindings, binding: WorkloadBindingName, - runtime: WorkloadRuntimeKind = "native", - alias = "supabase-workload", + runtime: WorkloadRuntimeKind, + alias: string, ): Readonly<{ readonly host: string; readonly port: number }> | undefined => { const declared = bindingFor(bindings, binding); if (declared === undefined) return undefined; @@ -501,9 +446,6 @@ const privateEndpointFor = ( }; }; -const aliasFor = (workload: PlannedWorkload): string => - WORKLOAD_CATALOG[workload.id]?.containerAlias ?? `supabase-${workload.id.replace(/:/gu, "-")}`; - const artifactPath = (root: string, relative: string): string => relative === "." ? root : `${root.replace(/\/+$/u, "")}/${relative}`; @@ -511,20 +453,17 @@ const nativeArgsFor = ( workload: PlannedWorkload, args: ReadonlyArray, inputs: WorkloadRuntimeInputs, -): ReadonlyArray => - workload.id === "analytics:vector" && inputs.analytics?.vectorConfigPath !== undefined - ? ["--config", inputs.analytics.vectorConfigPath] - : workload.id === "functions:edge-runtime" && inputs.functions?.bootstrapPath !== undefined - ? (() => { - const bootstrapPath = inputs.functions?.bootstrapPath; - if (bootstrapPath === undefined) return args; - const bootstrapDirectory = - bootstrapPath.slice(0, bootstrapPath.lastIndexOf("/")) || bootstrapPath; - return args.map((arg) => - arg.startsWith("--main-service=") ? `--main-service=${bootstrapDirectory}` : arg, - ); - })() - : args; +): ReadonlyArray => { + if (workload.id === "analytics:vector" && inputs.analytics?.vectorConfigPath !== undefined) + return ["--config", inputs.analytics.vectorConfigPath]; + const bootstrapPath = inputs.functions?.bootstrapPath; + if (workload.id !== "functions:edge-runtime" || bootstrapPath === undefined) return args; + const bootstrapDirectory = + bootstrapPath.slice(0, bootstrapPath.lastIndexOf("/")) || bootstrapPath; + return args.map((arg) => + arg.startsWith("--main-service=") ? `--main-service=${bootstrapDirectory}` : arg, + ); +}; const nativeProcessFor = ( artifactRoot: string, @@ -534,7 +473,8 @@ const nativeProcessFor = ( spec: WorkloadRuntimeSpecDefinition, inputs: WorkloadRuntimeInputs = {}, ): NativeProcessResolution => { - const metadata: NativeWorkloadProcess | undefined = WORKLOAD_CATALOG[workload.id]?.nativeProcess; + const catalog = catalogEntryFor(workload.id); + const metadata: NativeWorkloadProcess | undefined = catalog?.nativeProcess; if (metadata !== undefined) { const metadataArgs = metadata.args.map((arg) => arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, @@ -545,7 +485,7 @@ const nativeProcessFor = ( cwd: artifactPath(artifactRoot, metadata.cwd), }; } - const executablePath = WORKLOAD_CATALOG[workload.id]?.executablePath; + const executablePath = catalog?.executablePath; const resolvedPort = privatePortFor(state, workload.id, "primary") ?? port; const args = spec .args(state, workload, resolvedPort, "native") @@ -632,14 +572,14 @@ const withRestSettings = ( ...common(workload, port), ...capabilityEnv(state, "rest", "PGRST"), PGRST_DB_URI: dbUrl(state, "authenticator", runtime), - PGRST_DB_SCHEMAS: valueAt(state, "rest", "schemas") || "public,graphql_public", - PGRST_DB_EXTRA_SEARCH_PATH: valueAt(state, "rest", "extra_search_path") || "public,extensions", + PGRST_DB_SCHEMAS: valueAt(state, "rest", "schemas"), + PGRST_DB_EXTRA_SEARCH_PATH: valueAt(state, "rest", "extra_search_path"), PGRST_DB_ANON_ROLE: "anon", PGRST_JWT_SECRET: usesResolvedJwks(state) ? (_inputs.auth?.jwks ?? "") : secret(state, "secret:auth.settings.jwt_secret"), PGRST_SERVER_PORT: String(port), - PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows") || "1000", + PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows"), PGRST_ADMIN_SERVER_PORT: String(workloadPort(state, "rest:rest", "admin", runtime, 3001)), PGRST_OPENAPI_SERVER_PROXY_URI: valueAt(state, "rest", "external_url"), }); @@ -806,12 +746,13 @@ const withAuthSettings = ( ), GOTRUE_DB_DATABASE_URL: dbUrl(state, "supabase_auth_admin", runtime), GOTRUE_DB_DRIVER: "postgres", - GOTRUE_SITE_URL: valueAt(state, "auth", "site_url") || "http://127.0.0.1:3000", + GOTRUE_SITE_URL: valueAt(state, "auth", "site_url"), GOTRUE_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - GOTRUE_JWT_EXP: valueAt(state, "auth", "jwt_expiry") || "3600", + GOTRUE_JWT_EXP: valueAt(state, "auth", "jwt_expiry"), GOTRUE_JWT_AUD: "authenticated", GOTRUE_JWT_ADMIN_ROLES: "service_role", GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", + // GoTrue releases consume either spelling; keep both for cross-version compatibility. GOTRUE_JWT_VALIDMETHODS: "HS256,RS256,ES256", GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", ...(inputs.auth?.jwtKeys === undefined ? {} : { GOTRUE_JWT_KEYS: inputs.auth.jwtKeys }), @@ -897,10 +838,11 @@ const withAuthSettings = ( } : capabilityEnabled(state, "mail") ? { - GOTRUE_SMTP_HOST: runtime === "container" ? MAIL_NETWORK_ALIAS : "127.0.0.1", + GOTRUE_SMTP_HOST: + runtime === "container" ? containerAliasFor("mail:mail") : "127.0.0.1", GOTRUE_SMTP_PORT: String(workloadPort(state, "mail:mail", "smtp", runtime, 1025)), - GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "mail", "admin_email") || "admin@email.com", - GOTRUE_SMTP_SENDER_NAME: valueAt(state, "mail", "sender_name") || "Admin", + GOTRUE_SMTP_ADMIN_EMAIL: valueAt(state, "mail", "admin_email"), + GOTRUE_SMTP_SENDER_NAME: valueAt(state, "mail", "sender_name"), } : {}), GOTRUE_SMS_AUTOCONFIRM: @@ -934,8 +876,9 @@ const withStorageSettings = ( port: number, workload: PlannedWorkload, inputs: WorkloadRuntimeInputs = {}, -): Record => - compactEnvironment({ +): Record => { + const fileSizeLimit = parseFileSize(valueAt(state, "storage", "file_size_limit")); + return compactEnvironment({ ...common(workload, port), ...capabilityEnv( state, @@ -952,8 +895,7 @@ const withStorageSettings = ( SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), AUTH_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), DATABASE_URL: dbUrl(state, "supabase_storage_admin", runtime), - FILE_SIZE_LIMIT: - parseFileSize(valueAt(state, "storage", "file_size_limit") || "50MiB") ?? "52428800", + ...(fileSizeLimit === undefined ? {} : { FILE_SIZE_LIMIT: fileSizeLimit }), STORAGE_BACKEND: "file", FILE_STORAGE_BACKEND_PATH: runtime === "container" @@ -963,12 +905,11 @@ const withStorageSettings = ( runtime === "container" ? "/mnt" : (inputs.storage?.dataPath ?? `${state.identity.projectRoot}/.supabase/storage`), - ENABLE_IMAGE_TRANSFORMATION: - valueAt(state, "storage", "image_transformation.enabled") || "false", - S3_PROTOCOL_ENABLED: valueAt(state, "storage", "s3_protocol.enabled") || "true", - S3_PROTOCOL_ACCESS_KEY_ID: valueAt(state, "storage", "s3_protocol.access_key_id") || "local", + ENABLE_IMAGE_TRANSFORMATION: valueAt(state, "storage", "image_transformation.enabled"), + S3_PROTOCOL_ENABLED: valueAt(state, "storage", "s3_protocol.enabled"), + S3_PROTOCOL_ACCESS_KEY_ID: valueAt(state, "storage", "s3_protocol.access_key_id"), S3_PROTOCOL_ACCESS_KEY_SECRET: valueAt(state, "storage", "s3_protocol.secret_access_key"), - STORAGE_S3_REGION: valueAt(state, "storage", "s3_protocol.region") || "local", + STORAGE_S3_REGION: valueAt(state, "storage", "s3_protocol.region"), GLOBAL_S3_BUCKET: "stub", TENANT_ID: "stub", S3_PROTOCOL_PREFIX: "/storage/v1", @@ -980,7 +921,7 @@ const withStorageSettings = ( TUS_URL_PATH: "/storage/v1/upload/resumable", IMGPROXY_URL: runtime === "container" - ? "http://supabase-imgproxy:5001" + ? `http://${containerAliasFor("storage:imgproxy")}:5001` : `http://127.0.0.1:${workloadPort(state, "storage:imgproxy", "primary", runtime, 5001)}`, ...(valueAt(state, "storage", "vector.enabled") === "true" ? { @@ -991,6 +932,7 @@ const withStorageSettings = ( } : {}), }); +}; const databaseArgs = ( state: PersistedStackState, @@ -1020,7 +962,7 @@ const analyticsEnv = ( workload: PlannedWorkload, inputs: WorkloadRuntimeInputs = {}, ): Record => { - const backend = valueAt(state, "analytics", "backend") || "postgres"; + const backend = valueAt(state, "analytics", "backend"); const gcpJwtPath = inputs.analytics?.gcpJwtPath ?? ""; return compactEnvironment({ ...common(workload, port), @@ -1049,8 +991,8 @@ const analyticsEnv = ( } : { GOOGLE_DATASET_ID_APPEND: "_prod", - GOOGLE_PROJECT_ID: valueAt(state, "analytics", "gcp_project_id") || "local", - GOOGLE_PROJECT_NUMBER: valueAt(state, "analytics", "gcp_project_number") || "0", + GOOGLE_PROJECT_ID: valueAt(state, "analytics", "gcp_project_id"), + GOOGLE_PROJECT_NUMBER: valueAt(state, "analytics", "gcp_project_number"), GOOGLE_APPLICATION_CREDENTIALS: runtime === "container" && gcpJwtPath.length > 0 ? "/opt/app/rel/logflare/bin/gcloud.json" @@ -1076,7 +1018,6 @@ const specs: Readonly> = { }), containerArgs: (state, _workload, port) => databaseArgs(state, port, "container"), readiness: { protocol: "tcp" }, - networkAliases: [DATABASE_NETWORK_ALIAS], }, "rest:rest": { bindings: { primary: { containerPort: 3000 }, admin: { containerPort: 3001 } }, @@ -1084,7 +1025,6 @@ const specs: Readonly> = { env: (state, workload, port, runtime = "native", inputs = {}) => withRestSettings(state, runtime, port, workload, inputs), containerArgs: () => [], - networkAliases: ["supabase-rest"], readiness: { protocol: "http", path: "/" }, }, "auth:auth": { @@ -1094,7 +1034,6 @@ const specs: Readonly> = { withAuthSettings(state, runtime, port, workload, inputs), containerArgs: () => [], containerStartupProcesses: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], - networkAliases: ["supabase-auth"], readiness: { protocol: "http", path: "/health" }, }, "realtime:realtime": { @@ -1120,7 +1059,7 @@ const specs: Readonly> = { DNS_NODES: "''", APP_NAME: "realtime", SEED_SELF_HOST: "true", - MAX_HEADER_LENGTH: valueAt(state, "realtime", "max_header_length") || "4096", + MAX_HEADER_LENGTH: valueAt(state, "realtime", "max_header_length"), ERL_AFLAGS: valueAt(state, "realtime", "ip_version") === "IPv6" ? "-proto_dist inet6_tcp" @@ -1128,7 +1067,6 @@ const specs: Readonly> = { RUN_JANITOR: "true", }), containerArgs: () => [], - networkAliases: ["supabase-realtime"], readiness: { protocol: "http", path: "/healthcheck" }, }, "storage:storage": { @@ -1140,7 +1078,6 @@ const specs: Readonly> = { containerStartupProcesses: [ { entrypoint: "/node/bin/node", command: ["dist/scripts/migrate-call.js"] }, ], - networkAliases: ["supabase-storage"], readiness: { protocol: "http", path: "/status" }, }, "storage:imgproxy": { @@ -1152,7 +1089,6 @@ const specs: Readonly> = { IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", }), containerArgs: () => [], - networkAliases: ["supabase-imgproxy"], readiness: { protocol: "http", path: "/health" }, }, "functions:edge-runtime": { @@ -1162,7 +1098,7 @@ const specs: Readonly> = { "start", `--main-service=${functionsRoot(state)}`, `--port=${port}`, - `--policy=${valueAt(state, "functions", "edge_runtime.policy") || "per_worker"}`, + `--policy=${valueAt(state, "functions", "edge_runtime.policy")}`, ], env: (state, workload, port, runtime = "native", inputs = {}) => ({ ...common(workload, port), @@ -1175,8 +1111,8 @@ const specs: Readonly> = { runtime === "container" ? FUNCTIONS_CONTAINER_ROOT : functionsRoot(state), SUPABASE_INTERNAL_FUNCTIONS_CONFIG: functionsConfigEnvironment(state), SUPABASE_URL: apiListenerUrl(state, runtime === "container" ? inputs : undefined), - EDGE_RUNTIME_POLICY: valueAt(state, "functions", "edge_runtime.policy") || "per_worker", - EDGE_RUNTIME_DENO_VERSION: valueAt(state, "functions", "edge_runtime.deno_version") || "2", + EDGE_RUNTIME_POLICY: valueAt(state, "functions", "edge_runtime.policy"), + EDGE_RUNTIME_DENO_VERSION: valueAt(state, "functions", "edge_runtime.deno_version"), INSPECTOR_MODE: valueAt(state, "functions", "inspector.mode"), INSPECTOR_MAIN: valueAt(state, "functions", "inspector.main"), }), @@ -1184,12 +1120,11 @@ const specs: Readonly> = { "start", `--main-service=${FUNCTIONS_BOOTSTRAP_CONTAINER_PATH}`, `--port=${port}`, - `--policy=${valueAt(state, "functions", "edge_runtime.policy") || "per_worker"}`, + `--policy=${valueAt(state, "functions", "edge_runtime.policy")}`, ], containerMounts: (state) => [ { source: functionsRoot(state), target: FUNCTIONS_CONTAINER_ROOT, readOnly: true }, ], - networkAliases: ["supabase-functions"], readiness: { protocol: "http", path: "/_internal/health" }, }, "studio:studio": { @@ -1203,16 +1138,16 @@ const specs: Readonly> = { HOSTNAME: "0.0.0.0", STUDIO_PG_META_URL: runtime === "container" - ? "http://supabase-pgmeta:8080" + ? `http://${containerAliasFor("studio:pgmeta")}:8080` : `http://127.0.0.1:${workloadPort(state, "studio:pgmeta", "primary", runtime, 8080)}`, LOGFLARE_URL: runtime === "container" - ? "http://supabase-analytics:4000" + ? `http://${containerAliasFor("analytics:analytics")}:4000` : `http://127.0.0.1:${workloadPort(state, "analytics:analytics", "primary", runtime, 4000)}`, LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), NEXT_PUBLIC_ENABLE_LOGS: valueAt(state, "analytics", "backend").length > 0 ? "true" : "false", - NEXT_ANALYTICS_BACKEND_PROVIDER: valueAt(state, "analytics", "backend") || "postgres", + NEXT_ANALYTICS_BACKEND_PROVIDER: valueAt(state, "analytics", "backend"), SUPABASE_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), SUPABASE_PUBLIC_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), SUPABASE_ANON_KEY: secret(state, "secret:auth.settings.anon_key"), @@ -1233,7 +1168,6 @@ const specs: Readonly> = { containerMounts: (state) => [ { source: functionsRoot(state), target: FUNCTIONS_CONTAINER_ROOT, readOnly: true }, ], - networkAliases: ["supabase-studio"], readiness: { protocol: "http", path: "/api/platform/profile" }, }, "studio:pgmeta": { @@ -1250,7 +1184,6 @@ const specs: Readonly> = { PG_META_DB_PASSWORD: secret(state, "secret:database.internal.password"), }), containerArgs: () => [], - networkAliases: ["supabase-pgmeta"], readiness: { protocol: "http", path: "/health" }, }, "mail:mail": { @@ -1269,7 +1202,6 @@ const specs: Readonly> = { MP_SMTP_DISABLE_RDNS: "true", }), containerArgs: () => [], - networkAliases: [MAIL_NETWORK_ALIAS], readiness: { protocol: "http", path: "/readyz", binding: "ui" }, }, "analytics:analytics": { @@ -1280,7 +1212,7 @@ const specs: Readonly> = { // The slim container entrypoint performs Logflare migrations before start. containerArgs: () => [], containerMounts: (state, _workload, inputs = {}) => { - const backend = valueAt(state, "analytics", "backend") || "postgres"; + const backend = valueAt(state, "analytics", "backend"); const source = inputs.analytics?.gcpJwtPath ?? ""; return backend === "bigquery" && source.length > 0 ? [ @@ -1292,7 +1224,6 @@ const specs: Readonly> = { ] : []; }, - networkAliases: ["supabase-analytics"], readiness: { protocol: "http", path: "/health" }, }, "analytics:vector": { @@ -1305,7 +1236,7 @@ const specs: Readonly> = { VECTOR_API_PORT: String(port), LOGFLARE_URL: runtime === "container" - ? "http://supabase-analytics:4000" + ? `http://${containerAliasFor("analytics:analytics")}:4000` : `http://127.0.0.1:${workloadPort(state, "analytics:analytics", "primary", runtime, 4000)}`, LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), }), @@ -1323,7 +1254,6 @@ const specs: Readonly> = { readOnly: true, }, ], - networkAliases: ["supabase-vector"], readiness: { protocol: "http", path: "/health" }, }, "pooler:pooler": { @@ -1377,7 +1307,6 @@ const specs: Readonly> = { readOnly: true, }, ], - networkAliases: ["supabase-pooler"], readiness: { protocol: "tcp" }, }, }; @@ -1436,7 +1365,8 @@ export const validatePrivateAssignments = ( export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | undefined => { const spec = specs[workload.id]; - if (spec === undefined) return undefined; + const catalog = catalogEntryFor(workload.id); + if (spec === undefined || catalog === undefined) return undefined; const primary = spec.bindings.primary ?? spec.bindings.admin ?? @@ -1447,28 +1377,11 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | return { ...spec, containerPort: primary.containerPort, - networkAliases: [aliasFor(workload)], cwd: spec.cwd ?? ((state) => state.identity.projectRoot), args: (state, currentWorkload, port, runtime = "native") => - spec.args( - state, - currentWorkload, - runtime === "container" - ? containerPortFor(state, currentWorkload.id, "primary", primary.containerPort) - : (privatePortFor(state, currentWorkload.id, "primary") ?? port), - runtime, - ), + spec.args(state, currentWorkload, port, runtime), env: (state, currentWorkload, port, runtime = "native", inputs = {}) => - spec.env( - state, - currentWorkload, - runtime === "container" - ? containerPortFor(state, currentWorkload.id, "primary", primary.containerPort) - : (privatePortFor(state, currentWorkload.id, spec.readiness.binding ?? "primary") ?? - port), - runtime, - inputs, - ), + spec.env(state, currentWorkload, port, runtime, inputs), containerStartupProcesses: spec.containerStartupProcesses ?? [], readiness: { ...spec.readiness, binding: spec.readiness.binding ?? "primary" }, privateEndpoint: @@ -1480,7 +1393,7 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | spec.bindings, binding, runtime, - aliasFor(workload), + catalog.containerAlias, )), nativeProcess: (artifactRoot, state, currentWorkload, port, inputs = {}) => nativeProcessFor(artifactRoot, state, currentWorkload, port, spec, inputs), @@ -1498,6 +1411,8 @@ export const containerResolutionFor = ( ): ContainerWorkloadResolution | undefined => { const spec = runtimeSpecFor(workload); if (spec === undefined) return undefined; + const catalog = catalogEntryFor(workload.id); + if (catalog === undefined) return undefined; return { command: spec.containerArgs( state, @@ -1514,7 +1429,7 @@ export const containerResolutionFor = ( inputs, ), mounts: spec.containerMounts?.(state, workload, inputs) ?? [], - networkAliases: [aliasFor(workload)], + networkAliases: [catalog.containerAlias], publications: declaredBindings(spec.bindings).flatMap(([binding, definition]) => { const assignment = state.privatePorts.find( (entry) => entry.workloadId === workload.id && entry.binding === binding, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 25fe77afb8..f852457be5 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -37,7 +37,6 @@ const key: RuntimeWorkloadKey = { const containerArtifact: ContainerArtifact = { kind: "container", - service: "database", image: "example/database:1", }; @@ -48,7 +47,7 @@ const workload = (selected: PlannedWorkload["selected"] = containerArtifact): Pl readiness: { mode: "tcp" }, restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { - native: { kind: "native", service: "database", release: "1" }, + native: { kind: "native", release: "1" }, container: containerArtifact, }, selected, @@ -224,7 +223,6 @@ const memoryLogStore = (records: LogRecord[]): LogStore => ({ ...record, })).pipe(Effect.tap((entry) => Effect.sync(() => records.push(entry)))), read: () => Effect.succeed([]), - retained: () => Effect.succeed([]), stream: () => Stream.empty, }); @@ -744,7 +742,6 @@ describe("container runtime", () => { path: logError.path, append: () => Effect.fail(logError), read: () => Effect.succeed([]), - retained: () => Effect.succeed([]), stream: () => Stream.empty, }, resolveWorkload: () => @@ -790,7 +787,6 @@ describe("container runtime", () => { path: logError.path, append: () => Effect.fail(logError), read: () => Effect.succeed([]), - retained: () => Effect.succeed([]), stream: () => Stream.empty, }, resolveWorkload: () => @@ -1031,7 +1027,7 @@ describe("container runtime", () => { ownerSessionId: "owner-session", }); const result = yield* runtime - .start(key, workload({ kind: "native", service: "database", release: "1" })) + .start(key, workload({ kind: "native", release: "1" })) .pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); expect(called).toBe(false); @@ -1120,7 +1116,6 @@ describe("container runtime", () => { path: logError.path, append: () => Effect.fail(logError), read: () => Effect.succeed([]), - retained: () => Effect.succeed([]), stream: () => Stream.empty, }; const runtime = yield* makeContainerRuntime({ diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 1d2309f3d3..44a1fd1455 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -32,10 +32,10 @@ const workload = (id: string, bootstrap?: "database"): PlannedWorkload => ({ readiness: { mode: "tcp" }, restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { - native: { kind: "native", service: id, release: "test" }, - container: { kind: "container", service: id, image: `test/${id}` }, + native: { kind: "native", release: "test" }, + container: { kind: "container", image: `test/${id}` }, }, - selected: { kind: "native", service: id, release: "test" }, + selected: { kind: "native", release: "test" }, specHash: id, }); @@ -290,7 +290,6 @@ describe("native runtime", { timeout: 15_000 }, () => { new LogStoreError({ path: "memory://failing-log-store", message: "disk full" }), ), read: () => Effect.succeed([]), - retained: () => Effect.succeed([]), stream: () => Stream.empty, }; const runtime = yield* makeNativeRuntime({ @@ -652,7 +651,6 @@ describe("native runtime", { timeout: 15_000 }, () => { }), ), read: () => Effect.succeed([]), - retained: () => Effect.succeed([]), stream: () => Stream.empty, }; const runtime = yield* makeNativeRuntime({ @@ -808,7 +806,6 @@ describe("native runtime", { timeout: 15_000 }, () => { return yield* Deferred.await(appendFailure); }), read: () => Effect.succeed([]), - retained: () => Effect.succeed([]), stream: () => Stream.empty, }; const runtime = yield* makeNativeRuntime({ diff --git a/packages/stack/src/runtime/postgres-database-session.integration.test.ts b/packages/stack/src/runtime/postgres-database-session.integration.test.ts index fd327eeb9e..a407af4d61 100644 --- a/packages/stack/src/runtime/postgres-database-session.integration.test.ts +++ b/packages/stack/src/runtime/postgres-database-session.integration.test.ts @@ -1,14 +1,109 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Redacted, Ref } from "effect"; -import { SqlError, UnknownError } from "effect/unstable/sql/SqlError"; +import { + AuthenticationError, + ConnectionError, + SqlError, + UnknownError, +} from "effect/unstable/sql/SqlError"; import { ensureInternalDatabase, makeDatabaseSessionFromAcquisition, makeDatabaseSessionFromSqlClient, type DatabaseSqlClient, } from "./PostgresDatabaseSession.ts"; +import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; describe("Postgres database session", () => { + it.live("classifies SQL connection failures without retaining raw causes", () => + Effect.gen(function* () { + const reasons = [ + [ConnectionError, true], + [AuthenticationError, false], + [UnknownError, false], + ] as const; + for (const [Reason, retryable] of reasons) { + const secret = "database-secret"; + const client: DatabaseSqlClient = { + unsafe: () => + Effect.fail( + new SqlError({ + reason: new Reason({ + message: `connection failed with ${secret}`, + cause: new Error(`underlying ${secret}`), + }), + }), + ), + withTransaction: (effect: Effect.Effect) => effect, + }; + const result = yield* makeDatabaseSessionFromSqlClient(client) + .execute("SELECT 1") + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(DatabaseBootstrapError); + if (!(error instanceof DatabaseBootstrapError)) continue; + expect(error).toMatchObject({ retryable }); + expect(error.message).not.toContain(secret); + expect(error.cause).toBeUndefined(); + } + } + }), + ); + + it.live("classifies connection-acquisition UnknownError as retryable", () => + Effect.gen(function* () { + const secret = "database-secret"; + const acquire = Effect.fail( + new SqlError({ + reason: new UnknownError({ + message: `connection refused ${secret}`, + cause: new Error(`underlying ${secret}`), + }), + }), + ); + const result = yield* Effect.scoped(makeDatabaseSessionFromAcquisition(acquire)).pipe( + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(DatabaseBootstrapError); + if (!(error instanceof DatabaseBootstrapError)) return; + expect(error.retryable).toBe(true); + expect(error.message).not.toContain(secret); + expect(error.cause).toBeUndefined(); + } + }), + ); + + it.live("keeps connection-acquisition AuthenticationError non-retryable", () => + Effect.gen(function* () { + const secret = "database-secret"; + const acquire = Effect.fail( + new SqlError({ + reason: new AuthenticationError({ + message: `authentication failed ${secret}`, + cause: new Error(`underlying ${secret}`), + }), + }), + ); + const result = yield* Effect.scoped(makeDatabaseSessionFromAcquisition(acquire)).pipe( + Effect.exit, + ); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(DatabaseBootstrapError); + if (!(error instanceof DatabaseBootstrapError)) return; + expect(error.retryable).toBe(false); + expect(error.message).not.toContain(secret); + expect(error.cause).toBeUndefined(); + } + }), + ); + it.live("creates the internal database once and provisions service schemas", () => Effect.gen(function* () { const calls: Array<{ readonly sql: string; readonly params: ReadonlyArray }> = []; diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index ad0465856c..85358ebf46 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -37,6 +37,7 @@ import { } from "./ProductionRuntime.ts"; import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; import { InvalidStackConfigError, StackPreparationError } from "../public/Errors.ts"; +import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -91,10 +92,9 @@ const workloadFor = ( readiness: { mode: "tcp" }, restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { - native: { kind: "native", service: "postgres", release: "17.6.1.167" }, + native: { kind: "native", release: "17.6.1.167" }, container: { kind: "container", - service: "postgres", image: "ghcr.io/supabase/cli/postgres:17.6.1.167", }, }, @@ -102,8 +102,15 @@ const workloadFor = ( specHash: `hash-${id}`, }); -const stateStoreFor = (current: { value: PersistedStackState }): StackStateStore => ({ - read: () => Effect.sync(() => current.value), +const stateStoreFor = ( + current: { value: PersistedStackState }, + onRead?: () => void, +): StackStateStore => ({ + read: () => + Effect.sync(() => { + onRead?.(); + return current.value; + }), write: () => Effect.die("unused"), initialize: () => Effect.die("unused"), replace: () => Effect.die("unused"), @@ -126,7 +133,6 @@ const memoryLogStore = (entries: StackLogEntry[]): LogStore => ({ return entry; }), read: () => Effect.succeed(entries), - retained: () => Effect.succeed(entries), stream: () => Stream.fromIterable(entries), }); @@ -306,6 +312,194 @@ const ownerInputContainerEngine = (createdSpecs: ContainerContainerSpec[]): Cont }; describe("production runtime composition", () => { + it.live("retries a transient database bootstrap connection failure", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-bootstrap-", + }); + const readinessServer = createNetServer((socket) => socket.end()); + yield* listenForNativeReadiness(readinessServer); + const address = readinessServer.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Database readiness server did not expose an address"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + }); + const current = { + value: { + ...stateFor( + { + "secret:database.internal.password": { policy: "managed", value: "db-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + }, + { kind: "container", engine: "docker" }, + ), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + privatePorts: [ + { workloadId: "database:database", binding: "primary", port: address.port }, + ], + }, + } satisfies { value: PersistedStackState }; + const database = compiled.executionPlan.workloads.find( + (workload) => workload.id === "database:database", + ); + if (database === undefined) return yield* Effect.die("Expected database workload"); + const createdSpecs: ContainerContainerSpec[] = []; + let attempts = 0; + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: ownerInputContainerEngine(createdSpecs), + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "present" as const, + image: workload.selected.kind === "container" ? workload.selected.image : undefined, + }), + }, + logStore: memoryLogStore([]), + bootstrapDatabase: () => { + attempts += 1; + return attempts === 1 + ? Effect.fail( + new DatabaseBootstrapError({ + message: "Database is still accepting connections", + retryable: true, + }), + ) + : Effect.void; + }, + }); + const runtime = yield* factory.make(current.value); + const ready = yield* runtime.driver.start( + { + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }, + database, + ); + expect(ready.state).toBe("ready"); + expect(attempts).toBe(2); + yield* runtime.driver.stop({ + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("does not retry non-retryable database bootstrap failures", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-bootstrap-fail-", + }); + const readinessServer = createNetServer((socket) => socket.end()); + yield* listenForNativeReadiness(readinessServer); + const address = readinessServer.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Database readiness server did not expose an address"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + }); + const current = { + value: { + ...stateFor( + { + "secret:database.internal.password": { policy: "managed", value: "db-secret" }, + "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, + }, + { kind: "container", engine: "docker" }, + ), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + privatePorts: [ + { workloadId: "database:database", binding: "primary", port: address.port }, + ], + }, + } satisfies { value: PersistedStackState }; + const database = compiled.executionPlan.workloads.find( + (workload) => workload.id === "database:database", + ); + if (database === undefined) return yield* Effect.die("Expected database workload"); + let attempts = 0; + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: ownerInputContainerEngine([]), + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "present" as const, + image: workload.selected.kind === "container" ? workload.selected.image : undefined, + }), + }, + logStore: memoryLogStore([]), + bootstrapDatabase: () => { + attempts += 1; + return Effect.fail(new DatabaseBootstrapError({ message: "invalid password" })); + }, + }); + const runtime = yield* factory.make(current.value); + const result = yield* runtime.driver + .start( + { + stackId, + desiredGeneration: 1, + workloadId: database.id, + specHash: database.specHash, + }, + database, + ) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(attempts).toBe(1); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("activates a capability through its public listener workload", () => Effect.scoped( Effect.gen(function* () { @@ -836,6 +1030,8 @@ describe("production runtime composition", () => { it.live("exposes artifact-only preparation without starting workloads or mutating state", () => Effect.scoped( Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-prepare-" }); const current = { value: stateFor({}) }; const preparedCalls: string[] = []; const preparer: RuntimeArtifactPreparer = { @@ -855,7 +1051,7 @@ describe("production runtime composition", () => { }; const context = yield* Effect.context(); const factory = yield* makeProductionRuntimeFactory({ - stateRoot: "/tmp/production-runtime-prepare-state", + stateRoot: root, stackId, ownerSessionId: "owner", stateStore: stateStoreFor(current), @@ -873,12 +1069,10 @@ describe("production runtime composition", () => { const result = yield* runtime.prepare({ kind: "native" }, [ workloadFor("database:database", "database", { kind: "native", - service: "postgres", release: "17.6.1.167", }), workloadFor("rest:download", "rest", { kind: "native", - service: "postgres", release: "17.6.1.167", }), ]); @@ -895,25 +1089,60 @@ describe("production runtime composition", () => { it.live("redacts logs using secret slots materialized after factory creation", () => Effect.scoped( Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-logs-" }); const current = { value: stateFor({}) }; + const reads = { value: 0 }; const entries: StackLogEntry[] = []; + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const candidate = { + ...stateFor({ + "secret:auth.settings.jwt_secret": { + policy: "managed" as const, + value: "rotated-secret", + }, + }), + desiredLifecycle: "running" as const, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + } satisfies PersistedStackState; const context = yield* Effect.context(); const factory = yield* makeProductionRuntimeFactory({ - stateRoot: "/tmp/production-runtime-state", + stateRoot: root, stackId, ownerSessionId: "owner", - stateStore: stateStoreFor(current), + stateStore: stateStoreFor(current, () => reads.value++), context, ingress, - artifactPreparer: artifacts, + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "present" as const, + }), + }, envFileOwner: envFiles, functionsBootstrapOwner: bootstrap, logStore: memoryLogStore(entries), bootstrapDatabase: () => Effect.void, }); const runtime = yield* factory.make(current.value); - current.value = stateFor({ - "secret:auth.settings.jwt_secret": { policy: "managed", value: "rotated-secret" }, + if (runtime.preflight === undefined) return yield* Effect.die("preflight seam missing"); + yield* runtime.preflight({ + stackId, + generation: 1, + desiredLifecycle: "running", + state: candidate, + previous: current.value, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: candidate.secrets, + plan: compiled.executionPlan, }); const logStore = runtime.logStore; expect(logStore).toBeDefined(); @@ -923,7 +1152,14 @@ describe("production runtime composition", () => { stream: "stdout", message: "token=rotated-secret", }); + yield* logStore.append({ + source: "auth", + stream: "stdout", + message: "token=rotated-secret again", + }); expect(entries[0]?.message).toBe("token=[REDACTED]"); + expect(entries[1]?.message).toBe("token=[REDACTED] again"); + expect(reads.value).toBe(1); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -983,17 +1219,7 @@ describe("production runtime composition", () => { response.setHeader("Connection", "close"); response.end("ok"); }); - yield* Effect.acquireRelease( - Effect.callback((resume) => { - authServer.once("error", (error) => resume(Effect.fail(error))); - authServer.listen(0, "127.0.0.1", () => resume(Effect.void)); - }), - () => - Effect.callback((resume) => { - if (!authServer.listening) return resume(Effect.void); - authServer.close(() => resume(Effect.void)); - }), - ); + yield* listenForNativeReadiness(authServer); const authAddress = authServer.address(); if (typeof authAddress !== "object" || authAddress === null) return yield* Effect.die("Auth readiness server did not expose an address"); @@ -1002,17 +1228,7 @@ describe("production runtime composition", () => { response.setHeader("Connection", "close"); response.end("ok"); }); - yield* Effect.acquireRelease( - Effect.callback((resume) => { - functionsServer.once("error", (error) => resume(Effect.fail(error))); - functionsServer.listen(0, "127.0.0.1", () => resume(Effect.void)); - }), - () => - Effect.callback((resume) => { - if (!functionsServer.listening) return resume(Effect.void); - functionsServer.close(() => resume(Effect.void)); - }), - ); + yield* listenForNativeReadiness(functionsServer); const functionsAddress = functionsServer.address(); if (typeof functionsAddress !== "object" || functionsAddress === null) return yield* Effect.die("Functions readiness server did not expose an address"); @@ -1021,32 +1237,12 @@ describe("production runtime composition", () => { response.setHeader("Connection", "close"); response.end("ok"); }); - yield* Effect.acquireRelease( - Effect.callback((resume) => { - analyticsServer.once("error", (error) => resume(Effect.fail(error))); - analyticsServer.listen(0, "127.0.0.1", () => resume(Effect.void)); - }), - () => - Effect.callback((resume) => { - if (!analyticsServer.listening) return resume(Effect.void); - analyticsServer.close(() => resume(Effect.void)); - }), - ); + yield* listenForNativeReadiness(analyticsServer); const analyticsAddress = analyticsServer.address(); if (typeof analyticsAddress !== "object" || analyticsAddress === null) return yield* Effect.die("Analytics readiness server did not expose an address"); const poolerServer = createNetServer(); - yield* Effect.acquireRelease( - Effect.callback((resume) => { - poolerServer.once("error", (error) => resume(Effect.fail(error))); - poolerServer.listen(0, "127.0.0.1", () => resume(Effect.void)); - }), - () => - Effect.callback((resume) => { - if (!poolerServer.listening) return resume(Effect.void); - poolerServer.close(() => resume(Effect.void)); - }), - ); + yield* listenForNativeReadiness(poolerServer); const poolerAddress = poolerServer.address(); if (typeof poolerAddress !== "object" || poolerAddress === null) return yield* Effect.die("Pooler readiness server did not expose an address"); @@ -1295,17 +1491,7 @@ describe("production runtime composition", () => { response.setHeader("Connection", "close"); response.end("ok"); }); - yield* Effect.acquireRelease( - Effect.callback((resume) => { - restServer.once("error", (error) => resume(Effect.fail(error))); - restServer.listen(0, "127.0.0.1", () => resume(Effect.void)); - }), - () => - Effect.callback((resume) => { - if (!restServer.listening) return resume(Effect.void); - restServer.close(() => resume(Effect.void)); - }), - ); + yield* listenForNativeReadiness(restServer); const address = restServer.address(); if (typeof address !== "object" || address === null) return yield* Effect.die("REST readiness server did not expose an address"); @@ -1422,17 +1608,7 @@ describe("production runtime composition", () => { }, }).pipe(Effect.provide(NodeServices.layer)); const poolerServer = createNetServer(); - yield* Effect.acquireRelease( - Effect.callback((resume) => { - poolerServer.once("error", (error) => resume(Effect.fail(error))); - poolerServer.listen(0, "127.0.0.1", () => resume(Effect.void)); - }), - () => - Effect.callback((resume) => { - if (!poolerServer.listening) return resume(Effect.void); - poolerServer.close(() => resume(Effect.void)); - }), - ); + yield* listenForNativeReadiness(poolerServer); const poolerAddress = poolerServer.address(); if (typeof poolerAddress !== "object" || poolerAddress === null) return yield* Effect.die("Pooler readiness server did not expose an address"); diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 0870998597..f82df458df 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -19,6 +19,7 @@ import { StackIdSchema } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { resolveSecrets } from "../state/SecretStore.ts"; +import { containerAliasFor } from "../model/WorkloadCatalog.ts"; import { makeRuntimeInputOwner } from "./RuntimeInputOwner.ts"; const stackId = StackIdSchema.make("f".repeat(64)); @@ -557,7 +558,7 @@ describe("runtime input owner", () => { expect(tenant).toBeDefined(); const content = yield* fs.readFileString(tenant!); expect(content).toContain('"external_id" => "tenant-custom"'); - expect(content).toContain('"db_host" => "supabase-database"'); + expect(content).toContain(`"db_host" => "${containerAliasFor("database:database")}"`); expect(content).toContain('"db_port" => 5432'); expect(content).toContain("\\#{System.halt()}"); expect(content).toContain("Supavisor.Repo.preload(existing, :users)"); diff --git a/packages/stack/src/runtime/runtime-recovery.integration.test.ts b/packages/stack/src/runtime/runtime-recovery.integration.test.ts index 44015969a8..707cd5eee8 100644 --- a/packages/stack/src/runtime/runtime-recovery.integration.test.ts +++ b/packages/stack/src/runtime/runtime-recovery.integration.test.ts @@ -21,7 +21,6 @@ const stackId = StackIdSchema.make("e".repeat(64)); const otherStackId = StackIdSchema.make("f".repeat(64)); const artifact: ContainerArtifact = { kind: "container", - service: "database", image: "example/database:1", }; const key: RuntimeWorkloadKey = { @@ -37,7 +36,7 @@ const workload = (selected: PlannedWorkload["selected"] = artifact): PlannedWork readiness: { mode: "tcp" }, restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { - native: { kind: "native", service: "database", release: "1" }, + native: { kind: "native", release: "1" }, container: artifact, }, selected, @@ -432,7 +431,7 @@ describe("runtime recovery", () => { waitForReadiness: () => Effect.void, }); const otherKey = { ...key, stackId: otherStackId }; - const nativeWorkload = workload({ kind: "native", service: "database", release: "1" }); + const nativeWorkload = workload({ kind: "native", release: "1" }); yield* runtime.start(key, nativeWorkload); yield* runtime.start(otherKey, { ...nativeWorkload, id: otherKey.workloadId }); yield* runtime.cleanup({ stackId, destroy: true }); @@ -459,7 +458,7 @@ describe("runtime recovery", () => { }), waitForReadiness: () => Effect.void, }); - const nativeWorkload = workload({ kind: "native", service: "database", release: "1" }); + const nativeWorkload = workload({ kind: "native", release: "1" }); yield* runtime.start(key, nativeWorkload); const otherKey = { ...key, stackId: otherStackId }; yield* runtime.start(otherKey, { ...nativeWorkload, id: otherKey.workloadId }); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 14f7826e39..408b35757a 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -94,15 +94,76 @@ const planned = (id: string): PlannedWorkload => { readiness: { mode: "tcp" }, restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { - native: { kind: "native", service: entry.service, release: entry.nativeVersion }, - container: { kind: "container", service: entry.service, image: entry.containerImage }, + native: { kind: "native", release: entry.nativeVersion }, + container: { kind: "container", image: entry.containerImage }, }, - selected: { kind: "native", service: entry.service, release: entry.nativeVersion }, + selected: { kind: "native", release: entry.nativeVersion }, specHash: `${capability}:${name}`, }; }; describe("workload runtime catalog", () => { + it.live("consumes persisted runtime defaults without rebuilding them", () => + Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + }).pipe(Effect.provide(NodeServices.layer)); + const configured: PersistedStackState = { ...state, definition: compiled.definition }; + const rest = planned("rest:rest"); + expect(runtimeSpecFor(rest)?.env(configured, rest, 3000)).toMatchObject({ + PGRST_DB_SCHEMAS: "public,graphql_public", + PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", + PGRST_DB_MAX_ROWS: "1000", + }); + const auth = planned("auth:auth"); + expect(runtimeSpecFor(auth)?.env(configured, auth, 3000)).toMatchObject({ + GOTRUE_SITE_URL: "http://127.0.0.1:3000", + GOTRUE_JWT_EXP: "3600", + GOTRUE_SMTP_ADMIN_EMAIL: "admin@email.com", + GOTRUE_SMTP_SENDER_NAME: "Admin", + }); + const storage = planned("storage:storage"); + expect(runtimeSpecFor(storage)?.env(configured, storage, 5000)).toMatchObject({ + FILE_SIZE_LIMIT: "52428800", + ENABLE_IMAGE_TRANSFORMATION: "false", + S3_PROTOCOL_ENABLED: "true", + S3_PROTOCOL_ACCESS_KEY_ID: "625729a08b95bf1b7ff351a663f3a23c", + STORAGE_S3_REGION: "local", + }); + const realtime = planned("realtime:realtime"); + expect(runtimeSpecFor(realtime)?.env(configured, realtime, 4000)).toMatchObject({ + MAX_HEADER_LENGTH: "4096", + }); + const functions = planned("functions:edge-runtime"); + expect(runtimeSpecFor(functions)?.env(configured, functions, 9000)).toMatchObject({ + FUNCTIONS_ROOT: `${state.identity.projectRoot}/supabase/functions`, + EDGE_RUNTIME_POLICY: "per_worker", + EDGE_RUNTIME_DENO_VERSION: "2", + }); + expect(runtimeSpecFor(functions)?.args(configured, functions, 9000)).toContain( + "--policy=per_worker", + ); + + const bigquery = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: { kind: "native" }, + config: { capabilities: { analytics: { settings: { backend: "bigquery" } } } }, + }).pipe(Effect.provide(NodeServices.layer)); + const analytics = planned("analytics:analytics"); + expect( + runtimeSpecFor(analytics)?.env( + { ...state, definition: bigquery.definition }, + analytics, + 4000, + ), + ).toMatchObject({ + GOOGLE_PROJECT_ID: "local", + GOOGLE_PROJECT_NUMBER: "0", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("derives one closed private binding intent for every planned workload binding", () => Effect.gen(function* () { const compiled = yield* compileStack({ @@ -209,7 +270,7 @@ describe("workload runtime catalog", () => { expect(spec.env(state, workload, port).SUPABASE_STACK_WORKLOAD).toBe(id); expect(spec.readiness.protocol).toMatch(/http|tcp/u); expect(spec.args(state, workload, port)).toBeInstanceOf(Array); - expect(spec.cwd(state, workload)).toBeTruthy(); + expect(typeof spec.cwd(state, workload)).toBe("string"); expect(spec.privateEndpoint(state, spec.readiness.binding)).toEqual({ host: "127.0.0.1", port: expect.any(Number), @@ -218,7 +279,9 @@ describe("workload runtime catalog", () => { host: WORKLOAD_CATALOG[id]?.containerAlias, port: spec.bindings[spec.readiness.binding]?.containerPort, }); - expect(spec.networkAliases).toEqual([WORKLOAD_CATALOG[id]?.containerAlias]); + expect(containerResolutionFor(state, workload)?.networkAliases).toEqual([ + WORKLOAD_CATALOG[id]?.containerAlias, + ]); } }); @@ -247,11 +310,16 @@ describe("workload runtime catalog", () => { { address: "127.0.0.1", hostPort: 30011, containerPort: 1025 }, { address: "127.0.0.1", hostPort: 30012, containerPort: 1110 }, ]); - expect(runtimeSpecFor(mail)?.env(state, mail, 8025)).toMatchObject({ + expect(runtimeSpecFor(mail)?.env(state, mail, 30010)).toMatchObject({ MP_UI_BIND_ADDR: "127.0.0.1:30010", MP_SMTP_BIND_ADDR: "127.0.0.1:30011", MP_POP3_BIND_ADDR: "127.0.0.1:30012", }); + const realtime = planned("realtime:realtime"); + expect(runtimeSpecFor(realtime)?.env(state, realtime, 32000).PORT).toBe("32000"); + expect( + runtimeSpecFor(realtime)?.env(state, realtime, 32000).SUPABASE_STACK_PRIVATE_PORT, + ).toBe("32000"); const secondState: PersistedStackState = { ...state, privatePorts: state.privatePorts.map((assignment) => ({ @@ -640,12 +708,6 @@ describe("workload runtime catalog", () => { expect( analytics?.env(configured, planned("analytics:analytics"), 4000, "container"), ).not.toHaveProperty("LOGFLARE_PUBLIC_ACCESS_TOKEN"); - expect(analytics?.env(state, planned("analytics:analytics"), 4000, "native")).toMatchObject( - { - DB_USERNAME: "supabase_admin", - POSTGRES_BACKEND_URL: expect.stringContaining("/_supabase"), - }, - ); expect( analytics?.containerMounts?.(configured, planned("analytics:analytics"), analyticsInputs), ).toEqual([ @@ -662,7 +724,7 @@ describe("workload runtime catalog", () => { [], ); const pooler = runtimeSpecFor(planned("pooler:pooler")); - expect(pooler?.env(configured, planned("pooler:pooler"), 6543)).toMatchObject({ + expect(pooler?.env(configured, planned("pooler:pooler"), 30016)).toMatchObject({ POOL_MODE: "session", MAX_CLIENT_CONN: "250", TENANT_ID: "pooler-dev", @@ -783,7 +845,7 @@ describe("workload runtime catalog", () => { runtimeSpecFor(planned("analytics:vector"))?.env( configured, planned("analytics:vector"), - 9001, + 30014, "native", ).VECTOR_API_ADDRESS, ).toBe("127.0.0.1:30014"); @@ -1164,11 +1226,19 @@ describe("workload runtime catalog", () => { APP_SECRET: "value", EMPTY_SECRET: "", EDGE_RUNTIME_PORT: "secret-collision", + SUPABASE_INTERNAL_JWT_SECRET: "forbidden-jwt", + SUPABASE_INTERNAL_PUBLISHABLE_KEY: "forbidden-publishable", + SUPABASE_INTERNAL_SECRET_KEY: "forbidden-secret", + SUPABASE_INTERNAL_HOST_PORT: "forbidden-port", + SUPABASE_JWKS: "forbidden-jwks", }, }, }); expect(symmetric?.env).toMatchObject({ SUPABASE_INTERNAL_JWT_SECRET: "symmetric-secret", + SUPABASE_INTERNAL_PUBLISHABLE_KEY: "sb_publishable_test", + SUPABASE_INTERNAL_SECRET_KEY: "sb_secret_test", + SUPABASE_INTERNAL_HOST_PORT: "54321", SUPABASE_JWKS: '{"keys":[{"kty":"EC"}]}', APP_SECRET: "value", EMPTY_SECRET: "", diff --git a/packages/stack/src/state/MaterializedSettings.ts b/packages/stack/src/state/MaterializedSettings.ts new file mode 100644 index 0000000000..8223c8de67 --- /dev/null +++ b/packages/stack/src/state/MaterializedSettings.ts @@ -0,0 +1,71 @@ +import type { CapabilityName } from "../public/Capability.ts"; +import type { PersistedStackState } from "./StackState.ts"; + +/** A plain object in the persisted, materialized settings document. */ +export const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Returns one capability's persisted settings without applying defaults. */ +export const settingsFor = (state: PersistedStackState, capability: CapabilityName): unknown => + state.definition?.capabilities[capability].settings; + +/** Resolves a persisted secret slot to the value supplied to a workload. */ +export const secret = (state: PersistedStackState, slot: string): string => + state.secrets[slot]?.value ?? ""; + +/** Converts a materialized setting leaf into the string form expected by workloads. */ +export const settingValue = (state: PersistedStackState, value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") + return String(value); + if (Array.isArray(value)) return value.map((entry) => settingValue(state, entry)).join(","); + if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) + return secret(state, value.slot); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- dynamic persisted settings are stringified for workload env values + return JSON.stringify(value) ?? ""; +}; + +/** Flattens nested materialized settings into environment-style keys. */ +export const flattenSettings = ( + state: PersistedStackState, + value: unknown, + prefix: string, + out: Record, +): void => { + if (value === null || value === undefined) return; + if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) { + out[prefix] = secret(state, value.slot); + return; + } + if (Array.isArray(value)) { + out[prefix] = value.map((entry) => settingValue(state, entry)).join(","); + return; + } + if (isRecord(value)) { + for (const [key, entry] of Object.entries(value)) { + const normalized = key.replace(/[^A-Za-z0-9]+/gu, "_").toUpperCase(); + flattenSettings( + state, + entry, + prefix.length === 0 ? normalized : `${prefix}_${normalized}`, + out, + ); + } + return; + } + out[prefix] = settingValue(state, value); +}; + +/** Reads a dotted path from one capability's materialized settings. */ +export const valueAt = ( + state: PersistedStackState, + capability: CapabilityName, + path: string, +): string => { + let current: unknown = settingsFor(state, capability); + for (const segment of path.split(".")) { + if (!isRecord(current)) return ""; + current = current[segment]; + } + return settingValue(state, current); +}; diff --git a/packages/stack/src/state/StackStateStore.ts b/packages/stack/src/state/StackStateStore.ts index dbe39fdbab..6467a769e5 100644 --- a/packages/stack/src/state/StackStateStore.ts +++ b/packages/stack/src/state/StackStateStore.ts @@ -5,7 +5,7 @@ import { StackStateGenerationMismatchError, StackStateInvalidError, } from "../public/Errors.ts"; -import { resolveStackPaths } from "./Paths.ts"; +import { resolveStackPaths, type StackPaths } from "./Paths.ts"; import { deriveStackId } from "../identity/Identity.ts"; import { StackIdSchema } from "../public/StackId.ts"; import { @@ -80,16 +80,6 @@ export interface StackStateStore { const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -const hasExactKeys = ( - value: unknown, - keys: ReadonlyArray, -): value is Readonly> => { - if (!isRecord(value)) return false; - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return actual.length === expected.length && expected.every((key, index) => actual[index] === key); -}; - const stateError = (message: string, cause?: unknown) => new StackStateInvalidError({ message, ...(cause === undefined ? {} : { cause }) }); @@ -110,23 +100,18 @@ const decodeState = ( if (raw.format !== STACK_STATE_FORMAT) { return Effect.fail( new StackStateFormatUnsupportedError({ - format: isRecord(raw) && typeof raw.format === "string" ? raw.format : undefined, + format: raw.format, message: `Unsupported stack state format; expected ${STACK_STATE_FORMAT}`, }), ); } if (!isRecord(raw.secrets)) return Effect.fail(stateError("Persisted secret values must be a record")); - for (const [slot, entry] of Object.entries(raw.secrets)) { + // Schema.Record validates values but does not enforce dynamic object-key checks + // while decoding JSON, so retain this format-level slot-name guard. + for (const slot of Object.keys(raw.secrets)) if (!/^[A-Za-z0-9_.:/-]+$/.test(slot)) return Effect.fail(stateError(`Persisted secret slot key is invalid: ${slot}`)); - if (!isRecord(entry) || !hasExactKeys(entry, ["policy", "value"])) - return Effect.fail(stateError(`Persisted secret entry ${slot} has an invalid shape`)); - if (entry.policy !== "managed" && entry.policy !== "passthrough") - return Effect.fail(stateError(`Persisted secret entry ${slot} has an invalid policy`)); - if (typeof entry.value !== "string") - return Effect.fail(stateError(`Persisted secret entry ${slot} has an invalid value`)); - } return Schema.decodeUnknownEffect(PersistedStackStateSchema)(raw, { onExcessProperty: "error", }).pipe( @@ -156,6 +141,16 @@ const validateIdentityForStackId = ( ); }; +const validateStateSchema = ( + state: PersistedStackState, +): Effect.Effect => + Schema.decodeEffect(PersistedStackStateSchema)(state, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => stateError(`Invalid persisted stack state: ${String(error)}`)), + Effect.asVoid, + ); + const atomicWrite = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -230,6 +225,31 @@ const atomicWrite = ( // rename is the strongest cross-platform atomicity guarantee available here. }); +const validateState = ( + stackId: string, + state: PersistedStackState, +): Effect.Effect => + Effect.gen(function* () { + yield* validateIdentityForStackId(state.identity, stackId); + yield* validateStateSchema(state); + }); + +const persistValidatedState = ( + fs: FileSystem.FileSystem, + path: Path.Path, + crypto: Crypto.Crypto, + paths: StackPaths, + state: PersistedStackState, +): Effect.Effect => + Effect.gen(function* () { + yield* ownerDirectory(fs, paths.stackRoot).pipe( + Effect.mapError((error) => + stateError(`Unable to create stack state directory: ${error.message}`), + ), + ); + yield* atomicWrite(fs, path, crypto, paths.stateDocument, paths.stackRoot, state); + }); + /** * Acquires one short cross-process lock. The lock is an O_EXCL file owned by this operation and is * removed exactly on release. We intentionally do not steal stale locks because ownership cannot @@ -377,25 +397,13 @@ export const makeStackStateStore = (options: { options.stateRoot, Effect.gen(function* () { const paths = yield* pathsFor(stackId); - yield* validateIdentityForStackId(next.identity, stackId); - yield* Schema.decodeEffect(PersistedStackStateSchema)(next, { - onExcessProperty: "error", - }).pipe( - Effect.mapError((error) => - stateError(`Invalid persisted stack state: ${String(error)}`), - ), - ); + yield* validateState(stackId, next); const existing = yield* read(stackId); if (existing !== undefined) return yield* stateError( "Stack state already exists; use a generation-fenced replacement", ); - yield* ownerDirectory(fs, paths.stackRoot).pipe( - Effect.mapError((error) => - stateError(`Unable to create stack state directory: ${error.message}`), - ), - ); - yield* atomicWrite(fs, path, crypto, paths.stateDocument, paths.stackRoot, next); + yield* persistValidatedState(fs, path, crypto, paths, next); }), ); @@ -413,20 +421,8 @@ export const makeStackStateStore = (options: { const existing = yield* read(stackId); if (existing !== undefined) return existing; const paths = yield* pathsFor(stackId); - yield* validateIdentityForStackId(candidate.identity, stackId); - yield* Schema.decodeEffect(PersistedStackStateSchema)(candidate, { - onExcessProperty: "error", - }).pipe( - Effect.mapError((error) => - stateError(`Invalid persisted stack state: ${String(error)}`), - ), - ); - yield* ownerDirectory(fs, paths.stackRoot).pipe( - Effect.mapError((error) => - stateError(`Unable to create stack state directory: ${error.message}`), - ), - ); - yield* atomicWrite(fs, path, crypto, paths.stateDocument, paths.stackRoot, candidate); + yield* validateState(stackId, candidate); + yield* persistValidatedState(fs, path, crypto, paths, candidate); return candidate; }), ); @@ -454,19 +450,8 @@ export const makeStackStateStore = (options: { message: "Persisted stack state generation changed", }); } - if (next.identity.stackId !== stackId) - return yield* stateError("Persisted identity does not match StackId"); - yield* Schema.decodeEffect(PersistedStackStateSchema)(next, { - onExcessProperty: "error", - }).pipe( - Effect.mapError((error) => stateError(`Invalid persisted stack state: ${String(error)}`)), - ); - yield* ownerDirectory(fs, paths.stackRoot).pipe( - Effect.mapError((error) => - stateError(`Unable to create stack state directory: ${error.message}`), - ), - ); - yield* atomicWrite(fs, path, crypto, paths.stateDocument, paths.stackRoot, next); + yield* validateStateSchema(next); + yield* persistValidatedState(fs, path, crypto, paths, next); }); const replace = ( diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 6fc27ea3c5..e086f5c613 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -185,6 +185,24 @@ const materializeCandidate = ( }; }); +const persistedCandidate = ( + stackId: StackId, + state: PersistedStackState, + runtime: StackRuntime, +): Effect.Effect => + Effect.gen(function* () { + const definition = state.definition; + const inputFingerprint = state.inputFingerprint; + if (definition === undefined || inputFingerprint === undefined) + return yield* invalidDefinition(stackId); + return { + definition, + inputFingerprint, + secrets: state.secrets, + plan: yield* rebuildExecutionPlan(runtime, definition), + }; + }); + const lifecycleInput = ( stackId: StackId, previous: PersistedStackState, @@ -335,27 +353,16 @@ export const makeLifecycleController = ( if (current.desiredLifecycle === "destroying") return yield* lifecycleConflict("Stack is being destroyed"); if (current.desiredLifecycle === "stopped") { - if (current.definition === undefined || current.inputFingerprint === undefined) - return yield* invalidDefinition(options.stackId); - const candidate: Candidate = { - definition: current.definition, - inputFingerprint: current.inputFingerprint, - secrets: current.secrets, - plan: yield* rebuildExecutionPlan(current.runtime, current.definition), - }; + const candidate = yield* persistedCandidate( + options.stackId, + current, + current.runtime, + ); // Stopped state is durable intent, not proof that runtime cleanup completed. Retry // the idempotent cleanup without changing the generation. return { state: current, previous: current, candidate, cleanup: true }; } - const definition = current.definition; - if (definition === undefined || current.inputFingerprint === undefined) - return yield* invalidDefinition(options.stackId); - const candidate: Candidate = { - definition, - inputFingerprint: current.inputFingerprint, - secrets: current.secrets, - plan: yield* rebuildExecutionPlan(current.runtime, definition), - }; + const candidate = yield* persistedCandidate(options.stackId, current, current.runtime); const next: PersistedStackState = { ...current, desiredLifecycle: "stopped", @@ -418,16 +425,11 @@ export const makeLifecycleController = ( desiredGeneration: current.desiredGeneration + 1, }; yield* options.stateStore.replace(options.stackId, stopped, current.desiredGeneration); - const currentDefinition = current.definition; - const currentFingerprint = current.inputFingerprint; - if (currentDefinition === undefined || currentFingerprint === undefined) - return yield* invalidDefinition(options.stackId); - const stoppedCandidate: Candidate = { - definition: currentDefinition, - inputFingerprint: currentFingerprint, - secrets: current.secrets, - plan: yield* rebuildExecutionPlan(current.runtime, currentDefinition), - }; + const stoppedCandidate = yield* persistedCandidate( + options.stackId, + current, + current.runtime, + ); return { current, stopped, stoppedCandidate }; }), ); @@ -502,15 +504,7 @@ export const makeLifecycleController = ( previous: PersistedStackState, ): Effect.Effect => Effect.gen(function* () { - const definition = state.definition; - if (definition === undefined || state.inputFingerprint === undefined) - return yield* invalidDefinition(options.stackId); - const candidate: Candidate = { - definition, - inputFingerprint: state.inputFingerprint, - secrets: state.secrets, - plan: yield* rebuildExecutionPlan(state.runtime, definition), - }; + const candidate = yield* persistedCandidate(options.stackId, state, state.runtime); const input = lifecycleInput(options.stackId, previous, state, candidate); yield* options.backend.reconcile(input); yield* options.backend.cleanup(input); diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index 50e4167c52..0b3be07c3e 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -54,9 +54,6 @@ export interface LogStore { readonly read: ( options?: LogOptions, ) => Effect.Effect, LogStoreError>; - readonly retained: ( - options?: LogOptions, - ) => Effect.Effect, LogStoreError>; /** * Returns retained records followed by live records when `follow` is true. * Subscription creation and retained snapshotting happen in one critical @@ -414,7 +411,6 @@ export const makeLogStore = ( path: options.path, append, read, - retained: read, stream, }; }); diff --git a/packages/stack/src/supervisor/Reconciler.unit.test.ts b/packages/stack/src/supervisor/Reconciler.unit.test.ts index d82471e23e..ffd692aa60 100644 --- a/packages/stack/src/supervisor/Reconciler.unit.test.ts +++ b/packages/stack/src/supervisor/Reconciler.unit.test.ts @@ -17,10 +17,10 @@ const workload = ( readiness: { mode: "tcp" }, restart: { maxAttempts: 2, backoffMs: 0 }, artifacts: { - native: { kind: "native", service: id, release: "test" }, - container: { kind: "container", service: id, image: `test/${id}` }, + native: { kind: "native", release: "test" }, + container: { kind: "container", image: `test/${id}` }, }, - selected: { kind: "native", service: id, release: "test" }, + selected: { kind: "native", release: "test" }, specHash, }); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 24e63a6472..f23eb90cc8 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -27,35 +27,12 @@ import { StackConfigSchema, type StackConfig } from "../public/Config.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, - InvalidStackIdentityError, - InvalidProjectRootError, InvalidStackConfigError, StackDefinitionRequiredError, StackLifecycleConflictError, - StackNotFoundError, StackNotRunningError, - StackMustBeStoppedError, - StackOwnershipConflictError, - StackRuntimeMismatchError, StackReconciliationError, - StackVersionUnsupportedError, - StackStateFormatUnsupportedError, - StackUpgradeRequiredError, - StackUpgradeReplacementError, - StackStateGenerationMismatchError, - StackSecretMismatchError, - InvalidJwtSigningMaterialError, - PortAllocationError, - PortUnavailableError, - ServiceStartError, - ServiceReadinessError, - ContainerEngineError, - StackDestructionError, - GatewayAuthenticationError, - GatewayStaleGenerationError, StackPreparationError, - ArtifactIntegrityError, - ContainerPullError, StackStateInvalidError, type StackError, } from "../public/Errors.ts"; @@ -372,41 +349,7 @@ const mapReconcileError = (error: unknown): StackError => { }); }; -const rpcTag = (error: StackError, fallback: StackRpcError["tag"]): StackRpcError["tag"] => { - if (error instanceof InvalidStackIdentityError) return "InvalidStackIdentityError"; - if (error instanceof InvalidProjectRootError) return "InvalidProjectRootError"; - if (error instanceof InvalidStackConfigError) return "InvalidStackConfigError"; - if (error instanceof StackNotFoundError) return "StackNotFoundError"; - if (error instanceof StackOwnershipConflictError) return "StackOwnershipConflictError"; - if (error instanceof StackRuntimeMismatchError) return "StackRuntimeMismatchError"; - if (error instanceof StackDefinitionRequiredError) return "StackDefinitionRequiredError"; - if (error instanceof StackVersionUnsupportedError) return "StackVersionUnsupportedError"; - if (error instanceof StackLifecycleConflictError) return "StackLifecycleConflictError"; - if (error instanceof StackNotRunningError) return "StackNotRunningError"; - if (error instanceof StackMustBeStoppedError) return "StackMustBeStoppedError"; - if (error instanceof StackStateInvalidError) return "StackStateInvalidError"; - if (error instanceof StackStateFormatUnsupportedError) return "StackStateFormatUnsupportedError"; - if (error instanceof StackStateGenerationMismatchError) - return "StackStateGenerationMismatchError"; - if (error instanceof StackUpgradeRequiredError) return "StackUpgradeRequiredError"; - if (error instanceof StackUpgradeReplacementError) return "StackUpgradeReplacementError"; - if (error instanceof StackSecretMismatchError) return "StackSecretMismatchError"; - if (error instanceof InvalidJwtSigningMaterialError) return "InvalidJwtSigningMaterialError"; - if (error instanceof PortAllocationError) return "PortAllocationError"; - if (error instanceof PortUnavailableError) return "PortUnavailableError"; - if (error instanceof ServiceStartError) return "ServiceStartError"; - if (error instanceof ServiceReadinessError) return "ServiceReadinessError"; - if (error instanceof ContainerEngineError) return "ContainerEngineError"; - if (error instanceof StackReconciliationError) return "StackReconciliationError"; - if (error instanceof StackDestructionError) return "StackDestructionError"; - if (error instanceof GatewayAuthenticationError) return "GatewayAuthenticationError"; - if (error instanceof GatewayStaleGenerationError) return "GatewayStaleGenerationError"; - if (error instanceof GatewayActivationError) return "GatewayActivationError"; - if (error instanceof StackPreparationError) return "StackPreparationError"; - if (error instanceof ArtifactIntegrityError) return "ArtifactIntegrityError"; - if (error instanceof ContainerPullError) return "ContainerPullError"; - return fallback; -}; +const rpcTag = (error: StackError): StackRpcError["tag"] => error._tag; /** Compose one owner process around the durable lifecycle controller and a runtime driver. */ export const makeSupervisor = ( @@ -1002,10 +945,8 @@ export const makeSupervisor = ( stopOperation(), continueShutdownAfterInterrupt, ); - const operation = (effect: Effect.Effect, fallback: StackRpcError["tag"]) => - effect.pipe( - Effect.mapError((error) => rpcError(rpcTag(error, fallback), stateErrorMessage(error))), - ); + const operation = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))); const destroyOperation = controller .destroy() .pipe(Effect.provideContext(options.context), Effect.andThen(Ref.set(phase, "stopped"))); @@ -1060,9 +1001,7 @@ export const makeSupervisor = ( const credentials: Effect.Effect = Effect.gen( function* () { const state = yield* read().pipe( - Effect.mapError((error) => - rpcError(rpcTag(error, "StackStateInvalidError"), stateErrorMessage(error)), - ), + Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), ); const actualPhase = yield* Ref.get(phase); const adoptedGeneration = yield* Ref.get(generation); @@ -1264,30 +1203,22 @@ export const makeSupervisor = ( }).pipe(Effect.provideContext(options.context)); const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ status: () => - status.pipe( - Effect.mapError((error) => rpcError("StackStateInvalidError", stateErrorMessage(error))), - ), + status.pipe(Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))), credentials: () => credentials, prepare: ({ config, capabilities }) => prepareOperation({ config, capabilities }).pipe( - Effect.mapError((error) => - rpcError(rpcTag(error, "StackPreparationError"), stateErrorMessage(error)), - ), + Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), ), - start: ({ config }: { readonly config?: StackConfig }) => - operation(start({ config }), "StackReconciliationError"), - restart: ({ config }: { readonly config?: StackConfig }) => - operation(restart({ config }), "StackReconciliationError"), - destroy: () => operation(destroy, "StackDestructionError"), + start: ({ config }: { readonly config?: StackConfig }) => operation(start({ config })), + restart: ({ config }: { readonly config?: StackConfig }) => operation(restart({ config })), + destroy: () => operation(destroy), logs: (logOptions: LogOptions) => logs(logOptions).pipe( - Stream.mapError((error) => - rpcError(rpcTag(error, "StackStateInvalidError"), stateErrorMessage(error)), - ), + Stream.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), ), watchStatus: () => watchStatus.pipe( - Stream.mapError((error) => rpcError("StackStateInvalidError", String(error))), + Stream.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), ), }); return { diff --git a/packages/stack/src/supervisor/reconciler.integration.test.ts b/packages/stack/src/supervisor/reconciler.integration.test.ts index d3620dbc18..c3f09d5e40 100644 --- a/packages/stack/src/supervisor/reconciler.integration.test.ts +++ b/packages/stack/src/supervisor/reconciler.integration.test.ts @@ -23,10 +23,10 @@ const workload = ( readiness: { mode: "tcp" }, restart, artifacts: { - native: { kind: "native", service: id, release: "test" }, - container: { kind: "container", service: id, image: `test/${id}` }, + native: { kind: "native", release: "test" }, + container: { kind: "container", image: `test/${id}` }, }, - selected: { kind: "native", service: id, release: "test" }, + selected: { kind: "native", release: "test" }, specHash: id, }); const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 1b64b87e47..466f4906b4 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -337,7 +337,6 @@ const makeFixture = ( path: "memory://logs", append: () => Effect.succeed(entry), read: () => Effect.succeed([entry]), - retained: () => Effect.succeed([entry]), stream: (options) => Stream.unwrap( Ref.update(logOptions, (current) => [...current, options]).pipe( From 559fa3ccdb5eb2a8946983a7840571ea2425c76c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 2 Sep 2026 21:33:17 +0200 Subject: [PATCH 107/174] feat(stack): complete managed runtime rewrite --- .gitignore | 1 + .gitmodules | 3 - .repos/process-compose | 1 - apps/cli/README.md | 2 +- apps/cli/package.json | 16 - .../db/schema/declarative/declarative.flow.ts | 4 +- .../declarative/declarative.former-default.ts | 2 +- .../legacy-pgdelta-declarative-shadow-prep.ts | 2 +- .../db/shared/legacy-pgdelta-engine.layer.ts | 4 +- .../legacy-pgdelta-engine.legacy.layer.ts | 2 +- .../shared/legacy-pgdelta-engine.service.ts | 2 +- .../db/shared/legacy-pgdelta-files.ts | 2 +- .../legacy-pgdelta-next-adapter.service.ts | 2 - .../db/shared/legacy-pgdelta.write.ts | 2 +- .../legacy/commands/domains/domains.errors.ts | 4 +- .../commands/encryption/encryption.errors.ts | 4 +- .../experimental/workers/workers.shared.ts | 2 +- .../commands/functions/serve/serve.handler.ts | 17 +- .../serve/serve.handler.unit.test.ts | 50 +- .../functions/serve/serve.integration.test.ts | 18 +- apps/cli/src/legacy/docs/legacy-docs-spec.ts | 4 +- .../shared/db-bootstrap/health-check.ts | 2 +- .../db-bootstrap/reset-local-database.ts | 2 +- .../shared/db-bootstrap/shadow-cache.ts | 2 +- .../shared/db-bootstrap/shadow-database.ts | 6 +- .../shared/db-bootstrap/start-database.ts | 2 +- .../db-bootstrap/start-local-database.ts | 4 +- .../shared/legacy-db-config.toml-read.ts | 2 +- apps/cli/src/legacy/shared/legacy-db-image.ts | 10 - .../legacy/shared/legacy-docker-remove-all.ts | 14 +- .../branches/switch/switch.handler.ts | 13 +- .../switch/switch.integration.test.ts | 20 +- .../commands/functions/dev/dev.e2e.test.ts | 69 +- .../functions-dev-runtime.integration.test.ts | 346 +++- .../functions/dev/functions-dev-runtime.ts | 232 +-- .../src/next/commands/login/login.handler.ts | 2 +- .../src/next/commands/logs/logs.command.ts | 11 +- .../src/next/commands/logs/logs.handler.ts | 85 +- .../commands/logs/logs.integration.test.ts | 100 +- .../next/commands/restart/restart.command.ts | 2 +- .../next/commands/restart/restart.handler.ts | 12 +- .../restart/restart.integration.test.ts | 15 +- apps/cli/src/next/commands/start/signal.ts | 17 - .../src/next/commands/start/start.command.ts | 19 +- .../commands/start/start.command.unit.test.ts | 2 +- .../src/next/commands/start/start.handler.ts | 24 +- .../commands/start/start.integration.test.ts | 57 +- .../next/commands/status/status.handler.ts | 66 +- .../status/status.integration.test.ts | 121 +- .../src/next/commands/stop/stop.handler.ts | 40 +- .../commands/stop/stop.integration.test.ts | 183 ++- .../src/next/config/managed-port-intents.ts | 5 - .../next/config/project-link-remote.layer.ts | 4 +- apps/cli/src/next/config/stack-config.ts | 133 +- .../src/next/config/stack-config.unit.test.ts | 63 +- apps/cli/src/next/main.ts | 7 +- .../functions/managed-functions-runtime.ts | 297 ++++ .../functions/serve-main-bundler.unit.test.ts | 1 + apps/cli/src/shared/functions/serve.ts | 1349 +-------------- .../shared/runtime/file-watcher.service.ts | 40 - apps/cli/src/shared/runtime/ink.layer.ts | 13 - apps/cli/src/shared/runtime/ink.service.ts | 15 - .../runtime/parcel-file-watcher.layer.ts | 95 -- .../runtime/parcel-watcher-wrapper.d.ts | 3 - .../src/shared/services/services.shared.ts | 2 +- .../shared/telemetry/error-actionability.ts | 51 - apps/cli/tests/helpers/mocks.ts | 42 - ...7-simplified-managed-stack-architecture.md | 166 +- ...26-08-28-stack-runtime-rewrite-progress.md | 1321 --------------- .../plans/2026-08-28-stack-runtime-rewrite.md | 744 --------- ...2026-08-28-stack-runtime-rewrite-design.md | 997 ------------ knip.json | 3 +- packages/stack/README.md | 23 +- packages/stack/package.json | 7 +- packages/stack/src/control/ControlServer.ts | 259 ++- .../stack/src/control/MaintenanceProtocol.ts | 16 +- .../control-transport.integration.test.ts | 387 ++--- .../stack/src/entrypoints/supervisor-node.ts | 12 +- .../stack/src/functions/FunctionDiscovery.ts | 286 ---- .../stack/src/functions/FunctionsBootstrap.ts | 32 +- packages/stack/src/functions/FunctionsRoot.ts | 293 ---- .../functions-bootstrap.integration.test.ts | 13 +- .../functions/functions.integration.test.ts | 274 ---- .../serve-main-resolver.integration.test.ts | 115 ++ .../src/functions/serve-main-resolver.ts | 48 +- packages/stack/src/gateway/Gateway.ts | 124 +- packages/stack/src/gateway/HttpGateway.ts | 29 +- packages/stack/src/gateway/TcpGateway.ts | 7 +- .../src/gateway/gateway.integration.test.ts | 263 +-- .../src/identity/identity.integration.test.ts | 3 - packages/stack/src/index.ts | 19 +- .../stack/src/internal/supervisor-process.ts | 12 + packages/stack/src/model/CapabilityModule.ts | 32 +- packages/stack/src/model/Compiler.ts | 48 +- packages/stack/src/model/DatabaseBootstrap.ts | 11 +- packages/stack/src/model/ExecutionPlan.ts | 43 +- packages/stack/src/model/WorkloadCatalog.ts | 2 +- .../stack/src/model/capabilities/analytics.ts | 2 +- packages/stack/src/model/capabilities/auth.ts | 6 +- .../stack/src/model/capabilities/functions.ts | 36 +- packages/stack/src/model/capabilities/mail.ts | 2 +- .../stack/src/model/capabilities/pooler.ts | 4 +- .../stack/src/model/capabilities/realtime.ts | 20 +- .../stack/src/model/capabilities/studio.ts | 4 +- .../src/model/catalog.integration.test.ts | 9 + .../src/model/compiler.integration.test.ts | 31 +- .../stack/src/preparation/ArtifactStore.ts | 196 ++- .../stack/src/preparation/RuntimeArtifacts.ts | 32 +- .../preparation/artifacts.integration.test.ts | 89 +- packages/stack/src/public/EffectStack.ts | 971 ++++++++--- packages/stack/src/public/Errors.ts | 47 +- packages/stack/src/public/PromiseStack.ts | 78 +- packages/stack/src/public/Status.ts | 13 +- packages/stack/src/public/Testing.ts | 72 +- .../public/effect-stack.integration.test.ts | 811 ++++++++- packages/stack/src/public/index.ts | 1 + .../src/public/promise.integration.test.ts | 24 +- .../src/public/testing.integration.test.ts | 10 +- .../stack/src/public/whole-stack.e2e.test.ts | 835 +++++++--- packages/stack/src/runtime/ContainerEngine.ts | 180 +- .../stack/src/runtime/ContainerRuntime.ts | 569 +++---- packages/stack/src/runtime/DockerEngine.ts | 253 +-- packages/stack/src/runtime/NativeProcess.ts | 8 +- packages/stack/src/runtime/NativeRuntime.ts | 75 +- packages/stack/src/runtime/PodmanEngine.ts | 235 +-- .../stack/src/runtime/ProductionRuntime.ts | 221 ++- packages/stack/src/runtime/RuntimeDriver.ts | 42 +- packages/stack/src/runtime/RuntimeEnvFile.ts | 34 +- .../stack/src/runtime/RuntimeInputOwner.ts | 177 +- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 164 +- .../container-runtime.integration.test.ts | 763 ++++----- ...base-bootstrap-catalog.integration.test.ts | 2 - .../native-runtime.integration.test.ts | 36 +- .../production-runtime.integration.test.ts | 585 ++++--- .../runtime-env-file.integration.test.ts | 20 +- .../runtime-input-owner.integration.test.ts | 199 +-- .../runtime-recovery.integration.test.ts | 481 ------ .../workload-runtime.integration.test.ts | 106 +- .../stack/src/state/MaterializedSettings.ts | 40 +- packages/stack/src/state/Ownership.ts | 428 ++++- packages/stack/src/state/Paths.ts | 4 - packages/stack/src/state/PortCoordinator.ts | 114 +- packages/stack/src/state/PortRegistry.ts | 26 +- packages/stack/src/state/SecretStore.ts | 2 +- packages/stack/src/state/StackState.ts | 5 - packages/stack/src/state/StackStateStore.ts | 152 +- .../src/state/ownership.integration.test.ts | 402 ++++- .../stack/src/state/ports.integration.test.ts | 313 ++-- .../src/state/secrets.integration.test.ts | 8 + .../src/state/state-store.integration.test.ts | 185 ++- packages/stack/src/supervisor/DesiredState.ts | 52 +- packages/stack/src/supervisor/HostListener.ts | 23 +- packages/stack/src/supervisor/Ingress.ts | 71 +- packages/stack/src/supervisor/Launcher.ts | 63 +- packages/stack/src/supervisor/Lifecycle.ts | 351 ++-- packages/stack/src/supervisor/LogStore.ts | 185 ++- packages/stack/src/supervisor/OwnerSession.ts | 51 - packages/stack/src/supervisor/Reconciler.ts | 135 +- .../src/supervisor/Reconciler.unit.test.ts | 27 +- packages/stack/src/supervisor/StatusHub.ts | 22 - .../stack/src/supervisor/StatusProjection.ts | 130 ++ packages/stack/src/supervisor/Supervisor.ts | 961 +++++------ .../supervisor/handles.integration.test.ts | 133 +- .../supervisor/ingress.integration.test.ts | 64 +- .../supervisor/lifecycle.integration.test.ts | 123 +- .../observability.integration.test.ts | 37 +- .../supervisor/reconciler.integration.test.ts | 164 +- .../supervisor/supervisor.integration.test.ts | 1447 ++++++++++------- pnpm-lock.yaml | 596 +------ pnpm-workspace.yaml | 26 +- 170 files changed, 9826 insertions(+), 13290 deletions(-) delete mode 160000 .repos/process-compose delete mode 100644 apps/cli/src/next/commands/start/signal.ts delete mode 100644 apps/cli/src/next/config/managed-port-intents.ts create mode 100644 apps/cli/src/shared/functions/managed-functions-runtime.ts delete mode 100644 apps/cli/src/shared/runtime/file-watcher.service.ts delete mode 100644 apps/cli/src/shared/runtime/ink.layer.ts delete mode 100644 apps/cli/src/shared/runtime/ink.service.ts delete mode 100644 apps/cli/src/shared/runtime/parcel-file-watcher.layer.ts delete mode 100644 apps/cli/src/shared/runtime/parcel-watcher-wrapper.d.ts delete mode 100644 docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md delete mode 100644 docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md delete mode 100644 docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md delete mode 100644 packages/stack/src/functions/FunctionDiscovery.ts delete mode 100644 packages/stack/src/functions/FunctionsRoot.ts delete mode 100644 packages/stack/src/functions/functions.integration.test.ts create mode 100644 packages/stack/src/internal/supervisor-process.ts delete mode 100644 packages/stack/src/runtime/runtime-recovery.integration.test.ts delete mode 100644 packages/stack/src/supervisor/OwnerSession.ts delete mode 100644 packages/stack/src/supervisor/StatusHub.ts create mode 100644 packages/stack/src/supervisor/StatusProjection.ts diff --git a/.gitignore b/.gitignore index 60cf25ba91..db42bafcef 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ packages/cli-*/bin/ # Turbo .turbo/ +apps/cli/.supabase/ diff --git a/.gitmodules b/.gitmodules index ec13d73342..084b300883 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,9 +13,6 @@ [submodule ".repos/cheffect"] path = .repos/cheffect url = https://github.com/tim-smart/cheffect.git -[submodule ".repos/process-compose"] - path = .repos/process-compose - url = https://github.com/F1bonacc1/process-compose.git [submodule ".repos/t3code"] path = .repos/t3code url = https://github.com/pingdotgg/t3code.git diff --git a/.repos/process-compose b/.repos/process-compose deleted file mode 160000 index a4038d6698..0000000000 --- a/.repos/process-compose +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a4038d669818c35fc68fc7fc240b39e371ce0e7a diff --git a/apps/cli/README.md b/apps/cli/README.md index 0f4745b3c5..cbb10bdee7 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -119,7 +119,7 @@ Important areas: - `src/next/commands/` for the next/V3 command tree - `src/legacy/commands/` for the legacy command tree - `src/shared/output/` for text / JSON / NDJSON output policies -- `src/shared/runtime/` for TTY, stdin, browser, Ink, and process-control services +- `src/shared/runtime/` for TTY, stdin, browser, and process-control services - `src/next/auth/` for login-related services The local stack commands use `@supabase/stack` for lifecycle, status, logs, and runtime operations. diff --git a/apps/cli/package.json b/apps/cli/package.json index e3961090e0..ea6da12e0c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -51,21 +51,11 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.234", "@anthropic-ai/sdk": "^0.117.1", "@clack/prompts": "^1.7.0", - "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", "@effect/sql-pg": "catalog:", "@effect/vitest": "catalog:", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "^1.3.0", - "@parcel/watcher": "^2.6.0", - "@parcel/watcher-darwin-arm64": "2.6.0", - "@parcel/watcher-darwin-x64": "2.6.0", - "@parcel/watcher-linux-arm64-glibc": "2.6.0", - "@parcel/watcher-linux-arm64-musl": "2.6.0", - "@parcel/watcher-linux-x64-glibc": "2.6.0", - "@parcel/watcher-linux-x64-musl": "2.6.0", - "@parcel/watcher-win32-arm64": "2.6.0", - "@parcel/watcher-win32-x64": "2.6.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.46", @@ -75,20 +65,14 @@ "@types/bun": "catalog:", "@types/pg": "^8.23.1", "@types/pg-copy-streams": "^1.2.5", - "@types/react": "^19.2.18", "@vercel/detect-agent": "^1.2.5", "@vitest/coverage-istanbul": "catalog:", "dotenv": "^17.4.2", "effect": "catalog:", - "esbuild": "^0.28.2", - "ink": "^7.1.1", - "ink-spinner": "^5.0.0", "pg": "^8.23.0", "pg-copy-streams": "^7.0.0", "posthog-node": "^5.49.1", "prettier": "3.9.6", - "react": "^19.2.8", - "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.9", "smol-toml": "^1.8.0", "tldts": "catalog:", diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts index bdf22189e6..0247a8f0eb 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts @@ -9,7 +9,7 @@ import type { LegacyPgDeltaRemovalSummary } from "../../shared/legacy-pgdelta-en /** Extensions that legacy pg-delta treated as part of its implicit Supabase baseline. */ const LEGACY_IMPLICIT_EXTENSIONS = ["pg_net", "pgcrypto", "uuid-ossp"] as const; -export type LegacyDeclarativeImplicitExtension = (typeof LEGACY_IMPLICIT_EXTENSIONS)[number]; +type LegacyDeclarativeImplicitExtension = (typeof LEGACY_IMPLICIT_EXTENSIONS)[number]; export interface LegacyDeclarativeLoadDiagnostic { readonly code: string; @@ -294,7 +294,7 @@ function schemaArguments(schema: ReadonlyArray, platform: LegacyShellPla .join(""); } -export const legacyFormatDeclarativeSyncCommand = ( +const legacyFormatDeclarativeSyncCommand = ( schema: ReadonlyArray, platform: LegacyShellPlatform, ): string => diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.former-default.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.former-default.ts index 0a9d17416d..3c79c3dca8 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.former-default.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.former-default.ts @@ -9,7 +9,7 @@ import { legacyWalkSqlFiles } from "../../../../shared/legacy-glob.ts"; /** The default declarative directory before it moved to `supabase/schemas`. */ const FORMER_DEFAULT_SEGMENTS = ["supabase", "database"] as const; -export const legacyFormerDeclarativeDefaultWarning = ( +const legacyFormerDeclarativeDefaultWarning = ( formerDirRel: string, defaultDirRel: string, ): string => diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.ts index 93b399af25..05493ac475 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-declarative-shadow-prep.ts @@ -6,7 +6,7 @@ export type LegacyDeclarativeShadowClient = { readonly query: (sql: string) => Promise<{ readonly rows: ReadonlyArray }>; }; -export interface LegacyDeclarativeShadowPrepResult { +interface LegacyDeclarativeShadowPrepResult { /** True only when prep dropped an installed image pgjwt to recreate pgcrypto. */ readonly restorePgjwt: boolean; } diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts index 9b7d34e85e..f2ef09bf20 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts @@ -54,7 +54,7 @@ export function legacyPgDeltaEngineSelectorLayer( } /** Resolves the rollout flag once when the command-scoped layer is constructed. */ -export const legacyPgDeltaEngineLayer = Layer.unwrap( +const legacyPgDeltaEngineLayer = Layer.unwrap( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -71,7 +71,7 @@ export const legacyPgDeltaEngineLayer = Layer.unwrap( }), ); -export const legacyPgDeltaCliSettingsRuntimeLayer = legacyCliSettingsLayer.pipe( +const legacyPgDeltaCliSettingsRuntimeLayer = legacyCliSettingsLayer.pipe( Layer.provide(legacyDebugLoggerLayer), ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts index a0878b34a8..9619601ca9 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts @@ -47,7 +47,7 @@ const mapError = (cause: { readonly message: string }) => * user believe an unsupported-object guard is active. Mirrors `db diff`'s * `warnPgSchemaDeprecated` line shape. */ -export const legacyStrictCoverageIgnoredWarning = `${legacyYellow( +const legacyStrictCoverageIgnoredWarning = `${legacyYellow( "WARNING:", )} "--strict-coverage" has no effect with the legacy pg-delta engine.`; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts index 837a057614..713410f3fc 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts @@ -69,7 +69,7 @@ export interface LegacyPgDeltaRemovalSummary { readonly extensionIntents: ReadonlyArray; } -export type LegacyPgDeltaHazardKind = +type LegacyPgDeltaHazardKind = | "data_loss" | "rewrite_risk" | "non_transactional" diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts index 91c98882d0..537fd09017 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts @@ -13,7 +13,7 @@ import type { const EXPORT_MANIFEST_FILE = ".pgdelta-export.json"; -export class LegacyPgDeltaFilesError extends Data.TaggedError("LegacyPgDeltaFilesError")<{ +class LegacyPgDeltaFilesError extends Data.TaggedError("LegacyPgDeltaFilesError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts index cce5e36e6a..73795f42b3 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts @@ -10,7 +10,6 @@ import type { LegacyMigrationTransactionMode } from "../../../shared/legacy-migr import type { LegacyPgDeltaErrorDiagnostic, LegacyPgDeltaExportManifest, - LegacyPgDeltaHazardKind, LegacyPgDeltaHazardReport, LegacyPgDeltaRemovalSummary, } from "./legacy-pgdelta-engine.service.ts"; @@ -48,7 +47,6 @@ export interface LegacyPgDeltaNextRenderedFile { readonly actionCount: number; } -export type LegacyPgDeltaNextHazardKind = LegacyPgDeltaHazardKind; export type LegacyPgDeltaNextHazardReport = LegacyPgDeltaHazardReport; export interface LegacyPgDeltaNextSqlFile { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts index 62912a0cd5..5e01ae6423 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts @@ -265,7 +265,7 @@ export const legacyDeclarativeSchemaWrittenLine = (dir: string): string => * files may be deleted. Name the survivors and give the one instruction that * actually produces a clean tree. */ -export const legacyPreservedUnmanagedDeclarativeFilesWarning = ( +const legacyPreservedUnmanagedDeclarativeFilesWarning = ( dir: string, files: ReadonlyArray, ): string => diff --git a/apps/cli/src/legacy/commands/domains/domains.errors.ts b/apps/cli/src/legacy/commands/domains/domains.errors.ts index 57d6086b89..96044a8942 100644 --- a/apps/cli/src/legacy/commands/domains/domains.errors.ts +++ b/apps/cli/src/legacy/commands/domains/domains.errors.ts @@ -12,7 +12,7 @@ import { * Transport-level failure talking to the Management API custom-hostname * endpoints. Message format: `failed to custom hostname: `. */ -export class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ +class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ readonly message: string; readonly decode?: boolean; }> { @@ -28,7 +28,7 @@ export class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNe * success (201 for create/reverify/activate, 200 for get/delete). Message * format: `unexpected hostname status : `. */ -export class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( +class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( "LegacyDomainsUnexpectedStatusError", )<{ readonly status: number; diff --git a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts index 7de88ad24a..68507b4dd6 100644 --- a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts +++ b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts @@ -12,7 +12,7 @@ import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; * Transport-level failure talking to the Management API pgsodium endpoints. * Message format: `failed to pgsodium config: `. */ -export class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ +class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ readonly message: string; readonly decode?: boolean; }> { @@ -28,7 +28,7 @@ export class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryp * (only `JSON200` is accepted). Message format: * `unexpected pgsodium config status : `. */ -export class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( +class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( "LegacyEncryptionUnexpectedStatusError", )<{ readonly status: number; diff --git a/apps/cli/src/legacy/commands/experimental/workers/workers.shared.ts b/apps/cli/src/legacy/commands/experimental/workers/workers.shared.ts index d4445a72ab..f018fe7626 100644 --- a/apps/cli/src/legacy/commands/experimental/workers/workers.shared.ts +++ b/apps/cli/src/legacy/commands/experimental/workers/workers.shared.ts @@ -120,7 +120,7 @@ export const legacyLoadWorkersProjectForReporting = Effect.fnUntraced(function* } satisfies LegacyWorkersProject; }); -export interface LegacyResolvedWorker { +interface LegacyResolvedWorker { readonly name: string; readonly entry: WorkerEntry | undefined; /** The worker's default directory, `supabase/workers//`. */ diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 81316463ff..69311c0ce6 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -1,26 +1,14 @@ import { Effect, Option } from "effect"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { - buildFunctionsServeInspectArgs, - resolveFunctionsServeInspectMode, - type FunctionsServeFlags, -} from "../../../../shared/functions/serve.ts"; +import type { FunctionsServeFlags } from "../../../../shared/functions/serve.ts"; import { serveManagedFunctions, type ServeManagedFunctionsOperations, -} from "../../../../next/commands/functions/dev/functions-dev-runtime.ts"; - -export type { - ManagedFunctionsStack, - ServeManagedFunctionsOperations, -} from "../../../../next/commands/functions/dev/functions-dev-runtime.ts"; +} from "../../../../shared/functions/managed-functions-runtime.ts"; export type LegacyFunctionsServeFlags = FunctionsServeFlags; -export const legacyResolveFunctionsServeInspectMode = resolveFunctionsServeInspectMode; -export const legacyBuildFunctionsServeInspectArgs = buildFunctionsServeInspectArgs; - export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function* ( flags: LegacyFunctionsServeFlags, operations?: ServeManagedFunctionsOperations, @@ -30,6 +18,7 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function yield* serveManagedFunctions( { projectRoot: cliSettings.workdir, + cwd: cliSettings.workdir, stackName: "default", envFile: Option.getOrUndefined(flags.envFile), noVerifyJwt: Option.getOrUndefined(flags.noVerifyJwt), diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.unit.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.unit.test.ts index 441513d221..45feb9eb30 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.unit.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.unit.test.ts @@ -1,60 +1,18 @@ -import { Option } from "effect"; import { describe, expect, it } from "vitest"; -import { - legacyBuildFunctionsServeInspectArgs, - legacyResolveFunctionsServeInspectMode, - type LegacyFunctionsServeFlags, -} from "./serve.handler.ts"; - -function baseFlags(): LegacyFunctionsServeFlags { - return { - noVerifyJwt: Option.none(), - envFile: Option.none(), - importMap: Option.none(), - inspect: false, - inspectMode: Option.none(), - inspectMain: false, - all: true, - }; -} +import { buildFunctionsServeInspectArgs } from "../../../../shared/functions/serve.ts"; describe("legacy functions serve inspect flags", () => { - it("treats --inspect as inspect-mode brk", () => { - expect(legacyResolveFunctionsServeInspectMode({ ...baseFlags(), inspect: true })).toBe("brk"); - }); - - it("uses the explicit inspect mode when set", () => { - expect( - legacyResolveFunctionsServeInspectMode({ - ...baseFlags(), - inspectMode: Option.some("wait"), - }), - ).toBe("wait"); - }); - - it("rejects setting both --inspect and --inspect-mode", () => { - expect(() => - legacyResolveFunctionsServeInspectMode({ - ...baseFlags(), - inspect: true, - inspectMode: Option.some("run"), - }), - ).toThrow( - "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", - ); - }); - it("rejects --inspect-main without an inspect mode", () => { - expect(() => legacyBuildFunctionsServeInspectArgs(undefined, true)).toThrow( + expect(() => buildFunctionsServeInspectArgs(undefined, true)).toThrow( "--inspect-main must be used together with one of these flags: [inspect inspect-mode]", ); }); it("builds the edge-runtime inspect flags for explicit modes", () => { - expect(legacyBuildFunctionsServeInspectArgs("wait", true)).toEqual([ + expect(buildFunctionsServeInspectArgs("wait", true)).toEqual([ "--inspect-wait=0.0.0.0:8083", "--inspect-main", ]); - expect(legacyBuildFunctionsServeInspectArgs("run", false)).toEqual(["--inspect=0.0.0.0:8083"]); + expect(buildFunctionsServeInspectArgs("run", false)).toEqual(["--inspect=0.0.0.0:8083"]); }); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 85533011f9..b238dc762b 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -10,11 +10,11 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import type { LegacyFunctionsServeFlags } from "./serve.handler.ts"; -import { - legacyFunctionsServe, - type ServeManagedFunctionsOperations, - type ManagedFunctionsStack, -} from "./serve.handler.ts"; +import { legacyFunctionsServe } from "./serve.handler.ts"; +import type { + ManagedFunctionsStack, + ServeManagedFunctionsOperations, +} from "../../../../shared/functions/managed-functions-runtime.ts"; const baseFlags = (): LegacyFunctionsServeFlags => ({ noVerifyJwt: Option.some(true), @@ -57,8 +57,6 @@ describe("legacy functions serve", () => { it.live("uses managed Functions with flags, gateway readiness, and stack logs", () => { let startedConfig: StackConfig | undefined; const stack: ManagedFunctionsStack = { - id: stackId, - status: () => Effect.succeed(status("running")), start: (options) => Effect.sync(() => { startedConfig = options?.config; @@ -73,7 +71,6 @@ describe("legacy functions serve", () => { stream: "stdout" as const, message: "ready", }), - close: () => Effect.void, }; const output = mockOutput({ interactive: false }); const manifest: FunctionsManifest = { @@ -87,9 +84,7 @@ describe("legacy functions serve", () => { }, }; const operations: ServeManagedFunctionsOperations = { - findStack: () => Effect.succeed(Option.none()), createStack: () => Effect.succeed(stack), - openStack: () => Effect.succeed(stack), loadConfig: () => Effect.succeed(Schema.decodeUnknownSync(CliConfigSchema)({})), loadManifest: () => Effect.succeed(manifest), }; @@ -109,9 +104,10 @@ describe("legacy functions serve", () => { functions !== undefined && "settings" in functions ? functions.settings : undefined; const hello = functionSettings?.functions?.hello; expect(functionSettings?.functions_root).toBe("supabase/functions"); + expect(functionSettings?.edge_runtime?.import_map_default).toBe("deno.json"); expect(functionSettings?.inspector).toEqual({ mode: "run", main: true }); expect(hello?.verify_jwt).toBe(false); - expect(hello?.import_map).toBe("supabase/functions/deno.json"); + expect(hello?.import_map).toBeUndefined(); expect(output.messages).toContainEqual( expect.objectContaining({ message: "http://127.0.0.1:54321/functions/v1" }), ); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.ts index 2e93b72b74..126bfe14ea 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.ts @@ -74,7 +74,7 @@ export interface LegacyDocsExample { readonly response?: string; } -export interface LegacyDocsAcceptedValue { +interface LegacyDocsAcceptedValue { readonly id: string; readonly name: string; readonly type: string; @@ -102,7 +102,7 @@ export interface LegacyDocsCommand { readonly flags: ReadonlyArray; } -export interface LegacyDocsInfo { +interface LegacyDocsInfo { readonly id: string; readonly version: string; readonly title: string; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts index f791a6f6fe..627e778964 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts @@ -69,7 +69,7 @@ export interface LegacyHealthCheckFailure { } /** Runtime-internal probe sentinel; exported only for the exhaustive actionability guard. */ -export class LegacyHealthCheckProbeError extends Data.TaggedError("LegacyHealthCheckProbeError")<{ +class LegacyHealthCheckProbeError extends Data.TaggedError("LegacyHealthCheckProbeError")<{ readonly failures: ReadonlyArray; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts index 7c07800c97..d547a92991 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts @@ -70,7 +70,7 @@ import { legacyRecreateLocalDatabase } from "./recreate-local-database.ts"; * actionability guard can inspect its declaration; runtime callers consume the * enclosing effect rather than importing this class. */ -export class LegacyResetLocalDbNotRunningError extends Data.TaggedError( +class LegacyResetLocalDbNotRunningError extends Data.TaggedError( "LegacyResetLocalDbNotRunningError", )<{ readonly message: string; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts index dde392250c..0521949b9f 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts @@ -120,7 +120,7 @@ const legacyShadowCacheUnavailable = ( // --------------------------------------------------------------------------- /** One of the three PG15+ one-shot migrate jobs, as the cache key sees it. */ -export interface LegacyShadowCacheServiceInput { +interface LegacyShadowCacheServiceInput { readonly enabled: boolean; /** Registry-resolved image; hashed only when {@link enabled}. */ readonly image: string; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts index 1d243bc4ec..90321358aa 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -169,7 +169,7 @@ export const LEGACY_SHADOW_CREATE_TEMPLATE_SQL = * (`apps/cli-go/internal/db/diff/diff.go:187,200`, `internal/migration/squash/squash.go:91`) * passes the same `10*time.Second` literal. */ -export const LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS = 10; +const LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS = 10; /** * Go's `NewBackoffPolicy(ctx, timeout)` (`apps/cli-go/internal/db/start/start.go:192-198`): a @@ -388,7 +388,7 @@ export interface LegacyShadowSourceResult { } /** Fields shared by `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput`/{@link LegacyPrepareRawShadowInput}. */ -export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseInput { +interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseInput { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; readonly hostname: string; @@ -842,7 +842,7 @@ export interface LegacyShadowBaselineState { } /** The baseline state every uncached caller passes: provision it, snapshot nothing. */ -export const LEGACY_SHADOW_BASELINE_COLD: LegacyShadowBaselineState = { +const LEGACY_SHADOW_BASELINE_COLD: LegacyShadowBaselineState = { baselinePresent: false, snapshotRequired: false, snapshotBaseline: Effect.void, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 3050238a8c..010025b22e 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -111,7 +111,7 @@ type Spawner = ChildProcessSpawner["Service"]; * Exported only so the exhaustive actionability guard can inspect its declaration; * runtime callers observe it through {@link LegacyStartDatabaseError}. */ -export class LegacyStartBackupVolumeExistsError extends Data.TaggedError( +class LegacyStartBackupVolumeExistsError extends Data.TaggedError( "LegacyStartBackupVolumeExistsError", )<{ readonly message: string; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts index 3a6d929adb..b78001a36f 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts @@ -99,9 +99,9 @@ function wrapDbConfigOverride( }); } -export type LegacyStartLocalDatabaseStatus = "already-running" | "started"; +type LegacyStartLocalDatabaseStatus = "already-running" | "started"; -export interface LegacyStartLocalDatabaseResult { +interface LegacyStartLocalDatabaseResult { readonly status: LegacyStartLocalDatabaseStatus; } diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 114bffc012..cf42b93051 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -307,7 +307,7 @@ function legacyResolveValidatedRemoteProjectId( * When a matched `[remotes.*]` block supplies any of these, the block value * must beat the matching env override. */ -export const LEGACY_ENV_OVERRIDABLE_KEYS = [ +const LEGACY_ENV_OVERRIDABLE_KEYS = [ // The matched `[remotes.]` block's own `project_id` field is what selected it in the // first place (`applyRemoteOverride` above matches on exactly this key) — same override-tier // reasoning as every other key in this array. NOT guaranteed present, though: a block can also diff --git a/apps/cli/src/legacy/shared/legacy-db-image.ts b/apps/cli/src/legacy/shared/legacy-db-image.ts index b2519c46c7..d938ef54cb 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.ts @@ -54,16 +54,6 @@ function compareSemver(a: string, b: string): number { return 0; } -export interface LegacyResolvedDbImage { - /** Pull/create reference — slim-translated when the flag is on and the pin is current. */ - readonly image: string; - /** - * Unprefixed docker.io / OrioleDB / 13–15 identity for version-compare. - * Never `ghcr.io/...` — {@link legacyPostgresImageVersionTag} splits on the first `:`. - */ - readonly configImage: string; -} - /** * Resolve the Postgres image for `majorVersion`, honoring the pinned version * written by `supabase start` to `supabase/.temp/postgres-version` (Go reads diff --git a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts index 6a083e78d6..16a344485e 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts @@ -27,9 +27,7 @@ type Spawner = ChildProcessSpawner["Service"]; * their string `_tag`, never by importing the classes themselves. The classes * are exported so the exhaustive telemetry guard can verify their declarations. */ -export class LegacyDockerRemoveAllListError extends Data.TaggedError( - "LegacyDockerRemoveAllListError", -)<{ +class LegacyDockerRemoveAllListError extends Data.TaggedError("LegacyDockerRemoveAllListError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -37,9 +35,7 @@ export class LegacyDockerRemoveAllListError extends Data.TaggedError( } } -export class LegacyDockerRemoveAllStopError extends Data.TaggedError( - "LegacyDockerRemoveAllStopError", -)<{ +class LegacyDockerRemoveAllStopError extends Data.TaggedError("LegacyDockerRemoveAllStopError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -47,7 +43,7 @@ export class LegacyDockerRemoveAllStopError extends Data.TaggedError( } } -export class LegacyDockerRemoveAllContainerPruneError extends Data.TaggedError( +class LegacyDockerRemoveAllContainerPruneError extends Data.TaggedError( "LegacyDockerRemoveAllContainerPruneError", )<{ readonly message: string; @@ -57,7 +53,7 @@ export class LegacyDockerRemoveAllContainerPruneError extends Data.TaggedError( } } -export class LegacyDockerRemoveAllVolumePruneError extends Data.TaggedError( +class LegacyDockerRemoveAllVolumePruneError extends Data.TaggedError( "LegacyDockerRemoveAllVolumePruneError", )<{ readonly message: string; @@ -67,7 +63,7 @@ export class LegacyDockerRemoveAllVolumePruneError extends Data.TaggedError( } } -export class LegacyDockerRemoveAllNetworkPruneError extends Data.TaggedError( +class LegacyDockerRemoveAllNetworkPruneError extends Data.TaggedError( "LegacyDockerRemoveAllNetworkPruneError", )<{ readonly message: string; diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index 3f0ff22764..84792d30f3 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -1,4 +1,4 @@ -import { findStack } from "@supabase/stack/effect"; +import { findStack, InvalidProjectRootError } from "@supabase/stack/effect"; import { Effect, Option } from "effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { CliProjectHome } from "../../../config/cli-project-home.service.ts"; @@ -93,7 +93,16 @@ export const switchBranch = Effect.fn("branches.switch")(function* ( // Branch switching updates the linked project state. A running local stack // remains untouched; users can restart it explicitly with `supabase restart`. - const descriptor = yield* operations.findStack({ projectRoot: cliProjectHome.projectRoot }); + const descriptor = yield* operations + .findStack({ projectRoot: cliProjectHome.projectRoot }) + // Branch switching is valid outside a local checkout. In that case there + // is simply no managed stack to remind the user about. + .pipe( + Effect.catchIf( + (error) => error instanceof InvalidProjectRootError, + () => Effect.succeed(Option.none()), + ), + ); if ( Option.isSome(descriptor) && descriptor.value.desiredLifecycle === "running" && diff --git a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts index a4c579eded..5eb7557bf6 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts @@ -11,7 +11,7 @@ import { mockProjectLinkState, } from "../../../../../tests/helpers/mocks.ts"; import { switchBranch } from "./switch.handler.ts"; -import { StackIdSchema } from "@supabase/stack/effect"; +import { InvalidProjectRootError, StackIdSchema } from "@supabase/stack/effect"; const MAIN = { id: "00000000-0000-4000-8000-000000000001", @@ -133,4 +133,22 @@ describe("branches switch handler", () => { ), ); }); + + it.live("switches branches when the command is outside a local checkout", () => { + const { out, layer } = setup(); + const operations = { + findStack: () => + Effect.fail(new InvalidProjectRootError({ message: "not a local checkout" })), + }; + return switchBranch({ name: Option.some("dev") }, operations).pipe( + Effect.provide(layer), + Effect.tap(() => + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "outro", message: "Switched to branch 'dev'." }), + ); + }), + ), + ); + }); }); diff --git a/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts index 4a7587062d..94828978b7 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -11,16 +11,15 @@ import { import { cleanupRegisteredStackProjects } from "../../../../../tests/helpers/stack-e2e-cleanup.ts"; const FUNCTIONS_DEV_STARTUP_TIMEOUT_MS = 180_000; -const FUNCTIONS_URL_PATTERN = /Functions URL:\s+(https?:\/\/[^\s/]+\/functions\/v1)/; +const FUNCTIONS_URL_PATTERN = /(https?:\/\/[^\s/]+\/functions\/v1)/; const FUNCTIONS_DEV_STEP_TIMEOUT_MS = 30_000; const FUNCTIONS_DEV_CLEANUP_TIMEOUT_MS = 30_000; const FUNCTIONS_DEV_TEST_TIMEOUT_MS = FUNCTIONS_DEV_STARTUP_TIMEOUT_MS + - FUNCTIONS_DEV_STEP_TIMEOUT_MS * 7 + + FUNCTIONS_DEV_STEP_TIMEOUT_MS * 4 + FUNCTIONS_DEV_CLEANUP_TIMEOUT_MS; const FUNCTION_RESPONSE_ATTEMPT_TIMEOUT_MS = 5_000; const FUNCTION_RESPONSE_RETRY_BACKOFF_MS = 250; -const FUNCTION_FILES_RESTART_PATTERN = /Function files changed\. Restarting edge-runtime\./; type SpawnedSupabase = ReturnType; @@ -67,7 +66,7 @@ describe("supabase functions dev (e2e)", () => { afterEach(cleanupRegisteredStackProjects); test( - "serves a function created while running and applies config and source changes", + "serves a function created while running and applies source changes", { timeout: FUNCTIONS_DEV_TEST_TIMEOUT_MS }, async () => { const home = makeTempHome(); @@ -77,13 +76,17 @@ describe("supabase functions dev (e2e)", () => { await mkdir(join(project.dir, "supabase"), { recursive: true }); await writeFile( join(project.dir, "supabase", "config.toml"), - 'project_id = "functions-dev-e2e"\n', + `project_id = "functions-dev-e2e" + +[functions.hello-world] +verify_jwt = false +`, ); const functionPath = join(project.dir, "supabase", "functions", "hello-world", "index.ts"); let devProc: SpawnedSupabase | undefined; try { - devProc = spawnSupabase(["functions", "dev"], { + devProc = spawnSupabase(["functions", "serve"], { cwd: project.dir, home: home.dir, cleanupProcessGroupOnClose: false, @@ -91,7 +94,7 @@ describe("supabase functions dev (e2e)", () => { }); await devProc.waitForOutput( - /Edge Functions dev server is running\./, + /Functions stack is running\./, FUNCTIONS_DEV_STARTUP_TIMEOUT_MS, ); await devProc.waitForOutput(FUNCTIONS_URL_PATTERN, FUNCTIONS_DEV_STARTUP_TIMEOUT_MS); @@ -103,54 +106,12 @@ describe("supabase functions dev (e2e)", () => { } const functionUrl = `${functionUrlMatch[1]}/hello-world`; - const functionOffset = devProc.stdout().length; const newResult = await runSupabase(["functions", "new", "hello-world"], { cwd: project.dir, home: home.dir, exitTimeoutMs: FUNCTIONS_DEV_STEP_TIMEOUT_MS, }); expect(newResult.exitCode).toBe(0); - // The dev server prints its startup lines before the file watcher has - // subscribed and exposes no watcher-ready signal, so a write that lands - // in that window is silently lost. Re-touch the created function until - // the dev server reports it, bounded by the step deadline. - const functionRestartDeadline = Date.now() + FUNCTIONS_DEV_STEP_TIMEOUT_MS; - for (;;) { - try { - await devProc.waitForOutput(FUNCTION_FILES_RESTART_PATTERN, 5_000, functionOffset); - break; - } catch (error) { - if ( - Date.now() >= functionRestartDeadline || - (error instanceof Error && error.message.startsWith("Process exited")) - ) { - throw error; - } - await writeFile(functionPath, await readFile(functionPath, "utf8")); - } - } - - await assertFunctionResponse(functionUrl, {}, (response, body) => { - expect(response.status).toBe(401); - expect(body).toContain("Missing authorization header"); - }); - - const configOffset = devProc.stdout().length; - const configRestart = devProc.waitForOutput( - FUNCTION_FILES_RESTART_PATTERN, - FUNCTIONS_DEV_STEP_TIMEOUT_MS, - configOffset, - ); - await writeFile( - join(project.dir, "supabase", "config.toml"), - `project_id = "functions-dev-e2e" - -[functions.hello-world] -verify_jwt = false -`, - ); - await configRestart; - await assertFunctionResponse( functionUrl, { @@ -164,12 +125,6 @@ verify_jwt = false }, ); - const sourceOffset = devProc.stdout().length; - const sourceRestart = devProc.waitForOutput( - FUNCTION_FILES_RESTART_PATTERN, - FUNCTIONS_DEV_STEP_TIMEOUT_MS, - sourceOffset, - ); await writeFile( functionPath, `Deno.serve(() => { @@ -179,8 +134,6 @@ verify_jwt = false }); `, ); - await sourceRestart; - await assertFunctionResponse( functionUrl, { diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts index ba01aa8b39..90bc21b057 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts @@ -2,15 +2,33 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { CliConfigSchema } from "@supabase/config"; import type { FunctionsManifest } from "@supabase/config"; -import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, + Schema, + Stream, +} from "effect"; import type { StackConfig, StackId, StackStatus } from "@supabase/stack/effect"; -import { CAPABILITY_NAMES, StackIdSchema } from "@supabase/stack/effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + CAPABILITY_NAMES, + InvalidStackConfigError, + StackIdSchema, + StackMustBeStoppedError, + StackUpgradeRequiredError, +} from "@supabase/stack/effect"; +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { serveManagedFunctions, type ManagedFunctionsStack, type ServeManagedFunctionsOperations, -} from "./functions-dev-runtime.ts"; +} from "../../../../shared/functions/managed-functions-runtime.ts"; +import { toStartStackConfig } from "../../../config/stack-config.ts"; const stackId: StackId = StackIdSchema.make( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", @@ -40,11 +58,47 @@ const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ }); describe("managed Functions serving", () => { + it.live("keeps no-flag serve input identical to start input for live functions", () => { + const output = mockOutput({ interactive: false }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-functions-parity-" }); + const projectRoot = path.join(root, "project"); + const functionsRoot = path.join(projectRoot, "supabase", "functions", "hello"); + yield* fs.makeDirectory(functionsRoot, { recursive: true }); + yield* fs.writeFileString(path.join(functionsRoot, "index.ts"), "export default () => {};\n"); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "functions", ".env"), + "GLOBAL_TOKEN=global-secret\n", + ); + + const cliConfig = Schema.decodeUnknownSync(CliConfigSchema)({}); + const translated = yield* toStartStackConfig(cliConfig, []); + let servedConfig: StackConfig | undefined; + const stack: ManagedFunctionsStack = { + start: (options) => + Effect.sync(() => { + servedConfig = options?.config; + return status("running"); + }), + watchStatus: () => Stream.empty, + logs: () => Stream.empty, + }; + const operations: ServeManagedFunctionsOperations = { + createStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(cliConfig), + }; + + yield* serveManagedFunctions({ projectRoot, stackName: "default" }, operations); + expect(servedConfig).toBeDefined(); + expect(servedConfig).toEqual(translated); + }).pipe(Effect.provide(Layer.mergeAll(output.layer, BunServices.layer))); + }); + it.live("maps flags and live manifest, waits for gateway readiness, then streams logs", () => { let startedConfig: StackConfig | undefined; const stack: ManagedFunctionsStack = { - id: stackId, - status: () => Effect.succeed(status("running")), start: (options) => Effect.sync(() => { startedConfig = options?.config; @@ -61,9 +115,9 @@ describe("managed Functions serving", () => { message: "ready", }, ]), - close: () => Effect.void, }; const output = mockOutput({ interactive: false }); + let envPath: string | undefined; const manifest: FunctionsManifest = { hello: { enabled: true, @@ -75,9 +129,7 @@ describe("managed Functions serving", () => { }, }; const operations: ServeManagedFunctionsOperations = { - findStack: () => Effect.succeed(Option.none()), createStack: () => Effect.succeed(stack), - openStack: () => Effect.succeed(stack), loadConfig: () => Effect.succeed( Schema.decodeUnknownSync(CliConfigSchema)({ @@ -85,11 +137,16 @@ describe("managed Functions serving", () => { }), ), loadManifest: () => Effect.succeed(manifest), - readEnvFile: () => Effect.succeed({ ENV_TOKEN: "env-secret" }), + readEnvFile: (pathname) => + Effect.sync(() => { + envPath = pathname; + return { ENV_TOKEN: "env-secret" }; + }), }; return serveManagedFunctions( { projectRoot: "/tmp/project", + cwd: "/tmp/caller", stackName: "default", envFile: "flags.env", noVerifyJwt: true, @@ -99,7 +156,9 @@ describe("managed Functions serving", () => { }, operations, ).pipe( - Effect.provide(Layer.mergeAll(output.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(output.layer, BunServices.layer, mockRuntimeInfo({ cwd: "/tmp/caller" })), + ), Effect.tap(() => Effect.sync(() => { const functions = startedConfig?.capabilities?.functions; @@ -107,17 +166,23 @@ describe("managed Functions serving", () => { functions !== undefined && "settings" in functions ? functions.settings : undefined; const hello = functionSettings?.functions?.hello; expect(functionSettings?.functions_root).toBe("supabase/functions"); + expect(functionSettings?.edge_runtime).toMatchObject({ + verify_jwt_default: false, + import_map_default: "hello/custom-deno.json", + }); expect(functionSettings?.inspector).toEqual({ mode: "wait", main: true }); expect(hello?.verify_jwt).toBe(false); expect(hello?.import_map).toBe("custom-deno.json"); - expect(hello?.entrypoint).toBe("index.ts"); - expect(hello?.static_files).toEqual(["public/*.html"]); - const configToken = hello?.env?.CONFIG_TOKEN; - const envToken = hello?.env?.ENV_TOKEN; - expect(configToken).toBeDefined(); - expect(envToken).toBeDefined(); - if (configToken !== undefined) expect(Redacted.value(configToken)).toBe("config-secret"); - if (envToken !== undefined) expect(Redacted.value(envToken)).toBe("env-secret"); + // Manifest discovery is live-runtime behavior. The stack input only contains + // values derived from explicit flags; entrypoint/static files default to index.ts + // and the per-function environment is not copied from the manifest. + expect(hello?.entrypoint).toBeUndefined(); + expect(hello?.static_files).toBeUndefined(); + expect(hello?.env).toBeUndefined(); + const globalToken = functionSettings?.edge_runtime?.secrets?.ENV_TOKEN; + expect(globalToken).toBeDefined(); + if (globalToken !== undefined) expect(Redacted.value(globalToken)).toBe("env-secret"); + expect(envPath).toBe("/tmp/caller/flags.env"); expect(startedConfig?.listeners?.functionsInspector).toEqual({ port: 8090 }); expect(output.messages).toContainEqual( expect.objectContaining({ message: "http://127.0.0.1:54321/functions/v1" }), @@ -127,4 +192,247 @@ describe("managed Functions serving", () => { ), ); }); + + it.live("persists a shared root import map for functions discovered later", () => { + let startedConfig: StackConfig | undefined; + const output = mockOutput({ interactive: false }); + const stack: ManagedFunctionsStack = { + start: (options) => + Effect.sync(() => { + startedConfig = options?.config; + return status("running"); + }), + watchStatus: () => Stream.empty, + logs: () => Stream.empty, + }; + const operations: ServeManagedFunctionsOperations = { + createStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(undefined), + loadManifest: () => + Effect.succeed({ + hello: { + enabled: true, + verify_jwt: true, + import_map: "", + entrypoint: "./functions/hello/index.ts", + static_files: [], + env: {}, + }, + }), + }; + return serveManagedFunctions( + { + projectRoot: "/tmp/project", + stackName: "default", + importMap: "./functions/shared-deno.json", + }, + operations, + ).pipe( + Effect.provide(Layer.mergeAll(output.layer, BunServices.layer)), + Effect.tap(() => + Effect.sync(() => { + const functions = startedConfig?.capabilities?.functions; + const settings = + functions !== undefined && "settings" in functions ? functions.settings : undefined; + expect(settings?.edge_runtime?.import_map_default).toBe("shared-deno.json"); + // The global option is rooted at functions_root; no slug snapshot should + // shadow it for the current or newly discovered functions. + expect(settings?.functions?.hello?.import_map).toBeUndefined(); + }), + ), + ); + }); + + it.live("validates inspector flags before acquiring a managed stack", () => { + const output = mockOutput({ interactive: false }); + let acquired = false; + const operations: ServeManagedFunctionsOperations = { + createStack: () => + Effect.gen(function* () { + acquired = true; + return yield* Effect.fail(new Error("createStack should not be called")); + }), + loadConfig: () => Effect.succeed(undefined), + }; + return serveManagedFunctions( + { + projectRoot: "/tmp/project", + stackName: "default", + inspect: true, + inspectMode: "wait", + }, + operations, + ).pipe( + Effect.provide(Layer.mergeAll(output.layer, BunServices.layer)), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(acquired).toBe(false); + }), + ), + ); + }); + + it.live("fails before stack acquisition when Functions are disabled", () => { + const output = mockOutput({ interactive: false }); + let acquired = false; + const operations: ServeManagedFunctionsOperations = { + createStack: () => + Effect.gen(function* () { + acquired = true; + return yield* Effect.fail(new Error("createStack should not be called")); + }), + loadConfig: () => + Effect.succeed( + Schema.decodeUnknownSync(CliConfigSchema)({ + edge_runtime: { enabled: false }, + }), + ), + }; + return serveManagedFunctions( + { projectRoot: "/tmp/project", stackName: "default" }, + operations, + ).pipe( + Effect.provide(Layer.mergeAll(output.layer, BunServices.layer)), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(acquired).toBe(false); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(InvalidStackConfigError); + expect(error.value).toMatchObject({ + message: expect.stringContaining("Functions are disabled in supabase/config.toml"), + }); + } + } + }), + ), + ); + }); + + it.live("fails before stack acquisition when an explicit env file is missing", () => { + const output = mockOutput({ interactive: false }); + let acquired = false; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-functions-env-" }); + const operations: ServeManagedFunctionsOperations = { + createStack: () => + Effect.gen(function* () { + acquired = true; + return yield* Effect.fail(new Error("createStack should not be called")); + }), + loadConfig: () => Effect.succeed(undefined), + }; + const exit = yield* serveManagedFunctions( + { projectRoot, stackName: "default", envFile: path.join(projectRoot, "missing.env") }, + operations, + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(acquired).toBe(false); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toMatchObject({ + message: expect.stringContaining("Functions env file was not found"), + }); + expect(error.value).toBeInstanceOf(InvalidStackConfigError); + } + } + }).pipe(Effect.provide(Layer.mergeAll(output.layer, BunServices.layer))); + }); + + it.live("suggests restart when explicit serving inputs change a running stack", () => { + const output = mockOutput({ interactive: false }); + const stack: ManagedFunctionsStack = { + start: () => + Effect.fail( + new StackMustBeStoppedError({ + message: "Running stack input changed", + }), + ), + watchStatus: () => Stream.empty, + logs: () => Stream.empty, + }; + const operations: ServeManagedFunctionsOperations = { + createStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(undefined), + readEnvFile: () => Effect.succeed({}), + }; + return serveManagedFunctions( + { + projectRoot: "/tmp/project", + stackName: "default", + envFile: "flags.env", + }, + operations, + ).pipe( + Effect.provide(Layer.mergeAll(output.layer, BunServices.layer)), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackMustBeStoppedError); + expect(error.value).toMatchObject({ + message: expect.stringContaining( + "run supabase restart to apply stopped-time changes", + ), + }); + } + } + }), + ), + ); + }); + + it.live("preserves upgrade errors while suggesting an owner restart", () => { + const output = mockOutput({ interactive: false }); + const stack: ManagedFunctionsStack = { + start: () => + Effect.fail( + new StackUpgradeRequiredError({ + message: "Stack owner release is outdated", + }), + ), + watchStatus: () => Stream.empty, + logs: () => Stream.empty, + }; + const operations: ServeManagedFunctionsOperations = { + createStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(undefined), + }; + return serveManagedFunctions( + { projectRoot: "/tmp/project", stackName: "default" }, + operations, + ).pipe( + Effect.provide(Layer.mergeAll(output.layer, BunServices.layer)), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackUpgradeRequiredError); + expect(error.value).toMatchObject({ + message: expect.stringContaining("run supabase restart to upgrade the stack owner"), + }); + } + } + }), + ), + ); + }); }); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 1e02babc77..4fda6b066f 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -1,146 +1,15 @@ -import { inferFunctionsManifest, loadCliConfig } from "@supabase/config/effect"; -import { CliConfigSchema } from "@supabase/config"; -import type { CliConfig, FunctionsManifest } from "@supabase/config"; -import { Crypto, FileSystem, Effect, Option, Path, Redacted, Scope, Schema, Stream } from "effect"; -import { parse as parseDotenv } from "dotenv"; -import { resolve } from "node:path"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { - createStack, - findStack, - openStack, - type EffectStack, - type FindStackOptions, - type CreateStackOptions, -} from "@supabase/stack/effect"; -import type { StackDescriptor, StackStatus, StackId } from "@supabase/stack/effect"; +import { Effect, Option } from "effect"; import { CliProjectHome } from "../../../config/cli-project-home.service.ts"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { toStartStackConfig } from "../../../config/stack-config.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import type { FunctionsDevFlags } from "./dev.command.ts"; - -/** The narrow stack surface owned by the managed Functions command. */ -export type ManagedFunctionsStack = Pick< - EffectStack, - "id" | "start" | "status" | "watchStatus" | "logs" | "close" ->; - -type ManagedFunctionsRuntime = - | Scope.Scope - | FileSystem.FileSystem - | Path.Path - | Crypto.Crypto - | ChildProcessSpawner.ChildProcessSpawner; - -export interface ServeManagedFunctionsOptions { - readonly projectRoot: string; - readonly stackName: string; - readonly envFile?: string; - readonly noVerifyJwt?: boolean; - readonly importMap?: string; - readonly inspect?: boolean; - readonly inspectMode?: "run" | "brk" | "wait"; - readonly inspectMain?: boolean; -} - -export interface ServeManagedFunctionsOperations { - readonly findStack: ( - options: FindStackOptions, - ) => Effect.Effect, unknown, ManagedFunctionsRuntime>; - readonly createStack: ( - options: CreateStackOptions, - ) => Effect.Effect; - readonly openStack: ( - id: StackId, - ) => Effect.Effect; - readonly loadConfig: ( - cwd: string, - ) => Effect.Effect; - readonly loadManifest?: ( - cwd: string, - config: CliConfig, - ) => Effect.Effect; - readonly readEnvFile?: ( - pathname: string, - ) => Effect.Effect>, unknown, FileSystem.FileSystem>; -} - -const defaultOperations: Required = { - findStack, - createStack, - openStack, - loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), - loadManifest: (cwd, config) => inferFunctionsManifest({ cwd, config }), - readEnvFile: (pathname) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const contents = yield* fs.readFileString(pathname); - return yield* Effect.try({ - try: () => parseDotenv(contents), - catch: (error) => error, - }); - }), -}; - -const capabilityReady = (status: StackStatus): boolean => - status.capabilities.some( - (capability) => - capability.name === "functions" && - (capability.state === "ready" || capability.state === "dormant"), - ); - -const servingReady = (status: StackStatus): boolean => - status.lifecycle === "running" && status.endpoints.api !== undefined && capabilityReady(status); - -const statusAfterReadiness = Effect.fnUntraced(function* ( - stack: ManagedFunctionsStack, - initial: StackStatus, -) { - if (servingReady(initial)) return initial; - const next = yield* stack.watchStatus().pipe(Stream.filter(servingReady), Stream.runHead); - if (Option.isNone(next)) { - return yield* Effect.fail(new Error("Managed Functions stack did not reach gateway readiness")); - } - return next.value; -}); - -/** Converts a manifest path (relative to `supabase`) to the function directory's path. */ -function relativeFunctionPath(slug: string, pathname: string): string; -function relativeFunctionPath(slug: string, pathname: undefined): undefined; -function relativeFunctionPath(slug: string, pathname: string | undefined): string | undefined { - if (pathname === undefined) return undefined; - const prefix = `./functions/${slug}/`; - return pathname.startsWith(prefix) ? pathname.slice(prefix.length) : pathname; -} - -const toFunctionOverrides = ( - manifest: FunctionsManifest, - options: ServeManagedFunctionsOptions, - envFile: Readonly>, -) => - Object.fromEntries( - Object.entries(manifest).map(([slug, config]) => [ - slug, - { - enabled: config.enabled, - verify_jwt: options.noVerifyJwt === true ? false : config.verify_jwt, - import_map: relativeFunctionPath(slug, options.importMap ?? config.import_map), - entrypoint: relativeFunctionPath(slug, config.entrypoint), - static_files: config.static_files.map((pathname) => relativeFunctionPath(slug, pathname)), - env: Object.fromEntries( - Object.entries({ ...config.env, ...envFile }).map(([name, value]) => [ - name, - Redacted.make(value), - ]), - ), - }, - ]), - ); +import { serveManagedFunctions } from "../../../../shared/functions/managed-functions-runtime.ts"; export const runFunctionsDevRuntime = Effect.fnUntraced(function* (flags: FunctionsDevFlags) { + const runtimeInfo = yield* RuntimeInfo; return yield* serveManagedFunctions({ projectRoot: (yield* CliProjectHome).projectRoot, stackName: flags.stack, + cwd: runtimeInfo.cwd, envFile: Option.getOrUndefined(flags.envFile), noVerifyJwt: flags.noVerifyJwt, importMap: Option.getOrUndefined(flags.importMap), @@ -149,94 +18,3 @@ export const runFunctionsDevRuntime = Effect.fnUntraced(function* (flags: Functi inspectMain: flags.inspectMain, }); }); - -/** Serves every local Function through the stack-owned Edge Runtime. */ -export const serveManagedFunctions = Effect.fnUntraced(function* ( - options: ServeManagedFunctionsOptions, - operations: ServeManagedFunctionsOperations = defaultOperations, -) { - const output = yield* Output; - yield* Effect.scoped( - Effect.gen(function* () { - const descriptor = yield* operations.findStack({ - projectRoot: options.projectRoot, - name: options.stackName, - }); - const stack = Option.isNone(descriptor) - ? yield* operations.createStack({ - projectRoot: options.projectRoot, - name: options.stackName, - runtime: { kind: "container" }, - }) - : yield* operations.openStack(descriptor.value.id); - const loadedConfig = yield* operations.loadConfig(options.projectRoot); - const config = loadedConfig ?? Schema.decodeUnknownSync(CliConfigSchema)({}); - const manifest = yield* (operations.loadManifest ?? defaultOperations.loadManifest)( - options.projectRoot, - config, - ); - const envFile = - options.envFile === undefined - ? {} - : yield* (operations.readEnvFile ?? defaultOperations.readEnvFile)( - resolve(options.projectRoot, options.envFile), - ); - const translated = toStartStackConfig(loadedConfig, [], "docker"); - if (options.inspect && options.inspectMode !== undefined) { - return yield* Effect.fail( - new Error( - "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", - ), - ); - } - const inspectorMode = options.inspectMode ?? (options.inspect ? "brk" : undefined); - if (options.inspectMain && inspectorMode === undefined) { - return yield* Effect.fail( - new Error("--inspect-main must be used together with --inspect or --inspect-mode"), - ); - } - const functionsCapability = translated.capabilities?.functions; - const existingSettings = - functionsCapability !== undefined && "settings" in functionsCapability - ? functionsCapability.settings - : undefined; - const functionsConfig = { - ...(functionsCapability !== undefined ? functionsCapability : {}), - enabled: true, - settings: { - ...existingSettings, - functions_root: "supabase/functions", - ...(inspectorMode === undefined - ? {} - : { inspector: { mode: inspectorMode, main: options.inspectMain === true } }), - functions: toFunctionOverrides(manifest, options, envFile), - }, - }; - const inspectorPort = loadedConfig?.edge_runtime?.inspector_port ?? 8083; - const status = yield* stack.start({ - config: { - ...translated, - capabilities: { - ...translated.capabilities, - functions: functionsConfig, - }, - listeners: { - ...translated.listeners, - ...(inspectorMode === undefined ? {} : { functionsInspector: { port: inspectorPort } }), - }, - }, - }); - const ready = yield* statusAfterReadiness(stack, status); - const apiUrl = ready.endpoints.api?.url; - yield* output.success(`Functions stack is ${ready.lifecycle}.`, { - stack: options.stackName, - functions_root: "supabase/functions", - lifecycle: ready.lifecycle, - }); - if (apiUrl !== undefined) yield* output.info(`${apiUrl}/functions/v1`); - yield* stack - .logs({ capabilities: ["functions"], follow: true }) - .pipe(Stream.runForEach((entry) => output.info(`[${entry.source}] ${entry.message}`))); - }), - ); -}); diff --git a/apps/cli/src/next/commands/login/login.handler.ts b/apps/cli/src/next/commands/login/login.handler.ts index 35b368a1de..d750093f11 100644 --- a/apps/cli/src/next/commands/login/login.handler.ts +++ b/apps/cli/src/next/commands/login/login.handler.ts @@ -28,7 +28,7 @@ import type { NonInteractiveError } from "../../../shared/output/errors.ts"; import { LoginFailedError, NoTtyError } from "./login.errors.ts"; import type { LoginFlags } from "./login.command.ts"; -export class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ +class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ cause: ApiError; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/next/commands/logs/logs.command.ts b/apps/cli/src/next/commands/logs/logs.command.ts index e7f77ab8aa..1486fb6fa4 100644 --- a/apps/cli/src/next/commands/logs/logs.command.ts +++ b/apps/cli/src/next/commands/logs/logs.command.ts @@ -1,4 +1,5 @@ import { DEFAULT_MANAGED_STACK_NAME } from "../../../shared/stack-constants.ts"; +import { CAPABILITY_NAMES } from "@supabase/stack/effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -21,12 +22,12 @@ const flags = { ), Flag.withDefault(100), ), - service: Flag.string("service").pipe( + service: Flag.choice("service", CAPABILITY_NAMES).pipe( Flag.atMost(4), Flag.withDescription( - "Filter by service name. Repeat the flag for multiple services (for example: --service postgres --service auth)", + "Filter by service name. Repeat the flag for multiple services (for example: --service database --service auth)", ), - Flag.withDefault([] as ReadonlyArray), + Flag.withDefault([]), ), noFollow: Flag.boolean("no-follow").pipe( Flag.withDescription("Print buffered history only and exit without following live logs."), @@ -49,11 +50,11 @@ export const logsCommand = Command.make("logs", flags).pipe( description: "Print recent logs across all services, then continue following live output", }, { - command: "supabase logs --service postgres --no-follow", + command: "supabase logs --service database --no-follow", description: "Print a recent Postgres-only snapshot and exit", }, { - command: "supabase logs --service postgres --service auth --tail 20", + command: "supabase logs --service database --service auth --tail 20", description: "Focus on a small recent backlog for two services, then follow live logs", }, ]), diff --git a/apps/cli/src/next/commands/logs/logs.handler.ts b/apps/cli/src/next/commands/logs/logs.handler.ts index 005314df16..1b53bcf9d6 100644 --- a/apps/cli/src/next/commands/logs/logs.handler.ts +++ b/apps/cli/src/next/commands/logs/logs.handler.ts @@ -1,11 +1,40 @@ -import { Effect, Option, Stream } from "effect"; -import { findStack, openStack } from "@supabase/stack/effect"; +import { Crypto, Effect, FileSystem, Option, Path, Scope, Stream } from "effect"; +import { + findStack, + openStack, + type CapabilityName, + type EffectStack, + type FindStackOptions, + type StackDescriptor, + type StackId, +} from "@supabase/stack/effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import type { LogsFlags } from "./logs.command.ts"; import { UnsupportedLogsOutputFormatError } from "./logs.errors.ts"; -export const logs = Effect.fnUntraced(function* (flags: LogsFlags) { +type LogsRuntime = + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner; + +type LogsStack = Pick; +export interface LogsOperations { + readonly findStack: ( + options: FindStackOptions, + ) => Effect.Effect, unknown, LogsRuntime>; + readonly openStack: (id: StackId) => Effect.Effect; +} + +const defaultOperations: LogsOperations = { findStack, openStack }; + +export const logs = Effect.fnUntraced(function* ( + flags: LogsFlags, + operations: LogsOperations = defaultOperations, +) { const output = yield* Output; const project = yield* CliProjectHome; yield* output.intro("Show local Supabase logs"); @@ -15,7 +44,7 @@ export const logs = Effect.fnUntraced(function* (flags: LogsFlags) { suggestion: "Use --output-format stream-json for machine-readable streaming logs.", }); } - const descriptorOption = yield* findStack({ + const descriptorOption = yield* operations.findStack({ projectRoot: project.projectRoot, name: flags.stack, }); @@ -23,23 +52,37 @@ export const logs = Effect.fnUntraced(function* (flags: LogsFlags) { return yield* output.outro("No local Supabase stack was found."); yield* Effect.scoped( Effect.gen(function* () { - const stack = yield* openStack(descriptorOption.value.id); - const stream = flags.noFollow - ? stack.logs({ follow: false }).pipe(Stream.take(flags.tail)) - : stack.logs({ follow: true }); - yield* stream.pipe( - Stream.runForEach((entry) => - output.format === "stream-json" - ? output.success("", { - type: "log-entry", - timestamp: entry.timestamp, - service: entry.source, - stream: entry.stream, - line: entry.message, - }) - : output.info(`[${entry.source}] ${entry.message}`), - ), - ); + const stack = yield* operations.openStack(descriptorOption.value.id); + const capabilities: ReadonlyArray | undefined = + flags.service.length === 0 ? undefined : flags.service; + const retainedChunk = yield* stack + .logs({ capabilities, follow: false }) + .pipe(Stream.runCollect); + const retained = retainedChunk; + const history = flags.tail === 0 ? [] : retained.slice(-flags.tail); + let cursorEntries = retained; + if (retained.length === 0 && capabilities !== undefined) { + const fallbackChunk = yield* stack.logs({ follow: false }).pipe(Stream.runCollect); + cursorEntries = fallbackChunk; + } + const emit = (source: "history" | "live") => (entry: (typeof retained)[number]) => + output.format === "stream-json" + ? output.event({ + type: "log-entry", + timestamp: entry.timestamp, + service: entry.source, + stream: entry.stream === "stdout" ? "stdout" : "stderr", + line: entry.message, + source, + }) + : output.info(`[${entry.source}] ${entry.message}`); + yield* Stream.fromIterable(history).pipe(Stream.runForEach(emit("history"))); + if (!flags.noFollow) { + const cursor = cursorEntries.at(-1)?.cursor; + yield* stack + .logs({ capabilities, follow: true, ...(cursor === undefined ? {} : { cursor }) }) + .pipe(Stream.runForEach(emit("live"))); + } }), ); yield* output.outro("Finished showing local Supabase logs."); diff --git a/apps/cli/src/next/commands/logs/logs.integration.test.ts b/apps/cli/src/next/commands/logs/logs.integration.test.ts index a5b8afd346..223260babe 100644 --- a/apps/cli/src/next/commands/logs/logs.integration.test.ts +++ b/apps/cli/src/next/commands/logs/logs.integration.test.ts @@ -1,15 +1,107 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; -import { logs } from "./logs.handler.ts"; +import { Effect, Exit, Layer, Option, Stream } from "effect"; +import { StackIdSchema } from "@supabase/stack/effect"; +import { logs, type LogsOperations } from "./logs.handler.ts"; import { emptyEnv, mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; describe("logs handler", () => { it.live("fails clearly when no managed stack exists", () => { const out = mockOutput({ interactive: false }); - return logs({ stack: "default", service: [], tail: 10, noFollow: true }).pipe( + const operations: LogsOperations = { + findStack: () => Effect.succeed(Option.none()), + openStack: () => Effect.die("openStack should not be called"), + }; + return logs({ stack: "default", service: [], tail: 10, noFollow: true }, operations).pipe( Effect.provide(Layer.mergeAll(emptyEnv(), out.layer, mockProcessControl().layer)), Effect.exit, - Effect.tap((exit) => Effect.sync(() => expect(Exit.isFailure(exit)).toBe(true))), + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ message: "No local Supabase stack was found." }), + ); + }), + ), + ); + }); + + it.live("filters services, emits the newest tail, then follows from the retained cursor", () => { + const out = mockOutput({ format: "stream-json", interactive: false }); + const id = StackIdSchema.make( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + const entries = [ + { + cursor: { opaque: "1" }, + timestamp: "1", + source: "auth" as const, + stream: "stdout" as const, + message: "old", + }, + { + cursor: { opaque: "2" }, + timestamp: "2", + source: "auth" as const, + stream: "stdout" as const, + message: "new", + }, + { + cursor: { opaque: "3" }, + timestamp: "3", + source: "storage" as const, + stream: "stdout" as const, + message: "other", + }, + ]; + const live = { + cursor: { opaque: "4" }, + timestamp: "4", + source: "auth" as const, + stream: "stderr" as const, + message: "live", + }; + const seen: Array = []; + const operations: LogsOperations = { + findStack: () => + Effect.succeed( + Option.some({ + id, + projectRoot: "/tmp/project", + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }), + ), + openStack: () => + Effect.succeed({ + logs: (options) => { + seen.push(options); + if (options?.follow === true) return Stream.succeed(live); + return Stream.fromIterable( + entries.filter((entry) => options?.capabilities?.includes(entry.source) ?? true), + ); + }, + }), + }; + return logs({ stack: "default", service: ["auth"], tail: 1, noFollow: false }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer, mockProcessControl().layer)), + Effect.tap(() => + Effect.sync(() => { + expect(seen[0]).toMatchObject({ capabilities: ["auth"], follow: false }); + expect(seen[1]).toMatchObject({ + capabilities: ["auth"], + follow: true, + cursor: { opaque: "2" }, + }); + expect(out.events).toContainEqual( + expect.objectContaining({ type: "log-entry", source: "history", line: "new" }), + ); + expect(out.events).toContainEqual( + expect.objectContaining({ type: "log-entry", source: "live", line: "live" }), + ); + }), + ), ); }); }); diff --git a/apps/cli/src/next/commands/restart/restart.command.ts b/apps/cli/src/next/commands/restart/restart.command.ts index 26a466fa76..eb1f16a867 100644 --- a/apps/cli/src/next/commands/restart/restart.command.ts +++ b/apps/cli/src/next/commands/restart/restart.command.ts @@ -6,7 +6,7 @@ import { provideCliProjectCommandRuntime } from "../../config/project-runtime.la import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { restart } from "./restart.handler.ts"; -import { excludeFlag } from "../start/start.command.ts"; +import { excludeFlag } from "../../config/stack-config.ts"; const flags = { stack: Flag.string("stack").pipe( diff --git a/apps/cli/src/next/commands/restart/restart.handler.ts b/apps/cli/src/next/commands/restart/restart.handler.ts index a0c921979e..c845d8b2e5 100644 --- a/apps/cli/src/next/commands/restart/restart.handler.ts +++ b/apps/cli/src/next/commands/restart/restart.handler.ts @@ -7,6 +7,7 @@ import { openStack, type EffectStack, type FindStackOptions, + type OpenStackOptions, } from "@supabase/stack/effect"; import type { StackDescriptor, StackId, StackStatus } from "@supabase/stack/effect"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; @@ -27,7 +28,10 @@ export interface RestartOperations { readonly findStack: ( options: FindStackOptions, ) => Effect.Effect, unknown, RestartRuntime>; - readonly openStack: (id: StackId) => Effect.Effect; + readonly openStack: ( + id: StackId, + options?: OpenStackOptions, + ) => Effect.Effect; readonly loadConfig: ( cwd: string, ) => Effect.Effect; @@ -53,10 +57,12 @@ export const restart = Effect.fnUntraced(function* ( return yield* new StackNotFoundError({ message: "No local Supabase stack was found." }); } const loadedConfig = yield* operations.loadConfig(project.projectRoot); - const config = toStartStackConfig(loadedConfig, flags.exclude); + const config = yield* toStartStackConfig(loadedConfig, flags.exclude); yield* Effect.scoped( Effect.gen(function* () { - const stack = yield* operations.openStack(descriptor.value.id); + const stack = yield* operations.openStack(descriptor.value.id, { + replaceIncompatibleOwner: true, + }); const status: StackStatus = yield* stack.restart({ config }); yield* output.success( status.lifecycle === "running" diff --git a/apps/cli/src/next/commands/restart/restart.integration.test.ts b/apps/cli/src/next/commands/restart/restart.integration.test.ts index be1b6d3a37..5c58855af3 100644 --- a/apps/cli/src/next/commands/restart/restart.integration.test.ts +++ b/apps/cli/src/next/commands/restart/restart.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { StackIdSchema } from "@supabase/stack/effect"; import type { StackStatus } from "@supabase/stack/effect"; import { CAPABILITY_NAMES, StackUpgradeRequiredError } from "@supabase/stack/effect"; @@ -26,6 +26,7 @@ const status: StackStatus = { describe("restart handler", () => { it.live("restarts the managed stack with translated config", () => { let receivedConfig: unknown; + let receivedOptions: unknown; const output = mockOutput({ interactive: false }); const stack: RestartStack = { restart: (options) => @@ -46,7 +47,11 @@ describe("restart handler", () => { desiredLifecycle: "running" as const, }), ), - openStack: () => Effect.succeed(stack), + openStack: (_id, options) => + Effect.sync(() => { + receivedOptions = options; + return stack; + }), loadConfig: () => Effect.succeed(undefined), }; return restart({ stack: "default", exclude: ["auth"] }, operations).pipe( @@ -62,6 +67,7 @@ describe("restart handler", () => { expect(receivedConfig).toMatchObject({ capabilities: { auth: { enabled: false } }, }); + expect(receivedOptions).toEqual({ replaceIncompatibleOwner: true }); expect(output.messages).toContainEqual( expect.objectContaining({ message: "Local Supabase stack restarted." }), ); @@ -97,6 +103,11 @@ describe("restart handler", () => { Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackUpgradeRequiredError); + } }), ), ); diff --git a/apps/cli/src/next/commands/start/signal.ts b/apps/cli/src/next/commands/start/signal.ts deleted file mode 100644 index 789e71b198..0000000000 --- a/apps/cli/src/next/commands/start/signal.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Effect } from "effect"; -import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; - -/** - * Wait for a process-level shutdown signal (SIGTERM, SIGINT, or stdin close) - * and complete successfully with a stop intent. - * - * This does NOT call stack.dispose() — the caller is responsible for - * cleanup via Effect finalizers. This avoids double-disposal races while - * keeping normal stop requests off the error path. - */ -export const interruptOnSignal: Effect.Effect = Effect.gen( - function* () { - const processControl = yield* ProcessControl; - return yield* processControl.awaitShutdown; - }, -); diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index d5e4d53a53..322858d76a 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -3,26 +3,12 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { DEFAULT_MANAGED_STACK_NAME } from "../../../shared/stack-constants.ts"; import { provideCliProjectCommandRuntime } from "../../config/project-runtime.layer.ts"; -import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { inkLayer } from "../../../shared/runtime/ink.layer.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { start } from "./start.handler.ts"; +import { excludeFlag } from "../../config/stack-config.ts"; -export const excludedStackServices = [ - "auth", - "realtime", - "storage", - "studio", - "analytics", - "pooler", -] as const; -export type ExcludedStackService = (typeof excludedStackServices)[number]; -export const excludeFlag = Flag.choice("exclude", excludedStackServices).pipe( - Flag.atMost(excludedStackServices.length), - Flag.withDefault([] as ReadonlyArray), -); const modeFlag = Flag.choice("mode", ["native", "docker"] as const).pipe( Flag.optional, Flag.map(Option.getOrUndefined), @@ -43,7 +29,4 @@ export const startCommand = Command.make("start", flags).pipe( start(flags).pipe(withCommandInstrumentation({ flags }), withJsonErrorHandling), ), Command.provide(provideCliProjectCommandRuntime(Layer.mergeAll(commandRuntimeLayer(["start"])))), - Command.provide(inkLayer), ); - -export { ensureProjectStateIgnored }; diff --git a/apps/cli/src/next/commands/start/start.command.unit.test.ts b/apps/cli/src/next/commands/start/start.command.unit.test.ts index 56f04c406d..bc35dd9553 100644 --- a/apps/cli/src/next/commands/start/start.command.unit.test.ts +++ b/apps/cli/src/next/commands/start/start.command.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Exit } from "effect"; -import { excludeFlag } from "./start.command.ts"; +import { excludeFlag } from "../../config/stack-config.ts"; describe("start command exclude flag", () => { test("parses repeated excluded services", async () => { diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index 6bdd17149c..11427ccebe 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -1,7 +1,7 @@ import { Crypto, Effect, FileSystem, Path, Scope } from "effect"; import { createStack, - StackUpgradeRequiredError, + StackMustBeStoppedError, type EffectStack, type CreateStackOptions, } from "@supabase/stack/effect"; @@ -22,7 +22,7 @@ type StartRuntime = | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner; -export type StartStack = Pick; +export type StartStack = Pick; export interface StartOperations { readonly createStack: ( options: CreateStackOptions, @@ -54,16 +54,18 @@ export const start = Effect.fnUntraced(function* ( name: flags.stack, runtime: runtimePreference(flags.mode), }); - const config = toStartStackConfig(loadedConfig, flags.exclude, flags.mode); - yield* stack.prepare({ config }); + const config = yield* toStartStackConfig(loadedConfig, flags.exclude); const status = yield* stack.start({ config }).pipe( - Effect.catchTag("StackUpgradeRequiredError", (error) => - Effect.fail( - new StackUpgradeRequiredError({ - message: `${error.message}; run supabase restart to apply it`, - guidance: "Run supabase restart to apply stopped-time changes", - }), - ), + Effect.catchIf( + (error) => error instanceof StackMustBeStoppedError, + (error) => + Effect.fail( + new StackMustBeStoppedError({ + ...error, + message: `${error.message}; run supabase restart to apply it`, + guidance: "Run supabase restart to apply stopped-time changes", + }), + ), ), ); yield* output.success( diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index f7c09a3d70..6f765d2fad 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,23 +1,46 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import { start, type StartOperations, type StartStack } from "./start.handler.ts"; -import { CAPABILITY_NAMES, StackIdSchema, StackUpgradeRequiredError } from "@supabase/stack/effect"; +import { + CAPABILITY_NAMES, + ServiceStartError, + StackIdSchema, + StackMustBeStoppedError, +} from "@supabase/stack/effect"; import type { StackStatus } from "@supabase/stack/effect"; -import { emptyEnv, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { emptyEnv, mockCliProjectHome, mockOutput } from "../../../../tests/helpers/mocks.ts"; describe("start handler", () => { - it.live("reports runtime startup failures through the command boundary", () => { + it.live("preserves the runtime startup failure tag through the command boundary", () => { const out = mockOutput({ interactive: false }); - return start({ stack: "default", mode: "native", exclude: [], detach: true }).pipe( - Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + const stack: StartStack = { + start: () => Effect.fail(new ServiceStartError({ message: "auth failed" })), + }; + const operations: StartOperations = { + createStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(undefined), + }; + return start({ stack: "default", mode: "native", exclude: [], detach: true }, operations).pipe( + Effect.provide( + Layer.mergeAll(emptyEnv(), out.layer, mockCliProjectHome({ projectRoot: process.cwd() })), + ), Effect.exit, - Effect.tap((exit) => Effect.sync(() => expect(Exit.isFailure(exit)).toBe(true))), + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(failure).toBeInstanceOf(ServiceStartError); + if (failure instanceof ServiceStartError) expect(failure.message).toBe("auth failed"); + } + }), + ), ); }); - it.live("prepares the translated config before starting the managed stack", () => { + it.live("starts the managed stack with the translated config", () => { const out = mockOutput({ interactive: false }); - const events: Array = []; + let receivedConfig: unknown; const id = StackIdSchema.make( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); @@ -35,14 +58,9 @@ describe("start handler", () => { })), }; const stack: StartStack = { - prepare: () => - Effect.sync(() => { - events.push("prepare"); - return { capabilities: [] }; - }), - start: () => + start: (options) => Effect.sync(() => { - events.push("start"); + receivedConfig = options?.config; return status; }), }; @@ -54,7 +72,7 @@ describe("start handler", () => { Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), Effect.tap(() => Effect.sync(() => { - expect(events).toEqual(["prepare", "start"]); + expect(receivedConfig).toBeDefined(); expect(out.messages).toContainEqual( expect.objectContaining({ message: "Local Supabase stack is running." }), ); @@ -66,10 +84,9 @@ describe("start handler", () => { it.live("preserves explicit restart guidance when running input changed", () => { const out = mockOutput({ interactive: false }); const stack: StartStack = { - prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.fail( - new StackUpgradeRequiredError({ + new StackMustBeStoppedError({ message: "Running stack input changed; call restart explicitly to apply it", guidance: "Use restart() to apply stopped-time changes", }), @@ -87,8 +104,8 @@ describe("start handler", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); - expect(failure).toBeInstanceOf(StackUpgradeRequiredError); - if (failure instanceof StackUpgradeRequiredError) + expect(failure).toBeInstanceOf(StackMustBeStoppedError); + if (failure instanceof StackMustBeStoppedError) expect(failure.message).toContain("supabase restart"); } }), diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts index 414a45f395..694b5377cb 100644 --- a/apps/cli/src/next/commands/status/status.handler.ts +++ b/apps/cli/src/next/commands/status/status.handler.ts @@ -1,14 +1,38 @@ -import { Effect, Option } from "effect"; -import { findStack, inspectStack, openStack } from "@supabase/stack/effect"; +import { Crypto, Effect, FileSystem, Option, Path } from "effect"; +import { + findStack, + inspectStack, + type FindStackOptions, + type StackDescriptor, + type StackDiscoveryError, + type StackId, + type StackInspection, + type StackNotFoundError, +} from "@supabase/stack/effect"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import type { StatusFlags } from "./status.command.ts"; -export const status = Effect.fnUntraced(function* (flags: StatusFlags) { +type StatusRuntime = FileSystem.FileSystem | Path.Path | Crypto.Crypto; +export interface StatusOperations { + readonly findStack: ( + options: FindStackOptions, + ) => Effect.Effect, StackDiscoveryError, StatusRuntime>; + readonly inspectStack: ( + id: StackId, + ) => Effect.Effect; +} + +const defaultOperations: StatusOperations = { findStack, inspectStack }; + +export const status = Effect.fnUntraced(function* ( + flags: StatusFlags, + operations: StatusOperations = defaultOperations, +) { const output = yield* Output; const project = yield* CliProjectHome; yield* output.intro("Show local Supabase stack status"); - const descriptorOption = yield* findStack({ + const descriptorOption = yield* operations.findStack({ projectRoot: project.projectRoot, name: flags.stack, }); @@ -18,18 +42,20 @@ export const status = Effect.fnUntraced(function* (flags: StatusFlags) { return yield* output.success(message, { running: false, stack: flags.stack }); } const descriptor = descriptorOption.value; - const inspection = yield* inspectStack(descriptor.id); - let observed = inspection.status; - if (inspection.owner === "running") { - observed = yield* Effect.scoped( - Effect.gen(function* () { - const stack = yield* openStack(descriptor.id); - return yield* stack.status(); - }), - ); - } - const running = observed?.lifecycle === "running"; - const message = running ? "Local Supabase stack is running." : "Local Supabase stack is stopped."; + const inspection = yield* operations.inspectStack(descriptor.id); + const observed = inspection.status; + const lifecycle = observed?.lifecycle ?? descriptor.desiredLifecycle; + const running = inspection.owner === "running" && lifecycle === "running"; + const crashed = inspection.owner === "absent" && descriptor.desiredLifecycle === "running"; + const unavailable = + inspection.owner === "incompatible" || inspection.owner === "unreachable" || crashed; + let message: string; + if (inspection.owner === "incompatible") + message = "Local Supabase stack has an incompatible owner; run supabase restart."; + else if (inspection.owner === "unreachable") + message = "Local Supabase stack owner is unreachable; run supabase restart."; + else if (crashed) message = "Local Supabase stack owner is not running; run supabase start."; + else message = `Local Supabase stack is ${lifecycle}.`; const data = { stack: descriptor.name, running, @@ -39,11 +65,13 @@ export const status = Effect.fnUntraced(function* (flags: StatusFlags) { endpoints: observed?.endpoints ?? {}, versions: observed?.versions ?? {}, capabilities: observed?.capabilities ?? [], + owner: inspection.owner, }; if (output.format !== "text") return yield* output.success(message, data); - yield* running ? output.success(message) : output.info(message); + if (unavailable) yield* output.warn(message); + else yield* running ? output.success(message) : output.info(message); yield* output.info(`Stack: ${descriptor.name}`); - yield* output.info(`Lifecycle: ${data.lifecycle}`); + yield* output.info(`Lifecycle: ${lifecycle}`); yield* output.info(`Runtime: ${descriptor.runtime.kind}`); for (const [name, endpoint] of Object.entries(data.endpoints)) { if (endpoint !== undefined) yield* output.info(`${name}: ${endpoint.url}`); @@ -52,6 +80,6 @@ export const status = Effect.fnUntraced(function* (flags: StatusFlags) { yield* output.info(`${capability.name}: ${capability.state}`); } yield* output.outro( - `Local Supabase stack ${descriptor.name} is ${running ? "running" : "stopped"}.`, + `Local Supabase stack ${descriptor.name} is ${unavailable ? "unavailable" : lifecycle}.`, ); }); diff --git a/apps/cli/src/next/commands/status/status.integration.test.ts b/apps/cli/src/next/commands/status/status.integration.test.ts index b81feae75e..d47ada6709 100644 --- a/apps/cli/src/next/commands/status/status.integration.test.ts +++ b/apps/cli/src/next/commands/status/status.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { status } from "./status.handler.ts"; +import { Effect, Layer, Option } from "effect"; +import { CAPABILITY_NAMES, StackIdSchema } from "@supabase/stack/effect"; +import { status, type StatusOperations } from "./status.handler.ts"; import { emptyEnv, mockCliProjectHome, mockOutput } from "../../../../tests/helpers/mocks.ts"; describe("status handler", () => { @@ -17,4 +18,120 @@ describe("status handler", () => { ), ); }); + + it.live("distinguishes an incompatible owner and provides restart guidance", () => { + const out = mockOutput({ interactive: false, format: "text" }); + const id = StackIdSchema.make( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + const descriptor = { + id, + projectRoot: process.cwd(), + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const operations: StatusOperations = { + findStack: () => Effect.succeed(Option.some(descriptor)), + inspectStack: () => Effect.succeed({ descriptor, owner: "incompatible" as const }), + }; + return status({ stack: "default" }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("supabase restart"), + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ message: "Lifecycle: running" }), + ); + }), + ), + ); + }); + + it.live("reports a compatible owner that is still starting", () => { + const out = mockOutput({ interactive: false, format: "json" }); + const id = StackIdSchema.make( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + ); + const descriptor = { + id, + projectRoot: process.cwd(), + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const runningStatus = { + id, + lifecycle: "starting" as const, + desiredLifecycle: "running" as const, + runtime: descriptor.runtime, + endpoints: {}, + versions: {}, + capabilities: CAPABILITY_NAMES.map((name) => ({ + name, + activation: "lazy" as const, + state: "starting" as const, + })), + }; + const operations: StatusOperations = { + findStack: () => Effect.succeed(Option.some(descriptor)), + inspectStack: () => + Effect.succeed({ descriptor, owner: "running" as const, status: runningStatus }), + }; + return status({ stack: "default" }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Local Supabase stack is starting.", + }), + ); + }), + ), + ); + }); + + it.live("reports running intent without an owner as unavailable", () => { + const out = mockOutput({ interactive: false, format: "text" }); + const id = StackIdSchema.make( + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + ); + const descriptor = { + id, + projectRoot: process.cwd(), + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + const operations: StatusOperations = { + findStack: () => Effect.succeed(Option.some(descriptor)), + inspectStack: () => Effect.succeed({ descriptor, owner: "absent" as const }), + }; + return status({ stack: "default" }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("supabase start"), + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ message: expect.stringContaining("unavailable") }), + ); + }), + ), + ); + }); }); diff --git a/apps/cli/src/next/commands/stop/stop.handler.ts b/apps/cli/src/next/commands/stop/stop.handler.ts index 52ed8a2b2c..9dbbdf1f68 100644 --- a/apps/cli/src/next/commands/stop/stop.handler.ts +++ b/apps/cli/src/next/commands/stop/stop.handler.ts @@ -1,23 +1,53 @@ -import { Effect, Option } from "effect"; -import { findStack, openStack, StackNotFoundError } from "@supabase/stack/effect"; +import { Crypto, Effect, FileSystem, Option, Path, Scope } from "effect"; +import { + findStack, + openStack, + StackNotFoundError, + type EffectStack, + type FindStackOptions, + type StackDescriptor, + type StackId, +} from "@supabase/stack/effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import type { StopFlags } from "./stop.command.ts"; -export const stop = Effect.fnUntraced(function* (flags: StopFlags) { +type StopRuntime = + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner; +type StopStack = Pick; +export interface StopOperations { + readonly findStack: ( + options: FindStackOptions, + ) => Effect.Effect, unknown, StopRuntime>; + readonly openStack: (id: StackId) => Effect.Effect; +} + +const defaultOperations: StopOperations = { findStack, openStack }; + +export const stop = Effect.fnUntraced(function* ( + flags: StopFlags, + operations: StopOperations = defaultOperations, +) { const output = yield* Output; const project = yield* CliProjectHome; yield* output.intro("Stop local Supabase stack"); - const descriptorOption = yield* findStack({ + const descriptorOption = yield* operations.findStack({ projectRoot: project.projectRoot, name: flags.stack, }); if (Option.isNone(descriptorOption)) { return yield* new StackNotFoundError({ message: "No local Supabase stack was found." }); } + const descriptor = descriptorOption.value; yield* Effect.scoped( Effect.gen(function* () { - const stack = yield* openStack(descriptorOption.value.id); + const stack = yield* operations.openStack(descriptor.id); + // Stable stop is what makes destroy possible when an older owner speaks a different RPC release. yield* stack.stop(); if (flags.noBackup) yield* stack.destroy(); }), diff --git a/apps/cli/src/next/commands/stop/stop.integration.test.ts b/apps/cli/src/next/commands/stop/stop.integration.test.ts index adc1797c74..df8e903dd4 100644 --- a/apps/cli/src/next/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/next/commands/stop/stop.integration.test.ts @@ -1,15 +1,188 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; -import { stop } from "./stop.handler.ts"; -import { emptyEnv, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { StackIdSchema, StackNotFoundError } from "@supabase/stack/effect"; +import { stop, type StopOperations } from "./stop.handler.ts"; +import { emptyEnv, mockCliProjectHome, mockOutput } from "../../../../tests/helpers/mocks.ts"; describe("stop handler", () => { it.live("fails clearly when no stack descriptor exists", () => { const out = mockOutput({ interactive: false }); return stop({ stack: "default", noBackup: false }).pipe( - Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.provide( + Layer.mergeAll(emptyEnv(), out.layer, mockCliProjectHome({ projectRoot: process.cwd() })), + ), Effect.exit, - Effect.tap((exit) => Effect.sync(() => expect(Exit.isFailure(exit)).toBe(true))), + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(Cause.squash(exit.cause)).toBeInstanceOf(StackNotFoundError); + }), + ), + ); + }); + + it.live("stops an ownerless persisted stack through the package handle", () => { + const out = mockOutput({ interactive: false }); + const descriptor = { + id: StackIdSchema.make("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + projectRoot: process.cwd(), + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "stopped" as const, + }; + let stopped = false; + const operations: StopOperations = { + findStack: () => Effect.succeed(Option.some(descriptor)), + openStack: () => + Effect.succeed({ + stop: () => Effect.sync(() => void (stopped = true)), + destroy: () => Effect.void, + }), + }; + return stop({ stack: "default", noBackup: false }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(stopped).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ message: "Local Supabase stopped" }), + ); + }), + ), + ); + }); + + it.live("destroys an ownerless stack through the managed package with --no-backup", () => { + const out = mockOutput({ interactive: false }); + const descriptor = { + id: StackIdSchema.make("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"), + projectRoot: process.cwd(), + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "stopped" as const, + }; + let destroyed = false; + let stopped = false; + const operations: StopOperations = { + findStack: () => Effect.succeed(Option.some(descriptor)), + openStack: () => + Effect.succeed({ + stop: () => Effect.sync(() => void (stopped = true)), + destroy: () => Effect.sync(() => void (destroyed = true)), + }), + }; + return stop({ stack: "default", noBackup: true }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(stopped).toBe(true); + expect(destroyed).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ + message: "Local Supabase stopped and persisted data deleted", + }), + ); + }), + ), + ); + }); + + it.live("uses stable maintenance stop for an incompatible owner", () => { + const out = mockOutput({ interactive: false }); + const descriptor = { + id: StackIdSchema.make("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + projectRoot: process.cwd(), + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + let stopped = false; + const operations: StopOperations = { + findStack: () => Effect.succeed(Option.some(descriptor)), + openStack: () => + Effect.sync(() => { + return { + stop: () => Effect.sync(() => void (stopped = true)), + destroy: () => Effect.void, + }; + }), + }; + return stop({ stack: "default", noBackup: false }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(stopped).toBe(true); + }), + ), + ); + }); + + it.live("delegates dead-owner recovery to the package handle", () => { + const out = mockOutput({ interactive: false }); + const descriptor = { + id: StackIdSchema.make("fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"), + projectRoot: process.cwd(), + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + let stopped = false; + const operations: StopOperations = { + findStack: () => Effect.succeed(Option.some(descriptor)), + openStack: () => + Effect.succeed({ + stop: () => Effect.sync(() => void (stopped = true)), + destroy: () => Effect.void, + }), + }; + return stop({ stack: "default", noBackup: false }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(stopped).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ message: "Local Supabase stopped" }), + ); + }), + ), + ); + }); + + it.live("stops an incompatible owner before deleting with --no-backup", () => { + const out = mockOutput({ interactive: false }); + const descriptor = { + id: StackIdSchema.make("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"), + projectRoot: process.cwd(), + name: "default", + branchContext: "main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }; + let destroyed = false; + let stopped = false; + const operations: StopOperations = { + findStack: () => Effect.succeed(Option.some(descriptor)), + openStack: () => + Effect.sync(() => { + return { + stop: () => Effect.sync(() => void (stopped = true)), + destroy: () => Effect.sync(() => void (destroyed = true)), + }; + }), + }; + return stop({ stack: "default", noBackup: true }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), out.layer)), + Effect.tap(() => + Effect.sync(() => { + expect(stopped).toBe(true); + expect(destroyed).toBe(true); + }), + ), ); }); }); diff --git a/apps/cli/src/next/config/managed-port-intents.ts b/apps/cli/src/next/config/managed-port-intents.ts deleted file mode 100644 index e963368549..0000000000 --- a/apps/cli/src/next/config/managed-port-intents.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PromiseStackConfig } from "@supabase/stack"; - -/** Port configuration now belongs to the stack compiler; CLI no longer persists a parallel document. */ -export const managedPortIntents = (_config: PromiseStackConfig, _loaded?: unknown): undefined => - undefined; diff --git a/apps/cli/src/next/config/project-link-remote.layer.ts b/apps/cli/src/next/config/project-link-remote.layer.ts index 6a553e17f4..ce28d1868b 100644 --- a/apps/cli/src/next/config/project-link-remote.layer.ts +++ b/apps/cli/src/next/config/project-link-remote.layer.ts @@ -15,7 +15,7 @@ import { } from "./project-link-remote.service.ts"; import type { LinkedServiceVersions } from "./project-link-state.service.ts"; -export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ +class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ readonly service: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -23,7 +23,7 @@ export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersio } } -export class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ +class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ readonly projectRef: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 68f80d999b..c7e75305d7 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -1,4 +1,6 @@ -import { Redacted, Schema } from "effect"; +import { Effect, Redacted, Schema } from "effect"; +import { Flag } from "effect/unstable/cli"; +import { isAbsolute, relative, resolve } from "node:path"; import type { CliConfig } from "@supabase/config"; import { StackConfigSchema, @@ -8,9 +10,10 @@ import { type RestSettings, type StackConfig, type StackRuntimePreference, + InvalidStackConfigError, } from "@supabase/stack/effect"; -export const excludedStackServices = [ +const excludedStackServices = [ "auth", "realtime", "storage", @@ -19,26 +22,28 @@ export const excludedStackServices = [ "pooler", ] as const; export type ExcludedStackService = (typeof excludedStackServices)[number]; -export const isExcludedStackService = (value: string): value is ExcludedStackService => +export const excludeFlag = Flag.choice("exclude", excludedStackServices).pipe( + Flag.atMost(excludedStackServices.length), + Flag.withDefault([] as ReadonlyArray), +); +const isExcludedStackService = (value: string): value is ExcludedStackService => excludedStackServices.some((candidate) => candidate === value); -export const startModes = ["native", "docker"] as const; +const startModes = ["native", "docker"] as const; export type StartMode = (typeof startModes)[number]; const disabledCapability = ( name: string, - _native: boolean, excluded: ReadonlySet, enabled?: boolean, ) => (isExcludedStackService(name) && excluded.has(name)) || enabled === false; const capability = ( name: string, - native: boolean, excluded: ReadonlySet, enabled: boolean | undefined, settings: S, ) => - disabledCapability(name, native, excluded, enabled) + disabledCapability(name, excluded, enabled) ? ({ enabled: false } as const) : settings === undefined ? {} @@ -116,6 +121,76 @@ const toRestSettings = (api: CliConfig["api"] | undefined): RestSettings => ({ external_url: api?.external_url, }); +/** + * Converts a config path (relative to `supabase`) to the path expected by the + * stack's Functions runtime (relative to that function's directory). + * + * CLI config and Functions manifests use paths such as + * `./functions/hello/index.ts`, while the stack resolves `hello/index.ts` + * against `functions_root`. Both `start` and `functions serve` reuse this + * translation. + */ +const normalizeFunctionsPath = (pathname: string): string => + pathname + .replaceAll("\\", "/") + .replace(/^\.\//, "") + .replace(/^supabase\//, ""); + +export function relativeFunctionPath(slug: string, pathname: string): string; +export function relativeFunctionPath(slug: string, pathname: undefined): undefined; +export function relativeFunctionPath( + slug: string, + pathname: string | undefined, +): string | undefined { + if (pathname === undefined) return undefined; + const normalized = normalizeFunctionsPath(pathname); + const prefix = `functions/${slug}/`; + return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : pathname; +} + +/** Whether a path is explicitly rooted at one function's directory. */ +export function isFunctionScopedPath(slug: string, pathname: string | undefined): boolean { + if (pathname === undefined) return false; + const normalized = normalizeFunctionsPath(pathname); + return normalized.startsWith(`functions/${slug}/`); +} + +/** + * Converts a Functions path supplied for one project function into a path + * relative to the shared `functions_root`. This is used by serve's global + * `--import-map` default; `./functions/hello/deno.json` becomes + * `hello/deno.json`, so every discovered slug resolves the same file. Absolute + * or caller-cwd paths are also converted when `projectRoot` is supplied. + */ +export function relativeGlobalFunctionPath( + pathname: string | undefined, + options: { readonly projectRoot?: string; readonly cwd?: string } = {}, +): string | undefined { + if (pathname === undefined) return undefined; + const normalized = normalizeFunctionsPath(pathname); + if (normalized.startsWith("functions/")) return normalized.slice("functions/".length); + + // CLI flags are normally relative to the caller's cwd. Persist only a path + // relative to `functions_root` so the same config works in native and + // container runtimes. Out-of-root values remain unchanged and are rejected + // by the runtime's canonical containment check. + if (options.projectRoot !== undefined) { + const functionsRoot = resolve(options.projectRoot, "supabase", "functions"); + const absolute = isAbsolute(pathname) + ? pathname + : resolve(options.cwd ?? options.projectRoot, pathname); + const rootRelative = relative(functionsRoot, absolute).replaceAll("\\", "/"); + if ( + rootRelative !== "" && + rootRelative !== ".." && + !rootRelative.startsWith("../") && + !isAbsolute(rootRelative) + ) + return rootRelative; + } + return normalized; +} + const toFunctionsSettings = ( edgeRuntime: CliConfig["edge_runtime"] | undefined, functions: CliConfig["functions"] | undefined, @@ -138,6 +213,11 @@ const toFunctionsSettings = ( { ...fn, env: secretRecord(fn.env), + import_map: relativeFunctionPath(name, fn.import_map), + entrypoint: relativeFunctionPath(name, fn.entrypoint), + static_files: fn.static_files?.map((pathname) => + relativeFunctionPath(name, pathname), + ), }, ]), ), @@ -240,14 +320,12 @@ const toAuthSettings = (auth: CliConfig["auth"] | undefined): AuthSettings | und }; }; -/** Translates CLI exclusions and mode to the stack's closed capability config. */ +/** Translates CLI exclusions to the stack's closed capability config. */ export function toStartStackConfig( config: CliConfig | undefined, exclude: ReadonlyArray, - mode?: StartMode, -): StackConfig { +): Effect.Effect { const excluded = new Set(exclude); - const native = mode === "native"; const db = config?.db; const api = config?.api; const auth = config?.auth; @@ -290,13 +368,13 @@ export function toStartStackConfig( const configOutput = { capabilities: { database: { version: db?.major_version?.toString(), settings: databaseSettings }, - rest: capability("rest", native, excluded, api?.enabled, toRestSettings(api)), - auth: capability("auth", native, excluded, auth?.enabled, authSettings), - realtime: capability("realtime", native, excluded, realtime?.enabled, { + rest: capability("rest", excluded, api?.enabled, toRestSettings(api)), + auth: capability("auth", excluded, auth?.enabled, authSettings), + realtime: capability("realtime", excluded, realtime?.enabled, { ip_version: ipVersion(realtime?.ip_version), max_header_length: realtime?.max_header_length, }), - storage: capability("storage", native, excluded, storage?.enabled, { + storage: capability("storage", excluded, storage?.enabled, { file_size_limit: storage?.file_size_limit, image_transformation: storage?.image_transformation, buckets: storage?.buckets, @@ -304,14 +382,14 @@ export function toStartStackConfig( analytics: storage?.analytics, vector: storage?.vector, }), - functions: capability("functions", native, excluded, edgeRuntime?.enabled, functionSettings), - studio: capability("studio", native, excluded, studio?.enabled, studioSettings), - mail: capability("mail", native, excluded, mail?.enabled, { + functions: capability("functions", excluded, edgeRuntime?.enabled, functionSettings), + studio: capability("studio", excluded, studio?.enabled, studioSettings), + mail: capability("mail", excluded, mail?.enabled, { admin_email: mail?.admin_email, sender_name: mail?.sender_name, }), - analytics: capability("analytics", native, excluded, analytics?.enabled, analyticsSettings), - pooler: capability("pooler", native, excluded, pooler?.enabled, poolerSettings), + analytics: capability("analytics", excluded, analytics?.enabled, analyticsSettings), + pooler: capability("pooler", excluded, pooler?.enabled, poolerSettings), }, listeners: { api: api === undefined ? undefined : { port: api.port }, @@ -342,9 +420,18 @@ export function toStartStackConfig( }, }; - // This synchronous, service-free CLI boundary validates the normalized overlay against the - // closed stack schema before handing it to the Effect-native runtime. - return Schema.decodeUnknownSync(StackConfigSchema)(omitUndefined(configOutput)); + // Keep schema failures in the Effect error channel so command boundaries can render a tagged + // InvalidStackConfigError rather than throwing synchronously inside an Effect generator. + return Schema.decodeUnknownEffect(StackConfigSchema)(omitUndefined(configOutput), { + onExcessProperty: "error", + }).pipe( + Effect.mapError( + (error) => + new InvalidStackConfigError({ + message: `Invalid stack configuration: ${String(error)}`, + }), + ), + ); } export function runtimePreference(mode?: StartMode): StackRuntimePreference | undefined { diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index 18ed2d0b86..fb582e374b 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -1,25 +1,30 @@ import { describe, expect, it } from "vitest"; import { CliConfigSchema } from "@supabase/config"; -import { Redacted, Schema } from "effect"; +import { Effect, Redacted, Schema } from "effect"; import { StackConfigSchema } from "@supabase/stack/effect"; -import { toStartStackConfig } from "./stack-config.ts"; +import { + isFunctionScopedPath, + relativeFunctionPath, + relativeGlobalFunctionPath, + toStartStackConfig, +} from "./stack-config.ts"; describe("toStartStackConfig", () => { - it("leaves runtime selection to the stack when mode is unset", () => { - expect(toStartStackConfig(undefined, [], undefined)).toMatchObject({ + it("leaves runtime selection to the stack", () => { + expect(Effect.runSync(toStartStackConfig(undefined, []))).toMatchObject({ capabilities: expect.any(Object), }); }); - it("keeps configured capabilities enabled for native mode", () => { - const config = toStartStackConfig(undefined, [], "native"); + it("keeps configured capabilities enabled by default", () => { + const config = Effect.runSync(toStartStackConfig(undefined, [])); expect(config.capabilities?.database).toMatchObject({ settings: expect.any(Object) }); expect(config.capabilities?.rest).toEqual({ settings: expect.any(Object) }); expect(config.capabilities?.functions).toEqual({ settings: expect.any(Object) }); }); - it("disables explicitly excluded capabilities in container mode", () => { - const config = toStartStackConfig(undefined, ["auth", "storage"], "docker"); + it("disables explicitly excluded capabilities", () => { + const config = Effect.runSync(toStartStackConfig(undefined, ["auth", "storage"])); expect(config.capabilities?.auth).toEqual({ enabled: false }); expect(config.capabilities?.storage).toEqual({ enabled: false }); expect(config.capabilities?.rest).toMatchObject({ settings: expect.any(Object) }); @@ -31,7 +36,7 @@ describe("toStartStackConfig", () => { edge_runtime: { secrets: { EDGE_TOKEN: "env(EDGE_TOKEN)" } }, functions: { hello: { env: { FUNCTION_TOKEN: "env(FUNCTION_TOKEN)" } } }, }); - const config = toStartStackConfig(loaded, [], "native"); + const config = Effect.runSync(toStartStackConfig(loaded, [])); expect(config.capabilities?.functions).toMatchObject({ settings: { functions_root: "supabase/functions" }, }); @@ -50,7 +55,9 @@ describe("toStartStackConfig", () => { functions: { hello: { env: { FUNCTION_TOKEN: "env(FUNCTION_TOKEN)" } } }, }); - const decoded = Schema.decodeSync(StackConfigSchema)(toStartStackConfig(loaded, [], "native")); + const decoded = Schema.decodeSync(StackConfigSchema)( + Effect.runSync(toStartStackConfig(loaded, [])), + ); expect(decoded.capabilities?.database?.version).toBe("17"); expect(decoded.capabilities?.database?.settings?.settings?.session_replication_role).toBe( @@ -67,4 +74,40 @@ describe("toStartStackConfig", () => { expect(Redacted.value(jwtSecret.secret)).toBe("jwt-secret"); } }); + + it("translates Functions paths from the supabase directory to each function directory", () => { + const loaded = Schema.decodeSync(CliConfigSchema)({ + functions: { + hello: { + entrypoint: "./functions/hello/main.ts", + import_map: "./functions/hello/deno.json", + static_files: ["./functions/hello/public/*.html"], + }, + }, + }); + const config = Effect.runSync(toStartStackConfig(loaded, [])); + const functions = config.capabilities?.functions; + if (functions === undefined || !("settings" in functions) || functions.settings === undefined) + throw new Error("Functions settings were not translated"); + expect(functions.settings.functions?.hello).toMatchObject({ + entrypoint: "main.ts", + import_map: "deno.json", + static_files: ["public/*.html"], + }); + }); + + it("translates a global Functions path for newly discovered slugs", () => { + expect(relativeGlobalFunctionPath("./functions/hello/deno.json")).toBe("hello/deno.json"); + expect(relativeGlobalFunctionPath("./functions/deno.json")).toBe("deno.json"); + expect(relativeGlobalFunctionPath("supabase/functions/deno.json")).toBe("deno.json"); + expect(relativeGlobalFunctionPath("deno.json")).toBe("deno.json"); + expect( + relativeGlobalFunctionPath("/tmp/project/supabase/functions/shared/deno.json", { + projectRoot: "/tmp/project", + }), + ).toBe("shared/deno.json"); + expect(isFunctionScopedPath("hello", "./functions/hello/deno.json")).toBe(true); + expect(isFunctionScopedPath("created", "./functions/hello/deno.json")).toBe(false); + expect(relativeFunctionPath("hello", "supabase/functions/hello/deno.json")).toBe("deno.json"); + }); }); diff --git a/apps/cli/src/next/main.ts b/apps/cli/src/next/main.ts index c47b8d8f1d..4e0c77ce86 100644 --- a/apps/cli/src/next/main.ts +++ b/apps/cli/src/next/main.ts @@ -1,10 +1,7 @@ #!/usr/bin/env bun -import { runSupervisorProcess } from "../../../../packages/stack/src/entrypoints/supervisor-node.ts"; -import { SUPERVISOR_DISPATCH_SENTINEL } from "../../../../packages/stack/src/supervisor/Launcher.ts"; +import { runSupervisorProcessIfDispatched } from "@supabase/stack/internal/supervisor"; const args = process.argv.slice(2); -if (args[0] === SUPERVISOR_DISPATCH_SENTINEL) { - await runSupervisorProcess(args.slice(1)); -} else { +if (!(await runSupervisorProcessIfDispatched(args))) { await import("./cli/main.ts"); } diff --git a/apps/cli/src/shared/functions/managed-functions-runtime.ts b/apps/cli/src/shared/functions/managed-functions-runtime.ts new file mode 100644 index 0000000000..8e9263c840 --- /dev/null +++ b/apps/cli/src/shared/functions/managed-functions-runtime.ts @@ -0,0 +1,297 @@ +import { inferFunctionsManifest, loadCliConfig } from "@supabase/config/effect"; +import { CliConfigSchema } from "@supabase/config"; +import type { CliConfig, FunctionsManifest } from "@supabase/config"; +import { Crypto, FileSystem, Effect, Option, Path, Redacted, Scope, Schema, Stream } from "effect"; +import { parse as parseDotenv } from "dotenv"; +import { resolve } from "node:path"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { createStack, type EffectStack, type CreateStackOptions } from "@supabase/stack/effect"; +import type { StackStatus } from "@supabase/stack/effect"; +import { + InvalidStackConfigError, + StackMustBeStoppedError, + StackUpgradeRequiredError, +} from "@supabase/stack/effect"; +import { Output } from "../output/output.service.ts"; +import { + isFunctionScopedPath, + relativeFunctionPath, + relativeGlobalFunctionPath, + toStartStackConfig, +} from "../../next/config/stack-config.ts"; + +/** The narrow stack surface owned by the managed Functions command. */ +export type ManagedFunctionsStack = Pick; + +type ManagedFunctionsRuntime = + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner; + +export interface ServeManagedFunctionsOptions { + readonly projectRoot: string; + readonly stackName: string; + /** Caller working directory used to resolve an explicit --env-file. */ + readonly cwd?: string; + readonly envFile?: string; + readonly noVerifyJwt?: boolean; + readonly importMap?: string; + readonly inspect?: boolean; + readonly inspectMode?: "run" | "brk" | "wait"; + readonly inspectMain?: boolean; +} + +export interface ServeManagedFunctionsOperations { + readonly createStack: ( + options: CreateStackOptions, + ) => Effect.Effect; + readonly loadConfig: ( + cwd: string, + ) => Effect.Effect; + readonly loadManifest?: ( + cwd: string, + config: CliConfig, + ) => Effect.Effect; + readonly readEnvFile?: ( + pathname: string, + ) => Effect.Effect>, unknown, FileSystem.FileSystem>; +} + +const defaultOperations: Required = { + createStack, + loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), + loadManifest: (cwd, config) => inferFunctionsManifest({ cwd, config }), + readEnvFile: (pathname) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(pathname))) { + return yield* Effect.fail( + new InvalidStackConfigError({ + message: `Functions env file was not found: ${pathname}`, + }), + ); + } + const contents = yield* fs.readFileString(pathname); + return yield* Effect.try({ + try: () => parseDotenv(contents), + catch: (error) => error, + }); + }), +}; + +const capabilityReady = (status: StackStatus): boolean => + status.capabilities.some( + (capability) => + capability.name === "functions" && + (capability.state === "ready" || capability.state === "dormant"), + ); + +const servingReady = (status: StackStatus): boolean => + status.lifecycle === "running" && status.endpoints.api !== undefined && capabilityReady(status); + +const statusAfterReadiness = Effect.fnUntraced(function* ( + stack: ManagedFunctionsStack, + initial: StackStatus, +) { + if (servingReady(initial)) return initial; + const next = yield* stack.watchStatus().pipe(Stream.filter(servingReady), Stream.runHead); + if (Option.isNone(next)) { + return yield* Effect.fail(new Error("Managed Functions stack did not reach gateway readiness")); + } + return next.value; +}); + +const toFunctionOverrides = (manifest: FunctionsManifest, options: ServeManagedFunctionsOptions) => + Object.fromEntries( + Object.entries(manifest).flatMap(([slug]) => { + const override = { + ...(options.noVerifyJwt === true ? { verify_jwt: false } : {}), + ...(options.importMap === undefined || !isFunctionScopedPath(slug, options.importMap) + ? {} + : { import_map: relativeFunctionPath(slug, options.importMap) }), + }; + return Object.keys(override).length === 0 ? [] : [[slug, override]]; + }), + ); + +/** Serves every local Function through the stack-owned Edge Runtime. */ +export const serveManagedFunctions = Effect.fnUntraced(function* ( + options: ServeManagedFunctionsOptions, + operations: ServeManagedFunctionsOperations = defaultOperations, +) { + const output = yield* Output; + const resolvedOperations: Required = { + ...defaultOperations, + ...operations, + loadManifest: operations.loadManifest ?? defaultOperations.loadManifest, + readEnvFile: operations.readEnvFile ?? defaultOperations.readEnvFile, + }; + + // Validate mutually-exclusive flags before finding or creating a stack. A malformed invocation + // must never leave a Supervisor running behind a command that is about to fail. + if (options.inspect && options.inspectMode !== undefined) { + return yield* Effect.fail( + new Error( + "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", + ), + ); + } + const inspectorMode = options.inspectMode ?? (options.inspect ? "brk" : undefined); + if (options.inspectMain && inspectorMode === undefined) { + return yield* Effect.fail( + new Error("--inspect-main must be used together with --inspect or --inspect-mode"), + ); + } + + // Decode and validate the stack input before acquiring an owner. Serving a + // disabled Functions capability can never reach readiness, so fail with a + // direct configuration fix instead of leaving the command waiting forever. + const loadedConfig = yield* resolvedOperations.loadConfig(options.projectRoot); + const translated = yield* toStartStackConfig(loadedConfig, []); + const functionsCapability = translated.capabilities?.functions; + if (functionsCapability !== undefined && "enabled" in functionsCapability) { + if (functionsCapability.enabled === false) { + return yield* Effect.fail( + new InvalidStackConfigError({ + message: + "Functions are disabled in supabase/config.toml; set [edge_runtime].enabled = true before running supabase functions serve.", + }), + ); + } + } + + const config = loadedConfig ?? (yield* Schema.decodeUnknownEffect(CliConfigSchema)({})); + const hasFunctionFlags = options.noVerifyJwt === true || options.importMap !== undefined; + const manifest = hasFunctionFlags + ? yield* resolvedOperations.loadManifest(options.projectRoot, config) + : undefined; + // The default functions/.env remains runtime-owned and must not alter the + // stack fingerprint on a no-flag serve join. An explicit --env-file is a + // deliberate stack input and is persisted as global Edge Runtime secrets. + const envFile = + options.envFile === undefined + ? {} + : yield* resolvedOperations.readEnvFile( + resolve(options.cwd ?? options.projectRoot, options.envFile), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* resolvedOperations.createStack({ + projectRoot: options.projectRoot, + name: options.stackName, + }); + const existingSettings = + functionsCapability !== undefined && "settings" in functionsCapability + ? functionsCapability.settings + : undefined; + const existingEdgeRuntime = existingSettings?.edge_runtime; + const existingSecrets = existingEdgeRuntime?.secrets ?? {}; + const functionDefaults = { + ...(options.noVerifyJwt === true ? { verify_jwt_default: false } : {}), + ...(options.importMap === undefined + ? {} + : { + import_map_default: relativeGlobalFunctionPath(options.importMap, { + projectRoot: options.projectRoot, + cwd: options.cwd, + }), + }), + }; + const edgeRuntimeSettings = { + ...existingEdgeRuntime, + ...functionDefaults, + ...(options.envFile === undefined + ? {} + : { + secrets: { + ...existingSecrets, + ...Object.fromEntries( + Object.entries(envFile).map(([name, value]) => [name, Redacted.make(value)]), + ), + }, + }), + }; + const hasExplicitChanges = + hasFunctionFlags || options.envFile !== undefined || inspectorMode !== undefined; + const functionsConfig = hasExplicitChanges + ? { + ...(functionsCapability !== undefined ? functionsCapability : {}), + enabled: true, + settings: { + ...existingSettings, + ...(manifest === undefined + ? {} + : { + functions: { + ...existingSettings?.functions, + ...toFunctionOverrides(manifest, options), + }, + }), + ...(inspectorMode === undefined + ? {} + : { inspector: { mode: inspectorMode, main: options.inspectMain === true } }), + ...(Object.keys(edgeRuntimeSettings).length === 0 + ? {} + : { edge_runtime: edgeRuntimeSettings }), + }, + } + : functionsCapability; + const status = yield* stack + .start({ + config: { + ...translated, + capabilities: { + ...translated.capabilities, + ...(functionsConfig === undefined ? {} : { functions: functionsConfig }), + }, + listeners: { + ...translated.listeners, + ...(inspectorMode === undefined + ? {} + : { + functionsInspector: translated.listeners?.functionsInspector ?? { + enabled: true, + }, + }), + }, + }, + }) + .pipe( + Effect.catchIf( + (error) => error instanceof StackMustBeStoppedError, + (error) => + Effect.fail( + new StackMustBeStoppedError({ + ...error, + message: `${error.message}; run supabase restart to apply stopped-time changes`, + }), + ), + ), + Effect.catchIf( + (error) => error instanceof StackUpgradeRequiredError, + (error) => + Effect.fail( + new StackUpgradeRequiredError({ + ...error, + message: `${error.message}; run supabase restart to upgrade the stack owner`, + }), + ), + ), + ); + const ready = yield* statusAfterReadiness(stack, status); + const apiUrl = ready.endpoints.api?.url; + yield* output.success(`Functions stack is ${ready.lifecycle}.`, { + stack: options.stackName, + functions_root: "supabase/functions", + lifecycle: ready.lifecycle, + }); + if (apiUrl !== undefined) yield* output.info(`${apiUrl}/functions/v1`); + yield* stack + .logs({ capabilities: ["functions"], follow: true }) + .pipe(Stream.runForEach((entry) => output.info(`[${entry.source}] ${entry.message}`))); + }), + ); +}); diff --git a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts index 97bc74db34..264da88ea7 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts @@ -21,6 +21,7 @@ describe("bundleServeMainTemplate", () => { expect(packageJson.exports).toEqual({ ".": "./src/index.ts", "./effect": "./src/effect.ts", + "./internal/supervisor": "./src/internal/supervisor-process.ts", "./testing": "./src/testing.ts", }); expect(bundleServeMainTemplate).toBe(stackBundleServeMainTemplate); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index f322f38833..22676f45f5 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -1,74 +1,20 @@ import { - CliConfigSchema, - findCliProjectPaths, - inferFunctionsManifest, - loadCliConfig, - resolveCliConfigSubtree, - resolveCliConfigValue, type CliConfig, - type CliProjectEnvironment, type ResolvedCliConfigValue, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config/effect"; -import { - defaultJwtSecret, - defaultPublishableKey, - defaultSecretKey, - edgeRuntimeNofileUlimit, -} from "../stack-constants.ts"; -import { - createHmac, - createPrivateKey, - sign as signJwtBytes, - type JsonWebKeyInput, -} from "node:crypto"; -import { existsSync, watch } from "node:fs"; -import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { styleText } from "node:util"; -import { - Cause, - Duration, - Effect, - Exit, - Layer, - Option, - Queue, - Redacted, - Schema, - Stream, -} from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { - legacyDescribeContainerCliFailure, - spawnContainerCli, -} from "../../legacy/shared/legacy-container-cli.ts"; -import { - LEGACY_SUGGEST_DOCKER_INSTALL, - legacyIsDockerDaemonUnreachable, -} from "../../legacy/shared/legacy-docker-suggest.ts"; +import { edgeRuntimeNofileUlimit } from "../stack-constants.ts"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; +import { Effect, Option, Redacted, Stream } from "effect"; import { parseDotEnv } from "../../legacy/shared/legacy-dotenv.ts"; -import { legacyViperEnvStringWithProjectFallback } from "../legacy/legacy-viper-env.ts"; -import { - resolveRemoteJwks, - resolveThirdPartyIssuerUrl, - thirdPartyIssuerUrlUnchecked, - toPublicJwk, -} from "../auth/jwks.ts"; import { Output } from "../output/output.service.ts"; -import { - FileWatcher, - FileWatcherError, - type FileWatchEvent, -} from "../runtime/file-watcher.service.ts"; -import { ProcessControl } from "../runtime/process-control.service.ts"; import { buildDockerBinds, discoverFunctionSlugs, type DockerBind, formatDockerBind, dockerWorkdirLabel, - rawFunctionConfigRecord, resolveFunctionConfigs, type ResolvedDeployFunctionConfig, } from "./deploy.ts"; @@ -79,66 +25,19 @@ import { ensureDockerNamedVolume, ensureDockerNetwork, localDockerId, - normalizeProjectId, - resolveDockerNetworkMode, - resolveEdgeRuntimeVersion, - resolveFunctionsDockerImage, runChildProcess, toDockerPath, } from "./functions-docker.ts"; -import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; -import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; -const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); -const defaultCliConfig = decodeCliConfig({}); const dockerRuntimeServerPort = 8081; const dockerRuntimeInspectorPort = 8083; -// Unix timestamp (~2032-11-30) used as the `exp` claim of the local-dev default -// JWTs, matching the Go CLI's hardcoded expiry for anon/service_role tokens. -const defaultJwtExpiry = 1983812996; -const defaultSigningKey = { - kty: "EC", - kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", - use: "sig", - key_ops: ["verify"], - alg: "ES256", - ext: true, - crv: "P-256", - x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", -} as const; const functionsDirName = join("supabase", "functions"); const fallbackEnvFilePath = join("supabase", "functions", ".env"); -const ignoredDirNames = new Set([ - ".git", - "node_modules", - ".vscode", - ".idea", - ".DS_Store", - "vendor", -]); -const dockerLogRetryDelay = Duration.millis(400); -const dockerLogDiagnosticTailLength = 4_096; -const defaultSupabaseEnv = "development"; const serveMainDir = "/root"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedLegacyFunctionsServeMainTemplate: string | undefined; -const watchIgnoreGlobs = [ - "**/.git/**", - "**/node_modules/**", - "**/.vscode/**", - "**/.idea/**", - "**/.DS_Store", - "**/vendor/**", - "**/*~", - "**/.*.swp", - "**/.*.swx", - "**/___*", - "**/*.tmp", - "**/.#*", -] as const; -export const FUNCTIONS_SERVE_INSPECT_MODES = ["run", "brk", "wait"] as const; +const FUNCTIONS_SERVE_INSPECT_MODES = ["run", "brk", "wait"] as const; export type FunctionsServeInspectMode = (typeof FUNCTIONS_SERVE_INSPECT_MODES)[number]; @@ -152,35 +51,6 @@ export interface FunctionsServeFlags { readonly all: boolean; } -export interface FunctionsServeDependencies { - readonly projectRoot: string; - readonly supabaseDir: string; - readonly flagCwd: string; - readonly platform: NodeJS.Platform; - readonly debug: boolean; - readonly networkId: Option.Option; - readonly projectIdOverride: Option.Option; - readonly goViperCompat: boolean; - /** - * `undefined` in `next`; the legacy shell injects - * `legacyFunctionsGoConfigCompat` so this file never imports `legacy/` - * directly — see {@link FunctionsGoConfigCompat}. Distinct from - * `goViperCompat` above, which only gates `env(...)` interpolation. - */ - readonly goConfigCompat: FunctionsGoConfigCompat | undefined; -} - -interface PlainServeAuthConfig { - readonly enabled: boolean; - readonly signing_keys_path?: string; - readonly publishable_key?: string; - readonly secret_key?: string; - readonly jwt_secret?: string; - readonly anon_key?: string; - readonly service_role_key?: string; - readonly third_party: CliConfig["auth"]["third_party"]; -} - export interface PlainServeEdgeRuntimeConfig { readonly policy: CliConfig["edge_runtime"]["policy"]; readonly inspector_port: number; @@ -188,19 +58,6 @@ export interface PlainServeEdgeRuntimeConfig { readonly secrets: Readonly>; } -interface ServeResolvedConfig { - readonly projectId: string; - readonly apiPort: number; - readonly auth: PlainServeAuthConfig; - readonly edgeRuntime: PlainServeEdgeRuntimeConfig; - readonly configDeclaredFunctions: Readonly>; - readonly configFunctions: Readonly>; - readonly rawConfigFunctions: Readonly>>>; - readonly configPath?: string; - /** Go's post-`loadNestedEnv` merged env (ambient-wins). `undefined` in `next`. */ - readonly projectEnvValues: Readonly> | undefined; -} - interface ServeFunctionContainerConfig { readonly verifyJWT: boolean; readonly entrypointPath: string; @@ -209,28 +66,16 @@ interface ServeFunctionContainerConfig { readonly env?: Readonly>; } -interface WatchSpec { - readonly root: string; - readonly recursive: boolean; - readonly matchPaths?: ReadonlySet; -} - export interface StartedRuntime { readonly containerId: string; readonly cleanup: Effect.Effect; - readonly watchSpecs: ReadonlyArray; } /** * Every already-resolved secret/key {@link startEdgeRuntimeContainer} needs, * matching {@link finalizeAuthArtifacts}'s return shape. Named and exported so - * a caller outside this module (`start`'s own edge-runtime bring-up, - * `legacy/commands/start/services/edge-runtime.service.ts`) can build the - * exact same shape from values it has already resolved itself, instead of - * calling {@link resolveLocalAuthArtifacts} (which re-reads `config.toml`/signing - * keys independently — correct for the standalone `functions serve` command, - * but would risk resolving different secrets than the rest of a `start` - * stack for a caller that already has these values). + * the legacy start bring-up can build the exact same shape from values it has + * already resolved, without reading `config.toml` or signing keys a second time. */ export interface ServeAuthArtifacts { readonly publishableKey: string; @@ -243,14 +88,8 @@ export interface ServeAuthArtifacts { /** * Everything {@link startEdgeRuntimeContainer} needs from `config.toml` - * beyond auth (see {@link ServeAuthArtifacts}) — a narrowed, exported view of - * {@link ServeResolvedConfig} (which also carries `auth`/`configPath`, used - * only to resolve {@link ServeAuthArtifacts} and therefore irrelevant once - * those are already resolved). `start`'s own bring-up builds this directly - * from its own already-loaded `CliConfig` via {@link toPlainEdgeRuntimeConfig}/ - * {@link toPlainFunctionRecord}/`inferFunctionsManifest` (`@supabase/config`) - * rather than going through {@link resolveServeConfig}'s independent - * config-loading pipeline. + * beyond auth (see {@link ServeAuthArtifacts}). The legacy start bring-up + * builds this directly from its already-loaded `CliConfig`. */ export interface ServeEdgeRuntimeContainerConfig { readonly projectId: string; @@ -264,27 +103,18 @@ export interface ServeEdgeRuntimeContainerConfig { } /** - * Input to {@link startEdgeRuntimeContainer} — the reusable "bring up one - * Edge Runtime container" core extracted from `serveFunctions`'s interactive - * loop, kept independent of BOTH `functions serve`'s own config-loading - * (`resolveServeConfig`/`resolveLocalAuthArtifacts`) and its file-watch/log-stream - * loop, so `start`'s bring-up can call it directly with values it has already - * resolved through its own pipeline (see `internal-db-connection.ts` for why - * {@link dbUrl} specifically must be caller-supplied rather than hardcoded). + * Input to {@link startEdgeRuntimeContainer}, the reusable "bring up one + * Edge Runtime container" core used by the legacy start bring-up. Config and + * secrets are resolved by the caller (see `internal-db-connection.ts` for why + * {@link dbUrl} must be caller-supplied rather than hardcoded). */ export interface StartEdgeRuntimeContainerInput { readonly onContainerCreated?: () => void; readonly config: ServeEdgeRuntimeContainerConfig; readonly authArtifacts: ServeAuthArtifacts; /** - * `SUPABASE_DB_URL`. Deliberately NOT hardcoded in the shared core: Go's own - * two callers resolve this differently — standalone `functions serve` - * (`restartEdgeRuntime`, `serve.go:122`) always uses the `db` network alias - * (`postgresql://postgres:postgres@db:5432/postgres`, matching - * {@link legacyDefaultServeDbUrl} below), while `start`'s direct call - * (`start.go:66-72,1103`) uses the real `dbConfig` — the `db` container's - * own sanitized name and `config.db.password` — NOT the alias. Every caller - * must supply its own Go-accurate value; this module does not choose one. + * `SUPABASE_DB_URL`. Deliberately not hardcoded in the shared core: the + * caller supplies the database URL matching its resolved runtime topology. */ readonly dbUrl: string; /** Already-resolved edge-runtime image reference (registry-mapped, tag/deno-version already applied). */ @@ -296,7 +126,7 @@ export interface StartEdgeRuntimeContainerInput { readonly debug: boolean; readonly networkId: string; readonly envFile: Option.Option; - /** Standalone `functions serve` discovers `supabase/functions//.env`; `start` does not. */ + /** Whether to load each function's local `.env` file; legacy start passes false. */ readonly discoverFunctionEnvFiles: boolean; readonly importMap: Option.Option; readonly noVerifyJwt: Option.Option; @@ -304,57 +134,8 @@ export interface StartEdgeRuntimeContainerInput { readonly inspectMain: boolean; } -type SigningKeyJwk = JsonWebKeyInput["key"] & { - readonly kty: "EC" | "RSA"; - readonly kid?: string; - readonly use?: string; - readonly ext?: boolean; - readonly n?: string; - readonly e?: string; - readonly crv?: string; - readonly x?: string; - readonly y?: string; - readonly alg?: "ES256" | "RS256"; - readonly key_ops?: ReadonlyArray; -}; - declare const SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; -export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => - FileWatcher.of({ - watch: (root, options) => - Stream.callback, FileWatcherError>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const recursive = options?.recursive ?? true; - const watcher = watch(root, { recursive }, (eventType, filename) => { - const pathname = - filename === null || filename === undefined || filename.length === 0 - ? root - : resolve(root, filename.toString()); - // Node's `fs.watch` only distinguishes "rename" (create/delete/ - // rename) from "change" (write); Go prints the real fsnotify op - // (`internal/functions/serve/watcher.go:100`). The closest - // recoverable equivalent is an existence check on "rename" - // events: present → create, gone → delete; "change" → update. - const type: FileWatchEvent["type"] = - eventType === "rename" ? (existsSync(pathname) ? "create" : "delete") : "update"; - Queue.offerUnsafe(queue, [{ path: pathname, type }]); - }); - watcher.on("error", (cause) => { - Queue.failCauseUnsafe(queue, Cause.fail(new FileWatcherError({ path: root, cause }))); - }); - return watcher; - }), - (watcher) => - Effect.sync(() => { - watcher.close(); - }), - ), - ), - }), -); - /** * `serve.main.ts` runs verbatim as a Deno entrypoint inside the edge-runtime * container (written to `/root/index.ts`). It is bundled into a single @@ -392,44 +173,6 @@ function reveal(value: string | Redacted.Redacted | undefined): string | return Redacted.isRedacted(value) ? Redacted.value(value) : value; } -function toPlainAuthConfig( - auth: CliConfig["auth"] | ResolvedCliConfigValue, -): PlainServeAuthConfig { - return { - enabled: auth.enabled, - signing_keys_path: reveal(auth.signing_keys_path), - publishable_key: reveal(auth.publishable_key), - secret_key: reveal(auth.secret_key), - jwt_secret: reveal(auth.jwt_secret), - anon_key: reveal(auth.anon_key), - service_role_key: reveal(auth.service_role_key), - third_party: { - firebase: { - enabled: auth.third_party.firebase.enabled, - project_id: reveal(auth.third_party.firebase.project_id), - }, - auth0: { - enabled: auth.third_party.auth0.enabled, - tenant: reveal(auth.third_party.auth0.tenant), - tenant_region: reveal(auth.third_party.auth0.tenant_region), - }, - aws_cognito: { - enabled: auth.third_party.aws_cognito.enabled, - user_pool_id: reveal(auth.third_party.aws_cognito.user_pool_id), - user_pool_region: reveal(auth.third_party.aws_cognito.user_pool_region), - }, - clerk: { - enabled: auth.third_party.clerk.enabled, - domain: reveal(auth.third_party.clerk.domain), - }, - workos: { - enabled: auth.third_party.workos.enabled, - issuer_url: reveal(auth.third_party.workos.issuer_url), - }, - }, - }; -} - /** * Exported so `start`'s own edge-runtime bring-up * (`legacy/commands/start/services/edge-runtime.service.ts`) can reuse this @@ -488,375 +231,6 @@ export function toPlainFunctionRecord( ); } -function normalizeEnvPath(flagCwd: string, pathname: string) { - return isAbsolute(pathname) ? pathname : resolve(flagCwd, pathname); -} - -function encodeBase64Url(input: string) { - return Buffer.from(input).toString("base64url"); -} - -function toJsonWebKey(signingKey: SigningKeyJwk): JsonWebKeyInput["key"] { - return { - ...signingKey, - ...(signingKey.key_ops === undefined ? {} : { key_ops: [...signingKey.key_ops] }), - }; -} - -function jwtPayload(role: string, exp: number) { - return JSON.stringify({ iss: "supabase-demo", role, exp }); -} - -function generateSymmetricJwt(secret: string, role: string) { - const header = encodeBase64Url(JSON.stringify({ alg: "HS256", typ: "JWT" })); - const payload = encodeBase64Url(jwtPayload(role, defaultJwtExpiry)); - const data = `${header}.${payload}`; - const signature = createHmac("sha256", secret).update(data).digest("base64url"); - return `${data}.${signature}`; -} - -function generateAsymmetricJwt(signingKey: SigningKeyJwk, role: string) { - const algorithm = signingKey.alg; - if (algorithm !== "ES256" && algorithm !== "RS256") { - throw new Error(`unsupported algorithm: ${String(algorithm)}`); - } - - const header = { - alg: algorithm, - typ: "JWT", - ...(signingKey.kid === undefined ? {} : { kid: signingKey.kid }), - }; - const payload = { - iss: "supabase-demo", - role, - exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 10, - }; - const encodedHeader = encodeBase64Url(JSON.stringify(header)); - const encodedPayload = encodeBase64Url(JSON.stringify(payload)); - const data = `${encodedHeader}.${encodedPayload}`; - const key = createPrivateKey({ - key: toJsonWebKey(signingKey), - format: "jwk", - }); - const signature = signJwtBytes("sha256", Buffer.from(data), { - key, - ...(algorithm === "ES256" ? { dsaEncoding: "ieee-p1363" as const } : {}), - }).toString("base64url"); - return `${data}.${signature}`; -} - -async function readSigningKeys(pathname: string): Promise> { - const decoded = JSON.parse(await readFile(pathname, "utf8")); - if (!Array.isArray(decoded)) { - throw new Error("expected a JSON array"); - } - return decoded as ReadonlyArray; -} - -/** - * {@link resolveLocalAuthArtifacts}'s return shape — everything - * {@link finalizeAuthArtifacts} needs to assemble the final - * {@link ServeAuthArtifacts} once the remote-JWKS fetch is allowed to run - * (i.e. after the DB assertion — see {@link startEdgeRuntime}). - */ -interface ServeLocalAuthArtifacts { - readonly publishableKey: string; - readonly secretKey: string; - readonly jwtSecret: string; - readonly anonKey: string; - readonly serviceRoleKey: string; - /** Third-party issuer to fetch remote JWKS from, if one is configured. */ - readonly issuerUrl: string | undefined; - /** Local JWKS entries (signing keys / oct fallback), appended AFTER any remote keys (`config.go:1776-1786`). */ - readonly localKeys: ReadonlyArray; -} - -/** - * Config-load-time auth resolution — exactly the work Go performs during - * `flags.LoadConfig`/`Config.Validate`, BEFORE `AssertSupabaseDbIsRunning`: - * the signing-keys read (`pkg/config/config.go:1110-1115`), the - * `auth.jwt_secret` ≥16-chars check (`pkg/config/apikeys.go:43-47` via - * `config.go:1156`), and anon/service-role key generation. Deliberately does - * NOT fetch remote JWKS: Go only does that inside `ServeFunctions` - * (`serve.go:141` → `ResolveJWKS`, `config.go:1727-1776`), after the DB - * assertion — that half lives in {@link finalizeAuthArtifacts} so a config - * error here still beats a docker-down error, matching Go's precedence. - */ -const resolveLocalAuthArtifacts = Effect.fnUntraced(function* ( - auth: PlainServeAuthConfig, - configPath: string | undefined, -) { - const signingKeysPath = - auth.signing_keys_path === undefined || auth.signing_keys_path.length === 0 - ? "" - : isAbsolute(auth.signing_keys_path) - ? auth.signing_keys_path - : resolve( - dirname(configPath ?? join(process.cwd(), "supabase", "config.toml")), - auth.signing_keys_path, - ); - - const signingKeys = yield* Effect.tryPromise({ - try: async () => (signingKeysPath.length === 0 ? [] : await readSigningKeys(signingKeysPath)), - catch: (cause) => { - if (cause instanceof SyntaxError) { - return new Error(`failed to decode signing keys: ${cause.message}`); - } - return new Error( - `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - }, - }); - - const jwtSecret = - auth.jwt_secret === undefined || auth.jwt_secret.length === 0 - ? defaultJwtSecret - : auth.jwt_secret; - if (jwtSecret.length < 16) { - return yield* Effect.fail( - new Error("Invalid config for auth.jwt_secret. Must be at least 16 characters"), - ); - } - - const anonKey = - auth.anon_key === undefined || auth.anon_key.length === 0 - ? signingKeys.length > 0 - ? generateAsymmetricJwt(signingKeys[0]!, "anon") - : generateSymmetricJwt(jwtSecret, "anon") - : auth.anon_key; - const serviceRoleKey = - auth.service_role_key === undefined || auth.service_role_key.length === 0 - ? signingKeys.length > 0 - ? generateAsymmetricJwt(signingKeys[0]!, "service_role") - : generateSymmetricJwt(jwtSecret, "service_role") - : auth.service_role_key; - const shouldUseJwtSecretFallback = signingKeysPath.length === 0; - - // Go's `Auth.ThirdParty.validate()` (the "at most one enabled" + required-field checks - // `resolveThirdPartyIssuerUrl` performs) only runs inside `Config.Validate`'s `if - // c.Auth.Enabled` block (`config.go:1087-1153`), but `functions serve`'s own JWKS resolution - // (`serve.go:141`) discards `ResolveJWKS`'s error unconditionally, regardless of `auth.enabled`. - // So a malformed/multi-enabled third-party config must not throw here when auth is disabled — - // use the unchecked, no-throw `IssuerURL()`-only builder instead, matching Go exactly. - const issuerUrl = auth.enabled - ? resolveThirdPartyIssuerUrl(auth.third_party) - : thirdPartyIssuerUrlUnchecked(auth.third_party); - const localKeys: unknown[] = []; - localKeys.push( - ...(signingKeys.length > 0 - ? signingKeys.map(toPublicJwk) - : shouldUseJwtSecretFallback - ? [defaultSigningKey] - : []), - ); - if (shouldUseJwtSecretFallback) { - localKeys.push({ - kty: "oct", - k: Buffer.from(jwtSecret).toString("base64url"), - }); - } - - return { - publishableKey: - auth.publishable_key === undefined || auth.publishable_key.length === 0 - ? defaultPublishableKey - : auth.publishable_key, - secretKey: - auth.secret_key === undefined || auth.secret_key.length === 0 - ? defaultSecretKey - : auth.secret_key, - jwtSecret, - anonKey, - serviceRoleKey, - issuerUrl, - localKeys, - } satisfies ServeLocalAuthArtifacts; -}); - -/** - * The post-assertion half of `functions serve`'s auth resolution — Go's - * `ResolveJWKS` call inside `ServeFunctions` (`serve.go:141`, - * `pkg/config/config.go:1727-1786`): fetch the third-party provider's remote - * JWKS (two sequential OIDC/JWKS requests with 10s-timeout clients, - * `config.go:1727-1776`) with the fetch error discarded (`jwks, _ :=`), then - * assemble the final key set with remote keys FIRST and local keys after - * (`config.go:1776-1786`). Kept separate from - * {@link resolveLocalAuthArtifacts} so `startEdgeRuntime` can run it strictly - * after `assertLocalDbRunning`, matching Go's ordering — with Docker down, no - * external JWKS request is ever made. - */ -const finalizeAuthArtifacts = Effect.fnUntraced(function* (local: ServeLocalAuthArtifacts) { - const keys: unknown[] = []; - if (local.issuerUrl !== undefined) { - const issuerUrl = local.issuerUrl; - const remoteJwks = yield* Effect.tryPromise({ - try: () => resolveRemoteJwks(issuerUrl), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe(Effect.catch(() => Effect.succeed([] as ReadonlyArray))); - keys.push(...remoteJwks); - } - keys.push(...local.localKeys); - - return { - publishableKey: local.publishableKey, - secretKey: local.secretKey, - jwtSecret: local.jwtSecret, - anonKey: local.anonKey, - serviceRoleKey: local.serviceRoleKey, - jwks: JSON.stringify({ keys }), - } satisfies ServeAuthArtifacts; -}); - -const resolveServeConfig = Effect.fnUntraced(function* ( - projectRoot: string, - projectIdOverride: Option.Option, - goViperCompat: boolean, - goConfigCompat: FunctionsGoConfigCompat | undefined, -) { - const projectEnv = yield* loadServeCliProjectEnvironment(projectRoot); - const projectRef = Option.match(projectIdOverride, { - onNone: () => undefined, - onSome: (value) => { - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; - }, - }); - // `loadCliConfig` interpolates `env()` references against the project - // environment. We resolve that environment ourselves (Go-accurate, layering - // `.env.`/`.env.local`/`.env` over the ambient env) and pass it - // in, so loading neither re-reads those files nor mutates `process.env`. - // - // `search: false`/`tomlOnly: true` when `goConfigCompat` is set (legacy - // shell): this MUST match `loadFunctionsCliConfig`'s own options below - // exactly, or the two loads can resolve two different files (an ancestor's - // config.toml vs this dir's; a stray config.json vs config.toml) — one - // supplying `auth`/`edgeRuntime`/`apiPort` here, the other supplying - // `denoVersion`/`Config.Validate` below, silently mixing fields from two - // different projects. `next` (`goConfigCompat === undefined`) keeps the - // package defaults (ancestor search, JSON preferred), unchanged. - const loadedConfig = yield* loadCliConfig(projectRoot, { - ...(projectRef === undefined ? {} : { projectRef }), - ...(projectEnv === null ? {} : { cliProjectEnv: projectEnv }), - goViperCompat, - ...(goConfigCompat === undefined ? {} : { search: false, tomlOnly: true }), - }); - const baseConfig = loadedConfig?.config ?? defaultCliConfig; - - const auth = - projectEnv === null - ? toPlainAuthConfig(baseConfig.auth) - : toPlainAuthConfig( - yield* resolveCliConfigSubtree(baseConfig.auth, projectEnv, "auth", { goViperCompat }), - ); - const edgeRuntime = - projectEnv === null - ? toPlainEdgeRuntimeConfig(baseConfig.edge_runtime) - : toPlainEdgeRuntimeConfig( - yield* resolveCliConfigSubtree(baseConfig.edge_runtime, projectEnv, "edge_runtime", { - goViperCompat, - }), - ); - const apiPort = - projectEnv === null - ? baseConfig.api.port - : (yield* resolveCliConfigSubtree(baseConfig.api, projectEnv, "api", { goViperCompat })).port; - const configDeclaredFunctions = - projectEnv === null - ? toPlainFunctionRecord(baseConfig.functions) - : toPlainFunctionRecord( - yield* resolveCliConfigSubtree(baseConfig.functions, projectEnv, "functions", { - goViperCompat, - }), - ); - const configForManifest: CliConfig = { - ...baseConfig, - functions: configDeclaredFunctions, - }; - const configFunctions = yield* inferFunctionsManifest({ - cwd: projectRoot, - config: configForManifest, - }); - const configProjectId = - projectEnv === null - ? (baseConfig.project_id ?? "") - : (reveal( - yield* resolveCliConfigValue(baseConfig.project_id ?? "", projectEnv, "project_id", { - goViperCompat, - }), - ) ?? ""); - const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); - const fallbackProjectId = basename(resolve(projectRoot)); - - // Go: `flags.LoadConfig` -> `Config.Validate` (`pkg/config/config.go:878,989-1192`) - // — `restartEdgeRuntime` runs this FIRST, before `AssertSupabaseDbIsRunning` - // (see this function's own caller for that ordering) — so an invalid - // config must fail here too, before any Docker check. Legacy shell only; - // `next` keeps its own package-default config resolution above unchanged. - // A second, independent config/dotenv load (rather than reusing this - // function's own `loadedConfig`/`projectEnv` above) — that pipeline's - // `env(...)`-interpolation purpose is unrelated to Go's `SUPABASE_*` - // `AutomaticEnv` override system this one provides, and the two shouldn't - // be entangled for a shipped, long-running command's config path. - // `search`/`tomlOnly` are aligned with this file's own `loadedConfig` call - // above (see its comment) so the two loads can never disagree about which - // file is "the" project config. `projectEnvValues` (for registry/network-id - // env lookups, this file's own caller) and the env-overridden - // `deno_version` are consumed from it; `auth`/`apiPort`/functions above - // keep their existing derivation. `projectId` also keeps its existing - // derivation — a known gap, narrow to trigger but NOT cosmetic when hit: - // unlike `deploy`/`download` (which use `context.projectId` outright), - // `rawProjectId` below only ever sees `SUPABASE_PROJECT_ID` from the - // *ambient* shell (`projectIdOverride`, from `LegacyCliSettings`), not from - // project dotenv. A project that sets it only in `supabase/.env` therefore - // gets a different `supabase_edge_runtime_`/`supabase_network_` - // here than `deploy`/`download`/`start` resolve for the SAME project — so - // `serve` creates a second network and a container `reloadKong(projectId)`'s - // Kong (named off the other id) can't route to: a silently non-functional - // `serve`, where Go reads one `Config.ProjectId` for everything. Folding - // `goContext.projectEnvValues` in here would also require reconciling this - // function's `projectIdOverride`-wins-unconditionally precedence with - // `legacyResolveLocalProjectId`'s config-file-wins-over-`projectRef` - // precedence (they're not the same order) — left open rather than risking - // that regression under time pressure (review round on CLI-1963). - const goContext = - goConfigCompat === undefined - ? undefined - : yield* loadFunctionsCliConfig({ - projectRoot, - projectRef, - goConfigCompat, - }); - - return { - projectId: normalizeProjectId(rawProjectId.length > 0 ? rawProjectId : fallbackProjectId), - apiPort, - auth, - edgeRuntime: - goContext === undefined - ? edgeRuntime - : { ...edgeRuntime, deno_version: goContext.denoVersion }, - configDeclaredFunctions, - configFunctions, - rawConfigFunctions: rawFunctionConfigRecord(loadedConfig?.document), - configPath: loadedConfig?.path, - projectEnvValues: goContext?.projectEnvValues, - } satisfies ServeResolvedConfig; -}); - -export function resolveFunctionsServeInspectMode( - flags: FunctionsServeFlags, -): FunctionsServeInspectMode | undefined { - if (flags.inspect && Option.isSome(flags.inspectMode)) { - throw new Error( - "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", - ); - } - if (Option.isSome(flags.inspectMode)) { - return flags.inspectMode.value; - } - return flags.inspect ? "brk" : undefined; -} - export function buildFunctionsServeInspectArgs( inspectMode: FunctionsServeInspectMode | undefined, inspectMain: boolean, @@ -878,6 +252,10 @@ export function buildFunctionsServeInspectArgs( ]; } +function normalizeEnvPath(flagCwd: string, pathname: string) { + return isAbsolute(pathname) ? pathname : resolve(flagCwd, pathname); +} + const readDotEnvFile = Effect.fnUntraced(function* (pathname: string, optional: boolean) { const contents = yield* Effect.tryPromise({ try: () => @@ -977,11 +355,8 @@ async function writeDockerEnvFile(env: Readonly>, dir: st return undefined; } - // Self-healing: `dir` is a deterministic, reused path (not a fresh mkdtemp - // each call), so a stale directory from an earlier invocation in the same - // process (e.g. `functions serve`'s watch-mode restart loop) is removed - // first — otherwise leftover files from a shrinking env set would survive - // alongside the fresh write. + // Self-healing: `dir` is deterministic and reused across invocations, so + // remove any stale files before writing a shrinking environment set. await rm(dir, { recursive: true, force: true }); await mkdir(dir, { recursive: true, mode: 0o700 }); const path = join(dir, "docker.env"); @@ -1067,10 +442,6 @@ function validateDockerMultilineEnvNames(env: ReadonlyArray - value === undefined ? [] : [[key, value]], - ), - ); -} - -const loadServeCliProjectEnvironment = Effect.fnUntraced(function* (projectRoot: string) { - const paths = yield* findCliProjectPaths(projectRoot); - if (paths === null) { - return null; - } - - const values: Record = ambientProjectEnv(); - const sources: Record = Object.fromEntries( - Object.keys(values).map((key) => [key, "ambient"]), - ); - const loadedPaths: string[] = []; - const env = process.env["SUPABASE_ENV"] || defaultSupabaseEnv; - - for (const dir of [paths.supabaseDir, paths.projectRoot]) { - for (const filename of loadDefaultEnvFilenames(env)) { - const envPath = join(dir, filename); - const contents = yield* Effect.tryPromise({ - try: () => - readFile(envPath, "utf8").then( - (value) => value, - (error) => { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return undefined; - } - throw error; - }, - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); - if (contents === undefined) { - continue; - } - loadedPaths.push(envPath); - const parsed = yield* Effect.try({ - try: () => parseDotEnv(contents), - catch: (cause) => sanitizeDotEnvParseError(envPath, cause), - }); - for (const [key, value] of Object.entries(parsed)) { - if (values[key] !== undefined) { - continue; - } - values[key] = value; - sources[key] = filename.includes(".local") ? ".env.local" : ".env"; - } - } - } - - return { paths, values, loadedPaths, sources } satisfies CliProjectEnvironment; -}); - -/** - * Whether any bind mounts something at `containerPath` or below it, i.e. whether - * that path exists inside the container. Docker creates a missing `--workdir`, - * but Podman rejects the container outright (supabase/cli#6035), so the flag can - * only be set for a path a bind actually materializes. - */ +/** Whether any bind mounts something at `containerPath` or below it. */ function hasBindUnder(binds: Iterable, containerPath: string): boolean { for (const bind of binds) { if ( @@ -1178,319 +486,6 @@ function hasBindUnder(binds: Iterable, containerPath: string): boole return false; } -async function buildWatchSpecs( - binds: ReadonlyArray, -): Promise> { - const specs = new Map(); - - for (const bind of binds) { - const hostPath = bind.hostPath; - if (!isAbsolute(hostPath)) { - continue; - } - - try { - const info = await stat(hostPath); - if (info.isDirectory()) { - specs.set(hostPath, { root: hostPath, recursive: true }); - } else { - const root = dirname(hostPath); - const existing = specs.get(root); - if (existing !== undefined && existing.matchPaths === undefined) { - continue; - } - const matchPaths = new Set(existing?.matchPaths ?? []); - matchPaths.add(hostPath); - specs.set(root, { root, recursive: false, matchPaths }); - } - } catch { - continue; - } - } - - return [...specs.values()]; -} - -function shouldIgnoreEvent(pathname: string) { - const normalized = pathname.replaceAll("\\", "/"); - const segments = normalized.split("/"); - if (segments.some((segment) => ignoredDirNames.has(segment))) { - return true; - } - const base = segments[segments.length - 1] ?? normalized; - return ( - base.endsWith("~") || - (base.startsWith(".") && base.endsWith(".swp")) || - (base.startsWith(".") && base.endsWith(".swx")) || - base.startsWith("___") || - base.endsWith(".tmp") || - base.startsWith(".#") - ); -} - -function eventMatchesSpec(spec: WatchSpec, event: FileWatchEvent) { - if (shouldIgnoreEvent(event.path)) { - return false; - } - if (spec.matchPaths === undefined) { - return true; - } - return spec.matchPaths.has(event.path); -} - -/** - * fsnotify op tokens as Go prints them in the file-change line - * (`event.Op.String()`, `internal/functions/serve/watcher.go:100`). RENAME and - * CHMOD are unreachable here: Node's `fs.watch` folds renames into - * create/delete pairs and does not report metadata-only changes. - */ -const goFileEventOp = { create: "CREATE", update: "WRITE", delete: "REMOVE" } as const; - -const waitForRestartSignal = Effect.fnUntraced(function* (watchSpecs: ReadonlyArray) { - if (watchSpecs.length === 0) { - return yield* Effect.never; - } - - const fileWatcher = yield* FileWatcher; - const output = yield* Output; - - const stream = Stream.mergeAll( - watchSpecs.map((spec) => - fileWatcher - .watch(spec.root, { - ignore: watchIgnoreGlobs, - recursive: spec.recursive, - }) - .pipe( - Stream.map((events) => events.filter((event) => eventMatchesSpec(spec, event))), - Stream.filter((events) => events.length > 0), - ), - ), - { concurrency: "unbounded" }, - ).pipe( - Stream.tap((events) => - Effect.forEach(events, (event) => - output.raw( - `File change detected: ${event.path} (${goFileEventOp[event.type]})\n`, - "stderr", - ), - ).pipe(Effect.asVoid), - ), - Stream.debounce(Duration.millis(500)), - ); - - const next = yield* Stream.runHead(stream); - return Option.match(next, { - onNone: () => Effect.never, - onSome: () => Effect.void, - }); -}); - -function forwardByteStream( - stream: Stream.Stream, - write: (text: string, stream: "stdout" | "stderr") => Effect.Effect, - streamName: "stdout" | "stderr", -) { - const decoder = new TextDecoder(); - return Stream.runForEach(stream, (chunk) => - write(decoder.decode(chunk, { stream: true }), streamName), - ).pipe(Effect.andThen(write(decoder.decode(), streamName))); -} - -function isRetriableDockerLogsError(stderr: string) { - const normalized = stderr.toLowerCase(); - return ( - normalized.includes("no such container") || - normalized.includes("no such object") || - normalized.includes("conflict") || - normalized.includes("can not get logs from container which is dead or marked for removal") - ); -} - -function appendDiagnosticTail(existing: string, text: string) { - const combined = existing + text; - return combined.length <= dockerLogDiagnosticTailLength - ? combined - : combined.slice(combined.length - dockerLogDiagnosticTailLength); -} - -const inspectContainerExitCode = Effect.fnUntraced(function* (containerId: string) { - const result = yield* runChildProcess( - "docker", - ["container", "inspect", "--format", "{{.State.ExitCode}}", containerId], - { - stdout: "pipe", - stderr: "pipe", - }, - ); - - if (result.exitCode !== 0) { - const detail = result.stderr.trim() || result.stdout.trim() || "failed to inspect container"; - return yield* Effect.fail(new Error(detail)); - } - - const exitCode = Number.parseInt(result.stdout.trim(), 10); - if (Number.isNaN(exitCode)) { - return yield* Effect.fail( - new Error(`failed to parse container exit code: ${result.stdout.trim()}`), - ); - } - - return exitCode; -}); - -const streamContainerLogs = Effect.fnUntraced(function* (containerId: string) { - const output = yield* Output; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - for (;;) { - const child = yield* spawnContainerCli(spawner, ["logs", "-f", "--timestamps", containerId], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - extendEnv: true, - }); - - let stderrText = ""; - const [exitCode] = yield* Effect.all( - [ - child.exitCode.pipe(Effect.map(Number)), - forwardByteStream(child.stdout, (text, stream) => output.raw(text, stream), "stdout"), - forwardByteStream( - child.stderr, - (text, stream) => { - stderrText = appendDiagnosticTail(stderrText, text); - return output.raw(text, stream); - }, - "stderr", - ), - ], - { concurrency: "unbounded" }, - ); - - if (exitCode === 0) { - const containerExitCode = yield* inspectContainerExitCode(containerId); - if (containerExitCode === 0) { - return yield* Effect.fail(new Error(`container exited gracefully: ${containerId}`)); - } - if (containerExitCode === 137) { - yield* Effect.sleep(dockerLogRetryDelay); - continue; - } - return yield* Effect.fail(new Error(`error running container: exit ${containerExitCode}`)); - } - - const trimmedStderr = stderrText.trim(); - if (!isRetriableDockerLogsError(trimmedStderr)) { - return yield* Effect.fail( - new Error(trimmedStderr.length > 0 ? trimmedStderr : `docker logs exited with ${exitCode}`), - ); - } - - yield* Effect.sleep(dockerLogRetryDelay); - } -}); - -const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { - const dbId = localDockerId("db", projectId); - // A spawn failure (neither `docker` nor `podman` on PATH) must keep its - // cause: it is the shell-out equivalent of Go's missing daemon socket, which - // `client.IsErrConnectionFailed` classifies as a connection failure and so - // gets the Docker Desktop install hint (`internal/utils/misc.go:155-166`). - // Blanking stderr here would demote it to a bare "failed to inspect - // service" with no guidance. - const result = yield* runChildProcess("docker", ["container", "inspect", dbId], { - stdout: "ignore", - stderr: "pipe", - }).pipe( - Effect.catch((cause) => - Effect.succeed({ exitCode: 1, stdout: "", stderr: legacyDescribeContainerCliFailure(cause) }), - ), - ); - - if (result.exitCode === 0) { - return; - } - - if (result.stderr.includes("No such container") || result.stderr.includes("No such object")) { - return yield* Effect.fail(new Error("supabase start is not running.")); - } - - const message = - result.stderr.trim().length > 0 - ? `failed to inspect service: ${result.stderr.trim()}` - : "failed to inspect service"; - // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` - // on a daemon-connection failure (`internal/utils/misc.go:155-166`), which - // `recoverAndExit` prints on its own stderr line after the red error - // (`cmd/root.go:300-303`) — mirrored here by the `suggestion` property that - // `normalizeCliError`/`Output.fail` render the same way. - return yield* Effect.fail( - legacyIsDockerDaemonUnreachable(result.stderr) - ? Object.assign(new Error(message), { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL }) - : new Error(message), - ); -}); - -const bestEffortRemoveContainer = Effect.fnUntraced(function* (containerId: string) { - yield* runChildProcess("docker", ["container", "rm", "-f", "-v", containerId], { - stdout: "ignore", - stderr: "ignore", - }).pipe(Effect.ignore); -}); - -// One step of Edge Runtime's create → cp → start bring-up. Only the cp step passes a -// `messagePrefix` — its raw stderr is uninterpretable alone — while create/start keep the -// `docker run -d` era stderr surface byte-identical. -const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( - args: ReadonlyArray, - opts: { readonly messagePrefix?: string; readonly stdin?: Stream.Stream } = {}, -) { - const result = yield* runChildProcess("docker", args, { - stdin: opts.stdin, - stdout: "pipe", - stderr: "pipe", - }); - if (result.exitCode !== 0) { - const detail = result.stderr.trim() || result.stdout.trim(); - const message = - opts.messagePrefix === undefined - ? detail || "failed to start edge runtime" - : detail.length > 0 - ? `${opts.messagePrefix}: ${detail}` - : opts.messagePrefix; - return yield* Effect.fail(new Error(message)); - } -}); - -const reloadKong = Effect.fnUntraced(function* (projectId: string) { - const output = yield* Output; - const kongId = localDockerId("kong", projectId); - // Reload re-renders nginx.conf from Kong's default template, so it needs the - // template bring-up wrote (`kong.service.ts`; formerly Go's - // `start.go:589-592`, deleted as unreachable in CLI-1966, last present at - // commit a253ccba2) handed back — otherwise it drops that template's - // `email_templates` server (#6059). Go's own `restartEdgeRuntime` - // (`internal/functions/serve/serve.go:129`) passes the same flag for the - // same reason — an earlier revision of this file dropped it believing it was - // start-only (#5976), which #6065 proved wrong. - const result = yield* runChildProcess( - "docker", - ["exec", kongId, "kong", "reload", "--nginx-conf", "/home/kong/custom_nginx.template"], - { stdout: "ignore", stderr: "pipe" }, - ).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); - - if (result.exitCode !== 0) { - const suffix = result.stderr.trim().length > 0 ? ` ${result.stderr.trim()}` : ""; - yield* output.raw(`Warning: failed to reload Kong:${suffix}\n`, "stderr"); - } -}); - -const writeStoppedServingMessage = Effect.fnUntraced(function* () { - const output = yield* Output; - yield* output.raw(`Stopped serving ${styleText("bold", functionsDirName)}\n`, "stdout"); -}); - export function buildServeEntrypointCommand( command: ReadonlyArray, multilineEnvScriptPath?: string, @@ -1527,19 +522,9 @@ const resolveServeFunctionConfigs = Effect.fnUntraced(function* ( /** * Docker bind mounts (function source, import map, static assets) for every - * enabled function under `supabase/functions/**` — Go's - * `serve.PopulatePerFunctionConfigs` (`internal/functions/serve/serve.go: - * 277-318`), called both from Edge Runtime bring-up below (as part of its - * own loop) and, standalone, from `start`'s Studio container spec - * (formerly `internal/start/start.go:1149-1159`, deleted as unreachable in - * CLI-1966; last present at commit a253ccba2), which needs only the bind mounts, - * unconditionally of whether Edge Runtime itself is enabled or excluded. - * `PopulatePerFunctionConfigs` logs `Skipped serving Function: ` - * unconditionally for every disabled function, regardless of which of its - * two callers invoked it — so this shared helper reproduces that logging - * too. Note this means Go (and this port) genuinely double-prints the - * message when both Edge Runtime and Studio are enabled, since both call - * sites fire; don't dedupe it, that would itself diverge from Go. + * enabled function under `supabase/functions/**`. Both the legacy Edge Runtime + * and Studio bring-ups use this helper, so disabled-function diagnostics are + * emitted consistently for either caller. */ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBindMounts")( function* ( @@ -1599,26 +584,34 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin }, ); +// One step of Edge Runtime's create → cp → start bring-up. Only the cp step passes a +// `messagePrefix` — its raw stderr is uninterpretable alone — while create/start keep the +// `docker run -d` era stderr surface byte-identical. +const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( + args: ReadonlyArray, + opts: { readonly messagePrefix?: string; readonly stdin?: Stream.Stream } = {}, +) { + const result = yield* runChildProcess("docker", args, { + stdin: opts.stdin, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + const message = + opts.messagePrefix === undefined + ? detail || "failed to start edge runtime" + : detail.length > 0 + ? `${opts.messagePrefix}: ${detail}` + : opts.messagePrefix; + return yield* Effect.fail(new Error(message)); + } +}); + /** - * The reusable "bring up one Edge Runtime container" core — Go's - * `ServeFunctions` (`internal/functions/serve/serve.go:135-252`), called both - * by standalone `functions serve` (indirectly, via `startEdgeRuntime` below, - * mirroring Go's `restartEdgeRuntime` wrapper) and directly by `start`'s own - * bring-up (formerly `internal/start/start.go:1101-1108`, no wrapper step in - * between; `internal/start` was deleted as unreachable in CLI-1966, last - * present at commit a253ccba2). - * Deliberately excludes everything `ServeFunctions` itself excludes too: no - * config-loading (caller resolves {@link StartEdgeRuntimeContainerInput.config}/ - * {@link StartEdgeRuntimeContainerInput.authArtifacts} itself, matching how - * Go's two callers each resolve `config.toml`/secrets once, independently, and - * pass already-resolved values/strings into this shared core — see - * `serve.go:141-151` vs. `start.go:66-72`), no file-watching, and no log - * streaming (`serveFunctions`'s own loop, below, still owns both of those for - * the standalone command). Also excludes the Kong reload: `ServeFunctions` - * itself never reloads Kong — that only happens in `restartEdgeRuntime` - * (`startEdgeRuntime` below), after this core succeeds, so `start`'s own - * bring-up (which calls this core directly) correctly never reloads Kong - * either. + * Reusable Edge Runtime container bring-up used by the legacy start path. + * Config, secrets, file watching, and log streaming remain outside this core; + * Kong reload is also owned by the caller after this operation succeeds. */ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeContainer")( function* (input: StartEdgeRuntimeContainerInput) { @@ -1662,7 +655,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const functionsDir = join(input.projectRoot, functionsDirName); const functionBinds = new Map(); - const watchableBinds = new Map(); const emittedScopeWarnings = new Set(); const functionsConfig: Record = {}; for (const config of functionConfigs) { @@ -1683,9 +675,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo )) { const key = formatDockerBind(bind); functionBinds.set(key, bind); - if (!bind.externalScope) { - watchableBinds.set(key, bind); - } } const missingSourceWarning = bindWarnings.find((warning) => warning.includes("failed to read file:"), @@ -1840,239 +829,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo return { containerId, cleanup: removeRuntimeArtifacts.pipe(Effect.orDie), - watchSpecs: yield* Effect.promise(() => buildWatchSpecs([...watchableBinds.values()])), } satisfies StartedRuntime; }).pipe(Effect.onError(() => bestEffortCleanupRuntimeArtifacts)); }, ); - -/** - * `SUPABASE_DB_URL`'s value for standalone `functions serve` — Go's - * `restartEdgeRuntime` (`internal/functions/serve/serve.go:121-122`): "Use - * network alias because Deno cannot resolve `_` in hostname", always - * `postgresql://postgres:postgres@db:5432/postgres` regardless of - * project/config (`db`, `utils.DbAliases[0]`, is a fixed network alias, and - * `db.Password` is `toml:"-"` — never configurable, always the `"postgres"` - * literal default, `pkg/config/config.go:459`). This is genuinely NOT the - * same value `start`'s own bring-up uses — see - * {@link StartEdgeRuntimeContainerInput.dbUrl}'s doc comment. - */ -const legacyDefaultServeDbUrl = "postgresql://postgres:postgres@db:5432/postgres"; - -/** - * Go's `restartEdgeRuntime` (`internal/functions/serve/serve.go:108-133`): - * resolves `functions serve`'s own config/secrets/image independently on - * every (re)start, then delegates the actual bring-up to - * {@link startEdgeRuntimeContainer} (Go's `ServeFunctions`) exactly like - * `start`'s own bring-up will. Once that bring-up succeeds, this wrapper — and - * only this wrapper, matching Go's `restartEdgeRuntime` — reloads Kong - * (`serve.go:126-131`) so Kong's routing table picks up the freshly - * (re)started container. - */ -const startEdgeRuntime = Effect.fnUntraced(function* (input: { - readonly flags: FunctionsServeFlags; - readonly dependencies: FunctionsServeDependencies; - readonly debug: boolean; - readonly networkId: Option.Option; - readonly inspectMode: FunctionsServeInspectMode | undefined; -}) { - const output = yield* Output; - // Deliberately NO docker precheck here — Go's `restartEdgeRuntime` - // (`internal/functions/serve/serve.go:107-113`) runs its sanity checks in - // order: `flags.LoadConfig` first, then `utils.AssertSupabaseDbIsRunning()`. - // A down Docker daemon therefore surfaces from the DB inspect below - // (`assertLocalDbRunning`) as `failed to inspect service: …` with the - // Docker Desktop install hint as a suggestion (`misc.go:155-166`), never as - // an upfront `failed to run docker.` failure. The remote-JWKS fetch is - // likewise held until AFTER that assertion (`finalizeAuthArtifacts` below — - // Go only fetches inside `ServeFunctions`, `serve.go:141`), so a down - // daemon never waits on external OIDC/JWKS requests first. - const resolved = yield* resolveServeConfig( - input.dependencies.projectRoot, - input.dependencies.projectIdOverride, - input.dependencies.goViperCompat, - input.dependencies.goConfigCompat, - ); - const projectId = resolved.projectId; - const containerId = localDockerId("edge_runtime", projectId); - let ownsRuntime = false; - let startedRuntime: StartedRuntime | undefined; - return yield* Effect.gen(function* () { - // `SUPABASE_NETWORK_ID` (env or project dotenv) is legacy-shell-only — - // same Go-viper-parity gate as `resolved.projectEnvValues` itself - // (`undefined` in `next`). - const networkMode = resolveDockerNetworkMode({ - explicit: Option.getOrUndefined(input.networkId), - envOverride: - resolved.projectEnvValues === undefined - ? undefined - : legacyViperEnvStringWithProjectFallback( - "SUPABASE_NETWORK_ID", - resolved.projectEnvValues, - ), - projectId, - }); - const localAuthArtifacts = yield* resolveLocalAuthArtifacts(resolved.auth, resolved.configPath); - const edgeRuntimeVersionOverride = yield* resolveEdgeRuntimeVersionPin( - input.dependencies.supabaseDir, - ); - const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( - resolved.edgeRuntime.deno_version, - edgeRuntimeVersionOverride, - ); - - yield* assertLocalDbRunning(projectId); - yield* bestEffortRemoveContainer(containerId); - - // Go's `restartEdgeRuntime` prints this right before calling `ServeFunctions` - // (`serve.go:124-125`) — `ServeFunctions` itself (this file's `startEdgeRuntimeContainer`, - // also called directly by `start.go:1104`) never prints it, so it belongs in this - // `functions serve`-only wrapper, not the shared core. - yield* output.raw("Setting up Edge Functions runtime...\n", "stderr"); - - // Go's remote-JWKS fetch happens inside `ServeFunctions` (`serve.go:141`) - // — i.e. after `AssertSupabaseDbIsRunning`, the container removal, and the - // "Setting up…" print above — never before. Finalizing here (rather than - // inside `startEdgeRuntimeContainer`) keeps the shared core's - // caller-supplies-artifacts contract intact for `start`'s bring-up - // (`edge-runtime.service.ts`), which resolves its own JWKS. - const authArtifacts = yield* finalizeAuthArtifacts(localAuthArtifacts); - - // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) - // — resolved here, not earlier: `hasLocalImage` fails fast on an - // unreachable daemon, which would otherwise hijack the down-daemon - // message `assertLocalDbRunning` above is responsible for producing. - // - // Known ordering divergence (not fixed here — see below): Go's own - // `ServeFunctions` (`serve.go:134-167`) parses `--env-file` and every - // per-function config BEFORE ever calling `DockerStart` - // (`serve.go:218`), so a broken env file or function config fails fast, - // before any pull. This port's `startEdgeRuntimeContainer` (below) does - // that same parsing internally, but AFTER receiving an already-resolved - // `image` — so on a cold image cache, a broken `--env-file` now surfaces - // after a potentially slow `docker pull` instead of immediately. Fixing - // this properly means splitting `startEdgeRuntimeContainer` into a - // "build container config" phase and a "run it" phase so this resolve - // can move between them — but that function is also `start`'s bring-up - // core (`edge-runtime.service.ts`), which already passes in a - // pre-resolved image via `legacyEnsureImagesCached`, so restructuring it - // risks that shipped, more critical path. Left as a documented - // UX-only regression (the command still fails with the right error, - // just later) rather than a hasty change to shared, `start`-critical - // code (review round on CLI-1963). - const image = yield* resolveFunctionsDockerImage( - edgeRuntimeImage(edgeRuntimeVersion), - resolved.projectEnvValues, - ); - - startedRuntime = yield* startEdgeRuntimeContainer({ - onContainerCreated: () => { - ownsRuntime = true; - }, - config: { - projectId, - apiPort: resolved.apiPort, - edgeRuntimePolicy: resolved.edgeRuntime.policy, - edgeRuntimeInspectorPort: resolved.edgeRuntime.inspector_port, - edgeRuntimeSecrets: resolved.edgeRuntime.secrets, - configDeclaredFunctions: resolved.configDeclaredFunctions, - configFunctions: resolved.configFunctions, - rawConfigFunctions: resolved.rawConfigFunctions, - }, - authArtifacts, - dbUrl: legacyDefaultServeDbUrl, - image, - projectRoot: input.dependencies.projectRoot, - supabaseDir: input.dependencies.supabaseDir, - flagCwd: input.dependencies.flagCwd, - platform: input.dependencies.platform, - debug: input.debug, - networkId: networkMode, - envFile: input.flags.envFile, - discoverFunctionEnvFiles: true, - importMap: input.flags.importMap, - noVerifyJwt: input.flags.noVerifyJwt, - inspectMode: input.inspectMode, - inspectMain: input.flags.inspectMain, - }); - - yield* reloadKong(projectId); - - return startedRuntime; - }).pipe( - // `startEdgeRuntimeContainer`'s own `Effect.onError` only reaches while it's still running — - // once it returns successfully, a failure or interrupt here (e.g. mid-`reloadKong`) escapes - // that scope entirely, so this wrapper must also run the returned runtime's own staging-file - // cleanup, not just remove the container. Removal stays with this caller for bring-up - // failures too (`docker cp`/`docker start`), matching `docker run -d` behavior — the shared - // core never removes the container it created. - Effect.onExit((exit) => - Exit.isFailure(exit) - ? Effect.all([ - ownsRuntime ? bestEffortRemoveContainer(containerId) : Effect.void, - startedRuntime === undefined ? Effect.void : startedRuntime.cleanup, - ]).pipe(Effect.asVoid) - : Effect.void, - ), - ); -}); - -export const serveFunctions = Effect.fn("functions.serve")(function* ( - flags: FunctionsServeFlags, - dependencies: FunctionsServeDependencies, -) { - const processControl = yield* ProcessControl; - const inspectMode = yield* Effect.try({ - try: () => { - const resolvedInspectMode = resolveFunctionsServeInspectMode(flags); - buildFunctionsServeInspectArgs(resolvedInspectMode, flags.inspectMain); - return resolvedInspectMode; - }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); - - const loop = Effect.gen(function* () { - for (;;) { - const startOutcome = yield* Effect.raceFirst( - processControl.awaitSignal().pipe(Effect.as("shutdown" as const)), - startEdgeRuntime({ - flags, - dependencies, - debug: dependencies.debug, - networkId: dependencies.networkId, - inspectMode, - }).pipe(Effect.map((started) => ({ _tag: "started" as const, started }))), - ); - - if (startOutcome === "shutdown") { - yield* writeStoppedServingMessage(); - return; - } - - const started = startOutcome.started; - - // `streamContainerLogs` never succeeds: it streams logs until the container - // exits, then fails. A container crash therefore propagates out of this race - // and terminates `serve` — the Go CLI never auto-restarts a crashed container. - // The race only ever resolves to "shutdown" (signal) or "restart" (file change). - const outcome = yield* Effect.raceFirst( - Effect.raceFirst( - processControl.awaitSignal().pipe(Effect.as("shutdown" as const)), - waitForRestartSignal(started.watchSpecs).pipe(Effect.as("restart" as const)), - ), - streamContainerLogs(started.containerId), - ).pipe( - Effect.ensuring( - bestEffortRemoveContainer(started.containerId).pipe(Effect.ensuring(started.cleanup)), - ), - ); - - if (outcome === "shutdown") { - yield* writeStoppedServingMessage(); - return; - } - } - }); - - yield* Effect.scoped(loop); -}); diff --git a/apps/cli/src/shared/runtime/file-watcher.service.ts b/apps/cli/src/shared/runtime/file-watcher.service.ts deleted file mode 100644 index 1641904057..0000000000 --- a/apps/cli/src/shared/runtime/file-watcher.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Stream } from "effect"; -import { Data, Context } from "effect"; - -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../telemetry/error-actionability.ts"; - -type FileWatchEventType = "create" | "update" | "delete"; - -export interface FileWatchEvent { - readonly path: string; - readonly type: FileWatchEventType; -} - -export interface FileWatchOptions { - readonly ignore?: ReadonlyArray; - readonly recursive?: boolean; -} - -export class FileWatcherError extends Data.TaggedError("FileWatcherError")<{ - readonly path: string; - readonly cause: unknown; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.permission; - } -} - -interface FileWatcherShape { - readonly watch: ( - path: string, - options?: FileWatchOptions, - ) => Stream.Stream, FileWatcherError>; -} - -export class FileWatcher extends Context.Service()( - "supabase/runtime/FileWatcher", -) {} diff --git a/apps/cli/src/shared/runtime/ink.layer.ts b/apps/cli/src/shared/runtime/ink.layer.ts deleted file mode 100644 index 3f9d790f10..0000000000 --- a/apps/cli/src/shared/runtime/ink.layer.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Effect, Layer } from "effect"; - -import { Ink } from "./ink.service.ts"; - -export const inkLayer = Layer.sync(Ink, () => - Ink.of({ - render: (element) => - Effect.promise(async () => { - const { render } = await import("ink"); - return render(element, { exitOnCtrlC: false }); - }), - }), -); diff --git a/apps/cli/src/shared/runtime/ink.service.ts b/apps/cli/src/shared/runtime/ink.service.ts deleted file mode 100644 index e8af34d325..0000000000 --- a/apps/cli/src/shared/runtime/ink.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Effect } from "effect"; -import { Context } from "effect"; -import type { ReactElement } from "react"; - -export interface InkInstance { - readonly unmount: () => void; - readonly rerender: (element: ReactElement) => void; - readonly waitUntilExit: () => Promise; -} - -interface InkShape { - readonly render: (element: ReactElement) => Effect.Effect; -} - -export class Ink extends Context.Service()("supabase/runtime/Ink") {} diff --git a/apps/cli/src/shared/runtime/parcel-file-watcher.layer.ts b/apps/cli/src/shared/runtime/parcel-file-watcher.layer.ts deleted file mode 100644 index abd7424e33..0000000000 --- a/apps/cli/src/shared/runtime/parcel-file-watcher.layer.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Cause, Effect, Layer, Queue, Stream } from "effect"; -import { createWrapper } from "@parcel/watcher/wrapper"; -import type ParcelWatcher from "@parcel/watcher"; - -import { - FileWatcher, - FileWatcherError, - type FileWatchEvent, - type FileWatchOptions, -} from "./file-watcher.service.ts"; - -declare const SUPABASE_LIBC: string | undefined; - -function wrapBinding(binding: unknown): typeof import("@parcel/watcher") { - return createWrapper(binding); -} - -function loadParcelWatcher(): typeof import("@parcel/watcher") { - if (process.platform === "darwin") { - if (process.arch === "arm64") { - return wrapBinding(require("@parcel/watcher-darwin-arm64")); - } - if (process.arch === "x64") { - return wrapBinding(require("@parcel/watcher-darwin-x64")); - } - } - - if (process.platform === "linux") { - if (process.arch === "arm64") { - if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") { - return wrapBinding(require("@parcel/watcher-linux-arm64-musl")); - } - return wrapBinding(require("@parcel/watcher-linux-arm64-glibc")); - } - if (process.arch === "x64") { - if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") { - return wrapBinding(require("@parcel/watcher-linux-x64-musl")); - } - return wrapBinding(require("@parcel/watcher-linux-x64-glibc")); - } - } - - if (process.platform === "win32") { - if (process.arch === "arm64") { - return wrapBinding(require("@parcel/watcher-win32-arm64")); - } - if (process.arch === "x64") { - return wrapBinding(require("@parcel/watcher-win32-x64")); - } - } - - throw new Error(`Unsupported @parcel/watcher platform: ${process.platform}-${process.arch}`); -} - -function toParcelOptions(options?: FileWatchOptions): ParcelWatcher.Options | undefined { - if (options?.ignore === undefined) { - return undefined; - } - return { - ignore: [...options.ignore], - }; -} - -export const parcelFileWatcherLayer = Layer.sync(FileWatcher, () => - FileWatcher.of({ - watch: (path, options) => - Stream.callback, FileWatcherError>((queue) => { - const watcher = loadParcelWatcher(); - return Effect.acquireRelease( - Effect.tryPromise({ - try: () => - watcher.subscribe( - path, - (error, events) => { - if (error !== null) { - Queue.failCauseUnsafe( - queue, - Cause.fail(new FileWatcherError({ path, cause: error })), - ); - return; - } - Queue.offerUnsafe(queue, events); - }, - toParcelOptions(options), - ), - catch: (cause) => new FileWatcherError({ path, cause }), - }), - (subscription) => - Effect.promise(() => subscription.unsubscribe()).pipe( - Effect.ignore({ log: true, message: "Failed to unsubscribe file watcher" }), - ), - ); - }), - }), -); diff --git a/apps/cli/src/shared/runtime/parcel-watcher-wrapper.d.ts b/apps/cli/src/shared/runtime/parcel-watcher-wrapper.d.ts deleted file mode 100644 index 6a81a4359e..0000000000 --- a/apps/cli/src/shared/runtime/parcel-watcher-wrapper.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "@parcel/watcher/wrapper" { - export function createWrapper(binding: unknown): typeof import("@parcel/watcher"); -} diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 21cdb470d0..25ccd27de8 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -227,7 +227,7 @@ export interface ServiceFetchConfig { } /** @public */ -export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ +class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ readonly service: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index d03b987fa1..6cc405f638 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -224,57 +224,6 @@ type CliErrorSuggestion = readonly suggested_command?: never; }; -/** Q2 KPI metric definitions, kept next to the taxonomy they consume. */ -export const CliErrorActionabilityMetricDefinitions = { - strictRecovery: { - id: "same_command_success_same_session", - event: "cli_command_executed", - partition_by: ["device_id", "$session_id", "command"], - command_identity: - "Exact equality of the command property, which is the normalized command path emitted by CLI instrumentation without argument values.", - order_by: "event timestamp ascending", - eligible_failure: "exit_code != 0 AND error_kind = 'user_actionable'", - recovered_when: - "For eligible failure F, there exists event S with S.timestamp > F.timestamp, S.exit_code = 0, and S.device_id = F.device_id, S.$session_id = F.$session_id, and S.command = F.command. Intervening events do not change the result.", - reset_when: - "The observation window ends with the $session_id. A success with a different device_id, $session_id, or command never counts.", - description: - "A user-actionable failure is recovered only when the same normalized command later succeeds for the same device_id and $session_id.", - }, - repeatError: { - id: "same_command_same_error_same_session_before_success", - event: "cli_command_executed", - partition_by: ["device_id", "$session_id", "command"], - command_identity: - "Exact equality of the command property, which is the normalized command path emitted by CLI instrumentation without argument values.", - order_by: "event timestamp ascending", - eligible_failure: - "exit_code != 0 AND error_kind = 'user_actionable' AND error_fingerprint IS NOT NULL", - repeated_when: - "Failed event F is a repeat when an earlier failed event P in the same partition has P.error_fingerprint = F.error_fingerprint and no event S in that partition has P.timestamp < S.timestamp < F.timestamp and S.exit_code = 0.", - reset_when: - "Any exit_code = 0 event in the partition clears every prior fingerprint for that command. A different $session_id starts a new partition; successes for other commands do not reset it.", - description: - "The second and each later occurrence of the same user-actionable error_fingerprint is a repeat until that normalized command succeeds or the session changes.", - }, - internalUnknownBugRate: { - id: "failed_commands_internal_bug_or_unknown", - event: "cli_command_executed", - denominator: "count where exit_code != 0 AND error_kind IS NOT NULL", - numerator: "count where exit_code != 0 AND error_kind IN ('internal_bug', 'unknown')", - description: - "Internal/unknown bug failure rate is the share of classified failed commands reported as internal_bug or unknown. Failures without error_kind (pure Go-proxy commands, CLI versions predating the classification) are excluded from both sides; classificationCoverage reports their share.", - }, - classificationCoverage: { - id: "failed_commands_with_classification", - event: "cli_command_executed", - denominator: "count where exit_code != 0", - numerator: "count where exit_code != 0 AND error_kind IS NOT NULL", - description: - "Share of failed commands carrying the error classification. Pure Go-proxy commands report through the Go binary without these fields, and older CLI versions never send them, so the recovery, repeat, and bug-rate metrics cover exactly this fraction of the failure volume.", - }, -} as const; - /** * Symbol under which CLI error classes declare their own actionability. * It is intentionally absent from the global symbol registry, so errors from diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 2fab2677c2..f622e38a13 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -3,7 +3,6 @@ import { join } from "node:path"; import process from "node:process"; import { BunServices } from "@effect/platform-bun"; import { Deferred, Effect, Layer, Option, Redacted, Stream } from "effect"; -import type { ReactElement } from "react"; import type { CliProjectEnvironment, CliProjectPaths } from "@supabase/config"; import { Api } from "../../src/next/auth/api.service.ts"; import type { LoginSessionResponse, ProfileResponse } from "../../src/next/auth/api.service.ts"; @@ -26,7 +25,6 @@ import { NonInteractiveError } from "../../src/shared/output/errors.ts"; import { Output } from "../../src/shared/output/output.service.ts"; import type { OutputFormat } from "../../src/shared/output/types.ts"; import { Browser } from "../../src/shared/runtime/browser.service.ts"; -import { Ink, type InkInstance } from "../../src/shared/runtime/ink.service.ts"; import { ProcessControl, type CliProcessSignal, @@ -604,46 +602,6 @@ export function mockTelemetryRuntime( ); } -export function mockInk(opts: { manualExit?: boolean } = {}) { - let rendered = false; - let unmounted = false; - let element: ReactElement | null = null; - let resolveExit = () => {}; - const exitPromise = new Promise((resolve) => { - resolveExit = () => resolve(undefined); - }); - return { - layer: Layer.succeed(Ink, { - render: (nextElement) => - Effect.sync(() => { - rendered = true; - element = nextElement; - return { - unmount: () => { - unmounted = true; - }, - rerender: (updatedElement) => { - element = updatedElement; - }, - waitUntilExit: () => (opts.manualExit ? exitPromise : Promise.resolve()), - } satisfies InkInstance; - }), - }), - get rendered() { - return rendered; - }, - get unmounted() { - return unmounted; - }, - get element() { - return element; - }, - exit() { - resolveExit(); - }, - }; -} - // --------------------------------------------------------------------------- // Environment helpers // --------------------------------------------------------------------------- diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 23ffa5833e..d9928be833 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -6,86 +6,108 @@ ## Decision Managed local-stack coordination has one implementation and one durable state -document. `ManagedStackManager` owns identity, sticky-port intent, launch -metadata, lifecycle transitions, and document writes. `managed/control.ts` -owns deterministic control ownership and endpoint derivation. The supervisor -child owns the runtime lease and `DaemonServer`; `RemoteStack` is the client -transport. CLI handlers call the lifecycle facade and never maintain a second -metadata file or PID-based liveness path. - -The managed document is private unreleased state under the user-level managed -root. It is intentionally not a SQLite schema, repository contract, service -registry, or compatibility facade. Storage and lifecycle decisions stay in the -manager; platform entrypoints only provide filesystem, path, process, HTTP, -and control-transport services. - -Launch updates use same-version Effect RPC through the supervisor. An attached -caller asks that supervisor to update launch metadata; a caller with owned -maintenance control updates the document directly. Stop acquires control first, -waits for the persisted `stopped` lifecycle, and handles a stale owner with -deterministic cleanup keyed by stack id. Delete also requires a maintenance -lease; stale running or failed documents are reconciled and cleaned before -removal, while a live owner is never deleted underneath. - -Every managed document records one concrete launch selection. Native launch -state has `mode: "native"`; container launch state has `mode: "docker"` and -the selected Docker or Podman executable. The document never stores an -unresolved or mode-less launch. Runtime configuration uses the same correlated -union, so impossible mode/runtime combinations are not representable after -selection. - -Read-only discovery never acquires control ownership. It scans the stack id's -deterministic endpoint candidates through `/owner` and treats an unreachable, -incompatible, or colliding listener as non-live. Mutations scan the same -sequence for an existing matching owner, then bind the first available -candidate; exhaustion fails closed. Each candidate maps digest bytes from the -stack id into the reserved loopback range `127.0.0.1:10000..32767`. This is -pragmatic single-user localhost coordination, not a hostile multi-user security -boundary. We are not adding control tokens until the threat model or a real -collision rate justifies more protocol and persistence machinery. - -The stable owner protocol is an exhaustive supervisor/maintenance union. -Supervisors publish lifecycle, readiness, and immutable CLI version identity; -maintenance leases publish only their operation and cannot serve runtime RPC or -be replaced as an incompatible daemon. Session-fenced stop requests carry -explicit or replacement intent. The supervisor's queue serializes shutdown, -persists an explicit stop before listener release, and leaves replacement stops -eligible for the one authorized CLI-upgrade start. +document. The stack package owns identity, sticky ports, launch selection, +lifecycle transitions, and state writes. A detached Supervisor holds the +exclusive ownership lease only while the stack is running or an admitted +operation is in flight, while stop-time cleanup remains unproven, or while an +unproven destroy leaves durable intent `destroying`. Public +handles communicate with it through same-release Effect RPC and a small +release-stable maintenance protocol for probe and stop. CLI handlers call this +facade and do not maintain a second metadata or PID-based liveness path. + +The managed document is private, unreleased state under the user-level managed +root. It is not a repository contract, service registry, compatibility facade, +or independently versioned database schema. Platform entrypoints only provide +filesystem, path, process, HTTP, and control-transport services. + +Stack handles are lightweight identity-scoped clients. Creating or opening one +does not launch a Supervisor. Successful stop drains ingress, removes every +ephemeral runtime resource, persists stopped state, delivers its response, then +closes control and releases ownership. The caller waits for both response and +lease release. A stopped stack therefore consumes no live process, container, +network, socket, listener, or gateway. Status and retained logs are read from +durable files only when owner metadata and the ownership lock are both absent. +The same handle lazily launches a fresh Supervisor on its next start. + +A Supervisor launched for a mutation also exits when that mutation cannot leave +running intent behind. In particular, a failed start before the running state is +committed releases its control endpoint and ownership lease after reporting the +failure. A successful start keeps the Supervisor alive; an idempotent start joins +that current session without resetting lazy activation or publishing a synthetic +starting transition. + +A restart that has committed stopped intent retries exact runtime cleanup before +its temporary Supervisor may exit. If cleanup still cannot be proven, status +remains `stopping` and the owner stays available for a retryable `stop()`; a +cleaned stopped stack never retains that owner. + +There is no durable generation counter and runtime resources are never adopted +across owner sessions. The exclusive stack lease and `ownerSessionId` identify +the only writer and current ephemeral resources. Explicit start, restart, stop, +or destroy first removes exact stack-owned remnants and creates fresh resources; +failure to complete or validate cleanup fails closed. Persistent data, sticky +ports, secrets, definitions, logs, and artifacts remain identity-scoped. + +Before a native cold start creates anything, it verifies every persisted public +and private port is bindable. PostgreSQL lock evidence containing a live or +unclassifiable owner PID fails closed; a lock naming a process that no longer +exists is left for PostgreSQL's own stale-lock recovery. A live restart +preflights configuration without trying to bind ports that the current +Supervisor intentionally owns. Cold recovery never adopts those resources. + +Every managed document records one concrete runtime selection. Native and +container runtimes never mix. Container state records the selected Docker or +Podman engine; it never stores an unresolved or mode-less choice. + +Read-only discovery never acquires ownership. Owner metadata points to a Unix +domain socket on POSIX or a named pipe on Windows, and the ownership lock is the +single-writer authority. Offline status and retained logs are available only +when both metadata and lock are absent. Mutations use the live owner or acquire +the lease and launch a fresh Supervisor. Ambiguous ownership fails closed. + +The stable maintenance protocol contains only probe and stop. Same-release +callers use Effect RPC for status, logs, preparation, activation, start, +restart, and destroy. An incompatible owner can always be stopped; explicit +restart may stop it, wait for lease release, launch the current Supervisor, and +start again. The first admitted lifecycle operation runs to completion; +equivalent callers join it and conflicting callers fail immediately rather than +queueing. + +PostgreSQL is the only eager capability by default. Start prepares and launches +only the eager dependency closure. Every other enabled capability prepares and +starts when traffic first reaches its stable gateway route. Explicit +`stack.prepare(...)` remains available for callers that intentionally want to +warm selected artifacts, but it is not part of ordinary CLI start. Preparation +is owned by a temporary Supervisor, survives cancellation of the caller that +requested it, and releases control and ownership after the transfer completes. ## Why this replaces ADR-0015 -ADR-0015 proposed 104 exported contract fixtures, a repository boundary, and -parallel persistent adapters. None of those were shipped producers or -consumers. Keeping them would preserve private compatibility surfaces and a -second architecture that the current CLI does not use. The proposal is -superseded; its identity and lifecycle intent are retained only where they are -implemented by the manager and supervisor. +ADR-0015 proposed exported contract fixtures, a repository boundary, and +parallel persistent adapters without shipped producers or consumers. Keeping +them would preserve a second architecture. Its identity and lifecycle intent +survive only where implemented by the stack package and Supervisor. ## Testing decision -Tests follow the real consumed boundaries: +Tests follow consumed boundaries: -- manager integration covers ordinary folders, sibling worktrees, identity, - sticky ports, document lifecycle, concurrent read/start ownership, stale - owner recovery, and interrupted deletion; -- supervisor integration starts a real detached child, reattaches through the - control endpoint, updates launch metadata, stops, and deletes; and -- CLI handler integration covers argument translation, output projections, - telemetry, and the managed facade calls. +- stack integration covers identity, sticky ports, durable lifecycle, + ownership, stale-owner recovery, and interrupted cleanup; +- supervisor integration covers detached ownership, RPC, stop, restart, and + destroy; and +- one shared stack-package E2E journey runs in native and Docker modes, starts + with PostgreSQL alone, activates every other service through realistic + traffic, verifies cross-service behavior and migrations, then exercises + stop/start and retained offline observability. -Private store/model algorithms receive focused unit coverage where useful. A -test that snapshots every export or validates a private contract registry is -not an architectural guarantee and should be deleted. Add an e2e journey only -when the compiled CLI/process boundary cannot be represented faithfully by one -of the existing integration journeys. +CLI handler integration covers only argument translation, output, telemetry, +and calls into the stack facade. Private implementation tests are retained only +where public scenarios cannot make a branch observable. ## Consequences -The architecture is smaller and has one source of truth for managed lifecycle -state. Refactors update the manager/facade and its real consumers together; -there is no fixture adapter or compatibility layer to keep in sync. The private -document format may change with the current build, while destructive cleanup -and control ownership remain explicit safeguards. A supervisor that attaches -and later takes ownership re-reads this source of truth before choosing the -runtime or cleaning stale resources; it does not act on a pre-takeover -snapshot. +The architecture has one source of truth for lifecycle state and one owner for +ephemeral resources. Refactors update the package facade and its real consumers +together. The private document format may change with the current build, while +destructive cleanup and ownership remain explicit safeguards. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md deleted file mode 100644 index 685c4a3ae3..0000000000 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md +++ /dev/null @@ -1,1321 +0,0 @@ -# Stack Runtime Rewrite Progress - -**Branch:** `feat/stack-runtime-rewrite` -**Base:** `de26a30c7` (`origin/develop` on 2026-08-28) -**Design:** `docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md` -**Plan:** `docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md` - -This tracked ledger records implementation state and rulings that may deserve user review. It is -updated in the same commit as each completed slice. Scratch review packages live outside Git under -`.superpowers/sdd/`. - -## Current state - -- [x] Existing Codex-managed worktree verified as an isolated linked worktree. -- [x] Local branch created directly from `origin/develop`. -- [x] Pinned pnpm dependencies installed with an unchanged lockfile. -- [x] Baseline package type checks passed for `@supabase/process-compose` and `@supabase/stack`. -- [x] Baseline targeted tests passed: process-compose dependency graph (18 tests) and stack identity - (1 test). -- [x] Approved architecture imported as the canonical tracked design. -- [x] Layer-by-layer implementation plan completed and reviewed against the design. -- [x] Legacy process-compose package and stack implementation removed. -- [x] Greenfield rewrite implemented through the public Effect and Promise surfaces. -- [x] Targeted test suite green. -- [ ] Completion audit, final review, push, and pull request completed. - -## Slice ledger - -### Task 0 — remove legacy runtimes and establish the empty package boundary - -- Baseline commit: `de26a30c7`. -- Active process-compose references at the deletion baseline (from `git grep -n` at the base) were: - `apps/cli/AGENTS.md:393`, `apps/cli/package.json:73`, - `apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts:18`, - `apps/cli/src/legacy/commands/functions/download/download.integration.test.ts:23`, - `apps/cli/src/legacy/commands/gen/types/types.integration.test.ts:44`, - `apps/cli/src/next/commands/functions/download/download.integration.test.ts:27`, - `apps/cli/src/next/main.ts:6`, `apps/cli/src/shared/functions/functions-docker.unit.test.ts:19`, - `apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts:372`, - `apps/cli/src/shared/telemetry/error-actionability.ts:18,1153`, - `apps/cli/tests/helpers/running-stack.ts:34`, `package.json:19-20`, `.oxlintrc.json:12`, - `knip.json:37`, `turbo.json:64`, `packages/stack/package.json:38`, and process-compose imports - in the deleted package docs/manifest plus throughout the legacy - `packages/stack/src/**` implementation (including `LocalStack.ts`, `Stack.ts`, `StackBuilder.ts`, - `StackRpc.ts`, `StackServiceState.ts`, `StackStateProjection.ts`, `createStack.ts`, `effect.ts`, - `index.ts`, `stackHandle.ts`, `RemoteStack.rpc.integration.test.ts`, - `StackRpcHandlers.integration.test.ts`, `Stack.unit.test.ts`, and `services/**`). -- Deleted the `packages/process-compose` workspace and the legacy stack source, tests, scripts, and - stale package docs. Added the empty `@supabase/stack` public, Effect, and testing barrels. -- Moved `mockChildProcessSpawner` to `apps/cli/tests/helpers/child-process-spawner.ts` and updated - all active CLI imports. -- Removed active process-compose telemetry, manifest, bootstrap, lint, docs, and workspace references; - `.repos/process-compose` and historical ADR/design references remain preserved. - -## Rulings and uncertain decisions - -> **Ruling:** Use `Data.TaggedError` for ordinary domain failures and pinned Effect v4 -> `Schema.TaggedErrorClass` only at schema-serialized boundaries — this follows the repository rule -> while using the actual RC.111 API rather than the older `Schema.TaggedError` name — cost if wrong: -> an error crossing RPC/state may need to be promoted to a schema-backed class later. - -> **Ruling:** The initial deletion commit may temporarily break `apps/cli` type-checking until its -> direct migration task — compatibility stubs would preserve the exact legacy API the greenfield -> rewrite is meant to remove — cost if wrong: intermediate commits cannot be cherry-picked or treated -> as releasable checkpoints. - -> **Ruling:** Define the container activation protocol against a narrow fake host-route resolver -> before Docker/Podman adapters exist — this separates protocol authority from engine mechanics — -> cost if wrong: the engine task may require a small activation interface adjustment. - -> **Ruling:** Validate `StackId` as the actual 64-character lowercase SHA-256 digest before it can -> reach identity-scoped filesystem paths — “opaque” means callers do not interpret it, not that -> arbitrary path-like strings are valid — cost if wrong: a future non-SHA identity encoding will need -> an explicitly versioned schema change. - -> **Ruling:** Derive Git `workspaceId` and `checkoutId` from the canonical common Git directory and -> checkout-specific Git directory, with the full symbolic branch ref as `branchContext`; ordinary -> folders use their canonical project root and the documented ordinary-workspace marker — this avoids -> another mutable identity registry — cost if wrong: physically moving a checkout intentionally -> produces a new identity instead of reattaching its old stacks. - -> **Ruling:** Hash each identity tuple field as a four-byte big-endian UTF-8 byte length followed by -> the field bytes, then SHA-256 the concatenation — this is a single unambiguous mechanism with no -> separator escaping — cost if wrong: fields larger than 4 GiB are unrepresentable, which is outside -> any valid filesystem/ref/name input. - -> **Ruling:** Functions have one serving path through the managed stack Edge Runtime in both native -> and container modes; `supabase functions serve` is a managed-stack client, not a separate Docker -> workflow. The caller owns migrations, schemas, and seeds. Database reset is deliberately outside -> this rewrite and reserves no API, durable state, or lifecycle variant; it will be designed in a -> separate future session from observed caller needs — cost if wrong: reset work would require an -> explicit later public-contract change rather than a placeholder in this implementation. - -> **Ruling:** `supabase/slim-services` is the canonical release contract for native and container -> workloads: each supported version uses its portable archive and the GHCR image derived from that -> archive. The initial database catalog supports only PostgreSQL `17.6.1.167`; PostgreSQL 15 is added -> only after both artifacts are published, and all other selectors fail before mutation — cost if -> wrong: adding a version is a catalog change, while retaining unsupported historical selectors would -> create runtime identities that cannot be prepared. - -> **Uncertain:** Studio currently depends on Analytics and therefore fails compilation when Studio is -> enabled but Analytics is disabled. The design specifies dependency failure for disabled required -> capabilities but does not decide whether Studio must support a degraded Analytics-free mode. Keep -> the current fail-closed contract until that product behavior is explicitly decided; do not add -> conditional runtime wiring speculatively. - -When the design does not determine another choice, record it here as: - -> **Ruling:** decision — reason — cost if wrong. - -Implementation continues after recording a ruling; this file is not a question queue. - -## Verification evidence - -### Plan self-review — 2026-08-28 - -- Mapped all design sections to the implementation tasks for cleanup, public model, identity, compiler, - durable state/secrets/ports, ownership, reconciliation/observability, native preparation/runtime, - gateway/activation, Functions, container runtime, lifecycle/recovery, facades/CLI, complete - catalog/platform coverage, and completion audit. Database reset is tracked outside this rewrite. -- Removed an ordering conflict: public `StackConfig` is defined with capability Modules before the - durable state schema consumes `StackDefinition`. -- Verified 55 executable steps, 42 balanced Markdown fences, and no placeholder patterns. -- Recorded exact baseline sources for supported settings before their deletion. -- Initialized `.repos/effect` and verified the pinned v4 source. Confirmed - `Schema.TaggedErrorClass`, effectful Schema codecs, services/Layers, scoped acquisition, Deferred, - Semaphore, SubscriptionRef, PubSub, Schedule, callback cancellation, platform filesystem layers, - and Effect RPC against their actual source/tests. - -### Baseline — 2026-08-28 - -- `pnpm install --frozen-lockfile` — passed. -- `pnpm types:check` in `packages/process-compose` — passed. -- `pnpm types:check` in `packages/stack` — passed. -- `pnpm exec vitest run src/DependencyGraph.unit.test.ts` in `packages/process-compose` — 18 passed. -- `pnpm exec vitest run --project unit src/StackIdentity.unit.test.ts` in `packages/stack` — 1 passed. - -### Task 0 — 2026-08-28 - -- `pnpm install --lockfile-only` — passed (pnpm v11.4.0; platform warnings for unsupported binary - wrapper workspaces only). -- `rg -n '@supabase/process-compose|packages/process-compose' package.json packages apps ---glob '!docs/superpowers/**' --glob '!docs/adr/**'` — no matches (exit 1 as expected). -- `pnpm --dir packages/stack types:check` — passed. -- The CLI type-check was not run; the accepted staging ruling allows the old CLI stack call sites to - fail until the direct migration task. The telemetry coverage test was attempted and could not load - the removed `@supabase/stack/managed-model` export; its adapters are part of that migration. - -### Task 1 — 2026-08-28 - -- Added the Effect-native public model for stack identity, runtimes, capabilities, complete status - snapshots, logs, credentials, and operation-specific tagged failures. -- `StackStatusSchema` validates that a snapshot contains exactly one status for each of the ten - public capabilities, including disabled capabilities. -- `pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts` - RED: failed before implementation with `Cannot find module './Status.ts'`; GREEN: 3 tests passed. -- `pnpm --dir packages/stack types:check` — passed. - -### Task 2 — 2026-08-28 - -- Added read-only deterministic identity resolution for canonical Git checkouts, linked worktrees, - detached HEADs, nested project roots, and ordinary folders. Git metadata is read through Effect - `FileSystem`/`Path`; no registry or identity-marker writes occur during discovery. -- Added length-delimited UTF-8 tuple hashing through Effect `Crypto`; `StackIdSchema` now accepts - only the 64 lowercase hexadecimal characters emitted by SHA-256. -- Added exact StackId-scoped state/data/log/runtime/control/temporary paths with runtime id - validation and no path traversal; changed the public status fixture to a valid digest. -- Added real temporary Git/worktree and path-safety integration scenarios (9 tests). -- `pnpm --dir packages/stack exec vitest run --project integration src/identity/identity.integration.test.ts` - RED before implementation: failed to import the missing `../state/Paths.ts` module. -- `pnpm --dir packages/stack exec vitest run --project integration src/identity/identity.integration.test.ts` - GREEN: 9 tests passed. -- `pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts` - GREEN: 3 tests passed. -- `pnpm --dir packages/stack types:check` — passed. -- `pnpm exec oxfmt --check packages/stack/src/identity packages/stack/src/state packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed. - -#### Task 2 review fixes — 2026-08-28 - -- Preserved the supplied project-root string (including valid leading/trailing-space path names), - while still trimming only for blankness checks; canonicalization remains filesystem-backed. -- Linked-worktree `commondir` targets are now statted and must resolve to directories. Git `HEAD` - metadata now accepts only full `refs/...` symbolic refs or 40/64-character hexadecimal object - ids, with malformed content failing as `InvalidStackIdentityError`. -- Reworked identity integration coverage to scoped `it.live` Effects with the Node platform layer, - typed Git subprocess failures, symlink canonicalization, malformed metadata cases, and exact names - for every state-path field including the temporary sibling. -- `pnpm --dir packages/stack exec vitest run --project integration src/identity/identity.integration.test.ts` - — 13 tests passed. -- `pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts` - — 3 tests passed. -- `pnpm --dir packages/stack types:check` — passed. -- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/identity packages/stack/src/state/Paths.ts packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed with zero warnings. -- `pnpm exec oxfmt --check packages/stack/src/identity packages/stack/src/state/Paths.ts packages/stack/src/public/StackId.ts packages/stack/src/public/public-model.integration.test.ts` — passed. - -### Task 3 — 2026-08-28 - -- Added direct closed schemas for all ten public capabilities. Settings cover the current CLI - database, REST, Auth (providers/hooks/rate limits/MFA/email/SMS/OAuth/third-party/Web3), - Realtime, Storage, Functions/Edge Runtime, Studio, Mail, Analytics, and Pooler fields. Public - listeners reuse the eight `PORT_FIELDS`; migrations, seed, shadow port, and hosted metadata stay - caller-owned or outside the stack contract. -- Added deterministic pure compilation with complete defaults and explicit `null` absences, - Redacted secret leaves converted to stable slots, sorted canonical fingerprints, path-safe - `functions_root`, dependency closure validation, deterministic start/stop order, semantic - workload hashes, and runtime-selected native/container logical artifacts. Database versions use - an explicit supported Supabase release map and fail before output when unknown. -- Private workload companions are represented for storage imgproxy, studio - pgmeta, analytics vector, and managed Edge Runtime Functions. Functions remain one capability; - all serving is through that managed Edge Runtime. -- Added 19 integration scenarios covering each capability, defaults, unknown fields, stable record - fingerprints, omission versus explicit selection, secret redaction, previous-definition reuse, - version validation, runtime artifact selection, path escapes, and dependency closure. -- Red/green: the compiler scenarios were added against the empty model boundary and then turned - green as the modules/compiler were implemented; `compiler.integration.test.ts` and the existing - public-model integration file now pass (22 tests). -- `pnpm --dir packages/stack types:check` — passed. -- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/public/Config.ts packages/stack/src/model packages/stack/src/public/Errors.ts` — passed with zero warnings. -- `pnpm exec oxfmt --check packages/stack/src/public/Config.ts packages/stack/src/model packages/stack/src/public/Errors.ts` — passed. - -#### Task 3 review fixes — 2026-08-28 - -- Replaced the parallel version map/workload defaults with one release catalog per capability. - Database selectors are limited to supported majors 13, 14, 15, and 17 and resolve to their - exact Supabase Postgres releases; non-database selectors must match the current catalog bundle. - Native and container descriptors now stay coherent for the selected release, while private - companions retain their bundle-owned versions. -- Previous compilation reuse now carries only the persisted definition and fingerprint. An - identical fingerprint reuses the exact definition object but rebuilds a fresh runtime plan and - workload hashes from persisted versions/settings, rejecting unsupported persisted releases. -- Storage bucket and per-function records receive their complete defaults before materialization; - Function slug/environment key constraints are checked before schema decoding so invalid Record - keys cannot be silently dropped. Pooler uses the current `pool_mode` spelling. -- Added one shared `NetworkPortSchema` (integer 1..65535) for listeners, endpoints, SMTP, - the public Functions inspector listener, and analytics vector ports. Capability/module dependencies now drive graph - planning and missing private dependencies/cycles fail with `InvalidStackConfigError`. -- Strengthened compiler scenarios for exact tagged errors, release/artifact coherence, persisted - plan rebuilding, record defaults, key/port validation, and secret absence from definition, - fingerprint, plan, and formatted values. Task 4 owns the exhaustive materialized - `StackDefinitionSchema` and round-trip state codec; Task 3 exposes concrete per-capability input - schemas/types and release metadata without an open or generated settings bag. -- Fix-round RED: 7 new scenarios failed against `06800c953`; GREEN: compiler/public integration - suites now pass (31 tests). -- `pnpm --dir packages/stack types:check` — passed. -- `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — 30 tests passed before final empty-release scenario (31 total expected; rerun final command below). -- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. -- `pnpm exec oxfmt --check packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed. - -### Task 4 — 2026-08-28 - -- Added the strict `supabase-stack-state-v1` Effect codec for complete materialized definitions, - persisted identity/runtime/generation/lifecycle, sticky host-port assignments, and dedicated - secret values. Unknown fields, malformed formats, invalid record keys, incomplete dynamic - defaults, forged identity tuples, duplicate ports, and definition/fingerprint mismatches fail - closed with tagged errors. Reads remain lock-free; writes use an owner-only root, a sibling - exclusive temporary file, file sync, and same-directory atomic rename. Effect Platform has no - portable directory-fsync API, so the implementation documents that file-sync/same-directory - rename is the strongest available durability boundary. -- Added one short O_EXCL registry lock (bounded Schedule retry, exact release, no stale stealing) - shared by state mutations and port planning; the per-stack `state.json.ports` fields remain the - sole durable port authority. Automatic ports are sticky and exclusive across automatic/live - assignments, exact stopped reservations may coexist, and runtime ownership/publication occurs - after the state transaction with assignments retained on publication races. -- Added module-owned secret policies and required managed slots. Auth API credentials, Pooler - encryption/key-base, the default JWT signing slot, and the internal database password are - managed; other Redacted settings remain pass-through. Managed omissions generate once and reuse; - pass-through changes/additions/removals require a stopped lifecycle. Secret bytes never enter - definitions, fingerprints, errors, or loggable redaction output. -- RED evidence: focused state/secret/port tests initially failed to load the missing Task 4 state, - secret, and port modules; the final integration scenarios now cover concurrent old-or-new reads, - exact cleanup, strict dynamic/default round-trips, secret policy matrices, automatic/exact port - conflicts, native listener handoff, host occupancy, and container publication races. -- `pnpm --dir packages/stack exec vitest run --project integration src/state/state-store.integration.test.ts src/state/secrets.integration.test.ts src/state/ports.integration.test.ts` — passed (17 tests). -- `pnpm --dir packages/stack types:check` — passed. -- Effect lint over changed Task 4 sources — passed with zero warnings. - -#### Task 4 port review fixes — 2026-08-28 - -- Exact requests now reject ports held by another stack's automatic reservation with the canonical - `PortAllocationError`, while exact reservations on stopped stacks continue to coexist. -- Running stacks are assignment-fenced: exact/automatic changes and enablement changes fail with - `StackLifecycleConflictError`; an unchanged repeat is accepted without advancing generation. -- Native multi-listener acquisition is transactional in an operation-owned child `Scope`; a later - bind failure closes earlier listeners before the failure escapes, while successful listeners remain - owned by the caller's scope. The fork/acquire/handoff boundary is masked against interruption, - with the actual bind effects restored to interruptible execution. -- Review RED evidence: the three new scenarios initially observed the missing automatic conflict, - missing running-assignment fence, and leaked first native listener; all now pass. -- `pnpm --dir packages/stack exec vitest run --project integration src/state/ports.integration.test.ts` — passed (10 tests). -- `pnpm --dir packages/stack test` — passed (67 integration tests; no unit files). - -#### Task 3 review fixes — round 2 — 2026-08-28 - -- **Superseded release decision:** earlier selector-alias work was removed. The rewrite accepts only - PostgreSQL `17.6.1.167`; PostgreSQL 15 remains deferred until its matching native archive and - derived image are published, and every other selector fails before mutation. -- Identical-fingerprint reuse now branches immediately after strict decode, Functions-root - normalization, and fingerprint calculation. It collects only supplied Redacted slots, validates - persisted versions and capability/workload closure, and rebuilds a fresh runtime plan from the - persisted definition. Candidate defaults/materializers are not evaluated on this path. -- Removed duplicate Functions authorities from `edge_runtime`: capability enablement remains on - `capabilities.functions.enabled`, and the inspector gateway port remains on the public - `functionsInspector` listener. Functions settings persist only runtime behavior, root, secrets, - and per-function overrides; recursive unknown-field tests reject the removed controls. -- Persisted-definition codecs remain a Task 4 responsibility; Task 3 intentionally exports concrete - input schemas/types and release metadata without a generated or open materialized settings bag. -- Round-2 RED/GREEN: the targeted compiler/public suites pass (32 tests), including rejection of - unsupported database selectors, immediate reuse, removed Functions controls, exact tagged - failures, and closure checks. -- `pnpm --dir packages/stack types:check` — passed. -- `pnpm --dir packages/stack exec vitest run --project integration src/model/compiler.integration.test.ts src/public/public-model.integration.test.ts` — passed, 32 tests. -- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed with zero warnings. -- `pnpm exec oxfmt --check packages/stack/src/model packages/stack/src/public/Config.ts packages/stack/src/public/Status.ts packages/stack/src/public/Errors.ts` — passed. - -### Task 5 — 2026-08-28 - -- Added identity-scoped owner metadata and one O_EXCL `runtime/owner.lock`. Ownership is proved - only by the lock; metadata is published after endpoint bind through a sibling temporary file, - file sync, and same-directory rename. Reads validate the StackId directory and deterministic - full-digest endpoint before probing. Session-fenced release never removes a replacement lock or - metadata, and transport owns socket unlink only after a successful bind. -- Added `StackStateStore.initialize`, a registry-lock transaction that returns an existing complete - state or writes the candidate once. This closes the concurrent equivalent-create race without a - process-global registry. -- Added the one local control endpoint with frozen maintenance v1 and exact-release Effect RPC, - bounded framing, identity/session/release fencing, per-request deadlines, bounded maintenance - concurrency, and post-response quiesce completion. Maintenance stop retains the compatible owner; - only quiesce releases it for explicit replacement. -- Added detached Supervisor launching through Effect's `ChildProcessSpawner` and fd3 readiness - handoff. Concurrent losers join winner metadata through a pre-started filesystem watcher plus a - bounded Schedule reread that closes the platform watch-acquisition gap. Every returned owner is - release/session/probe validated. Readiness has a thirty-second deadline and terminates the exact child - and observer on timeout, failure, or caller interruption. Owner processes outlive caller handles. -- Added managed Effect handles and observational discovery (`openStack`, `findStack`, `listStacks`, - `inspectStack`), truthful unconfigured/stopped status, operation-specific public errors, and exact - RPC error-tag preservation. POSIX endpoints use `/tmp` plus the complete StackId digest to remain - below Unix socket path limits. -- Added ownership/state initialization, protocol framing/fencing, incompatible-release, - project-filter, caller-process-exit, concurrent create, and handle lifecycle integration scenarios. -- `pnpm --dir packages/stack exec vitest run --project integration src/control/control-transport.integration.test.ts src/state/ownership.integration.test.ts src/supervisor/handles.integration.test.ts` - — passed (22 tests). -- `pnpm --filter @supabase/stack test` — passed (89 integration tests; no unit files). -- `pnpm --dir packages/stack types:check` — passed. -- Effect lint over all Task 5 sources and tests — passed with zero warnings. -- Formatting and `git diff --check` — passed; no matching Supervisor processes or control sockets - remained after the integration suites. - -### Task 6 — 2026-08-29 - -- Added the exact private `RuntimeDriver` boundary and a deterministic desired-state planner. Running - reconciliation starts in dependency order; stop, destroy, stale-generation replacement, and - duplicate-resource cleanup quiesce active resources before removal in reverse dependency order. - Every runtime mutation uses the full StackId, desired generation, workload id, and semantic spec - hash. -- Added one Supervisor-local reconciliation semaphore and generation fences before and after every - mutation and observation boundary. Independent branches continue after a workload failure, while - the complete transitive dependent closure is blocked and any already-running descendants are - stopped and removed child-first. -- Readiness uses a deadline and bounded `Schedule` retry policy. Exhaustion is scoped by stack, - generation, and workload, persists across repeated running reconciliation, resets on an explicit - stopped/destroying intent, and starts fresh for a new generation. Retry control classifies the - complete failed `Cause` before scheduling so defects and interruption are never collapsed into an - ordinary workload failure. -- Added complete status projections backed by `SubscriptionRef`; every watcher begins with the - current snapshot. Added owner-only bounded retained logs with strict persisted decoding, opaque - monotonic cursors, capability filters, exact known-secret redaction before persistence and live - publication, and one semaphore-protected retained-to-live `PubSub` handoff without gaps or - duplicates. -- Retained log limits reject non-finite or undersized configurations, existing log directories and - files are repaired to `0700`/`0600`, and retained entries are re-redacted and republished - atomically when the current known-secret set newly matches old content. -- Review RED/GREEN evidence: the first package run exposed duplicate blocked reports and a hanging - readiness scenario; later regression coverage exposed unsafe stale-resource cleanup, restart - budgets surviving explicit stop, and a mixed driver cause being reduced to a normal failure. The - final implementations and observable, Deferred/TestClock-coordinated scenarios cover each case. -- `pnpm --dir packages/stack types:check` — passed. -- `pnpm --dir packages/stack test:unit:run -- Reconciler.unit.test.ts` — passed (4 tests). -- `pnpm --dir packages/stack test:integration:run -- reconciler.integration.test.ts observability.integration.test.ts` - — passed (104 integration tests across 11 files). -- `pnpm exec oxlint -c .oxlintrc.effect.json` over the eight Task 6 source/test files — passed with - zero warnings. -- `pnpm exec oxfmt --check` over the eight Task 6 source/test files and `git diff --check` — passed. - -### Task 7 — 2026-08-29 - -- Added an owner-only verified artifact store with canonical path containment, SHA-256 validation, - required-runtime-path checks, executable repair, exact temporary cleanup, same-directory atomic - publication, strict cache revalidation, and store-scoped single-flight ownership that survives an - individual caller's interruption. -- Added the native process boundary and runtime driver. Workload commands and environments travel in - a private inherited descriptor instead of argv; the parent-loss descriptor terminates the exact - launcher process tree. Start is shared, readiness and log persistence are typed startup gates, - stop/remove target the exact generation/workload/spec identity, and a stopped identity can start a - fresh process without retaining its completed `Deferred` or closed scope. -- Database bootstrap is a post-probe readiness phase on the real database workload, never a private - companion workload. The generic runner validates an ordered release plan, creates its tracking - schema under a transaction-scoped advisory lock, applies and records one revision atomically, and - reconciles `Redacted` role credentials outside SQL text. Concrete per-release revision catalogs - remain Task 13 work; gateway listener ownership/transfer remains Task 8 work with the gateway - contract. -- Added integration coverage for verified cache/download behavior, corruption and containment, - concurrent publisher convergence, caller/store-scope cancellation, native readiness/logging/ - restart/identity ownership, owner-loss process-tree termination, and ordered/concurrent/retried - bootstrap behavior without secret disclosure. -- Ruling: the two-store publication test intentionally uses separate `ArtifactStore` instances in one - process. Those instances share no in-memory single-flight state and therefore exercise the atomic - filesystem rename/revalidation race that also coordinates separate processes. A child-process-only - duplicate of that scenario would add subprocess scaffolding without testing another invariant. -- `pnpm --dir packages/stack types:check` — passed. -- Focused Task 7/compiler integration suite — passed (57 tests across 4 files). -- `pnpm --dir packages/stack test` — passed (4 unit and 132 integration tests). -- Effect lint over all Task 7 source/test files — passed with zero warnings. -- Formatting over all Task 7 source/test files and `git diff --check` — passed. -- Independent re-review found no Critical or Important findings; all earlier lifecycle, bootstrap, - credential, logging, cleanup, and cancellation findings are closed. - -### Task 8 — 2026-08-29 - -- Added internal HTTP, WebSocket, and transparent TCP gateways with injected capability routes and - private backend resolution. HTTP owns CORS, forwarding headers, health/status dormancy, and - 503/502 mapping; TCP preserves opaque PostgreSQL, TLS, SMTP, POP3, STARTTLS, backpressure, and - half-close behavior. -- Added mandatory one-way lazy activation per desired generation. Concurrent callers join one - owner-scoped activation fiber, dependency closures activate once, successful results remain live, - failed attempts may retry, waiter interruption does not cancel shared work, and owner-scope - closure interrupts it exactly. -- Added the container activation-only protocol: bounded exact-schema frames, 16-request concurrency, - TestClock-verifiable five-second deadlines, capability/StackId/generation/gateway/session fences, - generic wire errors, and a high-entropy Redacted capability. The owner-only activation file uses - exact decoding, pre/post-read size bounds, atomic publication, immediate temporary cleanup, and - fenced finalization that cannot delete a rotated successor. -- Native port reservations now carry the exact pre-bound HTTP/TCP listener into the gateway. TCP - transfer requires `allowHalfOpen: true`; shutdown is single-flight, closes retained keep-alive and - tunnel sockets, invokes the listener release, and never performs a bind-close-rebind. A failed or - interrupted final generation fence closes the operation-owned listener scope before returning. -- Review fixes added partial gateway-acquisition rollback, complete foreign-callback listener - cleanup, connection-before-listener race removal, post-header backend failure handling, adopted - listener ownership, idle keep-alive cleanup, deterministic deadline coverage, large-stream - transfer coverage, and final-fence listener cleanup. Final independent re-review found no Critical - or Important findings. -- RED evidence: both gateway suites initially failed because the new modules did not exist; added - lifecycle and protocol regressions failed against the first implementation before each fix. -- `pnpm --dir packages/stack types:check` — passed. -- Focused gateway, activation, port, and reconciler suite — passed (45 tests across 5 files). -- `pnpm --dir packages/stack test` — passed (4 unit and 156 integration tests). -- Effect/generic lint over all Task 8 sources and tests, formatting, and `git diff --check` — passed. -- Concrete capability route catalogs remain Task 13; Functions discovery, container engine routing, - and full Supervisor lifecycle composition remain Tasks 9, 10, and 11 respectively. - -### Task 9 — 2026-08-29 - -- Added request-time FunctionsRoot containment and canonical one-mount descriptors. Every function, - entrypoint, import-map, static pattern, shared path, and symlink target is checked against the - live root; filesystem edits become visible on the next discovery without a watcher or state write. -- Added closed FunctionDiscovery settings/defaults, custom entrypoint/static/import-map descriptors, - Redacted environment values, disabled/not-found and typed path failures, generic module resolution, - and Functions gateway preparation (`/functions/v1/:slug`) that captures the exact invocation in a - typed backend-dispatch closure and avoids activation on 404/503. -- Added integration coverage for live create/edit/delete, config-only overrides, root and category - symlink escapes, path traversal/absolute values, shared modules, canonical mounts, gateway - preflight activation fencing, and HTTP/WebSocket dispatch descriptor handoff. -- RED evidence: the first functions integration run failed because the new discovery modules were - absent; subsequent gateway preflight coverage initially exposed eager activation construction. -- A parallel full-package run exposed a 512 KiB TCP response truncation: normal target close could - destroy the source before its buffered response drained. The tunnel now distinguishes normal EOF - from abrupt close and registers half-close listeners before connect; 48 concurrent targeted runs - passed after the fix. -- `pnpm --dir packages/stack types:check`, focused Functions/gateway integration, full package - integration (163 tests), Effect lint, formatting, and `git diff --check` passed. - -### Task 10 — 2026-08-29 - -- Added independent, closed Docker and Podman CLI codecs plus one narrow container-engine contract - for only the probe, image, resource-list, network, volume, and container lifecycle operations the - runtime consumes. Commands use exact argv and bounded stdout/stderr, decoders admit only the - required scalar label fields, nonzero/malformed responses remain typed failures, and automatic - selection falls back from Docker to Podman only when the Docker executable is genuinely missing. -- Added production engine resolution for new container identities. The selected adapter must pass - its routing preflight before state mutation, and the concrete `docker`/`podman` result is persisted. - Existing identities reuse that engine without probing; conflicting explicit preferences fail - before probing. Native identities never consult a container resolver. -- Added the strict `ContainerRuntime`: native artifacts fail before engine access; workload - containers remain private; exact StackId/owner/generation/workload/spec-hash resources are adopted; - same-owner stale containers are stopped and removed before recreation; foreign and sanitized-name - collisions fail before image pulls or mutation; concurrent starts serialize; readiness cleanup - preserves and combines the original and cleanup Causes. -- Persistent container storage is deliberately one volume per workload. The runtime accepts only a - target/read-only request and derives the physical name from the full StackId and workload id, - excluding generation so it survives restart. Volumes are identity-scoped, reused after container - removal, and retained until destroy. This is the smallest unambiguous model supported by the - current catalog labels. -- The real process runner now fails on the first output chunk beyond its bound, interrupts sibling - reads/exit waiting, and performs an interruptible SIGTERM wait followed by exact SIGKILL fallback. - A hostile child that remains alive and ignores SIGTERM proves both fail-fast overflow and reaping. -- Fixed the native launcher/readiness race uncovered by the combined runtime suite. Inherited owner - and payload descriptors now use `node:net` sockets rather than filesystem streams, avoiding a - blocked libuv threadpool shutdown; one cached exit-code Effect is shared by readiness and the - watcher, and native runtime rejects container artifacts before process resolution. The exact - diagnostic parent/launcher processes created while minimizing this failure were terminated and - verified absent. -- Gateway-container lifecycle composition and stopped-stack ephemeral network cleanup remain Task 11. During running intent, a single workload readiness failure intentionally retains the shared - private network and persistent volume for retries and other workloads; the failed newly-created - workload container itself is stopped and removed. -- RED/GREEN review closed two Important findings: global caller-supplied volume names and output - overflow waiting for EOF. Independent closure review found no remaining Critical or Important - finding. -- `pnpm --dir packages/stack types:check` — passed. -- Focused container runtime, native runtime, and managed-handle integration suite — passed (43 tests - across 3 files). -- Generic and Effect lint over all Task 10 sources/tests, formatting over sources/tests/docs, and - `git diff --check` — passed. - -### Task 11 — 2026-08-29 - -- Added one Effect-native lifecycle controller over durable state and an injected runtime backend. - Start materializes and commits complete intent before reconciliation, reuses omitted configured - input, treats identical running input as reconciliation, and rejects changed running input with - explicit restart guidance. Stop retains durable state and reruns idempotent cleanup after a failed - attempt; restart is generation-fenced so a concurrent stop wins; destroy retains the destroying - fence until exact runtime/data cleanup succeeds and can be retried. -- Added running-only runtime recovery and exact stopped cleanup. Container recovery adopts only one - exact current-generation workload/network, removes stale and duplicate owned resources plus every - stale gateway, retains persistent volumes and foreign stacks, and never starts missing work. - Native recovery similarly adopts only exact local resources without starting them. Stopped and - unconfigured recovery use cleanup, so no current-generation ephemeral network is retained. -- Composed recovery, lifecycle, reconciliation, lazy activation, status, status watching, and logs - in the Supervisor. Recovery completes before owner readiness; a persisted running intent recovers - exact resources but reports the new owner stopped and never auto-starts after reboot. Lazy - capabilities remain dormant until explicit activation, then activate their dependency closure and - remain active for the generation. Actual phase is independent from durable desired intent, and - complete status snapshots are published only at observable transitions. -- Maintenance quiesce releases ownership only after successful cleanup. Public Effect handles pass - log options and expose scoped status/log streams. Expected lifecycle/runtime failures retain their - exact RPC error tags instead of collapsing into a generic reconciliation error. -- The owner entrypoint intentionally has no concrete runtime factory yet. Its unavailable default - makes a production start fail closed; Task 13 must wire the exhaustive native/container artifact, - route, preparation, activation, and log-store catalogs before end-to-end stack startup can work. - Credentials and prepare remain explicit unavailable stubs for the same later composition slice. -- Review RED/GREEN closed the stopped-recovery network ambiguity by making `recover` accept only a - running desired lifecycle; stopped/unconfigured states must call `cleanup`. Independent review - found no further Critical or Important lifecycle, recovery, lazy-activation, status/log, RPC, or - quiesce defect, and separately identified the deliberately deferred production runtime wiring - above. -- Focused lifecycle/runtime/reconciliation/Supervisor/handle/control verification passed (90 tests - across 8 integration files), followed by the stack type-check. Generic and Effect lint over all 14 - changed TypeScript files, formatting, and `git diff --check` passed. - -### Task 12 — 2026-08-29 - -- Added the Promise facade as a mechanical outer adapter over the authoritative Effect handle. Each - Promise handle owns a private scope, closes idempotently without implicit destruction, unwraps every - Redacted leaf to plain strings, maps absent Options to `undefined`, and exposes cancellation-safe - AsyncIterables without an async-dispose symbol. -- Added `createTestStack` as the sole AsyncDisposable resource. It creates a unique temporary identity, - starts and waits for readiness, and on disposal or startup failure destroys only its owned identity and - removes its exact temporary root. -- Migrated CLI stack consumers to `@supabase/stack/effect` and current descriptors/status projections. - `start` and managed Functions serving load `CliConfig` and translate the complete closed `StackConfig`, - including listener/security settings, Functions root, and recursively redacted secret leaves. Both - Functions serving command paths use the managed stack Functions capability and log stream. -- Retained non-stack command behavior (link, update, services, branch switch, and Functions list/new) - while removing obsolete manager/daemon/version/transport compatibility helpers and stale fixtures. - Running start-input changes retain explicit restart guidance; legacy start's integrated Edge Runtime - channel remains intentionally outside this slice by ruling. -- RED/GREEN evidence: facade integration (`promise.integration.test.ts`, `testing.integration.test.ts`) - passed 3 tests; migrated CLI integration scenarios passed 36 tests across 12 files. Stack and CLI type - checks passed; generic and Effect lint, oxfmt, and `git diff --check` passed. - -#### Fix round 1/5 evidence — 2026-08-29 - -- Canonical JSON schema decoding now covers nested Promise secret leaves; stream close and - `createTestStack` cleanup preserve primary failures while finalizing exact owned resources. -- CLI Functions serving maps loaded config/manifest/env-file and managed readiness/API/logs; roots are - project-relative, JWKS signing paths are emitted, and both top-level and `stack` restart commands - provide explicit restart guidance. -- Post-fix focused verification: stack facade integration 2 files/7 tests; CLI integration 13 files/40 - tests; CLI units 4 files/10 tests; both workspace types; generic/Effect lint, formatting, and diff - checks passed. - -#### Fix round 2/5 evidence — 2026-08-29 - -- Closed Functions inspector mode/main settings preserve `run`/`brk`/`wait` and `--inspect-main` for - both serve registrations without eager activation. Test-resource readiness is now derived from - supplied config (disabled capabilities/listeners are exempt), with exact cleanup on close failure. -- Added natural/post-close Promise stream coverage, start changed-running `supabase restart` guidance, - restart exclusion preservation, and honest linked/unlinked update output. -- Focused verification: stack facade/model/compiler 3 files/40 tests; CLI Functions/start/restart/update - 5 files/9 tests plus legacy/config units 2 files/9 tests; both workspace types; lint/format/diff checks - passed. - -#### Fix round 3/5 evidence — 2026-08-29 - -- Test-resource readiness now treats the public `disabled` capability state as terminal-ready even for - default-disabled capabilities such as pooler. The ordinary isolated-stack fixture models pooler as - disabled and resolves successfully; explicit disabled Functions/API coverage remains intact. -- Focused testing integration (5 tests), stack types, Effect/generic lint, formatting, and diff checks - passed. - -### Task 13A — 2026-08-29 (foundation) - -| Capability | Native/container artifacts | Settings/dependencies | Ports/routes/probes | Secrets/logs/activation | Evidence | -| -------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------- | -| database | `postgres` slim release `17.6.1.167`; `ghcr.io/supabase/cli/postgres:17.6.1.167` image | database settings and bootstrap workload | database TCP readiness | managed database password; eager | compiler + runtime integration | -| rest | `postgrest`; `postgrest/postgrest` | REST settings/env; database dependency | API HTTP route; private HTTP readiness | passthrough settings; lazy activation | compiler + workload-runtime | -| auth | `auth`; `supabase/auth` | Auth settings/env and managed key slots; database dependency | API HTTP route; private `/health` readiness | managed JWT/key slots; lazy activation | compiler + workload-runtime | -| realtime | `realtime`; `supabase/realtime` | Realtime settings/env; database dependency | API HTTP route; private `/api/ping` readiness | passthrough settings; lazy activation | compiler + workload-runtime integration | -| storage (+ imgproxy) | `storage`/`imgproxy`; storage-api/imgproxy images | Storage settings/env and volume; image transformation selects companion | API route/readiness; companion private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | -| functions | Edge Runtime slim artifact/container | functions root, policy, inspector, per-function env | `/functions/v1/*` route; request-time activation and private `/_internal/health` | functionsRoot is the sole read-only `/__supabase_functions` mount | catalog + workload-runtime + container-runtime | -| studio (+ pgmeta) | studio/pgmeta slim artifacts; studio/pg-meta images | Studio settings/env; REST/analytics deps | Studio HTTP route; private profile/health probes | passthrough settings; eager activation | compiler + workload-runtime integration | -| mail | mailpit slim artifact/container | Mail settings/env | mail UI/SMTP/POP3 listeners; private `/readyz` probe | passthrough settings; eager activation | compiler + workload-runtime integration | -| analytics (+ vector) | logflare/vector slim artifacts; Logflare/vector images | Analytics settings/env; vector selected by `vector_port`; database dependency | API route/readiness; vector private HTTP endpoint | passthrough settings; lazy activation | catalog conditional + workload-runtime integration | -| pooler | `supavisor` slim release `v2.9.12`; matching image | Pooler settings/env and database dependency | pooler TCP route/readiness; private transaction endpoint | managed pooler key slots; eager activation | compiler + catalog + workload-runtime integration | - -Common Task 13A source/test coverage: `model/WorkloadCatalog.ts` is the exhaustive slim-services identity table -with darwin-arm64, linux-amd64, and linux-arm64 target validation; `ExecutionPlan` materializes optional -companions from persisted settings; `runtime/WorkloadRuntimeSpec.ts` resolves every workload's command, args, -cwd, environment, private endpoint and readiness metadata; container specs carry env/commands and -Docker/Podman serializers emit them. `preparation/SlimServicesSource.ts` verifies manifest/checksum metadata, -decompresses and safely extracts archives, and composes with the atomic `ArtifactStore`; tests inject transport -and archive bytes and never access the network. Task 13B still owns Supervisor/RuntimeFactory production -composition, owner-scoped `StackGateway` lifecycle, exact private/public port allocation, credentials, and -redacting runtime logs. Functions remains stack-owned Edge Runtime; `functions serve` remains a client. - -Narrow rulings and cost if wrong: Task 13A keeps transport and process/engine boundaries injectable so tests -remain offline and deterministic. Task 13B must preserve the native/container identity split and make -private-port reservation plus gateway ownership atomic with accepted lifecycle generations. - -#### Task 13A fix round 1 — artifact and runtime hardening (2026-08-29) - -- Native slim identities now match release archives for Realtime (`bin/server`), Analytics (`bin/logflare`), - and Supavisor (`bin/server`, `bin/supavisor`, `bin/migrate`); catalog tests pin executable and required paths. -- `SlimServicesSource` uses injected Effect process tar list/link/extract boundaries, validates member/link - traversal (including relative links), verifies SHA256SUMS and compressed bytes before extraction, and - validates extracted realpaths. Tests cover internal links, PAX long names, malformed archives, and - interruption without publication. -- Runtime specs flatten nested settings with service-recognized aliases, use native loopback versus the - container `supabase-database` network alias, expose `containerResolutionFor`, and mount Functions' sole - `functionsRoot` at `/__supabase_functions` read-only with aligned `--main-service` arguments. -- Docker/Podman serializers accept only owned env-file paths (never secret `NAME=value` argv) and serialize - exact network aliases. Compiler integration proves the managed database password slot is persisted once - and reused. Task 13B still owns env-file creation, gateway/port lifecycle, and production composition. - -#### Task 13A fix round 2 — complete workload execution contracts (2026-08-29) - -- Every catalog workload now has one stable container DNS alias, reused by private endpoints and - intra-stack URLs (`supabase-database`, `supabase-pgmeta`, `supabase-imgproxy`, and companions). - Studio's host-owned API URL and Analytics credentials use explicit owner-resolved inputs; no engine - hostname or host-file path is guessed in this layer. -- Native Node workloads carry artifact-root-relative executable, script arguments, and working-directory - metadata. Vector's native command targets the shipped `share/doc/vector/config/vector.yaml`; container - resolution retains its separate `/etc/vector/vector.yaml` contract. Functions remains one Edge Runtime - workload with its sole read-only `/__supabase_functions` mount and aligned `--main-service` argument. -- Runtime settings now include Realtime's IPv4/IPv6 `ERL_AFLAGS` and resolved JWKS input, Auth's exact - SMS/double-confirm/password semantics, Mailpit fallback SMTP, Storage's imgproxy alias, Studio's - pg-meta/analytics aliases, and BigQuery's read-only service-account mount. Optional values are omitted - rather than emitted as blank service environment values. -- Database bootstrap credentials use the seven managed login roles from `schema.sql` and one Redacted - `secret:database.internal.password`; integration coverage verifies all roles and no password leakage. -- Slim artifact decompression is an owned transform stream that is destroyed on interruption; checksum, - manifest, safe archive extraction, and atomic publication remain injected/offline-testable. -- Fix-round evidence: catalog/compiler/database-bootstrap/runtime/slim/container integration tests pass - (80 targeted tests in the latest run); Task 13B still owns env-file materialization, gateway/port - lifecycle, credentials wiring, and production composition. - -#### Task 13A fix round 3 — close remaining workload contract gaps (2026-08-29) - -- PostgREST now selects the managed symmetric JWT secret or owner-resolved JWKS according to the - persisted signing/third-party mode; typed resolution validation rejects missing JWKS and template - base inputs before process/container creation. -- Auth mailer templates and enabled notifications use the legacy `GOTRUE_MAILER_TEMPLATES_*`, - `SUBJECTS_*`, and notification suffixes with owner-resolved gateway URLs and file extensions. -- Third-party Auth issuer derivation is a closed pure mapping (Firebase/Auth0/Cognito/Clerk/WorkOS), - with compiler validation for required fields and one-provider exclusivity. -- Studio native entrypoint cwd is the artifact `app`; Vector requires its shipped config; Postgres - slim artifacts receive a contained `init-scripts` alias for the current archive layout. - -#### Task 13A fix round 4 — provide Edge Runtime JWT material (2026-08-29) - -- Functions validation now treats the stack-owned Edge Runtime as a JWT consumer, requiring owner- - resolved JWKS material for `jwks-file` and third-party Auth modes before container/process creation. -- Edge Runtime receives the same `SUPABASE_INTERNAL_JWT_SECRET` managed Auth slot as GoTrue plus a - valid `SUPABASE_JWKS` document (owner-resolved when available, otherwise the empty-key fallback); - integration coverage exercises symmetric, jwks-file, and Firebase third-party paths. - -#### Task 13B1 — unify ingress and private ports (2026-08-29) - -- Persisted stack state now requires `privatePorts`, identifying every workload endpoint by the - `(workloadId, binding)` pair. State decoding rejects duplicate bindings, duplicate ports, and any - overlap between public and private assignments; omitted state fields fail closed without a migration. -- `PortCoordinator` allocates sticky private loopback ports from `30000–39999` and public host ports - from `40000–65535` in one registry-lock transaction. Claims are scanned across every stack, - retained while stopped, and returned as complete public/private assignment sets. -- Public host listeners use one `bindHost`/`HostListener` path for native and container stacks and are - returned for direct adoption by the Supervisor-owned gateway. Container workloads publish only - exact `127.0.0.1::` endpoints; Docker/Podman reject malformed or non-loopback - publications before daemon mutation. -- The obsolete gateway container, activation server, activation file, and wire protocol were removed; - lazy activation remains a direct in-process Supervisor call in both runtime modes. -- Focused ports/state, container-runtime/recovery, and gateway integration coverage passes. Production - Supervisor/RuntimeFactory lifecycle wiring and credentials/log integration remain Task 13B's next slice. - -#### Task 13B2a — complete private routing contracts (2026-08-29) - -- Workloads expose a closed binding catalog: ordinary services use `primary`, while Mailpit declares - `ui`/`smtp`/`pop3`. `privateBindingIntentsFor` derives every reservation from the execution plan; - runtime resolution validates durable assignments and returns loopback container publications with - fixed internal ports. -- `StackGateway` now owns maps of HTTP/TCP listeners keyed by `PortField`, rolling back all acquired - listeners on failure and closing them once. `RouteCatalog` covers enabled API prefixes plus direct - Studio/Mail/Functions inspector and database/pooler/SMTP/POP3 routes. -- Gateway forwarding supports route and prepared-route upstream path transforms. Legacy service roots - are represented explicitly (GraphQL `/rpc/graphql`, Realtime `/socket/websocket` and `/api`, Storage - S3 `/s3`), with query strings preserved. Functions requests rewrite `/functions/v1/` to the - Edge Runtime `/` path for both HTTP and WebSocket forwarding. -- Evidence: workload-runtime (7), gateway (10), route-catalog (1), and the broader catalog/compiler, - preparation/artifact, container, bootstrap, runtime, and gateway integration set (100 tests) pass; - stack types, Effect/generic lint, formatting, and diff checks pass. Task 13B2b still owns production - listener allocation/reconciliation and Edge Runtime bootstrap composition. - -#### Task 13B2a review fix — gateway request transforms (2026-08-29) - -- Gateway routes and prepared routes can transform upstream headers after hop-by-hop filtering for both - HTTP and WebSocket forwarding. Internal `x-forwarded-*` values are rewritten from the accepted client - connection, so client-supplied overrides cannot spoof them; WebSocket handshake headers are restored by - the gateway after transformation. -- `routeCatalogFor` accepts owner-resolved API material and maps matching publishable/secret API keys to - the corresponding anonymous/service JWT. REST/Auth/Realtime/Storage/GraphQL use `Authorization`, - Functions use `sb-api-key`, GraphQL enforces `Content-Profile: graphql_public`, Storage S3 preserves - AWS Authorization, and Realtime WebSocket `apikey` query mapping preserves unrelated parameters. -- No authentication is performed in the gateway and no API material is logged or included in failures. - Focused gateway/route integration tests cover transforms, spoof removal, key mappings, S3/GraphQL/ - Realtime behavior, query preservation, Functions, and HTTP/WebSocket forwarding. Task 13B production - composition still owns the resolved material source and listener lifecycle. - -#### Task 13A fix round 5 — unify local JWT signing material (2026-08-29) - -- Symmetric JWT configuration now has one canonical managed `secret:auth.settings.jwt_secret` slot; - top-level `security.jwt.signing.secret` is accepted as an alias, equal dual declarations are - deduplicated, and conflicting values fail typed before lifecycle mutation. -- JWKS-file and third-party modes retain Auth's local JWT secret for GoTrue, Edge Runtime, Realtime, - Storage, and Supavisor while PostgREST consumes owner-resolved JWKS material where required. -- Compiler, runtime, and secret-store integration coverage verifies default/one-sided/equal/mismatch - inputs, no duplicate slot, internal JWT consumers, and JWKS/third-party resolution semantics. - -#### Task 13A fix round 6 — generate valid managed API credentials (2026-08-29) - -- Managed Auth omissions now use slot-aware lifecycle generation: publishable and secret keys receive - cryptographically random `sb_publishable_`/`sb_secret_` values, while the canonical JWT secret - receives 32 random bytes encoded as base64url. Existing persisted or explicit values remain sticky. -- Omitted anonymous and service-role keys are legacy-shaped JWTs (`iss: supabase-demo`, role, expiry) - signed with the canonical local secret in symmetric mode or the first supported private ES256/RS256 - JWK in `security.jwt.signing.path`. JWK files are read only during secret resolution, with - project-root containment and redacted typed failures for malformed/public-only material. -- Compiler carries only private generator descriptors; no generated bytes or signing-file reads enter - preparation or the durable definition. Secret-store integration covers entropy/prefixes, sticky and - explicit values, HS256/ES256 signatures, and invalid/escaping signing files. - -#### Task 13B2b1 — production runtime preparation primitives (2026-08-29) - -- `RuntimeArtifacts` resolves native catalog artifacts and published checksums into one validated - `ArtifactRequest`/`ArtifactStore` operation, while container preparation probes and inspects the - persisted Docker/Podman engine and pulls only the exact missing image (never create/start). -- `RuntimeEnvFile` owns generation/workload container env files below the stack runtime directory, - using atomic owner-only writes, deterministic encoded workload names, validation before writing, - and exact idempotent generation/all cleanup. Native workloads continue to use fd4 env transport. -- `ReadinessProbe` provides cancellation-safe HTTP/TCP probes with bounded Effect Schedule retries; - requests and sockets are destroyed on interruption and failures are typed without carrying secret - material. Focused integration evidence covers native/container artifact outcomes, env permissions, - validation/cleanup, readiness success/failure, and interruption. - -#### Task 13B2c — stack-owned Edge Runtime functions bootstrap (2026-08-29) - -- The offline-bundled Edge Runtime main service now belongs to `@supabase/stack`; the CLI imports - the stack bundler rather than maintaining a second template. Request-time discovery reads the - mounted functions root on every request, uses persisted overrides or complete defaults, and - creates workers with `forceCreate`/no module cache so new, edited, and deleted functions are - reflected without restarting the stack. Root containment and realpath checks fail closed for - traversal and symlink escapes. -- Functions workloads use a private stack-owned generation bootstrap (`0700` directory/`0600` - file). Containers retain one read-only functions-root bind and copy the bootstrap into `/root` - after create and before start; adopted containers are never recopied. Native launchers receive - the owner-resolved bootstrap directory through runtime inputs. Docker and Podman copy commands - use argv only, with no shell or secret-bearing arguments. The published package exports the - stack-owned bundler/template directly, and the generated runtime environment keeps the resolved - `functionsRoot`, `SUPABASE_INTERNAL_FUNCTIONS_ROOT`, and JWT/API host-port material in the exact - workload env contract without duplicating CLI helpers. -- Focused stack bundler/bootstrap/workload/container and CLI function tests pass; stack package - type-checking now passes after the lifecycle/secret-store Clock follow-up. -- The published package metadata contains only the stack-owned bootstrap entrypoint and template; - resolved function environments include the exact `SUPABASE_INTERNAL_PUBLISHABLE_KEY`, - `SUPABASE_INTERNAL_SECRET_KEY`, `SUPABASE_INTERNAL_JWT_SECRET`, and - `SUPABASE_INTERNAL_HOST_PORT` values consumed by Edge Runtime. - -#### Task 13B2b1 correction — harden managed credential generation (2026-08-29) - -- Managed ES256/RS256 API-key JWTs now derive their ten-year expiry from the injected Effect Clock, - while the legacy HS256 path retains its fixed `1_983_812_996` expiry and exact claim shape. -- JWK signing resolves canonical project-root and file realpaths before reading, rejecting symlink - escapes with the existing redacted typed error. Header/payload construction is explicitly typed; - focused coverage uses TestClock and a temp-directory symlink escape. - -#### Task 13B2b2a — Supervisor-owned ingress lifecycle foundation (2026-08-29) - -- `SupervisorRuntime` may provide one cohesive ingress owner. Accepted running generations reserve - sticky public/private ports and bind host listeners before eager workload reconciliation; the - gateway is opened only after reconciliation succeeds. Same-generation starts reuse the exact - listener/server identity, while restart/stop/destroy close ingress before runtime cleanup. -- Persisted-running recovery adopts runtime resources, starts missing eager workloads (lazy routes - remain dormant), then acquires and opens ingress before the owner reports readiness. Reconcile, - gateway, and listener failures close newly acquired resources while preserving the original typed - cause for recovery retry. -- Production ingress composes `PortCoordinator`, `privateBindingIntentsFor`, `routeCatalogFor`, and - `makeGateway`; API forwarding material is read only from persisted key/JWT slots. The host listener - binder uses `node:http` for HTTP fields and half-open `node:net` for TCP fields, with interruption- - safe, scope-owned close and typed occupied-port failures. Focused host-listener integration tests - cover HTTP/TCP binding, adoption metadata, and occupied exact ports. - -#### Task 13B2b2a correction — generation-fenced port materialization (2026-08-29) - -- Persisted state now records `portsGeneration` explicitly (`null` until public/private assignments are - materialized). Running acquisition keeps the prior marker through assignment writes and advances it only - after every listener binds and the final generation fence succeeds; failed binds leave the old marker so - recovery can retry the same accepted generation. Stopped plans mark assignments materialized immediately. -- Running listener/private intent fences use marker equality rather than assignment-array length, including - generations with no listeners or private bindings. Focused state/ports tests cover null/number round trips, - empty-set fencing, sticky retry after bind failure, deterministic final-fence cleanup, and stopped markers. - -#### Task 13B2b3 — initial database bootstrap runtime (2026-08-29) - -- The slim Postgres artifact remains the owner of bundled init scripts and migrations. Stack runtime - bootstrap does not enumerate or reapply artifact SQL; after database readiness it reconciles the - `_realtime` schema owner, all seven managed login-role passwords, and `app.settings.jwt_secret` / - `app.settings.jwt_exp` under the bootstrap advisory lock. -- Both native and container database workloads run the same one-shot bootstrap through the persisted - loopback private database port. PostgreSQL access uses the scoped Effect SQL client; role and setting - statements are built by parameterized `format()` calls so generated secret SQL is never exposed in - mapped errors. A bootstrap failure cleans up new resources and stops an adopted database container - without removing it, allowing an idempotent retry on the next reconciliation. -- Initial runtime settings are intentionally distinct from caller-driven database migrations and seeds. - Database reset is outside this rewrite and will be tackled in a separate future session. - -#### Task 13B2b4 — production runtime composition (2026-08-29) - -- The real Supervisor entrypoint now composes one runtime factory from the persisted runtime choice. Native - stacks construct only the slim-artifact/native driver path; container stacks construct only their selected - Docker or Podman engine and never probe or prepare the opposite runtime. -- Every workload operation re-reads persisted state before resolving artifacts, private endpoints, env, and - bootstrap inputs. Immutable preparation is cached per workload identity, container env remains in owned - `0600` generation files, native env remains on fd4, and database bootstrap runs only after a real loopback - readiness probe for both new and recovered workloads. -- Database and Storage use disjoint identity-scoped persistent roots. Container volumes survive stop and are - removed only on destroy; native paths remain under `/data/database` and `/data/storage`. - Functions bootstrap files and container env files are exact stack-owned runtime resources and are removed - on stop/destroy even when process/engine cleanup also fails, with both typed causes preserved. -- The runtime exposes the same loopback private endpoint to lazy gateway activation in native and container - modes. Runtime logs re-read current secret slots before every append/read/stream operation, so secrets - materialized after factory creation are redacted before persistence and in retained output. -- Review corrections removed eager native construction in container mode, added container readiness on start - and recovery before database bootstrap, and made runtime-file cleanup independent of engine cleanup. -- Fresh evidence: production-runtime, container-runtime, runtime-artifact, and functions-bootstrap integration - suites pass (32 tests), and `@supabase/stack` type-checking passes. Complete container log attachment, - public `credentials`/`prepare` handlers, and owner-resolved service file inputs remain later Task 13 slices. - -#### Milestone Fable review — committed rewrite components (2026-08-29) - -- Ran six independent Fable reviews over the committed model/state, ownership/lifecycle, artifact/runtime, - gateway/Functions, production/database/logging, and public facade/CLI milestones. Active implementation - work was paused so the review snapshot contained only committed code. -- Accepted findings were triaged against the design and reduced to demonstrated lifecycle ownership, - production lazy activation, explicit routed workloads, gateway/Functions correctness, runtime startup and - persistence contracts, compiler/state error invariants, facade boundaries, and minimal CLI coverage. -- Rejected speculative work includes database reset placeholders, cross-release RPC migrations before a - second released protocol exists, alternate artifact trust roots, archive/cache framework expansion, and - broad aesthetic refactors. The implementation remains greenfield and exact-use-case scoped. - -#### Task 13B2b5 — service startup contracts (2026-08-29) - -- Native workloads now use one strict process plan: sequential scoped service-owned startup one-shots, then - the long-lived process and readiness. Fast process exit does not cancel stdout/stderr draining, failures - prevent the main process, and interruption kills only the exact owned child. -- Auth, Storage, Realtime, Analytics, and Pooler use their audited slim-services migration/startup commands. - Auth and Storage run explicit container init processes through their pinned image entrypoints before the main - container; Realtime, Analytics, and Pooler retain their derived-image entrypoints. Mailpit uses only `MP_*` - environment configuration. -- Storage owns one `/mnt` volume and imgproxy mounts the same volume read-only with `/mnt` as its filesystem - root. Native and container persistence contracts now match. -- Initial commit `a0c127637`; fix commit `81edf9969` removed the obsolete direct-process resolver fallback. - Independent review then found no Critical or Important issue. Targeted native/workload/recovery integration - verification passed 37 tests with stack type-check, lint, formatting, and diff checks green. - -#### Task 13B2b6 — stack-owned runtime inputs (2026-08-29) - -- Added one Effect-native owner for contained project files, all local private signing JWKs and public JWKS, - injected third-party OIDC discovery, Auth template mappings, Analytics `gcp_jwt_path`, atomic owner-only - Pooler tenant generation files, and materialized Functions secret names/values. -- Project paths must be relative regular files whose canonical path remains under the canonical project root. - Functions rejects reserved `SUPABASE_*` names and all C0/DEL control characters. OIDC error labels remove - userinfo, query, and fragment data while network requests retain the configured URL. -- JWT material is resolved only when at least one of REST, Auth, Realtime, Storage, or Functions is enabled; - disabling Auth alone does not omit material still required by another consumer. -- Initial commit `ff02f4181`; fix commit `a77c0e013` closed the enabled-consumer and diagnostic-redaction review - findings. Independent re-review was clean; focused input/secret integration verification passed 27 tests - with stack type-check, lint, formatting, and diff checks green. Production and gateway wiring remain the - next slice. - -#### Task 13B2b7 — lifecycle/Fable correction (2026-08-31) - -- Supervisor lifecycle, activation, and recovery work now runs in one owner-scoped FiberSet behind a serialized - execution lane. Equivalent starts use an Effect-encoded, canonical SHA-256 request digest (including transient - redacted values only for identity); distinct secret values cannot join. Lazy activation commits success once per - generation, retries failures, and invalidates on a generation change. -- Supervisor construction no longer observes the runtime engine before owner control/readiness publication. The - entrypoint publishes the endpoint and fd3 readiness first, then runs deferred recovery; blocked or failed recovery - leaves the owner attachable and retains a supervisor error log. Control maintenance waiters may time out without - cancelling accepted stop work. -- Destroy shutdown is transport-owned: the exact owner session is signalled only after a successful destroy response - write. Focused supervisor/control/handle/lifecycle verification passed 69 integration tests with stack type-check, - Effect lint, formatting, and diff checks green. - -#### Task 13B2b7 correction — lifecycle/Fable follow-up (2026-08-31) - -- Persisted plan rebuilding is deferred until owner-scoped recovery, after control endpoint and fd3 readiness; - malformed persisted material leaves an attachable owner and recovery failures are visible as stopped/failed status. - Explicit start/restart clears the failure marker and remains retryable. -- Quiesce keeps accepted stop work in the Supervisor FiberSet. Normal maintenance signals shutdown after the response - is written, while an interrupted waiter starts an owner-scoped join that signals only after successful quiesce. - Ordinary stop does not shut down the owner. Destroy callbacks require a connected client; stale request IDs are - removed through a single-consumer disconnect proxy queue. -- Start/restart identity uses compiler-normalized definitions and effective secret values in a canonical SHA-256 digest; - equivalent defaults join, distinct transient secrets remain isolated. Focused supervisor/control/handle/lifecycle - verification passed 74 integration tests with stack type-check, Effect lint, formatting, and diff checks green. - -#### Task 13B2b7 correction round 2 — owned interruption and recovery guards (2026-08-31) - -- Owned lifecycle waiters now retain and pass the exact accepted Deferred to interruption continuations; - quiesce and destroy therefore complete terminal shutdown after waiter timeout/disconnect without re-evaluating - the operation. Activation rejects while persisted recovery is failed and resumes only after explicit recovery. -- Client reset read/close errors are treated as normal per-connection completion while open/write failures remain - visible. A real handles flow persists an unsupported plan, observes failed status after fd3/control readiness, - probes the same owner, and restores state for scoped cleanup. -- Supervisor readiness uses a bounded thirty-second local deadline to accommodate real subprocess startup; the - subprocess-backed handles suite carries the same test guard. Focused lifecycle/control/handles verification is - green with stack type-check, Effect lint, formatting, and diff checks. - -#### Task 13B2b7 correction round 3 — terminal transport handoffs (2026-08-31) - -- Destroy request disconnects are held until the accepted RPC exit is processed. A successful response write is the - post-response handoff even if the client closes immediately afterward; a disconnected or failed write uses the - explicit abandoned-destroy path, and failed destroy exits never signal owner shutdown. -- Quiesce retains its Supervisor-owned exact-operation continuation for interrupted waiters and adds a transport - abandoned path for successful responses interrupted before normal completion. Maintenance stop/probe remain - non-terminal, and raw client reset read/close errors complete only that connection. -- Focused supervisor/control/handle/lifecycle verification passed 80 integration tests with stack type-check, - Effect lint, formatting, and diff checks green. - -#### Task 13B2b7 Fable delta — freshness and atomic handoffs (2026-08-31) - -- Accepted Fable findings 1.1–1.4 and the generation-bump regression from 1.5; deferred the duplicate candidate - materialization and classifier/test-scaffolding refactors as disproportionate to this minimal slice. -- Deferred recovery now re-reads persisted state after acquiring execution. Superseded generation/lifecycle/input - becomes a no-op owned by the newer lifecycle operation, while generation adoption refuses stale backward moves. - A restart-before-recovery regression preserves running status, generation, and credentials. -- Owned waiter acceptance and continuation registration are now masked atomically, restoring interruption only for - the Deferred wait. Maintenance response classification is attached before operation evaluation and guarded so - normal completion cannot be forked twice. -- Launcher owner rereads now derive their 25ms schedule budget from the bounded 30-second readiness deadline. - Focused lifecycle/control/handle verification passed 81 integration tests with stack type-check, Effect lint, - formatting, and diff checks green. - -#### Task 13B2b7 Fable delta — completion witness and recovery retry (2026-08-31) - -- Accepted Fable findings 1.1, 1.2, and cleanup 3.1; deferred the duplicate-suppression test and classifier - refactor as disproportionate to this minimal slice. -- Maintenance completion callbacks now fork into the owner-scoped FiberSet and publish their completion witness - inside one uninterruptible handoff, so an interruption before the fork remains recoverable while a completed fork - cannot be classified twice. -- Recovery now has an explicit same-generation fail-then-retry regression: the first attempt exposes stopped/failed - status, the second succeeds without changing generation, and the driver is invoked exactly twice. Removed the - unused recovery gate from the shared fixture. -- Focused lifecycle/control/handle verification passed 81 integration tests with stack type-check, Effect lint, - formatting, and diff checks green. - -#### Task 14 — runtime-input and startup-contract corrections (2026-08-31) - -- Tightened persisted runtime inputs: PostgreSQL remains pinned to `17.6.1.167`; native/container Pooler - endpoints derive from persisted runtime state and private database assignments, tenant settings are persisted, - and tenant bootstrap updates existing Supavisor tenants idempotently with Elixir interpolation escaped. -- The owner now performs generation/state single-flight with owner-scoped Fibers, retries failed resolutions, - invalidates exact generations, and publishes atomic `0600` Pooler files only after Functions-secret validation. - Project files remain live canonical paths; OIDC diagnostics redact userinfo/query/fragment data while requests - retain configured URLs. Signing JWKS rejects mixed invalid files, preserves all private keys, and publishes - symmetric verification metadata. Auth material is resolved only when a JWT consumer is enabled. -- Startup contracts set imgproxy's filesystem root to `/`, require Storage's migration artifact, and pass native - Supavisor artifact paths as quoted positional arguments. Focused owner/secret/workload/catalog integration tests - pass 55 cases with stack type-check, Effect lint, formatting, and diff checks green. Production wiring remains - the next slice; `RuntimeInputOwner.resolve(state, generation)` now derives runtime from persisted state and the - owner requires an ambient `Scope` for its FiberSet. - -#### Task 14 Fable correction round 1 (2026-08-31) - -- Pooler sizing now rejects missing or whitespace persisted values while retaining the compiler's finite-number - contract; invalid sizing fails before any generation file is published. Generated tenant create clauses use - consistent Elixir indentation. -- Runtime-input tests now gate an actual cold in-flight resolve with `Deferred`, prove one discovery/JWKS pair, - shared tenant material, warm-cache reuse, and survival when a joined waiter is interrupted. Owner finalization is - attached outside semaphore acquisition so queued interruption clears only its matching slot and completes the - exact deferred exit; successful cache publication remains conditional on slot ownership. -- Added combined local/remote JWKS, space-bearing native artifact paths, positive container tenant-path, and - sibling-generation cleanup coverage. Focused owner/secret/workload/catalog integration verification passes 57 - cases with stack type-check, Effect lint, formatting, and diff checks green. - -#### Task 14 Fable correction round 2 (2026-08-31) - -- Added a public owner-surface scope-teardown regression: a gated materialization and a distinct generation - queued behind the execution lane both terminate with failure when the explicit owner scope closes, proving the - owner `onExit` handoff completes both deferred callers without hanging. -- Removed redundant `Effect.yieldNow` scheduler nudges from cold-join and interrupted-waiter tests; startup uses - `forkChild(..., { startImmediately: true })` and Deferred gates exclusively. Focused owner/secret/workload/catalog - integration verification passes 58 cases with stack type-check, Effect lint, formatting, and diff checks green. - -#### Task 14 lint-compliance correction (2026-08-31) - -- The injected OIDC fetcher now exposes `StackPreparationError` in its typed failure channel; JSON output uses - Effect schema encoding at the production boundary, while dynamic integration fixtures carry narrow rationale - comments for their direct parse/stringify assertions. The exact changed-file Effect lint now exits cleanly. -- Focused owner/secret/workload/catalog integration verification remains green at 58 cases with stack type-check, - formatting, and diff checks passing. - -#### Knip phase 1–2 cleanup (2026-08-31) - -- Added the `packages/stack` Knip workspace with its `mkfifo` binary exception, removed obsolete ControlClient and - supervisor-bun entrypoints, and deleted confirmed-unused duplicate aliases. -- Knip's scoped exports/types fix made 120 internal declarations non-exported and removed only declarations proven - unused by generic lint; package entry exports and function build subpaths remain intact. Narrow Effect lint rationale - comments preserve the standalone Edge Runtime Promise/Node boundaries, and RPC handler requirements are typed with - `never` to avoid an `any` service requirement. -- Knip, stack type-check, package unit tests, focused control/supervisor/functions integration tests, generic and Effect - lint, formatting, and `git diff --check` pass. Full integration was also verified with two workers (384/384); the - default parallel run remains subject to concurrent native-process load as documented in the runtime test fix. - -#### Task 15 — close the stack package export surface (2026-08-31) - -- Closed `@supabase/stack` to the package root, `./effect`, and `./testing`; the Edge Runtime bootstrap modules - remain private stack-owned sources used internally by `ProductionRuntime`. -- The two CLI Functions bridges now import those exact private sources through documented monorepo-internal relative - paths, preserving source execution and compiled CLI bundling without a new public alias or duplicate runtime. -- Bridge identity tests assert both CLI modules expose the stack-owned implementations and that no private Functions - subpaths remain in the package export map. - -#### Task 16 — managed Functions request-time discovery inputs (2026-08-31) - -- Translated manifest entrypoint, import-map, and static-file paths from the Supabase directory to the selected - function directory by stripping only the exact `./functions//` prefix; undefined values remain unchanged and - Stack retains path-safety validation. -- The authoritative managed-stack Functions E2E belongs in `packages/stack`; the existing CLI Functions E2E remains - unchanged and is not used as the rewrite's end-to-end contract. -- TDD evidence: the focused translation integration test first failed with `./functions/hello/custom-deno.json` - instead of `custom-deno.json`, then passed after the narrow normalization. -- A separate compiled-CLI Supervisor launcher defect was found while exercising the boundary: the standalone binary - resolved the embedded `supervisor-node.ts` to a `/$bunfs` path and invoked its CLI parser instead of a bundled - Supervisor entrypoint, so it exited before fd3 readiness. Direct source-Bun Supervisor startup published readiness. -- Added a private compiled-CLI sentinel dispatch: source Bun continues to execute the Supervisor entrypoint directly, - while the standalone binary re-enters itself with the sentinel and dispatches to the same Supervisor decoder/runner - before normal CLI parsing. The source-level launch-target test and compiled fd3 smoke check pass. - -#### Task 16B — CLI-to-Stack config boundary (2026-08-31) - -- Added a real `CliConfigSchema` → `toStartStackConfig` → `StackConfigSchema` boundary regression covering database - settings, Auth JWT material, Functions secrets, and API listener defaults. The test first reproduced the closed-schema - failure at `capabilities.database.settings.vault` when the translation emitted an explicit `undefined` leaf. -- The CLI translation now recursively omits only undefined object properties before a synchronous, service-free - `StackConfigSchema` decode. Arrays, nulls, primitives, and Effect `Redacted` values remain unchanged. -- Focused and full stack-config unit tests, CLI type-checking, lint, formatting, and diff checks pass. - -#### Task 17 — container service startup migrations (2026-08-31) - -- Added private Docker/Podman `--entrypoint` serialization and a cancellation-safe `wait-container` operation that - decodes exactly one safe numeric exit code. Auth and Storage now resolve their concrete slim-image init processes - (`/usr/local/bin/auth migrate` and `/node/bin/node dist/scripts/migrate-call.js`) while their main containers use - image defaults; Realtime, Analytics, and Pooler continue to use their image-owned entrypoints. -- ContainerRuntime creates a deterministic init container only for newly-created/replaced workloads, reusing the - workload network, env file, mounts, volume, and host route without public publications. It waits for the exact exit, - attaches log streaming after init start so fast exits are replayed, persists init logs when streaming is available, - removes the exact init container on success, failure, or interruption, and starts the main container only after exit 0. Adopted exact main containers do not rerun migration; stale init names are removed as collisions before retry. -- TDD coverage first reproduced ignored entrypoints, absent wait decoding, and missing startup ordering, then passed - serializer, wait, Auth/Storage resolution, ordering, nonzero, interruption, adoption, and stale-collision scenarios. - -#### Task 17 Fable correction — migration observability and failure authority (2026-08-31) - -- Accepted the independently reproduced findings that synthetic startup identities leaked through runtime observation, - init containers inherited the main service's inbound network aliases, and a log-persistence failure could mask a - simultaneous nonzero migration exit. Observation now excludes only the deterministic startup hash suffix, init - containers retain their env file but no inbound aliases or publications, and combined failures keep the process exit - as the first cause without weakening fatal log persistence. -- Added public-runtime regressions for in-flight observation, env/alias/publication separation, sequential startup - entries, crash-orphan recovery, and combined log/exit failures. A proposed bounded log-follower timeout was rejected: - the concrete engine follower is an owned subprocess, interruption already closes it, and no hang was reproduced; a - timeout or polling loop would add policy without evidence. -- Deferred no correctness work: the pinned Auth/Storage image-default and migration-entrypoint contract is owned by the - shared Docker whole-stack E2E. Suggested resolution-type, init-name, and engine-codec refactors were rejected as - cleanup that would not simplify the two distinct boundaries or improve the demonstrated lifecycle. -- The second Fable pass confirmed the three corrections and production wiring, then closed the remaining cheap - evidence gaps: exit-zero plus failed log persistence still blocks main creation, both Docker and Podman execute the - strict wait decoder, and the deliberate init/main name reuse is documented beside the invariant. The milestone is - converged conditional only on the shared Docker E2E exercising the pinned images. - -#### Pause checkpoint — shared whole-stack E2E and native PostgreSQL startup (2026-08-31) - -- Added the public `createTestStack` resource helper draft with an isolated project/state root, `setupProject`, - automatic start/readiness, and exact destroy/close/root cleanup. Added one Stack-package-only whole-stack E2E body - parameterized solely by native versus Docker mode; it exercises SQL, REST, Auth, Realtime, Storage/imgproxy, - Functions live discovery and cross-service REST, SMTP/Mailpit, Studio/pgmeta, Analytics, and Pooler. This E2E is a - draft and has not yet completed against either real runtime. -- Readiness budgets are converged: the persisted database `health_timeout` drives database readiness, other workloads - use the shared 30-second budget, the outer Effect timeout is the sole total deadline, and Reconciler no longer masks - PostgreSQL's budget with an implicit cap. Missing persisted defaults fail before spawn. Compiler/default/configured/ - generic/zero/cancellation behavior passed 69 focused Bun integration tests. Fable reports are saved at - `/Users/jgoux/.codex/fable-reviews/cli/20260831T161313Z.md` and - `/Users/jgoux/.codex/fable-reviews/cli/20260831T163013Z.md`; the second pass found no remaining logic defect. -- Native process preparation now preserves the host environment with string-only service overrides and uses Bun-safe - fd3/fd4 read streams. `NativeRuntime` supports scoped post-readiness one-shots and continuously races the complete - readiness → post-readiness → database-bootstrap → ready transition against the owned main exit and log failure. - The pinned PostgreSQL archive normalization removes the contained duplicate root `init-scripts` sibling while - preserving `migrations/init-scripts`; catalog/artifact/runtime coverage passed 55 focused tests and the native suite - passed 21/21. The second native Fable consensus pass was intentionally interrupted for this pause and produced no - report; rerun it with the full fifteen-minute allowance before accepting the checkpoint. -- PostgreSQL production wiring is the exact next implementation step and is not started: detect first boot from the - database data directory, run the artifact's canonical `share/supabase-cli/migrations/migrate.sh` as a native - post-readiness process, then run the existing Stack database bootstrap. Do not add Stack-owned Supabase migration - SQL or database-reset behavior. -- After native PostgreSQL boots, run the shared native E2E and address only observed service gaps. One known likely - gap is Analytics Vector: the pinned generic artifact/image ships a demo config with its API disabled, while the - current runtime spec expects `/health`; resolve this with the smallest shared native/container Stack-owned config - if the real E2E reproduces it. Then run the unchanged Docker case, Fable-review the whole E2E milestone, audit the - remaining design checklist, and perform final verification. -- The worktree intentionally remains uncommitted at this pause. `.repos/slim-services/` is a read-only upstream - checkout used for artifact/container comparison and must not be committed. - -#### Final rewrite checkpoint — runtime parity, whole-stack E2E, and Fable consensus (2026-09-01) - -This checkpoint supersedes only the incomplete-status statements in the pause checkpoint above; the pause remains -as historical evidence of what was known at that time. - -- Native PostgreSQL first boot now runs the pinned `17.6.1.167` artifact's canonical - `share/supabase-cli/migrations/migrate.sh` after readiness and before Stack bootstrap. An atomic - `.supabase-stack-migration-complete` witness is published only after exit 0; `PG_VERSION` without the witness - identifies an incomplete first boot, so the exact PGDATA is recreated before retry and is never published ready. - Pre-witness development data is intentionally disposable: this package is unreleased and has no shipped producer - or consumer requiring a compatibility backfill. PostgreSQL 15 and every other selector remain unsupported. -- Stateful service migrations are part of workload startup rather than caller hooks. Native Auth, Storage, Realtime, - Analytics, and Pooler run their artifact migration processes before their main process is accepted ready. Container - Auth and Storage use explicit one-shot init containers; Realtime, Analytics, and Pooler retain the pinned image-owned - entrypoint migrations. Dependents cannot start until these phases succeed. -- Runtime inputs are generation-owned and atomic. Pooler cleanup removes only Pooler tenant input; Vector cleanup - removes only Vector material and invalidates its cache so a same-generation restart republishes the config. Vector - emits one finite E2E marker to Analytics and uses `internal_metrics` to a blackhole solely to remain a long-lived - companion, avoiding recurring synthetic Analytics rows. -- Omitted Analytics API keys are generated once as managed 32-byte base64url secrets and persisted; caller-supplied - keys remain pass-through values that may change only while stopped. Log redaction no longer treats the fixed text - `api-key` as a secret. Database `health_timeout` is persisted and must be strictly positive before mutation. -- Native launcher ownership is covered under both Bun and Node fd3 adapters. Graceful SIGTERM/SIGINT forwarding is - tracked separately from exact process-group termination, so later owner-pipe loss still escalates to SIGKILL and - cannot orphan descendants. The launcher inherits only the explicit host allowlist and never ambient database - credentials. -- Destroy completion uses a preface-only control socket owned by the Supervisor scope. The response callback is - handed to an owner-scoped fiber uninterruptibly; `destroy()` returns only after the exact owner closes the socket. - The internal `makeHandle` test seam is not exported by `@supabase/stack/effect`. -- Native artifacts share one immutable test cache outside disposable stack roots. `.repos/slim-services/` is ignored - by Git and remains a read-only upstream comparison checkout, not part of the product diff. - -The final public Stack-package scenario is one `runWholeStackScenario(mode)` body; only the runtime selector changes: - -| Capability | User-shaped observable proof | -| ---------- | ------------------------------------------------------------------------------------------------------ | -| Database | Connect with public credentials and execute SQL against PostgreSQL 17. | -| REST | Trigger lazy activation, authenticate, insert, and read a real row. | -| Auth | Sign up a user and request password recovery. | -| Realtime | Subscribe over WebSocket and observe the inserted database-change payload. | -| Storage | Create a bucket, upload/download an image, and transform it through imgproxy. | -| Functions | Discover a function through Edge Runtime, call REST from it, edit it live, and observe the new result. | -| Studio | Trigger lazy activation and reach the profile endpoint through its pgmeta dependency. | -| Mail | Deliver Auth recovery mail over SMTP and observe it through Mailpit. | -| Analytics | Authenticate, ingest/query a marker, and query the one Vector-delivered marker. | -| Pooler | Connect through the public pooler listener and execute SQL. | - -- Native whole-stack E2E: 1 passed in 63.92s; log `/tmp/whole-native-e2e-vector-keepalive-043459.log`. -- Docker whole-stack E2E: 1 passed in 95.88s; log `/tmp/whole-docker-e2e-vector-keepalive-043615.log`. -- Final named-file rerun exercised both parameterized cases together: 2 passed in 120.98s; log - `/tmp/whole-native-e2e-final.log`. -- Both post-run audits found no owned listeners in 30000-30020, Supervisor/launcher/workload processes, disposable - roots, or matching Docker containers, networks, and volumes. The final audit also enumerated every - `com.supabase.stack.stackId` Docker resource: no resource was created by the final run; stopped remnants from - earlier August 31 development attempts were left untouched rather than treated as this run's ownership. -- Final focused correction verification passed 137 integration tests across seven files, Stack type-checking, - formatting, and `git diff --check`. -- Final parallel-suite verification removed two test-only synchronization hazards without changing runtime behavior. - Lower-level port-coordinator tests now use scope-owned non-listening listener objects while real binding/adoption - remains covered through `HostListener` and Supervisor ingress. The native early-exit regression now awaits the - caller's typed lifecycle failure instead of an incidental final log line that failed-start teardown may cancel. - The named native test passed 20 consecutive runs and the complete integration project passed five consecutive - default-parallel runs (449 tests each; the suite now contains 450 after the descendant-cleanup regression was - added in `packages/stack/src/runtime/native-runtime.integration.test.ts`). -- Fresh completion gates passed the Stack package test command (5 unit and 451 integration tests after the - post-merge canonical-artifact regression), Stack and CLI - type-checks, scoped generic and Effect lint, Stack Knip analysis, tracked-file formatting, and `git diff --check`. -- Fable milestone reports: - `/Users/jgoux/.codex/fable-reviews/cli/20260901T020640Z.md`, - `/Users/jgoux/.codex/fable-reviews/cli/20260901T023939Z.md`, and - `/Users/jgoux/.codex/fable-reviews/cli/20260901T025708Z.md`. The first two passes produced bounded corrections; - the third reached consensus with no material remaining finding. Refuted items were either unreachable through the - serialized Supervisor/Reconciler boundary, already fail-closed before execution, or disproportionate cleanup for - this minimal milestone. Database reset remains completely outside this rewrite. - -#### Final cleanup correction — native BEAM distribution (2026-09-01) - -- A clean-host native whole-stack rerun passed the complete user flow in 43.05s, then exposed one teardown defect - missed by the earlier process audit: Realtime left its artifact-bundled `epmd -daemon` listening on port 4369. - The exact process had been reparented to PID 1 and moved into a process group outside the Stack launcher, proving - that ordinary negative-PGID cleanup cannot own this daemon. -- A generic native-process regression first proved that same-group descendants survived after a direct workload - exited normally. `NativeProcess.exitCode` now performs one cached, parent-side cleanup of the exact launcher group - after capturing the exit code; ESRCH is the only tolerated already-gone result. The regression failed before the - correction, passed ten consecutive runs after it, and the native runtime integration file passes 27 tests. -- That generic correction deliberately remains: it closes the normal descendant contract for every native workload. - It cannot reach Erlang's deliberately detached daemon. The pinned Realtime, Analytics, and Pooler artifacts each - bundle an `env.sh` that unconditionally resets `RELEASE_DISTRIBUTION=name`, even though their generated release - launcher already supports `RELEASE_DISTRIBUTION=none`. -- The minimal completion path is an upstream `supabase/slim-services` artifact correction: make those three generated - `env.sh` files treat `name` as a default while honoring a caller value, rebuild the published native artifacts and - derived images, then set `RELEASE_DISTRIBUTION=none` only for native Stack workloads. Docker keeps its distributed - default. Stack-owned release wrappers, mutable cache patching, global PID scans, and a managed hidden EPMD port were - rejected because they add lifecycle machinery or weaken artifact parity. -- The exact stale test-owned EPMD processes were terminated after evidence capture. The final native E2E must be rerun - from a host with no EPMD listener after rebuilt artifacts are available, and teardown must leave no EPMD process. - Until that proof exists, the rewrite is not complete and Step 5 remains open. -- Post-merge Fable review: - `/Users/jgoux/.codex/fable-reviews/cli/20260901T043012Z.md`. Independently validated Keep findings cover canonical - native artifact tags, typed companion selection, removal of the false imgproxy/database edge, an explicit - user-supplied Pooler tenant in the E2E, and small catalog type/duplication cleanup. The Studio/Analytics contract - remains recorded above as uncertain. Findings about cross-package registry tests, hypothetical unobserved process - exits, explicit-undefined defaults, generic registry-reference parsing, and PID reuse were refuted or deferred as - unreachable, already intentional, or disproportionate to this minimal rewrite. -- Convergence Fable review: - `/Users/jgoux/.codex/fable-reviews/cli/20260901T045319Z.md`. The second pass reached consensus with no material - finding. Its one concrete low-cost hardening was applied: fresh workload spec hashes now name the completed - materialized settings explicitly instead of relying on the managed-slot helper mutating an earlier alias in place. -- Upstream BEAM correction PR: `supabase/slim-services#293`. Its shared fail-closed artifact fixup changes generated - release environments from an unconditional named-distribution assignment to `name` as the default, preserving an - explicit caller value. Native Realtime, Analytics, and Pooler artifact smokes request `none`; derived images omit - the override and therefore retain their existing named-distribution default. -- Stack runtime environments now set `RELEASE_DISTRIBUTION=none` only for native Realtime, Analytics, and Pooler. - One red/green integration scenario covers all three native mappings and proves all three container mappings omit - the variable. `ProductionRuntime` shares the resolved environment across startup migrations/evals and the main - process, so no parallel startup mechanism is required. -- Replacing the already-published artifacts remains separately approval-gated. Until those releases are rebuilt from - the upstream correction, the final clean-host E2E/no-EPMD proof remains open. -- Pause checkpoint Fable review: - `/Users/jgoux/.codex/fable-reviews/cli/20260901T052134Z.md`. The design remains accepted. The next bounded pass must - add behavior-level override verification against the three built artifacts, remove Analytics' native-only - `--sname` smoke argument so the smoke matches Stack, prove `ProductionRuntime` propagates the resolved BEAM value - into a real startup process, and document why container environments intentionally omit the variable. Pooler's - template was initialized and independently confirmed to contain the exact assignment handled by the upstream - fixup; the artifact rollout gate was already recorded above. -- Resume from the committed Stack-side environment mapping and the pushed upstream PR. Do not replace published - Realtime, Analytics, or Pooler assets until that separate destructive release action is explicitly confirmed. -- Resume correction completed locally. Stack commit `499cd6c0e` adds an integration scenario through - `makeProductionRuntimeFactory` and `runtime.driver.start`; a real Realtime migration fixture observes - `RELEASE_DISTRIBUTION=none`, proving the shared startup-process merge rather than only the workload-spec mapping. - The native/container rationale is documented at the shared environment helper. Fresh focused verification passed - 31 integration tests, Stack type-checking, both lint configurations, formatting, and `git diff --check`. -- Upstream follow-up commits `4743f176` and `f5dfac9f` correct a build-context defect discovered after the first PR - commit: real local temporary exports and Docker-hosted Nix overlays do not contain repository-relative helper - scripts. Each of the three concrete Nix packages therefore owns the same small, fail-closed transform, requiring - exactly one generated `env.sh` and exactly one exported assignment. Native artifact smokes execute the actual - release launcher with complete service runtime settings and prove unset caller -> `name` plus explicit caller -> - `none`. The dead standalone transformer and native Analytics' ignored `--sname` argument were removed; image smoke - and entry paths remain unchanged. -- A separate repository-check portability failure surfaced while validating the upstream change: this host kills a - heredoc-fed Python process when the same invocation carries larger JSON argv. The poller now passes those payloads - through environment variables without changing release-selection behavior. Its 16 focused tests and the complete - upstream repository-check sequence pass, together with ShellCheck, all three Nix parse/type checks, and the BEAM - behavior fixture. -- Real native artifact builds were not available locally. PR CI remains responsible for building and running the - three actual artifact smokes. Even after that proof, replacing published assets and running the final clean-host - Stack E2E/no-EPMD gate remain separately approval-gated; neither action has been performed. -- Fable convergence pass: - `/Users/jgoux/.codex/fable-reviews/cli/20260901T064559Z.md`. The review found no design or Stack-runtime defect. - Accepted hardening now extracts one unique marker from noisy release `eval` output, rejects missing/duplicate/wrong - values, handles empty environment arrays on the macOS Bash baseline, and reuses the existing scoped readiness - listener in the Stack integration fixture. The claim that Analytics' template shape was unconfirmed was refuted by - direct inspection: the pinned Realtime, Analytics, and Pooler templates each contain exactly one supported exported - assignment. Extra process fixtures, keep-in-sync comments, and unrelated state-fixture cleanup were rejected as - duplicate coverage or non-executable maintenance noise. -- Fable convergence re-review: - `/Users/jgoux/.codex/fable-reviews/cli/20260901T071406Z.md`. It reached consensus that all accepted corrections are - correct, fail-closed, and minimal, with no introduced regression. Its remaining observations are explicitly - non-blocking: partial-line launcher noise would fail closed, the macOS empty-array branch is difficult to exercise - on newer CI Bash, and broader pre-existing test-listener deduplication is outside this correction. -- Non-publishing artifact workflow - [run 33481429073](https://github.com/supabase/slim-services/actions/runs/33481429073) built, audited, packaged, and - smoke-tested Realtime, Analytics, and Pooler successfully on Linux AMD64, Linux ARM64, and macOS ARM64 from upstream - PR head `6732080a`. All nine service jobs passed and no cached artifact was reused. This workflow exercises the - repository's currently pinned source releases (`realtime-v2.123.5`, `analytics-v1.46.0`, `pooler-v2.9.10`), proving - the correction across the real local/Docker build and launcher paths but not replacing the exact Stack-selected - releases (`v2.130.0`, `v1.50.6`, `v2.9.12`). Those exact releases still require approved rebuild/replacement before - the final clean-host native Stack E2E can close the no-EPMD gate. -- User authorized forced replacement of the published artifacts from `supabase/slim-services#293` at head `6732080a`; - the PR remains open and is not claimed as merged. Exact service-release runs succeeded for Realtime `v2.130.0` - (`33488596843`), Analytics `v1.50.6` (`33488596567`), and Pooler `v2.9.12` (`33488596827`). Each passed Linux - AMD64, Linux ARM64, and macOS ARM64 artifact build/audit/host smoke, derived-image checks, and GitHub release - publication. Fresh GHCR digests are Realtime `sha256:d2cb57c4ea72f48f926e780afd353251114844551e947037a356add8bac40385`, - Analytics `sha256:3e398c147b50eac8fb807960ef5f03035c975e442f79621c086096d80723928f`, and Pooler - `sha256:a4fa7e89764a0a70fb7f94d93597d960f73ca4022e129533901c83f2666e7d4c`. -- The first native rerun failed before service startup because the intentionally shared test artifact cache under the - macOS tmpdir contained stale same-version checksums. Only the exact three test-owned cache entries were moved to the - recoverable quarantine `/private/tmp/supabase-stack-stale-beam-cache.Usz2AZ`; unrelated artifacts were untouched. -- Final Stack package shared E2E from fresh exact artifacts: native `1 passed / 1 skipped`, 114.59s test time (115.35s - total), with post-run audit finding no EPMD process/listener, no owned ports 30000-30020, and no - Supervisor/launcher/BEAM workload. Docker, after pulling the exact new digests, had `1 passed / 1 skipped`, 71.21s - test time (71.99s total); post-run only the previously recorded Aug 31 stale Docker resources existed, with no new - resources from this run. The final no-EPMD gate is closed and both native and Docker modes are now proven. diff --git a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md b/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md deleted file mode 100644 index fb8d804e00..0000000000 --- a/docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md +++ /dev/null @@ -1,744 +0,0 @@ -# Stack Runtime Greenfield Rewrite Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to -> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace `@supabase/process-compose` and the existing `@supabase/stack` implementation with -one minimal Effect v4 managed-stack runtime implementing the approved native/container architecture. - -**Architecture:** Public APIs name stacks and capabilities; private capability Modules compile a -fully materialized definition into workloads. One detached Supervisor owns durable intent, -reconciliation, private runtime resources, the public gateway, and a single control endpoint. The -Effect API is authoritative; Promise and CLI surfaces mechanically adapt it. - -**Tech Stack:** TypeScript 7, Effect v4 RC pinned by the workspace catalog, Effect Platform for Bun -and Node, Effect RPC, Vitest with `@effect/vitest`, pnpm/Turbo, native subprocesses, Docker/Podman. - -**Spec:** `docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md` - -**Progress:** `docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md` - -## Global constraints - -- Greenfield implementation: delete obsolete APIs, tests, adapters, and compatibility scaffolding. -- Managed stacks only. Ordinary handles use explicit `close()`; only `createTestStack` supports - `await using` and destroys its unique identity on disposal. -- Core code is Effect-native. Promises exist only at public non-Effect entrypoints and foreign leaf - adapters; production code must not use TypeScript casts to silence type errors. -- Consult `.repos/effect/` before choosing an Effect construct. Use `Data.TaggedError` by default and - the pinned v4 `Schema.TaggedErrorClass` only when the error itself needs a Schema; use services and - Layers, Scope/acquireRelease, Deferred or shared Fibers for single-flight, Semaphore for lifecycle - serialization, SubscriptionRef for snapshot state, PubSub for retained/live logs, Schedule for - retry, and Effect RPC for the exact-release protocol. -- Never allocate reusable mutable ownership state while constructing an Effect. Build it inside - `Effect.gen`, `Effect.suspend`, or scoped acquisition. -- Native and container workloads never mix in one identity. Every catalog workload has both native - and container artifacts; unsupported platform/runtime mappings fail before durable mutation. -- Persist complete materialized definitions including defaults. Persist secret bytes and concrete - port assignments exactly once in their dedicated state fields. -- Lazy activation is mandatory in both runtimes and has no idle eviction. -- Functions are served only through the stack Edge Runtime. `functionsRoot` is the sole host root and - container mount; reject traversal, absolute, and symlink escapes. -- Use the portable archive and image from the same qualified `supabase/slim-services` release for - each workload. Initially accept only PostgreSQL `17.6.1.167`; add PostgreSQL 15 only after its - matching archive and image are published. -- The caller owns migrations, declarative schemas, and seeds. Database reset is outside this rewrite; - do not add a reset API, state, lifecycle variant, or compatibility placeholder. -- Tests follow strict red-green-refactor and exercise consumed behavior. Prefer integration tests; - unit-test only pure compiler/planner/state-machine logic. No arbitrary sleeps, released-port reuse, - global process scans, or broad cleanup. -- Use only targeted tests during implementation. Do not run unrelated e2e suites. -- Every completed task updates the tracked progress ledger and lands as one focused commit. - -## Component map - -```text -packages/stack/src -├── public/ schemas, errors, Effect contract, Promise adapter -├── identity/ project/worktree identity and StackId -├── model/ capability Modules, catalog, materialized compiler, execution graph -├── state/ paths, atomic document, secret slots, ports, ownership -├── control/ stable maintenance framing, exact-release RPC, clients -├── supervisor/ owner session, desired state, reconciler, status/log publication -├── preparation/ verified native/container artifacts -├── runtime/ common contract, native driver, container driver/engine adapters -├── gateway/ Supervisor-owned HTTP/TCP ingress and direct activation -├── functions/ safe live function discovery -└── entrypoints/ Node/Bun Supervisor and gateway programs -``` - -Each file owns one responsibility. Public modules never export workload ids, execution plans, -artifact keys, state schemas, drivers, or control protocols. - ---- - -### Task 0: Remove the legacy implementations and establish the empty package boundary - -**Files:** - -- Delete: `packages/process-compose/**` -- Delete: legacy `packages/stack/src/**`, `packages/stack/tests/**`, `packages/stack/scripts/**`, and - stale `packages/stack/docs/**` -- Create: `packages/stack/src/public/index.ts`, `packages/stack/src/index.ts`, - `packages/stack/src/effect.ts`, `packages/stack/src/testing.ts` -- Replace: `packages/stack/package.json`, `packages/stack/README.md`, `packages/stack/vitest.config.ts` -- Modify: `package.json`, `.oxlintrc.json`, `knip.json`, `turbo.json`, `apps/cli/package.json`, - `apps/cli/src/next/main.ts`, CLI tests importing the old process mock, telemetry coverage, - `AGENTS.md`, `CONTRIBUTING.md`, `pnpm-lock.yaml` - -**Interfaces:** - -- Produces a private ESM `@supabase/stack` workspace with only `.`, `./effect`, and `./testing` - exports and no runtime behavior yet. -- Moves the generic `mockChildProcessSpawner` test utility to - `apps/cli/tests/helpers/child-process-spawner.ts`; this is test infrastructure, not a runtime API. - -- [x] **Step 1: Record the deletion baseline** - - Add the base commit and the exact active process-compose references to the tracked progress file. - -- [x] **Step 2: Delete both legacy implementations before writing replacement runtime code** - - Keep only package configuration, the empty public barrels, and the approved tracked design. Do not - copy orchestration implementation into the new tree. - -- [x] **Step 3: Remove workspace and CLI process-compose coupling** - - Remove manifest/config/lockfile entries, process-compose self-dispatch in the CLI bootstrap, old - telemetry adapters, and active documentation references. Preserve `.repos/process-compose` and - historical design/ADR references because those are source history, not the deleted workspace. - -- [x] **Step 4: Regenerate the lockfile and verify the empty boundary** - - ```bash - pnpm install --lockfile-only - rg -n '@supabase/process-compose|packages/process-compose' package.json packages apps \ - --glob '!docs/superpowers/**' --glob '!docs/adr/**' - pnpm --dir packages/stack types:check - ``` - - Expected: install and stack type-check exit 0; the search finds no active reference. - -- [x] **Step 5: Commit** - - ```bash - git add -A - git commit -m "refactor(stack): remove legacy stack runtimes" - ``` - -### Task 1: Public schemas, tagged failures, and stable status snapshots - -**Files:** - -- Create: `packages/stack/src/public/StackId.ts`, `Runtime.ts`, `Capability.ts`, `Status.ts`, - `Logs.ts`, `Credentials.ts`, `Errors.ts` -- Create: `packages/stack/src/public/public-model.integration.test.ts` -- Modify: `packages/stack/src/public/index.ts`, `packages/stack/src/index.ts` - -**Interfaces:** - -- Produces the common public model from the spec: `StackId`, `StackRuntime`, - `StackRuntimePreference`, ten `CapabilityName` values, `StackStatus`, `LogCursor`, `StackLogEntry`, - descriptors/inspection, credentials, and operation-specific error unions. Task 3 adds the closed - capability configuration after every Module owns its settings schema. -- Promise secret-bearing types contain `string`; Effect secret-bearing types contain - `Redacted.Redacted`. - -- [x] **Step 1: Write decoding and snapshot-contract integration tests** - - ```ts - it.effect("decodes a complete status snapshot", () => - Schema.decodeUnknownEffect(StackStatusSchema)(STATUS_FIXTURE).pipe( - Effect.map((status) => expect(status.capabilities).toHaveLength(10)), - ), - ); - ``` - - Assert with public predicates or error matching rather than Effect runtime fields. - -- [x] **Step 2: Run RED** - - ```bash - pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts - ``` - - Expected: failure because the schemas do not exist. - -- [x] **Step 3: Implement schemas and errors with Effect v4 APIs** - - Use `Data.TaggedError` for ordinary domain failures and pinned v4 `Schema.TaggedErrorClass` only for - failures that cross a schema-serialized boundary. Decode through `Schema.decodeUnknownEffect`; - never throw expected validation failures. - -- [x] **Step 4: Verify and commit** - - ```bash - pnpm --dir packages/stack exec vitest run --project integration src/public/public-model.integration.test.ts - pnpm --dir packages/stack types:check - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): define the greenfield public model" - ``` - -### Task 2: Deterministic identity and safe state paths - -**Files:** - -- Create: `packages/stack/src/identity/Identity.ts`, `GitIdentity.ts`, `FolderIdentity.ts` -- Create: `packages/stack/src/state/Paths.ts` -- Create: `packages/stack/src/identity/identity.integration.test.ts` -- Modify: `packages/stack/src/public/StackId.ts`, `public-model.integration.test.ts` -- Modify: `packages/stack/package.json`, `pnpm-lock.yaml` to add the concrete Node platform test layer; - core identity/path functions retain visible `FileSystem` and `Path` requirements - -**Interfaces:** - -- Produces `resolveStackIdentity({ projectRoot, name })`, `deriveStackId(identity)`, and - exact identity-scoped paths beneath the user-local stack state root. -- `StackIdSchema` accepts exactly the 64 lowercase hexadecimal characters produced by SHA-256; path - APIs accept only this decoded branded value. -- Path construction accepts only validated `StackId` values and cannot address a parent, sibling, or - another identity. - -- [x] **Step 1: Write real filesystem/worktree and path-safety tests and verify RED** - - Cover repeat identity, named isolation, sibling worktrees, detached checkout, nested project roots, - branch changes, invalid ids, and exact path containment. - - ```bash - pnpm --dir packages/stack exec vitest run --project integration \ - src/identity/identity.integration.test.ts - ``` - -- [x] **Step 2: Implement identity without a registry daemon** - - Canonicalize the project root, derive the tuple documented by the spec, and hash length-delimited - UTF-8 components into an opaque lowercase digest. Discovery performs no writes. - -- [x] **Step 3: Implement exact state-path derivation with platform services** - - Keep `FileSystem` and `Path` requirements visible. Validate identity before joining path segments; - expose stack root, state document, data, logs, control metadata, and temporary sibling paths without - performing lifecycle mutation. - -- [x] **Step 4: Verify and commit** - - Run the targeted identity file and package type-check, update progress, then commit: - - ```bash - git add packages/stack pnpm-lock.yaml \ - docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md \ - docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): add identity and safe state paths" - ``` - -### Task 3: Closed capability Modules and the materialized compiler - -**Files:** - -- Create: `packages/stack/src/model/CapabilityModule.ts`, `ExecutionPlan.ts`, `Compiler.ts` -- Create: `packages/stack/src/public/Config.ts` -- Create: `packages/stack/src/model/capabilities/{database,rest,auth,realtime,storage,functions,studio,mail,analytics,pooler}.ts` -- Create: `packages/stack/src/model/compiler.integration.test.ts` -- Create: targeted pure planner tests beside `Compiler.ts` only for fingerprint and graph branches - -**Interfaces:** - -- Produces the exact closed `StackConfig` and listener/security schemas from the design. -- Each Module owns its exhaustive settings Schema, defaults, validation, secret classification, - dependencies, workload definitions, readiness, routes, and both artifact mappings. -- Produces `compileStack(input, previous?)` returning non-secret `StackDefinition`, non-secret - `InputFingerprint`, resolved secret-slot inputs, and private `ExecutionPlan`. -- Workload ids and concrete artifact keys remain private. - -- [x] **Step 1: Write one public compiler scenario per capability and verify RED** - - Each scenario supplies non-default values accepted by current CLI configuration and asserts they - survive in the materialized definition. Add cross-cutting scenarios for all defaults persisted, - unknown fields rejected, secret bytes absent, exact unsupported versions rejected before output, - both runtime mappings present, and functions path normalization. - -- [x] **Step 2: Implement the ten direct closed schemas** - - Use direct `Schema.Struct` declarations owned by each Module; do not add a code generator or open - property bags. Port the actual supported fields from `CliConfig` and from the baseline sources at - `de26a30c7:packages/stack/src/StackConfig.ts` and `ServiceCatalog.ts`, updating consumers in the - same task rather than preserving the old shapes. - -- [x] **Step 3: Implement pure compilation** - - Canonicalize non-secret input for the fingerprint, preserve explicit omission versus selection, - apply every default, convert secret leaves to generated slots, validate dependency closure, and - derive semantic workload-spec hashes. Reuse the persisted definition on identical fingerprints. - -- [x] **Step 4: Verify and commit** - - Run only `compiler.integration.test.ts`, adjacent pure planner tests, and stack type-check. Commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): compile closed capability definitions" - ``` - -### Task 4: Atomic durable state, sticky secrets, and port coordination - -**Files:** - -- Create: `packages/stack/src/state/StackState.ts`, `StackStateStore.ts`, `SecretStore.ts`, - `PortRegistry.ts`, `PortCoordinator.ts` -- Create: `packages/stack/src/state/state-store.integration.test.ts`, `secrets.integration.test.ts`, - `ports.integration.test.ts` - -**Interfaces:** - -- Produces `StackStateStore`, `resolveSecrets(candidate, persisted, lifecycle)`, and scoped - `PortCoordinator.planAndReserve(stackId, listenerIntents)`. -- `PersistedStackState` has one format identifier, identity, concrete runtime, generation, desired - lifecycle, optional materialized definition/fingerprint, ports, and secret values. Reads are - lock-free; writes atomically replace one complete owner-only document. -- Automatic assignments are globally exclusive while stopped/running; stopped exact assignments may - coexist and are validated only on start. Assignments stay sticky after bind races. - -- [x] **Step 1: Write state, secret, and port scenarios first** - - Test atomic old-or-new reads, corrupt-state fail-closed behavior, exact-identity cleanup, managed - generation/reuse/mismatch, stopped-only pass-through replacement, no secret bytes in fingerprints/ - log messages, automatic sticky exclusivity, exact stopped coexistence, live exact conflicts, - unchanged assignment reuse, native socket transfer, and container bind-race retention. - -- [x] **Step 2: Verify RED, then implement atomic state and scoped acquisition** - - Encode/decode through Effect Schema; atomically replace owner-only state through a sibling temporary - file and required fsync operations. Reserve complete candidate sets under the short registry lock - and state generation fence. Own native sockets with `Effect.acquireRelease`; use an engine- - authoritative publish result for containers. Never bind-release-rebind an automatic native port. - The first release reads and writes only `supabase-stack-state-v1`; add no migration framework. - -- [x] **Step 3: Verify and commit** - - Run the three targeted integration files plus stack type-check and commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): persist state secrets and sticky ports" - ``` - -### Task 5: One owner endpoint, Supervisor launch, and managed handles - -**Files:** - -- Create: `packages/stack/src/state/Ownership.ts` -- Create: `packages/stack/src/control/MaintenanceProtocol.ts`, `StackRpc.ts`, `ControlClient.ts` -- Create: `packages/stack/src/supervisor/Supervisor.ts`, `OwnerSession.ts`, `Launcher.ts` -- Create: `packages/stack/src/entrypoints/supervisor-{node,bun}.ts` -- Create: `packages/stack/src/public/EffectStack.ts` -- Create: `packages/stack/src/supervisor/handles.integration.test.ts` - -**Interfaces:** - -- Produces Effect `createStack`, `openStack`, `findStack`, `listStacks`, and `inspectStack`, plus the - complete `EffectStack` handle contract: status, credentials, prepare, start, restart, stop, destroy, - close, status snapshots, and logs. Later tasks connect those methods to their runtime components. -- One atomic ownership primitive and endpoint carries frozen bounded-JSON maintenance operations plus - exact-release Effect RPC. Ordinary handles are scoped/closeable; owner work outlives handles. - -- [x] **Step 1: Write multi-process handle scenarios and verify RED** - - Cover create without config/start, same identity joining one owner, open-only behavior, read-only - discovery, closing/caller exit survival, concurrent equivalent create, incompatible owner failure, - and stable maintenance stop. - -- [x] **Step 2: Implement ownership and protocols** - - Use one endpoint, random owner session, bounded framing, typed decode failures, and Effect RPC for - exact-release operations. Register all callback listeners before starting I/O, resume at most once, - and remove every owned listener/socket in the cancellation Effect. - -- [x] **Step 3: Implement shared launch/attachment** - - Represent launch with a cached Effect/shared Fiber or `Deferred>`; never a boolean plus - polling. Waiter interruption must not cancel owner launch or cleanup. Supervisor state is allocated - inside its scoped Effect execution. - -- [x] **Step 4: Verify and commit** - - Run `handles.integration.test.ts`, stack type-check, update progress, and commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): add supervisor ownership and managed handles" - ``` - -### Task 6: Reconciler, status snapshots, and retained/live logs - -**Files:** - -- Create: `packages/stack/src/supervisor/Reconciler.ts`, `DesiredState.ts`, `StatusHub.ts`, `LogStore.ts` -- Create: `packages/stack/src/runtime/RuntimeDriver.ts` -- Create: `packages/stack/src/supervisor/reconciler.integration.test.ts`, `observability.integration.test.ts` -- Create: `packages/stack/src/supervisor/Reconciler.unit.test.ts` for deterministic graph planning - -**Interfaces:** - -- `RuntimeDriver` observes, starts, stops, and removes exact private workloads. -- `Reconciler` compiles a deterministic plan from durable generation + observed snapshot. -- `status`/`watchStatus` publish complete projections; logs have bounded storage, opaque cursors, - exact known-secret redaction, and atomic retained-to-live handoff. - -- [x] **Step 1: Write failure-isolation and concurrency scenarios** - - Cover dependency ordering, reverse stop, independent branch survival, blocked dependents, readiness - deadlines, bounded restart budgets, caller cancellation, generation fences, one reconciliation at a - time, first watch snapshot, no duplicate/gap log handoff, and capability-only public attribution. - -- [x] **Step 2: Implement with Effect coordination primitives** - - Use one `Semaphore` for lifecycle serialization, `SubscriptionRef` for current snapshot and change - stream, `PubSub` for live logs, `Deferred` for activation/readiness handoff, and `Schedule` for retry. - Do not recreate waiter arrays, boolean gates, or sleep-based propagation. - -- [x] **Step 3: Verify and commit** - - Run the two integration files and the focused pure planner test, then commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): reconcile workloads and publish observability" - ``` - -### Task 7: Artifact preparation, native execution, and database bootstrap - -**Files:** - -- Create: `packages/stack/src/preparation/ArtifactStore.ts`, `Integrity.ts` -- Create: `packages/stack/src/runtime/NativeRuntime.ts`, `NativeProcess.ts` -- Create: `packages/stack/src/model/DatabaseBootstrap.ts` -- Create: `packages/stack/src/preparation/artifacts.integration.test.ts` -- Create: `packages/stack/src/runtime/native-runtime.integration.test.ts` - -**Interfaces:** - -- `prepare` verifies and atomically publishes selected native/container artifacts without state, - ports, network, secrets, or workload mutation. -- `NativeRuntime` implements `RuntimeDriver` with exact process-tree ownership. Database bootstrap is - a post-probe readiness phase on the real database workload, not a synthetic workload. Task 7 defines - the release-plan resolver and PostgreSQL runner. A native release's bundled, non-idempotent first-boot - migration script uses one atomic PGDATA completion witness; an initialized directory without that - witness is recreated before retry and is never published ready. Task 13 supplies the runtime-owned - ordered bootstrap revision catalog, whose applied revisions live inside PostgreSQL. Both phases gate - every database dependent on completion. Gateway listener ownership and native transfer belong to - Task 8, where the gateway contract is implemented; Task 7 does not add a speculative listener - interface. - -- [x] **Step 1: Write preparation/native behavior tests and verify RED** - - Cover cached/downloaded outcomes, integrity failure cleanup, Supervisor-local single-flight, - concurrent Supervisor duplicate safety, cancellation, process readiness/failure, exact process-tree - termination, parent-loss termination, and bootstrap-before-dependent readiness. - -- [x] **Step 2: Implement leaf foreign boundaries** - - Wrap foreign Promise/callback APIs once with cancellation signals and typed errors. Publish downloads - by atomic rename. Run native subprocesses through Effect Platform; attach output to `LogStore` and - termination to Scope. - -- [x] **Step 3: Verify and commit** - - Run the two targeted files and stack type-check, then commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): prepare artifacts and run native workloads" - ``` - -### Task 8: Gateway and mandatory lazy activation - -**Files:** - -- Create: `packages/stack/src/gateway/Gateway.ts`, `HttpGateway.ts`, `TcpGateway.ts` -- Create: `packages/stack/src/gateway/gateway.integration.test.ts` -- Delete after production composition replaces the provisional split path: - `packages/stack/src/gateway/ActivationServer.ts`, `ActivationFile.ts`, - `packages/stack/src/gateway/activation.integration.test.ts` - -**Interfaces:** - -- Gateway is the only public ingress and routes HTTP/WebSocket and transparent TCP listeners. -- The Supervisor-owned in-process gateway calls Supervisor activation directly in both runtime - modes. Container workloads expose only loopback-bound private backend ports to it. - -- [x] **Step 1: Write observable protocol scenarios and verify RED** - - Cover HTTP routes/CORS/forwarding/WebSocket, PostgreSQL/TLS/SMTP/POP3/STARTTLS byte transparency, - backpressure/half-close, 503 activation failure, 502 backend failure, dormant health probes, - dependency-closure activation, generation/session fences, concurrent activation joining, and no - idle eviction. - -- [x] **Step 2: Implement ingress and activation** - - Use stream/socket platform APIs with scoped cancellation. Keep the gateway in the Supervisor so - both runtime modes share the same direct activation path. Do not introduce a gateway artifact, - activation server, capability file, or second control protocol. - -- [x] **Step 3: Verify and commit** - - Run both gateway integration files and stack type-check, then commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): add gateway and lazy activation" - ``` - -### Task 9: Live Functions through the managed Edge Runtime - -**Files:** - -- Create: `packages/stack/src/functions/FunctionsRoot.ts`, `FunctionDiscovery.ts` -- Modify: `packages/stack/src/model/capabilities/functions.ts`, gateway routing, native runtime -- Create: `packages/stack/src/functions/functions.integration.test.ts` - -**Interfaces:** - -- Produces request-time slug discovery under one `functionsRoot` with closed persisted overrides. -- Native and container execution use the stack Edge Runtime; no separate functions-serving workflow. - -- [x] **Step 1: Write safe live-edit scenarios and verify RED** - - Cover create/edit/delete visible on next request, shared modules, import maps/static files, disabled - override not-found behavior, and rejection of traversal, absolute paths, and symlink escapes. - -- [x] **Step 2: Implement safe resolution** - - Canonicalize and realpath every resolved entry/import/static/symlink target before opening it; prove - membership beneath `functionsRoot`. Container mode mounts only the whole root read-only at one stable - path. Do not add a watcher or desired-state mutation for code edits. - -- [x] **Step 3: Verify and commit** - - Run `functions.integration.test.ts` in native fixtures, stack type-check, and commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): serve live functions through edge runtime" - ``` - -### Task 10: Strict container runtime and engine adapters - -**Files:** - -- Create: `packages/stack/src/runtime/ContainerEngine.ts`, `DockerEngine.ts`, `PodmanEngine.ts`, - `ContainerRuntime.ts` -- Modify: gateway backend routing and Supervisor composition -- Create: `packages/stack/src/runtime/container-runtime.integration.test.ts` - -**Interfaces:** - -- Docker and Podman adapters create exact identity/generation-labeled private networks, workloads, - persistent volumes, loopback-only private backend publications, read-only config files, and exact - cleanup. -- No native process is started for a container identity and no container is created for native. - -- [x] **Step 1: Write controlled engine integration scenarios and verify RED** - - Cover platform alias selection (`host.docker.internal`, Linux host-gateway, - `host.containers.internal`), unsupported routing preflight, no public workload publication, - loopback-only private backend endpoints, engine bind races, semantic-hash adoption, stale removal, - persistent volumes, strict runtime split, and lazy Functions edits through the mounted root. - -- [x] **Step 2: Implement the smallest engine contract** - - Model only inspect/pull/create/start/stop/remove/network/volume operations actually used by the - execution plan. Parse exact structured engine output and map failures at the leaf boundary. Do not - build a general container SDK. - -- [x] **Step 3: Verify and commit** - - Run `container-runtime.integration.test.ts` against controlled Docker and Podman adapters, stack - type-check, and commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): run strict container stacks" - ``` - -### Task 11: Start/stop/restart/destroy and crash recovery - -**Files:** - -- Modify: public Effect stack, Supervisor, Reconciler, owner/control, state, both runtime drivers -- Create: `packages/stack/src/supervisor/lifecycle.integration.test.ts` -- Create: `packages/stack/src/supervisor/recovery.integration.test.ts` - -**Interfaces:** - -- `start` applies config only at unconfigured/stopped boundaries and returns current status for an - identical running input. Changed running input fails and suggests explicit restart. -- `restart` preflights, quiesces through stable maintenance, may apply allowed stopped-time changes, - and loses to concurrent stop. Destroy removes exact data; stop retains state/data/logs/artifacts. - -- [x] **Step 1: Write the complete lifecycle matrix and verify RED** - - Cover identical/changed fingerprints, pass-through-only changes, version rules, initialized database - immutability, concurrent calls, stop winning restart, owner replacement failure, reboot no-autostart, - native owner loss, container fail-closed, exact adoption fences, corrupt state, and explicit exact - destructive cleanup. - -- [x] **Step 2: Implement durable transitions** - - Commit validated complete intent before reconciliation. Use stable maintenance only for stop/quiesce; - protocol mismatch is an upgrade signal. Keep replacement interruptible outside the narrow - acquisition-to-registration mask and preserve unrecognized Causes. - -- [x] **Step 3: Verify and commit** - - Run both targeted lifecycle files and stack type-check, then commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): complete managed lifecycle and recovery" - ``` - -### Task 12: Promise facade, test resource, and CLI migration - -**Files:** - -- Create: `packages/stack/src/public/PromiseStack.ts`, `packages/stack/src/public/Testing.ts` -- Modify: root/effect/testing exports and Node/Bun platform entrypoints -- Modify: all `apps/cli` imports/callers of the deleted stack API, start/stop/status/functions commands, - CLI bootstrap, telemetry adapters, and relevant integration helpers/tests -- Create: `packages/stack/src/public/promise.integration.test.ts` -- Create: `packages/stack/src/public/testing.integration.test.ts` - -**Interfaces:** - -- Root Promise facade exposes create/open/find/list/inspect with plain-string secrets and - AsyncIterable status/log streams. Handles have explicit `close()` and no async-dispose symbol. -- `createTestStack` is an `AsyncDisposable` wrapper that creates, starts, waits, and destroys only its - unique temporary identity. -- CLI translates `CliConfig` to `StackConfig`; `supabase functions serve` starts/opens the managed - stack Functions capability and streams its logs through Edge Runtime. - -- [x] **Step 1: Write facade ownership tests and CLI handler integration tests first** - - Cover plain-string credentials/config, `Option` to `undefined`, stream cancellation, explicit handle - close survival, test-wrapper disposal cleanup/startup failure cleanup, CLI start/restart guidance, - complete status rendering, stop/destroy, and managed Functions serving/live edits. - -- [x] **Step 2: Implement mechanical adapters** - - Give each Promise handle one private Scope closed by `close()`. Unwrap `Redacted` only at the outer - boundary and preserve tagged errors. Implement the test wrapper as the sole `AsyncDisposable`. - -- [x] **Step 3: Migrate CLI consumers to the new API** - - Update call sites directly; do not recreate `daemonLayer`, `foregroundLayer`, managed manager, - launch metadata, old port/version helpers, or compatibility subpaths. Move configuration translation - into the CLI and use new descriptors/status projections. - -- [x] **Step 4: Verify and commit** - - Run both stack facade integration files plus only changed CLI handler integration files, then stack - and CLI type-checks. Commit: - - ```bash - git add packages/stack apps/cli docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): expose facades and migrate the CLI" - ``` - -### Task 13: Complete catalog and platform coverage - -**Files:** - -- Modify: every capability Module and catalog mapping -- Create: only the integration fixtures needed to exercise missing native/container mappings - -**Interfaces:** - -- Completes all ten capabilities with exhaustive settings and real native/container artifacts. -- Does not add new public lifecycle concepts or a second Functions-serving path. - -- [x] **Step 1: Audit capability and platform coverage** - - Build the requirement-to-source/test table in the tracked progress document. For every capability, - identify exhaustive settings coverage, exact native and container artifacts, dependency edges, - ports, secrets, routes, probes, and activation behavior. - -- [x] **Step 2: Fill only demonstrated gaps test-first** - - Exercise at least one native and one container preparation/execution mapping per capability. Keep - Functions exclusively on the managed Edge Runtime and reject unsupported exact artifacts before - durable or runtime mutation. The final real-runtime proof consumes only the public Stack package; - the CLI is not part of this E2E contract. - -- [x] **Step 3: Verify and commit** - - Run only the affected catalog/compiler/runtime integration files and stack type-check. Commit: - - ```bash - git add packages/stack docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - git commit -m "feat(stack): complete the workload catalog" - ``` - -Database reset is deliberately not an implementation task in this plan. It will be designed from -observed caller needs in a separate session after this rewrite is complete. - -### Final documentation and completion audit - -**Files:** - -- Modify: package README and architecture docs, tracked progress, public export metadata, and CLI - docs/help affected by the new lifecycle -- Create: one `packages/stack` whole-stack E2E scenario shared unchanged by native and Docker mode - -**Interfaces:** - -- Confirms all ten capabilities, limits public exports to `.`, - `./effect`, and `./testing`, and leaves no process-compose or legacy stack surface. - -- [x] **Step 1: Audit every spec requirement against authoritative evidence** - - Build a requirement-to-test/source table in the tracked progress document. Missing or indirect - evidence is incomplete work, not a pass. - -- [x] **Step 2: Fill audit gaps test-first** - - Treat any missing catalog/platform evidence as incomplete Task 13 work. Complete one user-shaped - Stack-package scenario that creates and starts a managed stack, exercises every capability and - private companion through observable behavior, and disposes it. Run the exact same scenario in - native and Docker mode with only the runtime selector changed. - -- [x] **Step 3: Run final targeted verification** - - ```bash - pnpm --dir packages/stack types:check - pnpm --dir packages/stack exec vitest run --project unit - pnpm --dir packages/stack exec vitest run --project integration - pnpm --dir packages/stack exec vitest run --project e2e src/public/whole-stack.e2e.test.ts - pnpm --dir apps/cli types:check - pnpm exec oxlint --config .oxlintrc.json packages/stack apps/cli/src/next apps/cli/src/shared/telemetry - pnpm exec oxlint --config .oxlintrc.effect.json packages/stack - pnpm exec oxfmt --config .oxfmtrc.json --check packages/stack \ - docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md \ - docs/superpowers/plans/2026-08-28-stack-runtime-rewrite.md \ - docs/superpowers/plans/2026-08-28-stack-runtime-rewrite-progress.md - pnpm exec knip-bun --workspace packages/stack - ``` - - Run only the named Stack-package whole-stack E2E file, not an unrelated E2E suite. - -- [x] **Step 4: Whole-branch review and one fix wave** - - Review the full merge-base diff against the design and progress rulings. Fix every load-bearing - finding, re-run affected targeted checks, and record any adjudicated non-blocking ruling. - -- [ ] **Step 5: Commit, push, and open the PR against develop** - - ```bash - git add -A - git commit -m "docs(stack): finalize the managed runtime rewrite" - git push -u origin feat/stack-runtime-rewrite - gh pr create --base develop --title "feat(stack): rewrite the managed stack runtime" \ - --body $'## Summary\n\nReplaces the legacy stack and process-compose implementations with the managed Effect v4 stack runtime described in the tracked architecture.' - ``` - - The PR description explains what and why, includes no test-plan section, and links/supersedes an - issue or PR only when authoritative repository evidence identifies one. diff --git a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md b/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md deleted file mode 100644 index a6b5fdca50..0000000000 --- a/docs/superpowers/specs/2026-08-28-stack-runtime-rewrite-design.md +++ /dev/null @@ -1,997 +0,0 @@ -# Supabase Local Stack Runtime Architecture - -Status: final greenfield architecture -Last updated: 2026-08-29 - -## Goals and scope - -`@supabase/stack` defines, prepares, runs, observes, stops, restarts, and destroys one local -Supabase stack. It supports a strict native or container runtime split behind one public contract. -The runtime choice is made when an identity is created, is immutable for that identity, and all -workloads use the same runtime. Native and container execution never mix workloads. -The package supports managed stacks only: callers receive handles to a Supervisor-owned identity, -not ownership of an independently launched process or container. - -The package must let callers: - -- run isolated stacks for projects, branches, worktrees, and named instances; -- keep a running stack alive after every creating process exits; -- stop and explicitly restart without changing identity, data, runtime, versions, secrets, or - sticky automatic ports unless the restart explicitly supplies an allowed stopped-time change; -- change resolved settings, capability membership, activation policy, listeners, and exact ports - only at a stop boundary; -- prepare artifacts without creating runtime resources or starting services; -- use eager or mandatory-lazy capabilities through one gateway; -- edit and invoke Edge Functions from the host while the stack remains managed; -- discover stacks without claiming ownership or starting work; and -- recover or replace a compatible Supervisor without silently replacing a healthy owner. - -The package does not parse `config.toml`, run project migrations or seeds, expose Compose, or act as -a general process orchestrator. The CLI resolves its configuration and calls the package API. - -## Settled design decisions - -These decisions are closed for this rewrite: - -- `createStack` returns an ordinary managed handle. A separate testing helper creates, starts, and - destroys an isolated stack and is the only API designed for `await using`. -- Only managed stacks are supported. There is no foreground/unmanaged ownership mode. -- A materialized definition persists every resolved value, including defaults and explicit - absences. Reopening a stack therefore never reinterprets old state through newer defaults. -- Calling `start` on an already-running stack is idempotent only for the same materialized input. - Incompatible input fails with guidance to use explicit `restart`; it is never applied on top of a - running stack. -- Lazy activation is mandatory. Once activated, a capability remains active for that generation; - there is no idle eviction. -- Native and container stacks are strictly separate, and every catalog workload has both a native - and a container artifact. One stack identity can never mix them. -- `supabase/slim-services` is the canonical workload release contract: a supported service version - uses its portable archive in native mode and the image derived from that same archive in container - mode. The initial database catalog supports only PostgreSQL `17.6.1.167`; PostgreSQL 15 can be - added when its matching archive and image exist, while every other selector fails before mutation. -- Functions have exactly one serving path: the stack-owned Edge Runtime. This applies to normal - stack traffic and `supabase functions serve`; there is no separate Docker or standalone serving - workflow. `functionsRoot` is the only host root and the only read-only container mount, and every - resolved path must remain beneath it. -- The Promise facade exposes secret-bearing values as plain strings. Redacted values remain an - Effect-side concern. -- Each capability accepts the complete set of supported per-service settings; the compiler rejects - unknown fields and persists the complete resolved result. -- Database version selection resolves only to an exact database artifact supported by the catalog. - An unknown selector fails before mutation. Internal bootstrap revisions are implementation-owned, - ordered steps selected by that exact artifact; they are not a second public version input. -- Corrupt durable state fails closed. Logs redact every exact known secret and never persist or emit - full secret environments, files, or arguments. -- Database reset is outside this rewrite. No reset operation, session, lifecycle state, or durable - reset metadata is included; the caller continues to own migrations, declarative schemas, and seeds. - -The design follows the object-level separation used by Docker without copying its CLI: - -```text -CLI-resolved StackConfig - │ - ▼ -StackCompiler ──► materialized StackDefinition + runtime ExecutionPlan - │ │ - ▼ ▼ - durable state/fingerprint per-stack Supervisor - │ - StackReconciler - │ │ - ▼ ▼ - ArtifactStore RuntimeDriver - native | container - │ - ▼ - StackGateway - HTTP and transparent TCP -``` - -## Core invariants - -- One `StackId` has at most one live Supervisor and one owned control endpoint. -- A running Supervisor and its workloads outlive every caller handle. Closing a handle only - releases that handle, its subscriptions, and its facade-owned scope. -- `createStack` and `openStack` return ordinary managed-stack handles. Promise handles are explicitly - closed and are not `AsyncDisposable`; `await using` is reserved for the isolated testing helper. - Closing a handle never stops or destroys the managed stack. -- Every accepted desired-state mutation has one durable `desiredGeneration`; equivalent concurrent - callers join the same owner operation. -- Only the Supervisor mutates one stack and it executes at most one reconciliation plan at a time. -- Canceling a caller wait never cancels Supervisor-owned start, stop, restart, preparation, - activation, or destruction work. -- A stopped stack has no live processes, service containers, gateway, private network, or published - listeners. It retains its state, data, logs, artifacts, secrets, and logical port records. -- Runtime kind and engine, materialized definition, input fingerprint, exact capability versions, - managed secrets, and selected automatic ports are sticky until an allowed stop-time change. -- Public listeners belong only to `StackGateway`; backend workloads use private endpoints. -- Runtime adoption requires `StackId`, `desiredGeneration`, and a semantic workload-spec hash that - matches the current compiler output. Compiler changes cannot adopt incompatible old resources. -- Destroy removes the complete stack and all of its data. Cleanup is exact-identity-only. -- Lazy activation is one-way per generation: once a capability is activated, it remains desired - active and its workloads use catalog restart policy until stop or explicit restart. There is no - idle eviction. - -## Public model and API - -`@supabase/stack/effect` is Effect-native end to end. The package root is a thin Promise and -`AsyncIterable` facade; no internal helper returns a Promise. - -```ts -import type * as Effect from "effect/Effect"; -import type * as Option from "effect/Option"; -import type * as Redacted from "effect/Redacted"; -import type * as Scope from "effect/Scope"; -import type * as Stream from "effect/Stream"; - -declare const StackIdTypeId: unique symbol; -type StackId = string & { readonly [StackIdTypeId]: "StackId" }; - -type StackRuntime = - | { readonly kind: "native" } - | { readonly kind: "container"; readonly engine: "docker" | "podman" }; - -type StackRuntimePreference = - | { readonly kind: "native" } - | { readonly kind: "container"; readonly engine?: "docker" | "podman" | "auto" }; - -type CapabilityName = - | "database" - | "rest" - | "auth" - | "realtime" - | "storage" - | "functions" - | "studio" - | "mail" - | "analytics" - | "pooler"; - -type CapabilityState = - "disabled" | "dormant" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed"; - -type StackLifecycle = - "unconfigured" | "stopped" | "starting" | "running" | "stopping" | "destroying"; - -type DesiredStackLifecycle = "unconfigured" | "stopped" | "running" | "destroying"; -type ActivationMode = "eager" | "lazy"; -type PortField = keyof StackListenersConfig; - -interface StackEndpoint { - readonly protocol: "http" | "tcp"; - readonly address: string; - readonly port: number; - readonly url: string; -} - -interface CapabilityStatus { - readonly name: CapabilityName; - readonly activation: ActivationMode; - readonly state: CapabilityState; - readonly error?: string; -} - -/** Complete point-in-time projection used by both info and watch. */ -interface StackStatus { - readonly id: StackId; - readonly lifecycle: StackLifecycle; - readonly desiredLifecycle: DesiredStackLifecycle; - readonly runtime: StackRuntime; - readonly desiredGeneration?: number; - readonly endpoints: Readonly>>; - readonly versions: Readonly>>; - readonly capabilities: ReadonlyArray; -} - -interface LogCursor { - readonly opaque: string; -} - -interface LogOptions { - readonly capabilities?: ReadonlyArray; - readonly follow?: boolean; - readonly cursor?: LogCursor; -} - -interface StackLogEntry { - readonly cursor: LogCursor; - readonly timestamp: string; - readonly source: CapabilityName | "supervisor" | "gateway"; - readonly stream: "stdout" | "stderr" | "internal"; - readonly message: string; -} - -interface StackDescriptor { - readonly id: StackId; - readonly projectRoot: string; - readonly name: string; - readonly branchContext: string; - readonly runtime: StackRuntime; - readonly desiredLifecycle: DesiredStackLifecycle; -} - -interface StackInspection { - readonly descriptor: StackDescriptor; - readonly owner: "running" | "absent" | "unreachable" | "incompatible"; - readonly status?: StackStatus; -} -``` - -`StackStatus` is a complete projection, not an event delta. `status()` returns one projection and -`watchStatus()` is a stream whose every subscription begins with the current complete projection and -then publishes complete meaningful snapshots. There is no sequence-number event union and no -duplicate projection or delta API. Health is derived from capability states, not a separate lifecycle -value. Logs remain a separate retained-to-live stream with an opaque cursor. - -### Closed capability settings - -`StackConfig` contains a closed `StackCapabilitiesConfig`, `StackListenersConfig`, and security -policy. Every setting accepted by CLI configuration is represented in a schema-derived type; a -capability's schema is exhaustive, not an open property map or a partial provider example. - -```ts -interface StackConfig { - readonly capabilities?: StackCapabilitiesConfig; - readonly listeners?: StackListenersConfig; - readonly security?: StackSecurityConfig; -} - -interface StackCapabilitiesConfig { - readonly database?: DatabaseCapabilityConfig; - readonly rest?: OptionalCapabilityConfig; - readonly auth?: OptionalCapabilityConfig; - readonly realtime?: OptionalCapabilityConfig; - readonly storage?: OptionalCapabilityConfig; - readonly functions?: OptionalCapabilityConfig; - readonly studio?: OptionalCapabilityConfig; - readonly mail?: OptionalCapabilityConfig; - readonly analytics?: OptionalCapabilityConfig; - readonly pooler?: OptionalCapabilityConfig; -} - -type OptionalCapabilityConfig = - | { readonly enabled: false } - | { - readonly enabled?: true; - readonly activation?: ActivationMode; - readonly version?: string; - readonly settings?: Settings; - }; - -interface DatabaseCapabilityConfig { - readonly version?: string; - readonly settings?: DatabaseSettings; -} - -interface StackSecurityConfig { - readonly jwt?: JwtSecurityConfig; -} - -interface JwtSecurityConfig { - readonly issuer?: string; - readonly signing?: JwtSigningConfig; -} - -type JwtSigningConfig = - | { readonly kind: "symmetric"; readonly secret: Redacted.Redacted } - | { readonly kind: "jwks-file"; readonly path: string }; - -// These aliases stand for generated closed schemas. Each generated declaration -// contains every supported field, defaults, validation, secret classification, -// and native/container artifact mapping for that capability. -type RestSettings = GeneratedSettingsSchema<"rest">; -type AuthSettings = GeneratedSettingsSchema<"auth">; -type RealtimeSettings = GeneratedSettingsSchema<"realtime">; -type StorageSettings = GeneratedSettingsSchema<"storage">; -type FunctionsSettings = GeneratedSettingsSchema<"functions">; -type StudioSettings = GeneratedSettingsSchema<"studio">; -type MailSettings = GeneratedSettingsSchema<"mail">; -type AnalyticsSettings = GeneratedSettingsSchema<"analytics">; -type PoolerSettings = GeneratedSettingsSchema<"pooler">; -type DatabaseSettings = GeneratedSettingsSchema<"database">; -``` - -The code comment above is notation for schema-derived declarations, not a literal suggestion to -erase fields. Each capability Module owns its full settings schema, defaulting, validation, secret -classification, native artifact mapping, container artifact mapping, and workload compilation. -Auth providers, mail modes, storage options, and every other catalog-supported field are described -by those modules rather than by incomplete architecture examples. - -Listeners use the closed shape below. Omitted ports request an automatic assignment; an explicit -port is an exact assignment. - -```ts -interface StackListenersConfig { - readonly api?: ListenerConfig; - readonly database?: ListenerConfig; - readonly pooler?: ListenerConfig; - readonly studio?: ListenerConfig; - readonly mailUi?: ListenerConfig; - readonly smtp?: ListenerConfig; - readonly pop3?: ListenerConfig; - readonly functionsInspector?: ListenerConfig; -} - -type ListenerConfig = - | { readonly enabled: false } - | { readonly enabled?: true; readonly address?: string; readonly port?: number }; -``` - -### Effect and Promise entry points - -```ts -interface EffectStackCredentials { - readonly database: { - readonly url: Redacted.Redacted; - readonly password: Redacted.Redacted; - }; - readonly api: { - readonly publishableKey: string; - readonly secretKey: Redacted.Redacted; - readonly anonJwt: string; - readonly serviceRoleJwt: Redacted.Redacted; - }; - readonly storage?: { - readonly endpoint: string; - readonly region: string; - readonly accessKeyId: string; - readonly secretAccessKey: Redacted.Redacted; - }; -} - -interface PromiseStackCredentials { - readonly database: { readonly url: string; readonly password: string }; - readonly api: { - readonly publishableKey: string; - readonly secretKey: string; - readonly anonJwt: string; - readonly serviceRoleJwt: string; - }; - readonly storage?: { - readonly endpoint: string; - readonly region: string; - readonly accessKeyId: string; - readonly secretAccessKey: string; - }; -} - -interface StartStackOptions { - readonly config?: StackConfig; -} -interface PrepareStackOptions { - readonly config?: StackConfig; - readonly capabilities?: ReadonlyArray; -} -interface CreateStackOptions { - readonly projectRoot: string; - readonly name?: string; - readonly runtime?: StackRuntimePreference; -} -interface FindStackOptions { - readonly projectRoot: string; - readonly name?: string; -} -interface ListStacksOptions { - readonly projectRoot?: string; -} - -/** Generated Promise counterpart: every Redacted leaf is a plain string. */ -type PromiseStackConfig = GeneratedPromiseStackConfig; -type PromiseStartStackOptions = { readonly config?: PromiseStackConfig }; -type PromisePrepareStackOptions = { - readonly config?: PromiseStackConfig; - readonly capabilities?: ReadonlyArray; -}; - -interface PreparedCapability { - readonly capability: CapabilityName; - readonly version: string; - readonly outcome: "cached" | "downloaded" | "pulled"; -} -interface PrepareStackResult { - readonly capabilities: ReadonlyArray; -} - -interface EffectStack { - readonly id: StackId; - readonly status: () => Effect.Effect; - readonly credentials: () => Effect.Effect; - readonly prepare: ( - options?: PrepareStackOptions, - ) => Effect.Effect; - readonly start: (options?: StartStackOptions) => Effect.Effect; - readonly restart: (options?: StartStackOptions) => Effect.Effect; - readonly stop: () => Effect.Effect; - readonly destroy: () => Effect.Effect; - readonly close: () => Effect.Effect; - readonly watchStatus: () => Stream.Stream; - readonly logs: (options?: LogOptions) => Stream.Stream; -} - -function createStack( - options: CreateStackOptions, -): Effect.Effect; -function openStack(id: StackId): Effect.Effect; -function findStack( - options: FindStackOptions, -): Effect.Effect, StackDiscoveryError>; -function listStacks( - options?: ListStacksOptions, -): Effect.Effect, StackDiscoveryError>; -function inspectStack( - id: StackId, -): Effect.Effect; - -declare namespace PromiseApi { - interface Stack { - readonly id: StackId; - readonly status: () => Promise; - readonly credentials: () => Promise; - readonly prepare: (options?: PromisePrepareStackOptions) => Promise; - readonly start: (options?: PromiseStartStackOptions) => Promise; - readonly restart: (options?: PromiseStartStackOptions) => Promise; - readonly stop: () => Promise; - readonly destroy: () => Promise; - readonly close: () => Promise; - readonly watchStatus: () => AsyncIterable; - readonly logs: (options?: LogOptions) => AsyncIterable; - } - - function createStack(options: CreateStackOptions): Promise; - function openStack(id: StackId): Promise; - function findStack(options: FindStackOptions): Promise; - function listStacks(options?: ListStacksOptions): Promise>; - function inspectStack(id: StackId): Promise; -} -``` - -The Promise facade mechanically runs the Effect contract in one private scope per handle, maps -`Option` to `undefined`, adapts `Stream` to `AsyncIterable`, and translates `Redacted` values to -plain strings at the outer boundary. `GeneratedPromiseStackConfig` is a generated closed counterpart -of `StackConfig` with every secret leaf represented as `string`, including all capability settings; -it is not an open property map. Secret-bearing types are therefore intentionally different; -the facade does not claim that every shared type is identical. Ordinary Promise handles require an -explicit idempotent `close()` when the caller wants to release handle-local resources. They do not -implement `AsyncDisposable`, because `await using` is reserved for the test helper that owns and -destroys a unique test identity. - -`createStack` canonicalizes the project root, resolves the stable identity, persists a concrete -runtime for a new identity, starts or attaches to its Supervisor, and returns a handle. It does not -read configuration, allocate ports, prepare artifacts, or start workloads. `openStack` attaches only -to an existing opaque id and never creates an identity. `close` is idempotent and never stops a -managed stack. - -`findStack`, `listStacks`, and `inspectStack` are read-only. Inspection may include the durable -descriptor and a compatible observed `StackStatus` when an owner is reachable; it never claims -ownership, starts work, or recovers resources. A new identity that has never accepted a definition -has desired lifecycle `unconfigured`. - -### Testing helper - -`@supabase/stack/testing` exposes a high-level `createTestStack` helper. It creates a unique isolated -managed identity in a temporary root, starts it, waits for readiness, and returns an -`AsyncDisposable` test resource wrapper (distinct from an ordinary `createStack`/`openStack` handle) -whose disposal destroys, rather than merely closes, that identity. The helper records the exact -identity it created, never attaches to existing user state, and destroys only that identity. If -startup fails, it cleans every partial resource and state record it owns before rejecting. This -helper is the sanctioned test setup for public integration scenarios. - -```ts -await using stack = await createTestStack({ config }); -// The stack is ready here. Leaving the scope destroys this exact test identity. -``` - -## Identity - -Identity is resolved before runtime ownership: - -```text -workspaceId + checkoutId + branchContext + localProjectKey + stackName → StackId -``` - -`workspaceId` identifies the repository or ordinary workspace, `checkoutId` the concrete checkout -or worktree, `branchContext` the full branch ref (or `detached`/`ordinary-workspace`), -`localProjectKey` the normalized project path relative to checkout root, and `stackName` an explicit -instance selector defaulting to `default`. `StackId` is an opaque digest of this tuple. A branch -rename creates a new identity; switching branches never stops the previous stack. - -The user-local registry enumerates state documents and serializes identity and port transactions -with one short cross-process lock. It is not a daemon. State, data, logs, runtime names, labels, -control endpoint, and logical ports are all namespaced by exact `StackId`. - -## Materialized configuration and lifecycle - -### Compilation and durable definition - -The CLI resolves `config.toml`, flags, environment interpolation, and secret redaction into -`StackConfig`. At `start` or `prepare`, an Effectful leaf snapshots referenced non-secret auxiliary -files. Live Function code and secret files are not snapshotted. - -```ts -interface StackDefinition { - /** Every capability is present with enabled, activation, version, and defaulted settings. */ - readonly capabilities: { - readonly database: MaterializedCapability>; - readonly rest: MaterializedCapability>; - readonly auth: MaterializedCapability>; - readonly realtime: MaterializedCapability>; - readonly storage: MaterializedCapability>; - readonly functions: MaterializedCapability>; - readonly studio: MaterializedCapability>; - readonly mail: MaterializedCapability>; - readonly analytics: MaterializedCapability>; - readonly pooler: MaterializedCapability>; - }; - readonly listeners: MaterializedListenersConfig; - readonly security: MaterializedSecurityConfig; -} - -interface MaterializedCapability { - readonly enabled: boolean; - readonly activation: ActivationMode; - readonly version: string; - readonly settings: Settings; -} - -/** Generated closed settings with every default applied and each secret leaf replaced by a slot. */ -type MaterializedSettings = GeneratedMaterializedSettingsSchema; -type MaterializedListenersConfig = GeneratedMaterializedListenersSchema; -type MaterializedSecurityConfig = GeneratedMaterializedSecuritySchema; - -type InputFingerprint = string & { readonly __inputFingerprint: unique symbol }; -``` - -The pure compiler produces a fully resolved non-secret `StackDefinition`, a canonical non-secret -`inputFingerprint`, resolved secret inputs keyed by generated slots, and an in-memory runtime-specific -execution plan. The definition contains every logical value, including defaults, explicit capability -settings, activation, listener policy and port intent, security policy, versions, and secret-slot -references. Actual port assignments and secret bytes are persisted once in their dedicated state -fields. Together these fields are the complete materialized state; defaults are never reconstructed -during recovery. The fingerprint preserves explicit choices, omissions, normalized paths, -auxiliary-source digests, and secret shape/presence without any secret bytes. - -The private execution plan derives dependencies, reverse-stop ordering, routes, mounts, probes, -runtime ids, workload ids, semantic workload-spec hash, and concrete artifact keys. These are private -runtime state and are never durable public configuration. Every compiled workload release maps to -both a native artifact and a container artifact; the concrete key is selected only by the immutable -runtime and platform. - -Fingerprint rules are strict: - -1. An identical fingerprint reuses the exact persisted materialized definition even if package - catalog defaults or compiler code have changed. -2. A changed fingerprint is rejected while the stack is running with - `StackMustBeStoppedError`, even when current defaults would resolve the candidate to the same - definition. The running owner is never replaced implicitly. -3. While stopped, a changed fingerprint resolves a complete new definition against current - defaults and persists definition and fingerprint atomically after full validation. -4. A changed stopped definition retains only explicitly sticky values permitted by the state model: - runtime, exact capability versions from the previous definition, managed secrets, and unchanged - port assignments. - -Secret comparison is independent of the non-secret fingerprint. Managed secret omissions reuse the -persisted value and conflicting supplied values fail in every lifecycle state. Pass-through secret -declarations may change only while stopped, including when the non-secret fingerprint is unchanged. - -The compiler and all capability Modules validate unsupported exact versions and artifact mappings -before mutation. Artifact availability is not compatibility. Public version identifiers are -runtime-neutral release names; private artifact keys include runtime and platform details. - -### Start, stop, and explicit restart - -`start({ config })` and explicit `restart({ config })` are the only operations that apply -configuration. On an unconfigured identity, `start()` uses current defaults, materializes a -definition, and requires no caller secret. A configured identity with omitted config reuses its -persisted fingerprint and definition. A valid stopped start commits the complete definition, -fingerprint, ports, secrets, and a new running `desiredGeneration` atomically before reconciliation. - -On a compatible running stack, `start` first compares any supplied config and secrets. An identical -request is idempotent and returns the current `StackStatus`; any change fails with -`StackMustBeStoppedError`. If the owner or RPC release is incompatible, `start` fails with -`StackUpgradeRequiredError` and tells the CLI caller to run `supabase restart`; `start`, `openStack`, -`status`, and `logs` never replace an owner. - -`restart(options?)` is explicit and requires desired lifecycle `running`. It preflights and decodes -durable state, session-fences and quiesces the old owner through the stable maintenance protocol, -waits for ownership release, then launches the current Supervisor. It may apply newly resolved -stopped-time configuration from `options` only after the old owner has stopped at the fenced point. -Identity, data, runtime, managed secrets, and unchanged sticky records are preserved. Allowed -stopped-time options may change settings, capability versions other than an initialized database, -pass-through secrets, and exact port requests. An explicitly concurrent `stop` wins; -it fences the generation and prevents a delayed restart from relaunching the stack. - -`stop()` accepts a stopped `desiredGeneration`, rejects new activation, drains gateway traffic, -stops dependents in reverse dependency order, terminates exact native process trees or removes exact -containers, removes gateway/network resources, closes live listeners, and atomically retains logs. -It retains state, data, definitions, fingerprints, secrets, versions, ports, and cached artifacts. -Caller exit never rolls back durable running intent; the Supervisor continues reconciliation. - -`destroy()` fences destroying, completes or supersedes owner work at a safe boundary, removes exact -runtime resources and all data/logs, and deletes the state document under the registry lock. There -is no retain-data variant. - -### Versions and database immutability - -The first accepted definition persists an exact version for every enabled capability. Omitted -versions reuse existing pins; a newly enabled capability selects the current catalog release. A -stopped edit may change a non-database pin. Database version becomes immutable after database data -initialization. An exact unsupported version fails before state or data mutation; an available -artifact is not evidence of compatibility. Changing an initialized database version requires -destroy/recreate or a future explicit upgrade workflow. - -Internal database bootstrap is an idempotent readiness phase of the real database workload, not a -synthetic companion workload. Its ordered revision plan is selected from the exact pinned database -release, and tracking is stored inside the database, never in `state.json`. Bootstrap creates the -internal roles, passwords, extensions, schemas, and grants required by that pinned release. The -database cannot become ready—and therefore no dependent can activate—until the plan completes. - -## Durable state, secrets, and logs - -Each identity has one authoritative owner-only document: - -```text -//state.json -``` - -Its public shape is conceptually: - -```ts -interface PersistedStackState { - readonly format: "supabase-stack-state-v1"; - readonly identity: PersistedStackIdentity; - readonly runtime: StackRuntime; - readonly desiredGeneration: number; - readonly desiredLifecycle: DesiredStackLifecycle; - readonly definition?: StackDefinition; - readonly inputFingerprint?: InputFingerprint; - readonly ports: ReadonlyArray; - readonly secrets: PersistedSecretValues; -} - -/** Generated closed schema for all managed and pass-through secret leaves. */ -type PersistedSecretValues = GeneratedPersistedSecretSchema; -``` - -The durable definition is fully materialized and contains no PIDs, timestamps, runtime ids, -workload graph, routes, mounts, probes, concrete artifact keys, or secret bytes. Runtime-specific -execution plans are rebuilt on recovery. Exact capability versions have one durable representation: -the materialized definition. Concrete port assignments and managed/pass-through secret bytes live -only in their dedicated fields. - -Every mutation validates the expected generation, builds one complete next value, writes an owner-only -temporary sibling, fsyncs as required by the platform, and atomically replaces `state.json`. A crash -therefore exposes either the complete old or complete new value. Missing or corrupt state beside -exact identity-owned data, logs, or runtime resources fails closed with `StackStateInvalidError`. -Ordinary create/open/start/destroy paths never overwrite or guess replacement state. A separate, -explicitly destructive recovery/cleanup operation may remove exact `StackId`-owned remnants after -deliberate authorization; it is outside ordinary lifecycle operations. - -Stack-managed secrets include database and internal-role passwords, JWT signing material, encryption -keys, and generated API credentials. Omission generates each required value once; a different later -value fails with `StackSecretMismatchError` in every lifecycle state. Caller-owned pass-through -secrets (SMTP, OAuth, and Function secret environment) are complete declarations and can be added, -replaced, or removed only while stopped. Effect APIs use `Redacted`; the Promise facade unwraps to -plain strings only at its public boundary. - -Known exact managed and pass-through secret strings are redacted before persistence or streaming -logs. The implementation never logs full environment maps, secret files, or secret arguments. Logs -remain potentially sensitive because transformed or derived values cannot be guaranteed to match -every secret. Log files are owner-only, bounded, and rotated. A `LogCursor` identifies retained -position; the retained-to-live handoff is atomic so a follower sees each entry at most once without -a gap. - -### State format evolution - -The first release reads and writes only `supabase-stack-state-v1`; it has no readers for prior -development formats. Once a second concrete format ships, that release must decode the still- -supported earlier format and atomically migrate the complete document before reconciliation. Use one -explicit decoder/migration per released format; do not add a generic migration framework or downgrade -guarantee. An unsupported or semantically invalid format fails closed, while the stable maintenance -stop remains available to stop a live owner. - -## Capabilities and private workloads - -The public surface names only the ten `CapabilityName` values and aggregates each capability into a -`CapabilityState`. Internally, a capability Module compiles one or more private workloads. Examples -include storage plus imgproxy, studio plus pgmeta, and analytics plus vector. Database bootstrap is -deliberately different: it is a post-probe readiness phase on the real database workload and never -has its own workload id, artifact, process, lifecycle, or dependency node. -Workload ids drive labels, artifact selection, exact cleanup, readiness, internal log routing, -dependency edges, and restart policy. Public log entries are attributed to their capability rather -than exposing workload ids. Capability state is an aggregation of its workloads; an individual -failed workload does not erase unrelated capability state. - -Each workload release has both native and container mappings. A strict runtime split means a -StackId is entirely native or entirely container and no workload can be switched across runtimes in -place. Unsupported runtime/platform artifact mappings fail before mutation. - -## Ownership, control, and recovery - -The Supervisor proves ownership with one atomic primitive (an exact private endpoint bind or an -advisory lock) and records a random `ownerSessionId`. The same owned endpoint carries two -versioned protocols: - -- `OwnerMaintenanceControl` is a frozen bounded-JSON protocol for probe, stable stop, and quiesce/ - replacement. Stable maintenance stop is the sole stop mechanism that must work across releases. -- Exact-release `StackControlRpc` is Effect RPC for handles, preparation, status/watch, credentials, - logs, start, restart, and destroy. Its release is pinned and version-gated. It deliberately does - not expose stop; stop always goes through the stable maintenance operation. - -Both controls share the one owned endpoint; no parallel maintenance channel exists. RPC handlers -submit work to the Supervisor and never become lifecycle owners. Protocol mismatch is an upgrade -signal, not an implicit replacement: -the client decodes and validates state, asks the old owner to quiesce only after preflight, waits for -release, launches the current Supervisor, and reconciles the accepted generation. If preflight -fails, old owner and runtime remain untouched. Errors distinguish unsupported state format from -replacement failure. - -Recovery after attachment or reboot is explicit. The new owner decodes complete state and recompiles -the persisted `StackDefinition` before inspecting only exact native/container resources. It removes -leftover ephemeral resources for stopped state, and for running state adopts resources carrying -matching `StackId`, `desiredGeneration`, and semantic workload-spec hash. Native work starts clean; -containers with stale hashes are removed and recreated. -Persistent volumes are identity-scoped and survive stop/start. A machine reboot does not auto-start -a stack. Native owner loss terminates exact process trees through a parent-death primitive or private -launcher pipe; container ingress fails closed until a current fenced session is established. - -## Reconciliation and runtime execution - -`StackReconciler` compares the accepted durable generation and materialized definition with an -observed workload snapshot, produces a deterministic plan from the in-memory execution graph, and -executes it through one runtime driver. - -- Start follows dependency order; stop and destroy use reverse dependency order. -- A workload failure marks its capability failed and consumes its restart budget, while independent - workloads continue. A dependency failure blocks dependent workloads and reports the dependency - cause without stopping unrelated branches. -- Every readiness probe has a deadline. Catalog restart policy uses bounded exponential backoff, - per-workload budgets, and a generation fence; exhausted workloads remain failed until explicit - stop/restart. -- Durable running intent remains after caller failure and the Supervisor continues reconciliation. -- Process-tree termination and container removal target exact workload identity and generation. -- Lazy activation can prepare the dependency closure and start workloads once per generation; an - activated capability remains active until stop/restart and is never evicted for idleness. - -### Artifact preparation - -`ArtifactStore` verifies native downloads and container images, publishes them atomically, and uses -Supervisor-local single-flight for identical transfers. Concurrent Supervisors may duplicate transfer -work safely; the design makes no cross-Supervisor or cross-identity deduplication promise. `prepare` -uses persisted pins or a validated prospective config, never reads or generates secrets, allocates -ports, creates networks, starts workloads, or changes durable state. - -### Ports - -```ts -type PortIntent = - { readonly kind: "automatic" } | { readonly kind: "exact"; readonly port: number }; - -interface HostPortAssignment { - readonly field: PortField; - readonly port: number; - readonly intent: "automatic" | "exact"; -} -``` - -Automatic assignments are globally/logically exclusive while stacks are stopped or running. The -assignment is selected once and remains sticky. Exact assignments are persisted and sticky but do -not globally exclude another stopped stack; they conflict only with a live stack or host listener -when that stack starts. Under the registry lock, `PortCoordinator` retains unchanged assignments, -selects missing automatic ports, and validates exact requests. It commits the complete state before -releasing the lock and never performs downloads or runtime work in the transaction. - -Public listeners remain bound and transfer directly to the Supervisor-owned in-process gateway in -both runtime modes. Container workloads publish only their backend service port to a separately -reserved, durable `127.0.0.1` private host port; they never publish a public stack port. A host race -returns `PortUnavailableError` and retains the assignment without silently selecting another port. -Stop releases live sockets and loopback publications but not logical records; destroy releases all -records. - -### Gateway and mandatory lazy activation - -`StackGateway` is the only public ingress. HTTP owns routing, CORS, WebSockets, forwarding headers, -and error mapping. TCP copies bytes without interpreting PostgreSQL, TLS, SMTP, POP3, or STARTTLS. -Backend workloads use private network endpoints; only the gateway publishes host listeners. - -Lazy activation is mandatory for both native and container runtimes. The Supervisor-owned gateway -invokes the Supervisor activation capability directly in both modes. There is no gateway container, -activation server, activation protocol, or capability file. This keeps one ingress implementation -and one activation path while preserving the strict rule that capability workloads within a stack -are either all native or all containerized. - -Container workloads communicate with one another through fixed ports and aliases on their private -network. When the host gateway needs to reach one, the engine publishes only that workload's backend -port to its reserved `127.0.0.1` private host port. Container-to-host routes such as -`host.docker.internal` remain available only for workloads that must call a public stack endpoint; -unsupported engine/platform routing fails before workload creation. - -A request can activate only an enabled listener for the matching desired generation and owner -session. Activation is one-way per generation; activated workloads use catalog restart policy until -stop/restart and are never idle-evicted. HTTP activation failure maps to 503, post-activation backend -failure to 502, and TCP activation failure closes the connection. - -### Functions capability - -Functions remain one public capability. Filesystem membership and content are live inputs; requests -are oneshot and no Supervisor watcher or desired-state mutation is involved. `functionsRoot` is the -sole allowed filesystem root and the sole container bind mount. It may resolve from `projectRoot`, -but every entrypoint, import map, static file, shared module, and symlink target must remain inside -that root. Per-function entrypoints are relative to their function directory; shared code belongs -inside the root (for example `_shared`). Traversal, absolute escapes, and symlink escapes are -rejected. Container mode mounts the whole root read-only at one stable private path. - -On each request the Functions runtime resolves the slug, applies the persisted closed override, and -loads current entrypoint and module contents. Creating, editing, or deleting a function is visible on -the next request without a CLI call. A disabled override returns not found. - -`supabase functions serve` always uses the managed stack Functions capability and Edge Runtime. It -creates or opens and starts the managed stack as needed, waits for Functions readiness, prints the -gateway URL, streams logs, and observes live edits. Incompatible persisted Functions settings fail -with an upgrade-required error and require explicit `supabase restart`. Caller exit closes only its -handle; the managed stack remains until an explicit stop or destroy. - -## CLI boundaries - -The CLI reloads `config.toml` and flags for each `supabase start`, translates `CliConfig` to -`StackConfig`, and calls: - -```text -createStack({ projectRoot, name, runtime }) - │ - ├── optional stack.prepare({ config }) - └── stack.start({ config }) -``` - -Editing `config.toml` alone has no effect. A changed running config is rejected; after `supabase -stop`, the next `supabase start` resolves and atomically persists the complete new definition. -`supabase restart` is the explicit owner replacement operation and may apply newly resolved config at -its fenced stop point. `supabase status` and watch output consume complete `StackStatus` snapshots. - -The only workspace is `@supabase/stack`: - -```text -packages/stack -├── public/ Effect API, Promise facade, public schemas -├── identity/ root, worktree, branch, and StackId resolution -├── model/ StackDefinition, compiler, capability modules, catalog -├── state/ atomic state, registry, ownership, ports, secret policy -├── supervisor/ Supervisor and StackReconciler -├── preparation/ verified ArtifactStore -├── runtime/ native and container drivers plus engine adapters -├── gateway/ Supervisor-owned HTTP/TCP ingress and direct activation -├── control/ one owner endpoint, maintenance framing, exact-release RPC -└── entrypoints/ Supervisor and gateway executables -``` - -Only `@supabase/stack/effect`, the package-root Promise facade, testing helper, and shared public -types are exported. State, catalog, drivers, workload graph, control protocols, and gateway -internals remain private. - -Expected operational failures are finite `Data.TaggedError` unions. Principal tags include: - -- `InvalidStackIdentityError`, `InvalidProjectRootError`, `InvalidStackConfigError`; -- `StackNotFoundError`, `StackOwnershipConflictError`, `StackRuntimeMismatchError`; -- `StackDefinitionRequiredError`, `StackNotRunningError`, `StackMustBeStoppedError`, - `StackLifecycleConflictError`; -- `StackStateInvalidError`, `StackStateFormatUnsupportedError`, `StackUpgradeRequiredError`, - `StackUpgradeReplacementError`; -- `StackSecretMismatchError`, `InvalidJwtSigningMaterialError`; -- `PortAllocationError`, `PortUnavailableError`; -- `GatewayAuthenticationError`, `GatewayStaleGenerationError`, `GatewayActivationError`; -- `StackPreparationError`, `ArtifactIntegrityError`, `ContainerPullError`; -- `StackReconciliationError`, `ServiceStartError`, `ServiceReadinessError`, - `ContainerEngineError`, `StackDestructionError`. - -Operation-specific aliases expose only applicable tags. The Promise facade rejects with the same -tagged values and never collapses expected failures, interruption, or defects into a generic error. - -## Construction sequence - -Build observable vertical slices in this order: - -1. Identity, durable materialized definition/fingerprint, one-owner endpoint, maintenance control, - Supervisor launch, discovery, handle scopes, and caller-exit survival. -2. Native lifecycle: capability Modules, catalog/version validation, secrets, one representative - workload graph, reconciliation, start, stop, explicit restart, destroy, status/watch, logs, - credentials, and idempotent database bootstrap. -3. Ingress and preparation: sticky public and private ports, listener transfer, HTTP/TCP gateway, - ArtifactStore, mandatory direct lazy activation, and live Functions. -4. Container execution and recovery: engine adapters, private networks, loopback-only private - backend publication, host-gateway aliases, workload-spec-hash adoption, and owner recovery. -5. Complete catalog: every capability's native/container mappings, platform/engine activation - integration, port and secret matrices, and a small e2e smoke suite. -6. Promise facade and release: mechanical adaptation of the authoritative Effect contract, - including plain-string credentials, explicit handle closure, `Option`, streams, and tagged - failures. - -Database reset will be designed and implemented in a separate future session after this rewrite is -complete. This document intentionally does not reserve its API, state shape, or lifecycle vocabulary. - -## Testing strategy - -Public integration scenarios are authoritative. Targeted unit tests are allowed for complex pure -configuration normalization, graph planning, transitions, restart decisions, and port planning. -Real subprocess, process-tree, ownership, filesystem, socket, and transport seams remain in -integration tests. Container lifecycle and lazy activation use controlled Docker/Podman engine -integration. Direct tests of a private boundary are added only when a concrete observability gap -cannot be covered through the public surface. The final e2e contract uses only the public Stack -package and runs one user-shaped whole-stack scenario in native and Docker mode, with only the -runtime selector changed; the CLI is not part of that scenario. - -### Public integration scenarios - -Identity and handles: - -- `createStack` does not read config or start workloads; `createTestStack` creates and cleans only - its unique managed identity. -- Repeated calls, branches, detached worktrees, nested roots, and names produce documented ids. -- Discovery never creates or recovers a stack; two callers share one Supervisor. -- Closing an ordinary handle never stops the managed stack; only the unique test wrapper supports - asynchronous disposal, and it destroys only its own test identity. -- Caller process exit leaves a running stack available to a second process. -- Runtime kind and engine are immutable; conflicting preferences fail. - -Materialized configuration and lifecycle: - -- First start persists a complete defaulted non-secret `StackDefinition`, `inputFingerprint`, and - separately generated secret values without requiring a caller-supplied secret. -- Identical fingerprints reuse the exact definition across catalog/default changes. -- Any changed running fingerprint fails, even when the candidate definition would compare equal. -- Stopped changes resolve current defaults and persist definition/fingerprint atomically while - retaining sticky versions, managed secrets, runtime, and unchanged ports; pass-through-only secret - changes are detected independently of the non-secret fingerprint. -- A compatible identical running `start` returns the current status; changed input fails and - incompatible ownership requires explicit restart. -- Restart fences/quiesces the old owner, preserves identity/data/runtime and unchanged sticky - values, applies only allowed explicit stopped-time changes, and loses to an explicitly concurrent - stop. -- Stop removes ephemeral resources but retains state/data/logs/artifacts; destroy removes exact data. -- Complete status snapshots start every watch subscription; logs use opaque cursors and atomic - retained-to-live handoff. - -Capabilities, workloads, and versions: - -- Every one of the ten capability Modules validates its exhaustive settings schema and compiles - native/container workload mappings. -- Independent workload failure does not stop unrelated branches; dependency failure blocks only - dependents; readiness deadlines and restart budgets are observable. -- Exact unsupported versions fail before mutation; database version is immutable after initialization. -- Artifact preparation is atomic and Supervisor-local single-flight without a cross-Supervisor - deduplication promise. -- Native and container stacks never mix workloads; concrete artifact keys remain private. - -Secrets, logs, and ports: - -- Managed secrets are unique, sticky, and mismatch-protected; pass-through secrets change only while - stopped. Effect uses `Redacted`; Promise credentials are plain strings. -- Logs redact known exact secret strings, never emit full env/secret files or arguments, remain - owner-only and bounded, and expose cursor continuity. -- Automatic port assignments remain globally/logically exclusive while stopped and running. Stopped - exact assignments may coexist and conflict only with a live stack or host listener at start. -- Listener transfer avoids a public rebind window in both modes; container loopback bind races - preserve the sticky private claim. - -Gateway, activation, and Functions: - -- HTTP/TCP routing, WebSockets, TLS, STARTTLS, backpressure, half-close, and 503/502 mapping work - through the gateway. -- The Supervisor-owned gateway activates workloads directly in both modes; there is no gateway - container, activation server, capability file, or second control protocol. -- Lazy activation is one-way per generation, honors dependency readiness and restart policy, and has - no idle eviction. Health/status probes never activate a dormant capability. -- Function root traversal and symlink escapes are rejected; live create/edit/delete and shared - modules are visible on the next oneshot request in native and container modes. -- `supabase functions serve` uses the managed stack, waits for readiness, streams logs, observes - edits, requires explicit restart for incompatible persisted settings, and closes only its handle. - -Recovery: - -- Corrupt state fails closed beside exact remnants; destructive recovery is explicit and identity- - scoped. -- Native owner loss terminates exact process trees; containers fail closed until recovery. Adoption - requires identity, generation, and workload-spec hash; stale/foreign resources are untouched. -- Machine reboot does not auto-start stacks; caller failure leaves durable running intent. - -The Effect API is the behavioral test surface. Promise tests cover only mechanical adaptation and -handle ownership. Tests synchronize on readiness, events, control connections, files, or process -exit; they do not use arbitrary sleeps, released-port reuse, global process scans, or broad cleanup. diff --git a/knip.json b/knip.json index 8e0d504492..15705d7f9e 100644 --- a/knip.json +++ b/knip.json @@ -35,7 +35,8 @@ "ignoreDependencies": ["undici"] }, "packages/stack": { - "ignoreBinaries": ["mkfifo"] + "ignoreBinaries": ["mkfifo"], + "ignoreDependencies": ["@types/ws", "ws"] }, "packages/cli-test-helpers": { "entry": ["src/**/*.test.ts"] diff --git a/packages/stack/README.md b/packages/stack/README.md index dc648fe9dc..834cd06607 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -1,11 +1,28 @@ # `@supabase/stack` -The local Supabase stack runtime. Its public API is being rebuilt as a -greenfield Effect-native managed runtime; implementation modules are private -to the package. +The local Supabase stack runtime. Its public API is a greenfield, +Effect-native managed runtime; implementation modules are private to the +package. The supported entrypoints are: - `@supabase/stack` — Promise facade - `@supabase/stack/effect` — Effect-native API - `@supabase/stack/testing` — test helpers + +Stacks are managed identities: closing a handle does not stop a running stack. Creating or opening a +handle starts nothing, and a stopped stack retains no Supervisor, workload, container, network, or +listener. Status and retained logs remain available directly from durable state while stopped; a +later start on the same handle launches a fresh Supervisor. +With no configuration override, all capabilities are enabled, PostgreSQL is the only eager +capability, and every other capability is lazy. Starting the stack therefore launches only +PostgreSQL; traffic through the stack's listeners activates REST, Auth, Realtime, Storage, +Functions, Studio, Mail, Analytics, or Pooler on demand for the current running session. + +The package's end-to-end contract is exercised through the same public Stack API in native and +Docker modes. It begins from the PostgreSQL-only default, progressively activates every service with +realistic traffic, and verifies stop/start/restart behavior, stable ports, and persistent data. The +CLI is not involved in these runtime tests. + +Database reset is intentionally outside the current API. Applying migrations, declarative schemas, +and seeds remains the caller's responsibility. diff --git a/packages/stack/package.json b/packages/stack/package.json index ee809da7ef..9b8b40e295 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./src/index.ts", "./effect": "./src/effect.ts", - "./testing": "./src/testing.ts" + "./testing": "./src/testing.ts", + "./internal/supervisor": "./src/internal/supervisor-process.ts" }, "scripts": { "test": "pnpm run test:unit && pnpm run test:integration", @@ -28,8 +29,10 @@ "@effect/vitest": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/ws": "catalog:", "esbuild": "^0.28.2", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "ws": "catalog:" } } diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 2255ad9192..949bbf0fc9 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -8,7 +8,6 @@ import { FileSystem, Option, Predicate, - Queue, Schema, Scope, Semaphore, @@ -55,28 +54,19 @@ interface ControlIdentity { export interface MaintenanceHandlers { readonly probe: Effect.Effect; readonly stop: Effect.Effect; - readonly quiesce: Effect.Effect; } export interface ControlServerOptions extends ControlIdentity { readonly endpoint: ControlEndpoint; readonly rpcRelease?: string; readonly maintenanceHandlers: MaintenanceHandlers; - /** Invoked only after the maintenance response has been written and the connection closed. */ - readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; - /** Invoked when a successful quiesce has no writable response connection. */ - readonly onMaintenanceAbandoned?: (op: MaintenanceRequest["op"]) => Effect.Effect; - /** Invoked only after a successful destroy RPC response has been written. */ - readonly onDestroyResponse?: () => Effect.Effect; - /** Invoked when a successful destroy has no writable response connection. */ - readonly onDestroyAbandoned?: () => Effect.Effect; + /** Re-evaluates owner shutdown after a lifecycle response or disconnect. */ + readonly onShutdownReady?: Effect.Effect; readonly rpcHandlers: StackRpcHandlers; } export interface ControlServer { readonly endpoint: ControlEndpoint; - readonly shutdown: Effect.Effect; - readonly ready: Effect.Effect; } const endpointPath = (endpoint: ControlEndpoint): string => @@ -159,19 +149,20 @@ const demuxSocket = ( ), ); + type MaintenanceValidation = + | { readonly _tag: "invalid-request" } + | { readonly _tag: "stale-session"; readonly request: MaintenanceRequest } + | { readonly _tag: "valid"; readonly request: MaintenanceRequest }; + const dispatchMaintenance = ( - frame: Uint8Array, + validation: MaintenanceValidation, ): Effect.Effect => { let responseValue: MaintenanceResponse | undefined; let operationName: MaintenanceRequest["op"] | undefined; - let responseWritten = false; let completionStarted = false; - const startCompletion = ( - callback: (op: MaintenanceRequest["op"]) => Effect.Effect, - op: MaintenanceRequest["op"], - ) => + const startCompletion = (completion: Effect.Effect) => Effect.uninterruptible( - FiberSet.run(completionFibers, callback(op), { startImmediately: true }).pipe( + FiberSet.run(completionFibers, completion, { startImmediately: true }).pipe( // This witness is set only after the completion fiber has been // handed to the owner-scoped FiberSet. The uninterruptible // region keeps the fork and witness atomic to dispatch/onExit. @@ -183,72 +174,45 @@ const demuxSocket = ( ), ); const dispatch = Effect.gen(function* () { - const decoded = yield* Effect.exit(decodeFrame(frame)); - if (Exit.isFailure(decoded)) { - yield* close; - return; - } - const request = yield* Effect.exit( - Schema.decodeUnknownEffect(MaintenanceRequestSchema)(decoded.value, { - onExcessProperty: "error", - }), - ); - if (Exit.isFailure(request)) { + if (validation._tag === "invalid-request") { yield* sendJson(protocolFailure("invalid-request")); yield* close; return; } - if (request.value.stackId !== options.stackId) { - yield* sendJson(protocolFailure("invalid-request")); - yield* close; - return; - } - if (request.value.ownerSessionId !== options.ownerSessionId) { + if (validation._tag === "stale-session") { yield* sendJson(protocolFailure("stale-session")); yield* close; return; } - operationName = request.value.op; + const request = validation.request; + operationName = request.op; const operation = - request.value.op === "probe" + request.op === "probe" ? options.maintenanceHandlers.probe - : request.value.op === "stop" - ? options.maintenanceHandlers.stop - : options.maintenanceHandlers.quiesce; + : options.maintenanceHandlers.stop; const result = yield* Effect.exit(operation); const response = Exit.isSuccess(result) ? result.value : protocolFailure("operation-failed"); responseValue = response; - yield* sendJson(response).pipe( - Effect.tap(() => - Effect.sync(() => { - responseWritten = true; - }), - ), - ); + yield* sendJson(response); yield* close; - yield* markPrefaceReady; - if (response.ok && options.onMaintenanceComplete !== undefined) { + if (request.op === "stop" && options.onShutdownReady !== undefined) { // The connection scope closes as soon as the close frame is sent; // completion belongs to the owner session and must outlive it. - yield* startCompletion(options.onMaintenanceComplete, request.value.op); + yield* startCompletion(options.onShutdownReady); } }); return dispatch.pipe( Effect.onExit((exit) => { const response = responseValue; - if (response === undefined || !response.ok || operationName !== "quiesce") - return Effect.void; + if (response === undefined || operationName !== "stop") return Effect.void; if (Exit.isSuccess(exit)) return Effect.void; const connectionFailure = Cause.hasInterruptsOnly(exit.cause) || isResponseConnectionFailure(exit.cause); - if (!connectionFailure || (responseWritten && completionStarted)) return Effect.void; - const callback = responseWritten - ? options.onMaintenanceComplete - : options.onMaintenanceAbandoned; - if (callback === undefined) return Effect.void; - return startCompletion(callback, "quiesce").pipe(Effect.asVoid); + if (!connectionFailure || completionStarted || options.onShutdownReady === undefined) + return Effect.void; + return startCompletion(options.onShutdownReady).pipe(Effect.asVoid); }), Effect.catchReasons("SocketError", { SocketWriteError: () => Effect.void, @@ -267,12 +231,41 @@ const demuxSocket = ( for (const frame of result.value) { if (closed) break; if (phase === "maintenance") { - yield* maintenanceSemaphore.withPermit(dispatchMaintenance(frame)).pipe( - Effect.timeoutOrElse({ - duration: MAINTENANCE_REQUEST_DEADLINE_MS, - orElse: () => sendJson(protocolFailure("timeout")).pipe(Effect.andThen(close)), - }), + // Probe and request validation are bounded by the maintenance admission + // deadline. A validated stop owns its full cleanup operation and may + // legitimately outlive that connection deadline; the caller joins the result. + // The preface deadline only governs admission until the first frame arrives; + // once a frame is present, dispatch owns the remaining validation/operation + // policy below. + yield* markPrefaceReady; + const operation = yield* Effect.exit( + decodeFrame(frame).pipe( + Effect.flatMap((decoded) => + Schema.decodeUnknownEffect(MaintenanceRequestSchema)(decoded, { + onExcessProperty: "error", + }), + ), + ), ); + const validation: MaintenanceValidation = Exit.isFailure(operation) + ? { _tag: "invalid-request" } + : operation.value.stackId !== options.stackId + ? { _tag: "invalid-request" } + : operation.value.ownerSessionId !== options.ownerSessionId + ? { _tag: "stale-session", request: operation.value } + : { _tag: "valid", request: operation.value }; + const dispatch = maintenanceSemaphore.withPermit(dispatchMaintenance(validation)); + if (validation._tag === "valid" && validation.request.op !== "probe") { + yield* dispatch; + } else { + yield* dispatch.pipe( + Effect.timeoutOrElse({ + duration: MAINTENANCE_REQUEST_DEADLINE_MS, + orElse: () => + sendJson(protocolFailure("timeout")).pipe(Effect.andThen(close)), + }), + ); + } } else { const returned = handler(frame.slice(4)); if (Effect.isEffect(returned)) yield* returned; @@ -449,72 +442,37 @@ export const startControlServer = ( Effect.provideService(SocketServer.SocketServer, server), Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), ); - const destroyRequests = new Set(); - const destroyDisconnects = new Set(); - const disconnects = yield* Queue.unbounded(); - const startDestroyCompletion = (callback: () => Effect.Effect) => + const isCompletionRequest = (tag: string): boolean => + tag === "prepare" || tag === "start" || tag === "restart" || tag === "destroy"; + const completionRequests = new Set(); + const startShutdown = (completion: Effect.Effect) => Effect.uninterruptible( - FiberSet.run(completionFibers, callback(), { startImmediately: true }).pipe(Effect.asVoid), + FiberSet.run(completionFibers, completion, { startImmediately: true }).pipe(Effect.asVoid), ); - yield* FiberSet.run( - completionFibers, - Effect.forever( - Queue.take(protocol.disconnects).pipe( - Effect.flatMap((clientId) => - Effect.gen(function* () { - const prefix = `${clientId}:`; - const hasPendingDestroy = [...destroyRequests].some((request) => - request.startsWith(prefix), - ); - if (hasPendingDestroy) { - destroyDisconnects.add(clientId); - return; - } - yield* Queue.offer(disconnects, clientId); - }), - ), - Effect.asVoid, - ), - ), - { startImmediately: true }, - ); const patchedProtocol = RpcServer.Protocol.of({ ...protocol, - disconnects, run: (handler) => protocol.run((clientId, request) => { - if (Predicate.isTagged(request, "Request") && request.tag === "destroy") - destroyRequests.add(`${clientId}:${String(request.id)}`); + if (Predicate.isTagged(request, "Request") && isCompletionRequest(request.tag)) + completionRequests.add(`${clientId}:${String(request.id)}`); return handler(clientId, request); }), send: (clientId, response, transferables) => Predicate.isTagged(response, "Exit") && - destroyRequests.has(`${clientId}:${String(response.requestId)}`) + completionRequests.has(`${clientId}:${String(response.requestId)}`) ? Effect.gen(function* () { const key = `${clientId}:${String(response.requestId)}`; - const connected = - !destroyDisconnects.has(clientId) && (yield* protocol.clientIds).has(clientId); - const sent = connected - ? yield* Effect.exit(protocol.send(clientId, response, transferables)) - : Exit.succeed(undefined); - const wroteResponse = connected && Exit.isSuccess(sent); - const successful = Predicate.isTagged(response.exit, "Success"); - destroyRequests.delete(key); - if (successful) { - if (wroteResponse && options.onDestroyResponse !== undefined) - yield* startDestroyCompletion(options.onDestroyResponse); - if (!wroteResponse && options.onDestroyAbandoned !== undefined) - yield* startDestroyCompletion(options.onDestroyAbandoned); - } - if (![...destroyRequests].some((request) => request.startsWith(`${clientId}:`))) { - if (destroyDisconnects.delete(clientId)) yield* Queue.offer(disconnects, clientId); - } + const connected = (yield* protocol.clientIds).has(clientId); + if (connected) yield* Effect.exit(protocol.send(clientId, response, transferables)); + const completed = completionRequests.delete(key); + if (completed && options.onShutdownReady !== undefined) + yield* startShutdown(options.onShutdownReady); }) : protocol.send(clientId, response, transferables), }); - // Keep handler defects as keyed Exit responses so destroy request state can be cleaned by - // the same send path as typed failures. Without this option RpcServer emits a client-level - // Defect frame that has no requestId for the terminal handoff. + // Keep handler defects as keyed Exit responses so lifecycle-completion request state can be + // cleaned by the same send path as typed failures. Without this option RpcServer emits a + // client-level Defect frame that has no requestId for the terminal handoff. const rpcProgram: Effect.Effect = RpcServer.make(StackRpcGroup, { disableTracing: true, disableFatalDefects: true, @@ -528,8 +486,6 @@ export const startControlServer = ( yield* Effect.forkScoped(rpcProgram); return { endpoint: options.endpoint, - ready: Effect.void, - shutdown: Effect.void, } satisfies ControlServer; }); @@ -616,38 +572,27 @@ export interface ControlClient { // oxlint-disable-next-line effecttsgo/lazy-effect readonly probe: () => Effect.Effect< MaintenanceResponse, - Socket.SocketError | MaintenanceProtocolError, - Scope.Scope + Socket.SocketError | MaintenanceProtocolError >; // oxlint-disable-next-line effecttsgo/lazy-effect readonly stop: () => Effect.Effect< MaintenanceResponse, - Socket.SocketError | MaintenanceProtocolError, - Scope.Scope + Socket.SocketError | MaintenanceProtocolError >; // oxlint-disable-next-line effecttsgo/lazy-effect - readonly quiesce: () => Effect.Effect< - MaintenanceResponse, - Socket.SocketError | MaintenanceProtocolError, - Scope.Scope - >; /** Connects with an RPC preface and completes when the owner closes the socket. */ readonly awaitClose: (onOpen?: Effect.Effect) => Effect.Effect; readonly rpc: Effect.Effect; } -/** A scoped client seam used by OwnerSession and the public Stack handle. */ +/** A scoped client seam used by the Supervisor entrypoint and public Stack handles. */ export const makeControlClient = ( endpoint: ControlEndpoint, options: ControlClientOptions, ): ControlClient => { const maintenance = ( op: MaintenanceRequest["op"], - ): Effect.Effect< - MaintenanceResponse, - Socket.SocketError | MaintenanceProtocolError, - Scope.Scope - > => + ): Effect.Effect => Effect.scoped( Effect.gen(function* () { const socket = yield* NodeSocket.makeNet({ @@ -701,36 +646,44 @@ export const makeControlClient = ( stackId: options.stackId, ownerSessionId: options.ownerSessionId, }).pipe(Effect.flatMap(write)); - const readerDone = Fiber.join(fiber).pipe( - Effect.mapError( - () => new MaintenanceProtocolError({ message: "Control connection closed" }), - ), - Effect.flatMap(() => - Effect.fail(new MaintenanceProtocolError({ message: "Control connection closed" })), + // The server closes a successful maintenance connection immediately + // after flushing its response. Check the response witness after the + // reader exits so the close event cannot win that handoff race. + const readerDone = Effect.exit(Fiber.join(fiber)).pipe( + Effect.flatMap(() => Deferred.poll(response)), + Effect.flatMap((completed) => + Option.isSome(completed) + ? completed.value + : Effect.fail( + new MaintenanceProtocolError({ + message: "Control connection closed", + reason: "transport", + }), + ), ), ); - const value = yield* Effect.raceFirst(Deferred.await(response), readerDone).pipe( - Effect.timeoutOrElse({ - duration: MAINTENANCE_REQUEST_DEADLINE_MS, - orElse: () => - Effect.fail(new MaintenanceProtocolError({ message: "Control request timed out" })), - }), + const wait = Effect.raceFirst(Deferred.await(response), readerDone).pipe( Effect.ensuring(Fiber.interrupt(fiber)), ); - return value; - }), - ).pipe( - Effect.timeoutOrElse({ - duration: MAINTENANCE_REQUEST_DEADLINE_MS, - orElse: () => - Effect.fail(new MaintenanceProtocolError({ message: "Control request timed out" })), + return yield* wait; }), ); return { - probe: () => maintenance("probe"), + probe: () => + maintenance("probe").pipe( + Effect.timeoutOrElse({ + duration: MAINTENANCE_REQUEST_DEADLINE_MS, + orElse: () => + Effect.fail( + new MaintenanceProtocolError({ + message: "Control request timed out", + reason: "transport", + }), + ), + }), + ), stop: () => maintenance("stop"), - quiesce: () => maintenance("quiesce"), awaitClose: (onOpen = Effect.void) => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/control/MaintenanceProtocol.ts b/packages/stack/src/control/MaintenanceProtocol.ts index e5116f442e..37d37d5638 100644 --- a/packages/stack/src/control/MaintenanceProtocol.ts +++ b/packages/stack/src/control/MaintenanceProtocol.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Schema } from "effect"; +import { Data, Effect, Predicate, Schema } from "effect"; import { isStackId, StackIdSchema } from "../public/StackId.ts"; /** The stable protocol is intentionally frozen independently of Stack RPC releases. */ @@ -13,6 +13,13 @@ export class MaintenanceProtocolError extends Data.TaggedError("MaintenanceProto readonly reason?: string; }> {} +/** Errors that indicate an owner endpoint is unavailable rather than rejecting a request. */ +export const isMaintenanceTransportFailure = (error: unknown): boolean => { + if (Predicate.isTagged(error, "SocketError")) return true; + if (!Predicate.isTagged(error, "MaintenanceProtocolError")) return false; + return Predicate.hasProperty(error, "reason") && error.reason === "transport"; +}; + export const ownerSessionIdIsValid = (value: string): boolean => value.length > 0 && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value); @@ -44,11 +51,6 @@ export const MaintenanceRequestSchema = Schema.Union([ stackId: StackIdSchema, ownerSessionId: OwnerSessionIdSchema, }), - Schema.Struct({ - op: Schema.Literal("quiesce"), - stackId: StackIdSchema, - ownerSessionId: OwnerSessionIdSchema, - }), ]); export type MaintenanceRequest = Schema.Schema.Type; @@ -68,7 +70,7 @@ export const MaintenanceResponseSchema = Schema.Union([ stackId: StackIdSchema, rpcRelease: ReleaseTokenSchema, }), - Schema.Struct({ ok: Schema.Literal(true), op: Schema.Literals(["stop", "quiesce"] as const) }), + Schema.Struct({ ok: Schema.Literal(true), op: Schema.Literal("stop") }), Schema.Struct({ ok: Schema.Literal(false), error: Schema.Struct({ tag: MaintenanceErrorCodeSchema, message: Schema.String }), diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 3f7602c557..5ec18f6db5 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -7,14 +7,15 @@ import { Exit, Fiber, FileSystem, + Option, Path, Redacted, Ref, Scope, Stream, } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; -import * as Socket from "effect/unstable/socket/Socket"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import type { StackStatus } from "../public/Status.ts"; @@ -36,17 +37,13 @@ import { MaintenanceProtocolError, MAINTENANCE_MAX_FRAME_BYTES, type JsonValue, - type MaintenanceRequest, } from "./MaintenanceProtocol.ts"; -import type { StackRpcHandlers } from "./StackRpc.ts"; +import type { StackRpcError, StackRpcHandlers } from "./StackRpc.ts"; interface ServerOverrides { readonly rpcHandlers?: Partial; readonly maintenanceHandlers?: Partial; - readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; - readonly onMaintenanceAbandoned?: (op: MaintenanceRequest["op"]) => Effect.Effect; - readonly onDestroyResponse?: () => Effect.Effect; - readonly onDestroyAbandoned?: () => Effect.Effect; + readonly onShutdownReady?: Effect.Effect; } const withPlatform = (effect: Effect.Effect) => @@ -73,6 +70,8 @@ const withServer = ( readonly completionRelease: Deferred.Deferred; readonly stopCalls: Ref.Ref; readonly prepareCalls: Ref.Ref; + readonly maintenanceStarted: Deferred.Deferred; + readonly maintenanceRelease: Deferred.Deferred; }) => Effect.Effect, overrides?: (context: { readonly stackId: string; @@ -84,6 +83,8 @@ const withServer = ( readonly completionRelease: Deferred.Deferred; readonly stopCalls: Ref.Ref; readonly prepareCalls: Ref.Ref; + readonly maintenanceStarted: Deferred.Deferred; + readonly maintenanceRelease: Deferred.Deferred; }) => ServerOverrides, ) => withPlatform( @@ -100,13 +101,14 @@ const withServer = ( const completionRelease = yield* Deferred.make(); const stopCalls = yield* Ref.make(0); const prepareCalls = yield* Ref.make(0); + const maintenanceStarted = yield* Deferred.make(); + const maintenanceRelease = yield* Deferred.make(); const endpoint: ControlEndpoint = { kind: "unix", path: path.join(root, "control.sock") }; const status: StackStatus = { id: stackId, lifecycle: "stopped", desiredLifecycle: "stopped", runtime: { kind: "native" }, - desiredGeneration: 0, endpoints: {}, versions: {}, capabilities: CAPABILITY_NAMES.map((name) => ({ @@ -147,7 +149,6 @@ const withServer = ( rpcRelease: "stack-rpc-v1@0.1.0", }), stop: Effect.succeed({ ok: true, op: "stop" }), - quiesce: Effect.succeed({ ok: true, op: "quiesce" }), }; const custom = overrides?.({ @@ -160,6 +161,8 @@ const withServer = ( completionRelease, stopCalls, prepareCalls, + maintenanceStarted, + maintenanceRelease, }) ?? {}; const options: ControlServerOptions = { endpoint, @@ -167,10 +170,7 @@ const withServer = ( ownerSessionId, maintenanceHandlers: { ...defaultMaintenanceHandlers, ...custom.maintenanceHandlers }, rpcHandlers: { ...defaultRpcHandlers, ...custom.rpcHandlers }, - onMaintenanceComplete: custom.onMaintenanceComplete, - onMaintenanceAbandoned: custom.onMaintenanceAbandoned, - onDestroyResponse: custom.onDestroyResponse, - onDestroyAbandoned: custom.onDestroyAbandoned, + onShutdownReady: custom.onShutdownReady, }; yield* startControlServer(options); const info = yield* fs.stat(endpoint.path); @@ -185,6 +185,8 @@ const withServer = ( completionRelease, stopCalls, prepareCalls, + maintenanceStarted, + maintenanceRelease, }); }), ); @@ -281,113 +283,103 @@ const makeDestroyRequestFrame = (): Effect.Effect { - it.live("signals destroy shutdown only after writing the typed response", () => + it.live("reevaluates owner shutdown after a failed start response", () => withServer( ({ endpoint, stackId, ownerSessionId, completion }) => Effect.gen(function* () { const client = makeControlClient(endpoint, { stackId, ownerSessionId }); const rpc = yield* client.rpc; - yield* rpc.destroy(undefined); + const result = yield* rpc.start({}).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); yield* Deferred.await(completion).pipe( Effect.timeoutOrElse({ duration: 5_500, orElse: () => Effect.fail( - new MaintenanceProtocolError({ message: "destroy completion was not signaled" }), + new MaintenanceProtocolError({ + message: "failed start did not trigger shutdown reevaluation", + }), ), }), ); }), ({ completion }) => ({ - onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + rpcHandlers: { + start: () => + Effect.fail({ + tag: "StackPreparationError", + message: "injected start failure", + } satisfies StackRpcError), + }, + onShutdownReady: Deferred.succeed(completion, undefined).pipe(Effect.asVoid), }), ), ); - it.live("counts a successful destroy send before an immediate disconnect", () => + it.live("reevaluates owner shutdown after a failed restart response", () => withServer( - ({ - endpoint, - stackId, - ownerSessionId, - completionStarted, - completionRelease, - completion, - abandoned, - }) => + ({ endpoint, stackId, ownerSessionId, completion }) => Effect.gen(function* () { - const preface = encodePreface({ - kind: "rpc", - release: "stack-rpc-v1@0.1.0", - stackId, - ownerSessionId, - }); - const frame = yield* makeDestroyRequestFrame(); - const response = yield* sendRawSequenceAndReadFrame(endpoint, [ - concatBytes(preface, frame), - ]); - expect(response).toMatchObject({ _tag: "Exit", requestId: "1" }); - yield* Deferred.await(completionStarted).pipe( - Effect.timeoutOrElse({ - duration: 1_000, - orElse: () => - Effect.fail( - new MaintenanceProtocolError({ - message: "destroy completion did not survive the response disconnect", - }), - ), - }), - ); - yield* Deferred.succeed(completionRelease, undefined); + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpc = yield* client.rpc; + expect(Exit.isFailure(yield* rpc.restart({}).pipe(Effect.exit))).toBe(true); + yield* Deferred.await(completion); + }), + ({ completion }) => ({ + rpcHandlers: { + restart: () => + Effect.fail({ + tag: "StackReconciliationError", + message: "injected restart failure", + } satisfies StackRpcError), + }, + onShutdownReady: Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + }), + ), + ); + + it.live("reevaluates owner shutdown after a prepare response", () => + withServer( + ({ endpoint, stackId, ownerSessionId, completion }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpc = yield* client.rpc; + yield* rpc.prepare({}); + yield* Deferred.await(completion); + }), + ({ completion }) => ({ + onShutdownReady: Deferred.succeed(completion, undefined).pipe(Effect.asVoid), + }), + ), + ); + + it.live("signals destroy shutdown only after writing the typed response", () => + withServer( + ({ endpoint, stackId, ownerSessionId, completion }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpc = yield* client.rpc; + yield* rpc.destroy(undefined); yield* Deferred.await(completion).pipe( Effect.timeoutOrElse({ - duration: 1_000, + duration: 5_500, orElse: () => Effect.fail( new MaintenanceProtocolError({ message: "destroy completion was not signaled" }), ), }), ); - const abandonedExit = yield* Deferred.await(abandoned).pipe( - Effect.timeout("100 millis"), - Effect.exit, - ); - expect(Exit.isFailure(abandonedExit)).toBe(true); }), - ({ completionStarted, completionRelease, completion, abandoned }) => ({ - onDestroyResponse: () => - Deferred.succeed(completionStarted, undefined).pipe( - Effect.andThen(Deferred.await(completionRelease)), - Effect.andThen(Deferred.succeed(completion, undefined)), - Effect.asVoid, - ), - onDestroyAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), + ({ completion }) => ({ + onShutdownReady: Deferred.succeed(completion, undefined).pipe(Effect.asVoid), }), ), ); - it.live("signals abandoned destroy shutdown after the RPC client disconnects", () => + it.live("counts a successful destroy send before an immediate disconnect", () => withServer( - ({ - endpoint, - stackId, - ownerSessionId, - completionStarted, - completionRelease, - completion, - abandoned, - }) => + ({ endpoint, stackId, ownerSessionId, completionStarted, completionRelease, completion }) => Effect.gen(function* () { - const socket = yield* NodeSocket.makeNet({ - path: endpoint.kind === "unix" ? endpoint.path : endpoint.name, - }); - const write = yield* socket.writer; - const reader = yield* Effect.forkChild( - socket.runRaw(() => Effect.void), - { - startImmediately: true, - }, - ); const preface = encodePreface({ kind: "rpc", release: "stack-rpc-v1@0.1.0", @@ -395,107 +387,45 @@ describe("control transport", () => { ownerSessionId, }); const frame = yield* makeDestroyRequestFrame(); - yield* write(concatBytes(preface, frame)); + const response = yield* sendRawSequenceAndReadFrame(endpoint, [ + concatBytes(preface, frame), + ]); + expect(response).toMatchObject({ _tag: "Exit", requestId: "1" }); yield* Deferred.await(completionStarted).pipe( Effect.timeoutOrElse({ duration: 1_000, orElse: () => Effect.fail( - new MaintenanceProtocolError({ message: "destroy handler did not start" }), + new MaintenanceProtocolError({ + message: "destroy completion did not survive the response disconnect", + }), ), }), ); - yield* write(new Socket.CloseEvent(1000)); yield* Deferred.succeed(completionRelease, undefined); - yield* Deferred.await(abandoned).pipe( - Effect.timeoutOrElse({ - duration: 1_000, - orElse: () => - Effect.fail( - new MaintenanceProtocolError({ message: "abandoned destroy was not signaled" }), - ), - }), - ); - const postResponse = yield* Deferred.await(completion).pipe( - Effect.timeout("100 millis"), - Effect.exit, - ); - expect(Exit.isFailure(postResponse)).toBe(true); - yield* Fiber.interrupt(reader); - }), - ({ completionStarted, completionRelease, completion, abandoned }) => ({ - rpcHandlers: { - destroy: () => - Deferred.succeed(completionStarted, undefined).pipe( - Effect.andThen(Deferred.await(completionRelease)), - Effect.asVoid, - ), - }, - onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), - onDestroyAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), - }), - ), - ); - - it.live("does not signal abandoned destroy shutdown after a failed operation", () => - withServer( - ({ endpoint, stackId, ownerSessionId, completionStarted, completionRelease, abandoned }) => - Effect.gen(function* () { - const socket = yield* NodeSocket.makeNet({ - path: endpoint.kind === "unix" ? endpoint.path : endpoint.name, - }); - const write = yield* socket.writer; - const reader = yield* Effect.forkChild( - socket.runRaw(() => Effect.void), - { - startImmediately: true, - }, - ); - const preface = encodePreface({ - kind: "rpc", - release: "stack-rpc-v1@0.1.0", - stackId, - ownerSessionId, - }); - const frame = yield* makeDestroyRequestFrame(); - yield* write(concatBytes(preface, frame)); - yield* Deferred.await(completionStarted).pipe( + yield* Deferred.await(completion).pipe( Effect.timeoutOrElse({ duration: 1_000, orElse: () => Effect.fail( - new MaintenanceProtocolError({ message: "destroy handler did not start" }), + new MaintenanceProtocolError({ message: "destroy completion was not signaled" }), ), }), ); - yield* write(new Socket.CloseEvent(1000)); - yield* Deferred.succeed(completionRelease, undefined); - const result = yield* Deferred.await(abandoned).pipe( - Effect.timeout("100 millis"), - Effect.exit, - ); - expect(Exit.isFailure(result)).toBe(true); - yield* Fiber.interrupt(reader); }), - ({ completionStarted, completionRelease }) => ({ - rpcHandlers: { - destroy: () => - Effect.gen(function* () { - yield* Deferred.succeed(completionStarted, undefined); - yield* Deferred.await(completionRelease); - return yield* Effect.fail({ - tag: "StackDestructionError" as const, - message: "injected destroy failure", - }); - }), - }, + ({ completionStarted, completionRelease, completion }) => ({ + onShutdownReady: Deferred.succeed(completionStarted, undefined).pipe( + Effect.andThen(Deferred.await(completionRelease)), + Effect.andThen(Deferred.succeed(completion, undefined)), + Effect.asVoid, + ), }), ), ); - it.live("returns a request-scoped defect for destroy and keeps the endpoint usable", () => + it.live("reevaluates shutdown after a request-scoped defect and keeps the endpoint usable", () => withServer( - ({ endpoint, stackId, ownerSessionId, completion, abandoned }) => + ({ endpoint, stackId, ownerSessionId, completion }) => Effect.gen(function* () { const preface = encodePreface({ kind: "rpc", @@ -512,98 +442,18 @@ describe("control transport", () => { requestId: "1", exit: { _tag: "Failure" }, }); - const completionExit = yield* Deferred.await(completion).pipe( - Effect.timeout("100 millis"), - Effect.exit, - ); - const abandonedExit = yield* Deferred.await(abandoned).pipe( - Effect.timeout("100 millis"), - Effect.exit, - ); - expect(Exit.isFailure(completionExit)).toBe(true); - expect(Exit.isFailure(abandonedExit)).toBe(true); + yield* Deferred.await(completion); const client = makeControlClient(endpoint, { stackId, ownerSessionId }); const rpc = yield* client.rpc; const status = yield* rpc.status(undefined); expect(status.id).toBe(stackId); }), - ({ completion, abandoned }) => ({ + ({ completion }) => ({ rpcHandlers: { destroy: () => Effect.die(new Error("injected destroy defect")), }, - onDestroyResponse: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), - onDestroyAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), - }), - ), - ); - - it.live("signals abandoned quiesce shutdown when the response connection is lost", () => - withServer( - ({ - endpoint, - stackId, - ownerSessionId, - completionStarted, - completionRelease, - completion, - abandoned, - }) => - Effect.gen(function* () { - const socket = yield* NodeSocket.makeNet({ - path: endpoint.kind === "unix" ? endpoint.path : endpoint.name, - }); - const write = yield* socket.writer; - const reader = yield* Effect.forkChild( - socket.runRaw(() => Effect.void), - { - startImmediately: true, - }, - ); - const preface = encodePreface({ - kind: "maintenance", - release: "maintenance-v1", - stackId, - ownerSessionId, - }); - const frame = yield* encodeFrame({ op: "quiesce", stackId, ownerSessionId }); - yield* write(concatBytes(preface, frame)); - yield* Deferred.await(completionStarted).pipe( - Effect.timeoutOrElse({ - duration: 1_000, - orElse: () => - Effect.fail( - new MaintenanceProtocolError({ message: "quiesce handler did not start" }), - ), - }), - ); - yield* write(new Socket.CloseEvent(1000)); - yield* Deferred.succeed(completionRelease, undefined); - yield* Deferred.await(abandoned).pipe( - Effect.timeoutOrElse({ - duration: 1_000, - orElse: () => - Effect.fail( - new MaintenanceProtocolError({ message: "abandoned quiesce was not signaled" }), - ), - }), - ); - const postResponse = yield* Deferred.await(completion).pipe( - Effect.timeout("100 millis"), - Effect.exit, - ); - expect(Exit.isFailure(postResponse)).toBe(true); - yield* Fiber.interrupt(reader); - }), - ({ completionStarted, completionRelease, completion, abandoned }) => ({ - maintenanceHandlers: { - quiesce: Deferred.succeed(completionStarted, undefined).pipe( - Effect.andThen(Deferred.await(completionRelease)), - Effect.as({ ok: true, op: "quiesce" }), - ), - }, - onMaintenanceComplete: () => Deferred.succeed(completion, undefined).pipe(Effect.asVoid), - onMaintenanceAbandoned: () => Deferred.succeed(abandoned, undefined).pipe(Effect.asVoid), + onShutdownReady: Deferred.succeed(completion, undefined).pipe(Effect.asVoid), }), ), ); @@ -739,6 +589,40 @@ describe("control transport", () => { ); }); + it.effect("allows validated stop to outlive the admission deadline", () => { + return withServer( + ({ endpoint, stackId, ownerSessionId, maintenanceStarted, maintenanceRelease }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { + stackId, + ownerSessionId, + }); + const stopCompleted = yield* Deferred.make(); + const stopFiber = yield* Effect.forkChild( + client.stop().pipe(Effect.ensuring(Deferred.succeed(stopCompleted, undefined))), + ); + yield* Deferred.await(maintenanceStarted); + yield* TestClock.adjust("6 seconds"); + yield* Effect.yieldNow; + const pending = yield* Deferred.poll(stopCompleted); + expect(Option.isNone(pending)).toBe(true); + yield* Deferred.succeed(maintenanceRelease, undefined); + expect(yield* Fiber.join(stopFiber)).toMatchObject({ ok: true, op: "stop" }); + }), + ({ maintenanceStarted, maintenanceRelease }) => { + return { + maintenanceHandlers: { + stop: Effect.gen(function* () { + yield* Deferred.succeed(maintenanceStarted, undefined); + yield* Deferred.await(maintenanceRelease); + return { ok: true, op: "stop" }; + }), + }, + }; + }, + ); + }); + it.live("runs maintenance completion only after the response is flushed", () => { return withServer( ({ endpoint, stackId, ownerSessionId, completionStarted, completionRelease, completion }) => @@ -755,12 +639,11 @@ describe("control transport", () => { yield* Deferred.await(completion); }), ({ completionStarted, completionRelease, completion }) => ({ - onMaintenanceComplete: () => - Deferred.succeed(completionStarted, undefined).pipe( - Effect.andThen(Deferred.await(completionRelease)), - Effect.andThen(Deferred.succeed(completion, undefined)), - Effect.asVoid, - ), + onShutdownReady: Deferred.succeed(completionStarted, undefined).pipe( + Effect.andThen(Deferred.await(completionRelease)), + Effect.andThen(Deferred.succeed(completion, undefined)), + Effect.asVoid, + ), }), ); }); diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index 72dbcebc2f..5694e0efdb 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -13,7 +13,7 @@ import { makeStackStateStore } from "../state/StackStateStore.ts"; import { StackIdSchema } from "../public/StackId.ts"; import { makeSupervisor } from "../supervisor/Supervisor.ts"; import { makeProductionRuntimeFactory } from "../runtime/ProductionRuntime.ts"; -import { startOwnerSession } from "../supervisor/OwnerSession.ts"; +import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; import { StackOwnershipConflictError } from "../public/Errors.ts"; @@ -141,23 +141,17 @@ const runSupervisor = (args: SupervisorArgs) => context, runtimeFactory, }); - const session = yield* startOwnerSession({ + yield* startControlServer({ endpoint: lease.metadata.endpoint, stackId: args.stackId, ownerSessionId, rpcRelease: args.rpcRelease || STACK_RPC_RELEASE, maintenanceHandlers: supervisor.maintenanceHandlers, rpcHandlers: supervisor.rpcHandlers, - onMaintenanceComplete: (op) => (op === "quiesce" ? supervisor.signalShutdown : Effect.void), - onMaintenanceAbandoned: (op) => - op === "quiesce" ? supervisor.signalShutdown : Effect.void, - onDestroyResponse: () => supervisor.signalShutdown, - onDestroyAbandoned: () => supervisor.signalShutdown, + onShutdownReady: supervisor.shutdownIfIdle, }); - yield* session.ready; yield* publishOwnership(lease); yield* writeReadiness({ ok: true, stackId: args.stackId, ownerSessionId }); - yield* supervisor.recover; yield* supervisor.shutdown; }), ).pipe(Effect.provide(NodeServices.layer)); diff --git a/packages/stack/src/functions/FunctionDiscovery.ts b/packages/stack/src/functions/FunctionDiscovery.ts deleted file mode 100644 index 56c716f51e..0000000000 --- a/packages/stack/src/functions/FunctionDiscovery.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { Effect, FileSystem, Path } from "effect"; -import type { - FunctionSecret, - MaterializedFunctionSettings, - FunctionSettings, -} from "../model/capabilities/functions.ts"; -import { FunctionSettingsDefaults } from "../model/capabilities/functions.ts"; -import type { - ActivationResult, - BackendEndpoint, - GatewayRoute, - GatewayRouteRequest, -} from "../gateway/Gateway.ts"; -import { GatewayRouteNotFoundError } from "../gateway/Gateway.ts"; -import { GatewayActivationError } from "../public/Errors.ts"; -import { - FunctionNotFoundError, - FunctionPathError, - type ContainedPath, - type FunctionsContainerMapping, - type FunctionsRoot, -} from "./FunctionsRoot.ts"; - -export { FunctionNotFoundError, FunctionPathError } from "./FunctionsRoot.ts"; - -interface FunctionInvocationPath { - readonly native: string; - readonly container: string; -} - -interface FunctionInvocationRoot { - readonly native: string; - readonly container: string; - readonly mount: FunctionsContainerMapping["mount"]; -} - -export interface FunctionInvocation { - readonly slug: string; - readonly verifyJwt: boolean; - readonly entrypoint: FunctionInvocationPath; - readonly importMap?: FunctionInvocationPath; - readonly staticPatterns: ReadonlyArray; - readonly env: Readonly>; - readonly root: FunctionInvocationRoot; -} - -export interface FunctionDiscoveryOptions { - readonly root: FunctionsRoot; - readonly settings: MaterializedFunctionSettings; - readonly globalEnv?: Readonly>; -} - -export interface FunctionDiscovery { - readonly discover: ( - slug: string, - ) => Effect.Effect; -} - -const functionSlugPattern = /^[A-Za-z0-9_-]+$/u; -const notFound = (message: string, slug?: string) => - new FunctionNotFoundError({ message, ...(slug === undefined ? {} : { slug }) }); - -const pathFailure = (message: string, path?: string, cause?: unknown) => - new FunctionPathError({ - message, - ...(path === undefined ? {} : { path }), - ...(cause === undefined ? {} : { cause }), - }); - -const makeInvocation = ( - root: FunctionsRoot, - options: FunctionDiscoveryOptions, - slug: string, - settings: FunctionSettings, -): Effect.Effect< - FunctionInvocation, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem | Path.Path -> => - Effect.gen(function* () { - if (!settings.enabled) return yield* notFound("Function is disabled", slug); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const canonicalRoot = yield* root.resolveContained("", { kind: "directory" }); - const functionDirectory = yield* root.resolveFunctionPath(slug, "", { kind: "directory" }); - - const validateTree = ( - directory: string, - relativeDirectory: string, - seen: Set, - ): Effect.Effect< - void, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem | Path.Path - > => - Effect.gen(function* () { - const canonical = yield* fs - .realPath(directory) - .pipe( - Effect.mapError((cause) => - pathFailure("Function directory cannot be resolved", directory, cause), - ), - ); - if (seen.has(canonical)) return; - seen.add(canonical); - const entries = yield* fs - .readDirectory(directory) - .pipe( - Effect.mapError((cause) => - pathFailure("Function directory cannot be read", directory, cause), - ), - ); - for (const entry of entries) { - const child = path.join(directory, entry); - const relative = path.join(relativeDirectory, entry); - const info = yield* fs - .stat(child) - .pipe( - Effect.mapError((cause) => - pathFailure("Function path cannot be inspected", relative, cause), - ), - ); - yield* root.resolveContained(relative, { - kind: info.type === "Directory" ? "directory" : "file", - }); - if (info.type === "Directory") yield* validateTree(child, relative, seen); - } - }); - - yield* validateTree(functionDirectory.native, functionDirectory.relative, new Set()); - const shared = yield* root - .resolveContained("_shared", { kind: "directory" }) - .pipe(Effect.catchTag("FunctionNotFoundError", () => Effect.as(Effect.void, undefined))); - if (shared !== undefined) yield* validateTree(shared.native, shared.relative, new Set()); - - const entrypointRelative = settings.entrypoint.length > 0 ? settings.entrypoint : "index.ts"; - const entrypoint = yield* root - .resolveFunctionPath(slug, entrypointRelative, { kind: "file" }) - .pipe( - Effect.catchTag("FunctionNotFoundError", () => - Effect.fail(notFound("Function entrypoint is not present", slug)), - ), - ); - - let importMap: ContainedPath | undefined; - const explicitImportMap = settings.import_map.length > 0 ? settings.import_map : undefined; - if (explicitImportMap !== undefined) { - importMap = yield* root - .resolveFunctionPath(slug, explicitImportMap, { kind: "file" }) - .pipe( - Effect.catchTag("FunctionNotFoundError", () => - Effect.fail(pathFailure("Function import map is not present", explicitImportMap)), - ), - ); - } else { - for (const candidate of ["deno.json", "deno.jsonc"]) { - const resolved = yield* root - .resolveFunctionPath(slug, candidate, { kind: "file" }) - .pipe(Effect.catchTag("FunctionNotFoundError", () => Effect.as(Effect.void, undefined))); - if (resolved !== undefined) { - importMap = resolved; - break; - } - } - } - - const staticPatterns: ContainedPath[] = []; - for (const pattern of settings.static_files) { - if (pattern.length === 0) - return yield* pathFailure("Function static pattern must not be empty", pattern); - const resolved = yield* root.resolveFunctionPath(slug, pattern, { kind: "pattern" }); - staticPatterns.push(resolved); - } - - const env = Object.freeze(Object.assign({}, options.globalEnv ?? {}, settings.env)); - const nativeRoot = canonicalRoot.native; - const mount = Object.freeze({ - source: nativeRoot, - target: canonicalRoot.container, - readOnly: true, - }); - const containerRoot = mount.target; - return Object.freeze({ - slug, - verifyJwt: settings.verify_jwt, - entrypoint: Object.freeze({ native: entrypoint.native, container: entrypoint.container }), - ...(importMap === undefined - ? {} - : { - importMap: Object.freeze({ native: importMap.native, container: importMap.container }), - }), - staticPatterns: Object.freeze( - staticPatterns.map((value) => - Object.freeze({ native: value.native, container: value.container }), - ), - ), - env, - root: Object.freeze({ - native: nativeRoot, - container: containerRoot, - mount, - }), - }) satisfies FunctionInvocation; - }); - -export const makeFunctionDiscovery = ( - options: FunctionDiscoveryOptions, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const discover = (slug: string) => { - if (!functionSlugPattern.test(slug) || slug === "_shared") - return Effect.fail(notFound("Function slug is invalid", slug)); - const settings = options.settings[slug] ?? FunctionSettingsDefaults; - return makeInvocation(options.root, options, slug, settings).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); - }; - return { discover } satisfies FunctionDiscovery; - }); - -export const discoverFunction = ( - root: FunctionsRoot, - slug: string, - settings: MaterializedFunctionSettings, -): Effect.Effect< - FunctionInvocation, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem | Path.Path -> => - makeFunctionDiscovery({ root, settings }).pipe( - Effect.flatMap((discovery) => discovery.discover(slug)), - ); - -const functionSlugFromRequest = (request: GatewayRouteRequest): string | undefined => { - const path = request.path.split("?", 1)[0] ?? request.path; - const match = /^\/functions\/v1\/([^/]+)(?:\/.*)?$/u.exec(path); - return match?.[1]; -}; - -/** Remove the public Functions prefix before forwarding to Edge Runtime. */ -export const rewriteFunctionRequestPath = (requestPath: string): string => { - const queryIndex = requestPath.indexOf("?"); - const pathname = queryIndex < 0 ? requestPath : requestPath.slice(0, queryIndex); - const query = queryIndex < 0 ? "" : requestPath.slice(queryIndex); - const prefix = "/functions/v1"; - if (!pathname.startsWith(`${prefix}/`) && pathname !== prefix) return requestPath; - const rewritten = pathname.slice(prefix.length) || "/"; - return `${rewritten}${query}`; -}; - -export interface FunctionsGatewayRouteOptions { - readonly dispatch: ( - invocation: FunctionInvocation, - activation: ActivationResult, - ) => Effect.Effect; -} - -export const makeFunctionsGatewayRoute = ( - discovery: FunctionDiscovery, - options: FunctionsGatewayRouteOptions, -): GatewayRoute => ({ - capability: "functions", - match: (request) => functionSlugFromRequest(request) !== undefined, - prepare: (request) => { - const slug = functionSlugFromRequest(request); - if (slug === undefined) - return Effect.fail(new GatewayRouteNotFoundError({ message: "Function route not found" })); - return discovery.discover(slug).pipe( - Effect.map((invocation) => ({ - resolveBackend: (activation: ActivationResult) => options.dispatch(invocation, activation), - upstreamPath: (request: GatewayRouteRequest) => rewriteFunctionRequestPath(request.path), - })), - Effect.catchTags({ - FunctionNotFoundError: () => - Effect.fail(new GatewayRouteNotFoundError({ message: "Function route not found" })), - FunctionPathError: (error) => - Effect.fail( - new GatewayActivationError({ message: "Function path is invalid", cause: error }), - ), - }), - ); - }, -}); diff --git a/packages/stack/src/functions/FunctionsBootstrap.ts b/packages/stack/src/functions/FunctionsBootstrap.ts index c713fa2080..b8da240a65 100644 --- a/packages/stack/src/functions/FunctionsBootstrap.ts +++ b/packages/stack/src/functions/FunctionsBootstrap.ts @@ -4,12 +4,10 @@ import { resolveStackPaths } from "../state/Paths.ts"; import type { StackId } from "../public/StackId.ts"; export interface FunctionsBootstrapOwner { - /** Publishes the stack-owned Edge Runtime main service for one generation. */ + /** Publishes the stack-owned Edge Runtime main service for the current session. */ readonly write: (input: { - readonly generation: number; readonly content: string; }) => Effect.Effect; - readonly cleanupGeneration: (generation: number) => Effect.Effect; /** Removes only this stack's functions bootstrap root. */ readonly cleanupAll: Effect.Effect; } @@ -46,33 +44,25 @@ export const makeFunctionsBootstrapOwner = ( const root = path.join(stackPaths.runtime, "functions"); const write = (input: { - readonly generation: number; readonly content: string; }): Effect.Effect => { - if (!Number.isSafeInteger(input.generation) || input.generation < 0) - return Effect.fail(failure("Invalid functions bootstrap generation")); if (input.content.includes("\u0000")) return Effect.fail(failure("Functions bootstrap contains an invalid character")); - const generationRoot = path.join(root, String(input.generation)); - const target = path.join(generationRoot, "index.ts"); + const target = path.join(root, "index.ts"); return Effect.gen(function* () { const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => failure("Unable to allocate functions bootstrap file", { cause }), ), ); - const temporary = path.join(generationRoot, `.index.ts.${token}.tmp`); + const temporary = path.join(root, `.index.ts.${token}.tmp`); return yield* Effect.gen(function* () { yield* mapFs( - generationRoot, + root, "create functions bootstrap directory", - fs.makeDirectory(generationRoot, { recursive: true, mode: 0o700 }), - ); - yield* mapFs( - generationRoot, - "secure functions bootstrap directory", - fs.chmod(generationRoot, 0o700), + fs.makeDirectory(root, { recursive: true, mode: 0o700 }), ); + yield* mapFs(root, "secure functions bootstrap directory", fs.chmod(root, 0o700)); yield* Effect.scoped( Effect.gen(function* () { const file = yield* mapFs( @@ -110,18 +100,10 @@ export const makeFunctionsBootstrapOwner = ( }); }; - const cleanupGeneration = (generation: number): Effect.Effect => - !Number.isSafeInteger(generation) || generation < 0 - ? Effect.fail(failure("Invalid functions bootstrap generation")) - : mapFs( - path.join(root, String(generation)), - "clean functions bootstrap generation", - fs.remove(path.join(root, String(generation)), { recursive: true, force: true }), - ); const cleanupAll = mapFs( root, "clean functions bootstrap files", fs.remove(root, { recursive: true, force: true }), ); - return { write, cleanupGeneration, cleanupAll }; + return { write, cleanupAll }; }); diff --git a/packages/stack/src/functions/FunctionsRoot.ts b/packages/stack/src/functions/FunctionsRoot.ts deleted file mode 100644 index a56f274a52..0000000000 --- a/packages/stack/src/functions/FunctionsRoot.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { Data, Effect, FileSystem, Path } from "effect"; - -export class FunctionNotFoundError extends Data.TaggedError("FunctionNotFoundError")<{ - readonly slug?: string; - readonly path?: string; - readonly message: string; -}> {} - -export class FunctionPathError extends Data.TaggedError("FunctionPathError")<{ - readonly path?: string; - readonly message: string; - readonly cause?: unknown; -}> {} - -const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; - -export interface FunctionsRootOptions { - /** The configured functionsRoot; Compiler supplies an absolute path. */ - readonly root: string; -} - -interface ContainerMount { - readonly source: string; - readonly target: string; - readonly readOnly: true; -} - -export interface FunctionsContainerMapping { - readonly root: string; - readonly mount: ContainerMount; -} - -export interface ContainedPath { - readonly native: string; - readonly relative: string; - readonly container: string; - readonly kind: "file" | "directory" | "pattern"; -} - -export interface FunctionsRoot { - readonly configuredRoot: string; - /** Resolve the current canonical root and its sole read-only container mount. */ - readonly mount: Effect.Effect< - FunctionsContainerMapping, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem - >; - /** Resolve a root-relative path after refreshing root realpath and containment. */ - readonly resolveContained: ( - relative: string, - options?: { readonly kind?: "file" | "directory" | "pattern" }, - ) => Effect.Effect< - ContainedPath, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem - >; - /** Resolve a function-relative path using the same containment authority. */ - readonly resolveFunctionPath: ( - slug: string, - relative: string, - options?: { readonly kind?: "file" | "directory" | "pattern" }, - ) => Effect.Effect< - ContainedPath, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem - >; - /** Resolve a local module relative to a contained file; ../_shared is allowed. */ - readonly resolveModulePath: ( - base: ContainedPath, - relative: string, - ) => Effect.Effect< - ContainedPath, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem - >; -} - -const pathError = (message: string, path?: string, cause?: unknown) => - new FunctionPathError({ - message, - ...(path === undefined ? {} : { path }), - ...(cause === undefined ? {} : { cause }), - }); - -const notFound = (message: string, slug?: string, path?: string) => - new FunctionNotFoundError({ - message, - ...(slug === undefined ? {} : { slug }), - ...(path === undefined ? {} : { path }), - }); - -const hasLexicalEscape = (relative: string): boolean => { - if (relative.length === 0) return false; - return relative.split(/[\\/]/u).some((segment) => segment === ".."); -}; - -const isInside = (path: Path.Path, root: string, candidate: string): boolean => { - const relative = path.relative(root, candidate); - return ( - relative === "" || - (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) - ); -}; - -const patternBase = (path: Path.Path, relative: string): string => { - const parts = relative.split(/[\\/]/u); - const fixed: string[] = []; - for (const part of parts) { - if (/[?*[{]/u.test(part)) break; - fixed.push(part); - } - return path.join(...fixed); -}; - -const containerPath = (relative: string): string => - relative.length === 0 - ? FUNCTIONS_CONTAINER_ROOT - : `${FUNCTIONS_CONTAINER_ROOT}/${relative.replaceAll(/[\\/]/gu, "/")}`; - -/** - * FunctionsRoot is the sole filesystem authority for the Functions runtime. - * It intentionally does not cache root existence or child membership: every - * resolver call re-reads and realpaths the configured root. - */ -export const makeFunctionsRoot = ( - options: FunctionsRootOptions, -): Effect.Effect => - Effect.gen(function* () { - const path = yield* Path.Path; - if (!path.isAbsolute(options.root)) - return yield* pathError("functionsRoot must be absolute", options.root); - const configuredRoot = path.resolve(options.root); - const containerRoot = FUNCTIONS_CONTAINER_ROOT; - - const resolveContained = ( - relative: string, - resolveOptions: { readonly kind?: "file" | "directory" | "pattern" } = {}, - ): Effect.Effect< - ContainedPath, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem - > => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - if (path.isAbsolute(relative) || hasLexicalEscape(relative)) - return yield* pathError("Function path escapes functionsRoot", relative); - if (resolveOptions.kind === "pattern" && relative.length === 0) - return yield* pathError("Function pattern must not be empty", relative); - const realRoot = yield* fs - .realPath(configuredRoot) - .pipe( - Effect.mapError(() => - notFound("Functions root is not present", undefined, configuredRoot), - ), - ); - const normalized = relative.replaceAll(/[\\/]/gu, path.sep); - const lexical = path.resolve(realRoot, normalized); - if (!isInside(path, realRoot, lexical)) - return yield* pathError("Function path escapes functionsRoot", relative); - const kind = resolveOptions.kind ?? "file"; - const checkPath = kind === "pattern" ? patternBase(path, normalized) : normalized; - const checkLexical = path.resolve(realRoot, checkPath); - if (!isInside(path, realRoot, checkLexical)) - return yield* pathError("Function path escapes functionsRoot", relative); - const exists = yield* fs - .exists(checkLexical) - .pipe( - Effect.mapError((cause) => - pathError("Unable to inspect function path", relative, cause), - ), - ); - if (!exists) return yield* notFound("Function path is not present", undefined, relative); - const realCheck = yield* fs - .realPath(checkLexical) - .pipe( - Effect.mapError((cause) => - pathError("Unable to resolve function path", relative, cause), - ), - ); - if (!isInside(path, realRoot, realCheck)) - return yield* pathError("Function path symlink escapes functionsRoot", relative); - const info = yield* fs - .stat(realCheck) - .pipe( - Effect.mapError((cause) => - pathError("Unable to inspect function path", relative, cause), - ), - ); - if (kind === "file" && info.type !== "File") - return yield* pathError("Function path must be a file", relative); - if (kind === "directory" && info.type !== "Directory") - return yield* pathError("Function path must be a directory", relative); - const relativeCanonical = - kind === "pattern" - ? relative.replaceAll(/[\\/]/gu, path.sep) - : path.relative(realRoot, realCheck); - return { - native: kind === "pattern" ? lexical : realCheck, - relative: relativeCanonical, - container: containerPath(relativeCanonical), - kind, - } satisfies ContainedPath; - }); - - const resolveFunctionPath = ( - slug: string, - relative: string, - resolveOptions: { readonly kind?: "file" | "directory" | "pattern" } = {}, - ) => { - if (!/^[A-Za-z0-9_-]+$/u.test(slug)) - return Effect.fail(notFound("Function slug is invalid", slug)); - if (path.isAbsolute(relative) || hasLexicalEscape(relative)) - return Effect.fail(pathError("Function path escapes function directory", relative)); - return resolveContained(path.join(slug, relative), resolveOptions); - }; - - const resolveModulePath = ( - base: ContainedPath, - relative: string, - ): Effect.Effect< - ContainedPath, - FunctionNotFoundError | FunctionPathError, - FileSystem.FileSystem - > => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - if (path.isAbsolute(relative) || relative.length === 0) - return yield* pathError("Function module path is invalid", relative); - const realRoot = yield* fs - .realPath(configuredRoot) - .pipe( - Effect.mapError(() => - notFound("Functions root is not present", undefined, configuredRoot), - ), - ); - const validatedBase = yield* resolveContained(base.relative, { kind: "file" }); - const normalized = relative.replaceAll(/[\\/]/gu, path.sep); - const lexical = path.resolve(path.dirname(validatedBase.native), normalized); - if (!isInside(path, realRoot, lexical)) - return yield* pathError("Function module path escapes functionsRoot", relative); - const exists = yield* fs - .exists(lexical) - .pipe( - Effect.mapError((cause) => - pathError("Unable to inspect function module", relative, cause), - ), - ); - if (!exists) return yield* notFound("Function module is not present", undefined, relative); - const real = yield* fs - .realPath(lexical) - .pipe( - Effect.mapError((cause) => - pathError("Unable to resolve function module", relative, cause), - ), - ); - if (!isInside(path, realRoot, real)) - return yield* pathError("Function module symlink escapes functionsRoot", relative); - const info = yield* fs - .stat(real) - .pipe( - Effect.mapError((cause) => - pathError("Unable to inspect function module", relative, cause), - ), - ); - if (info.type !== "File") - return yield* pathError("Function module must be a file", relative); - const relativeCanonical = path.relative(realRoot, real); - return { - native: real, - relative: relativeCanonical, - container: containerPath(relativeCanonical), - kind: "file", - } satisfies ContainedPath; - }); - - const mount = resolveContained("", { kind: "directory" }).pipe( - Effect.map( - (root) => - ({ - root: root.native, - mount: Object.freeze({ source: root.native, target: containerRoot, readOnly: true }), - }) satisfies FunctionsContainerMapping, - ), - ); - - return { - configuredRoot, - mount, - resolveContained, - resolveFunctionPath, - resolveModulePath, - } satisfies FunctionsRoot; - }); diff --git a/packages/stack/src/functions/functions-bootstrap.integration.test.ts b/packages/stack/src/functions/functions-bootstrap.integration.test.ts index 73a3dee58e..033ef02868 100644 --- a/packages/stack/src/functions/functions-bootstrap.integration.test.ts +++ b/packages/stack/src/functions/functions-bootstrap.integration.test.ts @@ -5,7 +5,7 @@ import { isStackId } from "../public/StackId.ts"; import { makeFunctionsBootstrapOwner } from "./FunctionsBootstrap.ts"; describe("functions bootstrap owner", () => { - it.live("publishes one private generation file with restrictive modes", () => + it.live("publishes one private session file with restrictive modes", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -14,14 +14,14 @@ describe("functions bootstrap owner", () => { if (!isStackId(stackIdValue)) throw new Error("invalid test stack id"); const stackId = stackIdValue; const owner = yield* makeFunctionsBootstrapOwner({ stateRoot: root, stackId }); - const target = yield* owner.write({ generation: 4, content: "export default 1" }); - expect(target).toContain(path.join(root, stackId, "runtime", "functions", "4", "index.ts")); + const target = yield* owner.write({ content: "export default 1" }); + expect(target).toContain(path.join(root, stackId, "runtime", "functions", "index.ts")); expect((yield* fs.stat(path.dirname(target))).mode! & 0o777).toBe(0o700); expect((yield* fs.stat(target)).mode! & 0o777).toBe(0o600); expect(yield* fs.readFileString(target)).toBe("export default 1"); - yield* owner.cleanupGeneration(4); + yield* owner.cleanupAll; expect(yield* fs.exists(target)).toBe(false); - const stale = yield* owner.write({ generation: 5, content: "export default 2" }); + const stale = yield* owner.write({ content: "export default 2" }); yield* owner.cleanupAll; expect(yield* fs.exists(stale)).toBe(false); expect(yield* fs.exists(path.join(root, stackId, "runtime", "functions"))).toBe(false); @@ -46,13 +46,12 @@ describe("functions bootstrap owner", () => { stackId: stackIdValue, }); - const target = yield* owner.write({ generation: 1, content: "export default 2" }); + const target = yield* owner.write({ content: "export default 2" }); const expected = path.join( yield* fs.realPath(canonicalRoot), stackIdValue, "runtime", "functions", - "1", "index.ts", ); expect(target).toBe(expected); diff --git a/packages/stack/src/functions/functions.integration.test.ts b/packages/stack/src/functions/functions.integration.test.ts deleted file mode 100644 index 2421a5fea2..0000000000 --- a/packages/stack/src/functions/functions.integration.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Redacted } from "effect"; -import { - FunctionSettingsDefaults, - type FunctionSettings, -} from "../model/capabilities/functions.ts"; -import { FunctionNotFoundError, FunctionPathError, makeFunctionsRoot } from "./FunctionsRoot.ts"; -import { discoverFunction } from "./FunctionDiscovery.ts"; - -const withPlatform = (effect: Effect.Effect) => - Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -const write = (path: string, content: string) => - FileSystem.FileSystem.pipe(Effect.flatMap((fs) => fs.writeFileString(path, content))); - -const settings = (override: Partial = {}): FunctionSettings => ({ - ...FunctionSettingsDefaults, - ...override, -}); - -const capture = (effect: Effect.Effect) => - effect.pipe( - Effect.map((value) => ({ tag: "success" as const, value })), - Effect.catch((error: E) => Effect.succeed({ tag: "failure" as const, error })), - ); - -describe("live Functions discovery", () => { - it.live("discovers a function entrypoint on every request", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-" }); - const functionRoot = `${root}/functions`; - yield* fs.makeDirectory(`${functionRoot}/rest`, { recursive: true }); - yield* write(`${functionRoot}/rest/index.ts`, "export default () => new Response('one')"); - const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); - const invocation = yield* discoverFunction(functionsRoot, "rest", { - rest: FunctionSettingsDefaults, - }); - expect(invocation.slug).toBe("rest"); - expect(invocation.entrypoint.native).toBe( - yield* fs.realPath(`${functionRoot}/rest/index.ts`), - ); - expect(invocation.root.mount.readOnly).toBe(true); - }), - ), - ); - - it.live( - "observes live edits, custom metadata, shared modules, and stable container mapping", - () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-live-" }); - const functionRoot = `${root}/functions`; - yield* fs.makeDirectory(`${functionRoot}/rest/public`, { recursive: true }); - yield* fs.makeDirectory(`${functionRoot}/config-only`, { recursive: true }); - yield* fs.makeDirectory(`${functionRoot}/_shared`, { recursive: true }); - yield* write( - `${functionRoot}/config-only/main.ts`, - "export default () => new Response('config')", - ); - yield* write( - `${functionRoot}/rest/custom.ts`, - "export default () => new Response('custom')", - ); - yield* write(`${functionRoot}/rest/index.ts`, "export default () => new Response('one')"); - yield* write(`${functionRoot}/rest/public/logo.txt`, "logo"); - yield* write(`${functionRoot}/_shared/shared.ts`, "export const shared = true"); - yield* write( - `${functionRoot}/rest/deno.json`, - '{"imports":{"shared":"../_shared/shared.ts"}}', - ); - const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); - const entryFile = yield* functionsRoot.resolveFunctionPath("rest", "index.ts", { - kind: "file", - }); - const sharedFile = yield* functionsRoot.resolveModulePath( - entryFile, - "../_shared/shared.ts", - ); - expect(sharedFile.native).toBe(yield* fs.realPath(`${functionRoot}/_shared/shared.ts`)); - const secret = Redacted.make("never-in-an-error"); - const first = yield* discoverFunction(functionsRoot, "rest", { - rest: settings({ - entrypoint: "custom.ts", - static_files: ["public/*.txt"], - env: { API_KEY: secret }, - }), - }); - expect(yield* fs.readFileString(first.entrypoint.native)).toContain("custom"); - expect(first.entrypoint.native).toBe( - yield* fs.realPath(`${functionRoot}/rest/custom.ts`), - ); - expect(first.importMap?.native).toBe( - yield* fs.realPath(`${functionRoot}/rest/deno.json`), - ); - expect(first.staticPatterns[0]?.container).toBe( - "/__supabase_functions/rest/public/*.txt", - ); - expect(first.root.mount).toEqual({ - source: yield* fs.realPath(functionRoot), - target: "/__supabase_functions", - readOnly: true, - }); - expect(Redacted.isRedacted(first.env.API_KEY)).toBe(true); - - const configOnly = yield* discoverFunction(functionsRoot, "config-only", { - "config-only": settings({ entrypoint: "main.ts" }), - }); - expect(configOnly.entrypoint.native).toBe( - yield* fs.realPath(`${functionRoot}/config-only/main.ts`), - ); - - yield* write( - `${functionRoot}/rest/custom.ts`, - "export default () => new Response('edited')", - ); - const edited = yield* discoverFunction(functionsRoot, "rest", { - rest: settings({ entrypoint: "custom.ts" }), - }); - expect(edited.entrypoint.native).toBe(first.entrypoint.native); - expect(yield* fs.readFileString(edited.entrypoint.native)).toContain("edited"); - yield* fs.remove(`${functionRoot}/rest/custom.ts`); - const deletedEffect = discoverFunction(functionsRoot, "rest", { - rest: settings({ entrypoint: "custom.ts" }), - }); - const deleted = yield* capture(deletedEffect); - expect(deleted.tag).toBe("failure"); - if (deleted.tag === "failure") - expect(deleted.error).toBeInstanceOf(FunctionNotFoundError); - }), - ), - ); - - it.live("fails closed for disabled, invalid, and escaping function paths", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-paths-" }); - const functionRoot = `${root}/functions`; - const outside = `${root}/outside.ts`; - yield* fs.makeDirectory(`${functionRoot}/rest`, { recursive: true }); - yield* write(`${functionRoot}/rest/index.ts`, "export default 1"); - yield* write(outside, "export default 0"); - yield* fs.symlink(outside, `${functionRoot}/rest/escape.ts`); - const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); - const escaped = discoverFunction(functionsRoot, "rest", { - rest: settings({ entrypoint: "escape.ts" }), - }); - const escapedResult = yield* capture(escaped); - expect(escapedResult.tag).toBe("failure"); - if (escapedResult.tag === "failure") - expect(escapedResult.error).toBeInstanceOf(FunctionPathError); - - const traversal = discoverFunction(functionsRoot, "rest", { - rest: settings({ entrypoint: "../outside.ts" }), - }); - const traversalResult = yield* capture(traversal); - expect(traversalResult.tag).toBe("failure"); - if (traversalResult.tag === "failure") - expect(traversalResult.error).toBeInstanceOf(FunctionPathError); - - const disabled = discoverFunction(functionsRoot, "rest", { - rest: settings({ enabled: false }), - }); - const disabledResult = yield* capture(disabled); - expect(disabledResult.tag).toBe("failure"); - if (disabledResult.tag === "failure") - expect(disabledResult.error).toBeInstanceOf(FunctionNotFoundError); - - const invalid = discoverFunction(functionsRoot, "not.valid", { - rest: settings(), - }); - const invalidResult = yield* capture(invalid); - expect(invalidResult.tag).toBe("failure"); - if (invalidResult.tag === "failure") - expect(invalidResult.error).toBeInstanceOf(FunctionNotFoundError); - }), - ), - ); - - it.live("rejects traversal and absolute values for every configured path class", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-config-" }); - const functionRoot = `${root}/functions`; - yield* fs.makeDirectory(`${functionRoot}/rest/public`, { recursive: true }); - yield* write(`${functionRoot}/rest/index.ts`, "export default 1"); - const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); - const cases: ReadonlyArray]> = [ - ["entrypoint traversal", { entrypoint: "../outside.ts" }], - ["entrypoint absolute", { entrypoint: `${root}/outside.ts` }], - ["import map traversal", { import_map: "../deno.json" }], - ["import map absolute", { import_map: `${root}/deno.json` }], - ["static traversal", { static_files: ["../outside.txt"] }], - ["static absolute", { static_files: [`${root}/outside.txt`] }], - ]; - for (const [name, override] of cases) { - const result = yield* capture( - discoverFunction(functionsRoot, "rest", { rest: settings(override) }), - ); - expect(result.tag, name).toBe("failure"); - if (result.tag === "failure") expect(result.error).toBeInstanceOf(FunctionPathError); - } - }), - ), - ); - - it.live( - "observes a function created after an initial miss and canonicalizes a symlinked root", - () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-root-" }); - const canonical = `${root}/canonical`; - const configured = `${root}/configured`; - yield* fs.makeDirectory(`${canonical}/rest`, { recursive: true }); - yield* fs.symlink(canonical, configured); - const functionsRoot = yield* makeFunctionsRoot({ root: configured }); - const missing = yield* capture( - discoverFunction(functionsRoot, "rest", { rest: settings() }), - ); - expect(missing.tag).toBe("failure"); - if (missing.tag === "failure") - expect(missing.error).toBeInstanceOf(FunctionNotFoundError); - yield* write(`${canonical}/rest/index.ts`, "export default 1"); - const found = yield* discoverFunction(functionsRoot, "rest", { rest: settings() }); - expect(found.root.native).toBe(yield* fs.realPath(canonical)); - expect(found.root.mount.source).toBe(found.root.native); - - yield* fs.makeDirectory(`${canonical}/new`, { recursive: true }); - yield* write(`${canonical}/new/index.ts`, "export default 2"); - const created = yield* discoverFunction(functionsRoot, "new", {}); - expect(created.slug).toBe("new"); - }), - ), - ); - - it.live("rejects symlink escapes from the function directory and shared root", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-symlink-" }); - const functionRoot = `${root}/functions`; - const outside = `${root}/outside`; - yield* fs.makeDirectory(`${functionRoot}/rest`, { recursive: true }); - yield* fs.makeDirectory(`${outside}/rest`, { recursive: true }); - yield* write(`${outside}/rest/index.ts`, "export default 0"); - yield* fs.symlink(`${outside}/rest`, `${functionRoot}/linked`); - const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); - const linked = yield* capture( - discoverFunction(functionsRoot, "linked", { linked: settings() }), - ); - expect(linked.tag).toBe("failure"); - if (linked.tag === "failure") expect(linked.error).toBeInstanceOf(FunctionPathError); - - yield* fs.remove(`${functionRoot}/linked`); - yield* write(`${outside}/shared.ts`, "export const outside = true"); - yield* fs.symlink(outside, `${functionRoot}/_shared`); - yield* write(`${functionRoot}/rest/index.ts`, "export default 1"); - const shared = yield* capture( - discoverFunction(functionsRoot, "rest", { rest: settings() }), - ); - expect(shared.tag).toBe("failure"); - if (shared.tag === "failure") expect(shared.error).toBeInstanceOf(FunctionPathError); - }), - ), - ); -}); diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts index d001d65af6..72cd128185 100644 --- a/packages/stack/src/functions/serve-main-resolver.integration.test.ts +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -150,6 +150,121 @@ describe("Edge Runtime request-time function resolver", () => { } }); + it("applies global defaults to newly discovered functions while preserving overrides", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-defaults-")); + try { + const hello = join(root, "hello"); + const created = join(root, "created"); + const canonicalRoot = await realpath(root); + await mkdir(hello, { recursive: true }); + await mkdir(created, { recursive: true }); + await writeFile(join(hello, "index.ts"), "export default 1"); + await writeFile(join(created, "index.ts"), "export default 2"); + await writeFile(join(root, "shared-deno.json"), "{}"); + + const defaults = { + verify_jwt: false, + import_map_root: "shared-deno.json", + }; + const overrides = { + $default: defaults, + hello: { verify_jwt: true }, + }; + + expect( + await resolveFunctionConfig({ root, slug: "created", overrides, fs: nodeFileSystem }), + ).toMatchObject({ + verifyJWT: false, + importMapPath: join(canonicalRoot, "shared-deno.json"), + }); + expect( + await resolveFunctionConfig({ root, slug: "hello", overrides, fs: nodeFileSystem }), + ).toMatchObject({ + verifyJWT: true, + importMapPath: join(canonicalRoot, "shared-deno.json"), + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("keeps a closed per-function import map relative to that function", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-import-map-")); + try { + const hello = join(root, "hello"); + await mkdir(hello, { recursive: true }); + await writeFile(join(hello, "index.ts"), "export default 1"); + await writeFile(join(root, "shared-deno.json"), "{}"); + await writeFile(join(hello, "custom-deno.json"), "{}"); + + const canonicalRoot = await realpath(root); + await expect( + resolveFunctionConfig({ + root, + slug: "hello", + overrides: { + $default: { import_map_root: "shared-deno.json" }, + hello: { import_map: "custom-deno.json" }, + }, + fs: nodeFileSystem, + }), + ).resolves.toMatchObject({ + importMapPath: join(canonicalRoot, "hello", "custom-deno.json"), + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("accepts a symlinked functions root while enforcing canonical descendants", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-root-link-")); + try { + const canonical = join(root, "canonical"); + const configured = join(root, "configured"); + await mkdir(join(canonical, "hello"), { recursive: true }); + await writeFile(join(canonical, "hello", "index.ts"), "export default 1"); + await symlink(canonical, configured, "dir"); + const found = await resolveFunctionConfig({ + root: configured, + slug: "hello", + overrides: {}, + fs: nodeFileSystem, + }); + expect(found?.entrypointPath).toBe(join(await realpath(canonical), "hello", "index.ts")); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("allows package.json discovery under a symlinked functions root", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-package-root-link-")); + try { + const canonical = join(root, "canonical"); + const configured = join(root, "configured"); + const functionRoot = join(canonical, "hello"); + await mkdir(functionRoot, { recursive: true }); + await writeFile(join(functionRoot, "index.ts"), "export default 1"); + await writeFile(join(functionRoot, "package.json"), "{}"); + await symlink(canonical, configured, "dir"); + const config = await resolveFunctionConfig({ + root: configured, + slug: "hello", + overrides: {}, + fs: nodeFileSystem, + }); + expect(config).toBeDefined(); + expect( + await packageJsonContainedFor({ + root: configured, + config: { ...config!, importMapPath: "" }, + fs: nodeFileSystem, + }), + ).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("requires an absolute root and rejects traversal and symlink escapes", async () => { const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-paths-")); const outside = await mkdtemp(join(tmpdir(), "stack-functions-resolver-outside-")); diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index da620d73d2..feda073e57 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -9,11 +9,15 @@ interface FunctionOverride { readonly entrypoint?: string; readonly importMapPath?: string; readonly import_map?: string; + /** Reserved `$default` field: path relative to the shared functions root. */ + readonly importMapRoot?: string; + readonly import_map_root?: string; readonly staticFiles?: ReadonlyArray; readonly static_files?: ReadonlyArray; readonly env?: Readonly>; } +/** Persisted per-function overrides plus the reserved global defaults entry. */ export type FunctionOverrides = Readonly>; export interface FunctionConfig { @@ -105,11 +109,24 @@ export const resolveFunctionConfig = async (options: { const { root, slug, overrides, fs } = options; if (!root.startsWith("/") || !slugPattern.test(slug) || slug === "_shared") return undefined; const rootInfo = await optionalInfo(fs, root); - if (rootInfo === undefined || !rootInfo.isDirectory || rootInfo.isSymbolicLink) return undefined; + // The configured functions root itself may be a symlink; descendants remain + // subject to canonical containment and entrypoint symlink rejection below. + if (rootInfo === undefined) return undefined; const canonicalRoot = await fs.realPath(root).catch(() => ""); if (!canonicalRoot.startsWith("/")) return undefined; - const override = overrides[slug]; - if (override?.enabled === false) return undefined; + const canonicalInfo = await optionalInfo(fs, canonicalRoot); + if (canonicalInfo === undefined || !canonicalInfo.isDirectory) return undefined; + const globalDefaults = overrides.$default; + const functionOverride = overrides[slug]; + // `$default` cannot be a function slug (the slug schema only accepts letters, + // digits, `_`, and `-`) and therefore provides a collision-free global + // default for functions discovered after the stack started. A closed + // per-function override is spread last so it always wins. + const override = { + ...globalDefaults, + ...functionOverride, + }; + if (override.enabled === false) return undefined; const functionDirectory = join(canonicalRoot, slug); const directoryInfo = await optionalInfo(fs, functionDirectory); if (directoryInfo === undefined || !directoryInfo.isDirectory) return undefined; @@ -118,7 +135,7 @@ export const resolveFunctionConfig = async (options: { const rawEntrypoint = override?.entrypointPath && override.entrypointPath.length > 0 ? override.entrypointPath - : override?.entrypoint && override.entrypoint.length > 0 + : override.entrypoint && override.entrypoint.length > 0 ? override.entrypoint : "index.ts"; const entrypointPath = relativePath(functionDirectory, rawEntrypoint); @@ -127,8 +144,17 @@ export const resolveFunctionConfig = async (options: { if (entrypointInfo === undefined || !entrypointInfo.isFile || entrypointInfo.isSymbolicLink) return undefined; - const rawImportMap = override?.importMapPath ?? override?.import_map ?? ""; - let importMapPath = relativePath(functionDirectory, rawImportMap); + // Per-function import maps are relative to that function's directory. The + // reserved global default is explicitly root-relative, so one shared map is + // reused by every slug (including slugs created after serve starts). + const functionImportMap = functionOverride?.importMapPath ?? functionOverride?.import_map; + const globalImportMap = globalDefaults?.importMapRoot ?? globalDefaults?.import_map_root; + let importMapPath = + functionImportMap !== undefined + ? relativePath(functionDirectory, functionImportMap) + : globalImportMap !== undefined + ? relativePath(canonicalRoot, globalImportMap) + : relativePath(functionDirectory, ""); if (importMapPath.length > 0) { if (!(await safeRealPath(fs, canonicalRoot, importMapPath))) return undefined; const info = await optionalInfo(fs, importMapPath); @@ -146,7 +172,7 @@ export const resolveFunctionConfig = async (options: { } } - const staticFiles = (override?.staticFiles ?? override?.static_files ?? []).map((pattern) => + const staticFiles = (override.staticFiles ?? override.static_files ?? []).map((pattern) => relativePath(functionDirectory, pattern), ); for (const pattern of staticFiles) { @@ -172,8 +198,8 @@ export const resolveFunctionConfig = async (options: { entrypointPath, importMapPath, staticFiles, - verifyJWT: override?.verifyJWT ?? override?.verify_jwt ?? true, - env: override?.env, + verifyJWT: override.verifyJWT ?? override.verify_jwt ?? true, + env: override.env, }; }; @@ -188,9 +214,11 @@ export const packageJsonContainedFor = async (options: { }): Promise => { if (!options.root.startsWith("/")) return false; const rootInfo = await optionalInfo(options.fs, options.root); - if (rootInfo === undefined || !rootInfo.isDirectory || rootInfo.isSymbolicLink) return false; + if (rootInfo === undefined || (!rootInfo.isDirectory && !rootInfo.isSymbolicLink)) return false; const canonicalRoot = await options.fs.realPath(options.root).catch(() => ""); if (!canonicalRoot.startsWith("/")) return false; + const canonicalInfo = await optionalInfo(options.fs, canonicalRoot); + if (canonicalInfo === undefined || !canonicalInfo.isDirectory) return false; const packagePath = packageJsonPathFor(options.config); const packageInfo = await optionalInfo(options.fs, packagePath); if (packageInfo === undefined || !packageInfo.isFile || packageInfo.isSymbolicLink) return false; diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts index 27050cc79a..7cbf9208f9 100644 --- a/packages/stack/src/gateway/Gateway.ts +++ b/packages/stack/src/gateway/Gateway.ts @@ -1,6 +1,6 @@ -import { Data, Deferred, Effect, Exit, FiberSet, Semaphore } from "effect"; +import { Data, Effect, Exit } from "effect"; import { GatewayActivationError } from "../public/Errors.ts"; -import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import type { CapabilityName } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; import { makeHttpGateway, type HttpGateway, type HttpGatewayOptions } from "./HttpGateway.ts"; import { makeTcpGateway, type TcpGateway, type TcpGatewayOptions } from "./TcpGateway.ts"; @@ -16,10 +16,6 @@ export interface ActivationResult { readonly endpoint: BackendEndpoint; } -interface ActivationTarget { - readonly dependencies: ReadonlyArray; -} - /** Internal route preflight failure used for request-time discovery. */ export class GatewayRouteNotFoundError extends Data.TaggedError("GatewayRouteNotFoundError")<{ readonly message: string; @@ -35,118 +31,6 @@ export interface PreparedGatewayRoute { readonly upstreamHeaders?: GatewayHeaderTransform; } -export interface LazyActivator { - /** Activates a capability and all of its dependencies once for a generation. */ - readonly activate: ( - capability: CapabilityName, - ) => Effect.Effect; - readonly generation: number; -} - -export interface LazyActivatorOptions { - readonly generation: number; - readonly targets: Readonly>>; - readonly activate: ( - capability: CapabilityName, - ) => Effect.Effect; -} - -const activationError = (message: string): GatewayActivationError => - new GatewayActivationError({ message }); - -/** - * Build a Supervisor-local one-way activation coordinator. A per-capability - * semaphore joins concurrent requests to one dependency/start operation; - * successful activations are retained until the owner scope ends, while a - * failed operation can be retried under the Supervisor restart policy. - */ -export const createLazyActivator = ( - options: LazyActivatorOptions, -): Effect.Effect => - Effect.gen(function* () { - interface ActivationState { - result?: ActivationResult; - flight?: Deferred.Deferred; - readonly lock: Semaphore.Semaphore; - } - // Activation fibers belong to the Supervisor owner scope, not to an - // individual gateway request. This keeps a cancelled waiter from - // interrupting the shared start operation while still interrupting all - // starts when the owner scope closes. - const runFork = yield* FiberSet.makeRuntime(); - const states = new Map(); - const visiting = new Set(); - const visited = new Set(); - const visit = (capability: CapabilityName): boolean => { - if (visiting.has(capability)) return false; - if (visited.has(capability)) return true; - const target = options.targets[capability]; - if (target === undefined) return false; - visiting.add(capability); - const valid = target.dependencies.every(visit); - visiting.delete(capability); - if (!valid) return false; - visited.add(capability); - return true; - }; - for (const name of CAPABILITY_NAMES) { - if (options.targets[name] !== undefined && !visit(name)) - return yield* activationError("Capability dependency graph is invalid"); - } - const activate = ( - capability: CapabilityName, - ): Effect.Effect => { - const target = options.targets[capability]; - if (target === undefined) - return Effect.fail(activationError("Requested capability is not enabled")); - const state = states.get(capability); - if (state === undefined) - return Effect.fail(activationError("Requested capability is not enabled")); - const execute = Effect.forEach(target.dependencies, activate, { - concurrency: "unbounded", - }).pipe(Effect.andThen(options.activate(capability))); - return Effect.gen(function* () { - const decision = yield* state.lock.withPermit( - Effect.sync(() => { - if (state.result !== undefined) return { _tag: "done" as const, result: state.result }; - if (state.flight !== undefined) - return { _tag: "join" as const, deferred: state.flight }; - const deferred = Deferred.makeUnsafe(); - state.flight = deferred; - // This fiber is registered in the owner FiberSet and therefore is - // independent from the request fibers awaiting its Deferred. - runFork( - Effect.uninterruptibleMask((restore) => - Effect.exit(restore(execute)).pipe( - Effect.flatMap((exit) => - state.lock.withPermit( - Effect.sync(() => { - if (Exit.isSuccess(exit)) state.result = exit.value; - state.flight = undefined; - Deferred.doneUnsafe(deferred, exit); - }), - ), - ), - ), - ), - ); - return { _tag: "join" as const, deferred }; - }), - ); - return decision._tag === "done" - ? decision.result - : yield* Deferred.await(decision.deferred); - }); - }; - - // Install every cached effect before returning the service. This avoids a - // race where two callers can both create the first flight for a target. - for (const name of CAPABILITY_NAMES) - if (options.targets[name] !== undefined) states.set(name, { lock: yield* Semaphore.make(1) }); - - return { generation: options.generation, activate } satisfies LazyActivator; - }); - export interface GatewayRouteRequest { readonly path: string; readonly method?: string; @@ -221,7 +105,9 @@ interface TcpGatewayListenerOptions { export interface StackGatewayOptions { readonly http?: ReadonlyArray; readonly tcp?: ReadonlyArray; - readonly activate: LazyActivator["activate"]; + readonly activate: ( + capability: CapabilityName, + ) => Effect.Effect; } /** Compose the protocol gateways under one Supervisor-owned lifecycle scope. */ diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index 98137c77e4..479c624c1f 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -11,11 +11,7 @@ import { import { Socket } from "node:net"; import type { Duplex } from "node:stream"; import type { Fiber } from "effect/Fiber"; -import { - GatewayActivationError, - GatewayAuthenticationError, - GatewayStaleGenerationError, -} from "../public/Errors.ts"; +import { GatewayActivationError, GatewayAuthenticationError } from "../public/Errors.ts"; import type { ActivationResult, BackendEndpoint, @@ -24,7 +20,6 @@ import type { GatewayRoute, GatewayRouteRequest, GatewayHeaders, - LazyActivator, PreparedGatewayRoute, } from "./Gateway.ts"; import { GatewayRouteNotFoundError, isGatewayProxyRoute } from "./Gateway.ts"; @@ -39,7 +34,9 @@ export interface HttpGatewayOptions { readonly port?: number; readonly listener?: HostListener; readonly routes: ReadonlyArray; - readonly activate: LazyActivator["activate"]; + readonly activate: ( + capability: import("../public/Capability.ts").CapabilityName, + ) => Effect.Effect; readonly resolveBackend?: ( route: GatewayProxyRoute, request: GatewayRouteRequest, @@ -193,7 +190,9 @@ const resolveProxyRequest = ( path: preparedPath(route, prepared, view), headers: preparedHeaders(route, prepared, view, request), })), - Effect.mapError((cause) => new GatewayBackendError({ cause })), + Effect.mapError((cause) => + cause instanceof GatewayActivationError ? cause : new GatewayBackendError({ cause }), + ), ); }), ), @@ -286,13 +285,9 @@ const proxy = ( const mapFailure = (cause: Cause.Cause): number => { const error = Cause.findErrorOption(cause); if (Option.isSome(error) && error.value instanceof GatewayRouteNotFoundError) return 404; + if (Option.isSome(error) && error.value instanceof GatewayActivationError) return 503; if (Option.isSome(error) && error.value instanceof GatewayBackendError) return 502; - if ( - Option.isSome(error) && - (error.value instanceof GatewayAuthenticationError || - error.value instanceof GatewayStaleGenerationError) - ) - return 503; + if (Option.isSome(error) && error.value instanceof GatewayAuthenticationError) return 503; return 503; }; @@ -314,9 +309,9 @@ const handleRequest = ( response.end(); return; } - const health = options.healthPaths ?? ["/health", "/healthz", "/status"]; + const health = options.healthPaths; const pathname = view.path.split("?", 1)[0] ?? view.path; - if (health.some((path) => pathname === path)) { + if (health?.some((path) => pathname === path)) { response.statusCode = 200; response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ ok: true })); @@ -402,7 +397,7 @@ const handleUpgrade = ( const activation = resolveProxyRequest(request, view, route, options).pipe( Effect.flatMap(({ backend, path, headers }) => Effect.callback((resume) => { - const target = new Socket(); + const target = new Socket({ allowHalfOpen: true }); let settled = false; const fail = (cause: unknown) => { if (settled) return; diff --git a/packages/stack/src/gateway/TcpGateway.ts b/packages/stack/src/gateway/TcpGateway.ts index c0f3093e2d..c375ff42c5 100644 --- a/packages/stack/src/gateway/TcpGateway.ts +++ b/packages/stack/src/gateway/TcpGateway.ts @@ -8,7 +8,6 @@ import type { GatewayRoute, GatewayProxyRoute, GatewayRouteRequest, - LazyActivator, } from "./Gateway.ts"; import { isGatewayProxyRoute } from "./Gateway.ts"; import type { HostListener } from "../state/PortCoordinator.ts"; @@ -18,7 +17,9 @@ export interface TcpGatewayOptions { readonly port?: number; readonly listener?: HostListener; readonly routes: ReadonlyArray; - readonly activate: LazyActivator["activate"]; + readonly activate: ( + capability: import("../public/Capability.ts").CapabilityName, + ) => Effect.Effect; readonly resolveBackend?: ( route: GatewayProxyRoute, request: GatewayRouteRequest, @@ -41,7 +42,7 @@ const tunnel = ( backend: BackendEndpoint, ): Effect.Effect => Effect.callback((resume) => { - const target = new Socket(); + const target = new Socket({ allowHalfOpen: true }); let settled = false; let targetEnded = false; const onSourceError = (cause: Error) => { diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index ae4df80dea..28b1ff48d1 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, FileSystem, Fiber, Redacted } from "effect"; +import { Deferred, Effect, Exit, Fiber } from "effect"; import { GatewayActivationError } from "../public/Errors.ts"; import { connect as connectNet, createServer, type Server } from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import @@ -14,21 +14,14 @@ import { // oxlint-disable-next-line effecttsgo/node-builtin-import } from "node:http"; import { - createLazyActivator, GatewayRouteNotFoundError, makeGateway, type BackendEndpoint, + type GatewayRouteRequest, } from "./Gateway.ts"; import { makeHttpGateway } from "./HttpGateway.ts"; import { makeTcpGateway } from "./TcpGateway.ts"; import type { HostListener } from "../state/PortCoordinator.ts"; -import { FunctionSettingsDefaults } from "../model/capabilities/functions.ts"; -import { makeFunctionsRoot } from "../functions/FunctionsRoot.ts"; -import { - makeFunctionDiscovery, - makeFunctionsGatewayRoute, - rewriteFunctionRequestPath, -} from "../functions/FunctionDiscovery.ts"; const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -214,18 +207,6 @@ describe("stack gateway", () => { if (typeof address !== "object" || address === null) return; const backendEndpoint: BackendEndpoint = { host: "127.0.0.1", port: address.port }; const activated: string[] = []; - const activator = yield* createLazyActivator({ - generation: 1, - targets: { - rest: { dependencies: [] }, - database: { dependencies: [] }, - }, - activate: (capability) => - Effect.sync(() => { - activated.push(capability); - return { capability, endpoint: backendEndpoint }; - }), - }); const gateway = yield* makeHttpGateway({ address: "127.0.0.1", port: 0, @@ -237,7 +218,11 @@ describe("stack gateway", () => { }, { capability: "database", match: (request) => request.path.startsWith("/db") }, ], - activate: activator.activate, + activate: (capability) => + Effect.sync(() => { + activated.push(capability); + return { capability, endpoint: backendEndpoint }; + }), resolveBackend: () => Effect.succeed(backendEndpoint), cors: { "access-control-allow-origin": "https://example.test" }, }); @@ -688,16 +673,11 @@ describe("stack gateway", () => { const address = backend.address(); if (typeof address !== "object" || address === null) return; const endpoint: BackendEndpoint = { host: "127.0.0.1", port: address.port }; - const activator = yield* createLazyActivator({ - generation: 2, - targets: { database: { dependencies: [] } }, - activate: () => Effect.succeed({ capability: "database", endpoint }), - }); const gateway = yield* makeTcpGateway({ address: "127.0.0.1", port: 0, routes: [{ capability: "database", match: () => true }], - activate: activator.activate, + activate: () => Effect.succeed({ capability: "database", endpoint }), resolveBackend: () => Effect.succeed(endpoint), }); const largeFixture = Buffer.alloc(512 * 1024); @@ -728,11 +708,61 @@ describe("stack gateway", () => { ), ); + it.live("preserves a backend-initiated TCP half-close", () => + withPlatform( + Effect.gen(function* () { + let receivedAfterBackendFin = Buffer.alloc(0); + const backendReceived = yield* Deferred.make(); + const backend = createServer({ allowHalfOpen: true }, (socket) => { + socket.write("backend-fin"); + socket.end(); + socket.on("data", (chunk) => { + receivedAfterBackendFin = Buffer.concat([receivedAfterBackendFin, Buffer.from(chunk)]); + Deferred.doneUnsafe(backendReceived, Effect.void); + }); + }); + yield* Effect.callback((resume) => { + backend.once("error", (error) => resume(Effect.fail(error))); + backend.listen(0, "127.0.0.1", () => resume(Effect.void)); + }); + const address = backend.address(); + if (typeof address !== "object" || address === null) return; + const endpoint: BackendEndpoint = { host: "127.0.0.1", port: address.port }; + const gateway = yield* makeTcpGateway({ + address: "127.0.0.1", + port: 0, + routes: [{ capability: "database", match: () => true }], + activate: () => Effect.succeed({ capability: "database", endpoint }), + resolveBackend: () => Effect.succeed(endpoint), + }); + const response = yield* Effect.callback((resume) => { + const socket = connectNet({ port: gateway.port, host: "127.0.0.1", allowHalfOpen: true }); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => { + chunks.push(chunk); + if (Buffer.concat(chunks).includes("backend-fin")) socket.end("client-after-fin"); + }); + socket.once("end", () => resume(Effect.succeed(Buffer.concat(chunks)))); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }); + expect(response.toString()).toContain("backend-fin"); + yield* Deferred.await(backendReceived); + expect(receivedAfterBackendFin.toString()).toContain("client-after-fin"); + yield* gateway.close; + yield* closeServer(backend); + }), + ), + ); + it.live("keeps probes dormant and maps activation/backend failures", () => withPlatform( Effect.gen(function* () { let activations = 0; - const route = { capability: "rest" as const, match: () => true }; + const route = { + capability: "rest" as const, + match: (request: GatewayRouteRequest) => request.path === "/api", + }; const dormant = yield* makeHttpGateway({ address: "127.0.0.1", port: 0, @@ -742,7 +772,7 @@ describe("stack gateway", () => { return Effect.fail(new GatewayActivationError({ message: "should not run" })); }, }); - expect(yield* getStatus(dormant.port, "/health")).toBe(200); + expect(yield* getStatus(dormant.port, "/health")).toBe(404); expect(activations).toBe(0); yield* dormant.close; @@ -764,7 +794,7 @@ describe("stack gateway", () => { resolveBackend: () => Effect.fail(new GatewayActivationError({ message: "Gateway backend failed" })), }); - expect(yield* getStatus(backendFailure.port, "/api")).toBe(502); + expect(yield* getStatus(backendFailure.port, "/api")).toBe(503); yield* backendFailure.close; const tcpFailure = yield* makeTcpGateway({ @@ -786,175 +816,4 @@ describe("stack gateway", () => { }), ), ); - - it.live("preflights Functions routes before activation and maps discovery failures", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-functions-gateway-" }); - const functionRoot = `${root}/functions`; - yield* fs.makeDirectory(`${functionRoot}/rest`, { recursive: true }); - yield* fs.writeFileString(`${functionRoot}/rest/index.ts`, "export default 1"); - const functionsRoot = yield* makeFunctionsRoot({ root: functionRoot }); - let activations = 0; - const disabledDiscovery = yield* makeFunctionDiscovery({ - root: functionsRoot, - settings: { rest: { ...FunctionSettingsDefaults, enabled: false } }, - }); - const disabled = yield* makeHttpGateway({ - address: "127.0.0.1", - port: 0, - routes: [ - makeFunctionsGatewayRoute(disabledDiscovery, { - dispatch: (_invocation, activation) => Effect.succeed(activation.endpoint), - }), - ], - activate: () => { - activations += 1; - return Effect.succeed({ - capability: "functions", - endpoint: { host: "127.0.0.1", port: 1 }, - }); - }, - }); - expect(yield* getStatus(disabled.port, "/functions/v1/rest")).toBe(404); - expect(yield* getStatus(disabled.port, "/functions/v1/missing")).toBe(404); - expect(activations).toBe(0); - yield* disabled.close; - - const pathDiscovery = yield* makeFunctionDiscovery({ - root: functionsRoot, - settings: { rest: { ...FunctionSettingsDefaults, entrypoint: "../outside.ts" } }, - }); - const invalid = yield* makeHttpGateway({ - address: "127.0.0.1", - port: 0, - routes: [ - makeFunctionsGatewayRoute(pathDiscovery, { - dispatch: (_invocation, activation) => Effect.succeed(activation.endpoint), - }), - ], - activate: () => { - activations += 1; - return Effect.succeed({ - capability: "functions", - endpoint: { host: "127.0.0.1", port: 1 }, - }); - }, - }); - expect(yield* getStatus(invalid.port, "/functions/v1/rest")).toBe(503); - expect(activations).toBe(0); - yield* invalid.close; - - const backendPaths: string[] = []; - const backend = createHttpServer((request, response) => { - backendPaths.push(request.url ?? ""); - response.end("function-response"); - }); - backend.on("upgrade", (request, socket) => { - backendPaths.push(request.url ?? ""); - socket.end("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\nws-response"); - }); - yield* Effect.callback((resume) => { - backend.once("error", (error) => resume(Effect.fail(error))); - backend.listen(0, "127.0.0.1", () => resume(Effect.void)); - }); - const backendAddress = backend.address(); - if (typeof backendAddress !== "object" || backendAddress === null) return; - const endpoint: BackendEndpoint = { host: "127.0.0.1", port: backendAddress.port }; - yield* fs.makeDirectory(`${functionRoot}/rest/public`, { recursive: true }); - yield* fs.writeFileString(`${functionRoot}/rest/custom.ts`, "export default 1"); - yield* fs.writeFileString(`${functionRoot}/rest/deno.json`, "{}"); - yield* fs.writeFileString(`${functionRoot}/rest/public/file.txt`, "file"); - const dispatched: Array<{ - readonly slug: string; - readonly entrypoint: string; - readonly verifyJwt: boolean; - readonly importMap?: string; - readonly staticPattern?: string; - readonly redacted: boolean; - }> = []; - const runtimeDiscovery = yield* makeFunctionDiscovery({ - root: functionsRoot, - settings: { - rest: { - ...FunctionSettingsDefaults, - verify_jwt: false, - entrypoint: "custom.ts", - import_map: "deno.json", - static_files: ["public/*.txt"], - env: { TOKEN: Redacted.make("opaque-secret") }, - }, - }, - }); - const runtimeGateway = yield* makeHttpGateway({ - address: "127.0.0.1", - port: 0, - routes: [ - makeFunctionsGatewayRoute(runtimeDiscovery, { - dispatch: (invocation) => - Effect.sync(() => { - dispatched.push({ - slug: invocation.slug, - entrypoint: invocation.entrypoint.native, - verifyJwt: invocation.verifyJwt, - ...(invocation.importMap === undefined - ? {} - : { importMap: invocation.importMap.native }), - ...(invocation.staticPatterns[0] === undefined - ? {} - : { staticPattern: invocation.staticPatterns[0].native }), - redacted: Redacted.isRedacted(invocation.env.TOKEN), - }); - return endpoint; - }), - }), - ], - activate: () => Effect.succeed({ capability: "functions", endpoint }), - }); - const body = yield* Effect.callback((resume) => { - const request = requestHttp( - { host: "127.0.0.1", port: runtimeGateway.port, path: "/functions/v1/rest" }, - (response) => { - const chunks: Buffer[] = []; - response.on("data", (chunk: Buffer) => chunks.push(chunk)); - response.once("end", () => resume(Effect.succeed(Buffer.concat(chunks).toString()))); - }, - ); - request.once("error", (error) => resume(Effect.fail(error))); - request.end(); - return Effect.sync(() => request.destroy()); - }); - expect(body).toBe("function-response"); - const websocket = yield* Effect.callback((resume) => { - const socket = connectNet(runtimeGateway.port, "127.0.0.1"); - const chunks: Buffer[] = []; - socket.once("connect", () => - socket.write( - "GET /functions/v1/rest HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", - ), - ); - socket.on("data", (chunk: Buffer) => chunks.push(chunk)); - socket.once("end", () => resume(Effect.succeed(Buffer.concat(chunks).toString()))); - socket.once("error", (error) => resume(Effect.fail(error))); - return Effect.sync(() => socket.destroy()); - }); - expect(websocket).toContain("101 Switching Protocols"); - expect(websocket).toContain("ws-response"); - expect(dispatched).toHaveLength(2); - expect(dispatched[0]?.slug).toBe("rest"); - expect(dispatched[0]?.entrypoint).toContain("/rest/custom.ts"); - expect(dispatched[0]?.verifyJwt).toBe(false); - expect(dispatched[0]?.importMap).toContain("/rest/deno.json"); - expect(dispatched[0]?.staticPattern).toContain("/rest/public/*.txt"); - expect(dispatched[0]?.redacted).toBe(true); - expect(backendPaths).toEqual(["/rest", "/rest"]); - expect(rewriteFunctionRequestPath("/functions/v1/rest/items?limit=1")).toBe( - "/rest/items?limit=1", - ); - yield* runtimeGateway.close; - yield* closeServer(backend); - }), - ), - ); }); diff --git a/packages/stack/src/identity/identity.integration.test.ts b/packages/stack/src/identity/identity.integration.test.ts index 1650b821b8..3a153128e3 100644 --- a/packages/stack/src/identity/identity.integration.test.ts +++ b/packages/stack/src/identity/identity.integration.test.ts @@ -277,9 +277,6 @@ describe("deterministic stack identity and state paths", () => { expect(paths.logs).toBe(path.join(stateRoot, id, "logs")); expect(paths.runtime).toBe(path.join(stateRoot, id, "runtime")); expect(paths.controlMetadata).toBe(path.join(stateRoot, id, "control.json")); - expect(paths.temporaryDirectory).toBe(path.join(stateRoot, id, ".tmp")); - expect(paths.temporarySibling).toBe(path.join(stateRoot, id, "state.json.tmp")); - expect(path.dirname(paths.temporarySibling)).toBe(paths.stackRoot); for (const value of Object.values(paths)) { const relative = path.relative(paths.stackRoot, value); expect( diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 5f0b507075..3f6a7efb5a 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1,4 +1,21 @@ -export * from "./public/PromiseStack.ts"; +export { + createStack, + openStack, + findStack, + listStacks, + inspectStack, +} from "./public/PromiseStack.ts"; +export type { + PromiseStack, + PromiseStackApi, + PromiseStackConfig, + PromiseStartStackOptions, + PromisePrepareStackOptions, + CreateStackOptions, + FindStackOptions, + ListStacksOptions, + PreparedCapability, +} from "./public/PromiseStack.ts"; export type { CapabilityName, CapabilityStatus, diff --git a/packages/stack/src/internal/supervisor-process.ts b/packages/stack/src/internal/supervisor-process.ts new file mode 100644 index 0000000000..0e35523a81 --- /dev/null +++ b/packages/stack/src/internal/supervisor-process.ts @@ -0,0 +1,12 @@ +import { SUPERVISOR_DISPATCH_SENTINEL } from "../supervisor/Launcher.ts"; +import { runSupervisorProcess } from "../entrypoints/supervisor-node.ts"; + +/** + * Supported process-entrypoint seam for embedders such as the CLI binary. + * Returns true when the argv is the supervisor child dispatch, otherwise the + * caller should continue with its normal command entrypoint. + */ +export const runSupervisorProcessIfDispatched = (argv: ReadonlyArray): Promise => { + if (argv[0] !== SUPERVISOR_DISPATCH_SENTINEL) return Promise.resolve(false); + return runSupervisorProcess(argv.slice(1)).then(() => true); +}; diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index 75e101a0bc..6d5979a9be 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -1,6 +1,7 @@ import type { Redacted, Schema } from "effect"; import type { CapabilityName, ActivationMode } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; +import { catalogEntryFor } from "./WorkloadCatalog.ts"; /** Logical artifact descriptors. Download/image resolution belongs to preparation. */ export interface NativeArtifact { @@ -21,6 +22,11 @@ export interface WorkloadSpec { readonly dependencies: ReadonlyArray; readonly readiness: Readonly<{ readonly mode: "http" | "tcp"; readonly portField?: PortField }>; readonly restart: Readonly<{ readonly maxAttempts: number; readonly backoffMs: number }>; + /** Exact artifacts selected for this workload release. */ + readonly artifacts: Readonly<{ + readonly native: NativeArtifact; + readonly container: ContainerArtifact; + }>; } /** Settings shape persisted by the compiler, where absent and secret leaves are materialized. */ @@ -83,11 +89,21 @@ export const workload = ( readonly readiness?: WorkloadSpec["readiness"]; readonly restart?: WorkloadSpec["restart"]; } = {}, -): WorkloadSpec => ({ - name, - capability, - ...(options.bootstrap === undefined ? {} : { bootstrap: options.bootstrap }), - dependencies: options.dependencies ?? [], - readiness: options.readiness ?? { mode: "tcp" }, - restart: options.restart ?? { maxAttempts: 5, backoffMs: 250 }, -}); +): WorkloadSpec => { + const catalog = catalogEntryFor(`${capability}:${name}`); + if (catalog === undefined) + throw new Error(`Missing workload artifact declaration: ${capability}:${name}`); + const artifacts = { + native: { kind: "native" as const, release: catalog.nativeVersion }, + container: { kind: "container" as const, image: catalog.containerImage }, + }; + return { + name, + capability, + ...(options.bootstrap === undefined ? {} : { bootstrap: options.bootstrap }), + dependencies: options.dependencies ?? [], + readiness: options.readiness ?? { mode: "tcp" }, + restart: options.restart ?? { maxAttempts: 5, backoffMs: 250 }, + artifacts, + }; +}; diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index e8b6669a3e..f543062cc7 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -15,7 +15,7 @@ import type { JwtSigning, } from "../public/Config.ts"; import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; -import { PORT_FIELDS, type PortField } from "../public/Status.ts"; +import type { PortField } from "../public/Status.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { AuthModule, @@ -96,7 +96,6 @@ export interface SecretSlotInput { } const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; -const SECURITY_JWT_SIGNING_SECRET_SLOT = "secret:security.jwt.signing.secret"; /** Internal credentials are not user settings but still need durable managed slots. */ const INTERNAL_MANAGED_SECRET_SLOTS = ["secret:database.internal.password"] as const; @@ -110,7 +109,7 @@ export interface CompiledStack { export interface PreviousCompilation { readonly definition: StackDefinition; - readonly inputFingerprint: InputFingerprint; + readonly inputFingerprint: string; } export interface CompileStackInput { @@ -212,8 +211,6 @@ const ensureCanonicalJwtSlot = ( slots: SecretSlotInput[], value: Redacted.Redacted | undefined, ): void => { - for (let index = slots.length - 1; index >= 0; index--) - if (slots[index]?.slot === SECURITY_JWT_SIGNING_SECRET_SLOT) slots.splice(index, 1); const existingIndex = slots.findIndex((entry) => entry.slot === AUTH_JWT_SECRET_SLOT); if (existingIndex < 0) { slots.push({ @@ -264,6 +261,10 @@ const managedRandomBase64urlGenerators: Readonly "secret:analytics.settings.api_key": { kind: "random-base64url", bytes: 32 }, "secret:pooler.settings.encryption_key": { kind: "random-base64url", bytes: 24 }, "secret:pooler.settings.secret_key_base": { kind: "random-base64url", bytes: 48 }, + // Realtime encrypts with AES-128-ECB and consumes this value as a raw 16-byte key. + // Twelve random bytes encode to exactly sixteen unpadded base64url characters. + "secret:realtime.settings.db_enc_key": { kind: "random-base64url", bytes: 12 }, + "secret:realtime.settings.secret_key_base": { kind: "random-base64url", bytes: 48 }, }; const attachManagedRandomSecretGenerators = (slots: SecretSlotInput[]): void => { @@ -477,9 +478,11 @@ const validateFunctionKeys = (config: unknown): Effect.Effect( module: CapabilityModule, raw: unknown, + previousVersion?: string, ): Effect.Effect => { const selected = extract(raw, "version"); - const selector = typeof selected === "string" ? selected : module.defaultVersion; + const selector = + typeof selected === "string" ? selected : (previousVersion ?? module.defaultVersion); const release = module.releases[selector]; if (release !== undefined) return Effect.succeed(release); return Effect.fail( @@ -535,6 +538,7 @@ const materializeCapability = ( slots: SecretSlotInput[], specHashes: Map, normalizeFunctions: boolean, + previousVersion?: string, ): Effect.Effect< MaterializedCapability & { readonly release: CapabilityRelease }, InvalidStackConfigError | StackVersionUnsupportedError, @@ -551,7 +555,7 @@ const materializeCapability = ( const merged = materializeAbsence(normalizedSettings); const slotted = slotsFor(merged, `${module.name}.settings`, slots, module.secretPolicy); const completeSettings = ensureManagedSlots(slotted, module, selected.enabled, slots); - const selectedRelease = yield* releaseFor(module, selected.raw); + const selectedRelease = yield* releaseFor(module, selected.raw, previousVersion); const version = selectedRelease.version; for (const entry of selectedRelease.workloads) { const bytes = yield* crypto.digest( @@ -695,7 +699,7 @@ const planForDefinition = ( const specHashes = new Map(); return hashDefinitionWorkloads(definition, crypto, specHashes).pipe( Effect.flatMap(() => - createExecutionPlan(runtime, enabled, specHashes, versions, CAPABILITY_MODULES, settings), + createExecutionPlan(runtime, enabled, specHashes, versions, settings, CAPABILITY_MODULES), ), ); }; @@ -816,6 +820,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.database.version, ); const restResult = yield* materializeCapability( RestModule, @@ -826,6 +831,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.rest.version, ); const authResult = yield* materializeCapability( AuthModule, @@ -836,6 +842,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.auth.version, ); const thirdParty = resolveThirdPartyIssuer(authResult.settings); if (!thirdParty.ok) @@ -852,6 +859,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.realtime.version, ); const storageResult = yield* materializeCapability( StorageModule, @@ -862,6 +870,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.storage.version, ); const functionsResult = yield* materializeCapability( FunctionsModule, @@ -872,6 +881,7 @@ export const compileStack = ( slots, specHashes, true, + previous?.definition.capabilities.functions.version, ); const studioResult = yield* materializeCapability( StudioModule, @@ -882,6 +892,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.studio.version, ); const mailResult = yield* materializeCapability( MailModule, @@ -892,6 +903,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.mail.version, ); const analyticsResult = yield* materializeCapability( AnalyticsModule, @@ -902,6 +914,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.analytics.version, ); const poolerResult = yield* materializeCapability( PoolerModule, @@ -912,6 +925,7 @@ export const compileStack = ( slots, specHashes, false, + previous?.definition.capabilities.pooler.version, ); const database = withoutRelease(databaseResult); const rest = withoutRelease(restResult); @@ -949,14 +963,14 @@ export const compileStack = ( }; const rawListeners = isRecord(config.listeners) ? config.listeners : {}; const listeners = { - [PORT_FIELDS[0]]: materializeListener(rawListeners[PORT_FIELDS[0]], true), - [PORT_FIELDS[1]]: materializeListener(rawListeners[PORT_FIELDS[1]], true), - [PORT_FIELDS[2]]: materializeListener(rawListeners[PORT_FIELDS[2]], false), - [PORT_FIELDS[3]]: materializeListener(rawListeners[PORT_FIELDS[3]], true), - [PORT_FIELDS[4]]: materializeListener(rawListeners[PORT_FIELDS[4]], true), - [PORT_FIELDS[5]]: materializeListener(rawListeners[PORT_FIELDS[5]], false), - [PORT_FIELDS[6]]: materializeListener(rawListeners[PORT_FIELDS[6]], false), - [PORT_FIELDS[7]]: materializeListener(rawListeners[PORT_FIELDS[7]], false), + api: materializeListener(rawListeners.api, true), + database: materializeListener(rawListeners.database, true), + pooler: materializeListener(rawListeners.pooler, true), + studio: materializeListener(rawListeners.studio, true), + mailUi: materializeListener(rawListeners.mailUi, true), + smtp: materializeListener(rawListeners.smtp, false), + pop3: materializeListener(rawListeners.pop3, false), + functionsInspector: materializeListener(rawListeners.functionsInspector, false), } satisfies Record; const rawJwt = config.security?.jwt; ensureCanonicalJwtSlot(slots, jwtSecret); @@ -993,7 +1007,6 @@ export const compileStack = ( enabled, specHashes, versions, - CAPABILITY_MODULES, { database: database.settings, rest: rest.settings, @@ -1006,6 +1019,7 @@ export const compileStack = ( analytics: analytics.settings, pooler: pooler.settings, }, + CAPABILITY_MODULES, ); return { definition, inputFingerprint, secrets: slots, executionPlan }; }).pipe( diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index 112b5c814e..6733ae5b3c 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -105,13 +105,11 @@ interface DatabaseBootstrapRevision { const TRACKING_SCHEMA = "supabase_internal"; const TRACKING_TABLE = `${TRACKING_SCHEMA}.bootstrap_revisions`; -const TRACKING_TABLE_STATEMENT = ` - CREATE SCHEMA IF NOT EXISTS ${TRACKING_SCHEMA}; - CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} ( +const TRACKING_SCHEMA_STATEMENT = `CREATE SCHEMA IF NOT EXISTS ${TRACKING_SCHEMA};`; +const TRACKING_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} ( revision text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now() - ); -`; + );`; const APPLIED_REVISIONS_STATEMENT = ` SELECT revision FROM ${TRACKING_TABLE} ORDER BY revision; `; @@ -152,6 +150,9 @@ export const runDatabaseBootstrap = ( yield* transaction .execute(ADVISORY_LOCK_STATEMENT) .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); + yield* transaction + .execute(TRACKING_SCHEMA_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, TRACKING_SCHEMA_STATEMENT))); yield* transaction .execute(TRACKING_TABLE_STATEMENT) .pipe(Effect.mapError((error) => statementError(error, TRACKING_TABLE_STATEMENT))); diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index 7424228b5b..9bfecc67cb 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -19,7 +19,6 @@ import type { ContainerArtifact, MaterializedSettings, } from "./CapabilityModule.ts"; -import { WORKLOAD_CATALOG } from "./WorkloadCatalog.ts"; import { Effect } from "effect"; import { InvalidStackConfigError } from "../public/Errors.ts"; import type { AnalyticsSettings } from "./capabilities/analytics.ts"; @@ -66,7 +65,6 @@ export interface ExecutionPlan { /** Materialized lazy/eager policy consumed by the Supervisor gateway seam. */ readonly activation: Readonly<{ [Name in CapabilityName]: EnabledCapability["activation"] }>; readonly startOrder: ReadonlyArray; - readonly stopOrder: ReadonlyArray; readonly dependencies: Readonly<{ [Name in CapabilityName]: ReadonlyArray }>; readonly routes: ReadonlyArray< Readonly<{ @@ -78,6 +76,26 @@ export interface ExecutionPlan { readonly workloads: ReadonlyArray; } +export const eagerCapabilities = (plan: ExecutionPlan): Set => { + const active = new Set(); + const visit = (name: CapabilityName): void => { + if (active.has(name)) return; + active.add(name); + for (const dependency of plan.dependencies[name]) visit(dependency); + }; + for (const name of CAPABILITY_NAMES) if (plan.activation[name] === "eager") visit(name); + return active; +}; + +export const activeExecutionPlan = ( + plan: ExecutionPlan, + active: ReadonlySet, +): ExecutionPlan => ({ + ...plan, + workloads: plan.workloads.filter((workload) => active.has(workload.capability)), + startOrder: plan.startOrder.filter((name) => active.has(name)), +}); + export interface EnabledCapability { readonly enabled: boolean; readonly activation: "eager" | "lazy"; @@ -131,8 +149,8 @@ export const createExecutionPlan = ( enabled: Readonly<{ [Name in CapabilityName]: EnabledCapability }>, specHashes: ReadonlyMap, versions: Readonly<{ [Name in CapabilityName]: string }>, - modules: typeof CAPABILITY_MODULES = CAPABILITY_MODULES, settings: PlanSettings, + modules: typeof CAPABILITY_MODULES = CAPABILITY_MODULES, ): Effect.Effect => { const dependencyMap = { database: modules.database.dependencies, @@ -191,7 +209,6 @@ export const createExecutionPlan = ( }; for (const name of CAPABILITY_NAMES) visit(name); if (capabilityGraphError !== undefined) return Effect.fail(capabilityGraphError); - const stopOrder = [...start].reverse(); const routes = CAPABILITY_NAMES.flatMap((name) => enabled[name].enabled ? modules[name].routes.map((route) => ({ capability: name, ...route })) @@ -212,19 +229,6 @@ export const createExecutionPlan = ( const selectedEntries = selectedWorkloads(name, modules, settings, release.workloads); for (const entry of selectedEntries) { const id = `${name}:${entry.name}`; - const catalog = WORKLOAD_CATALOG[id]; - if (catalog === undefined) - return Effect.fail( - new InvalidStackConfigError({ - message: `Missing workload catalog entry ${id}`, - capability: name, - workload: id, - }), - ); - const artifacts: PlannedWorkload["artifacts"] = { - native: { kind: "native", release: catalog.nativeVersion }, - container: { kind: "container", image: catalog.containerImage }, - }; const specHash = specHashes.get(id); if (specHash === undefined || specHash.length === 0) return Effect.fail( @@ -241,8 +245,8 @@ export const createExecutionPlan = ( dependencies: entry.dependencies, readiness: entry.readiness, restart: entry.restart, - artifacts, - selected: runtime.kind === "native" ? artifacts.native : artifacts.container, + artifacts: entry.artifacts, + selected: runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, specHash, }); } @@ -293,7 +297,6 @@ export const createExecutionPlan = ( runtime, activation, startOrder: start, - stopOrder, dependencies: dependencyMap, routes, workloads: workloadOrder, diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 47a1aea6af..1be69a1473 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -216,7 +216,7 @@ export const targetForPlatform = (platform: { return undefined; }; -/** Returns the catalog entry for an open workload identity. */ +/** Returns the catalog entry for a workload. */ export const catalogEntryFor = (workloadId: string): WorkloadCatalogEntry | undefined => WORKLOAD_CATALOG[workloadId]; diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index 9d96c05c03..41d9fcb754 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -40,7 +40,7 @@ export const AnalyticsModule: CapabilityModule = { ]), }, routes: [{ listener: "api", protocol: "http" }], - secretPolicy: () => "passthrough", + secretPolicy: () => "managed", managedSecretSlots: ["analytics.settings.api_key"], selectWorkloads: (settings, workloads) => { const enabled = typeof settings.vector_port === "number"; diff --git a/packages/stack/src/model/capabilities/auth.ts b/packages/stack/src/model/capabilities/auth.ts index 6516ce7dff..454ff04418 100644 --- a/packages/stack/src/model/capabilities/auth.ts +++ b/packages/stack/src/model/capabilities/auth.ts @@ -138,10 +138,10 @@ const EmailSchema = Schema.Struct({ notification: Schema.optionalKey(Schema.Record(Schema.String, EmailNotificationSchema)), }); -const SmsAuthProvider = Schema.Struct({ enabled: OptionalBoolean }); +const EnabledFlag = Schema.Struct({ enabled: OptionalBoolean }); const Web3Schema = Schema.Struct({ - solana: Schema.optionalKey(SmsAuthProvider), - ethereum: Schema.optionalKey(SmsAuthProvider), + solana: Schema.optionalKey(EnabledFlag), + ethereum: Schema.optionalKey(EnabledFlag), }); const CaptchaSchema = Schema.Struct({ enabled: OptionalBoolean, diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index 718d17d6b1..3187990259 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -1,8 +1,7 @@ -import { Redacted, Schema } from "effect"; +import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; const Secret = Schema.Redacted(Schema.String); -export type FunctionSecret = Redacted.Redacted; const FunctionSlug = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)); const EnvName = Schema.String.check(Schema.isPattern(/^[A-Z_][A-Z0-9_]*$/)); const FunctionOverrideSchema = Schema.Struct({ @@ -14,29 +13,12 @@ const FunctionOverrideSchema = Schema.Struct({ env: Schema.optionalKey(Schema.Record(EnvName, Secret)), }); -/** Fully materialized per-function settings consumed by request-time discovery. */ -export interface FunctionSettings { - readonly enabled: boolean; - readonly verify_jwt: boolean; - readonly import_map: string; - readonly entrypoint: string; - readonly static_files: ReadonlyArray; - readonly env: Readonly>; -} -export type MaterializedFunctionSettings = Readonly>; - -export const FunctionSettingsDefaults: FunctionSettings = { - enabled: true, - verify_jwt: true, - import_map: "", - entrypoint: "", - static_files: [], - env: {}, -}; - const EdgeRuntimeSettingsSchema = Schema.Struct({ policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"] as const)), deno_version: Schema.optionalKey(Schema.Finite), + /** Defaults applied to functions discovered after the stack starts. */ + verify_jwt_default: Schema.optionalKey(Schema.Boolean), + import_map_default: Schema.optionalKey(Schema.String), secrets: Schema.optionalKey(Schema.Record(Schema.String, Secret)), }); @@ -67,6 +49,7 @@ export const FunctionsModule: CapabilityModule = { deno_version: 2, secrets: {}, }, + inspector: undefined, functions: {}, }, defaultEnabled: true, @@ -92,7 +75,14 @@ export const FunctionsModule: CapabilityModule = { functions: Object.fromEntries( Object.entries(settings.functions ?? {}).map(([name, fn]) => [ name, - { ...FunctionSettingsDefaults, ...fn, env: fn.env ?? {} }, + { + enabled: fn.enabled ?? true, + verify_jwt: fn.verify_jwt ?? true, + import_map: fn.import_map ?? "", + entrypoint: fn.entrypoint ?? "", + static_files: fn.static_files ?? [], + env: fn.env ?? {}, + }, ]), ), }), diff --git a/packages/stack/src/model/capabilities/mail.ts b/packages/stack/src/model/capabilities/mail.ts index d87a3c72bf..8024a33325 100644 --- a/packages/stack/src/model/capabilities/mail.ts +++ b/packages/stack/src/model/capabilities/mail.ts @@ -11,7 +11,7 @@ export const MailModule: CapabilityModule = { settings: MailSettingsSchema, defaultSettings: { admin_email: "admin@email.com", sender_name: "Admin" }, defaultEnabled: true, - defaultActivation: "eager", + defaultActivation: "lazy", defaultVersion: "v1.30.2", dependencies: [], releases: { diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts index 365be03bba..93f3ece4ff 100644 --- a/packages/stack/src/model/capabilities/pooler.ts +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -22,8 +22,8 @@ export const PoolerModule: CapabilityModule = { default_pool_size: 20, max_client_conn: 100, }, - defaultEnabled: false, - defaultActivation: "eager", + defaultEnabled: true, + defaultActivation: "lazy", defaultVersion: "v2.9.12", dependencies: ["database"], releases: { diff --git a/packages/stack/src/model/capabilities/realtime.ts b/packages/stack/src/model/capabilities/realtime.ts index 67e06c2eca..24c51215f7 100644 --- a/packages/stack/src/model/capabilities/realtime.ts +++ b/packages/stack/src/model/capabilities/realtime.ts @@ -1,18 +1,27 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +const Secret = Schema.Redacted(Schema.String); + export const RealtimeSettingsSchema = Schema.Struct({ ip_version: Schema.optionalKey(Schema.Literals(["IPv4", "IPv6"] as const)), max_header_length: Schema.optionalKey(Schema.Finite), + db_enc_key: Schema.optionalKey(Secret), + secret_key_base: Schema.optionalKey(Secret), }); export type RealtimeSettings = Schema.Schema.Type; export const RealtimeModule: CapabilityModule = { name: "realtime", settings: RealtimeSettingsSchema, - defaultSettings: { ip_version: "IPv4", max_header_length: 4096 }, + defaultSettings: { + ip_version: "IPv4", + max_header_length: 4096, + db_enc_key: undefined, + secret_key_base: undefined, + }, defaultEnabled: true, - defaultActivation: "eager", + defaultActivation: "lazy", defaultVersion: "v2.130.0", dependencies: ["database"], releases: { @@ -24,6 +33,9 @@ export const RealtimeModule: CapabilityModule = { ]), }, routes: [{ listener: "api", protocol: "http" }], - secretPolicy: () => "passthrough", - managedSecretSlots: [], + secretPolicy: (path) => + path === "realtime.settings.db_enc_key" || path === "realtime.settings.secret_key_base" + ? "managed" + : "passthrough", + managedSecretSlots: ["realtime.settings.db_enc_key", "realtime.settings.secret_key_base"], }; diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts index b759e59fe0..e8a20d91e2 100644 --- a/packages/stack/src/model/capabilities/studio.ts +++ b/packages/stack/src/model/capabilities/studio.ts @@ -10,9 +10,9 @@ export type StudioSettings = Schema.Schema.Type; export const StudioModule: CapabilityModule = { name: "studio", settings: StudioSettingsSchema, - defaultSettings: { api_url: "http://127.0.0.1", openai_api_key: undefined }, + defaultSettings: { api_url: "", openai_api_key: undefined }, defaultEnabled: true, - defaultActivation: "eager", + defaultActivation: "lazy", defaultVersion: "2026.08.24-sha-8ec45b2", dependencies: ["rest", "analytics"], releases: { diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index acb4f6148d..e293b47057 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, Option } from "effect"; import { compileStack } from "./Compiler.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { CAPABILITY_MODULES } from "./ExecutionPlan.ts"; +import { workload } from "./CapabilityModule.ts"; import { resolveNativeArtifactForWorkload, targetForPlatform, @@ -24,6 +25,14 @@ const compileContainer = (config: Parameters[0]["config"]) }).pipe(Effect.provide(NodeServices.layer)); describe("complete workload catalog", () => { + it("uses the catalog release for both runtime artifact identities", () => { + const selected = workload("rest", "rest"); + expect(selected.artifacts).toEqual({ + native: { kind: "native", release: WORKLOAD_CATALOG["rest:rest"]?.nativeVersion }, + container: { kind: "container", image: WORKLOAD_CATALOG["rest:rest"]?.containerImage }, + }); + }); + it("matches the executable paths shipped by slim-services archives", () => { const expected = { "database:database": "share/supabase-cli/bin/supabase-postgres-init.sh", diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 5aae2696cf..907c0eeecc 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -57,6 +57,32 @@ describe("closed capability compiler", () => { }), ); + it.live("defaults only the database capability to eager activation", () => + Effect.gen(function* () { + const result = yield* compile({}); + expect(result.executionPlan.activation).toEqual({ + database: "eager", + rest: "lazy", + auth: "lazy", + realtime: "lazy", + storage: "lazy", + functions: "lazy", + studio: "lazy", + mail: "lazy", + analytics: "lazy", + pooler: "lazy", + }); + expect(result.definition.capabilities.pooler).toMatchObject({ + enabled: true, + activation: "lazy", + }); + expect(result.definition.listeners.pooler.enabled).toBe(true); + const disabledPooler = yield* compile({ capabilities: { pooler: { enabled: false } } }); + expect(disabledPooler.definition.capabilities.pooler.enabled).toBe(false); + expect(disabledPooler.definition.listeners.pooler.enabled).toBe(true); + }), + ); + it.live("rejects an invalid database health timeout during compilation", () => Effect.gen(function* () { const result = yield* compile({ @@ -405,15 +431,16 @@ describe("closed capability compiler", () => { }), ); - it.live("preserves a supplied Analytics access key as pass-through", () => + it.live("manages a supplied Analytics access key", () => Effect.gen(function* () { const result = yield* compile({ capabilities: { analytics: { settings: { api_key: Redacted.make("custom-api-key") } } }, }); expect(result.secrets).toContainEqual({ slot: "secret:analytics.settings.api_key", - policy: "passthrough", + policy: "managed", value: Redacted.make("custom-api-key"), + generator: { kind: "random-base64url", bytes: 32 }, }); expect(result.definition.capabilities.analytics.settings.api_key).toEqual({ slot: "secret:analytics.settings.api_key", diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 1f39f83446..3c640f0bbf 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -12,7 +12,7 @@ import { } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; -import { validateRelativePath, validateSha256, verifySha256 } from "./Integrity.ts"; +import { digestHex, validateRelativePath, validateSha256, verifySha256 } from "./Integrity.ts"; /** * A concrete artifact identity. `key` is deliberately private to preparation and may contain @@ -67,8 +67,7 @@ export interface ArtifactStore { ) => Effect.Effect; } -const ARTIFACT_FORMAT = "supabase-stack-artifact-v1"; -const ARCHIVE_NAME = ".artifact-source"; +const ARTIFACT_FORMAT = "supabase-stack-artifact-v2"; const METADATA_NAME = ".artifact.json"; const EXECUTABLE_MODE = 0o755; @@ -83,6 +82,7 @@ const ArtifactMetadataSchema = Schema.Struct({ key: Schema.String, sha256: Schema.String, requiredRuntimePaths: Schema.Array(Schema.String), + requiredRuntimeDigests: Schema.Record(Schema.String, Schema.String), executablePath: Schema.optional(Schema.String), }); type ArtifactMetadata = Schema.Schema.Type; @@ -147,14 +147,142 @@ const requestFlightKey = (request: ArtifactRequest, expectedSha256: string): str "\u0000", ); -const metadataFor = (request: ArtifactRequest, sha256: string): ArtifactMetadata => ({ +const metadataFor = ( + request: ArtifactRequest, + sha256: string, + requiredRuntimeDigests: Readonly>, +): ArtifactMetadata => ({ format: ARTIFACT_FORMAT, key: request.key, sha256, requiredRuntimePaths: [...request.requiredRuntimePaths], + requiredRuntimeDigests, ...(request.executablePath === undefined ? {} : { executablePath: request.executablePath }), }); +const digestRuntimePaths = ( + fs: FileSystem.FileSystem, + crypto: Crypto.Crypto, + path: Path.Path, + root: string, + relativePaths: ReadonlyArray, +): Effect.Effect>, ArtifactIntegrityError> => + Effect.gen(function* () { + const realRoot = yield* fs + .realPath(root) + .pipe( + Effect.mapError((cause) => + metadataError("Unable to resolve runtime path root", { path: root, cause }), + ), + ); + const digest = (bytes: Uint8Array, candidate: string) => + crypto.digest("SHA-256", bytes).pipe( + Effect.mapError((cause) => + metadataError("Unable to digest required runtime path", { + path: candidate, + cause, + }), + ), + ); + const digestPath = (relative: string): Effect.Effect => + Effect.gen(function* () { + const candidate = path.resolve(root, relative); + const realCandidate = yield* fs.realPath(candidate).pipe( + Effect.mapError((cause) => + metadataError("Unable to resolve required runtime path", { + path: candidate, + cause, + }), + ), + ); + if (!pathAtOrBelow(realRoot, realCandidate, path.sep)) + return yield* metadataError("Required runtime path escapes its installation directory", { + path: relative, + }); + const info = yield* fs + .stat(candidate) + .pipe( + Effect.mapError((cause) => + metadataError("Unable to inspect required runtime path", { path: candidate, cause }), + ), + ); + if (info.type === "File") { + const bytes = yield* fs + .readFile(candidate) + .pipe( + Effect.mapError((cause) => + metadataError("Unable to read required runtime path", { path: candidate, cause }), + ), + ); + return yield* digest(bytes, candidate).pipe(Effect.map(digestHex)); + } + if (info.type !== "Directory") + return yield* metadataError("Required runtime path must be a file or directory", { + path: relative, + type: info.type, + }); + const children = yield* fs.readDirectory(candidate, { recursive: true }).pipe( + Effect.mapError((cause) => + metadataError("Unable to inspect required runtime directory", { + path: candidate, + cause, + }), + ), + ); + const records: Array = []; + for (const child of children.sort()) { + const childPath = path.join(candidate, child); + const realChild = yield* fs.realPath(childPath).pipe( + Effect.mapError((cause) => + metadataError("Unable to resolve required runtime path", { + path: childPath, + cause, + }), + ), + ); + if (!pathAtOrBelow(realRoot, realChild, path.sep)) + return yield* metadataError( + "Required runtime path escapes its installation directory", + { + path: child, + }, + ); + const childInfo = yield* fs.stat(childPath).pipe( + Effect.mapError((cause) => + metadataError("Unable to inspect required runtime path", { + path: childPath, + cause, + }), + ), + ); + if (childInfo.type === "File") { + const bytes = yield* fs + .readFile(childPath) + .pipe( + Effect.mapError((cause) => + metadataError("Unable to read required runtime path", { path: childPath, cause }), + ), + ); + const childDigest = yield* digest(bytes, childPath).pipe(Effect.map(digestHex)); + records.push(`${child}\u0000file\u0000${childDigest}`); + } else if (childInfo.type === "Directory") { + records.push(`${child}\u0000directory`); + } else { + return yield* metadataError("Required runtime path must contain files or directories", { + path: child, + type: childInfo.type, + }); + } + } + const directoryBytes = new TextEncoder().encode(records.join("\n")); + return yield* digest(directoryBytes, candidate).pipe(Effect.map(digestHex)); + }); + const entries = yield* Effect.forEach(relativePaths, (relative) => + digestPath(relative).pipe(Effect.map((digest) => [relative, digest] as const)), + ); + return Object.fromEntries(entries); + }); + const encodeMetadata = (metadata: ArtifactMetadata): Effect.Effect => Schema.encodeEffect(Schema.fromJsonString(ArtifactMetadataSchema))(metadata).pipe( Effect.mapError((cause) => @@ -348,6 +476,20 @@ const verifyMetadata = ( return Effect.void; }; +const verifyRuntimeDigests = ( + metadata: ArtifactMetadata, + actual: Readonly>, +): Effect.Effect => { + const expectedEntries = Object.entries(metadata.requiredRuntimeDigests); + const actualEntries = Object.entries(actual); + if ( + expectedEntries.length !== actualEntries.length || + expectedEntries.some(([path, digest]) => actual[path] !== digest) + ) + return Effect.fail(metadataError("Cached artifact runtime path failed integrity verification")); + return Effect.void; +}; + const writeBytesSync = ( fs: FileSystem.FileSystem, path: string, @@ -420,29 +562,20 @@ const makeArtifactOperation = ( ? yield* ensureSafeRoot(fs, path, target, cacheRoot) : undefined; const metadataPath = path.join(target, METADATA_NAME); - const archivePath = path.join(target, ARCHIVE_NAME); const checkCached: Effect.Effect = Effect.gen( function* () { const realRoot = cachedRoot ?? (yield* ensureSafeRoot(fs, path, target, cacheRoot)); const metadata = yield* readMetadata(fs, metadataPath); yield* verifyMetadata(request, expectedSha256, metadata); - const archive = yield* fs - .readFile(archivePath) - .pipe( - Effect.mapError((cause) => - metadataError("Cached artifact source is unreadable", { path: archivePath, cause }), - ), - ); - yield* verifySha256(archive, expectedSha256).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.mapError((error) => - metadataError("Cached artifact source failed integrity verification", { - path: archivePath, - cause: error, - }), - ), - ); yield* ensureSafePaths(fs, path, target, realRoot, request.requiredRuntimePaths); + const runtimeDigests = yield* digestRuntimePaths( + fs, + crypto, + path, + target, + request.requiredRuntimePaths, + ); + yield* verifyRuntimeDigests(metadata, runtimeDigests); if (request.executablePath !== undefined) { yield* ensureExecutableFile(fs, path, target, request.executablePath); const executable = path.resolve(target, request.executablePath); @@ -499,12 +632,17 @@ const makeArtifactOperation = ( }), ), ); - const sourceArchive = path.join(temporary, ARCHIVE_NAME); - yield* writeBytesSync(fs, sourceArchive, archive); + const runtimeDigests = yield* digestRuntimePaths( + fs, + crypto, + path, + temporary, + request.requiredRuntimePaths, + ); yield* writeMetadataSync( fs, path.join(temporary, METADATA_NAME), - metadataFor(request, expectedSha256), + metadataFor(request, expectedSha256, runtimeDigests), ); yield* ensureSafePaths(fs, path, temporary, temporaryRoot, request.requiredRuntimePaths); if (request.executablePath !== undefined) { @@ -517,7 +655,10 @@ const makeArtifactOperation = ( ); } const nowExists = yield* mapFs(target, "inspect concurrent artifact", fs.exists(target)); - if (nowExists) return yield* checkCached; + if (nowExists) { + const cached = yield* checkCached; + return cached; + } const rename = mapFs(temporary, "publish artifact", fs.rename(temporary, target)).pipe( Effect.as(undefined), ); @@ -526,7 +667,10 @@ const makeArtifactOperation = ( ): Effect.Effect => Effect.gen(function* () { const exists = yield* mapFs(target, "inspect concurrent artifact", fs.exists(target)); - if (exists) return yield* checkCached; + if (exists) { + const cached = yield* checkCached; + return cached; + } return yield* error; }); const recovered: Effect.Effect = diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 608496ee9b..cc273735d6 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -6,7 +6,7 @@ import { type NativeWorkloadArtifact, } from "../model/WorkloadCatalog.ts"; import type { StackRuntime } from "../public/Runtime.ts"; -import { StackPreparationError } from "../public/Errors.ts"; +import { ContainerPullError, StackPreparationError } from "../public/Errors.ts"; import { makeArtifactStore, type ArtifactStore, @@ -28,7 +28,7 @@ export interface PreparedWorkloadArtifact { readonly workloadId: string; readonly capability: PlannedWorkload["capability"]; readonly version: string; - readonly outcome: "cached" | "downloaded" | "present" | "pulled"; + readonly outcome: "cached" | "downloaded" | "pulled"; /** Native installation root. Container preparation deliberately has no root. */ readonly artifactRoot?: string; readonly executablePath?: string; @@ -44,7 +44,10 @@ export interface RuntimeArtifactPreparer { readonly containerEngine?: ContainerEngine; } -export type RuntimeArtifactPreparationError = ArtifactStoreError | StackPreparationError; +export type RuntimeArtifactPreparationError = + | ArtifactStoreError + | StackPreparationError + | ContainerPullError; export interface RuntimeArtifactPreparerOptions { readonly native?: { @@ -127,10 +130,18 @@ export const makeRuntimeArtifactPreparer = ( workloadId: workload.id, capability: workload.capability, version: containerVersion(image), - outcome: "present", + outcome: "cached", image, }) : engine.pullImage(image).pipe( + Effect.mapError( + (cause) => + new ContainerPullError({ + message: `Unable to pull container image ${image}`, + workload: workload.id, + cause, + }), + ), Effect.as({ workloadId: workload.id, capability: workload.capability, @@ -140,11 +151,14 @@ export const makeRuntimeArtifactPreparer = ( }), ), ), - Effect.mapError(() => - error("Container image preparation failed", { - workload: workload.id, - engine: runtime.engine, - }), + Effect.mapError((cause) => + cause instanceof ContainerPullError + ? cause + : error("Container image preparation failed", { + workload: workload.id, + engine: runtime.engine, + cause, + }), ), ); }, diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index caac1f5bfe..c8a99db8c6 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -48,7 +48,7 @@ describe("verified native artifact preparation", () => { expect(prepared.outcome).toBe("downloaded"); expect(yield* fs.exists(`${prepared.path}/bin/postgres`)).toBe(true); expect((yield* fs.stat(`${prepared.path}/bin/postgres`)).mode & 0o111).not.toBe(0); - expect(yield* fs.exists(`${root}/database/postgres/.artifact-source`)).toBe(true); + expect(yield* fs.exists(`${prepared.path}/.artifact-source`)).toBe(false); }), ), ); @@ -78,21 +78,94 @@ describe("verified native artifact preparation", () => { ), ); - it.live("rejects a corrupted cached archive without replacing the published tree", () => + it.live("fails closed for unknown artifact metadata instead of executing the cached tree", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-stack-artifact-corrupt-cache-", + prefix: "supabase-stack-artifact-unknown-metadata-", + }); + const target = `${root}/${request.key}`; + yield* fs.makeDirectory(`${target}/bin`, { recursive: true }); + yield* fs.writeFileString(`${target}/bin/postgres`, "unverified postgres"); + yield* fs.writeFileString( + `${target}/.artifact.json`, + '{"format":"supabase-stack-artifact-v0"}', + ); + let called = false; + const source: ArtifactSource = { + materialize: () => + Effect.sync(() => { + called = true; + return archive; + }), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + + const exit = yield* store.prepare(request).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + expect(called).toBe(false); + expect(yield* fs.readFileString(`${target}/bin/postgres`)).toBe("unverified postgres"); + }), + ), + ); + + it.live("rejects a tampered required executable on a cache hit", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-tampered-runtime-", }); const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); const published = yield* store.prepare(request); - yield* fs.writeFileString(`${published.path}/.artifact-source`, "corrupt"); - + yield* fs.writeFileString(`${published.path}/bin/postgres`, "tampered executable"); const exit = yield* store.prepare(request).pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); - expect(yield* fs.exists(`${published.path}/bin/postgres`)).toBe(true); - expect(yield* fs.exists(`${published.path}/.artifact-source`)).toBe(true); + }), + ), + ); + + it.live("verifies required directory contents and internal symlinks on a cache hit", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-directory-runtime-", + }); + const directoryRequest: ArtifactRequest = { + ...request, + key: "database/postgres-directory-runtime", + requiredRuntimePaths: ["bin/postgres", "share/runtime"], + }; + const source: ArtifactSource = { + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/share/runtime`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.writeFileString(`${destination}/share/runtime/config`, "config"); + yield* fs.symlink("config", `${destination}/share/runtime/config-link`); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const prepared = yield* store.prepare(directoryRequest); + expect((yield* store.prepare(directoryRequest)).outcome).toBe("cached"); + + yield* fs.writeFileString(`${prepared.path}/share/runtime/config`, "tampered config"); + const exit = yield* store.prepare(directoryRequest).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); }), ), ); @@ -111,7 +184,7 @@ describe("verified native artifact preparation", () => { .pipe(Effect.exit); expect(errorOf(conflict)).toBeInstanceOf(ArtifactIntegrityError); expect(yield* fs.exists(`${published.path}/bin/postgres`)).toBe(true); - expect(yield* fs.exists(`${published.path}/.artifact-source`)).toBe(true); + expect(yield* fs.exists(`${published.path}/.artifact-source`)).toBe(false); }), ), ); diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 8a6492d50d..280e2e2b69 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -1,4 +1,6 @@ import { + Cause, + Context, Crypto, Deferred, Effect, @@ -9,21 +11,25 @@ import { Path, Predicate, Ref, + Schedule, Scope, Schema, Stream, } from "effect"; import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import type { SocketError } from "effect/unstable/socket/Socket"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import type { StackIdentity } from "../identity/Identity.ts"; import { resolveStackIdentity, deriveStackId } from "../identity/Identity.ts"; -import type { PersistedStackState } from "../state/StackState.ts"; +import type { PersistedStackIdentity, PersistedStackState } from "../state/StackState.ts"; import { toPersistedIdentity } from "../state/StackState.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; import { StackIdSchema, type StackId } from "./StackId.ts"; import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; import type { StackConfig } from "./Config.ts"; -import type { StackStatus, StackDescriptor, StackInspection } from "./Status.ts"; +import { type StackStatus, type StackDescriptor, type StackInspection } from "./Status.ts"; import type { CapabilityName } from "./Capability.ts"; import type { LogOptions, StackLogEntry } from "./Logs.ts"; import type { EffectStackCredentials } from "./Credentials.ts"; @@ -49,7 +55,6 @@ import { ServiceReadinessError, ContainerEngineError, StackUpgradeReplacementError, - StackStateGenerationMismatchError, StackStateInvalidError, StackStateFormatUnsupportedError, StackUpgradeRequiredError, @@ -57,7 +62,6 @@ import { PortAllocationError, PortUnavailableError, GatewayAuthenticationError, - GatewayStaleGenerationError, GatewayActivationError, type CreateStackError, type OpenStackError, @@ -68,7 +72,6 @@ import { type StackStartError, type StackRestartError, type StackStopError, - type StackCloseError, type StackStatusWatchError, type StackLogsError, type DestroyStackError, @@ -76,9 +79,17 @@ import { type StackErrorTag, isStackErrorTag, } from "./Errors.ts"; -import { readOwnerMetadata } from "../state/Ownership.ts"; +import { + ownerLockExists, + readOwnerMetadata, + type OwnerMetadata, + type StackRuntimeEnvironmentValue, +} from "../state/Ownership.ts"; import { makeControlClient } from "../control/ControlServer.ts"; -import { MaintenanceProtocolError } from "../control/MaintenanceProtocol.ts"; +import { + isMaintenanceTransportFailure, + MaintenanceProtocolError, +} from "../control/MaintenanceProtocol.ts"; import { STACK_RPC_RELEASE, type StackRpcError, type StackRpcClient } from "../control/StackRpc.ts"; import { ensureSupervisor, @@ -96,8 +107,10 @@ import { type ContainerEngineKind, type ContainerPlatform, } from "../runtime/ContainerEngine.ts"; +import { statusFor } from "../supervisor/StatusProjection.ts"; import { makeDockerEngine } from "../runtime/DockerEngine.ts"; import { makePodmanEngine } from "../runtime/PodmanEngine.ts"; +import { readRetainedLogs } from "../supervisor/LogStore.ts"; export interface StartStackOptions { readonly config?: StackConfig; @@ -115,6 +128,10 @@ export interface FindStackOptions { readonly projectRoot: string; readonly name?: string; } +export interface OpenStackOptions { + /** Explicit restart path used to replace an owner from an older RPC release. */ + readonly replaceIncompatibleOwner?: boolean; +} export interface ListStacksOptions { readonly projectRoot?: string; } @@ -147,23 +164,20 @@ export interface EffectStack { // oxlint-disable-next-line effecttsgo/lazy-effect readonly destroy: () => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect - readonly close: () => Effect.Effect; + readonly close: () => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect readonly watchStatus: () => Stream.Stream; // oxlint-disable-next-line effecttsgo/lazy-effect readonly logs: (options?: LogOptions) => Stream.Stream; } -const runtimeFor = ( - preference: StackRuntimePreference | undefined, - engine?: ContainerEngineKind, -): StackRuntime => - preference?.kind === "container" - ? { - kind: "container", - engine: engine ?? (preference.engine === "podman" ? "podman" : "docker"), - } - : { kind: "native" }; +const containerRuntime = (engine: ContainerEngineKind): StackRuntime => ({ + kind: "container", + engine, +}); + +const optionOf = (value: A | undefined): Option.Option => + value === undefined ? Option.none() : Option.some(value); const defaultContainerPlatform = (): ContainerPlatform => { // Host details are read only at this composition boundary. The real @@ -189,11 +203,7 @@ const defaultContainerEngineResolver = (): ContainerEngineResolverShape => ({ const resolveRuntime = ( preference: StackRuntimePreference | undefined, -): Effect.Effect< - StackRuntime, - ContainerEngineError, - import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner -> => +): Effect.Effect => preference?.kind !== "container" ? Effect.succeed({ kind: "native" }) : Effect.serviceOption(ContainerEngineResolver).pipe( @@ -201,7 +211,7 @@ const resolveRuntime = ( Option.isSome(service) ? service.value : defaultContainerEngineResolver(), ), Effect.flatMap((resolver) => resolver.resolve(preference.engine ?? "auto")), - Effect.map((engine) => runtimeFor(preference, engine)), + Effect.map(containerRuntime), Effect.mapError( (error) => new ContainerEngineError({ @@ -227,7 +237,12 @@ const environment = () => Effect.map(Option.getOrElse(defaultRuntimeEnvironment)), ); -type ControlError = StackRpcError | RpcClientError | SocketError | MaintenanceProtocolError; +type ControlError = + | StackRpcError + | RpcClientError + | SocketError + | MaintenanceProtocolError + | StackError; const stackErrorFactories = { InvalidStackIdentityError: (message: string) => new InvalidStackIdentityError({ message }), @@ -244,8 +259,6 @@ const stackErrorFactories = { StackStateInvalidError: (message: string) => new StackStateInvalidError({ message }), StackStateFormatUnsupportedError: (message: string) => new StackStateFormatUnsupportedError({ message }), - StackStateGenerationMismatchError: (message: string) => - new StackStateGenerationMismatchError({ message }), StackUpgradeRequiredError: (message: string) => new StackUpgradeRequiredError({ message }), StackUpgradeReplacementError: (message: string) => new StackUpgradeReplacementError({ message }), StackSecretMismatchError: (message: string) => new StackSecretMismatchError({ message }), @@ -254,7 +267,6 @@ const stackErrorFactories = { PortAllocationError: (message: string) => new PortAllocationError({ message }), PortUnavailableError: (message: string) => new PortUnavailableError({ message }), GatewayAuthenticationError: (message: string) => new GatewayAuthenticationError({ message }), - GatewayStaleGenerationError: (message: string) => new GatewayStaleGenerationError({ message }), GatewayActivationError: (message: string) => new GatewayActivationError({ message }), StackPreparationError: (message: string) => new StackPreparationError({ message }), ArtifactIntegrityError: (message: string) => new ArtifactIntegrityError({ message }), @@ -267,6 +279,21 @@ const stackErrorFactories = { } satisfies Record StackError>; const errorForRpc = (error: ControlError): StackError => { + if ( + Predicate.isTagged(error, "StackOwnershipConflictError") || + Predicate.isTagged(error, "StackLifecycleConflictError") || + Predicate.isTagged(error, "StackStateInvalidError") || + Predicate.isTagged(error, "StackStateFormatUnsupportedError") + ) + return error; + if ( + Predicate.isTagged(error, "RpcClientError") || + Predicate.isTagged(error, "SocketError") || + isMaintenanceTransportFailure(error) + ) + return new StackOwnershipConflictError({ + message: `Stack owner is unreachable: ${error.message}`, + }); if ( typeof error === "object" && error !== null && @@ -286,6 +313,7 @@ const errorForRpc = (error: ControlError): StackError => { const statusError = (error: ControlError): StackStatusError => { const mapped = errorForRpc(error); return mapped instanceof StackNotFoundError || + mapped instanceof StackOwnershipConflictError || mapped instanceof StackStateInvalidError || mapped instanceof StackStateFormatUnsupportedError || mapped instanceof StackUpgradeRequiredError @@ -297,6 +325,7 @@ const credentialsError = (error: ControlError): StackCredentialsError => { const mapped = errorForRpc(error); return mapped instanceof StackNotFoundError || mapped instanceof StackNotRunningError || + mapped instanceof StackOwnershipConflictError || mapped instanceof StackSecretMismatchError || mapped instanceof InvalidJwtSigningMaterialError ? mapped @@ -307,27 +336,37 @@ const prepareError = (error: ControlError): PrepareStackError => { const mapped = errorForRpc(error); return mapped instanceof StackPreparationError || mapped instanceof ArtifactIntegrityError || - mapped instanceof ContainerPullError + mapped instanceof ContainerPullError || + mapped instanceof StackOwnershipConflictError || + mapped instanceof StackStateInvalidError || + mapped instanceof StackLifecycleConflictError ? mapped : new StackPreparationError({ message: mapped.message }); }; const startError = (error: ControlError): StackStartError => { const mapped = errorForRpc(error); - return mapped instanceof StackDefinitionRequiredError || + return mapped instanceof InvalidStackConfigError || + mapped instanceof StackDefinitionRequiredError || mapped instanceof StackVersionUnsupportedError || + mapped instanceof StackNotRunningError || + mapped instanceof StackMustBeStoppedError || mapped instanceof StackLifecycleConflictError || mapped instanceof StackStateInvalidError || mapped instanceof StackStateFormatUnsupportedError || mapped instanceof StackUpgradeRequiredError || mapped instanceof StackSecretMismatchError || + mapped instanceof InvalidJwtSigningMaterialError || + mapped instanceof PortAllocationError || + mapped instanceof PortUnavailableError || mapped instanceof StackPreparationError || mapped instanceof ArtifactIntegrityError || mapped instanceof ContainerPullError || mapped instanceof StackReconciliationError || mapped instanceof ServiceStartError || mapped instanceof ServiceReadinessError || - mapped instanceof ContainerEngineError + mapped instanceof ContainerEngineError || + mapped instanceof StackOwnershipConflictError ? mapped : new StackStateInvalidError({ message: mapped.message }); }; @@ -337,21 +376,56 @@ const restartError = (error: ControlError): StackRestartError => { return mapped instanceof StackUpgradeReplacementError ? mapped : startError(error); }; +const restartErrorFromStack = (error: StackError): StackRestartError => + error instanceof StackUpgradeReplacementError || + error instanceof InvalidStackConfigError || + error instanceof StackDefinitionRequiredError || + error instanceof StackVersionUnsupportedError || + error instanceof StackOwnershipConflictError || + error instanceof StackNotRunningError || + error instanceof StackMustBeStoppedError || + error instanceof StackLifecycleConflictError || + error instanceof StackStateInvalidError || + error instanceof StackStateFormatUnsupportedError || + error instanceof StackUpgradeRequiredError || + error instanceof StackSecretMismatchError || + error instanceof InvalidJwtSigningMaterialError || + error instanceof PortAllocationError || + error instanceof PortUnavailableError || + error instanceof StackPreparationError || + error instanceof ArtifactIntegrityError || + error instanceof ContainerPullError || + error instanceof StackReconciliationError || + error instanceof ServiceStartError || + error instanceof ServiceReadinessError || + error instanceof ContainerEngineError + ? error + : new StackLifecycleConflictError({ message: error.message }); + const stopError = (error: ControlError): StackStopError => { const mapped = errorForRpc(error); - return mapped instanceof StackNotRunningError || - mapped instanceof StackLifecycleConflictError || - mapped instanceof StackReconciliationError || - mapped instanceof ServiceReadinessError || - mapped instanceof ContainerEngineError + return mapped instanceof StackOwnershipConflictError || + mapped instanceof StackLifecycleConflictError ? mapped : new StackLifecycleConflictError({ message: mapped.message }); }; +const logsError = (error: ControlError): StackLogsError => { + const mapped = errorForRpc(error); + return mapped instanceof StackNotFoundError || + mapped instanceof StackNotRunningError || + mapped instanceof StackStateInvalidError || + mapped instanceof StackOwnershipConflictError || + mapped instanceof StackLifecycleConflictError + ? mapped + : new StackStateInvalidError({ message: mapped.message }); +}; + const destroyError = (error: ControlError): DestroyStackError => { const mapped = errorForRpc(error); return mapped instanceof StackDestructionError || mapped instanceof StackNotFoundError || + mapped instanceof StackOwnershipConflictError || mapped instanceof StackLifecycleConflictError || mapped instanceof ContainerEngineError ? mapped @@ -362,29 +436,83 @@ const destroyError = (error: ControlError): DestroyStackError => { export const makeHandle = ( id: StackId, metadata: { - readonly endpoint: Parameters[0]; - readonly ownerSessionId: string; - readonly rpcRelease: string; + readonly endpoint?: Parameters[0]; + readonly ownerSessionId?: string; + readonly rpcRelease?: string; }, + options: { + readonly resolveOwner?: ( + launch: boolean, + ) => Effect.Effect, StackError>; + readonly readOfflineState?: () => Effect.Effect, StackError>; + readonly readPersistedState?: () => Effect.Effect< + Option.Option, + StackError + >; + readonly readLogs?: ( + options?: LogOptions, + ) => Effect.Effect, StackLogsError>; + readonly waitForRelease?: () => Effect.Effect; + readonly replacement?: (options?: StartStackOptions) => Effect.Effect; + } = {}, ): Effect.Effect => Effect.gen(function* () { const closed = yield* Ref.make(false); - const client = makeControlClient(metadata.endpoint, { - stackId: id, - ownerSessionId: metadata.ownerSessionId, - rpcRelease: metadata.rpcRelease, - }); - const check = (effect: Effect.Effect): Effect.Effect => + const readOfflineState = options.readOfflineState; + const readPersistedState = options.readPersistedState; + const readLogs = options.readLogs; + let replacement = options.replacement; + type ReplacementExit = Exit.Exit; + const replacementActive = yield* Ref.make(false); + const client: ReturnType | undefined = + metadata.endpoint !== undefined && + metadata.ownerSessionId !== undefined && + metadata.rpcRelease !== undefined + ? makeControlClient(metadata.endpoint, { + stackId: id, + ownerSessionId: metadata.ownerSessionId, + rpcRelease: metadata.rpcRelease, + }) + : undefined; + const resolveClient = ( + launch: boolean, + ): Effect.Effect, StackError> => + options.resolveOwner !== undefined + ? options.resolveOwner(launch).pipe( + Effect.flatMap((owner) => + Option.isSome(owner) + ? Effect.succeed( + makeControlClient(owner.value.endpoint, { + stackId: id, + ownerSessionId: owner.value.ownerSessionId, + rpcRelease: owner.value.rpcRelease, + }), + ) + : Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ), + ), + ) + : client !== undefined + ? Effect.succeed(client) + : Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ); + const check = ( + effect: Effect.Effect, + ): Effect.Effect => Ref.get(closed).pipe( - Effect.flatMap((isClosed): Effect.Effect => - isClosed ? Effect.interrupt : effect, + Effect.flatMap((isClosed): Effect.Effect => + isClosed + ? Effect.fail(new StackLifecycleConflictError({ message: "Stack handle is closed" })) + : effect, ), ); const mapRpcClientFailure = ( error: RpcClientError, mapError: (error: ControlError) => E, ): Effect.Effect => - Effect.exit(Effect.scoped(client.probe())).pipe( + Effect.exit(resolveClient(false).pipe(Effect.flatMap((owner) => owner.probe()))).pipe( Effect.flatMap((probe) => { if ( Exit.isSuccess(probe) && @@ -404,10 +532,11 @@ export const makeHandle = ( const invoke = ( call: (rpc: StackRpcClient) => Effect.Effect, mapError: (error: ControlError) => E, - ): Effect.Effect => { - const rpcCall: Effect.Effect = Effect.scoped( - client.rpc.pipe(Effect.flatMap(call)), - ); + launch = false, + ): Effect.Effect => { + const rpcCall: Effect.Effect = resolveClient( + launch, + ).pipe(Effect.flatMap((owner) => Effect.scoped(owner.rpc.pipe(Effect.flatMap(call))))); const mapped: Effect.Effect = rpcCall.pipe( Effect.catchIf( (error): error is RpcClientError => Predicate.isTagged(error, "RpcClientError"), @@ -418,98 +547,298 @@ export const makeHandle = ( return check(mapped); }; const destroyAndAwaitOwner: Effect.Effect = check( - Effect.gen(function* () { - const ownerConnected = yield* Deferred.make(); - const ownerWatch = client.awaitClose( - Deferred.succeed(ownerConnected, undefined).pipe(Effect.asVoid), - ); - const ownerFiber = yield* Effect.forkChild(ownerWatch, { startImmediately: true }); - const ownerReady = Deferred.await(ownerConnected).pipe( - Effect.raceFirst( - Fiber.join(ownerFiber).pipe( - Effect.flatMap(() => - Effect.fail( - new StackDestructionError({ - message: "Unable to observe Supervisor control connection", - }), + resolveClient(true).pipe( + Effect.mapError(destroyError), + Effect.flatMap((client) => + Effect.gen(function* () { + const ownerConnected = yield* Deferred.make(); + const ownerWatch = client.awaitClose( + Deferred.succeed(ownerConnected, undefined).pipe(Effect.asVoid), + ); + const ownerFiber = yield* Effect.forkChild(ownerWatch, { startImmediately: true }); + const ownerReady = Deferred.await(ownerConnected).pipe( + Effect.raceFirst( + Fiber.join(ownerFiber).pipe( + Effect.flatMap(() => + Effect.fail( + new StackDestructionError({ + message: "Unable to observe Supervisor control connection", + }), + ), + ), + Effect.mapError( + (cause) => + new StackDestructionError({ + message: "Unable to observe Supervisor control connection", + cause, + }), + ), ), ), - Effect.mapError( - (cause) => - new StackDestructionError({ - message: "Unable to observe Supervisor control connection", - cause, - }), + ); + const result = yield* Effect.exit( + ownerReady.pipe( + Effect.andThen(invoke((rpc) => rpc.destroy(undefined), destroyError)), + // The owner closes its control server only after all workload cleanup has completed. + // Await the exact preface-only socket instead of decoding a terminal RPC stream Exit. + Effect.andThen( + Fiber.join(ownerFiber).pipe( + Effect.mapError( + (cause) => + new StackDestructionError({ + message: "Unable to observe Supervisor shutdown completion", + cause, + }), + ), + ), + ), ), + ); + if (Exit.isFailure(result)) { + yield* Fiber.interrupt(ownerFiber); + } + return yield* result; + }), + ), + ), + ); + const status = () => { + const rpcStatus = invoke((rpc) => rpc.status(undefined), statusError); + if (readOfflineState === undefined) return rpcStatus; + return rpcStatus.pipe( + Effect.catchTag("StackOwnershipConflictError", () => + readOfflineState().pipe( + Effect.mapError(statusError), + Effect.flatMap((state) => + Option.isSome(state) && + (state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured") + ? statusFor(state.value, [], new Set(), "stopped").pipe( + Effect.mapError(statusError), + ) + : Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ), ), ), - ); - const result = yield* Effect.exit( - ownerReady.pipe( - Effect.andThen(invoke((rpc) => rpc.destroy(undefined), destroyError)), - // The owner closes its control server only after all workload cleanup has completed. - // Await the exact preface-only socket instead of decoding a terminal RPC stream Exit. - Effect.andThen( - Fiber.join(ownerFiber).pipe( - Effect.mapError( - (cause) => - new StackDestructionError({ - message: "Unable to observe Supervisor shutdown completion", - cause, - }), - ), + ), + ); + }; + const start = (options?: StartStackOptions) => + invoke( + (rpc) => + options?.config === undefined ? rpc.start({}) : rpc.start({ config: options.config }), + startError, + true, + ); + const stopOwner = (owner: ReturnType) => + Effect.gen(function* () { + // Subscribe to the owner control connection before sending stop so a + // fast shutdown cannot race the close witness. + const closeFiber = yield* Effect.forkChild(owner.awaitClose(), { startImmediately: true }); + const response = yield* owner.stop().pipe(Effect.exit); + if (Exit.isFailure(response)) { + yield* Fiber.interrupt(closeFiber); + return yield* Effect.failCause(response.cause); + } + if (!response.value.ok) { + yield* Fiber.interrupt(closeFiber); + return yield* new StackLifecycleConflictError({ message: response.value.error.message }); + } + yield* Fiber.join(closeFiber).pipe(Effect.ignore); + if (options.waitForRelease !== undefined) yield* options.waitForRelease(); + }).pipe(Effect.mapError(stopError)); + const stop = () => + check( + resolveClient(false).pipe( + Effect.mapError(stopError), + Effect.flatMap(stopOwner), + Effect.catchTag("StackOwnershipConflictError", () => + (readOfflineState === undefined + ? Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ) + : readOfflineState().pipe( + Effect.mapError(stopError), + Effect.flatMap((state) => + Option.isSome(state) && + (state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured") + ? Effect.void + : resolveClient(true).pipe( + Effect.mapError(stopError), + Effect.flatMap(stopOwner), + ), + ), + ) + ).pipe( + // Ownership artifacts that block the offline fast path may be + // stale. Let ensureSupervisor arbitrate the lease; a live owner + // remains protected and returns a typed conflict. + Effect.catchTag("StackOwnershipConflictError", () => + resolveClient(true).pipe(Effect.mapError(stopError), Effect.flatMap(stopOwner)), ), ), ), + ), + ); + const prepare = (prepareOptions?: PrepareStackOptions) => + Effect.gen(function* () { + const ownerBefore = yield* resolveClient(false).pipe( + Effect.mapError(prepareError), + Effect.flatMap((owner) => + owner.probe().pipe( + Effect.flatMap((response) => + response.ok + ? Effect.succeed(true) + : Effect.fail(new StackPreparationError({ message: response.error.message })), + ), + Effect.mapError((error) => + isMaintenanceTransportFailure(error) + ? new StackOwnershipConflictError({ + message: "The existing Supervisor is unreachable", + }) + : prepareError(error), + ), + ), + ), + Effect.catchTag("StackOwnershipConflictError", () => + readPersistedState === undefined + ? Effect.succeed(false) + : readPersistedState().pipe( + Effect.mapError(prepareError), + Effect.flatMap((state) => + Option.isSome(state) && state.value.desiredLifecycle === "running" + ? Effect.fail( + new StackOwnershipConflictError({ + stackId: id, + message: + "Stack has running intent without a reachable Supervisor; restart it before preparing", + }), + ) + : Effect.succeed(false), + ), + ), + ), + ); + const result = yield* Effect.exit( + invoke( + (rpc) => + prepareOptions === undefined + ? rpc.prepare({}) + : rpc.prepare({ + ...(prepareOptions.config === undefined + ? {} + : { config: prepareOptions.config }), + ...(prepareOptions.capabilities === undefined + ? {} + : { capabilities: prepareOptions.capabilities }), + }), + prepareError, + true, + ), ); - if (Exit.isFailure(result)) { - yield* Fiber.interrupt(ownerFiber); + if (!ownerBefore && options.waitForRelease !== undefined) { + const released = yield* Effect.uninterruptible(options.waitForRelease()).pipe( + Effect.mapError(prepareError), + Effect.exit, + ); + if (Exit.isFailure(released)) { + if (Exit.isSuccess(result)) { + const retainedOwner = yield* resolveClient(false).pipe( + Effect.flatMap((owner) => owner.probe()), + Effect.exit, + ); + if (Exit.isSuccess(retainedOwner) && retainedOwner.value.ok) return result.value; + } + return yield* Effect.failCause(released.cause); + } } - return yield* result; - }), - ); - const status = () => invoke((rpc) => rpc.status(undefined), statusError); + return yield* Exit.isSuccess(result) + ? Effect.succeed(result.value) + : Effect.failCause(result.cause); + }); return { id, status, credentials: () => invoke((rpc) => rpc.credentials(undefined), credentialsError), - prepare: (options) => - invoke( - (rpc) => rpc.prepare({ config: options?.config, capabilities: options?.capabilities }), - prepareError, - ), - start: (options) => invoke((rpc) => rpc.start({ config: options?.config }), startError), - restart: (options) => invoke((rpc) => rpc.restart({ config: options?.config }), restartError), - stop: () => - check( - Effect.scoped(client.stop()).pipe( - Effect.mapError((error) => stopError(error)), - Effect.flatMap((response) => - response.ok - ? Effect.void - : Effect.fail(new StackLifecycleConflictError({ message: response.error.message })), - ), - ), - ), + prepare, + start, + restart: (options) => + Effect.gen(function* () { + if (replacement !== undefined) { + const admitted = yield* Ref.modify(replacementActive, (active) => + active ? [false, true] : [true, true], + ); + if (!admitted) + return yield* new StackLifecycleConflictError({ + message: "A replacement restart is already active", + }); + const result = yield* Deferred.make(); + const ownerFiber = replacement(options).pipe( + Effect.mapError(restartErrorFromStack), + Effect.exit, + Effect.tap((exit) => + (Exit.isSuccess(exit) + ? Effect.sync(() => { + replacement = undefined; + }) + : Effect.void + ).pipe( + Effect.andThen(Ref.set(replacementActive, false)), + Effect.andThen(Deferred.succeed(result, exit)), + ), + ), + ); + // Replacement owns a stop/start handoff. It must finish even when the initiating + // request is interrupted, while later callers receive an explicit conflict. + yield* ownerFiber.pipe(Effect.forkDetach); + const exit = yield* Deferred.await(result); + if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause); + return exit.value; + } + return yield* invoke( + (rpc) => + options?.config === undefined + ? rpc.restart({}) + : rpc.restart({ config: options.config }), + restartError, + true, + ); + }), + stop, destroy: () => destroyAndAwaitOwner, close: () => Ref.set(closed, true), watchStatus: () => Stream.unwrap( check( - client.rpc.pipe( - Effect.map((rpc) => - rpc.watchStatus(undefined).pipe( - Stream.catchIf( - (error): error is RpcClientError => Predicate.isTagged(error, "RpcClientError"), - (error) => Stream.fromEffect(mapRpcClientFailure(error, statusError)), - (error) => Stream.fail(statusError(error)), + resolveClient(false).pipe( + Effect.mapError(statusError), + Effect.map((owner) => + Stream.unwrap( + owner.rpc.pipe( + Effect.map((rpc) => + rpc + .watchStatus(undefined) + .pipe( + Stream.mapError((error) => + Predicate.isTagged(error, "RpcClientError") + ? new StackOwnershipConflictError({ message: error.message }) + : statusError(error), + ), + ), + ), + Effect.catchIf( + (error): error is RpcClientError => + Predicate.isTagged(error, "RpcClientError"), + (error) => + Effect.fail(new StackOwnershipConflictError({ message: error.message })), + ), ), ), ), - Effect.catchIf( - (error): error is RpcClientError => Predicate.isTagged(error, "RpcClientError"), - (error) => mapRpcClientFailure(error, statusError), - (error) => Effect.fail(statusError(error)), + Effect.catchTag("StackOwnershipConflictError", () => + Effect.succeed(Stream.fromEffect(status())), ), ), ), @@ -517,33 +846,126 @@ export const makeHandle = ( logs: (options) => Stream.unwrap( check( - client.rpc.pipe( - Effect.map((rpc) => - rpc.logs(options ?? {}).pipe( - Stream.mapError((error) => { - const mapped = errorForRpc(error); - return mapped instanceof StackNotFoundError || - mapped instanceof StackNotRunningError || - mapped instanceof StackStateInvalidError - ? mapped - : new StackStateInvalidError({ message: mapped.message }); - }), + resolveClient(false).pipe( + Effect.mapError(logsError), + Effect.map((owner) => + Stream.unwrap( + owner.rpc.pipe( + Effect.map((rpc) => rpc.logs(options ?? {}).pipe(Stream.mapError(logsError))), + Effect.catchIf( + (error): error is RpcClientError => + Predicate.isTagged(error, "RpcClientError"), + (error) => + Effect.fail(new StackOwnershipConflictError({ message: error.message })), + ), + ), ), ), - Effect.mapError((error) => { - const mapped = errorForRpc(error); - return mapped instanceof StackNotFoundError || - mapped instanceof StackNotRunningError || - mapped instanceof StackStateInvalidError - ? mapped - : new StackStateInvalidError({ message: mapped.message }); - }), + Effect.catchTag("StackOwnershipConflictError", () => + readLogs === undefined + ? Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ) + : readLogs(options).pipe(Effect.map((entries) => Stream.fromIterable(entries))), + ), ), ), ).pipe(Stream.scoped), } satisfies EffectStack; }); +const replaceIncompatibleOwner = ( + id: StackId, + state: PersistedStackState, + owner: OwnerMetadata, + env: StackRuntimeEnvironmentValue, + store: StackStateStore, +): Effect.Effect< + EffectStack, + OpenStackError, + Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService +> => + Effect.gen(function* () { + const client = makeControlClient(owner.endpoint, { + stackId: id, + ownerSessionId: owner.ownerSessionId, + }); + const probeExit = yield* client.probe().pipe(Effect.exit); + if (Exit.isFailure(probeExit)) { + const failure = Cause.findErrorOption(probeExit.cause); + const transportFailure = + Option.isSome(failure) && isMaintenanceTransportFailure(failure.value); + if (!transportFailure) + return yield* new StackOwnershipConflictError({ + stackId: id, + message: Option.isSome(failure) + ? `Unable to probe incompatible stack owner: ${failure.value.message}` + : "Unable to probe incompatible stack owner", + }); + const currentOwner = yield* ensureSupervisor({ + identity: runtimeIdentity(state.identity), + stackId: id, + stateStore: store, + environment: env, + }); + return yield* makeHandle(id, currentOwner); + } + // Stable maintenance stop is supported across RPC releases and fully shuts down + // the previous owner before replacement is launched. + const responseExit = yield* client.stop().pipe(Effect.exit); + if (Exit.isFailure(responseExit)) { + const failure = Cause.findErrorOption(responseExit.cause); + const transportFailure = + Option.isSome(failure) && isMaintenanceTransportFailure(failure.value); + if (!transportFailure) + return yield* new StackOwnershipConflictError({ + stackId: id, + message: Option.isSome(failure) + ? `Unable to stop incompatible stack owner: ${failure.value.message}` + : "Unable to stop incompatible stack owner", + }); + return yield* new StackOwnershipConflictError({ + stackId: id, + message: "Live incompatible stack owner stopped responding during stop", + }); + } + const response = responseExit.value; + if (!response.ok) + return yield* new StackOwnershipConflictError({ + stackId: id, + message: response.error.message, + }); + + const released = Effect.gen(function* () { + const current = yield* readOwnerMetadata(env.stateRoot, id, env); + if (current !== undefined) + return yield* new StackOwnershipConflictError({ + stackId: id, + message: "Previous stack owner is still shutting down", + }); + if (yield* ownerLockExists(env.stateRoot, id)) + return yield* new StackOwnershipConflictError({ + stackId: id, + message: "Previous stack ownership lease is still held", + }); + }).pipe( + Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), + Effect.mapError((error) => + error instanceof StackOwnershipConflictError + ? error + : new StackStateInvalidError({ message: String(error) }), + ), + ); + yield* released; + const currentOwner = yield* ensureSupervisor({ + identity: runtimeIdentity(state.identity), + stackId: id, + stateStore: store, + environment: env, + }); + return yield* makeHandle(id, currentOwner); + }); + const stateInitial = ( identity: StackIdentity, stackId: StackId, @@ -552,24 +974,133 @@ const stateInitial = ( format: "supabase-stack-state-v1", identity: toPersistedIdentity(identity, stackId), runtime, - desiredGeneration: 0, - portsGeneration: null, desiredLifecycle: "unconfigured", ports: [], privatePorts: [], secrets: {}, }); +const runtimeIdentity = (identity: PersistedStackIdentity): StackIdentity => { + const { stackId: _stackId, ...value } = identity; + return value; +}; + +type ChildProcessSpawnerValue = Context.Service.Shape< + typeof ChildProcessSpawner.ChildProcessSpawner +>; + +const handleDependencies = (options: { + readonly environment: StackRuntimeEnvironmentValue; + readonly store: StackStateStore; + readonly id: StackId; + readonly identity: StackIdentity; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly crypto: Crypto.Crypto; + readonly spawner: ChildProcessSpawnerValue; +}) => { + const provide = ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), + ); + const resolveOwner = (launch: boolean) => + provide(readOwnerMetadata(options.environment.stateRoot, options.id, options.environment)).pipe( + Effect.flatMap((owner) => + launch + ? Effect.scoped( + ensureSupervisor({ + identity: options.identity, + stackId: options.id, + stateStore: options.store, + environment: options.environment, + }).pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), + ), + ).pipe(Effect.map(Option.some)) + : owner !== undefined + ? Effect.succeed(Option.some(owner)) + : Effect.succeed(Option.none()), + ), + ); + const ensureOffline = () => + provide(readOwnerMetadata(options.environment.stateRoot, options.id, options.environment)).pipe( + Effect.flatMap((owner) => + owner !== undefined + ? Effect.fail( + new StackOwnershipConflictError({ message: "A Supervisor still owns this stack" }), + ) + : provide(ownerLockExists(options.environment.stateRoot, options.id)).pipe( + Effect.flatMap((locked) => + locked + ? Effect.fail( + new StackOwnershipConflictError({ + message: "The stack ownership lease is still held", + }), + ) + : Effect.void, + ), + ), + ), + ); + const readOfflineState = () => + ensureOffline().pipe( + Effect.andThen(provide(options.store.read(options.id))), + Effect.map(optionOf), + ); + const readPersistedState = () => + provide(options.store.read(options.id)).pipe(Effect.map(optionOf)); + const readLogs = (logOptions?: LogOptions) => + ensureOffline().pipe( + Effect.andThen( + resolveStackPaths({ stateRoot: options.environment.stateRoot, stackId: options.id }), + ), + Effect.provideService(Path.Path, options.path), + Effect.flatMap((paths) => readRetainedLogs(options.fileSystem, paths.logs, logOptions)), + Effect.mapError((error) => + error instanceof StackOwnershipConflictError + ? error + : new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + const waitForRelease = () => + Effect.gen(function* () { + if ( + (yield* readOwnerMetadata( + options.environment.stateRoot, + options.id, + options.environment, + )) !== undefined + ) + return yield* new StackOwnershipConflictError({ + message: "Supervisor is still shutting down", + }); + if (yield* ownerLockExists(options.environment.stateRoot, options.id)) + return yield* new StackOwnershipConflictError({ + message: "Supervisor ownership lease is still held", + }); + }).pipe( + Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), + Effect.mapError((error) => new StackOwnershipConflictError({ message: error.message })), + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), + ); + return { resolveOwner, readOfflineState, readPersistedState, readLogs, waitForRelease }; +}; + export const createStack = ( options: CreateStackOptions, ): Effect.Effect< EffectStack, CreateStackError, - | Scope.Scope - | FileSystem.FileSystem - | Path.Path - | Crypto.Crypto - | import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner + Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService > => Effect.gen(function* () { const env = yield* environment(); @@ -603,21 +1134,30 @@ export const createStack = ( return yield* new StackRuntimeMismatchError({ message: "Stack runtime is immutable for an existing identity", }); - const owner = yield* ensureSupervisor({ - identity, - stackId, - stateStore: store, + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const dependencies = handleDependencies({ environment: env, + store, + id: stackId, + identity, + fileSystem: fs, + path, + crypto, + spawner, }); - return yield* makeHandle(stackId, owner); + return yield* makeHandle(stackId, {}, dependencies); }); export const openStack = ( id: StackId, + options: OpenStackOptions = {}, ): Effect.Effect< EffectStack, OpenStackError, - Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto + Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService > => Effect.gen(function* () { const env = yield* environment(); @@ -625,39 +1165,53 @@ export const openStack = ( const state = yield* store.read(id); if (state === undefined) return yield* new StackNotFoundError({ stackId: id, message: "Stack state was not found" }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const identity = runtimeIdentity(state.identity); + const dependencies = handleDependencies({ + environment: env, + store, + id, + identity, + fileSystem: fs, + path, + crypto, + spawner, + }); const owner = yield* readOwnerMetadata(env.stateRoot, id, env); - if (owner === undefined) - return yield* new StackOwnershipConflictError({ - stackId: id, - message: "No Supervisor owns this stack", - }); - if (owner.rpcRelease !== STACK_RPC_RELEASE) - return yield* new StackUpgradeRequiredError({ - message: `Stack owner release ${owner.rpcRelease} requires explicit restart`, - expectedRelease: STACK_RPC_RELEASE, - actualRelease: owner.rpcRelease, - }); - const probe = yield* makeControlClient(owner.endpoint, { - stackId: id, - ownerSessionId: owner.ownerSessionId, - }) - .probe() - .pipe( - Effect.mapError( - (error) => new StackOwnershipConflictError({ stackId: id, message: String(error) }), - ), - ); if ( - !probe.ok || - probe.op !== "probe" || - probe.stackId !== id || - probe.ownerSessionId !== owner.ownerSessionId - ) - return yield* new StackOwnershipConflictError({ - stackId: id, - message: "Owner session probe failed", - }); - return yield* makeHandle(id, owner); + owner !== undefined && + owner.rpcRelease !== STACK_RPC_RELEASE && + options.replaceIncompatibleOwner + ) { + // Defer the entire maintenance handoff until restart is invoked. This + // keeps stop, owner release, replacement launch, and start in one + // package-owned operation with no cancellable gap after openStack(). + const replacement = ( + startOptions?: StartStackOptions, + ): Effect.Effect => + Effect.scoped( + replaceIncompatibleOwner(id, state, owner, env, store).pipe( + Effect.flatMap((handle) => handle.start(startOptions)), + ), + ).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + return yield* makeHandle( + id, + {}, + { + ...dependencies, + replacement, + }, + ); + } + return yield* makeHandle(id, {}, dependencies); }); export const findStack = ( @@ -733,22 +1287,37 @@ export const inspectStack = ( if (state === undefined) return yield* new StackNotFoundError({ stackId: id, message: "Stack state was not found" }); const metadata = yield* readOwnerMetadata(env.stateRoot, id, env); - if (metadata === undefined) return { descriptor: descriptor(state), owner: "absent" }; + if (metadata === undefined) + return { + descriptor: descriptor(state), + owner: (yield* ownerLockExists(env.stateRoot, id)) ? "unreachable" : "absent", + }; if (metadata.rpcRelease !== STACK_RPC_RELEASE) return { descriptor: descriptor(state), owner: "incompatible" }; - const probe = yield* Effect.scoped( - makeControlClient(metadata.endpoint, { - stackId: id, - ownerSessionId: metadata.ownerSessionId, - }).probe(), + const status = yield* Effect.scoped( + Effect.gen(function* () { + const client = makeControlClient(metadata.endpoint, { + stackId: id, + ownerSessionId: metadata.ownerSessionId, + }); + const rpc = yield* client.rpc; + return yield* rpc.status(undefined); + }), ).pipe(Effect.exit); - if (Exit.isFailure(probe)) return { descriptor: descriptor(state), owner: "unreachable" }; - if ( - !probe.value.ok || - probe.value.op !== "probe" || - probe.value.stackId !== id || - probe.value.ownerSessionId !== metadata.ownerSessionId - ) - return { descriptor: descriptor(state), owner: "incompatible" }; - return { descriptor: descriptor(state), owner: "running" }; + if (Exit.isFailure(status)) { + const failure = Cause.findErrorOption(status.cause); + if ( + Option.isSome(failure) && + (isMaintenanceTransportFailure(failure.value) || + (Predicate.isTagged(failure.value, "RpcClientError") && + Predicate.hasProperty(failure.value, "reason") && + (Predicate.isTagged(failure.value.reason, "SocketOpenError") || + Predicate.isTagged(failure.value.reason, "SocketReadError") || + Predicate.isTagged(failure.value.reason, "SocketWriteError") || + Predicate.isTagged(failure.value.reason, "SocketCloseError")))) + ) + return { descriptor: descriptor(state), owner: "unreachable" }; + return { descriptor: descriptor(state), owner: "running" }; + } + return { descriptor: descriptor(state), owner: "running", status: status.value }; }); diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 5d59c98826..493534fa5c 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -47,9 +47,6 @@ export class StackStateInvalidError extends Data.TaggedError( export class StackStateFormatUnsupportedError extends Data.TaggedError( "StackStateFormatUnsupportedError", ) {} -export class StackStateGenerationMismatchError extends Data.TaggedError( - "StackStateGenerationMismatchError", -) {} export class StackUpgradeRequiredError extends Data.TaggedError( "StackUpgradeRequiredError", ) {} @@ -74,9 +71,6 @@ export class PortUnavailableError extends Data.TaggedError( export class GatewayAuthenticationError extends Data.TaggedError( "GatewayAuthenticationError", ) {} -export class GatewayStaleGenerationError extends Data.TaggedError( - "GatewayStaleGenerationError", -) {} export class GatewayActivationError extends Data.TaggedError( "GatewayActivationError", ) {} @@ -118,7 +112,6 @@ export const STACK_ERROR_TAGS = [ "StackLifecycleConflictError", "StackStateInvalidError", "StackStateFormatUnsupportedError", - "StackStateGenerationMismatchError", "StackUpgradeRequiredError", "StackUpgradeReplacementError", "StackSecretMismatchError", @@ -126,7 +119,6 @@ export const STACK_ERROR_TAGS = [ "PortAllocationError", "PortUnavailableError", "GatewayAuthenticationError", - "GatewayStaleGenerationError", "GatewayActivationError", "StackPreparationError", "ArtifactIntegrityError", @@ -157,7 +149,6 @@ export type StackError = | StackLifecycleConflictError | StackStateInvalidError | StackStateFormatUnsupportedError - | StackStateGenerationMismatchError | StackUpgradeRequiredError | StackUpgradeReplacementError | StackSecretMismatchError @@ -165,7 +156,6 @@ export type StackError = | PortAllocationError | PortUnavailableError | GatewayAuthenticationError - | GatewayStaleGenerationError | GatewayActivationError | StackPreparationError | ArtifactIntegrityError @@ -183,8 +173,7 @@ export type CreateStackError = | StackRuntimeMismatchError | ContainerEngineError | StackStateInvalidError - | StackStateFormatUnsupportedError - | StackUpgradeRequiredError; + | StackStateFormatUnsupportedError; export type OpenStackError = | StackNotFoundError | StackOwnershipConflictError @@ -200,6 +189,8 @@ export type StackDiscoveryError = | StackStateFormatUnsupportedError; export type StackStatusError = | StackNotFoundError + | StackOwnershipConflictError + | StackLifecycleConflictError | StackStateInvalidError | StackStateFormatUnsupportedError | StackUpgradeRequiredError; @@ -207,17 +198,32 @@ export type StackStatusWatchError = StackStatusError; export type StackCredentialsError = | StackNotFoundError | StackNotRunningError + | StackOwnershipConflictError + | StackLifecycleConflictError | StackSecretMismatchError | InvalidJwtSigningMaterialError; -export type PrepareStackError = StackPreparationError | ArtifactIntegrityError | ContainerPullError; +export type PrepareStackError = + | StackPreparationError + | ArtifactIntegrityError + | ContainerPullError + | StackOwnershipConflictError + | StackStateInvalidError + | StackLifecycleConflictError; export type StackStartError = + | InvalidStackConfigError | StackDefinitionRequiredError | StackVersionUnsupportedError + | StackOwnershipConflictError + | StackNotRunningError + | StackMustBeStoppedError | StackLifecycleConflictError | StackStateInvalidError | StackStateFormatUnsupportedError | StackUpgradeRequiredError | StackSecretMismatchError + | InvalidJwtSigningMaterialError + | PortAllocationError + | PortUnavailableError | StackPreparationError | ArtifactIntegrityError | ContainerPullError @@ -226,16 +232,17 @@ export type StackStartError = | ServiceReadinessError | ContainerEngineError; export type StackRestartError = StackStartError | StackUpgradeReplacementError; -export type StackStopError = +/** Stable maintenance stop reports cleanup failures as lifecycle conflicts with their message. */ +export type StackStopError = StackOwnershipConflictError | StackLifecycleConflictError; +export type StackLogsError = + | StackNotFoundError | StackNotRunningError - | StackLifecycleConflictError - | StackReconciliationError - | ServiceReadinessError - | ContainerEngineError; -export type StackCloseError = StackOwnershipConflictError | StackReconciliationError; -export type StackLogsError = StackNotFoundError | StackNotRunningError | StackStateInvalidError; + | StackStateInvalidError + | StackOwnershipConflictError + | StackLifecycleConflictError; export type DestroyStackError = | StackDestructionError | StackNotFoundError + | StackOwnershipConflictError | StackLifecycleConflictError | ContainerEngineError; diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 356654be82..c395cc6660 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -21,6 +21,7 @@ import { openStack as openEffectStack, type EffectStack, type CreateStackOptions, + type OpenStackOptions, type FindStackOptions, type ListStacksOptions, type PrepareStackOptions, @@ -33,6 +34,7 @@ import type { LogOptions, StackLogEntry } from "./Logs.ts"; import type { StackDescriptor, StackInspection, StackStatus } from "./Status.ts"; import type { StackId } from "./StackId.ts"; import type { PreparedCapability, PrepareStackResult } from "./EffectStack.ts"; +import { StackLifecycleConflictError, InvalidStackConfigError } from "./Errors.ts"; import { StackRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; // Promise methods are the deliberate outer boundary of this package. @@ -40,7 +42,7 @@ import { StackRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "../s // oxlint-disable effecttsgo/any-unknown-in-error-context -- Promise callers receive native rejection values. /** Recursively replaces Effect `Redacted` leaves with their plain value. */ -export type Unredacted = +type Unredacted = T extends Redacted.Redacted ? Unredacted : T extends readonly (infer Item)[] @@ -73,7 +75,7 @@ export interface PromiseStack { export interface PromiseStackApi { readonly createStack: (options: CreateStackOptions) => Promise; - readonly openStack: (id: StackId) => Promise; + readonly openStack: (id: StackId, options?: OpenStackOptions) => Promise; readonly findStack: (options: FindStackOptions) => Promise; readonly listStacks: (options?: ListStacksOptions) => Promise>; readonly inspectStack: (id: StackId) => Promise; @@ -91,12 +93,22 @@ type RuntimeRequirements = // declarations nested in records and arrays. At this explicit Promise boundary, // plain JSON strings become Effect Redacted values for the Effect handle. const stackConfigJsonCodec = Schema.toCodecJson(StackConfigSchema); -const decodePromiseConfig = (input: PromiseStackConfig): StackConfig => - Schema.decodeSync(stackConfigJsonCodec)(input); +const decodePromiseConfig = ( + input: PromiseStackConfig, +): Effect.Effect => + Schema.decodeEffect(stackConfigJsonCodec)(input, { onExcessProperty: "error" }).pipe( + Effect.mapError( + (cause) => + new InvalidStackConfigError({ + message: `Invalid stack config: ${String(cause)}`, + cause, + }), + ), + ); /** Recursively unwraps every Redacted value at the Promise boundary. */ -export function unredact(input: T): Unredacted; -export function unredact(input: unknown): unknown { +function unredact(input: T): Unredacted; +function unredact(input: unknown): unknown { if (Redacted.isRedacted(input)) return unredact(Redacted.value(input)); if (Array.isArray(input)) return input.map(unredact); if (typeof input === "object" && input !== null) { @@ -118,7 +130,7 @@ export const adaptEffectStack = ( let closed = false; let closePromise: Promise | undefined; const active: Set<() => Promise> = new Set(); - const closedError = () => new Error("Stack handle is closed"); + const closedError = () => new StackLifecycleConflictError({ message: "Stack handle is closed" }); const iterable = (stream: Stream.Stream): AsyncIterable => ({ [Symbol.asyncIterator]() { if (closed) { @@ -160,6 +172,15 @@ export const adaptEffectStack = ( if (closed) return Promise.reject(closedError()); return Effect.runPromise(effect); }; + const withConfig = ( + options: { readonly config?: PromiseStackConfig } | undefined, + operation: (config?: StackConfig) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const config = + options?.config === undefined ? undefined : yield* decodePromiseConfig(options.config); + return yield* operation(config); + }); return { id: effectStack.id, status: () => invoke(effectStack.status()), @@ -169,36 +190,33 @@ export const adaptEffectStack = ( ), prepare: (options) => invoke( - effectStack.prepare( - options - ? { - ...options, - config: - options.config === undefined ? undefined : decodePromiseConfig(options.config), - } - : undefined, + withConfig(options, (config) => + effectStack.prepare( + options === undefined + ? undefined + : { + ...(options.capabilities === undefined + ? {} + : { capabilities: options.capabilities }), + ...(config === undefined ? {} : { config }), + }, + ), ), ), start: (options) => invoke( - effectStack.start( - options - ? { - config: - options.config === undefined ? undefined : decodePromiseConfig(options.config), - } - : undefined, + withConfig(options, (config) => + options === undefined + ? effectStack.start() + : effectStack.start(config === undefined ? {} : { config }), ), ), restart: (options) => invoke( - effectStack.restart( - options - ? { - config: - options.config === undefined ? undefined : decodePromiseConfig(options.config), - } - : undefined, + withConfig(options, (config) => + options === undefined + ? effectStack.restart() + : effectStack.restart(config === undefined ? {} : { config }), ), ), stop: () => invoke(effectStack.stop()), @@ -279,7 +297,7 @@ export const makePromiseApi = ( }; return { createStack: (options) => createOrOpen(createEffectStack(options)), - openStack: (id) => createOrOpen(openEffectStack(id)), + openStack: (id, options) => createOrOpen(openEffectStack(id, options)), findStack: (options) => run(findEffectStack(options)).then((value) => Option.getOrUndefined(value)), listStacks: (options) => run(listEffectStacks(options)), diff --git a/packages/stack/src/public/Status.ts b/packages/stack/src/public/Status.ts index 3858cdf16e..15d5e70e4d 100644 --- a/packages/stack/src/public/Status.ts +++ b/packages/stack/src/public/Status.ts @@ -41,6 +41,17 @@ export const PORT_FIELDS = [ ] as const; export type PortField = (typeof PORT_FIELDS)[number]; +export const PORT_FIELD_PROTOCOL: Readonly> = { + api: "http", + database: "tcp", + pooler: "tcp", + studio: "http", + mailUi: "http", + smtp: "tcp", + pop3: "tcp", + functionsInspector: "http", +}; + /** A concrete host/network port. Automatic assignment is represented by listeners, not here. */ export const NetworkPortSchema = Schema.Int.check( Schema.isBetween({ minimum: 1, maximum: 65_535 }), @@ -101,7 +112,6 @@ export const StackStatusSchema = Schema.Struct({ lifecycle: StackLifecycleSchema, desiredLifecycle: DesiredStackLifecycleSchema, runtime: StackRuntimeSchema, - desiredGeneration: Schema.optionalKey(Schema.Finite), endpoints: StackEndpointsSchema, versions: CapabilityVersionsSchema, capabilities: CompleteCapabilityStatusesSchema, @@ -112,7 +122,6 @@ export interface StackStatus { readonly lifecycle: StackLifecycle; readonly desiredLifecycle: DesiredStackLifecycle; readonly runtime: StackRuntime; - readonly desiredGeneration?: number; readonly endpoints: Readonly>>; readonly versions: Readonly>>; readonly capabilities: ReadonlyArray; diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index 83f7b4b62d..315ed7751d 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource builds an isolated root. import { join } from "node:path"; import { NodeServices } from "@effect/platform-node"; +import { Data } from "effect"; import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; import { makePromiseApi, @@ -15,6 +16,10 @@ import { import type { CreateStackOptions } from "./EffectStack.ts"; import type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; +class TestStackReadinessError extends Data.TaggedError("TestStackReadinessError")<{ + readonly message: string; +}> {} + export interface CreateTestStackOptions { readonly config?: PromiseStackConfig; readonly name?: string; @@ -58,6 +63,7 @@ const waitForReadiness = async ( stack: PromiseStack, initial: Awaited>, config: PromiseStackConfig | undefined, + signal?: AbortSignal, ) => { const disabledCapabilities = new Set( Object.entries(config?.capabilities ?? {}).flatMap(([name, capability]) => @@ -88,7 +94,7 @@ const waitForReadiness = async ( ([endpointName, endpoint]) => endpointName === name && endpoint !== undefined, ), ); - const terminalFailure = (status: typeof initial): Error | undefined => { + const terminalFailure = (status: typeof initial): TestStackReadinessError | undefined => { const failed = status.capabilities.find( (capability) => !disabledCapabilities.has(capability.name) && @@ -96,11 +102,12 @@ const waitForReadiness = async ( (status.lifecycle === "running" && capability.state === "stopped")), ); if (failed !== undefined) { - return new Error( - failed.error === undefined - ? `Capability ${failed.name} ${failed.state} before stack became ready` - : failed.error, - ); + return new TestStackReadinessError({ + message: + failed.error === undefined + ? `Capability ${failed.name} ${failed.state} before stack became ready` + : failed.error, + }); } if ( status.lifecycle === "stopped" || @@ -108,19 +115,35 @@ const waitForReadiness = async ( status.lifecycle === "unconfigured" || status.lifecycle === "stopping" ) { - return new Error(`Stack lifecycle ${status.lifecycle} before stack became ready`); + return new TestStackReadinessError({ + message: `Stack lifecycle ${status.lifecycle} before stack became ready`, + }); } return undefined; }; if (ready(initial)) return; const initialFailure = terminalFailure(initial); if (initialFailure !== undefined) throw initialFailure; - for await (const status of stack.watchStatus()) { - if (ready(status)) return; - const failure = terminalFailure(status); - if (failure !== undefined) throw failure; + const iterator = stack.watchStatus()[Symbol.asyncIterator](); + const abort = () => { + void iterator.return?.(); + }; + signal?.addEventListener("abort", abort, { once: true }); + try { + while (true) { + const next = await iterator.next(); + if (next.done) + throw new TestStackReadinessError({ + message: "Stack did not reach running readiness before its status stream ended", + }); + if (ready(next.value)) return; + const failure = terminalFailure(next.value); + if (failure !== undefined) throw failure; + } + } finally { + signal?.removeEventListener("abort", abort); + await iterator.return?.(); } - throw new Error("Stack did not reach running readiness before its status stream ended"); }; const cleanup = async ( @@ -130,10 +153,12 @@ const cleanup = async ( primary?: unknown, ) => { let failure = primary; + let rootCanBeRemoved = true; if (stack !== undefined) { try { await stack.destroy(); } catch (error) { + rootCanBeRemoved = false; if (failure === undefined) failure = error; } try { @@ -142,10 +167,17 @@ const cleanup = async ( if (failure === undefined) failure = error; } } - try { - await operations.removeRoot(root); - } catch (error) { - if (failure === undefined) failure = error; + // Retain the exact root when destroy fails because durable/Docker state may remain recoverable. + if (rootCanBeRemoved) { + try { + await operations.removeRoot(root); + } catch (error) { + if (failure === undefined) failure = error; + } + } + if (!rootCanBeRemoved) { + const reason = failure instanceof Error ? failure.message : String(failure); + throw new Error(`${reason}; retained test stack root ${root}`, { cause: failure }); } if (failure !== undefined) throw failure; }; @@ -172,7 +204,13 @@ export const createTestStackWith = async ( ? undefined : ({ config: options.config } satisfies PromiseStartStackOptions), ); - await waitForReadiness(stack, started, options.config); + const readyStack = stack; + try { + await waitForReadiness(readyStack, started, options.config, AbortSignal.timeout(120_000)); + } catch (error) { + if (error instanceof TestStackReadinessError) throw error; + throw new TestStackReadinessError({ message: String(error) }); + } const resource = stack; return { ...resource, diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 61bb168660..8bb2246163 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -10,13 +10,44 @@ import { Option, Path, Redacted, + Schedule, + Schema, Scope, Stream, } from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import { + defaultRuntimeEnvironment, + ensureSupervisor, + type StackRuntimeEnvironmentValue, +} from "../supervisor/Launcher.ts"; +import { + StackRuntimeEnvironment, + acquireOwnership, + ownerLockExists, + publishOwnership, + readOwnerMetadata, +} from "../state/Ownership.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; +import { toPersistedIdentity } from "../state/StackState.ts"; import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; -import { StackDestructionError, StackUpgradeRequiredError } from "./Errors.ts"; -import { makeHandle } from "./EffectStack.ts"; +import { + StackDestructionError, + StackLifecycleConflictError, + StackOwnershipConflictError, + StackPreparationError, + StackStateInvalidError, +} from "./Errors.ts"; +import { + createStack, + inspectStack, + makeHandle, + openStack, + type EffectStack, +} from "./EffectStack.ts"; import * as effectApi from "../effect.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import { StackIdSchema } from "./StackId.ts"; @@ -50,11 +81,455 @@ const credentials = { }, }; +const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-effect-stack-owner-" }); + yield* Effect.addFinalizer(() => + fs.remove(root, { recursive: true, force: true }).pipe(Effect.ignore), + ); + const project = path.join(root, "project"); + yield* fs.makeDirectory(project); + const defaults = defaultRuntimeEnvironment(); + const runtime: StackRuntimeEnvironmentValue = { + ...defaults, + stateRoot: path.join(root, "managed", "stacks"), + tempRoot: "/tmp", + platform: "posix", + }; + return yield* effect(project).pipe(Effect.provideService(StackRuntimeEnvironment, runtime)); + }), + ).pipe(Effect.provide(NodeServices.layer)); + describe("Effect stack lifecycle handoff", () => { + it.live("creates a stopped stack without launching a Supervisor", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project }); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); + const offline = yield* stack.status(); + expect(offline.lifecycle).toBe("unconfigured"); + expect(offline.capabilities.every(({ state }) => state === "disabled")).toBe(true); + const opened = yield* openStack(stack.id, { replaceIncompatibleOwner: true }); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); + yield* opened.close(); + yield* stack.stop(); + expect(yield* Stream.runCollect(stack.watchStatus())).toHaveLength(1); + expect(yield* Stream.runCollect(stack.logs())).toHaveLength(0); + yield* stack.close(); + }), + ), + ); + + it.live("releases a temporary Supervisor after a pre-commit start failure", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); + + const started = yield* stack + .start({ config: { capabilities: { database: { version: "15" } } } }) + .pipe(Effect.exit); + + expect(Exit.isFailure(started)).toBe(true); + yield* Effect.gen(function* () { + const owner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); + const locked = yield* ownerLockExists(env.stateRoot, stack.id); + if (owner !== undefined || locked) + return yield* new StackOwnershipConflictError({ + message: "temporary Supervisor is still shutting down", + }); + }).pipe(Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 })))); + expect((yield* stack.status()).lifecycle).toBe("unconfigured"); + yield* stack.close(); + }), + ), + ); + + it.live("never projects offline state while metadata or the ownership lock remains", () => + Effect.gen(function* () { + for (const artifact of ["metadata", "lock"] as const) { + const ownership = new StackOwnershipConflictError({ + message: `owner ${artifact} is still present`, + }); + const stack = yield* makeHandle( + stackId, + {}, + { + resolveOwner: () => Effect.succeed(Option.none()), + readOfflineState: () => Effect.fail(ownership), + readLogs: () => Effect.fail(ownership), + }, + ); + const status = yield* stack.status().pipe(Effect.exit); + expect(Exit.isFailure(status)).toBe(true); + const logs = yield* Stream.runCollect(stack.logs()).pipe(Effect.exit); + expect(Exit.isFailure(logs)).toBe(true); + const watch = yield* Stream.runHead(stack.watchStatus()).pipe(Effect.exit); + expect(Exit.isFailure(watch)).toBe(true); + yield* stack.close(); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "guards offline operations against real lock-only and metadata ownership", + () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project }); + const lease = yield* acquireOwnership({ + stateRoot: env.stateRoot, + stackId: stack.id, + ownerSessionId: "offline-guard", + rpcRelease: STACK_RPC_RELEASE, + environment: env, + }); + const assertGuarded = (candidate: EffectStack) => + Effect.gen(function* () { + expect(Exit.isFailure(yield* candidate.status().pipe(Effect.exit))).toBe(true); + expect( + Exit.isFailure(yield* Stream.runCollect(candidate.logs()).pipe(Effect.exit)), + ).toBe(true); + expect( + Exit.isFailure(yield* Stream.runHead(candidate.watchStatus()).pipe(Effect.exit)), + ).toBe(true); + yield* candidate.close(); + }); + const lockOnly = yield* openStack(stack.id); + expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(true); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); + expect((yield* inspectStack(stack.id)).owner).toBe("unreachable"); + yield* assertGuarded(lockOnly); + yield* lease.release; + + const metadataLease = yield* acquireOwnership({ + stateRoot: env.stateRoot, + stackId: stack.id, + ownerSessionId: "offline-metadata", + rpcRelease: STACK_RPC_RELEASE, + environment: env, + }); + yield* publishOwnership(metadataLease); + const metadataOnly = yield* openStack(stack.id); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeDefined(); + yield* assertGuarded(metadataOnly); + yield* stack.close(); + }), + ), + 60_000, + ); + + it.live("waits for server-owned temporary Supervisor shutdown after prepare", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-effect-stack-prepare-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "prepare-session"; + const shutdownReady = yield* Deferred.make(); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + const rpcHandlers: StackRpcHandlers = { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + prepare: () => Effect.fail({ tag: "StackRpcProtocolError", message: "prepare failed" }), + start: () => Effect.succeed(runningStatus), + restart: () => Effect.succeed(runningStatus), + destroy: () => Effect.void, + logs: () => Stream.empty, + watchStatus: () => Stream.empty, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + }, + onShutdownReady: Deferred.succeed(shutdownReady, undefined).pipe(Effect.asVoid), + }); + let launched = false; + const stack = yield* makeHandle( + stackId, + {}, + { + resolveOwner: (launch) => + Effect.sync(() => { + if (launch) launched = true; + return launched ? Option.some(owner) : Option.none(); + }), + waitForRelease: () => Deferred.await(shutdownReady), + }, + ); + const result = yield* stack.prepare().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackPreparationError); + } + yield* stack.close(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("classifies successful prepare by whether the temporary owner remains live", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-effect-stack-prepare-start-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "prepare-start-session"; + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => Effect.succeed(runningStatus), + restart: () => Effect.succeed(runningStatus), + destroy: () => Effect.void, + logs: () => Stream.empty, + watchStatus: () => Stream.empty, + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + }, + }); + for (const retained of [true, false]) { + let launched = false; + const stack = yield* makeHandle( + stackId, + {}, + { + resolveOwner: (launch) => + Effect.sync(() => { + if (launch) { + launched = true; + return Option.some(owner); + } + return retained && launched ? Option.some(owner) : Option.none(); + }), + waitForRelease: () => + Effect.fail( + new StackOwnershipConflictError({ + message: "Supervisor is still shutting down", + }), + ), + }, + ); + + const prepared = yield* stack.prepare().pipe(Effect.exit); + expect(Exit.isSuccess(prepared)).toBe(retained); + if (Exit.isFailure(prepared)) { + const error = Option.getOrUndefined(Cause.findErrorOption(prepared.cause)); + expect(error).toBeInstanceOf(StackOwnershipConflictError); + expect(error?.message).toBe("Supervisor is still shutting down"); + } + yield* stack.close(); + } + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("does not change crashed running intent while preparing", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project }); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const state = yield* store.read(stack.id); + if (state === undefined) return yield* Effect.die("stack state was not initialized"); + yield* store.replace(stack.id, { ...state, desiredLifecycle: "running" }); + + const prepared = yield* stack.prepare().pipe(Effect.exit); + + expect(Exit.isFailure(prepared)).toBe(true); + if (Exit.isFailure(prepared)) { + const error = Option.getOrUndefined(Cause.findErrorOption(prepared.cause)); + expect(error).toBeInstanceOf(StackOwnershipConflictError); + } + expect((yield* store.read(stack.id))?.desiredLifecycle).toBe("running"); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); + yield* stack.close(); + }), + ), + ); + it("does not publish the internal handle through the Effect barrel", () => { expect(Object.hasOwn(effectApi, "makeHandle")).toBe(false); }); + it.live("omits undefined optional RPC payload keys", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-rpc-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "session"; + const payloads: { + prepare: Array; + start: Array; + restart: Array; + } = { + prepare: [], + start: [], + restart: [], + }; + const rpcHandlers: StackRpcHandlers = { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + prepare: (payload) => { + payloads.prepare.push(payload); + return Effect.succeed({ capabilities: [] }); + }, + start: (payload) => { + payloads.start.push(payload); + return Effect.succeed(runningStatus); + }, + restart: (payload) => { + payloads.restart.push(payload); + return Effect.succeed(runningStatus); + }, + destroy: () => Effect.void, + logs: () => Stream.empty, + watchStatus: () => Stream.empty, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + } as const), + stop: Effect.succeed({ ok: true, op: "stop" } as const), + }, + }); + const stack = yield* makeHandle(stackId, { + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }); + yield* stack.prepare(); + yield* stack.start(); + yield* stack.restart(); + yield* stack.prepare({ config: {} }); + yield* stack.start({ config: {} }); + yield* stack.restart({ config: {} }); + expect(payloads.prepare).toEqual([{}, { config: {} }]); + expect(payloads.start).toEqual([{}, { config: {} }]); + expect(payloads.restart).toEqual([{}, { config: {} }]); + yield* stack.close(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("launches a compatible Supervisor when restart finds no live owner", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-restart-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "restart-session"; + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => Effect.succeed(runningStatus), + restart: () => Effect.succeed(runningStatus), + destroy: () => Effect.void, + logs: () => Stream.empty, + watchStatus: () => Stream.empty, + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + }, + }); + let launched = false; + const stack = yield* makeHandle( + stackId, + {}, + { + resolveOwner: (launch) => + Effect.sync(() => { + launched ||= launch; + return launched ? Option.some(owner) : Option.none(); + }), + }, + ); + expect((yield* stack.restart()).lifecycle).toBe("running"); + expect(launched).toBe(true); + yield* stack.close(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("completes destroy after the owner closes its control socket", () => Effect.scoped( Effect.gen(function* () { @@ -84,7 +559,6 @@ describe("Effect stack lifecycle handoff", () => { rpcRelease: STACK_RPC_RELEASE, } as const), stop: Effect.succeed({ ok: true, op: "stop" } as const), - quiesce: Effect.succeed({ ok: true, op: "quiesce" } as const), }; yield* startControlServer({ endpoint, @@ -92,7 +566,7 @@ describe("Effect stack lifecycle handoff", () => { ownerSessionId, rpcHandlers, maintenanceHandlers, - onDestroyResponse: () => Deferred.succeed(responseSent, undefined).pipe(Effect.asVoid), + onShutdownReady: Deferred.succeed(responseSent, undefined).pipe(Effect.asVoid), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); const stack = yield* makeHandle(stackId, { endpoint, @@ -109,6 +583,45 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("continues an explicit replacement after the restart waiter is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const endpoint = { kind: "unix" as const, path: "/tmp/unreachable-replacement.sock" }; + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId: "replacement-owner", + rpcRelease: STACK_RPC_RELEASE, + }; + const stack = yield* makeHandle(stackId, owner, { + replacement: () => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + yield* Deferred.succeed(completed, undefined); + return runningStatus; + }), + }); + const first = yield* Effect.forkChild(stack.restart(), { startImmediately: true }); + yield* Deferred.await(started); + const concurrent = yield* stack.restart().pipe(Effect.exit); + expect(Exit.isFailure(concurrent)).toBe(true); + if (Exit.isFailure(concurrent)) { + const error = Option.getOrUndefined(Cause.findErrorOption(concurrent.cause)); + expect(error).toBeInstanceOf(StackLifecycleConflictError); + } + yield* Fiber.interrupt(first); + yield* Deferred.succeed(release, undefined); + yield* Deferred.await(completed); + yield* stack.close(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("fails destroy when the owner control endpoint is unavailable", () => Effect.scoped( Effect.gen(function* () { @@ -135,69 +648,241 @@ describe("Effect stack lifecycle handoff", () => { ), ); - it.live("surfaces an incompatible RPC release from an existing handle", () => - Effect.scoped( + it.live( + "replaces an incompatible owner only through explicit restart", + () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); + yield* Effect.addFinalizer(() => stack.destroy().pipe(Effect.ignore)); + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + yield* ensureSupervisor({ + identity, + stackId: stack.id, + stateStore: store, + environment: env, + }); + const owner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); + expect(owner).toBeDefined(); + if (owner === undefined) return; + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); + const incompatibleOwner = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown), + )({ + ...owner, + rpcRelease: "stack-rpc-v0@0.0.1", + }).pipe( + Effect.mapError( + (cause) => + new StackDestructionError({ message: "Unable to encode test owner", cause }), + ), + ); + yield* fs.writeFileString(paths.controlMetadata, incompatibleOwner); + + const oldOwner = yield* openStack(stack.id); + expect(Exit.isFailure(yield* oldOwner.status().pipe(Effect.exit))).toBe(true); + yield* oldOwner.close(); + const ordinaryCreate = yield* createStack({ projectRoot: project }); + expect(ordinaryCreate.id).toBe(stack.id); + yield* ordinaryCreate.close(); + const restarted = yield* openStack(stack.id, { replaceIncompatibleOwner: true }); + yield* Effect.addFinalizer(() => restarted.destroy().pipe(Effect.ignore)); + const status = yield* restarted.restart({ config: { capabilities: {} } }); + const currentOwner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); + expect(currentOwner?.rpcRelease).toBe(STACK_RPC_RELEASE); + expect(currentOwner?.ownerSessionId).not.toBe(owner.ownerSessionId); + expect(status.id).toBe(stack.id); + expect(status.runtime).toEqual({ kind: "native" }); + expect((yield* restarted.status()).lifecycle).toBe("running"); + const observed = yield* restarted.watchStatus().pipe(Stream.runHead); + expect(Option.isSome(observed)).toBe(true); + if (Option.isSome(observed)) expect(observed.value.lifecycle).toBe("running"); + const secondStatus = yield* restarted.restart({ config: { capabilities: {} } }); + expect(secondStatus.lifecycle).toBe("running"); + yield* restarted.stop(); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); + expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); + const startedAgain = yield* restarted.start({ config: { capabilities: {} } }); + expect(startedAgain.lifecycle).toBe("running"); + yield* restarted.destroy(); + yield* restarted.close(); + yield* stack.close(); + }), + ), + 240_000, + ); + + it.live( + "reclaims a dead incompatible owner through create and explicit replacement", + () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = yield* StackRuntimeEnvironment; + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const makeDeadOwner = (projectRoot: string, rpcRelease: string) => + Effect.gen(function* () { + const identity = yield* resolveStackIdentity({ projectRoot }); + const id = yield* deriveStackId(identity); + yield* store.initialize(id, { + format: "supabase-stack-state-v1", + identity: toPersistedIdentity(identity, id), + runtime: { kind: "native" }, + desiredLifecycle: "unconfigured", + ports: [], + privatePorts: [], + secrets: {}, + }); + const encodedEnvironment = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown), + )({ + stateRoot: env.stateRoot, + tempRoot: env.tempRoot, + platform: env.platform, + }); + const child = yield* ChildProcess.make( + process.execPath, + [ + "--input-type=module", + "-e", + ` + const { Effect } = await import("effect"); + const { NodeServices } = await import("@effect/platform-node"); + const { acquireOwnership, publishOwnership } = await import(process.env.OWNERSHIP_MODULE); + const environment = JSON.parse(process.env.OWNERSHIP_ENVIRONMENT); + await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const lease = yield* acquireOwnership({ + stateRoot: environment.stateRoot, + stackId: process.env.OWNERSHIP_STACK_ID, + ownerSessionId: "crashed-owner", + rpcRelease: process.env.OWNERSHIP_RPC_RELEASE, + environment, + }); + yield* publishOwnership(lease); + process.stdout.write("READY\\n"); + yield* Effect.never; + }).pipe(Effect.provide(NodeServices.layer)))); + `, + ], + { + cwd: process.cwd(), + env: { + OWNERSHIP_MODULE: new URL("../state/Ownership.ts", import.meta.url).href, + OWNERSHIP_STACK_ID: id, + OWNERSHIP_RPC_RELEASE: rpcRelease, + OWNERSHIP_ENVIRONMENT: encodedEnvironment, + }, + extendEnv: true, + stdout: "pipe", + stderr: "pipe", + }, + ); + const ready = yield* Deferred.make(); + const output = yield* child.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => + line === "READY" + ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkChild({ startImmediately: true }), + ); + const stderr = yield* child.stderr.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runDrain, + Effect.forkChild({ startImmediately: true }), + ); + try { + yield* Deferred.await(ready).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail(new StackStateInvalidError({ message: "owner child not ready" })), + }), + ); + expect(yield* ownerLockExists(env.stateRoot, id)).toBe(true); + yield* child.kill({ killSignal: "SIGKILL" }); + yield* child.exitCode.pipe(Effect.ignore); + return id; + } finally { + yield* child.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore); + yield* Fiber.interrupt(output); + yield* Fiber.interrupt(stderr); + } + }); + + const createProject = path.join(project, "create"); + const openProject = path.join(project, "open"); + yield* fs.makeDirectory(createProject); + yield* fs.makeDirectory(openProject); + const createId = yield* makeDeadOwner(createProject, "stack-rpc-v0@0.0.1"); + const created = yield* createStack({ projectRoot: createProject }); + yield* Effect.addFinalizer(() => created.destroy().pipe(Effect.ignore)); + expect(created.id).toBe(createId); + // Creating a handle is intentionally lightweight; prepare probes the + // stale owner, reclaims it, and cleans up its temporary Supervisor. + yield* created.prepare({ + capabilities: [], + config: { listeners: { api: { enabled: false } } }, + }); + expect(yield* readOwnerMetadata(env.stateRoot, createId, env)).toBeUndefined(); + expect(yield* ownerLockExists(env.stateRoot, createId)).toBe(false); + yield* created.close(); + + const openId = yield* makeDeadOwner(openProject, "stack-rpc-v0@0.0.1"); + const replaced = yield* openStack(openId, { replaceIncompatibleOwner: true }); + yield* Effect.addFinalizer(() => replaced.destroy().pipe(Effect.ignore)); + yield* replaced.restart({ config: { capabilities: {} } }); + expect((yield* readOwnerMetadata(env.stateRoot, openId, env))?.rpcRelease).toBe( + STACK_RPC_RELEASE, + ); + yield* replaced.destroy(); + yield* replaced.close(); + }), + ), + 300_000, + ); + + it.live("accepts maintenance stop requests from an older RPC owner", () => + withRuntimeRoot((project) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-" }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "session"; - const rpcHandlers: StackRpcHandlers = { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - prepare: () => Effect.succeed({ capabilities: [] }), - start: () => Effect.succeed(runningStatus), - restart: () => Effect.succeed(runningStatus), - destroy: () => Effect.void, - logs: () => Stream.empty, - watchStatus: () => Stream.empty, - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcRelease: "stack-rpc-v0@0.0.1", - rpcHandlers, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: "stack-rpc-v0@0.0.1", - } as const), - stop: Effect.succeed({ ok: true, op: "stop" } as const), - quiesce: Effect.succeed({ ok: true, op: "quiesce" } as const), - }, + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + yield* ensureSupervisor({ + identity, + stackId: stack.id, + stateStore: store, + environment: env, }); - const stack = yield* makeHandle(stackId, { - endpoint, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }); - const result = yield* stack.status().pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Cause.findErrorOption(result.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StackUpgradeRequiredError); - expect(error.value.message).toContain("stack-rpc-v0@0.0.1"); - } - } - const streamResult = yield* stack.watchStatus().pipe(Stream.runHead, Effect.exit); - expect(Exit.isFailure(streamResult)).toBe(true); - if (Exit.isFailure(streamResult)) { - const error = Cause.findErrorOption(streamResult.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(StackUpgradeRequiredError); - expect(error.value.message).toContain("stack-rpc-v0@0.0.1"); - } - } + const owner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); + expect(owner).toBeDefined(); + if (owner === undefined) return; + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); + const incompatibleOwner = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))( + { + ...owner, + rpcRelease: "stack-rpc-v0@0.0.1", + }, + ).pipe( + Effect.mapError( + (cause) => new StackDestructionError({ message: "Unable to encode test owner", cause }), + ), + ); + yield* fs.writeFileString(paths.controlMetadata, incompatibleOwner); + const oldOwnerHandle = yield* openStack(stack.id); + yield* oldOwnerHandle.stop(); + yield* oldOwnerHandle.close(); yield* stack.close(); - }).pipe(Effect.provide(NodeServices.layer)), + }), ), ); }); diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index bd911e2d46..d32838e2f5 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -13,6 +13,7 @@ export type { StartStackOptions, PrepareStackOptions, CreateStackOptions, + OpenStackOptions, FindStackOptions, ListStacksOptions, PreparedCapability, diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index 9b27198030..95ede2d8f4 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -7,7 +7,11 @@ import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./Effe import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; import type { StackLogEntry } from "./Logs.ts"; -import { StackStateInvalidError } from "./Errors.ts"; +import { + InvalidStackConfigError, + StackLifecycleConflictError, + StackStateInvalidError, +} from "./Errors.ts"; import { adaptEffectStack, type PromiseStack } from "./PromiseStack.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -108,14 +112,24 @@ describe("Promise stack facade", () => { const logsIterator = stack.logs()[Symbol.asyncIterator](); await expect(logsIterator.next()).resolves.toMatchObject({ done: true }); await stack.close(); - await expect(stack.watchStatus()[Symbol.asyncIterator]().next()).rejects.toThrow( - "Stack handle is closed", + await expect(stack.watchStatus()[Symbol.asyncIterator]().next()).rejects.toBeInstanceOf( + StackLifecycleConflictError, ); - await expect(stack.logs()[Symbol.asyncIterator]().next()).rejects.toThrow( - "Stack handle is closed", + await expect(stack.logs()[Symbol.asyncIterator]().next()).rejects.toBeInstanceOf( + StackLifecycleConflictError, ); }); + it("rejects malformed configs asynchronously with a tagged error", async () => { + const stack = adaptEffectStack(effectStack()); + await expect( + stack.start({ + config: JSON.parse('{"capabilities":{"rest":{"settings":{"unknown":true}}}}'), + }), + ).rejects.toBeInstanceOf(InvalidStackConfigError); + await stack.close(); + }); + it("redacts nested config secrets for prepare, start, and restart", async () => { let preparedConfig: StartStackOptions["config"] | undefined; let startedConfig: StartStackOptions["config"] | undefined; diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index d246409ae7..fc73dda0a8 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -113,7 +113,7 @@ const fakeStack = ( }); describe("test stack resource", () => { - it("starts with default-disabled pooler readiness and destroys only its owned identity", async () => { + it("starts automatically and destroys only its owned identity", async () => { const events: Array = []; const removed: Array = []; const operations: TestStackOperations = { @@ -132,7 +132,7 @@ describe("test stack resource", () => { expect(removed).toEqual(["/tmp/stack-test-owned"]); }); - it("preserves startup failure while cleaning up destroy, close, and temporary root", async () => { + it("preserves startup failure while retaining the root when destroy fails", async () => { const events: Array = []; const removed: Array = []; const operations: TestStackOperations = { @@ -142,9 +142,11 @@ describe("test stack resource", () => { removed.push(root); }, }; - await expect(createTestStackWith({}, operations)).rejects.toThrow("startup failed"); + await expect(createTestStackWith({}, operations)).rejects.toThrow( + "startup failed; retained test stack root /tmp/stack-test-failed", + ); expect(events).toEqual(["start", "destroy", "close"]); - expect(removed).toEqual(["/tmp/stack-test-failed"]); + expect(removed).toEqual([]); }); it("fails and cleans up when readiness status ends before becoming ready", async () => { diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index f8407e2438..f60513efff 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -7,9 +7,13 @@ // oxlint-disable effecttsgo/global-date -- query windows use host timestamps. // oxlint-disable effecttsgo/node-builtin-import -- E2E setup writes a real project with host fs/path APIs. import { PgClient } from "@effect/sql-pg"; -import { Data, Effect, Redacted, Schedule } from "effect"; -import { mkdir, writeFile } from "node:fs/promises"; +import { Data, Effect, Redacted, Schedule, type Duration } from "effect"; +import { execFile as execFileCallback } from "node:child_process"; +import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { connect as connectTcp } from "node:net"; import { join } from "node:path"; +import { promisify } from "node:util"; +import { WebSocket } from "ws"; import { describe, expect, test } from "vitest"; import { createTestStack, type TestStack } from "../testing.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; @@ -20,19 +24,90 @@ import type { StackEndpoint, StackStatus } from "./Status.ts"; const E2E_TIMEOUT_MS = 15 * 60_000; const REQUEST_TIMEOUT_MS = 60_000; -// Studio's first lazy request can start four workloads sequentially (4 × 30s). -const LAZY_STUDIO_ACTIVATION_TIMEOUT_MS = 180_000; +const execFile = promisify(execFileCallback); const ANALYTICS_QUERY_RETRY_SCHEDULE = Schedule.spaced("1 second").pipe( Schedule.upTo({ duration: "3 minutes" }), ); const MAILPIT_DELIVERY_RETRY_SCHEDULE = Schedule.spaced("250 millis").pipe( Schedule.upTo({ duration: "30 seconds" }), ); +// Studio's first lazy request can start four workloads sequentially (4 × 30s). +const LAZY_STUDIO_ACTIVATION_TIMEOUT_MS = 180_000; +// Pooler is activated by the first TCP connection and may take longer than the normal +// query deadline while its migration and tenant bootstrap processes run. +const LAZY_POOLER_ACTIVATION_TIMEOUT = "30 seconds"; const RUNTIME_CASES = [ { name: "native", runtime: { kind: "native" as const } }, { name: "Docker", runtime: { kind: "container" as const, engine: "docker" as const } }, ] as const; +const dockerOwnedResourceCount = async ( + stackId: string, + kind: "containers" | "networks" | "volumes", +): Promise => { + const args = + kind === "containers" + ? ["ps", "-aq", "--filter", `label=com.supabase.stack.stackId=${stackId}`] + : [ + kind === "networks" ? "network" : "volume", + "ls", + "-q", + "--filter", + `label=com.supabase.stack.stackId=${stackId}`, + ]; + const result = await execFile("docker", args, { encoding: "utf8" }); + return result.stdout.trim().length === 0 ? 0 : result.stdout.trim().split("\n").length; +}; + +const supervisorPid = async (stackId: string): Promise => { + const result = await execFile("ps", ["-axo", "pid=,command="], { encoding: "utf8" }); + const matches = result.stdout.split("\n").flatMap((line) => { + const match = /^\s*(\d+)\s+(.+)$/u.exec(line); + if (match === null) return []; + const pid = Number(match[1]); + const command = match[2] ?? ""; + return command.includes(stackId) && + (command.includes("supervisor-node.ts") || command.includes("__supabase_stack_supervisor__")) + ? [pid] + : []; + }); + if (matches.length !== 1) + throw new Error(`Expected one Supervisor process for ${stackId}, found ${matches.length}`); + const pid = matches[0]; + if (pid === undefined) throw new Error(`Supervisor process for ${stackId} has no PID`); + return pid; +}; + +const waitForProcessExit = (pid: number): Promise => { + const exited = Effect.try({ + try: () => { + try { + process.kill(pid, 0); + } catch (cause) { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ESRCH" + ) + return; + throw cause; + } + throw new Error(`Process ${pid} is still running`); + }, + catch: (cause) => + new E2ERequestError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + return Effect.runPromise( + Effect.retry(exited, { + schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "15 seconds" })), + }), + ); +}; + type JsonObject = Record; class E2ERequestError extends Data.TaggedError("E2ERequestError")<{ @@ -43,12 +118,65 @@ class E2ERequestError extends Data.TaggedError("E2ERequestError")<{ const isJsonObject = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value); +const websocketDataText = (data: WebSocket.RawData): string => { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + return Buffer.concat(data).toString("utf8"); +}; + const endpoint = (status: StackStatus, name: keyof StackStatus["endpoints"]): StackEndpoint => { const value = status.endpoints[name]; if (value === undefined) throw new Error(`Expected ${name} listener in stack status`); return value; }; +const capabilityState = (status: StackStatus, name: string): string | undefined => + status.capabilities.find((capability) => capability.name === name)?.state; + +const expectDefaultLazyState = (status: StackStatus): void => { + expect(status.lifecycle).toBe("running"); + expect(capabilityState(status, "database")).toBe("ready"); + for (const name of CAPABILITY_NAMES) { + if (name === "database") continue; + expect(capabilityState(status, name), `${name} should remain dormant`).toBe("dormant"); + } +}; + +/** Wait for one capability transition while subscribing before sending traffic. */ +const activate = async ( + stack: TestStack, + name: string, + action: () => Promise, +): Promise => { + const before = await stack.status(); + expect(capabilityState(before, name), `${name} should be dormant before activation`).toBe( + "dormant", + ); + const iterator = stack.watchStatus()[Symbol.asyncIterator](); + const first = iterator.next(); + void first.catch(() => undefined); + const waitUntilReady = async (): Promise => { + let next = await first; + while (!next.done) { + if (capabilityState(next.value, name) === "ready") { + return; + } + next = await iterator.next(); + } + const current = await stack.status(); + if (capabilityState(current, name) === "ready") return; + throw new Error(`Capability ${name} did not become ready after traffic`); + }; + try { + const result = await action(); + await waitUntilReady(); + return result; + } finally { + await iterator.return?.(); + } +}; + const request = async (base: string, path: string, init: RequestInit = {}): Promise => { const url = new URL(path, `${base.replace(/\/$/u, "")}/`); const method = init.method ?? "GET"; @@ -69,6 +197,42 @@ const request = async (base: string, path: string, init: RequestInit = {}): Prom return response; }; +const connect = (endpoint: StackEndpoint): Promise => + new Promise((resolve, reject) => { + const socket = connectTcp(endpoint.port, endpoint.address); + const fail = (cause: unknown) => { + socket.destroy(); + reject(cause instanceof Error ? cause : new Error(String(cause))); + }; + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("error", fail); + socket.setTimeout(2_000, () => fail(new Error("TCP connection timed out"))); + }); + +const expectEndpointsRefused = async (endpoints: ReadonlyArray): Promise => { + for (const listener of endpoints) { + await expect( + connect(listener), + `${listener.protocol} ${listener.url} should be closed`, + ).rejects.toThrow(); + } +}; + +const expectRuntimeInputsAbsent = async (projectRoot: string, stackId: string): Promise => { + const stackRoot = join(projectRoot, ".supabase", "managed", "stacks", stackId); + for (const relativePath of ["runtime/env", "runtime/inputs", "runtime/functions"]) { + const path = join(stackRoot, relativePath); + try { + expect(await readdir(path), `${path} should be empty after cleanup`).toHaveLength(0); + } catch (cause) { + if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")) throw cause; + } + } +}; + const throwStudioProfileDiagnostics = async (stack: TestStack, cause: unknown): Promise => { let status: StackStatus | undefined; try { @@ -112,22 +276,35 @@ const databaseQuery = async ( url: string, statement: string, parameters: ReadonlyArray = [], -): Promise> => - Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const client = yield* PgClient.PgClient; - return yield* client.unsafe(statement, parameters); - }).pipe( - Effect.provide( - PgClient.layer({ - url: Redacted.make(url), - connectTimeout: "10 seconds", - }), + options: Readonly<{ readonly connectTimeout?: Duration.Input }> = {}, +): Promise> => { + try { + return await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const client = yield* PgClient.PgClient; + return yield* client.unsafe(statement, parameters); + }).pipe( + Effect.provide( + PgClient.layer({ + url: Redacted.make(url), + connectTimeout: options.connectTimeout ?? "10 seconds", + }), + ), ), ), - ), - ); + ); + } catch (cause) { + let target = ""; + try { + const parsed = new URL(url); + target = `${parsed.protocol}//${parsed.hostname}:${parsed.port || "default"}${parsed.pathname}`; + } catch { + // Keep diagnostics safe even when the connection URL is malformed. + } + throw new Error(`databaseQuery failed for ${target}: ${statement}`, { cause }); + } +}; const apiHeaders = ( credentials: PromiseStackCredentials, @@ -178,9 +355,9 @@ const waitForSocket = ( let settled = false; const cleanup = () => { clearTimeout(timeout); - socket.removeEventListener("message", onMessage); - socket.removeEventListener("error", onError); - socket.removeEventListener("close", onClose); + socket.removeListener("message", onMessage); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); }; const finish = (error: Error | undefined, value?: JsonObject) => { if (settled) return; @@ -193,9 +370,9 @@ const waitForSocket = ( () => finish(new Error("Timed out waiting for Realtime message")), REQUEST_TIMEOUT_MS, ); - const onMessage = (event: MessageEvent) => { + const onMessage = (data: WebSocket.RawData) => { try { - const value: unknown = JSON.parse(String(event.data)); + const value: unknown = JSON.parse(websocketDataText(data)); if (!isJsonObject(value)) return; if (!predicate(value)) return; finish(undefined, Object.fromEntries(Object.entries(value))); @@ -205,26 +382,19 @@ const waitForSocket = ( }; const onError = () => finish(new Error("Realtime WebSocket failed while waiting")); const onClose = () => finish(new Error("Realtime WebSocket closed while waiting")); - socket.addEventListener("message", onMessage); - socket.addEventListener("error", onError); - socket.addEventListener("close", onClose); + socket.on("message", onMessage); + socket.on("error", onError); + socket.on("close", onClose); }); const openSocket = (url: string): Promise => new Promise((resolve, reject) => { - const socket = new WebSocket(url); - const timeout = setTimeout(() => { - socket.close(); - reject(new Error("Timed out opening Realtime WebSocket")); - }, REQUEST_TIMEOUT_MS); - socket.addEventListener("open", () => { - clearTimeout(timeout); - resolve(socket); - }); - socket.addEventListener("error", () => { - clearTimeout(timeout); - reject(new Error("Realtime WebSocket failed to open")); + const socket = new WebSocket(url, { + handshakeTimeout: REQUEST_TIMEOUT_MS, + perMessageDeflate: false, }); + socket.once("open", () => resolve(socket)); + socket.once("error", (cause) => reject(cause)); }); const makeRealtimeUrl = (api: StackEndpoint, apikey: string): string => { @@ -242,52 +412,23 @@ const onePixelPng = Uint8Array.from( (character) => character.charCodeAt(0), ); -const stackConfig = ( +const optionalWorkloadConfig = ( functionSlug: string, analyticsApiKey: string, - poolerTenantId: string, ): PromiseStackConfig => ({ capabilities: { - database: { version: "17.6.1.167" }, - rest: { enabled: true, activation: "lazy" }, - auth: { - enabled: true, - settings: { - enable_signup: true, - email: { enable_signup: true, enable_confirmations: false }, - }, - }, - realtime: { enabled: true }, - storage: { enabled: true, settings: { image_transformation: { enabled: true } } }, - functions: { - enabled: true, - settings: { - functions_root: "supabase/functions", - functions: { [functionSlug]: { enabled: true, verify_jwt: false } }, - }, - }, - studio: { enabled: true, activation: "lazy" }, - mail: { enabled: true }, - analytics: { enabled: true, settings: { vector_port: 9001, api_key: analyticsApiKey } }, - pooler: { enabled: true, settings: { tenant_id: poolerTenantId } }, - }, - listeners: { - api: { enabled: true }, - database: { enabled: true }, - pooler: { enabled: true }, - studio: { enabled: true }, - mailUi: { enabled: true }, - smtp: { enabled: true }, - pop3: { enabled: true }, - functionsInspector: { enabled: true }, + storage: { settings: { image_transformation: { enabled: true } } }, + functions: { settings: { functions: { [functionSlug]: { verify_jwt: false } } } }, + analytics: { settings: { vector_port: 9001, api_key: analyticsApiKey } }, }, + listeners: { smtp: { enabled: true } }, }); const queryAnalyticsMarker = async ( api: StackEndpoint, analyticsApiKey: string, marker: string, -): Promise<{ readonly count: number; readonly response?: JsonObject; readonly error?: string }> => { +): Promise => { const query = new URLSearchParams({ project: "default", iso_timestamp_start: new Date(Date.now() - 3_600_000).toISOString(), @@ -297,38 +438,25 @@ const queryAnalyticsMarker = async ( marker + "')", }); - let response: JsonObject | undefined; - let lastError: string | undefined; const attempt = Effect.tryPromise({ try: async () => { - response = await jsonObject( + const response = await jsonObject( await request(api.url, "/analytics/v1/api/endpoints/query/logs.all?" + query, { headers: { "x-api-key": analyticsApiKey }, }), ); const rows = response.result; const count = Array.isArray(rows) && isJsonObject(rows[0]) ? Number(rows[0].c) : 0; - if (count <= 0) throw new Error("Analytics query pending (count=" + count + ")"); - return response; - }, - catch: (cause) => { - const reason = cause instanceof Error ? cause.message : String(cause); - lastError = reason; - return new E2ERequestError({ message: reason, cause }); + if (count <= 0) throw new Error(`Analytics query pending (count=${count})`); + return count; }, + catch: (cause) => + new E2ERequestError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), }); - try { - await Effect.runPromise(Effect.retry(attempt, { schedule: ANALYTICS_QUERY_RETRY_SCHEDULE })); - } catch { - // The returned diagnostics identify the final response and error. - } - const rows = response?.result; - const count = Array.isArray(rows) && isJsonObject(rows[0]) ? Number(rows[0].c) : 0; - return { - count, - ...(response === undefined ? {} : { response }), - ...(lastError === undefined ? {} : { error: lastError }), - }; + return Effect.runPromise(Effect.retry(attempt, { schedule: ANALYTICS_QUERY_RETRY_SCHEDULE })); }; const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Promise => { @@ -339,14 +467,11 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const email = `${identity}@example.test`; const password = "SupabaseStackE2e!123"; const markers = { first: `first-${identity}`, second: `second-${identity}` }; - const analyticsApiKey = `analytics-key-${identity}`; - const poolerTenantId = `pooler-${identity}`; let projectRoot = ""; await using stack: TestStack = await createTestStack({ name: `stack-e2e-${identity}`, runtime: mode.runtime, - config: stackConfig(functionSlug, analyticsApiKey, poolerTenantId), setupProject: async (root) => { projectRoot = root; const directory = join(root, "supabase", "functions", functionSlug); @@ -361,11 +486,13 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const pooler = endpoint(initial, "pooler"); const studio = endpoint(initial, "studio"); const mailUi = endpoint(initial, "mailUi"); + const initialEndpoints = Object.values(initial.endpoints).filter( + (value): value is StackEndpoint => value !== undefined, + ); expect(initial.runtime).toEqual(mode.runtime); expect(projectRoot.length).toBeGreaterThan(0); - expect(initial.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); - expect(initial.capabilities.find(({ name }) => name === "studio")?.state).toBe("dormant"); + expectDefaultLazyState(initial); // Direct SQL creates the table and enables Realtime's publication for it. await databaseQuery( @@ -389,20 +516,26 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom // PostgREST reads the SQL-created row, then writes through the authenticated user token. const restPath = `/rest/v1/${table}?select=id,payload&order=id`; - const restRows = await jsonValue( - await request(api.url, restPath, { - headers: { ...apiHeaders(credentials), Accept: "application/json" }, - }), - ); + let restRows: unknown = undefined; + await activate(stack, "rest", async () => { + restRows = await jsonValue( + await request(api.url, restPath, { + headers: { ...apiHeaders(credentials), Accept: "application/json" }, + }), + ); + }); expect(restRows).toEqual(expect.arrayContaining([{ id: 1, payload: markers.first }])); - const signup = await jsonObject( - await request(api.url, "/auth/v1/signup", { - method: "POST", - headers: { ...apiHeaders(credentials), "content-type": "application/json" }, - body: JSON.stringify({ email, password }), - }), - ); + let signup: JsonObject = {}; + await activate(stack, "auth", async () => { + signup = await jsonObject( + await request(api.url, "/auth/v1/signup", { + method: "POST", + headers: { ...apiHeaders(credentials), "content-type": "application/json" }, + body: JSON.stringify({ email, password }), + }), + ); + }); const accessToken = signup.access_token; expect(typeof accessToken).toBe("string"); if (typeof accessToken !== "string") @@ -424,7 +557,19 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom ); // Realtime receives the next Postgres change over the same public API listener. - const socket = await openSocket(makeRealtimeUrl(api, credentials.api.publishableKey)); + let openedSocket: WebSocket | undefined; + const socket = await (async (): Promise => { + try { + return await activate(stack, "realtime", async () => { + const candidate = await openSocket(makeRealtimeUrl(api, credentials.api.publishableKey)); + openedSocket = candidate; + return candidate; + }); + } catch (cause) { + openedSocket?.close(); + throw cause; + } + })(); const socketWaiters: Array> = []; try { const joined = waitForSocket(socket, (value) => { @@ -478,32 +623,31 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom } // Storage exercises the lazy Storage workload and its image-transform companion. - await request(api.url, "/storage/v1/bucket", { - method: "POST", - headers: { ...serviceHeaders(credentials), "content-type": "application/json" }, - body: JSON.stringify({ id: bucket, name: bucket, public: true }), - }); - await request(api.url, `/storage/v1/object/${bucket}/pixel.png`, { - method: "POST", - headers: { ...serviceHeaders(credentials), "content-type": "image/png" }, - body: new Blob([onePixelPng], { type: "image/png" }), - }); - const downloaded = await request(api.url, `/storage/v1/object/public/${bucket}/pixel.png`, { - headers: serviceHeaders(credentials), + await activate(stack, "storage", async () => { + await request(api.url, "/storage/v1/bucket", { + method: "POST", + headers: { ...serviceHeaders(credentials), "content-type": "application/json" }, + body: JSON.stringify({ id: bucket, name: bucket, public: true }), + }); + await request(api.url, `/storage/v1/object/${bucket}/pixel.png`, { + method: "POST", + headers: { ...serviceHeaders(credentials), "content-type": "image/png" }, + body: new Blob([onePixelPng], { type: "image/png" }), + }); + const downloaded = await request(api.url, `/storage/v1/object/public/${bucket}/pixel.png`, { + headers: serviceHeaders(credentials), + }); + expect((await downloaded.arrayBuffer()).byteLength).toBeGreaterThan(0); }); - expect((await downloaded.arrayBuffer()).byteLength).toBeGreaterThan(0); - const transformed = await request( - api.url, - `/storage/v1/render/image/public/${bucket}/pixel.png?width=1&height=1`, - { headers: serviceHeaders(credentials) }, - ); - expect((await transformed.arrayBuffer()).byteLength).toBeGreaterThan(0); // Functions are request-time discovered and call REST through SUPABASE_URL. const functionPath = `/functions/v1/${functionSlug}`; - const firstFunction = await jsonObject( - await request(api.url, functionPath, { headers: apiHeaders(credentials) }), - ); + let firstFunction: JsonObject = {}; + await activate(stack, "functions", async () => { + firstFunction = await jsonObject( + await request(api.url, functionPath, { headers: apiHeaders(credentials) }), + ); + }); expect(firstFunction).toEqual( expect.objectContaining({ marker: markers.first, @@ -524,78 +668,403 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom }), ); - // Recovery email traverses Auth → SMTP → Mailpit, then is observable via Mailpit's API. - await request(api.url, "/auth/v1/recover", { - method: "POST", - headers: { ...apiHeaders(credentials), "content-type": "application/json" }, - body: JSON.stringify({ email }), + // Mailpit UI traffic lazily activates the mail capability. SMTP delivery is + // covered by the explicit SMTP configuration scenario below. + await activate(stack, "mail", async () => { + await request(mailUi.url, "/api/v1/messages?limit=100"); }); - const mailAttempt = Effect.tryPromise({ - try: async () => { - const messages = await request(mailUi.url, "/api/v1/messages?limit=100"); - const body = await messages.text(); - if (!body.includes(email)) throw new Error(`Mailpit delivery pending for ${email}`); - return body; - }, - catch: (cause) => - new E2ERequestError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); - await Effect.runPromise(Effect.retry(mailAttempt, { schedule: MAILPIT_DELIVERY_RETRY_SCHEDULE })); // Studio's profile endpoint lazily activates Studio and its transitive dependencies. + // Analytics is activated first so Studio does not hide that assertion. + await activate(stack, "analytics", async () => { + await request(api.url, "/analytics/v1/health"); + }); try { - await request(studio.url, "/api/platform/profile", { - headers: serviceHeaders(credentials), - signal: AbortSignal.timeout(LAZY_STUDIO_ACTIVATION_TIMEOUT_MS), + await activate(stack, "studio", async () => { + await request(studio.url, "/api/platform/profile", { + headers: serviceHeaders(credentials), + signal: AbortSignal.timeout(LAZY_STUDIO_ACTIVATION_TIMEOUT_MS), + }); }); } catch (cause) { await throwStudioProfileDiagnostics(stack, cause); } - // Analytics exercises health, authenticated ingestion, and query through the public API route; - // vector is a readiness dependency selected by the configured vector_port above. - await request(api.url, "/analytics/v1/health"); - const analyticsMarker = `analytics-${identity}`; - await request(api.url, "/analytics/v1/logs?source_name=postgres.logs", { - method: "POST", - headers: { "x-api-key": analyticsApiKey, "content-type": "application/json" }, - body: JSON.stringify({ - event_message: analyticsMarker, - project: "default", - metadata: { source: "stack-e2e" }, - }), - }); - const analyticsResult = await queryAnalyticsMarker(api, analyticsApiKey, analyticsMarker); - expect( - analyticsResult.count, - `Analytics query did not observe ${analyticsMarker}; last response: ${JSON.stringify(analyticsResult.response ?? null)}; last error: ${analyticsResult.error ?? "none"}`, - ).toBeGreaterThan(0); - const vectorResult = await queryAnalyticsMarker(api, analyticsApiKey, "supabase-stack-vector"); - expect( - vectorResult.count, - `Analytics query did not observe Vector marker; last response: ${JSON.stringify(vectorResult.response ?? null)}; last error: ${vectorResult.error ?? "none"}`, - ).toBeGreaterThan(0); - // Pooler is a separate public TCP listener over the same database credentials. const poolerUrl = new URL(credentials.database.url); poolerUrl.port = String(pooler.port); - poolerUrl.username = `postgres.${poolerTenantId}`; - const poolerRows = await databaseQuery(poolerUrl.toString(), "SELECT 42 AS answer"); + poolerUrl.username = "postgres.pooler-dev"; + let poolerRows: ReadonlyArray = []; + await activate(stack, "pooler", async () => { + poolerRows = await databaseQuery(poolerUrl.toString(), "SELECT 42 AS answer", [], { + connectTimeout: LAZY_POOLER_ACTIVATION_TIMEOUT, + }); + }); expect(poolerRows).toEqual([{ answer: 42 }]); + const ready = await stack.status(); + expect(ready.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( + CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), + ); + const idempotentStart = await stack.start(); + expect(idempotentStart.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( + CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), + ); + + const endpointSnapshot = Object.fromEntries( + Object.entries(ready.endpoints).map(([name, value]) => [name, value?.port]), + ); + const persistedMarker = await databaseQuery( + credentials.database.url, + `SELECT payload FROM public."${table}" WHERE id = 1`, + ); + expect(persistedMarker).toEqual([{ payload: markers.first }]); + const volumesBeforeStop = + mode.runtime.kind === "container" + ? await dockerOwnedResourceCount(initial.id, "volumes") + : undefined; + + const reactivate = async (): Promise => { + await activate(stack, "rest", async () => { + await request(api.url, restPath, { headers: apiHeaders(credentials) }); + }); + await activate(stack, "auth", async () => { + await request(api.url, "/auth/v1/settings", { headers: apiHeaders(credentials) }); + }); + await activate(stack, "realtime", async () => { + const probe = await openSocket(makeRealtimeUrl(api, credentials.api.publishableKey)); + probe.close(); + }); + await activate(stack, "storage", async () => { + await request(api.url, "/storage/v1/bucket", { headers: serviceHeaders(credentials) }); + }); + await activate(stack, "functions", async () => { + await request(api.url, functionPath, { headers: apiHeaders(credentials) }); + }); + await activate(stack, "mail", async () => { + await request(mailUi.url, "/api/v1/messages?limit=1"); + }); + await activate(stack, "analytics", async () => { + await request(api.url, "/analytics/v1/health"); + }); + await activate(stack, "studio", async () => { + await request(studio.url, "/api/platform/profile", { + headers: serviceHeaders(credentials), + signal: AbortSignal.timeout(LAZY_STUDIO_ACTIVATION_TIMEOUT_MS), + }); + }); + await activate(stack, "pooler", async () => { + await databaseQuery(poolerUrl.toString(), "SELECT 42 AS answer", [], { + connectTimeout: LAZY_POOLER_ACTIVATION_TIMEOUT, + }); + }); + }; + + try { + await stack.stop(); + } catch (cause) { + let diagnostic: StackStatus | undefined; + try { + diagnostic = await stack.status(); + } catch { + // Preserve the original stop failure if the owner is already gone. + } + const reason = cause instanceof Error ? cause.message : String(cause); + const states = + diagnostic === undefined + ? "unavailable" + : diagnostic.capabilities + .map( + ({ name, state, error }) => + `${name}=${state}${error === undefined ? "" : ` (${error})`}`, + ) + .join(", "); + throw new Error( + `Stack stop failed: ${reason}; lifecycle=${diagnostic?.lifecycle ?? "unknown"}; ${states}`, + { + cause, + }, + ); + } + const stopped = await stack.status(); + expect(stopped.lifecycle).toBe("stopped"); + expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); + const offlineStatuses: StackStatus[] = []; + for await (const status of stack.watchStatus()) offlineStatuses.push(status); + expect(offlineStatuses).toEqual([stopped]); + const retainedLogs: StackLogEntry[] = []; + for await (const entry of stack.logs({ follow: false })) retainedLogs.push(entry); + expect(retainedLogs.length).toBeGreaterThan(0); + await expectRuntimeInputsAbsent(projectRoot, initial.id); + await expectEndpointsRefused(initialEndpoints); + if (mode.runtime.kind === "container") { + expect(await dockerOwnedResourceCount(initial.id, "containers")).toBe(0); + expect(await dockerOwnedResourceCount(initial.id, "networks")).toBe(0); + expect(await dockerOwnedResourceCount(initial.id, "volumes")).toBe(volumesBeforeStop); + } + + const started = await stack.start(); + expectDefaultLazyState(started); + expect( + Object.fromEntries( + Object.entries(started.endpoints).map(([name, value]) => [name, value?.port]), + ), + ).toEqual(endpointSnapshot); + expect( + await databaseQuery( + credentials.database.url, + `SELECT payload FROM public."${table}" WHERE id = 1`, + ), + ).toEqual([{ payload: markers.first }]); + await reactivate(); + + await stack.restart(); + const restarted = await stack.status(); + expectDefaultLazyState(restarted); + expect( + Object.fromEntries( + Object.entries(restarted.endpoints).map(([name, value]) => [name, value?.port]), + ), + ).toEqual(endpointSnapshot); + expect( + await databaseQuery( + credentials.database.url, + `SELECT payload FROM public."${table}" WHERE id = 1`, + ), + ).toEqual([{ payload: markers.first }]); + await reactivate(); + const final = await stack.status(); expect(final.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), ); + + await stack.stop(); + const reconfigured = await stack.start({ + config: { + capabilities: { studio: { enabled: false } }, + listeners: { + api: { port: studio.port }, + studio: { enabled: false }, + }, + }, + }); + expect(capabilityState(reconfigured, "database")).toBe("ready"); + expect(capabilityState(reconfigured, "studio")).toBe("disabled"); + expect(endpoint(reconfigured, "api").port).toBe(studio.port); + expect(endpoint(reconfigured, "api").port).not.toBe(api.port); + expect(reconfigured.endpoints.studio).toBeUndefined(); }; describe("managed Supabase stack whole-stack E2E", () => { + test( + "recovers the native database after abrupt Supervisor termination", + { timeout: E2E_TIMEOUT_MS }, + async () => { + let projectRoot = ""; + await using stack: TestStack = await createTestStack({ + name: `stack-crash-recovery-${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`, + runtime: { kind: "native" }, + setupProject: async (root) => { + projectRoot = root; + }, + }); + const before = await stack.status(); + const database = endpoint(before, "database"); + const lockPath = join( + projectRoot, + ".supabase", + "managed", + "stacks", + stack.id, + "data", + "database", + "postmaster.pid", + ); + const databasePid = Number((await readFile(lockPath, "utf8")).split("\n", 1)[0]); + if (!Number.isSafeInteger(databasePid) || databasePid <= 0) + throw new Error("Native database lock did not contain a valid PID"); + process.kill(await supervisorPid(stack.id), "SIGKILL"); + await waitForProcessExit(databasePid); + await expect(connect(database)).rejects.toThrow(); + await access(lockPath); + + const recovered = await stack.start(); + expectDefaultLazyState(recovered); + expect( + await databaseQuery((await stack.credentials()).database.url, "SELECT 1 AS value"), + ).toEqual([{ value: 1 }]); + }, + ); + for (const mode of RUNTIME_CASES) { test(`supports the complete user flow in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, () => runWholeStackScenario(mode), ); + + test( + `supports optional image and vector workloads in ${mode.name} mode`, + { timeout: E2E_TIMEOUT_MS }, + async () => { + const identity = crypto.randomUUID().replaceAll("-", "").slice(0, 20).toLowerCase(); + const bucket = `stack-optional-${identity}`; + const functionSlug = `optional_${identity}`; + const analyticsApiKey = `analytics-key-${identity}`; + const email = `${identity}@example.test`; + const password = "SupabaseStackE2e!123"; + await using stack: TestStack = await createTestStack({ + name: `stack-optional-${identity}`, + runtime: mode.runtime, + config: optionalWorkloadConfig(functionSlug, analyticsApiKey), + setupProject: async (root) => { + await mkdir(join(root, "supabase", "functions", functionSlug), { recursive: true }); + await writeFile( + join(root, "supabase", "functions", functionSlug, "index.ts"), + "Deno.serve(() => new Response('ok'))", + ); + }, + }); + const credentials = await stack.credentials(); + const status = await stack.status(); + const api = endpoint(status, "api"); + const mailUi = endpoint(status, "mailUi"); + await activate(stack, "storage", async () => { + await request(api.url, "/storage/v1/bucket", { + method: "POST", + headers: { ...serviceHeaders(credentials), "content-type": "application/json" }, + body: JSON.stringify({ id: bucket, name: bucket, public: true }), + }); + await request(api.url, `/storage/v1/object/${bucket}/pixel.png`, { + method: "POST", + headers: { ...serviceHeaders(credentials), "content-type": "image/png" }, + body: new Blob([onePixelPng], { type: "image/png" }), + }); + const transformed = await request( + api.url, + `/storage/v1/render/image/public/${bucket}/pixel.png?width=1&height=1`, + { headers: serviceHeaders(credentials) }, + ); + expect((await transformed.arrayBuffer()).byteLength).toBeGreaterThan(0); + }); + await activate(stack, "analytics", async () => { + await request(api.url, "/analytics/v1/health", { + headers: { "x-api-key": analyticsApiKey }, + }); + const marker = `analytics-${identity}`; + await request(api.url, "/analytics/v1/logs?source_name=postgres.logs", { + method: "POST", + headers: { "x-api-key": analyticsApiKey, "content-type": "application/json" }, + body: JSON.stringify({ + event_message: marker, + project: "default", + metadata: { source: "stack-e2e" }, + }), + }); + expect(await queryAnalyticsMarker(api, analyticsApiKey, marker)).toBeGreaterThan(0); + expect( + await queryAnalyticsMarker(api, analyticsApiKey, "supabase-stack-vector"), + ).toBeGreaterThan(0); + }); + await activate(stack, "mail", async () => { + await request(mailUi.url, "/api/v1/messages?limit=100"); + }); + let signup: JsonObject = {}; + await activate(stack, "auth", async () => { + signup = await jsonObject( + await request(api.url, "/auth/v1/signup", { + method: "POST", + headers: { ...apiHeaders(credentials), "content-type": "application/json" }, + body: JSON.stringify({ email, password }), + }), + ); + }); + expect(typeof signup.access_token).toBe("string"); + await request(api.url, "/auth/v1/recover", { + method: "POST", + headers: { ...apiHeaders(credentials), "content-type": "application/json" }, + body: JSON.stringify({ email }), + }); + const mailAttempt = Effect.tryPromise({ + try: async () => { + const body = await (await request(mailUi.url, "/api/v1/messages?limit=100")).text(); + if (!body.includes(email)) throw new Error(`Mailpit delivery pending for ${email}`); + return body; + }, + catch: (cause) => + new E2ERequestError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + await Effect.runPromise( + Effect.retry(mailAttempt, { schedule: MAILPIT_DELIVERY_RETRY_SCHEDULE }), + ); + }, + ); + } + + for (const mode of RUNTIME_CASES) { + test( + `releases owned resources across repeated stop/start cycles in ${mode.name} mode`, + { timeout: E2E_TIMEOUT_MS }, + async () => { + let stackId: string | undefined; + let projectRoot = ""; + const snapshots: Array< + Readonly<{ containers: number; networks: number; volumes: number }> + > = []; + let endpointSnapshot: ReadonlyArray = []; + { + await using stack: TestStack = await createTestStack({ + name: `stack-resource-audit-${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`, + runtime: mode.runtime, + setupProject: async (root) => { + projectRoot = root; + }, + }); + stackId = stack.id; + const initial = await stack.status(); + expectDefaultLazyState(initial); + endpointSnapshot = Object.values(initial.endpoints).filter( + (value): value is StackEndpoint => value !== undefined, + ); + for (let cycle = 0; cycle < 3; cycle += 1) { + await stack.stop(); + const stopped = await stack.status(); + expect(stopped.lifecycle).toBe("stopped"); + expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); + await expectRuntimeInputsAbsent(projectRoot, stack.id); + await expectEndpointsRefused(endpointSnapshot); + if (mode.runtime.kind === "container") { + if (stackId === undefined) throw new Error("Stack id was not assigned"); + snapshots.push({ + containers: await dockerOwnedResourceCount(stackId, "containers"), + networks: await dockerOwnedResourceCount(stackId, "networks"), + volumes: await dockerOwnedResourceCount(stackId, "volumes"), + }); + expect(snapshots.at(-1)).toEqual({ containers: 0, networks: 0, volumes: 1 }); + } + await stack.start(); + const restarted = await stack.status(); + expectDefaultLazyState(restarted); + expect( + Object.values(restarted.endpoints).filter( + (value): value is StackEndpoint => value !== undefined, + ), + ).toEqual(endpointSnapshot); + } + if (mode.runtime.kind === "container") + expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBe(1); + } + expect(projectRoot.length).toBeGreaterThan(0); + await expect(access(projectRoot)).rejects.toThrow(); + if (mode.runtime.kind === "container") { + if (stackId === undefined) throw new Error("Stack id was not assigned"); + expect(await dockerOwnedResourceCount(stackId, "containers")).toBe(0); + expect(await dockerOwnedResourceCount(stackId, "networks")).toBe(0); + expect(await dockerOwnedResourceCount(stackId, "volumes")).toBe(0); + } + }, + ); } }); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 87c8372940..55162ea259 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -2,7 +2,7 @@ import { Data, Effect, Predicate, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; -import type { StackId } from "../public/StackId.ts"; +import { StackIdSchema, type StackId } from "../public/StackId.ts"; import { NetworkPortSchema } from "../public/Status.ts"; export type ContainerEngineKind = "docker" | "podman"; @@ -41,7 +41,6 @@ export type ContainerResourceRole = "network" | "volume" | "workload"; interface ContainerIdentityLabels { readonly stackId: StackId; readonly ownerSessionId: string; - readonly desiredGeneration: number; } export interface ContainerNetworkLabels extends ContainerIdentityLabels { readonly role: "network"; @@ -166,23 +165,6 @@ export interface ContainerCommandRunner { request: ContainerProcessRequest, ) => Stream.Stream; } -export interface ControlledCommandRunnerOptions { - readonly executable?: string; - readonly run: ( - request: ContainerProcessRequest, - ) => Effect.Effect; - readonly stream?: ( - request: ContainerProcessRequest, - ) => Stream.Stream; -} -export const makeControlledCommandRunner = ( - options: ControlledCommandRunnerOptions, -): ContainerCommandRunner => ({ - executable: options.executable ?? "controlled-container-engine", - run: options.run, - ...(options.stream === undefined ? {} : { stream: options.stream }), -}); - export interface ProcessCommandRunnerOptions { readonly executable: string; readonly baseArgs?: ReadonlyArray; @@ -358,7 +340,7 @@ export const makeProcessCommandRunner = ( return { executable: options.executable, run, stream }; }); -interface ContainerEngineCodecs { +export interface ContainerEngineCodecs { readonly serialize: (command: ContainerCommand) => ContainerProcessRequest; readonly decodeProbe: ( result: ContainerCommandResult, @@ -389,6 +371,164 @@ interface ContainerEngineCodecs { options: ContainerLogOptions | undefined, ) => ContainerProcessRequest; } + +export const makeContainerEngineCodecs = (options: { + readonly engineName: "Docker" | "Podman"; + readonly scalarFormat: "json" | "raw"; + readonly serialize: ContainerEngineCodecs["serialize"]; + readonly serializeLogs: ContainerEngineCodecs["serializeLogs"]; +}): ContainerEngineCodecs => { + const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => + new ContainerEngineProtocolError({ + operation, + message: `${operation} returned an invalid ${options.engineName} response`, + ...(cause === undefined ? {} : { cause }), + }); + const lines = (raw: string): ReadonlyArray => + raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const scalar = ( + operation: string, + raw: string, + ): Effect.Effect => { + if (options.scalarFormat === "raw") { + const value = raw.trim(); + return value.length > 0 && !/\\t|\t/.test(value) + ? Effect.succeed(value) + : Effect.fail(protocol(operation)); + } + return Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(raw).pipe( + Effect.mapError((cause) => protocol(operation, cause)), + Effect.flatMap((value) => + typeof value === "string" && value.length > 0 + ? Effect.succeed(value) + : Effect.fail(protocol(operation)), + ), + ); + }; + const fields = (operation: string, line: string, count: number) => { + const values = line.split(/\\t|\t/); + if (values.length !== count) return Effect.fail(protocol(operation)); + return options.scalarFormat === "json" + ? Effect.forEach(values, (value) => scalar(operation, value)) + : Effect.succeed(values); + }; + const decodeIdentity = (operation: string, stack: string | undefined) => + stack === undefined + ? Effect.fail(protocol(operation)) + : Schema.decodeEffect(StackIdSchema)(stack).pipe( + Effect.mapError((error) => protocol(operation, error)), + ); + const networkLabels = (operation: string, values: ReadonlyArray) => { + const [stack, owner, role] = values; + if (owner === undefined || role !== "network") return Effect.fail(protocol(operation)); + return decodeIdentity(operation, stack).pipe( + Effect.map((stackId) => ({ stackId, ownerSessionId: owner, role: "network" as const })), + ); + }; + const workloadLabels = (operation: string, values: ReadonlyArray) => { + const [stack, owner, workload, hash, role] = values; + if (owner === undefined || workload === undefined || hash === undefined || role !== "workload") + return Effect.fail(protocol(operation)); + return decodeIdentity(operation, stack).pipe( + Effect.map((stackId) => ({ + stackId, + ownerSessionId: owner, + workloadId: workload, + specHash: hash, + role: "workload" as const, + })), + ); + }; + const decodeRows = ( + operation: string, + raw: string, + count: number, + decode: (values: ReadonlyArray) => Effect.Effect, + ) => + Effect.forEach(lines(raw), (line) => + fields(operation, line, count).pipe(Effect.flatMap(decode)), + ); + const decodeContainers: ContainerEngineCodecs["decodeContainers"] = (result) => + decodeRows("inspect-containers", result.stdout, 8, (values) => { + const [id, name, stack, owner, workload, hash, role, state] = values; + if ( + id === undefined || + name === undefined || + stack === undefined || + owner === undefined || + workload === undefined || + hash === undefined || + role !== "workload" || + state === undefined + ) + return Effect.fail(protocol("inspect-containers")); + return workloadLabels("inspect-containers", [stack, owner, workload, hash, role]).pipe( + Effect.map((labels): ContainerResource => ({ + id, + name, + kind: "workload", + labels, + state: state.includes("running") ? "running" : "stopped", + })), + ); + }); + const decodeNetworks: ContainerEngineCodecs["decodeNetworks"] = (result) => + decodeRows("inspect-networks", result.stdout, 5, (values) => { + const [id, name, stack, owner, role] = values; + if (id === undefined || name === undefined || role === undefined) + return Effect.fail(protocol("inspect-networks")); + return networkLabels("inspect-networks", [stack ?? "", owner ?? "", role]).pipe( + Effect.map((labels): ContainerResource => ({ id, name, kind: "network", labels })), + ); + }); + const decodeVolumes: ContainerEngineCodecs["decodeVolumes"] = (result) => + decodeRows("inspect-volumes", result.stdout, 4, (values) => { + const [name, stack, workload, role] = values; + if (name === undefined || stack === undefined || workload === undefined || role !== "volume") + return Effect.fail(protocol("inspect-volumes")); + return decodeIdentity("inspect-volumes", stack).pipe( + Effect.map((stackId): ContainerResource => ({ + id: name, + name, + kind: "volume", + labels: { stackId, workloadId: workload, role: "volume" }, + })), + ); + }); + const decodeCreate: ContainerEngineCodecs["decodeCreate"] = (operation, result, spec, kind) => { + const id = result.stdout.trim(); + return id.length > 0 && !/[\r\n]/.test(id) + ? Effect.succeed({ id, name: spec.name, kind, labels: spec.labels, state: "created" }) + : Effect.fail(protocol(operation)); + }; + const decodeWait: ContainerEngineCodecs["decodeWait"] = (result) => { + const values = lines(result.stdout); + const value = values[0]; + if (values.length !== 1 || value === undefined || !/^\d+$/.test(value)) + return Effect.fail(protocol("wait-container")); + const exitCode = Number(value); + return Number.isSafeInteger(exitCode) + ? Effect.succeed(exitCode) + : Effect.fail(protocol("wait-container")); + }; + return { + serialize: options.serialize, + serializeLogs: options.serializeLogs, + decodeProbe: (result) => scalar("probe", result.stdout).pipe(Effect.asVoid), + decodeImage: (result) => + Effect.forEach(lines(result.stdout), (line) => scalar("inspect-image", line)).pipe( + Effect.map((values) => ({ present: values.length > 0 })), + ), + decodeContainers, + decodeNetworks, + decodeVolumes, + decodeCreate, + decodeWait, + }; +}; export interface ContainerEngineOptions { readonly kind: ContainerEngineKind; readonly runner: ContainerCommandRunner; diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 2e8f189c67..315d5cf067 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -4,7 +4,8 @@ import { Effect, Exit, Fiber, - Option, + PubSub, + Ref, Scope, Semaphore, Stream, @@ -34,7 +35,6 @@ import { import { RuntimeDriverError, type RuntimeCleanupRequest, - type RuntimeRecoveryRequest, type ObservedWorkload, type RuntimeDriver, type RuntimeWorkloadKey, @@ -55,7 +55,7 @@ export interface ContainerRuntimeOptions { workload: PlannedWorkload, resource: ContainerResource, ) => Effect.Effect; - /** Probes an adopted or newly-started workload before reporting it ready. */ + /** Probes a newly-started workload before reporting it ready. */ readonly waitForReadiness?: ( key: RuntimeWorkloadKey, workload: PlannedWorkload, @@ -66,6 +66,8 @@ export interface ContainerRuntimeOptions { } export interface ContainerWorkloadResolution { + /** Optional main-container entrypoint override (startup processes are explicit). */ + readonly entrypoint?: string; readonly mounts?: ReadonlyArray; readonly volume?: ContainerVolumeRequest; /** Path to an owned env file; secret bytes are kept out of engine argv. */ @@ -102,7 +104,6 @@ interface ContainerVolumeRequest { interface ContainerRuntimeResourceIds { readonly container: string; - readonly network: string; readonly state: "running" | "stopped" | "failed"; readonly error?: string; } @@ -110,18 +111,20 @@ interface ContainerRuntimeResourceIds { interface ContainerRuntimeResource extends ContainerRuntimeResourceIds { readonly key: RuntimeWorkloadKey; readonly workload: PlannedWorkload; + readonly ownerSessionId: string; readonly failure: Deferred.Deferred; logFiber?: Fiber.Fiber; + watchFiber?: Fiber.Fiber; stopRequested: boolean; } const resourceKey = (key: RuntimeWorkloadKey): string => - `${key.stackId}:${key.desiredGeneration}:${key.workloadId}:${key.specHash}`; + `${key.stackId}:${key.workloadId}:${key.specHash}`; const nameFor = (key: RuntimeWorkloadKey, role: ContainerResourceRole): string => role === "network" - ? `supabase-${key.stackId.slice(0, 16)}-${key.desiredGeneration}-network` - : `supabase-${key.stackId.slice(0, 16)}-${key.desiredGeneration}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-${role}`; + ? `supabase-${key.stackId.slice(0, 16)}-network` + : `supabase-${key.stackId.slice(0, 16)}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-${role}`; const networkLabelsFor = ( key: RuntimeWorkloadKey, @@ -129,7 +132,6 @@ const networkLabelsFor = ( ): ContainerNetworkLabels => ({ stackId: key.stackId, ownerSessionId, - desiredGeneration: key.desiredGeneration, role: "network", }); const workloadLabelsFor = ( @@ -138,7 +140,6 @@ const workloadLabelsFor = ( ): ContainerWorkloadLabels => ({ stackId: key.stackId, ownerSessionId, - desiredGeneration: key.desiredGeneration, workloadId: key.workloadId, specHash: key.specHash, role: "workload", @@ -187,7 +188,6 @@ const sameLabels = (left: ContainerLabels, right: ContainerLabels): boolean => "ownerSessionId" in left && "ownerSessionId" in right && left.ownerSessionId === right.ownerSessionId && - left.desiredGeneration === right.desiredGeneration && (left.role === "network" || ("workloadId" in left && "workloadId" in right && @@ -196,19 +196,19 @@ const sameLabels = (left: ContainerLabels, right: ContainerLabels): boolean => "specHash" in right && left.specHash === right.specHash))); -/** Recovery identity deliberately excludes ownerSessionId. The ownership lock fences adoption. */ +/** Workload identity deliberately excludes ownerSessionId; stack ownership is fenced externally. */ const sameWorkloadIdentity = (left: ContainerLabels, right: ContainerWorkloadLabels): boolean => left.role === "workload" && left.stackId === right.stackId && - left.desiredGeneration === right.desiredGeneration && left.workloadId === right.workloadId && left.specHash === right.specHash; -/** Networks are adopted by stack and desired generation, never by old owner session. */ +/** Networks are identified by stack identity. */ const sameNetworkIdentity = (left: ContainerLabels, right: ContainerNetworkLabels): boolean => left.role === "network" && left.stackId === right.stackId && - left.desiredGeneration === right.desiredGeneration; + "ownerSessionId" in left && + left.ownerSessionId === right.ownerSessionId; const toDriverError = ( key: Pick, @@ -252,16 +252,17 @@ const isValidPublication = ( /** RuntimeDriver implementation backed by one exact, injected container engine. */ export const makeContainerRuntime = ( options: ContainerRuntimeOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const startupProcessTimeout = options.startupProcessTimeout ?? ("5 minutes" satisfies Duration.Input); - const parentScope = yield* Effect.serviceOption(Scope.Scope); - const runtimeScope = Option.isSome(parentScope) - ? yield* Scope.fork(parentScope.value, "parallel") - : yield* Scope.make("parallel"); + const parentScope = yield* Scope.Scope; + const runtimeScope = yield* Scope.fork(parentScope, "parallel"); const lifecycle = yield* Semaphore.make(1); const resources = new Map(); + const startGuards = new Map>(); + const startFibers = new Map>(); + const failures = yield* PubSub.unbounded(); const withEngine = ( key: Pick, @@ -272,13 +273,19 @@ export const makeContainerRuntime = ( const stopLogs = (resource: ContainerRuntimeResource): Effect.Effect => resource.logFiber === undefined ? Effect.void : Fiber.interrupt(resource.logFiber); - const reportLogFailure = ( + const stopExitWatcher = (resource: ContainerRuntimeResource): Effect.Effect => + resource.watchFiber === undefined ? Effect.void : Fiber.interrupt(resource.watchFiber); + + const reportFailure = ( resource: ContainerRuntimeResource, error: unknown, + message = `Container workload failed for ${resource.key.workloadId}`, ): Effect.Effect => { - if (resource.stopRequested) return Effect.void; + const current = resources.get(resourceKey(resource.key)); + if (resource.stopRequested || current !== resource || resource.state === "failed") + return Effect.void; const failure = new RuntimeDriverError({ - message: `Container log stream failed for ${resource.key.workloadId}`, + message, stackId: resource.key.stackId, workloadId: resource.key.workloadId, cause: error, @@ -288,7 +295,14 @@ export const makeContainerRuntime = ( state: "failed", error: failure.message, }); - return Deferred.fail(resource.failure, failure).pipe(Effect.asVoid); + return Effect.all([ + PubSub.publish(failures, { + ...resource.key, + state: "failed", + error: failure.message, + }), + Deferred.fail(resource.failure, failure), + ]).pipe(Effect.asVoid); }; const attachLogs = ( @@ -298,7 +312,11 @@ export const makeContainerRuntime = ( const logStore = options.logStore; if (logStore === undefined) return Effect.void; if (options.engine.streamLogs === undefined) - return reportLogFailure(resource, new Error("Container log streaming is unavailable")); + return reportFailure( + resource, + new Error("Container log streaming is unavailable"), + `Container log stream failed for ${resource.key.workloadId}`, + ); const stream = options.engine.streamLogs(resource.container, { tail }); const consume = stream.pipe( Stream.runForEach((line) => @@ -311,7 +329,13 @@ export const makeContainerRuntime = ( .pipe(Effect.asVoid), ), Effect.mapError((error) => toDriverError(resource.key, error)), - Effect.catch((error) => reportLogFailure(resource, error)), + Effect.catch((error) => + reportFailure( + resource, + error, + `Container log stream failed for ${resource.key.workloadId}`, + ), + ), ); return Effect.gen(function* () { resource.logFiber = yield* Effect.forkIn(consume, runtimeScope); @@ -320,6 +344,33 @@ export const makeContainerRuntime = ( }); }; + const attachExitWatcher = (resource: ContainerRuntimeResource): Effect.Effect => { + if (resource.watchFiber !== undefined) return Effect.void; + const wait = Effect.exit( + withEngine(resource.key, options.engine.waitContainer(resource.container)), + ).pipe( + Effect.flatMap((result) => + resource.stopRequested + ? Effect.void + : reportFailure( + resource, + Exit.isFailure(result) + ? result.cause + : new Error(`Container exited with code ${String(result.value)}`), + ), + ), + Effect.ignore, + ); + return Effect.forkIn(wait, runtimeScope).pipe( + Effect.tap((fiber) => + Effect.sync(() => { + resource.watchFiber = fiber; + }), + ), + Effect.asVoid, + ); + }; + const observe = ( stackId: StackId, ): Effect.Effect, RuntimeDriverError> => @@ -332,7 +383,6 @@ export const makeContainerRuntime = ( .map((entry) => { const key: RuntimeWorkloadKey = { stackId: entry.labels.stackId, - desiredGeneration: entry.labels.desiredGeneration, workloadId: entry.labels.workloadId, specHash: entry.labels.specHash, }; @@ -356,7 +406,7 @@ export const makeContainerRuntime = ( const runStartupProcess = ( key: RuntimeWorkloadKey, workload: PlannedWorkload, - process: ContainerStartupProcess, + startupProcess: ContainerStartupProcess, index: number, context: Readonly<{ readonly artifact: ContainerArtifact; @@ -377,8 +427,8 @@ export const makeContainerRuntime = ( context.volumeRequest === undefined ? [] : [volumeMountFor(key, context.volumeRequest)], publications: [], role: "workload", - entrypoint: process.entrypoint, - command: process.command, + entrypoint: startupProcess.entrypoint, + command: startupProcess.command, ...(context.resolution.envFile === undefined ? {} : { envFile: context.resolution.envFile }), @@ -478,16 +528,37 @@ export const makeContainerRuntime = ( options.resolveWorkload === undefined ? Effect.succeed({}) : options.resolveWorkload(key, workload); + const id = resourceKey(key); + const guard = Effect.gen(function* () { + const current = startGuards.get(id); + if (current === undefined) + return yield* toDriverError( + key, + new Error("Container workload start was not registered"), + ); + const stopped = yield* Ref.get(current); + if (stopped) + return yield* toDriverError(key, new Error("Container workload start was stopped")); + }); const startEffect = Effect.gen(function* () { + yield* guard; const existing = resources.get(resourceKey(key)); + if ( + existing !== undefined && + existing.ownerSessionId === options.ownerSessionId && + existing.state === "running" + ) + return { ...key, state: "ready" } satisfies ObservedWorkload; if (existing !== undefined && existing.state !== "running") { existing.stopRequested = true; yield* stopLogs(existing); + yield* stopExitWatcher(existing); if (existing.state === "failed") yield* withEngine(key, options.engine.stopContainer(existing.container)); resources.delete(resourceKey(key)); } const resolution = yield* resolved; + yield* guard; const requestedPublications = resolution.publications ?? []; if (requestedPublications.some((publication) => !isValidPublication(publication))) return yield* toDriverError( @@ -521,7 +592,7 @@ export const makeContainerRuntime = ( return yield* toDriverError(key, new Error("Container volume identity collision")); } } - const exact = entries.find( + const existingExact = entries.find( (entry) => entry.kind === "workload" && sameWorkloadIdentity(entry.labels, labels), ); const namedCollision = entries.find( @@ -532,7 +603,7 @@ export const makeContainerRuntime = ( ? namedCollision : undefined; if ( - exact === undefined && + existingExact === undefined && namedCollision !== undefined && (collision === undefined || collision.labels.stackId !== key.stackId || @@ -564,14 +635,21 @@ export const makeContainerRuntime = ( new Error("Container network name is owned by another stack"), ); - if (exact === undefined) - yield* withEngine(key, options.engine.inspectImage(artifact.image)).pipe( - Effect.flatMap((inspected) => - inspected.present - ? Effect.void - : withEngine(key, options.engine.pullImage(artifact.image)), - ), - ); + // A new owner never adopts a durable container. Remove any exact identity first; + // lifecycle ownership has already fenced the stack before this activation. + if (existingExact !== undefined) { + if (existingExact.state === "running") + yield* withEngine(key, options.engine.stopContainer(existingExact.id)); + yield* withEngine(key, options.engine.removeContainer(existingExact.id)); + } + yield* withEngine(key, options.engine.inspectImage(artifact.image)).pipe( + Effect.flatMap((inspected) => + inspected.present + ? Effect.void + : withEngine(key, options.engine.pullImage(artifact.image)), + ), + ); + yield* guard; if (exactNetwork === undefined && networkCollision !== undefined) yield* withEngine(key, options.engine.removeNetwork(networkCollision.id)); @@ -594,77 +672,70 @@ export const makeContainerRuntime = ( yield* withEngine(key, options.engine.createVolume(volume)); } - if (exact === undefined && collision !== undefined) { + if (collision !== undefined && collision.id !== existingExact?.id) { if (collision.state === "running") yield* withEngine(key, options.engine.stopContainer(collision.id)); yield* withEngine(key, options.engine.removeContainer(collision.id)); } - if (exact === undefined) - for (const [index, startup] of (resolution.startup ?? []).entries()) - yield* runStartupProcess(key, workload, startup, index, { - artifact, - network: networkResource, - resolution, - ...(volumeRequest === undefined ? {} : { volumeRequest }), - }); + for (const [index, startup] of (resolution.startup ?? []).entries()) + yield* runStartupProcess(key, workload, startup, index, { + artifact, + network: networkResource, + resolution, + ...(volumeRequest === undefined ? {} : { volumeRequest }), + }); + yield* guard; - let container: ContainerResource; let created = false; let startedByUs = false; - if (exact !== undefined) { - container = exact; - if (exact.state !== "running") { - yield* withEngine(key, options.engine.startContainer(exact.id)); - startedByUs = true; - } - } else { - container = yield* withEngine( - key, - options.engine.createContainer({ - name: nameFor(key, "workload"), - image: artifact.image, - labels, - network: networkResource.id, - mounts: resolution.mounts ?? [], - publications, - volumeMounts: volumeRequest === undefined ? [] : [volumeMountFor(key, volumeRequest)], - role: "workload", - ...(resolution.envFile === undefined ? {} : { envFile: resolution.envFile }), - ...(resolution.networkAliases === undefined - ? {} - : { networkAliases: resolution.networkAliases }), - ...(resolution.hostRoute === undefined ? {} : { hostRoute: resolution.hostRoute }), - ...(resolution.command === undefined ? {} : { command: resolution.command }), - } satisfies ContainerContainerSpec), - ); - created = true; - if (resolution.bootstrap !== undefined) { - const copied = yield* Effect.exit( - withEngine( - key, - options.engine.copyToContainer( - container.id, - resolution.bootstrap.source, - resolution.bootstrap.destination, - ), + const container = yield* withEngine( + key, + options.engine.createContainer({ + name: nameFor(key, "workload"), + image: artifact.image, + labels, + network: networkResource.id, + mounts: resolution.mounts ?? [], + publications, + volumeMounts: volumeRequest === undefined ? [] : [volumeMountFor(key, volumeRequest)], + role: "workload", + ...(resolution.envFile === undefined ? {} : { envFile: resolution.envFile }), + ...(resolution.networkAliases === undefined + ? {} + : { networkAliases: resolution.networkAliases }), + ...(resolution.hostRoute === undefined ? {} : { hostRoute: resolution.hostRoute }), + ...(resolution.entrypoint === undefined ? {} : { entrypoint: resolution.entrypoint }), + ...(resolution.command === undefined ? {} : { command: resolution.command }), + } satisfies ContainerContainerSpec), + ); + created = true; + if (resolution.bootstrap !== undefined) { + const copied = yield* Effect.exit( + withEngine( + key, + options.engine.copyToContainer( + container.id, + resolution.bootstrap.source, + resolution.bootstrap.destination, ), + ), + ); + if (Exit.isFailure(copied)) { + const removed = yield* Effect.exit( + withEngine(key, options.engine.removeContainer(container.id)), + ); + const cleanupCause = Exit.isFailure(removed) ? removed.cause : Cause.empty; + return yield* Effect.failCause( + cleanupCause.reasons.length === 0 + ? copied.cause + : Cause.combine(copied.cause, cleanupCause), ); - if (Exit.isFailure(copied)) { - const removed = yield* Effect.exit( - withEngine(key, options.engine.removeContainer(container.id)), - ); - const cleanupCause = Exit.isFailure(removed) ? removed.cause : Cause.empty; - return yield* Effect.failCause( - cleanupCause.reasons.length === 0 - ? copied.cause - : Cause.combine(copied.cause, cleanupCause), - ); - } } - yield* withEngine(key, options.engine.startContainer(container.id)); - startedByUs = true; } + yield* guard; + yield* withEngine(key, options.engine.startContainer(container.id)); + startedByUs = true; const current = resources.get(resourceKey(key)); const resource = current !== undefined && current.container === container.id && current.state === "running" @@ -674,8 +745,8 @@ export const makeContainerRuntime = ( const createdResource: ContainerRuntimeResource = { key, workload, + ownerSessionId: options.ownerSessionId, container: container.id, - network: networkResource.id, state: "running", failure, stopRequested: false, @@ -684,6 +755,7 @@ export const makeContainerRuntime = ( return createdResource; }); if (resource.logFiber === undefined) yield* attachLogs(resource, created ? "all" : 0); + yield* attachExitWatcher(resource); const readiness = resolution.waitForReadiness === undefined ? options.waitForReadiness === undefined @@ -705,6 +777,7 @@ export const makeContainerRuntime = ( if (Exit.isFailure(ready)) { resource.stopRequested = true; yield* stopLogs(resource); + yield* stopExitWatcher(resource); const stopExit = startedByUs || workload.bootstrap === "database" ? yield* Effect.exit(withEngine(key, options.engine.stopContainer(container.id))) @@ -724,21 +797,57 @@ export const makeContainerRuntime = ( const result: ObservedWorkload = { ...key, state: "ready" }; return result; }); - return lifecycle.withPermit(startEffect); + return Effect.gen(function* () { + const inFlight = startFibers.get(id); + if (inFlight !== undefined) return yield* Fiber.join(inFlight); + const guardRef = yield* Ref.make(false); + startGuards.set(id, guardRef); + // Track the whole lifecycle wait, not only the body after permit acquisition. Cleanup + // must be able to interrupt starts queued behind another activation as well. The permit + // only covers forking the body; readiness and log following continue after it is released. + const run = Effect.gen(function* () { + const body = yield* lifecycle.withPermit( + Effect.forkChild(startEffect, { startImmediately: true }), + ); + return yield* Fiber.join(body); + }); + const fiber = yield* Effect.forkChild(run, { startImmediately: true }); + startFibers.set(id, fiber); + return yield* Fiber.join(fiber).pipe( + Effect.ensuring( + Effect.sync(() => { + if (startGuards.get(id) === guardRef) startGuards.delete(id); + if (startFibers.get(id) === fiber) startFibers.delete(id); + }), + ), + ); + }); }; const stopInPermit = (key: RuntimeWorkloadKey): Effect.Effect => { const found = resources.get(resourceKey(key)); + const guard = startGuards.get(resourceKey(key)); return found !== undefined ? found.state === "stopped" ? Effect.void : Effect.gen(function* () { found.stopRequested = true; + if (guard !== undefined) yield* Ref.set(guard, true); + yield* Deferred.fail( + found.failure, + new RuntimeDriverError({ + message: "Container workload was stopped while starting", + stackId: key.stackId, + workloadId: key.workloadId, + }), + ); yield* stopLogs(found); + yield* stopExitWatcher(found); yield* withEngine(key, options.engine.stopContainer(found.container)); resources.set(resourceKey(key), { ...found, state: "stopped" }); }) - : withEngine(key, options.engine.listResources(key.stackId)).pipe( + : (guard === undefined ? Effect.void : Ref.set(guard, true)).pipe( + Effect.andThen(withEngine(key, options.engine.listResources(key.stackId))), Effect.flatMap((entries) => { const exact = entries .filter(isWorkloadResource) @@ -766,13 +875,26 @@ export const makeContainerRuntime = ( const found = resources.get(resourceKey(key)); if (found !== undefined) { found.stopRequested = true; + const guard = startGuards.get(resourceKey(key)); + if (guard !== undefined) yield* Ref.set(guard, true); + yield* Deferred.fail( + found.failure, + new RuntimeDriverError({ + message: "Container workload was removed while starting", + stackId: key.stackId, + workloadId: key.workloadId, + }), + ); yield* stopLogs(found); + yield* stopExitWatcher(found); if (found.state !== "stopped") yield* withEngine(key, options.engine.stopContainer(found.container)); yield* withEngine(key, options.engine.removeContainer(found.container)); resources.delete(resourceKey(key)); return; } + const guard = startGuards.get(resourceKey(key)); + if (guard !== undefined) yield* Ref.set(guard, true); const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); const exact = entries .filter(isWorkloadResource) @@ -789,6 +911,23 @@ export const makeContainerRuntime = ( const cleanup = (request: RuntimeCleanupRequest): Effect.Effect => lifecycle.withPermit( Effect.gen(function* () { + // Stop and interrupt starts that have not registered a resource yet. Without this + // fence, cleanup can observe an empty resource list while an activation is still inside + // createContainer, then the activation creates a new orphan after cleanup returns. + const pendingStartIds = new Set( + [...startGuards.keys(), ...startFibers.keys()].filter((id) => + id.startsWith(`${request.stackId}:`), + ), + ); + for (const id of pendingStartIds) { + const guard = startGuards.get(id); + if (guard !== undefined) yield* Ref.set(guard, true); + } + for (const id of pendingStartIds) { + const fiber = startFibers.get(id); + if (fiber !== undefined) yield* Fiber.interrupt(fiber); + } + const entries = yield* withEngine( { stackId: request.stackId, workloadId: "" }, options.engine.listResources(request.stackId), @@ -807,6 +946,7 @@ export const makeContainerRuntime = ( if (resource.key.stackId === request.stackId) { resource.stopRequested = true; yield* stopLogs(resource); + yield* stopExitWatcher(resource); } // Stop and remove every stack workload before touching its network. A failed @@ -844,221 +984,22 @@ export const makeContainerRuntime = ( for (const id of resources.keys()) if (id.startsWith(`${request.stackId}:`)) resources.delete(id); - yield* Scope.close(runtimeScope, Exit.void); if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); }), ); - const recover = ( - request: RuntimeRecoveryRequest, - ): Effect.Effect, RuntimeDriverError> => - lifecycle.withPermit( - Effect.gen(function* () { - const entries = yield* withEngine( - { stackId: request.stackId, workloadId: "" }, - options.engine.listResources(request.stackId), - ); - const owned = entries - .filter((entry) => entry.labels.stackId === request.stackId) - .sort((left, right) => left.id.localeCompare(right.id)); - const desiredHashes = new Map( - request.plan.workloads.map((workload) => [workload.id, workload.specHash]), - ); - const desiredWorkloads = new Map( - request.plan.workloads.map((workload) => [workload.id, workload]), - ); - const adopted: Array = []; - const adoptedResourceKeys = new Set(); - const adoptedIdentities = new Set(); - let retainedNetworkId: string | undefined; - let recoveryCause: Cause.Cause = Cause.empty; - const attempt = (effect: Effect.Effect) => - Effect.gen(function* () { - const result = yield* Effect.exit(effect); - if (Exit.isFailure(result)) - recoveryCause = Cause.combine(recoveryCause, result.cause); - }); - const stopAndRemove = (entry: ContainerResource) => - Effect.gen(function* () { - if (entry.state === "running") - yield* attempt( - withEngine( - { stackId: request.stackId, workloadId: "" }, - options.engine.stopContainer(entry.id), - ), - ); - yield* attempt( - withEngine( - { stackId: request.stackId, workloadId: "" }, - options.engine.removeContainer(entry.id), - ), - ); - }); - const stopAdopted = ( - entry: ContainerResource & { readonly labels: ContainerWorkloadLabels }, - ) => - withEngine( - { stackId: request.stackId, workloadId: entry.labels.workloadId }, - options.engine.stopContainer(entry.id), - ); - - // Recovery never starts or creates anything. Workload identities are evaluated before - // retaining the network so stale resources cannot leak into the new owner. - for (const entry of owned.filter(isWorkloadResource)) { - const hash = desiredHashes.get(entry.labels.workloadId); - const current = - entry.labels.desiredGeneration === request.desiredGeneration && - hash !== undefined && - hash === entry.labels.specHash; - if (!current) { - yield* stopAndRemove(entry); - continue; - } - const identity = `${entry.labels.desiredGeneration}:${entry.labels.workloadId}:${entry.labels.specHash}`; - if (adoptedIdentities.has(identity)) { - yield* stopAndRemove(entry); - continue; - } - adoptedIdentities.add(identity); - const desiredWorkload = desiredWorkloads.get(entry.labels.workloadId); - const key: RuntimeWorkloadKey = { - stackId: entry.labels.stackId, - desiredGeneration: entry.labels.desiredGeneration, - workloadId: entry.labels.workloadId, - specHash: entry.labels.specHash, - }; - if (entry.state === "running" && desiredWorkload !== undefined) { - const readiness = options.waitForReadiness; - if (readiness !== undefined) { - const readinessExit = yield* Effect.exit(readiness(key, desiredWorkload, entry)); - if (Exit.isFailure(readinessExit)) { - recoveryCause = Cause.combine(recoveryCause, readinessExit.cause); - const stopExit = yield* Effect.exit(stopAdopted(entry)); - if (Exit.isFailure(stopExit)) - recoveryCause = Cause.combine(recoveryCause, stopExit.cause); - continue; - } - } - } - if (entry.state === "running" && desiredWorkload?.bootstrap === "database") { - const bootstrap = - options.bootstrapDatabase === undefined - ? Effect.fail( - toDriverError( - key, - new Error("Database bootstrap resolver is not configured"), - ), - ) - : options.bootstrapDatabase(key, desiredWorkload, entry); - const bootstrapExit = yield* Effect.exit(bootstrap); - if (Exit.isFailure(bootstrapExit)) { - recoveryCause = Cause.combine(recoveryCause, bootstrapExit.cause); - const stopExit = yield* Effect.exit(stopAdopted(entry)); - if (Exit.isFailure(stopExit)) - recoveryCause = Cause.combine(recoveryCause, stopExit.cause); - continue; - } - } - if (entry.state === "running" && desiredWorkload !== undefined) { - const known = resources.get(resourceKey(key)); - if (known !== undefined && known.container === entry.id && known.state === "failed") { - recoveryCause = Cause.combine( - recoveryCause, - Cause.fail( - toDriverError(key, new Error(known.error ?? "Container log stream failed")), - ), - ); - known.stopRequested = true; - const stopExit = yield* Effect.exit(stopAdopted(entry)); - if (Exit.isFailure(stopExit)) - recoveryCause = Cause.combine(recoveryCause, stopExit.cause); - yield* stopLogs(known); - resources.delete(resourceKey(key)); - continue; - } - if ( - known !== undefined && - (known.container !== entry.id || known.state !== "running") - ) { - known.stopRequested = true; - yield* stopLogs(known); - resources.delete(resourceKey(key)); - } - const resource = - resources.get(resourceKey(key)) ?? - (yield* Effect.gen(function* () { - const failure = yield* Deferred.make(); - const adoptedResource: ContainerRuntimeResource = { - key, - workload: desiredWorkload, - container: entry.id, - network: retainedNetworkId ?? "", - state: "running", - failure, - stopRequested: false, - }; - resources.set(resourceKey(key), adoptedResource); - return adoptedResource; - })); - if (resource.logFiber === undefined) yield* attachLogs(resource, 0); - if (resource.state === "failed") { - recoveryCause = Cause.combine( - recoveryCause, - Cause.fail( - toDriverError(key, new Error(resource.error ?? "Container log stream failed")), - ), - ); - resource.stopRequested = true; - const stopExit = yield* Effect.exit(stopAdopted(entry)); - if (Exit.isFailure(stopExit)) - recoveryCause = Cause.combine(recoveryCause, stopExit.cause); - resources.delete(resourceKey(key)); - continue; - } - adoptedResourceKeys.add(resourceKey(key)); - } - adopted.push({ - stackId: entry.labels.stackId, - desiredGeneration: entry.labels.desiredGeneration, - workloadId: entry.labels.workloadId, - specHash: entry.labels.specHash, - state: - entry.state === "running" - ? "ready" - : entry.state === "stopped" - ? "stopped" - : "starting", - }); - } - - // Networks are only considered after all workloads. Keep one network for - // the exact current generation; duplicate or stale-generation networks are ephemera. - for (const entry of owned.filter(isNetworkResource)) { - if ( - retainedNetworkId === undefined && - entry.labels.desiredGeneration === request.desiredGeneration - ) { - retainedNetworkId = entry.id; - continue; - } - yield* attempt( - withEngine( - { stackId: request.stackId, workloadId: "" }, - options.engine.removeNetwork(entry.id), - ), - ); - } - - for (const [id, resource] of resources) - if (id.startsWith(`${request.stackId}:`) && !adoptedResourceKeys.has(id)) { - resource.stopRequested = true; - yield* stopLogs(resource); - resources.delete(id); - } - if (recoveryCause.reasons.length > 0) return yield* Effect.failCause(recoveryCause); - return adopted; - }), - ); - - return { observe, start, stop, remove, cleanup, recover } satisfies RuntimeDriver; + const watchFailures = Stream.unwrap( + PubSub.subscribe(failures).pipe( + Effect.map((subscription) => Stream.fromEffectRepeat(PubSub.take(subscription))), + ), + ).pipe(Stream.scoped); + + return { + watchFailures, + observe, + start, + stop, + remove, + cleanup, + } satisfies RuntimeDriver; }); diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index b541dce642..70107fae38 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -1,25 +1,16 @@ -import { Effect, Schema } from "effect"; -import { StackIdSchema, type StackId } from "../public/StackId.ts"; import { + makeContainerEngineCodecs, makeContainerEngineCore, type ContainerCommand, - type ContainerCommandResult, type ContainerEngine, - type ContainerEngineFailure, type ContainerEngineOptions, type ContainerLogOptions, type ContainerLabels, - type ContainerNetworkLabels, type ContainerProcessRequest, - type ContainerResource, - type ContainerResourceRole, - type ContainerWorkloadLabels, } from "./ContainerEngine.ts"; -import { ContainerEngineProtocolError } from "./ContainerEngine.ts"; const stackLabel = "com.supabase.stack.stackId"; const ownerLabel = "com.supabase.stack.ownerSessionId"; -const generationLabel = "com.supabase.stack.desiredGeneration"; const workloadLabel = "com.supabase.stack.workloadId"; const hashLabel = "com.supabase.stack.specHash"; const roleLabel = "com.supabase.stack.role"; @@ -30,7 +21,6 @@ const labels = (value: ContainerLabels): ReadonlyArray => { ? [ ["stackId", value.stackId], ["ownerSessionId", value.ownerSessionId], - ["desiredGeneration", value.desiredGeneration], ["role", value.role], ] : value.role === "volume" @@ -42,7 +32,6 @@ const labels = (value: ContainerLabels): ReadonlyArray => { : [ ["stackId", value.stackId], ["ownerSessionId", value.ownerSessionId], - ["desiredGeneration", value.desiredGeneration], ["workloadId", value.workloadId], ["specHash", value.specHash], ["role", value.role], @@ -59,7 +48,6 @@ const containerFormat = [ "{{json .Names}}", jsonLabel(stackLabel), jsonLabel(ownerLabel), - jsonLabel(generationLabel), jsonLabel(workloadLabel), jsonLabel(hashLabel), jsonLabel(roleLabel), @@ -70,7 +58,6 @@ const networkFormat = [ "{{json .Name}}", jsonLabel(stackLabel), jsonLabel(ownerLabel), - jsonLabel(generationLabel), jsonLabel(roleLabel), ].join("\\t"); const volumeFormat = [ @@ -205,234 +192,16 @@ const serializeDockerLogs = ( ], }); -const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => - new ContainerEngineProtocolError({ - operation, - message: `${operation} returned an invalid Docker response`, - ...(cause === undefined ? {} : { cause }), - }); - -const parseJson = ( - operation: string, - raw: string, -): Effect.Effect => - Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(raw).pipe( - Effect.mapError((cause) => protocol(operation, cause)), - ); - -const lines = (raw: string): ReadonlyArray => - raw - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0); - -const scalarString = ( - operation: string, - raw: string, -): Effect.Effect => - parseJson(operation, raw).pipe( - Effect.flatMap((value) => - typeof value === "string" && value.length > 0 - ? Effect.succeed(value) - : Effect.fail(protocol(operation)), - ), - ); - -const fields = ( - operation: string, - line: string, - count: number, -): Effect.Effect, ContainerEngineFailure> => { - const values = line.split(/\\t|\t/); - if (values.length !== count) return Effect.fail(protocol(operation)); - return Effect.forEach(values, (value) => - parseJson(operation, value).pipe( - Effect.flatMap((parsed) => - typeof parsed === "string" ? Effect.succeed(parsed) : Effect.fail(protocol(operation)), - ), - ), - ); -}; - -const decodeIdentity = ( - operation: string, - stack: string | undefined, -): Effect.Effect => { - if (stack === undefined) return Effect.fail(protocol(operation)); - return Schema.decodeEffect(StackIdSchema)(stack).pipe( - Effect.mapError((error) => protocol(operation, error)), - ); -}; - -const networkLabels = ( - operation: string, - values: ReadonlyArray, -): Effect.Effect => { - const [stack, owner, generation, role] = values; - if (owner === undefined || generation === undefined || role !== "network") - return Effect.fail(protocol(operation)); - return decodeIdentity(operation, stack).pipe( - Effect.flatMap((stackId) => { - const desiredGeneration = Number(generation); - return Number.isInteger(desiredGeneration) - ? Effect.succeed({ - stackId, - ownerSessionId: owner, - desiredGeneration, - role: "network", - }) - : Effect.fail(protocol(operation)); - }), - ); -}; - -const workloadLabels = ( - operation: string, - values: ReadonlyArray, -): Effect.Effect => { - const [stack, owner, generation, workload, hash, role] = values; - if ( - owner === undefined || - generation === undefined || - workload === undefined || - hash === undefined || - role !== "workload" - ) - return Effect.fail(protocol(operation)); - return decodeIdentity(operation, stack).pipe( - Effect.flatMap((stackId) => { - const desiredGeneration = Number(generation); - return Number.isInteger(desiredGeneration) - ? Effect.succeed({ - stackId, - ownerSessionId: owner, - desiredGeneration, - workloadId: workload, - specHash: hash, - role, - }) - : Effect.fail(protocol(operation)); - }), - ); -}; - -const decodeRows = ( - operation: string, - raw: string, - count: number, - decode: (values: ReadonlyArray) => Effect.Effect, -): Effect.Effect, ContainerEngineFailure> => - Effect.forEach(lines(raw), (line) => fields(operation, line, count).pipe(Effect.flatMap(decode))); - -const decodeContainers = (result: ContainerCommandResult) => - decodeRows("inspect-containers", result.stdout, 9, (values) => { - const [id, name, stack, owner, generation, workload, hash, role, state] = values; - if ( - id === undefined || - name === undefined || - stack === undefined || - owner === undefined || - generation === undefined || - workload === undefined || - hash === undefined || - role === undefined || - state === undefined || - role !== "workload" - ) - return Effect.fail(protocol("inspect-containers")); - return workloadLabels("inspect-containers", [ - stack, - owner, - generation, - workload, - hash, - role, - ]).pipe( - Effect.map((labels): ContainerResource => ({ - id, - name, - kind: role, - labels, - state: state.includes("running") ? "running" : "stopped", - })), - ); - }); - -const decodeNetworks = (result: ContainerCommandResult) => - decodeRows("inspect-networks", result.stdout, 6, (values) => { - const [id, name, stack, owner, generation, role] = values; - if (id === undefined || name === undefined || role === undefined) - return Effect.fail(protocol("inspect-networks")); - return networkLabels("inspect-networks", [ - stack ?? "", - owner ?? "", - generation ?? "", - role, - ]).pipe(Effect.map((labels): ContainerResource => ({ id, name, kind: "network", labels }))); - }); - -const decodeVolumes = (result: ContainerCommandResult) => - decodeRows("inspect-volumes", result.stdout, 4, (values) => { - const [name, stack, workload, role] = values; - if (name === undefined || stack === undefined || workload === undefined || role === undefined) - return Effect.fail(protocol("inspect-volumes")); - return Schema.decodeEffect(StackIdSchema)(stack).pipe( - Effect.mapError((error) => protocol("inspect-volumes", error)), - Effect.flatMap((stackId) => - role === "volume" - ? Effect.succeed({ - id: name, - name, - kind: "volume", - labels: { stackId, workloadId: workload, role: "volume" }, - }) - : Effect.fail(protocol("inspect-volumes")), - ), - ); - }); - -const decodeCreate = ( - operation: string, - result: ContainerCommandResult, - spec: { readonly name: string; readonly labels: ContainerLabels }, - kind: R, -): Effect.Effect => { - const id = result.stdout.trim(); - return id.length > 0 && !/[\r\n]/.test(id) - ? Effect.succeed({ id, name: spec.name, kind, labels: spec.labels, state: "created" }) - : Effect.fail(protocol(operation)); -}; - -const decodeWait = ( - result: ContainerCommandResult, -): Effect.Effect => { - const values = lines(result.stdout); - const value = values[0]; - if (values.length !== 1 || value === undefined || !/^\d+$/.test(value)) - return Effect.fail(protocol("wait-container")); - const exitCode = Number(value); - return Number.isSafeInteger(exitCode) - ? Effect.succeed(exitCode) - : Effect.fail(protocol("wait-container")); -}; - -const makeDockerCodecs = () => ({ - serialize: serializeDockerCommand, - serializeLogs: serializeDockerLogs, - decodeProbe: (result: ContainerCommandResult) => - scalarString("probe", result.stdout).pipe(Effect.asVoid), - decodeImage: (result: ContainerCommandResult) => - Effect.forEach(lines(result.stdout), (line) => scalarString("inspect-image", line)).pipe( - Effect.map((values) => ({ present: values.length > 0 })), - ), - decodeContainers, - decodeNetworks, - decodeVolumes, - decodeCreate, - decodeWait, -}); - export const makeDockerEngine = ( options: Omit, ): ContainerEngine => - makeContainerEngineCore({ ...options, kind: "docker", codecs: makeDockerCodecs() }); + makeContainerEngineCore({ + ...options, + kind: "docker", + codecs: makeContainerEngineCodecs({ + engineName: "Docker", + scalarFormat: "json", + serialize: serializeDockerCommand, + serializeLogs: serializeDockerLogs, + }), + }); diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index 0accf49d11..add812a2d5 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -13,6 +13,10 @@ export interface NativeProcessSpec { readonly args?: ReadonlyArray; readonly env?: Readonly>; readonly cwd?: string; + /** Signal used for an explicit graceful stop before the forced kill fallback. */ + readonly gracefulStopSignal?: "SIGTERM" | "SIGINT"; + /** Graceful-stop budget for this workload. */ + readonly gracefulStopTimeout?: Duration.Input; /** Maximum time allowed for this service-owned one-shot process. */ readonly timeout?: Duration.Input; /** Exact lifecycle witness written after a successful one-shot process. */ @@ -144,8 +148,8 @@ export const spawnNativeProcess = ( // effect sends the signal and waits for the launcher exit event; // bound that wait before forcing the same group. const graceful = yield* handle - .kill({ killSignal: "SIGTERM" }) - .pipe(Effect.timeoutOption("2 seconds")); + .kill({ killSignal: spec.gracefulStopSignal ?? "SIGTERM" }) + .pipe(Effect.timeoutOption(spec.gracefulStopTimeout ?? "2 seconds")); if (Option.isNone(graceful)) { const running = yield* handle.isRunning; if (running) yield* handle.kill({ killSignal: "SIGKILL" }); diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index ee67bd15cb..bb8cc1d676 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -6,6 +6,7 @@ import { Fiber, FileSystem, Path, + PubSub, Ref, Scope, Semaphore, @@ -21,7 +22,6 @@ import { LogStoreError } from "../supervisor/LogStore.ts"; import { RuntimeDriverError, type RuntimeCleanupRequest, - type RuntimeRecoveryRequest, type ObservedWorkload, type RuntimeDriver, type RuntimeWorkloadKey, @@ -93,11 +93,10 @@ interface Resource { } const resourceKey = (key: RuntimeWorkloadKey): string => - JSON.stringify([key.stackId, key.desiredGeneration, key.workloadId, key.specHash]); + JSON.stringify([key.stackId, key.workloadId, key.specHash]); const sameKey = (left: RuntimeWorkloadKey, right: RuntimeWorkloadKey): boolean => left.stackId === right.stackId && - left.desiredGeneration === right.desiredGeneration && left.workloadId === right.workloadId && left.specHash === right.specHash; @@ -185,6 +184,7 @@ export const makeNativeRuntime = ( const runtimeScope = yield* Scope.fork(parentScope, "parallel"); const lifecycle = yield* Semaphore.make(1); const resources = new Map(); + const failures = yield* PubSub.unbounded(); const cleanup = (resource: Resource): Effect.Effect => Scope.close(resource.scope, Exit.void).pipe( @@ -220,11 +220,13 @@ export const makeNativeRuntime = ( yield* Ref.update(resource.state, (current): ObservedWorkload => current.state === "failed" ? current : next, ); - if (Exit.isFailure(result) && !resource.stopRequested) { - yield* Deferred.fail( - resource.failure, - driverError(resource.key, `Native workload exited before readiness`, result.cause), - ); + if (!resource.stopRequested) { + yield* PubSub.publish(failures, next); + if (Exit.isFailure(result)) + yield* Deferred.fail( + resource.failure, + driverError(resource.key, `Native workload exited before readiness`, result.cause), + ); } }).pipe(Effect.ignore); @@ -232,16 +234,31 @@ export const makeNativeRuntime = ( resource: Resource, error: LogStoreError | NativeProcessError, ): Effect.Effect => { + if (resource.stopRequested) return Effect.void; const failure = driverError( resource.key, `Native log stream failed for ${resource.key.workloadId}: ${error.message}`, error, ); - return Ref.update(resource.state, (current): ObservedWorkload => - current.state === "stopped" || current.state === "failed" - ? current - : { ...current, state: "failed", error: failure.message }, - ).pipe(Effect.andThen(Deferred.fail(resource.failure, failure))); + return Ref.modify(resource.state, (current) => { + if (current.state === "stopped" || current.state === "failed") + return [false, current] satisfies readonly [boolean, ObservedWorkload]; + const next: ObservedWorkload = { ...current, state: "failed", error: failure.message }; + return [true, next] satisfies readonly [boolean, ObservedWorkload]; + }).pipe( + Effect.flatMap((changed) => + changed + ? Effect.all([ + PubSub.publish(failures, { + ...resource.key, + state: "failed", + error: failure.message, + }), + Deferred.fail(resource.failure, failure), + ]).pipe(Effect.asVoid) + : Effect.void, + ), + ); }; const attachLogs = (resource: Resource, process: NativeProcess) => { @@ -522,7 +539,7 @@ export const makeNativeRuntime = ( const existing = resources.get(id); if (existing !== undefined) { const current = yield* Ref.get(existing.state); - if (current.state !== "stopped" && current.state !== "failed") return existing; + if (current.state === "starting") return existing; yield* cleanup(existing); } const processScope = yield* Scope.fork(runtimeScope, "parallel"); @@ -650,36 +667,18 @@ export const makeNativeRuntime = ( }), ); - const recover = ( - request: RuntimeRecoveryRequest, - ): Effect.Effect, RuntimeDriverError> => - lifecycle.withPermit( - Effect.gen(function* () { - let recoveryCause: Cause.Cause = Cause.empty; - const attempt = (effect: Effect.Effect) => - Effect.gen(function* () { - const result = yield* Effect.exit(effect); - if (Exit.isFailure(result)) - recoveryCause = Cause.combine(recoveryCause, result.cause); - }); - const owned = [...resources.values()].filter( - (resource) => resource.key.stackId === request.stackId, - ); - for (const resource of owned) { - yield* attempt(stopResource(resource)); - yield* attempt(removeResource(resource)); - } - if (recoveryCause.reasons.length > 0) return yield* Effect.failCause(recoveryCause); - return []; - }), - ); + const watchFailures = Stream.unwrap( + PubSub.subscribe(failures).pipe( + Effect.map((subscription) => Stream.fromEffectRepeat(PubSub.take(subscription))), + ), + ).pipe(Stream.scoped); return { + watchFailures, observe, start, stop, remove, cleanup: cleanupRuntime, - recover, } satisfies RuntimeDriver; }); diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index 247c4f2db7..9868d57484 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -1,25 +1,16 @@ -import { Effect, Schema } from "effect"; -import { StackIdSchema } from "../public/StackId.ts"; import { + makeContainerEngineCodecs, makeContainerEngineCore, type ContainerCommand, - type ContainerCommandResult, type ContainerEngine, - type ContainerEngineFailure, type ContainerEngineOptions, type ContainerLogOptions, type ContainerLabels, - type ContainerNetworkLabels, type ContainerProcessRequest, - type ContainerResource, - type ContainerResourceRole, - type ContainerWorkloadLabels, } from "./ContainerEngine.ts"; -import { ContainerEngineProtocolError } from "./ContainerEngine.ts"; const stackLabel = "com.supabase.stack.stackId"; const ownerLabel = "com.supabase.stack.ownerSessionId"; -const generationLabel = "com.supabase.stack.desiredGeneration"; const workloadLabel = "com.supabase.stack.workloadId"; const hashLabel = "com.supabase.stack.specHash"; const roleLabel = "com.supabase.stack.role"; @@ -30,7 +21,6 @@ const labels = (value: ContainerLabels): ReadonlyArray => { ? [ ["stackId", value.stackId], ["ownerSessionId", value.ownerSessionId], - ["desiredGeneration", value.desiredGeneration], ["role", value.role], ] : value.role === "volume" @@ -42,7 +32,6 @@ const labels = (value: ContainerLabels): ReadonlyArray => { : [ ["stackId", value.stackId], ["ownerSessionId", value.ownerSessionId], - ["desiredGeneration", value.desiredGeneration], ["workloadId", value.workloadId], ["specHash", value.specHash], ["role", value.role], @@ -60,7 +49,6 @@ const containerFormat = [ "{{.Names}}", templateLabel(stackLabel), templateLabel(ownerLabel), - templateLabel(generationLabel), templateLabel(workloadLabel), templateLabel(hashLabel), templateLabel(roleLabel), @@ -71,7 +59,6 @@ const networkFormat = [ "{{.Name}}", templateMapLabel(stackLabel), templateMapLabel(ownerLabel), - templateMapLabel(generationLabel), templateMapLabel(roleLabel), ].join("\\t"); const volumeFormat = [ @@ -201,216 +188,16 @@ const serializePodmanLogs = ( ], }); -const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => - new ContainerEngineProtocolError({ - operation, - message: `${operation} returned an invalid Podman response`, - ...(cause === undefined ? {} : { cause }), - }); - -const lines = (raw: string): ReadonlyArray => - raw - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0); - -const scalar = (operation: string, raw: string): Effect.Effect => { - const value = raw.trim(); - return value.length > 0 && !/\\t|\t/.test(value) - ? Effect.succeed(value) - : Effect.fail(protocol(operation)); -}; - -const fields = ( - operation: string, - line: string, - count: number, -): Effect.Effect, ContainerEngineFailure> => { - const values = line.split(/\\t|\t/); - return values.length === count && values.every((value) => !/[\r\n]/.test(value)) - ? Effect.succeed(values) - : Effect.fail(protocol(operation)); -}; - -const decodeIdentity = ( - operation: string, - stack: string | undefined, -): Effect.Effect => { - if (stack === undefined) return Effect.fail(protocol(operation)); - return Schema.decodeEffect(StackIdSchema)(stack).pipe( - Effect.mapError((error) => protocol(operation, error)), - ); -}; - -const networkLabels = ( - operation: string, - values: ReadonlyArray, -): Effect.Effect => { - const [stack, owner, generation, role] = values; - if (owner === undefined || generation === undefined || role !== "network") - return Effect.fail(protocol(operation)); - return decodeIdentity(operation, stack).pipe( - Effect.flatMap((stackId) => { - const desiredGeneration = Number(generation); - return Number.isInteger(desiredGeneration) - ? Effect.succeed({ - stackId, - ownerSessionId: owner, - desiredGeneration, - role: "network", - }) - : Effect.fail(protocol(operation)); - }), - ); -}; - -const workloadLabels = ( - operation: string, - values: ReadonlyArray, -): Effect.Effect => { - const [stack, owner, generation, workload, hash, role] = values; - if ( - owner === undefined || - generation === undefined || - workload === undefined || - hash === undefined || - role !== "workload" - ) - return Effect.fail(protocol(operation)); - return decodeIdentity(operation, stack).pipe( - Effect.flatMap((stackId) => { - const desiredGeneration = Number(generation); - return Number.isInteger(desiredGeneration) - ? Effect.succeed({ - stackId, - ownerSessionId: owner, - desiredGeneration, - workloadId: workload, - specHash: hash, - role, - }) - : Effect.fail(protocol(operation)); - }), - ); -}; - -const decodeRows = ( - operation: string, - raw: string, - count: number, - decode: (values: ReadonlyArray) => Effect.Effect, -): Effect.Effect, ContainerEngineFailure> => - Effect.forEach(lines(raw), (line) => fields(operation, line, count).pipe(Effect.flatMap(decode))); - -const decodeContainers = (result: ContainerCommandResult) => - decodeRows("inspect-containers", result.stdout, 9, (values) => { - const [id, name, stack, owner, generation, workload, hash, role, state] = values; - if ( - id === undefined || - name === undefined || - stack === undefined || - owner === undefined || - generation === undefined || - workload === undefined || - hash === undefined || - role === undefined || - state === undefined || - role !== "workload" - ) - return Effect.fail(protocol("inspect-containers")); - return workloadLabels("inspect-containers", [ - stack, - owner, - generation, - workload, - hash, - role, - ]).pipe( - Effect.map((labels): ContainerResource => ({ - id, - name, - kind: role, - labels, - state: state.includes("running") ? "running" : "stopped", - })), - ); - }); - -const decodeNetworks = (result: ContainerCommandResult) => - decodeRows("inspect-networks", result.stdout, 6, (values) => { - const [id, name, stack, owner, generation, role] = values; - if (id === undefined || name === undefined || role === undefined) - return Effect.fail(protocol("inspect-networks")); - return networkLabels("inspect-networks", [ - stack ?? "", - owner ?? "", - generation ?? "", - role, - ]).pipe(Effect.map((labels): ContainerResource => ({ id, name, kind: "network", labels }))); - }); - -const decodeVolumes = (result: ContainerCommandResult) => - decodeRows("inspect-volumes", result.stdout, 4, (values) => { - const [name, stack, workload, role] = values; - if (name === undefined || stack === undefined || workload === undefined || role === undefined) - return Effect.fail(protocol("inspect-volumes")); - return Schema.decodeEffect(StackIdSchema)(stack).pipe( - Effect.mapError((error) => protocol("inspect-volumes", error)), - Effect.flatMap((stackId) => - role === "volume" - ? Effect.succeed({ - id: name, - name, - kind: "volume", - labels: { stackId, workloadId: workload, role: "volume" }, - }) - : Effect.fail(protocol("inspect-volumes")), - ), - ); - }); - -const decodeCreate = ( - operation: string, - result: ContainerCommandResult, - spec: { readonly name: string; readonly labels: ContainerLabels }, - kind: R, -): Effect.Effect => { - const id = result.stdout.trim(); - return id.length > 0 && !/[\r\n]/.test(id) - ? Effect.succeed({ id, name: spec.name, kind, labels: spec.labels, state: "created" }) - : Effect.fail(protocol(operation)); -}; - -const decodeWait = ( - result: ContainerCommandResult, -): Effect.Effect => { - const values = lines(result.stdout); - const value = values[0]; - if (values.length !== 1 || value === undefined || !/^\d+$/.test(value)) - return Effect.fail(protocol("wait-container")); - const exitCode = Number(value); - return Number.isSafeInteger(exitCode) - ? Effect.succeed(exitCode) - : Effect.fail(protocol("wait-container")); -}; - -const makePodmanCodecs = () => ({ - serialize: serializePodmanCommand, - serializeLogs: serializePodmanLogs, - decodeProbe: (result: ContainerCommandResult) => - scalar("probe", result.stdout).pipe(Effect.asVoid), - decodeImage: (result: ContainerCommandResult) => - Effect.forEach(lines(result.stdout), (line) => scalar("inspect-image", line)).pipe( - Effect.map((values) => ({ present: values.length > 0 })), - ), - decodeContainers, - decodeNetworks, - decodeVolumes, - decodeCreate, - decodeWait, -}); - export const makePodmanEngine = ( options: Omit, ): ContainerEngine => - makeContainerEngineCore({ ...options, kind: "podman", codecs: makePodmanCodecs() }); + makeContainerEngineCore({ + ...options, + kind: "podman", + codecs: makeContainerEngineCodecs({ + engineName: "Podman", + scalarFormat: "raw", + serialize: serializePodmanCommand, + serializeLogs: serializePodmanLogs, + }), + }); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 7dc55a907f..e8cf2bd1e4 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -14,9 +14,8 @@ import { Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { eagerCapabilities, type PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { StackDefinition } from "../model/Compiler.ts"; -import { bundleServeMainTemplate } from "../functions/serve-main-bundler.ts"; import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, @@ -34,13 +33,15 @@ import { StackPreparationError, ArtifactIntegrityError, ContainerPullError, + PortUnavailableError, StackRuntimeMismatchError, StackStateInvalidError, type StackError, } from "../public/Errors.ts"; import { makeSupervisorIngress, type SupervisorIngress } from "../supervisor/Ingress.ts"; +import { checkHostPort } from "../supervisor/HostListener.ts"; import { makeLogStore, type LogStore, type LogRecord } from "../supervisor/LogStore.ts"; -import type { LifecycleInput, LifecyclePrepared } from "../supervisor/Lifecycle.ts"; +import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; import type { SupervisorRuntime, SupervisorRuntimeFactory } from "../supervisor/Supervisor.ts"; import { makeProductionRuntimeArtifactPreparer, @@ -69,6 +70,7 @@ import { parseGoDuration } from "../model/capabilities/database.ts"; import type { ContainerEngine, ContainerHostRoute } from "./ContainerEngine.ts"; import { bootstrapDatabaseAt } from "./PostgresDatabaseSession.ts"; import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; +import { validateMaterializedSecrets } from "../state/MaterializedSettings.ts"; import { RuntimeDriverError, type RuntimeDriver, @@ -148,6 +150,58 @@ const urlHost = (host: string): string => { const DATABASE_WORKLOAD_ID = "database:database"; /** Atomic lifecycle witness for a successfully migrated native cluster. */ export const NATIVE_DATABASE_MIGRATION_MARKER = ".supabase-stack-migration-complete"; +const checkNativeDatabaseLockEvidence = ( + fileSystem: FileSystem.FileSystem, + lockPath: string, +): Effect.Effect => + Effect.gen(function* () { + if ( + !(yield* fileSystem + .exists(lockPath) + .pipe( + Effect.mapError((error) => + preparationError("Unable to inspect native database lock evidence", error), + ), + )) + ) + return; + const firstLine = (yield* fileSystem + .readFileString(lockPath) + .pipe( + Effect.mapError((error) => + preparationError("Unable to read native database lock evidence", error), + ), + )) + .split("\n", 1)[0] + ?.trim(); + if (firstLine === undefined || !/^[1-9]\d*$/u.test(firstLine)) + return yield* preparationError("Native database lock evidence contains an invalid PID"); + const pid = Number(firstLine); + if (!Number.isSafeInteger(pid)) + return yield* preparationError("Native database lock evidence contains an invalid PID"); + const probe = yield* Effect.sync( + (): { readonly alive: true } | { readonly alive: false; readonly cause: unknown } => { + try { + process.kill(pid, 0); + return { alive: true }; + } catch (cause) { + return { alive: false, cause }; + } + }, + ); + const alive = probe.alive + ? true + : typeof probe.cause === "object" && + probe.cause !== null && + "code" in probe.cause && + probe.cause.code === "ESRCH" + ? false + : yield* preparationError("Unable to verify native database lock owner", probe.cause); + if (alive) + return yield* preparationError( + `Native database lock is held by live process ${pid}; refusing a clean restart`, + ); + }); const isDatabaseWorkload = (workload: PlannedWorkload): boolean => workload.id === DATABASE_WORKLOAD_ID; const configuredDatabaseReadinessDeadline = ( @@ -272,7 +326,6 @@ interface NativeDatabaseMigrationOptions { readonly artifactRoot: string; readonly environment: Readonly>; readonly endpointPort: number; - readonly timeout: Duration.Duration; readonly fileSystem: FileSystem.FileSystem; readonly pathService: Path.Path; } @@ -282,16 +335,8 @@ const prepareNativeDatabaseMigration = ( options: NativeDatabaseMigrationOptions, ): Effect.Effect => Effect.gen(function* () { - const { - key, - dataPath, - artifactRoot, - environment, - endpointPort, - timeout, - fileSystem, - pathService, - } = options; + const { key, dataPath, artifactRoot, environment, endpointPort, fileSystem, pathService } = + options; const markerPath = pathService.join(dataPath, NATIVE_DATABASE_MIGRATION_MARKER); const migrated = yield* fileSystem .exists(markerPath) @@ -309,7 +354,21 @@ const prepareNativeDatabaseMigration = ( driverError(key, `Unable to inspect native database data path: ${error.message}`, error), ), ); - if (initialized) + const postmasterOptionsPath = pathService.join(dataPath, "postmaster.opts"); + const completeInitialization = initialized + ? yield* fileSystem + .exists(postmasterOptionsPath) + .pipe( + Effect.mapError((error) => + driverError( + key, + `Unable to inspect native database data path: ${error.message}`, + error, + ), + ), + ) + : false; + if (initialized && !completeInitialization) yield* fileSystem .remove(dataPath, { recursive: true, force: true }) .pipe( @@ -330,7 +389,9 @@ const prepareNativeDatabaseMigration = ( return { executable: pathService.join(migrationsPath, "migrate.sh"), cwd: migrationsPath, - timeout, + // Migration can legitimately outlive readiness on a cold machine; its marker is + // written only after success, so rerunning remains idempotent when the marker is absent. + timeout: "15 minutes", successMarker: markerPath, env: { ...environment, @@ -344,10 +405,21 @@ const prepareNativeDatabaseMigration = ( }; }); -const bootstrapContent = Effect.tryPromise({ - try: () => bundleServeMainTemplate(), - catch: (cause) => preparationError("Unable to bundle functions bootstrap", cause), -}); +declare const SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; + +// Release builds inject the already-bundled Edge Runtime entrypoint. The +// source-only fallback keeps local development/tests convenient while keeping +// esbuild out of the shipped supervisor's runtime dependency graph. +const bootstrapContent = + typeof SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string" + ? Effect.succeed(SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE) + : Effect.tryPromise({ + try: () => + import("../functions/serve-main-bundler.ts").then(({ bundleServeMainTemplate }) => + bundleServeMainTemplate(), + ), + catch: (cause) => preparationError("Unable to bundle functions bootstrap", cause), + }); const mapDriverError = ( key: Pick, @@ -506,7 +578,6 @@ export const makeProductionRuntimeFactory = ( Effect.gen(function* () { const artifacts = new Map(); const hostRoute = yield* Ref.make(undefined); - const bootstrapPaths = new Map(); const freshState = (key: Pick) => currentStateReader(options).pipe( @@ -540,27 +611,15 @@ export const makeProductionRuntimeFactory = ( Effect.forEach(workloads, (workload) => prepare(runtime, workload), { concurrency: 4, }); - const functionsPath = ( - generation: number, - ): Effect.Effect => { - const cached = bootstrapPaths.get(generation); - return cached === undefined - ? Effect.gen(function* () { - const content = yield* serveTemplate; - const path = yield* functionsBootstrap.write({ generation, content }); - bootstrapPaths.set(generation, path); - return path; - }) - : Effect.succeed(cached); - }; + const functionsPath = (): Effect.Effect => + serveTemplate.pipe(Effect.flatMap((content) => functionsBootstrap.write({ content }))); const runtimeInputs = ( workload: PlannedWorkload, fresh: PersistedStackState, - generation: number, host: ContainerHostRoute | undefined, ): Effect.Effect => Effect.gen(function* () { - const material = yield* inputOwner.resolve(fresh, generation, { + const material = yield* inputOwner.resolve(fresh, { includePooler: workload.id === "pooler:pooler", }); const templates = material.auth?.templates; @@ -584,7 +643,7 @@ export const makeProductionRuntimeFactory = ( const functions = workload.id === "functions:edge-runtime" ? { - bootstrapPath: yield* functionsPath(generation), + bootstrapPath: yield* functionsPath(), bootstrapContainerPath: FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, ...(material.functions?.secrets === undefined ? {} @@ -606,7 +665,10 @@ export const makeProductionRuntimeFactory = ( }; }); - const preflight = (input: LifecycleInput): Effect.Effect => + const preflight = ( + input: LifecycleInput, + mode: "cold" | "live", + ): Effect.Effect => Effect.gen(function* () { if (!runtimeMatches(input.state.runtime, state.runtime)) return yield* new StackRuntimeMismatchError({ @@ -618,6 +680,9 @@ export const makeProductionRuntimeFactory = ( // Eagerly validate the candidate before any engine/artifact work. Start-time checks // below revalidate the fresh persisted definition because it is runtime authority. yield* validateDatabaseReadinessBudget(input.definition, input.plan.workloads); + yield* Effect.forEach(input.plan.workloads, (workload) => + validateMaterializedSecrets(input.state, workload.capability), + ); yield* Effect.forEach(input.plan.workloads, (workload) => Effect.gen(function* () { if (runtimeSpecFor(workload) === undefined) @@ -641,8 +706,37 @@ export const makeProductionRuntimeFactory = ( ); yield* Ref.set(hostRoute, route); } - yield* prepareArtifacts(input.state.runtime, input.plan.workloads); - return {}; + if (input.state.runtime.kind === "native" && mode === "cold") { + for (const assignment of input.state.ports) { + const listener = input.definition.listeners[assignment.field]; + yield* checkHostPort(listener.address, assignment.port, assignment.field).pipe( + Effect.mapError( + (error) => + new PortUnavailableError({ + field: assignment.field, + port: assignment.port, + message: `Persisted ${assignment.field} port is unavailable`, + cause: error, + }), + ), + ); + } + for (const assignment of input.state.privatePorts) + yield* checkHostPort( + "127.0.0.1", + assignment.port, + `${assignment.workloadId}:${assignment.binding}`, + ); + yield* checkNativeDatabaseLockEvidence( + fileSystem, + pathService.join(paths.data, "database", "postmaster.pid"), + ); + } + const eager = eagerCapabilities(input.plan); + yield* prepareArtifacts( + input.state.runtime, + input.plan.workloads.filter((workload) => eager.has(workload.capability)), + ); }); const activate = ( @@ -725,17 +819,14 @@ export const makeProductionRuntimeFactory = ( key, `Unknown runtime specification for ${workload.id}`, ); + const readinessBudget = yield* readinessDeadlineFor(fresh, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); // Revalidate the fresh persisted definition before spawning; preflight checks // the candidate definition, while this state is the runtime authority. - const readinessBudget = yield* readinessDeadlineFor(fresh, workload).pipe( + const inputs = yield* runtimeInputs(workload, fresh, undefined).pipe( Effect.mapError((error) => mapDriverError(key, error)), ); - const inputs = yield* runtimeInputs( - workload, - fresh, - key.desiredGeneration, - undefined, - ).pipe(Effect.mapError((error) => mapDriverError(key, error))); yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( Effect.mapError((error) => mapDriverError(key, error)), ); @@ -788,7 +879,6 @@ export const makeProductionRuntimeFactory = ( artifactRoot: prepared.artifactRoot, environment, endpointPort: endpoint.port, - timeout: readinessBudget, fileSystem, pathService, }); @@ -857,12 +947,9 @@ export const makeProductionRuntimeFactory = ( ); yield* Ref.set(hostRoute, route); } - const inputs = yield* runtimeInputs( - workload, - fresh, - key.desiredGeneration, - route, - ).pipe(Effect.mapError((error) => mapDriverError(key, error))); + const inputs = yield* runtimeInputs(workload, fresh, route).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( Effect.mapError((error) => mapDriverError(key, error)), ); @@ -878,7 +965,6 @@ export const makeProductionRuntimeFactory = ( ); const envFile = yield* envFiles .write({ - generation: key.desiredGeneration, workloadId: workload.id, values: resolution.env, }) @@ -920,31 +1006,8 @@ export const makeProductionRuntimeFactory = ( functionsBootstrap, inputOwner, ); - const cleanupOwnedInputs = (key: RuntimeWorkloadKey) => - key.workloadId === "pooler:pooler" - ? inputOwner - .cleanupGeneration(key.desiredGeneration, "pooler") - .pipe( - Effect.mapError((error) => - driverError(key, "Unable to clean pooler runtime input", error), - ), - ) - : key.workloadId === "analytics:vector" - ? inputOwner - .cleanupGeneration(key.desiredGeneration, "vector") - .pipe( - Effect.mapError((error) => - driverError(key, "Unable to clean Vector runtime input", error), - ), - ) - : Effect.void; - const ownedDriver: RuntimeDriver = { - ...baseDriver, - stop: (key) => baseDriver.stop(key).pipe(Effect.tap(() => cleanupOwnedInputs(key))), - remove: (key) => baseDriver.remove(key).pipe(Effect.tap(() => cleanupOwnedInputs(key))), - }; return { - driver: ownedDriver, + driver: baseDriver, prepare: prepareArtifacts, preflight, activate, diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index 9e51bd3b6d..24ce941e2b 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -1,11 +1,10 @@ -import { Data, Effect } from "effect"; +import { Data, Effect, Stream } from "effect"; import type { StackId } from "../public/StackId.ts"; -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; /** The exact identity used when touching a private runtime resource. */ export interface RuntimeWorkloadKey { readonly stackId: StackId; - readonly desiredGeneration: number; readonly workloadId: string; readonly specHash: string; } @@ -17,15 +16,6 @@ export interface RuntimeCleanupRequest { readonly destroy: boolean; } -/** Exact persisted intent used when an owner is replaced or resumes after a crash. */ -export interface RuntimeRecoveryRequest { - readonly stackId: StackId; - readonly desiredGeneration: number; - /** Recovery only adopts a durable running stack; stopped state uses cleanup instead. */ - readonly desiredLifecycle: "running"; - readonly plan: ExecutionPlan; -} - /** A driver only reports states that can be acted on by the reconciler. */ type ObservedWorkloadState = "absent" | "starting" | "ready" | "stopped" | "failed"; @@ -37,6 +27,8 @@ export interface ObservedWorkload extends RuntimeWorkloadKey { type RuntimeWorkload = PlannedWorkload; export interface RuntimeDriver { + /** Unexpected post-readiness failures, scoped to this driver owner. */ + readonly watchFailures: Stream.Stream; /** Enumerates only private resources owned by this exact stack identity. */ readonly observe: ( stackId: StackId, @@ -46,22 +38,15 @@ export interface RuntimeDriver { key: RuntimeWorkloadKey, workload: RuntimeWorkload, ) => Effect.Effect; - /** Stops one exact resource; no other generation or stack may be touched. */ + /** Stops one exact resource; no other stack may be touched. */ readonly stop: (key: RuntimeWorkloadKey) => Effect.Effect; - /** Removes one exact resource; no other generation or stack may be touched. */ + /** Removes one exact resource; no other stack may be touched. */ readonly remove: (key: RuntimeWorkloadKey) => Effect.Effect; /** * Cleans up exact resources belonging to one stack. Ordinary stop retains volumes; destructive * cleanup removes them after containers and networks have been removed. */ readonly cleanup: (request: RuntimeCleanupRequest) => Effect.Effect; - /** - * Inspects and fences leftover resources without starting anything. Native resources are - * terminated; containers with current identities are adopted and stale resources removed. - */ - readonly recover: ( - request: RuntimeRecoveryRequest, - ) => Effect.Effect, RuntimeDriverError>; } export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ @@ -71,21 +56,6 @@ export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ readonly cause?: unknown; }> {} -export class RuntimeGenerationMismatchError extends Data.TaggedError( - "RuntimeGenerationMismatchError", -)<{ - readonly message: string; - readonly expectedGeneration: number; - readonly actualGeneration: number; -}> {} - -export class RuntimeReadinessTimeoutError extends Data.TaggedError("RuntimeReadinessTimeoutError")<{ - readonly message: string; - readonly stackId: StackId; - readonly workloadId: string; - readonly desiredGeneration: number; -}> {} - export class RuntimeRestartBudgetExceededError extends Data.TaggedError( "RuntimeRestartBudgetExceededError", )<{ diff --git a/packages/stack/src/runtime/RuntimeEnvFile.ts b/packages/stack/src/runtime/RuntimeEnvFile.ts index 33f378ba20..b91a12059d 100644 --- a/packages/stack/src/runtime/RuntimeEnvFile.ts +++ b/packages/stack/src/runtime/RuntimeEnvFile.ts @@ -4,14 +4,11 @@ import { resolveStackPaths } from "../state/Paths.ts"; import type { StackId } from "../public/StackId.ts"; export interface RuntimeEnvFileOwner { - /** Writes one generation/workload file and returns its exact owned path. */ + /** Writes one workload file and returns its exact owned path. */ readonly write: (input: { - readonly generation: number; readonly workloadId: string; readonly values: Readonly>; }) => Effect.Effect; - /** Removes one exact generation's files; safe when already absent. */ - readonly cleanupGeneration: (generation: number) => Effect.Effect; /** Removes only this owner's env-file directory; safe when already absent. */ readonly cleanupAll: Effect.Effect; } @@ -24,9 +21,6 @@ export interface RuntimeEnvFileOwnerOptions { const error = (message: string, fields: Readonly> = {}) => new StackPreparationError({ message, ...fields }); -const validGeneration = (generation: number): boolean => - Number.isSafeInteger(generation) && generation >= 0; - const validWorkloadId = (workloadId: string): boolean => /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/u.test(workloadId); @@ -81,33 +75,25 @@ export const makeRuntimeEnvFileOwner = ( const envRoot = path.join(paths.runtime, "env"); const write = (input: { - readonly generation: number; readonly workloadId: string; readonly values: Readonly>; }): Effect.Effect => { - if (!validGeneration(input.generation)) - return Effect.fail(error("Invalid runtime environment generation")); if (!validWorkloadId(input.workloadId)) return Effect.fail(error("Invalid runtime environment workload identity")); return Effect.gen(function* () { const text = yield* contentFor(input.values); - const generationRoot = path.join(envRoot, String(input.generation)); - const target = path.join(generationRoot, `${encodeWorkloadId(input.workloadId)}.env`); + const target = path.join(envRoot, `${encodeWorkloadId(input.workloadId)}.env`); const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError(() => error("Unable to allocate runtime environment file name")), ); const temporary = `${target}.${token}.tmp`; return yield* Effect.gen(function* () { yield* mapFile( - generationRoot, + envRoot, "create runtime environment directory", - fs.makeDirectory(generationRoot, { recursive: true, mode: 0o700 }), - ); - yield* mapFile( - generationRoot, - "secure runtime environment directory", - fs.chmod(generationRoot, 0o700), + fs.makeDirectory(envRoot, { recursive: true, mode: 0o700 }), ); + yield* mapFile(envRoot, "secure runtime environment directory", fs.chmod(envRoot, 0o700)); yield* Effect.scoped( Effect.gen(function* () { const file = yield* mapFile( @@ -141,18 +127,10 @@ export const makeRuntimeEnvFileOwner = ( }); }; - const cleanupGeneration = (generation: number): Effect.Effect => - !validGeneration(generation) - ? Effect.fail(error("Invalid runtime environment generation")) - : mapFile( - path.join(envRoot, String(generation)), - "clean runtime environment generation", - fs.remove(path.join(envRoot, String(generation)), { recursive: true, force: true }), - ); const cleanupAll = mapFile( envRoot, "clean runtime environment files", fs.remove(envRoot, { recursive: true, force: true }), ); - return { write, cleanupGeneration, cleanupAll }; + return { write, cleanupAll }; }); diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 3f45a2249d..2fb08b40c6 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -17,6 +17,7 @@ import { StackPreparationError } from "../public/Errors.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { isRecord, settingValue, settingsFor } from "../state/MaterializedSettings.ts"; import { + base64UrlEncode, resolveSigningKeyMaterial, type ResolvedSigningKeyMaterial, } from "../state/SecretStore.ts"; @@ -45,7 +46,7 @@ interface RuntimeInputMaterial { }>; readonly analytics?: Readonly<{ readonly gcpJwtPath?: string; - /** Generation-scoped Vector config owned by this stack runtime. */ + /** Session-scoped Vector config owned by this stack runtime. */ readonly vectorConfigPath?: string; }>; readonly pooler?: Readonly<{ readonly tenantPath?: string }>; @@ -56,7 +57,6 @@ export interface RuntimeInputOwner { /** Resolves all stack-owned inputs needed before a workload is created. */ readonly resolve: ( state: PersistedStackState, - generation: number, options?: Readonly<{ readonly includePooler?: boolean }>, ) => Effect.Effect; /** Resolves one configured project-relative regular file without copying it. */ @@ -67,16 +67,9 @@ export interface RuntimeInputOwner { readonly resolveAuthTemplates: ( state: PersistedStackState, ) => Effect.Effect, StackPreparationError>; - /** Removes only the generation-owned material for the stopped workload. */ - readonly cleanupGeneration: ( - generation: number, - owner: RuntimeInputOwnerKind, - ) => Effect.Effect; readonly cleanupAll: Effect.Effect; } -type RuntimeInputOwnerKind = "pooler" | "vector"; - export interface RuntimeInputOwnerOptions { readonly stateRoot: string; readonly stackId: StackId; @@ -96,9 +89,6 @@ const mapFile = ( Effect.mapError((error) => failure(`Unable to ${operation}`, { path: target, cause: error })), ); -const invalidGeneration = (generation: number): boolean => - !Number.isSafeInteger(generation) || generation < 0; - const finiteSetting = (value: string): number | undefined => { const trimmed = value.trim(); if (trimmed.length === 0) return undefined; @@ -140,21 +130,6 @@ const safeUrlLabel = (value: string): string => { } }; -const base64UrlEncode = (bytes: Uint8Array): string => { - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let output = ""; - for (let index = 0; index < bytes.length; index += 3) { - const first = bytes[index] ?? 0; - const second = bytes[index + 1]; - const third = bytes[index + 2]; - output += alphabet[first >> 2] ?? ""; - output += alphabet[((first & 3) << 4) | ((second ?? 0) >> 4)] ?? ""; - if (second !== undefined) output += alphabet[((second & 15) << 2) | ((third ?? 0) >> 6)] ?? ""; - if (third !== undefined) output += alphabet[third & 63] ?? ""; - } - return output; -}; - const symmetricJwk = (secret: string): Readonly> => ({ kty: "oct", alg: "HS256", @@ -501,10 +476,8 @@ export const makeRuntimeInputOwner = ( const writePoolerTenant = ( state: PersistedStackState, - generation: number, runtime: "native" | "container", ): Effect.Effect => { - if (invalidGeneration(generation)) return Effect.fail(failure("Invalid pooler generation")); const password = state.secrets["secret:database.internal.password"]?.value ?? ""; if (password.length === 0) return Effect.fail(failure("Persisted database secret is missing")); @@ -539,24 +512,19 @@ export const makeRuntimeInputOwner = ( defaultPoolSize, maxClientConn, }); - const generationRoot = path.join(poolerRoot, String(generation)); - const target = path.join(generationRoot, "pooler_tenant.exs"); + const target = path.join(poolerRoot, "pooler_tenant.exs"); return Effect.gen(function* () { const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError(() => failure("Unable to allocate pooler tenant file")), ); - const temporary = path.join(generationRoot, `.pooler_tenant.exs.${token}.tmp`); + const temporary = path.join(poolerRoot, `.pooler_tenant.exs.${token}.tmp`); return yield* Effect.gen(function* () { yield* mapFile( - generationRoot, + poolerRoot, "create pooler tenant directory", - fs.makeDirectory(generationRoot, { recursive: true, mode: 0o700 }), - ); - yield* mapFile( - generationRoot, - "secure pooler tenant directory", - fs.chmod(generationRoot, 0o700), + fs.makeDirectory(poolerRoot, { recursive: true, mode: 0o700 }), ); + yield* mapFile(poolerRoot, "secure pooler tenant directory", fs.chmod(poolerRoot, 0o700)); yield* Effect.scoped( Effect.gen(function* () { const file = yield* mapFile( @@ -588,31 +556,24 @@ export const makeRuntimeInputOwner = ( const writeVectorConfig = ( state: PersistedStackState, - generation: number, ): Effect.Effect => { - if (invalidGeneration(generation)) return Effect.fail(failure("Invalid Vector generation")); const assignment = state.privatePorts.find( (entry) => entry.workloadId === "analytics:vector" && entry.binding === "primary", ); if (state.runtime.kind === "native" && assignment === undefined) return Effect.fail(failure("Persisted native Vector assignment is missing")); - const generationRoot = path.join(vectorRoot, String(generation)); - const target = path.join(generationRoot, "vector.yaml"); + const target = path.join(vectorRoot, "vector.yaml"); return Effect.gen(function* () { yield* mapFile( - generationRoot, + vectorRoot, "create Vector config directory", - fs.makeDirectory(generationRoot, { recursive: true, mode: 0o700 }), - ); - yield* mapFile( - generationRoot, - "secure Vector config directory", - fs.chmod(generationRoot, 0o700), + fs.makeDirectory(vectorRoot, { recursive: true, mode: 0o700 }), ); + yield* mapFile(vectorRoot, "secure Vector config directory", fs.chmod(vectorRoot, 0o700)); const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError(() => failure("Unable to allocate Vector config file")), ); - const temporary = path.join(generationRoot, `.vector.yaml.${token}.tmp`); + const temporary = path.join(vectorRoot, `.vector.yaml.${token}.tmp`); yield* Effect.ensuring( Effect.scoped( Effect.gen(function* () { @@ -648,28 +609,11 @@ export const makeRuntimeInputOwner = ( const commonCompleted = new Map(); const poolerPending = new Map>(); const poolerCompleted = new Map(); - let acceptedGeneration: number | undefined; - const keyFor = (state: PersistedStackState, generation: number): string => - `${state.identity.projectRoot}\u0000${state.inputFingerprint ?? ""}\u0000${state.runtime.kind}\u0000${generation}`; - const generationFor = (key: string): number => Number(key.slice(key.lastIndexOf("\u0000") + 1)); - const retireEntries = ( - pending: Map>, - completed: Map, - matches: (key: string) => boolean, - ): ReadonlyArray> => { - const retired: Array> = []; - for (const [key, deferred] of pending) - if (matches(key)) { - pending.delete(key); - retired.push(deferred); - } - for (const key of completed.keys()) if (matches(key)) completed.delete(key); - return retired; - }; + const keyFor = (state: PersistedStackState): string => + `${state.identity.projectRoot}\u0000${state.inputFingerprint ?? ""}\u0000${state.runtime.kind}`; const materializeCommon = ( state: PersistedStackState, - generation: number, ): Effect.Effect => Effect.gen(function* () { const jwtConsumers = ["rest", "auth", "realtime", "storage", "functions"] as const; @@ -686,9 +630,7 @@ export const makeRuntimeInputOwner = ( ? settingValue(state, analyticsSettings.vector_port) : ""; const vectorConfigPath = - analyticsEnabled && vectorPort.length > 0 - ? yield* writeVectorConfig(state, generation) - : undefined; + analyticsEnabled && vectorPort.length > 0 ? yield* writeVectorConfig(state) : undefined; const analytics = !analyticsEnabled || (gcpPath.length === 0 && vectorConfigPath === undefined) ? undefined @@ -736,7 +678,9 @@ export const makeRuntimeInputOwner = ( const completion = yield* admission.withPermit( Effect.sync(() => { if (pending.get(key) !== deferred) - return Exit.fail(failure("Runtime input generation was invalidated")); + return Exit.fail( + failure("Runtime input materialization was invalidated"), + ); pending.delete(key); if (Exit.isSuccess(exit)) completed.set(key, exit.value); return exit; @@ -753,7 +697,7 @@ export const makeRuntimeInputOwner = ( Effect.sync(() => pending.get(key) === deferred), ); if (!stillCurrent) - return yield* failure("Runtime input generation was invalidated"); + return yield* failure("Runtime input materialization was invalidated"); return yield* restore(materialize); }).pipe(Effect.onExit(publish)), ); @@ -776,45 +720,15 @@ export const makeRuntimeInputOwner = ( const resolve = ( state: PersistedStackState, - generation: number, options: Readonly<{ readonly includePooler?: boolean }> = {}, ): Effect.Effect => Effect.gen(function* () { - const key = keyFor(state, generation); - type Retirement = Readonly<{ - readonly rejected: boolean; - readonly retired: ReadonlyArray>; - }>; - const retirement = yield* admission.withPermit( - Effect.sync((): Retirement => { - const accepted = acceptedGeneration; - if (accepted !== undefined && generation < accepted) - return { rejected: true, retired: [] }; - if (accepted === undefined || generation <= accepted) { - acceptedGeneration = generation; - return { rejected: false, retired: [] }; - } - const retired = retireEntries( - commonPending, - commonCompleted, - (pendingKey) => generationFor(pendingKey) < generation, - ); - acceptedGeneration = generation; - return { rejected: false, retired }; - }), - ); - yield* Effect.forEach(retirement.retired, (deferred) => - Deferred.succeed( - deferred, - Exit.fail(failure("Runtime input generation was invalidated")), - ), - ); - if (retirement.rejected) return yield* failure("Runtime input generation was invalidated"); + const key = keyFor(state); const common = yield* singleFlight( key, commonPending, commonCompleted, - materializeCommon(state, generation), + materializeCommon(state), ); if ( options.includePooler !== true || @@ -825,55 +739,10 @@ export const makeRuntimeInputOwner = ( key, poolerPending, poolerCompleted, - writePoolerTenant(state, generation, state.runtime.kind), + writePoolerTenant(state, state.runtime.kind), ); return { ...common, pooler: { tenantPath } }; }); - - const cleanupGeneration = ( - generation: number, - owner: RuntimeInputOwnerKind, - ): Effect.Effect => - invalidGeneration(generation) - ? Effect.fail(failure("Invalid runtime input generation")) - : execution.withPermit( - Effect.gen(function* () { - const matches = (key: string) => key.endsWith(`\u0000${generation}`); - if (owner === "pooler") { - const retired = yield* admission.withPermit( - Effect.sync(() => retireEntries(poolerPending, poolerCompleted, matches)), - ); - yield* Effect.forEach(retired, (deferred) => - Deferred.succeed( - deferred, - Exit.fail(failure("Runtime input generation was invalidated")), - ), - ); - } else { - // Vector config is part of common material. Invalidate both - // completed and in-flight entries so a same-generation - // restart publishes a fresh config path. - const retired = yield* admission.withPermit( - Effect.sync(() => retireEntries(commonPending, commonCompleted, matches)), - ); - yield* Effect.forEach(retired, (deferred) => - Deferred.succeed( - deferred, - Exit.fail(failure("Runtime input generation was invalidated")), - ), - ); - } - const root = owner === "pooler" ? poolerRoot : vectorRoot; - yield* mapFile( - path.join(root, String(generation)), - `clean ${owner} generation`, - fs.remove(path.join(root, String(generation)), { - recursive: true, - force: true, - }), - ); - }), - ); const cleanupAll = execution.withPermit( Effect.gen(function* () { const pending = yield* admission.withPermit( @@ -884,7 +753,6 @@ export const makeRuntimeInputOwner = ( commonCompleted.clear(); poolerPending.clear(); poolerCompleted.clear(); - acceptedGeneration = undefined; return { common, pooler }; }), ); @@ -911,7 +779,6 @@ export const makeRuntimeInputOwner = ( resolve, resolveProjectFile, resolveAuthTemplates, - cleanupGeneration, cleanupAll, }; }); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 49910553ae..d6a60bbf1b 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -21,8 +21,8 @@ import { } from "../model/WorkloadCatalog.ts"; import { parseFileSize } from "../model/capabilities/storage.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; -import { Effect } from "effect"; -import { StackPreparationError } from "../public/Errors.ts"; +import { Effect, type Duration } from "effect"; +import { StackPreparationError, StackStateInvalidError } from "../public/Errors.ts"; type WorkloadRuntimeKind = "native" | "container"; @@ -81,6 +81,8 @@ interface NativeProcessResolution { readonly args: ReadonlyArray; readonly cwd: string; readonly env?: Readonly>; + readonly gracefulStopSignal?: "SIGTERM" | "SIGINT"; + readonly gracefulStopTimeout?: Duration.Input; } /** Complete process-side contract for one catalog workload. */ @@ -112,8 +114,14 @@ export interface WorkloadRuntimeSpec { port: number, inputs?: WorkloadRuntimeInputs, ) => ReadonlyArray; - /** Closed set of service-owned one-shot container processes. */ - readonly containerStartupProcesses: ReadonlyArray; + /** Optional main-container entrypoint override; startup processes remain explicit. */ + readonly containerEntrypoint?: string; + /** Service-owned one-shot container processes run before the main container. */ + readonly containerStartupProcesses: ( + state: PersistedStackState, + workload: PlannedWorkload, + inputs?: WorkloadRuntimeInputs, + ) => ReadonlyArray; readonly containerMounts?: ( state: PersistedStackState, workload: PlannedWorkload, @@ -169,6 +177,7 @@ type WorkloadRuntimeSpecDefinition = Omit< >; export interface ContainerWorkloadResolution { + readonly entrypoint?: string; readonly command: ReadonlyArray; readonly startup?: ReadonlyArray; readonly env: Readonly>; @@ -192,7 +201,7 @@ export const validateWorkloadRuntimeInputs = ( state: PersistedStackState, workload: PlannedWorkload, inputs: WorkloadRuntimeInputs = {}, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const signing = state.definition?.security.jwt.signing; const thirdParty = resolveThirdPartyIssuer(settingsFor(state, "auth")); @@ -393,8 +402,16 @@ const edgeRuntimeJwtEnvironment = ( const functionsConfigEnvironment = (state: PersistedStackState): string => { const settings = settingsFor(state, "functions"); + const edgeRuntime = + isRecord(settings) && isRecord(settings.edge_runtime) ? settings.edge_runtime : {}; const configured = isRecord(settings) && isRecord(settings.functions) ? settings.functions : {}; const result: Record = {}; + const defaults: Record = {}; + if (typeof edgeRuntime.verify_jwt_default === "boolean") + defaults.verify_jwt = edgeRuntime.verify_jwt_default; + if (typeof edgeRuntime.import_map_default === "string") + defaults.import_map_root = edgeRuntime.import_map_default; + if (Object.keys(defaults).length > 0) result.$default = defaults; for (const [slug, value] of Object.entries(configured)) { if (!isRecord(value)) continue; const env = isRecord(value.env) @@ -483,6 +500,9 @@ const nativeProcessFor = ( executable: artifactPath(artifactRoot, metadata.executablePath), args: nativeArgsFor(workload, metadataArgs, inputs), cwd: artifactPath(artifactRoot, metadata.cwd), + ...(workload.id === "database:database" + ? { gracefulStopSignal: "SIGINT", gracefulStopTimeout: "15 seconds" } + : {}), }; } const executablePath = catalog?.executablePath; @@ -498,6 +518,9 @@ const nativeProcessFor = ( executablePath === undefined ? artifactRoot : artifactPath(artifactRoot, executablePath), args: nativeArgs, cwd: spec.cwd?.(state, workload) ?? state.identity.projectRoot, + ...(workload.id === "database:database" + ? { gracefulStopSignal: "SIGINT", gracefulStopTimeout: "15 seconds" } + : {}), }; }; @@ -1033,7 +1056,7 @@ const specs: Readonly> = { env: (state, workload, port, runtime = "native", inputs = {}) => withAuthSettings(state, runtime, port, workload, inputs), containerArgs: () => [], - containerStartupProcesses: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + containerStartupProcesses: () => [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], readiness: { protocol: "http", path: "/health" }, }, "realtime:realtime": { @@ -1054,8 +1077,8 @@ const specs: Readonly> = { API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), ...(inputs.auth?.jwks === undefined ? {} : { API_JWT_JWKS: inputs.auth.jwks }), METRICS_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - DB_ENC_KEY: "supabaserealtime", - SECRET_KEY_BASE: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + DB_ENC_KEY: secret(state, "secret:realtime.settings.db_enc_key"), + SECRET_KEY_BASE: secret(state, "secret:realtime.settings.secret_key_base"), DNS_NODES: "''", APP_NAME: "realtime", SEED_SELF_HOST: "true", @@ -1066,7 +1089,15 @@ const specs: Readonly> = { : "-proto_dist inet_tcp", RUN_JANITOR: "true", }), - containerArgs: () => [], + containerEntrypoint: "/usr/bin/tini", + containerArgs: () => ["-s", "-g", "--", "/app/bin/server"], + containerStartupProcesses: () => [ + { entrypoint: "/app/bin/migrate", command: [] }, + { + entrypoint: "/app/bin/realtime", + command: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], + }, + ], readiness: { protocol: "http", path: "/healthcheck" }, }, "storage:storage": { @@ -1075,7 +1106,7 @@ const specs: Readonly> = { env: (state, workload, port, runtime = "native", inputs = {}) => withStorageSettings(state, runtime, port, workload, inputs), containerArgs: () => [], - containerStartupProcesses: [ + containerStartupProcesses: () => [ { entrypoint: "/node/bin/node", command: ["dist/scripts/migrate-call.js"] }, ], readiness: { protocol: "http", path: "/status" }, @@ -1102,10 +1133,8 @@ const specs: Readonly> = { ], env: (state, workload, port, runtime = "native", inputs = {}) => ({ ...common(workload, port), - ...capabilityEnv(state, "functions", "FUNCTIONS"), ...edgeRuntimeJwtEnvironment(state, inputs), EDGE_RUNTIME_PORT: String(port), - FUNCTIONS_ROOT: functionsRoot(state), FUNCTIONS_CONTAINER_ROOT, SUPABASE_INTERNAL_FUNCTIONS_ROOT: runtime === "container" ? FUNCTIONS_CONTAINER_ROOT : functionsRoot(state), @@ -1149,7 +1178,7 @@ const specs: Readonly> = { valueAt(state, "analytics", "backend").length > 0 ? "true" : "false", NEXT_ANALYTICS_BACKEND_PROVIDER: valueAt(state, "analytics", "backend"), SUPABASE_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), - SUPABASE_PUBLIC_URL: apiGatewayUrl(state, runtime === "container" ? inputs : undefined), + SUPABASE_PUBLIC_URL: apiListenerUrl(state), SUPABASE_ANON_KEY: secret(state, "secret:auth.settings.anon_key"), SUPABASE_SERVICE_KEY: secret(state, "secret:auth.settings.service_role_key"), SUPABASE_PUBLISHABLE_KEY: secret(state, "secret:auth.settings.publishable_key"), @@ -1257,46 +1286,65 @@ const specs: Readonly> = { readiness: { protocol: "http", path: "/health" }, }, "pooler:pooler": { - bindings: { primary: { containerPort: 6543 } }, + bindings: { primary: { containerPort: 6543 }, admin: { containerPort: 4000 } }, args: () => ["start"], - env: (state, workload, port, runtime = "native", inputs = {}) => ({ - ...common(workload, port), - ...beamDistributionEnvironment(runtime), - ...capabilityEnv(state, "pooler", "POOLER"), - PORT: "4000", - PROXY_PORT_SESSION: - runtime === "native" && valueAt(state, "pooler", "pool_mode") === "session" - ? String(port) - : "5432", - PROXY_PORT_TRANSACTION: - runtime === "native" && valueAt(state, "pooler", "pool_mode") !== "session" - ? String(port) - : "6543", - DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, - API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - REGION: "local", - TENANT_ID: valueAt(state, "pooler", "tenant_id"), - CLUSTER_POSTGRES: "true", - SECRET_KEY_BASE: valueAt(state, "pooler", "secret_key_base"), - VAULT_ENC_KEY: valueAt(state, "pooler", "encryption_key"), - METRICS_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), - DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size"), - MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn"), - POOL_MODE: valueAt(state, "pooler", "pool_mode"), - ...(runtime === "container" && inputs.pooler?.tenantPath !== undefined - ? { SUPABASE_POOLER_TENANT_PATH: "/app/pooler_tenant.exs" } - : {}), - }), - // The slim container entrypoint performs Supavisor migrations. Tenant - // provisioning is a separate owner/bootstrap phase. - containerArgs: (_state, _workload, _port, inputs = {}) => - inputs.pooler?.tenantPath === undefined - ? [] + env: (state, workload, port, runtime = "native", inputs = {}) => { + const adminPort = + runtime === "container" ? 4000 : privatePortFor(state, "pooler:pooler", "admin"); + const primaryPort = privatePortFor(state, "pooler:pooler", "primary"); + if (adminPort === undefined || primaryPort === undefined) + throw new Error("Pooler private port assignments must be validated before env resolution"); + return { + ...common(workload, port), + ...beamDistributionEnvironment(runtime), + ...capabilityEnv(state, "pooler", "POOLER"), + // `port` is the readiness binding (admin); proxy listeners use the primary SQL binding. + // validatePrivateAssignments guarantees both native bindings are assigned. + PORT: String(adminPort), + PROXY_PORT_SESSION: + runtime === "native" && valueAt(state, "pooler", "pool_mode") === "session" + ? String(primaryPort) + : "5432", + PROXY_PORT_TRANSACTION: + runtime === "native" && valueAt(state, "pooler", "pool_mode") !== "session" + ? String(primaryPort) + : "6543", + DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, + API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + REGION: "local", + TENANT_ID: valueAt(state, "pooler", "tenant_id"), + CLUSTER_POSTGRES: "true", + SECRET_KEY_BASE: valueAt(state, "pooler", "secret_key_base"), + VAULT_ENC_KEY: valueAt(state, "pooler", "encryption_key"), + METRICS_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), + DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size"), + MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn"), + POOL_MODE: valueAt(state, "pooler", "pool_mode"), + ...(runtime === "container" && inputs.pooler?.tenantPath !== undefined + ? { SUPABASE_POOLER_TENANT_PATH: "/app/pooler_tenant.exs" } + : {}), + }; + }, + // Mirror native convergence explicitly: migrate before the main server. + // Tenant provisioning remains a separate owner/bootstrap phase. + containerEntrypoint: "/usr/bin/tini", + containerStartupProcesses: (_state, _workload, inputs = {}) => { + const migrate = { entrypoint: "/app/bin/migrate", command: [] }; + const tenantPath = inputs.pooler?.tenantPath; + return tenantPath === undefined + ? [migrate] : [ - "/usr/bin/sh", - "-c", - '/app/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")" && exec /app/bin/server', - ], + migrate, + { + entrypoint: "/usr/bin/sh", + command: [ + "-c", + 'exec /app/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"', + ], + }, + ]; + }, + containerArgs: () => ["-s", "-g", "--", "/app/bin/server"], containerMounts: (_state, _workload, inputs = {}) => inputs.pooler?.tenantPath === undefined ? [] @@ -1307,7 +1355,7 @@ const specs: Readonly> = { readOnly: true, }, ], - readiness: { protocol: "tcp" }, + readiness: { protocol: "http", path: "/api/health", binding: "admin" }, }, }; @@ -1347,6 +1395,7 @@ export const validatePrivateAssignments = ( ): Effect.Effect => { const spec = specs[workload.id]; if (spec === undefined) return Effect.void; + // Runtime env resolution assumes every declared binding was assigned here. for (const [binding] of declaredBindings(spec.bindings)) { if ( !state.privatePorts.some( @@ -1382,7 +1431,8 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | spec.args(state, currentWorkload, port, runtime), env: (state, currentWorkload, port, runtime = "native", inputs = {}) => spec.env(state, currentWorkload, port, runtime, inputs), - containerStartupProcesses: spec.containerStartupProcesses ?? [], + containerStartupProcesses: (state, currentWorkload, inputs = {}) => + spec.containerStartupProcesses?.(state, currentWorkload, inputs) ?? [], readiness: { ...spec.readiness, binding: spec.readiness.binding ?? "primary" }, privateEndpoint: spec.privateEndpoint ?? @@ -1414,13 +1464,14 @@ export const containerResolutionFor = ( const catalog = catalogEntryFor(workload.id); if (catalog === undefined) return undefined; return { + ...(spec.containerEntrypoint === undefined ? {} : { entrypoint: spec.containerEntrypoint }), command: spec.containerArgs( state, workload, containerPortFor(state, workload.id, "primary", spec.containerPort), inputs, ), - startup: spec.containerStartupProcesses, + startup: spec.containerStartupProcesses(state, workload, inputs), env: spec.env( state, workload, @@ -1467,7 +1518,10 @@ export const resolveContainerResolutionFor = ( state: PersistedStackState, workload: PlannedWorkload, inputs: WorkloadRuntimeInputs = {}, -): Effect.Effect => +): Effect.Effect< + ContainerWorkloadResolution | undefined, + StackPreparationError | StackStateInvalidError +> => Effect.all([ validateWorkloadRuntimeInputs(state, workload, inputs), validatePrivateAssignments(state, workload), diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index f852457be5..505932449f 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -2,14 +2,13 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Deferred, Effect, Exit, Fiber, Option, Stream } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { NodeServices } from "@effect/platform-node"; -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; import { StackIdSchema } from "../public/StackId.ts"; import { ContainerExecutableNotFoundError, ContainerCommandError, ContainerEngineProtocolError, - makeControlledCommandRunner, makeProcessCommandRunner, selectContainerEngine, type ContainerProcessRequest, @@ -17,6 +16,7 @@ import { type ContainerContainerSpec, type ContainerLogLine, type ContainerEngine, + type ContainerCommandRunner, type ContainerNetworkSpec, type ContainerResource, type ContainerVolumeSpec, @@ -27,10 +27,17 @@ import { makeContainerRuntime } from "./ContainerRuntime.ts"; import { RuntimeDriverError, type RuntimeWorkloadKey } from "./RuntimeDriver.ts"; import { LogStoreError, type LogRecord, type LogStore } from "../supervisor/LogStore.ts"; +const makeControlledCommandRunner = ( + options: Omit & { readonly executable?: string }, +): ContainerCommandRunner => ({ + executable: options.executable ?? "controlled-container-engine", + run: options.run, + ...(options.stream === undefined ? {} : { stream: options.stream }), +}); + const stackId = StackIdSchema.make("a".repeat(64)); const key: RuntimeWorkloadKey = { stackId, - desiredGeneration: 7, workloadId: "database:database", specHash: "hash-7", }; @@ -54,38 +61,6 @@ const workload = (selected: PlannedWorkload["selected"] = containerArtifact): Pl specHash: key.specHash, }); -const recoveryPlan = (workloads: ReadonlyArray): ExecutionPlan => ({ - runtime: { kind: "container", engine: "docker" }, - activation: { - database: "eager", - rest: "eager", - auth: "eager", - realtime: "eager", - storage: "eager", - functions: "eager", - studio: "eager", - mail: "eager", - analytics: "eager", - pooler: "eager", - }, - startOrder: ["database"], - stopOrder: ["database"], - dependencies: { - database: [], - rest: [], - auth: [], - realtime: [], - storage: [], - functions: [], - studio: [], - mail: [], - analytics: [], - pooler: [], - }, - routes: [], - workloads, -}); - const commandResult = (value: unknown): ContainerCommandResult => ({ stdout: typeof value === "string" ? value : JSON.stringify(value), stderr: "", @@ -312,7 +287,6 @@ describe("container runtime", () => { labels: { stackId, ownerSessionId: "owner", - desiredGeneration: 7, workloadId: "auth:auth", specHash: key.specHash, role: "workload", @@ -521,6 +495,46 @@ describe("container runtime", () => { }), ); + it.live("publishes an unexpected container workload exit after readiness", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const exit = yield* Deferred.make(); + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + waitContainer: (resourceId) => + state.calls.includes(`wait:${resourceId}`) + ? Deferred.await(exit) + : base.waitContainer(resourceId), + }, + ownerSessionId: "owner-session", + }); + const failures = runtime.watchFailures; + expect(failures).toBeDefined(); + if (failures === undefined) return; + const eventFiber = yield* failures.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + yield* Deferred.succeed(exit, 17); + const events = yield* Fiber.join(eventFiber); + expect(events).toEqual([ + expect.objectContaining({ workloadId: key.workloadId, state: "failed" }), + ]); + yield* runtime.remove(key); + }), + ); + it.live("ignores an interrupt-only one-shot log follower exit", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -883,17 +897,90 @@ describe("container runtime", () => { }), ); - it.live("does not rerun migration for an adopted exact main container", () => + it.live("allows stop to interrupt a blocked container readiness", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + waitForReadiness: () => + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + }), + }), + }); + const starting = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(entered); + yield* runtime.stop(key); + const failed = yield* Fiber.join(starting).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + yield* Deferred.succeed(release, undefined); + }), + ); + + it.live("does not let cleanup race an in-flight container creation", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const createEntered = yield* Deferred.make(); + const releaseCreate = yield* Deferred.make(); + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + createContainer: (spec) => + Effect.gen(function* () { + yield* Deferred.succeed(createEntered, undefined); + yield* Deferred.await(releaseCreate); + return yield* base.createContainer(spec); + }), + }, + ownerSessionId: "owner-session", + }); + const starting = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(createEntered); + + // Cleanup must interrupt the start before it can create an unowned container. + yield* runtime.cleanup({ stackId, destroy: true }); + yield* Deferred.succeed(releaseCreate, undefined); + const startExit = yield* Fiber.join(starting).pipe(Effect.exit); + expect(Exit.isFailure(startExit)).toBe(true); + + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + }), + ); + + it.live("does not adopt an exact main container when starting a new session", () => Effect.gen(function* () { const existing: ContainerResource = { id: "existing-main", - name: "supabase-aaaaaaaaaaaaaaaa-7-database_database-workload", + name: "supabase-aaaaaaaaaaaaaaaa-database_database-workload", kind: "workload", state: "running", labels: { stackId, ownerSessionId: "owner-session", - desiredGeneration: key.desiredGeneration, workloadId: key.workloadId, specHash: key.specHash, role: "workload", @@ -916,8 +1003,8 @@ describe("container runtime", () => { }); const ready = yield* runtime.start(key, workload()); expect(ready.state).toBe("ready"); - expect(state.createdSpecs).toEqual([]); - expect(state.calls.some((call) => call.startsWith("wait:"))).toBe(false); + expect(state.createdSpecs).toHaveLength(2); + expect(state.calls.some((call) => call.startsWith("wait:"))).toBe(true); }), ); @@ -925,13 +1012,12 @@ describe("container runtime", () => { Effect.gen(function* () { const stale: ContainerResource = { id: "stale-startup", - name: `supabase-${stackId.slice(0, 16)}-${key.desiredGeneration}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`, + name: `supabase-${stackId.slice(0, 16)}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`, kind: "workload", state: "stopped", labels: { stackId, ownerSessionId: "owner-session", - desiredGeneration: key.desiredGeneration, workloadId: key.workloadId, specHash: `${key.specHash}:startup:0`, role: "workload", @@ -959,59 +1045,6 @@ describe("container runtime", () => { }), ); - it.live("removes crash-orphaned startup containers during recovery", () => - Effect.gen(function* () { - const startup: ContainerResource = { - id: "orphaned-startup", - name: `supabase-${stackId.slice(0, 16)}-${key.desiredGeneration}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`, - kind: "workload", - state: "stopped", - labels: { - stackId, - ownerSessionId: "previous-owner", - desiredGeneration: key.desiredGeneration, - workloadId: key.workloadId, - specHash: `${key.specHash}:startup:0`, - role: "workload", - }, - }; - const main: ContainerResource = { - id: "main-workload", - name: startup.name, - kind: "workload", - state: "running", - labels: { - stackId, - ownerSessionId: "previous-owner", - desiredGeneration: key.desiredGeneration, - workloadId: key.workloadId, - specHash: key.specHash, - role: "workload", - }, - }; - const state: FakeContainerState = { - resources: [startup, main], - imagePresent: true, - calls: [], - createdSpecs: [], - nextId: 1, - }; - const runtime = yield* makeContainerRuntime({ - engine: fakeContainerEngine(state), - ownerSessionId: "new-owner", - }); - const recovered = yield* runtime.recover({ - stackId, - desiredGeneration: key.desiredGeneration, - desiredLifecycle: "running", - plan: recoveryPlan([workload()]), - }); - expect(recovered).toEqual([{ ...key, state: "ready" }]); - expect(state.calls).toContain("remove:orphaned-startup"); - expect(state.resources.map((resource) => resource.id)).toEqual(["main-workload"]); - }), - ); - it.live("rejects native artifacts before invoking the engine", () => Effect.gen(function* () { let called = false; @@ -1098,6 +1131,148 @@ describe("container runtime", () => { }), ); + it.live("keeps following logs after a non-destructive cleanup and restart", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const records: LogRecord[] = []; + const firstAppended = yield* Deferred.make(); + const secondAppended = yield* Deferred.make(); + let followerCalls = 0; + const base = fakeContainerEngine(state); + const baseLogStore = memoryLogStore(records); + const logStore: LogStore = { + ...baseLogStore, + append: (record) => + baseLogStore + .append(record) + .pipe( + Effect.tap(() => + records.length === 1 + ? Deferred.succeed(firstAppended, undefined) + : Deferred.succeed(secondAppended, undefined), + ), + ), + }; + const engine: ContainerEngine = { + ...base, + streamLogs: () => { + const sequence = followerCalls + 1; + followerCalls += 1; + return Stream.fromIterable([ + { + stream: "stdout", + message: `container-started-${sequence}`, + } satisfies ContainerLogLine, + ]); + }, + }; + const runtime = yield* makeContainerRuntime({ + engine, + ownerSessionId: "owner-session", + logStore, + }); + + yield* runtime.start(key, workload()); + yield* Deferred.await(firstAppended); + yield* runtime.cleanup({ stackId: key.stackId, destroy: false }); + + const restartedKey: RuntimeWorkloadKey = { ...key, specHash: "hash-8" }; + const restartedWorkload: PlannedWorkload = { + ...workload(), + specHash: restartedKey.specHash, + }; + yield* runtime.start(restartedKey, restartedWorkload); + yield* Deferred.await(secondAppended); + + expect(followerCalls).toBe(2); + expect(records.map((record) => record.message)).toEqual([ + "container-started-1", + "container-started-2", + ]); + }), + ); + + it.live("propagates restart-session log failures after a non-destructive cleanup", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const readiness = yield* Deferred.make(); + const followerAttached = yield* Deferred.make(); + const followerFailure = yield* Deferred.make(); + const restartedKey: RuntimeWorkloadKey = { ...key, specHash: "hash-9" }; + const restartedWorkload: PlannedWorkload = { + ...workload(), + specHash: restartedKey.specHash, + }; + let followerCalls = 0; + const engine: ContainerEngine = { + ...fakeContainerEngine(state), + streamLogs: () => { + followerCalls += 1; + return followerCalls === 1 + ? Stream.empty + : Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(followerAttached, undefined); + return yield* Deferred.await(followerFailure); + }), + ); + }, + }; + const runtime = yield* makeContainerRuntime({ + engine, + ownerSessionId: "owner-session", + logStore: memoryLogStore([]), + resolveWorkload: (requestKey) => + Effect.succeed({ + waitForReadiness: + requestKey.specHash === restartedKey.specHash + ? () => Deferred.await(readiness) + : undefined, + }), + }); + + yield* runtime.start(key, workload()); + yield* runtime.cleanup({ stackId: key.stackId, destroy: false }); + + const starting = yield* runtime + .start(restartedKey, restartedWorkload) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(followerAttached); + yield* Deferred.fail( + followerFailure, + new ContainerEngineProtocolError({ + operation: "logs", + message: "restart-session follower disconnected", + }), + ); + const result = yield* Fiber.join(starting).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(RuntimeDriverError); + expect(error.value.message).toContain("Container log stream failed"); + } + } + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); + }), + ); + it.live("fails startup and removes a new container when log persistence fails", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -1212,7 +1387,6 @@ describe("container runtime", () => { labels: { stackId, ownerSessionId: "owner-session", - desiredGeneration: 1, workloadId: "database:database", specHash: "hash", role: "workload", @@ -1244,11 +1418,11 @@ describe("container runtime", () => { ? "not-json\n" : "" : request.args[0] === "ps" - ? `${dockerJsonRow(["container-id", "backend", stackId, "owner", "7", key.workloadId, key.specHash, "workload", "running"])}\n` + ? `${dockerJsonRow(["container-id", "backend", stackId, "owner", key.workloadId, key.specHash, "workload", "running"])}\n` : request.args[0] === "network" && request.args[1] === "create" ? "created-id\nsecond\n" : request.args[0] === "network" - ? `${dockerJsonRow(["network-id", "private", stackId, "owner", "7", "network"])}\n` + ? `${dockerJsonRow(["network-id", "private", stackId, "owner", "network"])}\n` : request.args[0] === "volume" ? `${dockerJsonRow(["volume-name", stackId, key.workloadId, "volume"])}\n` : request.args[0] === "version" @@ -1275,7 +1449,7 @@ describe("container runtime", () => { const multiline = yield* engine .createNetwork({ name: "private", - labels: { stackId, ownerSessionId: "owner", desiredGeneration: 7, role: "network" }, + labels: { stackId, ownerSessionId: "owner", role: "network" }, }) .pipe(Effect.exit); expect(Exit.isFailure(multiline)).toBe(true); @@ -1290,7 +1464,6 @@ describe("container runtime", () => { labels: { stackId, ownerSessionId: "owner", - desiredGeneration: 7, workloadId: "backend", specHash: key.specHash, role: "workload" as const, @@ -1364,9 +1537,9 @@ describe("container runtime", () => { ? "bad\trow\n" : "" : request.args[0] === "ps" - ? `container-id\tbackend\t${stackId}\towner\t7\t${key.workloadId}\t${key.specHash}\tworkload\trunning\n` + ? `container-id\tbackend\t${stackId}\towner\t${key.workloadId}\t${key.specHash}\tworkload\trunning\n` : request.args[0] === "network" - ? `network-id\tprivate\t${stackId}\towner\t7\tnetwork\n` + ? `network-id\tprivate\t${stackId}\towner\tnetwork\n` : request.args[0] === "volume" ? `volume-name\t${stackId}\t${key.workloadId}\tvolume\n` : "created-id\n", @@ -1517,7 +1690,7 @@ describe("container runtime", () => { 10_000, ); - it.live("adopts an exact running container and replaces only same-owner stale state", () => + it.live("replaces an exact running container for each new start session", () => Effect.gen(function* () { const state: FakeContainerState = { resources: [], @@ -1531,11 +1704,16 @@ describe("container runtime", () => { ownerSessionId: "owner-session", }); yield* runtime.start(key, workload()); + const nextRuntime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "next-owner-session", + }); state.calls.length = 0; - yield* runtime.start(key, workload()); - expect(state.calls).not.toContain("inspect-image"); - expect(state.calls).not.toContain("create-container"); - expect(state.calls.some((call) => call.startsWith("start:"))).toBe(false); + yield* nextRuntime.start(key, workload()); + expect(state.calls).toContain("inspect-image"); + expect(state.calls).toContain("create-container"); + expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); const staleKey = { ...key, specHash: "hash-new" }; state.calls.length = 0; @@ -1549,22 +1727,19 @@ describe("container runtime", () => { }), ); - it.live("runs database bootstrap before reporting an adopted running container ready", () => + it.live("recreates a same-stack network owned by a previous start session", () => Effect.gen(function* () { + const networkName = `supabase-${key.stackId.slice(0, 16)}-network`; const state: FakeContainerState = { resources: [ { - id: "adopted-database", - name: "adopted-database", - kind: "workload", - state: "running", + id: "stale-network", + name: networkName, + kind: "network", labels: { stackId: key.stackId, ownerSessionId: "previous-owner", - desiredGeneration: key.desiredGeneration, - workloadId: key.workloadId, - specHash: key.specHash, - role: "workload", + role: "network", }, }, ], @@ -1573,156 +1748,33 @@ describe("container runtime", () => { createdSpecs: [], nextId: 1, }; - const order: string[] = []; const runtime = yield* makeContainerRuntime({ engine: fakeContainerEngine(state), - ownerSessionId: "new-owner", - waitForReadiness: () => Effect.sync(() => order.push("readiness")), - bootstrapDatabase: () => Effect.sync(() => order.push("bootstrap")), + ownerSessionId: "current-owner", }); - const databaseWorkload = { ...workload(), bootstrap: "database" as const }; - const adopted = yield* runtime.recover({ - stackId: key.stackId, - desiredGeneration: key.desiredGeneration, - desiredLifecycle: "running", - plan: recoveryPlan([databaseWorkload]), - }); - expect(order).toEqual(["readiness", "bootstrap"]); - expect(adopted).toEqual([{ ...key, state: "ready" }]); - expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(false); - }), - ); - it.live("attaches one current-output follower when recovering a running container", () => - Effect.gen(function* () { - const state: FakeContainerState = { - resources: [ - { - id: "adopted-container", - name: "adopted-container", - kind: "workload", - state: "running", - labels: { - stackId: key.stackId, - ownerSessionId: "previous-owner", - desiredGeneration: key.desiredGeneration, - workloadId: key.workloadId, - specHash: key.specHash, - role: "workload", - }, - }, - ], - imagePresent: true, - calls: [], - createdSpecs: [], - nextId: 1, - }; - const order: string[] = []; - const records: LogRecord[] = []; - let followers = 0; - const runtime = yield* makeContainerRuntime({ - engine: { - ...fakeContainerEngine(state), - streamLogs: (_id, options) => { - followers += 1; - order.push(`attach:${options?.tail ?? "default"}`); - return Stream.fromIterable([ - { stream: "stdout", message: "recovered-current-output" } satisfies ContainerLogLine, - ]); - }, - }, - ownerSessionId: "new-owner", - logStore: memoryLogStore(records), - waitForReadiness: () => Effect.sync(() => order.push("readiness")), - }); - const recovered = yield* runtime.recover({ - stackId: key.stackId, - desiredGeneration: key.desiredGeneration, - desiredLifecycle: "running", - plan: recoveryPlan([workload()]), - }); - expect(recovered).toEqual([{ ...key, state: "ready" }]); - expect(order).toEqual(["readiness", "attach:0"]); - yield* runtime.recover({ - stackId: key.stackId, - desiredGeneration: key.desiredGeneration, - desiredLifecycle: "running", - plan: recoveryPlan([workload()]), - }); - expect(followers).toBe(1); - yield* runtime.cleanup({ stackId: key.stackId, destroy: false }); - expect(records.map((record) => record.message)).toContain("recovered-current-output"); - }), - ); + yield* runtime.start(key, workload()); - it.live( - "stops but does not remove an adopted database after bootstrap failure, then retries", - () => - Effect.gen(function* () { - const state: FakeContainerState = { - resources: [ - { - id: "adopted-database", - name: "adopted-database", - kind: "workload", - state: "running", - labels: { - stackId: key.stackId, - ownerSessionId: "previous-owner", - desiredGeneration: key.desiredGeneration, - workloadId: key.workloadId, - specHash: key.specHash, - role: "workload", - }, - }, - ], - imagePresent: true, - calls: [], - createdSpecs: [], - nextId: 1, - }; - const bootstrapError = new RuntimeDriverError({ - message: "recovered database bootstrap failed", - stackId: key.stackId, - workloadId: key.workloadId, - }); - let attempts = 0; - const runtime = yield* makeContainerRuntime({ - engine: fakeContainerEngine(state), - ownerSessionId: "new-owner", - bootstrapDatabase: () => - Effect.gen(function* () { - attempts += 1; - if (attempts === 1) return yield* bootstrapError; - }), - }); - const databaseWorkload = { ...workload(), bootstrap: "database" as const }; - const failed = yield* runtime - .recover({ - stackId: key.stackId, - desiredGeneration: key.desiredGeneration, - desiredLifecycle: "running", - plan: recoveryPlan([databaseWorkload]), - }) - .pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect(state.resources.find((resource) => resource.id === "adopted-database")?.state).toBe( - "stopped", - ); - expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); - expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(false); - const retry = yield* runtime.start(key, databaseWorkload); - expect(retry.state).toBe("ready"); - expect(attempts).toBe(2); - expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(false); - }), + const removeIndex = state.calls.findIndex((call) => call === "remove-network:stale-network"); + const createIndex = state.calls.findIndex((call) => call === "create-network"); + expect(removeIndex).toBeGreaterThanOrEqual(0); + expect(createIndex).toBeGreaterThan(removeIndex); + expect( + state.resources.some( + (resource) => + resource.kind === "network" && + resource.labels.role === "network" && + resource.labels.ownerSessionId === "current-owner", + ), + ).toBe(true); + }), ); it.live("leaves foreign and sanitized-name collisions untouched", () => Effect.gen(function* () { const foreignStackId = StackIdSchema.make("c".repeat(64)); const foreignKey = { ...key, stackId: foreignStackId, workloadId: "api:api" }; - const foreignName = `supabase-${stackId.slice(0, 16)}-${foreignKey.desiredGeneration}-api-api-workload`; + const foreignName = `supabase-${stackId.slice(0, 16)}-api-api-workload`; const state: FakeContainerState = { resources: [ { @@ -1733,7 +1785,6 @@ describe("container runtime", () => { labels: { stackId: foreignStackId, ownerSessionId: "other-session", - desiredGeneration: foreignKey.desiredGeneration, workloadId: foreignKey.workloadId, specHash: foreignKey.specHash, role: "workload", @@ -1805,7 +1856,13 @@ describe("container runtime", () => { yield* runtime.start(otherStackKey, workload()); expect(state.resources.filter((resource) => resource.kind === "volume")).toHaveLength(2); state.calls.length = 0; - yield* runtime.start(key, workload()); + const nextRuntime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "next-owner-session", + resolveWorkload: () => + Effect.succeed({ bootstrap: { source: "/tmp/main.ts", destination: "/root" } }), + }); + yield* nextRuntime.start(key, workload()); expect(state.calls).not.toContain(`create-volume:${physicalVolumeName}`); const invalidState: FakeContainerState = { @@ -1900,121 +1957,6 @@ describe("container runtime", () => { }), ); - it.live("recovers an exact shared owner volume and destroys it once", () => - Effect.gen(function* () { - const ownerWorkloadId = "storage:storage"; - const secondaryWorkloadId = "storage:imgproxy"; - const ownerSpecHash = "storage-hash"; - const secondarySpecHash = "imgproxy-hash"; - const ownerWorkload = { - ...workload(), - id: ownerWorkloadId, - capability: "storage" as const, - specHash: ownerSpecHash, - }; - const secondaryWorkload = { - ...workload(), - id: secondaryWorkloadId, - capability: "storage" as const, - specHash: secondarySpecHash, - }; - const sharedVolumeName = `supabase-${stackId}-${ownerWorkloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-volume`; - const state: FakeContainerState = { - resources: [ - { - id: "shared-network", - name: "shared-network", - kind: "network", - labels: { - stackId, - ownerSessionId: "previous-owner", - desiredGeneration: key.desiredGeneration, - role: "network", - }, - }, - { - id: "shared-volume", - name: sharedVolumeName, - kind: "volume", - labels: { stackId, workloadId: ownerWorkloadId, role: "volume" }, - }, - { - id: "storage-container", - name: "storage-container", - kind: "workload", - state: "running", - labels: { - stackId, - ownerSessionId: "previous-owner", - desiredGeneration: key.desiredGeneration, - workloadId: ownerWorkloadId, - specHash: ownerSpecHash, - role: "workload", - }, - }, - { - id: "imgproxy-container", - name: "imgproxy-container", - kind: "workload", - state: "running", - labels: { - stackId, - ownerSessionId: "previous-owner", - desiredGeneration: key.desiredGeneration, - workloadId: secondaryWorkloadId, - specHash: secondarySpecHash, - role: "workload", - }, - }, - ], - imagePresent: true, - calls: [], - createdSpecs: [], - nextId: 1, - }; - const runtime = yield* makeContainerRuntime({ - engine: fakeContainerEngine(state), - ownerSessionId: "new-owner", - resolveWorkload: (requestKey) => - Effect.succeed({ - volume: - requestKey.workloadId === ownerWorkloadId - ? { target: "/var/lib/storage", readOnly: false, ownerWorkloadId } - : { target: "/mnt", readOnly: true, ownerWorkloadId }, - }), - }); - const plan = recoveryPlan([ownerWorkload, secondaryWorkload]); - const observed = yield* runtime.recover({ - stackId, - desiredGeneration: key.desiredGeneration, - desiredLifecycle: "running", - plan, - }); - expect(observed).toHaveLength(2); - expect(observed.map(({ workloadId }) => workloadId)).toEqual( - expect.arrayContaining([ownerWorkloadId, secondaryWorkloadId]), - ); - expect(state.calls.some((call) => call.startsWith("create-volume:"))).toBe(false); - yield* runtime.start( - { ...key, workloadId: ownerWorkloadId, specHash: ownerSpecHash }, - ownerWorkload, - ); - yield* runtime.start( - { ...key, workloadId: secondaryWorkloadId, specHash: secondarySpecHash }, - secondaryWorkload, - ); - expect(state.calls.some((call) => call.startsWith("create-volume:"))).toBe(false); - yield* runtime.cleanup({ stackId, destroy: false }); - expect(state.resources.some((resource) => resource.id === "shared-volume")).toBe(true); - const volumeId = state.resources.find((resource) => resource.id === "shared-volume")?.id; - yield* runtime.cleanup({ stackId, destroy: true }); - expect(state.calls.filter((call) => call.startsWith("remove-volume:"))).toEqual([ - `remove-volume:${volumeId}`, - ]); - expect(state.resources.some((resource) => resource.id === "shared-volume")).toBe(false); - }), - ); - it.live( "cleans up a newly created container when readiness fails and keeps the original cause", () => @@ -2115,7 +2057,7 @@ describe("container runtime", () => { }), ); - it.live("does not remove an adopted running database container on bootstrap failure", () => + it.live("removes a replaced running database container on bootstrap failure", () => Effect.gen(function* () { const state: FakeContainerState = { resources: [], @@ -2131,12 +2073,12 @@ describe("container runtime", () => { yield* owner.start(key, workload()); state.calls.length = 0; const bootstrapError = new RuntimeDriverError({ - message: "adopted database bootstrap failed", + message: "replaced database bootstrap failed", stackId: key.stackId, workloadId: key.workloadId, }); let attempts = 0; - const adopted = yield* makeContainerRuntime({ + const replacementRuntime = yield* makeContainerRuntime({ engine: fakeContainerEngine(state), ownerSessionId: "new-owner", bootstrapDatabase: () => @@ -2145,47 +2087,52 @@ describe("container runtime", () => { if (attempts === 1) return yield* bootstrapError; }), }); - const result = yield* adopted + const result = yield* replacementRuntime .start(key, { ...workload(), bootstrap: "database" as const }) .pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); - expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(false); - expect(state.resources.find((resource) => resource.kind === "workload")?.state).toBe( - "stopped", - ); - const retry = yield* adopted.start(key, { ...workload(), bootstrap: "database" as const }); + expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); + expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); + const retry = yield* replacementRuntime.start(key, { + ...workload(), + bootstrap: "database" as const, + }); expect(retry.state).toBe("ready"); expect(attempts).toBe(2); }), ); - it.live( - "copies a new functions bootstrap before starting and never copies an adopted container", - () => - Effect.gen(function* () { - const state: FakeContainerState = { - resources: [], - imagePresent: true, - calls: [], - createdSpecs: [], - nextId: 1, - }; - const runtime = yield* makeContainerRuntime({ - engine: fakeContainerEngine(state), - ownerSessionId: "owner-session", - resolveWorkload: () => - Effect.succeed({ bootstrap: { source: "/tmp/main.ts", destination: "/root" } }), - }); - yield* runtime.start(key, workload()); - const copyIndex = state.calls.findIndex((call) => call.startsWith("copy:")); - const startIndex = state.calls.findIndex((call) => call.startsWith("start:")); - expect(copyIndex).toBeGreaterThanOrEqual(0); - expect(startIndex).toBeGreaterThan(copyIndex); - state.calls.length = 0; - yield* runtime.start(key, workload()); - expect(state.calls.some((call) => call.startsWith("copy:"))).toBe(false); - }), + it.live("copies a new functions bootstrap on each fresh start", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ bootstrap: { source: "/tmp/main.ts", destination: "/root" } }), + }); + yield* runtime.start(key, workload()); + const copyIndex = state.calls.findIndex((call) => call.startsWith("copy:")); + const startIndex = state.calls.findIndex((call) => call.startsWith("start:")); + expect(copyIndex).toBeGreaterThanOrEqual(0); + expect(startIndex).toBeGreaterThan(copyIndex); + state.calls.length = 0; + const nextRuntime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "next-owner-session", + resolveWorkload: () => + Effect.succeed({ bootstrap: { source: "/tmp/main.ts", destination: "/root" } }), + }); + yield* nextRuntime.start(key, workload()); + expect(state.calls.some((call) => call.startsWith("copy:"))).toBe(true); + }), ); it.live("removes a newly-created container when bootstrap copy fails", () => diff --git a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts index 634af1c6a5..d70369b63c 100644 --- a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts +++ b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts @@ -21,8 +21,6 @@ const stateFrom = (definition: PersistedStackState["definition"]): PersistedStac stackName: "default", }, runtime: { kind: "native" }, - desiredGeneration: 1, - portsGeneration: 1, desiredLifecycle: "stopped", definition, inputFingerprint: "b".repeat(64), diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 44a1fd1455..6695757e94 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -41,7 +41,6 @@ const workload = (id: string, bootstrap?: "database"): PlannedWorkload => ({ const keyFor = (id: string): RuntimeWorkloadKey => ({ stackId, - desiredGeneration: 1, workloadId: `database:${id}`, specHash: id, }); @@ -137,6 +136,41 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); + it.live("publishes an unexpected native workload exit after readiness", () => + withPlatform( + Effect.gen(function* () { + let startedProcess: NativeProcess | undefined; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("native-watch"))), + waitForReadiness: (_key, _workload, process) => + Effect.sync(() => { + startedProcess = process; + }), + }); + const failures = runtime.watchFailures; + expect(failures).toBeDefined(); + if (failures === undefined) return; + const eventFiber = yield* failures.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + const ready = yield* runtime.start(keyFor("watch"), workload("watch")); + expect(ready.state).toBe("ready"); + expect(startedProcess).toBeDefined(); + if (startedProcess !== undefined) yield* startedProcess.kill; + const events = yield* Fiber.join(eventFiber); + expect(events).toEqual([ + expect.objectContaining({ + workloadId: keyFor("watch").workloadId, + state: "failed", + }), + ]); + yield* runtime.remove(keyFor("watch")); + }), + ), + ); + it.live("keeps a ready workload after the losing exit observer is interrupted", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 85358ebf46..09600d20d1 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -3,16 +3,14 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, - Deferred, Duration, Effect, Exit, FileSystem, - Fiber, Path, Crypto, Redacted, - Scope, + Schema, Option, Stream, } from "effect"; @@ -23,6 +21,7 @@ import { createServer as createNetServer } from "node:net"; import type { StackLogEntry } from "../public/Logs.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; +import { NetworkPortSchema } from "../public/Status.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; @@ -36,7 +35,11 @@ import { withOwnedRuntimeFileCleanup, } from "./ProductionRuntime.ts"; import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; -import { InvalidStackConfigError, StackPreparationError } from "../public/Errors.ts"; +import { + InvalidStackConfigError, + PortUnavailableError, + StackPreparationError, +} from "../public/Errors.ts"; import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; @@ -73,8 +76,6 @@ const stateFor = ( stackName: "production-runtime", }, runtime, - desiredGeneration: 1, - portsGeneration: null, desiredLifecycle: "stopped", ports: [], privatePorts: [], @@ -111,7 +112,6 @@ const stateStoreFor = ( onRead?.(); return current.value; }), - write: () => Effect.die("unused"), initialize: () => Effect.die("unused"), replace: () => Effect.die("unused"), replaceUnlocked: () => Effect.die("unused"), @@ -224,18 +224,20 @@ const listenForNativeReadiness = ( const envFiles: RuntimeEnvFileOwner = { write: () => Effect.die("unused"), - cleanupGeneration: () => Effect.void, cleanupAll: Effect.void, }; const bootstrap: FunctionsBootstrapOwner = { write: () => Effect.die("unused"), - cleanupGeneration: () => Effect.void, cleanupAll: Effect.void, }; -const ownerInputContainerEngine = (createdSpecs: ContainerContainerSpec[]): ContainerEngine => { +const ownerInputContainerEngine = ( + createdSpecs: ContainerContainerSpec[], + copiedFiles: Array> = [], +): ContainerEngine => { const resources: ContainerResource[] = []; + const oneShot = new Set(); let nextId = 1; const resource = ( kind: ContainerResource["kind"], @@ -262,10 +264,7 @@ const ownerInputContainerEngine = (createdSpecs: ContainerContainerSpec[]): Cont probe: Effect.void, inspectImage: () => Effect.succeed({ present: true }), pullImage: () => Effect.void, - listResources: () => - Effect.sync(() => { - return [...resources]; - }), + listResources: () => Effect.sync(() => [...resources]), createNetwork: (spec: ContainerNetworkSpec) => Effect.sync(() => { const created = resource("network", spec.name, spec.labels); @@ -292,15 +291,25 @@ const ownerInputContainerEngine = (createdSpecs: ContainerContainerSpec[]): Cont Effect.sync(() => { createdSpecs.push(spec); const created = resource("workload", spec.name, spec.labels); + if ( + spec.entrypoint?.startsWith("/app/bin/") || + spec.command?.includes("migrate") || + spec.command?.join(" ").includes("eval") || + spec.entrypoint === "/node/bin/node" + ) + oneShot.add(created.id); resources.push(created); return created; }), - copyToContainer: () => Effect.void, + copyToContainer: (_id, source, destination) => + Effect.sync(() => { + copiedFiles.push({ source, destination }); + }), startContainer: (id) => Effect.sync(() => { updateState(id, "running"); }), - waitContainer: () => Effect.succeed(0), + waitContainer: (id) => (oneShot.has(id) ? Effect.succeed(0) : Effect.never), stopContainer: (id) => Effect.sync(() => updateState(id, "stopped")), removeContainer: (id) => Effect.sync(() => { @@ -373,7 +382,7 @@ describe("production runtime composition", () => { workloadId: workload.id, capability: workload.capability, version: "test", - outcome: "present" as const, + outcome: "cached" as const, image: workload.selected.kind === "container" ? workload.selected.image : undefined, }), }, @@ -394,7 +403,6 @@ describe("production runtime composition", () => { const ready = yield* runtime.driver.start( { stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }, @@ -404,7 +412,6 @@ describe("production runtime composition", () => { expect(attempts).toBe(2); yield* runtime.driver.stop({ stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }); @@ -472,7 +479,7 @@ describe("production runtime composition", () => { workloadId: workload.id, capability: workload.capability, version: "test", - outcome: "present" as const, + outcome: "cached" as const, image: workload.selected.kind === "container" ? workload.selected.image : undefined, }), }, @@ -487,7 +494,6 @@ describe("production runtime composition", () => { .start( { stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }, @@ -562,10 +568,8 @@ describe("production runtime composition", () => { const runtime = yield* factory.make(current.value); const input = { stackId, - generation: 1, desiredLifecycle: "running" as const, state: current.value, - previous: current.value, definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, secrets: current.value.secrets, @@ -649,7 +653,7 @@ describe("production runtime composition", () => { workloadId: "realtime:realtime", capability: "realtime" as const, version: "v2.130.0", - outcome: "present" as const, + outcome: "cached" as const, artifactRoot, }), }, @@ -663,7 +667,6 @@ describe("production runtime composition", () => { if (realtime === undefined) return yield* Effect.die("Expected Realtime workload"); const key = { stackId, - desiredGeneration: 1, workloadId: realtime.id, specHash: realtime.specHash, }; @@ -741,7 +744,7 @@ describe("production runtime composition", () => { workloadId: "database:database", capability: "database" as const, version: "17.6.1.167", - outcome: "present" as const, + outcome: "cached" as const, artifactRoot, }), }, @@ -756,7 +759,6 @@ describe("production runtime composition", () => { const ready = yield* runtime.driver.start( { stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }, @@ -776,7 +778,6 @@ describe("production runtime composition", () => { expect(events.indexOf("migration")).toBeLessThan(events.indexOf("bootstrap")); yield* runtime.driver.stop({ stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }); @@ -831,6 +832,11 @@ describe("production runtime composition", () => { const databaseDataPath = path.join(runtimePaths.data, "database"); yield* fs.makeDirectory(databaseDataPath, { recursive: true }); yield* fs.writeFileString(path.join(databaseDataPath, "PG_VERSION"), "17\n"); + // A completed initdb has both PG_VERSION and postmaster.opts. The + // missing migration marker must not erase its durable contents. + yield* fs.writeFileString(path.join(databaseDataPath, "postmaster.opts"), "postgres\n"); + const durableMarker = path.join(databaseDataPath, "durable-user-data"); + yield* fs.writeFileString(durableMarker, "preserve-me"); const bootstrap = () => Effect.gen(function* () { const previous = yield* fs @@ -859,7 +865,7 @@ describe("production runtime composition", () => { workloadId: "database:database", capability: "database" as const, version: "17.6.1.167", - outcome: "present" as const, + outcome: "cached" as const, artifactRoot, }), }, @@ -875,7 +881,6 @@ describe("production runtime composition", () => { .start( { stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }, @@ -883,13 +888,13 @@ describe("production runtime composition", () => { ) .pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); + expect(yield* fs.readFileString(durableMarker)).toBe("preserve-me"); expect( yield* fs.exists(path.join(databaseDataPath, NATIVE_DATABASE_MIGRATION_MARKER)), ).toBe(false); yield* runtime.driver.start( { stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }, @@ -903,7 +908,6 @@ describe("production runtime composition", () => { ).toBe(true); yield* runtime.driver.stop({ stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }); @@ -911,7 +915,6 @@ describe("production runtime composition", () => { yield* runtime.driver.start( { stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }, @@ -920,7 +923,6 @@ describe("production runtime composition", () => { expect(yield* fs.readFileString(eventsPath)).toBe("bootstrap\n"); yield* runtime.driver.stop({ stackId, - desiredGeneration: 1, workloadId: database.id, specHash: database.specHash, }); @@ -1086,6 +1088,254 @@ describe("production runtime composition", () => { ), ); + it.live("does not create the Functions root during preflight", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-functions-root-", + }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const secrets = Object.fromEntries( + compiled.secrets.map((entry) => [ + entry.slot, + { policy: entry.policy, value: "test-secret" }, + ]), + ); + const current = { + value: { + ...stateFor(secrets), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets, + }, + } satisfies { value: PersistedStackState }; + const functionsRoot = compiled.definition.capabilities.functions.settings.functions_root; + if (functionsRoot === null) return yield* Effect.die("Functions root was not materialized"); + expect(yield* fs.exists(functionsRoot)).toBe(false); + const prepared: string[] = []; + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + logStore: memoryLogStore([]), + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.sync(() => { + prepared.push(workload.id); + return { + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "cached" as const, + }; + }), + }, + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + yield* runtime.preflight( + { + stackId, + desiredLifecycle: "stopped", + state: current.value, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets, + plan: compiled.executionPlan, + }, + "cold", + ); + expect(yield* fs.exists(functionsRoot)).toBe(false); + expect(prepared).toEqual(["database:database"]); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects an occupied persisted native port during a cold preflight", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-preflight-" }); + const occupied = createNetServer(); + yield* listenForNativeReadiness(occupied); + const address = occupied.address(); + if (address === null || typeof address === "string") + return yield* Effect.die("occupied listener has no TCP address"); + const port = yield* Schema.decodeEffect(NetworkPortSchema)(address.port).pipe(Effect.orDie); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const secrets = Object.fromEntries( + compiled.secrets.map((entry) => [ + entry.slot, + { policy: entry.policy, value: "test-secret" }, + ]), + ); + const current = { + value: { + ...stateFor(secrets), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + ports: [{ field: "database", port, intent: "exact" }], + secrets, + }, + } satisfies { value: PersistedStackState }; + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + logStore: memoryLogStore([]), + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "cached", + }), + }, + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + const result = yield* runtime + .preflight( + { + stackId, + desiredLifecycle: "running", + state: current.value, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets, + plan: compiled.executionPlan, + }, + "cold", + ) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(error).toBeInstanceOf(PortUnavailableError); + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("rejects native database lock evidence owned by a live process", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-lock-" }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const secrets = Object.fromEntries( + compiled.secrets.map((entry) => [ + entry.slot, + { policy: entry.policy, value: "test-secret" }, + ]), + ); + const current = { + value: { + ...stateFor(secrets), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets, + }, + } satisfies { value: PersistedStackState }; + const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); + yield* fs.makeDirectory(path.join(paths.data, "database"), { recursive: true }); + yield* fs.writeFileString( + path.join(paths.data, "database", "postmaster.pid"), + `${process.pid}\n`, + ); + const context = yield* Effect.context(); + const factory = yield* makeProductionRuntimeFactory({ + stateRoot: root, + stackId, + ownerSessionId: "owner", + stateStore: stateStoreFor(current), + context, + ingress, + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + logStore: memoryLogStore([]), + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "cached", + }), + }, + bootstrapDatabase: () => Effect.void, + }); + const runtime = yield* factory.make(current.value); + const result = yield* runtime + .preflight( + { + stackId, + desiredLifecycle: "running", + state: current.value, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets, + plan: compiled.executionPlan, + }, + "cold", + ) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(error).toBeInstanceOf(StackPreparationError); + expect(error?.message).toContain("live process"); + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("redacts logs using secret slots materialized after factory creation", () => Effect.scoped( Effect.gen(function* () { @@ -1098,8 +1348,15 @@ describe("production runtime composition", () => { projectRoot: root, runtime: { kind: "native" }, }); + const completeSecrets = Object.fromEntries( + compiled.secrets.map((entry) => [ + entry.slot, + { policy: entry.policy, value: "placeholder" }, + ]), + ); const candidate = { ...stateFor({ + ...completeSecrets, "secret:auth.settings.jwt_secret": { policy: "managed" as const, value: "rotated-secret", @@ -1123,7 +1380,7 @@ describe("production runtime composition", () => { workloadId: workload.id, capability: workload.capability, version: "test", - outcome: "present" as const, + outcome: "cached" as const, }), }, envFileOwner: envFiles, @@ -1132,18 +1389,18 @@ describe("production runtime composition", () => { bootstrapDatabase: () => Effect.void, }); const runtime = yield* factory.make(current.value); - if (runtime.preflight === undefined) return yield* Effect.die("preflight seam missing"); - yield* runtime.preflight({ - stackId, - generation: 1, - desiredLifecycle: "running", - state: candidate, - previous: current.value, - definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, - secrets: candidate.secrets, - plan: compiled.executionPlan, - }); + yield* runtime.preflight( + { + stackId, + desiredLifecycle: "running", + state: candidate, + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: candidate.secrets, + plan: compiled.executionPlan, + }, + "cold", + ); const logStore = runtime.logStore; expect(logStore).toBeDefined(); if (logStore === undefined) return; @@ -1241,7 +1498,10 @@ describe("production runtime composition", () => { const analyticsAddress = analyticsServer.address(); if (typeof analyticsAddress !== "object" || analyticsAddress === null) return yield* Effect.die("Analytics readiness server did not expose an address"); - const poolerServer = createNetServer(); + const poolerServer = createServer((_request, response) => { + response.statusCode = 204; + response.end(); + }); yield* listenForNativeReadiness(poolerServer); const poolerAddress = poolerServer.address(); if (typeof poolerAddress !== "object" || poolerAddress === null) @@ -1277,7 +1537,6 @@ describe("production runtime composition", () => { desiredLifecycle: "running" as const, definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, - portsGeneration: 1, ports: [{ field: "api" as const, port: 40_000, intent: "exact" as const }], privatePorts: [ { @@ -1288,6 +1547,11 @@ describe("production runtime composition", () => { { workloadId: "pooler:pooler", binding: "primary", + port: poolerAddress.port + 1, + }, + { + workloadId: "pooler:pooler", + binding: "admin", port: poolerAddress.port, }, { @@ -1305,7 +1569,8 @@ describe("production runtime composition", () => { } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); const createdSpecs: ContainerContainerSpec[] = []; - const engine = ownerInputContainerEngine(createdSpecs); + const copiedFiles: Array> = []; + const engine = ownerInputContainerEngine(createdSpecs, copiedFiles); const factory = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, @@ -1320,7 +1585,7 @@ describe("production runtime composition", () => { workloadId: workload.id, capability: workload.capability, version: "test", - outcome: "present" as const, + outcome: "cached" as const, image: workload.selected.kind === "container" ? workload.selected.image : undefined, }), }, @@ -1339,7 +1604,6 @@ describe("production runtime composition", () => { yield* runtime.driver.start( { stackId, - desiredGeneration: 1, workloadId: auth.id, specHash: auth.specHash, }, @@ -1364,7 +1628,6 @@ describe("production runtime composition", () => { yield* runtime.driver.start( { stackId, - desiredGeneration: 1, workloadId: functions.id, specHash: functions.specHash, }, @@ -1373,13 +1636,17 @@ describe("production runtime composition", () => { const functionsSpec = createdSpecs.find((spec) => spec.labels.workloadId === functions.id); if (functionsSpec?.envFile === undefined) return yield* Effect.die("Functions container was not captured"); + const firstBootstrap = copiedFiles.at(-1); + if (firstBootstrap === undefined) + return yield* Effect.die("Functions bootstrap was not captured"); + const firstBootstrapPath = firstBootstrap.source; + expect(yield* fs.exists(firstBootstrapPath)).toBe(true); expect(yield* fs.readFileString(functionsSpec.envFile)).toContain( "FACTORY_SECRET=factory-secret", ); yield* runtime.driver.start( { stackId, - desiredGeneration: 1, workloadId: analytics.id, specHash: analytics.specHash, }, @@ -1394,19 +1661,30 @@ describe("production runtime composition", () => { yield* runtime.driver.start( { stackId, - desiredGeneration: 1, workloadId: pooler.id, specHash: pooler.specHash, }, pooler, ); - const poolerSpec = createdSpecs.find((spec) => spec.labels.workloadId === pooler.id); + const poolerSpec = createdSpecs.find( + (spec) => + spec.labels.workloadId === pooler.id && spec.labels.specHash === pooler.specHash, + ); + const poolerStartupSpecs = createdSpecs.filter( + (spec) => + spec.labels.workloadId === pooler.id && + spec.labels.specHash.startsWith(`${pooler.specHash}:startup:`), + ); expect(poolerSpec?.mounts).toContainEqual({ source: expect.stringContaining("pooler_tenant.exs"), target: "/app/pooler_tenant.exs", readOnly: true, }); - expect(poolerSpec?.command?.join(" ")).toContain("/app/bin/supavisor eval"); + expect( + poolerStartupSpecs.some((spec) => + spec.command?.join(" ").includes("/app/bin/supavisor eval"), + ), + ).toBe(true); const tenantPath = poolerSpec?.mounts.find( (mount) => mount.target === "/app/pooler_tenant.exs", )?.source; @@ -1415,32 +1693,42 @@ describe("production runtime composition", () => { expect(yield* fs.exists(tenantPath)).toBe(true); yield* runtime.driver.stop({ stackId, - desiredGeneration: 1, workloadId: pooler.id, specHash: pooler.specHash, }); - expect(yield* fs.exists(tenantPath)).toBe(false); - current.value = { ...current.value, desiredGeneration: 2, portsGeneration: 2 }; + // Runtime inputs are session-owned and remain available across an individual workload + // restart; the owner cleanup at session end removes the flat path. + expect(yield* fs.exists(tenantPath)).toBe(true); yield* runtime.driver.start( { stackId, - desiredGeneration: 2, workloadId: pooler.id, specHash: pooler.specHash, }, pooler, ); - const generationTwoSpec = createdSpecs.find( - (spec) => spec.labels.workloadId === pooler.id && spec.labels.desiredGeneration === 2, - ); - const generationTwoTenantPath = generationTwoSpec?.mounts.find( + const secondSpec = createdSpecs.findLast((spec) => spec.labels.workloadId === pooler.id); + const secondTenantPath = secondSpec?.mounts.find( (mount) => mount.target === "/app/pooler_tenant.exs", )?.source; - if (generationTwoTenantPath === undefined) - return yield* Effect.die("Second-generation Pooler tenant mount was not captured"); - expect(yield* fs.exists(generationTwoTenantPath)).toBe(true); + if (secondTenantPath === undefined) + return yield* Effect.die("Second Pooler tenant mount was not captured"); + expect(yield* fs.exists(secondTenantPath)).toBe(true); yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* fs.exists(generationTwoTenantPath)).toBe(false); + expect(yield* fs.exists(secondTenantPath)).toBe(false); + expect(yield* fs.exists(firstBootstrapPath)).toBe(false); + yield* runtime.driver.start( + { + stackId, + workloadId: functions.id, + specHash: functions.specHash, + }, + functions, + ); + const restartedBootstrap = copiedFiles.at(-1); + if (restartedBootstrap === undefined || restartedBootstrap === firstBootstrap) + return yield* Effect.die("Restarted Functions bootstrap was not captured"); + expect(yield* fs.exists(restartedBootstrap.source)).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -1537,7 +1825,7 @@ describe("production runtime composition", () => { workloadId: workload.id, capability: workload.capability, version: "test", - outcome: "present" as const, + outcome: "cached" as const, image: workload.selected.kind === "container" ? workload.selected.image : undefined, }), }, @@ -1554,15 +1842,12 @@ describe("production runtime composition", () => { if (rest === undefined || auth === undefined) return yield* Effect.die("Expected REST and Auth workloads"); yield* runtime.driver.start( - { stackId, desiredGeneration: 1, workloadId: rest.id, specHash: rest.specHash }, + { stackId, workloadId: rest.id, specHash: rest.specHash }, rest, ); expect(createdSpecs.some((spec) => spec.labels.workloadId === rest.id)).toBe(true); const authStart = yield* runtime.driver - .start( - { stackId, desiredGeneration: 1, workloadId: auth.id, specHash: auth.specHash }, - auth, - ) + .start({ stackId, workloadId: auth.id, specHash: auth.specHash }, auth) .pipe(Effect.exit); expect(Exit.isFailure(authStart)).toBe(true); expect(createdSpecs.some((spec) => spec.labels.workloadId === auth.id)).toBe(false); @@ -1570,144 +1855,7 @@ describe("production runtime composition", () => { ), ); - it.live("closes the production owner scope while input resolution is in flight", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-owner-close-", - }); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "container", engine: "docker" }, - config: { - capabilities: { - auth: { - settings: { - third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, - }, - }, - pooler: { enabled: true }, - rest: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - }, - listeners: { - database: { enabled: false }, - pooler: { enabled: false }, - studio: { enabled: false }, - mailUi: { enabled: false }, - smtp: { enabled: false }, - pop3: { enabled: false }, - functionsInspector: { enabled: false }, - }, - }, - }).pipe(Effect.provide(NodeServices.layer)); - const poolerServer = createNetServer(); - yield* listenForNativeReadiness(poolerServer); - const poolerAddress = poolerServer.address(); - if (typeof poolerAddress !== "object" || poolerAddress === null) - return yield* Effect.die("Pooler readiness server did not expose an address"); - const current = { - value: { - ...stateFor( - { - "secret:database.internal.password": { policy: "managed", value: "db-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - }, - { kind: "container", engine: "docker" }, - ), - identity: { - ...stateFor({}).identity, - projectRoot: root, - checkoutRoot: root, - workspaceId: root, - checkoutId: root, - }, - definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, - desiredLifecycle: "running" as const, - portsGeneration: 1, - privatePorts: [ - { workloadId: "pooler:pooler", binding: "primary", port: poolerAddress.port }, - ], - }, - } satisfies { value: PersistedStackState }; - const ownerScope = yield* Scope.make(); - const context = yield* Effect.context(); - const createdSpecs: ContainerContainerSpec[] = []; - const engine = ownerInputContainerEngine(createdSpecs); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let fetches = 0; - const factory = yield* makeProductionRuntimeFactory({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - containerEngine: engine, - artifactPreparer: { - prepare: (_runtime, workload) => - Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: "present" as const, - image: workload.selected.kind === "container" ? workload.selected.image : undefined, - }), - }, - fetchJson: (url) => - Effect.gen(function* () { - fetches += 1; - if (fetches > 2) { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - } - return url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; - }), - logStore: memoryLogStore([]), - bootstrapDatabase: () => Effect.void, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const runtime = yield* factory.make(current.value); - const pooler = compiled.executionPlan.workloads.find( - (workload) => workload.id === "pooler:pooler", - ); - if (pooler === undefined) return yield* Effect.die("Expected Pooler workload"); - yield* runtime.driver.start( - { stackId, desiredGeneration: 1, workloadId: pooler.id, specHash: pooler.specHash }, - pooler, - ); - const firstTenant = createdSpecs - .find((spec) => spec.labels.desiredGeneration === 1) - ?.mounts.find((mount) => mount.target === "/app/pooler_tenant.exs")?.source; - if (firstTenant === undefined) return yield* Effect.die("Missing first Pooler tenant"); - current.value = { ...current.value, desiredGeneration: 2, portsGeneration: 2 }; - const inFlight = yield* Effect.forkChild( - runtime.driver.start( - { stackId, desiredGeneration: 2, workloadId: pooler.id, specHash: pooler.specHash }, - pooler, - ), - { startImmediately: true }, - ); - yield* Deferred.await(started); - yield* Scope.close(ownerScope, Exit.void); - const inFlightExit = yield* Fiber.join(inFlight).pipe(Effect.exit); - expect(Exit.isFailure(inFlightExit)).toBe(true); - expect(yield* fs.exists(firstTenant)).toBe(false); - yield* Deferred.succeed(release, undefined); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("cleans stale env and functions generations only during stack cleanup", () => + it.live("cleans owner env and Functions files only during stack cleanup", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1719,12 +1867,10 @@ describe("production runtime composition", () => { stackId, }); const envPath = yield* envOwner.write({ - generation: 4, workloadId: "database:database", values: { TOKEN: "stale" }, }); const bootstrapPath = yield* functionsOwner.write({ - generation: 4, content: "export default 1", }); const context = yield* Effect.context(); @@ -1742,7 +1888,6 @@ describe("production runtime composition", () => { const runtime = yield* factory.make(current.value); yield* runtime.driver.stop({ stackId, - desiredGeneration: 4, workloadId: "database:database", specHash: "stale", }); @@ -1762,23 +1907,21 @@ describe("production runtime composition", () => { stackId, }); const driver: RuntimeDriver = { + watchFailures: Stream.empty, observe: () => Effect.succeed([]), start: () => Effect.die("unused"), stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.fail(runtimeFailure), - recover: () => Effect.succeed([]), }; const envOwner: RuntimeEnvFileOwner = { write: () => Effect.die("unused"), - cleanupGeneration: () => Effect.void, cleanupAll: Effect.sync(() => { calls.push("env"); }), }; const functionsOwner: FunctionsBootstrapOwner = { write: () => Effect.die("unused"), - cleanupGeneration: () => Effect.void, cleanupAll: Effect.sync(() => { calls.push("functions"); }), @@ -1792,16 +1935,15 @@ describe("production runtime composition", () => { it("continues owner cleanup when one file owner fails", () => { const calls: string[] = []; const driver: RuntimeDriver = { + watchFailures: Stream.empty, observe: () => Effect.succeed([]), start: () => Effect.die("unused"), stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.void, - recover: () => Effect.succeed([]), }; const envOwner: RuntimeEnvFileOwner = { write: () => Effect.die("unused"), - cleanupGeneration: () => Effect.void, cleanupAll: Effect.gen(function* () { calls.push("env"); return yield* new StackPreparationError({ message: "env cleanup failed" }); @@ -1809,7 +1951,6 @@ describe("production runtime composition", () => { }; const functionsOwner: FunctionsBootstrapOwner = { write: () => Effect.die("unused"), - cleanupGeneration: () => Effect.void, cleanupAll: Effect.sync(() => { calls.push("functions"); }), diff --git a/packages/stack/src/runtime/runtime-env-file.integration.test.ts b/packages/stack/src/runtime/runtime-env-file.integration.test.ts index 0c26a8b273..56683ae1e2 100644 --- a/packages/stack/src/runtime/runtime-env-file.integration.test.ts +++ b/packages/stack/src/runtime/runtime-env-file.integration.test.ts @@ -10,7 +10,7 @@ const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); describe("runtime environment file owner", () => { - it.live("writes deterministic owner-only generation files atomically", () => + it.live("writes deterministic owner-only session files atomically", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -18,12 +18,10 @@ describe("runtime environment file owner", () => { const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-env-" }); const owner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); const first = yield* owner.write({ - generation: 4, workloadId: "database:database", values: { Z_LAST: "two", A_FIRST: "one" }, }); const second = yield* owner.write({ - generation: 4, workloadId: "database:database", values: { A_FIRST: "updated" }, }); @@ -44,7 +42,6 @@ describe("runtime environment file owner", () => { const secret = "very-secret-value"; const invalid = yield* owner .write({ - generation: 1, workloadId: "database:database", values: { VALID: `${secret}\nINJECTED` }, }) @@ -53,28 +50,27 @@ describe("runtime environment file owner", () => { if (Exit.isFailure(invalid)) expect(String(invalid.cause)).not.toContain(secret); expect(yield* fs.exists(`${root}/${stackId}/runtime/env`)).toBe(false); const invalidWorkload = yield* owner - .write({ generation: 1, workloadId: "../escape", values: { SAFE: "ok" } }) + .write({ workloadId: "../escape", values: { SAFE: "ok" } }) .pipe(Effect.exit); expect(Exit.isFailure(invalidWorkload)).toBe(true); }), ), ); - it.live("cleans exact generations idempotently", () => + it.live("cleans exact session files idempotently", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-env-cleanup-" }); const owner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); const file = yield* owner.write({ - generation: 2, workloadId: "rest:rest", values: { X: "y" }, }); - yield* owner.cleanupGeneration(2); + yield* owner.cleanupAll; expect(yield* fs.exists(file)).toBe(false); - yield* owner.cleanupGeneration(2); - yield* owner.write({ generation: 3, workloadId: "rest:rest", values: { X: "z" } }); + yield* owner.cleanupAll; + yield* owner.write({ workloadId: "rest:rest", values: { X: "z" } }); yield* owner.cleanupAll; expect(yield* fs.exists(`${root}/${stackId}/runtime/env`)).toBe(false); }), @@ -89,8 +85,8 @@ describe("runtime environment file owner", () => { const owner = yield* makeRuntimeEnvFileOwner({ stateRoot: root, stackId }); const files = yield* Effect.all( [ - owner.write({ generation: 7, workloadId: "rest:rest", values: { A: "one" } }), - owner.write({ generation: 7, workloadId: "auth:auth", values: { B: "two" } }), + owner.write({ workloadId: "rest:rest", values: { A: "one" } }), + owner.write({ workloadId: "auth:auth", values: { B: "two" } }), ], { concurrency: "unbounded" }, ); diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index f82df458df..2290364355 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -47,8 +47,6 @@ const stateFor = ( format: "supabase-stack-state-v1", identity: identityFor(root), runtime, - desiredGeneration: 1, - portsGeneration: null, desiredLifecycle: "stopped", definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, @@ -158,7 +156,7 @@ describe("runtime input owner", () => { : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }, ), }); - const material = yield* owner.resolve(state, 1); + const material = yield* owner.resolve(state); // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWT fixture expect(JSON.parse(material.auth?.jwtKeys ?? "[]")).toHaveLength(2); // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWKS fixture @@ -204,7 +202,7 @@ describe("runtime input owner", () => { }, }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(state, 1).pipe(Effect.exit); + const failed = yield* owner.resolve(state).pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); }), ), @@ -236,7 +234,7 @@ describe("runtime input owner", () => { : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; }), }); - const material = yield* owner.resolve(state, 2); + const material = yield* owner.resolve(state); expect(requested).toEqual([ "https://securetoken.google.com/demo/.well-known/openid-configuration", "https://issuer.example/keys", @@ -260,7 +258,7 @@ describe("runtime input owner", () => { }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWKS fixture - const jwks = JSON.parse((yield* owner.resolve(state, 1)).auth?.jwks ?? "{}"); + const jwks = JSON.parse((yield* owner.resolve(state)).auth?.jwks ?? "{}"); expect(jwks.keys).toEqual([ { kty: "oct", @@ -308,7 +306,7 @@ describe("runtime input owner", () => { stackId, fetchJson: () => Effect.die("OIDC fetch should not run"), }); - const material = yield* owner.resolve(state, 1); + const material = yield* owner.resolve(state); expect(material.auth).toBeUndefined(); }), ), @@ -333,7 +331,7 @@ describe("runtime input owner", () => { }, }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1); + const material = yield* owner.resolve(state); expect(material.auth?.jwks).toContain('"kty":"oct"'); }), ), @@ -364,7 +362,7 @@ describe("runtime input owner", () => { stackId, fetchJson: () => Effect.fail(new StackPreparationError({ message: "transport failure" })), }); - const failed = yield* owner.resolve(state, 1).pipe(Effect.exit); + const failed = yield* owner.resolve(state).pipe(Effect.exit); const error = errorOf(failed); expect(error?.message).toContain("OIDC discovery request failed"); // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assert sanitized diagnostic payload @@ -399,13 +397,13 @@ describe("runtime input owner", () => { : { keys: [] }, ), }); - const failed = yield* owner.resolve(state, 1).pipe(Effect.exit); + const failed = yield* owner.resolve(state).pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("contains no keys"); }), ), ); - it.live("writes a generation-scoped owner-only Pooler tenant file and cleans it", () => + it.live("writes a session-scoped owner-only Pooler tenant file and cleans it", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -425,31 +423,26 @@ describe("runtime input owner", () => { { workloadId: "analytics:vector", binding: "primary", port: 30_008 }, ], }; - const first = yield* owner.resolve(nativeState, 3, { includePooler: true }); - const second = yield* owner.resolve(nativeState, 3, { includePooler: true }); - const thirdGenerationTenant = first.pooler?.tenantPath; - expect(second.pooler?.tenantPath).toBe(thirdGenerationTenant); - expect(thirdGenerationTenant).toBeDefined(); - expect((yield* fs.stat(thirdGenerationTenant!)).mode & 0o777).toBe(0o600); - const content = yield* fs.readFileString(thirdGenerationTenant!); + const first = yield* owner.resolve(nativeState, { includePooler: true }); + const second = yield* owner.resolve(nativeState, { includePooler: true }); + const tenant = first.pooler?.tenantPath; + expect(second.pooler?.tenantPath).toBe(tenant); + expect(tenant).toBeDefined(); + expect((yield* fs.stat(tenant!)).mode & 0o777).toBe(0o600); + const content = yield* fs.readFileString(tenant!); expect(content).toContain('"db_host" => "127.0.0.1"'); expect(content).toContain('"external_id" => "pooler-dev"'); expect(content).toContain('"db_port" => 30001'); expect(content).not.toContain('"db_password" => ""'); expect(first.analytics?.vectorConfigPath).toBeDefined(); - const fourth = yield* owner.resolve(nativeState, 4, { includePooler: true }); - const fourthGenerationTenant = fourth.pooler?.tenantPath; - expect(fourthGenerationTenant).toBeDefined(); - expect(fourthGenerationTenant).not.toBe(thirdGenerationTenant); - yield* owner.cleanupGeneration(3, "pooler"); - expect(yield* fs.exists(path.dirname(thirdGenerationTenant!))).toBe(false); - expect(yield* fs.exists(fourthGenerationTenant!)).toBe(true); - expect(yield* fs.exists(first.analytics?.vectorConfigPath ?? "")).toBe(true); + yield* owner.cleanupAll; + expect(yield* fs.exists(path.dirname(tenant!))).toBe(false); + expect(yield* fs.exists(first.analytics?.vectorConfigPath ?? "")).toBe(false); }), ), ); - it.live("writes and cleans generation-scoped Vector config material", () => + it.live("writes and cleans session-scoped Vector config material", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -463,7 +456,7 @@ describe("runtime input owner", () => { ...base, privatePorts: [{ workloadId: "analytics:vector", binding: "primary", port: 30_008 }], }; - const material = yield* owner.resolve(native, 7); + const material = yield* owner.resolve(native); const configPath = material.analytics?.vectorConfigPath; expect(configPath).toBeDefined(); expect(yield* fs.stat(configPath!)).toMatchObject({ type: "File" }); @@ -481,11 +474,11 @@ describe("runtime input owner", () => { expect(config).toContain("retry_attempts: 5"); expect(config).toContain("retry_max_duration_secs: 10"); expect(config).not.toContain('x-api-key: "api-key"'); - yield* owner.cleanupGeneration(7, "vector"); - expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "vector", "7"))).toBe( + yield* owner.cleanupAll; + expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "vector"))).toBe( false, ); - const rematerialized = yield* owner.resolve(native, 7); + const rematerialized = yield* owner.resolve(native); expect(rematerialized.analytics?.vectorConfigPath).toBeDefined(); expect(yield* fs.exists(rematerialized.analytics?.vectorConfigPath ?? "")).toBe(true); }), @@ -506,16 +499,16 @@ describe("runtime input owner", () => { privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const first = yield* owner.resolve(nativeState, 3, { includePooler: true }); + const first = yield* owner.resolve(nativeState, { includePooler: true }); const tenant = first.pooler?.tenantPath; expect(tenant).toBeDefined(); - yield* owner.cleanupGeneration(3, "pooler"); - const common = yield* owner.resolve(nativeState, 3); + yield* owner.cleanupAll; + const common = yield* owner.resolve(nativeState); expect(common.pooler).toBeUndefined(); - expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler", "3"))).toBe( + expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( false, ); - const recreated = yield* owner.resolve(nativeState, 3, { includePooler: true }); + const recreated = yield* owner.resolve(nativeState, { includePooler: true }); expect(recreated.pooler?.tenantPath).toBeDefined(); expect(yield* fs.exists(recreated.pooler?.tenantPath ?? "")).toBe(true); }), @@ -553,7 +546,7 @@ describe("runtime input owner", () => { }; const containerState = state; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(containerState, 4, { includePooler: true }); + const material = yield* owner.resolve(containerState, { includePooler: true }); const tenant = material.pooler?.tenantPath; expect(tenant).toBeDefined(); const content = yield* fs.readFileString(tenant!); @@ -577,7 +570,7 @@ describe("runtime input owner", () => { capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(state, 9, { includePooler: true }).pipe(Effect.exit); + const failed = yield* owner.resolve(state, { includePooler: true }).pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); }), ), @@ -603,19 +596,12 @@ describe("runtime input owner", () => { const blank = JSON.parse(JSON.stringify(native)); blank.definition.capabilities.pooler.settings.default_pool_size = " "; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - for (const [generation, invalid] of [ - [10, missing], - [11, blank], - ] as const) { - const failed = yield* owner - .resolve(invalid, generation, { includePooler: true }) - .pipe(Effect.exit); + for (const invalid of [missing, blank]) { + const failed = yield* owner.resolve(invalid, { includePooler: true }).pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("sizing settings are invalid"); - expect( - yield* fs.exists( - path.join(root, stackId, "runtime", "inputs", "pooler", `${generation}`), - ), - ).toBe(false); + expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( + false, + ); } }), ), @@ -631,6 +617,7 @@ describe("runtime input owner", () => { capabilities: { pooler: { enabled: true }, functions: { + enabled: true, settings: { edge_runtime: { secrets: { SUPABASE_BAD: Redacted.make("x") } }, }, @@ -642,11 +629,9 @@ describe("runtime input owner", () => { privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner - .resolve(nativeState, 7, { includePooler: true }) - .pipe(Effect.exit); + const failed = yield* owner.resolve(nativeState, { includePooler: true }).pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); - expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler", "7"))).toBe( + expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( false, ); }), @@ -691,11 +676,11 @@ describe("runtime input owner", () => { }); }, }); - const first = yield* Effect.forkChild(owner.resolve(native, 5, { includePooler: true }), { + const first = yield* Effect.forkChild(owner.resolve(native, { includePooler: true }), { startImmediately: true, }); yield* Deferred.await(started); - const second = yield* Effect.forkChild(owner.resolve(native, 5, { includePooler: true }), { + const second = yield* Effect.forkChild(owner.resolve(native, { includePooler: true }), { startImmediately: true, }); expect(fetches).toBe(1); @@ -707,7 +692,7 @@ describe("runtime input owner", () => { expect(fetches).toBe(2); expect(firstMaterial.auth?.jwks).toBe(secondMaterial.auth?.jwks); expect(firstMaterial.pooler?.tenantPath).toBe(secondMaterial.pooler?.tenantPath); - const cached = yield* owner.resolve(native, 5, { includePooler: true }); + const cached = yield* owner.resolve(native, { includePooler: true }); expect(cached.pooler?.tenantPath).toBe(firstMaterial.pooler?.tenantPath); expect(fetches).toBe(2); }), @@ -748,11 +733,11 @@ describe("runtime input owner", () => { }); }, }); - const first = yield* Effect.forkChild(owner.resolve(state, 6), { + const first = yield* Effect.forkChild(owner.resolve(state), { startImmediately: true, }); yield* Deferred.await(started); - const second = yield* Effect.forkChild(owner.resolve(state, 6), { + const second = yield* Effect.forkChild(owner.resolve(state), { startImmediately: true, }); expect(fetches).toBe(1); @@ -761,79 +746,13 @@ describe("runtime input owner", () => { const firstMaterial = yield* Fiber.join(first); expect(firstMaterial.auth?.jwks).toContain('"keys"'); expect(fetches).toBe(2); - const cached = yield* owner.resolve(state, 6); + const cached = yield* owner.resolve(state); expect(cached.auth?.jwks).toBe(firstMaterial.auth?.jwks); expect(fetches).toBe(2); }), ), ); - it.live("retires older generations and fences their in-flight waiters", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-generation-retire-", - }); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let fetches = 0; - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, - }, - }, - }, - }); - const newer = { ...state, inputFingerprint: `${state.inputFingerprint}-new` }; - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: (url) => { - fetches += 1; - return Effect.gen(function* () { - if (fetches === 1) { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - } - return url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; - }); - }, - }); - const first = yield* Effect.forkChild(owner.resolve(state, 40), { - startImmediately: true, - }); - yield* Deferred.await(started); - const firstWaiter = yield* Effect.forkChild(owner.resolve(state, 40), { - startImmediately: true, - }); - const second = yield* Effect.forkChild(owner.resolve(newer, 41), { - startImmediately: true, - }); - const firstExit = yield* Fiber.join(first).pipe(Effect.exit); - const firstWaiterExit = yield* Fiber.join(firstWaiter).pipe(Effect.exit); - expect(Exit.isFailure(firstExit)).toBe(true); - expect(Exit.isFailure(firstWaiterExit)).toBe(true); - expect(errorOf(firstExit)?.message).toContain("generation was invalidated"); - expect(errorOf(firstWaiterExit)?.message).toContain("generation was invalidated"); - yield* Deferred.succeed(release, undefined); - const secondMaterial = yield* Fiber.join(second); - expect(secondMaterial.auth?.jwks).toContain('"keys"'); - // The superseded owner may finish its already-started fetch, but its - // result is fenced and never enters the cache. - expect(fetches).toBe(4); - const late = yield* owner.resolve(state, 40).pipe(Effect.exit); - expect(Exit.isFailure(late)).toBe(true); - expect(errorOf(late)?.message).toContain("generation was invalidated"); - yield* owner.resolve(newer, 41); - expect(fetches).toBe(4); - }), - ), - ); - it.live("completes materializing and queued owners when their scope closes", () => withPlatform( Effect.gen(function* () { @@ -869,11 +788,11 @@ describe("runtime input owner", () => { }); }, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const materializing = yield* Effect.forkChild(owner.resolve(state, 20), { + const materializing = yield* Effect.forkChild(owner.resolve(state), { startImmediately: true, }); yield* Deferred.await(started); - const queued = yield* Effect.forkChild(owner.resolve(state, 21), { + const queued = yield* Effect.forkChild(owner.resolve(state), { startImmediately: true, }); expect(fetches).toBe(1); @@ -897,7 +816,7 @@ describe("runtime input owner", () => { capabilities: { analytics: { settings: { gcp_jwt_path: "gcp.json" } } }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1); + const material = yield* owner.resolve(state); expect(material.analytics?.gcpJwtPath).toBe( path.join(yield* fs.realPath(root), "gcp.json"), ); @@ -936,6 +855,8 @@ describe("runtime input owner", () => { edge_runtime: { policy: null, deno_version: null, + verify_jwt_default: null, + import_map_default: null, secrets: { SUPABASE_RESERVED: { slot: "missing" } }, }, }, @@ -944,7 +865,7 @@ describe("runtime input owner", () => { }, }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1); + const material = yield* owner.resolve(state); expect(material.analytics).toBeUndefined(); expect(material.functions).toBeUndefined(); }), @@ -1026,6 +947,7 @@ describe("runtime input owner", () => { const state = yield* compiledState(root, { capabilities: { functions: { + enabled: true, settings: { edge_runtime: { secrets: { API_TOKEN: Redacted.make("actual-value") } }, }, @@ -1033,31 +955,20 @@ describe("runtime input owner", () => { }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state, 1); + const material = yield* owner.resolve(state); expect(material.functions?.secrets).toEqual({ API_TOKEN: "actual-value" }); const invalid = yield* compiledState(root, { capabilities: { functions: { + enabled: true, settings: { edge_runtime: { secrets: { SUPABASE_TOKEN: Redacted.make("value") } }, }, }, }, }); - const failed = yield* owner.resolve(invalid, 1).pipe(Effect.exit); + const failed = yield* owner.resolve(invalid).pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("secret name is reserved"); - - const control = yield* compiledState(root, { - capabilities: { - functions: { - settings: { - edge_runtime: { secrets: { API_TOKEN: Redacted.make("value\tinvalid") } }, - }, - }, - }, - }); - const controlFailure = yield* owner.resolve(control, 2).pipe(Effect.exit); - expect(errorOf(controlFailure)?.message).toContain("secret value is invalid"); }), ), ); diff --git a/packages/stack/src/runtime/runtime-recovery.integration.test.ts b/packages/stack/src/runtime/runtime-recovery.integration.test.ts deleted file mode 100644 index 707cd5eee8..0000000000 --- a/packages/stack/src/runtime/runtime-recovery.integration.test.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; -import type { ContainerArtifact } from "../model/CapabilityModule.ts"; -import { StackIdSchema, type StackId } from "../public/StackId.ts"; -import type { - ContainerContainerSpec, - ContainerEngine, - ContainerNetworkLabels, - ContainerNetworkSpec, - ContainerResource, - ContainerWorkloadLabels, - ContainerVolumeSpec, -} from "./ContainerEngine.ts"; -import { makeContainerRuntime } from "./ContainerRuntime.ts"; -import { makeNativeRuntime } from "./NativeRuntime.ts"; -import type { RuntimeWorkloadKey } from "./RuntimeDriver.ts"; - -const stackId = StackIdSchema.make("e".repeat(64)); -const otherStackId = StackIdSchema.make("f".repeat(64)); -const artifact: ContainerArtifact = { - kind: "container", - image: "example/database:1", -}; -const key: RuntimeWorkloadKey = { - stackId, - desiredGeneration: 3, - workloadId: "database:database", - specHash: "hash-current", -}; -const workload = (selected: PlannedWorkload["selected"] = artifact): PlannedWorkload => ({ - id: key.workloadId, - capability: "database", - dependencies: [], - readiness: { mode: "tcp" }, - restart: { maxAttempts: 1, backoffMs: 0 }, - artifacts: { - native: { kind: "native", release: "1" }, - container: artifact, - }, - selected, - specHash: key.specHash, -}); - -const processPlan = (main: A) => ({ startup: [], main }); - -const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ - runtime: { kind: "native" }, - activation: { - database: "eager", - rest: "eager", - auth: "eager", - realtime: "eager", - storage: "eager", - functions: "eager", - studio: "eager", - mail: "eager", - analytics: "eager", - pooler: "eager", - }, - startOrder: ["database"], - stopOrder: ["database"], - dependencies: { - database: [], - rest: [], - auth: [], - realtime: [], - storage: [], - functions: [], - studio: [], - mail: [], - analytics: [], - pooler: [], - }, - routes: [], - workloads, -}); - -interface EngineState { - resources: Array; - calls: Array; - nextId: number; -} - -const makeEngine = (state: EngineState): ContainerEngine => { - const next = (prefix: string) => `${prefix}-${state.nextId++}`; - const find = (id: string) => state.resources.find((resource) => resource.id === id); - return { - kind: "docker", - executable: "controlled", - preflight: Effect.succeed({ host: "host.docker.internal" }), - probe: Effect.void, - inspectImage: () => Effect.succeed({ present: true }), - pullImage: () => Effect.sync(() => state.calls.push("pull-image")), - listResources: () => Effect.sync(() => [...state.resources]), - createNetwork: (spec: ContainerNetworkSpec) => - Effect.sync(() => { - state.calls.push("create-network"); - const resource: ContainerResource = { - id: next("network"), - name: spec.name, - kind: "network", - labels: spec.labels, - }; - state.resources.push(resource); - return resource; - }), - removeNetwork: (id) => - Effect.sync(() => { - state.calls.push(`remove-network:${id}`); - state.resources = state.resources.filter((resource) => resource.id !== id); - }), - createVolume: (spec: ContainerVolumeSpec) => - Effect.sync(() => { - state.calls.push("create-volume"); - const resource: ContainerResource = { - id: next("volume"), - name: spec.name, - kind: "volume", - labels: spec.labels, - }; - state.resources.push(resource); - return resource; - }), - removeVolume: (id) => - Effect.sync(() => { - state.calls.push(`remove-volume:${id}`); - state.resources = state.resources.filter((resource) => resource.id !== id); - }), - createContainer: (spec: ContainerContainerSpec) => - Effect.sync(() => { - state.calls.push(`create-${spec.role}`); - const resource: ContainerResource = { - id: next(spec.role), - name: spec.name, - kind: spec.role, - labels: spec.labels, - state: "created", - }; - state.resources.push(resource); - return resource; - }), - copyToContainer: () => Effect.void, - startContainer: (id) => - Effect.sync(() => { - state.calls.push(`start:${id}`); - const resource = find(id); - if (resource !== undefined) - state.resources = state.resources.map((entry) => - entry.id === id ? { ...entry, state: "running" } : entry, - ); - }), - waitContainer: () => Effect.succeed(0), - stopContainer: (id) => - Effect.sync(() => { - state.calls.push(`stop:${id}`); - const resource = find(id); - if (resource !== undefined) - state.resources = state.resources.map((entry) => - entry.id === id ? { ...entry, state: "stopped" } : entry, - ); - }), - removeContainer: (id) => - Effect.sync(() => { - state.calls.push(`remove:${id}`); - state.resources = state.resources.filter((resource) => resource.id !== id); - }), - }; -}; - -const workloadLabels = ( - id: StackId, - ownerSessionId: string, - specHash: string, -): ContainerWorkloadLabels => ({ - stackId: id, - ownerSessionId, - desiredGeneration: key.desiredGeneration, - workloadId: key.workloadId, - specHash, - role: "workload", -}); - -const networkLabels = (id: StackId, ownerSessionId: string): ContainerNetworkLabels => ({ - stackId: id, - ownerSessionId, - desiredGeneration: key.desiredGeneration, - role: "network", -}); - -const networkName = `supabase-${stackId.slice(0, 16)}-${key.desiredGeneration}-network`; -const workloadName = `supabase-${stackId.slice(0, 16)}-${key.desiredGeneration}-${key.workloadId.replace(/[^A-Za-z0-9_.-]/g, "-")}-workload`; - -describe("runtime recovery", () => { - it.live("adopts exact old-session workload and network resources", () => - Effect.gen(function* () { - const state: EngineState = { - calls: [], - nextId: 1, - resources: [ - { - id: "old-container", - name: workloadName, - kind: "workload", - state: "running", - labels: workloadLabels(stackId, "old-owner", key.specHash), - }, - { - id: "old-network", - name: networkName, - kind: "network", - labels: networkLabels(stackId, "old-owner"), - }, - ], - }; - const runtime = yield* makeContainerRuntime({ - engine: makeEngine(state), - ownerSessionId: "new-owner", - }); - const ready = yield* runtime.start(key, workload()); - expect(ready.state).toBe("ready"); - expect(state.calls).toEqual([]); - expect(yield* runtime.observe(stackId)).toEqual([{ ...key, state: "ready" }]); - }), - ); - - it.live("replaces a stale old-session hash without touching the adopted network", () => - Effect.gen(function* () { - const state: EngineState = { - calls: [], - nextId: 1, - resources: [ - { - id: "stale-container", - name: workloadName, - kind: "workload", - state: "running", - labels: workloadLabels(stackId, "old-owner", "hash-old"), - }, - { - id: "old-network", - name: networkName, - kind: "network", - labels: networkLabels(stackId, "old-owner"), - }, - ], - }; - const runtime = yield* makeContainerRuntime({ - engine: makeEngine(state), - ownerSessionId: "new-owner", - }); - yield* runtime.start(key, workload()); - expect(state.calls).toEqual([ - "stop:stale-container", - "remove:stale-container", - "create-workload", - "start:workload-1", - ]); - expect(state.resources.some((resource) => resource.id === "old-network")).toBe(true); - }), - ); - - it.live("cleans exact stack containers before networks, retaining then destroying volumes", () => - Effect.gen(function* () { - const volumeName = `supabase-${stackId}-${key.workloadId}-volume`; - const state: EngineState = { - calls: [], - nextId: 1, - resources: [ - { - id: "workload", - name: workloadName, - kind: "workload", - state: "running", - labels: workloadLabels(stackId, "owner", key.specHash), - }, - { - id: "network", - name: networkName, - kind: "network", - labels: networkLabels(stackId, "owner"), - }, - { - id: "volume", - name: volumeName, - kind: "volume", - labels: { stackId, workloadId: key.workloadId, role: "volume" }, - }, - { - id: "foreign", - name: "foreign", - kind: "workload", - state: "running", - labels: workloadLabels(otherStackId, "other-owner", key.specHash), - }, - ], - }; - const runtime = yield* makeContainerRuntime({ - engine: makeEngine(state), - ownerSessionId: "new-owner", - }); - yield* runtime.cleanup({ stackId, destroy: false }); - expect(state.calls.slice(0, 3)).toEqual([ - "stop:workload", - "remove:workload", - "remove-network:network", - ]); - expect(state.resources.some((resource) => resource.id === "volume")).toBe(true); - expect(state.resources.some((resource) => resource.id === "foreign")).toBe(true); - const callCount = state.calls.length; - yield* runtime.cleanup({ stackId, destroy: false }); - expect(state.calls).toHaveLength(callCount); - yield* runtime.cleanup({ stackId, destroy: true }); - expect(state.resources.some((resource) => resource.id === "volume")).toBe(false); - expect(state.resources.some((resource) => resource.id === "foreign")).toBe(true); - }), - ); - - it.live("recovers containers without starting and removes stale workloads", () => - Effect.gen(function* () { - const state: EngineState = { - calls: [], - nextId: 1, - resources: [ - { - id: "current", - name: workloadName, - kind: "workload", - state: "running", - labels: workloadLabels(stackId, "old-owner", key.specHash), - }, - { - id: "current-duplicate", - name: workloadName, - kind: "workload", - state: "running", - labels: workloadLabels(stackId, "previous-owner", key.specHash), - }, - { - id: "stale-hash", - name: "stale-hash", - kind: "workload", - state: "running", - labels: workloadLabels(stackId, "old-owner", "hash-old"), - }, - { - id: "stale-generation", - name: "stale-generation", - kind: "workload", - state: "running", - labels: { - ...workloadLabels(stackId, "old-owner", "hash-generation"), - desiredGeneration: key.desiredGeneration - 1, - }, - }, - { - id: "unplanned", - name: "unplanned", - kind: "workload", - state: "running", - labels: { - ...workloadLabels(stackId, "old-owner", "hash-unplanned"), - workloadId: "storage:storage", - }, - }, - { - id: "current-network", - name: networkName, - kind: "network", - labels: networkLabels(stackId, "old-owner"), - }, - { - id: "stale-network", - name: "stale-network", - kind: "network", - labels: { ...networkLabels(stackId, "old-owner"), desiredGeneration: 2 }, - }, - { - id: "volume", - name: "persistent", - kind: "volume", - labels: { stackId, workloadId: key.workloadId, role: "volume" }, - }, - { - id: "foreign", - name: "foreign", - kind: "workload", - state: "running", - labels: workloadLabels(otherStackId, "other-owner", key.specHash), - }, - ], - }; - const runtime = yield* makeContainerRuntime({ - engine: makeEngine(state), - ownerSessionId: "new-owner", - }); - const observed = yield* runtime.recover({ - stackId, - desiredGeneration: key.desiredGeneration, - desiredLifecycle: "running", - plan: planFor([workload()]), - }); - expect(observed).toEqual([{ ...key, state: "ready" }]); - expect( - state.calls.some((call) => call.startsWith("create-") || call.startsWith("start:")), - ).toBe(false); - expect(state.calls).toContain("stop:current-duplicate"); - expect(state.calls).toContain("remove:current-duplicate"); - expect(state.calls.slice(-1)).toEqual(["remove-network:stale-network"]); - expect(state.resources.map((resource) => resource.id)).toEqual([ - "current", - "current-network", - "volume", - "foreign", - ]); - }), - ); - - it.live("cleans only exact native resources and is idempotent", () => - Effect.scoped( - Effect.gen(function* () { - const runtime = yield* makeNativeRuntime({ - resolveProcess: () => - Effect.succeed( - processPlan({ - executable: process.execPath, - args: ["-e", "setInterval(() => {}, 1000)"], - }), - ), - waitForReadiness: () => Effect.void, - }); - const otherKey = { ...key, stackId: otherStackId }; - const nativeWorkload = workload({ kind: "native", release: "1" }); - yield* runtime.start(key, nativeWorkload); - yield* runtime.start(otherKey, { ...nativeWorkload, id: otherKey.workloadId }); - yield* runtime.cleanup({ stackId, destroy: true }); - expect(yield* runtime.observe(stackId)).toEqual([]); - expect((yield* runtime.observe(otherStackId)).length).toBe(1); - yield* runtime.cleanup({ stackId, destroy: true }); - yield* runtime.cleanup({ stackId: otherStackId, destroy: true }); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.live("recovers native resources by stopping exact stack remnants without starting", () => - Effect.scoped( - Effect.gen(function* () { - let launches = 0; - const runtime = yield* makeNativeRuntime({ - resolveProcess: () => - Effect.sync(() => { - launches += 1; - return processPlan({ - executable: process.execPath, - args: ["-e", "setInterval(() => {}, 1000)"], - }); - }), - waitForReadiness: () => Effect.void, - }); - const nativeWorkload = workload({ kind: "native", release: "1" }); - yield* runtime.start(key, nativeWorkload); - const otherKey = { ...key, stackId: otherStackId }; - yield* runtime.start(otherKey, { ...nativeWorkload, id: otherKey.workloadId }); - expect(launches).toBe(2); - expect( - yield* runtime.recover({ - stackId, - desiredGeneration: key.desiredGeneration, - desiredLifecycle: "running", - plan: planFor([]), - }), - ).toEqual([]); - expect(launches).toBe(2); - expect(yield* runtime.observe(stackId)).toEqual([]); - expect((yield* runtime.observe(otherStackId)).length).toBe(1); - yield* runtime.cleanup({ stackId: otherStackId, destroy: true }); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 408b35757a..7673fc41bb 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Redacted } from "effect"; +import { Effect, Exit, FileSystem, Redacted, Schema } from "effect"; import { WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { deriveStackId } from "../identity/Identity.ts"; @@ -44,8 +44,6 @@ const state: PersistedStackState = { stackName: "runtime-spec", }, runtime: { kind: "native" }, - desiredGeneration: 1, - portsGeneration: 1, desiredLifecycle: "running", ports: [ { field: "database", port: 55432, intent: "exact" }, @@ -68,6 +66,7 @@ const state: PersistedStackState = { { workloadId: "analytics:analytics", binding: "primary", port: 30_013 }, { workloadId: "analytics:vector", binding: "primary", port: 30_014 }, { workloadId: "pooler:pooler", binding: "primary", port: 30_016 }, + { workloadId: "pooler:pooler", binding: "admin", port: 30_017 }, ], secrets: { "secret:database.internal.password": { policy: "managed", value: "postgres" }, @@ -77,6 +76,11 @@ const state: PersistedStackState = { value: "sb_publishable_test", }, "secret:auth.settings.secret_key": { policy: "managed", value: "sb_secret_test" }, + "secret:realtime.settings.db_enc_key": { policy: "managed", value: "realtime-db-key" }, + "secret:realtime.settings.secret_key_base": { + policy: "managed", + value: "realtime-secret-base", + }, }, }; @@ -137,7 +141,7 @@ describe("workload runtime catalog", () => { }); const functions = planned("functions:edge-runtime"); expect(runtimeSpecFor(functions)?.env(configured, functions, 9000)).toMatchObject({ - FUNCTIONS_ROOT: `${state.identity.projectRoot}/supabase/functions`, + SUPABASE_INTERNAL_FUNCTIONS_ROOT: `${state.identity.projectRoot}/supabase/functions`, EDGE_RUNTIME_POLICY: "per_worker", EDGE_RUNTIME_DENO_VERSION: "2", }); @@ -217,11 +221,9 @@ describe("workload runtime catalog", () => { }; const stackId = yield* deriveStackId(identity); const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.write(stackId, { + yield* store.initialize(stackId, { ...state, identity: { ...identity, stackId }, - desiredGeneration: 0, - portsGeneration: null, desiredLifecycle: "stopped", ports: [], privatePorts: [], @@ -231,7 +233,6 @@ describe("workload runtime catalog", () => { stackId, disabledListenerIntents, { - lifecycle: "stopped", privateBindings: privateBindingIntentsFor(compiled.executionPlan), }, ); @@ -471,14 +472,39 @@ describe("workload runtime catalog", () => { expect(containerResolutionFor(state, storage)?.startup).toEqual([ { entrypoint: "/node/bin/node", command: ["dist/scripts/migrate-call.js"] }, ]); - expect(containerResolutionFor(state, realtime)?.command).toEqual([]); + expect(containerResolutionFor(state, realtime)?.entrypoint).toBe("/usr/bin/tini"); + expect(containerResolutionFor(state, realtime)?.command).toEqual([ + "-s", + "-g", + "--", + "/app/bin/server", + ]); + expect(containerResolutionFor(state, realtime)?.startup).toEqual([ + { entrypoint: "/app/bin/migrate", command: [] }, + { + entrypoint: "/app/bin/realtime", + command: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], + }, + ]); expect(containerResolutionFor(state, analytics)?.command).toEqual([]); - expect(containerResolutionFor(state, pooler)?.command).toEqual([]); + expect(containerResolutionFor(state, pooler)?.entrypoint).toBe("/usr/bin/tini"); + expect(containerResolutionFor(state, pooler)?.command).toEqual([ + "-s", + "-g", + "--", + "/app/bin/server", + ]); + expect(containerResolutionFor(state, pooler)?.startup).toEqual([ + { entrypoint: "/app/bin/migrate", command: [] }, + ]); const poolerWithTenant = containerResolutionFor(state, pooler, tenantInput); - expect(poolerWithTenant?.command).toEqual([ - "/usr/bin/sh", - "-c", - '/app/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")" && exec /app/bin/server', + expect(poolerWithTenant?.command).toEqual(["-s", "-g", "--", "/app/bin/server"]); + expect(poolerWithTenant?.startup).toEqual([ + { entrypoint: "/app/bin/migrate", command: [] }, + { + entrypoint: "/usr/bin/sh", + command: ["-c", 'exec /app/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"'], + }, ]); expect(poolerWithTenant?.mounts).toEqual([ { source: "/tmp/pooler-tenant.exs", target: "/app/pooler_tenant.exs", readOnly: true }, @@ -511,7 +537,12 @@ describe("workload runtime catalog", () => { }, functions: { settings: { - edge_runtime: { policy: "oneshot", deno_version: 1 }, + edge_runtime: { + policy: "oneshot", + deno_version: 1, + verify_jwt_default: false, + import_map_default: "shared-deno.json", + }, inspector: { mode: "brk", main: true }, functions: { hello: { verify_jwt: false } }, }, @@ -575,6 +606,21 @@ describe("workload runtime catalog", () => { const rest = runtimeSpecFor(planned("rest:rest")); const storage = runtimeSpecFor(planned("storage:storage")); const functions = planned("functions:edge-runtime"); + const functionsEnvironment = runtimeSpecFor(functions)?.env( + configured, + functions, + 9000, + "native", + ); + const functionsConfig = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + functionsEnvironment?.SUPABASE_INTERNAL_FUNCTIONS_CONFIG ?? "{}", + ); + expect(functionsConfig).toMatchObject({ + $default: { + verify_jwt: false, + import_map_root: "shared-deno.json", + }, + }); expect(runtimeSpecFor(functions)?.env(configured, functions, 9000, "native")).toMatchObject( { SUPABASE_URL: "http://127.0.0.1:54321", @@ -728,7 +774,7 @@ describe("workload runtime catalog", () => { POOL_MODE: "session", MAX_CLIENT_CONN: "250", TENANT_ID: "pooler-dev", - PORT: "4000", + PORT: "30017", PROXY_PORT_SESSION: "30016", PROXY_PORT_TRANSACTION: "6543", }); @@ -740,6 +786,7 @@ describe("workload runtime catalog", () => { ); expect(containerResolutionFor(configured, planned("pooler:pooler"))?.publications).toEqual([ { address: "127.0.0.1", hostPort: 30016, containerPort: 5432 }, + { address: "127.0.0.1", hostPort: 30017, containerPort: 4000 }, ]); const resolution = containerResolutionFor(configured, functions, { hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, @@ -759,8 +806,10 @@ describe("workload runtime catalog", () => { EDGE_RUNTIME_DENO_VERSION: "1", INSPECTOR_MODE: "brk", INSPECTOR_MAIN: "true", - FUNCTIONS_FUNCTIONS_HELLO_VERIFY_JWT: "false", }); + expect( + Object.keys(resolution?.env ?? {}).some((key) => key.startsWith("FUNCTIONS_FUNCTIONS_")), + ).toBe(false); expect(resolution?.env.EDGE_RUNTIME_PORT).toBe("9000"); expect(resolution?.hostRoute).toEqual({ host: "host.docker.internal", @@ -793,6 +842,10 @@ describe("workload runtime catalog", () => { LOGFLARE_URL: "http://supabase-analytics:4000", EDGE_FUNCTIONS_MANAGEMENT_FOLDER: FUNCTIONS_CONTAINER_ROOT, }); + expect(studio?.env(configured, planned("studio:studio"), 3000, "native")).toMatchObject({ + SUPABASE_URL: "https://studio.example", + SUPABASE_PUBLIC_URL: "http://127.0.0.1:54321", + }); expect(studio?.containerMounts?.(configured, planned("studio:studio"))).toEqual([ { source: `${state.identity.projectRoot}/supabase/functions`, @@ -816,6 +869,17 @@ describe("workload runtime catalog", () => { args: ["/tmp/native-artifact/app/apps/studio/docker-entrypoint.mjs"], cwd: "/tmp/native-artifact/app", }); + expect( + runtimeSpecFor(planned("database:database"))?.nativeProcess( + nodeArtifactRoot, + configured, + planned("database:database"), + 5432, + ), + ).toMatchObject({ + gracefulStopSignal: "SIGINT", + gracefulStopTimeout: "15 seconds", + }); expect( runtimeSpecFor(planned("analytics:vector"))?.nativeProcess( nodeArtifactRoot, @@ -1066,6 +1130,14 @@ describe("workload runtime catalog", () => { } }); + it("uses managed per-stack Realtime encryption keys", () => { + const workload = planned("realtime:realtime"); + expect(runtimeSpecFor(workload)?.env(state, workload, 3000)).toMatchObject({ + DB_ENC_KEY: "realtime-db-key", + SECRET_KEY_BASE: "realtime-secret-base", + }); + }); + it.live("keeps Auth's local JWT secret alongside resolved JWKS material", () => Effect.gen(function* () { const compileWith = (config: Parameters[0]["config"]) => diff --git a/packages/stack/src/state/MaterializedSettings.ts b/packages/stack/src/state/MaterializedSettings.ts index 8223c8de67..c037c43074 100644 --- a/packages/stack/src/state/MaterializedSettings.ts +++ b/packages/stack/src/state/MaterializedSettings.ts @@ -1,5 +1,7 @@ -import type { CapabilityName } from "../public/Capability.ts"; +import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; import type { PersistedStackState } from "./StackState.ts"; +import { Effect } from "effect"; +import { StackStateInvalidError } from "../public/Errors.ts"; /** A plain object in the persisted, materialized settings document. */ export const isRecord = (value: unknown): value is Readonly> => @@ -69,3 +71,39 @@ export const valueAt = ( } return settingValue(state, current); }; + +/** Ensures every materialized secret slot is present before runtime mutation. */ +export const validateMaterializedSecrets = ( + state: PersistedStackState, + capability?: CapabilityName, +): Effect.Effect => { + const visit = (value: unknown, path: string): StackStateInvalidError | undefined => { + if (isRecord(value) && typeof value.slot === "string" && Object.keys(value).length === 1) { + return state.secrets[value.slot] === undefined + ? new StackStateInvalidError({ + message: `Missing materialized secret slot ${value.slot} at ${path}`, + slot: value.slot, + }) + : undefined; + } + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) { + const failure = visit(entry, `${path}[${index}]`); + if (failure !== undefined) return failure; + } + } else if (isRecord(value)) { + for (const [key, entry] of Object.entries(value)) { + const failure = visit(entry, path.length === 0 ? key : `${path}.${key}`); + if (failure !== undefined) return failure; + } + } + return undefined; + }; + const names = capability === undefined ? CAPABILITY_NAMES : [capability]; + for (const name of names) { + const settings = state.definition?.capabilities[name]?.settings; + const failure = visit(settings, `${name}.settings`); + if (failure !== undefined) return Effect.fail(failure); + } + return Effect.void; +}; diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index ae7e8d3297..c8bd122c86 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -1,11 +1,26 @@ -import { Context, Effect, FileSystem, Option, Path, Predicate, Schema, Scope } from "effect"; +import { + Context, + Data, + Effect, + Exit, + FileSystem, + Option, + Path, + Predicate, + Schema, + Scope, +} from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { createServer, type Server } from "node:net"; import { StackIdSchema, type StackId } from "../public/StackId.ts"; import { resolveStackPaths } from "./Paths.ts"; import { StackOwnershipConflictError, StackStateInvalidError } from "../public/Errors.ts"; import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; +import { NetworkPortSchema } from "../public/Status.ts"; /** The owner metadata format is deliberately fail-closed. */ const OWNERSHIP_FORMAT = "supabase-stack-owner-v1" as const; +export const OWNER_LOCK_FORMAT = "supabase-stack-lease-v1" as const; interface UnixControlEndpoint { readonly kind: "unix"; @@ -50,14 +65,174 @@ const OwnerMetadataSchema = Schema.Struct({ }); export type OwnerMetadata = Schema.Schema.Type; +const OwnerLockSchema = Schema.Struct({ + format: Schema.Literal(OWNER_LOCK_FORMAT), + token: Schema.String, + port: NetworkPortSchema, +}); +export type OwnerLock = Schema.Schema.Type; +export class LeaseSlotTaken extends Data.TaggedError("LeaseSlotTaken")<{}> {} + export interface OwnerLease { readonly metadata: OwnerMetadata; readonly lockPath: string; readonly metadataPath: string; - /** Releases only this session's exact metadata and O_EXCL lock. */ + /** Releases only this session's exact metadata and atomic owner lock. */ readonly release: Effect.Effect; } +const ownerLockFrom = (value: unknown): Effect.Effect => + Schema.decodeUnknownEffect(OwnerLockSchema)(value, { onExcessProperty: "error" }).pipe( + Effect.mapError((error) => stateError(`Invalid owner lock: ${String(error)}`)), + Effect.flatMap((lock) => + lock.token.length > 0 + ? Effect.succeed(lock) + : Effect.fail(stateError("Invalid owner lock lease")), + ), + ); + +export const readOwnerLock = ( + fs: FileSystem.FileSystem, + lockPath: string, +): Effect.Effect => + fs.readFileString(lockPath).pipe( + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( + Effect.mapError((error) => stateError(`Unable to parse owner lock: ${String(error)}`)), + Effect.flatMap(ownerLockFrom), + ), + ), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? // oxlint-disable-next-line effecttsgo/effect-succeed-with-void -- this branch carries Option.none as undefined + Effect.succeed(undefined) + : Effect.fail(stateError(`Unable to read owner lock: ${error.message}`)), + ), + ); + +const bindLease = (port: number): Effect.Effect => + Effect.callback((resume) => { + const server = createServer({ allowHalfOpen: false }, (socket) => socket.destroy()); + let settled = false; + let canceled = false; + const cleanup = () => { + server.off("error", onError); + server.off("listening", onListening); + }; + const close = () => { + if (server.listening) { + try { + server.close(() => undefined); + } catch { + // The listener may have failed before a handle was allocated. + } + } + }; + const onError = (cause: Error & { readonly code?: string }) => { + if (settled) return; + settled = true; + cleanup(); + close(); + if (canceled) return; + resume( + Effect.fail( + new StackStateInvalidError({ + message: `Unable to acquire owner lease: ${cause.message}`, + code: cause.code, + cause, + }), + ), + ); + }; + const onListening = () => { + if (canceled) { + settled = true; + cleanup(); + close(); + return; + } + if (settled) return; + settled = true; + cleanup(); + resume(Effect.succeed(server)); + }; + server.once("error", onError); + server.once("listening", onListening); + try { + server.listen({ host: "127.0.0.1", port }); + } catch (cause) { + onError(cause instanceof Error ? cause : new Error(String(cause))); + } + return Effect.sync(() => { + if (!settled) { + canceled = true; + if (server.listening) close(); + } + }); + }); + +const closeBoundServer = (server: Server): Effect.Effect => + Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return; + } + let settled = false; + const finish = () => { + if (!settled) { + settled = true; + resume(Effect.void); + } + }; + try { + server.close(finish); + } catch { + finish(); + } + return Effect.sync(() => { + if (!settled) { + settled = true; + try { + server.close(() => undefined); + } catch { + // The exact listener is already closed or was never allocated. + } + } + }); + }); + +const leasePort = (server: Server): Effect.Effect => + Effect.sync(() => { + const address = server.address(); + if (address === null || typeof address === "string" || !Number.isInteger(address.port)) + return undefined; + return address.port; + }).pipe( + Effect.flatMap((port) => + port === undefined + ? Effect.fail(stateError("Owner lease did not expose a bound port")) + : Effect.succeed(port), + ), + ); + +export interface HeldPortLease { + readonly port: number; + readonly close: Effect.Effect; +} + +export const acquirePortLease = ( + port: number, +): Effect.Effect => + Effect.uninterruptible( + Effect.gen(function* () { + const server = yield* bindLease(port); + return yield* leasePort(server).pipe( + Effect.map((actualPort) => ({ port: actualPort, close: closeBoundServer(server) })), + Effect.onExit((exit) => (Exit.isFailure(exit) ? closeBoundServer(server) : Effect.void)), + ); + }), + ); + const stateError = (message: string) => new StackStateInvalidError({ message }); const decodeStackId = (value: string): Effect.Effect => @@ -239,19 +414,113 @@ export const publishOwnership = ( ); }); -const removeLockIfOwned = ( +export const removeLeaseIfHeld = ( fs: FileSystem.FileSystem, lockPath: string, - ownerSessionId: string, + token: string, ): Effect.Effect => - fs.readFileString(lockPath).pipe( + readOwnerLock(fs, lockPath).pipe( Effect.flatMap((owner) => - owner === ownerSessionId ? fs.remove(lockPath, { force: true }) : Effect.void, + owner?.token === token ? fs.remove(lockPath, { force: true }) : Effect.void, ), - Effect.catchTag("PlatformError", () => Effect.void), + Effect.catchTags({ + PlatformError: () => Effect.void, + StackStateInvalidError: () => Effect.void, + }), ); -/** Acquires the sole O_EXCL ownership proof. Existing locks are never stolen. */ +export const writeLockTemp = ( + fs: FileSystem.FileSystem, + path: Path.Path, + lockPath: string, + lock: OwnerLock, +): Effect.Effect => + Effect.gen(function* () { + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(OwnerLockSchema))(lock).pipe( + Effect.mapError((error) => stateError(`Unable to encode owner lock: ${String(error)}`)), + ); + const temporary = path.join(path.dirname(lockPath), `.owner.${lock.token}.tmp`); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs + .open(temporary, { flag: "wx", mode: 0o600 }) + .pipe( + Effect.mapError((error) => stateError(`Unable to open owner lock: ${error.message}`)), + ); + yield* file + .writeAll(new TextEncoder().encode(encoded)) + .pipe( + Effect.mapError((error) => stateError(`Unable to write owner lock: ${error.message}`)), + ); + yield* file.sync.pipe( + Effect.mapError((error) => stateError(`Unable to sync owner lock: ${error.message}`)), + ); + }), + ); + return temporary; + }).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? fs + .remove(path.join(path.dirname(lockPath), `.owner.${lock.token}.tmp`), { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)) + : Effect.void, + ), + ); + +export const installLease = (options: { + readonly fs: FileSystem.FileSystem; + readonly lockPath: string; + readonly temporary: string; + readonly observed: OwnerLock | undefined; +}): Effect.Effect => + Effect.gen(function* () { + if (options.observed !== undefined) { + yield* verifyObservedLease(options.fs, options.lockPath, options.observed); + yield* options.fs + .remove(options.lockPath, { force: false }) + .pipe( + Effect.mapError((error) => + Predicate.isTagged(error.reason, "NotFound") + ? new LeaseSlotTaken() + : stateError(`Unable to remove stale owner lock: ${error.message}`), + ), + ); + } + yield* options.fs + .link(options.temporary, options.lockPath) + .pipe( + Effect.mapError((error) => + Predicate.isTagged(error.reason, "AlreadyExists") + ? new LeaseSlotTaken() + : stateError(`Unable to publish owner lock: ${error.message}`), + ), + ); + }); + +/** Revalidates the exact stale record immediately before destructive recovery. */ +export const verifyObservedLease = ( + fs: FileSystem.FileSystem, + lockPath: string, + observed: OwnerLock, +): Effect.Effect => + readOwnerLock(fs, lockPath).pipe( + Effect.flatMap((current) => + current !== undefined && + current.format === observed.format && + current.token === observed.token && + current.port === observed.port + ? Effect.void + : Effect.fail(new LeaseSlotTaken()), + ), + ); + +/** + * Acquires an OS-held loopback lease. The canonical lock is atomically installed + * by hard-linking a uniquely created temporary record, and the listener remains bound for the + * lifetime of the lease. A valid lock is reclaimable only when its recorded + * port can be bound again. + */ export const acquireOwnership = (options: { readonly stateRoot: string; readonly stackId: StackId | string; @@ -278,36 +547,6 @@ export const acquireOwnership = (options: { endpoint: controlEndpointFor(validId, options.environment), rpcRelease: options.rpcRelease, }; - const release = Effect.suspend(() => { - return fs.readFileString(paths.controlMetadata).pipe( - Effect.flatMap((text) => - Schema.decodeEffect(Schema.fromJsonString(OwnerMetadataSchema))(text).pipe( - Effect.flatMap((current) => - current.stackId === validId && current.ownerSessionId === options.ownerSessionId - ? fs.remove(paths.controlMetadata, { force: true }).pipe( - Effect.catchTag("PlatformError", () => Effect.void), - Effect.andThen(removeLockIfOwned(fs, lockPath, options.ownerSessionId)), - ) - : Effect.void, - ), - ), - ), - // Missing metadata means acquisition failed before publication; the - // lock still belongs to this random session and may be removed. - Effect.catchTag("PlatformError", () => - removeLockIfOwned(fs, lockPath, options.ownerSessionId), - ), - // Malformed metadata is fail-closed: an old finalizer must not remove - // a lock that could now belong to another owner. - Effect.ignore, - ); - }); - const lease = { - metadata, - lockPath, - metadataPath: paths.controlMetadata, - release, - } satisfies OwnerLease; const scope = yield* Scope.Scope; return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -316,20 +555,119 @@ export const acquireOwnership = (options: { stateError(`Unable to create owner directory: ${error.message}`), ), ); - yield* restore( - fs.writeFileString(lockPath, options.ownerSessionId, { flag: "wx", mode: 0o600 }), - ).pipe( + + const existing = yield* readOwnerLock(fs, lockPath); + const metadataExists = yield* fs + .exists(paths.controlMetadata) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect stale owner metadata: ${error.message}`), + ), + ); + if (existing === undefined && metadataExists) + return yield* new StackOwnershipConflictError({ + message: "Owner metadata exists without a lease lock; refusing recovery", + stackId: validId, + }); + const held = yield* acquirePortLease(existing?.port ?? 0).pipe( Effect.mapError((error) => - Predicate.isTagged(error.reason, "AlreadyExists") + existing !== undefined && error["code"] === "EADDRINUSE" ? new StackOwnershipConflictError({ message: "A Supervisor already owns this stack", stackId: validId, }) - : stateError(`Unable to acquire owner lock: ${error.message}`), + : error, ), ); - yield* Scope.addFinalizer(scope, release); - return lease; + const acquire = Effect.gen(function* () { + const port = held.port; + const lock: OwnerLock = { + format: OWNER_LOCK_FORMAT, + token: options.ownerSessionId, + port, + }; + const temporary = yield* writeLockTemp(fs, path, lockPath, lock); + const installed = Effect.gen(function* () { + // Revalidate the exact observed stale record before deleting any + // metadata or socket. This closes the recovery crash window. + if (existing !== undefined) + yield* verifyObservedLease(fs, lockPath, existing).pipe( + Effect.mapError((error) => + Predicate.isTagged(error, "LeaseSlotTaken") + ? new StackOwnershipConflictError({ + message: "A Supervisor already owns this stack", + stackId: validId, + }) + : error, + ), + ); + // The old control document/socket can only be removed after the stale + // lease has been revalidated; the canonical lock remains until install. + if (existing !== undefined) { + yield* fs + .remove(paths.controlMetadata, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)); + if (options.environment.platform === "posix" && metadata.endpoint.kind === "unix") + yield* fs + .remove(metadata.endpoint.path, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)); + } + yield* installLease({ + fs, + lockPath, + temporary, + observed: existing, + }).pipe( + Effect.mapError((error) => + Predicate.isTagged(error, "LeaseSlotTaken") + ? new StackOwnershipConflictError({ + message: "A Supervisor already owns this stack", + stackId: validId, + }) + : error, + ), + ); + }); + yield* installed.pipe( + Effect.ensuring( + fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + ); + const release = Effect.suspend(() => + fs.readFileString(paths.controlMetadata).pipe( + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(OwnerMetadataSchema))(text).pipe( + Effect.flatMap((current) => + current.stackId === validId && current.ownerSessionId === options.ownerSessionId + ? fs.remove(paths.controlMetadata, { force: true }).pipe( + Effect.catchTag("PlatformError", () => Effect.void), + Effect.andThen(removeLeaseIfHeld(fs, lockPath, options.ownerSessionId)), + ) + : Effect.void, + ), + ), + ), + Effect.catchTag("PlatformError", () => + removeLeaseIfHeld(fs, lockPath, options.ownerSessionId), + ), + Effect.ignore, + Effect.andThen(held.close), + ), + ); + const lease = { + metadata, + lockPath, + metadataPath: paths.controlMetadata, + release, + } satisfies OwnerLease; + yield* Scope.addFinalizer(scope, Effect.uninterruptible(release)); + return lease; + }); + return yield* acquire.pipe( + Effect.onExit((exit) => (Exit.isFailure(exit) ? held.close : Effect.void)), + ); }), ); }); diff --git a/packages/stack/src/state/Paths.ts b/packages/stack/src/state/Paths.ts index 801a58750e..c50184019a 100644 --- a/packages/stack/src/state/Paths.ts +++ b/packages/stack/src/state/Paths.ts @@ -12,8 +12,6 @@ export interface StackPaths { readonly runtime: string; /** Control metadata owned by the identity's supervisor. */ readonly controlMetadata: string; - readonly temporaryDirectory: string; - readonly temporarySibling: string; } export interface ResolveStackPathsOptions { @@ -58,7 +56,5 @@ export const resolveStackPaths = ( logs: path.join(stackRoot, "logs"), runtime: path.join(stackRoot, "runtime"), controlMetadata: path.join(stackRoot, "control.json"), - temporaryDirectory: path.join(stackRoot, ".tmp"), - temporarySibling: path.join(stackRoot, "state.json.tmp"), }; }); diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index a3e8315198..0e99b5762d 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -8,9 +8,7 @@ import { PortAllocationError, PortUnavailableError, InvalidProjectRootError, - StackLifecycleConflictError, StackStateFormatUnsupportedError, - StackStateGenerationMismatchError, StackStateInvalidError, } from "../public/Errors.ts"; import type { @@ -49,10 +47,6 @@ type HostListenerBinding = | { readonly kind: "tcp"; readonly server: NetServer; readonly allowHalfOpen: true }; interface PortPlanOptions { - readonly lifecycle?: "stopped" | "running"; - readonly expectedGeneration?: number; - /** Generation to persist after this transaction; unchanged unless explicitly supplied. */ - readonly nextGeneration?: number; /** Requested durable workload endpoints. Every binding receives an automatic port. */ readonly privateBindings?: ReadonlyArray; } @@ -71,12 +65,6 @@ export interface PortCoordinatorOptions { port: number, field: PortField, ) => Effect.Effect; - /** Optional non-owning host probe used to skip occupied automatic ports. */ - readonly checkHostPort?: ( - address: string, - port: number, - field: PortField, - ) => Effect.Effect; } export interface PortCoordinator { @@ -88,11 +76,9 @@ export interface PortCoordinator { PortReservation, | PortAllocationError | PortUnavailableError - | StackLifecycleConflictError | StackStateInvalidError | StackStateFormatUnsupportedError - | InvalidProjectRootError - | StackStateGenerationMismatchError, + | InvalidProjectRootError, Scope.Scope | Crypto.Crypto | FileSystem.FileSystem | Path.Path >; } @@ -121,12 +107,6 @@ const automaticConflict = (port: number, field: PortField) => message: `Port ${port} for ${field} is reserved by another stack's automatic assignment`, }); -const lifecycleConflict = (field: string) => - new StackLifecycleConflictError({ - field, - message: `Cannot change the ${field} port assignment while the stack is running`, - }); - export const makePortCoordinator = ( registry: PortRegistry, store: StackStateStore, @@ -141,16 +121,7 @@ export const makePortCoordinator = ( return yield* new StackStateInvalidError({ message: "Cannot allocate ports for an unconfigured stack", }); - const expected = planOptions.expectedGeneration ?? current.desiredGeneration; - if (current.desiredGeneration !== expected) - return yield* new StackStateGenerationMismatchError({ - expectedGeneration: expected, - actualGeneration: current.desiredGeneration, - message: "Persisted stack state generation changed", - }); - const lifecycle = - planOptions.lifecycle ?? - (current.desiredLifecycle === "running" ? "running" : "stopped"); + const lifecycle = current.desiredLifecycle === "running" ? "running" : "stopped"; const allStates = yield* registry.states; const usedAutomaticPublic = new Set(); const usedLivePublic = new Set(); @@ -179,31 +150,6 @@ export const makePortCoordinator = ( current.privatePorts.map((entry) => [bindingKey(entry), entry]), ); - // A running generation may reject listener/private intent changes only after its - // assignment materialization has committed. The marker is explicit so an empty set of - // assignments is still fenced, while a failed first bind (which leaves the marker old) - // can be retried for the accepted generation. - if (lifecycle === "running" && current.portsGeneration === current.desiredGeneration) { - for (const field of fields) { - const intent = listenerIntents[field]; - const prior = existing.get(field); - const unchanged = - intent.enabled && prior !== undefined - ? intent.port === "automatic" - ? prior.intent === "automatic" - : prior.intent === "exact" && prior.port === intent.port - : !intent.enabled && prior === undefined; - if (!unchanged) return yield* lifecycleConflict(field); - } - const priorKeys = new Set(current.privatePorts.map(bindingKey)); - const nextKeys = new Set(privateIntents.map(bindingKey)); - if ( - priorKeys.size !== nextKeys.size || - [...priorKeys].some((key) => !nextKeys.has(key)) - ) - return yield* lifecycleConflict("private workload"); - } - const assignments: HostPortAssignment[] = []; const byField: Partial> = {}; const usedByThisStack = new Set(); @@ -225,10 +171,6 @@ export const makePortCoordinator = ( !usedReservedPrivate.has(port) && !usedByThisStack.has(port) ) { - if (options.checkHostPort !== undefined) { - const available = yield* options.checkHostPort(intent.address, port, field); - if (!available) continue; - } selected = port; break; } @@ -308,44 +250,40 @@ export const makePortCoordinator = ( const next: PersistedStackState = { ...current, - // Keep a running lifecycle only after ownership/publication succeeds. - desiredLifecycle: lifecycle === "running" ? current.desiredLifecycle : lifecycle, - desiredGeneration: planOptions.nextGeneration ?? current.desiredGeneration, - portsGeneration: - lifecycle === "running" - ? current.portsGeneration - : (planOptions.nextGeneration ?? current.desiredGeneration), + // Lifecycle is owned by Supervisor; port planning never mutates it. + desiredLifecycle: current.desiredLifecycle, ports: assignments, privatePorts: privateAssignments, }; - yield* store.replaceUnlocked(stackId, next, expected); + yield* store.replaceUnlocked(stackId, next); return { current, next, lifecycle, byField, privateAssignments }; }), ); const hostListeners: HostListener[] = []; - const runningState: PersistedStackState = { - ...committed.next, - desiredLifecycle: "running", - portsGeneration: committed.next.desiredGeneration, - }; - const commitRunning = registry.withLock( - Effect.gen(function* () { - const latest = yield* store.read(stackId); - if (latest === undefined || latest.desiredGeneration !== committed.next.desiredGeneration) - return yield* new StackStateGenerationMismatchError({ - expectedGeneration: committed.next.desiredGeneration, - actualGeneration: latest?.desiredGeneration, - message: "Persisted stack state changed while acquiring runtime ownership", - }); - yield* store.replaceUnlocked(stackId, runningState, latest.desiredGeneration); - }), - ); const enabledAssignments = fields.flatMap((field) => { const intent = listenerIntents[field]; const assignment = committed.byField[field]; return intent.enabled && assignment !== undefined ? [{ field, intent, assignment }] : []; }); + const rollbackFreshAutomatic = registry.withLock( + Effect.gen(function* () { + const latest = yield* store.read(stackId); + if (latest === undefined) + return yield* new StackStateInvalidError({ message: "Stack state disappeared" }); + const priorByField = new Map( + committed.current.ports.map((entry) => [entry.field, entry]), + ); + const ports = committed.next.ports.filter( + (entry) => entry.intent !== "automatic" || priorByField.has(entry.field), + ); + const priorBindings = new Set(committed.current.privatePorts.map(bindingKey)); + const privatePorts = committed.next.privatePorts.filter((entry) => + priorBindings.has(bindingKey(entry)), + ); + yield* store.replaceUnlocked(stackId, { ...latest, ports, privatePorts }); + }), + ); if (committed.lifecycle === "running") { const bindHost = options.bindHost; if (bindHost === undefined) { @@ -370,13 +308,9 @@ export const makePortCoordinator = ( ); if (Exit.isFailure(bound)) { yield* Scope.close(listenerScope, bound); + yield* rollbackFreshAutomatic; return yield* Effect.failCause(bound.cause); } - const committedRunning = yield* Effect.exit(restore(commitRunning)); - if (Exit.isFailure(committedRunning)) { - yield* Scope.close(listenerScope, committedRunning); - return yield* Effect.failCause(committedRunning.cause); - } return bound.value; }), ); diff --git a/packages/stack/src/state/PortRegistry.ts b/packages/stack/src/state/PortRegistry.ts index 16eb989d28..4887900492 100644 --- a/packages/stack/src/state/PortRegistry.ts +++ b/packages/stack/src/state/PortRegistry.ts @@ -4,11 +4,7 @@ import { StackStateFormatUnsupportedError, StackStateInvalidError, } from "../public/Errors.ts"; -import type { - HostPortAssignment, - PersistedStackState, - PrivatePortAssignment, -} from "./StackState.ts"; +import type { PersistedStackState } from "./StackState.ts"; import { withRegistryLock, type StackStateStore } from "./StackStateStore.ts"; type PortRegistryError = @@ -17,7 +13,6 @@ type PortRegistryError = | StackStateFormatUnsupportedError; export interface PortRegistry { - readonly stateRoot: string; /** Serializes one complete port transaction with other processes. */ readonly withLock: ( action: Effect.Effect, @@ -28,20 +23,6 @@ export interface PortRegistry { PortRegistryError, FileSystem.FileSystem | Path.Path | Crypto.Crypto >; - readonly assignments: ( - stackId: string, - ) => Effect.Effect< - ReadonlyArray, - PortRegistryError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto - >; - readonly privateAssignments: ( - stackId: string, - ) => Effect.Effect< - ReadonlyArray, - PortRegistryError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto - >; } const idPattern = /^[0-9a-f]{64}$/; @@ -51,7 +32,6 @@ export const makePortRegistry = (options: { readonly store: StackStateStore; }): Effect.Effect => Effect.succeed({ - stateRoot: options.stateRoot, withLock: (action: Effect.Effect) => withRegistryLock(options.stateRoot, action), states: Effect.gen(function* () { @@ -76,8 +56,4 @@ export const makePortRegistry = (options: { entry !== undefined, ); }), - assignments: (stackId) => - options.store.read(stackId).pipe(Effect.map((state) => state?.ports ?? [])), - privateAssignments: (stackId) => - options.store.read(stackId).pipe(Effect.map((state) => state?.privatePorts ?? [])), }); diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index b61409cf77..5541c42822 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -101,7 +101,7 @@ const encodeJwtHeader = (value: JwtHeader) => const encodeJwtPayload = (value: JwtPayload) => Schema.encodeEffect(Schema.fromJsonString(JwtPayloadSchema))(value); -const base64UrlEncode = (bytes: Uint8Array): string => { +export const base64UrlEncode = (bytes: Uint8Array): string => { let output = ""; for (let index = 0; index < bytes.length; index += 3) { const first = bytes[index] ?? 0; diff --git a/packages/stack/src/state/StackState.ts b/packages/stack/src/state/StackState.ts index aed779fd0d..d21073daed 100644 --- a/packages/stack/src/state/StackState.ts +++ b/packages/stack/src/state/StackState.ts @@ -303,15 +303,10 @@ const PrivatePortAssignmentsSchema = Schema.Array(PrivatePortAssignmentSchema).p }), ); -const StackGenerationSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); - const stateShape = Schema.Struct({ format: Schema.Literal(STACK_STATE_FORMAT), identity: PersistedStackIdentitySchema, runtime: StackRuntimeSchema, - desiredGeneration: StackGenerationSchema, - /** Generation for which the persisted public/private assignments are materialized. */ - portsGeneration: Schema.NullOr(StackGenerationSchema), desiredLifecycle: DesiredStackLifecycleSchema, definition: Schema.optional(StackDefinitionSchema), inputFingerprint: Schema.optional(Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/))), diff --git a/packages/stack/src/state/StackStateStore.ts b/packages/stack/src/state/StackStateStore.ts index 6467a769e5..6a5996364f 100644 --- a/packages/stack/src/state/StackStateStore.ts +++ b/packages/stack/src/state/StackStateStore.ts @@ -1,8 +1,7 @@ -import { Crypto, Effect, FileSystem, Path, Schedule, Schema } from "effect"; +import { Crypto, Data, Effect, Exit, FileSystem, Path, Predicate, Schedule, Schema } from "effect"; import { InvalidProjectRootError, StackStateFormatUnsupportedError, - StackStateGenerationMismatchError, StackStateInvalidError, } from "../public/Errors.ts"; import { resolveStackPaths, type StackPaths } from "./Paths.ts"; @@ -13,6 +12,17 @@ import { STACK_STATE_FORMAT, type PersistedStackState, } from "./StackState.ts"; +import { + acquirePortLease, + installLease, + readOwnerLock, + removeLeaseIfHeld, + writeLockTemp, + type OwnerLock, + OWNER_LOCK_FORMAT, +} from "./Ownership.ts"; + +class RegistryBusyError extends Data.TaggedError("RegistryBusyError")<{}> {} export interface StackStateStore { /** Reads without taking the registry lock. `undefined` means an unconfigured identity. */ @@ -23,15 +33,6 @@ export interface StackStateStore { InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto >; - /** Writes a complete state document. Intended for creation or explicitly fenced callers. */ - readonly write: ( - stackId: string, - state: PersistedStackState, - ) => Effect.Effect< - void, - InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto - >; /** Initializes one identity atomically, returning the winner's complete state. */ readonly initialize: ( stackId: string, @@ -41,30 +42,22 @@ export interface StackStateStore { InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto >; - /** Replaces one complete state value after checking the expected generation. */ + /** Replaces one complete state value while holding the registry lock. */ readonly replace: ( stackId: string, state: PersistedStackState, - expectedGeneration: number, ) => Effect.Effect< void, - | InvalidProjectRootError - | StackStateInvalidError - | StackStateFormatUnsupportedError - | StackStateGenerationMismatchError, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto >; /** Internal transaction primitive for callers that already hold the registry lock. */ readonly replaceUnlocked: ( stackId: string, state: PersistedStackState, - expectedGeneration: number, ) => Effect.Effect< void, - | InvalidProjectRootError - | StackStateInvalidError - | StackStateFormatUnsupportedError - | StackStateGenerationMismatchError, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto >; /** Explicit, exact-identity destructive cleanup. Ordinary lifecycle paths never call this. */ @@ -250,11 +243,7 @@ const persistValidatedState = ( yield* atomicWrite(fs, path, crypto, paths.stateDocument, paths.stackRoot, state); }); -/** - * Acquires one short cross-process lock. The lock is an O_EXCL file owned by this operation and is - * removed exactly on release. We intentionally do not steal stale locks because ownership cannot - * be proven safely after a process crash. - */ +/** Acquires one short cross-process lock using the same OS-held lease as Supervisor ownership. */ export const withRegistryLock = ( stateRoot: string, action: Effect.Effect, @@ -278,25 +267,60 @@ export const withRegistryLock = ( ), ); const lockPath = path.join(root, ".stack-registry.lock"); - // `writeFileString(..., { flag: "wx" })` is the platform's atomic O_EXCL - // primitive. The lock descriptor itself need not stay open: the exact - // lock file is the ownership token and is removed only by this operation. - // Avoid scoping the action here: callers may acquire native listeners in - // their Scope and those must outlive the short registry transaction. - yield* fs.writeFileString(lockPath, "", { flag: "wx", mode: 0o600 }).pipe( + const acquire = Effect.suspend(() => + Effect.gen(function* () { + const existing = yield* readOwnerLock(fs, lockPath); + const held = yield* acquirePortLease(existing?.port ?? 0).pipe( + Effect.mapError((error) => + existing !== undefined && error["code"] === "EADDRINUSE" + ? new RegistryBusyError() + : error, + ), + ); + const install = Effect.gen(function* () { + const token = yield* Effect.try({ + // Registry tokens are ephemeral identity labels; ownership is proven + // by the held loopback lease and atomic canonical link. + // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect + try: () => globalThis.crypto.randomUUID(), + catch: (cause) => + stateError(`Unable to allocate registry lock token: ${String(cause)}`), + }); + const lock: OwnerLock = { format: OWNER_LOCK_FORMAT, token, port: held.port }; + const temporary = yield* writeLockTemp(fs, path, lockPath, lock); + yield* installLease({ fs, lockPath, temporary, observed: existing }).pipe( + Effect.ensuring( + fs + .remove(temporary, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)), + ), + ); + return { held, token }; + }); + return yield* install.pipe( + Effect.onExit((exit) => (Exit.isFailure(exit) ? held.close : Effect.void)), + ); + }), + ).pipe( + Effect.mapError((error) => + Predicate.isTagged(error, "LeaseSlotTaken") ? new RegistryBusyError() : error, + ), Effect.retry({ - schedule: Schedule.exponential("1 millis").pipe(Schedule.upTo({ times: 20 })), + while: (error) => Predicate.isTagged(error, "RegistryBusyError"), + // Registry transactions include port leasing and atomic state writes; allow a few + // seconds for a concurrent owner to finish before failing closed. + schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 80 })), }), Effect.mapError((error) => - stateError(`Unable to acquire stack registry lock: ${error.message}`), + Predicate.isTagged(error, "RegistryBusyError") + ? stateError("Stack registry is busy") + : error, ), ); - return yield* action.pipe( - Effect.ensuring( - fs - .remove(lockPath, { force: true }) - .pipe(Effect.catchTag("PlatformError", () => Effect.void)), - ), + return yield* Effect.acquireUseRelease( + acquire, + () => action, + ({ held, token }) => removeLeaseIfHeld(fs, lockPath, token).pipe(Effect.andThen(held.close)), ); }); @@ -385,28 +409,6 @@ export const makeStackStateStore = (options: { return decoded; }); - const write = ( - stackId: string, - next: PersistedStackState, - ): Effect.Effect< - void, - InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto - > => - withRegistryLock( - options.stateRoot, - Effect.gen(function* () { - const paths = yield* pathsFor(stackId); - yield* validateState(stackId, next); - const existing = yield* read(stackId); - if (existing !== undefined) - return yield* stateError( - "Stack state already exists; use a generation-fenced replacement", - ); - yield* persistValidatedState(fs, path, crypto, paths, next); - }), - ); - const initialize = ( stackId: string, candidate: PersistedStackState, @@ -430,26 +432,16 @@ export const makeStackStateStore = (options: { const replaceUnlocked = ( stackId: string, next: PersistedStackState, - expectedGeneration: number, ): Effect.Effect< void, - | InvalidProjectRootError - | StackStateInvalidError - | StackStateFormatUnsupportedError - | StackStateGenerationMismatchError, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto > => Effect.gen(function* () { const paths = yield* pathsFor(stackId); yield* validateIdentityForStackId(next.identity, stackId); const current = yield* read(stackId); - if (current === undefined || current.desiredGeneration !== expectedGeneration) { - return yield* new StackStateGenerationMismatchError({ - expectedGeneration, - actualGeneration: current?.desiredGeneration, - message: "Persisted stack state generation changed", - }); - } + if (current === undefined) return yield* stateError("Cannot replace missing stack state"); yield* validateStateSchema(next); yield* persistValidatedState(fs, path, crypto, paths, next); }); @@ -457,15 +449,11 @@ export const makeStackStateStore = (options: { const replace = ( stackId: string, next: PersistedStackState, - expectedGeneration: number, ): Effect.Effect< void, - | InvalidProjectRootError - | StackStateInvalidError - | StackStateFormatUnsupportedError - | StackStateGenerationMismatchError, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, FileSystem.FileSystem | Path.Path | Crypto.Crypto - > => withRegistryLock(options.stateRoot, replaceUnlocked(stackId, next, expectedGeneration)); + > => withRegistryLock(options.stateRoot, replaceUnlocked(stackId, next)); const cleanup = ( stackId: string, @@ -488,7 +476,7 @@ export const makeStackStateStore = (options: { }), ); - return { read, write, initialize, replace, replaceUnlocked, cleanup } satisfies StackStateStore; + return { read, initialize, replace, replaceUnlocked, cleanup } satisfies StackStateStore; }); export { PersistedStackStateSchema } from "./StackState.ts"; diff --git a/packages/stack/src/state/ownership.integration.test.ts b/packages/stack/src/state/ownership.integration.test.ts index 07284214ec..b83a4829f9 100644 --- a/packages/stack/src/state/ownership.integration.test.ts +++ b/packages/stack/src/state/ownership.integration.test.ts @@ -1,17 +1,72 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Option, + Path, + Ref, + Schema, + Stream, +} from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import { createServer, type Server } from "node:net"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { StackOwnershipConflictError, StackStateInvalidError } from "../public/Errors.ts"; import { + acquirePortLease, acquireOwnership, controlEndpointFor, + installLease, + LeaseSlotTaken, ownerLockExists, publishOwnership, readOwnerMetadata, + readOwnerLock, + writeLockTemp, + OWNER_LOCK_FORMAT, type StackRuntimeEnvironmentValue, } from "./Ownership.ts"; import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; +import { resolveStackPaths } from "./Paths.ts"; + +const bindEphemeral = Effect.callback((resume) => { + const server = createServer((socket) => socket.destroy()); + const onError = (error: Error) => { + server.off("listening", onListening); + resume(Effect.fail(error)); + }; + const onListening = () => { + server.off("error", onError); + resume(Effect.succeed(server)); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen({ host: "127.0.0.1", port: 0 }); + return Effect.sync(() => { + server.off("error", onError); + server.off("listening", onListening); + if (server.listening) server.close(() => undefined); + }); +}); + +const closeServer = (server: Server) => + Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return; + } + server.close((error) => + error === undefined ? resume(Effect.void) : resume(Effect.fail(error)), + ); + }); + +const jsonText = (value: unknown) => + Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(value); const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -30,8 +85,6 @@ const stateFor = (stackId: string): PersistedStackState => ({ format: "supabase-stack-state-v1", identity: { ...identity, stackId }, runtime: { kind: "native" }, - desiredGeneration: 0, - portsGeneration: null, desiredLifecycle: "unconfigured", ports: [], privatePorts: [], @@ -42,6 +95,22 @@ const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("stack ownership", () => { + it.live("releases an exact port after a competing lease bind fails", () => + withPlatform( + Effect.gen(function* () { + const blocker = yield* bindEphemeral; + const address = blocker.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Blocker did not expose a bound port"); + const failed = yield* acquirePortLease(address.port).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + yield* closeServer(blocker); + const lease = yield* acquirePortLease(address.port); + yield* lease.close; + }), + ), + ); + it.live("initializes one complete state for concurrent equivalent creators", () => withPlatform( Effect.gen(function* () { @@ -95,6 +164,138 @@ describe("stack ownership", () => { yield* lease.release; expect(yield* ownerLockExists(root, stackId)).toBe(false); expect(yield* readOwnerMetadata(root, stackId, environment)).toBeUndefined(); + const after = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "session-three", + rpcRelease: "stack-rpc-v1@0.1.0", + environment, + }); + yield* after.release; + }), + ), + ); + + it.live("recovers an owner lease after an exact supervisor SIGKILL", () => + withPlatform( + Effect.gen(function* () { + if (process.platform === "win32") return yield* Effect.void; + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-crash-" }); + const childIdentity = { + ...identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + localProjectKey: ".", + }; + const stackId = yield* deriveStackId(childIdentity); + const environment: StackRuntimeEnvironmentValue = { + stateRoot: root, + tempRoot: root, + platform: "posix", + }; + const moduleUrl = new URL("./Ownership.ts", import.meta.url).href; + const script = ` + const { Effect } = await import("effect"); + const { NodeServices } = await import("@effect/platform-node"); + const { acquireOwnership, publishOwnership } = await import(process.env.OWNERSHIP_MODULE); + const environment = JSON.parse(process.env.OWNERSHIP_ENVIRONMENT); + const lease = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { + const value = yield* acquireOwnership({ + stateRoot: environment.stateRoot, + stackId: process.env.OWNERSHIP_STACK_ID, + ownerSessionId: "crashed-owner", + rpcRelease: "stack-rpc-v1@0.1.0", + environment, + }); + yield* publishOwnership(value); + process.stdout.write("READY\\n"); + yield* Effect.never; + }).pipe(Effect.provide(NodeServices.layer)))); + void lease; + `; + const child = yield* ChildProcess.make( + process.execPath, + ["--input-type=module", "-e", script], + { + cwd: process.cwd(), + env: { + OWNERSHIP_MODULE: moduleUrl, + OWNERSHIP_STACK_ID: stackId, + OWNERSHIP_ENVIRONMENT: jsonText(environment), + }, + extendEnv: true, + stdout: "pipe", + stderr: "pipe", + }, + ); + const ready = yield* Deferred.make(); + const stderrText = yield* Ref.make>([]); + const childExitCode = yield* Ref.make>(Option.none()); + const output = yield* child.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => + line === "READY" ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) : Effect.void, + ), + Effect.forkChild({ startImmediately: true }), + ); + const stderr = yield* child.stderr.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => Ref.update(stderrText, (lines) => [...lines, line])), + Effect.forkChild({ startImmediately: true }), + ); + const exitWatcher = yield* child.exitCode.pipe( + Effect.flatMap((code) => Ref.set(childExitCode, Option.some(code))), + Effect.forkChild({ startImmediately: true }), + ); + try { + yield* Deferred.await(ready).pipe( + Effect.timeoutOrElse({ + duration: "15 seconds", + orElse: () => + Effect.gen(function* () { + const lines = yield* Ref.get(stderrText); + const exitCode = yield* Ref.get(childExitCode); + const state = Option.isSome(exitCode) + ? ` (exit code ${String(exitCode.value)})` + : ""; + return yield* new StackStateInvalidError({ + message: `owner child did not become ready${state}${lines.length === 0 ? "" : `: ${lines.join("\\n")}`}`, + }); + }), + }), + ); + expect(yield* ownerLockExists(root, stackId)).toBe(true); + const metadata = yield* readOwnerMetadata(root, stackId, environment); + expect(metadata?.ownerSessionId).toBe("crashed-owner"); + yield* child.kill({ killSignal: "SIGKILL" }); + yield* child.exitCode.pipe(Effect.ignore); + const successor = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "successor-owner", + rpcRelease: "stack-rpc-v1@0.1.0", + environment, + }); + expect(successor.metadata.endpoint).toEqual(controlEndpointFor(stackId, environment)); + expect(yield* ownerLockExists(root, stackId)).toBe(true); + yield* publishOwnership(successor); + expect((yield* readOwnerMetadata(root, stackId, environment))?.ownerSessionId).toBe( + "successor-owner", + ); + yield* successor.release; + expect(yield* ownerLockExists(root, stackId)).toBe(false); + expect(yield* readOwnerMetadata(root, stackId, environment)).toBeUndefined(); + } finally { + yield* child.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore); + yield* Fiber.interrupt(output); + yield* Fiber.interrupt(stderr); + yield* Fiber.interrupt(exitWatcher); + } }), ), ); @@ -129,4 +330,199 @@ describe("stack ownership", () => { }), ), ); + + it.live("reclaims a valid owner lock after its lease is released", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-reclaim-" }); + const stackId = yield* deriveStackId(identity); + const environment: StackRuntimeEnvironmentValue = { + stateRoot: root, + tempRoot: root, + platform: "posix", + }; + const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); + yield* fs.makeDirectory(paths.runtime, { recursive: true }); + const stale = yield* bindEphemeral; + const address = stale.address(); + if (address === null || typeof address === "string") + return yield* new StackStateInvalidError({ message: "missing lease port" }); + const staleEndpoint = controlEndpointFor(stackId, environment); + if (staleEndpoint.kind !== "unix") + return yield* new StackStateInvalidError({ message: "expected unix endpoint" }); + yield* fs.writeFileString( + path.join(paths.runtime, "owner.lock"), + jsonText({ + format: "supabase-stack-lease-v1", + token: "old-session", + port: address.port, + }), + ); + yield* fs.writeFileString( + paths.controlMetadata, + jsonText({ + format: "supabase-stack-owner-v1", + stackId, + ownerSessionId: "old-session", + endpoint: staleEndpoint, + rpcRelease: "stack-rpc-v1@0.1.0", + }), + ); + yield* fs.writeFileString(staleEndpoint.path, "stale socket"); + yield* closeServer(stale); + const lease = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "new-session", + rpcRelease: "stack-rpc-v1@0.1.0", + environment, + }); + expect(lease.metadata.ownerSessionId).toBe("new-session"); + expect(yield* fs.exists(paths.controlMetadata)).toBe(false); + expect(yield* fs.exists(staleEndpoint.path)).toBe(false); + yield* lease.release; + }), + ), + ); + + it.live("serializes concurrent recovery contenders", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-race-" }); + const stackId = yield* deriveStackId(identity); + const environment: StackRuntimeEnvironmentValue = { + stateRoot: root, + tempRoot: root, + platform: "posix", + }; + const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); + yield* fs.makeDirectory(paths.runtime, { recursive: true }); + const stale = yield* bindEphemeral; + const address = stale.address(); + if (address === null || typeof address === "string") + return yield* new StackStateInvalidError({ message: "missing lease port" }); + yield* fs.writeFileString( + path.join(paths.runtime, "owner.lock"), + jsonText({ format: "supabase-stack-lease-v1", token: "old", port: address.port }), + ); + yield* closeServer(stale); + const attempts = yield* Effect.all( + ["one", "two"].map((ownerSessionId) => + acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId, + rpcRelease: "rpc", + environment, + }).pipe(Effect.exit), + ), + { concurrency: 2 }, + ); + const successes = attempts.filter(Exit.isSuccess); + expect(successes).toHaveLength(1); + expect( + attempts + .filter(Exit.isFailure) + .map(errorOf) + .some((error) => error instanceof StackOwnershipConflictError), + ).toBe(true); + if (successes[0] !== undefined) yield* successes[0].value.release; + }), + ), + ); + + it.live("fails closed for a malformed owner lock", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-malformed-" }); + const stackId = yield* deriveStackId(identity); + const environment: StackRuntimeEnvironmentValue = { + stateRoot: root, + tempRoot: root, + platform: "posix", + }; + const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); + yield* fs.makeDirectory(paths.runtime, { recursive: true }); + yield* fs.writeFileString(path.join(paths.runtime, "owner.lock"), "not-json"); + const result = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "new", + rpcRelease: "rpc", + environment, + }).pipe(Effect.exit); + expect(errorOf(result)).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); + + it.live("fails closed when metadata exists without an owner lock", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-metadata-only-" }); + const stackId = yield* deriveStackId(identity); + const environment: StackRuntimeEnvironmentValue = { + stateRoot: root, + tempRoot: root, + platform: "posix", + }; + const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); + yield* fs.makeDirectory(paths.runtime, { recursive: true }); + const metadata = { + format: "supabase-stack-owner-v1", + stackId, + ownerSessionId: "orphaned-owner", + endpoint: controlEndpointFor(stackId, environment), + rpcRelease: "rpc", + }; + yield* fs.writeFileString(paths.controlMetadata, jsonText(metadata)); + const result = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "new-owner", + rpcRelease: "rpc", + environment, + }).pipe(Effect.exit); + expect(errorOf(result)).toBeInstanceOf(StackOwnershipConflictError); + expect(yield* fs.readFileString(paths.controlMetadata)).toBe(jsonText(metadata)); + expect(yield* fs.exists(paths.runtime + "/owner.lock")).toBe(false); + }), + ), + ); + + it.live("does not replace a fresh lock installed after stale observation", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-race-install-" }); + const stackId = yield* deriveStackId(identity); + const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); + yield* fs.makeDirectory(paths.runtime, { recursive: true }); + const old = { format: OWNER_LOCK_FORMAT, token: "old", port: 45_001 } as const; + const fresh = { format: OWNER_LOCK_FORMAT, token: "fresh", port: 45_002 } as const; + const lockPath = path.join(paths.runtime, "owner.lock"); + yield* fs.writeFileString(lockPath, jsonText(old)); + const temporary = yield* writeLockTemp(fs, path, lockPath, { + format: OWNER_LOCK_FORMAT, + token: "candidate", + port: 45_003, + }); + yield* fs.writeFileString(lockPath, jsonText(fresh)); + const result = yield* installLease({ fs, lockPath, temporary, observed: old }).pipe( + Effect.exit, + ); + expect(errorOf(result)).toBeInstanceOf(LeaseSlotTaken); + expect(yield* readOwnerLock(fs, lockPath)).toEqual(fresh); + yield* fs.remove(temporary, { force: true }); + }), + ), + ); }); diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 772cba8bdb..e6a292b7dc 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -1,16 +1,11 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Option, Scope } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Scope } from "effect"; import { createServer, type Server } from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer as createHttpServer } from "node:http"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; -import { - PortAllocationError, - PortUnavailableError, - StackStateGenerationMismatchError, - StackLifecycleConflictError, -} from "../public/Errors.ts"; +import { PortAllocationError, PortUnavailableError } from "../public/Errors.ts"; import { makePortRegistry } from "./PortRegistry.ts"; import { makePortCoordinator, type HostListener, type ListenerIntents } from "./PortCoordinator.ts"; import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; @@ -48,8 +43,6 @@ const baseState = ( stackId, }, runtime: { kind: "native" }, - desiredGeneration: 0, - portsGeneration: null, desiredLifecycle, ports: [], privatePorts: [], @@ -158,8 +151,8 @@ describe("sticky port coordination", () => { const bIdentity = makeIdentity(root, "b"); const a = yield* deriveStackId(aIdentity); const b = yield* deriveStackId(bIdentity); - yield* store.write(a, baseState(a, aIdentity)); - yield* store.write(b, baseState(b, bIdentity)); + yield* store.initialize(a, baseState(a, aIdentity)); + yield* store.initialize(b, baseState(b, bIdentity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store); const first = yield* coordinator.planAndReserve(a, intents("automatic")); @@ -183,8 +176,8 @@ describe("sticky port coordination", () => { const bIdentity = makeIdentity(root, "exact-requester"); const a = yield* deriveStackId(aIdentity); const b = yield* deriveStackId(bIdentity); - yield* store.write(a, baseState(a, aIdentity)); - yield* store.write(b, baseState(b, bIdentity)); + yield* store.initialize(a, baseState(a, aIdentity)); + yield* store.initialize(b, baseState(b, bIdentity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store); const automatic = yield* coordinator.planAndReserve(a, intents("automatic")); @@ -192,22 +185,18 @@ describe("sticky port coordination", () => { expect(occupied).toBeTypeOf("number"); if (occupied === undefined) return; const exit = yield* coordinator - .planAndReserve( - b, - { - ...disabledIntents(), - api: { enabled: true, address: "127.0.0.1", port: occupied }, - }, - { lifecycle: "stopped" }, - ) + .planAndReserve(b, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: occupied }, + }) .pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortAllocationError); - expect(yield* registry.assignments(b)).toEqual([]); + expect((yield* store.read(b))?.ports).toEqual([]); }), ), ); - it.live("rejects assignment changes while a stack is running", () => + it.live("applies listener changes already accepted by the lifecycle", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -217,52 +206,36 @@ describe("sticky port coordination", () => { const automaticIdentity = makeIdentity(root, "running-automatic"); const exactId = yield* deriveStackId(exactIdentity); const automaticId = yield* deriveStackId(automaticIdentity); - yield* store.write(exactId, { + yield* store.initialize(exactId, { ...baseState(exactId, exactIdentity, "running"), - desiredGeneration: 7, - portsGeneration: 7, ports: [{ field: "api", port: 55435, intent: "exact" }], privatePorts: [], }); - yield* store.write(automaticId, { + yield* store.initialize(automaticId, { ...baseState(automaticId, automaticIdentity, "running"), - desiredGeneration: 9, - portsGeneration: 9, ports: [{ field: "api", port: 55436, intent: "automatic" }], privatePorts: [], }); const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store); - const exactToDifferent = yield* coordinator - .planAndReserve( - exactId, - { - ...disabledIntents(), - api: { enabled: true, address: "127.0.0.1", port: 55437 }, - }, - { lifecycle: "running" }, - ) - .pipe(Effect.exit); - expect(errorOf(exactToDifferent)).toBeInstanceOf(StackLifecycleConflictError); - expect((yield* store.read(exactId))?.desiredGeneration).toBe(7); + const coordinator = makePortCoordinator(registry, store, { + bindHost: makeTestHostListener, + }); + const exactToDifferent = yield* coordinator.planAndReserve(exactId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55437 }, + }); + expect(exactToDifferent.assignments.api?.port).toBe(55437); - const automaticToExact = yield* coordinator - .planAndReserve( - automaticId, - { - ...disabledIntents(), - api: { enabled: true, address: "127.0.0.1", port: 55438 }, - }, - { lifecycle: "running" }, - ) - .pipe(Effect.exit); - expect(errorOf(automaticToExact)).toBeInstanceOf(StackLifecycleConflictError); - expect((yield* store.read(automaticId))?.desiredGeneration).toBe(9); + const automaticToExact = yield* coordinator.planAndReserve(automaticId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55438 }, + }); + expect(automaticToExact.assignments.api?.port).toBe(55438); }), ), ); - it.live("keeps an unchanged running assignment without advancing generation", () => + it.live("keeps an unchanged running assignment", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -272,10 +245,8 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const identity = makeIdentity(root, "running-repeat"); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, { + yield* store.initialize(stackId, { ...baseState(stackId, identity, "running"), - desiredGeneration: 11, - portsGeneration: 11, ports: [{ field: "api", port: 55439, intent: "exact" }], privatePorts: [], }); @@ -283,21 +254,16 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator(registry, store, { bindHost: makeTestHostListener, }); - const result = yield* coordinator.planAndReserve( - stackId, - { - ...disabledIntents(), - api: { enabled: true, address: "127.0.0.1", port: 55439 }, - }, - { lifecycle: "running" }, - ); + const result = yield* coordinator.planAndReserve(stackId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55439 }, + }); expect(result.assignments.api?.port).toBe(55439); - expect((yield* store.read(stackId))?.desiredGeneration).toBe(11); }), ), ); - it.live("materializes ports for a newly accepted running generation", () => + it.live("materializes ports for a newly accepted running stack", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -307,10 +273,12 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const identity = makeIdentity(root, "running-initial"); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, { + yield* store.initialize(stackId, { ...baseState(stackId, identity, "running"), - desiredGeneration: 5, - ports: [], + ports: [ + { field: "api", port: 55440, intent: "exact" }, + { field: "database", port: 55441, intent: "automatic" }, + ], privatePorts: [], }); const registry = yield* makePortRegistry({ stateRoot: root, store }); @@ -322,21 +290,17 @@ describe("sticky port coordination", () => { { ...disabledIntents(), api: { enabled: true, address: "127.0.0.1", port: 55440 }, + database: { enabled: true, address: "127.0.0.1", port: "automatic" }, }, - { - lifecycle: "running", - expectedGeneration: 5, - nextGeneration: 5, - }, + {}, ); expect(result.assignments.api?.port).toBe(55440); expect((yield* store.read(stackId))?.desiredLifecycle).toBe("running"); - expect((yield* store.read(stackId))?.portsGeneration).toBe(5); }), ), ); - it.live("fences a newly accepted running generation with no listeners", () => + it.live("materializes a newly accepted running stack with no listeners", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -346,38 +310,15 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const identity = makeIdentity(root, "running-empty"); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, { + yield* store.initialize(stackId, { ...baseState(stackId, identity, "running"), - desiredGeneration: 6, }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { bindHost: makeTestHostListener, }); - const result = yield* coordinator.planAndReserve(stackId, disabledIntents(), { - lifecycle: "running", - expectedGeneration: 6, - nextGeneration: 6, - }); + const result = yield* coordinator.planAndReserve(stackId, disabledIntents(), {}); expect(result.hostListeners).toHaveLength(0); - expect((yield* store.read(stackId))?.portsGeneration).toBe(6); - const changed = yield* coordinator - .planAndReserve( - stackId, - { ...disabledIntents(), api: { enabled: true, address: "127.0.0.1", port: 55441 } }, - { lifecycle: "running", expectedGeneration: 6, nextGeneration: 6 }, - ) - .pipe(Effect.exit); - expect(errorOf(changed)).toBeInstanceOf(StackLifecycleConflictError); - const privateChanged = yield* coordinator - .planAndReserve(stackId, disabledIntents(), { - lifecycle: "running", - expectedGeneration: 6, - nextGeneration: 6, - privateBindings: [{ workloadId: "database:database", binding: "primary" }], - }) - .pipe(Effect.exit); - expect(errorOf(privateChanged)).toBeInstanceOf(StackLifecycleConflictError); }), ), ); @@ -392,18 +333,19 @@ describe("sticky port coordination", () => { const dIdentity = makeIdentity(root, "d"); const c = yield* deriveStackId(cIdentity); const d = yield* deriveStackId(dIdentity); - yield* store.write(c, { + yield* store.initialize(c, { ...baseState(c, cIdentity, "running"), ports: [{ field: "api", port: 55432, intent: "exact" }], privatePorts: [], }); - yield* store.write(d, baseState(d, dIdentity)); + yield* store.initialize(d, baseState(d, dIdentity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store); - yield* coordinator.planAndReserve(d, intents(55432), { lifecycle: "stopped" }); - const conflict = yield* coordinator - .planAndReserve(d, intents(55432), { lifecycle: "running" }) - .pipe(Effect.exit); + yield* coordinator.planAndReserve(d, intents(55432)); + const stopped = yield* store.read(d); + if (stopped === undefined) return; + yield* store.replace(d, { ...stopped, desiredLifecycle: "running" }); + const conflict = yield* coordinator.planAndReserve(d, intents(55432)).pipe(Effect.exit); expect(errorOf(conflict)).toBeInstanceOf(PortUnavailableError); }), ), @@ -419,19 +361,15 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const identity = makeIdentity(root, "duplicate"); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, baseState(stackId, identity)); + yield* store.initialize(stackId, baseState(stackId, identity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store); const exit = yield* coordinator - .planAndReserve( - stackId, - { - ...disabledIntents(), - api: { enabled: true, address: "127.0.0.1", port: 55434 }, - database: { enabled: true, address: "127.0.0.1", port: 55434 }, - }, - { lifecycle: "stopped" }, - ) + .planAndReserve(stackId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 55434 }, + database: { enabled: true, address: "127.0.0.1", port: 55434 }, + }) .pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); }), @@ -447,29 +385,30 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const identity = makeIdentity(root, "host-conflict"); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, baseState(stackId, identity)); + yield* store.initialize(stackId, baseState(stackId, identity)); const host = yield* holdHostPort(0); const hostAddress = host.address(); expect(hostAddress).toBeTypeOf("object"); if (typeof hostAddress !== "object" || hostAddress === null) return; const occupiedPort = hostAddress.port; + const running = { + ...baseState(stackId, identity, "running"), + ports: [{ field: "api", port: occupiedPort, intent: "exact" as const }] as const, + }; + yield* store.replace(stackId, running); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { bindHost: bindHostListener, }); const exit = yield* coordinator - .planAndReserve( - stackId, - { - ...disabledIntents(), - api: { enabled: true, address: "127.0.0.1", port: occupiedPort }, - }, - { lifecycle: "running" }, - ) + .planAndReserve(stackId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: occupiedPort }, + }) .pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); expect( - (yield* registry.assignments(stackId)).some( + ((yield* store.read(stackId))?.ports ?? []).some( (assignment) => assignment.port === occupiedPort, ), ).toBe(true); @@ -488,7 +427,13 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const eIdentity = makeIdentity(root, "e"); const e = yield* deriveStackId(eIdentity); - yield* store.write(e, baseState(e, eIdentity)); + yield* store.initialize(e, { + ...baseState(e, eIdentity, "running"), + ports: [ + { field: "api", port: 55450, intent: "automatic" }, + { field: "database", port: 55451, intent: "automatic" }, + ], + }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { bindHost: (address, port, field) => @@ -501,9 +446,7 @@ describe("sticky port coordination", () => { }, }).pipe(Effect.tap(() => Effect.sync(() => bound.add(port)))), }); - const result = yield* coordinator.planAndReserve(e, intents("automatic"), { - lifecycle: "running", - }); + const result = yield* coordinator.planAndReserve(e, intents("automatic"), {}); expect(result.hostListeners).toHaveLength(2); expect(closed).toBe(0); expect(bound.size).toBe(2); @@ -524,7 +467,13 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const identity = makeIdentity(root, "native-failure"); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, baseState(stackId, identity)); + yield* store.initialize(stackId, { + ...baseState(stackId, identity, "running"), + ports: [ + { field: "api", port: 55452, intent: "automatic" }, + { field: "database", port: 55453, intent: "automatic" }, + ], + }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const bound = new Set(); let attempts = 0; @@ -541,77 +490,12 @@ describe("sticky port coordination", () => { }, }); const exit = yield* coordinator - .planAndReserve(stackId, intents("automatic"), { - lifecycle: "running", - }) + .planAndReserve(stackId, intents("automatic"), {}) .pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); expect(bound).toHaveLength(0); - expect((yield* store.read(stackId))?.portsGeneration).toBeNull(); - const retry = yield* coordinator.planAndReserve(stackId, intents("automatic"), { - lifecycle: "running", - }); + const retry = yield* coordinator.planAndReserve(stackId, intents("automatic"), {}); expect(retry.hostListeners).toHaveLength(2); - expect((yield* store.read(stackId))?.portsGeneration).toBe(0); - }), - ), - ); - - it.live("closes native listeners when the final generation fence fails", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-stack-port-native-fence-", - }); - const store = yield* makeStackStateStore({ stateRoot: root }); - const identity = makeIdentity(root, "native-fence"); - const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, baseState(stackId, identity)); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const entered = Deferred.makeUnsafe(); - const release = Deferred.makeUnsafe(); - const bound = new Set(); - const closed = new Set(); - const coordinator = makePortCoordinator(registry, store, { - bindHost: (address, port, field) => - makeTestHostListener(address, port, field, { - onClose: () => { - bound.delete(port); - closed.add(port); - }, - }).pipe( - Effect.tap(() => - Effect.sync(() => { - bound.add(port); - }), - ), - Effect.tap(() => Deferred.succeed(entered, undefined)), - Effect.tap(() => Deferred.await(release)), - ), - }); - const reservation = yield* Effect.forkChild( - coordinator.planAndReserve( - stackId, - { - ...disabledIntents(), - api: { enabled: true, address: "127.0.0.1", port: 55443 }, - }, - { lifecycle: "running" }, - ), - { startImmediately: true }, - ); - yield* Deferred.await(entered); - const current = yield* store.read(stackId); - if (current === undefined) return; - expect(current.portsGeneration).toBeNull(); - yield* store.replace(stackId, { ...current, desiredGeneration: 1 }, 0); - yield* Deferred.succeed(release, undefined); - const result = yield* Fiber.join(reservation).pipe(Effect.exit); - expect(errorOf(result)).toBeInstanceOf(StackStateGenerationMismatchError); - expect(bound).toHaveLength(0); - expect(closed).toHaveLength(1); - expect((yield* store.read(stackId))?.portsGeneration).toBeNull(); }), ), ); @@ -624,19 +508,21 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const fIdentity = makeIdentity(root, "f"); const f = yield* deriveStackId(fIdentity); - yield* store.write(f, baseState(f, fIdentity)); + yield* store.initialize(f, { + ...baseState(f, fIdentity, "running"), + ports: [ + { field: "api", port: 55433, intent: "exact" }, + { field: "database", port: 55434, intent: "automatic" }, + ], + }); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store, { bindHost: (address, port, field) => Effect.fail(new PortUnavailableError({ port, field, message: `race at ${address}` })), }); - const exit = yield* coordinator - .planAndReserve(f, intents(55433), { - lifecycle: "running", - }) - .pipe(Effect.exit); + const exit = yield* coordinator.planAndReserve(f, intents(55433), {}).pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(PortUnavailableError); - const reserved = yield* registry.assignments(f); + const reserved = (yield* store.read(f))?.ports ?? []; expect(reserved.some((assignment) => assignment.port === 55433)).toBe(true); }), ), @@ -650,7 +536,7 @@ describe("sticky port coordination", () => { const store = yield* makeStackStateStore({ stateRoot: root }); const identity = makeIdentity(root, "private"); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, baseState(stackId, identity)); + yield* store.initialize(stackId, baseState(stackId, identity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store); const bindings = [ @@ -660,7 +546,6 @@ describe("sticky port coordination", () => { { workloadId: "database:database", binding: "primary" }, ]; const first = yield* coordinator.planAndReserve(stackId, disabledIntents(), { - lifecycle: "stopped", privateBindings: bindings, }); expect(first.privateAssignments).toHaveLength(bindings.length); @@ -668,9 +553,7 @@ describe("sticky port coordination", () => { bindings.length, ); expect(first.privateAssignments.every((entry) => entry.port >= 30_000)).toBe(true); - expect((yield* store.read(stackId))?.portsGeneration).toBe(0); const stopped = yield* coordinator.planAndReserve(stackId, disabledIntents(), { - lifecycle: "stopped", privateBindings: bindings, }); expect(stopped.privateAssignments).toEqual(first.privateAssignments); @@ -690,8 +573,8 @@ describe("sticky port coordination", () => { const bIdentity = makeIdentity(root, "private-b"); const a = yield* deriveStackId(aIdentity); const b = yield* deriveStackId(bIdentity); - yield* store.write(a, baseState(a, aIdentity)); - yield* store.write(b, baseState(b, bIdentity)); + yield* store.initialize(a, baseState(a, aIdentity)); + yield* store.initialize(b, baseState(b, bIdentity)); const registry = yield* makePortRegistry({ stateRoot: root, store }); const coordinator = makePortCoordinator(registry, store); const first = yield* coordinator.planAndReserve(a, disabledIntents(), { @@ -702,7 +585,7 @@ describe("sticky port coordination", () => { }); expect(second.privateAssignments[0]?.port).not.toBe(first.privateAssignments[0]?.port); const overlapping = yield* store - .write(a, { + .replace(a, { ...baseState(a, aIdentity), ports: [{ field: "api", port: 30_000, intent: "exact" }], privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_000 }], diff --git a/packages/stack/src/state/secrets.integration.test.ts b/packages/stack/src/state/secrets.integration.test.ts index 5dddbd7199..ed0615720d 100644 --- a/packages/stack/src/state/secrets.integration.test.ts +++ b/packages/stack/src/state/secrets.integration.test.ts @@ -59,10 +59,18 @@ describe("managed and pass-through secrets", () => { "secret:auth.settings.anon_key", "secret:auth.settings.service_role_key", "secret:storage.settings.s3_protocol.secret_access_key", + "secret:realtime.settings.db_enc_key", + "secret:realtime.settings.secret_key_base", ]) { expect(first.persisted[slot]?.policy).toBe("managed"); expect(first.persisted[slot]?.value).toBe(second.persisted[slot]?.value); } + expect(first.persisted["secret:realtime.settings.db_enc_key"]?.value).toMatch( + /^[A-Za-z0-9_-]{16}$/, + ); + expect(first.persisted["secret:realtime.settings.secret_key_base"]?.value).toMatch( + /^[A-Za-z0-9_-]{64}$/, + ); expect(first.persisted["secret:auth.settings.publishable_key"]?.value).toMatch( /^sb_publishable_[A-Za-z0-9_-]{32,}$/, ); diff --git a/packages/stack/src/state/state-store.integration.test.ts b/packages/stack/src/state/state-store.integration.test.ts index 5bad772888..2edf283de8 100644 --- a/packages/stack/src/state/state-store.integration.test.ts +++ b/packages/stack/src/state/state-store.integration.test.ts @@ -1,18 +1,53 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted, Schema } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Option, + Path, + Redacted, + Schema, +} from "effect"; +import { createServer, type Server } from "node:net"; import { compileStack, type StackDefinition } from "../model/Compiler.ts"; import { deriveStackId } from "../identity/Identity.ts"; -import { - StackStateFormatUnsupportedError, - StackStateGenerationMismatchError, - StackStateInvalidError, -} from "../public/Errors.ts"; +import { StackStateFormatUnsupportedError, StackStateInvalidError } from "../public/Errors.ts"; import { makeStackStateStore, + withRegistryLock, PersistedStackStateSchema, type PersistedStackState, } from "./StackStateStore.ts"; +import { removeLeaseIfHeld } from "./Ownership.ts"; + +const bindEphemeral = Effect.callback((resume) => { + const server = createServer(); + const onError = (error: Error) => resume(Effect.fail(error)); + const onListening = () => { + server.off("error", onError); + resume(Effect.succeed(server)); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen({ host: "127.0.0.1", port: 0 }); + return Effect.sync(() => { + server.off("error", onError); + server.off("listening", onListening); + if (server.listening) server.close(() => undefined); + }); +}); + +const closeServer = (server: Server) => + Effect.callback((resume) => { + if (!server.listening) return resume(Effect.void); + server.close((error) => + error === undefined ? resume(Effect.void) : resume(Effect.fail(error)), + ); + }); const layer = NodeServices.layer; const withPlatform = (effect: Effect.Effect) => @@ -28,16 +63,10 @@ const identity = { stackName: "default", } as const; -const state = ( - stackId: string, - generation = 0, - definition?: StackDefinition, -): PersistedStackState => ({ +const state = (stackId: string, definition?: StackDefinition): PersistedStackState => ({ format: "supabase-stack-state-v1", identity: { ...identity, stackId }, runtime: { kind: "native" }, - desiredGeneration: generation, - portsGeneration: null, desiredLifecycle: "stopped", definition, inputFingerprint: definition === undefined ? undefined : "d".repeat(64), @@ -50,8 +79,104 @@ const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; const jsonText = (value: unknown) => Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value); +const jsonTextSync = (value: unknown) => + Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(value); describe("atomic stack state", () => { + it.live("rejects a competing registry action while the lease is held", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-registry-busy-" }); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + let ran = false; + const first = yield* Effect.forkChild( + withRegistryLock( + root, + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + }), + ), + { startImmediately: true }, + ); + yield* Deferred.await(entered); + const competing = yield* withRegistryLock( + root, + Effect.sync(() => { + ran = true; + return undefined; + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(competing)).toBe(true); + expect(ran).toBe(false); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + }), + ), + ); + + it.live("does not let a stale registry release remove a successor lock", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-registry-fence-", + }); + const path = yield* Path.Path; + const lockPath = path.join(root, ".stack-registry.lock"); + yield* fs.writeFileString( + lockPath, + jsonTextSync({ format: "supabase-stack-lease-v1", token: "successor", port: 45_678 }), + ); + yield* removeLeaseIfHeld(fs, lockPath, "stale-owner"); + expect(yield* fs.readFileString(lockPath)).toContain("successor"); + }), + ), + ); + + it.live("reclaims a registry lock whose lease was released by a crashed process", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-registry-" }); + const stale = yield* bindEphemeral; + const address = stale.address(); + if (address === null || typeof address === "string") + return yield* new StackStateInvalidError({ message: "missing lease port" }); + yield* fs.writeFileString( + path.join(root, ".stack-registry.lock"), + jsonTextSync({ + format: "supabase-stack-lease-v1", + token: "old-registry", + port: address.port, + }), + ); + yield* closeServer(stale); + const result = yield* withRegistryLock(root, Effect.succeed("recovered")); + expect(result).toBe("recovered"); + expect(yield* fs.exists(path.join(root, ".stack-registry.lock"))).toBe(false); + }), + ), + ); + + it.live("fails closed immediately for malformed registry state", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-registry-invalid-", + }); + yield* fs.writeFileString(path.join(root, ".stack-registry.lock"), "not-json"); + const result = yield* withRegistryLock(root, Effect.void).pipe(Effect.exit); + expect(errorOf(result)).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); + it.live("round-trips a compiled complete definition and rejects nested unknowns", () => withPlatform( Effect.gen(function* () { @@ -78,13 +203,13 @@ describe("atomic stack state", () => { }, }, }); - const complete = state(stackId, 2, compiled.definition); - yield* store.write(stackId, complete); + const complete = state(stackId, compiled.definition); + yield* store.initialize(stackId, complete); expect(yield* store.read(stackId)).toEqual(complete); - const materialized = { ...complete, portsGeneration: 2 }; - yield* store.replace(stackId, materialized, 2); + const materialized = { ...complete }; + yield* store.replace(stackId, materialized); expect(yield* store.read(stackId)).toEqual(materialized); - yield* store.replace(stackId, complete, 2); + yield* store.replace(stackId, complete); const encoded = yield* Schema.encodeEffect(PersistedStackStateSchema)(complete); const statePath = path.join(root, stackId, "state.json"); @@ -203,26 +328,24 @@ describe("atomic stack state", () => { ), ); - it.live("rejects unsupported format and stale generation mutations", () => + it.live("rejects unsupported state formats", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-generation-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-format-" }); const store = yield* makeStackStateStore({ stateRoot: root }); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, state(stackId, 4)); - const stale = yield* store.replace(stackId, state(stackId, 5), 3).pipe(Effect.exit); - expect(errorOf(stale)).toBeInstanceOf(StackStateGenerationMismatchError); + yield* store.initialize(stackId, state(stackId)); yield* fs.writeFileString( path.join(root, stackId, "state.json"), - yield* jsonText({ ...state(stackId, 4), format: "supabase-stack-state-v2" }), + yield* jsonText({ ...state(stackId), format: "supabase-stack-state-v2" }), ); const unsupported = yield* store.read(stackId).pipe(Effect.exit); expect(errorOf(unsupported)).toBeInstanceOf(StackStateFormatUnsupportedError); yield* fs.writeFileString( path.join(root, stackId, "state.json"), - yield* jsonText({ ...state(stackId, 4), format: 1 }), + yield* jsonText({ ...state(stackId), format: 1 }), ); const malformed = yield* store.read(stackId).pipe(Effect.exit); expect(errorOf(malformed)).toBeInstanceOf(StackStateInvalidError); @@ -243,7 +366,7 @@ describe("atomic stack state", () => { ...original, identity: { ...original.identity, workspaceId: "/tmp/forged" }, }; - const exit = yield* store.write(stackId, forged).pipe(Effect.exit); + const exit = yield* store.initialize(stackId, forged).pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(StackStateInvalidError); expect(yield* fs.exists(path.join(root, stackId))).toBe(false); }), @@ -258,22 +381,16 @@ describe("atomic stack state", () => { }); const store = yield* makeStackStateStore({ stateRoot: root }); const stackId = yield* deriveStackId(identity); - yield* store.write(stackId, state(stackId, 0)); - const updates = Array.from({ length: 8 }, (_, index) => - store.replace(stackId, state(stackId, index + 1), index), - ); + yield* store.initialize(stackId, state(stackId)); + const updates = Array.from({ length: 8 }, () => store.replace(stackId, state(stackId))); const writer = Effect.forEach(updates, (update) => update, { concurrency: 1 }); const readers = Effect.forEach(Array.from({ length: 32 }), () => store.read(stackId), { concurrency: 8, }); const [, observations] = yield* Effect.all([writer, readers], { concurrency: 2 }); - const allowedGenerations = new Set( - Array.from({ length: 9 }, (_, generation) => generation), - ); for (const observation of observations) { expect(observation).toBeDefined(); if (observation === undefined) continue; - expect(allowedGenerations.has(observation.desiredGeneration)).toBe(true); expect(observation.format).toBe("supabase-stack-state-v1"); expect(observation.identity.stackId).toBe(stackId); expect(Array.isArray(observation.ports)).toBe(true); diff --git a/packages/stack/src/supervisor/DesiredState.ts b/packages/stack/src/supervisor/DesiredState.ts index 4955aa3557..757474abc6 100644 --- a/packages/stack/src/supervisor/DesiredState.ts +++ b/packages/stack/src/supervisor/DesiredState.ts @@ -6,7 +6,6 @@ export type DesiredLifecycle = "unconfigured" | "stopped" | "running" | "destroy export interface ReconciliationInput { readonly stackId: StackId; - readonly desiredGeneration: number; readonly desiredLifecycle: DesiredLifecycle; readonly plan: ExecutionPlan; readonly observed: ReadonlyArray; @@ -40,21 +39,16 @@ export interface DesiredStatePlan { readonly blocked: ReadonlyArray; } -const keyFor = ( - stackId: StackId, - desiredGeneration: number, - workloadId: string, - specHash: string, -): RuntimeWorkloadKey => ({ stackId, desiredGeneration, workloadId, specHash }); +const keyFor = (stackId: StackId, workloadId: string, specHash: string): RuntimeWorkloadKey => ({ + stackId, + workloadId, + specHash, +}); const isCurrent = ( observed: ObservedWorkload | undefined, - generation: number, specHash: string, -): observed is ObservedWorkload => - observed !== undefined && - observed.desiredGeneration === generation && - observed.specHash === specHash; +): observed is ObservedWorkload => observed !== undefined && observed.specHash === specHash; /** * Builds a deterministic desired-state delta. `ExecutionPlan.workloads` is already a stable @@ -69,20 +63,16 @@ export const planDesiredState = (input: ReconciliationInput): DesiredStatePlan = const observationsFor = (workloadId: string): ReadonlyArray => input.observed .filter((entry) => entry.workloadId === workloadId) - .sort( - (left, right) => - left.desiredGeneration - right.desiredGeneration || - left.specHash.localeCompare(right.specHash), - ); + .sort((left, right) => left.specHash.localeCompare(right.specHash)); const removeEntry = (entry: ObservedWorkload) => removes.push({ kind: "remove", - key: keyFor(input.stackId, entry.desiredGeneration, entry.workloadId, entry.specHash), + key: keyFor(input.stackId, entry.workloadId, entry.specHash), }); if (input.desiredLifecycle === "running") { // Failed workloads are restart candidates. Only a failed start in this reconciliation blocks - // dependents; an observed failure gets one fresh attempt while its generation budget allows. + // dependents; an observed failure gets one fresh attempt while its session budget allows. const failed = new Set(); for (const workload of input.plan.workloads) { const dependencyFailure = workload.dependencies.find((dependency) => failed.has(dependency)); @@ -92,31 +82,29 @@ export const planDesiredState = (input: ReconciliationInput): DesiredStatePlan = continue; } const observations = observationsFor(workload.id); - const current = observations.filter((entry) => - isCurrent(entry, input.desiredGeneration, workload.specHash), - ); - // Every duplicate old-generation/spec resource is stale, even when an exact current + const current = observations.filter((entry) => isCurrent(entry, workload.specHash)); + // Every duplicate old-definition/spec resource is stale, even when an exact current // resource also exists. Never collapse observations by workload id: each exact key must be // cleaned independently. const stale = observations.filter( (entry) => - !isCurrent(entry, input.desiredGeneration, workload.specHash) || + !isCurrent(entry, workload.specHash) || entry.state === "failed" || entry.state === "stopped", ); if (stale.length > 0) staleByWorkload.set(workload.id, stale); if (current.some((entry) => entry.state === "ready" || entry.state === "starting")) continue; - const key = keyFor(input.stackId, input.desiredGeneration, workload.id, workload.specHash); + const key = keyFor(input.stackId, workload.id, workload.specHash); starts.push({ kind: "start", key, workload }); } // Stale replacements are removed child-first, preserving dependency safety when several - // generations/spec hashes for a planned graph are present at once. + // definitions/spec hashes for a planned graph are present at once. for (const workload of [...input.plan.workloads].reverse()) { for (const entry of staleByWorkload.get(workload.id) ?? []) { if (entry.state === "ready" || entry.state === "starting") { stops.push({ kind: "stop", - key: keyFor(input.stackId, entry.desiredGeneration, entry.workloadId, entry.specHash), + key: keyFor(input.stackId, entry.workloadId, entry.specHash), }); } removeEntry(entry); @@ -127,12 +115,7 @@ export const planDesiredState = (input: ReconciliationInput): DesiredStatePlan = for (const workload of reverse) { for (const observed of observationsFor(workload.id)) { if (observed.state === "absent") continue; - const key = keyFor( - input.stackId, - observed.desiredGeneration, - observed.workloadId, - observed.specHash, - ); + const key = keyFor(input.stackId, observed.workloadId, observed.specHash); if (observed.state !== "stopped") { stops.push({ kind: "stop", key }); } @@ -149,14 +132,13 @@ export const planDesiredState = (input: ReconciliationInput): DesiredStatePlan = .sort( (left, right) => left.workloadId.localeCompare(right.workloadId) || - left.desiredGeneration - right.desiredGeneration || left.specHash.localeCompare(right.specHash), ); for (const entry of stale) { if (entry.state === "ready" || entry.state === "starting") { stops.push({ kind: "stop", - key: keyFor(input.stackId, entry.desiredGeneration, entry.workloadId, entry.specHash), + key: keyFor(input.stackId, entry.workloadId, entry.specHash), }); } removeEntry(entry); diff --git a/packages/stack/src/supervisor/HostListener.ts b/packages/stack/src/supervisor/HostListener.ts index 2cd238ec24..c500313caf 100644 --- a/packages/stack/src/supervisor/HostListener.ts +++ b/packages/stack/src/supervisor/HostListener.ts @@ -5,14 +5,7 @@ import { createServer as createHttpServer, type Server as HttpServer } from "nod import { createServer as createNetServer, type Server as NetServer } from "node:net"; import { PortUnavailableError } from "../public/Errors.ts"; import { type HostListener } from "../state/PortCoordinator.ts"; -import type { PortField } from "../public/Status.ts"; - -const HTTP_FIELDS: ReadonlySet = new Set([ - "api", - "studio", - "mailUi", - "functionsInspector", -]); +import { PORT_FIELD_PROTOCOL, type PortField } from "../public/Status.ts"; export interface HostListenerBindOptions { readonly createHttpServer?: () => HttpServer; @@ -42,7 +35,7 @@ const bind = ( server: T, address: string, port: number, - field: PortField, + field: string, listen: HostListenerBindOptions["listen"] = (value, host, number, onListening) => value.listen({ host, port: number }, onListening), ): Effect.Effect => @@ -102,6 +95,14 @@ const bind = ( }); }); +/** Verifies an address/port can be bound without retaining a listener. */ +export const checkHostPort = ( + address: string, + port: number, + field: string, +): Effect.Effect => + Effect.scoped(bind(createNetServer(), address, port, field).pipe(Effect.flatMap(closeServer))); + /** Bind and retain one public host listener for direct adoption by a gateway. */ export const bindHostListener = ( address: string, @@ -116,7 +117,7 @@ export const bindHostListenerWithOptions = ( field: PortField, options: HostListenerBindOptions = {}, ): Effect.Effect => { - if (HTTP_FIELDS.has(field)) + if (PORT_FIELD_PROTOCOL[field] === "http") return bind( options.createHttpServer?.() ?? createHttpServer(), address, @@ -161,4 +162,4 @@ export const bindHostListenerWithOptions = ( ); }; -export const isHttpPortField = (field: PortField): boolean => HTTP_FIELDS.has(field); +export const isHttpPortField = (field: PortField): boolean => PORT_FIELD_PROTOCOL[field] === "http"; diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 0df73a0527..d147b78b88 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -1,4 +1,4 @@ -import { Context, Crypto, Effect, FileSystem, Path, Ref, Scope, Semaphore } from "effect"; +import { Context, Crypto, Effect, Exit, FileSystem, Path, Ref, Scope, Semaphore } from "effect"; import type { LifecycleInput } from "./Lifecycle.ts"; import type { ActivationResult, @@ -27,9 +27,8 @@ import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; import type { PortField } from "../public/Status.ts"; import { bindHostListener, isHttpPortField } from "./HostListener.ts"; -export interface SupervisorIngressReservation extends PortReservation { - readonly generation: number; - /** False when this generation already owns the exact listeners and gateway. */ +interface SupervisorIngressReservation extends PortReservation { + /** False when this accepted definition already owns the exact listeners and gateway. */ readonly fresh: boolean; } @@ -60,7 +59,7 @@ export interface SupervisorIngressOptions { readonly apiMaterial?: ( state: LifecycleInput["state"], ) => Effect.Effect; - /** Resolves the accepted generation's Auth templates for live local serving. */ + /** Resolves the accepted definition's Auth templates for live local serving. */ readonly resolveAuthTemplates?: (state: LifecycleInput["state"]) => Effect.Effect< ReadonlyArray<{ readonly id: string; @@ -114,13 +113,25 @@ const routeBackend = ( activation: ActivationResult, ) => { if (route.binding === undefined) return Effect.succeed(activation.endpoint); - const workload = input.plan.workloads.find((entry) => entry.capability === route.capability); - const assignment = reservation.privateAssignments.find( - (entry) => entry.workloadId === workload?.id && entry.binding === route.binding, + const workloadIds = new Set( + input.plan.workloads + .filter((entry) => entry.capability === route.capability) + .map((entry) => entry.id), ); - return assignment === undefined - ? Effect.fail(new GatewayActivationError({ message: "Gateway private binding is unavailable" })) - : Effect.succeed({ host: "127.0.0.1", port: assignment.port }); + const assignments = reservation.privateAssignments.filter( + (entry) => workloadIds.has(entry.workloadId) && entry.binding === route.binding, + ); + const [assignment, ...additionalAssignments] = assignments; + if (assignment === undefined || additionalAssignments.length > 0) + return Effect.fail( + new GatewayActivationError({ + message: + assignment === undefined + ? "Gateway private binding is unavailable" + : "Gateway private binding is ambiguous", + }), + ); + return Effect.succeed({ host: "127.0.0.1", port: assignment.port }); }; const templateContentType = (extension: string): string => { @@ -153,6 +164,7 @@ export const makeSupervisorIngress = ( | { readonly input: LifecycleInput; readonly reservation: SupervisorIngressReservation; + readonly scope: Scope.Scope; readonly gateway?: StackGateway; } | undefined @@ -168,26 +180,23 @@ export const makeSupervisorIngress = ( lock.withPermit( Effect.gen(function* () { const existing = yield* Ref.get(current); - if (existing !== undefined && existing.reservation.generation === input.generation) + if (existing !== undefined && existing.input.inputFingerprint === input.inputFingerprint) return { ...existing.reservation, fresh: false }; if (existing !== undefined) yield* closeCurrent(existing); + const reservationScope = Scope.forkUnsafe(ownerScope); const reservation = yield* coordinator .planAndReserve(options.stackId, listenerIntents(input), { - lifecycle: "running", - expectedGeneration: input.generation, - nextGeneration: input.generation, privateBindings: privateBindingIntentsFor(input.plan), }) .pipe( Effect.provideContext(options.context), - Effect.provideService(Scope.Scope, ownerScope), + Effect.provideService(Scope.Scope, reservationScope), ); const owned: SupervisorIngressReservation = { ...reservation, - generation: input.generation, fresh: true, }; - yield* Ref.set(current, { input, reservation: owned }); + yield* Ref.set(current, { input, reservation: owned, scope: reservationScope }); return owned; }), ); @@ -195,13 +204,11 @@ export const makeSupervisorIngress = ( const closeCurrent = (entry: { readonly reservation: SupervisorIngressReservation; readonly gateway?: StackGateway; + readonly scope: Scope.Scope; }): Effect.Effect => Effect.gen(function* () { if (entry.gateway !== undefined) yield* entry.gateway.close; - yield* Effect.forEach(entry.reservation.hostListeners, (listener) => listener.close, { - concurrency: "unbounded", - discard: true, - }); + yield* Scope.close(entry.scope, Exit.void); }); const close: Effect.Effect = lock.withPermit( @@ -223,12 +230,11 @@ export const makeSupervisorIngress = ( lock.withPermit( Effect.gen(function* () { const entry = yield* Ref.get(current); - if ( - entry !== undefined && - entry.reservation.generation === reservation.generation && - entry.gateway !== undefined - ) - return; + if (entry === undefined || entry.input.inputFingerprint !== input.inputFingerprint) + return yield* new GatewayActivationError({ + message: "Gateway reservation is no longer current", + }); + if (entry.gateway !== undefined) return; const material = input.definition.listeners.api.enabled ? yield* (options.apiMaterial ?? defaultApiMaterial)(input.state) : undefined; @@ -320,8 +326,13 @@ export const makeSupervisorIngress = ( : new GatewayActivationError({ message: error.message, cause: error }), ), ), - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - yield* Ref.set(current, { input, reservation, gateway }); + }).pipe(Effect.provideService(Scope.Scope, entry.scope)); + yield* Ref.set(current, { + input, + reservation, + scope: entry.scope, + gateway, + }); }), ); diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 85854f0a65..4c4cfcaf05 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -1,6 +1,8 @@ import { + Cause, Crypto, Effect, + Exit, FileSystem, Fiber, Option, @@ -24,6 +26,7 @@ import { } from "../state/Ownership.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import { makeControlClient } from "../control/ControlServer.ts"; +import { isMaintenanceTransportFailure } from "../control/MaintenanceProtocol.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; import { StackOwnershipConflictError, @@ -154,18 +157,32 @@ const validateCompatibleOwner = ( Scope.Scope > => Effect.gen(function* () { - if (metadata.rpcRelease !== STACK_RPC_RELEASE) - return yield* new StackUpgradeRequiredError({ - message: `Stack owner release ${metadata.rpcRelease} requires explicit restart`, - expectedRelease: STACK_RPC_RELEASE, - actualRelease: metadata.rpcRelease, - }); - const probe = yield* makeControlClient(metadata.endpoint, { + const probeExit = yield* makeControlClient(metadata.endpoint, { stackId: options.stackId, ownerSessionId: metadata.ownerSessionId, }) .probe() - .pipe(Effect.mapError((error) => mapFailure(String(error)))); + .pipe(Effect.exit); + if (Exit.isFailure(probeExit)) { + const failure = Cause.findErrorOption(probeExit.cause); + if (Option.isSome(failure) && isMaintenanceTransportFailure(failure.value)) + return yield* mapFailure("Unable to probe existing Supervisor: transport unavailable"); + return yield* new StackOwnershipConflictError({ + message: "Existing Supervisor probe failed", + stackId: options.stackId, + }); + } + const probe = probeExit.value; + if ( + probe.ok && + probe.op === "probe" && + (metadata.rpcRelease !== STACK_RPC_RELEASE || probe.rpcRelease !== STACK_RPC_RELEASE) + ) + return yield* new StackUpgradeRequiredError({ + message: `Stack owner release ${probe.rpcRelease} requires explicit restart`, + expectedRelease: STACK_RPC_RELEASE, + actualRelease: probe.rpcRelease, + }); if ( !probe.ok || probe.op !== "probe" || @@ -220,7 +237,7 @@ const launchAndAwait = ( paths: { readonly stackRoot: string }, ): Effect.Effect< OwnerMetadata, - StackOwnershipConflictError | StackStateInvalidError, + StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > => Effect.gen(function* () { @@ -230,7 +247,7 @@ const launchAndAwait = ( return yield* mapFailure("Supervisor launcher is not configured"); const encoded = yield* encodeLaunchPayload(payload); // Subscribe before spawning: a loser can observe the winner's exact - // metadata publication even when its own child exits on O_EXCL conflict. + // metadata publication even when its own child loses the atomic lock race. const ownerFiber = yield* Effect.forkChild(observeOwner(options, paths), { startImmediately: true, }); @@ -298,7 +315,21 @@ const launchAndAwait = ( options.stackId, options.environment, ); - if (current !== undefined) return current; + if (current !== undefined) { + // The watcher is only a launch-time helper once the winner is visible. + yield* Fiber.interrupt(ownerFiber); + return yield* validateCompatibleOwner(options, current).pipe( + Effect.as(current), + Effect.catchTag("StackStateInvalidError", () => + Effect.fail( + new StackOwnershipConflictError({ + message: "A live Supervisor owns this stack but is not responding", + stackId: options.stackId, + }), + ), + ), + ); + } return yield* Fiber.join(ownerFiber).pipe( Effect.timeoutOrElse({ duration: 5_000, @@ -346,8 +377,14 @@ export const ensureSupervisor = ( options.environment, ); if (existing !== undefined) { - yield* validateCompatibleOwner(options, existing); - return existing; + const compatible = yield* validateCompatibleOwner(options, existing).pipe( + Effect.as(true), + // A published document with an unavailable control endpoint may be a + // crashed owner. Let the child arbitrate through the OS-held lease; + // malformed metadata remains fail-closed in readOwnerMetadata. + Effect.catchTag("StackStateInvalidError", () => Effect.succeed(false)), + ); + if (compatible) return existing; } const stackId = yield* Schema.decodeEffect(StackIdSchema)(options.stackId).pipe( Effect.mapError((error) => mapFailure(`Invalid StackId: ${String(error)}`)), diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index e086f5c613..48092f615f 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, FileSystem, Path, Redacted, Ref, Semaphore } from "effect"; +import { Crypto, Effect, Exit, FileSystem, Path, Redacted } from "effect"; import type { StackDefinition, CompiledStack, SecretSlotInput } from "../model/Compiler.ts"; import { compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; @@ -8,16 +8,12 @@ import type { StackId } from "../public/StackId.ts"; import { StackDefinitionRequiredError, StackLifecycleConflictError, + StackMustBeStoppedError, StackNotRunningError, StackStateInvalidError, - StackUpgradeRequiredError, type StackError, } from "../public/Errors.ts"; -import type { - HostPortAssignment, - PersistedSecretValues, - PersistedStackState, -} from "../state/StackState.ts"; +import type { PersistedSecretValues, PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import { resolveSecrets, @@ -27,38 +23,39 @@ import { /** * The runtime-facing contract deliberately contains no Docker/native concepts. Concrete drivers - * own resources; this controller owns accepted durable intent and generation fences. + * own resources; this controller owns accepted durable intent and lifecycle transitions. */ export interface LifecycleInput { readonly stackId: StackId; - readonly generation: number; readonly desiredLifecycle: "running" | "stopped" | "destroying"; readonly state: PersistedStackState; - readonly previous: PersistedStackState; readonly definition: StackDefinition; readonly inputFingerprint: string; readonly secrets: PersistedSecretValues; readonly plan: ExecutionPlan; } -export interface LifecyclePrepared { - /** Backend may return newly allocated concrete ports; omitted means retain persisted assignments. */ - readonly ports?: ReadonlyArray; -} - export interface LifecycleBackend { /** Must complete all runtime/resource validation before the controller writes accepted intent. */ - readonly preflight: (input: LifecycleInput) => Effect.Effect; - /** Applies the desired lifecycle to runtime resources for one fenced generation. */ - readonly reconcile: (input: LifecycleInput) => Effect.Effect; + readonly preflight: ( + input: LifecycleInput, + mode: "cold" | "live", + ) => Effect.Effect; + /** Applies the desired lifecycle to runtime resources for one accepted definition. */ + readonly reconcile: ( + input: LifecycleInput, + session: "fresh" | "current", + ) => Effect.Effect; /** Removes runtime resources while retaining durable state/data (stop path). */ - readonly cleanup: (input: LifecycleInput) => Effect.Effect; + readonly cleanup: Effect.Effect; /** Removes persistent data after exact runtime cleanup (destroy path). */ - readonly destroyData: (input: LifecycleInput) => Effect.Effect; + readonly destroyData: Effect.Effect; } interface LifecycleStartOptions { readonly config?: StackConfig; + /** A new Supervisor recovering running intent begins a fresh runtime session. */ + readonly freshSession?: boolean; } export interface LifecycleController { @@ -91,13 +88,6 @@ interface Candidate { readonly plan: ExecutionPlan; } -interface StopTransition { - readonly state: PersistedStackState; - readonly previous: PersistedStackState; - readonly candidate?: Candidate; - readonly cleanup: boolean; -} - const missingState = (stackId: StackId): StackStateInvalidError => new StackStateInvalidError({ stackId, @@ -167,11 +157,19 @@ const materializeCandidate = ( plan, }; } - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime, - config, - }); + const compiled = yield* compileStack( + { + projectRoot: state.identity.projectRoot, + runtime, + config, + }, + state.definition === undefined || state.inputFingerprint === undefined + ? undefined + : { + definition: state.definition, + inputFingerprint: state.inputFingerprint, + }, + ); const resolved = yield* resolveSecrets( declarationsFromCompiled(compiled), state.desiredLifecycle === "unconfigured" ? undefined : state.secrets, @@ -205,7 +203,6 @@ const persistedCandidate = ( const lifecycleInput = ( stackId: StackId, - previous: PersistedStackState, state: PersistedStackState, candidate: Candidate, desiredLifecycle: LifecycleInput["desiredLifecycle"] = state.desiredLifecycle === "unconfigured" @@ -213,10 +210,8 @@ const lifecycleInput = ( : state.desiredLifecycle, ): LifecycleInput => ({ stackId, - generation: state.desiredGeneration, desiredLifecycle, state, - previous, definition: candidate.definition, inputFingerprint: candidate.inputFingerprint, secrets: candidate.secrets, @@ -227,34 +222,20 @@ const stateWithCandidate = ( state: PersistedStackState, candidate: Candidate, desiredLifecycle: PersistedStackState["desiredLifecycle"], - generation: number, - ports: ReadonlyArray, ): PersistedStackState => ({ ...state, - desiredGeneration: generation, desiredLifecycle, definition: candidate.definition, inputFingerprint: candidate.inputFingerprint, secrets: candidate.secrets, - ports, -}); - -const noOpStop = (state: PersistedStackState): StopTransition => ({ - state, - previous: state, - cleanup: false, + ports: state.ports, }); /** Creates one Supervisor-local lifecycle owner. Mutable coordination is allocated per Effect run. */ export const makeLifecycleController = ( options: LifecycleControllerOptions, ): Effect.Effect => - Effect.gen(function* () { - const lifecycle = yield* Semaphore.make(1); - // A stop request epoch is incremented before waiting for the lifecycle permit. Restart captures - // the epoch during preflight and refuses to relaunch if a stop was accepted in the meantime. - const stopRequestEpoch = yield* Ref.make(0); - + Effect.sync(() => { const read = (): Effect.Effect => options.stateStore .read(options.stackId) @@ -275,121 +256,70 @@ export const makeLifecycleController = ( if (initial.desiredLifecycle === "destroying") return yield* lifecycleConflict("Stack is being destroyed"); - // Running stacks reject changed materialized input before any backend operation. An omitted - // config or identical supplied input only joins/reconciles the accepted generation. - if (initial.desiredLifecycle === "running" && supplied !== undefined) { - const candidate = yield* materializeCandidate(initial, initial.runtime, supplied).pipe( - Effect.catchTag("StackMustBeStoppedError", (error) => - Effect.fail( - new StackUpgradeRequiredError({ - stackId: options.stackId, - message: `${error.message}; call restart explicitly to apply it`, - guidance: "Use restart() to apply stopped-time changes", - }), - ), - ), - ); - if (candidate.inputFingerprint !== initial.inputFingerprint) - return yield* new StackUpgradeRequiredError({ + const candidate = yield* materializeCandidate(initial, initial.runtime, supplied); + if (initial.desiredLifecycle === "running") { + if (supplied !== undefined && candidate.inputFingerprint !== initial.inputFingerprint) + return yield* new StackMustBeStoppedError({ stackId: options.stackId, message: "Running stack input changed; call restart explicitly to apply it", guidance: "Use restart() to apply stopped-time changes", }); - if (!sameSecrets(candidate.secrets, initial.secrets)) - return yield* new StackUpgradeRequiredError({ + if (supplied !== undefined && !sameSecrets(candidate.secrets, initial.secrets)) + return yield* new StackMustBeStoppedError({ stackId: options.stackId, message: "Running stack secrets changed; call restart explicitly to apply them", guidance: "Use restart() to apply stopped-time changes", }); - const input = lifecycleInput(options.stackId, initial, initial, candidate); - yield* options.backend.reconcile(input); - return yield* read(); - } - if (initial.desiredLifecycle === "running" && supplied === undefined) { - const candidate = yield* materializeCandidate(initial, initial.runtime, undefined); - const input = lifecycleInput(options.stackId, initial, initial, candidate); - yield* options.backend.reconcile(input); - return yield* read(); + if (startOptions?.freshSession === true) + yield* options.backend.preflight( + lifecycleInput(options.stackId, initial, candidate), + "cold", + ); + yield* options.backend.reconcile( + lifecycleInput(options.stackId, initial, candidate), + startOptions?.freshSession === true ? "fresh" : "current", + ); + return initial; } - const candidate = yield* materializeCandidate(initial, initial.runtime, supplied); - const prepared = yield* options.backend.preflight( - lifecycleInput(options.stackId, initial, initial, candidate), - ); - return yield* lifecycle.withPermit( - Effect.gen(function* () { - const current = yield* read(); - if (current.desiredGeneration !== initial.desiredGeneration) - return yield* new StackUpgradeRequiredError({ - stackId: options.stackId, - message: "Stack lifecycle changed while start was preparing; retry start", - }); - if (current.desiredLifecycle === "destroying") - return yield* lifecycleConflict("Stack is being destroyed"); - const next = stateWithCandidate( - current, - candidate, - "running", - current.desiredGeneration + 1, - prepared.ports ?? current.ports, - ); - yield* options.stateStore.replace(options.stackId, next, current.desiredGeneration); - yield* options.backend.reconcile( - lifecycleInput(options.stackId, current, next, candidate), - ); - return next; - }), + yield* options.backend.preflight( + lifecycleInput(options.stackId, initial, candidate), + "cold", ); + const next = stateWithCandidate(initial, candidate, "running"); + yield* options.stateStore.replace(options.stackId, next); + yield* options.backend.reconcile(lifecycleInput(options.stackId, next, candidate), "fresh"); + return next; }); }; const stop = (): Effect.Effect => Effect.gen(function* () { - yield* Ref.update(stopRequestEpoch, (epoch) => epoch + 1); - const transition = yield* lifecycle.withPermit( - Effect.gen(function* () { - const current = yield* read(); - if (current.desiredLifecycle === "unconfigured") return noOpStop(current); - if (current.desiredLifecycle === "destroying") - return yield* lifecycleConflict("Stack is being destroyed"); - if (current.desiredLifecycle === "stopped") { - const candidate = yield* persistedCandidate( - options.stackId, - current, - current.runtime, - ); - // Stopped state is durable intent, not proof that runtime cleanup completed. Retry - // the idempotent cleanup without changing the generation. - return { state: current, previous: current, candidate, cleanup: true }; - } - const candidate = yield* persistedCandidate(options.stackId, current, current.runtime); - const next: PersistedStackState = { - ...current, - desiredLifecycle: "stopped", - desiredGeneration: current.desiredGeneration + 1, - }; - yield* options.stateStore.replace(options.stackId, next, current.desiredGeneration); - return { state: next, previous: current, candidate, cleanup: true }; - }), - ); - if (!transition.cleanup || transition.candidate === undefined) return transition.state; - const input = lifecycleInput( - options.stackId, - transition.previous, - transition.state, - transition.candidate, - "stopped", + const current = yield* read(); + if (current.desiredLifecycle === "unconfigured") return current; + if (current.desiredLifecycle === "destroying") + return yield* lifecycleConflict("Stack is being destroyed"); + const candidate = yield* Effect.exit( + persistedCandidate(options.stackId, current, current.runtime), ); - yield* options.backend.reconcile(input); - yield* options.backend.cleanup(input); - return transition.state; + const stopped: PersistedStackState = + current.desiredLifecycle === "stopped" + ? current + : { ...current, desiredLifecycle: "stopped" }; + if (stopped !== current) yield* options.stateStore.replace(options.stackId, stopped); + if (Exit.isSuccess(candidate)) + yield* options.backend.reconcile( + lifecycleInput(options.stackId, stopped, candidate.value, "stopped"), + "current", + ); + yield* options.backend.cleanup; + return stopped; }); const restart = ( restartOptions?: LifecycleStartOptions, ): Effect.Effect => Effect.gen(function* () { - const restartEpoch = yield* Ref.get(stopRequestEpoch); const initial = yield* read(); if (initial.desiredLifecycle !== "running") return yield* new StackNotRunningError({ @@ -404,111 +334,64 @@ export const makeLifecycleController = ( restartOptions?.config, "stopped", ); - const prepared = yield* options.backend.preflight( - lifecycleInput(options.stackId, initial, initial, candidate), + yield* options.backend.preflight( + lifecycleInput(options.stackId, initial, candidate), + restartOptions?.freshSession === true ? "cold" : "live", ); - const stoppedTransition = yield* lifecycle.withPermit( - Effect.gen(function* () { - const current = yield* read(); - if ( - current.desiredGeneration !== initial.desiredGeneration || - current.desiredLifecycle !== "running" - ) - return yield* new StackLifecycleConflictError({ - stackId: options.stackId, - message: - "Stack lifecycle changed while restart was preparing; restart lost the generation fence", - }); - const stopped: PersistedStackState = { - ...current, - desiredLifecycle: "stopped", - desiredGeneration: current.desiredGeneration + 1, - }; - yield* options.stateStore.replace(options.stackId, stopped, current.desiredGeneration); - const stoppedCandidate = yield* persistedCandidate( - options.stackId, - current, - current.runtime, - ); - return { current, stopped, stoppedCandidate }; - }), - ); - const stopInput = lifecycleInput( + const stopped: PersistedStackState = { ...initial, desiredLifecycle: "stopped" }; + yield* options.stateStore.replace(options.stackId, stopped); + const stoppedCandidate = yield* persistedCandidate( options.stackId, - stoppedTransition.current, - stoppedTransition.stopped, - stoppedTransition.stoppedCandidate, - "stopped", - ); - yield* options.backend.reconcile(stopInput); - yield* options.backend.cleanup(stopInput); - const next = yield* lifecycle.withPermit( - Effect.gen(function* () { - const current = yield* read(); - if ( - current.desiredGeneration !== stoppedTransition.stopped.desiredGeneration || - current.desiredLifecycle !== "stopped" || - (yield* Ref.get(stopRequestEpoch)) !== restartEpoch - ) - return yield* new StackLifecycleConflictError({ - stackId: options.stackId, - message: - "Stack stop was accepted during restart; restart lost the generation fence", - }); - const next = stateWithCandidate( - current, - candidate, - "running", - current.desiredGeneration + 1, - prepared.ports ?? current.ports, - ); - yield* options.stateStore.replace(options.stackId, next, current.desiredGeneration); - return next; - }), + initial, + initial.runtime, ); + const stopInput = lifecycleInput(options.stackId, stopped, stoppedCandidate, "stopped"); + yield* options.backend.reconcile(stopInput, "current"); + yield* options.backend.cleanup; + const next = stateWithCandidate(stopped, candidate, "running"); + yield* options.stateStore.replace(options.stackId, next); yield* options.backend.reconcile( - lifecycleInput(options.stackId, stoppedTransition.stopped, next, candidate, "running"), + lifecycleInput(options.stackId, next, candidate, "running"), + "fresh", ); return next; }); const destroy = (): Effect.Effect => - lifecycle.withPermit( - Effect.gen(function* () { - const current = yield* read(); - if (current.desiredLifecycle === "unconfigured" && current.definition === undefined) { - yield* options.stateStore.cleanup(options.stackId); - return; - } - if (current.desiredLifecycle === "destroying") { - if (current.definition === undefined) return yield* invalidDefinition(options.stackId); - } else { - const destroying: PersistedStackState = { - ...current, - desiredLifecycle: "destroying", - desiredGeneration: current.desiredGeneration + 1, - }; - yield* options.stateStore.replace( - options.stackId, - destroying, - current.desiredGeneration, - ); - return yield* destroyRuntime(destroying, current); - } - return yield* destroyRuntime(current, current); - }), - ); + Effect.gen(function* () { + const current = yield* read(); + if (current.desiredLifecycle === "unconfigured" && current.definition === undefined) { + yield* options.stateStore.cleanup(options.stackId); + return; + } + if (current.desiredLifecycle === "destroying" && current.definition === undefined) + return yield* invalidDefinition(options.stackId); + const destroying: PersistedStackState = + current.desiredLifecycle === "destroying" + ? current + : { ...current, desiredLifecycle: "destroying" }; + if (destroying !== current) yield* options.stateStore.replace(options.stackId, destroying); + yield* destroyRuntime(destroying); + }); const destroyRuntime = ( state: PersistedStackState, - previous: PersistedStackState, ): Effect.Effect => Effect.gen(function* () { - const candidate = yield* persistedCandidate(options.stackId, state, state.runtime); - const input = lifecycleInput(options.stackId, previous, state, candidate); - yield* options.backend.reconcile(input); - yield* options.backend.cleanup(input); - yield* options.backend.destroyData(input); + const candidate = yield* Effect.exit( + persistedCandidate(options.stackId, state, state.runtime), + ); + if (Exit.isSuccess(candidate)) { + const input = lifecycleInput(options.stackId, state, candidate.value); + yield* options.backend.reconcile(input, "current"); + yield* options.backend.cleanup; + yield* options.backend.destroyData; + } else { + // Preserve the stable maintenance guarantee: stop and destroy remain usable even when + // persisted configuration is no longer compilable. + yield* options.backend.cleanup; + yield* options.backend.destroyData; + } yield* options.stateStore.cleanup(options.stackId); }); diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index 0b3be07c3e..ba04f86eeb 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -19,7 +19,6 @@ import { } from "../public/Logs.ts"; import { redactKnownSecrets } from "../state/SecretStore.ts"; -const LOG_FORMAT = "supabase-stack-logs-v1" as const; const CURSOR_PREFIX = "v1_"; /** File or decoding failure while opening or writing retained logs. */ @@ -63,44 +62,15 @@ export interface LogStore { } interface LogDocument { - readonly format: typeof LOG_FORMAT; readonly nextCursor: number; readonly entries: ReadonlyArray; } -const LogDocumentSchema = Schema.Struct({ - format: Schema.Literal(LOG_FORMAT), - nextCursor: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), - entries: Schema.Array(StackLogEntrySchema), -}); - const emptyDocument = (): LogDocument => ({ - format: LOG_FORMAT, nextCursor: 1, entries: [], }); -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const hasExactKeys = ( - value: unknown, - keys: ReadonlyArray, -): value is Record => - isRecord(value) && - Object.keys(value).length === keys.length && - keys.every((key) => Object.hasOwn(value, key)); - -const isRawLogDocument = (value: unknown): boolean => { - if (!hasExactKeys(value, ["format", "nextCursor", "entries"]) || !Array.isArray(value.entries)) - return false; - return value.entries.every( - (entry) => - hasExactKeys(entry, ["cursor", "timestamp", "source", "stream", "message"]) && - hasExactKeys(entry.cursor, ["opaque"]), - ); -}; - const fileError = (path: string, message: string, cause?: unknown) => new LogStoreError({ path, message, ...(cause === undefined ? {} : { cause }) }); @@ -166,28 +136,57 @@ const validateDocument = ( const readDocument = ( fs: FileSystem.FileSystem, path: string, -): Effect.Effect => - fs.readFileString(path).pipe( - Effect.flatMap((text) => - Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( - Effect.mapError((cause) => fileError(path, "Retained log file is malformed", cause)), - Effect.filterOrFail(isRawLogDocument, () => - fileError(path, "Retained log file has unexpected fields"), - ), - Effect.flatMap((raw) => - Schema.decodeUnknownEffect(LogDocumentSchema)(raw).pipe( - Effect.mapError((cause) => fileError(path, "Retained log file is malformed", cause)), - Effect.flatMap((document) => validateDocument(path, document)), - ), - ), +): Effect.Effect => { + const parse = (text: string, sourcePath: string) => { + const rawLines = text.split(/\r?\n/); + // A process crash can leave one unterminated tail after an append. Keep all complete JSONL + // records and drop only that final tail; a file containing no complete record remains an error. + const hasCompleteTail = text.endsWith("\n"); + const lines = ( + hasCompleteTail || rawLines.length === 1 ? rawLines : rawLines.slice(0, -1) + ).filter((line) => line.length > 0); + return Effect.forEach(lines, (line) => + Schema.decodeEffect(Schema.fromJsonString(StackLogEntrySchema))(line).pipe( + Effect.mapError((cause) => fileError(sourcePath, "Retained log file is malformed", cause)), ), - ), + ).pipe( + Effect.flatMap((entries) => { + const nextCursor = entries.reduce( + (next, entry) => Math.max(next, Number.parseInt(entry.cursor.opaque.slice(3), 36) + 1), + 1, + ); + return validateDocument(sourcePath, { nextCursor, entries }); + }), + ); + }; + return fs.readFileString(path).pipe( + Effect.flatMap((text) => { + return parse(text, path); + }), Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed(emptyDocument()) + ? fs.readFileString(`${path}.1`).pipe( + Effect.flatMap((text) => parse(text, `${path}.1`)), + Effect.catchTag("PlatformError", (backupError) => + Predicate.isTagged(backupError.reason, "NotFound") + ? Effect.succeed(emptyDocument()) + : Effect.fail(fileError(path, "Unable to read retained log file", backupError)), + ), + ) : Effect.fail(fileError(path, "Unable to read retained log file", error)), ), ); +}; + +/** Read retained entries without opening or mutating a LogStore. */ +export const readRetainedLogs = ( + fs: FileSystem.FileSystem, + path: string, + options?: LogOptions, +): Effect.Effect, LogStoreError> => + readDocument(fs, path).pipe( + Effect.flatMap((document) => selected(document.entries, options, path)), + ); const redactDocument = ( document: LogDocument, @@ -202,48 +201,61 @@ const redactDocument = ( return { document: { ...document, entries }, changed }; }; -const encodedSize = (document: LogDocument, path: string): Effect.Effect => - Schema.encodeEffect(Schema.fromJsonString(LogDocumentSchema))(document).pipe( - Effect.map((encoded) => new TextEncoder().encode(encoded).byteLength), +const encodedEntry = (entry: StackLogEntry, path: string): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(StackLogEntrySchema))(entry).pipe( Effect.mapError((cause) => fileError(path, "Unable to encode retained logs", cause)), ); +const encodedSize = ( + entries: ReadonlyArray, + path: string, +): Effect.Effect => + Effect.forEach(entries, (entry) => encodedEntry(entry, path), { concurrency: "unbounded" }).pipe( + Effect.map( + (lines) => + new TextEncoder().encode(lines.join("\n") + (lines.length > 0 ? "\n" : "")).byteLength, + ), + ); + const bounded = ( document: LogDocument, maxEntries: number, maxBytes: number, path: string, -): Effect.Effect => + initialBytes?: number, +): Effect.Effect<{ readonly document: LogDocument; readonly bytes: number }, LogStoreError> => Effect.gen(function* () { const entries = [...document.entries]; - while ( - entries.length > maxEntries || - (yield* encodedSize({ ...document, entries }, path)) > maxBytes - ) { + let bytes = initialBytes ?? (yield* encodedSize(entries, path)); + while (entries.length > maxEntries || bytes > maxBytes) { if (entries.length === 0) break; - entries.shift(); + const removed = entries.shift(); + if (removed !== undefined) { + const encoded = yield* encodedEntry(removed, path); + bytes -= new TextEncoder().encode(`${encoded}\n`).byteLength; + } } - return { ...document, entries }; + return { document: { ...document, entries }, bytes }; }); const persist = ( fs: FileSystem.FileSystem, path: string, - document: LogDocument, + entries: ReadonlyArray, ): Effect.Effect => { const temporary = `${path}.tmp`; const write = Effect.gen(function* () { - const text = yield* Schema.encodeEffect(Schema.fromJsonString(LogDocumentSchema))( - document, - ).pipe(Effect.mapError((cause) => fileError(path, "Unable to encode retained logs", cause))); + const lines = yield* Effect.forEach(entries, (entry) => encodedEntry(entry, path)); + const text = lines.length === 0 ? "" : `${lines.join("\n")}\n`; yield* Effect.scoped( Effect.gen(function* () { const file = yield* fs.open(temporary, { flag: "w", mode: 0o600 }); - yield* file.writeAll(new TextEncoder().encode(text)); - yield* file.sync; + if (text.length > 0) yield* file.writeAll(new TextEncoder().encode(text)); }), ); yield* fs.chmod(temporary, 0o600); + yield* fs.remove(`${path}.1`, { force: true }); + if (yield* fs.exists(path)) yield* fs.rename(path, `${path}.1`); yield* fs.rename(temporary, path); yield* fs.chmod(path, 0o600); }); @@ -290,8 +302,7 @@ export const makeLogStore = ( fileError(options.path, "Unable to secure retained log directory", error), ), ); - const emptySize = yield* encodedSize(emptyDocument(), options.path); - if (maxBytes < emptySize) + if (maxBytes < 2) return yield* fileError(options.path, "Log byte retention limit is too small"); const knownSecrets = options.knownSecrets ?? []; const loaded = yield* readDocument(fs, options.path); @@ -302,7 +313,8 @@ export const makeLogStore = ( maxBytes, options.path, ); - let document = boundedLoaded; + let document = boundedLoaded.document; + let retainedBytes = boundedLoaded.bytes; const exists = yield* fs .exists(options.path) .pipe( @@ -318,8 +330,8 @@ export const makeLogStore = ( fileError(options.path, "Unable to secure retained logs", error), ), ); - if (redactedLoaded.changed || boundedLoaded.entries.length !== loaded.entries.length) - yield* persist(fs, options.path, boundedLoaded); + if (redactedLoaded.changed || boundedLoaded.document.entries.length !== loaded.entries.length) + yield* persist(fs, options.path, boundedLoaded.document.entries); const semaphore = yield* Semaphore.make(1); const pubsub = yield* PubSub.unbounded(); @@ -334,17 +346,34 @@ export const makeLogStore = ( stream: record.stream, message: redactKnownSecrets(record.message, knownSecrets), } satisfies StackLogEntry; - document = yield* bounded( - { - format: LOG_FORMAT, - nextCursor: document.nextCursor + 1, - entries: [...document.entries, entry], - }, - maxEntries, - maxBytes, - options.path, - ); - yield* persist(fs, options.path, document); + const next = { + nextCursor: document.nextCursor + 1, + entries: [...document.entries, entry], + }; + const encoded = yield* encodedEntry(entry, options.path); + const entryBytes = new TextEncoder().encode(`${encoded}\n`).byteLength; + const nextBytes = retainedBytes + entryBytes; + if (document.entries.length + 1 <= maxEntries && nextBytes <= maxBytes) { + document = next; + retainedBytes = nextBytes; + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(options.path, { flag: "a", mode: 0o600 }); + yield* file.writeAll(new TextEncoder().encode(`${encoded}\n`)); + }), + ).pipe( + Effect.mapError((error) => + error instanceof LogStoreError + ? error + : fileError(options.path, "Unable to append retained logs", error), + ), + ); + } else { + const boundedNext = yield* bounded(next, maxEntries, maxBytes, options.path, nextBytes); + document = boundedNext.document; + retainedBytes = boundedNext.bytes; + yield* persist(fs, options.path, document.entries); + } yield* PubSub.publish(pubsub, entry); return entry; }), diff --git a/packages/stack/src/supervisor/OwnerSession.ts b/packages/stack/src/supervisor/OwnerSession.ts deleted file mode 100644 index 1d2faf71e4..0000000000 --- a/packages/stack/src/supervisor/OwnerSession.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Effect, FileSystem, Scope } from "effect"; -import type { ControlEndpoint } from "../state/Ownership.ts"; -import { - startControlServer, - type ControlServer, - type MaintenanceHandlers, -} from "../control/ControlServer.ts"; -import type { StackRpcHandlers } from "../control/StackRpc.ts"; -import { StackRpcProtocolError } from "../control/StackRpc.ts"; -import type { MaintenanceRequest } from "../control/MaintenanceProtocol.ts"; - -/** A bound owner session; ownership and metadata publication live in Launcher. */ -export interface OwnerSession { - readonly endpoint: ControlEndpoint; - readonly ready: Effect.Effect; - readonly shutdown: Effect.Effect; -} - -export interface OwnerSessionOptions { - readonly endpoint: ControlEndpoint; - readonly stackId: string; - readonly ownerSessionId: string; - readonly rpcRelease?: string; - readonly maintenanceHandlers: MaintenanceHandlers; - /** Called after a stop/quiesce response is written and the connection closes. */ - readonly onMaintenanceComplete?: (op: MaintenanceRequest["op"]) => Effect.Effect; - /** Called when a successful quiesce has no writable response connection. */ - readonly onMaintenanceAbandoned?: (op: MaintenanceRequest["op"]) => Effect.Effect; - /** Called after a successful destroy response is written. */ - readonly onDestroyResponse?: () => Effect.Effect; - /** Called when a successful destroy has no writable response connection. */ - readonly onDestroyAbandoned?: () => Effect.Effect; - readonly rpcHandlers: StackRpcHandlers; -} - -/** - * Bind the exact endpoint and run both control protocols. This function does - * not acquire an ownership lock or write metadata; callers publish metadata - * only after the returned `ready` effect completes. - */ -export const startOwnerSession = ( - options: OwnerSessionOptions, -): Effect.Effect => - startControlServer(options).pipe( - Effect.map((server: ControlServer) => ({ - endpoint: server.endpoint, - ready: server.ready, - shutdown: server.shutdown, - })), - Effect.mapError((error) => new StackRpcProtocolError({ message: error.message })), - ); diff --git a/packages/stack/src/supervisor/Reconciler.ts b/packages/stack/src/supervisor/Reconciler.ts index 98769e5419..37a4af442a 100644 --- a/packages/stack/src/supervisor/Reconciler.ts +++ b/packages/stack/src/supervisor/Reconciler.ts @@ -1,4 +1,4 @@ -import { Cause, Duration, Effect, Exit, Option, Schedule, Semaphore } from "effect"; +import { Cause, Effect, Exit, Option, Schedule, Semaphore } from "effect"; import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; import type { StackId } from "../public/StackId.ts"; import { @@ -8,8 +8,6 @@ import { } from "./DesiredState.ts"; import { RuntimeDriverError, - RuntimeGenerationMismatchError, - RuntimeReadinessTimeoutError, RuntimeRestartBudgetExceededError, type ObservedWorkload, type RuntimeDriver, @@ -17,30 +15,22 @@ import { interface ReconcilerRequest { readonly stackId: StackId; - readonly desiredGeneration: number; readonly desiredLifecycle: DesiredLifecycle; readonly plan: ExecutionPlan; } export interface ReconcilerOptions { readonly driver: RuntimeDriver; - /** Reads durable state and lets every mutation fence itself to the accepted generation. */ - readonly readGeneration: (stackId: StackId) => Effect.Effect; - readonly readinessTimeout?: Duration.Input; } interface ReconciliationResult { - readonly generation: number; readonly observed: ReadonlyArray; readonly started: ReadonlyArray; readonly stopped: ReadonlyArray; readonly removed: ReadonlyArray; readonly failed: ReadonlyArray<{ readonly workloadId: string; - readonly error: - | RuntimeDriverError - | RuntimeReadinessTimeoutError - | RuntimeRestartBudgetExceededError; + readonly error: RuntimeDriverError | RuntimeRestartBudgetExceededError; }>; readonly blocked: ReadonlyArray<{ readonly workloadId: string; readonly dependencyId: string }>; } @@ -51,16 +41,9 @@ export interface Reconciler { ) => Effect.Effect; } -type ReconcilerError = - | RuntimeDriverError - | RuntimeGenerationMismatchError - | RuntimeReadinessTimeoutError - | RuntimeRestartBudgetExceededError; +type ReconcilerError = RuntimeDriverError | RuntimeRestartBudgetExceededError; -type AttemptFailure = - | RuntimeDriverError - | RuntimeGenerationMismatchError - | RuntimeReadinessTimeoutError; +type AttemptFailure = RuntimeDriverError; /** Internal retry sentinel that keeps the complete failed attempt Cause intact. */ class RuntimeAttemptControlError extends RuntimeDriverError { @@ -82,38 +65,20 @@ class RuntimeAttemptControlError extends RuntimeDriverError { export const makeReconciler = (options: ReconcilerOptions): Effect.Effect => Effect.gen(function* () { const lifecycle = yield* Semaphore.make(1); - // Restart budgets are Supervisor-local. A new accepted generation gets a fresh key; repeated - // observations of the same failed generation cannot silently reset an exhausted budget. - const exhausted = new Map(); + // Restart budgets are Supervisor-local and reset when lifecycle enters stopped. + const restartAttempts = new Map(); const reconcile = ( request: ReconcilerRequest, ): Effect.Effect => lifecycle.withPermit( Effect.gen(function* () { - const readGeneration = options.readGeneration; - const generationFence = (expected: number) => - readGeneration(request.stackId).pipe( - Effect.flatMap((actual) => - actual === expected - ? Effect.void - : Effect.fail( - new RuntimeGenerationMismatchError({ - message: `Desired generation changed from ${expected} to ${actual}`, - expectedGeneration: expected, - actualGeneration: actual, - }), - ), - ), - ); - - yield* generationFence(request.desiredGeneration); if (request.desiredLifecycle !== "running") { const prefix = `${request.stackId}:`; - for (const key of exhausted.keys()) if (key.startsWith(prefix)) exhausted.delete(key); + for (const key of restartAttempts.keys()) + if (key.startsWith(prefix)) restartAttempts.delete(key); } const observed = yield* options.driver.observe(request.stackId); - yield* generationFence(request.desiredGeneration); const desired: ReconciliationInput = { ...request, observed }; const delta = planDesiredState(desired); const failed: Array = []; @@ -129,11 +94,9 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect( effect: Effect.Effect, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { - yield* generationFence(request.desiredGeneration); const result = yield* effect.pipe(Effect.exit); - yield* generationFence(request.desiredGeneration); if (Exit.isSuccess(result)) return result.value; return yield* Effect.failCause(result.cause); }); @@ -141,7 +104,6 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect Effect.gen(function* () { const current = yield* options.driver.observe(request.stackId); - yield* generationFence(request.desiredGeneration); const affected: Array<{ readonly workload: (typeof request.plan.workloads)[number]; readonly dependencyId: string; @@ -162,13 +124,11 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect workloadId === workload.id); if ( observed === undefined || - observed.desiredGeneration !== request.desiredGeneration || (observed.state !== "ready" && observed.state !== "starting") ) continue; const key = { stackId: request.stackId, - desiredGeneration: observed.desiredGeneration, workloadId: observed.workloadId, specHash: observed.specHash, }; @@ -184,6 +144,23 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect + entry.stackId === action.key.stackId && + entry.workloadId === action.key.workloadId && + entry.specHash === action.key.specHash, + ); + const workload = request.plan.workloads.find(({ id }) => id === action.key.workloadId); + const budgetKey = `${request.stackId}:${action.key.workloadId}`; + const attempts = restartAttempts.get(budgetKey); + const preserveExhaustedFailure = + observedEntry?.state === "failed" && + workload !== undefined && + attempts !== undefined && + attempts >= Math.max(1, workload.restart.maxAttempts); + // Keep a terminal failed resource visible to status. Removing it would make an + // exhausted post-readiness crash look dormant/stopped and lose the diagnostic error. + if (preserveExhaustedFailure) continue; yield* runMutation(options.driver.remove(action.key)); removed.push(action.key.workloadId); } @@ -198,9 +175,9 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect= maxAttempts) { failedIds.add(action.workload.id); failed.push({ workloadId: action.workload.id, @@ -214,25 +191,32 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect - Option.isSome(result) - ? Effect.succeed(result.value) - : Effect.fail( - new RuntimeReadinessTimeoutError({ - message: `Readiness deadline exceeded for ${action.workload.id}`, - stackId: request.stackId, - workloadId: action.workload.id, - desiredGeneration: request.desiredGeneration, - }), - ), - ), - ); + const hadReadyFailure = observed.some( + (entry) => + entry.workloadId === action.workload.id && + entry.specHash === action.workload.specHash && + entry.state === "failed", + ); + if (hadReadyFailure) { + const attempts = restartAttempts.get(budgetKey) ?? 0; + if (attempts >= maxAttempts) { + restartAttempts.set(budgetKey, attempts); + failedIds.add(action.workload.id); + failed.push({ + workloadId: action.workload.id, + error: new RuntimeRestartBudgetExceededError({ + message: `Restart budget exhausted for ${action.workload.id}`, + stackId: request.stackId, + workloadId: action.workload.id, + attempts, + }), + }); + yield* quiesceDependents(); + continue; + } + restartAttempts.set(budgetKey, attempts + 1); + } + const attempt = options.driver.start(action.key, action.workload); const schedule: Schedule.Schedule = Schedule.exponential(`${Math.max(0, action.workload.restart.backoffMs)} millis`).pipe( Schedule.upTo({ times: Math.max(0, maxAttempts - 1) }), @@ -243,8 +227,7 @@ export const makeReconciler = (options: ReconcilerOptions): Effect.Effect): ExecutionPlan => ({ pooler: "eager", }, startOrder: ["database"], - stopOrder: ["database"], dependencies: { database: [], rest: [], @@ -59,11 +58,9 @@ const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ const observed = ( workloadId: string, state: ObservedWorkload["state"], - desiredGeneration = 1, specHash = workloadId, ): ObservedWorkload => ({ stackId, - desiredGeneration, workloadId, specHash, state, @@ -75,7 +72,6 @@ describe("desired workload planning", () => { const plan = planFor(workloads); const running = planDesiredState({ stackId, - desiredGeneration: 1, desiredLifecycle: "running", plan, observed: [], @@ -84,7 +80,6 @@ describe("desired workload planning", () => { const stopped = planDesiredState({ stackId, - desiredGeneration: 1, desiredLifecycle: "stopped", plan, observed: workloads.map(({ id }) => observed(id, "ready")), @@ -94,7 +89,6 @@ describe("desired workload planning", () => { const destroying = planDesiredState({ stackId, - desiredGeneration: 1, desiredLifecycle: "destroying", plan, observed: workloads.map(({ id }) => observed(id, "ready")), @@ -102,23 +96,22 @@ describe("desired workload planning", () => { expect(destroying.stops.map(({ key }) => key.workloadId)).toEqual(["c", "b", "a"]); }); - it("replaces a stale generation or workload spec before starting the accepted key", () => { + it("replaces a stale workload spec before starting the accepted key", () => { const plan = planFor([workload("a", [], "new-hash")]); const delta = planDesiredState({ stackId, - desiredGeneration: 2, desiredLifecycle: "running", plan, - observed: [observed("a", "ready", 1, "old-hash")], + observed: [observed("a", "ready", "old-hash")], }); expect(delta.stops.map(({ key }) => key)).toEqual([ - { stackId, desiredGeneration: 1, workloadId: "a", specHash: "old-hash" }, + { stackId, workloadId: "a", specHash: "old-hash" }, ]); expect(delta.removes.map(({ key }) => key)).toEqual([ - { stackId, desiredGeneration: 1, workloadId: "a", specHash: "old-hash" }, + { stackId, workloadId: "a", specHash: "old-hash" }, ]); expect(delta.starts.map(({ key }) => key)).toEqual([ - { stackId, desiredGeneration: 2, workloadId: "a", specHash: "new-hash" }, + { stackId, workloadId: "a", specHash: "new-hash" }, ]); }); @@ -126,14 +119,13 @@ describe("desired workload planning", () => { const plan = planFor([workload("a"), workload("b", ["a"])]); const delta = planDesiredState({ stackId, - desiredGeneration: 2, desiredLifecycle: "running", plan, observed: [ - observed("a", "ready", 1, "old-a"), - observed("a", "ready", 2, "a"), - observed("b", "ready", 1, "old-b"), - observed("b", "ready", 2, "b"), + observed("a", "ready", "old-a"), + observed("a", "ready", "a"), + observed("b", "ready", "old-b"), + observed("b", "ready", "b"), ], }); expect(delta.starts).toHaveLength(0); @@ -150,7 +142,6 @@ describe("desired workload planning", () => { ]); const delta = planDesiredState({ stackId, - desiredGeneration: 1, desiredLifecycle: "running", plan, observed: [observed("root-failed", "failed")], diff --git a/packages/stack/src/supervisor/StatusHub.ts b/packages/stack/src/supervisor/StatusHub.ts deleted file mode 100644 index c7b1663f0e..0000000000 --- a/packages/stack/src/supervisor/StatusHub.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Effect, Stream, SubscriptionRef } from "effect"; -import type { StackStatus } from "../public/Status.ts"; - -/** - * In-memory complete status projection shared by a supervisor and its clients. - * - * `changes` starts each subscriber with the current snapshot and then emits - * every complete replacement. SubscriptionRef provides the serialization and - * broadcast semantics; no per-subscriber waiter bookkeeping is required. - */ -export interface StatusHub { - readonly current: Effect.Effect; - readonly publish: (status: StackStatus) => Effect.Effect; - readonly changes: Stream.Stream; -} - -export const makeStatusHub = (initial: StackStatus): Effect.Effect => - Effect.map(SubscriptionRef.make(initial), (ref) => ({ - current: SubscriptionRef.get(ref), - publish: (status: StackStatus) => SubscriptionRef.set(ref, status), - changes: SubscriptionRef.changes(ref), - })); diff --git a/packages/stack/src/supervisor/StatusProjection.ts b/packages/stack/src/supervisor/StatusProjection.ts new file mode 100644 index 0000000000..508c5e7ab9 --- /dev/null +++ b/packages/stack/src/supervisor/StatusProjection.ts @@ -0,0 +1,130 @@ +import { Effect, Schema } from "effect"; +import { + CAPABILITY_NAMES, + type CapabilityName, + type CapabilityStatus, +} from "../public/Capability.ts"; +import { StackStateInvalidError } from "../public/Errors.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import { PORT_FIELD_PROTOCOL, type StackStatus } from "../public/Status.ts"; +import type { ObservedWorkload } from "../runtime/RuntimeDriver.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; + +export type ActualPhase = "stopped" | "starting" | "running" | "stopping" | "destroying"; + +const observedForCapability = ( + name: CapabilityName, + observed: ReadonlyArray, +): ReadonlyArray => + observed.filter((entry) => entry.workloadId.startsWith(`${name}:`)); + +const capabilityState = ( + name: CapabilityName, + state: PersistedStackState, + observed: ReadonlyArray, + active: ReadonlySet, + phase: ActualPhase, +): CapabilityStatus["state"] => { + const configured = state.definition?.capabilities[name]; + if (configured === undefined || !configured.enabled) return "disabled"; + if ( + state.desiredLifecycle !== "running" || + phase === "stopped" || + phase === "stopping" || + phase === "destroying" + ) + return "stopped"; + if (configured.activation === "lazy" && !active.has(name)) return "dormant"; + if (phase === "starting") return "starting"; + const resources = observedForCapability(name, observed); + if (resources.some((entry) => entry.state === "failed")) return "failed"; + if (resources.some((entry) => entry.state === "starting")) return "starting"; + if (resources.length > 0 && resources.every((entry) => entry.state === "ready")) return "ready"; + return "stopped"; +}; + +const capabilityError = ( + name: CapabilityName, + observed: ReadonlyArray, + state: CapabilityStatus["state"], +): string | undefined => { + if (state !== "failed") return undefined; + const failed = observedForCapability(name, observed).filter((entry) => entry.state === "failed"); + if (failed.length === 1) return failed[0]?.error; + const details = failed.flatMap((entry) => + entry.error === undefined ? [] : [`${entry.workloadId}: ${entry.error}`], + ); + return details.length === 0 ? undefined : details.join("; "); +}; + +const stackLifecycle = ( + state: PersistedStackState, + phase: ActualPhase, + activeStates: ReadonlyArray, +): StackStatus["lifecycle"] => { + if (state.desiredLifecycle === "unconfigured") return "unconfigured"; + if (phase === "destroying" || state.desiredLifecycle === "destroying") return "destroying"; + if (phase === "stopping") return "stopping"; + if (phase === "starting") return "starting"; + if (state.desiredLifecycle === "stopped") return "stopped"; + if (phase === "running") return "running"; + if (activeStates.some(({ state }) => state === "starting")) return "starting"; + if (activeStates.length > 0 && activeStates.every(({ state }) => state === "ready")) + return "running"; + return "stopped"; +}; + +export const statusFor = ( + state: PersistedStackState, + observed: ReadonlyArray, + active: ReadonlySet, + phase: ActualPhase, +): Effect.Effect => + Schema.decodeEffect(StackIdSchema)(state.identity.stackId).pipe( + Effect.mapError( + (error) => + new StackStateInvalidError({ message: `Invalid persisted StackId: ${String(error)}` }), + ), + Effect.map((id) => { + const definition = state.definition; + const capabilities = CAPABILITY_NAMES.map((name) => { + const capability = capabilityState(name, state, observed, active, phase); + const error = capabilityError(name, observed, capability); + return { + name, + activation: + definition?.capabilities[name].activation ?? (name === "database" ? "eager" : "lazy"), + state: capability, + ...(error === undefined ? {} : { error }), + }; + }); + const versions: Partial> = {}; + if (definition !== undefined) + for (const name of CAPABILITY_NAMES) versions[name] = definition.capabilities[name].version; + const endpoints = state.ports.reduce((result, assignment) => { + const protocol = PORT_FIELD_PROTOCOL[assignment.field]; + const listener = definition?.listeners[assignment.field]; + return { + ...result, + [assignment.field]: { + protocol, + address: listener?.address ?? "127.0.0.1", + port: assignment.port, + url: `${protocol}://${listener?.address ?? "127.0.0.1"}:${assignment.port}`, + }, + }; + }, {}); + const activeStates = capabilities.filter( + ({ name }) => active.has(name) && definition?.capabilities[name].enabled, + ); + return { + id, + lifecycle: stackLifecycle(state, phase, activeStates), + desiredLifecycle: state.desiredLifecycle, + runtime: state.runtime, + endpoints, + versions, + capabilities, + } satisfies StackStatus; + }), + ); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index f23eb90cc8..5998e60f29 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -1,12 +1,12 @@ import { Context, - Cause, Crypto, Deferred, Effect, Exit, FileSystem, FiberSet, + Option, Path, Redacted, Ref, @@ -14,15 +14,17 @@ import { Semaphore, Scope, Stream, + SubscriptionRef, } from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; import { canonicalize, compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import { - CAPABILITY_NAMES, - type CapabilityName, - type CapabilityStatus, -} from "../public/Capability.ts"; + activeExecutionPlan, + eagerCapabilities, + type ExecutionPlan, + type PlannedWorkload, +} from "../model/ExecutionPlan.ts"; +import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; import { StackConfigSchema, type StackConfig } from "../public/Config.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { @@ -36,16 +38,11 @@ import { StackStateInvalidError, type StackError, } from "../public/Errors.ts"; -import type { StackEndpoint, StackStatus } from "../public/Status.ts"; -import { StackIdSchema, type StackId } from "../public/StackId.ts"; +import type { StackStatus } from "../public/Status.ts"; +import type { StackId } from "../public/StackId.ts"; import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; import type { EffectStackCredentials } from "../public/Credentials.ts"; -import type { - RuntimeDriver, - RuntimeRecoveryRequest, - ObservedWorkload, -} from "../runtime/RuntimeDriver.ts"; -import { RuntimeDriverError, RuntimeGenerationMismatchError } from "../runtime/RuntimeDriver.ts"; +import type { RuntimeDriver, ObservedWorkload } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import { makeReconciler, type Reconciler } from "./Reconciler.ts"; @@ -53,14 +50,13 @@ import { makeLifecycleController, type LifecycleBackend, type LifecycleInput, - type LifecyclePrepared, } from "./Lifecycle.ts"; -import { makeStatusHub } from "./StatusHub.ts"; import type { LogStore } from "./LogStore.ts"; import type { SupervisorIngress } from "./Ingress.ts"; import { StackRpcGroup, type StackRpcError, type StackRpcHandlers } from "../control/StackRpc.ts"; import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; import type { PreparedWorkloadArtifact } from "../preparation/RuntimeArtifacts.ts"; +import { statusFor, type ActualPhase } from "./StatusProjection.ts"; interface ActivationResult { readonly capability: CapabilityName; @@ -71,18 +67,21 @@ interface ActivationResult { export interface SupervisorRuntime { readonly driver: RuntimeDriver; /** Verifies and prepares immutable workload artifacts without changing lifecycle state. */ - readonly prepare?: ( + readonly prepare: ( runtime: StackRuntime, workloads: ReadonlyArray, ) => Effect.Effect, StackError>; - readonly preflight?: (input: LifecycleInput) => Effect.Effect; - readonly activate?: ( + readonly preflight: ( + input: LifecycleInput, + mode: "cold" | "live", + ) => Effect.Effect; + readonly activate: ( capability: CapabilityName, input: LifecycleInput, ) => Effect.Effect; - /** Optional Supervisor-owned public ingress and lazy route activation lifecycle. */ - readonly ingress?: SupervisorIngress; - readonly logStore?: LogStore; + /** Supervisor-owned public ingress and lazy route activation lifecycle. */ + readonly ingress: SupervisorIngress; + readonly logStore: LogStore; } export interface SupervisorRuntimeFactory { @@ -101,12 +100,10 @@ export interface Supervisor { readonly config?: StackConfig; }) => Effect.Effect; readonly destroy: Effect.Effect; - /** Completes persisted-running recovery for a deferred owner session. */ - readonly recover: Effect.Effect; - /** Completes after a successful destroy or quiesce shutdown signal. */ + /** Completes after a successful stop or destroy shutdown signal. */ readonly shutdown: Effect.Effect; - /** Signals owner shutdown after accepted quiesce or a successful destroy response. */ - readonly signalShutdown: Effect.Effect; + /** Shuts down only when durable state is absent or cleanly non-running. */ + readonly shutdownIfIdle: Effect.Effect; readonly watchStatus: Stream.Stream; readonly logs: (options?: LogOptions) => Stream.Stream; readonly activate: ( @@ -115,24 +112,28 @@ export interface Supervisor { readonly maintenanceHandlers: { readonly probe: Effect.Effect; readonly stop: Effect.Effect; - readonly quiesce: Effect.Effect; }; readonly rpcHandlers: StackRpcHandlers; } -export interface SupervisorOptions { +type SupervisorRuntimeInput = + | { + readonly runtime: SupervisorRuntime; + readonly runtimeFactory?: never; + } + | { + readonly runtimeFactory: SupervisorRuntimeFactory; + readonly runtime?: never; + }; + +export type SupervisorOptions = { readonly identity: StackIdentity; readonly stackId: StackId; readonly ownerSessionId: string; readonly rpcRelease: string; readonly stateStore: StackStateStore; readonly context: Context.Context; - /** Tests and future catalog composition may provide a concrete runtime. */ - readonly runtime?: SupervisorRuntime; - readonly runtimeFactory?: SupervisorRuntimeFactory; -} - -type ActualPhase = "stopped" | "starting" | "running" | "stopping" | "destroying"; +} & SupervisorRuntimeInput; const rpcError = (tag: StackRpcError["tag"], message: string): StackRpcError => ({ tag, message }); const stateErrorMessage = (error: StackError | { readonly message?: string }): string => @@ -153,196 +154,8 @@ const revealRedacted = (value: unknown): unknown => { const digestHex = (bytes: Uint8Array): string => [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -const runtimeUnavailable = (): SupervisorRuntime => { - const unavailable = (operation: string): Effect.Effect => - Effect.fail(new RuntimeDriverError({ message: `Runtime ${operation} is unavailable` })); - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: () => unavailable("start"), - stop: () => unavailable("stop"), - remove: () => unavailable("remove"), - cleanup: () => unavailable("cleanup"), - recover: () => unavailable("recovery"), - }; - return { - driver, - preflight: () => - Effect.fail(new StackReconciliationError({ message: "Stack runtime is unavailable" })), - }; -}; - -const eagerCapabilities = (plan: ExecutionPlan): Set => { - const active = new Set(); - const visit = (name: CapabilityName): void => { - if (active.has(name)) return; - active.add(name); - for (const dependency of plan.dependencies[name]) visit(dependency); - }; - for (const name of CAPABILITY_NAMES) if (plan.activation[name] === "eager") visit(name); - return active; -}; - -const activePlan = (plan: ExecutionPlan, active: ReadonlySet): ExecutionPlan => ({ - ...plan, - workloads: plan.workloads.filter((workload) => active.has(workload.capability)), - startOrder: plan.startOrder.filter((name) => active.has(name)), - stopOrder: plan.stopOrder.filter((name) => active.has(name)), -}); - -const observedForCapability = ( - name: CapabilityName, - observed: ReadonlyArray, -): ReadonlyArray => - observed.filter((entry) => entry.workloadId.startsWith(`${name}:`)); - -const capabilityState = ( - name: CapabilityName, - state: PersistedStackState, - observed: ReadonlyArray, - active: ReadonlySet, - phase: ActualPhase, - recoveryFailed: boolean, -): CapabilityStatus["state"] => { - const configured = state.definition?.capabilities[name]; - if (configured === undefined || !configured.enabled) return "disabled"; - if ( - state.desiredLifecycle !== "running" || - phase === "stopped" || - phase === "stopping" || - phase === "destroying" - ) - return "stopped"; - if (recoveryFailed) return "failed"; - if (configured.activation === "lazy" && !active.has(name)) return "dormant"; - if (phase === "starting") return "starting"; - const resources = observedForCapability(name, observed); - if (resources.some((entry) => entry.state === "failed")) return "failed"; - if (resources.some((entry) => entry.state === "starting")) return "starting"; - if (resources.length > 0 && resources.every((entry) => entry.state === "ready")) return "ready"; - return "stopped"; -}; - -const capabilityError = ( - name: CapabilityName, - observed: ReadonlyArray, - state: CapabilityStatus["state"], -): string | undefined => { - if (state !== "failed") return undefined; - const failed = observedForCapability(name, observed).filter((entry) => entry.state === "failed"); - if (failed.length === 1) return failed[0]?.error; - const details = failed.flatMap((entry) => - entry.error === undefined ? [] : [`${entry.workloadId}: ${entry.error}`], - ); - return details.length === 0 ? undefined : details.join("; "); -}; - -const statusFor = ( - state: PersistedStackState, - observed: ReadonlyArray, - active: ReadonlySet, - phase: ActualPhase, - recoveryFailed: boolean, -): Effect.Effect => - Schema.decodeEffect(StackIdSchema)(state.identity.stackId).pipe( - Effect.mapError( - (error) => - new StackStateInvalidError({ message: `Invalid persisted StackId: ${String(error)}` }), - ), - Effect.map((id) => { - const definition = state.definition; - const capabilities = CAPABILITY_NAMES.map((name) => { - const capability = capabilityState(name, state, observed, active, phase, recoveryFailed); - const error = capabilityError(name, observed, capability); - return { - name, - activation: definition?.capabilities[name].activation ?? "eager", - state: capability, - ...(error === undefined ? {} : { error }), - }; - }); - const versions: Partial> = {}; - if (definition !== undefined) - for (const name of CAPABILITY_NAMES) versions[name] = definition.capabilities[name].version; - const endpoints = state.ports.reduce< - Readonly< - Partial< - Record< - | "api" - | "database" - | "pooler" - | "studio" - | "mailUi" - | "smtp" - | "pop3" - | "functionsInspector", - StackEndpoint - > - > - > - >((result, assignment) => { - const tcp = - assignment.field === "database" || - assignment.field === "pooler" || - assignment.field === "smtp" || - assignment.field === "pop3"; - const protocol = tcp ? "tcp" : "http"; - const listener = state.definition?.listeners[assignment.field]; - return { - ...result, - [assignment.field]: { - protocol, - address: listener?.address ?? "127.0.0.1", - port: assignment.port, - url: `${protocol}://${listener?.address ?? "127.0.0.1"}:${assignment.port}`, - }, - }; - }, {}); - const activeStates = capabilities.filter( - ({ name }) => active.has(name) && definition?.capabilities[name].enabled, - ); - const anyStarting = activeStates.some(({ state }) => state === "starting"); - const anyFailed = activeStates.some(({ state }) => state === "failed"); - const allReady = - activeStates.length > 0 && activeStates.every(({ state }) => state === "ready"); - const lifecycle = - state.desiredLifecycle === "unconfigured" - ? "unconfigured" - : state.desiredLifecycle === "destroying" - ? "destroying" - : state.desiredLifecycle === "stopped" - ? "stopped" - : recoveryFailed - ? "stopped" - : anyFailed - ? "stopped" - : phase === "starting" - ? "starting" - : phase === "stopping" - ? "stopping" - : phase === "destroying" - ? "destroying" - : anyStarting - ? "starting" - : allReady - ? "running" - : "stopped"; - return { - id, - lifecycle, - desiredLifecycle: state.desiredLifecycle, - runtime: state.runtime, - desiredGeneration: state.desiredGeneration, - endpoints, - versions, - capabilities, - } satisfies StackStatus; - }), - ); - const mapReconcileError = (error: unknown): StackError => { if (error instanceof StackStateInvalidError) return error; - if (error instanceof RuntimeGenerationMismatchError) - return new StackLifecycleConflictError({ message: error.message }); return new StackReconciliationError({ message: error instanceof Error ? error.message : String(error), cause: error, @@ -362,75 +175,38 @@ export const makeSupervisor = ( if (initial === undefined) return yield* new StackStateInvalidError({ message: "Stack state is missing" }); const runtime = - options.runtime ?? - (options.runtimeFactory === undefined - ? runtimeUnavailable() - : yield* options.runtimeFactory.make(initial)); + options.runtimeFactory !== undefined + ? yield* options.runtimeFactory.make(initial) + : options.runtime; const reconciler: Reconciler = yield* makeReconciler({ driver: runtime.driver, - readGeneration: (stackId) => - options.stateStore.read(stackId).pipe( - Effect.provideContext(options.context), - Effect.flatMap((state) => - state === undefined - ? Effect.fail(new RuntimeDriverError({ message: "Stack state is missing", stackId })) - : Effect.succeed(state.desiredGeneration), - ), - Effect.mapError((error) => - error instanceof RuntimeDriverError - ? error - : new RuntimeDriverError({ message: error.message, stackId, cause: error }), - ), - ), }); const active = yield* Ref.make>(new Set()); - const generation = yield* Ref.make(initial.desiredGeneration); - const phase = yield* Ref.make( - initial.desiredLifecycle === "running" ? "starting" : "stopped", - ); - const recoveryFailure = yield* Ref.make(false); + const phase = yield* Ref.make("stopped"); + // A Supervisor created after a crash must clean exact runtime ephemera once before its first + // fresh start. The marker is session-local and only advances after cleanup succeeds. + const sessionInitialized = yield* Ref.make(false); type ActivationHandler = ( capability: CapabilityName, ) => Effect.Effect; - // The ingress is opened during persisted-running recovery, before the public Supervisor - // methods are assembled. A one-shot handoff keeps a request waiting for the handler instead of - // exposing a construction-time race. + // The ingress opens during an explicit lifecycle operation. A one-shot handoff keeps a request + // waiting for the handler instead of exposing a construction-time race. const activationHandler = yield* Deferred.make(); const ingressActivate = ( capability: CapabilityName, ): Effect.Effect => Deferred.await(activationHandler).pipe(Effect.flatMap((handler) => handler(capability))); - const initializeActivation = ( - plan: ExecutionPlan, - observed: ReadonlyArray = [], - ) => { - const next = eagerCapabilities(plan); - for (const entry of observed) { - const capability = CAPABILITY_NAMES.find((name) => entry.workloadId.startsWith(`${name}:`)); - if (capability !== undefined) next.add(capability); - } - return Ref.set(active, next); - }; - const resetForGeneration = (input: LifecycleInput) => + const initializeActivation = (plan: ExecutionPlan) => Ref.set(active, eagerCapabilities(plan)); + const resetForSession = (input: LifecycleInput) => Effect.gen(function* () { - const current = yield* Ref.get(generation); - if (current === input.generation && input.desiredLifecycle === "running") return; - if (input.generation < current) - return yield* new StackLifecycleConflictError({ - stackId: options.stackId, - message: "Lifecycle input generation is older than the adopted generation", - }); - yield* Ref.set(generation, input.generation); activationOwned.clear(); yield* initializeActivation(input.plan); }); const observe = () => runtime.driver.observe(options.stackId).pipe(Effect.mapError(mapReconcileError)); const observedForStatus = () => - Effect.all({ phase: Ref.get(phase), recoveryFailed: Ref.get(recoveryFailure) }).pipe( - Effect.flatMap(({ phase: current, recoveryFailed }) => - current === "running" && !recoveryFailed ? observe() : Effect.succeed([]), - ), + Ref.get(phase).pipe( + Effect.flatMap((current) => (current === "running" ? observe() : Effect.succeed([]))), ); const initialStatus = yield* statusFor( @@ -438,9 +214,8 @@ export const makeSupervisor = ( [], yield* Ref.get(active), yield* Ref.get(phase), - false, ); - const hub = yield* makeStatusHub(initialStatus); + const hub = yield* SubscriptionRef.make(initialStatus); const snapshot = (): Effect.Effect => Effect.gen(function* () { const state = yield* read(); @@ -451,24 +226,30 @@ export const makeSupervisor = ( yield* observedForStatus(), yield* Ref.get(active), yield* Ref.get(phase), - yield* Ref.get(recoveryFailure), ); return status; }); const publish = (): Effect.Effect => - snapshot().pipe(Effect.tap((value) => hub.publish(value))); + snapshot().pipe(Effect.tap((value) => SubscriptionRef.set(hub, value))); const restorePhase = (previous: ActualPhase): Effect.Effect => Effect.gen(function* () { const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); - const next: ActualPhase = - previous === "running" && state?.desiredLifecycle === "running" ? "running" : "stopped"; + const currentPhase = yield* Ref.get(phase); + let next: ActualPhase = "stopped"; + if (state?.desiredLifecycle === "destroying") next = "destroying"; + else if (state?.desiredLifecycle === "running") { + if (currentPhase === "running" || previous === "running") next = "running"; + else next = "starting"; + } yield* Ref.set(phase, next); yield* publish().pipe(Effect.ignore); }); - // Admission and execution are separate: callers join the same owned fiber while the - // execution semaphore serializes lifecycle/activation transitions. + // Admission joins equivalent operations while execution serializes lifecycle and activation + // work against background failure supervision. const admission = yield* Semaphore.make(1); + // Background failure supervision shares the execution gate with explicit lifecycle operations. + // This prevents a failure event from reconciling against a concurrently stopping lifecycle. const execution = yield* Semaphore.make(1); const supervisorScope = yield* Effect.scope; const ownedFibers = yield* FiberSet.make().pipe( @@ -476,40 +257,64 @@ export const makeSupervisor = ( ); const joinExit = (result: Exit.Exit): Effect.Effect => Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); - type OwnedResult = Deferred.Deferred, never>; - const startOwned = new Map>(); - const restartOwned = new Map>(); - const stopOwned = new Map>(); - const destroyOwned = new Map>(); - const recoveryOwned = new Map>(); + type LifecycleKind = "start" | "restart" | "stop" | "destroy"; + type LifecycleResult = Deferred.Deferred, never>; + type ActiveLifecycle = Readonly<{ + kind: LifecycleKind; + key: string; + result: LifecycleResult; + }>; + const lifecycleActive = yield* Ref.make(undefined); + const preparationsActive = yield* Ref.make(0); + const shutdownSignal = yield* Deferred.make(); + const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); + const ensureAcceptingOperations = Deferred.poll(shutdownSignal).pipe( + Effect.flatMap((shutdown) => + Option.isNone(shutdown) + ? Effect.void + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: "Stack owner is shutting down", + }), + ), + ), + ); - const submitOwned = ( - slots: Map>, + const submitLifecycle = ( + kind: LifecycleKind, key: string, - effect: Effect.Effect, - onWaiterInterrupt?: (owned: OwnedResult) => Effect.Effect, - ): Effect.Effect => + effect: Effect.Effect, + onWaiterInterrupt?: (owned: LifecycleResult) => Effect.Effect, + ): Effect.Effect => Effect.gen(function* () { const result = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const owned = yield* admission.withPermit( Effect.gen(function* () { - const current = slots.get(key); - if (current !== undefined) return current; - const deferred = yield* Deferred.make, never>(); - slots.set(key, deferred); + yield* ensureAcceptingOperations; + const current = yield* Ref.get(lifecycleActive); + if (current !== undefined) { + if (current.kind === kind && current.key === key) return current.result; + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Lifecycle operation ${current.kind} is already active`, + }); + } + const deferred = yield* Deferred.make, never>(); + yield* Ref.set(lifecycleActive, { kind, key, result: deferred }); + const release = admission.withPermit( + Ref.update(lifecycleActive, (current) => + current?.result === deferred ? undefined : current, + ), + ); const owner = Effect.gen(function* () { const result = yield* execution.withPermit(effect).pipe(Effect.exit); + // Release the admission slot before waking waiters so a completed operation + // cannot make the next lifecycle request look like a conflict. + yield* release; yield* Deferred.succeed(deferred, result); - }).pipe( - Effect.ensuring( - admission.withPermit( - Effect.sync(() => { - if (slots.get(key) === deferred) slots.delete(key); - }), - ), - ), - ); + }).pipe(Effect.ensuring(release)); yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); return deferred; }), @@ -526,44 +331,33 @@ export const makeSupervisor = ( const reconcileBackend = ( input: LifecycleInput, + session: "fresh" | "current", selectedOverride?: ReadonlySet, ): Effect.Effect => Effect.gen(function* () { + if (session === "fresh") yield* resetForSession(input); const reservation = - input.desiredLifecycle === "running" && runtime.ingress !== undefined - ? yield* runtime.ingress.acquire(input) - : undefined; - if (input.desiredLifecycle !== "running" && runtime.ingress !== undefined) - yield* runtime.ingress.close; - yield* resetForGeneration(input); + input.desiredLifecycle === "running" ? yield* runtime.ingress.acquire(input) : undefined; + if (input.desiredLifecycle !== "running") yield* runtime.ingress.close; + if (session === "fresh") yield* Ref.set(sessionInitialized, true); const selected = selectedOverride ?? (yield* Ref.get(active)); const plan = - input.desiredLifecycle === "running" ? activePlan(input.plan, selected) : input.plan; + input.desiredLifecycle === "running" + ? activeExecutionPlan(input.plan, selected) + : input.plan; const reconciled = yield* reconciler .reconcile({ stackId: options.stackId, - desiredGeneration: input.generation, desiredLifecycle: input.desiredLifecycle, plan, }) .pipe(Effect.mapError(mapReconcileError), Effect.exit); if (Exit.isFailure(reconciled)) { - if (reservation?.fresh === true && runtime.ingress !== undefined) - yield* runtime.ingress.close.pipe(Effect.ignore); + if (reservation?.fresh === true) yield* runtime.ingress.close.pipe(Effect.ignore); return yield* Effect.failCause(reconciled.cause); } const result = reconciled.value; - if (result.failed.length > 0) - return yield* Effect.gen(function* () { - if (reservation?.fresh === true && runtime.ingress !== undefined) - yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* new StackReconciliationError({ - message: result.failed - .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) - .join("; "), - }); - }); - if (runtime.ingress !== undefined && reservation !== undefined) { + if (reservation !== undefined) { const opened = yield* runtime.ingress .open(input, reservation, ingressActivate) .pipe(Effect.exit); @@ -572,30 +366,39 @@ export const makeSupervisor = ( return yield* Effect.failCause(opened.cause); } } + if (result.failed.length > 0) { + // Keep independent workloads and the gateway reachable. The failed capability remains + // visible through status while start reports a typed reconciliation failure. + yield* Ref.set(phase, "running"); + yield* publish().pipe(Effect.ignore); + return yield* new StackReconciliationError({ + message: result.failed + .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) + .join("; "), + }); + } yield* publish(); }); + const cleanupEpoch = yield* Ref.make(0); const backend: LifecycleBackend = { - preflight: (input) => - runtime.preflight === undefined ? Effect.succeed({}) : runtime.preflight(input), + preflight: runtime.preflight, reconcile: reconcileBackend, - cleanup: () => - Effect.gen(function* () { - if (runtime.ingress !== undefined) yield* runtime.ingress.close; - yield* runtime.driver - .cleanup({ stackId: options.stackId, destroy: false }) - .pipe(Effect.mapError(mapReconcileError)); - yield* Ref.set(active, new Set()); - yield* Ref.set(phase, "stopped"); - yield* publish(); - }), - destroyData: () => - Effect.gen(function* () { - if (runtime.ingress !== undefined) yield* runtime.ingress.close; - yield* runtime.driver - .cleanup({ stackId: options.stackId, destroy: true }) - .pipe(Effect.mapError(mapReconcileError)); - }), + cleanup: Effect.gen(function* () { + yield* runtime.ingress.close; + yield* runtime.driver + .cleanup({ stackId: options.stackId, destroy: false }) + .pipe(Effect.mapError(mapReconcileError)); + yield* Ref.set(active, new Set()); + yield* Ref.update(cleanupEpoch, (epoch) => epoch + 1); + yield* publish(); + }), + destroyData: Effect.gen(function* () { + yield* runtime.ingress.close; + yield* runtime.driver + .cleanup({ stackId: options.stackId, destroy: true }) + .pipe(Effect.mapError(mapReconcileError)); + }), }; const controller = yield* makeLifecycleController({ stackId: options.stackId, @@ -605,94 +408,16 @@ export const makeSupervisor = ( }).pipe(Effect.provideContext(options.context)); const status = snapshot(); - const recoveryOperation: Effect.Effect = Effect.gen(function* () { - const current = yield* read(); - if (current === undefined) - return yield* new StackStateInvalidError({ message: "Stack state is missing" }); - // Recovery is deferred until after owner publication. A lifecycle operation may have - // superseded the construction snapshot while it waited for execution; that operation owns - // the current phase/generation, so stale recovery must become a no-op. - if ( - current.desiredGeneration !== initial.desiredGeneration || - current.desiredLifecycle !== initial.desiredLifecycle || - current.inputFingerprint !== initial.inputFingerprint - ) - return; - const definition = current.definition; - if (definition === undefined || current.desiredLifecycle !== "running") { - yield* Ref.set(recoveryFailure, false); - if (options.runtime !== undefined || options.runtimeFactory !== undefined) - yield* runtime.driver - .cleanup({ stackId: options.stackId, destroy: false }) - .pipe(Effect.mapError(mapReconcileError)); - yield* Ref.set(active, new Set()); - yield* Ref.set(phase, "stopped"); - yield* publish().pipe(Effect.ignore); - return; - } - - yield* Ref.set(phase, "starting"); - yield* Ref.set(recoveryFailure, false); - yield* publish().pipe(Effect.ignore); - const attempt = yield* Effect.exit( - Effect.gen(function* () { - const plan = yield* rebuildExecutionPlan(current.runtime, definition).pipe( - Effect.provideContext(options.context), - Effect.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), - ), - ); - const request: RuntimeRecoveryRequest = { - stackId: options.stackId, - desiredGeneration: current.desiredGeneration, - desiredLifecycle: "running", - plan, - }; - const recoveryInput: LifecycleInput = { - stackId: options.stackId, - generation: current.desiredGeneration, - desiredLifecycle: "running", - state: current, - previous: current, - definition, - inputFingerprint: current.inputFingerprint ?? "", - secrets: current.secrets, - plan, - }; - yield* initializeActivation(plan); - const recovered = yield* runtime.driver - .recover(request) - .pipe(Effect.mapError(mapReconcileError)); - yield* initializeActivation(plan, recovered); - yield* reconcileBackend(recoveryInput); - }), - ); - if (Exit.isFailure(attempt)) { - // Recovery must not tear down the owner session. Keep the phase observable as starting - // and retain the cause in the owner-only log for the next inspection/retry. - if (runtime.logStore !== undefined) - yield* runtime.logStore - .append({ - source: "supervisor", - stream: "stderr", - message: `Stack recovery failed: ${Cause.pretty(attempt.cause)}`, - }) - .pipe(Effect.ignore); - yield* Ref.set(recoveryFailure, true); - yield* Ref.set(phase, "starting"); - yield* publish().pipe(Effect.ignore); - return; - } - yield* Ref.set(phase, "running"); - yield* publish().pipe(Effect.ignore); - }).pipe(Effect.provideContext(options.context)); - - const recover = submitOwned(recoveryOwned, "recovery", recoveryOperation).pipe(Effect.ignore); - const activateOperation = ( capability: CapabilityName, ): Effect.Effect => Effect.gen(function* () { + const lifecycle = yield* Ref.get(lifecycleActive); + if (lifecycle !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Cannot activate while ${lifecycle.kind} is in progress`, + }); const state = yield* read(); if (state === undefined) return yield* new StackStateInvalidError({ message: "Stack state is missing" }); @@ -700,10 +425,6 @@ export const makeSupervisor = ( return yield* new StackNotRunningError({ message: "Stack must be running before activation", }); - if (yield* Ref.get(recoveryFailure)) - return yield* new StackNotRunningError({ - message: "Stack recovery has failed; start or restart the stack before activation", - }); const definition = state.definition; if (definition === undefined || !definition.capabilities[capability].enabled) return yield* new GatewayActivationError({ @@ -724,27 +445,61 @@ export const makeSupervisor = ( visit(capability); const input: LifecycleInput = { stackId: options.stackId, - generation: state.desiredGeneration, desiredLifecycle: "running", state, - previous: state, definition, inputFingerprint: state.inputFingerprint ?? "", secrets: state.secrets, plan, }; - yield* reconcileBackend(input, next); - if (runtime.activate === undefined) - return yield* new GatewayActivationError({ - message: `Capability ${capability} has no route endpoint`, - }); - const endpoint = yield* runtime.activate(capability, input); + // Activation is accepted for this lifecycle session before reconciliation begins. This keeps a + // failed lazy start visible as failed rather than leaving the capability dormant. yield* Ref.set(active, next); + yield* reconcileBackend(input, "current", next); yield* Ref.set(phase, "running"); - yield* publish(); + yield* publish().pipe(Effect.ignore); + const endpoint = yield* runtime.activate(capability, input); return { capability, endpoint }; }); + const superviseFailure = (failure: ObservedWorkload): Effect.Effect => + execution + .withPermit( + Effect.gen(function* () { + // Explicit lifecycle transitions own the execution gate. Failure events are consumed + // only while the accepted lifecycle is fully running; stopping/restarting sessions + // are fenced by the durable lifecycle and therefore skipped safely. + if ((yield* Ref.get(phase)) !== "running") return; + const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); + if ( + state === undefined || + state.desiredLifecycle !== "running" || + failure.state !== "failed" || + state.definition === undefined + ) + return; + const plan = yield* rebuildExecutionPlan(state.runtime, state.definition).pipe( + Effect.provideContext(options.context), + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + Effect.orElseSucceed(() => undefined), + ); + if (plan === undefined) return; + const input: LifecycleInput = { + stackId: options.stackId, + desiredLifecycle: "running", + state, + definition: state.definition, + inputFingerprint: state.inputFingerprint ?? "", + secrets: state.secrets, + plan, + }; + yield* reconcileBackend(input, "current").pipe(Effect.ignore); + }), + ) + .pipe(Effect.ignoreCause); + const activationOwned = new Map< CapabilityName, | { @@ -767,6 +522,12 @@ export const makeSupervisor = ( capability: CapabilityName, ): Effect.Effect => Effect.gen(function* () { + const lifecycle = yield* Ref.get(lifecycleActive); + if (lifecycle !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Cannot activate while ${lifecycle.kind} is in progress`, + }); const token = yield* admission.withPermit( Effect.gen(function* () { const current = activationOwned.get(capability); @@ -784,7 +545,27 @@ export const makeSupervisor = ( activationOwned.set(capability, { _tag: "pending", result: deferred }); const owner = Effect.gen(function* () { const result = yield* execution - .withPermit(activateOperation(capability)) + .withPermit( + admission + .withPermit( + Effect.sync(() => { + const current = activationOwned.get(capability); + return current?._tag === "pending" && current.result === deferred; + }), + ) + .pipe( + Effect.flatMap((stillAdmitted) => + stillAdmitted + ? activateOperation(capability) + : Effect.fail( + new StackLifecycleConflictError({ + stackId: options.stackId, + message: "Lazy activation was superseded by a lifecycle transition", + }), + ), + ), + ), + ) .pipe(Effect.exit); yield* admission.withPermit( Effect.sync(() => { @@ -820,15 +601,23 @@ export const makeSupervisor = ( const startOperation = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { const previous = yield* Ref.get(phase); - yield* Ref.set(phase, "starting"); - yield* publish(); - yield* controller.start({ config: startOptions?.config }).pipe( - Effect.provideContext(options.context), - Effect.tapError(() => restorePhase(previous)), - ); + const freshSession = !(yield* Ref.get(sessionInitialized)); + if (freshSession || previous === "stopping") { + yield* backend.cleanup; + yield* Ref.set(phase, "starting"); + yield* publish().pipe(Effect.ignore); + } + yield* controller + .start({ + config: startOptions?.config, + freshSession, + }) + .pipe( + Effect.provideContext(options.context), + Effect.tapError(() => restorePhase(previous)), + ); yield* Ref.set(phase, "running"); - yield* Ref.set(recoveryFailure, false); - return yield* publish(); + yield* publish(); }); const operationKey = (config: StackConfig | undefined): Effect.Effect => Effect.gen(function* () { @@ -861,34 +650,30 @@ export const makeSupervisor = ( stackId: options.stackId, message: "Persisted stack definition fingerprint is missing", }); - const secretIdentity: Record< - string, - { readonly policy: "managed" | "passthrough"; readonly value?: string } - > = {}; + // The compiler fingerprint captures caller semantics. Include only explicitly supplied + // secret values; generated managed values are persisted during start and must not make an + // equivalent concurrent request conflict with its owner. + const secretIdentity: Record = + {}; if (compiled === undefined) { for (const [slot, entry] of Object.entries(state.secrets)) - secretIdentity[slot] = { policy: entry.policy, value: entry.value }; + if (entry.value !== undefined) + secretIdentity[slot] = { policy: entry.policy, value: entry.value }; } else { for (const declaration of compiled.secrets) { - const persisted = state.secrets[declaration.slot]; - const value = - declaration.value === undefined - ? persisted?.value - : String(Redacted.value(declaration.value)); + if (declaration.value === undefined) continue; secretIdentity[declaration.slot] = { policy: declaration.policy, - ...(value === undefined ? {} : { value }), + value: String(Redacted.value(declaration.value)), }; } } - const semantics = { - fingerprint, - definition: compiled?.definition ?? state.definition, - secrets: secretIdentity, - }; const crypto = yield* Crypto.Crypto; const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(canonicalize(revealRedacted(semantics)))) + .digest( + "SHA-256", + new TextEncoder().encode(canonicalize(revealRedacted({ fingerprint, secretIdentity }))), + ) .pipe( Effect.mapError( (error) => @@ -904,69 +689,125 @@ export const makeSupervisor = ( const start = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { const key = yield* operationKey(startOptions?.config); - return yield* submitOwned(startOwned, key, startOperation(startOptions)); + yield* submitLifecycle("start", key, startOperation(startOptions), continueShutdown); + return yield* snapshot(); }); const restartOperation = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { const previous = yield* Ref.get(phase); + const freshSession = !(yield* Ref.get(sessionInitialized)); + if (freshSession) yield* backend.cleanup; + const cleanupBeforeRestart = yield* Ref.get(cleanupEpoch); yield* Ref.set(phase, "starting"); - yield* publish(); - yield* controller.restart({ config: startOptions?.config }).pipe( - Effect.provideContext(options.context), - Effect.tapError(() => restorePhase(previous)), - ); + yield* publish().pipe(Effect.ignore); + const restarted = yield* controller + .restart({ config: startOptions?.config, freshSession }) + .pipe(Effect.provideContext(options.context), Effect.exit); + if (Exit.isFailure(restarted)) { + const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); + if (state?.desiredLifecycle === "stopped") { + const cleanup = yield* backend.cleanup.pipe(Effect.exit); + yield* Ref.set(phase, Exit.isSuccess(cleanup) ? "stopped" : "stopping"); + yield* publish().pipe(Effect.ignore); + } else { + const tornDown = (yield* Ref.get(cleanupEpoch)) > cleanupBeforeRestart; + yield* restorePhase(tornDown ? "starting" : previous); + } + return yield* Effect.failCause(restarted.cause); + } yield* Ref.set(phase, "running"); - yield* Ref.set(recoveryFailure, false); - return yield* publish(); + yield* publish(); }); const restart = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { const key = yield* operationKey(startOptions?.config); - return yield* submitOwned(restartOwned, key, restartOperation(startOptions)); + yield* submitLifecycle("restart", key, restartOperation(startOptions), continueShutdown); + return yield* snapshot(); }); - const stopOperation = () => controller.stop().pipe(Effect.provideContext(options.context)); - const shutdownSignal = yield* Deferred.make(); - const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); - const signalShutdownAfterSuccess = (owned: OwnedResult) => - Deferred.await(owned).pipe( - Effect.flatMap((result) => (Exit.isSuccess(result) ? signalShutdown : Effect.void)), - ); - const continueShutdownAfterInterrupt = (owned: OwnedResult) => - FiberSet.run(ownedFibers, signalShutdownAfterSuccess(owned), { + const stopOperation = () => + Effect.gen(function* () { + const previous = yield* Ref.get(phase); + yield* Ref.set(phase, "stopping"); + yield* publish().pipe(Effect.ignore); + const result = yield* controller + .stop() + .pipe(Effect.provideContext(options.context), Effect.exit); + if (Exit.isFailure(result)) { + const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); + if (state?.desiredLifecycle === "stopped") { + // The durable stopped fence is already committed, but cleanup did not complete. + // Keep the observable phase stopping so callers cannot mistake this for a clean stop. + yield* Ref.set(phase, "stopping"); + yield* publish().pipe(Effect.ignore); + } else { + yield* restorePhase(previous); + } + return yield* Effect.failCause(result.cause); + } + yield* Ref.set(phase, "stopped"); + yield* publish(); + }); + const shutdownIfIdle = admission.withPermit( + Effect.gen(function* () { + const state = yield* read().pipe(Effect.exit); + if (Exit.isFailure(state)) return; + const lifecycle = yield* Ref.get(lifecycleActive); + const preparations = yield* Ref.get(preparationsActive); + const currentPhase = yield* Ref.get(phase); + if ( + lifecycle === undefined && + preparations === 0 && + currentPhase !== "stopping" && + (state.value === undefined || + state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured") + ) + yield* signalShutdown; + }), + ); + const continueShutdown = (owned: Deferred.Deferred, never>) => + FiberSet.run(ownedFibers, Deferred.await(owned).pipe(Effect.andThen(shutdownIfIdle)), { startImmediately: true, }).pipe(Effect.asVoid); - const stop = submitOwned(stopOwned, "stop", stopOperation()); - // Keep stop and quiesce in separate slots: quiesce's waiter interruption is terminal, while - // ordinary maintenance stop must never inherit that shutdown continuation. - const quiesceOwned = new Map>(); - const quiesce = submitOwned( - quiesceOwned, - "quiesce", - stopOperation(), - continueShutdownAfterInterrupt, - ); + // An interrupted stop waiter still requests shutdown after the owned operation succeeds. + const stopWithShutdown = submitLifecycle("stop", "stop", stopOperation(), continueShutdown); const operation = (effect: Effect.Effect) => effect.pipe(Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))); - const destroyOperation = controller - .destroy() - .pipe(Effect.provideContext(options.context), Effect.andThen(Ref.set(phase, "stopped"))); - const destroy = submitOwned( - destroyOwned, - "destroy", - destroyOperation, - continueShutdownAfterInterrupt, - ).pipe(Effect.asVoid); - const logs = (logOptions?: LogOptions): Stream.Stream => - runtime.logStore === undefined - ? Stream.fail(new StackNotRunningError({ message: "Stack logs are unavailable" })) - : runtime.logStore - .stream(logOptions) - .pipe( - Stream.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), - ), - ); - const watchStatus = hub.changes; + const streamOperation = (stream: Stream.Stream) => + stream.pipe(Stream.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))); + const destroyOperation = Effect.gen(function* () { + const previous = yield* Ref.get(phase); + yield* Ref.set(phase, "destroying"); + yield* publish().pipe(Effect.ignore); + const result = yield* controller + .destroy() + .pipe(Effect.provideContext(options.context), Effect.exit); + if (Exit.isFailure(result)) { + yield* restorePhase(previous); + return yield* Effect.failCause(result.cause); + } + yield* Ref.set(phase, "stopped"); + yield* publish().pipe(Effect.ignore); + return result.value; + }); + const destroy = submitLifecycle("destroy", "destroy", destroyOperation, continueShutdown).pipe( + Effect.asVoid, + ); + const logs = (logOptions?: LogOptions): Stream.Stream => { + const stream = runtime.logStore + .stream(logOptions) + .pipe( + Stream.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + return logOptions?.follow === true + ? stream.pipe(Stream.interruptWhen(Deferred.await(shutdownSignal))) + : stream; + }; + const watchStatus = SubscriptionRef.changes(hub).pipe( + Stream.interruptWhen(Deferred.await(shutdownSignal)), + ); const maintenanceHandlers = { probe: Effect.succeed({ ok: true, @@ -975,26 +816,14 @@ export const makeSupervisor = ( stackId: options.stackId, rpcRelease: options.rpcRelease, } satisfies MaintenanceResponse), - stop: stop.pipe( + stop: stopWithShutdown.pipe( Effect.provideContext(options.context), Effect.as({ ok: true, op: "stop" } satisfies MaintenanceResponse), - Effect.orElseSucceed( - () => - ({ - ok: false, - error: { tag: "operation-failed", message: "Unable to stop stack" }, - }) satisfies MaintenanceResponse, - ), - ), - quiesce: quiesce.pipe( - Effect.provideContext(options.context), - Effect.as({ ok: true, op: "quiesce" } satisfies MaintenanceResponse), - Effect.orElseSucceed( - () => - ({ - ok: false, - error: { tag: "operation-failed", message: "Unable to quiesce stack" }, - }) satisfies MaintenanceResponse, + Effect.catch((error) => + Effect.succeed({ + ok: false, + error: { tag: "operation-failed", message: stateErrorMessage(error) }, + } satisfies MaintenanceResponse), ), ), }; @@ -1004,13 +833,10 @@ export const makeSupervisor = ( Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), ); const actualPhase = yield* Ref.get(phase); - const adoptedGeneration = yield* Ref.get(generation); if ( state === undefined || actualPhase !== "running" || - state.desiredLifecycle !== "running" || - state.portsGeneration !== state.desiredGeneration || - adoptedGeneration !== state.desiredGeneration + state.desiredLifecycle !== "running" ) return yield* Effect.fail( rpcError("StackNotRunningError", "Stack credentials are unavailable"), @@ -1170,11 +996,6 @@ export const makeSupervisor = ( } const workloads = plan.workloads.filter((workload) => selected.has(workload.capability)); - if (runtime.prepare === undefined) - return yield* new StackPreparationError({ - stackId: options.stackId, - message: "Stack runtime artifact preparation is unavailable", - }); const artifacts = yield* runtime.prepare(state.runtime, workloads); const byCapability = new Map>(); for (const artifact of artifacts) { @@ -1201,26 +1022,51 @@ export const makeSupervisor = ( }); return { capabilities }; }).pipe(Effect.provideContext(options.context)); + const submitPreparation = ( + effect: Effect.Effect, + ): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const result = yield* Deferred.make, never>(); + const released = yield* Ref.make(false); + const release = Ref.getAndSet(released, true).pipe( + Effect.flatMap((alreadyReleased) => + alreadyReleased ? Effect.void : Ref.update(preparationsActive, (count) => count - 1), + ), + ); + const owner = Effect.gen(function* () { + const exit = yield* Effect.exit(effect); + yield* release; + yield* Deferred.succeed(result, exit); + }).pipe(Effect.ensuring(release)); + yield* admission.withPermit( + ensureAcceptingOperations.pipe( + Effect.andThen(Ref.update(preparationsActive, (count) => count + 1)), + Effect.andThen(FiberSet.run(ownedFibers, owner, { startImmediately: true })), + ), + ); + const awaitResult = Deferred.await(result).pipe( + Effect.onInterrupt(() => continueShutdown(result)), + ); + return yield* restore(awaitResult).pipe(Effect.flatMap(joinExit)); + }), + ); const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ - status: () => - status.pipe(Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))), + status: () => operation(status), credentials: () => credentials, prepare: ({ config, capabilities }) => - prepareOperation({ config, capabilities }).pipe( - Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), - ), + operation(submitPreparation(prepareOperation({ config, capabilities }))), start: ({ config }: { readonly config?: StackConfig }) => operation(start({ config })), restart: ({ config }: { readonly config?: StackConfig }) => operation(restart({ config })), destroy: () => operation(destroy), - logs: (logOptions: LogOptions) => - logs(logOptions).pipe( - Stream.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), - ), - watchStatus: () => - watchStatus.pipe( - Stream.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error))), - ), + logs: (logOptions: LogOptions) => streamOperation(logs(logOptions)), + watchStatus: () => streamOperation(watchStatus), }); + yield* FiberSet.run( + ownedFibers, + runtime.driver.watchFailures.pipe(Stream.runForEach(superviseFailure)), + { startImmediately: true }, + ); return { identity: options.identity, stackId: options.stackId, @@ -1229,9 +1075,8 @@ export const makeSupervisor = ( start, restart, destroy, - recover, shutdown: Deferred.await(shutdownSignal), - signalShutdown, + shutdownIfIdle, watchStatus, logs, activate, diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 7fce4b5962..eed6cd08a7 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -17,7 +17,6 @@ import { Stream, } from "effect"; import { ChildProcess } from "effect/unstable/process"; -import { compileStack } from "../model/Compiler.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { createStack, @@ -50,7 +49,6 @@ import { ContainerEngineError, StackPreparationError, StackRuntimeMismatchError, - StackUpgradeRequiredError, } from "../public/Errors.ts"; import { ContainerEngineProtocolError } from "../runtime/ContainerEngine.ts"; import { ContainerEngineResolver } from "../runtime/ContainerEngineResolver.ts"; @@ -77,7 +75,7 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect - Schema.is(StackIdSchema)(entry) ? quiesceOwner(StackIdSchema.make(entry)) : Effect.void, + Schema.is(StackIdSchema)(entry) ? stopOwner(StackIdSchema.make(entry)) : Effect.void, { discard: true }, ); }); @@ -88,7 +86,7 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect +const stopOwner = (id: StackId) => Effect.gen(function* () { const env = yield* StackRuntimeEnvironment; const fs = yield* FileSystem.FileSystem; @@ -110,7 +108,7 @@ const quiesceOwner = (id: StackId) => stackId: id, ownerSessionId: owner.ownerSessionId, rpcRelease: owner.rpcRelease, - }).quiesce(), + }).stop(), ); const remaining = yield* readOwnerMetadata(env.stateRoot, id, env); if (remaining === undefined) { @@ -353,8 +351,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { format: "supabase-stack-state-v1", identity: { ...identity, stackId }, runtime: { kind: "native" }, - desiredGeneration: 0, - portsGeneration: null, desiredLifecycle: "stopped", ports: [], privatePorts: [], @@ -374,7 +370,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { lifecycle: "stopped", desiredLifecycle: "stopped", runtime: { kind: "native" }, - desiredGeneration: 0, endpoints: {}, versions: {}, capabilities: CAPABILITY_NAMES.map((name) => ({ @@ -420,7 +415,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { rpcRelease: STACK_RPC_RELEASE, }), stop: Effect.succeed({ ok: true, op: "stop" }), - quiesce: Effect.succeed({ ok: true, op: "quiesce" }), }, rpcHandlers: handlers, }); @@ -450,8 +444,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { format: "supabase-stack-state-v1", identity: { ...identity, stackId }, runtime: { kind: "native" }, - desiredGeneration: 0, - portsGeneration: null, desiredLifecycle: "stopped", ports: [], privatePorts: [], @@ -472,7 +464,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { lifecycle: "stopped", desiredLifecycle: "stopped", runtime: { kind: "native" }, - desiredGeneration: 0, endpoints: {}, versions: {}, capabilities: CAPABILITY_NAMES.map((name) => ({ @@ -501,7 +492,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { rpcRelease: STACK_RPC_RELEASE, }), stop: Effect.succeed({ ok: true, op: "stop" }), - quiesce: Effect.succeed({ ok: true, op: "quiesce" }), }, rpcHandlers: { status: () => Effect.succeed(status), @@ -518,12 +508,11 @@ describe("managed stack handles", { timeout: 30_000 }, () => { logs: () => Stream.empty, watchStatus: () => Stream.make(status), }, - onDestroyResponse: () => - Deferred.succeed(callbackStarted, undefined).pipe( - Effect.andThen(Deferred.await(callbackRelease)), - Effect.andThen(Deferred.succeed(callbackCompleted, undefined)), - Effect.asVoid, - ), + onShutdownReady: Deferred.succeed(callbackStarted, undefined).pipe( + Effect.andThen(Deferred.await(callbackRelease)), + Effect.andThen(Deferred.succeed(callbackCompleted, undefined)), + Effect.asVoid, + ), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); const stack = yield* openStack(stackId); const destroyFiber = yield* Effect.forkChild( @@ -559,79 +548,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); - it.live("publishes an attachable owner before corrupt persisted recovery fails", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const env = yield* StackRuntimeEnvironment; - const identity = yield* resolveStackIdentity({ projectRoot: project }); - const stackId = yield* deriveStackId(identity); - const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const compiled = yield* compileStack({ - projectRoot: project, - runtime: { kind: "native" }, - config: { capabilities: { rest: {} } }, - }); - const corruptDefinition = { - ...compiled.definition, - capabilities: { - ...compiled.definition.capabilities, - rest: { ...compiled.definition.capabilities.rest, version: "unsupported" }, - }, - }; - const persisted = { - format: "supabase-stack-state-v1" as const, - identity: { ...identity, stackId }, - runtime: { kind: "native" } as const, - desiredGeneration: 1, - portsGeneration: null, - desiredLifecycle: "running" as const, - definition: corruptDefinition, - inputFingerprint: compiled.inputFingerprint, - ports: [], - privatePorts: [], - secrets: {}, - }; - yield* store.initialize(stackId, persisted); - const stack = yield* createStack({ - projectRoot: project, - runtime: { kind: "native" }, - }); - yield* Effect.gen(function* () { - const failed = yield* stack.watchStatus().pipe( - Stream.filter( - (status) => - status.lifecycle === "stopped" && - status.capabilities.some( - ({ name, state }) => name === "rest" && state === "failed", - ), - ), - Stream.runHead, - Effect.timeout("30 seconds"), - ); - const attached = Option.isSome(failed) ? yield* openStack(stack.id) : undefined; - if (attached !== undefined) { - const status = yield* attached.status(); - expect(status.lifecycle).toBe("stopped"); - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); - yield* attached.close(); - } - expect(Option.isSome(failed)).toBe(true); - }).pipe( - Effect.ensuring( - store - .replace( - stack.id, - { ...persisted, definition: compiled.definition }, - persisted.desiredGeneration, - ) - .pipe(Effect.ignore), - ), - ); - yield* stack.close(); - }), - ), - ); - it.live("discovery never creates an identity", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -666,39 +582,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); - it.live("rejects an incompatible owner release without replacing the owner", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const stack = yield* createStack({ projectRoot: project }); - const env = yield* StackRuntimeEnvironment; - const fs = yield* FileSystem.FileSystem; - const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); - const metadata = yield* readOwnerMetadata(env.stateRoot, stack.id, env); - expect(metadata).not.toBeUndefined(); - const current = yield* fs.readFileString(paths.controlMetadata); - yield* fs.writeFileString( - paths.controlMetadata, - current.replace(STACK_RPC_RELEASE, "stack-rpc-v0@0.0.1"), - ); - const opened = yield* openStack(stack.id).pipe(Effect.exit); - expect(Exit.isFailure(opened)).toBe(true); - if (Exit.isFailure(opened)) { - const error = Option.getOrUndefined(Cause.findErrorOption(opened.cause)); - expect(error).toBeInstanceOf(StackUpgradeRequiredError); - } - const recreated = yield* createStack({ projectRoot: project }).pipe(Effect.exit); - expect(Exit.isFailure(recreated)).toBe(true); - if (Exit.isFailure(recreated)) { - const error = Option.getOrUndefined(Cause.findErrorOption(recreated.cause)); - expect(error).toBeInstanceOf(StackUpgradeRequiredError); - } - const inspected = yield* inspectStack(stack.id); - expect(inspected.owner).toBe("incompatible"); - yield* stack.close(); - }), - ), - ); - it.live("filters read-only discovery by project root", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 1922116296..99e5f855b5 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -13,8 +13,10 @@ import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { compileStack } from "../model/Compiler.ts"; import { GatewayActivationError, StackPreparationError } from "../public/Errors.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; import { makePortRegistry } from "../state/PortRegistry.ts"; import { makeSupervisorIngress } from "./Ingress.ts"; +import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; const identity: StackIdentity = { projectRoot: "/tmp/supabase-ingress", @@ -112,17 +114,18 @@ describe("Supervisor ingress", () => { }, }); const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.write(stackId, { + yield* store.initialize(stackId, { format: "supabase-stack-state-v1", identity: { ...stackIdentity, stackId }, runtime: { kind: "native" }, - desiredGeneration: 1, - portsGeneration: null, desiredLifecycle: "running", definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, - ports: [], - privatePorts: [], + ports: [{ field: "api", port: 55432, intent: "automatic" }], + privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ + ...binding, + port: 30000 + index, + })), secrets: {}, }); const registry = yield* makePortRegistry({ stateRoot: root, store }); @@ -141,10 +144,8 @@ describe("Supervisor ingress", () => { }); const input = { stackId, - generation: 1, desiredLifecycle: "running" as const, state: yield* store.read(stackId).pipe(Effect.map((value) => value!)), - previous: yield* store.read(stackId).pipe(Effect.map((value) => value!)), definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, secrets: {}, @@ -201,7 +202,7 @@ describe("Supervisor ingress", () => { config: { capabilities: { rest: {} } }, }); const store = yield* makeStackStateStore({ stateRoot: root }); - yield* store.write(stackId, { + yield* store.initialize(stackId, { format: "supabase-stack-state-v1", identity: { ...identity, @@ -212,13 +213,18 @@ describe("Supervisor ingress", () => { stackId, }, runtime: { kind: "native" }, - desiredGeneration: 1, - portsGeneration: null, desiredLifecycle: "running", definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, - ports: [], - privatePorts: [], + ports: [ + { field: "api", port: 55433, intent: "automatic" }, + { field: "database", port: 55436, intent: "automatic" }, + { field: "pooler", port: 55437, intent: "automatic" }, + ] as const, + privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ + ...binding, + port: 30100 + index, + })), secrets: {}, }); const registry = yield* makePortRegistry({ stateRoot: root, store }); @@ -226,10 +232,8 @@ describe("Supervisor ingress", () => { const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); const reservation = yield* ingress.acquire({ stackId, - generation: 1, desiredLifecycle: "running", state, - previous: state, definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, secrets: {}, @@ -239,10 +243,8 @@ describe("Supervisor ingress", () => { .open( { stackId, - generation: 1, desiredLifecycle: "running", state, - previous: state, definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, secrets: {}, @@ -304,20 +306,21 @@ describe("Supervisor ingress", () => { }, }); const store = yield* makeStackStateStore({ stateRoot: root }); - const persisted = { + const persisted: PersistedStackState = { format: "supabase-stack-state-v1" as const, identity: { ...stackIdentity, stackId }, runtime: { kind: "native" as const }, - desiredGeneration: 1, - portsGeneration: null, desiredLifecycle: "running" as const, definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, - ports: [], - privatePorts: [], + ports: [{ field: "database", port: 55434, intent: "automatic" }] as const, + privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ + ...binding, + port: 30200 + index, + })), secrets: {}, }; - yield* store.write(stackId, persisted); + yield* store.initialize(stackId, persisted); const registry = yield* makePortRegistry({ stateRoot: root, store }); const ingress = yield* makeSupervisorIngress({ stackId, @@ -329,10 +332,8 @@ describe("Supervisor ingress", () => { }); const input = { stackId, - generation: 1, desiredLifecycle: "running" as const, state: persisted, - previous: persisted, definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, secrets: {}, @@ -413,20 +414,21 @@ describe("Supervisor ingress", () => { }, }); const store = yield* makeStackStateStore({ stateRoot: root }); - const persisted = { + const persisted: PersistedStackState = { format: "supabase-stack-state-v1" as const, identity: { ...stackIdentity, stackId }, runtime: { kind: "native" as const }, - desiredGeneration: 1, - portsGeneration: null, desiredLifecycle: "running" as const, definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, - ports: [], - privatePorts: [], + ports: [{ field: "api", port: 55435, intent: "automatic" }] as const, + privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ + ...binding, + port: 30300 + index, + })), secrets: {}, }; - yield* store.write(stackId, persisted); + yield* store.initialize(stackId, persisted); const registry = yield* makePortRegistry({ stateRoot: root, store }); const activated: string[] = []; const ingress = yield* makeSupervisorIngress({ @@ -462,10 +464,8 @@ describe("Supervisor ingress", () => { }); const input = { stackId, - generation: 1, desiredLifecycle: "running" as const, state: persisted, - previous: persisted, definition: compiled.definition, inputFingerprint: compiled.inputFingerprint, secrets: {}, diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index cac36332cd..025d07097c 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -1,13 +1,14 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Redacted } from "effect"; +import { Cause, Deferred, Effect, Exit, FileSystem, Option, Redacted } from "effect"; import { deriveStackId } from "../identity/Identity.ts"; import { StackReconciliationError, StackStateInvalidError, - StackUpgradeRequiredError, + StackMustBeStoppedError, } from "../public/Errors.ts"; import type { StackRuntime } from "../public/Runtime.ts"; +import type { PersistedStackState } from "../state/StackState.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import { makeLifecycleController, @@ -37,9 +38,12 @@ interface BackendState { failDestroyData?: boolean; failCleanupOnce?: boolean; gate?: Deferred.Deferred; + preflightStarted?: Deferred.Deferred; waitBeforeReconcile?: Deferred.Deferred; stopReconcileStarted?: Deferred.Deferred; stopReconcileGate?: Deferred.Deferred; + startReconcileStarted?: Deferred.Deferred; + lastLifecycle?: PersistedStackState["desiredLifecycle"]; } const backend = (state: BackendState): LifecycleBackend => ({ @@ -47,14 +51,18 @@ const backend = (state: BackendState): LifecycleBackend => ({ Effect.gen(function* () { state.calls.push("preflight"); state.preflight.push(input); + if (state.preflightStarted !== undefined) + yield* Deferred.succeed(state.preflightStarted, undefined); if (state.failPreflight) return yield* new StackReconciliationError({ message: "preflight failed" }); if (state.gate !== undefined) yield* Deferred.await(state.gate); - return { ports: input.previous.ports }; }), reconcile: (input) => Effect.gen(function* () { + state.lastLifecycle = input.state.desiredLifecycle; state.calls.push(`reconcile:${input.state.desiredLifecycle}`); + if (input.state.desiredLifecycle === "running" && state.startReconcileStarted !== undefined) + yield* Deferred.succeed(state.startReconcileStarted, undefined); if (input.state.desiredLifecycle === "stopped") { if (state.stopReconcileStarted !== undefined) yield* Deferred.succeed(state.stopReconcileStarted, undefined); @@ -62,20 +70,18 @@ const backend = (state: BackendState): LifecycleBackend => ({ } if (state.waitBeforeReconcile !== undefined) yield* Deferred.await(state.waitBeforeReconcile); }), - cleanup: (input) => - Effect.gen(function* () { - state.calls.push(`cleanup:${input.state.desiredLifecycle}`); - if (state.failCleanupOnce) { - state.failCleanupOnce = false; - return yield* new StackReconciliationError({ message: "cleanup failed" }); - } - }), - destroyData: (input) => - Effect.gen(function* () { - state.calls.push(`destroy-data:${input.state.desiredLifecycle}`); - if (state.failDestroyData) - return yield* new StackReconciliationError({ message: "destroy-data failed" }); - }), + cleanup: Effect.gen(function* () { + state.calls.push(`cleanup:${state.lastLifecycle ?? "invalid"}`); + if (state.failCleanupOnce) { + state.failCleanupOnce = false; + return yield* new StackReconciliationError({ message: "cleanup failed" }); + } + }), + destroyData: Effect.gen(function* () { + state.calls.push(`destroy-data:${state.lastLifecycle ?? "invalid"}`); + if (state.failDestroyData) + return yield* new StackReconciliationError({ message: "destroy-data failed" }); + }), }); const makeFixture = (runtime: StackRuntime = { kind: "native" }) => @@ -88,8 +94,6 @@ const makeFixture = (runtime: StackRuntime = { kind: "native" }) => format: "supabase-stack-state-v1", identity: { ...identity, stackId: id }, runtime, - desiredGeneration: 0, - portsGeneration: null, desiredLifecycle: "unconfigured", ports: [], privatePorts: [], @@ -117,7 +121,6 @@ describe("durable lifecycle controller", () => { expect(result.desiredLifecycle).toBe("running"); expect(result.definition).toBeDefined(); expect(result.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); - expect(result.desiredGeneration).toBe(1); expect(fixture.state.calls).toEqual(["preflight", "reconcile:running"]); expect(yield* fixture.store.read(fixture.id)).toEqual(result); }), @@ -136,7 +139,6 @@ describe("durable lifecycle controller", () => { const third = yield* fixture.controller.start(); expect(third.definition).toEqual(first.definition); expect(third.inputFingerprint).toBe(first.inputFingerprint); - expect(third.desiredGeneration).toBe(3); expect(fixture.state.preflight.at(-1)?.definition).toEqual(first.definition); }), ), @@ -155,7 +157,7 @@ describe("durable lifecycle controller", () => { ), ); - it.live("accepts pass-through secret changes only through restart", () => + it.live("requires restart for pass-through secret changes", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); @@ -179,7 +181,7 @@ describe("durable lifecycle controller", () => { }; yield* fixture.controller.start({ config: original }); const running = yield* fixture.controller.start({ config: changed }).pipe(Effect.exit); - expect(errorOf(running)).toBeInstanceOf(StackUpgradeRequiredError); + expect(errorOf(running)).toBeInstanceOf(StackMustBeStoppedError); const restarted = yield* fixture.controller.restart({ config: changed }); expect(restarted.desiredLifecycle).toBe("running"); expect(restarted.secrets).toMatchObject({ @@ -198,9 +200,8 @@ describe("durable lifecycle controller", () => { const exit = yield* fixture.controller .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) .pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(StackUpgradeRequiredError); + expect(errorOf(exit)).toBeInstanceOf(StackMustBeStoppedError); expect(fixture.state.calls).toEqual(before); - expect((yield* fixture.store.read(fixture.id))?.desiredGeneration).toBe(1); }), ), ); @@ -234,11 +235,9 @@ describe("durable lifecycle controller", () => { expect(errorOf(first)).toBeInstanceOf(StackReconciliationError); const stopped = yield* fixture.store.read(fixture.id); expect(stopped?.desiredLifecycle).toBe("stopped"); - const generation = stopped?.desiredGeneration; fixture.state.calls.length = 0; const second = yield* fixture.controller.stop(); expect(second.desiredLifecycle).toBe("stopped"); - expect(second.desiredGeneration).toBe(generation); expect(fixture.state.calls).toEqual(["reconcile:stopped", "cleanup:stopped"]); }), ), @@ -281,65 +280,12 @@ describe("durable lifecycle controller", () => { ), ); - it.live("lets a stop win while restart is still preflighting", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.controller.start(); - fixture.state.calls.length = 0; - const gate = yield* Deferred.make(); - fixture.state.gate = gate; - const restarting = yield* Effect.forkChild(fixture.controller.restart()); - yield* Effect.yieldNow; - const stopping = yield* Effect.forkChild(fixture.controller.stop()); - yield* Fiber.join(stopping); - yield* Deferred.succeed(gate, undefined); - const restartExit = yield* Fiber.join(restarting).pipe(Effect.exit); - expect(Exit.isFailure(restartExit)).toBe(true); - expect(yield* fixture.store.read(fixture.id)).toMatchObject({ - desiredLifecycle: "stopped", - }); - expect(fixture.state.calls).toEqual(["preflight", "reconcile:stopped", "cleanup:stopped"]); - }), - ), - ); - - it.live("does not relaunch when stop is accepted during the restart handoff", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.controller.start(); - fixture.state.calls.length = 0; - const stoppedStarted = yield* Deferred.make(); - const stoppedGate = yield* Deferred.make(); - fixture.state.stopReconcileStarted = stoppedStarted; - fixture.state.stopReconcileGate = stoppedGate; - const restarting = yield* Effect.forkChild(fixture.controller.restart()); - yield* Deferred.await(stoppedStarted); - const stopping = yield* Effect.forkChild(fixture.controller.stop()); - yield* Deferred.succeed(stoppedGate, undefined); - const stopped = yield* Fiber.join(stopping); - expect(stopped.desiredLifecycle).toBe("stopped"); - const restartExit = yield* Fiber.join(restarting).pipe(Effect.exit); - expect(Exit.isFailure(restartExit)).toBe(true); - expect(yield* fixture.store.read(fixture.id)).toMatchObject({ - desiredLifecycle: "stopped", - }); - expect(fixture.state.calls).toEqual([ - "preflight", - "reconcile:stopped", - "cleanup:stopped", - "reconcile:stopped", - "cleanup:stopped", - ]); - }), - ), - ); - - it.live("destroys runtime data before deleting durable state", () => + it.live("destroys runtime data before deleting the exact identity root", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${fixture.root}/${fixture.id}/runtime`, { recursive: true }); yield* fixture.controller.start(); fixture.state.calls.length = 0; yield* fixture.controller.destroy(); @@ -349,6 +295,17 @@ describe("durable lifecycle controller", () => { "destroy-data:destroying", ]); expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + expect(yield* fs.exists(`${fixture.root}/${fixture.id}`)).toBe(false); + const recreated = yield* fixture.store.initialize(fixture.id, { + format: "supabase-stack-state-v1", + identity: { ...identity, stackId: fixture.id }, + runtime: { kind: "native" }, + desiredLifecycle: "unconfigured", + ports: [], + privatePorts: [], + secrets: {}, + }); + expect(recreated.desiredLifecycle).toBe("unconfigured"); }), ), ); diff --git a/packages/stack/src/supervisor/observability.integration.test.ts b/packages/stack/src/supervisor/observability.integration.test.ts index 8cba7b4f3c..dbde618391 100644 --- a/packages/stack/src/supervisor/observability.integration.test.ts +++ b/packages/stack/src/supervisor/observability.integration.test.ts @@ -1,48 +1,15 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Path, Stream } from "effect"; -import { CAPABILITY_NAMES } from "../public/Capability.ts"; -import { StackIdSchema } from "../public/StackId.ts"; -import type { StackStatus } from "../public/Status.ts"; import { LogStoreError, makeLogStore } from "./LogStore.ts"; -import { makeStatusHub } from "./StatusHub.ts"; const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); -const status = (generation: number): StackStatus => ({ - id: StackIdSchema.make("a".repeat(64)), - lifecycle: generation === 0 ? "stopped" : "running", - desiredLifecycle: generation === 0 ? "stopped" : "running", - runtime: { kind: "native" }, - desiredGeneration: generation, - endpoints: {}, - versions: {}, - capabilities: CAPABILITY_NAMES.map((name) => ({ name, activation: "eager", state: "stopped" })), -}); - const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("observability", () => { - it.live("starts status watches with the current complete snapshot", () => - withPlatform( - Effect.gen(function* () { - const hub = yield* makeStatusHub(status(0)); - const ready = yield* Deferred.make(); - const values = yield* hub.changes.pipe( - Stream.tap(() => Deferred.succeed(ready, void 0)), - Stream.take(2), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); - yield* Deferred.await(ready); - yield* hub.publish(status(1)); - expect(yield* Fiber.join(values)).toEqual([status(0), status(1)]); - }), - ), - ); - it.live("retains bounded redacted records and resumes from an opaque cursor", () => withPlatform( Effect.gen(function* () { @@ -145,6 +112,10 @@ describe("observability", () => { const second = yield* makeLogStore({ path: logPath, knownSecrets: ["old-secret"] }); expect((yield* second.read()).map((entry) => entry.message)).toEqual(["[REDACTED]"]); expect(yield* fs.readFileString(logPath)).not.toContain("old-secret"); + const complete = yield* fs.readFileString(logPath); + yield* fs.writeFileString(logPath, `${complete}{"cursor"`); + const recovered = yield* makeLogStore({ path: logPath }); + expect((yield* recovered.read()).map((entry) => entry.message)).toEqual(["[REDACTED]"]); yield* fs.writeFileString(logPath, "not json"); const malformed = yield* makeLogStore({ path: logPath }).pipe(Effect.exit); expect(errorOf(malformed)).toBeInstanceOf(LogStoreError); diff --git a/packages/stack/src/supervisor/reconciler.integration.test.ts b/packages/stack/src/supervisor/reconciler.integration.test.ts index c3f09d5e40..ceef090b08 100644 --- a/packages/stack/src/supervisor/reconciler.integration.test.ts +++ b/packages/stack/src/supervisor/reconciler.integration.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref } from "effect"; -import * as TestClock from "effect/testing/TestClock"; -import { - RuntimeDriverError, - RuntimeGenerationMismatchError, - RuntimeReadinessTimeoutError, -} from "../runtime/RuntimeDriver.ts"; +import { Cause, Deferred, Effect, Exit, Fiber, Ref, Stream } from "effect"; +import { RuntimeDriverError, RuntimeRestartBudgetExceededError } from "../runtime/RuntimeDriver.ts"; import type { RuntimeDriver, ObservedWorkload } from "../runtime/RuntimeDriver.ts"; import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; import { StackIdSchema } from "../public/StackId.ts"; @@ -44,7 +39,6 @@ const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ pooler: "eager", }, startOrder: ["database"], - stopOrder: ["database"], dependencies: { database: [], rest: [], @@ -69,6 +63,7 @@ const fakeDriver = (options: { const resources = yield* Ref.make>([]); const starts = yield* Ref.make>([]); const driver: RuntimeDriver = { + watchFailures: Stream.empty, observe: () => Ref.get(resources), start: (key, entry) => Effect.gen(function* () { @@ -98,22 +93,17 @@ const fakeDriver = (options: { current.filter((entry) => entry.workloadId !== key.workloadId), ), cleanup: () => Effect.void, - recover: () => Effect.succeed([]), }; return { driver, resources, starts }; }); -const failureOf = (exit: Exit.Exit): E | undefined => - Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; - describe("stack reconciler", () => { it.live("keeps independent branches running and blocks failed dependents", () => Effect.gen(function* () { const { driver, starts } = yield* fakeDriver({ fail: new Set(["root"]) }); - const reconciler = yield* makeReconciler({ driver, readGeneration: () => Effect.succeed(1) }); + const reconciler = yield* makeReconciler({ driver }); const result = yield* reconciler.reconcile({ stackId, - desiredGeneration: 1, desiredLifecycle: "running", plan: planFor([workload("root"), workload("dependent", ["root"]), workload("independent")]), }); @@ -127,24 +117,20 @@ describe("stack reconciler", () => { Effect.gen(function* () { const { driver, resources } = yield* fakeDriver({ fail: new Set(["root"]) }); yield* Ref.set(resources, [ - { stackId, desiredGeneration: 1, workloadId: "root", specHash: "root", state: "failed" }, { stackId, - desiredGeneration: 1, workloadId: "dependent", specHash: "dependent", state: "ready", }, { stackId, - desiredGeneration: 1, workloadId: "leaf", specHash: "leaf", state: "ready", }, { stackId, - desiredGeneration: 1, workloadId: "independent", specHash: "independent", state: "ready", @@ -152,11 +138,9 @@ describe("stack reconciler", () => { ]); const reconciler = yield* makeReconciler({ driver, - readGeneration: () => Effect.succeed(1), }); const result = yield* reconciler.reconcile({ stackId, - desiredGeneration: 1, desiredLifecycle: "running", plan: planFor([ workload("root"), @@ -175,72 +159,54 @@ describe("stack reconciler", () => { }), ); - it.effect("reports a readiness deadline and preserves the bounded restart budget", () => + it.live("bounds repeated post-readiness crashes and resets after stop", () => Effect.gen(function* () { - const entered = [ - yield* Deferred.make(), - yield* Deferred.make(), - yield* Deferred.make(), - yield* Deferred.make(), - ] as const; + const resources = yield* Ref.make>([]); const starts = yield* Ref.make(0); - const generation = yield* Ref.make(1); const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: (_key) => + watchFailures: Stream.empty, + observe: () => Ref.get(resources), + start: (key) => Effect.gen(function* () { - const attempt = yield* Ref.updateAndGet(starts, (count) => count + 1); - const signal = entered[attempt - 1]; - if (signal !== undefined) yield* Deferred.succeed(signal, undefined); - return yield* Effect.never; + yield* Ref.update(starts, (count) => count + 1); + const ready = { ...key, state: "ready" as const }; + yield* Ref.set(resources, [ready]); + return ready; }), - stop: () => Effect.void, + stop: () => Ref.set(resources, []), remove: () => Effect.void, cleanup: () => Effect.void, - recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, - readGeneration: () => Ref.get(generation), - readinessTimeout: "5 millis", }); const request = { stackId, - desiredGeneration: 1, desiredLifecycle: "running" as const, - plan: planFor([workload("slow", [], { maxAttempts: 2, backoffMs: 0 })]), + plan: planFor([workload("post-ready", [], { maxAttempts: 2, backoffMs: 0 })]), }; - const fiber = yield* Effect.forkChild(reconciler.reconcile(request), { - startImmediately: true, - }); - yield* Deferred.await(entered[0]); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(entered[1]); - yield* TestClock.adjust("1 second"); - const result = yield* Fiber.join(fiber); - expect(result.failed[0]?.error).toBeInstanceOf(RuntimeReadinessTimeoutError); - expect(yield* Ref.get(starts)).toBe(2); + const key = { + stackId, + workloadId: "post-ready", + specHash: "post-ready", + }; + yield* Ref.set(resources, [{ ...key, state: "ready" }]); - const second = yield* reconciler.reconcile({ - ...request, - }); - expect(second.failed[0]?.error).toBeDefined(); + for (let attempt = 0; attempt < 2; attempt += 1) { + yield* Ref.set(resources, [{ ...key, state: "failed", error: "crashed" }]); + const result = yield* reconciler.reconcile(request); + expect(result.started).toEqual(["post-ready"]); + } + yield* Ref.set(resources, [{ ...key, state: "failed", error: "crashed" }]); + const exhausted = yield* reconciler.reconcile(request); + expect(exhausted.failed[0]?.error).toBeInstanceOf(RuntimeRestartBudgetExceededError); expect(yield* Ref.get(starts)).toBe(2); - yield* Ref.set(generation, 2); - const nextFiber = yield* Effect.forkChild( - reconciler.reconcile({ - ...request, - desiredGeneration: 2, - }), - { startImmediately: true }, - ); - yield* Deferred.await(entered[2]); - yield* TestClock.adjust("1 second"); - yield* Deferred.await(entered[3]); - yield* TestClock.adjust("1 second"); - const nextGeneration = yield* Fiber.join(nextFiber); - expect(nextGeneration.failed[0]?.error).toBeInstanceOf(RuntimeReadinessTimeoutError); - expect(yield* Ref.get(starts)).toBe(4); + + yield* reconciler.reconcile({ ...request, desiredLifecycle: "stopped" }); + yield* Ref.set(resources, [{ ...key, state: "failed", error: "crashed" }]); + const reset = yield* reconciler.reconcile(request); + expect(reset.started).toEqual(["post-ready"]); + expect(yield* Ref.get(starts)).toBe(3); }), ); @@ -252,6 +218,7 @@ describe("stack reconciler", () => { const entered = yield* Deferred.make(); const release = yield* Deferred.make(); const driver: RuntimeDriver = { + watchFailures: Stream.empty, observe: () => Ref.get(resources), start: (key, _entry) => Effect.gen(function* () { @@ -267,12 +234,10 @@ describe("stack reconciler", () => { stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.void, - recover: () => Effect.succeed([]), }; - const reconciler = yield* makeReconciler({ driver, readGeneration: () => Effect.succeed(1) }); + const reconciler = yield* makeReconciler({ driver }); const request = { stackId, - desiredGeneration: 1, desiredLifecycle: "running" as const, plan: planFor([workload("one")]), }; @@ -292,41 +257,6 @@ describe("stack reconciler", () => { }), ); - it.live("fences a generation change after a driver mutation without retrying", () => - Effect.gen(function* () { - const generation = yield* Ref.make(1); - const starts = yield* Ref.make(0); - const driver: RuntimeDriver = { - observe: () => Effect.succeed([]), - start: (key, _entry) => { - const ready: ObservedWorkload = { ...key, state: "ready" }; - return Ref.update(starts, (count) => count + 1).pipe( - Effect.andThen(Ref.set(generation, 2)), - Effect.andThen(Effect.succeed(ready)), - ); - }, - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, - recover: () => Effect.succeed([]), - }; - const reconciler = yield* makeReconciler({ - driver, - readGeneration: () => Ref.get(generation), - }); - const exit = yield* reconciler - .reconcile({ - stackId, - desiredGeneration: 1, - desiredLifecycle: "running", - plan: planFor([workload("fenced")]), - }) - .pipe(Effect.exit); - expect(failureOf(exit)).toBeInstanceOf(RuntimeGenerationMismatchError); - expect(yield* Ref.get(starts)).toBe(1); - }), - ); - it.live("releases lifecycle serialization when a waiting caller is interrupted", () => Effect.gen(function* () { const started = yield* Deferred.make(); @@ -334,6 +264,7 @@ describe("stack reconciler", () => { const blocked = yield* Ref.make(true); const starts = yield* Ref.make(0); const driver: RuntimeDriver = { + watchFailures: Stream.empty, observe: () => Effect.succeed([]), start: (key) => { const ready: ObservedWorkload = { ...key, state: "ready" }; @@ -350,15 +281,12 @@ describe("stack reconciler", () => { stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.void, - recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, - readGeneration: () => Effect.succeed(1), }); const request = { stackId, - desiredGeneration: 1, desiredLifecycle: "running" as const, plan: planFor([workload("interruptible")]), }; @@ -379,24 +307,22 @@ describe("stack reconciler", () => { Effect.gen(function* () { const trace = yield* Ref.make>([]); const resources: ReadonlyArray = [ - { stackId, desiredGeneration: 1, workloadId: "a", specHash: "a", state: "ready" }, - { stackId, desiredGeneration: 1, workloadId: "b", specHash: "b", state: "ready" }, + { stackId, workloadId: "a", specHash: "a", state: "ready" }, + { stackId, workloadId: "b", specHash: "b", state: "ready" }, ]; const driver: RuntimeDriver = { + watchFailures: Stream.empty, observe: () => Effect.succeed(resources), start: () => Effect.die("unexpected start"), stop: (key) => Ref.update(trace, (current) => [...current, `stop:${key.workloadId}`]), remove: (key) => Ref.update(trace, (current) => [...current, `remove:${key.workloadId}`]), cleanup: () => Effect.void, - recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, - readGeneration: () => Effect.succeed(1), }); yield* reconciler.reconcile({ stackId, - desiredGeneration: 1, desiredLifecycle: "stopped", plan: planFor([workload("a"), workload("b", ["a"])]), }); @@ -404,16 +330,14 @@ describe("stack reconciler", () => { }), ); - it.live("resets a generation-scoped restart budget after an explicit stop", () => + it.live("resets the restart budget after an explicit stop", () => Effect.gen(function* () { const { driver, starts } = yield* fakeDriver({ fail: new Set(["retryable"]) }); const reconciler = yield* makeReconciler({ driver, - readGeneration: () => Effect.succeed(1), }); const request = { stackId, - desiredGeneration: 1, desiredLifecycle: "running" as const, plan: planFor([workload("retryable")]), }; @@ -429,6 +353,7 @@ describe("stack reconciler", () => { it.live("preserves defects in a mixed driver Cause instead of reporting budget exhaustion", () => Effect.gen(function* () { const driver: RuntimeDriver = { + watchFailures: Stream.empty, observe: () => Effect.succeed([]), start: () => Effect.failCause( @@ -442,16 +367,13 @@ describe("stack reconciler", () => { stop: () => Effect.void, remove: () => Effect.void, cleanup: () => Effect.void, - recover: () => Effect.succeed([]), }; const reconciler = yield* makeReconciler({ driver, - readGeneration: () => Effect.succeed(1), }); const exit = yield* reconciler .reconcile({ stackId, - desiredGeneration: 1, desiredLifecycle: "running", plan: planFor([workload("mixed")]), }) diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 466f4906b4..4d87952c6e 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -9,6 +9,7 @@ import { FileSystem, Option, Path, + Queue, Redacted, Ref, Scope, @@ -22,10 +23,12 @@ import type { CapabilityName } from "../public/Capability.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, + PortUnavailableError, + StackLifecycleConflictError, StackNotRunningError, StackReconciliationError, StackStateInvalidError, - StackUpgradeRequiredError, + StackMustBeStoppedError, } from "../public/Errors.ts"; import { RuntimeDriverError, @@ -38,7 +41,7 @@ import { deriveStackId } from "../identity/Identity.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import { StackRpcGroup, type StackRpcError } from "../control/StackRpc.ts"; import { makeSupervisor, type Supervisor, type SupervisorRuntime } from "./Supervisor.ts"; -import type { SupervisorIngress, SupervisorIngressReservation } from "./Ingress.ts"; +import type { SupervisorIngress } from "./Ingress.ts"; const identity = { projectRoot: "/tmp/supabase-supervisor", @@ -114,90 +117,65 @@ const invokePrepare = ( return value; }); -const makeMockIngress = ( - timeline: Ref.Ref>, - failOpen = false, -): SupervisorIngress => { - let latest: SupervisorIngressReservation | undefined; - let openedGeneration: number | undefined; - return { - acquire: (input) => - Effect.gen(function* () { - if (latest?.generation === input.generation) { - yield* Ref.update(timeline, (current) => [ - ...current, - `acquire:cached:${input.generation}`, - ]); - return { ...latest, fresh: false }; - } - const reservation: SupervisorIngressReservation = { - assignments: {}, - privateAssignments: [], - hostListeners: [], - generation: input.generation, - fresh: true, - }; - latest = reservation; - openedGeneration = undefined; - yield* Ref.update(timeline, (current) => [...current, `acquire:${input.generation}`]); - return reservation; - }), - open: (input, reservation) => - Effect.gen(function* () { - if (openedGeneration === reservation.generation) { - yield* Ref.update(timeline, (current) => [ - ...current, - `open:cached:${reservation.generation}`, - ]); - return; - } - yield* Ref.update(timeline, (current) => [...current, `open:${input.generation}`]); - if (failOpen) - return yield* new GatewayActivationError({ message: "injected ingress open failure" }); - openedGeneration = reservation.generation; - }), - close: Effect.gen(function* () { - yield* Ref.update(timeline, (current) => [...current, "close"]); - latest = undefined; - openedGeneration = undefined; - }), - }; -}; - const makeFixture = ( fixtureOptions: { readonly ingress?: SupervisorIngress; readonly timeline?: Ref.Ref>; readonly runtime?: StackRuntime; - readonly prepareOutcome?: "cached" | "downloaded" | "present" | "pulled"; + readonly prepareOutcome?: "cached" | "downloaded" | "pulled"; + readonly prepareGate?: Deferred.Deferred; + readonly prepareStarted?: Deferred.Deferred; readonly startGate?: Deferred.Deferred; readonly startStarted?: Deferred.Deferred; readonly activationGate?: Deferred.Deferred; readonly activationStarted?: Deferred.Deferred; readonly activationCalls?: Ref.Ref; readonly activationFailFirst?: Ref.Ref; + readonly startFailures?: Ref.Ref; readonly preflightFailFirst?: Ref.Ref; + readonly preflightModes?: Ref.Ref>; readonly preflightGate?: Deferred.Deferred; readonly preflightStarted?: Deferred.Deferred; readonly stopGate?: Deferred.Deferred; readonly stopStarted?: Deferred.Deferred; + readonly workloadStopFailFirst?: Ref.Ref; + readonly stopFailFirst?: Ref.Ref; readonly destroyGate?: Deferred.Deferred; readonly destroyStarted?: Deferred.Deferred; - readonly recoveryFailFirst?: Ref.Ref; - readonly recoveryStarted?: Deferred.Deferred; + readonly destroyPreFenceFail?: Ref.Ref; + readonly failureQueue?: Queue.Queue; + readonly startQueue?: Queue.Queue; } = {}, ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-supervisor-" }); const id = yield* deriveStackId(identity); - const store = yield* makeStackStateStore({ stateRoot: root }); + const baseStore = yield* makeStackStateStore({ stateRoot: root }); + const destroyPreFenceFail = fixtureOptions.destroyPreFenceFail; + const store = + destroyPreFenceFail === undefined + ? baseStore + : { + ...baseStore, + replace: (stackId: string, state: Parameters[1]) => + Effect.gen(function* () { + if (state.desiredLifecycle === "destroying") { + const fail = yield* Ref.get(destroyPreFenceFail); + if (fail) { + yield* Ref.set(destroyPreFenceFail, false); + return yield* new StackStateInvalidError({ + message: "injected destroy fence failure", + }); + } + } + return yield* baseStore.replace(stackId, state); + }), + }; yield* store.initialize(id, { format: "supabase-stack-state-v1", identity: { ...identity, stackId: id }, runtime: fixtureOptions.runtime ?? { kind: "native" }, - desiredGeneration: 0, - portsGeneration: null, desiredLifecycle: "unconfigured", ports: [], privatePorts: [], @@ -207,10 +185,16 @@ const makeFixture = ( const calls = yield* Ref.make>([]); const logOptions = yield* Ref.make>([]); const failDestroy = yield* Ref.make(false); + let gateStopCleanup = false; const driver: RuntimeDriver = { + watchFailures: + fixtureOptions.failureQueue === undefined + ? Stream.empty + : Stream.fromQueue(fixtureOptions.failureQueue), observe: () => Ref.get(resources), start: (key, workload: PlannedWorkload) => Effect.gen(function* () { + gateStopCleanup = true; if (fixtureOptions.timeline !== undefined) yield* Ref.update(fixtureOptions.timeline, (current) => [ ...current, @@ -221,15 +205,47 @@ const makeFixture = ( yield* Deferred.succeed(fixtureOptions.startStarted, undefined); if (fixtureOptions.startGate !== undefined) yield* Deferred.await(fixtureOptions.startGate); + if ( + fixtureOptions.startFailures !== undefined && + key.workloadId === "functions:edge-runtime" + ) { + const remaining = yield* Ref.get(fixtureOptions.startFailures); + if (remaining > 0) { + yield* Ref.set(fixtureOptions.startFailures, remaining - 1); + const failed = { ...key, state: "failed" as const, error: "injected start failure" }; + yield* Ref.update(resources, (current) => [ + ...current.filter((entry) => entry.workloadId !== key.workloadId), + failed, + ]); + return yield* new RuntimeDriverError({ + message: "injected start failure", + stackId: key.stackId, + workloadId: key.workloadId, + }); + } + } const ready = { ...key, state: "ready" as const }; yield* Ref.update(resources, (current) => [ ...current.filter((entry) => entry.workloadId !== key.workloadId), ready, ]); + if (fixtureOptions.startQueue !== undefined) + yield* Queue.offer(fixtureOptions.startQueue, workload.id); return ready; }), stop: (key) => Effect.gen(function* () { + if (fixtureOptions.workloadStopFailFirst !== undefined) { + const fail = yield* Ref.get(fixtureOptions.workloadStopFailFirst); + if (fail) { + yield* Ref.set(fixtureOptions.workloadStopFailFirst, false); + return yield* new RuntimeDriverError({ + message: "injected workload stop failure", + stackId: key.stackId, + workloadId: key.workloadId, + }); + } + } if (fixtureOptions.timeline !== undefined) yield* Ref.update(fixtureOptions.timeline, (current) => [ ...current, @@ -247,6 +263,13 @@ const makeFixture = ( Effect.gen(function* () { if (destroy && (yield* Ref.get(failDestroy))) return yield* new RuntimeDriverError({ message: "destroy failed" }); + if (!destroy && fixtureOptions.stopFailFirst !== undefined) { + const fail = yield* Ref.get(fixtureOptions.stopFailFirst); + if (fail) { + yield* Ref.set(fixtureOptions.stopFailFirst, false); + return yield* new RuntimeDriverError({ message: "injected stop cleanup failure" }); + } + } if (fixtureOptions.timeline !== undefined) yield* Ref.update(fixtureOptions.timeline, (current) => [ ...current, @@ -260,25 +283,12 @@ const makeFixture = ( yield* Deferred.succeed(fixtureOptions.destroyStarted, undefined); if (destroy && fixtureOptions.destroyGate !== undefined) yield* Deferred.await(fixtureOptions.destroyGate); - if (!destroy && fixtureOptions.stopStarted !== undefined) + if (!destroy && gateStopCleanup && fixtureOptions.stopStarted !== undefined) yield* Deferred.succeed(fixtureOptions.stopStarted, undefined); - if (!destroy && fixtureOptions.stopGate !== undefined) + if (!destroy && gateStopCleanup && fixtureOptions.stopGate !== undefined) yield* Deferred.await(fixtureOptions.stopGate); yield* Ref.set(resources, []); }), - recover: () => - Effect.gen(function* () { - if (fixtureOptions.recoveryStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.recoveryStarted, undefined); - if (fixtureOptions.recoveryFailFirst !== undefined) { - const fail = yield* Ref.get(fixtureOptions.recoveryFailFirst); - if (fail) { - yield* Ref.set(fixtureOptions.recoveryFailFirst, false); - return yield* new RuntimeDriverError({ message: "injected recovery failure" }); - } - } - return yield* Ref.get(resources); - }), }; const entry: StackLogEntry = { cursor: { opaque: "v1_1" }, @@ -290,18 +300,26 @@ const makeFixture = ( const runtime: SupervisorRuntime = { driver, prepare: (_runtime, workloads) => - Effect.forEach(workloads, (workload) => - Ref.update(calls, (current) => [...current, `prepare:${workload.id}`]).pipe( - Effect.as({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: fixtureOptions.prepareOutcome ?? "cached", - }), - ), - ), - preflight: () => Effect.gen(function* () { + if (fixtureOptions.prepareStarted !== undefined) + yield* Deferred.succeed(fixtureOptions.prepareStarted, undefined); + if (fixtureOptions.prepareGate !== undefined) + yield* Deferred.await(fixtureOptions.prepareGate); + return yield* Effect.forEach(workloads, (workload) => + Ref.update(calls, (current) => [...current, `prepare:${workload.id}`]).pipe( + Effect.as({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: fixtureOptions.prepareOutcome ?? "cached", + }), + ), + ); + }), + preflight: (_input, mode) => + Effect.gen(function* () { + if (fixtureOptions.preflightModes !== undefined) + yield* Ref.update(fixtureOptions.preflightModes, (current) => [...current, mode]); if (fixtureOptions.preflightStarted !== undefined) yield* Deferred.succeed(fixtureOptions.preflightStarted, undefined); if (fixtureOptions.preflightGate !== undefined) @@ -313,7 +331,6 @@ const makeFixture = ( return yield* new StackReconciliationError({ message: "injected preflight failure" }); } } - return {}; }), activate: () => Effect.gen(function* () { @@ -332,7 +349,17 @@ const makeFixture = ( } return { host: "127.0.0.1", port: 9999 }; }), - ...(fixtureOptions.ingress === undefined ? {} : { ingress: fixtureOptions.ingress }), + ingress: fixtureOptions.ingress ?? { + acquire: () => + Effect.succeed({ + assignments: {}, + privateAssignments: [], + hostListeners: [], + fresh: false, + }), + open: () => Effect.void, + close: Effect.void, + }, logStore: { path: "memory://logs", append: () => Effect.succeed(entry), @@ -340,7 +367,11 @@ const makeFixture = ( stream: (options) => Stream.unwrap( Ref.update(logOptions, (current) => [...current, options]).pipe( - Effect.map(() => Stream.succeed(entry)), + Effect.map(() => + options?.follow === true + ? Stream.concat(Stream.succeed(entry), Stream.never) + : Stream.succeed(entry), + ), ), ), }, @@ -400,6 +431,43 @@ describe("Supervisor composition", () => { ), ); + it.live("keeps interrupted preparation owned until it completes, then shuts down", () => + run( + Effect.gen(function* () { + const prepareGate = yield* Deferred.make(); + const prepareStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ prepareGate, prepareStarted }); + const preparing = yield* Effect.forkChild(invokePrepare(fixture.supervisor, {}), { + startImmediately: true, + }); + yield* Deferred.await(prepareStarted); + const shutdown = yield* Effect.forkChild(fixture.supervisor.shutdown, { + startImmediately: true, + }); + yield* Fiber.interrupt(preparing); + yield* fixture.supervisor.shutdownIfIdle; + expect(shutdown.pollUnsafe()).toBeUndefined(); + yield* Deferred.succeed(prepareGate, undefined); + yield* Fiber.join(shutdown); + }), + ), + ); + + it.live("rejects new work after owner shutdown begins", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.shutdownIfIdle; + + const start = yield* fixture.supervisor.start().pipe(Effect.exit); + const prepare = yield* invokePrepare(fixture.supervisor).pipe(Effect.exit); + + expect(errorOf(start)).toBeInstanceOf(StackLifecycleConflictError); + expect(errorOf(prepare)?.tag).toBe("StackLifecycleConflictError"); + }), + ), + ); + it.live("prepares persisted pins and includes dependency closure in start order", () => run( Effect.gen(function* () { @@ -460,7 +528,7 @@ describe("Supervisor composition", () => { ), ); - it.live("maps present and pulled container workloads to cached and pulled outcomes", () => + it.live("reports cached and pulled container preparation outcomes", () => run( Effect.gen(function* () { const config = { @@ -478,7 +546,7 @@ describe("Supervisor composition", () => { }; const present = yield* makeFixture({ runtime: { kind: "container", engine: "docker" }, - prepareOutcome: "present", + prepareOutcome: "cached", }); const presentResult = yield* invokePrepare(present.supervisor, { config }); expect(presentResult.capabilities.every(({ outcome }) => outcome === "cached")).toBe(true); @@ -526,7 +594,9 @@ describe("Supervisor composition", () => { Effect.gen(function* () { const fixture = yield* makeFixture(); expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); - const status = yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const status = yield* fixture.supervisor.start({ + config: { capabilities: { rest: { activation: "eager" } } }, + }); expect(status.lifecycle).toBe("running"); expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); @@ -534,6 +604,224 @@ describe("Supervisor composition", () => { ), ); + it.live("keeps post-commit startup failure observable as starting", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture({ + ingress: { + acquire: () => + Effect.fail( + new PortUnavailableError({ + field: "api", + port: 54_321, + message: "injected ingress failure", + }), + ), + open: () => Effect.void, + close: Effect.void, + }, + }); + expect( + Exit.isFailure(yield* fixture.supervisor.start({ config: {} }).pipe(Effect.exit)), + ).toBe(true); + const status = yield* fixture.supervisor.status; + expect(status.desiredLifecycle).toBe("running"); + expect(status.lifecycle).toBe("starting"); + }), + ), + ); + + it.live("keeps post-commit restart failure observable as starting", () => + run( + Effect.gen(function* () { + const acquireCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ + ingress: { + acquire: () => + Ref.updateAndGet(acquireCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count !== 2 + ? Effect.succeed({ + assignments: {}, + privateAssignments: [], + hostListeners: [], + fresh: true, + }) + : Effect.fail( + new PortUnavailableError({ + field: "api", + port: 54_321, + message: "injected restart ingress failure", + }), + ), + ), + ), + open: () => Effect.void, + close: Effect.void, + }, + }); + yield* fixture.supervisor.start({ config: {} }); + + expect(Exit.isFailure(yield* fixture.supervisor.restart().pipe(Effect.exit))).toBe(true); + const status = yield* fixture.supervisor.status; + expect(status.desiredLifecycle).toBe("running"); + expect(status.lifecycle).toBe("starting"); + expect(errorOf(yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit))?.tag).toBe( + "StackNotRunningError", + ); + const recovered = yield* fixture.supervisor.start(); + expect(recovered.lifecycle).toBe("running"); + expect(recovered.capabilities.find(({ name }) => name === "database")?.state).toBe("ready"); + expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); + }), + ), + ); + + it.live("recovers a stopped live owner after fresh ingress acquisition fails", () => + run( + Effect.gen(function* () { + const acquireCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ + ingress: { + acquire: () => + Ref.updateAndGet(acquireCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count !== 2 + ? Effect.succeed({ + assignments: {}, + privateAssignments: [], + hostListeners: [], + fresh: true, + }) + : Effect.fail( + new PortUnavailableError({ + field: "api", + port: 54_321, + message: "injected stopped-owner ingress failure", + }), + ), + ), + ), + open: () => Effect.void, + close: Effect.void, + }, + }); + yield* fixture.supervisor.start({ config: {} }); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + + expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("starting"); + const recovered = yield* fixture.supervisor.start(); + expect(recovered.lifecycle).toBe("running"); + expect(recovered.capabilities.find(({ name }) => name === "database")?.state).toBe("ready"); + }), + ), + ); + + it.live("starts only database by default and keeps other capabilities dormant", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const status = yield* fixture.supervisor.start({ config: {} }); + expect(status.lifecycle).toBe("running"); + expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); + expect(status.capabilities.find(({ name }) => name === "database")?.state).toBe("ready"); + for (const name of [ + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", + ] as const) + expect(status.capabilities.find((capability) => capability.name === name)?.state).toBe( + "dormant", + ); + }), + ), + ); + + it.live("cleans stale runtime resources before the first start of a new supervisor", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: {} }); + yield* Ref.set(fixture.calls, []); + yield* Ref.set(fixture.resources, [ + { + stackId: fixture.id, + workloadId: "database:database", + specHash: "stale", + state: "ready", + }, + ]); + + const successor = yield* makeSupervisor({ + identity, + stackId: fixture.id, + ownerSessionId: "successor-session", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + yield* successor.start(); + expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); + + yield* Ref.set(fixture.calls, []); + yield* successor.start(); + expect(yield* Ref.get(fixture.calls)).toEqual([]); + }), + ), + ); + + it.live("uses cold preflight only when a fresh Supervisor performs restart", () => + run( + Effect.gen(function* () { + const preflightModes = yield* Ref.make>([]); + const fixture = yield* makeFixture({ preflightModes }); + yield* fixture.supervisor.start({ config: {} }); + yield* Ref.set(preflightModes, []); + + yield* fixture.supervisor.restart(); + expect(yield* Ref.get(preflightModes)).toEqual(["live"]); + + const successor = yield* makeSupervisor({ + identity, + stackId: fixture.id, + ownerSessionId: "successor-session", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + yield* Ref.set(preflightModes, []); + yield* successor.restart(); + expect(yield* Ref.get(preflightModes)).toEqual(["cold"]); + }), + ), + ); + + it.live("keeps first-start cleanup retryable when cleanup fails", () => + run( + Effect.gen(function* () { + const stopFailFirst = yield* Ref.make(true); + const fixture = yield* makeFixture({ stopFailFirst }); + const failed = yield* fixture.supervisor.start({ config: {} }).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("unconfigured"); + expect(yield* Ref.get(fixture.calls)).toEqual([]); + + const status = yield* fixture.supervisor.start({ config: {} }); + expect(status.lifecycle).toBe("running"); + expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); + }), + ), + ); + it.live("returns persisted database, API, and storage credentials while running", () => run( Effect.gen(function* () { @@ -545,18 +833,13 @@ describe("Supervisor composition", () => { if (running === undefined || running.definition === undefined) return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); yield* fixture.store - .replace( - fixture.id, - { - ...running, - ports: [ - { field: "api", port: 55433, intent: "exact" }, - { field: "database", port: 55432, intent: "exact" }, - ] as const, - portsGeneration: running.desiredGeneration, - }, - running.desiredGeneration, - ) + .replace(fixture.id, { + ...running, + ports: [ + { field: "api", port: 55433, intent: "exact" }, + { field: "database", port: 55432, intent: "exact" }, + ] as const, + }) .pipe(Effect.provideContext(fixture.context)); const credentials = yield* invokeCredentials(fixture.supervisor); expect(credentials.database.url).toEqual(expect.anything()); @@ -608,7 +891,6 @@ describe("Supervisor composition", () => { { field: "api", port: 55433, intent: "exact" as const }, { field: "database", port: 55432, intent: "exact" as const }, ] as const, - portsGeneration: running.desiredGeneration, secrets: { ...running.secrets, "secret:database.internal.password": { @@ -618,7 +900,7 @@ describe("Supervisor composition", () => { }, }; yield* fixture.store - .replace(fixture.id, state, running.desiredGeneration) + .replace(fixture.id, state) .pipe(Effect.provideContext(fixture.context)); const credentials = yield* invokeCredentials(fixture.supervisor); expect(Redacted.value(credentials.database.url)).toBe( @@ -646,10 +928,9 @@ describe("Supervisor composition", () => { { field: "api", port: 55433, intent: "exact" as const }, { field: "database", port: 55432, intent: "exact" as const }, ] as const, - portsGeneration: running.desiredGeneration, }; yield* fixture.store - .replace(fixture.id, state, running.desiredGeneration) + .replace(fixture.id, state) .pipe(Effect.provideContext(fixture.context)); const credentials = yield* invokeCredentials(fixture.supervisor); expect(credentials.storage).toBeUndefined(); @@ -657,74 +938,6 @@ describe("Supervisor composition", () => { ), ); - it.live("rejects credentials when the durable generation or phase is not running", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - const stale = { - ...running, - ports: [{ field: "database", port: 55432, intent: "exact" as const }] as const, - portsGeneration: running.desiredGeneration - 1, - }; - yield* fixture.store - .replace(fixture.id, stale, running.desiredGeneration) - .pipe(Effect.provideContext(fixture.context)); - const staleExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(staleExit)).toEqual( - expect.objectContaining({ tag: "StackNotRunningError" }), - ); - const stopped = { - ...stale, - portsGeneration: stale.desiredGeneration, - desiredLifecycle: "stopped" as const, - }; - yield* fixture.store - .replace(fixture.id, stopped, stale.desiredGeneration) - .pipe(Effect.provideContext(fixture.context)); - const stoppedExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(stoppedExit)).toEqual( - expect.objectContaining({ tag: "StackNotRunningError" }), - ); - }), - ), - ); - - it.live("fails closed when a persisted running generation is newer than this owner", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - const advancedGeneration = running.desiredGeneration + 1; - yield* fixture.store - .replace( - fixture.id, - { - ...running, - desiredGeneration: advancedGeneration, - portsGeneration: advancedGeneration, - }, - running.desiredGeneration, - ) - .pipe(Effect.provideContext(fixture.context)); - const staleOwnerExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(staleOwnerExit)).toEqual( - expect.objectContaining({ tag: "StackNotRunningError" }), - ); - }), - ), - ); - it.live("fails closed when Auth is disabled or a required secret slot is absent", () => run( Effect.gen(function* () { @@ -743,10 +956,9 @@ describe("Supervisor composition", () => { { field: "api", port: 55433, intent: "exact" as const }, { field: "database", port: 55432, intent: "exact" as const }, ] as const, - portsGeneration: running.desiredGeneration, }; yield* fixture.store - .replace(fixture.id, state, running.desiredGeneration) + .replace(fixture.id, state) .pipe(Effect.provideContext(fixture.context)); const authDisabled = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(authDisabled)).toEqual( @@ -779,12 +991,9 @@ describe("Supervisor composition", () => { ), ), }; - yield* fixture.store - .replace(fixture.id, missingSecret, state.desiredGeneration) - .pipe(Effect.provideContext(fixture.context)); const missingExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(missingExit)).toEqual( - expect.objectContaining({ tag: "StackSecretMismatchError" }), + expect.objectContaining({ tag: "StackNotRunningError" }), ); const storageSecretMissing = { ...missingSecret, @@ -795,7 +1004,7 @@ describe("Supervisor composition", () => { ), }; yield* fixture.store - .replace(fixture.id, storageSecretMissing, state.desiredGeneration) + .replace(fixture.id, storageSecretMissing) .pipe(Effect.provideContext(fixture.context)); const storageExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(storageExit)).toEqual( @@ -807,7 +1016,7 @@ describe("Supervisor composition", () => { ports: [{ field: "database", port: 55432, intent: "exact" as const }] as const, }; yield* fixture.store - .replace(fixture.id, missingApiListener, state.desiredGeneration) + .replace(fixture.id, missingApiListener) .pipe(Effect.provideContext(fixture.context)); const listenerExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(listenerExit)).toEqual( @@ -817,63 +1026,279 @@ describe("Supervisor composition", () => { ), ); - it.live("acknowledges quiesce only after runtime cleanup", () => + it.live("acknowledges stop only after runtime cleanup", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const response = yield* fixture.supervisor.maintenanceHandlers.quiesce; + const response = yield* fixture.supervisor.maintenanceHandlers.stop; expect(response.ok).toBe(true); expect(yield* Ref.get(fixture.calls)).toContain("cleanup:stop"); }), ), ); - it.live("passes log options through the Supervisor log stream", () => + it.live("publishes stopping while stop cleanup is still in progress", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); - const options: LogOptions = { follow: false, capabilities: ["auth"] }; - expect(yield* Stream.runCollect(fixture.supervisor.logs(options))).toHaveLength(1); - expect(yield* Ref.get(fixture.logOptions)).toEqual([options]); + const stopGate = yield* Deferred.make(); + const stopStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ stopGate, stopStarted }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + + const subscribed = yield* Deferred.make(); + const stopping = yield* Deferred.make(); + const watcher = yield* Effect.forkChild( + Stream.runForEach(fixture.supervisor.watchStatus, (status) => + Effect.gen(function* () { + if (!(yield* Deferred.isDone(subscribed))) + yield* Deferred.succeed(subscribed, undefined); + if (status.lifecycle === "stopping") yield* Deferred.succeed(stopping, undefined); + }), + ), + ); + yield* fixture.supervisor.start(); + yield* Deferred.await(subscribed); + + const stop = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); + yield* Deferred.await(stopStarted); + yield* Deferred.await(stopping); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + + yield* Deferred.succeed(stopGate, undefined); + expect((yield* Fiber.join(stop)).ok).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + yield* Fiber.interrupt(watcher); }), ), ); - it.live("keeps running state and explicit restart guidance for changed start input", () => + it.live("preserves stop cleanup diagnostics through maintenance responses", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); + const stopFailFirst = yield* Ref.make(false); + const fixture = yield* makeFixture({ stopFailFirst }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const failed = yield* fixture.supervisor - .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) - .pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackUpgradeRequiredError); - expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); + yield* Ref.set(stopFailFirst, true); + + const response = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(response).toEqual({ + ok: false, + error: { tag: "operation-failed", message: "injected stop cleanup failure" }, + }); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); }), ), ); - it.live("rejects restart while stopped without relaunching resources", () => + it.live("keeps restart in starting state across its internal stop leg", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); + const stopGate = yield* Deferred.make(); + const stopStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ stopGate, stopStarted }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* fixture.supervisor.maintenanceHandlers.stop; - const failed = yield* fixture.supervisor.restart().pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackNotRunningError); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + + const subscribed = yield* Deferred.make(); + const observed = yield* Ref.make>([]); + const watcher = yield* Effect.forkChild( + Stream.runForEach(fixture.supervisor.watchStatus, (status) => + Effect.gen(function* () { + yield* Ref.update(observed, (current) => [...current, status.lifecycle]); + if (!(yield* Deferred.isDone(subscribed))) + yield* Deferred.succeed(subscribed, undefined); + }), + ), + ); + yield* fixture.supervisor.start(); + yield* Deferred.await(subscribed); + + const restarting = yield* Effect.forkChild(fixture.supervisor.restart()); + yield* Deferred.await(stopStarted); + expect((yield* fixture.supervisor.status).lifecycle).toBe("starting"); + yield* Deferred.succeed(stopGate, undefined); + yield* Fiber.join(restarting); + const lifecycles = yield* Ref.get(observed); + expect(lifecycles).not.toContain("stopped"); + expect(lifecycles.at(-1)).toBe("running"); + yield* Fiber.interrupt(watcher); }), ), ); - it.live("keeps lazy capabilities dormant until explicit activation", () => + it.live("publishes destroying while persistent data cleanup is in progress", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); - const status = yield* fixture.supervisor.start({ - config: { - capabilities: { + const destroyGate = yield* Deferred.make(); + const destroyStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ destroyGate, destroyStarted }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + + const subscribed = yield* Deferred.make(); + const destroying = yield* Deferred.make(); + const watcher = yield* Effect.forkChild( + Stream.runForEach(fixture.supervisor.watchStatus, (status) => + Effect.gen(function* () { + if (!(yield* Deferred.isDone(subscribed))) + yield* Deferred.succeed(subscribed, undefined); + if (status.lifecycle === "destroying") yield* Deferred.succeed(destroying, undefined); + }), + ), + ); + yield* fixture.supervisor.start(); + yield* Deferred.await(subscribed); + + const destroy = yield* Effect.forkChild(fixture.supervisor.destroy); + yield* Deferred.await(destroyStarted); + yield* Deferred.await(destroying); + expect((yield* fixture.supervisor.status).lifecycle).toBe("destroying"); + + yield* Deferred.succeed(destroyGate, undefined); + yield* Fiber.join(destroy); + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + yield* Fiber.interrupt(watcher); + }), + ), + ); + + it.live("passes log options through the Supervisor log stream", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const options: LogOptions = { follow: false, capabilities: ["auth"] }; + expect(yield* Stream.runCollect(fixture.supervisor.logs(options))).toHaveLength(1); + expect(yield* Ref.get(fixture.logOptions)).toEqual([options]); + }), + ), + ); + + it.live("completes subscribed status streams after a clean stop", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const stopped = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const watcher = yield* Effect.forkChild( + Stream.runForEach(fixture.supervisor.watchStatus, (status) => + status.lifecycle === "stopped" ? Deferred.succeed(stopped, undefined) : Effect.void, + ).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))), + ); + const response = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(response.ok).toBe(true); + yield* Deferred.await(stopped); + yield* fixture.supervisor.shutdownIfIdle; + yield* Deferred.await(completed); + yield* Fiber.join(watcher); + }), + ), + ); + + it.live("completes followed logs after a clean stop", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const completed = yield* Deferred.make(); + const watcher = yield* Effect.forkChild( + Stream.runCollect(fixture.supervisor.logs({ follow: true })).pipe( + Effect.tap((entries) => Effect.sync(() => expect(entries).toHaveLength(1))), + Effect.ensuring(Deferred.succeed(completed, undefined)), + ), + ); + const response = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(response.ok).toBe(true); + yield* fixture.supervisor.shutdownIfIdle; + yield* Deferred.await(completed); + yield* Fiber.join(watcher); + }), + ), + ); + + it.live("keeps running state and explicit restart guidance for changed start input", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const failed = yield* fixture.supervisor + .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) + .pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackMustBeStoppedError); + expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); + }), + ), + ); + + it.live("rejects restart while stopped without relaunching resources", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + yield* fixture.supervisor.maintenanceHandlers.stop; + const failed = yield* fixture.supervisor.restart().pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackNotRunningError); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + }), + ), + ); + + it.live("keeps the owner when restart cannot prove its stopped cleanup", () => + run( + Effect.gen(function* () { + const workloadStopFailFirst = yield* Ref.make(false); + const stopFailFirst = yield* Ref.make(false); + const fixture = yield* makeFixture({ workloadStopFailFirst, stopFailFirst }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + yield* Ref.set(workloadStopFailFirst, true); + yield* Ref.set(stopFailFirst, true); + + const restarted = yield* fixture.supervisor.restart().pipe(Effect.exit); + expect(errorOf(restarted)).toBeInstanceOf(StackReconciliationError); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + + const shutdown = yield* Effect.forkChild(fixture.supervisor.shutdown, { + startImmediately: true, + }); + yield* fixture.supervisor.shutdownIfIdle; + expect(shutdown.pollUnsafe()).toBeUndefined(); + + const stopped = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(stopped.ok).toBe(true); + yield* fixture.supervisor.shutdownIfIdle; + yield* Fiber.join(shutdown); + expect(yield* Ref.get(fixture.resources)).toEqual([]); + }), + ), + ); + + it.live("retries unproven stopped cleanup before starting again", () => + run( + Effect.gen(function* () { + const workloadStopFailFirst = yield* Ref.make(false); + const stopFailFirst = yield* Ref.make(false); + const fixture = yield* makeFixture({ workloadStopFailFirst, stopFailFirst }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + yield* Ref.set(workloadStopFailFirst, true); + yield* Ref.set(stopFailFirst, true); + yield* fixture.supervisor.restart().pipe(Effect.exit); + yield* Ref.set(fixture.calls, []); + + const started = yield* fixture.supervisor.start(); + + expect(started.lifecycle).toBe("running"); + expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); + }), + ), + ); + + it.live("keeps lazy capabilities dormant until explicit activation", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const status = yield* fixture.supervisor.start({ + config: { + capabilities: { rest: { enabled: false }, auth: { enabled: false }, realtime: { enabled: false }, @@ -887,7 +1312,7 @@ describe("Supervisor composition", () => { }, }); expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("dormant"); - expect(yield* Ref.get(fixture.calls)).toEqual(["start:database:database"]); + expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); const activation = yield* fixture.supervisor.activate("functions"); expect(activation.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); expect( @@ -904,7 +1329,9 @@ describe("Supervisor composition", () => { const gate = yield* Deferred.make(); const started = yield* Deferred.make(); const fixture = yield* makeFixture({ startGate: gate, startStarted: started }); - const config = { capabilities: { rest: {} } }; + const config = { + capabilities: { rest: { activation: "eager" } }, + } satisfies import("../public/Config.ts").StackConfig; const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config })); yield* Deferred.await(started); yield* Fiber.interrupt(waiter); @@ -918,6 +1345,31 @@ describe("Supervisor composition", () => { ), ); + it.live("shuts down after an interrupted pre-commit start later fails", () => + run( + Effect.gen(function* () { + const preflightFailFirst = yield* Ref.make(true); + const preflightGate = yield* Deferred.make(); + const preflightStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ + preflightFailFirst, + preflightGate, + preflightStarted, + }); + const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config: {} })); + yield* Deferred.await(preflightStarted); + yield* Fiber.interrupt(waiter); + yield* Deferred.succeed(preflightGate, undefined); + yield* fixture.supervisor.shutdown.pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.die("idle Supervisor did not shut down"), + }), + ); + }), + ), + ); + it.live("joins concurrent compiler-equivalent starts", () => run( Effect.gen(function* () { @@ -980,12 +1432,12 @@ describe("Supervisor composition", () => { Fiber.join(second).pipe(Effect.exit), ]); expect(Exit.isFailure(firstExit)).toBe(true); - expect(Exit.isSuccess(secondExit)).toBe(true); + expect(errorOf(secondExit)).toBeInstanceOf(StackLifecycleConflictError); }), ), ); - it.live("single-flights lazy activation and retains its endpoint for the generation", () => + it.live("single-flights lazy activation and retains its endpoint", () => run( Effect.gen(function* () { const gate = yield* Deferred.make(); @@ -1024,463 +1476,260 @@ describe("Supervisor composition", () => { ), ); - it.live("allows a failed activation to retry", () => + it.live("keeps activated lazy workloads ready across an idempotent start", () => run( Effect.gen(function* () { - const activationFailFirst = yield* Ref.make(true); - const activationCalls = yield* Ref.make(0); - const fixture = yield* makeFixture({ activationFailFirst, activationCalls }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - const retry = yield* fixture.supervisor.activate("functions"); - expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); - expect(yield* Ref.get(activationCalls)).toBe(2); - }), - ), - ); + const fixture = yield* makeFixture(); + const config = { + capabilities: { + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { activation: "lazy" as const }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }; + yield* fixture.supervisor.start({ config }); + yield* fixture.supervisor.activate("functions"); + yield* Ref.set(fixture.calls, []); - it.live("fences activation against a concurrent stop generation", () => - run( - Effect.gen(function* () { - const activationGate = yield* Deferred.make(); - const activationStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ activationGate, activationStarted }); - yield* fixture.supervisor.start({ - config: { capabilities: { functions: { activation: "lazy" } } }, - }); - const activation = yield* Effect.forkChild(fixture.supervisor.activate("functions")); - yield* Deferred.await(activationStarted); - const stop = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); - yield* Deferred.succeed(activationGate, undefined); - yield* Fiber.join(activation); - yield* Fiber.join(stop); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopped"); - expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("stopped"); - }), - ), - ); + const status = yield* fixture.supervisor.start({ config }); - it.live("completes accepted quiesce after its waiter is interrupted", () => - run( - Effect.gen(function* () { - const stopGate = yield* Deferred.make(); - const stopStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ stopGate, stopStarted }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const waiter = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.quiesce); - yield* Deferred.await(stopStarted); - yield* Fiber.interrupt(waiter); - yield* Deferred.succeed(stopGate, undefined); - yield* fixture.supervisor.shutdown; - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + expect(yield* Ref.get(fixture.calls)).toEqual([]); + expect(status.lifecycle).toBe("running"); + expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("ready"); }), ), ); - it.live("completes accepted destroy after its waiter is interrupted", () => + it.live("restarts a failed eager workload without removing an active lazy workload", () => run( Effect.gen(function* () { - const destroyGate = yield* Deferred.make(); - const destroyStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ destroyGate, destroyStarted }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const waiter = yield* Effect.forkChild(fixture.supervisor.destroy); - yield* Deferred.await(destroyStarted); - yield* Fiber.interrupt(waiter); - yield* Deferred.succeed(destroyGate, undefined); - yield* fixture.supervisor.shutdown; - expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - }), - ), - ); + const failures = yield* Queue.unbounded(); + const starts = yield* Queue.unbounded(); + const fixture = yield* makeFixture({ failureQueue: failures, startQueue: starts }); + yield* fixture.supervisor.start({ config: { capabilities: { functions: {} } } }); + expect(yield* Queue.take(starts)).toBe("database:database"); + yield* fixture.supervisor.activate("functions"); + expect(yield* Queue.take(starts)).toBe("functions:edge-runtime"); + yield* Ref.set(fixture.calls, []); + const database = (yield* Ref.get(fixture.resources)).find( + ({ workloadId }) => workloadId === "database:database", + ); + if (database === undefined) return yield* Effect.die("database did not start"); + yield* Ref.update(fixture.resources, (current) => + current.map((entry) => + entry.workloadId === database.workloadId + ? { ...entry, state: "failed" as const, error: "crashed" } + : entry, + ), + ); - it.live("recovers a running durable intent and starts missing eager workloads", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const recoveredResources = yield* Ref.make>([]); - const recovered = yield* makeSupervisor({ - identity, - stackId: fixture.id, - ownerSessionId: "replacement-owner", - rpcRelease: "test-release", - stateStore: fixture.store, - context: fixture.context, - runtime: { - driver: { - observe: () => Ref.get(recoveredResources), - start: (key) => - Effect.gen(function* () { - const ready = { ...key, state: "ready" as const }; - yield* Ref.update(recoveredResources, (current) => [ - ...current.filter((entry) => entry.workloadId !== key.workloadId), - ready, - ]); - return ready; - }), - stop: (key) => - Ref.update(recoveredResources, (current) => - current.filter((entry) => entry.workloadId !== key.workloadId), - ), - remove: (key) => - Ref.update(recoveredResources, (current) => - current.filter((entry) => entry.workloadId !== key.workloadId), - ), - cleanup: () => Ref.set(recoveredResources, []), - recover: () => - Ref.update(fixture.calls, (current) => [...current, "recover"]).pipe(Effect.as([])), - }, - }, - }); - yield* recovered.recover; - const status = yield* recovered.status; - expect(status.desiredLifecycle).toBe("running"); - expect(status.lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.calls)).toContain("recover"); - }), - ), - ); + yield* Queue.offer(failures, { ...database, state: "failed", error: "crashed" }); + expect(yield* Queue.take(starts)).toBe("database:database"); - it.live("skips stale deferred recovery after restart adopts a newer generation", () => - run( - Effect.gen(function* () { - const stopGate = yield* Deferred.make(); - const stopStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ stopGate, stopStarted }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const recovered = yield* makeSupervisor({ - identity, - stackId: fixture.id, - ownerSessionId: "deferred-recovery-owner", - rpcRelease: "test-release", - stateStore: fixture.store, - context: fixture.context, - runtime: fixture.runtime, - }); - const restart = yield* Effect.forkChild(recovered.restart()); - yield* Deferred.await(stopStarted); - const recover = yield* Effect.forkChild(recovered.recover); - yield* Deferred.succeed(stopGate, undefined); - yield* Fiber.join(restart); - yield* Fiber.join(recover); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - expect(running.desiredLifecycle).toBe("running"); - expect(running.desiredGeneration).toBe(3); - const withCredentials = { - ...running, - ports: [ - { field: "api", port: 55433, intent: "exact" as const }, - { field: "database", port: 55432, intent: "exact" as const }, - ] as const, - portsGeneration: running.desiredGeneration, - }; - yield* fixture.store - .replace(fixture.id, withCredentials, running.desiredGeneration) - .pipe(Effect.provideContext(fixture.context)); - expect((yield* recovered.status).lifecycle).toBe("running"); - const credentials = yield* invokeCredentials(recovered); - expect(Redacted.value(credentials.database.password)).toEqual(expect.any(String)); + const resources = yield* Ref.get(fixture.resources); + expect( + resources.find(({ workloadId }) => workloadId === "functions:edge-runtime")?.state, + ).toBe("ready"); + expect(yield* Ref.get(fixture.calls)).not.toContain("stop:functions:edge-runtime"); }), ), ); - it.live("keeps an owner attachable until persisted plan recovery validates", () => + it.live("restarts a ready workload after an owner-scoped runtime failure", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); + const failures = yield* Queue.unbounded(); + const starts = yield* Queue.unbounded(); + const fixture = yield* makeFixture({ failureQueue: failures, startQueue: starts }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const running = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - if (running === undefined || running.definition === undefined) - return yield* new StackStateInvalidError({ message: "running fixture state is missing" }); - const invalid = { - ...running, - definition: { - ...running.definition, - capabilities: { - ...running.definition.capabilities, - rest: { ...running.definition.capabilities.rest, version: "unsupported" }, - }, - }, - }; - yield* fixture.store - .replace(fixture.id, invalid, running.desiredGeneration) - .pipe(Effect.provideContext(fixture.context)); - const recovered = yield* makeSupervisor({ - identity, - stackId: fixture.id, - ownerSessionId: "replacement-owner", - rpcRelease: "test-release", - stateStore: fixture.store, - context: fixture.context, - runtime: { - driver: { - observe: () => Effect.succeed([]), - start: (key) => Effect.succeed({ ...key, state: "ready" as const }), - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, - recover: () => Effect.succeed([]), - }, - }, - }); - expect((yield* recovered.status).lifecycle).toBe("starting"); - yield* recovered.recover; - const status = yield* recovered.status; - expect(status.lifecycle).toBe("stopped"); - expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); + yield* Queue.take(starts); + const ready = (yield* Ref.get(fixture.resources)).find( + (entry) => entry.workloadId === "database:database", + ); + if (ready === undefined) return yield* Effect.die("database did not start"); + yield* Ref.update(fixture.resources, (current) => + current.map((entry) => + entry.workloadId === ready.workloadId ? { ...entry, state: "failed" as const } : entry, + ), + ); + yield* Queue.offer(failures, { ...ready, state: "failed", error: "crashed" }); + expect(yield* Queue.take(starts)).toBe("database:database"); + expect( + (yield* Ref.get(fixture.resources)).find( + (entry) => entry.workloadId === "database:database", + )?.state, + ).toBe("ready"); }), ), ); - it.live("exposes recovery failure in status and permits a retry", () => + it.live("keeps a post-readiness crash budget exhausted and visible in status", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); + const failures = yield* Queue.unbounded(); + const starts = yield* Queue.unbounded(); + const failedStatus = yield* Deferred.make(); + const fixture = yield* makeFixture({ failureQueue: failures, startQueue: starts }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const failFirst = yield* Ref.make(true); - const recoverCalls = yield* Ref.make(0); - const activationCalls = yield* Ref.make(0); - const startCalls = yield* Ref.make(0); - const recoveredResources = yield* Ref.make>([]); - const recovered = yield* makeSupervisor({ - identity, - stackId: fixture.id, - ownerSessionId: "recovery-owner", - rpcRelease: "test-release", - stateStore: fixture.store, - context: fixture.context, - runtime: { - driver: { - observe: () => Ref.get(recoveredResources), - start: (key) => - Effect.gen(function* () { - yield* Ref.update(startCalls, (count) => count + 1); - const ready = { ...key, state: "ready" as const }; - yield* Ref.update(recoveredResources, (current) => [ - ...current.filter((entry) => entry.workloadId !== key.workloadId), - ready, - ]); - return ready; - }), - stop: (key) => - Ref.update(recoveredResources, (current) => - current.filter((entry) => entry.workloadId !== key.workloadId), - ), - remove: (key) => - Ref.update(recoveredResources, (current) => - current.filter((entry) => entry.workloadId !== key.workloadId), - ), - cleanup: () => Ref.set(recoveredResources, []), - recover: () => - Effect.gen(function* () { - yield* Ref.update(recoverCalls, (count) => count + 1); - if (yield* Ref.get(failFirst)) { - yield* Ref.set(failFirst, false); - return yield* new RuntimeDriverError({ message: "injected recovery failure" }); - } - return []; - }), - }, - activate: () => - Ref.update(activationCalls, (count) => count + 1).pipe( - Effect.andThen(Effect.succeed({ host: "127.0.0.1", port: 9999 })), - ), - }, - }); - yield* recovered.recover; - const failedStatus = yield* recovered.status; - expect(failedStatus.lifecycle).toBe("stopped"); - expect(failedStatus.capabilities.find(({ name }) => name === "rest")?.state).toBe("failed"); - const activation = yield* recovered.activate("rest").pipe(Effect.exit); - expect(Exit.isFailure(activation)).toBe(true); - expect(errorOf(activation)).toBeInstanceOf(StackNotRunningError); - expect(yield* Ref.get(activationCalls)).toBe(0); - expect(yield* Ref.get(startCalls)).toBe(0); - const failedGeneration = failedStatus.desiredGeneration; - yield* recovered.recover; - const retriedStatus = yield* recovered.status; - expect(retriedStatus.lifecycle).toBe("running"); - expect(retriedStatus.desiredGeneration).toBe(failedGeneration); - expect(yield* Ref.get(recoverCalls)).toBe(2); + const initialWorkload = yield* Queue.take(starts); + const ready = (yield* Ref.get(fixture.resources)).find( + (entry) => entry.workloadId === initialWorkload, + ); + if (ready === undefined) return yield* Effect.die("workload did not start"); + const watcher = yield* Effect.forkChild( + Stream.runForEach(fixture.supervisor.watchStatus, (status) => + Effect.gen(function* () { + const capability = status.capabilities.find(({ name }) => name === "database"); + if (capability?.state === "failed" && capability.error !== undefined) + yield* Deferred.succeed(failedStatus, undefined); + }), + ), + ); + + // The catalog default allows five post-ready attempts; the sixth crash is terminal. + for (let attempt = 0; attempt < 5; attempt += 1) { + yield* Ref.update(fixture.resources, (current) => + current.map((entry) => + entry.workloadId === ready.workloadId + ? { ...entry, state: "failed" as const, error: "crashed" } + : entry, + ), + ); + yield* Queue.offer(failures, { ...ready, state: "failed", error: "crashed" }); + yield* Queue.take(starts); + } + yield* Ref.update(fixture.resources, (current) => + current.map((entry) => + entry.workloadId === ready.workloadId + ? { ...entry, state: "failed" as const, error: "crashed" } + : entry, + ), + ); + yield* Queue.offer(failures, { ...ready, state: "failed", error: "crashed" }); + yield* Deferred.await(failedStatus); + const status = yield* fixture.supervisor.status; + const capability = status.capabilities.find(({ name }) => name === "database"); + expect(capability?.state).toBe("failed"); + expect(capability?.error).toContain("crashed"); + yield* Fiber.interrupt(watcher); }), ), ); - it.live("projects observed workload errors into failed capability status", () => + it.live("allows a failed activation to retry", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); + const activationFailFirst = yield* Ref.make(true); + const activationCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ activationFailFirst, activationCalls }); yield* fixture.supervisor.start({ - config: { - capabilities: { - database: {}, - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - yield* Ref.set(fixture.resources, [ - { - stackId: fixture.id, - desiredGeneration: 1, - workloadId: "database:database", - specHash: "test", - state: "failed", - error: "native process exited with code 3", - }, - ]); - const status = yield* fixture.supervisor.status; - expect(status.lifecycle).toBe("stopped"); - expect(status.capabilities.find(({ name }) => name === "database")).toMatchObject({ - state: "failed", - error: "native process exited with code 3", + config: { capabilities: { functions: { activation: "lazy" } } }, }); + const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + const retry = yield* fixture.supervisor.activate("functions"); + expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); + expect(yield* Ref.get(activationCalls)).toBe(2); }), ), ); - it.live("acquires ingress before eager workloads and opens it after readiness", () => + it.live("reports a failed lazy activation as failed instead of dormant", () => run( Effect.gen(function* () { - const timeline = yield* Ref.make>([]); - const fixture = yield* makeFixture({ - timeline, - ingress: makeMockIngress(timeline), - }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const events = yield* Ref.get(timeline); - expect(events.indexOf("acquire:1")).toBeGreaterThanOrEqual(0); - expect(events.indexOf("start:database:database")).toBeGreaterThan(0); - expect(events.indexOf("open:1")).toBeGreaterThan(events.indexOf("start:rest:rest")); + const startFailures = yield* Ref.make(5); + const fixture = yield* makeFixture({ startFailures }); + yield* fixture.supervisor.start({ config: { capabilities: { functions: {} } } }); + + const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); + + expect(Exit.isFailure(failed)).toBe(true); + expect( + (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") + ?.state, + ).toBe("failed"); }), ), ); - it.live("reserves and opens a database listener when the API listener is disabled", () => + it.live("rejects activation while an explicit lifecycle transition is active", () => run( Effect.gen(function* () { - const timeline = yield* Ref.make>([]); - const fixture = yield* makeFixture({ - timeline, - ingress: makeMockIngress(timeline), - }); + const stopGate = yield* Deferred.make(); + const stopStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ stopGate, stopStarted }); yield* fixture.supervisor.start({ - config: { - capabilities: { - database: {}, - rest: { enabled: false }, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - listeners: { api: { enabled: false } }, - }, + config: { capabilities: { rest: {}, functions: { activation: "lazy" } } }, }); - const events = yield* Ref.get(timeline); - expect(events).toContain("start:database:database"); - expect(events).toContain("acquire:1"); - expect(events).toContain("open:1"); - }), - ), - ); - it.live("keeps same-generation ingress identity on repeated start", () => - run( - Effect.gen(function* () { - const timeline = yield* Ref.make>([]); - const fixture = yield* makeFixture({ - timeline, - ingress: makeMockIngress(timeline), - }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* fixture.supervisor.start(); - const events = yield* Ref.get(timeline); - expect(events.filter((event) => event === "acquire:1")).toHaveLength(1); - expect(events).toContain("acquire:cached:1"); - expect(events).toContain("open:cached:1"); + const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); + yield* Deferred.await(stopStarted); + const activation = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); + expect(errorOf(activation)).toBeInstanceOf(StackLifecycleConflictError); + + yield* Deferred.succeed(stopGate, undefined); + yield* Fiber.join(stopping); }), ), ); - it.live("closes ingress before cleanup and reopens it for the next generation", () => + it.live("fences activation against a concurrent stop", () => run( Effect.gen(function* () { - const timeline = yield* Ref.make>([]); - const fixture = yield* makeFixture({ - timeline, - ingress: makeMockIngress(timeline), + const activationGate = yield* Deferred.make(); + const activationStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ activationGate, activationStarted }); + yield* fixture.supervisor.start({ + config: { capabilities: { functions: { activation: "lazy" } } }, }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* fixture.supervisor.restart(); - const events = yield* Ref.get(timeline); - const closeIndex = events.indexOf("close"); - const cleanupIndex = events.findIndex( - (event, index) => index > closeIndex && event === "cleanup:stop", - ); - expect(closeIndex).toBeGreaterThanOrEqual(0); - expect(closeIndex).toBeLessThan(cleanupIndex); - expect(events).toContain("acquire:3"); - expect(events).toContain("open:3"); + const activation = yield* Effect.forkChild(fixture.supervisor.activate("functions")); + yield* Deferred.await(activationStarted); + const stop = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); + yield* Deferred.succeed(activationGate, undefined); + yield* Fiber.join(activation); + yield* Fiber.join(stop); + const status = yield* fixture.supervisor.status; + expect(status.lifecycle).toBe("stopped"); + expect(status.capabilities.find(({ name }) => name === "functions")?.state).toBe("stopped"); }), ), ); - it.live("closes a fresh ingress reservation when opening fails", () => + it.live("completes accepted stop after its waiter is interrupted", () => run( Effect.gen(function* () { - const timeline = yield* Ref.make>([]); - const fixture = yield* makeFixture({ - timeline, - ingress: makeMockIngress(timeline, true), - }); - const failed = yield* fixture.supervisor - .start({ config: { capabilities: { rest: {} } } }) - .pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(GatewayActivationError); - const events = yield* Ref.get(timeline); - expect(events).toEqual( - expect.arrayContaining(["acquire:1", "start:database:database", "open:1", "close"]), - ); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + const stopGate = yield* Deferred.make(); + const stopStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ stopGate, stopStarted }); + yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + const waiter = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); + yield* Deferred.await(stopStarted); + yield* Fiber.interrupt(waiter); + yield* Deferred.succeed(stopGate, undefined); + yield* fixture.supervisor.shutdown; + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); }), ), ); - it.live("retains destroying state until exact data cleanup succeeds", () => + it.live("completes accepted destroy after its waiter is interrupted", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); + const destroyGate = yield* Deferred.make(); + const destroyStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ destroyGate, destroyStarted }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* Ref.set(fixture.failDestroy, true); - const failed = yield* fixture.supervisor.destroy.pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("destroying"); - yield* Ref.set(fixture.failDestroy, false); - yield* fixture.supervisor.destroy; + const waiter = yield* Effect.forkChild(fixture.supervisor.destroy); + yield* Deferred.await(destroyStarted); + yield* Fiber.interrupt(waiter); + yield* Deferred.succeed(destroyGate, undefined); + yield* fixture.supervisor.shutdown; expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); }), ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71710791d6..0d278dde8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,36 +6,36 @@ settings: catalogs: default: - '@effect/atom-react': - specifier: 4.0.0-rc.111 - version: 4.0.0-rc.111 '@effect/platform-bun': - specifier: 4.0.0-rc.111 - version: 4.0.0-rc.111 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 '@effect/platform-node': - specifier: 4.0.0-rc.111 - version: 4.0.0-rc.111 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 '@effect/sql-pg': - specifier: 4.0.0-rc.111 - version: 4.0.0-rc.111 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 '@effect/tsgo': specifier: 0.36.5 version: 0.36.5 '@effect/vitest': - specifier: 4.0.0-rc.111 - version: 4.0.0-rc.111 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 '@tsconfig/bun': specifier: ^1.0.11 version: 1.0.11 '@types/bun': specifier: ^1.4.0 version: 1.4.0 + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 '@vitest/coverage-istanbul': specifier: ^4.1.10 version: 4.1.10 effect: - specifier: 4.0.0-rc.111 - version: 4.0.0-rc.111 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 knip: specifier: ^6.32.2 version: 6.32.2 @@ -60,6 +60,9 @@ catalogs: vitest: specifier: ^4.1.10 version: 4.1.10 + ws: + specifier: 8.21.3 + version: 8.21.3 overrides: '@launchql/protobufjs>@types/node': 24.10.4 @@ -123,51 +126,21 @@ importers: '@clack/prompts': specifier: ^1.7.0 version: 1.7.0 - '@effect/atom-react': - specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(react@19.2.8)(scheduler@0.27.0) '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111) + version: 4.0.0-rc.112(effect@4.0.0-rc.112) '@effect/sql-pg': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111) + version: 4.0.0-rc.112(effect@4.0.0-rc.112) '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(vitest@4.1.10) '@modelcontextprotocol/sdk': specifier: ^1.30.0 version: 1.30.0(zod@4.4.3) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 - '@parcel/watcher': - specifier: ^2.6.0 - version: 2.6.0 - '@parcel/watcher-darwin-arm64': - specifier: 2.6.0 - version: 2.6.0 - '@parcel/watcher-darwin-x64': - specifier: 2.6.0 - version: 2.6.0 - '@parcel/watcher-linux-arm64-glibc': - specifier: 2.6.0 - version: 2.6.0 - '@parcel/watcher-linux-arm64-musl': - specifier: 2.6.0 - version: 2.6.0 - '@parcel/watcher-linux-x64-glibc': - specifier: 2.6.0 - version: 2.6.0 - '@parcel/watcher-linux-x64-musl': - specifier: 2.6.0 - version: 2.6.0 - '@parcel/watcher-win32-arm64': - specifier: 2.6.0 - version: 2.6.0 - '@parcel/watcher-win32-x64': - specifier: 2.6.0 - version: 2.6.0 '@supabase/api': specifier: workspace:* version: link:../../packages/api @@ -195,9 +168,6 @@ importers: '@types/pg-copy-streams': specifier: ^1.2.5 version: 1.2.5 - '@types/react': - specifier: ^19.2.18 - version: 19.2.18 '@vercel/detect-agent': specifier: ^1.2.5 version: 1.2.5 @@ -209,16 +179,7 @@ importers: version: 17.4.2 effect: specifier: 'catalog:' - version: 4.0.0-rc.111 - esbuild: - specifier: ^0.28.2 - version: 0.28.2 - ink: - specifier: ^7.1.1 - version: 7.1.1(@types/react@19.2.18)(react-devtools-core@7.0.1)(react@19.2.8) - ink-spinner: - specifier: ^5.0.0 - version: 5.0.0(ink@7.1.1(@types/react@19.2.18)(react-devtools-core@7.0.1)(react@19.2.8))(react@19.2.8) + version: 4.0.0-rc.112 pg: specifier: ^8.23.0 version: 8.23.0 @@ -231,12 +192,6 @@ importers: prettier: specifier: 3.9.6 version: 3.9.6 - react: - specifier: ^19.2.8 - version: 19.2.8 - react-devtools-core: - specifier: ^7.0.1 - version: 7.0.1 semantic-release: specifier: ^25.0.9 version: 25.0.9(typescript@7.0.2) @@ -346,13 +301,13 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111) + version: 4.0.0-rc.112(effect@4.0.0-rc.112) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1) effect: specifier: 'catalog:' - version: 4.0.0-rc.111 + version: 4.0.0-rc.112 undici: specifier: ^8.10.0 version: 8.10.0 @@ -418,10 +373,10 @@ importers: devDependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111) + version: 4.0.0-rc.112(effect@4.0.0-rc.112) '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 @@ -433,7 +388,7 @@ importers: version: 4.1.10(vitest@4.1.10) effect: specifier: 'catalog:' - version: 4.0.0-rc.111 + version: 4.0.0-rc.112 typescript: specifier: 'catalog:' version: 7.0.2 @@ -445,26 +400,29 @@ importers: dependencies: '@effect/platform-node': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1) '@effect/sql-pg': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111) + version: 4.0.0-rc.112(effect@4.0.0-rc.112) effect: specifier: 'catalog:' - version: 4.0.0-rc.111 + version: 4.0.0-rc.112 jose: specifier: ^6.2.9 version: 6.2.9 devDependencies: '@effect/vitest': specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(vitest@4.1.10) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 '@types/bun': specifier: 'catalog:' version: 1.4.0 + '@types/ws': + specifier: 'catalog:' + version: 8.18.1 esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -474,6 +432,9 @@ importers: vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + ws: + specifier: 'catalog:' + version: 8.21.3 packages: @@ -489,10 +450,6 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} - '@alcalzone/ansi-tokenize@0.3.0': - resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} - engines: {node: '>=18'} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.234': resolution: {integrity: sha512-6BJAbSOD5yGOzn1hU62ZfpybZhPlxcAe0r1w6qeGkAi/W3MD3Wl/TuVZ/y9/xvAwtfDmADDfbcHO0v6i0rjNIw==} cpu: [arm64] @@ -655,35 +612,28 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 - '@effect/atom-react@4.0.0-rc.111': - resolution: {integrity: sha512-MQgX2+ayms/J686g19vV2AkLSocMaubZmFf2VFrWN3fwPg60XXsaPtlQKdcsIy/McxVGZYgYUPXBVVtcmUjjSA==} - peerDependencies: - effect: ^4.0.0-rc.111 - react: '>=19.2.7 <20.0.0' - scheduler: '>=0.27.0 <0.28.0' - - '@effect/platform-bun@4.0.0-rc.111': - resolution: {integrity: sha512-z6MF1ztw8oSvh4nlvI90wraB63ib5K67JPaSYtz5K9IIAgjXgXYC0hjbcqF+PlC9ytlQmu8tWzk2+yGePWWe5A==} + '@effect/platform-bun@4.0.0-rc.112': + resolution: {integrity: sha512-Y5n2HhV/vsbrJls9ukX42RL7zqXQxISHegWFsP9njsCyDmGTnq7QYSO82rglwrLPwtTrv5dkSRTuGw4+oXxgMg==} peerDependencies: - effect: ^4.0.0-rc.111 + effect: ^4.0.0-rc.112 - '@effect/platform-node-shared@4.0.0-rc.111': - resolution: {integrity: sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw==} + '@effect/platform-node-shared@4.0.0-rc.112': + resolution: {integrity: sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-rc.111 + effect: ^4.0.0-rc.112 - '@effect/platform-node@4.0.0-rc.111': - resolution: {integrity: sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw==} + '@effect/platform-node@4.0.0-rc.112': + resolution: {integrity: sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-rc.111 + effect: ^4.0.0-rc.112 redis: '>=5.0.0 <7.0.0' - '@effect/sql-pg@4.0.0-rc.111': - resolution: {integrity: sha512-QjYFyN5cUlJGJWzXA8GHh7BJydc2K8ImIFvG3kf8f/Nv3w6ZuycuRKIW3TUNtOmHtVvqr/OugPFtyvHYhkDEbg==} + '@effect/sql-pg@4.0.0-rc.112': + resolution: {integrity: sha512-UYUA3LAGH1Pg88Yau7eTlTLHRrIb/uTdxdPGxVcRdIjjrTzxtA7iKHR4hcmUeq5lQEcGLCopN7E4ffsAKF8vEQ==} peerDependencies: - effect: ^4.0.0-rc.111 + effect: ^4.0.0-rc.112 '@effect/tsgo-darwin-arm64@0.36.5': resolution: {integrity: sha512-+JPS65Ekod5NS41Kg9OIyuUsygNcSw5/4Y+UNbY6Wob6dvP+CkEa51pGBAmHKQp3Z/D3g/A9GNE66ndu9kz8dw==} @@ -724,10 +674,10 @@ packages: resolution: {integrity: sha512-BHxVjeRK1/XlqYHWXkbT4W9JpOrT3sNA2wrfwQPBTojD5OSyxI2TUIltobYhWudyfuCrn770qP6uOpDjdrmghA==} hasBin: true - '@effect/vitest@4.0.0-rc.111': - resolution: {integrity: sha512-YDaEVT+grREBVMzykRNFtJwGxy02achzT0WXZYwMJA4ukzuB9krkgQ5roc3N6zhC3NQq/j4IyM32/Pcov7AhHw==} + '@effect/vitest@4.0.0-rc.112': + resolution: {integrity: sha512-mEKh/FI64mt8JK1/v9mpOrJYdnp+UFZdRUBMEdZMiKz7klg6NPqVgg/oeAGH6wOOQc2iAPcfc2H9BbAv1KyzMQ==} peerDependencies: - effect: ^4.0.0-rc.111 + effect: ^4.0.0-rc.112 vitest: '>=4.1.0 <5.0.0' '@emnapi/core@1.11.1': @@ -1907,88 +1857,6 @@ packages: cpu: [x64] os: [win32] - '@parcel/watcher-android-arm64@2.6.0': - resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.6.0': - resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.6.0': - resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.6.0': - resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.6.0': - resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm-musl@2.6.0': - resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-arm64-glibc@2.6.0': - resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm64-musl@2.6.0': - resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-x64-glibc@2.6.0': - resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-x64-musl@2.6.0': - resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@parcel/watcher-win32-arm64@2.6.0': - resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-x64@2.6.0': - resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - - '@parcel/watcher@2.6.0': - resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} - engines: {node: '>= 10.0.0'} - '@pgsql/quotes@17.1.0': resolution: {integrity: sha512-J/H+LcrENBpYgL45WW6aTjb5Yk4tX4+AmB2/k8KZa+Zh3wiCtqmNIag+HZz5HmWaF6EZK9ZGC95NBD1fs+rUvg==} @@ -3173,10 +3041,6 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - auto-bind@5.0.1: - resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - aws-sign2@0.7.0: resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} @@ -3379,31 +3243,15 @@ packages: resolution: {integrity: sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==} engines: {node: '>=14.16'} - cli-boxes@4.0.1: - resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} - engines: {node: '>=18.20 <19 || >=20.10'} - - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cli-highlight@2.1.11: resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} - cli-truncate@6.1.1: - resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} - engines: {node: '>=22'} - client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -3431,10 +3279,6 @@ packages: resolution: {integrity: sha512-rH0jBKeLkVrK7NsZ5Ba2l7WdMBmm1k0FMpABeXUU1PgTUxbz3261gEuCSsrYuXo4BwAe3yCcIbQ93YyS52lOGQ==} hasBin: true - code-excerpt@4.0.0: - resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -3519,10 +3363,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convert-to-spaces@2.0.1: - resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} @@ -3672,8 +3512,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@4.0.0-rc.111: - resolution: {integrity: sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} electron-to-chromium@1.5.389: resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} @@ -3737,9 +3577,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.49.0: - resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} - esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -3762,10 +3599,6 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -4329,26 +4162,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - ink-spinner@5.0.0: - resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} - engines: {node: '>=14.16'} - peerDependencies: - ink: '>=4.0.0' - react: '>=18.0.0' - - ink@7.1.1: - resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} - engines: {node: '>=22'} - peerDependencies: - '@types/react': '>=19.2.0' - react: '>=19.2.0' - react-devtools-core: '>=6.1.2' - peerDependenciesMeta: - '@types/react': - optional: true - react-devtools-core: - optional: true - inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -4383,10 +4196,6 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -4398,11 +4207,6 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-in-ci@2.0.0: - resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} - engines: {node: '>=20'} - hasBin: true - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4968,10 +4772,6 @@ packages: engines: {node: '>=16'} hasBin: true - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -5078,9 +4878,6 @@ packages: sass: optional: true - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} @@ -5231,10 +5028,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -5360,10 +5153,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - patch-console@2.0.0: - resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -5633,20 +5422,11 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-devtools-core@7.0.1: - resolution: {integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==} - react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: react: ^19.2.8 - react-reconciler@0.33.0: - resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} - engines: {node: '>=0.10.0'} - peerDependencies: - react: ^19.2.0 - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -5793,10 +5573,6 @@ packages: resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==} engines: {node: '>=20'} - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -5889,10 +5665,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.10.0: - resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} - engines: {node: '>= 0.4'} - shiki@4.4.3: resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} @@ -5938,10 +5710,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - slice-ansi@9.0.0: - resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} - engines: {node: '>=22'} - smol-toml@1.8.0: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} @@ -5994,10 +5762,6 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6128,10 +5892,6 @@ packages: resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==} engines: {node: '>=14.16'} - terminal-size@4.0.1: - resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} - engines: {node: '>=18'} - text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -6544,17 +6304,9 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@6.0.0: - resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} - engines: {node: '>=20'} - wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wrap-ansi@10.0.0: - resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} - engines: {node: '>=20'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6566,30 +6318,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.11: - resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} - engines: {node: '>=8.3.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -6638,9 +6366,6 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} - yoga-layout@3.2.1: - resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} - yuku-analyzer@0.8.4: resolution: {integrity: sha512-793VvcTjw38e2kjWEx+4R9M1A6CUIS+cRQqi6eHKAnn9rt2mufMEf5/0ZPhVamByEaKQrUsHaDkSuXRm7HKGiw==} @@ -6680,11 +6405,6 @@ snapshots: '@actions/io@3.0.2': {} - '@alcalzone/ansi-tokenize@0.3.0': - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.234': optional: true @@ -6889,33 +6609,27 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@effect/atom-react@4.0.0-rc.111(effect@4.0.0-rc.111)(react@19.2.8)(scheduler@0.27.0)': + '@effect/platform-bun@4.0.0-rc.112(effect@4.0.0-rc.112)': dependencies: - effect: 4.0.0-rc.111 - react: 19.2.8 - scheduler: 0.27.0 - - '@effect/platform-bun@4.0.0-rc.111(effect@4.0.0-rc.111)': - dependencies: - '@effect/platform-node-shared': 4.0.0-rc.111(effect@4.0.0-rc.111) - effect: 4.0.0-rc.111 + '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) + effect: 4.0.0-rc.112 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-rc.111(effect@4.0.0-rc.111)': + '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-rc.112)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-rc.111 + effect: 4.0.0-rc.112 ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1)': + '@effect/platform-node@4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1)': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.111(effect@4.0.0-rc.111) - effect: 4.0.0-rc.111 + '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) + effect: 4.0.0-rc.112 mime: 4.1.0 redis: 6.2.1 undici: 8.10.0 @@ -6923,9 +6637,9 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-pg@4.0.0-rc.111(effect@4.0.0-rc.111)': + '@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112)': dependencies: - effect: 4.0.0-rc.111 + effect: 4.0.0-rc.112 pg: 8.23.0 pg-connection-string: 2.14.0 pg-cursor: 2.22.0(pg@8.23.0) @@ -6965,9 +6679,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.36.5 '@effect/tsgo-win32-x64': 0.36.5 - '@effect/vitest@4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10)': + '@effect/vitest@4.0.0-rc.112(effect@4.0.0-rc.112)(vitest@4.1.10)': dependencies: - effect: 4.0.0-rc.111 + effect: 4.0.0-rc.112 vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@emnapi/core@1.11.1': @@ -7763,54 +7477,6 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.78.0': optional: true - '@parcel/watcher-android-arm64@2.6.0': - optional: true - - '@parcel/watcher-darwin-arm64@2.6.0': {} - - '@parcel/watcher-darwin-x64@2.6.0': {} - - '@parcel/watcher-freebsd-x64@2.6.0': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.6.0': - optional: true - - '@parcel/watcher-linux-arm-musl@2.6.0': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.6.0': {} - - '@parcel/watcher-linux-arm64-musl@2.6.0': {} - - '@parcel/watcher-linux-x64-glibc@2.6.0': {} - - '@parcel/watcher-linux-x64-musl@2.6.0': {} - - '@parcel/watcher-win32-arm64@2.6.0': {} - - '@parcel/watcher-win32-x64@2.6.0': {} - - '@parcel/watcher@2.6.0': - dependencies: - detect-libc: 2.1.2 - is-glob: 4.0.3 - node-addon-api: 7.1.1 - picomatch: 4.0.5 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.6.0 - '@parcel/watcher-darwin-arm64': 2.6.0 - '@parcel/watcher-darwin-x64': 2.6.0 - '@parcel/watcher-freebsd-x64': 2.6.0 - '@parcel/watcher-linux-arm-glibc': 2.6.0 - '@parcel/watcher-linux-arm-musl': 2.6.0 - '@parcel/watcher-linux-arm64-glibc': 2.6.0 - '@parcel/watcher-linux-arm64-musl': 2.6.0 - '@parcel/watcher-linux-x64-glibc': 2.6.0 - '@parcel/watcher-linux-x64-musl': 2.6.0 - '@parcel/watcher-win32-arm64': 2.6.0 - '@parcel/watcher-win32-x64': 2.6.0 - '@pgsql/quotes@17.1.0': {} '@pgsql/traverse@17.2.6': @@ -8959,8 +8625,6 @@ snapshots: atomic-sleep@1.0.0: {} - auto-bind@5.0.1: {} - aws-sign2@0.7.0: {} aws4@1.13.2: {} @@ -9153,12 +8817,6 @@ snapshots: dependencies: escape-string-regexp: 5.0.0 - cli-boxes@4.0.1: {} - - cli-cursor@4.0.0: - dependencies: - restore-cursor: 4.0.0 - cli-highlight@2.1.11: dependencies: chalk: 4.1.2 @@ -9168,19 +8826,12 @@ snapshots: parse5-htmlparser2-tree-adapter: 6.0.1 yargs: 16.2.2 - cli-spinners@2.9.2: {} - cli-table3@0.6.5: dependencies: string-width: 4.2.3 optionalDependencies: '@colors/colors': 1.5.0 - cli-truncate@6.1.1: - dependencies: - slice-ansi: 9.0.0 - string-width: 8.2.2 - client-only@0.0.1: {} clipanion@4.0.0-rc.4(typanion@3.14.0): @@ -9205,10 +8856,6 @@ snapshots: cnfast@0.1.0: {} - code-excerpt@4.0.0: - dependencies: - convert-to-spaces: 2.0.1 - collapse-white-space@2.1.0: {} color-convert@1.9.3: @@ -9292,8 +8939,6 @@ snapshots: convert-source-map@2.0.0: {} - convert-to-spaces@2.0.1: {} - cookie-signature@1.0.7: {} cookie-signature@1.2.2: {} @@ -9419,9 +9064,8 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-rc.111: + effect@4.0.0-rc.112: dependencies: - '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 msgpackr: 2.0.5 @@ -9473,8 +9117,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - es-toolkit@1.49.0: {} - esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -9524,8 +9166,6 @@ snapshots: escape-string-regexp@1.0.5: {} - escape-string-regexp@2.0.0: {} - escape-string-regexp@5.0.0: {} estree-util-attach-comments@3.0.0: @@ -10262,47 +9902,6 @@ snapshots: ini@1.3.8: {} - ink-spinner@5.0.0(ink@7.1.1(@types/react@19.2.18)(react-devtools-core@7.0.1)(react@19.2.8))(react@19.2.8): - dependencies: - cli-spinners: 2.9.2 - ink: 7.1.1(@types/react@19.2.18)(react-devtools-core@7.0.1)(react@19.2.8) - react: 19.2.8 - - ink@7.1.1(@types/react@19.2.18)(react-devtools-core@7.0.1)(react@19.2.8): - dependencies: - '@alcalzone/ansi-tokenize': 0.3.0 - ansi-escapes: 7.3.0 - ansi-styles: 6.2.3 - auto-bind: 5.0.1 - chalk: 5.6.2 - cli-boxes: 4.0.1 - cli-cursor: 4.0.0 - cli-truncate: 6.1.1 - code-excerpt: 4.0.0 - es-toolkit: 1.49.0 - indent-string: 5.0.0 - is-in-ci: 2.0.0 - patch-console: 2.0.0 - react: 19.2.8 - react-reconciler: 0.33.0(react@19.2.8) - scheduler: 0.27.0 - signal-exit: 3.0.7 - slice-ansi: 9.0.0 - stack-utils: 2.0.6 - string-width: 8.2.2 - terminal-size: 4.0.1 - type-fest: 5.8.0 - widest-line: 6.0.0 - wrap-ansi: 10.0.0 - ws: 8.21.1 - yoga-layout: 3.2.1 - optionalDependencies: - '@types/react': 19.2.18 - react-devtools-core: 7.0.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - inline-style-parser@0.2.7: {} ip-address@10.3.1: {} @@ -10326,10 +9925,6 @@ snapshots: is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.6.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -10338,8 +9933,6 @@ snapshots: is-hexadecimal@2.0.1: {} - is-in-ci@2.0.0: {} - is-number@7.0.0: {} is-obj@2.0.0: {} @@ -11115,8 +10708,6 @@ snapshots: mime@4.1.0: {} - mimic-fn@2.1.0: {} - mimic-fn@4.0.0: {} mimic-response@4.0.0: {} @@ -11213,8 +10804,6 @@ snapshots: - '@types/node' - babel-plugin-macros - node-addon-api@7.1.1: {} - node-emoji@2.2.0: dependencies: '@sindresorhus/is': 4.6.0 @@ -11282,10 +10871,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -11476,8 +11061,6 @@ snapshots: parseurl@1.3.3: {} - patch-console@2.0.0: {} - path-exists@3.0.0: {} path-key@3.1.1: {} @@ -11741,24 +11324,11 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-devtools-core@7.0.1: - dependencies: - shell-quote: 1.10.0 - ws: 7.5.11 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 scheduler: 0.27.0 - react-reconciler@0.33.0(react@19.2.8): - dependencies: - react: 19.2.8 - scheduler: 0.27.0 - react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -11972,11 +11542,6 @@ snapshots: dependencies: lowercase-keys: 3.0.0 - restore-cursor@4.0.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - reusify@1.1.0: {} rolldown@1.1.5: @@ -12167,8 +11732,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.10.0: {} - shiki@4.4.3: dependencies: '@shikijs/core': 4.4.3 @@ -12228,11 +11791,6 @@ snapshots: slash@3.0.0: {} - slice-ansi@9.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - smol-toml@1.8.0: {} sonic-boom@3.8.1: @@ -12285,10 +11843,6 @@ snapshots: safer-buffer: 2.1.2 tweetnacl: 0.14.5 - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} standardwebhooks@1.0.0: @@ -12433,8 +11987,6 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 - terminal-size@4.0.1: {} - text-decoder@1.2.7: dependencies: b4a: 1.8.1 @@ -12844,18 +12396,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@6.0.0: - dependencies: - string-width: 8.2.2 - wordwrap@1.0.0: {} - wrap-ansi@10.0.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.2.2 - strip-ansi: 7.2.0 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -12870,10 +12412,6 @@ snapshots: wrappy@1.0.2: {} - ws@7.5.11: {} - - ws@8.21.1: {} - ws@8.21.3: {} xtend@4.0.2: {} @@ -12909,8 +12447,6 @@ snapshots: yoctocolors@2.2.0: {} - yoga-layout@3.2.1: {} - yuku-analyzer@0.8.4: dependencies: '@yuku-toolchain/types': 0.8.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c816e33358..9e132bcec2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,17 +12,17 @@ allowBuilds: sharp: true catalog: - "@effect/atom-react": "4.0.0-rc.111" - "@effect/platform-bun": "4.0.0-rc.111" - "@effect/platform-node": "4.0.0-rc.111" - "@effect/sql-pg": "4.0.0-rc.111" + "@effect/platform-bun": "4.0.0-rc.112" + "@effect/platform-node": "4.0.0-rc.112" + "@effect/sql-pg": "4.0.0-rc.112" "@effect/tsgo": "0.36.5" - "@effect/vitest": "4.0.0-rc.111" + "@effect/vitest": "4.0.0-rc.112" "@tsconfig/bun": "^1.0.11" "@types/bun": "^1.4.0" + "@types/ws": "8.18.1" "typescript": "^7.0.2" "@vitest/coverage-istanbul": "^4.1.10" - "effect": "4.0.0-rc.111" + "effect": "4.0.0-rc.112" "knip": "^6.32.2" "oxfmt": "^0.63.0" "oxlint": "1.78.0" @@ -30,6 +30,7 @@ catalog: "tldts": "^7.4.10" "turbo": "2.10.11" "vitest": "^4.1.10" + "ws": "8.21.3" blockExoticSubdeps: true @@ -45,17 +46,16 @@ minimumReleaseAgeExclude: - "@turbo/linux-arm64@2.10.11" - "@turbo/windows-64@2.10.11" - "@turbo/windows-arm64@2.10.11" - - "@effect/atom-react@4.0.0-rc.111" - - "@effect/platform-bun@4.0.0-rc.111" - - "@effect/platform-node@4.0.0-rc.111" - - "@effect/platform-node-shared@4.0.0-rc.111" - - "@effect/sql-pg@4.0.0-rc.111" - - "@effect/vitest@4.0.0-rc.111" + - "@effect/platform-bun@4.0.0-rc.112" + - "@effect/platform-node@4.0.0-rc.112" + - "@effect/platform-node-shared@4.0.0-rc.112" + - "@effect/sql-pg@4.0.0-rc.112" + - "@effect/vitest@4.0.0-rc.112" - "@supabase/pg-delta@1.0.0-alpha.46" - "@supabase/pg-topo@1.0.0-alpha.5" - "@types/bun@1.4.0" - "bun-types@1.4.0" - - "effect@4.0.0-rc.111" + - "effect@4.0.0-rc.112" - "turbo@2.10.11" supportedArchitectures: From 56bfb3716671ecd743da26a8aaa2670472e39e00 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 01:30:37 +0200 Subject: [PATCH 108/174] chore(stack): simplify observation handles --- .../functions/serve/serve.integration.test.ts | 16 +- .../functions-dev-runtime.integration.test.ts | 25 +- .../src/next/commands/logs/logs.handler.ts | 21 +- .../commands/logs/logs.integration.test.ts | 24 +- .../functions/managed-functions-runtime.ts | 36 ++- packages/stack/src/control/StackRpc.ts | 13 +- .../control-transport.integration.test.ts | 4 +- packages/stack/src/index.ts | 9 +- packages/stack/src/public/EffectStack.ts | 304 +++++++++--------- packages/stack/src/public/Errors.ts | 1 - packages/stack/src/public/Logs.ts | 15 + packages/stack/src/public/PromiseStack.ts | 107 +----- packages/stack/src/public/Testing.ts | 61 ++-- .../public/effect-stack.integration.test.ts | 121 ++++--- .../src/public/promise.integration.test.ts | 89 ++--- .../src/public/testing.integration.test.ts | 53 ++- .../stack/src/public/whole-stack.e2e.test.ts | 41 +-- packages/stack/src/supervisor/Supervisor.ts | 69 ++-- .../supervisor/handles.integration.test.ts | 42 +-- .../supervisor/supervisor.integration.test.ts | 139 +++----- 20 files changed, 507 insertions(+), 683 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index b238dc762b..725b3dd74e 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -62,8 +62,22 @@ describe("legacy functions serve", () => { startedConfig = options?.config; return status("starting"); }), - watchStatus: () => Stream.succeed(status("running")), + status: () => Effect.succeed(status("running")), logs: () => + Effect.succeed({ + entries: [ + { + cursor: { opaque: "1" }, + timestamp: "now", + source: "functions" as const, + stream: "stdout" as const, + message: "ready", + }, + ], + cursor: { opaque: "1" }, + running: true, + }), + followLogs: () => Stream.succeed({ cursor: { opaque: "1" }, timestamp: "now", diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts index 90bc21b057..7ab1db8508 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts @@ -82,8 +82,9 @@ describe("managed Functions serving", () => { servedConfig = options?.config; return status("running"); }), - watchStatus: () => Stream.empty, - logs: () => Stream.empty, + status: () => Effect.succeed(status("running")), + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: true }), + followLogs: () => Stream.empty, }; const operations: ServeManagedFunctionsOperations = { createStack: () => Effect.succeed(stack), @@ -104,8 +105,9 @@ describe("managed Functions serving", () => { startedConfig = options?.config; return status("starting"); }), - watchStatus: () => Stream.succeed(status("running")), - logs: () => + status: () => Effect.succeed(status("running")), + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: true }), + followLogs: () => Stream.fromIterable([ { cursor: { opaque: "1" }, @@ -202,8 +204,9 @@ describe("managed Functions serving", () => { startedConfig = options?.config; return status("running"); }), - watchStatus: () => Stream.empty, - logs: () => Stream.empty, + status: () => Effect.succeed(status("running")), + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: true }), + followLogs: () => Stream.empty, }; const operations: ServeManagedFunctionsOperations = { createStack: () => Effect.succeed(stack), @@ -358,8 +361,9 @@ describe("managed Functions serving", () => { message: "Running stack input changed", }), ), - watchStatus: () => Stream.empty, - logs: () => Stream.empty, + status: () => Effect.succeed(status("running")), + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: true }), + followLogs: () => Stream.empty, }; const operations: ServeManagedFunctionsOperations = { createStack: () => Effect.succeed(stack), @@ -405,8 +409,9 @@ describe("managed Functions serving", () => { message: "Stack owner release is outdated", }), ), - watchStatus: () => Stream.empty, - logs: () => Stream.empty, + status: () => Effect.succeed(status("running")), + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: true }), + followLogs: () => Stream.empty, }; const operations: ServeManagedFunctionsOperations = { createStack: () => Effect.succeed(stack), diff --git a/apps/cli/src/next/commands/logs/logs.handler.ts b/apps/cli/src/next/commands/logs/logs.handler.ts index 1b53bcf9d6..81c55c6007 100644 --- a/apps/cli/src/next/commands/logs/logs.handler.ts +++ b/apps/cli/src/next/commands/logs/logs.handler.ts @@ -21,7 +21,7 @@ type LogsRuntime = | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner; -type LogsStack = Pick; +type LogsStack = Pick; export interface LogsOperations { readonly findStack: ( options: FindStackOptions, @@ -55,17 +55,9 @@ export const logs = Effect.fnUntraced(function* ( const stack = yield* operations.openStack(descriptorOption.value.id); const capabilities: ReadonlyArray | undefined = flags.service.length === 0 ? undefined : flags.service; - const retainedChunk = yield* stack - .logs({ capabilities, follow: false }) - .pipe(Stream.runCollect); - const retained = retainedChunk; - const history = flags.tail === 0 ? [] : retained.slice(-flags.tail); - let cursorEntries = retained; - if (retained.length === 0 && capabilities !== undefined) { - const fallbackChunk = yield* stack.logs({ follow: false }).pipe(Stream.runCollect); - cursorEntries = fallbackChunk; - } - const emit = (source: "history" | "live") => (entry: (typeof retained)[number]) => + const retained = yield* stack.logs({ capabilities, tail: flags.tail }); + const history = retained.entries; + const emit = (source: "history" | "live") => (entry: (typeof retained.entries)[number]) => output.format === "stream-json" ? output.event({ type: "log-entry", @@ -76,11 +68,10 @@ export const logs = Effect.fnUntraced(function* ( source, }) : output.info(`[${entry.source}] ${entry.message}`); - yield* Stream.fromIterable(history).pipe(Stream.runForEach(emit("history"))); + yield* Effect.forEach(history, emit("history"), { discard: true }); if (!flags.noFollow) { - const cursor = cursorEntries.at(-1)?.cursor; yield* stack - .logs({ capabilities, follow: true, ...(cursor === undefined ? {} : { cursor }) }) + .followLogs({ capabilities, cursor: retained.cursor }) .pipe(Stream.runForEach(emit("live"))); } }), diff --git a/apps/cli/src/next/commands/logs/logs.integration.test.ts b/apps/cli/src/next/commands/logs/logs.integration.test.ts index 223260babe..c296688c50 100644 --- a/apps/cli/src/next/commands/logs/logs.integration.test.ts +++ b/apps/cli/src/next/commands/logs/logs.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option, Stream } from "effect"; -import { StackIdSchema } from "@supabase/stack/effect"; +import { StackIdSchema, type EffectStack } from "@supabase/stack/effect"; import { logs, type LogsOperations } from "./logs.handler.ts"; import { emptyEnv, mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; @@ -75,12 +75,19 @@ describe("logs handler", () => { ), openStack: () => Effect.succeed({ - logs: (options) => { + logs: (options: Parameters[0]) => { seen.push(options); - if (options?.follow === true) return Stream.succeed(live); - return Stream.fromIterable( - entries.filter((entry) => options?.capabilities?.includes(entry.source) ?? true), - ); + return Effect.succeed({ + entries: entries.filter( + (entry) => options?.capabilities?.includes(entry.source) ?? true, + ), + cursor: { opaque: "v1_3" }, + running: true, + }); + }, + followLogs: (options: Parameters[0]) => { + seen.push(options); + return Stream.succeed(live); }, }), }; @@ -88,11 +95,10 @@ describe("logs handler", () => { Effect.provide(Layer.mergeAll(emptyEnv(), out.layer, mockProcessControl().layer)), Effect.tap(() => Effect.sync(() => { - expect(seen[0]).toMatchObject({ capabilities: ["auth"], follow: false }); + expect(seen[0]).toMatchObject({ capabilities: ["auth"], tail: 1 }); expect(seen[1]).toMatchObject({ capabilities: ["auth"], - follow: true, - cursor: { opaque: "2" }, + cursor: { opaque: "v1_3" }, }); expect(out.events).toContainEqual( expect.objectContaining({ type: "log-entry", source: "history", line: "new" }), diff --git a/apps/cli/src/shared/functions/managed-functions-runtime.ts b/apps/cli/src/shared/functions/managed-functions-runtime.ts index 8e9263c840..3c56cd4f93 100644 --- a/apps/cli/src/shared/functions/managed-functions-runtime.ts +++ b/apps/cli/src/shared/functions/managed-functions-runtime.ts @@ -1,7 +1,18 @@ import { inferFunctionsManifest, loadCliConfig } from "@supabase/config/effect"; import { CliConfigSchema } from "@supabase/config"; import type { CliConfig, FunctionsManifest } from "@supabase/config"; -import { Crypto, FileSystem, Effect, Option, Path, Redacted, Scope, Schema, Stream } from "effect"; +import { + Crypto, + Data, + FileSystem, + Effect, + Path, + Redacted, + Schedule, + Scope, + Schema, + Stream, +} from "effect"; import { parse as parseDotenv } from "dotenv"; import { resolve } from "node:path"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -21,7 +32,7 @@ import { } from "../../next/config/stack-config.ts"; /** The narrow stack surface owned by the managed Functions command. */ -export type ManagedFunctionsStack = Pick; +export type ManagedFunctionsStack = Pick; type ManagedFunctionsRuntime = | Scope.Scope @@ -91,16 +102,25 @@ const capabilityReady = (status: StackStatus): boolean => const servingReady = (status: StackStatus): boolean => status.lifecycle === "running" && status.endpoints.api !== undefined && capabilityReady(status); +class ReadinessPending extends Data.TaggedError("ReadinessPending")<{}> {} + const statusAfterReadiness = Effect.fnUntraced(function* ( stack: ManagedFunctionsStack, initial: StackStatus, ) { if (servingReady(initial)) return initial; - const next = yield* stack.watchStatus().pipe(Stream.filter(servingReady), Stream.runHead); - if (Option.isNone(next)) { - return yield* Effect.fail(new Error("Managed Functions stack did not reach gateway readiness")); - } - return next.value; + return yield* stack.status().pipe( + Effect.flatMap((next) => + servingReady(next) ? Effect.succeed(next) : Effect.fail(new ReadinessPending()), + ), + Effect.retry({ + schedule: Schedule.spaced("100 millis").pipe(Schedule.upTo({ duration: "3 minutes" })), + while: (error) => error instanceof ReadinessPending, + }), + Effect.catchTag("ReadinessPending", () => + Effect.fail(new Error("Managed Functions stack did not reach gateway readiness")), + ), + ); }); const toFunctionOverrides = (manifest: FunctionsManifest, options: ServeManagedFunctionsOptions) => @@ -290,7 +310,7 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( }); if (apiUrl !== undefined) yield* output.info(`${apiUrl}/functions/v1`); yield* stack - .logs({ capabilities: ["functions"], follow: true }) + .followLogs({ capabilities: ["functions"] }) .pipe(Stream.runForEach((entry) => output.info(`[${entry.source}] ${entry.message}`))); }), ); diff --git a/packages/stack/src/control/StackRpc.ts b/packages/stack/src/control/StackRpc.ts index df4b7f77ca..3e8c696892 100644 --- a/packages/stack/src/control/StackRpc.ts +++ b/packages/stack/src/control/StackRpc.ts @@ -5,7 +5,7 @@ import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import { EffectStackCredentialsSchema } from "../public/Credentials.ts"; import { StackConfigSchema } from "../public/Config.ts"; import { CapabilityNameSchema } from "../public/Capability.ts"; -import { LogOptionsSchema, StackLogEntrySchema } from "../public/Logs.ts"; +import { LogQuerySchema, StackLogBatchSchema } from "../public/Logs.ts"; import { StackStatusSchema } from "../public/Status.ts"; import { STACK_ERROR_TAGS } from "../public/Errors.ts"; @@ -65,15 +65,9 @@ const StackRpc = { }), destroy: Rpc.make("destroy", { success: Schema.Void, error: StackRpcErrorSchema }), logs: Rpc.make("logs", { - payload: LogOptionsSchema, - success: StackLogEntrySchema, + payload: LogQuerySchema, + success: StackLogBatchSchema, error: StackRpcErrorSchema, - stream: true, - }), - watchStatus: Rpc.make("watchStatus", { - success: StackStatusSchema, - error: StackRpcErrorSchema, - stream: true, }), } as const; @@ -85,7 +79,6 @@ export const StackRpcGroup = RpcGroup.make( StackRpc.restart, StackRpc.destroy, StackRpc.logs, - StackRpc.watchStatus, ); type StackRpcDefinitions = RpcGroup.Rpcs; export type StackRpcHandlers = { diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 5ec18f6db5..52e1de7a4b 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -12,7 +12,6 @@ import { Redacted, Ref, Scope, - Stream, } from "effect"; import * as TestClock from "effect/testing/TestClock"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; @@ -137,8 +136,7 @@ const withServer = ( start: () => Effect.succeed(status), restart: () => Effect.succeed(status), destroy: () => Effect.void, - logs: () => Stream.empty, - watchStatus: () => Stream.empty, + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }; const defaultMaintenanceHandlers: MaintenanceHandlers = { probe: Effect.succeed({ diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 3f6a7efb5a..c934bc97e5 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -37,6 +37,11 @@ export { CapabilityStatusSchema, ActivationModeSchema, } from "./public/Capability.ts"; -export { LogCursorSchema, LogOptionsSchema, StackLogEntrySchema } from "./public/Logs.ts"; -export type { LogCursor, LogOptions, StackLogEntry } from "./public/Logs.ts"; +export { + LogCursorSchema, + LogQuerySchema, + StackLogBatchSchema, + StackLogEntrySchema, +} from "./public/Logs.ts"; +export type { LogCursor, LogQuery, StackLogBatch, StackLogEntry } from "./public/Logs.ts"; export * from "./public/Errors.ts"; diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 280e2e2b69..0e22bcc252 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -31,7 +31,7 @@ import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; import type { StackConfig } from "./Config.ts"; import { type StackStatus, type StackDescriptor, type StackInspection } from "./Status.ts"; import type { CapabilityName } from "./Capability.ts"; -import type { LogOptions, StackLogEntry } from "./Logs.ts"; +import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import type { EffectStackCredentials } from "./Credentials.ts"; import { InvalidStackIdentityError, @@ -72,7 +72,6 @@ import { type StackStartError, type StackRestartError, type StackStopError, - type StackStatusWatchError, type StackLogsError, type DestroyStackError, type StackError, @@ -164,11 +163,9 @@ export interface EffectStack { // oxlint-disable-next-line effecttsgo/lazy-effect readonly destroy: () => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect - readonly close: () => Effect.Effect; + readonly logs: (query?: LogQuery) => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect - readonly watchStatus: () => Stream.Stream; - // oxlint-disable-next-line effecttsgo/lazy-effect - readonly logs: (options?: LogOptions) => Stream.Stream; + readonly followLogs: (query?: LogQuery) => Stream.Stream; } const containerRuntime = (engine: ContainerEngineKind): StackRuntime => ({ @@ -449,15 +446,12 @@ export const makeHandle = ( Option.Option, StackError >; - readonly readLogs?: ( - options?: LogOptions, - ) => Effect.Effect, StackLogsError>; + readonly readLogs?: (query?: LogQuery) => Effect.Effect; readonly waitForRelease?: () => Effect.Effect; readonly replacement?: (options?: StartStackOptions) => Effect.Effect; } = {}, ): Effect.Effect => Effect.gen(function* () { - const closed = yield* Ref.make(false); const readOfflineState = options.readOfflineState; const readPersistedState = options.readPersistedState; const readLogs = options.readLogs; @@ -498,16 +492,6 @@ export const makeHandle = ( : Effect.fail( new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), ); - const check = ( - effect: Effect.Effect, - ): Effect.Effect => - Ref.get(closed).pipe( - Effect.flatMap((isClosed): Effect.Effect => - isClosed - ? Effect.fail(new StackLifecycleConflictError({ message: "Stack handle is closed" })) - : effect, - ), - ); const mapRpcClientFailure = ( error: RpcClientError, mapError: (error: ControlError) => E, @@ -533,7 +517,7 @@ export const makeHandle = ( call: (rpc: StackRpcClient) => Effect.Effect, mapError: (error: ControlError) => E, launch = false, - ): Effect.Effect => { + ): Effect.Effect => { const rpcCall: Effect.Effect = resolveClient( launch, ).pipe(Effect.flatMap((owner) => Effect.scoped(owner.rpc.pipe(Effect.flatMap(call))))); @@ -544,62 +528,60 @@ export const makeHandle = ( (error) => Effect.fail(mapError(error)), ), ); - return check(mapped); + return mapped; }; - const destroyAndAwaitOwner: Effect.Effect = check( - resolveClient(true).pipe( - Effect.mapError(destroyError), - Effect.flatMap((client) => - Effect.gen(function* () { - const ownerConnected = yield* Deferred.make(); - const ownerWatch = client.awaitClose( - Deferred.succeed(ownerConnected, undefined).pipe(Effect.asVoid), - ); - const ownerFiber = yield* Effect.forkChild(ownerWatch, { startImmediately: true }); - const ownerReady = Deferred.await(ownerConnected).pipe( - Effect.raceFirst( - Fiber.join(ownerFiber).pipe( - Effect.flatMap(() => - Effect.fail( - new StackDestructionError({ - message: "Unable to observe Supervisor control connection", - }), - ), + const destroyAndAwaitOwner: Effect.Effect = resolveClient(true).pipe( + Effect.mapError(destroyError), + Effect.flatMap((client) => + Effect.gen(function* () { + const ownerConnected = yield* Deferred.make(); + const ownerWatch = client.awaitClose( + Deferred.succeed(ownerConnected, undefined).pipe(Effect.asVoid), + ); + const ownerFiber = yield* Effect.forkChild(ownerWatch, { startImmediately: true }); + const ownerReady = Deferred.await(ownerConnected).pipe( + Effect.raceFirst( + Fiber.join(ownerFiber).pipe( + Effect.flatMap(() => + Effect.fail( + new StackDestructionError({ + message: "Unable to observe Supervisor control connection", + }), ), + ), + Effect.mapError( + (cause) => + new StackDestructionError({ + message: "Unable to observe Supervisor control connection", + cause, + }), + ), + ), + ), + ); + const result = yield* Effect.exit( + ownerReady.pipe( + Effect.andThen(invoke((rpc) => rpc.destroy(undefined), destroyError)), + // The owner closes its control server only after all workload cleanup has completed. + // Await the exact preface-only socket instead of decoding a terminal RPC stream Exit. + Effect.andThen( + Fiber.join(ownerFiber).pipe( Effect.mapError( (cause) => new StackDestructionError({ - message: "Unable to observe Supervisor control connection", + message: "Unable to observe Supervisor shutdown completion", cause, }), ), ), ), - ); - const result = yield* Effect.exit( - ownerReady.pipe( - Effect.andThen(invoke((rpc) => rpc.destroy(undefined), destroyError)), - // The owner closes its control server only after all workload cleanup has completed. - // Await the exact preface-only socket instead of decoding a terminal RPC stream Exit. - Effect.andThen( - Fiber.join(ownerFiber).pipe( - Effect.mapError( - (cause) => - new StackDestructionError({ - message: "Unable to observe Supervisor shutdown completion", - cause, - }), - ), - ), - ), - ), - ); - if (Exit.isFailure(result)) { - yield* Fiber.interrupt(ownerFiber); - } - return yield* result; - }), - ), + ), + ); + if (Exit.isFailure(result)) { + yield* Fiber.interrupt(ownerFiber); + } + return yield* result; + }), ), ); const status = () => { @@ -649,35 +631,33 @@ export const makeHandle = ( if (options.waitForRelease !== undefined) yield* options.waitForRelease(); }).pipe(Effect.mapError(stopError)); const stop = () => - check( - resolveClient(false).pipe( - Effect.mapError(stopError), - Effect.flatMap(stopOwner), - Effect.catchTag("StackOwnershipConflictError", () => - (readOfflineState === undefined - ? Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ) - : readOfflineState().pipe( - Effect.mapError(stopError), - Effect.flatMap((state) => - Option.isSome(state) && - (state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured") - ? Effect.void - : resolveClient(true).pipe( - Effect.mapError(stopError), - Effect.flatMap(stopOwner), - ), - ), - ) - ).pipe( - // Ownership artifacts that block the offline fast path may be - // stale. Let ensureSupervisor arbitrate the lease; a live owner - // remains protected and returns a typed conflict. - Effect.catchTag("StackOwnershipConflictError", () => - resolveClient(true).pipe(Effect.mapError(stopError), Effect.flatMap(stopOwner)), - ), + resolveClient(false).pipe( + Effect.mapError(stopError), + Effect.flatMap(stopOwner), + Effect.catchTag("StackOwnershipConflictError", () => + (readOfflineState === undefined + ? Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ) + : readOfflineState().pipe( + Effect.mapError(stopError), + Effect.flatMap((state) => + Option.isSome(state) && + (state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured") + ? Effect.void + : resolveClient(true).pipe( + Effect.mapError(stopError), + Effect.flatMap(stopOwner), + ), + ), + ) + ).pipe( + // Ownership artifacts that block the offline fast path may be + // stale. Let ensureSupervisor arbitrate the lease; a live owner + // remains protected and returns a typed conflict. + Effect.catchTag("StackOwnershipConflictError", () => + resolveClient(true).pipe(Effect.mapError(stopError), Effect.flatMap(stopOwner)), ), ), ), @@ -758,6 +738,16 @@ export const makeHandle = ( ? Effect.succeed(result.value) : Effect.failCause(result.cause); }); + const logs = (query?: LogQuery): Effect.Effect => + invoke((rpc) => rpc.logs(query ?? {}), logsError).pipe( + Effect.catchTag("StackOwnershipConflictError", () => + readLogs === undefined + ? Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ) + : readLogs(query), + ), + ); return { id, status, @@ -808,69 +798,32 @@ export const makeHandle = ( }), stop, destroy: () => destroyAndAwaitOwner, - close: () => Ref.set(closed, true), - watchStatus: () => - Stream.unwrap( - check( - resolveClient(false).pipe( - Effect.mapError(statusError), - Effect.map((owner) => - Stream.unwrap( - owner.rpc.pipe( - Effect.map((rpc) => - rpc - .watchStatus(undefined) - .pipe( - Stream.mapError((error) => - Predicate.isTagged(error, "RpcClientError") - ? new StackOwnershipConflictError({ message: error.message }) - : statusError(error), - ), - ), - ), - Effect.catchIf( - (error): error is RpcClientError => - Predicate.isTagged(error, "RpcClientError"), - (error) => - Effect.fail(new StackOwnershipConflictError({ message: error.message })), - ), - ), - ), - ), - Effect.catchTag("StackOwnershipConflictError", () => - Effect.succeed(Stream.fromEffect(status())), - ), - ), - ), - ).pipe(Stream.scoped), - logs: (options) => - Stream.unwrap( - check( - resolveClient(false).pipe( - Effect.mapError(logsError), - Effect.map((owner) => - Stream.unwrap( - owner.rpc.pipe( - Effect.map((rpc) => rpc.logs(options ?? {}).pipe(Stream.mapError(logsError))), - Effect.catchIf( - (error): error is RpcClientError => - Predicate.isTagged(error, "RpcClientError"), - (error) => - Effect.fail(new StackOwnershipConflictError({ message: error.message })), - ), - ), - ), - ), - Effect.catchTag("StackOwnershipConflictError", () => - readLogs === undefined - ? Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ) - : readLogs(options).pipe(Effect.map((entries) => Stream.fromIterable(entries))), - ), + logs, + followLogs: (query) => + Stream.paginate({ cursor: query?.cursor, first: true }, ({ cursor, first }) => { + const { cursor: _initialCursor, ...baseQuery } = query ?? {}; + const options = { + ...baseQuery, + ...(cursor === undefined || cursor.opaque === "v1_0" ? {} : { cursor }), + }; + const request = logs(options); + const delayed = first + ? request + : Effect.schedule(Effect.void, Schedule.duration("100 millis")).pipe( + Effect.andThen(request), + ); + return delayed.pipe( + Effect.map( + (batch) => + [ + batch.entries, + batch.running + ? Option.some({ cursor: batch.cursor, first: false }) + : Option.none(), + ] as const, ), - ), - ).pipe(Stream.scoped), + ); + }), } satisfies EffectStack; }); @@ -1056,13 +1009,44 @@ const handleDependencies = (options: { ); const readPersistedState = () => provide(options.store.read(options.id)).pipe(Effect.map(optionOf)); - const readLogs = (logOptions?: LogOptions) => + const readLogs = (query?: LogQuery) => ensureOffline().pipe( Effect.andThen( resolveStackPaths({ stateRoot: options.environment.stateRoot, stackId: options.id }), ), Effect.provideService(Path.Path, options.path), - Effect.flatMap((paths) => readRetainedLogs(options.fileSystem, paths.logs, logOptions)), + Effect.flatMap((paths) => + readRetainedLogs( + options.fileSystem, + paths.logs, + query?.cursor === undefined || query.cursor.opaque === "v1_0" + ? undefined + : { cursor: query.cursor }, + ).pipe( + Effect.map((scanned) => { + const capabilities = + query?.capabilities === undefined ? undefined : new Set(query.capabilities); + const filtered = scanned.filter((entry) => + capabilities === undefined + ? true + : entry.source !== "gateway" && + entry.source !== "supervisor" && + capabilities.has(entry.source), + ); + const entries = + query?.tail === undefined + ? filtered + : query.tail <= 0 + ? [] + : filtered.slice(-Math.floor(query.tail)); + return { + entries, + cursor: scanned.at(-1)?.cursor ?? query?.cursor ?? { opaque: "v1_0" }, + running: false, + } satisfies StackLogBatch; + }), + ), + ), Effect.mapError((error) => error instanceof StackOwnershipConflictError ? error diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 493534fa5c..97d2f88620 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -194,7 +194,6 @@ export type StackStatusError = | StackStateInvalidError | StackStateFormatUnsupportedError | StackUpgradeRequiredError; -export type StackStatusWatchError = StackStatusError; export type StackCredentialsError = | StackNotFoundError | StackNotRunningError diff --git a/packages/stack/src/public/Logs.ts b/packages/stack/src/public/Logs.ts index cf882063c0..d28204a63b 100644 --- a/packages/stack/src/public/Logs.ts +++ b/packages/stack/src/public/Logs.ts @@ -6,10 +6,18 @@ export const LogCursorSchema = Schema.Struct({ }); export type LogCursor = Schema.Schema.Type; +export const LogQuerySchema = Schema.Struct({ + capabilities: Schema.optionalKey(Schema.Array(CapabilityNameSchema)), + cursor: Schema.optionalKey(LogCursorSchema), + tail: Schema.optionalKey(Schema.Finite), +}); +export type LogQuery = Schema.Schema.Type; + export const LogOptionsSchema = Schema.Struct({ capabilities: Schema.optionalKey(Schema.Array(CapabilityNameSchema)), follow: Schema.optionalKey(Schema.Boolean), cursor: Schema.optionalKey(LogCursorSchema), + tail: Schema.optionalKey(Schema.Finite), }); export type LogOptions = Schema.Schema.Type; @@ -22,4 +30,11 @@ export const StackLogEntrySchema = Schema.Struct({ }); export type StackLogEntry = Schema.Schema.Type; +export const StackLogBatchSchema = Schema.Struct({ + entries: Schema.Array(StackLogEntrySchema), + cursor: LogCursorSchema, + running: Schema.Boolean, +}); +export type StackLogBatch = Schema.Schema.Type; + export type StackLogSource = CapabilityName | "supervisor" | "gateway"; diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index c395cc6660..0d6f6802a5 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -30,11 +30,11 @@ import { import type { StackConfig } from "./Config.ts"; import { StackConfigSchema } from "./Config.ts"; import { PromiseStackCredentialsSchema, type PromiseStackCredentials } from "./Credentials.ts"; -import type { LogOptions, StackLogEntry } from "./Logs.ts"; +import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import type { StackDescriptor, StackInspection, StackStatus } from "./Status.ts"; import type { StackId } from "./StackId.ts"; import type { PreparedCapability, PrepareStackResult } from "./EffectStack.ts"; -import { StackLifecycleConflictError, InvalidStackConfigError } from "./Errors.ts"; +import { InvalidStackConfigError } from "./Errors.ts"; import { StackRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; // Promise methods are the deliberate outer boundary of this package. @@ -68,9 +68,8 @@ export interface PromiseStack { readonly restart: (options?: PromiseStartStackOptions) => Promise; readonly stop: () => Promise; readonly destroy: () => Promise; - readonly close: () => Promise; - readonly watchStatus: () => AsyncIterable; - readonly logs: (options?: LogOptions) => AsyncIterable; + readonly logs: (query?: LogQuery) => Promise; + readonly followLogs: (query?: LogQuery) => AsyncIterable; } export interface PromiseStackApi { @@ -123,55 +122,8 @@ const adaptStream = (stream: Stream.Stream): AsyncIterable => Stream.toAsyncIterable(stream); /** Adapts an already-created Effect handle; exported for facade integration tests. */ -export const adaptEffectStack = ( - effectStack: EffectStack, - handleScope?: Scope.Scope, -): PromiseStack => { - let closed = false; - let closePromise: Promise | undefined; - const active: Set<() => Promise> = new Set(); - const closedError = () => new StackLifecycleConflictError({ message: "Stack handle is closed" }); - const iterable = (stream: Stream.Stream): AsyncIterable => ({ - [Symbol.asyncIterator]() { - if (closed) { - return { - next: async () => Promise.reject(closedError()), - return: async () => ({ done: true, value: undefined }), - throw: async () => Promise.reject(closedError()), - }; - } - const iterator = adaptStream(stream)[Symbol.asyncIterator](); - const cancel = () => Promise.resolve(iterator.return?.()); - active.add(cancel); - return { - next: async (...args: [] | [undefined]) => { - try { - const result = await iterator.next(...args); - if (result.done === true) active.delete(cancel); - return result; - } catch (error) { - active.delete(cancel); - throw error; - } - }, - return: async (value?: unknown) => { - active.delete(cancel); - return iterator.return?.(value) ?? { done: true, value: undefined }; - }, - throw: - iterator.throw === undefined - ? undefined - : async (error?: unknown) => { - active.delete(cancel); - return iterator.throw!(error); - }, - }; - }, - }); - const invoke = (effect: Effect.Effect): Promise => { - if (closed) return Promise.reject(closedError()); - return Effect.runPromise(effect); - }; +export const adaptEffectStack = (effectStack: EffectStack): PromiseStack => { + const invoke = (effect: Effect.Effect): Promise => Effect.runPromise(effect); const withConfig = ( options: { readonly config?: PromiseStackConfig } | undefined, operation: (config?: StackConfig) => Effect.Effect, @@ -221,41 +173,8 @@ export const adaptEffectStack = ( ), stop: () => invoke(effectStack.stop()), destroy: () => invoke(effectStack.destroy()), - close: () => { - if (closePromise !== undefined) return closePromise; - closed = true; - closePromise = (async () => { - const failures: Array = []; - await Promise.all( - [...active].map(async (cancel) => { - try { - await cancel(); - } catch (error) { - failures.push(error); - } finally { - active.delete(cancel); - } - }), - ); - try { - await Effect.runPromise(effectStack.close()); - } catch (error) { - failures.push(error); - } finally { - if (handleScope !== undefined) { - try { - await Effect.runPromise(Scope.close(handleScope, Exit.void)); - } catch (error) { - failures.push(error); - } - } - } - if (failures.length > 0) throw new AggregateError(failures, "Failed to close stack handle"); - })(); - return closePromise; - }, - watchStatus: () => iterable(effectStack.watchStatus()), - logs: (options) => iterable(effectStack.logs(options)), + logs: (query) => invoke(effectStack.logs(query)), + followLogs: (query) => adaptStream(effectStack.followLogs(query)), }; }; @@ -286,15 +205,7 @@ export const makePromiseApi = ( const createOrOpen = async ( effect: Effect.Effect, - ): Promise => { - const scope = await makeScope(); - try { - return adaptEffectStack(await run(effect, scope), scope); - } catch (error) { - await Effect.runPromise(Scope.close(scope, Exit.fail(error))); - throw error; - } - }; + ): Promise => adaptEffectStack(await run(effect)); return { createStack: (options) => createOrOpen(createEffectStack(options)), openStack: (id, options) => createOrOpen(openEffectStack(id, options)), diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index 315ed7751d..a61220a98d 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource builds an isolated root. import { join } from "node:path"; import { NodeServices } from "@effect/platform-node"; -import { Data } from "effect"; +import { Data, Effect, Schedule } from "effect"; import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; import { makePromiseApi, @@ -19,6 +19,10 @@ import type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; class TestStackReadinessError extends Data.TaggedError("TestStackReadinessError")<{ readonly message: string; }> {} +class RetryReadiness extends Data.TaggedError("RetryReadiness")<{ + readonly message: string; + readonly terminal?: boolean; +}> {} export interface CreateTestStackOptions { readonly config?: PromiseStackConfig; @@ -124,25 +128,39 @@ const waitForReadiness = async ( if (ready(initial)) return; const initialFailure = terminalFailure(initial); if (initialFailure !== undefined) throw initialFailure; - const iterator = stack.watchStatus()[Symbol.asyncIterator](); - const abort = () => { - void iterator.return?.(); - }; - signal?.addEventListener("abort", abort, { once: true }); + const poll = Effect.tryPromise({ + try: () => { + signal?.throwIfAborted(); + return stack.status(); + }, + catch: (cause) => + new TestStackReadinessError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }).pipe( + Effect.flatMap( + (next): Effect.Effect>, RetryReadiness> => { + if (ready(next)) return Effect.succeed(next); + const failure = terminalFailure(next); + return Effect.fail( + new RetryReadiness({ + message: failure?.message ?? "Stack is not ready yet", + ...(failure === undefined ? {} : { terminal: true }), + }), + ); + }, + ), + Effect.retry({ + schedule: Schedule.spaced("100 millis").pipe(Schedule.upTo({ duration: "120 seconds" })), + while: (error) => error instanceof RetryReadiness && error.terminal !== true, + }), + ); try { - while (true) { - const next = await iterator.next(); - if (next.done) - throw new TestStackReadinessError({ - message: "Stack did not reach running readiness before its status stream ended", - }); - if (ready(next.value)) return; - const failure = terminalFailure(next.value); - if (failure !== undefined) throw failure; - } - } finally { - signal?.removeEventListener("abort", abort); - await iterator.return?.(); + await Effect.runPromise(poll); + } catch (error) { + if (error instanceof RetryReadiness) + throw new TestStackReadinessError({ message: error.message }); + throw error; } }; @@ -161,11 +179,6 @@ const cleanup = async ( rootCanBeRemoved = false; if (failure === undefined) failure = error; } - try { - await stack.close(); - } catch (error) { - if (failure === undefined) failure = error; - } } // Retain the exact root when destroy fails because durable/Docker state may remain recoverable. if (rootCanBeRemoved) { diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 8bb2246163..6833487ffc 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -10,6 +10,7 @@ import { Option, Path, Redacted, + Ref, Schedule, Schema, Scope, @@ -81,6 +82,9 @@ const credentials = { }, }; +const emptyLogs = () => + Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false } as const); + const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => Effect.scoped( Effect.gen(function* () { @@ -104,6 +108,66 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect { + it.live("returns filtered log batches and follows from their cursor", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot: project }); + const first = yield* stack.logs({ capabilities: ["auth"], tail: 20 }); + expect(first.entries.every((entry) => entry.source === "auth")).toBe(true); + const followed = yield* stack + .followLogs({ capabilities: ["auth"], cursor: first.cursor }) + .pipe(Stream.runCollect); + expect(Array.from(followed).every((entry) => entry.source === "auth")).toBe(true); + }), + ), + ); + + it.live("delivers final followed entries once and completes after stop", () => + withRuntimeRoot((_project) => + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- test-only handle seam + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const entries = [ + { + cursor: { opaque: "v1_1" }, + timestamp: "2026-01-01T00:00:00.000Z", + source: "auth" as const, + stream: "stdout" as const, + message: "started", + }, + { + cursor: { opaque: "v1_2" }, + timestamp: "2026-01-01T00:00:01.000Z", + source: "auth" as const, + stream: "stdout" as const, + message: "stopped", + }, + ]; + const stack = yield* makeHandle( + stackId, + {}, + { + resolveOwner: () => Effect.succeed(Option.none()), + readLogs: () => + Ref.getAndUpdate(calls, (current) => current + 1).pipe( + Effect.map((index) => ({ + entries: index === 0 ? [entries[0]] : [entries[1]], + cursor: entries[index === 0 ? 0 : 1].cursor, + running: index === 0, + })), + ), + }, + ); + const followed = yield* stack + .followLogs({ capabilities: ["auth"] }) + .pipe(Stream.runCollect, Effect.exit); + expect(Exit.isSuccess(followed)).toBe(true); + if (Exit.isSuccess(followed)) expect(Array.from(followed.value)).toEqual(entries); + expect(yield* Ref.get(calls)).toBe(2); + }), + ), + ); + it.live("creates a stopped stack without launching a Supervisor", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -113,13 +177,11 @@ describe("Effect stack lifecycle handoff", () => { const offline = yield* stack.status(); expect(offline.lifecycle).toBe("unconfigured"); expect(offline.capabilities.every(({ state }) => state === "disabled")).toBe(true); - const opened = yield* openStack(stack.id, { replaceIncompatibleOwner: true }); + yield* openStack(stack.id, { replaceIncompatibleOwner: true }); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - yield* opened.close(); yield* stack.stop(); - expect(yield* Stream.runCollect(stack.watchStatus())).toHaveLength(1); - expect(yield* Stream.runCollect(stack.logs())).toHaveLength(0); - yield* stack.close(); + expect((yield* stack.status()).lifecycle).toBe("unconfigured"); + expect((yield* stack.logs()).entries).toHaveLength(0); }), ), ); @@ -144,7 +206,6 @@ describe("Effect stack lifecycle handoff", () => { }); }).pipe(Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 })))); expect((yield* stack.status()).lifecycle).toBe("unconfigured"); - yield* stack.close(); }), ), ); @@ -166,11 +227,8 @@ describe("Effect stack lifecycle handoff", () => { ); const status = yield* stack.status().pipe(Effect.exit); expect(Exit.isFailure(status)).toBe(true); - const logs = yield* Stream.runCollect(stack.logs()).pipe(Effect.exit); + const logs = yield* stack.logs().pipe(Effect.exit); expect(Exit.isFailure(logs)).toBe(true); - const watch = yield* Stream.runHead(stack.watchStatus()).pipe(Effect.exit); - expect(Exit.isFailure(watch)).toBe(true); - yield* stack.close(); } }).pipe(Effect.provide(NodeServices.layer)), ); @@ -192,13 +250,7 @@ describe("Effect stack lifecycle handoff", () => { const assertGuarded = (candidate: EffectStack) => Effect.gen(function* () { expect(Exit.isFailure(yield* candidate.status().pipe(Effect.exit))).toBe(true); - expect( - Exit.isFailure(yield* Stream.runCollect(candidate.logs()).pipe(Effect.exit)), - ).toBe(true); - expect( - Exit.isFailure(yield* Stream.runHead(candidate.watchStatus()).pipe(Effect.exit)), - ).toBe(true); - yield* candidate.close(); + expect(Exit.isFailure(yield* candidate.logs().pipe(Effect.exit))).toBe(true); }); const lockOnly = yield* openStack(stack.id); expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(true); @@ -218,7 +270,6 @@ describe("Effect stack lifecycle handoff", () => { const metadataOnly = yield* openStack(stack.id); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeDefined(); yield* assertGuarded(metadataOnly); - yield* stack.close(); }), ), 60_000, @@ -249,8 +300,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Effect.succeed(runningStatus), restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, - logs: () => Stream.empty, - watchStatus: () => Stream.empty, + logs: emptyLogs, }; yield* startControlServer({ endpoint, @@ -289,7 +339,6 @@ describe("Effect stack lifecycle handoff", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackPreparationError); } - yield* stack.close(); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -322,8 +371,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Effect.succeed(runningStatus), restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, - logs: () => Stream.empty, - watchStatus: () => Stream.empty, + logs: emptyLogs, }, maintenanceHandlers: { probe: Effect.succeed({ @@ -366,7 +414,6 @@ describe("Effect stack lifecycle handoff", () => { expect(error).toBeInstanceOf(StackOwnershipConflictError); expect(error?.message).toBe("Supervisor is still shutting down"); } - yield* stack.close(); } }).pipe(Effect.provide(NodeServices.layer)), ), @@ -391,7 +438,6 @@ describe("Effect stack lifecycle handoff", () => { } expect((yield* store.read(stack.id))?.desiredLifecycle).toBe("running"); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); - yield* stack.close(); }), ), ); @@ -433,8 +479,7 @@ describe("Effect stack lifecycle handoff", () => { return Effect.succeed(runningStatus); }, destroy: () => Effect.void, - logs: () => Stream.empty, - watchStatus: () => Stream.empty, + logs: emptyLogs, }; yield* startControlServer({ endpoint, @@ -466,7 +511,6 @@ describe("Effect stack lifecycle handoff", () => { expect(payloads.prepare).toEqual([{}, { config: {} }]); expect(payloads.start).toEqual([{}, { config: {} }]); expect(payloads.restart).toEqual([{}, { config: {} }]); - yield* stack.close(); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -497,8 +541,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Effect.succeed(runningStatus), restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, - logs: () => Stream.empty, - watchStatus: () => Stream.empty, + logs: emptyLogs, }, maintenanceHandlers: { probe: Effect.succeed({ @@ -525,7 +568,6 @@ describe("Effect stack lifecycle handoff", () => { ); expect((yield* stack.restart()).lifecycle).toBe("running"); expect(launched).toBe(true); - yield* stack.close(); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -547,8 +589,7 @@ describe("Effect stack lifecycle handoff", () => { start: () => Effect.succeed(runningStatus), restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, - logs: () => Stream.empty, - watchStatus: () => Stream.concat(Stream.make(runningStatus), Stream.never), + logs: emptyLogs, }; const maintenanceHandlers = { probe: Effect.succeed({ @@ -578,7 +619,6 @@ describe("Effect stack lifecycle handoff", () => { yield* Scope.close(ownerScope, Exit.void); const destroyed = yield* Fiber.join(destroyFiber).pipe(Effect.exit); expect(Exit.isSuccess(destroyed)).toBe(true); - yield* stack.close(); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -617,7 +657,6 @@ describe("Effect stack lifecycle handoff", () => { yield* Fiber.interrupt(first); yield* Deferred.succeed(release, undefined); yield* Deferred.await(completed); - yield* stack.close(); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -643,7 +682,6 @@ describe("Effect stack lifecycle handoff", () => { expect(error.value.message).toContain("control connection"); } } - yield* stack.close(); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -684,10 +722,8 @@ describe("Effect stack lifecycle handoff", () => { const oldOwner = yield* openStack(stack.id); expect(Exit.isFailure(yield* oldOwner.status().pipe(Effect.exit))).toBe(true); - yield* oldOwner.close(); const ordinaryCreate = yield* createStack({ projectRoot: project }); expect(ordinaryCreate.id).toBe(stack.id); - yield* ordinaryCreate.close(); const restarted = yield* openStack(stack.id, { replaceIncompatibleOwner: true }); yield* Effect.addFinalizer(() => restarted.destroy().pipe(Effect.ignore)); const status = yield* restarted.restart({ config: { capabilities: {} } }); @@ -697,9 +733,6 @@ describe("Effect stack lifecycle handoff", () => { expect(status.id).toBe(stack.id); expect(status.runtime).toEqual({ kind: "native" }); expect((yield* restarted.status()).lifecycle).toBe("running"); - const observed = yield* restarted.watchStatus().pipe(Stream.runHead); - expect(Option.isSome(observed)).toBe(true); - if (Option.isSome(observed)) expect(observed.value.lifecycle).toBe("running"); const secondStatus = yield* restarted.restart({ config: { capabilities: {} } }); expect(secondStatus.lifecycle).toBe("running"); yield* restarted.stop(); @@ -708,8 +741,6 @@ describe("Effect stack lifecycle handoff", () => { const startedAgain = yield* restarted.start({ config: { capabilities: {} } }); expect(startedAgain.lifecycle).toBe("running"); yield* restarted.destroy(); - yield* restarted.close(); - yield* stack.close(); }), ), 240_000, @@ -833,7 +864,6 @@ describe("Effect stack lifecycle handoff", () => { }); expect(yield* readOwnerMetadata(env.stateRoot, createId, env)).toBeUndefined(); expect(yield* ownerLockExists(env.stateRoot, createId)).toBe(false); - yield* created.close(); const openId = yield* makeDeadOwner(openProject, "stack-rpc-v0@0.0.1"); const replaced = yield* openStack(openId, { replaceIncompatibleOwner: true }); @@ -843,7 +873,6 @@ describe("Effect stack lifecycle handoff", () => { STACK_RPC_RELEASE, ); yield* replaced.destroy(); - yield* replaced.close(); }), ), 300_000, @@ -880,8 +909,6 @@ describe("Effect stack lifecycle handoff", () => { yield* fs.writeFileString(paths.controlMetadata, incompatibleOwner); const oldOwnerHandle = yield* openStack(stack.id); yield* oldOwnerHandle.stop(); - yield* oldOwnerHandle.close(); - yield* stack.close(); }), ), ); diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index 95ede2d8f4..3eac4c170b 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -6,12 +6,7 @@ import { Effect, Redacted, Stream } from "effect"; import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./EffectStack.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; -import type { StackLogEntry } from "./Logs.ts"; -import { - InvalidStackConfigError, - StackLifecycleConflictError, - StackStateInvalidError, -} from "./Errors.ts"; +import { InvalidStackConfigError } from "./Errors.ts"; import { adaptEffectStack, type PromiseStack } from "./PromiseStack.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -67,13 +62,23 @@ const effectStack = (): EffectStack => restart: () => Effect.succeed(status), stop: () => Effect.void, destroy: () => Effect.void, - close: () => Effect.void, - watchStatus: () => Stream.make(status), - logs: () => Stream.make(), + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), + followLogs: () => Stream.empty, }) satisfies EffectStack; describe("Promise stack facade", () => { - it("unwraps every credential secret and exposes explicit close without async disposal", async () => { + it("returns log batches and follows from their cursor", async () => { + const stack = adaptEffectStack(effectStack()); + const first = await stack.logs({ capabilities: ["auth"], tail: 20 }); + expect(first.entries.every((entry) => entry.source === "auth")).toBe(true); + const followed = []; + for await (const entry of stack.followLogs({ capabilities: ["auth"], cursor: first.cursor })) { + followed.push(entry); + } + expect(followed.every((entry) => entry.source === "auth")).toBe(true); + }); + + it("unwraps every credential secret without a lifecycle close operation", async () => { const stack: PromiseStack = adaptEffectStack(effectStack()); await expect(stack.credentials()).resolves.toEqual({ @@ -92,32 +97,21 @@ describe("Promise stack facade", () => { }, }); expect(Symbol.asyncDispose in stack).toBe(false); - await expect(stack.close()).resolves.toBeUndefined(); - await expect(stack.close()).resolves.toBeUndefined(); }); it("cancels an async stream when the consumer returns early", async () => { const stack = adaptEffectStack(effectStack()); - const iterator = stack.watchStatus()[Symbol.asyncIterator](); - await expect(iterator.next()).resolves.toMatchObject({ done: false }); + const iterator = stack.followLogs()[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ done: true }); await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); - await stack.close(); }); - it("removes naturally completed streams and rejects new streams after close", async () => { + it("completes an empty follower immediately", async () => { const stack = adaptEffectStack(effectStack()); - const statusIterator = stack.watchStatus()[Symbol.asyncIterator](); - await expect(statusIterator.next()).resolves.toMatchObject({ done: false }); - await expect(statusIterator.next()).resolves.toMatchObject({ done: true }); - const logsIterator = stack.logs()[Symbol.asyncIterator](); + const logs = await stack.logs(); + expect(logs.entries).toHaveLength(0); + const logsIterator = stack.followLogs()[Symbol.asyncIterator](); await expect(logsIterator.next()).resolves.toMatchObject({ done: true }); - await stack.close(); - await expect(stack.watchStatus()[Symbol.asyncIterator]().next()).rejects.toBeInstanceOf( - StackLifecycleConflictError, - ); - await expect(stack.logs()[Symbol.asyncIterator]().next()).rejects.toBeInstanceOf( - StackLifecycleConflictError, - ); }); it("rejects malformed configs asynchronously with a tagged error", async () => { @@ -127,7 +121,6 @@ describe("Promise stack facade", () => { config: JSON.parse('{"capabilities":{"rest":{"settings":{"unknown":true}}}}'), }), ).rejects.toBeInstanceOf(InvalidStackConfigError); - await stack.close(); }); it("redacts nested config secrets for prepare, start, and restart", async () => { @@ -197,45 +190,5 @@ describe("Promise stack facade", () => { expect(compiled.definition.capabilities.functions.settings.functions_root).toBe( "/tmp/promise-facade-project/supabase/functions", ); - await stack.close(); - }); - - it("always closes the underlying handle when active stream cancellation rejects", async () => { - let underlyingClosed = false; - const cancellationError = new Error("stream cancellation failed"); - const entry: StackLogEntry = { - cursor: { opaque: "1" }, - timestamp: "now", - source: "functions", - stream: "stdout", - message: "running", - }; - const foreignStream: AsyncIterable = { - [Symbol.asyncIterator]: () => ({ - next: async () => ({ done: false, value: entry }), - return: async () => { - throw cancellationError; - }, - }), - }; - const source: EffectStack = { - ...effectStack(), - logs: () => - Stream.fromAsyncIterable( - foreignStream, - (error) => new StackStateInvalidError({ message: String(error) }), - ), - close: () => - Effect.sync(() => { - underlyingClosed = true; - }), - }; - const stack = adaptEffectStack(source); - const iterator = stack.logs()[Symbol.asyncIterator](); - await iterator.next(); - await expect(stack.close()).rejects.toThrow("Failed to close stack handle"); - expect(underlyingClosed).toBe(true); - await expect(stack.status()).rejects.toThrow("Stack handle is closed"); - await expect(stack.close()).rejects.toThrow("Failed to close stack handle"); }); }); diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index fc73dda0a8..d028edbc14 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -62,13 +62,13 @@ const fakeStack = ( events: Array, failStart = false, reachesReadiness = true, - failClose = false, includeApi = true, functionsState: "ready" | "dormant" | "stopped" = "dormant", failedCapability?: string, ): PromiseStack => ({ id: stackId, - status: async () => status("running", includeApi, functionsState), + status: async () => + status(reachesReadiness ? "running" : "stopped", includeApi, functionsState, failedCapability), credentials: async () => ({ database: { url: "postgres://test", password: "test" }, api: { @@ -96,20 +96,8 @@ const fakeStack = ( events.push("destroy"); if (failStart) throw new Error("destroy failed"); }, - close: async () => { - events.push("close"); - if (failClose) throw new Error("close failed"); - }, - watchStatus: () => - stream([ - status( - reachesReadiness ? "running" : "starting", - includeApi, - functionsState, - failedCapability, - ), - ]), - logs: () => stream([]), + logs: async () => ({ entries: [], cursor: { opaque: "v1_0" }, running: false }), + followLogs: () => stream([]), }); describe("test stack resource", () => { @@ -128,7 +116,7 @@ describe("test stack resource", () => { }; const stack = await createTestStackWith({}, operations); await stack[Symbol.asyncDispose](); - expect(events).toEqual(["create:/tmp/stack-test-owned", "start", "destroy", "close"]); + expect(events).toEqual(["create:/tmp/stack-test-owned", "start", "destroy"]); expect(removed).toEqual(["/tmp/stack-test-owned"]); }); @@ -145,7 +133,7 @@ describe("test stack resource", () => { await expect(createTestStackWith({}, operations)).rejects.toThrow( "startup failed; retained test stack root /tmp/stack-test-failed", ); - expect(events).toEqual(["start", "destroy", "close"]); + expect(events).toEqual(["start", "destroy"]); expect(removed).toEqual([]); }); @@ -161,24 +149,24 @@ describe("test stack resource", () => { }; await expect( createTestStackWith({ config: { capabilities: { database: {} } } }, operations), - ).rejects.toThrow("did not reach running readiness"); - expect(events).toEqual(["start", "destroy", "close"]); + ).rejects.toThrow("Stack lifecycle stopped before stack became ready"); + expect(events).toEqual(["start", "destroy"]); expect(removed).toEqual(["/tmp/stack-test-unready"]); }); - it("removes the exact root when disposal close fails and preserves that primary error", async () => { + it("removes the exact root after disposal", async () => { const events: Array = []; const removed: Array = []; const operations: TestStackOperations = { createRoot: async () => "/tmp/stack-test-close-failed", - createStack: async () => fakeStack(events, false, true, true), + createStack: async () => fakeStack(events), removeRoot: async (root) => { removed.push(root); }, }; const stack = await createTestStackWith({}, operations); - await expect(stack[Symbol.asyncDispose]()).rejects.toThrow("close failed"); - expect(events).toEqual(["start", "destroy", "close"]); + await expect(stack[Symbol.asyncDispose]()).resolves.toBeUndefined(); + expect(events).toEqual(["start", "destroy"]); expect(removed).toEqual(["/tmp/stack-test-close-failed"]); }); @@ -186,7 +174,7 @@ describe("test stack resource", () => { const events: Array = []; const operations: TestStackOperations = { createRoot: async () => "/tmp/stack-test-disabled-surfaces", - createStack: async () => fakeStack(events, false, true, false, false, "stopped"), + createStack: async () => fakeStack(events, false, true, false, "stopped"), removeRoot: async () => undefined, }; const stack = await createTestStackWith( @@ -194,7 +182,7 @@ describe("test stack resource", () => { operations, ); await stack[Symbol.asyncDispose](); - expect(events).toEqual(["start", "destroy", "close"]); + expect(events).toEqual(["start", "destroy"]); }); it("runs setupProject after creating the root and before creating the stack", async () => { @@ -227,7 +215,6 @@ describe("test stack resource", () => { "create:/tmp/stack-test-setup", "start", "destroy", - "close", "remove", ]); }); @@ -290,13 +277,13 @@ describe("test stack resource", () => { const removed: Array = []; const operations: TestStackOperations = { createRoot: async () => "/tmp/stack-test-capability-failed", - createStack: async () => fakeStack(events, false, true, false, true, "dormant", "auth"), + createStack: async () => fakeStack(events, false, true, true, "dormant", "auth"), removeRoot: async (root) => { removed.push(root); }, }; await expect(createTestStackWith({}, operations)).rejects.toThrow("auth failed"); - expect(events).toEqual(["start", "destroy", "close"]); + expect(events).toEqual(["start", "destroy"]); expect(removed).toEqual(["/tmp/stack-test-capability-failed"]); }); @@ -306,12 +293,12 @@ describe("test stack resource", () => { createRoot: async () => "/tmp/stack-test-lifecycle-stopped", createStack: async () => ({ ...fakeStack(events), - watchStatus: () => stream([status("stopped")]), + status: async () => status("stopped"), }), removeRoot: async () => undefined, }; await expect(createTestStackWith({}, operations)).rejects.toThrow("stopped"); - expect(events).toEqual(["start", "destroy", "close"]); + expect(events).toEqual(["start", "destroy"]); }); it("rejects readiness when the lifecycle starts stopping before becoming ready", async () => { @@ -320,11 +307,11 @@ describe("test stack resource", () => { createRoot: async () => "/tmp/stack-test-lifecycle-stopping", createStack: async () => ({ ...fakeStack(events), - watchStatus: () => stream([status("stopping")]), + status: async () => status("stopping"), }), removeRoot: async () => undefined, }; await expect(createTestStackWith({}, operations)).rejects.toThrow("stopping"); - expect(events).toEqual(["start", "destroy", "close"]); + expect(events).toEqual(["start", "destroy"]); }); }); diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 3c3ba7ddfd..32291b0e37 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -153,28 +153,17 @@ const activate = async ( expect(capabilityState(before, name), `${name} should be dormant before activation`).toBe( "dormant", ); - const iterator = stack.watchStatus()[Symbol.asyncIterator](); - const first = iterator.next(); - void first.catch(() => undefined); - const waitUntilReady = async (): Promise => { - let next = await first; - while (!next.done) { - if (capabilityState(next.value, name) === "ready") { - return; - } - next = await iterator.next(); - } - const current = await stack.status(); - if (capabilityState(current, name) === "ready") return; - throw new Error(`Capability ${name} did not become ready after traffic`); - }; - try { - const result = await action(); - await waitUntilReady(); - return result; - } finally { - await iterator.return?.(); - } + const waitUntilReady = Effect.tryPromise(() => stack.status()).pipe( + Effect.flatMap((current) => + capabilityState(current, name) === "ready" + ? Effect.succeed(current) + : Effect.fail(new E2ERequestError({ message: `Capability ${name} is not ready yet` })), + ), + Effect.retry(Schedule.spaced("100 millis").pipe(Schedule.upTo({ duration: "3 minutes" }))), + ); + const result = await action(); + await Effect.runPromise(waitUntilReady); + return result; }; const request = async (base: string, path: string, init: RequestInit = {}): Promise => { @@ -243,7 +232,7 @@ const throwStudioProfileDiagnostics = async (stack: TestStack, cause: unknown): const studio = status?.capabilities.find(({ name }) => name === "studio"); const recentLogs: StackLogEntry[] = []; try { - for await (const entry of stack.logs({ follow: false })) { + for (const entry of (await stack.logs()).entries) { if (entry.source !== "studio" && entry.source !== "gateway") continue; recentLogs.push(entry); if (recentLogs.length > 50) recentLogs.shift(); @@ -795,11 +784,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const stopped = await stack.status(); expect(stopped.lifecycle).toBe("stopped"); expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); - const offlineStatuses: StackStatus[] = []; - for await (const status of stack.watchStatus()) offlineStatuses.push(status); - expect(offlineStatuses).toEqual([stopped]); - const retainedLogs: StackLogEntry[] = []; - for await (const entry of stack.logs({ follow: false })) retainedLogs.push(entry); + const retainedLogs: StackLogEntry[] = (await stack.logs()).entries.slice(); expect(retainedLogs.length).toBeGreaterThan(0); await expectRuntimeInputsAbsent(projectRoot, initial.id); await expectEndpointsRefused(initialEndpoints); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 5998e60f29..9f9499576b 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -14,7 +14,6 @@ import { Semaphore, Scope, Stream, - SubscriptionRef, } from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; import { canonicalize, compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; @@ -40,7 +39,7 @@ import { } from "../public/Errors.ts"; import type { StackStatus } from "../public/Status.ts"; import type { StackId } from "../public/StackId.ts"; -import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; +import type { LogQuery, StackLogBatch } from "../public/Logs.ts"; import type { EffectStackCredentials } from "../public/Credentials.ts"; import type { RuntimeDriver, ObservedWorkload } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; @@ -104,8 +103,7 @@ export interface Supervisor { readonly shutdown: Effect.Effect; /** Shuts down only when durable state is absent or cleanly non-running. */ readonly shutdownIfIdle: Effect.Effect; - readonly watchStatus: Stream.Stream; - readonly logs: (options?: LogOptions) => Stream.Stream; + readonly logs: (query?: LogQuery) => Effect.Effect; readonly activate: ( capability: CapabilityName, ) => Effect.Effect; @@ -209,13 +207,6 @@ export const makeSupervisor = ( Effect.flatMap((current) => (current === "running" ? observe() : Effect.succeed([]))), ); - const initialStatus = yield* statusFor( - initial, - [], - yield* Ref.get(active), - yield* Ref.get(phase), - ); - const hub = yield* SubscriptionRef.make(initialStatus); const snapshot = (): Effect.Effect => Effect.gen(function* () { const state = yield* read(); @@ -229,8 +220,7 @@ export const makeSupervisor = ( ); return status; }); - const publish = (): Effect.Effect => - snapshot().pipe(Effect.tap((value) => SubscriptionRef.set(hub, value))); + const publish = (): Effect.Effect => snapshot(); const restorePhase = (previous: ActualPhase): Effect.Effect => Effect.gen(function* () { const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); @@ -773,8 +763,6 @@ export const makeSupervisor = ( const stopWithShutdown = submitLifecycle("stop", "stop", stopOperation(), continueShutdown); const operation = (effect: Effect.Effect) => effect.pipe(Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))); - const streamOperation = (stream: Stream.Stream) => - stream.pipe(Stream.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))); const destroyOperation = Effect.gen(function* () { const previous = yield* Ref.get(phase); yield* Ref.set(phase, "destroying"); @@ -793,21 +781,40 @@ export const makeSupervisor = ( const destroy = submitLifecycle("destroy", "destroy", destroyOperation, continueShutdown).pipe( Effect.asVoid, ); - const logs = (logOptions?: LogOptions): Stream.Stream => { - const stream = runtime.logStore - .stream(logOptions) - .pipe( - Stream.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), - ), + const logs = (query?: LogQuery): Effect.Effect => + Effect.gen(function* () { + const cursor = query?.cursor?.opaque === "v1_0" ? undefined : query?.cursor; + const scanned = yield* runtime.logStore + .read(cursor === undefined ? undefined : { cursor }) + .pipe( + Effect.mapError( + (error) => new StackStateInvalidError({ message: error.message, cause: error }), + ), + ); + const capabilities = + query?.capabilities === undefined ? undefined : new Set(query.capabilities); + const filtered = scanned.filter((entry) => + capabilities === undefined + ? true + : entry.source !== "gateway" && + entry.source !== "supervisor" && + capabilities.has(entry.source), ); - return logOptions?.follow === true - ? stream.pipe(Stream.interruptWhen(Deferred.await(shutdownSignal))) - : stream; - }; - const watchStatus = SubscriptionRef.changes(hub).pipe( - Stream.interruptWhen(Deferred.await(shutdownSignal)), - ); + const entries = + query?.tail === undefined + ? filtered + : query.tail <= 0 + ? [] + : filtered.slice(-Math.floor(query.tail)); + const state = yield* read(); + const running = + state?.desiredLifecycle === "running" && (yield* Ref.get(phase)) === "running"; + return { + entries, + cursor: scanned.at(-1)?.cursor ?? query?.cursor ?? { opaque: "v1_0" }, + running, + } satisfies StackLogBatch; + }); const maintenanceHandlers = { probe: Effect.succeed({ ok: true, @@ -1059,8 +1066,7 @@ export const makeSupervisor = ( start: ({ config }: { readonly config?: StackConfig }) => operation(start({ config })), restart: ({ config }: { readonly config?: StackConfig }) => operation(restart({ config })), destroy: () => operation(destroy), - logs: (logOptions: LogOptions) => streamOperation(logs(logOptions)), - watchStatus: () => streamOperation(watchStatus), + logs: (query: LogQuery) => operation(logs(query)), }); yield* FiberSet.run( ownedFibers, @@ -1077,7 +1083,6 @@ export const makeSupervisor = ( destroy, shutdown: Deferred.await(shutdownSignal), shutdownIfIdle, - watchStatus, logs, activate, maintenanceHandlers, diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index eed6cd08a7..5af18a3cee 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -140,7 +140,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { return "docker" as const; }), }; - const stack = yield* createStack({ + yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "auto" }, }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); @@ -148,7 +148,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { expect( (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, ).toEqual({ kind: "container", engine: "docker" }); - yield* stack.close(); }), ), ); @@ -164,7 +163,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { return "podman" as const; }), }; - const stack = yield* createStack({ + yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "auto" }, }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); @@ -172,7 +171,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { expect( (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, ).toEqual({ kind: "container", engine: "podman" }); - yield* stack.close(); }), ), ); @@ -221,12 +219,11 @@ describe("managed stack handles", { timeout: 30_000 }, () => { return "podman" as const; }), }; - const stack = yield* createStack({ + yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "podman" }, }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); expect(calls).toEqual(["podman"]); - yield* stack.close(); }), ), ); @@ -242,11 +239,10 @@ describe("managed stack handles", { timeout: 30_000 }, () => { return "podman" as const; }), }; - const first = yield* createStack({ + yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "podman" }, }).pipe(Effect.provideService(ContainerEngineResolver, firstResolver)); - yield* first.close(); const secondCalls: string[] = []; const secondResolver = { resolve: (preference: "auto" | "docker" | "podman") => @@ -255,7 +251,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { return "docker" as const; }), }; - const second = yield* createStack({ + yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "auto" }, }).pipe(Effect.provideService(ContainerEngineResolver, secondResolver)); @@ -264,7 +260,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { expect( (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, ).toEqual({ kind: "container", engine: "podman" }); - yield* second.close(); }), ), ); @@ -272,7 +267,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { it.live("rejects a conflicting explicit engine before probing", () => withRuntimeRoot((project) => Effect.gen(function* () { - const first = yield* createStack({ + yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "docker" }, }).pipe( @@ -280,7 +275,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { resolve: () => Effect.succeed("docker" as const), }), ); - yield* first.close(); const calls: string[] = []; const result = yield* createStack({ projectRoot: project, @@ -309,7 +303,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { withRuntimeRoot((project) => Effect.gen(function* () { const calls: string[] = []; - const stack = yield* createStack({ + yield* createStack({ projectRoot: project, runtime: { kind: "native" }, }).pipe( @@ -322,7 +316,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { }), ); expect(calls).toEqual([]); - yield* stack.close(); }), ), ); @@ -334,7 +327,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { const status = yield* stack.status(); expect(status.lifecycle).toBe("unconfigured"); expect(status.desiredLifecycle).toBe("unconfigured"); - yield* stack.close(); }), ), ); @@ -398,8 +390,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { Effect.fail({ tag: "StackPreparationError", message: "artifact is incomplete" }), restart: () => Effect.succeed(status), destroy: () => Effect.void, - logs: () => Stream.empty, - watchStatus: () => Stream.empty, + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }; yield* startControlServer({ endpoint: lease.metadata.endpoint, @@ -423,7 +414,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { const error = Exit.isFailure(failed) ? Option.getOrUndefined(Cause.findErrorOption(failed.cause)) : undefined; - yield* stack.close(); yield* lease.release; expect(Exit.isFailure(failed)).toBe(true); expect(error).toBeInstanceOf(StackPreparationError); @@ -505,8 +495,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { Effect.andThen(Deferred.await(responseRelease)), Effect.asVoid, ), - logs: () => Stream.empty, - watchStatus: () => Stream.make(status), + logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }, onShutdownReady: Deferred.succeed(callbackStarted, undefined).pipe( Effect.andThen(Deferred.await(callbackRelease)), @@ -527,7 +516,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { yield* Deferred.await(callbackCompleted); yield* Scope.close(ownerScope, Exit.void); yield* Fiber.join(destroyFiber); - yield* stack.close(); yield* lease.release; }), ), @@ -542,8 +530,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ); expect(second.id).toBe(first.id); expect((yield* second.status()).lifecycle).toBe("unconfigured"); - yield* first.close(); - yield* second.close(); }), ), ); @@ -569,15 +555,13 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); - it.live("a closed handle leaves its owner available to a replacement handle", () => + it.live("an ordinary handle exposes no lifecycle close operation", () => withRuntimeRoot((project) => Effect.gen(function* () { const first = yield* createStack({ projectRoot: project }); const id = first.id; - yield* first.close(); const second = yield* openStack(id); expect((yield* second.status()).lifecycle).toBe("unconfigured"); - yield* second.close(); }), ), ); @@ -590,11 +574,9 @@ describe("managed stack handles", { timeout: 30_000 }, () => { const other = path.join(path.dirname(project), "other-project"); yield* fs.makeDirectory(other); const first = yield* createStack({ projectRoot: project }); - const second = yield* createStack({ projectRoot: other }); + yield* createStack({ projectRoot: other }); const filtered = yield* listStacks({ projectRoot: project }); expect(filtered.map((entry) => entry.id)).toEqual([first.id]); - yield* first.close(); - yield* second.close(); }), ), ); @@ -649,7 +631,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { expect(first.id).toBe(second.id); const attached = yield* openStack(StackIdSchema.make(first.id)); expect((yield* attached.status()).lifecycle).toBe("unconfigured"); - yield* attached.close(); }), ), ); @@ -663,7 +644,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { yield* stack.stop(); const status = yield* stack.status(); expect(status.lifecycle).toBe("unconfigured"); - yield* stack.close(); }), ), ); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 4d87952c6e..ab25795d3b 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -12,13 +12,14 @@ import { Queue, Redacted, Ref, + Schedule, Scope, Stream, } from "effect"; import { Headers } from "effect/unstable/http"; import { Rpc } from "effect/unstable/rpc"; import { RequestId } from "effect/unstable/rpc/RpcMessage"; -import type { LogOptions, StackLogEntry } from "../public/Logs.ts"; +import type { LogQuery, StackLogEntry } from "../public/Logs.ts"; import type { CapabilityName } from "../public/Capability.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { @@ -183,7 +184,7 @@ const makeFixture = ( }); const resources = yield* Ref.make>([]); const calls = yield* Ref.make>([]); - const logOptions = yield* Ref.make>([]); + const logOptions = yield* Ref.make>([]); const failDestroy = yield* Ref.make(false); let gateStopCleanup = false; const driver: RuntimeDriver = { @@ -293,7 +294,7 @@ const makeFixture = ( const entry: StackLogEntry = { cursor: { opaque: "v1_1" }, timestamp: "2026-01-01T00:00:00.000Z", - source: "supervisor", + source: "auth", stream: "internal", message: "hello", }; @@ -363,17 +364,9 @@ const makeFixture = ( logStore: { path: "memory://logs", append: () => Effect.succeed(entry), - read: () => Effect.succeed([entry]), - stream: (options) => - Stream.unwrap( - Ref.update(logOptions, (current) => [...current, options]).pipe( - Effect.map(() => - options?.follow === true - ? Stream.concat(Stream.succeed(entry), Stream.never) - : Stream.succeed(entry), - ), - ), - ), + read: (options) => + Ref.update(logOptions, (current) => [...current, options]).pipe(Effect.as([entry])), + stream: () => Stream.empty, }, }; const context = yield* Effect.context< @@ -1046,29 +1039,13 @@ describe("Supervisor composition", () => { const fixture = yield* makeFixture({ stopGate, stopStarted }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const subscribed = yield* Deferred.make(); - const stopping = yield* Deferred.make(); - const watcher = yield* Effect.forkChild( - Stream.runForEach(fixture.supervisor.watchStatus, (status) => - Effect.gen(function* () { - if (!(yield* Deferred.isDone(subscribed))) - yield* Deferred.succeed(subscribed, undefined); - if (status.lifecycle === "stopping") yield* Deferred.succeed(stopping, undefined); - }), - ), - ); - yield* fixture.supervisor.start(); - yield* Deferred.await(subscribed); - const stop = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); yield* Deferred.await(stopStarted); - yield* Deferred.await(stopping); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); yield* Deferred.succeed(stopGate, undefined); expect((yield* Fiber.join(stop)).ok).toBe(true); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - yield* Fiber.interrupt(watcher); }), ), ); @@ -1099,29 +1076,12 @@ describe("Supervisor composition", () => { const fixture = yield* makeFixture({ stopGate, stopStarted }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const subscribed = yield* Deferred.make(); - const observed = yield* Ref.make>([]); - const watcher = yield* Effect.forkChild( - Stream.runForEach(fixture.supervisor.watchStatus, (status) => - Effect.gen(function* () { - yield* Ref.update(observed, (current) => [...current, status.lifecycle]); - if (!(yield* Deferred.isDone(subscribed))) - yield* Deferred.succeed(subscribed, undefined); - }), - ), - ); - yield* fixture.supervisor.start(); - yield* Deferred.await(subscribed); - const restarting = yield* Effect.forkChild(fixture.supervisor.restart()); yield* Deferred.await(stopStarted); expect((yield* fixture.supervisor.status).lifecycle).toBe("starting"); yield* Deferred.succeed(stopGate, undefined); yield* Fiber.join(restarting); - const lifecycles = yield* Ref.get(observed); - expect(lifecycles).not.toContain("stopped"); - expect(lifecycles.at(-1)).toBe("running"); - yield* Fiber.interrupt(watcher); + expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); }), ), ); @@ -1134,83 +1094,63 @@ describe("Supervisor composition", () => { const fixture = yield* makeFixture({ destroyGate, destroyStarted }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const subscribed = yield* Deferred.make(); - const destroying = yield* Deferred.make(); - const watcher = yield* Effect.forkChild( - Stream.runForEach(fixture.supervisor.watchStatus, (status) => - Effect.gen(function* () { - if (!(yield* Deferred.isDone(subscribed))) - yield* Deferred.succeed(subscribed, undefined); - if (status.lifecycle === "destroying") yield* Deferred.succeed(destroying, undefined); - }), - ), - ); - yield* fixture.supervisor.start(); - yield* Deferred.await(subscribed); - const destroy = yield* Effect.forkChild(fixture.supervisor.destroy); yield* Deferred.await(destroyStarted); - yield* Deferred.await(destroying); expect((yield* fixture.supervisor.status).lifecycle).toBe("destroying"); yield* Deferred.succeed(destroyGate, undefined); yield* Fiber.join(destroy); expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); - yield* Fiber.interrupt(watcher); }), ), ); - it.live("passes log options through the Supervisor log stream", () => + it.live("passes log query through the Supervisor log batch", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const options: LogQuery = { capabilities: ["auth"] }; + expect((yield* fixture.supervisor.logs(options)).entries).toHaveLength(1); + expect(yield* Ref.get(fixture.logOptions)).toEqual([undefined]); + }), + ), + ); + + it.live("returns filtered log batches with a running marker", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); - const options: LogOptions = { follow: false, capabilities: ["auth"] }; - expect(yield* Stream.runCollect(fixture.supervisor.logs(options))).toHaveLength(1); - expect(yield* Ref.get(fixture.logOptions)).toEqual([options]); + const batch = yield* fixture.supervisor.logs({ capabilities: ["auth"], tail: 20 }); + expect(batch.entries.every((entry) => entry.source === "auth")).toBe(true); + expect(batch.running).toBe(false); }), ), ); - it.live("completes subscribed status streams after a clean stop", () => + it.live("reports stopped after a clean stop", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const stopped = yield* Deferred.make(); - const completed = yield* Deferred.make(); - const watcher = yield* Effect.forkChild( - Stream.runForEach(fixture.supervisor.watchStatus, (status) => - status.lifecycle === "stopped" ? Deferred.succeed(stopped, undefined) : Effect.void, - ).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))), - ); const response = yield* fixture.supervisor.maintenanceHandlers.stop; expect(response.ok).toBe(true); - yield* Deferred.await(stopped); yield* fixture.supervisor.shutdownIfIdle; - yield* Deferred.await(completed); - yield* Fiber.join(watcher); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); }), ), ); - it.live("completes followed logs after a clean stop", () => + it.live("returns final logs after a clean stop", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const completed = yield* Deferred.make(); - const watcher = yield* Effect.forkChild( - Stream.runCollect(fixture.supervisor.logs({ follow: true })).pipe( - Effect.tap((entries) => Effect.sync(() => expect(entries).toHaveLength(1))), - Effect.ensuring(Deferred.succeed(completed, undefined)), - ), - ); const response = yield* fixture.supervisor.maintenanceHandlers.stop; expect(response.ok).toBe(true); yield* fixture.supervisor.shutdownIfIdle; - yield* Deferred.await(completed); - yield* Fiber.join(watcher); + const batch = yield* fixture.supervisor.logs(); + expect(batch.entries).toHaveLength(1); + expect(batch.running).toBe(false); }), ), ); @@ -1574,7 +1514,6 @@ describe("Supervisor composition", () => { Effect.gen(function* () { const failures = yield* Queue.unbounded(); const starts = yield* Queue.unbounded(); - const failedStatus = yield* Deferred.make(); const fixture = yield* makeFixture({ failureQueue: failures, startQueue: starts }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); const initialWorkload = yield* Queue.take(starts); @@ -1582,16 +1521,6 @@ describe("Supervisor composition", () => { (entry) => entry.workloadId === initialWorkload, ); if (ready === undefined) return yield* Effect.die("workload did not start"); - const watcher = yield* Effect.forkChild( - Stream.runForEach(fixture.supervisor.watchStatus, (status) => - Effect.gen(function* () { - const capability = status.capabilities.find(({ name }) => name === "database"); - if (capability?.state === "failed" && capability.error !== undefined) - yield* Deferred.succeed(failedStatus, undefined); - }), - ), - ); - // The catalog default allows five post-ready attempts; the sixth crash is terminal. for (let attempt = 0; attempt < 5; attempt += 1) { yield* Ref.update(fixture.resources, (current) => @@ -1612,12 +1541,16 @@ describe("Supervisor composition", () => { ), ); yield* Queue.offer(failures, { ...ready, state: "failed", error: "crashed" }); - yield* Deferred.await(failedStatus); - const status = yield* fixture.supervisor.status; + const status = yield* Effect.gen(function* () { + const current = yield* fixture.supervisor.status; + const capability = current.capabilities.find(({ name }) => name === "database"); + if (capability?.state !== "failed") + return yield* new StackStateInvalidError({ message: "database is not failed yet" }); + return current; + }).pipe(Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 40 })))); const capability = status.capabilities.find(({ name }) => name === "database"); expect(capability?.state).toBe("failed"); expect(capability?.error).toContain("crashed"); - yield* Fiber.interrupt(watcher); }), ), ); From 38f68cb2ac7786ebd899760ccf63ddb049e42d16 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 01:31:25 +0200 Subject: [PATCH 109/174] docs(stack): record observation simplification report --- .../task-1-report.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .superpowers/sdd/2026-09-03-stack-runtime-simplification/task-1-report.md diff --git a/.superpowers/sdd/2026-09-03-stack-runtime-simplification/task-1-report.md b/.superpowers/sdd/2026-09-03-stack-runtime-simplification/task-1-report.md new file mode 100644 index 0000000000..6bb3950bb5 --- /dev/null +++ b/.superpowers/sdd/2026-09-03-stack-runtime-simplification/task-1-report.md @@ -0,0 +1,56 @@ +# Task 1 report: stateless observation and polled log following + +## Outcome + +Implemented the stateless observation contract. Stack handles no longer expose lifecycle `close()` or `watchStatus()`. Log observation is a unary, cursor-based batch API with polling followers. + +## Changed files and behavior + +- `packages/stack/src/public/Logs.ts`: added `LogQuery`/`StackLogBatch` schemas and types; retained `LogOptions` for the internal `LogStore` seam, with finite tail validation. +- `packages/stack/src/public/EffectStack.ts`: changed `logs` to unary batches, added cursor-polled `followLogs`, advanced cursors across filtered scans, and removed closed-state/handle scope ownership. +- `packages/stack/src/public/PromiseStack.ts`: changed Promise logs to batches, added `followLogs`, and removed the closed guard, iterator registry, and private handle-scope teardown. +- `packages/stack/src/public/Errors.ts`: removed the obsolete status-watch error alias. +- `packages/stack/src/control/StackRpc.ts`: changed `logs` RPC to unary `LogQuery`/`StackLogBatch`; removed streaming `watchStatus` RPC. +- `packages/stack/src/supervisor/Supervisor.ts`: implemented unary filtered/tail batches with running markers and complete-scan cursors; removed status hub/watch stream and log stream RPC handler. +- `packages/stack/src/index.ts`: exported the new log query/batch public types and schemas; removed obsolete root `LogOptions` exports. +- `packages/stack/src/public/Testing.ts`: readiness now polls unary `status()` through an Effect `Schedule`; cleanup no longer calls `close()`. +- `packages/stack/src/public/{effect-stack,promise,testing}.integration.test.ts`: added batch/follower/close-free coverage and migrated fixtures/assertions. +- `packages/stack/src/public/whole-stack.e2e.test.ts`: migrated diagnostics and readiness to unary status/log batches. +- `packages/stack/src/supervisor/{handles,supervisor}.integration.test.ts` and `packages/stack/src/control/control-transport.integration.test.ts`: migrated handles, supervisor, and RPC fixtures to the unary API. +- `apps/cli/src/next/commands/logs/{logs.handler,logs.integration.test}.ts`: non-follow emits `logs(...).entries`; follow uses `followLogs` from the returned cursor. +- `apps/cli/src/shared/functions/managed-functions-runtime.ts`: readiness polls `status()` with a schedule and follows Functions logs through `followLogs`. +- `apps/cli/src/{legacy/commands/functions/serve/serve.integration.test.ts,next/commands/functions/dev/functions-dev-runtime.integration.test.ts}`: updated managed-stack fixtures to the new surface. + +## RED evidence + +The focused pre-implementation integration run failed as intended with missing unary/follower APIs (runtime errors including `TypeError: stack.logs is not a function` and `TypeError: fixture.supervisor.logs is not a function`) while the old streaming/closed-handle contract was still present. + +## GREEN verification + +- `bun --bun vitest run --project integration src/public/effect-stack.integration.test.ts src/public/promise.integration.test.ts src/public/testing.integration.test.ts src/supervisor/supervisor.integration.test.ts src/supervisor/handles.integration.test.ts src/control/control-transport.integration.test.ts` — **117 passed** (6 files). +- `bun --bun vitest run --project integration src/public/effect-stack.integration.test.ts -t 'returns filtered log batches|delivers final followed entries'` — **2 passed**. +- `bun --bun vitest run --project integration src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts src/legacy/commands/functions/serve/serve.integration.test.ts` — **9 passed**. +- `bun --bun vitest run --project integration src/next/commands/logs/logs.integration.test.ts` — **2 passed**. +- `pnpm --filter @supabase/stack types:check` — passed. +- `pnpm --filter supabase types:check` — passed. +- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack` — passed. +- `oxfmt --check` on all 20 changed files and `git diff --check` — passed. + +The first broad run encountered an orphaned Supervisor holding port 40000 (PID 91833, owned by the interrupted test run). I terminated that exact process, confirmed the port was free, and reran both affected restart tests (2/2 passed) and the full six-file command above (117/117 passed); no Supervisor remained afterward. + +## Commits + +- `56bfb3716` — `chore(stack): simplify observation handles` +- Report commit pending (this file is added in the follow-up commit). + +## Self-review + +- Cursor `v1_0` is treated as the empty sentinel for both live and offline reads, while non-empty cursors advance over every scanned entry before capability filtering/tailing. +- Followers emit each returned batch before checking `running`, then stop polling after the terminal batch; polling delay is expressed with an Effect `Schedule` and is interruptible with the follower stream. +- No ordinary stack handle owns a close operation or active follower registry; each follower owns only its own stream evaluation. +- Internal `LogStore` retains its private `LogOptions`/stream implementation because it is not the public control RPC; the public and RPC surfaces use `LogQuery`/`StackLogBatch`. + +## Deviations and concerns + +- Ownership was expanded to the requested compile-breaking CLI/function-dev call sites and the root Promise barrel export; no restart behavior was changed. +- Targeted e2e tests were not run (the task brief requires integration/type/lint/format verification; existing e2e helpers were mechanically migrated and app/stack typechecks pass). From 28b975e78ffd2989102016fafa9762f317fe4635 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 01:32:04 +0200 Subject: [PATCH 110/174] chore(stack): untrack local task report --- .../task-1-report.md | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 .superpowers/sdd/2026-09-03-stack-runtime-simplification/task-1-report.md diff --git a/.superpowers/sdd/2026-09-03-stack-runtime-simplification/task-1-report.md b/.superpowers/sdd/2026-09-03-stack-runtime-simplification/task-1-report.md deleted file mode 100644 index 6bb3950bb5..0000000000 --- a/.superpowers/sdd/2026-09-03-stack-runtime-simplification/task-1-report.md +++ /dev/null @@ -1,56 +0,0 @@ -# Task 1 report: stateless observation and polled log following - -## Outcome - -Implemented the stateless observation contract. Stack handles no longer expose lifecycle `close()` or `watchStatus()`. Log observation is a unary, cursor-based batch API with polling followers. - -## Changed files and behavior - -- `packages/stack/src/public/Logs.ts`: added `LogQuery`/`StackLogBatch` schemas and types; retained `LogOptions` for the internal `LogStore` seam, with finite tail validation. -- `packages/stack/src/public/EffectStack.ts`: changed `logs` to unary batches, added cursor-polled `followLogs`, advanced cursors across filtered scans, and removed closed-state/handle scope ownership. -- `packages/stack/src/public/PromiseStack.ts`: changed Promise logs to batches, added `followLogs`, and removed the closed guard, iterator registry, and private handle-scope teardown. -- `packages/stack/src/public/Errors.ts`: removed the obsolete status-watch error alias. -- `packages/stack/src/control/StackRpc.ts`: changed `logs` RPC to unary `LogQuery`/`StackLogBatch`; removed streaming `watchStatus` RPC. -- `packages/stack/src/supervisor/Supervisor.ts`: implemented unary filtered/tail batches with running markers and complete-scan cursors; removed status hub/watch stream and log stream RPC handler. -- `packages/stack/src/index.ts`: exported the new log query/batch public types and schemas; removed obsolete root `LogOptions` exports. -- `packages/stack/src/public/Testing.ts`: readiness now polls unary `status()` through an Effect `Schedule`; cleanup no longer calls `close()`. -- `packages/stack/src/public/{effect-stack,promise,testing}.integration.test.ts`: added batch/follower/close-free coverage and migrated fixtures/assertions. -- `packages/stack/src/public/whole-stack.e2e.test.ts`: migrated diagnostics and readiness to unary status/log batches. -- `packages/stack/src/supervisor/{handles,supervisor}.integration.test.ts` and `packages/stack/src/control/control-transport.integration.test.ts`: migrated handles, supervisor, and RPC fixtures to the unary API. -- `apps/cli/src/next/commands/logs/{logs.handler,logs.integration.test}.ts`: non-follow emits `logs(...).entries`; follow uses `followLogs` from the returned cursor. -- `apps/cli/src/shared/functions/managed-functions-runtime.ts`: readiness polls `status()` with a schedule and follows Functions logs through `followLogs`. -- `apps/cli/src/{legacy/commands/functions/serve/serve.integration.test.ts,next/commands/functions/dev/functions-dev-runtime.integration.test.ts}`: updated managed-stack fixtures to the new surface. - -## RED evidence - -The focused pre-implementation integration run failed as intended with missing unary/follower APIs (runtime errors including `TypeError: stack.logs is not a function` and `TypeError: fixture.supervisor.logs is not a function`) while the old streaming/closed-handle contract was still present. - -## GREEN verification - -- `bun --bun vitest run --project integration src/public/effect-stack.integration.test.ts src/public/promise.integration.test.ts src/public/testing.integration.test.ts src/supervisor/supervisor.integration.test.ts src/supervisor/handles.integration.test.ts src/control/control-transport.integration.test.ts` — **117 passed** (6 files). -- `bun --bun vitest run --project integration src/public/effect-stack.integration.test.ts -t 'returns filtered log batches|delivers final followed entries'` — **2 passed**. -- `bun --bun vitest run --project integration src/next/commands/functions/dev/functions-dev-runtime.integration.test.ts src/legacy/commands/functions/serve/serve.integration.test.ts` — **9 passed**. -- `bun --bun vitest run --project integration src/next/commands/logs/logs.integration.test.ts` — **2 passed**. -- `pnpm --filter @supabase/stack types:check` — passed. -- `pnpm --filter supabase types:check` — passed. -- `pnpm exec oxlint --config .oxlintrc.effect.json packages/stack` — passed. -- `oxfmt --check` on all 20 changed files and `git diff --check` — passed. - -The first broad run encountered an orphaned Supervisor holding port 40000 (PID 91833, owned by the interrupted test run). I terminated that exact process, confirmed the port was free, and reran both affected restart tests (2/2 passed) and the full six-file command above (117/117 passed); no Supervisor remained afterward. - -## Commits - -- `56bfb3716` — `chore(stack): simplify observation handles` -- Report commit pending (this file is added in the follow-up commit). - -## Self-review - -- Cursor `v1_0` is treated as the empty sentinel for both live and offline reads, while non-empty cursors advance over every scanned entry before capability filtering/tailing. -- Followers emit each returned batch before checking `running`, then stop polling after the terminal batch; polling delay is expressed with an Effect `Schedule` and is interruptible with the follower stream. -- No ordinary stack handle owns a close operation or active follower registry; each follower owns only its own stream evaluation. -- Internal `LogStore` retains its private `LogOptions`/stream implementation because it is not the public control RPC; the public and RPC surfaces use `LogQuery`/`StackLogBatch`. - -## Deviations and concerns - -- Ownership was expanded to the requested compile-breaking CLI/function-dev call sites and the root Promise barrel export; no restart behavior was changed. -- Targeted e2e tests were not run (the task brief requires integration/type/lint/format verification; existing e2e helpers were mechanically migrated and app/stack typechecks pass). From cf727f1e252a905b02df30273240138b826b602a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 02:00:54 +0200 Subject: [PATCH 111/174] chore(stack): close log observation review gaps --- packages/stack/src/public/EffectStack.ts | 50 +++- .../public/effect-stack.integration.test.ts | 231 ++++++++++++++++-- .../src/public/promise.integration.test.ts | 28 ++- packages/stack/src/supervisor/Supervisor.ts | 8 +- .../supervisor/supervisor.integration.test.ts | 73 ++++-- 5 files changed, 335 insertions(+), 55 deletions(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 0e22bcc252..4941d4bb3f 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -613,6 +613,10 @@ export const makeHandle = ( startError, true, ); + const logsStateError = (error: StackError): StackLogsError => + error instanceof StackOwnershipConflictError || error instanceof StackStateInvalidError + ? error + : new StackStateInvalidError({ message: error.message, cause: error }); const stopOwner = (owner: ReturnType) => Effect.gen(function* () { // Subscribe to the owner control connection before sending stop so a @@ -740,13 +744,42 @@ export const makeHandle = ( }); const logs = (query?: LogQuery): Effect.Effect => invoke((rpc) => rpc.logs(query ?? {}), logsError).pipe( - Effect.catchTag("StackOwnershipConflictError", () => - readLogs === undefined - ? Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ) - : readLogs(query), - ), + Effect.catchTag("StackOwnershipConflictError", (ownershipError) => { + if (readLogs === undefined) + return Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ); + if (readOfflineState === undefined || readPersistedState === undefined) + return Effect.fail(ownershipError); + const ownerStopped = readPersistedState().pipe( + Effect.map( + (state) => + Option.isSome(state) && + (state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured"), + ), + Effect.mapError(logsStateError), + ); + return ownerStopped.pipe( + Effect.flatMap((teardown) => { + if (!teardown) return Effect.fail(ownershipError); + return Effect.suspend(() => + // During an owner stop the control socket can close before its metadata/lease are + // released. Re-check ownership before each read and retry only that typed + // transition; a live owner or other log failure remains visible. + readOfflineState().pipe( + Effect.mapError(logsStateError), + Effect.andThen(readLogs(query)), + ), + ).pipe( + Effect.retry({ + schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 })), + while: (error) => error instanceof StackOwnershipConflictError, + }), + ); + }), + ); + }), ); return { id, @@ -801,9 +834,10 @@ export const makeHandle = ( logs, followLogs: (query) => Stream.paginate({ cursor: query?.cursor, first: true }, ({ cursor, first }) => { - const { cursor: _initialCursor, ...baseQuery } = query ?? {}; + const { cursor: _initialCursor, tail: _tail, ...baseQuery } = query ?? {}; const options = { ...baseQuery, + ...(first && query?.tail !== undefined ? { tail: query.tail } : {}), ...(cursor === undefined || cursor.opaque === "v1_0" ? {} : { cursor }), }; const request = logs(options); diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 6833487ffc..cc7b520e68 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -32,7 +32,7 @@ import { import { resolveStackPaths } from "../state/Paths.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; -import { toPersistedIdentity } from "../state/StackState.ts"; +import { toPersistedIdentity, type PersistedStackState } from "../state/StackState.ts"; import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; import { @@ -42,6 +42,7 @@ import { StackPreparationError, StackStateInvalidError, } from "./Errors.ts"; +import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import { createStack, inspectStack, @@ -82,6 +83,25 @@ const credentials = { }, }; +const stoppedState = (): PersistedStackState => ({ + format: "supabase-stack-state-v1", + identity: { + stackId, + projectRoot: "/tmp/project", + checkoutRoot: "/tmp/project", + workspaceId: "workspace", + checkoutId: "checkout", + branchContext: "branch", + localProjectKey: "key", + stackName: "stack", + }, + runtime: { kind: "native" }, + desiredLifecycle: "stopped", + ports: [], + privatePorts: [], + secrets: {}, +}); + const emptyLogs = () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false } as const); @@ -108,17 +128,128 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect { - it.live("returns filtered log batches and follows from their cursor", () => - withRuntimeRoot((project) => + it.live("hands off filtered logs through a real owner stop", () => + Effect.scoped( Effect.gen(function* () { - const stack = yield* createStack({ projectRoot: project }); - const first = yield* stack.logs({ capabilities: ["auth"], tail: 20 }); - expect(first.entries.every((entry) => entry.source === "auth")).toBe(true); - const followed = yield* stack - .followLogs({ capabilities: ["auth"], cursor: first.cursor }) - .pipe(Stream.runCollect); - expect(Array.from(followed).every((entry) => entry.source === "auth")).toBe(true); - }), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-logs-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "logs-session"; + const stopped = yield* Deferred.make(); + const followRead = yield* Deferred.make(); + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const initialAuth: StackLogEntry = { + cursor: { opaque: "v1_1" }, + timestamp: "2026-01-01T00:00:00.000Z", + source: "auth", + stream: "stdout", + message: "started", + }; + const unrelatedDatabase: StackLogEntry = { + cursor: { opaque: "v1_2" }, + timestamp: "2026-01-01T00:00:01.000Z", + source: "database", + stream: "stdout", + message: "ignored", + }; + const finalAuth: StackLogEntry = { + cursor: { opaque: "v1_3" }, + timestamp: "2026-01-01T00:00:02.000Z", + source: "auth", + stream: "stdout", + message: "stopped", + }; + const logEntries = yield* Ref.make>([ + initialAuth, + unrelatedDatabase, + ]); + const ownerRunning = yield* Ref.make(true); + const readLogs = (query: LogQuery): Effect.Effect => + Effect.gen(function* () { + if (query.cursor !== undefined) + yield* Deferred.succeed(followRead, undefined).pipe(Effect.asVoid); + const allEntries = yield* Ref.get(logEntries); + const cursorIndex = + query.cursor === undefined + ? -1 + : allEntries.findIndex((entry) => entry.cursor.opaque === query.cursor?.opaque); + const capabilities = query.capabilities; + const matching = allEntries + .slice(cursorIndex + 1) + .filter( + (entry) => + capabilities === undefined || + (entry.source !== "gateway" && + entry.source !== "supervisor" && + capabilities.includes(entry.source)), + ); + const entries = + query.tail === undefined ? matching : matching.slice(-Math.floor(query.tail)); + const cursor = allEntries.at(-1)?.cursor ?? { opaque: "v1_0" }; + return { + entries, + cursor, + running: yield* Ref.get(ownerRunning), + }; + }); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + prepare: () => Effect.succeed({ capabilities: [] }), + start: () => Effect.succeed(runningStatus), + restart: () => Effect.succeed(runningStatus), + destroy: () => Effect.void, + logs: (query) => readLogs(query), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.gen(function* () { + yield* Ref.set(ownerRunning, false); + yield* Ref.update(logEntries, (entries) => [...entries, finalAuth]); + yield* Deferred.succeed(stopped, undefined); + return { ok: true, op: "stop" } as const; + }), + }, + onShutdownReady: Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeHandle(stackId, owner); + const first = yield* stack.logs({ capabilities: ["auth"], tail: 1 }); + expect(first.entries).toEqual([initialAuth]); + expect(first.cursor).toEqual(unrelatedDatabase.cursor); + + const followedFiber = yield* Effect.forkChild( + stack + .followLogs({ capabilities: ["auth"], cursor: first.cursor }) + .pipe(Stream.runCollect), + { startImmediately: true }, + ); + yield* Deferred.await(followRead); + const stopFiber = yield* Effect.forkChild(stack.stop(), { startImmediately: true }); + yield* Deferred.await(stopped); + const followed = yield* Fiber.join(followedFiber); + expect(Array.from(followed)).toEqual([finalAuth]); + yield* Scope.close(ownerScope, Exit.void); + yield* Fiber.join(stopFiber); + }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -127,7 +258,7 @@ describe("Effect stack lifecycle handoff", () => { // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- test-only handle seam Effect.gen(function* () { const calls = yield* Ref.make(0); - const entries = [ + const entries: [StackLogEntry, StackLogEntry, StackLogEntry, StackLogEntry] = [ { cursor: { opaque: "v1_1" }, timestamp: "2026-01-01T00:00:00.000Z", @@ -142,24 +273,48 @@ describe("Effect stack lifecycle handoff", () => { stream: "stdout" as const, message: "stopped", }, + { + cursor: { opaque: "v1_3" }, + timestamp: "2026-01-01T00:00:02.000Z", + source: "auth" as const, + stream: "stdout" as const, + message: "drained", + }, + { + cursor: { opaque: "v1_4" }, + timestamp: "2026-01-01T00:00:03.000Z", + source: "auth" as const, + stream: "stdout" as const, + message: "closed", + }, ]; + const burst = entries.slice(1); const stack = yield* makeHandle( stackId, {}, { resolveOwner: () => Effect.succeed(Option.none()), - readLogs: () => + readPersistedState: () => Effect.succeed(Option.some(stoppedState())), + readOfflineState: () => Effect.succeed(Option.none()), + readLogs: (query?: LogQuery) => Ref.getAndUpdate(calls, (current) => current + 1).pipe( - Effect.map((index) => ({ - entries: index === 0 ? [entries[0]] : [entries[1]], - cursor: entries[index === 0 ? 0 : 1].cursor, - running: index === 0, - })), + Effect.map((index) => { + const batchEntries = index === 0 ? [entries[0]] : burst; + const visibleEntries = + query?.tail === undefined + ? batchEntries + : batchEntries.slice(-Math.floor(query.tail)); + return { + entries: visibleEntries, + cursor: entries.at(index === 0 ? 0 : 3)?.cursor ?? { opaque: "v1_0" }, + running: index === 0, + }; + }), ), }, ); const followed = yield* stack - .followLogs({ capabilities: ["auth"] }) + .followLogs({ capabilities: ["auth"], tail: 1 }) .pipe(Stream.runCollect, Effect.exit); expect(Exit.isSuccess(followed)).toBe(true); if (Exit.isSuccess(followed)) expect(Array.from(followed.value)).toEqual(entries); @@ -233,6 +388,44 @@ describe("Effect stack lifecycle handoff", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("waits for owner teardown before falling back to offline logs", () => + withRuntimeRoot((_project) => + Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const readAttempts = yield* Ref.make(0); + const ownership = new StackOwnershipConflictError({ + message: "Supervisor is still shutting down", + }); + const stack = yield* makeHandle( + stackId, + {}, + { + resolveOwner: () => Effect.succeed(Option.none()), + readPersistedState: () => Effect.succeed(Option.some(stoppedState())), + readOfflineState: () => + Ref.getAndUpdate(readAttempts, (current) => current + 1).pipe( + Effect.flatMap((attempt) => + attempt === 0 ? Effect.fail(ownership) : Effect.succeed(Option.none()), + ), + ), + readLogs: () => + Ref.update(attempts, (current) => current + 1).pipe( + Effect.as({ + entries: [], + cursor: { opaque: "v1_0" }, + running: false, + }), + ), + }, + ); + const batch = yield* stack.logs(); + expect(batch.entries).toEqual([]); + expect(yield* Ref.get(attempts)).toBe(1); + expect(yield* Ref.get(readAttempts)).toBe(2); + }), + ), + ); + it.live( "guards offline operations against real lock-only and metadata ownership", () => diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index 3eac4c170b..eebde885cb 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -4,6 +4,7 @@ import { NodeServices } from "@effect/platform-node"; import { CAPABILITY_NAMES, type CapabilityStatus } from "./Capability.ts"; import { Effect, Redacted, Stream } from "effect"; import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./EffectStack.ts"; +import type { StackLogEntry } from "./Logs.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; import { InvalidStackConfigError } from "./Errors.ts"; @@ -99,11 +100,32 @@ describe("Promise stack facade", () => { expect(Symbol.asyncDispose in stack).toBe(false); }); - it("cancels an async stream when the consumer returns early", async () => { - const stack = adaptEffectStack(effectStack()); + it("cancels an active async stream and witnesses its finalizer", async () => { + let finalized = false; + const entry: StackLogEntry = { + cursor: { opaque: "v1_1" }, + timestamp: "2026-01-01T00:00:00.000Z", + source: "auth", + stream: "stdout", + message: "active", + }; + const source: EffectStack = { + ...effectStack(), + followLogs: () => + Stream.make(entry).pipe( + Stream.concat(Stream.never), + Stream.ensuring( + Effect.sync(() => { + finalized = true; + }), + ), + ), + }; + const stack = adaptEffectStack(source); const iterator = stack.followLogs()[Symbol.asyncIterator](); - await expect(iterator.next()).resolves.toMatchObject({ done: true }); + await expect(iterator.next()).resolves.toEqual({ done: false, value: entry }); await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + expect(finalized).toBe(true); }); it("completes an empty follower immediately", async () => { diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 9f9499576b..2630b1424d 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -783,6 +783,10 @@ export const makeSupervisor = ( ); const logs = (query?: LogQuery): Effect.Effect => Effect.gen(function* () { + // Capture lifecycle phase before reading the log store. Stop publishes the stopped + // phase only after cleanup has appended terminal records; a stopping snapshot therefore + // stays live and gives followers one more poll rather than racing a final batch. + const phaseAtRead = yield* Ref.get(phase); const cursor = query?.cursor?.opaque === "v1_0" ? undefined : query?.cursor; const scanned = yield* runtime.logStore .read(cursor === undefined ? undefined : { cursor }) @@ -806,9 +810,7 @@ export const makeSupervisor = ( : query.tail <= 0 ? [] : filtered.slice(-Math.floor(query.tail)); - const state = yield* read(); - const running = - state?.desiredLifecycle === "running" && (yield* Ref.get(phase)) === "running"; + const running = phaseAtRead !== "stopped"; return { entries, cursor: scanned.at(-1)?.cursor ?? query?.cursor ?? { opaque: "v1_0" }, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index ab25795d3b..b668c851dd 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -12,7 +12,6 @@ import { Queue, Redacted, Ref, - Schedule, Scope, Stream, } from "effect"; @@ -146,6 +145,7 @@ const makeFixture = ( readonly destroyPreFenceFail?: Ref.Ref; readonly failureQueue?: Queue.Queue; readonly startQueue?: Queue.Queue; + readonly failureObserved?: Deferred.Deferred; } = {}, ) => Effect.gen(function* () { @@ -185,6 +185,20 @@ const makeFixture = ( const resources = yield* Ref.make>([]); const calls = yield* Ref.make>([]); const logOptions = yield* Ref.make>([]); + const entry: StackLogEntry = { + cursor: { opaque: "v1_1" }, + timestamp: "2026-01-01T00:00:00.000Z", + source: "auth", + stream: "internal", + message: "hello", + }; + const finalEntry: StackLogEntry = { + ...entry, + cursor: { opaque: "v1_2" }, + timestamp: "2026-01-01T00:00:01.000Z", + message: "stopped", + }; + const logEntries = yield* Ref.make>([entry]); const failDestroy = yield* Ref.make(false); let gateStopCleanup = false; const driver: RuntimeDriver = { @@ -192,7 +206,15 @@ const makeFixture = ( fixtureOptions.failureQueue === undefined ? Stream.empty : Stream.fromQueue(fixtureOptions.failureQueue), - observe: () => Ref.get(resources), + observe: () => + Ref.get(resources).pipe( + Effect.tap((current) => + fixtureOptions.failureObserved === undefined || + !current.some((entry) => entry.state === "failed") + ? Effect.void + : Deferred.succeed(fixtureOptions.failureObserved, undefined), + ), + ), start: (key, workload: PlannedWorkload) => Effect.gen(function* () { gateStopCleanup = true; @@ -289,15 +311,10 @@ const makeFixture = ( if (!destroy && gateStopCleanup && fixtureOptions.stopGate !== undefined) yield* Deferred.await(fixtureOptions.stopGate); yield* Ref.set(resources, []); + if (!destroy && gateStopCleanup) + yield* Ref.update(logEntries, (current) => [...current, finalEntry]); }), }; - const entry: StackLogEntry = { - cursor: { opaque: "v1_1" }, - timestamp: "2026-01-01T00:00:00.000Z", - source: "auth", - stream: "internal", - message: "hello", - }; const runtime: SupervisorRuntime = { driver, prepare: (_runtime, workloads) => @@ -365,7 +382,9 @@ const makeFixture = ( path: "memory://logs", append: () => Effect.succeed(entry), read: (options) => - Ref.update(logOptions, (current) => [...current, options]).pipe(Effect.as([entry])), + Ref.update(logOptions, (current) => [...current, options]).pipe( + Effect.andThen(Ref.get(logEntries)), + ), stream: () => Stream.empty, }, }; @@ -1140,16 +1159,25 @@ describe("Supervisor composition", () => { ), ); - it.live("returns final logs after a clean stop", () => + it.live("keeps followers live through stop and returns final logs once", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); + const stopGate = yield* Deferred.make(); + const stopStarted = yield* Deferred.make(); + const fixture = yield* makeFixture({ stopGate, stopStarted }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const response = yield* fixture.supervisor.maintenanceHandlers.stop; + const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); + yield* Deferred.await(stopStarted); + const duringStop = yield* fixture.supervisor.logs(); + expect(duringStop.running).toBe(true); + expect(duringStop.entries).toHaveLength(1); + yield* Deferred.succeed(stopGate, undefined); + const response = yield* Fiber.join(stopping); expect(response.ok).toBe(true); yield* fixture.supervisor.shutdownIfIdle; const batch = yield* fixture.supervisor.logs(); - expect(batch.entries).toHaveLength(1); + expect(batch.entries).toHaveLength(2); + expect(batch.entries.filter((entry) => entry.message === "stopped")).toHaveLength(1); expect(batch.running).toBe(false); }), ), @@ -1514,7 +1542,12 @@ describe("Supervisor composition", () => { Effect.gen(function* () { const failures = yield* Queue.unbounded(); const starts = yield* Queue.unbounded(); - const fixture = yield* makeFixture({ failureQueue: failures, startQueue: starts }); + const failureObserved = yield* Deferred.make(); + const fixture = yield* makeFixture({ + failureQueue: failures, + startQueue: starts, + failureObserved, + }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); const initialWorkload = yield* Queue.take(starts); const ready = (yield* Ref.get(fixture.resources)).find( @@ -1541,13 +1574,9 @@ describe("Supervisor composition", () => { ), ); yield* Queue.offer(failures, { ...ready, state: "failed", error: "crashed" }); - const status = yield* Effect.gen(function* () { - const current = yield* fixture.supervisor.status; - const capability = current.capabilities.find(({ name }) => name === "database"); - if (capability?.state !== "failed") - return yield* new StackStateInvalidError({ message: "database is not failed yet" }); - return current; - }).pipe(Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 40 })))); + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- fixture event seam + yield* Deferred.await(failureObserved); + const status = yield* fixture.supervisor.status; const capability = status.capabilities.find(({ name }) => name === "database"); expect(capability?.state).toBe("failed"); expect(capability?.error).toContain("crashed"); From c8d57f3d6401cdec1b6e3202c50454008984951b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 02:48:41 +0200 Subject: [PATCH 112/174] chore(stack): simplify lifecycle restart composition --- .../next/commands/restart/restart.handler.ts | 15 +- .../restart/restart.integration.test.ts | 19 +- .../shared/telemetry/error-actionability.ts | 1 - packages/stack/README.md | 2 +- packages/stack/src/control/ControlServer.ts | 2 +- packages/stack/src/control/StackRpc.ts | 8 +- .../control-transport.integration.test.ts | 23 -- packages/stack/src/public/EffectStack.ts | 220 +----------------- packages/stack/src/public/Errors.ts | 7 - packages/stack/src/public/PromiseStack.ts | 14 +- .../public/effect-stack.integration.test.ts | 89 ++----- packages/stack/src/public/index.ts | 1 - .../src/public/promise.integration.test.ts | 12 +- .../src/public/testing.integration.test.ts | 1 - .../stack/src/public/whole-stack.e2e.test.ts | 4 +- packages/stack/src/supervisor/Launcher.ts | 2 +- packages/stack/src/supervisor/Lifecycle.ts | 55 +---- packages/stack/src/supervisor/Supervisor.ts | 144 +----------- .../supervisor/handles.integration.test.ts | 2 - .../supervisor/lifecycle.integration.test.ts | 20 +- .../supervisor/supervisor.integration.test.ts | 95 +++----- 21 files changed, 101 insertions(+), 635 deletions(-) diff --git a/apps/cli/src/next/commands/restart/restart.handler.ts b/apps/cli/src/next/commands/restart/restart.handler.ts index c845d8b2e5..b5dbff42e4 100644 --- a/apps/cli/src/next/commands/restart/restart.handler.ts +++ b/apps/cli/src/next/commands/restart/restart.handler.ts @@ -7,7 +7,6 @@ import { openStack, type EffectStack, type FindStackOptions, - type OpenStackOptions, } from "@supabase/stack/effect"; import type { StackDescriptor, StackId, StackStatus } from "@supabase/stack/effect"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; @@ -23,15 +22,12 @@ type RestartRuntime = | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner; -export type RestartStack = Pick; +export type RestartStack = Pick; export interface RestartOperations { readonly findStack: ( options: FindStackOptions, ) => Effect.Effect, unknown, RestartRuntime>; - readonly openStack: ( - id: StackId, - options?: OpenStackOptions, - ) => Effect.Effect; + readonly openStack: (id: StackId) => Effect.Effect; readonly loadConfig: ( cwd: string, ) => Effect.Effect; @@ -60,10 +56,9 @@ export const restart = Effect.fnUntraced(function* ( const config = yield* toStartStackConfig(loadedConfig, flags.exclude); yield* Effect.scoped( Effect.gen(function* () { - const stack = yield* operations.openStack(descriptor.value.id, { - replaceIncompatibleOwner: true, - }); - const status: StackStatus = yield* stack.restart({ config }); + const stack = yield* operations.openStack(descriptor.value.id); + yield* stack.stop(); + const status: StackStatus = yield* stack.start({ config }); yield* output.success( status.lifecycle === "running" ? "Local Supabase stack restarted." diff --git a/apps/cli/src/next/commands/restart/restart.integration.test.ts b/apps/cli/src/next/commands/restart/restart.integration.test.ts index 5c58855af3..fbc7539996 100644 --- a/apps/cli/src/next/commands/restart/restart.integration.test.ts +++ b/apps/cli/src/next/commands/restart/restart.integration.test.ts @@ -24,13 +24,18 @@ const status: StackStatus = { }; describe("restart handler", () => { - it.live("restarts the managed stack with translated config", () => { + it.live("stops before starting the managed stack with translated config", () => { + const events: Array = []; let receivedConfig: unknown; - let receivedOptions: unknown; const output = mockOutput({ interactive: false }); const stack: RestartStack = { - restart: (options) => + stop: () => Effect.sync(() => { + events.push("stop"); + }), + start: (options) => + Effect.sync(() => { + events.push("start"); receivedConfig = options?.config; return status; }), @@ -47,9 +52,8 @@ describe("restart handler", () => { desiredLifecycle: "running" as const, }), ), - openStack: (_id, options) => + openStack: () => Effect.sync(() => { - receivedOptions = options; return stack; }), loadConfig: () => Effect.succeed(undefined), @@ -64,10 +68,10 @@ describe("restart handler", () => { ), Effect.tap(() => Effect.sync(() => { + expect(events).toEqual(["stop", "start"]); expect(receivedConfig).toMatchObject({ capabilities: { auth: { enabled: false } }, }); - expect(receivedOptions).toEqual({ replaceIncompatibleOwner: true }); expect(output.messages).toContainEqual( expect.objectContaining({ message: "Local Supabase stack restarted." }), ); @@ -92,7 +96,8 @@ describe("restart handler", () => { ), openStack: () => Effect.succeed({ - restart: () => + stop: () => Effect.void, + start: () => Effect.fail(new StackUpgradeRequiredError({ message: "owner upgrade required" })), }), loadConfig: () => Effect.succeed(undefined), diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 3edda1e904..4c3d1e2c24 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -850,7 +850,6 @@ const externalActionabilityByTag: Record = { StackStateFormatUnsupportedError: () => actionability.invalidConfig, StackStateGenerationMismatchError: () => actionability.startStack, StackUpgradeRequiredError: () => actionability.startStack, - StackUpgradeReplacementError: () => actionability.startStack, StackSecretMismatchError: () => actionability.invalidConfig, InvalidJwtSigningMaterialError: () => actionability.invalidConfig, PortUnavailableError: () => actionability.invalidConfig, diff --git a/packages/stack/README.md b/packages/stack/README.md index 834cd06607..1aa245d6ad 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -21,7 +21,7 @@ Functions, Studio, Mail, Analytics, or Pooler on demand for the current running The package's end-to-end contract is exercised through the same public Stack API in native and Docker modes. It begins from the PostgreSQL-only default, progressively activates every service with -realistic traffic, and verifies stop/start/restart behavior, stable ports, and persistent data. The +realistic traffic, and verifies stop/start cycles, stable ports, and persistent data. The CLI is not involved in these runtime tests. Database reset is intentionally outside the current API. Applying migrations, declarative schemas, diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index 949bbf0fc9..ba1453f3b1 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -443,7 +443,7 @@ export const startControlServer = ( Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), ); const isCompletionRequest = (tag: string): boolean => - tag === "prepare" || tag === "start" || tag === "restart" || tag === "destroy"; + tag === "prepare" || tag === "start" || tag === "destroy"; const completionRequests = new Set(); const startShutdown = (completion: Effect.Effect) => Effect.uninterruptible( diff --git a/packages/stack/src/control/StackRpc.ts b/packages/stack/src/control/StackRpc.ts index 3e8c696892..bd4c77ad21 100644 --- a/packages/stack/src/control/StackRpc.ts +++ b/packages/stack/src/control/StackRpc.ts @@ -9,7 +9,7 @@ import { LogQuerySchema, StackLogBatchSchema } from "../public/Logs.ts"; import { StackStatusSchema } from "../public/Status.ts"; import { STACK_ERROR_TAGS } from "../public/Errors.ts"; -/** Pinned release identifier. A changed release must go through explicit restart. */ +/** Pinned release identifier used to detect incompatible live owners. */ export const STACK_RPC_RELEASE = "stack-rpc-v1@0.1.0" as const; export class StackRpcProtocolError extends Data.TaggedError("StackRpcProtocolError")<{ @@ -58,11 +58,6 @@ const StackRpc = { success: StackStatusSchema, error: StackRpcErrorSchema, }), - restart: Rpc.make("restart", { - payload: Schema.Struct({ config: Schema.optionalKey(StackConfigSchema) }), - success: StackStatusSchema, - error: StackRpcErrorSchema, - }), destroy: Rpc.make("destroy", { success: Schema.Void, error: StackRpcErrorSchema }), logs: Rpc.make("logs", { payload: LogQuerySchema, @@ -76,7 +71,6 @@ export const StackRpcGroup = RpcGroup.make( StackRpc.credentials, StackRpc.prepare, StackRpc.start, - StackRpc.restart, StackRpc.destroy, StackRpc.logs, ); diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 52e1de7a4b..d4cfef3eb2 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -134,7 +134,6 @@ const withServer = ( prepare: () => Ref.update(prepareCalls, (count) => count + 1).pipe(Effect.as({ capabilities: [] })), start: () => Effect.succeed(status), - restart: () => Effect.succeed(status), destroy: () => Effect.void, logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }; @@ -314,28 +313,6 @@ describe("control transport", () => { ), ); - it.live("reevaluates owner shutdown after a failed restart response", () => - withServer( - ({ endpoint, stackId, ownerSessionId, completion }) => - Effect.gen(function* () { - const client = makeControlClient(endpoint, { stackId, ownerSessionId }); - const rpc = yield* client.rpc; - expect(Exit.isFailure(yield* rpc.restart({}).pipe(Effect.exit))).toBe(true); - yield* Deferred.await(completion); - }), - ({ completion }) => ({ - rpcHandlers: { - restart: () => - Effect.fail({ - tag: "StackReconciliationError", - message: "injected restart failure", - } satisfies StackRpcError), - }, - onShutdownReady: Deferred.succeed(completion, undefined).pipe(Effect.asVoid), - }), - ), - ); - it.live("reevaluates owner shutdown after a prepare response", () => withServer( ({ endpoint, stackId, ownerSessionId, completion }) => diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 4941d4bb3f..dbb3488d37 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -10,7 +10,6 @@ import { Option, Path, Predicate, - Ref, Schedule, Scope, Schema, @@ -54,7 +53,6 @@ import { ServiceStartError, ServiceReadinessError, ContainerEngineError, - StackUpgradeReplacementError, StackStateInvalidError, StackStateFormatUnsupportedError, StackUpgradeRequiredError, @@ -70,7 +68,6 @@ import { type StackCredentialsError, type PrepareStackError, type StackStartError, - type StackRestartError, type StackStopError, type StackLogsError, type DestroyStackError, @@ -127,10 +124,6 @@ export interface FindStackOptions { readonly projectRoot: string; readonly name?: string; } -export interface OpenStackOptions { - /** Explicit restart path used to replace an owner from an older RPC release. */ - readonly replaceIncompatibleOwner?: boolean; -} export interface ListStacksOptions { readonly projectRoot?: string; } @@ -157,8 +150,6 @@ export interface EffectStack { // oxlint-disable-next-line effecttsgo/lazy-effect readonly start: (options?: StartStackOptions) => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect - readonly restart: (options?: StartStackOptions) => Effect.Effect; - // oxlint-disable-next-line effecttsgo/lazy-effect readonly stop: () => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect readonly destroy: () => Effect.Effect; @@ -257,7 +248,6 @@ const stackErrorFactories = { StackStateFormatUnsupportedError: (message: string) => new StackStateFormatUnsupportedError({ message }), StackUpgradeRequiredError: (message: string) => new StackUpgradeRequiredError({ message }), - StackUpgradeReplacementError: (message: string) => new StackUpgradeReplacementError({ message }), StackSecretMismatchError: (message: string) => new StackSecretMismatchError({ message }), InvalidJwtSigningMaterialError: (message: string) => new InvalidJwtSigningMaterialError({ message }), @@ -280,7 +270,8 @@ const errorForRpc = (error: ControlError): StackError => { Predicate.isTagged(error, "StackOwnershipConflictError") || Predicate.isTagged(error, "StackLifecycleConflictError") || Predicate.isTagged(error, "StackStateInvalidError") || - Predicate.isTagged(error, "StackStateFormatUnsupportedError") + Predicate.isTagged(error, "StackStateFormatUnsupportedError") || + Predicate.isTagged(error, "StackUpgradeRequiredError") ) return error; if ( @@ -368,37 +359,6 @@ const startError = (error: ControlError): StackStartError => { : new StackStateInvalidError({ message: mapped.message }); }; -const restartError = (error: ControlError): StackRestartError => { - const mapped = errorForRpc(error); - return mapped instanceof StackUpgradeReplacementError ? mapped : startError(error); -}; - -const restartErrorFromStack = (error: StackError): StackRestartError => - error instanceof StackUpgradeReplacementError || - error instanceof InvalidStackConfigError || - error instanceof StackDefinitionRequiredError || - error instanceof StackVersionUnsupportedError || - error instanceof StackOwnershipConflictError || - error instanceof StackNotRunningError || - error instanceof StackMustBeStoppedError || - error instanceof StackLifecycleConflictError || - error instanceof StackStateInvalidError || - error instanceof StackStateFormatUnsupportedError || - error instanceof StackUpgradeRequiredError || - error instanceof StackSecretMismatchError || - error instanceof InvalidJwtSigningMaterialError || - error instanceof PortAllocationError || - error instanceof PortUnavailableError || - error instanceof StackPreparationError || - error instanceof ArtifactIntegrityError || - error instanceof ContainerPullError || - error instanceof StackReconciliationError || - error instanceof ServiceStartError || - error instanceof ServiceReadinessError || - error instanceof ContainerEngineError - ? error - : new StackLifecycleConflictError({ message: error.message }); - const stopError = (error: ControlError): StackStopError => { const mapped = errorForRpc(error); return mapped instanceof StackOwnershipConflictError || @@ -448,16 +408,12 @@ export const makeHandle = ( >; readonly readLogs?: (query?: LogQuery) => Effect.Effect; readonly waitForRelease?: () => Effect.Effect; - readonly replacement?: (options?: StartStackOptions) => Effect.Effect; } = {}, ): Effect.Effect => - Effect.gen(function* () { + Effect.sync(() => { const readOfflineState = options.readOfflineState; const readPersistedState = options.readPersistedState; const readLogs = options.readLogs; - let replacement = options.replacement; - type ReplacementExit = Exit.Exit; - const replacementActive = yield* Ref.make(false); const client: ReturnType | undefined = metadata.endpoint !== undefined && metadata.ownerSessionId !== undefined && @@ -506,7 +462,7 @@ export const makeHandle = ( ) { const mismatch: StackRpcError = { tag: "StackRpcProtocolError", - message: `Stack owner release ${probe.value.rpcRelease} requires explicit restart`, + message: `Stack owner release ${probe.value.rpcRelease} requires stop before start`, }; return Effect.fail(mapError(mismatch)); } @@ -697,7 +653,7 @@ export const makeHandle = ( new StackOwnershipConflictError({ stackId: id, message: - "Stack has running intent without a reachable Supervisor; restart it before preparing", + "Stack has running intent without a reachable Supervisor; stop it before preparing", }), ) : Effect.succeed(false), @@ -787,48 +743,6 @@ export const makeHandle = ( credentials: () => invoke((rpc) => rpc.credentials(undefined), credentialsError), prepare, start, - restart: (options) => - Effect.gen(function* () { - if (replacement !== undefined) { - const admitted = yield* Ref.modify(replacementActive, (active) => - active ? [false, true] : [true, true], - ); - if (!admitted) - return yield* new StackLifecycleConflictError({ - message: "A replacement restart is already active", - }); - const result = yield* Deferred.make(); - const ownerFiber = replacement(options).pipe( - Effect.mapError(restartErrorFromStack), - Effect.exit, - Effect.tap((exit) => - (Exit.isSuccess(exit) - ? Effect.sync(() => { - replacement = undefined; - }) - : Effect.void - ).pipe( - Effect.andThen(Ref.set(replacementActive, false)), - Effect.andThen(Deferred.succeed(result, exit)), - ), - ), - ); - // Replacement owns a stop/start handoff. It must finish even when the initiating - // request is interrupted, while later callers receive an explicit conflict. - yield* ownerFiber.pipe(Effect.forkDetach); - const exit = yield* Deferred.await(result); - if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause); - return exit.value; - } - return yield* invoke( - (rpc) => - options?.config === undefined - ? rpc.restart({}) - : rpc.restart({ config: options.config }), - restartError, - true, - ); - }), stop, destroy: () => destroyAndAwaitOwner, logs, @@ -861,98 +775,6 @@ export const makeHandle = ( } satisfies EffectStack; }); -const replaceIncompatibleOwner = ( - id: StackId, - state: PersistedStackState, - owner: OwnerMetadata, - env: StackRuntimeEnvironmentValue, - store: StackStateStore, -): Effect.Effect< - EffectStack, - OpenStackError, - Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService -> => - Effect.gen(function* () { - const client = makeControlClient(owner.endpoint, { - stackId: id, - ownerSessionId: owner.ownerSessionId, - }); - const probeExit = yield* client.probe().pipe(Effect.exit); - if (Exit.isFailure(probeExit)) { - const failure = Cause.findErrorOption(probeExit.cause); - const transportFailure = - Option.isSome(failure) && isMaintenanceTransportFailure(failure.value); - if (!transportFailure) - return yield* new StackOwnershipConflictError({ - stackId: id, - message: Option.isSome(failure) - ? `Unable to probe incompatible stack owner: ${failure.value.message}` - : "Unable to probe incompatible stack owner", - }); - const currentOwner = yield* ensureSupervisor({ - identity: runtimeIdentity(state.identity), - stackId: id, - stateStore: store, - environment: env, - }); - return yield* makeHandle(id, currentOwner); - } - // Stable maintenance stop is supported across RPC releases and fully shuts down - // the previous owner before replacement is launched. - const responseExit = yield* client.stop().pipe(Effect.exit); - if (Exit.isFailure(responseExit)) { - const failure = Cause.findErrorOption(responseExit.cause); - const transportFailure = - Option.isSome(failure) && isMaintenanceTransportFailure(failure.value); - if (!transportFailure) - return yield* new StackOwnershipConflictError({ - stackId: id, - message: Option.isSome(failure) - ? `Unable to stop incompatible stack owner: ${failure.value.message}` - : "Unable to stop incompatible stack owner", - }); - return yield* new StackOwnershipConflictError({ - stackId: id, - message: "Live incompatible stack owner stopped responding during stop", - }); - } - const response = responseExit.value; - if (!response.ok) - return yield* new StackOwnershipConflictError({ - stackId: id, - message: response.error.message, - }); - - const released = Effect.gen(function* () { - const current = yield* readOwnerMetadata(env.stateRoot, id, env); - if (current !== undefined) - return yield* new StackOwnershipConflictError({ - stackId: id, - message: "Previous stack owner is still shutting down", - }); - if (yield* ownerLockExists(env.stateRoot, id)) - return yield* new StackOwnershipConflictError({ - stackId: id, - message: "Previous stack ownership lease is still held", - }); - }).pipe( - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), - Effect.mapError((error) => - error instanceof StackOwnershipConflictError - ? error - : new StackStateInvalidError({ message: String(error) }), - ), - ); - yield* released; - const currentOwner = yield* ensureSupervisor({ - identity: runtimeIdentity(state.identity), - stackId: id, - stateStore: store, - environment: env, - }); - return yield* makeHandle(id, currentOwner); - }); - const stateInitial = ( identity: StackIdentity, stackId: StackId, @@ -1171,7 +993,6 @@ export const createStack = ( export const openStack = ( id: StackId, - options: OpenStackOptions = {}, ): Effect.Effect< EffectStack, OpenStackError, @@ -1198,37 +1019,6 @@ export const openStack = ( crypto, spawner, }); - const owner = yield* readOwnerMetadata(env.stateRoot, id, env); - if ( - owner !== undefined && - owner.rpcRelease !== STACK_RPC_RELEASE && - options.replaceIncompatibleOwner - ) { - // Defer the entire maintenance handoff until restart is invoked. This - // keeps stop, owner release, replacement launch, and start in one - // package-owned operation with no cancellable gap after openStack(). - const replacement = ( - startOptions?: StartStackOptions, - ): Effect.Effect => - Effect.scoped( - replaceIncompatibleOwner(id, state, owner, env, store).pipe( - Effect.flatMap((handle) => handle.start(startOptions)), - ), - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - return yield* makeHandle( - id, - {}, - { - ...dependencies, - replacement, - }, - ); - } return yield* makeHandle(id, {}, dependencies); }); diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 97d2f88620..8da56fc77f 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -50,10 +50,6 @@ export class StackStateFormatUnsupportedError extends Data.TaggedError( export class StackUpgradeRequiredError extends Data.TaggedError( "StackUpgradeRequiredError", ) {} -export class StackUpgradeReplacementError extends Data.TaggedError( - "StackUpgradeReplacementError", -) {} - export class StackSecretMismatchError extends Data.TaggedError( "StackSecretMismatchError", ) {} @@ -113,7 +109,6 @@ export const STACK_ERROR_TAGS = [ "StackStateInvalidError", "StackStateFormatUnsupportedError", "StackUpgradeRequiredError", - "StackUpgradeReplacementError", "StackSecretMismatchError", "InvalidJwtSigningMaterialError", "PortAllocationError", @@ -150,7 +145,6 @@ export type StackError = | StackStateInvalidError | StackStateFormatUnsupportedError | StackUpgradeRequiredError - | StackUpgradeReplacementError | StackSecretMismatchError | InvalidJwtSigningMaterialError | PortAllocationError @@ -230,7 +224,6 @@ export type StackStartError = | ServiceStartError | ServiceReadinessError | ContainerEngineError; -export type StackRestartError = StackStartError | StackUpgradeReplacementError; /** Stable maintenance stop reports cleanup failures as lifecycle conflicts with their message. */ export type StackStopError = StackOwnershipConflictError | StackLifecycleConflictError; export type StackLogsError = diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 0d6f6802a5..cc34547e14 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -21,7 +21,6 @@ import { openStack as openEffectStack, type EffectStack, type CreateStackOptions, - type OpenStackOptions, type FindStackOptions, type ListStacksOptions, type PrepareStackOptions, @@ -65,7 +64,6 @@ export interface PromiseStack { readonly credentials: () => Promise; readonly prepare: (options?: PromisePrepareStackOptions) => Promise; readonly start: (options?: PromiseStartStackOptions) => Promise; - readonly restart: (options?: PromiseStartStackOptions) => Promise; readonly stop: () => Promise; readonly destroy: () => Promise; readonly logs: (query?: LogQuery) => Promise; @@ -74,7 +72,7 @@ export interface PromiseStack { export interface PromiseStackApi { readonly createStack: (options: CreateStackOptions) => Promise; - readonly openStack: (id: StackId, options?: OpenStackOptions) => Promise; + readonly openStack: (id: StackId) => Promise; readonly findStack: (options: FindStackOptions) => Promise; readonly listStacks: (options?: ListStacksOptions) => Promise>; readonly inspectStack: (id: StackId) => Promise; @@ -163,14 +161,6 @@ export const adaptEffectStack = (effectStack: EffectStack): PromiseStack => { : effectStack.start(config === undefined ? {} : { config }), ), ), - restart: (options) => - invoke( - withConfig(options, (config) => - options === undefined - ? effectStack.restart() - : effectStack.restart(config === undefined ? {} : { config }), - ), - ), stop: () => invoke(effectStack.stop()), destroy: () => invoke(effectStack.destroy()), logs: (query) => invoke(effectStack.logs(query)), @@ -208,7 +198,7 @@ export const makePromiseApi = ( ): Promise => adaptEffectStack(await run(effect)); return { createStack: (options) => createOrOpen(createEffectStack(options)), - openStack: (id, options) => createOrOpen(openEffectStack(id, options)), + openStack: (id) => createOrOpen(openEffectStack(id)), findStack: (options) => run(findEffectStack(options)).then((value) => Option.getOrUndefined(value)), listStacks: (options) => run(listEffectStacks(options)), diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index cc7b520e68..7c0b7bd969 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -37,10 +37,10 @@ import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; import { StackDestructionError, - StackLifecycleConflictError, StackOwnershipConflictError, StackPreparationError, StackStateInvalidError, + StackUpgradeRequiredError, } from "./Errors.ts"; import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import { @@ -210,7 +210,6 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(runningStatus), - restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, logs: (query) => readLogs(query), }, @@ -332,7 +331,7 @@ describe("Effect stack lifecycle handoff", () => { const offline = yield* stack.status(); expect(offline.lifecycle).toBe("unconfigured"); expect(offline.capabilities.every(({ state }) => state === "disabled")).toBe(true); - yield* openStack(stack.id, { replaceIncompatibleOwner: true }); + yield* openStack(stack.id); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); yield* stack.stop(); expect((yield* stack.status()).lifecycle).toBe("unconfigured"); @@ -491,7 +490,6 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), prepare: () => Effect.fail({ tag: "StackRpcProtocolError", message: "prepare failed" }), start: () => Effect.succeed(runningStatus), - restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, logs: emptyLogs, }; @@ -562,7 +560,6 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(runningStatus), - restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, logs: emptyLogs, }, @@ -650,11 +647,9 @@ describe("Effect stack lifecycle handoff", () => { const payloads: { prepare: Array; start: Array; - restart: Array; } = { prepare: [], start: [], - restart: [], }; const rpcHandlers: StackRpcHandlers = { status: () => Effect.succeed(runningStatus), @@ -667,10 +662,6 @@ describe("Effect stack lifecycle handoff", () => { payloads.start.push(payload); return Effect.succeed(runningStatus); }, - restart: (payload) => { - payloads.restart.push(payload); - return Effect.succeed(runningStatus); - }, destroy: () => Effect.void, logs: emptyLogs, }; @@ -697,25 +688,22 @@ describe("Effect stack lifecycle handoff", () => { }); yield* stack.prepare(); yield* stack.start(); - yield* stack.restart(); yield* stack.prepare({ config: {} }); yield* stack.start({ config: {} }); - yield* stack.restart({ config: {} }); expect(payloads.prepare).toEqual([{}, { config: {} }]); expect(payloads.start).toEqual([{}, { config: {} }]); - expect(payloads.restart).toEqual([{}, { config: {} }]); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("launches a compatible Supervisor when restart finds no live owner", () => + it.live("launches a compatible Supervisor when start finds no live owner", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-restart-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-start-" }); const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "restart-session"; + const ownerSessionId = "start-session"; const owner = { format: "supabase-stack-owner-v1" as const, stackId, @@ -732,7 +720,6 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(runningStatus), - restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, logs: emptyLogs, }, @@ -759,7 +746,7 @@ describe("Effect stack lifecycle handoff", () => { }), }, ); - expect((yield* stack.restart()).lifecycle).toBe("running"); + expect((yield* stack.start()).lifecycle).toBe("running"); expect(launched).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), ), @@ -780,7 +767,6 @@ describe("Effect stack lifecycle handoff", () => { credentials: () => Effect.succeed(credentials), prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(runningStatus), - restart: () => Effect.succeed(runningStatus), destroy: () => Effect.void, logs: emptyLogs, }; @@ -816,44 +802,6 @@ describe("Effect stack lifecycle handoff", () => { ), ); - it.live("continues an explicit replacement after the restart waiter is interrupted", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const completed = yield* Deferred.make(); - const endpoint = { kind: "unix" as const, path: "/tmp/unreachable-replacement.sock" }; - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId: "replacement-owner", - rpcRelease: STACK_RPC_RELEASE, - }; - const stack = yield* makeHandle(stackId, owner, { - replacement: () => - Effect.gen(function* () { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - yield* Deferred.succeed(completed, undefined); - return runningStatus; - }), - }); - const first = yield* Effect.forkChild(stack.restart(), { startImmediately: true }); - yield* Deferred.await(started); - const concurrent = yield* stack.restart().pipe(Effect.exit); - expect(Exit.isFailure(concurrent)).toBe(true); - if (Exit.isFailure(concurrent)) { - const error = Option.getOrUndefined(Cause.findErrorOption(concurrent.cause)); - expect(error).toBeInstanceOf(StackLifecycleConflictError); - } - yield* Fiber.interrupt(first); - yield* Deferred.succeed(release, undefined); - yield* Deferred.await(completed); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - it.live("fails destroy when the owner control endpoint is unavailable", () => Effect.scoped( Effect.gen(function* () { @@ -880,7 +828,7 @@ describe("Effect stack lifecycle handoff", () => { ); it.live( - "replaces an incompatible owner only through explicit restart", + "stops an incompatible owner before starting a replacement", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -917,17 +865,26 @@ describe("Effect stack lifecycle handoff", () => { expect(Exit.isFailure(yield* oldOwner.status().pipe(Effect.exit))).toBe(true); const ordinaryCreate = yield* createStack({ projectRoot: project }); expect(ordinaryCreate.id).toBe(stack.id); - const restarted = yield* openStack(stack.id, { replaceIncompatibleOwner: true }); + const restarted = yield* openStack(stack.id); yield* Effect.addFinalizer(() => restarted.destroy().pipe(Effect.ignore)); - const status = yield* restarted.restart({ config: { capabilities: {} } }); + const directStart = yield* restarted + .start({ config: { capabilities: {} } }) + .pipe(Effect.exit); + expect(Exit.isFailure(directStart)).toBe(true); + if (Exit.isFailure(directStart)) { + const failure = Cause.findErrorOption(directStart.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) + expect(failure.value).toBeInstanceOf(StackUpgradeRequiredError); + } + yield* restarted.stop(); + const status = yield* restarted.start({ config: { capabilities: {} } }); const currentOwner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); expect(currentOwner?.rpcRelease).toBe(STACK_RPC_RELEASE); expect(currentOwner?.ownerSessionId).not.toBe(owner.ownerSessionId); expect(status.id).toBe(stack.id); expect(status.runtime).toEqual({ kind: "native" }); expect((yield* restarted.status()).lifecycle).toBe("running"); - const secondStatus = yield* restarted.restart({ config: { capabilities: {} } }); - expect(secondStatus.lifecycle).toBe("running"); yield* restarted.stop(); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); @@ -940,7 +897,7 @@ describe("Effect stack lifecycle handoff", () => { ); it.live( - "reclaims a dead incompatible owner through create and explicit replacement", + "reclaims a dead incompatible owner through create and start", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -1059,9 +1016,9 @@ describe("Effect stack lifecycle handoff", () => { expect(yield* ownerLockExists(env.stateRoot, createId)).toBe(false); const openId = yield* makeDeadOwner(openProject, "stack-rpc-v0@0.0.1"); - const replaced = yield* openStack(openId, { replaceIncompatibleOwner: true }); + const replaced = yield* openStack(openId); yield* Effect.addFinalizer(() => replaced.destroy().pipe(Effect.ignore)); - yield* replaced.restart({ config: { capabilities: {} } }); + yield* replaced.start({ config: { capabilities: {} } }); expect((yield* readOwnerMetadata(env.stateRoot, openId, env))?.rpcRelease).toBe( STACK_RPC_RELEASE, ); diff --git a/packages/stack/src/public/index.ts b/packages/stack/src/public/index.ts index d32838e2f5..bd911e2d46 100644 --- a/packages/stack/src/public/index.ts +++ b/packages/stack/src/public/index.ts @@ -13,7 +13,6 @@ export type { StartStackOptions, PrepareStackOptions, CreateStackOptions, - OpenStackOptions, FindStackOptions, ListStacksOptions, PreparedCapability, diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index eebde885cb..b8bce1e287 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -60,7 +60,6 @@ const effectStack = (): EffectStack => }), prepare: (_options?: PrepareStackOptions) => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(status), - restart: () => Effect.succeed(status), stop: () => Effect.void, destroy: () => Effect.void, logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), @@ -145,10 +144,9 @@ describe("Promise stack facade", () => { ).rejects.toBeInstanceOf(InvalidStackConfigError); }); - it("redacts nested config secrets for prepare, start, and restart", async () => { + it("redacts nested config secrets for prepare and start", async () => { let preparedConfig: StartStackOptions["config"] | undefined; let startedConfig: StartStackOptions["config"] | undefined; - let restartedConfig: StartStackOptions["config"] | undefined; const source: EffectStack = { ...effectStack(), prepare: (options?: PrepareStackOptions) => @@ -161,11 +159,6 @@ describe("Promise stack facade", () => { startedConfig = options?.config; return status; }), - restart: (options?: StartStackOptions) => - Effect.sync(() => { - restartedConfig = options?.config; - return status; - }), }; const stack = adaptEffectStack(source); const config = { @@ -186,8 +179,7 @@ describe("Promise stack facade", () => { await stack.prepare({ config }); await stack.start({ config }); - await stack.restart({ config }); - for (const value of [preparedConfig, startedConfig, restartedConfig]) { + for (const value of [preparedConfig, startedConfig]) { const database = value?.capabilities?.database?.settings; const auth = value?.capabilities?.auth; const functions = value?.capabilities?.functions; diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index d028edbc14..d1fd335879 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -90,7 +90,6 @@ const fakeStack = ( if (failStart) throw new Error("startup failed"); return status("starting", includeApi, functionsState); }, - restart: async () => status("running"), stop: async () => undefined, destroy: async () => { events.push("destroy"); diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 32291b0e37..32fb2617f1 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -809,8 +809,8 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom ).toEqual([{ payload: markers.first }]); await reactivate(); - await stack.restart(); - const restarted = await stack.status(); + await stack.stop(); + const restarted = await stack.start(); expectDefaultLazyState(restarted); expect( Object.fromEntries( diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 4c4cfcaf05..01fb50659a 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -179,7 +179,7 @@ const validateCompatibleOwner = ( (metadata.rpcRelease !== STACK_RPC_RELEASE || probe.rpcRelease !== STACK_RPC_RELEASE) ) return yield* new StackUpgradeRequiredError({ - message: `Stack owner release ${probe.rpcRelease} requires explicit restart`, + message: `Stack owner release ${probe.rpcRelease} requires stop before start`, expectedRelease: STACK_RPC_RELEASE, actualRelease: probe.rpcRelease, }); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 48092f615f..721ecd1533 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -9,7 +9,6 @@ import { StackDefinitionRequiredError, StackLifecycleConflictError, StackMustBeStoppedError, - StackNotRunningError, StackStateInvalidError, type StackError, } from "../public/Errors.ts"; @@ -65,9 +64,6 @@ export interface LifecycleController { // Each invocation builds a fresh read/transition effect while sharing the controller semaphore. // oxlint-disable-next-line effecttsgo/lazy-effect readonly stop: () => Effect.Effect; - readonly restart: ( - options?: LifecycleStartOptions, - ) => Effect.Effect; // oxlint-disable-next-line effecttsgo/lazy-effect readonly destroy: () => Effect.Effect; } @@ -261,14 +257,14 @@ export const makeLifecycleController = ( if (supplied !== undefined && candidate.inputFingerprint !== initial.inputFingerprint) return yield* new StackMustBeStoppedError({ stackId: options.stackId, - message: "Running stack input changed; call restart explicitly to apply it", - guidance: "Use restart() to apply stopped-time changes", + message: "Running stack input changed; stop the stack before applying it", + guidance: "Use stop() followed by start() to apply stopped-time changes", }); if (supplied !== undefined && !sameSecrets(candidate.secrets, initial.secrets)) return yield* new StackMustBeStoppedError({ stackId: options.stackId, - message: "Running stack secrets changed; call restart explicitly to apply them", - guidance: "Use restart() to apply stopped-time changes", + message: "Running stack secrets changed; stop the stack before applying them", + guidance: "Use stop() followed by start() to apply stopped-time changes", }); if (startOptions?.freshSession === true) yield* options.backend.preflight( @@ -316,47 +312,6 @@ export const makeLifecycleController = ( return stopped; }); - const restart = ( - restartOptions?: LifecycleStartOptions, - ): Effect.Effect => - Effect.gen(function* () { - const initial = yield* read(); - if (initial.desiredLifecycle !== "running") - return yield* new StackNotRunningError({ - stackId: options.stackId, - message: "Stack must be running before restart", - }); - if (initial.definition === undefined || initial.inputFingerprint === undefined) - return yield* invalidDefinition(options.stackId); - const candidate = yield* materializeCandidate( - initial, - initial.runtime, - restartOptions?.config, - "stopped", - ); - yield* options.backend.preflight( - lifecycleInput(options.stackId, initial, candidate), - restartOptions?.freshSession === true ? "cold" : "live", - ); - const stopped: PersistedStackState = { ...initial, desiredLifecycle: "stopped" }; - yield* options.stateStore.replace(options.stackId, stopped); - const stoppedCandidate = yield* persistedCandidate( - options.stackId, - initial, - initial.runtime, - ); - const stopInput = lifecycleInput(options.stackId, stopped, stoppedCandidate, "stopped"); - yield* options.backend.reconcile(stopInput, "current"); - yield* options.backend.cleanup; - const next = stateWithCandidate(stopped, candidate, "running"); - yield* options.stateStore.replace(options.stackId, next); - yield* options.backend.reconcile( - lifecycleInput(options.stackId, next, candidate, "running"), - "fresh", - ); - return next; - }); - const destroy = (): Effect.Effect => Effect.gen(function* () { const current = yield* read(); @@ -395,5 +350,5 @@ export const makeLifecycleController = ( yield* options.stateStore.cleanup(options.stackId); }); - return { start, stop, restart, destroy } satisfies LifecycleController; + return { start, stop, destroy } satisfies LifecycleController; }); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 2630b1424d..b725fda3a2 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -10,13 +10,12 @@ import { Path, Redacted, Ref, - Schema, Semaphore, Scope, Stream, } from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; -import { canonicalize, compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; +import { compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; import { activeExecutionPlan, eagerCapabilities, @@ -24,11 +23,10 @@ import { type PlannedWorkload, } from "../model/ExecutionPlan.ts"; import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; -import { StackConfigSchema, type StackConfig } from "../public/Config.ts"; +import type { StackConfig } from "../public/Config.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, - InvalidStackConfigError, StackDefinitionRequiredError, StackLifecycleConflictError, StackNotRunningError, @@ -95,9 +93,6 @@ export interface Supervisor { readonly start: (options?: { readonly config?: StackConfig; }) => Effect.Effect; - readonly restart: (options?: { - readonly config?: StackConfig; - }) => Effect.Effect; readonly destroy: Effect.Effect; /** Completes after a successful stop or destroy shutdown signal. */ readonly shutdown: Effect.Effect; @@ -140,18 +135,6 @@ const stateErrorMessage = (error: StackError | { readonly message?: string }): s const credentialHost = (address: string): string => address.includes(":") && !address.startsWith("[") ? `[${address}]` : address; -const revealRedacted = (value: unknown): unknown => { - if (Redacted.isRedacted(value)) return Redacted.value(value); - if (Array.isArray(value)) return value.map(revealRedacted); - if (typeof value !== "object" || value === null) return value; - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, revealRedacted(entry)]), - ); -}; - -const digestHex = (bytes: Uint8Array): string => - [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); - const mapReconcileError = (error: unknown): StackError => { if (error instanceof StackStateInvalidError) return error; return new StackReconciliationError({ @@ -235,8 +218,8 @@ export const makeSupervisor = ( yield* publish().pipe(Effect.ignore); }); - // Admission joins equivalent operations while execution serializes lifecycle and activation - // work against background failure supervision. + // Admission rejects every overlapping lifecycle operation while execution serializes + // lifecycle and activation work against background failure supervision. const admission = yield* Semaphore.make(1); // Background failure supervision shares the execution gate with explicit lifecycle operations. // This prevents a failure event from reconciling against a concurrently stopping lifecycle. @@ -247,11 +230,10 @@ export const makeSupervisor = ( ); const joinExit = (result: Exit.Exit): Effect.Effect => Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); - type LifecycleKind = "start" | "restart" | "stop" | "destroy"; + type LifecycleKind = "start" | "stop" | "destroy"; type LifecycleResult = Deferred.Deferred, never>; type ActiveLifecycle = Readonly<{ kind: LifecycleKind; - key: string; result: LifecycleResult; }>; const lifecycleActive = yield* Ref.make(undefined); @@ -273,7 +255,6 @@ export const makeSupervisor = ( const submitLifecycle = ( kind: LifecycleKind, - key: string, effect: Effect.Effect, onWaiterInterrupt?: (owned: LifecycleResult) => Effect.Effect, ): Effect.Effect => @@ -285,14 +266,13 @@ export const makeSupervisor = ( yield* ensureAcceptingOperations; const current = yield* Ref.get(lifecycleActive); if (current !== undefined) { - if (current.kind === kind && current.key === key) return current.result; return yield* new StackLifecycleConflictError({ stackId: options.stackId, message: `Lifecycle operation ${current.kind} is already active`, }); } const deferred = yield* Deferred.make, never>(); - yield* Ref.set(lifecycleActive, { kind, key, result: deferred }); + yield* Ref.set(lifecycleActive, { kind, result: deferred }); const release = admission.withPermit( Ref.update(lifecycleActive, (current) => current?.result === deferred ? undefined : current, @@ -370,7 +350,6 @@ export const makeSupervisor = ( yield* publish(); }); - const cleanupEpoch = yield* Ref.make(0); const backend: LifecycleBackend = { preflight: runtime.preflight, reconcile: reconcileBackend, @@ -380,7 +359,6 @@ export const makeSupervisor = ( .cleanup({ stackId: options.stackId, destroy: false }) .pipe(Effect.mapError(mapReconcileError)); yield* Ref.set(active, new Set()); - yield* Ref.update(cleanupEpoch, (epoch) => epoch + 1); yield* publish(); }), destroyData: Effect.gen(function* () { @@ -457,7 +435,7 @@ export const makeSupervisor = ( .withPermit( Effect.gen(function* () { // Explicit lifecycle transitions own the execution gate. Failure events are consumed - // only while the accepted lifecycle is fully running; stopping/restarting sessions + // only while the accepted lifecycle is fully running; stopping sessions // are fenced by the durable lifecycle and therefore skipped safely. if ((yield* Ref.get(phase)) !== "running") return; const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); @@ -609,109 +587,9 @@ export const makeSupervisor = ( yield* Ref.set(phase, "running"); yield* publish(); }); - const operationKey = (config: StackConfig | undefined): Effect.Effect => - Effect.gen(function* () { - const state = yield* read(); - if (state === undefined) - return yield* new StackStateInvalidError({ message: "Stack state is missing" }); - yield* Schema.encodeEffect(StackConfigSchema)(config ?? {}).pipe( - Effect.mapError( - (error) => - new InvalidStackConfigError({ - stackId: options.stackId, - message: `Invalid stack configuration: ${String(error)}`, - cause: error, - }), - ), - ); - const compiled = - config === undefined && - state.definition !== undefined && - state.inputFingerprint !== undefined - ? undefined - : yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: state.runtime, - config, - }).pipe(Effect.provideContext(options.context)); - const fingerprint = compiled?.inputFingerprint ?? state.inputFingerprint; - if (fingerprint === undefined) - return yield* new StackStateInvalidError({ - stackId: options.stackId, - message: "Persisted stack definition fingerprint is missing", - }); - // The compiler fingerprint captures caller semantics. Include only explicitly supplied - // secret values; generated managed values are persisted during start and must not make an - // equivalent concurrent request conflict with its owner. - const secretIdentity: Record = - {}; - if (compiled === undefined) { - for (const [slot, entry] of Object.entries(state.secrets)) - if (entry.value !== undefined) - secretIdentity[slot] = { policy: entry.policy, value: entry.value }; - } else { - for (const declaration of compiled.secrets) { - if (declaration.value === undefined) continue; - secretIdentity[declaration.slot] = { - policy: declaration.policy, - value: String(Redacted.value(declaration.value)), - }; - } - } - const crypto = yield* Crypto.Crypto; - const digest = yield* crypto - .digest( - "SHA-256", - new TextEncoder().encode(canonicalize(revealRedacted({ fingerprint, secretIdentity }))), - ) - .pipe( - Effect.mapError( - (error) => - new InvalidStackConfigError({ - stackId: options.stackId, - message: `Unable to digest stack configuration: ${error.message}`, - cause: error, - }), - ), - ); - return digestHex(digest); - }).pipe(Effect.provideContext(options.context)); const start = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { - const key = yield* operationKey(startOptions?.config); - yield* submitLifecycle("start", key, startOperation(startOptions), continueShutdown); - return yield* snapshot(); - }); - const restartOperation = (startOptions?: { readonly config?: StackConfig }) => - Effect.gen(function* () { - const previous = yield* Ref.get(phase); - const freshSession = !(yield* Ref.get(sessionInitialized)); - if (freshSession) yield* backend.cleanup; - const cleanupBeforeRestart = yield* Ref.get(cleanupEpoch); - yield* Ref.set(phase, "starting"); - yield* publish().pipe(Effect.ignore); - const restarted = yield* controller - .restart({ config: startOptions?.config, freshSession }) - .pipe(Effect.provideContext(options.context), Effect.exit); - if (Exit.isFailure(restarted)) { - const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); - if (state?.desiredLifecycle === "stopped") { - const cleanup = yield* backend.cleanup.pipe(Effect.exit); - yield* Ref.set(phase, Exit.isSuccess(cleanup) ? "stopped" : "stopping"); - yield* publish().pipe(Effect.ignore); - } else { - const tornDown = (yield* Ref.get(cleanupEpoch)) > cleanupBeforeRestart; - yield* restorePhase(tornDown ? "starting" : previous); - } - return yield* Effect.failCause(restarted.cause); - } - yield* Ref.set(phase, "running"); - yield* publish(); - }); - const restart = (startOptions?: { readonly config?: StackConfig }) => - Effect.gen(function* () { - const key = yield* operationKey(startOptions?.config); - yield* submitLifecycle("restart", key, restartOperation(startOptions), continueShutdown); + yield* submitLifecycle("start", startOperation(startOptions), continueShutdown); return yield* snapshot(); }); const stopOperation = () => @@ -760,7 +638,7 @@ export const makeSupervisor = ( startImmediately: true, }).pipe(Effect.asVoid); // An interrupted stop waiter still requests shutdown after the owned operation succeeds. - const stopWithShutdown = submitLifecycle("stop", "stop", stopOperation(), continueShutdown); + const stopWithShutdown = submitLifecycle("stop", stopOperation(), continueShutdown); const operation = (effect: Effect.Effect) => effect.pipe(Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))); const destroyOperation = Effect.gen(function* () { @@ -778,7 +656,7 @@ export const makeSupervisor = ( yield* publish().pipe(Effect.ignore); return result.value; }); - const destroy = submitLifecycle("destroy", "destroy", destroyOperation, continueShutdown).pipe( + const destroy = submitLifecycle("destroy", destroyOperation, continueShutdown).pipe( Effect.asVoid, ); const logs = (query?: LogQuery): Effect.Effect => @@ -1066,7 +944,6 @@ export const makeSupervisor = ( prepare: ({ config, capabilities }) => operation(submitPreparation(prepareOperation({ config, capabilities }))), start: ({ config }: { readonly config?: StackConfig }) => operation(start({ config })), - restart: ({ config }: { readonly config?: StackConfig }) => operation(restart({ config })), destroy: () => operation(destroy), logs: (query: LogQuery) => operation(logs(query)), }); @@ -1081,7 +958,6 @@ export const makeSupervisor = ( ownerSessionId: options.ownerSessionId, status, start, - restart, destroy, shutdown: Deferred.await(shutdownSignal), shutdownIfIdle, diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 5af18a3cee..0c3d9c5446 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -388,7 +388,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.fail({ tag: "StackPreparationError", message: "artifact is incomplete" }), - restart: () => Effect.succeed(status), destroy: () => Effect.void, logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), }; @@ -489,7 +488,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { Effect.fail({ tag: "StackNotRunningError" as const, message: "not running" }), prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(status), - restart: () => Effect.succeed(status), destroy: () => Deferred.succeed(destroyStarted, undefined).pipe( Effect.andThen(Deferred.await(responseRelease)), diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index 025d07097c..5696782b29 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -157,7 +157,7 @@ describe("durable lifecycle controller", () => { ), ); - it.live("requires restart for pass-through secret changes", () => + it.live("requires stop before applying pass-through secret changes", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); @@ -182,7 +182,8 @@ describe("durable lifecycle controller", () => { yield* fixture.controller.start({ config: original }); const running = yield* fixture.controller.start({ config: changed }).pipe(Effect.exit); expect(errorOf(running)).toBeInstanceOf(StackMustBeStoppedError); - const restarted = yield* fixture.controller.restart({ config: changed }); + yield* fixture.controller.stop(); + const restarted = yield* fixture.controller.start({ config: changed }); expect(restarted.desiredLifecycle).toBe("running"); expect(restarted.secrets).toMatchObject({ "secret:functions.settings.functions.hello.env.TOKEN": { value: "two" }, @@ -265,21 +266,6 @@ describe("durable lifecycle controller", () => { ), ); - it.live("keeps the running intent when restart preflight fails", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const running = yield* fixture.controller.start(); - fixture.state.calls.length = 0; - fixture.state.failPreflight = true; - const exit = yield* fixture.controller.restart().pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(StackReconciliationError); - expect(yield* fixture.store.read(fixture.id)).toEqual(running); - expect(fixture.state.calls).toEqual(["preflight"]); - }), - ), - ); - it.live("destroys runtime data before deleting the exact identity root", () => run( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index b668c851dd..fde397e56b 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -25,7 +25,6 @@ import { GatewayActivationError, PortUnavailableError, StackLifecycleConflictError, - StackNotRunningError, StackReconciliationError, StackStateInvalidError, StackMustBeStoppedError, @@ -643,7 +642,7 @@ describe("Supervisor composition", () => { ), ); - it.live("keeps post-commit restart failure observable as starting", () => + it.live("keeps post-commit start failure observable as starting", () => run( Effect.gen(function* () { const acquireCalls = yield* Ref.make(0); @@ -663,7 +662,7 @@ describe("Supervisor composition", () => { new PortUnavailableError({ field: "api", port: 54_321, - message: "injected restart ingress failure", + message: "injected start ingress failure", }), ), ), @@ -673,8 +672,9 @@ describe("Supervisor composition", () => { }, }); yield* fixture.supervisor.start({ config: {} }); + yield* fixture.supervisor.maintenanceHandlers.stop; - expect(Exit.isFailure(yield* fixture.supervisor.restart().pipe(Effect.exit))).toBe(true); + expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); const status = yield* fixture.supervisor.status; expect(status.desiredLifecycle).toBe("running"); expect(status.lifecycle).toBe("starting"); @@ -790,7 +790,7 @@ describe("Supervisor composition", () => { ), ); - it.live("uses cold preflight only when a fresh Supervisor performs restart", () => + it.live("uses cold preflight when starting after a stop", () => run( Effect.gen(function* () { const preflightModes = yield* Ref.make>([]); @@ -798,8 +798,9 @@ describe("Supervisor composition", () => { yield* fixture.supervisor.start({ config: {} }); yield* Ref.set(preflightModes, []); - yield* fixture.supervisor.restart(); - expect(yield* Ref.get(preflightModes)).toEqual(["live"]); + yield* fixture.supervisor.maintenanceHandlers.stop; + yield* fixture.supervisor.start(); + expect(yield* Ref.get(preflightModes)).toEqual(["cold"]); const successor = yield* makeSupervisor({ identity, @@ -811,7 +812,7 @@ describe("Supervisor composition", () => { runtime: fixture.runtime, }); yield* Ref.set(preflightModes, []); - yield* successor.restart(); + yield* successor.start(); expect(yield* Ref.get(preflightModes)).toEqual(["cold"]); }), ), @@ -1087,7 +1088,7 @@ describe("Supervisor composition", () => { ), ); - it.live("keeps restart in starting state across its internal stop leg", () => + it.live("keeps stopping state while an explicit stop is active", () => run( Effect.gen(function* () { const stopGate = yield* Deferred.make(); @@ -1095,11 +1096,13 @@ describe("Supervisor composition", () => { const fixture = yield* makeFixture({ stopGate, stopStarted }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const restarting = yield* Effect.forkChild(fixture.supervisor.restart()); + const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); yield* Deferred.await(stopStarted); - expect((yield* fixture.supervisor.status).lifecycle).toBe("starting"); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); yield* Deferred.succeed(stopGate, undefined); - yield* Fiber.join(restarting); + yield* Fiber.join(stopping); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + yield* fixture.supervisor.start(); expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); }), ), @@ -1183,7 +1186,7 @@ describe("Supervisor composition", () => { ), ); - it.live("keeps running state and explicit restart guidance for changed start input", () => + it.live("keeps running state and explicit stop guidance for changed start input", () => run( Effect.gen(function* () { const fixture = yield* makeFixture(); @@ -1197,31 +1200,16 @@ describe("Supervisor composition", () => { ), ); - it.live("rejects restart while stopped without relaunching resources", () => + it.live("keeps the owner when stop cannot prove its cleanup", () => run( Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* fixture.supervisor.maintenanceHandlers.stop; - const failed = yield* fixture.supervisor.restart().pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackNotRunningError); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }), - ), - ); - - it.live("keeps the owner when restart cannot prove its stopped cleanup", () => - run( - Effect.gen(function* () { - const workloadStopFailFirst = yield* Ref.make(false); const stopFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ workloadStopFailFirst, stopFailFirst }); + const fixture = yield* makeFixture({ stopFailFirst }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* Ref.set(workloadStopFailFirst, true); yield* Ref.set(stopFailFirst, true); - const restarted = yield* fixture.supervisor.restart().pipe(Effect.exit); - expect(errorOf(restarted)).toBeInstanceOf(StackReconciliationError); + const stopped = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(stopped.ok).toBe(false); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); @@ -1231,8 +1219,8 @@ describe("Supervisor composition", () => { yield* fixture.supervisor.shutdownIfIdle; expect(shutdown.pollUnsafe()).toBeUndefined(); - const stopped = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(stopped.ok).toBe(true); + const stoppedAgain = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(stoppedAgain.ok).toBe(true); yield* fixture.supervisor.shutdownIfIdle; yield* Fiber.join(shutdown); expect(yield* Ref.get(fixture.resources)).toEqual([]); @@ -1243,13 +1231,11 @@ describe("Supervisor composition", () => { it.live("retries unproven stopped cleanup before starting again", () => run( Effect.gen(function* () { - const workloadStopFailFirst = yield* Ref.make(false); const stopFailFirst = yield* Ref.make(false); - const fixture = yield* makeFixture({ workloadStopFailFirst, stopFailFirst }); + const fixture = yield* makeFixture({ stopFailFirst }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* Ref.set(workloadStopFailFirst, true); yield* Ref.set(stopFailFirst, true); - yield* fixture.supervisor.restart().pipe(Effect.exit); + yield* fixture.supervisor.maintenanceHandlers.stop; yield* Ref.set(fixture.calls, []); const started = yield* fixture.supervisor.start(); @@ -1291,28 +1277,6 @@ describe("Supervisor composition", () => { ), ); - it.live("keeps accepted start work alive when its waiter is interrupted", () => - run( - Effect.gen(function* () { - const gate = yield* Deferred.make(); - const started = yield* Deferred.make(); - const fixture = yield* makeFixture({ startGate: gate, startStarted: started }); - const config = { - capabilities: { rest: { activation: "eager" } }, - } satisfies import("../public/Config.ts").StackConfig; - const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config })); - yield* Deferred.await(started); - yield* Fiber.interrupt(waiter); - yield* Deferred.succeed(gate, undefined); - const status = yield* fixture.supervisor.start({ config }); - expect(status.lifecycle).toBe("running"); - const starts = (yield* Ref.get(fixture.calls)).filter((call) => call.startsWith("start:")); - expect(starts).toContain("start:database:database"); - expect(starts).toContain("start:rest:rest"); - }), - ), - ); - it.live("shuts down after an interrupted pre-commit start later fails", () => run( Effect.gen(function* () { @@ -1338,7 +1302,7 @@ describe("Supervisor composition", () => { ), ); - it.live("joins concurrent compiler-equivalent starts", () => + it.live("rejects concurrent starts even with identical input", () => run( Effect.gen(function* () { const preflightFailFirst = yield* Ref.make(true); @@ -1349,20 +1313,17 @@ describe("Supervisor composition", () => { preflightGate, preflightStarted, }); - const firstConfig = {}; - const equivalentConfig = { capabilities: {} }; + const firstConfig = { capabilities: {} }; const first = yield* Effect.forkChild(fixture.supervisor.start({ config: firstConfig })); yield* Deferred.await(preflightStarted); - const second = yield* Effect.forkChild( - fixture.supervisor.start({ config: equivalentConfig }), - ); + const second = yield* Effect.forkChild(fixture.supervisor.start({ config: firstConfig })); yield* Deferred.succeed(preflightGate, undefined); const [firstExit, secondExit] = yield* Effect.all([ Fiber.join(first).pipe(Effect.exit), Fiber.join(second).pipe(Effect.exit), ]); expect(Exit.isFailure(firstExit)).toBe(true); - expect(Exit.isFailure(secondExit)).toBe(true); + expect(errorOf(secondExit)).toBeInstanceOf(StackLifecycleConflictError); expect(yield* Ref.get(preflightFailFirst)).toBe(false); }), ), From d99eb606ce9ce56ea5c2227f19c1e5ea6ee47016 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 03:18:25 +0200 Subject: [PATCH 113/174] test(stack): strengthen lifecycle simplification coverage --- .../restart/restart.integration.test.ts | 131 +++++++++++++----- .../public/effect-stack.integration.test.ts | 81 +++++++++-- .../supervisor/supervisor.integration.test.ts | 32 +++++ 3 files changed, 200 insertions(+), 44 deletions(-) diff --git a/apps/cli/src/next/commands/restart/restart.integration.test.ts b/apps/cli/src/next/commands/restart/restart.integration.test.ts index fbc7539996..6ff362ecc1 100644 --- a/apps/cli/src/next/commands/restart/restart.integration.test.ts +++ b/apps/cli/src/next/commands/restart/restart.integration.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option } from "effect"; import { StackIdSchema } from "@supabase/stack/effect"; import type { StackStatus } from "@supabase/stack/effect"; -import { CAPABILITY_NAMES, StackUpgradeRequiredError } from "@supabase/stack/effect"; +import { + CAPABILITY_NAMES, + StackLifecycleConflictError, + StackUpgradeRequiredError, +} from "@supabase/stack/effect"; import { mockCliProjectHome, mockOutput, emptyEnv } from "../../../../tests/helpers/mocks.ts"; import { restart, type RestartOperations, type RestartStack } from "./restart.handler.ts"; @@ -28,18 +32,71 @@ describe("restart handler", () => { const events: Array = []; let receivedConfig: unknown; const output = mockOutput({ interactive: false }); - const stack: RestartStack = { - stop: () => - Effect.sync(() => { - events.push("stop"); - }), - start: (options) => - Effect.sync(() => { - events.push("start"); - receivedConfig = options?.config; - return status; - }), - }; + return Effect.gen(function* () { + const stopStarted = yield* Deferred.make(); + const stopRelease = yield* Deferred.make(); + const stopFinished = yield* Deferred.make(); + const startCalled = yield* Deferred.make(); + const stack: RestartStack = { + stop: () => + Effect.gen(function* () { + events.push("stop-started"); + yield* Deferred.succeed(stopStarted, undefined); + yield* Deferred.await(stopRelease); + events.push("stop-finished"); + yield* Deferred.succeed(stopFinished, undefined); + }), + start: (options) => + Effect.gen(function* () { + events.push("start"); + receivedConfig = options?.config; + yield* Deferred.succeed(startCalled, undefined); + return status; + }), + }; + const operations: RestartOperations = { + findStack: () => + Effect.succeed( + Option.some({ + id: stackId, + projectRoot: process.cwd(), + name: "default", + branchContext: "refs/heads/main", + runtime: { kind: "native" as const }, + desiredLifecycle: "running" as const, + }), + ), + openStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(undefined), + }; + const run = yield* Effect.forkChild( + restart({ stack: "default", exclude: ["auth"] }, operations), + ); + yield* Deferred.await(stopStarted); + expect(Option.isNone(yield* Deferred.poll(startCalled))).toBe(true); + yield* Deferred.succeed(stopRelease, undefined); + yield* Fiber.join(run); + expect(Option.isSome(yield* Deferred.poll(stopFinished))).toBe(true); + expect(events).toEqual(["stop-started", "stop-finished", "start"]); + expect(receivedConfig).toMatchObject({ + capabilities: { auth: { enabled: false } }, + }); + expect(output.messages).toContainEqual( + expect.objectContaining({ message: "Local Supabase stack restarted." }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + emptyEnv(), + output.layer, + mockCliProjectHome({ projectRoot: process.cwd() }), + ), + ), + ); + }); + + it.live("propagates an upgrade-required restart failure", () => { + const output = mockOutput({ interactive: false }); const operations: RestartOperations = { findStack: () => Effect.succeed( @@ -53,35 +110,33 @@ describe("restart handler", () => { }), ), openStack: () => - Effect.sync(() => { - return stack; + Effect.succeed({ + stop: () => Effect.void, + start: () => + Effect.fail(new StackUpgradeRequiredError({ message: "owner upgrade required" })), }), loadConfig: () => Effect.succeed(undefined), }; - return restart({ stack: "default", exclude: ["auth"] }, operations).pipe( - Effect.provide( - Layer.mergeAll( - emptyEnv(), - output.layer, - mockCliProjectHome({ projectRoot: process.cwd() }), - ), - ), - Effect.tap(() => + return restart({ stack: "default", exclude: [] }, operations).pipe( + Effect.provide(Layer.mergeAll(emptyEnv(), output.layer)), + Effect.exit, + Effect.tap((exit) => Effect.sync(() => { - expect(events).toEqual(["stop", "start"]); - expect(receivedConfig).toMatchObject({ - capabilities: { auth: { enabled: false } }, - }); - expect(output.messages).toContainEqual( - expect.objectContaining({ message: "Local Supabase stack restarted." }), - ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackUpgradeRequiredError); + } }), ), ); }); - it.live("propagates an upgrade-required restart failure", () => { + it.live("stops on failure without invoking start", () => { + let startCalled = false; const output = mockOutput({ interactive: false }); + const stopError = new StackLifecycleConflictError({ message: "stop still in progress" }); const operations: RestartOperations = { findStack: () => Effect.succeed( @@ -96,9 +151,12 @@ describe("restart handler", () => { ), openStack: () => Effect.succeed({ - stop: () => Effect.void, + stop: () => Effect.fail(stopError), start: () => - Effect.fail(new StackUpgradeRequiredError({ message: "owner upgrade required" })), + Effect.sync(() => { + startCalled = true; + return status; + }), }), loadConfig: () => Effect.succeed(undefined), }; @@ -107,11 +165,12 @@ describe("restart handler", () => { Effect.exit, Effect.tap((exit) => Effect.sync(() => { + expect(startCalled).toBe(false); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const error = Cause.findErrorOption(exit.cause); expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackUpgradeRequiredError); + if (Option.isSome(error)) expect(error.value).toBe(stopError); } }), ), diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 7c0b7bd969..28a3c54c11 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -31,10 +31,12 @@ import { } from "../state/Ownership.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { resolveSecrets } from "../state/SecretStore.ts"; import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; import { toPersistedIdentity, type PersistedStackState } from "../state/StackState.ts"; import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; +import { compileStack } from "../model/Compiler.ts"; import { StackDestructionError, StackOwnershipConflictError, @@ -905,19 +907,56 @@ describe("Effect stack lifecycle handoff", () => { const path = yield* Path.Path; const env = yield* StackRuntimeEnvironment; const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const makeDeadOwner = (projectRoot: string, rpcRelease: string) => + const makeDeadOwner = ( + projectRoot: string, + rpcRelease: string, + desiredLifecycle: "running" | "unconfigured" = "unconfigured", + ) => Effect.gen(function* () { const identity = yield* resolveStackIdentity({ projectRoot }); const id = yield* deriveStackId(identity); - yield* store.initialize(id, { + const persisted = + desiredLifecycle === "running" + ? yield* Effect.gen(function* () { + const compiled = yield* compileStack({ + projectRoot, + runtime: { kind: "native" }, + config: { capabilities: {} }, + }); + const resolved = yield* resolveSecrets( + { + declarations: compiled.secrets.map( + ({ slot, policy, value, generator }) => ({ + slot, + policy, + ...(value === undefined ? {} : { value }), + ...(generator === undefined ? {} : { generator }), + }), + ), + }, + undefined, + "running", + ); + return { + definition: compiled.definition, + inputFingerprint: compiled.inputFingerprint, + secrets: resolved.persisted, + }; + }) + : undefined; + const initialState: PersistedStackState = { format: "supabase-stack-state-v1", identity: toPersistedIdentity(identity, id), runtime: { kind: "native" }, - desiredLifecycle: "unconfigured", + desiredLifecycle, ports: [], privatePorts: [], secrets: {}, - }); + }; + yield* store.initialize( + id, + persisted === undefined ? initialState : { ...initialState, ...persisted }, + ); const encodedEnvironment = yield* Schema.encodeEffect( Schema.fromJsonString(Schema.Unknown), )({ @@ -1015,14 +1054,40 @@ describe("Effect stack lifecycle handoff", () => { expect(yield* readOwnerMetadata(env.stateRoot, createId, env)).toBeUndefined(); expect(yield* ownerLockExists(env.stateRoot, createId)).toBe(false); - const openId = yield* makeDeadOwner(openProject, "stack-rpc-v0@0.0.1"); + const openId = yield* makeDeadOwner(openProject, "stack-rpc-v0@0.0.1", "running"); const replaced = yield* openStack(openId); - yield* Effect.addFinalizer(() => replaced.destroy().pipe(Effect.ignore)); - yield* replaced.start({ config: { capabilities: {} } }); + const cleanupRecovered = yield* Effect.cached( + Effect.uninterruptible( + Effect.gen(function* () { + yield* replaced.stop(); + yield* replaced.destroy(); + yield* Effect.gen(function* () { + const owner = yield* readOwnerMetadata(env.stateRoot, openId, env); + const locked = yield* ownerLockExists(env.stateRoot, openId); + if (owner !== undefined || locked) + return yield* new StackOwnershipConflictError({ + message: "recovered owner did not release ownership", + }); + }).pipe( + Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), + ); + }), + ), + ); + // Register exact cleanup before launching the replacement. The cached effect makes the + // explicit assertion below and scope finalization share one stop/destroy transition. + yield* Effect.addFinalizer(() => cleanupRecovered.pipe(Effect.ignore)); + yield* replaced.start(); expect((yield* readOwnerMetadata(env.stateRoot, openId, env))?.rpcRelease).toBe( STACK_RPC_RELEASE, ); - yield* replaced.destroy(); + expect((yield* readOwnerMetadata(env.stateRoot, openId, env))?.ownerSessionId).not.toBe( + "crashed-owner", + ); + expect(yield* ownerLockExists(env.stateRoot, openId)).toBe(true); + yield* cleanupRecovered; + expect(yield* readOwnerMetadata(env.stateRoot, openId, env)).toBeUndefined(); + expect(yield* ownerLockExists(env.stateRoot, openId)).toBe(false); }), ), 300_000, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index fde397e56b..2e7103a04a 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -126,6 +126,7 @@ const makeFixture = ( readonly prepareStarted?: Deferred.Deferred; readonly startGate?: Deferred.Deferred; readonly startStarted?: Deferred.Deferred; + readonly startFinished?: Deferred.Deferred; readonly activationGate?: Deferred.Deferred; readonly activationStarted?: Deferred.Deferred; readonly activationCalls?: Ref.Ref; @@ -251,6 +252,8 @@ const makeFixture = ( ...current.filter((entry) => entry.workloadId !== key.workloadId), ready, ]); + if (fixtureOptions.startFinished !== undefined && workload.id === "rest:rest") + yield* Deferred.succeed(fixtureOptions.startFinished, undefined); if (fixtureOptions.startQueue !== undefined) yield* Queue.offer(fixtureOptions.startQueue, workload.id); return ready; @@ -1277,6 +1280,35 @@ describe("Supervisor composition", () => { ), ); + it.live("keeps accepted start work alive when its waiter is interrupted", () => + run( + Effect.gen(function* () { + const startGate = yield* Deferred.make(); + const startStarted = yield* Deferred.make(); + const startFinished = yield* Deferred.make(); + const fixture = yield* makeFixture({ startGate, startStarted, startFinished }); + const config = { + capabilities: { rest: { activation: "eager" } }, + } satisfies import("../public/Config.ts").StackConfig; + const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config })); + yield* Deferred.await(startStarted); + yield* Fiber.interrupt(waiter); + yield* Deferred.succeed(startGate, undefined); + yield* Deferred.await(startFinished); + // shutdownIfIdle acquires the lifecycle admission permit, so it waits until the accepted + // owner operation has released admission after publishing its running phase. + yield* fixture.supervisor.shutdownIfIdle; + + const status = yield* fixture.supervisor.status; + expect(status.lifecycle).toBe("running"); + expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); + const starts = (yield* Ref.get(fixture.calls)).filter((call) => call.startsWith("start:")); + expect(starts).toContain("start:database:database"); + expect(starts).toContain("start:rest:rest"); + }), + ), + ); + it.live("shuts down after an interrupted pre-commit start later fails", () => run( Effect.gen(function* () { From f8063af9b2f303de38489d158b6f7d1d7ba8586f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 03:27:35 +0200 Subject: [PATCH 114/174] test(stack): remove owner cleanup polling --- .../src/public/effect-stack.integration.test.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 28a3c54c11..e0c9f189e0 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -1061,16 +1061,8 @@ describe("Effect stack lifecycle handoff", () => { Effect.gen(function* () { yield* replaced.stop(); yield* replaced.destroy(); - yield* Effect.gen(function* () { - const owner = yield* readOwnerMetadata(env.stateRoot, openId, env); - const locked = yield* ownerLockExists(env.stateRoot, openId); - if (owner !== undefined || locked) - return yield* new StackOwnershipConflictError({ - message: "recovered owner did not release ownership", - }); - }).pipe( - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), - ); + expect(yield* readOwnerMetadata(env.stateRoot, openId, env)).toBeUndefined(); + expect(yield* ownerLockExists(env.stateRoot, openId)).toBe(false); }), ), ); @@ -1086,8 +1078,6 @@ describe("Effect stack lifecycle handoff", () => { ); expect(yield* ownerLockExists(env.stateRoot, openId)).toBe(true); yield* cleanupRecovered; - expect(yield* readOwnerMetadata(env.stateRoot, openId, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, openId)).toBe(false); }), ), 300_000, From 13c9ae68886b2029725acbec887eba1e1dd20170 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 04:13:05 +0200 Subject: [PATCH 115/174] refactor(stack): make preparation caller-owned --- packages/stack/src/control/ControlServer.ts | 3 +- packages/stack/src/control/StackRpc.ts | 20 -- .../control-transport.integration.test.ts | 60 ---- .../stack/src/preparation/RuntimeArtifacts.ts | 7 +- packages/stack/src/public/EffectStack.ts | 318 ++++++++++-------- packages/stack/src/public/Runtime.ts | 2 +- .../public/effect-stack.integration.test.ts | 280 +++++++-------- packages/stack/src/runtime/ContainerEngine.ts | 19 +- .../src/runtime/ContainerEngineResolver.ts | 10 +- .../container-runtime.integration.test.ts | 66 ++-- packages/stack/src/supervisor/Supervisor.ts | 132 +------- .../supervisor/handles.integration.test.ts | 205 ++++------- .../supervisor/supervisor.integration.test.ts | 224 ------------ 13 files changed, 413 insertions(+), 933 deletions(-) diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index ba1453f3b1..dd6fe3260e 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -442,8 +442,7 @@ export const startControlServer = ( Effect.provideService(SocketServer.SocketServer, server), Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json), ); - const isCompletionRequest = (tag: string): boolean => - tag === "prepare" || tag === "start" || tag === "destroy"; + const isCompletionRequest = (tag: string): boolean => tag === "start" || tag === "destroy"; const completionRequests = new Set(); const startShutdown = (completion: Effect.Effect) => Effect.uninterruptible( diff --git a/packages/stack/src/control/StackRpc.ts b/packages/stack/src/control/StackRpc.ts index bd4c77ad21..6fda1b8932 100644 --- a/packages/stack/src/control/StackRpc.ts +++ b/packages/stack/src/control/StackRpc.ts @@ -4,7 +4,6 @@ import * as RpcClient from "effect/unstable/rpc/RpcClient"; import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import { EffectStackCredentialsSchema } from "../public/Credentials.ts"; import { StackConfigSchema } from "../public/Config.ts"; -import { CapabilityNameSchema } from "../public/Capability.ts"; import { LogQuerySchema, StackLogBatchSchema } from "../public/Logs.ts"; import { StackStatusSchema } from "../public/Status.ts"; import { STACK_ERROR_TAGS } from "../public/Errors.ts"; @@ -29,30 +28,12 @@ const StackRpcErrorSchema = Schema.Struct({ }); export type StackRpcError = Schema.Schema.Type; -const PrepareStackResultSchema = Schema.Struct({ - capabilities: Schema.Array( - Schema.Struct({ - capability: CapabilityNameSchema, - version: Schema.String, - outcome: Schema.Literals(["cached", "downloaded", "pulled"] as const), - }), - ), -}); - const StackRpc = { status: Rpc.make("status", { success: StackStatusSchema, error: StackRpcErrorSchema }), credentials: Rpc.make("credentials", { success: EffectStackCredentialsSchema, error: StackRpcErrorSchema, }), - prepare: Rpc.make("prepare", { - payload: Schema.Struct({ - config: Schema.optionalKey(StackConfigSchema), - capabilities: Schema.optionalKey(Schema.Array(CapabilityNameSchema)), - }), - success: PrepareStackResultSchema, - error: StackRpcErrorSchema, - }), start: Rpc.make("start", { payload: Schema.Struct({ config: Schema.optionalKey(StackConfigSchema) }), success: StackStatusSchema, @@ -69,7 +50,6 @@ const StackRpc = { export const StackRpcGroup = RpcGroup.make( StackRpc.status, StackRpc.credentials, - StackRpc.prepare, StackRpc.start, StackRpc.destroy, StackRpc.logs, diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index d4cfef3eb2..04288cb046 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -68,7 +68,6 @@ const withServer = ( readonly completionStarted: Deferred.Deferred; readonly completionRelease: Deferred.Deferred; readonly stopCalls: Ref.Ref; - readonly prepareCalls: Ref.Ref; readonly maintenanceStarted: Deferred.Deferred; readonly maintenanceRelease: Deferred.Deferred; }) => Effect.Effect, @@ -81,7 +80,6 @@ const withServer = ( readonly completionStarted: Deferred.Deferred; readonly completionRelease: Deferred.Deferred; readonly stopCalls: Ref.Ref; - readonly prepareCalls: Ref.Ref; readonly maintenanceStarted: Deferred.Deferred; readonly maintenanceRelease: Deferred.Deferred; }) => ServerOverrides, @@ -99,7 +97,6 @@ const withServer = ( const completionStarted = yield* Deferred.make(); const completionRelease = yield* Deferred.make(); const stopCalls = yield* Ref.make(0); - const prepareCalls = yield* Ref.make(0); const maintenanceStarted = yield* Deferred.make(); const maintenanceRelease = yield* Deferred.make(); const endpoint: ControlEndpoint = { kind: "unix", path: path.join(root, "control.sock") }; @@ -131,8 +128,6 @@ const withServer = ( serviceRoleJwt: Redacted.make("service"), }, }), - prepare: () => - Ref.update(prepareCalls, (count) => count + 1).pipe(Effect.as({ capabilities: [] })), start: () => Effect.succeed(status), destroy: () => Effect.void, logs: () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false }), @@ -157,7 +152,6 @@ const withServer = ( completionStarted, completionRelease, stopCalls, - prepareCalls, maintenanceStarted, maintenanceRelease, }) ?? {}; @@ -181,7 +175,6 @@ const withServer = ( completionStarted, completionRelease, stopCalls, - prepareCalls, maintenanceStarted, maintenanceRelease, }); @@ -245,24 +238,6 @@ const sendRawSequenceAndReadFrame = ( return yield* decodeFrame(frame); }); -const makePrepareRequestFrame = ( - capabilities: ReadonlyArray, -): Effect.Effect => - Effect.gen(function* () { - const parser = RpcSerialization.json.makeUnsafe(); - const encoded = parser.encode({ - _tag: "Request", - id: "1", - tag: "prepare", - payload: { capabilities }, - headers: [], - }); - if (encoded === undefined) { - return yield* new MaintenanceProtocolError({ message: "RPC request could not be encoded" }); - } - return yield* encodeRawFrame(encoded); - }); - const makeDestroyRequestFrame = (): Effect.Effect => Effect.gen(function* () { const parser = RpcSerialization.json.makeUnsafe(); @@ -313,21 +288,6 @@ describe("control transport", () => { ), ); - it.live("reevaluates owner shutdown after a prepare response", () => - withServer( - ({ endpoint, stackId, ownerSessionId, completion }) => - Effect.gen(function* () { - const client = makeControlClient(endpoint, { stackId, ownerSessionId }); - const rpc = yield* client.rpc; - yield* rpc.prepare({}); - yield* Deferred.await(completion); - }), - ({ completion }) => ({ - onShutdownReady: Deferred.succeed(completion, undefined).pipe(Effect.asVoid), - }), - ), - ); - it.live("signals destroy shutdown only after writing the typed response", () => withServer( ({ endpoint, stackId, ownerSessionId, completion }) => @@ -466,26 +426,6 @@ describe("control transport", () => { ), ); - it.live("forwards a fragmented preface and same-write large typed RPC payload", () => - withServer(({ endpoint, stackId, ownerSessionId, prepareCalls }) => - Effect.gen(function* () { - const preface = encodePreface({ - kind: "rpc", - release: "stack-rpc-v1@0.1.0", - stackId, - ownerSessionId, - }); - const frame = yield* makePrepareRequestFrame(Array.from({ length: 100 }, () => "database")); - const response = yield* sendRawSequenceAndReadFrame(endpoint, [ - preface.slice(0, 3), - concatBytes(preface.slice(3), frame), - ]); - expect(response).toMatchObject({ _tag: "Exit", requestId: "1" }); - expect(yield* Ref.get(prepareCalls)).toBe(1); - }), - ), - ); - it.live("rejects a mismatched stack identity before decoding an oversized RPC frame", () => withServer(({ endpoint, ownerSessionId }) => Effect.gen(function* () { diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index cc273735d6..51959b2147 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -188,6 +188,8 @@ export const makeProductionRuntimeArtifactPreparer = (options: { readonly artifactCacheRoot?: string; readonly platform?: ContainerPlatform; readonly runtime?: StackRuntime; + /** Optional already-selected engine for caller-owned preparation. */ + readonly containerEngine?: ContainerEngine; }): Effect.Effect< RuntimeArtifactPreparer, RuntimeArtifactPreparationError, @@ -233,7 +235,8 @@ export const makeProductionRuntimeArtifactPreparer = (options: { const selectedEngine = options.runtime?.kind === "container" ? options.runtime.engine : undefined; const containerEngine = - selectedEngine === undefined + options.containerEngine ?? + (selectedEngine === undefined ? undefined : yield* makeProcessCommandRunner({ executable: selectedEngine }).pipe( Effect.mapError(() => error(`Unable to configure ${selectedEngine} artifact engine`)), @@ -242,7 +245,7 @@ export const makeProductionRuntimeArtifactPreparer = (options: { ? makeDockerEngine({ runner, platform }) : makePodmanEngine({ runner, platform }), ), - ); + )); const containers = selectedEngine === undefined || containerEngine === undefined ? undefined diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index dbb3488d37..11be9caf73 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -21,6 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import type { StackIdentity } from "../identity/Identity.ts"; import { resolveStackIdentity, deriveStackId } from "../identity/Identity.ts"; +import { compileStack, rebuildExecutionPlan, type StackDefinition } from "../model/Compiler.ts"; +import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; import type { PersistedStackIdentity, PersistedStackState } from "../state/StackState.ts"; import { toPersistedIdentity } from "../state/StackState.ts"; import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; @@ -29,7 +31,7 @@ import { StackIdSchema, type StackId } from "./StackId.ts"; import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; import type { StackConfig } from "./Config.ts"; import { type StackStatus, type StackDescriptor, type StackInspection } from "./Status.ts"; -import type { CapabilityName } from "./Capability.ts"; +import { CAPABILITY_NAMES, type CapabilityName } from "./Capability.ts"; import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import type { EffectStackCredentials } from "./Credentials.ts"; import { @@ -99,14 +101,18 @@ import { import { ContainerEngineProtocolError, makeProcessCommandRunner, - selectContainerEngine, type ContainerEngineKind, type ContainerPlatform, + type ContainerEngine, } from "../runtime/ContainerEngine.ts"; import { statusFor } from "../supervisor/StatusProjection.ts"; import { makeDockerEngine } from "../runtime/DockerEngine.ts"; import { makePodmanEngine } from "../runtime/PodmanEngine.ts"; import { readRetainedLogs } from "../supervisor/LogStore.ts"; +import { + makeProductionRuntimeArtifactPreparer, + type PreparedWorkloadArtifact, +} from "../preparation/RuntimeArtifacts.ts"; export interface StartStackOptions { readonly config?: StackConfig; @@ -159,11 +165,6 @@ export interface EffectStack { readonly followLogs: (query?: LogQuery) => Stream.Stream; } -const containerRuntime = (engine: ContainerEngineKind): StackRuntime => ({ - kind: "container", - engine, -}); - const optionOf = (value: A | undefined): Option.Option => value === undefined ? Option.none() : Option.some(value); @@ -176,40 +177,33 @@ const defaultContainerPlatform = (): ContainerPlatform => { }; const defaultContainerEngineResolver = (): ContainerEngineResolverShape => ({ - resolve: (preference) => + resolve: (kind) => Effect.gen(function* () { const platform = defaultContainerPlatform(); - const dockerRunner = yield* makeProcessCommandRunner({ executable: "docker" }); - const podmanRunner = yield* makeProcessCommandRunner({ executable: "podman" }); - const docker = makeDockerEngine({ runner: dockerRunner, platform }); - const podman = makePodmanEngine({ runner: podmanRunner, platform }); - const selected = yield* selectContainerEngine({ preference, docker, podman }); - yield* selected.preflight; - return selected.kind; + const runner = yield* makeProcessCommandRunner({ executable: kind }); + return kind === "docker" + ? makeDockerEngine({ runner, platform }) + : makePodmanEngine({ runner, platform }); }), }); -const resolveRuntime = ( - preference: StackRuntimePreference | undefined, -): Effect.Effect => - preference?.kind !== "container" - ? Effect.succeed({ kind: "native" }) - : Effect.serviceOption(ContainerEngineResolver).pipe( - Effect.map((service) => - Option.isSome(service) ? service.value : defaultContainerEngineResolver(), - ), - Effect.flatMap((resolver) => resolver.resolve(preference.engine ?? "auto")), - Effect.map(containerRuntime), - Effect.mapError( - (error) => - new ContainerEngineError({ - message: - error instanceof ContainerEngineProtocolError - ? `${error.operation}: ${error.message}` - : error.message, - }), - ), - ); +const resolveContainerEngine = ( + kind: ContainerEngineKind, + resolver: ContainerEngineResolverShape | undefined, + spawner: ChildProcessSpawnerValue, +): Effect.Effect => + (resolver ?? defaultContainerEngineResolver()).resolve(kind).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (error) => + new ContainerEngineError({ + message: + error instanceof ContainerEngineProtocolError + ? `${error.operation}: ${error.message}` + : error.message, + }), + ), + ); const descriptor = (state: PersistedStackState): StackDescriptor => ({ id: StackIdSchema.make(state.identity.stackId), @@ -320,18 +314,6 @@ const credentialsError = (error: ControlError): StackCredentialsError => { : new StackNotRunningError({ message: mapped.message }); }; -const prepareError = (error: ControlError): PrepareStackError => { - const mapped = errorForRpc(error); - return mapped instanceof StackPreparationError || - mapped instanceof ArtifactIntegrityError || - mapped instanceof ContainerPullError || - mapped instanceof StackOwnershipConflictError || - mapped instanceof StackStateInvalidError || - mapped instanceof StackLifecycleConflictError - ? mapped - : new StackPreparationError({ message: mapped.message }); -}; - const startError = (error: ControlError): StackStartError => { const mapped = errorForRpc(error); return mapped instanceof InvalidStackConfigError || @@ -408,6 +390,10 @@ export const makeHandle = ( >; readonly readLogs?: (query?: LogQuery) => Effect.Effect; readonly waitForRelease?: () => Effect.Effect; + readonly prepare?: ( + options?: PrepareStackOptions, + ) => Effect.Effect; + readonly containerEngineResolver?: ContainerEngineResolverShape; } = {}, ): Effect.Effect => Effect.sync(() => { @@ -622,82 +608,12 @@ export const makeHandle = ( ), ), ); - const prepare = (prepareOptions?: PrepareStackOptions) => - Effect.gen(function* () { - const ownerBefore = yield* resolveClient(false).pipe( - Effect.mapError(prepareError), - Effect.flatMap((owner) => - owner.probe().pipe( - Effect.flatMap((response) => - response.ok - ? Effect.succeed(true) - : Effect.fail(new StackPreparationError({ message: response.error.message })), - ), - Effect.mapError((error) => - isMaintenanceTransportFailure(error) - ? new StackOwnershipConflictError({ - message: "The existing Supervisor is unreachable", - }) - : prepareError(error), - ), - ), - ), - Effect.catchTag("StackOwnershipConflictError", () => - readPersistedState === undefined - ? Effect.succeed(false) - : readPersistedState().pipe( - Effect.mapError(prepareError), - Effect.flatMap((state) => - Option.isSome(state) && state.value.desiredLifecycle === "running" - ? Effect.fail( - new StackOwnershipConflictError({ - stackId: id, - message: - "Stack has running intent without a reachable Supervisor; stop it before preparing", - }), - ) - : Effect.succeed(false), - ), - ), - ), - ); - const result = yield* Effect.exit( - invoke( - (rpc) => - prepareOptions === undefined - ? rpc.prepare({}) - : rpc.prepare({ - ...(prepareOptions.config === undefined - ? {} - : { config: prepareOptions.config }), - ...(prepareOptions.capabilities === undefined - ? {} - : { capabilities: prepareOptions.capabilities }), - }), - prepareError, - true, - ), - ); - if (!ownerBefore && options.waitForRelease !== undefined) { - const released = yield* Effect.uninterruptible(options.waitForRelease()).pipe( - Effect.mapError(prepareError), - Effect.exit, - ); - if (Exit.isFailure(released)) { - if (Exit.isSuccess(result)) { - const retainedOwner = yield* resolveClient(false).pipe( - Effect.flatMap((owner) => owner.probe()), - Effect.exit, - ); - if (Exit.isSuccess(retainedOwner) && retainedOwner.value.ok) return result.value; - } - return yield* Effect.failCause(released.cause); - } - } - return yield* Exit.isSuccess(result) - ? Effect.succeed(result.value) - : Effect.failCause(result.cause); - }); + const prepare = ( + prepareOptions?: PrepareStackOptions, + ): Effect.Effect => + options.prepare === undefined + ? Effect.fail(new StackPreparationError({ message: "Stack preparation is unavailable" })) + : options.prepare(prepareOptions); const logs = (query?: LogQuery): Effect.Effect => invoke((rpc) => rpc.logs(query ?? {}), logsError).pipe( Effect.catchTag("StackOwnershipConflictError", (ownershipError) => { @@ -807,6 +723,7 @@ const handleDependencies = (options: { readonly path: Path.Path; readonly crypto: Crypto.Crypto; readonly spawner: ChildProcessSpawnerValue; + readonly containerEngineResolver?: ContainerEngineResolverShape; }) => { const provide = ( effect: Effect.Effect, @@ -865,6 +782,123 @@ const handleDependencies = (options: { ); const readPersistedState = () => provide(options.store.read(options.id)).pipe(Effect.map(optionOf)); + const directPrepareError = (cause: unknown): PrepareStackError => + cause instanceof StackPreparationError || + cause instanceof ArtifactIntegrityError || + cause instanceof ContainerPullError || + cause instanceof StackOwnershipConflictError || + cause instanceof StackStateInvalidError || + cause instanceof StackLifecycleConflictError + ? cause + : new StackPreparationError({ + stackId: options.id, + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); + const prepare = ( + prepareOptions?: PrepareStackOptions, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const state = yield* options.store + .read(options.id) + .pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), + Effect.mapError(directPrepareError), + ); + if (state === undefined) + return yield* new StackStateInvalidError({ + stackId: options.id, + message: "Stack state is missing", + }); + let definition: StackDefinition; + let plan: ExecutionPlan; + if (prepareOptions?.config === undefined && state.definition !== undefined) { + definition = state.definition; + plan = yield* rebuildExecutionPlan(state.runtime, definition).pipe( + Effect.provideService(Crypto.Crypto, options.crypto), + ); + } else { + const compiled = yield* compileStack({ + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config: prepareOptions?.config, + }).pipe( + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), + ); + definition = compiled.definition; + plan = compiled.executionPlan; + } + const selected = new Set(); + const visit = (name: CapabilityName): Effect.Effect => { + if (selected.has(name)) return Effect.void; + const capability = definition.capabilities[name]; + if (!capability.enabled) + return Effect.fail( + new StackPreparationError({ + stackId: options.id, + capability: name, + message: `Capability ${name} is disabled`, + }), + ); + selected.add(name); + return Effect.forEach(plan.dependencies[name], visit, { discard: true }); + }; + if (prepareOptions?.capabilities === undefined) { + for (const name of CAPABILITY_NAMES) + if (definition.capabilities[name].enabled) selected.add(name); + } else { + for (const name of new Set(prepareOptions.capabilities)) yield* visit(name); + } + const workloads = plan.workloads.filter((workload) => selected.has(workload.capability)); + const containerEngine = + state.runtime.kind === "container" + ? yield* resolveContainerEngine( + state.runtime.engine, + options.containerEngineResolver, + options.spawner, + ) + : undefined; + const preparer = yield* makeProductionRuntimeArtifactPreparer({ + stateRoot: options.environment.stateRoot, + runtime: state.runtime, + ...(containerEngine === undefined ? {} : { containerEngine }), + }).pipe( + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), + ); + const artifacts = yield* Effect.forEach( + workloads, + (workload) => preparer.prepare(state.runtime, workload), + { concurrency: 4 }, + ); + const byCapability = new Map>(); + for (const artifact of artifacts) { + const existing = byCapability.get(artifact.capability) ?? []; + byCapability.set(artifact.capability, [...existing, artifact]); + } + return { + capabilities: plan.startOrder + .filter((name) => selected.has(name)) + .map((name): PreparedCapability => { + const outcome: PreparedCapability["outcome"] = + state.runtime.kind === "native" + ? byCapability.get(name)?.some((entry) => entry.outcome === "downloaded") + ? "downloaded" + : "cached" + : byCapability.get(name)?.some((entry) => entry.outcome === "pulled") + ? "pulled" + : "cached"; + return { capability: name, version: definition.capabilities[name].version, outcome }; + }), + }; + }), + ).pipe(Effect.mapError(directPrepareError)); const readLogs = (query?: LogQuery) => ensureOffline().pipe( Effect.andThen( @@ -932,7 +966,14 @@ const handleDependencies = (options: { Effect.provideService(Path.Path, options.path), Effect.provideService(Crypto.Crypto, options.crypto), ); - return { resolveOwner, readOfflineState, readPersistedState, readLogs, waitForRelease }; + return { + resolveOwner, + readOfflineState, + readPersistedState, + readLogs, + waitForRelease, + prepare, + }; }; export const createStack = ( @@ -950,25 +991,20 @@ export const createStack = ( }); const stackId = yield* deriveStackId(identity); const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); - const current = + const requestedRuntime: StackRuntime = options.runtime?.kind === "container" - ? yield* Effect.gen(function* () { - const existing = yield* store.read(stackId); - const runtime = - existing === undefined ? yield* resolveRuntime(options.runtime) : existing.runtime; - return ( - existing ?? - (yield* store.initialize(stackId, stateInitial(identity, stackId, runtime))) - ); - }) - : yield* store.initialize(stackId, stateInitial(identity, stackId, { kind: "native" })); + ? { kind: "container", engine: options.runtime.engine ?? "docker" } + : { kind: "native" }; + const current = yield* store.initialize( + stackId, + stateInitial(identity, stackId, requestedRuntime), + ); const runtimeMismatch = options.runtime !== undefined && (current.runtime.kind !== options.runtime.kind || (options.runtime.kind === "container" && current.runtime.kind === "container" && options.runtime.engine !== undefined && - options.runtime.engine !== "auto" && current.runtime.engine !== options.runtime.engine)); if (runtimeMismatch) return yield* new StackRuntimeMismatchError({ @@ -978,6 +1014,9 @@ export const createStack = ( const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const containerEngineResolver = yield* Effect.serviceOption(ContainerEngineResolver).pipe( + Effect.map(Option.getOrUndefined), + ); const dependencies = handleDependencies({ environment: env, store, @@ -987,6 +1026,7 @@ export const createStack = ( path, crypto, spawner, + containerEngineResolver, }); return yield* makeHandle(stackId, {}, dependencies); }); @@ -1008,6 +1048,9 @@ export const openStack = ( const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const containerEngineResolver = yield* Effect.serviceOption(ContainerEngineResolver).pipe( + Effect.map(Option.getOrUndefined), + ); const identity = runtimeIdentity(state.identity); const dependencies = handleDependencies({ environment: env, @@ -1018,6 +1061,7 @@ export const openStack = ( path, crypto, spawner, + containerEngineResolver, }); return yield* makeHandle(id, {}, dependencies); }); diff --git a/packages/stack/src/public/Runtime.ts b/packages/stack/src/public/Runtime.ts index 6ddc2aabcb..dc4197bfdd 100644 --- a/packages/stack/src/public/Runtime.ts +++ b/packages/stack/src/public/Runtime.ts @@ -13,7 +13,7 @@ export const StackRuntimePreferenceSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal("native") }), Schema.Struct({ kind: Schema.Literal("container"), - engine: Schema.optionalKey(Schema.Literals(["docker", "podman", "auto"] as const)), + engine: Schema.optionalKey(RuntimeEngineSchema), }), ]); export type StackRuntimePreference = Schema.Schema.Type; diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index e0c9f189e0..4d840be018 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -2,6 +2,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, + Crypto, Deferred, Effect, Exit, @@ -40,7 +41,6 @@ import { compileStack } from "../model/Compiler.ts"; import { StackDestructionError, StackOwnershipConflictError, - StackPreparationError, StackStateInvalidError, StackUpgradeRequiredError, } from "./Errors.ts"; @@ -56,6 +56,9 @@ import * as effectApi from "../effect.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; +import { makeDockerEngine } from "../runtime/DockerEngine.ts"; +import type { ContainerCommandResult, ContainerCommandRunner } from "../runtime/ContainerEngine.ts"; +import { ContainerEngineResolver } from "../runtime/ContainerEngineResolver.ts"; const stackId = StackIdSchema.make( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", @@ -210,7 +213,6 @@ describe("Effect stack lifecycle handoff", () => { rpcHandlers: { status: () => Effect.succeed(runningStatus), credentials: () => Effect.succeed(credentials), - prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, logs: (query) => readLogs(query), @@ -469,149 +471,124 @@ describe("Effect stack lifecycle handoff", () => { 60_000, ); - it.live("waits for server-owned temporary Supervisor shutdown after prepare", () => - Effect.scoped( + it.live("prepares cache-only without an owner or state mutation", () => + withRuntimeRoot((project) => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-effect-stack-prepare-", - }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "prepare-session"; - const shutdownReady = yield* Deferred.make(); - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }; - const rpcHandlers: StackRpcHandlers = { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - prepare: () => Effect.fail({ tag: "StackRpcProtocolError", message: "prepare failed" }), - start: () => Effect.succeed(runningStatus), - destroy: () => Effect.void, - logs: emptyLogs, - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcHandlers, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Effect.succeed({ ok: true, op: "stop" }), - }, - onShutdownReady: Deferred.succeed(shutdownReady, undefined).pipe(Effect.asVoid), - }); - let launched = false; - const stack = yield* makeHandle( - stackId, - {}, - { - resolveOwner: (launch) => - Effect.sync(() => { - if (launch) launched = true; - return launched ? Option.some(owner) : Option.none(); - }), - waitForRelease: () => Deferred.await(shutdownReady), - }, + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project }); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const state = yield* store.read(stack.id); + if (state === undefined) return yield* Effect.die("stack state was not initialized"); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); + const before = yield* (yield* FileSystem.FileSystem).readFileString(paths.stateDocument); + const prepared = yield* stack.prepare({ capabilities: [] }); + expect(prepared.capabilities).toEqual([]); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); + expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); + expect(yield* (yield* FileSystem.FileSystem).readFileString(paths.stateDocument)).toBe( + before, ); - const result = yield* stack.prepare().pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Cause.findErrorOption(result.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) expect(error.value).toBeInstanceOf(StackPreparationError); - } - }).pipe(Effect.provide(NodeServices.layer)), + }), ), ); - it.live("classifies successful prepare by whether the temporary owner remains live", () => - Effect.scoped( + it.live("cancels unfinished direct preparation while retaining completed artifacts", () => + withRuntimeRoot((project) => Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-effect-stack-prepare-start-", - }); - const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; - const ownerSessionId = "prepare-start-session"; - const owner = { - format: "supabase-stack-owner-v1" as const, - stackId, - endpoint, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }; - yield* startControlServer({ - endpoint, - stackId, - ownerSessionId, - rpcHandlers: { - status: () => Effect.succeed(runningStatus), - credentials: () => Effect.succeed(credentials), - prepare: () => Effect.succeed({ capabilities: [] }), - start: () => Effect.succeed(runningStatus), - destroy: () => Effect.void, - logs: emptyLogs, - }, - maintenanceHandlers: { - probe: Effect.succeed({ - ok: true, - op: "probe", - stackId, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - }), - stop: Effect.succeed({ ok: true, op: "stop" }), + const crypto = { + ...(yield* Effect.service(Crypto.Crypto)), + randomBytes: () => { + throw new Error("direct preparation must not generate managed secrets"); }, - }); - for (const retained of [true, false]) { - let launched = false; - const stack = yield* makeHandle( - stackId, - {}, - { - resolveOwner: (launch) => - Effect.sync(() => { - if (launch) { - launched = true; - return Option.some(owner); - } - return retained && launched ? Option.some(owner) : Option.none(); - }), - waitForRelease: () => - Effect.fail( - new StackOwnershipConflictError({ - message: "Supervisor is still shutting down", + } satisfies Crypto.Crypto; + const firstPublished = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const secondCancelled = yield* Deferred.make(); + const secondRelease = yield* Deferred.make(); + const present = new Set(); + const runner: ContainerCommandRunner = { + executable: "controlled-docker", + run: (request) => { + const [command, subcommand, image] = request.args; + if (command === "version") + return Effect.succeed({ + stdout: '"1"\n', + stderr: "", + exitCode: 0, + }); + if (command === "image" && subcommand === "ls" && image !== undefined) { + if (image.includes("postgrest")) + return Deferred.succeed(secondStarted, undefined).pipe( + Effect.andThen(Deferred.await(secondRelease)), + Effect.onInterrupt(() => Deferred.succeed(secondCancelled, undefined)), + Effect.as({ stdout: "", stderr: "", exitCode: 0 }), + ); + return Effect.succeed({ + stdout: present.has(image) ? '"cached"\n' : "", + stderr: "", + exitCode: 0, + }); + } + if (command === "image" && subcommand === "pull" && image !== undefined) + return Effect.yieldNow.pipe( + Effect.andThen( + Effect.sync(() => { + present.add(image); + return { stdout: "", stderr: "", exitCode: 0 }; }), ), - }, - ); - - const prepared = yield* stack.prepare().pipe(Effect.exit); - expect(Exit.isSuccess(prepared)).toBe(retained); - if (Exit.isFailure(prepared)) { - const error = Option.getOrUndefined(Cause.findErrorOption(prepared.cause)); - expect(error).toBeInstanceOf(StackOwnershipConflictError); - expect(error?.message).toBe("Supervisor is still shutting down"); - } - } - }).pipe(Effect.provide(NodeServices.layer)), + Effect.andThen( + image.includes("postgres") + ? Deferred.succeed(firstPublished, undefined) + : Effect.void, + ), + Effect.as({ stdout: "", stderr: "", exitCode: 0 }), + ); + return Effect.succeed({ stdout: "", stderr: "", exitCode: 0 }); + }, + }; + const engine = makeDockerEngine({ + runner, + platform: { os: "linux" }, + }); + const resolver = { + resolve: () => Effect.succeed(engine), + }; + const stack = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "docker" }, + }).pipe( + Effect.provideService(ContainerEngineResolver, resolver), + Effect.provideService(Crypto.Crypto, crypto), + ); + const stateStore = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); + const before = yield* fs.readFileString(paths.stateDocument); + const preparation = yield* Effect.forkChild( + stack.prepare({ capabilities: ["database", "rest"] }), + { startImmediately: true }, + ); + yield* Deferred.await(firstPublished).pipe(Effect.timeout("5 seconds"), Effect.orDie); + yield* Deferred.await(secondStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); + yield* Fiber.interrupt(preparation); + yield* Deferred.await(secondCancelled).pipe(Effect.timeout("5 seconds"), Effect.orDie); + const canceled = yield* Fiber.join(preparation).pipe(Effect.exit); + expect(Exit.isFailure(canceled)).toBe(true); + const cached = yield* stack.prepare({ capabilities: ["database"] }); + expect(cached.capabilities).toEqual([ + { capability: "database", version: "17.6.1.167", outcome: "cached" }, + ]); + expect(yield* fs.readFileString(paths.stateDocument)).toBe(before); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); + expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); + expect(yield* stateStore.read(stack.id)).toBeDefined(); + }), ), ); - it.live("does not change crashed running intent while preparing", () => + it.live("prepares while running without consulting Supervisor ownership", () => withRuntimeRoot((project) => Effect.gen(function* () { const env = yield* StackRuntimeEnvironment; @@ -620,14 +597,7 @@ describe("Effect stack lifecycle handoff", () => { const state = yield* store.read(stack.id); if (state === undefined) return yield* Effect.die("stack state was not initialized"); yield* store.replace(stack.id, { ...state, desiredLifecycle: "running" }); - - const prepared = yield* stack.prepare().pipe(Effect.exit); - - expect(Exit.isFailure(prepared)).toBe(true); - if (Exit.isFailure(prepared)) { - const error = Option.getOrUndefined(Cause.findErrorOption(prepared.cause)); - expect(error).toBeInstanceOf(StackOwnershipConflictError); - } + expect((yield* stack.prepare({ capabilities: [] })).capabilities).toEqual([]); expect((yield* store.read(stack.id))?.desiredLifecycle).toBe("running"); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); }), @@ -646,20 +616,10 @@ describe("Effect stack lifecycle handoff", () => { const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-rpc-" }); const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; const ownerSessionId = "session"; - const payloads: { - prepare: Array; - start: Array; - } = { - prepare: [], - start: [], - }; + const payloads: { start: Array } = { start: [] }; const rpcHandlers: StackRpcHandlers = { status: () => Effect.succeed(runningStatus), credentials: () => Effect.succeed(credentials), - prepare: (payload) => { - payloads.prepare.push(payload); - return Effect.succeed({ capabilities: [] }); - }, start: (payload) => { payloads.start.push(payload); return Effect.succeed(runningStatus); @@ -688,11 +648,8 @@ describe("Effect stack lifecycle handoff", () => { ownerSessionId, rpcRelease: STACK_RPC_RELEASE, }); - yield* stack.prepare(); yield* stack.start(); - yield* stack.prepare({ config: {} }); yield* stack.start({ config: {} }); - expect(payloads.prepare).toEqual([{}, { config: {} }]); expect(payloads.start).toEqual([{}, { config: {} }]); }).pipe(Effect.provide(NodeServices.layer)), ), @@ -720,7 +677,6 @@ describe("Effect stack lifecycle handoff", () => { rpcHandlers: { status: () => Effect.succeed(runningStatus), credentials: () => Effect.succeed(credentials), - prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, logs: emptyLogs, @@ -767,7 +723,6 @@ describe("Effect stack lifecycle handoff", () => { const rpcHandlers: StackRpcHandlers = { status: () => Effect.succeed(runningStatus), credentials: () => Effect.succeed(credentials), - prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(runningStatus), destroy: () => Effect.void, logs: emptyLogs, @@ -1041,18 +996,9 @@ describe("Effect stack lifecycle handoff", () => { const openProject = path.join(project, "open"); yield* fs.makeDirectory(createProject); yield* fs.makeDirectory(openProject); - const createId = yield* makeDeadOwner(createProject, "stack-rpc-v0@0.0.1"); const created = yield* createStack({ projectRoot: createProject }); - yield* Effect.addFinalizer(() => created.destroy().pipe(Effect.ignore)); - expect(created.id).toBe(createId); - // Creating a handle is intentionally lightweight; prepare probes the - // stale owner, reclaims it, and cleans up its temporary Supervisor. - yield* created.prepare({ - capabilities: [], - config: { listeners: { api: { enabled: false } } }, - }); - expect(yield* readOwnerMetadata(env.stateRoot, createId, env)).toBeUndefined(); - expect(yield* ownerLockExists(env.stateRoot, createId)).toBe(false); + expect(yield* readOwnerMetadata(env.stateRoot, created.id, env)).toBeUndefined(); + expect(yield* ownerLockExists(env.stateRoot, created.id)).toBe(false); const openId = yield* makeDeadOwner(openProject, "stack-rpc-v0@0.0.1", "running"); const replaced = yield* openStack(openId); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 55162ea259..0c9e2a9b2c 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -730,24 +730,11 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai }; }; export interface SelectContainerEngineOptions { - readonly preference: "auto" | ContainerEngineKind; + readonly preference: ContainerEngineKind; readonly docker: ContainerEngine; readonly podman: ContainerEngine; } export const selectContainerEngine = ( options: SelectContainerEngineOptions, -): Effect.Effect => { - const explicit = - options.preference === "docker" - ? options.docker - : options.preference === "podman" - ? options.podman - : undefined; - if (explicit !== undefined) return explicit.probe.pipe(Effect.as(explicit)); - return options.docker.probe.pipe( - Effect.as(options.docker), - Effect.catchTag("ContainerExecutableNotFoundError", () => - options.podman.probe.pipe(Effect.as(options.podman)), - ), - ); -}; +): Effect.Effect => + options.preference === "docker" ? Effect.succeed(options.docker) : Effect.succeed(options.podman); diff --git a/packages/stack/src/runtime/ContainerEngineResolver.ts b/packages/stack/src/runtime/ContainerEngineResolver.ts index c18a359e7c..9b5bb14508 100644 --- a/packages/stack/src/runtime/ContainerEngineResolver.ts +++ b/packages/stack/src/runtime/ContainerEngineResolver.ts @@ -1,6 +1,10 @@ import { Context, Effect } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import type { ContainerEngineFailure, ContainerEngineKind } from "./ContainerEngine.ts"; +import type { + ContainerEngine, + ContainerEngineFailure, + ContainerEngineKind, +} from "./ContainerEngine.ts"; /** * Host-composition seam for selecting one concrete container engine. The @@ -9,8 +13,8 @@ import type { ContainerEngineFailure, ContainerEngineKind } from "./ContainerEng */ export interface ContainerEngineResolverShape { readonly resolve: ( - preference: "auto" | ContainerEngineKind, - ) => Effect.Effect; + preference: ContainerEngineKind, + ) => Effect.Effect; } export class ContainerEngineResolver extends Context.Service< diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 505932449f..6a700cabf1 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -6,7 +6,6 @@ import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ContainerArtifact } from "../model/CapabilityModule.ts"; import { StackIdSchema } from "../public/StackId.ts"; import { - ContainerExecutableNotFoundError, ContainerCommandError, ContainerEngineProtocolError, makeProcessCommandRunner, @@ -235,47 +234,38 @@ describe("container runtime", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("selects explicit engines and only falls back on missing executable", () => + it.live("selects only the explicitly requested engine without probing", () => Effect.gen(function* () { - const missing = makeControlledCommandRunner({ - run: () => - Effect.fail( - new ContainerExecutableNotFoundError({ - executable: "docker", - message: "docker executable was not found", + const dockerCalls: string[] = []; + const podmanCalls: string[] = []; + const docker = makeDockerEngine({ + runner: makeControlledCommandRunner({ + run: (request) => + Effect.sync(() => { + dockerCalls.push(request.args[0] ?? ""); + return commandResult({ ok: true }); }), - ), - }); - const podman = makeControlledCommandRunner({ - run: () => Effect.succeed(commandResult({ ok: true })), - }); - const selected = yield* selectContainerEngine({ - preference: "auto", - docker: makeDockerEngine({ runner: missing, platform: { os: "linux", desktop: false } }), - podman: makePodmanEngine({ runner: podman, platform: { os: "linux", rootless: true } }), + }), + platform: { os: "linux", desktop: false }, }); - expect(selected.kind).toBe("podman"); - const daemonFailure = makeControlledCommandRunner({ - run: () => - Effect.fail( - new ContainerEngineProtocolError({ operation: "probe", message: "daemon unavailable" }), - ), - }); - const failure = yield* selectContainerEngine({ - preference: "auto", - docker: makeDockerEngine({ - runner: daemonFailure, - platform: { os: "linux", desktop: false }, + const podman = makePodmanEngine({ + runner: makeControlledCommandRunner({ + run: (request) => + Effect.sync(() => { + podmanCalls.push(request.args[0] ?? ""); + return commandResult({ ok: true }); + }), }), - podman: makePodmanEngine({ runner: podman, platform: { os: "linux", rootless: true } }), - }).pipe(Effect.exit); - expect(Exit.isFailure(failure)).toBe(true); - if (Exit.isFailure(failure)) { - const error = Cause.findErrorOption(failure.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) - expect(error.value).not.toBeInstanceOf(ContainerExecutableNotFoundError); - } + platform: { os: "linux", rootless: true }, + }); + expect((yield* selectContainerEngine({ preference: "docker", docker, podman })).kind).toBe( + "docker", + ); + expect((yield* selectContainerEngine({ preference: "podman", docker, podman })).kind).toBe( + "podman", + ); + expect(dockerCalls).toEqual([]); + expect(podmanCalls).toEqual([]); }), ); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index b725fda3a2..884aeac42e 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -15,23 +15,21 @@ import { Stream, } from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; -import { compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; +import { rebuildExecutionPlan } from "../model/Compiler.ts"; import { activeExecutionPlan, eagerCapabilities, type ExecutionPlan, type PlannedWorkload, } from "../model/ExecutionPlan.ts"; -import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import type { CapabilityName } from "../public/Capability.ts"; import type { StackConfig } from "../public/Config.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, - StackDefinitionRequiredError, StackLifecycleConflictError, StackNotRunningError, StackReconciliationError, - StackPreparationError, StackStateInvalidError, type StackError, } from "../public/Errors.ts"; @@ -237,7 +235,6 @@ export const makeSupervisor = ( result: LifecycleResult; }>; const lifecycleActive = yield* Ref.make(undefined); - const preparationsActive = yield* Ref.make(0); const shutdownSignal = yield* Deferred.make(); const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); const ensureAcceptingOperations = Deferred.poll(shutdownSignal).pipe( @@ -620,11 +617,9 @@ export const makeSupervisor = ( const state = yield* read().pipe(Effect.exit); if (Exit.isFailure(state)) return; const lifecycle = yield* Ref.get(lifecycleActive); - const preparations = yield* Ref.get(preparationsActive); const currentPhase = yield* Ref.get(phase); if ( lifecycle === undefined && - preparations === 0 && currentPhase !== "stopping" && (state.value === undefined || state.value.desiredLifecycle === "stopped" || @@ -817,132 +812,9 @@ export const makeSupervisor = ( } satisfies EffectStackCredentials; }, ); - const prepareOperation = (prepareOptions?: { - readonly config?: StackConfig; - readonly capabilities?: ReadonlyArray; - }) => - Effect.gen(function* () { - // Preparation is deliberately based on one fresh state read and never writes it. A - // supplied config is compiled only to validate/materialize a prospective plan; its secret - // declarations are intentionally not resolved here. - const state = yield* read(); - if (state === undefined) - return yield* new StackStateInvalidError({ message: "Stack state is missing" }); - - let definition: import("../model/Compiler.ts").StackDefinition; - let plan: ExecutionPlan; - if (prepareOptions?.config === undefined && state.definition !== undefined) { - if (state.inputFingerprint === undefined) - return yield* new StackStateInvalidError({ - stackId: options.stackId, - message: "Persisted stack definition fingerprint is missing", - }); - definition = state.definition; - plan = yield* rebuildExecutionPlan(state.runtime, definition).pipe( - Effect.provideContext(options.context), - ); - } else { - if ( - prepareOptions?.config === undefined && - state.desiredLifecycle !== "unconfigured" && - state.definition === undefined - ) - return yield* new StackDefinitionRequiredError({ - stackId: options.stackId, - message: "A complete stack definition is required", - }); - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: state.runtime, - config: prepareOptions?.config, - }).pipe(Effect.provideContext(options.context)); - definition = compiled.definition; - plan = compiled.executionPlan; - } - - const selected = new Set(); - const visit = (name: CapabilityName): Effect.Effect => { - if (selected.has(name)) return Effect.void; - const capability = definition.capabilities[name]; - if (!capability.enabled) - return Effect.fail( - new StackPreparationError({ - stackId: options.stackId, - capability: name, - message: `Capability ${name} is disabled`, - }), - ); - selected.add(name); - return Effect.forEach(plan.dependencies[name], visit, { discard: true }); - }; - if (prepareOptions?.capabilities === undefined) { - for (const name of CAPABILITY_NAMES) - if (definition.capabilities[name].enabled) selected.add(name); - } else { - for (const name of new Set(prepareOptions.capabilities)) yield* visit(name); - } - - const workloads = plan.workloads.filter((workload) => selected.has(workload.capability)); - const artifacts = yield* runtime.prepare(state.runtime, workloads); - const byCapability = new Map>(); - for (const artifact of artifacts) { - const existing = byCapability.get(artifact.capability) ?? []; - byCapability.set(artifact.capability, [...existing, artifact]); - } - const capabilities = plan.startOrder - .filter((name) => selected.has(name)) - .map((name) => { - const entries = byCapability.get(name) ?? []; - const outcome: "cached" | "downloaded" | "pulled" = - state.runtime.kind === "native" - ? entries.some((entry) => entry.outcome === "downloaded") - ? "downloaded" - : "cached" - : entries.some((entry) => entry.outcome === "pulled") - ? "pulled" - : "cached"; - return { - capability: name, - version: definition.capabilities[name].version, - outcome, - }; - }); - return { capabilities }; - }).pipe(Effect.provideContext(options.context)); - const submitPreparation = ( - effect: Effect.Effect, - ): Effect.Effect => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const result = yield* Deferred.make, never>(); - const released = yield* Ref.make(false); - const release = Ref.getAndSet(released, true).pipe( - Effect.flatMap((alreadyReleased) => - alreadyReleased ? Effect.void : Ref.update(preparationsActive, (count) => count - 1), - ), - ); - const owner = Effect.gen(function* () { - const exit = yield* Effect.exit(effect); - yield* release; - yield* Deferred.succeed(result, exit); - }).pipe(Effect.ensuring(release)); - yield* admission.withPermit( - ensureAcceptingOperations.pipe( - Effect.andThen(Ref.update(preparationsActive, (count) => count + 1)), - Effect.andThen(FiberSet.run(ownedFibers, owner, { startImmediately: true })), - ), - ); - const awaitResult = Deferred.await(result).pipe( - Effect.onInterrupt(() => continueShutdown(result)), - ); - return yield* restore(awaitResult).pipe(Effect.flatMap(joinExit)); - }), - ); const rpcHandlers: StackRpcHandlers = StackRpcGroup.of({ status: () => operation(status), credentials: () => credentials, - prepare: ({ config, capabilities }) => - operation(submitPreparation(prepareOperation({ config, capabilities }))), start: ({ config }: { readonly config?: StackConfig }) => operation(start({ config })), destroy: () => operation(destroy), logs: (query: LogQuery) => operation(logs(query)), diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 0c3d9c5446..ad210460d1 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -45,12 +45,8 @@ import { makeStackStateStore } from "../state/StackStateStore.ts"; import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; -import { - ContainerEngineError, - StackPreparationError, - StackRuntimeMismatchError, -} from "../public/Errors.ts"; -import { ContainerEngineProtocolError } from "../runtime/ContainerEngine.ts"; +import { StackPreparationError, StackRuntimeMismatchError } from "../public/Errors.ts"; +import type { ContainerEngine } from "../runtime/ContainerEngine.ts"; import { ContainerEngineResolver } from "../runtime/ContainerEngineResolver.ts"; const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => @@ -121,6 +117,35 @@ const stopOwner = (id: StackId) => const quoteModuleSpecifier = (value: string): string => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; +const fakeContainerEngine = (kind: "docker" | "podman", calls: string[]): ContainerEngine => ({ + kind, + executable: kind, + preflight: Effect.succeed({ host: "host.containers.internal" }), + probe: Effect.sync(() => { + calls.push(`${kind}:probe`); + }), + inspectImage: (image) => + Effect.sync(() => { + calls.push(`${kind}:inspect:${image}`); + return { present: true }; + }), + pullImage: (image) => + Effect.sync(() => { + calls.push(`${kind}:pull:${image}`); + }), + listResources: () => Effect.succeed([]), + createNetwork: () => Effect.die("unused"), + removeNetwork: () => Effect.void, + createVolume: () => Effect.die("unused"), + removeVolume: () => Effect.void, + createContainer: () => Effect.die("unused"), + copyToContainer: () => Effect.void, + startContainer: () => Effect.void, + waitContainer: () => Effect.succeed(0), + stopContainer: () => Effect.void, + removeContainer: () => Effect.void, +}); + describe("managed stack handles", { timeout: 30_000 }, () => { it("selects the private dispatch marker only for compiled Bun paths", () => { expect( @@ -129,22 +154,13 @@ describe("managed stack handles", { timeout: 30_000 }, () => { expect(supervisorEntrypointFor(import.meta.url)).not.toBe(SUPERVISOR_DISPATCH_SENTINEL); }); - it.live("resolves a new automatic container identity and persists Docker", () => + it.live("persists Docker for an omitted container engine without probing", () => withRuntimeRoot((project) => Effect.gen(function* () { - const calls: string[] = []; - const resolver = { - resolve: (preference: "auto" | "docker" | "podman") => - Effect.sync(() => { - calls.push(preference); - return "docker" as const; - }), - }; - yield* createStack({ - projectRoot: project, - runtime: { kind: "container", engine: "auto" }, - }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); - expect(calls).toEqual(["auto"]); + const resolver = { resolve: () => Effect.die("resolver must not be called") }; + yield* createStack({ projectRoot: project, runtime: { kind: "container" } }).pipe( + Effect.provideService(ContainerEngineResolver, resolver), + ); expect( (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, ).toEqual({ kind: "container", engine: "docker" }); @@ -152,22 +168,14 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); - it.live("selects and persists Podman when the automatic resolver falls back", () => + it.live("persists explicit Podman without probing", () => withRuntimeRoot((project) => Effect.gen(function* () { - const calls: string[] = []; - const resolver = { - resolve: (preference: "auto" | "docker" | "podman") => - Effect.sync(() => { - calls.push(preference); - return "podman" as const; - }), - }; + const resolver = { resolve: () => Effect.die("resolver must not be called") }; yield* createStack({ projectRoot: project, - runtime: { kind: "container", engine: "auto" }, + runtime: { kind: "container", engine: "podman" }, }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); - expect(calls).toEqual(["auto"]); expect( (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, ).toEqual({ kind: "container", engine: "podman" }); @@ -175,91 +183,43 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); - it.live("maps an automatic engine daemon failure and does not fall through in createStack", () => + it.live("prepares through only the explicitly selected Podman engine", () => withRuntimeRoot((project) => Effect.gen(function* () { const calls: string[] = []; + const dockerCalls: string[] = []; const resolver = { - resolve: (preference: "auto" | "docker" | "podman") => - Effect.sync(() => { - calls.push(preference); - }).pipe( - Effect.andThen( - Effect.fail( - new ContainerEngineProtocolError({ - operation: "probe", - message: "Docker daemon unavailable", - }), - ), - ), + resolve: (kind: "docker" | "podman") => + Effect.succeed( + kind === "podman" + ? fakeContainerEngine(kind, calls) + : fakeContainerEngine(kind, dockerCalls), ), }; - const result = yield* createStack({ - projectRoot: project, - runtime: { kind: "container", engine: "auto" }, - }).pipe(Effect.provideService(ContainerEngineResolver, resolver), Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); - expect(error).toBeInstanceOf(ContainerEngineError); - } - expect(calls).toEqual(["auto"]); - }), - ), - ); - - it.live("probes only explicitly requested Podman", () => - withRuntimeRoot((project) => - Effect.gen(function* () { - const calls: string[] = []; - const resolver = { - resolve: (preference: "auto" | "docker" | "podman") => - Effect.sync(() => { - calls.push(preference); - return "podman" as const; - }), - }; - yield* createStack({ + const stack = yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "podman" }, }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); - expect(calls).toEqual(["podman"]); + const prepared = yield* stack.prepare({ capabilities: ["database"] }); + expect(prepared.capabilities).toHaveLength(1); + expect(calls).toEqual([ + "podman:probe", + "podman:inspect:ghcr.io/supabase/cli/postgres:17.6.1.167", + ]); + expect(dockerCalls).toEqual([]); }), ), ); - it.live("reuses a persisted engine for automatic create without probing again", () => + it.live("does not probe a persisted container engine when reopening", () => withRuntimeRoot((project) => Effect.gen(function* () { - const firstCalls: string[] = []; - const firstResolver = { - resolve: (preference: "auto" | "docker" | "podman") => - Effect.sync(() => { - firstCalls.push(preference); - return "podman" as const; - }), - }; - yield* createStack({ - projectRoot: project, - runtime: { kind: "container", engine: "podman" }, - }).pipe(Effect.provideService(ContainerEngineResolver, firstResolver)); - const secondCalls: string[] = []; - const secondResolver = { - resolve: (preference: "auto" | "docker" | "podman") => - Effect.sync(() => { - secondCalls.push(preference); - return "docker" as const; - }), - }; - yield* createStack({ - projectRoot: project, - runtime: { kind: "container", engine: "auto" }, - }).pipe(Effect.provideService(ContainerEngineResolver, secondResolver)); - expect(firstCalls).toEqual(["podman"]); - expect(secondCalls).toEqual([]); - expect( - (yield* findStack({ projectRoot: project })).pipe(Option.getOrUndefined)?.runtime, - ).toEqual({ kind: "container", engine: "podman" }); + yield* createStack({ projectRoot: project, runtime: { kind: "container" } }); + const resolver = { resolve: () => Effect.die("resolver must not be called") }; + const stack = yield* createStack({ projectRoot: project }).pipe( + Effect.provideService(ContainerEngineResolver, resolver), + ); + expect((yield* stack.status()).runtime).toEqual({ kind: "container", engine: "docker" }); }), ), ); @@ -270,22 +230,13 @@ describe("managed stack handles", { timeout: 30_000 }, () => { yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "docker" }, - }).pipe( - Effect.provideService(ContainerEngineResolver, { - resolve: () => Effect.succeed("docker" as const), - }), - ); - const calls: string[] = []; + }); const result = yield* createStack({ projectRoot: project, runtime: { kind: "container", engine: "podman" }, }).pipe( Effect.provideService(ContainerEngineResolver, { - resolve: (preference: "auto" | "docker" | "podman") => - Effect.sync(() => { - calls.push(preference); - return "podman" as const; - }), + resolve: () => Effect.die("resolver must not be called"), }), Effect.exit, ); @@ -294,29 +245,13 @@ describe("managed stack handles", { timeout: 30_000 }, () => { const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); expect(error).toBeInstanceOf(StackRuntimeMismatchError); } - expect(calls).toEqual([]); }), ), ); it.live("does not probe an explicitly native identity", () => withRuntimeRoot((project) => - Effect.gen(function* () { - const calls: string[] = []; - yield* createStack({ - projectRoot: project, - runtime: { kind: "native" }, - }).pipe( - Effect.provideService(ContainerEngineResolver, { - resolve: (preference: "auto" | "docker" | "podman") => - Effect.sync(() => { - calls.push(preference); - return "docker" as const; - }), - }), - ); - expect(calls).toEqual([]); - }), + createStack({ projectRoot: project, runtime: { kind: "native" } }), ), ); @@ -385,7 +320,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { serviceRoleJwt: Redacted.make("service"), }, }), - prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.fail({ tag: "StackPreparationError", message: "artifact is incomplete" }), destroy: () => Effect.void, @@ -486,7 +420,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { status: () => Effect.succeed(status), credentials: () => Effect.fail({ tag: "StackNotRunningError" as const, message: "not running" }), - prepare: () => Effect.succeed({ capabilities: [] }), start: () => Effect.succeed(status), destroy: () => Deferred.succeed(destroyStarted, undefined).pipe( @@ -616,9 +549,15 @@ describe("managed stack handles", { timeout: 30_000 }, () => { bytes.set(value, offset); offset += value.byteLength; } - const stderr = new TextDecoder().decode( - new Uint8Array(stderrChunks.reduce((sum, value) => sum + value.byteLength, 0)), + const stderrBytes = new Uint8Array( + stderrChunks.reduce((sum, value) => sum + value.byteLength, 0), ); + offset = 0; + for (const value of stderrChunks) { + stderrBytes.set(value, offset); + offset += value.byteLength; + } + const stderr = new TextDecoder().decode(stderrBytes); return { id: new TextDecoder().decode(bytes), code, stderr }; }); const [first, second] = yield* Effect.all([spawnCaller(), spawnCaller()], { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 2e7103a04a..3a675082e6 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -19,7 +19,6 @@ import { Headers } from "effect/unstable/http"; import { Rpc } from "effect/unstable/rpc"; import { RequestId } from "effect/unstable/rpc/RpcMessage"; import type { LogQuery, StackLogEntry } from "../public/Logs.ts"; -import type { CapabilityName } from "../public/Capability.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, @@ -74,48 +73,6 @@ const invokeCredentials = ( return value; }); -const invokePrepare = ( - supervisor: Supervisor, - payload: { - readonly config?: import("../public/Config.ts").StackConfig; - readonly capabilities?: ReadonlyArray; - } = {}, -): Effect.Effect< - { - readonly capabilities: ReadonlyArray<{ - readonly capability: CapabilityName; - readonly version: string; - readonly outcome: "cached" | "downloaded" | "pulled"; - }>; - }, - StackRpcError, - Scope.Scope -> => - Effect.gen(function* () { - const handler = yield* StackRpcGroup.accessHandler("prepare").pipe( - Effect.provide(StackRpcGroup.toLayerHandler("prepare", supervisor.rpcHandlers.prepare)), - ); - const value = yield* handler(payload, { - client: new Rpc.ServerClient(1), - requestId: RequestId(1), - headers: Headers.empty, - }); - if ( - Deferred.isDeferred< - { - readonly capabilities: ReadonlyArray<{ - readonly capability: CapabilityName; - readonly version: string; - readonly outcome: "cached" | "downloaded" | "pulled"; - }>; - }, - StackRpcError - >(value) - ) - return yield* Deferred.await(value); - return value; - }); - const makeFixture = ( fixtureOptions: { readonly ingress?: SupervisorIngress; @@ -410,63 +367,6 @@ const run = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); describe("Supervisor composition", () => { - it.live("prepares a prospective config without mutating state or starting resources", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const before = yield* fixture.store - .read(fixture.id) - .pipe(Effect.provideContext(fixture.context)); - const result = yield* invokePrepare(fixture.supervisor, { - config: { - capabilities: { - rest: {}, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - expect(result.capabilities).toEqual([ - { capability: "database", version: expect.any(String), outcome: "cached" }, - { capability: "rest", version: expect.any(String), outcome: "cached" }, - ]); - expect(yield* fixture.store.read(fixture.id)).toEqual(before); - expect(yield* Ref.get(fixture.calls)).toEqual([ - "prepare:database:database", - "prepare:rest:rest", - ]); - }), - ), - ); - - it.live("keeps interrupted preparation owned until it completes, then shuts down", () => - run( - Effect.gen(function* () { - const prepareGate = yield* Deferred.make(); - const prepareStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ prepareGate, prepareStarted }); - const preparing = yield* Effect.forkChild(invokePrepare(fixture.supervisor, {}), { - startImmediately: true, - }); - yield* Deferred.await(prepareStarted); - const shutdown = yield* Effect.forkChild(fixture.supervisor.shutdown, { - startImmediately: true, - }); - yield* Fiber.interrupt(preparing); - yield* fixture.supervisor.shutdownIfIdle; - expect(shutdown.pollUnsafe()).toBeUndefined(); - yield* Deferred.succeed(prepareGate, undefined); - yield* Fiber.join(shutdown); - }), - ), - ); - it.live("rejects new work after owner shutdown begins", () => run( Effect.gen(function* () { @@ -474,131 +374,7 @@ describe("Supervisor composition", () => { yield* fixture.supervisor.shutdownIfIdle; const start = yield* fixture.supervisor.start().pipe(Effect.exit); - const prepare = yield* invokePrepare(fixture.supervisor).pipe(Effect.exit); - expect(errorOf(start)).toBeInstanceOf(StackLifecycleConflictError); - expect(errorOf(prepare)?.tag).toBe("StackLifecycleConflictError"); - }), - ), - ); - - it.live("prepares persisted pins and includes dependency closure in start order", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ - config: { - capabilities: { - rest: {}, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - yield* Ref.set(fixture.calls, []); - const result = yield* invokePrepare(fixture.supervisor, { capabilities: ["rest", "rest"] }); - expect(result.capabilities.map(({ capability }) => capability)).toEqual([ - "database", - "rest", - ]); - expect(yield* Ref.get(fixture.calls)).toEqual([ - "prepare:database:database", - "prepare:rest:rest", - ]); - }), - ), - ); - - it.live("reports a native capability as downloaded when any workload was downloaded", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture({ prepareOutcome: "downloaded" }); - const result = yield* invokePrepare(fixture.supervisor, { - config: { - capabilities: { - rest: {}, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - }); - expect(result.capabilities).toEqual([ - { capability: "database", version: expect.any(String), outcome: "downloaded" }, - { capability: "rest", version: expect.any(String), outcome: "downloaded" }, - ]); - }), - ), - ); - - it.live("reports cached and pulled container preparation outcomes", () => - run( - Effect.gen(function* () { - const config = { - capabilities: { - rest: {}, - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }; - const present = yield* makeFixture({ - runtime: { kind: "container", engine: "docker" }, - prepareOutcome: "cached", - }); - const presentResult = yield* invokePrepare(present.supervisor, { config }); - expect(presentResult.capabilities.every(({ outcome }) => outcome === "cached")).toBe(true); - - const pulled = yield* makeFixture({ - runtime: { kind: "container", engine: "docker" }, - prepareOutcome: "pulled", - }); - const pulledResult = yield* invokePrepare(pulled.supervisor, { config }); - expect(pulledResult.capabilities).toEqual([ - { capability: "database", version: expect.any(String), outcome: "pulled" }, - { capability: "rest", version: expect.any(String), outcome: "pulled" }, - ]); - }), - ), - ); - - it.live("rejects a request for a disabled capability before preparing anything", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const failed = yield* invokePrepare(fixture.supervisor, { - config: { - capabilities: { - auth: { enabled: false }, - realtime: { enabled: false }, - storage: { enabled: false }, - functions: { enabled: false }, - studio: { enabled: false }, - mail: { enabled: false }, - analytics: { enabled: false }, - pooler: { enabled: false }, - }, - }, - capabilities: ["auth"], - }).pipe(Effect.exit); - expect(errorOf(failed)?.tag).toBe("StackPreparationError"); - expect(yield* Ref.get(fixture.calls)).toEqual([]); }), ), ); From a3fc2b8cc26092b8ec9c9304af3baf485a8e182e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 04:37:19 +0200 Subject: [PATCH 116/174] test(stack): harden preparation boundaries --- packages/stack/src/public/EffectStack.ts | 38 ++++-- .../public/effect-stack.integration.test.ts | 127 ++++++++++++++++++ .../src/public/promise.integration.test.ts | 30 ++++- .../supervisor/handles.integration.test.ts | 9 +- 4 files changed, 190 insertions(+), 14 deletions(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 11be9caf73..5240ecf2c4 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -219,6 +219,9 @@ const environment = () => Effect.map(Option.getOrElse(defaultRuntimeEnvironment)), ); +const isCapabilityName = (value: unknown): value is CapabilityName => + typeof value === "string" && CAPABILITY_NAMES.some((name) => name === value); + type ControlError = | StackRpcError | RpcClientError @@ -821,11 +824,19 @@ const handleDependencies = (options: { Effect.provideService(Crypto.Crypto, options.crypto), ); } else { - const compiled = yield* compileStack({ - projectRoot: state.identity.projectRoot, - runtime: state.runtime, - config: prepareOptions?.config, - }).pipe( + const compiled = yield* compileStack( + { + projectRoot: state.identity.projectRoot, + runtime: state.runtime, + config: prepareOptions?.config, + }, + state.definition === undefined || state.inputFingerprint === undefined + ? undefined + : { + definition: state.definition, + inputFingerprint: state.inputFingerprint, + }, + ).pipe( Effect.provideService(Path.Path, options.path), Effect.provideService(Crypto.Crypto, options.crypto), ); @@ -851,7 +862,15 @@ const handleDependencies = (options: { for (const name of CAPABILITY_NAMES) if (definition.capabilities[name].enabled) selected.add(name); } else { - for (const name of new Set(prepareOptions.capabilities)) yield* visit(name); + for (const name of prepareOptions.capabilities) { + if (!isCapabilityName(name)) + return yield* new StackPreparationError({ + stackId: options.id, + capability: String(name), + message: `Unknown capability ${String(name)}`, + }); + yield* visit(name); + } } const workloads = plan.workloads.filter((workload) => selected.has(workload.capability)); const containerEngine = @@ -1001,11 +1020,10 @@ export const createStack = ( ); const runtimeMismatch = options.runtime !== undefined && - (current.runtime.kind !== options.runtime.kind || - (options.runtime.kind === "container" && + (current.runtime.kind !== requestedRuntime.kind || + (requestedRuntime.kind === "container" && current.runtime.kind === "container" && - options.runtime.engine !== undefined && - current.runtime.engine !== options.runtime.engine)); + current.runtime.engine !== requestedRuntime.engine)); if (runtimeMismatch) return yield* new StackRuntimeMismatchError({ message: "Stack runtime is immutable for an existing identity", diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 4d840be018..c2b0d4d8f6 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -41,6 +41,8 @@ import { compileStack } from "../model/Compiler.ts"; import { StackDestructionError, StackOwnershipConflictError, + StackPreparationError, + StackRuntimeMismatchError, StackStateInvalidError, StackUpgradeRequiredError, } from "./Errors.ts"; @@ -51,6 +53,7 @@ import { makeHandle, openStack, type EffectStack, + type PrepareStackResult, } from "./EffectStack.ts"; import * as effectApi from "../effect.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; @@ -492,6 +495,130 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("treats an omitted container engine as Docker for runtime identity", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "podman" }, + }); + const result = yield* createStack({ + projectRoot: project, + runtime: { kind: "container" }, + }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) + expect(failure.value).toBeInstanceOf(StackRuntimeMismatchError); + } + }), + ), + ); + + it.live("rejects unknown capabilities as a typed preparation error", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot: project }); + const malformedOptions = { capabilities: ["not-a-capability"] }; + const malformedPrepare = (): Effect.Effect => + Reflect.apply(stack.prepare, stack, [malformedOptions]); + const result = yield* malformedPrepare().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) expect(failure.value).toBeInstanceOf(StackPreparationError); + } + }), + ), + ); + + it.live("rejects a disabled capability before preparing artifacts", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot: project }); + const result = yield* stack + .prepare({ + config: { capabilities: { pooler: { enabled: false } } }, + capabilities: ["pooler"], + }) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) expect(failure.value).toBeInstanceOf(StackPreparationError); + } + }), + ), + ); + + it.live("uses persisted pins and dependency closure for prospective preparation", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const env = yield* StackRuntimeEnvironment; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const calls: Array = []; + const runner: ContainerCommandRunner = { + executable: "controlled-docker", + run: (request) => { + calls.push(request.args.slice(0, 3).join(" ")); + if (request.args[0] === "version") + return Effect.succeed({ + stdout: '"1"\n', + stderr: "", + exitCode: 0, + }); + if (request.args[0] === "image" && request.args[1] === "ls") + return Effect.succeed({ + stdout: '"cached"\n', + stderr: "", + exitCode: 0, + }); + return Effect.succeed({ stdout: "", stderr: "", exitCode: 0 }); + }, + }; + const engine = makeDockerEngine({ runner, platform: { os: "linux" } }); + const stack = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "docker" }, + }).pipe( + Effect.provideService(ContainerEngineResolver, { + resolve: () => Effect.succeed(engine), + }), + ); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const state = yield* store.read(stack.id); + if (state === undefined) return yield* Effect.die("stack state was not initialized"); + const persisted = yield* compileStack({ + projectRoot: project, + runtime: { kind: "container", engine: "docker" }, + config: { capabilities: { database: { version: "17" } } }, + }).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + ); + yield* store.replace(stack.id, { + ...state, + definition: persisted.definition, + inputFingerprint: persisted.inputFingerprint, + }); + const prepared = yield* stack.prepare({ + config: { capabilities: { rest: { settings: { schemas: ["private"] } } } }, + capabilities: ["rest"], + }); + expect(prepared.capabilities).toEqual([ + { capability: "database", version: "17.6.1.167", outcome: "cached" }, + { capability: "rest", version: "v16.2", outcome: "cached" }, + ]); + expect(calls.filter((call) => call.startsWith("image ls"))).toHaveLength(2); + }), + ), + ); + it.live("cancels unfinished direct preparation while retaining completed artifacts", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index b8bce1e287..25056a10f5 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -1,6 +1,12 @@ // oxlint-disable effecttsgo/async-function -- integration test exercises the Promise facade directly. import { describe, expect, it } from "@effect/vitest"; import { NodeServices } from "@effect/platform-node"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- isolated Promise facade state root. +import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- isolated Promise facade state root. +import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- isolated Promise facade state root. +import { join } from "node:path"; import { CAPABILITY_NAMES, type CapabilityStatus } from "./Capability.ts"; import { Effect, Redacted, Stream } from "effect"; import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./EffectStack.ts"; @@ -8,7 +14,7 @@ import type { StackLogEntry } from "./Logs.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; import { InvalidStackConfigError } from "./Errors.ts"; -import { adaptEffectStack, type PromiseStack } from "./PromiseStack.ts"; +import { adaptEffectStack, makePromiseApi, type PromiseStack } from "./PromiseStack.ts"; import { compileStack } from "../model/Compiler.ts"; const stackId = StackIdSchema.make( @@ -67,6 +73,28 @@ const effectStack = (): EffectStack => }) satisfies EffectStack; describe("Promise stack facade", () => { + it("prepares a real stack without publishing owner metadata", async () => { + const root = await mkdtemp(join(tmpdir(), "supabase-promise-prepare-")); + try { + const project = join(root, "project"); + const stateRoot = join(root, "managed", "stacks"); + await mkdir(project); + const api = makePromiseApi(NodeServices.layer, { + stateRoot, + tempRoot: tmpdir(), + platform: "posix", + }); + const stack = await api.createStack({ projectRoot: project, runtime: { kind: "native" } }); + const statePath = join(stateRoot, stack.id, "state.json"); + const before = await readFile(statePath, "utf8"); + await expect(stack.prepare({ capabilities: [] })).resolves.toEqual({ capabilities: [] }); + expect(await readFile(statePath, "utf8")).toBe(before); + expect(await readdir(join(stateRoot, stack.id))).not.toContain("control.json"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("returns log batches and follows from their cursor", async () => { const stack = adaptEffectStack(effectStack()); const first = await stack.logs({ capabilities: ["auth"], tail: 20 }); diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index ad210460d1..f41f40cc52 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -214,12 +214,15 @@ describe("managed stack handles", { timeout: 30_000 }, () => { it.live("does not probe a persisted container engine when reopening", () => withRuntimeRoot((project) => Effect.gen(function* () { - yield* createStack({ projectRoot: project, runtime: { kind: "container" } }); + const created = yield* createStack({ + projectRoot: project, + runtime: { kind: "container", engine: "podman" }, + }); const resolver = { resolve: () => Effect.die("resolver must not be called") }; - const stack = yield* createStack({ projectRoot: project }).pipe( + const stack = yield* openStack(created.id).pipe( Effect.provideService(ContainerEngineResolver, resolver), ); - expect((yield* stack.status()).runtime).toEqual({ kind: "container", engine: "docker" }); + expect((yield* stack.status()).runtime).toEqual({ kind: "container", engine: "podman" }); }), ), ); From 01462b52de9c0bde663353615faff3dcd80032eb Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 04:52:50 +0200 Subject: [PATCH 117/174] refactor(stack): compare materialized definitions --- packages/stack/src/model/Compiler.ts | 89 ++----------------- .../src/model/compiler.integration.test.ts | 44 ++++----- packages/stack/src/public/EffectStack.ts | 7 +- .../public/effect-stack.integration.test.ts | 2 - .../stack/src/runtime/RuntimeInputOwner.ts | 3 +- ...base-bootstrap-catalog.integration.test.ts | 1 - .../production-runtime.integration.test.ts | 19 ---- .../runtime-input-owner.integration.test.ts | 1 - packages/stack/src/state/StackState.ts | 11 +-- .../src/state/state-store.integration.test.ts | 1 - packages/stack/src/supervisor/Ingress.ts | 8 +- packages/stack/src/supervisor/Lifecycle.ts | 32 ++----- packages/stack/src/supervisor/Supervisor.ts | 2 - .../supervisor/ingress.integration.test.ts | 9 -- .../supervisor/lifecycle.integration.test.ts | 16 +++- 15 files changed, 59 insertions(+), 186 deletions(-) diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index f543062cc7..9520a02e2e 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -40,9 +40,6 @@ import type { } from "./CapabilityModule.ts"; import type { SecretGenerator, SecretJwtSigning } from "../state/SecretStore.ts"; -type InputFingerprint = Schema.Schema.Type; -const InputFingerprintSchema = Schema.String.pipe(Schema.brand("InputFingerprint")); - interface SecretSlot { readonly slot: string; } @@ -102,14 +99,12 @@ const INTERNAL_MANAGED_SECRET_SLOTS = ["secret:database.internal.password"] as c export interface CompiledStack { readonly definition: StackDefinition; - readonly inputFingerprint: InputFingerprint; readonly secrets: ReadonlyArray; readonly executionPlan: ExecutionPlan; } export interface PreviousCompilation { readonly definition: StackDefinition; - readonly inputFingerprint: string; } export interface CompileStackInput { @@ -718,39 +713,6 @@ export const rebuildExecutionPlan = ( return yield* planForDefinition(runtime, definition, crypto); }); -const collectSuppliedSecrets = ( - config: StackConfig, - slots: SecretSlotInput[], - jwtSecret: Redacted.Redacted | undefined, -): void => { - const capabilities = config.capabilities; - for (const name of CAPABILITY_NAMES) { - const module = CAPABILITY_MODULES[name]; - const capability = capabilities?.[name]; - if (capability === undefined) { - if (module.defaultEnabled) { - for (const path of module.managedSecretSlots) - slots.push({ slot: `secret:${path}`, policy: "managed" }); - } - continue; - } - slotsFor( - "settings" in capability ? capability.settings : undefined, - `${name}.settings`, - slots, - module.secretPolicy, - ); - if ("enabled" in capability && capability.enabled === false) continue; - for (const path of module.managedSecretSlots) { - if (!slots.some((entry) => entry.slot === `secret:${path}`)) - slots.push({ slot: `secret:${path}`, policy: "managed" }); - } - } - const signing = config.security?.jwt?.signing; - if (signing?.kind === "jwks-file") slotsFor(signing, "security.jwt.signing", slots); - ensureCanonicalJwtSlot(slots, jwtSecret); -}; - export const compileStack = ( input: CompileStackInput, previous?: PreviousCompilation, @@ -769,45 +731,6 @@ export const compileStack = ( yield* validateStorageFileSizes(config); const jwtSecret = yield* canonicalJwtSecret(config); const rawCapabilities = isRecord(config.capabilities) ? config.capabilities : {}; - const normalizedFunctions = yield* materializeFunctionsRoot( - extract(extract(rawCapabilities, "functions"), "settings"), - input.projectRoot, - path, - ); - const rawForFingerprint = { - projectRoot: path.resolve(input.projectRoot), - runtime: input.runtime, - config: { - ...config, - capabilities: { - ...rawCapabilities, - functions: isRecord(rawCapabilities.functions) - ? { ...rawCapabilities.functions, settings: normalizedFunctions } - : rawCapabilities.functions, - }, - }, - }; - const fingerprintBytes = yield* crypto.digest( - "SHA-256", - new TextEncoder().encode(canonical(rawForFingerprint)), - ); - const inputFingerprint = yield* Schema.decodeEffect(InputFingerprintSchema)( - digestHex(fingerprintBytes), - ).pipe(Effect.mapError((error) => new InvalidStackConfigError({ message: String(error) }))); - if (previous?.inputFingerprint === inputFingerprint) { - const slots: SecretSlotInput[] = []; - for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); - collectSuppliedSecrets(config, slots, jwtSecret); - attachAuthSecretGenerators(slots, input.projectRoot, config.security?.jwt?.signing); - attachManagedRandomSecretGenerators(slots); - const executionPlan = yield* planForDefinition(input.runtime, previous.definition, crypto); - return { - definition: previous.definition, - inputFingerprint, - secrets: slots, - executionPlan, - }; - } const slots: SecretSlotInput[] = []; for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); const specHashes = new Map(); @@ -1021,11 +944,11 @@ export const compileStack = ( }, CAPABILITY_MODULES, ); - return { definition, inputFingerprint, secrets: slots, executionPlan }; - }).pipe( - Effect.catchTag("PlatformError", (error) => - Effect.fail(new InvalidStackConfigError({ message: error.message })), - ), - ); + return { definition, secrets: slots, executionPlan }; + }); export const canonicalize = canonical; + +/** Compares two complete materialized definitions by their canonical schema representation. */ +export const sameDefinition = (left: StackDefinition, right: StackDefinition): boolean => + canonical(left) === canonical(right); diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 907c0eeecc..c57d5dcd0c 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -2,7 +2,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; -import { canonicalize, compileStack } from "./Compiler.ts"; +import { canonicalize, compileStack, rebuildExecutionPlan, sameDefinition } from "./Compiler.ts"; import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { DEFAULT_DATABASE_HEALTH_TIMEOUT } from "./capabilities/database.ts"; import { parseFileSize } from "./capabilities/storage.ts"; @@ -154,7 +154,6 @@ describe("closed capability compiler", () => { site_url: "https://example.test", }); expect(canonicalize(result.definition)).not.toContain("secret-value"); - expect(canonicalize(result.inputFingerprint)).not.toContain("secret-value"); expect(canonicalize(result.executionPlan)).not.toContain("secret-value"); const supplied = result.secrets.find( (entry) => entry.slot === "secret:auth.settings.secret_key", @@ -527,7 +526,6 @@ describe("closed capability compiler", () => { { kind: "native" }, { definition: first.definition, - inputFingerprint: first.inputFingerprint, }, ); expect( @@ -554,7 +552,7 @@ describe("closed capability compiler", () => { }), ); - it.live("keeps record key insertion order out of fingerprints", () => + it.live("keeps record key insertion order out of materialized definitions", () => Effect.gen(function* () { const one = yield* compile({ capabilities: { @@ -566,19 +564,19 @@ describe("closed capability compiler", () => { storage: { settings: { buckets: { b: { public: false }, a: { public: true } } } }, }, }); - expect(one.inputFingerprint).toBe(two.inputFingerprint); + expect(sameDefinition(one.definition, two.definition)).toBe(true); }), ); - it.live("preserves omitted versus explicit capability selection", () => + it.live("materializes omitted and explicit defaults identically", () => Effect.gen(function* () { const omitted = yield* compile({}); const explicit = yield* compile({ capabilities: { rest: { enabled: true } } }); - expect(omitted.inputFingerprint).not.toBe(explicit.inputFingerprint); + expect(sameDefinition(omitted.definition, explicit.definition)).toBe(true); }), ); - it.live("reuses the exact previous definition for an identical fingerprint", () => + it.live("accepts an equivalent materialized definition from a previous compilation", () => Effect.gen(function* () { const first = yield* compile({ capabilities: { rest: { settings: { schemas: ["private"] } } }, @@ -591,8 +589,8 @@ describe("closed capability compiler", () => { { kind: "native" }, first, ); - expect(first.inputFingerprint).toBe(second.inputFingerprint); - expect(reused.definition).toBe(first.definition); + expect(sameDefinition(first.definition, second.definition)).toBe(true); + expect(sameDefinition(reused.definition, first.definition)).toBe(true); }), ); @@ -691,7 +689,7 @@ describe("closed capability compiler", () => { }), ); - it.live("rebuilds a plan from an identical persisted definition", () => + it.live("compiles supplied configuration against persisted release pins", () => Effect.gen(function* () { const first = yield* compile({ capabilities: { rest: { settings: { schemas: ["current"] } } }, @@ -706,17 +704,15 @@ describe("closed capability compiler", () => { }, }, }; - const reused = yield* compile( + const compiled = yield* compile( { capabilities: { rest: { settings: { schemas: ["current"] } } } }, { kind: "native" }, - { definition: persisted, inputFingerprint: first.inputFingerprint }, + { definition: persisted }, + ); + expect(compiled.definition.capabilities.rest.settings.schemas).toEqual(["current"]); + expect(compiled.definition.capabilities.rest.version).toBe( + persisted.capabilities.rest.version, ); - expect(reused.definition).toBe(persisted); - const previousHash = first.executionPlan.workloads.find( - (w) => w.id === "rest:rest", - )?.specHash; - const freshHash = reused.executionPlan.workloads.find((w) => w.id === "rest:rest")?.specHash; - expect(freshHash).not.toBe(previousHash); }), ); @@ -735,7 +731,6 @@ describe("closed capability compiler", () => { { kind: "native" }, { definition: persisted, - inputFingerprint: first.inputFingerprint, }, ).pipe(Effect.exit); expect(failureOf(result)).toBeInstanceOf(StackVersionUnsupportedError); @@ -752,11 +747,10 @@ describe("closed capability compiler", () => { rest: { ...first.definition.capabilities.rest, enabled: false }, }, }; - const result = yield* compile( - {}, - { kind: "native" }, - { definition: persisted, inputFingerprint: first.inputFingerprint }, - ).pipe(Effect.exit); + const result = yield* rebuildExecutionPlan({ kind: "native" }, persisted).pipe( + Effect.provide(layer), + Effect.exit, + ); expect(failureOf(result)).toBeInstanceOf(InvalidStackConfigError); }), ); diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 5240ecf2c4..803cd18ee2 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -830,12 +830,7 @@ const handleDependencies = (options: { runtime: state.runtime, config: prepareOptions?.config, }, - state.definition === undefined || state.inputFingerprint === undefined - ? undefined - : { - definition: state.definition, - inputFingerprint: state.inputFingerprint, - }, + state.definition === undefined ? undefined : { definition: state.definition }, ).pipe( Effect.provideService(Path.Path, options.path), Effect.provideService(Crypto.Crypto, options.crypto), diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index c2b0d4d8f6..8db1b95377 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -604,7 +604,6 @@ describe("Effect stack lifecycle handoff", () => { yield* store.replace(stack.id, { ...state, definition: persisted.definition, - inputFingerprint: persisted.inputFingerprint, }); const prepared = yield* stack.prepare({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } }, @@ -1021,7 +1020,6 @@ describe("Effect stack lifecycle handoff", () => { ); return { definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: resolved.persisted, }; }) diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 2fb08b40c6..d90d06e3f3 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -23,6 +23,7 @@ import { } from "../state/SecretStore.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; import { containerAliasFor } from "../model/WorkloadCatalog.ts"; +import { canonicalize } from "../model/Compiler.ts"; /** A parsed JSON document fetched by the owner for OIDC discovery. */ export type RuntimeJsonFetcher = (url: string) => Effect.Effect; @@ -610,7 +611,7 @@ export const makeRuntimeInputOwner = ( const poolerPending = new Map>(); const poolerCompleted = new Map(); const keyFor = (state: PersistedStackState): string => - `${state.identity.projectRoot}\u0000${state.inputFingerprint ?? ""}\u0000${state.runtime.kind}`; + `${options.stackId}\u0000${canonicalize(state.runtime)}\u0000${canonicalize(state.definition ?? {})}`; const materializeCommon = ( state: PersistedStackState, diff --git a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts index d70369b63c..0a885e3ba2 100644 --- a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts +++ b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts @@ -23,7 +23,6 @@ const stateFrom = (definition: PersistedStackState["definition"]): PersistedStac runtime: { kind: "native" }, desiredLifecycle: "stopped", definition, - inputFingerprint: "b".repeat(64), ports: [], privatePorts: [{ workloadId: "database:database", binding: "primary", port: 54_321 }], secrets: { diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 09600d20d1..65abcf4311 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -355,7 +355,6 @@ describe("production runtime composition", () => { }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, privatePorts: [ { workloadId: "database:database", binding: "primary", port: address.port }, ], @@ -453,7 +452,6 @@ describe("production runtime composition", () => { }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, privatePorts: [ { workloadId: "database:database", binding: "primary", port: address.port }, ], @@ -546,7 +544,6 @@ describe("production runtime composition", () => { }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, privatePorts: [ { workloadId: "storage:imgproxy", binding: "primary", port: 41_001 }, { workloadId: "storage:storage", binding: "primary", port: 41_002 }, @@ -571,7 +568,6 @@ describe("production runtime composition", () => { desiredLifecycle: "running" as const, state: current.value, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: current.value.secrets, plan: compiled.executionPlan, } satisfies LifecycleInput; @@ -633,7 +629,6 @@ describe("production runtime composition", () => { }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, privatePorts: [ { workloadId: "realtime:realtime", binding: "primary", port: address.port }, ], @@ -713,7 +708,6 @@ describe("production runtime composition", () => { }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, privatePorts: [ { workloadId: "database:database", binding: "primary", port: address.port }, ], @@ -822,7 +816,6 @@ describe("production runtime composition", () => { }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, privatePorts: [ { workloadId: "database:database", binding: "primary", port: address.port }, ], @@ -957,12 +950,10 @@ describe("production runtime composition", () => { const configuredState = { ...stateFor({}), definition: configured.definition, - inputFingerprint: configured.inputFingerprint, }; const defaultState = { ...stateFor({}), definition: defaulted.definition, - inputFingerprint: defaulted.inputFingerprint, }; expect( Duration.toMillis(yield* readinessDeadlineFor(configuredState, configuredDatabase)), @@ -1116,7 +1107,6 @@ describe("production runtime composition", () => { checkoutId: root, }, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets, }, } satisfies { value: PersistedStackState }; @@ -1156,7 +1146,6 @@ describe("production runtime composition", () => { desiredLifecycle: "stopped", state: current.value, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets, plan: compiled.executionPlan, }, @@ -1201,7 +1190,6 @@ describe("production runtime composition", () => { checkoutId: root, }, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, ports: [{ field: "database", port, intent: "exact" }], secrets, }, @@ -1236,7 +1224,6 @@ describe("production runtime composition", () => { desiredLifecycle: "running", state: current.value, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets, plan: compiled.executionPlan, }, @@ -1279,7 +1266,6 @@ describe("production runtime composition", () => { checkoutId: root, }, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets, }, } satisfies { value: PersistedStackState }; @@ -1319,7 +1305,6 @@ describe("production runtime composition", () => { desiredLifecycle: "running", state: current.value, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets, plan: compiled.executionPlan, }, @@ -1364,7 +1349,6 @@ describe("production runtime composition", () => { }), desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, } satisfies PersistedStackState; const context = yield* Effect.context(); const factory = yield* makeProductionRuntimeFactory({ @@ -1395,7 +1379,6 @@ describe("production runtime composition", () => { desiredLifecycle: "running", state: candidate, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: candidate.secrets, plan: compiled.executionPlan, }, @@ -1536,7 +1519,6 @@ describe("production runtime composition", () => { }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, ports: [{ field: "api" as const, port: 40_000, intent: "exact" as const }], privatePorts: [ { @@ -1801,7 +1783,6 @@ describe("production runtime composition", () => { }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, privatePorts: [ { workloadId: "rest:rest", binding: "primary", port: address.port }, { workloadId: "rest:rest", binding: "admin", port: address.port + 1 }, diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 2290364355..90164c9381 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -49,7 +49,6 @@ const stateFor = ( runtime, desiredLifecycle: "stopped", definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, ports: [], privatePorts: [], secrets, diff --git a/packages/stack/src/state/StackState.ts b/packages/stack/src/state/StackState.ts index d21073daed..a43c1de72a 100644 --- a/packages/stack/src/state/StackState.ts +++ b/packages/stack/src/state/StackState.ts @@ -309,22 +309,19 @@ const stateShape = Schema.Struct({ runtime: StackRuntimeSchema, desiredLifecycle: DesiredStackLifecycleSchema, definition: Schema.optional(StackDefinitionSchema), - inputFingerprint: Schema.optional(Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/))), ports: PortAssignmentsSchema, privatePorts: PrivatePortAssignmentsSchema, secrets: PersistedSecretValuesSchema, }); -/** Complete durable state. Definition and fingerprint are intentionally an all-or-none pair. */ +/** Complete durable state. An optional definition is present once the identity is configured. */ export const PersistedStackStateSchema = stateShape.pipe( Schema.decode({ decode: SchemaGetter.checkEffect((state) => Effect.succeed( - (state.definition === undefined) !== (state.inputFingerprint === undefined) - ? "definition and inputFingerprint must be persisted together" - : state.ports.some(({ port }) => state.privatePorts.some((entry) => entry.port === port)) - ? "Public and private persisted ports must not overlap" - : undefined, + state.ports.some(({ port }) => state.privatePorts.some((entry) => entry.port === port)) + ? "Public and private persisted ports must not overlap" + : undefined, ), ), encode: SchemaGetter.passthrough(), diff --git a/packages/stack/src/state/state-store.integration.test.ts b/packages/stack/src/state/state-store.integration.test.ts index 2edf283de8..a82250b74d 100644 --- a/packages/stack/src/state/state-store.integration.test.ts +++ b/packages/stack/src/state/state-store.integration.test.ts @@ -69,7 +69,6 @@ const state = (stackId: string, definition?: StackDefinition): PersistedStackSta runtime: { kind: "native" }, desiredLifecycle: "stopped", definition, - inputFingerprint: definition === undefined ? undefined : "d".repeat(64), ports: [], privatePorts: [], secrets: {}, diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index d147b78b88..eb7e3e3a4f 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -180,8 +180,10 @@ export const makeSupervisorIngress = ( lock.withPermit( Effect.gen(function* () { const existing = yield* Ref.get(current); - if (existing !== undefined && existing.input.inputFingerprint === input.inputFingerprint) - return { ...existing.reservation, fresh: false }; + // A Supervisor owns one ingress reservation for its running session. Definition + // changes are rejected while running and a stopped session closes this reservation, + // so a live reservation can always be reused without a configuration fingerprint. + if (existing !== undefined) return { ...existing.reservation, fresh: false }; if (existing !== undefined) yield* closeCurrent(existing); const reservationScope = Scope.forkUnsafe(ownerScope); const reservation = yield* coordinator @@ -230,7 +232,7 @@ export const makeSupervisorIngress = ( lock.withPermit( Effect.gen(function* () { const entry = yield* Ref.get(current); - if (entry === undefined || entry.input.inputFingerprint !== input.inputFingerprint) + if (entry === undefined || entry.reservation !== reservation) return yield* new GatewayActivationError({ message: "Gateway reservation is no longer current", }); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 721ecd1533..a0cbb8a63b 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -1,6 +1,6 @@ import { Crypto, Effect, Exit, FileSystem, Path, Redacted } from "effect"; import type { StackDefinition, CompiledStack, SecretSlotInput } from "../model/Compiler.ts"; -import { compileStack, rebuildExecutionPlan } from "../model/Compiler.ts"; +import { compileStack, rebuildExecutionPlan, sameDefinition } from "../model/Compiler.ts"; import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; import type { StackConfig } from "../public/Config.ts"; import type { StackRuntime } from "../public/Runtime.ts"; @@ -29,7 +29,6 @@ export interface LifecycleInput { readonly desiredLifecycle: "running" | "stopped" | "destroying"; readonly state: PersistedStackState; readonly definition: StackDefinition; - readonly inputFingerprint: string; readonly secrets: PersistedSecretValues; readonly plan: ExecutionPlan; } @@ -79,7 +78,6 @@ export interface LifecycleControllerOptions { interface Candidate { readonly definition: StackDefinition; - readonly inputFingerprint: string; readonly secrets: PersistedSecretValues; readonly plan: ExecutionPlan; } @@ -135,11 +133,7 @@ const materializeCandidate = ( secretLifecycle: PersistedStackState["desiredLifecycle"] = state.desiredLifecycle, ): Effect.Effect => Effect.gen(function* () { - if ( - config === undefined && - state.definition !== undefined && - state.inputFingerprint !== undefined - ) { + if (config === undefined && state.definition !== undefined) { const plan = yield* rebuildExecutionPlan(runtime, state.definition); const resolved = yield* resolveSecrets( declarationsFromPersisted(state.secrets), @@ -148,7 +142,6 @@ const materializeCandidate = ( ); return { definition: state.definition, - inputFingerprint: state.inputFingerprint, secrets: resolved.persisted, plan, }; @@ -159,12 +152,7 @@ const materializeCandidate = ( runtime, config, }, - state.definition === undefined || state.inputFingerprint === undefined - ? undefined - : { - definition: state.definition, - inputFingerprint: state.inputFingerprint, - }, + state.definition === undefined ? undefined : { definition: state.definition }, ); const resolved = yield* resolveSecrets( declarationsFromCompiled(compiled), @@ -173,7 +161,6 @@ const materializeCandidate = ( ); return { definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: resolved.persisted, plan: compiled.executionPlan, }; @@ -186,12 +173,9 @@ const persistedCandidate = ( ): Effect.Effect => Effect.gen(function* () { const definition = state.definition; - const inputFingerprint = state.inputFingerprint; - if (definition === undefined || inputFingerprint === undefined) - return yield* invalidDefinition(stackId); + if (definition === undefined) return yield* invalidDefinition(stackId); return { definition, - inputFingerprint, secrets: state.secrets, plan: yield* rebuildExecutionPlan(runtime, definition), }; @@ -209,7 +193,6 @@ const lifecycleInput = ( desiredLifecycle, state, definition: candidate.definition, - inputFingerprint: candidate.inputFingerprint, secrets: candidate.secrets, plan: candidate.plan, }); @@ -222,7 +205,6 @@ const stateWithCandidate = ( ...state, desiredLifecycle, definition: candidate.definition, - inputFingerprint: candidate.inputFingerprint, secrets: candidate.secrets, ports: state.ports, }); @@ -254,7 +236,11 @@ export const makeLifecycleController = ( const candidate = yield* materializeCandidate(initial, initial.runtime, supplied); if (initial.desiredLifecycle === "running") { - if (supplied !== undefined && candidate.inputFingerprint !== initial.inputFingerprint) + if ( + supplied !== undefined && + (initial.definition === undefined || + !sameDefinition(candidate.definition, initial.definition)) + ) return yield* new StackMustBeStoppedError({ stackId: options.stackId, message: "Running stack input changed; stop the stack before applying it", diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 884aeac42e..96e867aef1 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -413,7 +413,6 @@ export const makeSupervisor = ( desiredLifecycle: "running", state, definition, - inputFingerprint: state.inputFingerprint ?? "", secrets: state.secrets, plan, }; @@ -456,7 +455,6 @@ export const makeSupervisor = ( desiredLifecycle: "running", state, definition: state.definition, - inputFingerprint: state.inputFingerprint ?? "", secrets: state.secrets, plan, }; diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 99e5f855b5..a9475319bd 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -120,7 +120,6 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" }, desiredLifecycle: "running", definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, ports: [{ field: "api", port: 55432, intent: "automatic" }], privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, @@ -147,7 +146,6 @@ describe("Supervisor ingress", () => { desiredLifecycle: "running" as const, state: yield* store.read(stackId).pipe(Effect.map((value) => value!)), definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: {}, plan: compiled.executionPlan, }; @@ -215,7 +213,6 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" }, desiredLifecycle: "running", definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, ports: [ { field: "api", port: 55433, intent: "automatic" }, { field: "database", port: 55436, intent: "automatic" }, @@ -235,7 +232,6 @@ describe("Supervisor ingress", () => { desiredLifecycle: "running", state, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: {}, plan: compiled.executionPlan, }); @@ -246,7 +242,6 @@ describe("Supervisor ingress", () => { desiredLifecycle: "running", state, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: {}, plan: compiled.executionPlan, }, @@ -312,7 +307,6 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" as const }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, ports: [{ field: "database", port: 55434, intent: "automatic" }] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, @@ -335,7 +329,6 @@ describe("Supervisor ingress", () => { desiredLifecycle: "running" as const, state: persisted, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: {}, plan: compiled.executionPlan, }; @@ -420,7 +413,6 @@ describe("Supervisor ingress", () => { runtime: { kind: "native" as const }, desiredLifecycle: "running" as const, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, ports: [{ field: "api", port: 55435, intent: "automatic" }] as const, privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ ...binding, @@ -467,7 +459,6 @@ describe("Supervisor ingress", () => { desiredLifecycle: "running" as const, state: persisted, definition: compiled.definition, - inputFingerprint: compiled.inputFingerprint, secrets: {}, plan: compiled.executionPlan, }; diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index 5696782b29..203a31ba26 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -120,7 +120,6 @@ describe("durable lifecycle controller", () => { const result = yield* fixture.controller.start(); expect(result.desiredLifecycle).toBe("running"); expect(result.definition).toBeDefined(); - expect(result.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); expect(fixture.state.calls).toEqual(["preflight", "reconcile:running"]); expect(yield* fixture.store.read(fixture.id)).toEqual(result); }), @@ -138,7 +137,6 @@ describe("durable lifecycle controller", () => { fixture.state.calls.length = 0; const third = yield* fixture.controller.start(); expect(third.definition).toEqual(first.definition); - expect(third.inputFingerprint).toBe(first.inputFingerprint); expect(fixture.state.preflight.at(-1)?.definition).toEqual(first.definition); }), ), @@ -157,6 +155,19 @@ describe("durable lifecycle controller", () => { ), ); + it.live("accepts explicit materialized defaults for a running stack", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const first = yield* fixture.controller.start(); + const second = yield* fixture.controller.start({ + config: { capabilities: { rest: { enabled: true } } }, + }); + expect(second.definition).toEqual(first.definition); + }), + ), + ); + it.live("requires stop before applying pass-through secret changes", () => run( Effect.gen(function* () { @@ -259,7 +270,6 @@ describe("durable lifecycle controller", () => { expect(yield* fixture.store.read(fixture.id)).toMatchObject({ desiredLifecycle: "stopped", definition: first.definition, - inputFingerprint: first.inputFingerprint, }); expect(fixture.state.calls).toEqual(["preflight"]); }), From 40ec59121972f38e5989ce3d8d58b1c7cca18a34 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 05:34:57 +0200 Subject: [PATCH 118/174] refactor(stack): launch workloads per session --- packages/stack/src/model/CapabilityModule.ts | 3 - packages/stack/src/model/Compiler.ts | 106 +---- packages/stack/src/model/ExecutionPlan.ts | 14 - .../runtime-artifacts.integration.test.ts | 2 - packages/stack/src/runtime/ContainerEngine.ts | 19 +- .../stack/src/runtime/ContainerRuntime.ts | 46 +-- packages/stack/src/runtime/DockerEngine.ts | 6 +- packages/stack/src/runtime/NativeRuntime.ts | 39 +- packages/stack/src/runtime/PodmanEngine.ts | 6 +- .../stack/src/runtime/ProductionRuntime.ts | 2 +- packages/stack/src/runtime/RuntimeDriver.ts | 18 +- .../container-runtime.integration.test.ts | 77 +--- .../native-runtime.integration.test.ts | 26 +- .../production-runtime.integration.test.ts | 37 +- .../workload-runtime.integration.test.ts | 3 - packages/stack/src/supervisor/DesiredState.ts | 148 ------- packages/stack/src/supervisor/Ingress.ts | 2 +- packages/stack/src/supervisor/Lifecycle.ts | 53 ++- packages/stack/src/supervisor/Reconciler.ts | 273 ------------- .../src/supervisor/Reconciler.unit.test.ts | 156 ------- .../stack/src/supervisor/SessionLauncher.ts | 95 +++++ .../stack/src/supervisor/StatusProjection.ts | 2 +- packages/stack/src/supervisor/Supervisor.ts | 183 ++++----- .../supervisor/lifecycle.integration.test.ts | 18 + .../supervisor/reconciler.integration.test.ts | 385 ------------------ .../supervisor/supervisor.integration.test.ts | 255 +++++------- 26 files changed, 425 insertions(+), 1549 deletions(-) delete mode 100644 packages/stack/src/supervisor/DesiredState.ts delete mode 100644 packages/stack/src/supervisor/Reconciler.ts delete mode 100644 packages/stack/src/supervisor/Reconciler.unit.test.ts create mode 100644 packages/stack/src/supervisor/SessionLauncher.ts delete mode 100644 packages/stack/src/supervisor/reconciler.integration.test.ts diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index 6d5979a9be..4cc4cb246c 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -21,7 +21,6 @@ export interface WorkloadSpec { readonly bootstrap?: "database"; readonly dependencies: ReadonlyArray; readonly readiness: Readonly<{ readonly mode: "http" | "tcp"; readonly portField?: PortField }>; - readonly restart: Readonly<{ readonly maxAttempts: number; readonly backoffMs: number }>; /** Exact artifacts selected for this workload release. */ readonly artifacts: Readonly<{ readonly native: NativeArtifact; @@ -87,7 +86,6 @@ export const workload = ( readonly bootstrap?: WorkloadSpec["bootstrap"]; readonly dependencies?: ReadonlyArray; readonly readiness?: WorkloadSpec["readiness"]; - readonly restart?: WorkloadSpec["restart"]; } = {}, ): WorkloadSpec => { const catalog = catalogEntryFor(`${capability}:${name}`); @@ -103,7 +101,6 @@ export const workload = ( ...(options.bootstrap === undefined ? {} : { bootstrap: options.bootstrap }), dependencies: options.dependencies ?? [], readiness: options.readiness ?? { mode: "tcp" }, - restart: options.restart ?? { maxAttempts: 5, backoffMs: 250 }, artifacts, }; }; diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 9520a02e2e..6dca597c41 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -1,4 +1,4 @@ -import { Crypto, Duration, Effect, Path, Redacted, Schema } from "effect"; +import { Duration, Effect, Path, Redacted, Schema } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; import { StackConfigSchema, type StackConfig } from "../public/Config.ts"; import type { @@ -14,7 +14,7 @@ import type { PoolerSettings, JwtSigning, } from "../public/Config.ts"; -import { CAPABILITY_NAMES, type CapabilityName } from "../public/Capability.ts"; +import type { CapabilityName } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { @@ -351,9 +351,6 @@ function materializeFunctionsRoot( return Effect.succeed({ ...settings, functions_root: resolvedRoot }); } -const digestHex = (bytes: Uint8Array): string => - [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); - const decodeConfig = (config: unknown): Effect.Effect => Schema.decodeUnknownEffect(StackConfigSchema)(config, { onExcessProperty: "error" }).pipe( Effect.mapError( @@ -529,15 +526,13 @@ const materializeCapability = ( raw: unknown, projectRoot: string, path: Path.Path, - crypto: Crypto.Crypto, slots: SecretSlotInput[], - specHashes: Map, normalizeFunctions: boolean, previousVersion?: string, ): Effect.Effect< MaterializedCapability & { readonly release: CapabilityRelease }, InvalidStackConfigError | StackVersionUnsupportedError, - Path.Path | Crypto.Crypto + Path.Path > => { const selected = enabledSettings(module.name, { [module.name]: raw }); const mergedInput = merge(module.defaultSettings, selected.settings); @@ -552,15 +547,6 @@ const materializeCapability = ( const completeSettings = ensureManagedSlots(slotted, module, selected.enabled, slots); const selectedRelease = yield* releaseFor(module, selected.raw, previousVersion); const version = selectedRelease.version; - for (const entry of selectedRelease.workloads) { - const bytes = yield* crypto.digest( - "SHA-256", - new TextEncoder().encode( - canonical({ workload: entry, version, settings: completeSettings }), - ), - ); - specHashes.set(`${module.name}:${entry.name}`, digestHex(bytes)); - } return { enabled: selected.enabled, activation: selected.activation, @@ -568,11 +554,7 @@ const materializeCapability = ( settings: completeSettings, release: selectedRelease, }; - }).pipe( - Effect.catchTag("PlatformError", (error) => - Effect.fail(new InvalidStackConfigError({ message: error.message })), - ), - ); + }); }; const withoutRelease = ( @@ -584,46 +566,9 @@ const withoutRelease = ( settings: capability.settings, }); -const hashDefinitionWorkloads = ( - definition: StackDefinition, - crypto: Crypto.Crypto, - specHashes: Map, -): Effect.Effect => - Effect.gen(function* () { - for (const name of CAPABILITY_NAMES) { - const capability = definition.capabilities[name]; - const module = CAPABILITY_MODULES[name]; - const selectedRelease = module.releases[capability.version]; - if (selectedRelease === undefined) - return yield* new StackVersionUnsupportedError({ - capability: name, - version: capability.version, - message: `Unsupported ${name} version: ${capability.version}`, - }); - for (const entry of selectedRelease.workloads) { - const bytes = yield* crypto - .digest( - "SHA-256", - new TextEncoder().encode( - canonical({ - workload: entry, - version: capability.version, - settings: capability.settings, - }), - ), - ) - .pipe( - Effect.mapError((error) => new InvalidStackConfigError({ message: error.message })), - ); - specHashes.set(`${name}:${entry.name}`, digestHex(bytes)); - } - } - }); - const planForDefinition = ( runtime: StackRuntime, definition: StackDefinition, - crypto: Crypto.Crypto, ): Effect.Effect => { const enabled = { database: { @@ -691,27 +636,15 @@ const planForDefinition = ( analytics: definition.capabilities.analytics.settings, pooler: definition.capabilities.pooler.settings, }; - const specHashes = new Map(); - return hashDefinitionWorkloads(definition, crypto, specHashes).pipe( - Effect.flatMap(() => - createExecutionPlan(runtime, enabled, specHashes, versions, settings, CAPABILITY_MODULES), - ), - ); + return createExecutionPlan(runtime, enabled, versions, settings, CAPABILITY_MODULES); }; /** Rebuilds the private execution plan from a persisted, fully materialized definition. */ export const rebuildExecutionPlan = ( runtime: StackRuntime, definition: StackDefinition, -): Effect.Effect< - ExecutionPlan, - InvalidStackConfigError | StackVersionUnsupportedError, - Crypto.Crypto -> => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - return yield* planForDefinition(runtime, definition, crypto); - }); +): Effect.Effect => + planForDefinition(runtime, definition); export const compileStack = ( input: CompileStackInput, @@ -719,11 +652,10 @@ export const compileStack = ( ): Effect.Effect< CompiledStack, InvalidStackConfigError | StackVersionUnsupportedError, - Crypto.Crypto | Path.Path + Path.Path > => Effect.gen(function* () { const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; yield* validateFunctionKeys(input.config ?? {}); const config = yield* decodeConfig(input.config ?? {}); yield* validateDatabaseHealthTimeout(config); @@ -733,15 +665,12 @@ export const compileStack = ( const rawCapabilities = isRecord(config.capabilities) ? config.capabilities : {}; const slots: SecretSlotInput[] = []; for (const slot of INTERNAL_MANAGED_SECRET_SLOTS) slots.push({ slot, policy: "managed" }); - const specHashes = new Map(); const databaseResult = yield* materializeCapability( DatabaseModule, extract(rawCapabilities, "database"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.database.version, ); @@ -750,9 +679,7 @@ export const compileStack = ( extract(rawCapabilities, "rest"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.rest.version, ); @@ -761,9 +688,7 @@ export const compileStack = ( extract(rawCapabilities, "auth"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.auth.version, ); @@ -778,9 +703,7 @@ export const compileStack = ( extract(rawCapabilities, "realtime"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.realtime.version, ); @@ -789,9 +712,7 @@ export const compileStack = ( extract(rawCapabilities, "storage"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.storage.version, ); @@ -800,9 +721,7 @@ export const compileStack = ( extract(rawCapabilities, "functions"), input.projectRoot, path, - crypto, slots, - specHashes, true, previous?.definition.capabilities.functions.version, ); @@ -811,9 +730,7 @@ export const compileStack = ( extract(rawCapabilities, "studio"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.studio.version, ); @@ -822,9 +739,7 @@ export const compileStack = ( extract(rawCapabilities, "mail"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.mail.version, ); @@ -833,9 +748,7 @@ export const compileStack = ( extract(rawCapabilities, "analytics"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.analytics.version, ); @@ -844,9 +757,7 @@ export const compileStack = ( extract(rawCapabilities, "pooler"), input.projectRoot, path, - crypto, slots, - specHashes, false, previous?.definition.capabilities.pooler.version, ); @@ -928,7 +839,6 @@ export const compileStack = ( const executionPlan = yield* createExecutionPlan( input.runtime, enabled, - specHashes, versions, { database: database.settings, diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index 9bfecc67cb..e096da5f3d 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -51,13 +51,11 @@ export interface PlannedWorkload { readonly bootstrap?: WorkloadSpec["bootstrap"]; readonly dependencies: ReadonlyArray; readonly readiness: WorkloadSpec["readiness"]; - readonly restart: WorkloadSpec["restart"]; readonly artifacts: Readonly<{ readonly native: NativeArtifact; readonly container: ContainerArtifact; }>; readonly selected: NativeArtifact | ContainerArtifact; - readonly specHash: string; } export interface ExecutionPlan { @@ -147,7 +145,6 @@ const selectedWorkloads = ( export const createExecutionPlan = ( runtime: StackRuntime, enabled: Readonly<{ [Name in CapabilityName]: EnabledCapability }>, - specHashes: ReadonlyMap, versions: Readonly<{ [Name in CapabilityName]: string }>, settings: PlanSettings, modules: typeof CAPABILITY_MODULES = CAPABILITY_MODULES, @@ -229,25 +226,14 @@ export const createExecutionPlan = ( const selectedEntries = selectedWorkloads(name, modules, settings, release.workloads); for (const entry of selectedEntries) { const id = `${name}:${entry.name}`; - const specHash = specHashes.get(id); - if (specHash === undefined || specHash.length === 0) - return Effect.fail( - new InvalidStackConfigError({ - message: `Missing workload spec hash ${id}`, - capability: name, - workload: id, - }), - ); declaredWorkloads.push({ id, capability: name, ...(entry.bootstrap === undefined ? {} : { bootstrap: entry.bootstrap }), dependencies: entry.dependencies, readiness: entry.readiness, - restart: entry.restart, artifacts: entry.artifacts, selected: runtime.kind === "native" ? entry.artifacts.native : entry.artifacts.container, - specHash, }); } } diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 0c9f215912..b00493fc17 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -19,7 +19,6 @@ const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload capability: "database", dependencies: [], readiness: { mode: "tcp" }, - restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { native: { kind: "native", release: "17.6.1.167" }, container: { @@ -28,7 +27,6 @@ const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload }, }, selected, - specHash: "hash", }); const prepared = (request: ArtifactRequest): PreparedArtifact => ({ diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 0c9e2a9b2c..31b45e407d 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -47,7 +47,7 @@ export interface ContainerNetworkLabels extends ContainerIdentityLabels { } export interface ContainerWorkloadLabels extends ContainerIdentityLabels { readonly workloadId: string; - readonly specHash: string; + readonly startup?: boolean; readonly role: "workload"; } export interface ContainerVolumeLabels { @@ -429,15 +429,15 @@ export const makeContainerEngineCodecs = (options: { ); }; const workloadLabels = (operation: string, values: ReadonlyArray) => { - const [stack, owner, workload, hash, role] = values; - if (owner === undefined || workload === undefined || hash === undefined || role !== "workload") + const [stack, owner, workload, startup, role] = values; + if (owner === undefined || workload === undefined || role !== "workload") return Effect.fail(protocol(operation)); return decodeIdentity(operation, stack).pipe( Effect.map((stackId) => ({ stackId, ownerSessionId: owner, workloadId: workload, - specHash: hash, + ...(startup === "true" ? { startup: true } : {}), role: "workload" as const, })), ); @@ -453,19 +453,24 @@ export const makeContainerEngineCodecs = (options: { ); const decodeContainers: ContainerEngineCodecs["decodeContainers"] = (result) => decodeRows("inspect-containers", result.stdout, 8, (values) => { - const [id, name, stack, owner, workload, hash, role, state] = values; + const [id, name, stack, owner, workload, startup, role, state] = values; if ( id === undefined || name === undefined || stack === undefined || owner === undefined || workload === undefined || - hash === undefined || role !== "workload" || state === undefined ) return Effect.fail(protocol("inspect-containers")); - return workloadLabels("inspect-containers", [stack, owner, workload, hash, role]).pipe( + return workloadLabels("inspect-containers", [ + stack, + owner, + workload, + startup ?? "", + role, + ]).pipe( Effect.map((labels): ContainerResource => ({ id, name, diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 315d5cf067..5d0688c655 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -4,7 +4,6 @@ import { Effect, Exit, Fiber, - PubSub, Ref, Scope, Semaphore, @@ -118,8 +117,7 @@ interface ContainerRuntimeResource extends ContainerRuntimeResourceIds { stopRequested: boolean; } -const resourceKey = (key: RuntimeWorkloadKey): string => - `${key.stackId}:${key.workloadId}:${key.specHash}`; +const resourceKey = (key: RuntimeWorkloadKey): string => `${key.stackId}:${key.workloadId}`; const nameFor = (key: RuntimeWorkloadKey, role: ContainerResourceRole): string => role === "network" @@ -141,16 +139,14 @@ const workloadLabelsFor = ( stackId: key.stackId, ownerSessionId, workloadId: key.workloadId, - specHash: key.specHash, role: "workload", }); const startupLabelsFor = ( key: RuntimeWorkloadKey, ownerSessionId: string, - index: number, ): ContainerWorkloadLabels => ({ ...workloadLabelsFor(key, ownerSessionId), - specHash: `${key.specHash}:startup:${index}`, + startup: true, }); const volumeOwnerFor = (key: RuntimeWorkloadKey, request: ContainerVolumeRequest): string => request.ownerWorkloadId ?? key.workloadId; @@ -189,19 +185,14 @@ const sameLabels = (left: ContainerLabels, right: ContainerLabels): boolean => "ownerSessionId" in right && left.ownerSessionId === right.ownerSessionId && (left.role === "network" || - ("workloadId" in left && - "workloadId" in right && - left.workloadId === right.workloadId && - "specHash" in left && - "specHash" in right && - left.specHash === right.specHash))); + ("workloadId" in left && "workloadId" in right && left.workloadId === right.workloadId))); /** Workload identity deliberately excludes ownerSessionId; stack ownership is fenced externally. */ const sameWorkloadIdentity = (left: ContainerLabels, right: ContainerWorkloadLabels): boolean => left.role === "workload" && left.stackId === right.stackId && left.workloadId === right.workloadId && - left.specHash === right.specHash; + left.startup !== true; /** Networks are identified by stack identity. */ const sameNetworkIdentity = (left: ContainerLabels, right: ContainerNetworkLabels): boolean => @@ -229,9 +220,9 @@ const isWorkloadResource = ( ): entry is ContainerResource & { readonly labels: ContainerWorkloadLabels } => entry.kind === "workload" && entry.labels.role === "workload"; -/** Startup containers use the deterministic suffix emitted by startupLabelsFor. */ +/** Startup containers are marked by the startup label. */ const isStartupContainer = (entry: ContainerResource): boolean => - isWorkloadResource(entry) && /^.+:startup:(?:0|[1-9]\d*)$/.test(entry.labels.specHash); + isWorkloadResource(entry) && entry.labels.startup === true; const isNetworkResource = ( entry: ContainerResource, @@ -262,7 +253,6 @@ export const makeContainerRuntime = ( const resources = new Map(); const startGuards = new Map>(); const startFibers = new Map>(); - const failures = yield* PubSub.unbounded(); const withEngine = ( key: Pick, @@ -295,14 +285,7 @@ export const makeContainerRuntime = ( state: "failed", error: failure.message, }); - return Effect.all([ - PubSub.publish(failures, { - ...resource.key, - state: "failed", - error: failure.message, - }), - Deferred.fail(resource.failure, failure), - ]).pipe(Effect.asVoid); + return Deferred.fail(resource.failure, failure).pipe(Effect.asVoid); }; const attachLogs = ( @@ -384,7 +367,6 @@ export const makeContainerRuntime = ( const key: RuntimeWorkloadKey = { stackId: entry.labels.stackId, workloadId: entry.labels.workloadId, - specHash: entry.labels.specHash, }; const local = resources.get(resourceKey(key)); return { @@ -407,7 +389,6 @@ export const makeContainerRuntime = ( key: RuntimeWorkloadKey, workload: PlannedWorkload, startupProcess: ContainerStartupProcess, - index: number, context: Readonly<{ readonly artifact: ContainerArtifact; readonly network: ContainerResource; @@ -415,7 +396,7 @@ export const makeContainerRuntime = ( readonly volumeRequest?: ContainerVolumeRequest; }>, ): Effect.Effect => { - const labels = startupLabelsFor(key, options.ownerSessionId, index); + const labels = startupLabelsFor(key, options.ownerSessionId); const specification: ContainerContainerSpec = { // Reuse the main name so crash-orphaned init containers are exact collisions to clean up. name: nameFor(key, "workload"), @@ -678,8 +659,8 @@ export const makeContainerRuntime = ( yield* withEngine(key, options.engine.removeContainer(collision.id)); } - for (const [index, startup] of (resolution.startup ?? []).entries()) - yield* runStartupProcess(key, workload, startup, index, { + for (const startup of resolution.startup ?? []) + yield* runStartupProcess(key, workload, startup, { artifact, network: networkResource, resolution, @@ -988,14 +969,7 @@ export const makeContainerRuntime = ( }), ); - const watchFailures = Stream.unwrap( - PubSub.subscribe(failures).pipe( - Effect.map((subscription) => Stream.fromEffectRepeat(PubSub.take(subscription))), - ), - ).pipe(Stream.scoped); - return { - watchFailures, observe, start, stop, diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index 70107fae38..6cec8ebd6a 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -12,7 +12,7 @@ import { const stackLabel = "com.supabase.stack.stackId"; const ownerLabel = "com.supabase.stack.ownerSessionId"; const workloadLabel = "com.supabase.stack.workloadId"; -const hashLabel = "com.supabase.stack.specHash"; +const startupLabel = "com.supabase.stack.startup"; const roleLabel = "com.supabase.stack.role"; const labels = (value: ContainerLabels): ReadonlyArray => { @@ -33,7 +33,7 @@ const labels = (value: ContainerLabels): ReadonlyArray => { ["stackId", value.stackId], ["ownerSessionId", value.ownerSessionId], ["workloadId", value.workloadId], - ["specHash", value.specHash], + ["startup", value.startup === true ? "true" : "false"], ["role", value.role], ]; return pairs.flatMap(([key, label]) => ["--label", `com.supabase.stack.${key}=${String(label)}`]); @@ -49,7 +49,7 @@ const containerFormat = [ jsonLabel(stackLabel), jsonLabel(ownerLabel), jsonLabel(workloadLabel), - jsonLabel(hashLabel), + jsonLabel(startupLabel), jsonLabel(roleLabel), "{{json .State}}", ].join("\\t"); diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index bb8cc1d676..ed7536afe1 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -6,7 +6,6 @@ import { Fiber, FileSystem, Path, - PubSub, Ref, Scope, Semaphore, @@ -93,12 +92,10 @@ interface Resource { } const resourceKey = (key: RuntimeWorkloadKey): string => - JSON.stringify([key.stackId, key.workloadId, key.specHash]); + JSON.stringify([key.stackId, key.workloadId]); const sameKey = (left: RuntimeWorkloadKey, right: RuntimeWorkloadKey): boolean => - left.stackId === right.stackId && - left.workloadId === right.workloadId && - left.specHash === right.specHash; + left.stackId === right.stackId && left.workloadId === right.workloadId; const driverError = ( key: Pick, @@ -184,7 +181,6 @@ export const makeNativeRuntime = ( const runtimeScope = yield* Scope.fork(parentScope, "parallel"); const lifecycle = yield* Semaphore.make(1); const resources = new Map(); - const failures = yield* PubSub.unbounded(); const cleanup = (resource: Resource): Effect.Effect => Scope.close(resource.scope, Exit.void).pipe( @@ -220,14 +216,11 @@ export const makeNativeRuntime = ( yield* Ref.update(resource.state, (current): ObservedWorkload => current.state === "failed" ? current : next, ); - if (!resource.stopRequested) { - yield* PubSub.publish(failures, next); - if (Exit.isFailure(result)) - yield* Deferred.fail( - resource.failure, - driverError(resource.key, `Native workload exited before readiness`, result.cause), - ); - } + if (!resource.stopRequested && Exit.isFailure(result)) + yield* Deferred.fail( + resource.failure, + driverError(resource.key, `Native workload exited before readiness`, result.cause), + ); }).pipe(Effect.ignore); const reportLogFailure = ( @@ -247,16 +240,7 @@ export const makeNativeRuntime = ( return [true, next] satisfies readonly [boolean, ObservedWorkload]; }).pipe( Effect.flatMap((changed) => - changed - ? Effect.all([ - PubSub.publish(failures, { - ...resource.key, - state: "failed", - error: failure.message, - }), - Deferred.fail(resource.failure, failure), - ]).pipe(Effect.asVoid) - : Effect.void, + changed ? Deferred.fail(resource.failure, failure).pipe(Effect.asVoid) : Effect.void, ), ); }; @@ -667,14 +651,7 @@ export const makeNativeRuntime = ( }), ); - const watchFailures = Stream.unwrap( - PubSub.subscribe(failures).pipe( - Effect.map((subscription) => Stream.fromEffectRepeat(PubSub.take(subscription))), - ), - ).pipe(Stream.scoped); - return { - watchFailures, observe, start, stop, diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index 9868d57484..7c250aa505 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -12,7 +12,7 @@ import { const stackLabel = "com.supabase.stack.stackId"; const ownerLabel = "com.supabase.stack.ownerSessionId"; const workloadLabel = "com.supabase.stack.workloadId"; -const hashLabel = "com.supabase.stack.specHash"; +const startupLabel = "com.supabase.stack.startup"; const roleLabel = "com.supabase.stack.role"; const labels = (value: ContainerLabels): ReadonlyArray => { @@ -33,7 +33,7 @@ const labels = (value: ContainerLabels): ReadonlyArray => { ["stackId", value.stackId], ["ownerSessionId", value.ownerSessionId], ["workloadId", value.workloadId], - ["specHash", value.specHash], + ["startup", value.startup === true ? "true" : "false"], ["role", value.role], ]; return pairs.flatMap(([key, label]) => ["--label", `com.supabase.stack.${key}=${String(label)}`]); @@ -50,7 +50,7 @@ const containerFormat = [ templateLabel(stackLabel), templateLabel(ownerLabel), templateLabel(workloadLabel), - templateLabel(hashLabel), + templateLabel(startupLabel), templateLabel(roleLabel), "{{.State}}", ].join("\\t"); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index e8cf2bd1e4..e145c37b14 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -134,7 +134,7 @@ const currentStateReader = (options: ProductionRuntimeFactoryOptions) => ); const artifactKey = (runtime: StackRuntime, workload: PlannedWorkload): string => - `${runtime.kind}:${runtime.kind === "container" ? runtime.engine : ""}:${workload.id}:${workload.specHash}:${workload.selected.kind === "native" ? workload.selected.release : workload.selected.image}`; + `${runtime.kind}:${runtime.kind === "container" ? runtime.engine : ""}:${workload.id}:${workload.selected.kind === "native" ? workload.selected.release : workload.selected.image}`; const runtimeMatches = (left: StackRuntime, right: StackRuntime): boolean => { if (left.kind !== right.kind) return false; diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index 24ce941e2b..3c49a1bc42 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Stream } from "effect"; +import { Data, Effect } from "effect"; import type { StackId } from "../public/StackId.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; @@ -6,17 +6,16 @@ import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; export interface RuntimeWorkloadKey { readonly stackId: StackId; readonly workloadId: string; - readonly specHash: string; } -/** Exact stack-wide runtime cleanup requested after reconciliation or owner recovery. */ +/** Exact stack-wide runtime cleanup requested after lifecycle failure or owner recovery. */ export interface RuntimeCleanupRequest { readonly stackId: StackId; /** Remove persistent runtime volumes in addition to ephemeral containers/networks. */ readonly destroy: boolean; } -/** A driver only reports states that can be acted on by the reconciler. */ +/** Runtime states projected to callers and status inspection. */ type ObservedWorkloadState = "absent" | "starting" | "ready" | "stopped" | "failed"; export interface ObservedWorkload extends RuntimeWorkloadKey { @@ -27,8 +26,6 @@ export interface ObservedWorkload extends RuntimeWorkloadKey { type RuntimeWorkload = PlannedWorkload; export interface RuntimeDriver { - /** Unexpected post-readiness failures, scoped to this driver owner. */ - readonly watchFailures: Stream.Stream; /** Enumerates only private resources owned by this exact stack identity. */ readonly observe: ( stackId: StackId, @@ -55,12 +52,3 @@ export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ readonly workloadId?: string; readonly cause?: unknown; }> {} - -export class RuntimeRestartBudgetExceededError extends Data.TaggedError( - "RuntimeRestartBudgetExceededError", -)<{ - readonly message: string; - readonly stackId: StackId; - readonly workloadId: string; - readonly attempts: number; -}> {} diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 6a700cabf1..93be78233e 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -38,7 +38,6 @@ const stackId = StackIdSchema.make("a".repeat(64)); const key: RuntimeWorkloadKey = { stackId, workloadId: "database:database", - specHash: "hash-7", }; const containerArtifact: ContainerArtifact = { @@ -51,13 +50,11 @@ const workload = (selected: PlannedWorkload["selected"] = containerArtifact): Pl capability: key.workloadId.startsWith("functions:") ? "functions" : "database", dependencies: [], readiness: { mode: "tcp" }, - restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { native: { kind: "native", release: "1" }, container: containerArtifact, }, selected, - specHash: key.specHash, }); const commandResult = (value: unknown): ContainerCommandResult => ({ @@ -278,7 +275,6 @@ describe("container runtime", () => { stackId, ownerSessionId: "owner", workloadId: "auth:auth", - specHash: key.specHash, role: "workload", }, network: "private", @@ -506,19 +502,12 @@ describe("container runtime", () => { }, ownerSessionId: "owner-session", }); - const failures = runtime.watchFailures; - expect(failures).toBeDefined(); - if (failures === undefined) return; - const eventFiber = yield* failures.pipe( - Stream.take(1), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); const ready = yield* runtime.start(key, workload()); expect(ready.state).toBe("ready"); yield* Deferred.succeed(exit, 17); - const events = yield* Fiber.join(eventFiber); - expect(events).toEqual([ + yield* Effect.yieldNow; + const observed = yield* runtime.observe(stackId); + expect(observed).toEqual([ expect.objectContaining({ workloadId: key.workloadId, state: "failed" }), ]); yield* runtime.remove(key); @@ -826,7 +815,7 @@ describe("container runtime", () => { streamStates.push(resource?.state); return resource?.state === "running" && resource.labels.role === "workload" && - resource.labels.specHash.includes(":startup:") + resource.labels.startup === true ? Stream.fromIterable([ { stream: "stdout", message: "migrated" } satisfies ContainerLogLine, { stream: "stderr", message: "notice" } satisfies ContainerLogLine, @@ -972,7 +961,6 @@ describe("container runtime", () => { stackId, ownerSessionId: "owner-session", workloadId: key.workloadId, - specHash: key.specHash, role: "workload", }, }; @@ -1009,7 +997,7 @@ describe("container runtime", () => { stackId, ownerSessionId: "owner-session", workloadId: key.workloadId, - specHash: `${key.specHash}:startup:0`, + startup: true, role: "workload", }, }; @@ -1172,11 +1160,8 @@ describe("container runtime", () => { yield* Deferred.await(firstAppended); yield* runtime.cleanup({ stackId: key.stackId, destroy: false }); - const restartedKey: RuntimeWorkloadKey = { ...key, specHash: "hash-8" }; - const restartedWorkload: PlannedWorkload = { - ...workload(), - specHash: restartedKey.specHash, - }; + const restartedKey: RuntimeWorkloadKey = key; + const restartedWorkload: PlannedWorkload = workload(); yield* runtime.start(restartedKey, restartedWorkload); yield* Deferred.await(secondAppended); @@ -1200,14 +1185,13 @@ describe("container runtime", () => { const readiness = yield* Deferred.make(); const followerAttached = yield* Deferred.make(); const followerFailure = yield* Deferred.make(); - const restartedKey: RuntimeWorkloadKey = { ...key, specHash: "hash-9" }; - const restartedWorkload: PlannedWorkload = { - ...workload(), - specHash: restartedKey.specHash, - }; + const restartedKey: RuntimeWorkloadKey = key; + const restartedWorkload: PlannedWorkload = workload(); + let starts = 0; let followerCalls = 0; const engine: ContainerEngine = { ...fakeContainerEngine(state), + waitContainer: () => Effect.never, streamLogs: () => { followerCalls += 1; return followerCalls === 1 @@ -1227,8 +1211,11 @@ describe("container runtime", () => { resolveWorkload: (requestKey) => Effect.succeed({ waitForReadiness: - requestKey.specHash === restartedKey.specHash - ? () => Deferred.await(readiness) + requestKey.workloadId === restartedKey.workloadId + ? () => { + starts += 1; + return starts === 1 ? Effect.void : Deferred.await(readiness); + } : undefined, }), }); @@ -1378,7 +1365,6 @@ describe("container runtime", () => { stackId, ownerSessionId: "owner-session", workloadId: "database:database", - specHash: "hash", role: "workload", }, network: "private", @@ -1408,7 +1394,7 @@ describe("container runtime", () => { ? "not-json\n" : "" : request.args[0] === "ps" - ? `${dockerJsonRow(["container-id", "backend", stackId, "owner", key.workloadId, key.specHash, "workload", "running"])}\n` + ? `${dockerJsonRow(["container-id", "backend", stackId, "owner", key.workloadId, "false", "workload", "running"])}\n` : request.args[0] === "network" && request.args[1] === "create" ? "created-id\nsecond\n" : request.args[0] === "network" @@ -1455,7 +1441,6 @@ describe("container runtime", () => { stackId, ownerSessionId: "owner", workloadId: "backend", - specHash: key.specHash, role: "workload" as const, }, network: "private", @@ -1527,7 +1512,7 @@ describe("container runtime", () => { ? "bad\trow\n" : "" : request.args[0] === "ps" - ? `container-id\tbackend\t${stackId}\towner\t${key.workloadId}\t${key.specHash}\tworkload\trunning\n` + ? `container-id\tbackend\t${stackId}\towner\t${key.workloadId}\tfalse\tworkload\trunning\n` : request.args[0] === "network" ? `network-id\tprivate\t${stackId}\towner\tnetwork\n` : request.args[0] === "volume" @@ -1704,16 +1689,6 @@ describe("container runtime", () => { expect(state.calls).toContain("create-container"); expect(state.calls.some((call) => call.startsWith("stop:"))).toBe(true); expect(state.calls.some((call) => call.startsWith("remove:"))).toBe(true); - - const staleKey = { ...key, specHash: "hash-new" }; - state.calls.length = 0; - yield* runtime.start(staleKey, workload({ ...containerArtifact })); - const stopIndex = state.calls.findIndex((call) => call.startsWith("stop:")); - const removeIndex = state.calls.findIndex((call) => call.startsWith("remove:")); - const createIndex = state.calls.indexOf("create-container"); - expect(stopIndex).toBeGreaterThanOrEqual(0); - expect(removeIndex).toBeGreaterThan(stopIndex); - expect(createIndex).toBeGreaterThan(removeIndex); }), ); @@ -1776,7 +1751,6 @@ describe("container runtime", () => { stackId: foreignStackId, ownerSessionId: "other-session", workloadId: foreignKey.workloadId, - specHash: foreignKey.specHash, role: "workload", }, }, @@ -1798,8 +1772,8 @@ describe("container runtime", () => { expect(state.calls).not.toContain("create-container"); expect(state.resources[0]?.id).toBe("foreign-container"); - const firstKey = { ...key, workloadId: "foo/bar", specHash: "first" }; - const secondKey = { ...key, workloadId: "foo?bar", specHash: "second" }; + const firstKey = { ...key, workloadId: "foo/bar" }; + const secondKey = { ...key, workloadId: "foo?bar" }; state.resources = []; state.calls.length = 0; yield* runtime.start(firstKey, workload()); @@ -1887,12 +1861,10 @@ describe("container runtime", () => { const ownerKey: RuntimeWorkloadKey = { ...key, workloadId: "storage:storage", - specHash: "storage-hash", }; const secondaryKey: RuntimeWorkloadKey = { ...key, workloadId: "storage:imgproxy", - specHash: "imgproxy-hash", }; const ownerWorkload = { ...workload(), @@ -2000,15 +1972,8 @@ describe("container runtime", () => { Effect.succeed({ waitForReadiness: () => Effect.sync(() => order.push("readiness")) }), bootstrapDatabase: () => Effect.sync(() => order.push("bootstrap")), }); - const ready = yield* runtime.start(key, workload()); - expect(ready.state).toBe("ready"); - order.length = 0; - const databaseWorkload = { ...workload(), bootstrap: "database" as const }; - const databaseReady = yield* runtime.start( - { ...key, specHash: "database-bootstrap" }, - databaseWorkload, - ); + const databaseReady = yield* runtime.start(key, databaseWorkload); expect(databaseReady.state).toBe("ready"); expect(order).toEqual(["readiness", "bootstrap"]); }), diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 6695757e94..6a4dae7212 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -30,19 +30,16 @@ const workload = (id: string, bootstrap?: "database"): PlannedWorkload => ({ ...(bootstrap === undefined ? {} : { bootstrap }), dependencies: [], readiness: { mode: "tcp" }, - restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { native: { kind: "native", release: "test" }, container: { kind: "container", image: `test/${id}` }, }, selected: { kind: "native", release: "test" }, - specHash: id, }); const keyFor = (id: string): RuntimeWorkloadKey => ({ stackId, workloadId: `database:${id}`, - specHash: id, }); const fixtureProcess = (message: string) => ({ @@ -147,24 +144,17 @@ describe("native runtime", { timeout: 15_000 }, () => { startedProcess = process; }), }); - const failures = runtime.watchFailures; - expect(failures).toBeDefined(); - if (failures === undefined) return; - const eventFiber = yield* failures.pipe( - Stream.take(1), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); const ready = yield* runtime.start(keyFor("watch"), workload("watch")); expect(ready.state).toBe("ready"); expect(startedProcess).toBeDefined(); - if (startedProcess !== undefined) yield* startedProcess.kill; - const events = yield* Fiber.join(eventFiber); - expect(events).toEqual([ - expect.objectContaining({ - workloadId: keyFor("watch").workloadId, - state: "failed", - }), + if (startedProcess !== undefined) { + yield* startedProcess.kill; + yield* startedProcess.exitCode.pipe(Effect.exit); + yield* Effect.yieldNow; + } + const observed = yield* runtime.observe(stackId); + expect(observed).toEqual([ + expect.objectContaining({ workloadId: keyFor("watch").workloadId, state: "failed" }), ]); yield* runtime.remove(keyFor("watch")); }), diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 65abcf4311..cc0021efca 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -91,7 +91,6 @@ const workloadFor = ( capability, dependencies: [], readiness: { mode: "tcp" }, - restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { native: { kind: "native", release: "17.6.1.167" }, container: { @@ -100,7 +99,6 @@ const workloadFor = ( }, }, selected, - specHash: `hash-${id}`, }); const stateStoreFor = ( @@ -403,7 +401,6 @@ describe("production runtime composition", () => { { stackId, workloadId: database.id, - specHash: database.specHash, }, database, ); @@ -412,7 +409,6 @@ describe("production runtime composition", () => { yield* runtime.driver.stop({ stackId, workloadId: database.id, - specHash: database.specHash, }); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -493,7 +489,6 @@ describe("production runtime composition", () => { { stackId, workloadId: database.id, - specHash: database.specHash, }, database, ) @@ -663,7 +658,6 @@ describe("production runtime composition", () => { const key = { stackId, workloadId: realtime.id, - specHash: realtime.specHash, }; const ready = yield* runtime.driver.start(key, realtime); expect(ready.state).toBe("ready"); @@ -754,7 +748,6 @@ describe("production runtime composition", () => { { stackId, workloadId: database.id, - specHash: database.specHash, }, database, ); @@ -773,7 +766,6 @@ describe("production runtime composition", () => { yield* runtime.driver.stop({ stackId, workloadId: database.id, - specHash: database.specHash, }); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -875,7 +867,6 @@ describe("production runtime composition", () => { { stackId, workloadId: database.id, - specHash: database.specHash, }, database, ) @@ -889,7 +880,6 @@ describe("production runtime composition", () => { { stackId, workloadId: database.id, - specHash: database.specHash, }, database, ); @@ -902,14 +892,12 @@ describe("production runtime composition", () => { yield* runtime.driver.stop({ stackId, workloadId: database.id, - specHash: database.specHash, }); yield* fs.writeFileString(eventsPath, ""); yield* runtime.driver.start( { stackId, workloadId: database.id, - specHash: database.specHash, }, database, ); @@ -917,7 +905,6 @@ describe("production runtime composition", () => { yield* runtime.driver.stop({ stackId, workloadId: database.id, - specHash: database.specHash, }); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -1587,7 +1574,6 @@ describe("production runtime composition", () => { { stackId, workloadId: auth.id, - specHash: auth.specHash, }, auth, ); @@ -1611,7 +1597,6 @@ describe("production runtime composition", () => { { stackId, workloadId: functions.id, - specHash: functions.specHash, }, functions, ); @@ -1630,7 +1615,6 @@ describe("production runtime composition", () => { { stackId, workloadId: analytics.id, - specHash: analytics.specHash, }, analytics, ); @@ -1644,18 +1628,14 @@ describe("production runtime composition", () => { { stackId, workloadId: pooler.id, - specHash: pooler.specHash, }, pooler, ); const poolerSpec = createdSpecs.find( - (spec) => - spec.labels.workloadId === pooler.id && spec.labels.specHash === pooler.specHash, + (spec) => spec.labels.workloadId === pooler.id && spec.labels.startup !== true, ); const poolerStartupSpecs = createdSpecs.filter( - (spec) => - spec.labels.workloadId === pooler.id && - spec.labels.specHash.startsWith(`${pooler.specHash}:startup:`), + (spec) => spec.labels.workloadId === pooler.id && spec.labels.startup === true, ); expect(poolerSpec?.mounts).toContainEqual({ source: expect.stringContaining("pooler_tenant.exs"), @@ -1676,7 +1656,6 @@ describe("production runtime composition", () => { yield* runtime.driver.stop({ stackId, workloadId: pooler.id, - specHash: pooler.specHash, }); // Runtime inputs are session-owned and remain available across an individual workload // restart; the owner cleanup at session end removes the flat path. @@ -1685,7 +1664,6 @@ describe("production runtime composition", () => { { stackId, workloadId: pooler.id, - specHash: pooler.specHash, }, pooler, ); @@ -1703,7 +1681,6 @@ describe("production runtime composition", () => { { stackId, workloadId: functions.id, - specHash: functions.specHash, }, functions, ); @@ -1822,13 +1799,10 @@ describe("production runtime composition", () => { ); if (rest === undefined || auth === undefined) return yield* Effect.die("Expected REST and Auth workloads"); - yield* runtime.driver.start( - { stackId, workloadId: rest.id, specHash: rest.specHash }, - rest, - ); + yield* runtime.driver.start({ stackId, workloadId: rest.id }, rest); expect(createdSpecs.some((spec) => spec.labels.workloadId === rest.id)).toBe(true); const authStart = yield* runtime.driver - .start({ stackId, workloadId: auth.id, specHash: auth.specHash }, auth) + .start({ stackId, workloadId: auth.id }, auth) .pipe(Effect.exit); expect(Exit.isFailure(authStart)).toBe(true); expect(createdSpecs.some((spec) => spec.labels.workloadId === auth.id)).toBe(false); @@ -1870,7 +1844,6 @@ describe("production runtime composition", () => { yield* runtime.driver.stop({ stackId, workloadId: "database:database", - specHash: "stale", }); expect(yield* fs.exists(envPath)).toBe(true); expect(yield* fs.exists(bootstrapPath)).toBe(true); @@ -1888,7 +1861,6 @@ describe("production runtime composition", () => { stackId, }); const driver: RuntimeDriver = { - watchFailures: Stream.empty, observe: () => Effect.succeed([]), start: () => Effect.die("unused"), stop: () => Effect.void, @@ -1916,7 +1888,6 @@ describe("production runtime composition", () => { it("continues owner cleanup when one file owner fails", () => { const calls: string[] = []; const driver: RuntimeDriver = { - watchFailures: Stream.empty, observe: () => Effect.succeed([]), start: () => Effect.die("unused"), stop: () => Effect.void, diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 7673fc41bb..d2d05832f4 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -90,19 +90,16 @@ const planned = (id: string): PlannedWorkload => { const separator = id.indexOf(":"); const capability = CAPABILITY_NAMES.find((name) => name === id.slice(0, separator)); if (capability === undefined) throw new Error(`Missing test capability: ${id}`); - const name = id.slice(separator + 1); return { id, capability, dependencies: [], readiness: { mode: "tcp" }, - restart: { maxAttempts: 1, backoffMs: 0 }, artifacts: { native: { kind: "native", release: entry.nativeVersion }, container: { kind: "container", image: entry.containerImage }, }, selected: { kind: "native", release: entry.nativeVersion }, - specHash: `${capability}:${name}`, }; }; diff --git a/packages/stack/src/supervisor/DesiredState.ts b/packages/stack/src/supervisor/DesiredState.ts deleted file mode 100644 index 757474abc6..0000000000 --- a/packages/stack/src/supervisor/DesiredState.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; -import type { StackId } from "../public/StackId.ts"; -import type { ObservedWorkload, RuntimeWorkloadKey } from "../runtime/RuntimeDriver.ts"; - -export type DesiredLifecycle = "unconfigured" | "stopped" | "running" | "destroying"; - -export interface ReconciliationInput { - readonly stackId: StackId; - readonly desiredLifecycle: DesiredLifecycle; - readonly plan: ExecutionPlan; - readonly observed: ReadonlyArray; -} - -interface DesiredStart { - readonly kind: "start"; - readonly key: RuntimeWorkloadKey; - readonly workload: PlannedWorkload; -} - -interface DesiredStop { - readonly kind: "stop"; - readonly key: RuntimeWorkloadKey; -} - -interface DesiredRemove { - readonly kind: "remove"; - readonly key: RuntimeWorkloadKey; -} - -interface BlockedWorkload { - readonly workloadId: string; - readonly dependencyId: string; -} - -export interface DesiredStatePlan { - readonly starts: ReadonlyArray; - readonly stops: ReadonlyArray; - readonly removes: ReadonlyArray; - readonly blocked: ReadonlyArray; -} - -const keyFor = (stackId: StackId, workloadId: string, specHash: string): RuntimeWorkloadKey => ({ - stackId, - workloadId, - specHash, -}); - -const isCurrent = ( - observed: ObservedWorkload | undefined, - specHash: string, -): observed is ObservedWorkload => observed !== undefined && observed.specHash === specHash; - -/** - * Builds a deterministic desired-state delta. `ExecutionPlan.workloads` is already a stable - * topological order; reversing it gives the corresponding safe stop/remove order. - */ -export const planDesiredState = (input: ReconciliationInput): DesiredStatePlan => { - const starts: DesiredStart[] = []; - const stops: DesiredStop[] = []; - const removes: DesiredRemove[] = []; - const blocked: BlockedWorkload[] = []; - const staleByWorkload = new Map>(); - const observationsFor = (workloadId: string): ReadonlyArray => - input.observed - .filter((entry) => entry.workloadId === workloadId) - .sort((left, right) => left.specHash.localeCompare(right.specHash)); - const removeEntry = (entry: ObservedWorkload) => - removes.push({ - kind: "remove", - key: keyFor(input.stackId, entry.workloadId, entry.specHash), - }); - - if (input.desiredLifecycle === "running") { - // Failed workloads are restart candidates. Only a failed start in this reconciliation blocks - // dependents; an observed failure gets one fresh attempt while its session budget allows. - const failed = new Set(); - for (const workload of input.plan.workloads) { - const dependencyFailure = workload.dependencies.find((dependency) => failed.has(dependency)); - if (dependencyFailure !== undefined) { - blocked.push({ workloadId: workload.id, dependencyId: dependencyFailure }); - failed.add(workload.id); - continue; - } - const observations = observationsFor(workload.id); - const current = observations.filter((entry) => isCurrent(entry, workload.specHash)); - // Every duplicate old-definition/spec resource is stale, even when an exact current - // resource also exists. Never collapse observations by workload id: each exact key must be - // cleaned independently. - const stale = observations.filter( - (entry) => - !isCurrent(entry, workload.specHash) || - entry.state === "failed" || - entry.state === "stopped", - ); - if (stale.length > 0) staleByWorkload.set(workload.id, stale); - if (current.some((entry) => entry.state === "ready" || entry.state === "starting")) continue; - const key = keyFor(input.stackId, workload.id, workload.specHash); - starts.push({ kind: "start", key, workload }); - } - // Stale replacements are removed child-first, preserving dependency safety when several - // definitions/spec hashes for a planned graph are present at once. - for (const workload of [...input.plan.workloads].reverse()) { - for (const entry of staleByWorkload.get(workload.id) ?? []) { - if (entry.state === "ready" || entry.state === "starting") { - stops.push({ - kind: "stop", - key: keyFor(input.stackId, entry.workloadId, entry.specHash), - }); - } - removeEntry(entry); - } - } - } else { - const reverse = [...input.plan.workloads].reverse(); - for (const workload of reverse) { - for (const observed of observationsFor(workload.id)) { - if (observed.state === "absent") continue; - const key = keyFor(input.stackId, observed.workloadId, observed.specHash); - if (observed.state !== "stopped") { - stops.push({ kind: "stop", key }); - } - removeEntry(observed); - } - } - } - - // Resources that no longer appear in the accepted plan are stale by definition. Remove them - // after the planned reverse-order resources, with a lexical tie-break for deterministic output. - const known = new Set(input.plan.workloads.map(({ id }) => id)); - const stale = input.observed - .filter((entry) => !known.has(entry.workloadId) && entry.state !== "absent") - .sort( - (left, right) => - left.workloadId.localeCompare(right.workloadId) || - left.specHash.localeCompare(right.specHash), - ); - for (const entry of stale) { - if (entry.state === "ready" || entry.state === "starting") { - stops.push({ - kind: "stop", - key: keyFor(input.stackId, entry.workloadId, entry.specHash), - }); - } - removeEntry(entry); - } - - return { starts, stops, removes, blocked }; -}; diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index eb7e3e3a4f..812b8a18f2 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -33,7 +33,7 @@ interface SupervisorIngressReservation extends PortReservation { } export interface SupervisorIngress { - /** Reserve durable ports and bind public listeners before workload reconciliation. */ + /** Reserve durable ports and bind public listeners before workload launch. */ readonly acquire: ( input: LifecycleInput, ) => Effect.Effect; diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index a0cbb8a63b..60427b2846 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, Exit, FileSystem, Path, Redacted } from "effect"; +import { Cause, Crypto, Effect, Exit, FileSystem, Path, Redacted } from "effect"; import type { StackDefinition, CompiledStack, SecretSlotInput } from "../model/Compiler.ts"; import { compileStack, rebuildExecutionPlan, sameDefinition } from "../model/Compiler.ts"; import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; @@ -252,15 +252,30 @@ export const makeLifecycleController = ( message: "Running stack secrets changed; stop the stack before applying them", guidance: "Use stop() followed by start() to apply stopped-time changes", }); - if (startOptions?.freshSession === true) + const freshSession = startOptions?.freshSession === true; + if (freshSession) yield* options.backend.preflight( lifecycleInput(options.stackId, initial, candidate), "cold", ); - yield* options.backend.reconcile( - lifecycleInput(options.stackId, initial, candidate), - startOptions?.freshSession === true ? "fresh" : "current", - ); + const reconciled = yield* options.backend + .reconcile( + lifecycleInput(options.stackId, initial, candidate), + freshSession ? "fresh" : "current", + ) + .pipe(Effect.exit); + if (Exit.isFailure(reconciled) && freshSession) { + const stopped = { ...initial, desiredLifecycle: "stopped" as const }; + const persisted = yield* options.stateStore + .replace(options.stackId, stopped) + .pipe(Effect.exit); + const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); + let cause = reconciled.cause; + if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); + if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); + return yield* Effect.failCause(cause); + } + if (Exit.isFailure(reconciled)) return yield* Effect.failCause(reconciled.cause); return initial; } @@ -270,15 +285,35 @@ export const makeLifecycleController = ( ); const next = stateWithCandidate(initial, candidate, "running"); yield* options.stateStore.replace(options.stackId, next); - yield* options.backend.reconcile(lifecycleInput(options.stackId, next, candidate), "fresh"); - return next; + const started = yield* options.backend + .reconcile(lifecycleInput(options.stackId, next, candidate), "fresh") + .pipe(Effect.exit); + if (Exit.isSuccess(started)) return next; + + // A failed cold launch never leaves a durable running intent behind. Cleanup is attempted + // before publishing the stopped state; if cleanup is not proven, the stopped fence remains + // durable and the Supervisor stays available for an explicit retry. + const stopped = { ...next, desiredLifecycle: "stopped" as const }; + const persisted = yield* options.stateStore + .replace(options.stackId, stopped) + .pipe(Effect.exit); + const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); + let cause = started.cause; + if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); + if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); + return yield* Effect.failCause(cause); }); }; const stop = (): Effect.Effect => Effect.gen(function* () { const current = yield* read(); - if (current.desiredLifecycle === "unconfigured") return current; + if (current.desiredLifecycle === "unconfigured") { + // Even an unconfigured stack may have exact runtime remnants from an interrupted + // first start. Stop is the explicit retry boundary for that cleanup. + yield* options.backend.cleanup; + return current; + } if (current.desiredLifecycle === "destroying") return yield* lifecycleConflict("Stack is being destroyed"); const candidate = yield* Effect.exit( diff --git a/packages/stack/src/supervisor/Reconciler.ts b/packages/stack/src/supervisor/Reconciler.ts deleted file mode 100644 index 37a4af442a..0000000000 --- a/packages/stack/src/supervisor/Reconciler.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { Cause, Effect, Exit, Option, Schedule, Semaphore } from "effect"; -import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; -import type { StackId } from "../public/StackId.ts"; -import { - planDesiredState, - type DesiredLifecycle, - type ReconciliationInput, -} from "./DesiredState.ts"; -import { - RuntimeDriverError, - RuntimeRestartBudgetExceededError, - type ObservedWorkload, - type RuntimeDriver, -} from "../runtime/RuntimeDriver.ts"; - -interface ReconcilerRequest { - readonly stackId: StackId; - readonly desiredLifecycle: DesiredLifecycle; - readonly plan: ExecutionPlan; -} - -export interface ReconcilerOptions { - readonly driver: RuntimeDriver; -} - -interface ReconciliationResult { - readonly observed: ReadonlyArray; - readonly started: ReadonlyArray; - readonly stopped: ReadonlyArray; - readonly removed: ReadonlyArray; - readonly failed: ReadonlyArray<{ - readonly workloadId: string; - readonly error: RuntimeDriverError | RuntimeRestartBudgetExceededError; - }>; - readonly blocked: ReadonlyArray<{ readonly workloadId: string; readonly dependencyId: string }>; -} - -export interface Reconciler { - readonly reconcile: ( - request: ReconcilerRequest, - ) => Effect.Effect; -} - -type ReconcilerError = RuntimeDriverError | RuntimeRestartBudgetExceededError; - -type AttemptFailure = RuntimeDriverError; - -/** Internal retry sentinel that keeps the complete failed attempt Cause intact. */ -class RuntimeAttemptControlError extends RuntimeDriverError { - readonly originalCause: Cause.Cause; - readonly retryable: boolean; - - constructor(cause: Cause.Cause, retryable: boolean) { - super({ message: "runtime start attempt failed" }); - this.originalCause = cause; - this.retryable = retryable; - } -} - -/** - * Creates a Supervisor-local reconciler. The semaphore serializes every lifecycle mutation. The - * Supervisor is responsible for forking this effect in its owner Scope; callers waiting on an RPC - * must not become lifecycle owners or cancel the owner-managed operation. - */ -export const makeReconciler = (options: ReconcilerOptions): Effect.Effect => - Effect.gen(function* () { - const lifecycle = yield* Semaphore.make(1); - // Restart budgets are Supervisor-local and reset when lifecycle enters stopped. - const restartAttempts = new Map(); - - const reconcile = ( - request: ReconcilerRequest, - ): Effect.Effect => - lifecycle.withPermit( - Effect.gen(function* () { - if (request.desiredLifecycle !== "running") { - const prefix = `${request.stackId}:`; - for (const key of restartAttempts.keys()) - if (key.startsWith(prefix)) restartAttempts.delete(key); - } - const observed = yield* options.driver.observe(request.stackId); - const desired: ReconciliationInput = { ...request, observed }; - const delta = planDesiredState(desired); - const failed: Array = []; - const started: string[] = []; - const stopped: string[] = []; - const removed: string[] = []; - const failedIds = new Set(); - const blocked = [...delta.blocked]; - const addBlocked = (workloadId: string, dependencyId: string) => { - if (!blocked.some((entry) => entry.workloadId === workloadId)) - blocked.push({ workloadId, dependencyId }); - }; - - const runMutation = ( - effect: Effect.Effect, - ): Effect.Effect => - Effect.gen(function* () { - const result = yield* effect.pipe(Effect.exit); - if (Exit.isSuccess(result)) return result.value; - return yield* Effect.failCause(result.cause); - }); - - const quiesceDependents = () => - Effect.gen(function* () { - const current = yield* options.driver.observe(request.stackId); - const affected: Array<{ - readonly workload: (typeof request.plan.workloads)[number]; - readonly dependencyId: string; - }> = []; - // First compute the complete transitive closure in topological order. A reverse pass - // alone would visit leaf before its parent had been marked failed. - for (const workload of request.plan.workloads) { - const dependencyFailure = workload.dependencies.find((dependency) => - failedIds.has(dependency), - ); - if (dependencyFailure === undefined) continue; - failedIds.add(workload.id); - addBlocked(workload.id, dependencyFailure); - affected.push({ workload, dependencyId: dependencyFailure }); - } - // Stop and remove affected resources in reverse topological order. - for (const { workload } of affected.reverse()) { - const observed = current.find(({ workloadId }) => workloadId === workload.id); - if ( - observed === undefined || - (observed.state !== "ready" && observed.state !== "starting") - ) - continue; - const key = { - stackId: request.stackId, - workloadId: observed.workloadId, - specHash: observed.specHash, - }; - yield* runMutation(options.driver.stop(key)); - stopped.push(key.workloadId); - yield* runMutation(options.driver.remove(key)); - removed.push(key.workloadId); - } - }); - - for (const action of delta.stops) { - yield* runMutation(options.driver.stop(action.key)); - stopped.push(action.key.workloadId); - } - for (const action of delta.removes) { - const observedEntry = observed.find( - (entry) => - entry.stackId === action.key.stackId && - entry.workloadId === action.key.workloadId && - entry.specHash === action.key.specHash, - ); - const workload = request.plan.workloads.find(({ id }) => id === action.key.workloadId); - const budgetKey = `${request.stackId}:${action.key.workloadId}`; - const attempts = restartAttempts.get(budgetKey); - const preserveExhaustedFailure = - observedEntry?.state === "failed" && - workload !== undefined && - attempts !== undefined && - attempts >= Math.max(1, workload.restart.maxAttempts); - // Keep a terminal failed resource visible to status. Removing it would make an - // exhausted post-readiness crash look dormant/stopped and lose the diagnostic error. - if (preserveExhaustedFailure) continue; - yield* runMutation(options.driver.remove(action.key)); - removed.push(action.key.workloadId); - } - - for (const action of delta.starts) { - const dependencyFailure = action.workload.dependencies.find((dependency) => - failedIds.has(dependency), - ); - if (dependencyFailure !== undefined) { - failedIds.add(action.workload.id); - addBlocked(action.workload.id, dependencyFailure); - continue; - } - const maxAttempts = Math.max(1, action.workload.restart.maxAttempts); - const budgetKey = `${request.stackId}:${action.workload.id}`; - const previousAttempts = restartAttempts.get(budgetKey); - if (previousAttempts !== undefined && previousAttempts >= maxAttempts) { - failedIds.add(action.workload.id); - failed.push({ - workloadId: action.workload.id, - error: new RuntimeRestartBudgetExceededError({ - message: `Restart budget exhausted for ${action.workload.id}`, - stackId: request.stackId, - workloadId: action.workload.id, - attempts: previousAttempts, - }), - }); - yield* quiesceDependents(); - continue; - } - const hadReadyFailure = observed.some( - (entry) => - entry.workloadId === action.workload.id && - entry.specHash === action.workload.specHash && - entry.state === "failed", - ); - if (hadReadyFailure) { - const attempts = restartAttempts.get(budgetKey) ?? 0; - if (attempts >= maxAttempts) { - restartAttempts.set(budgetKey, attempts); - failedIds.add(action.workload.id); - failed.push({ - workloadId: action.workload.id, - error: new RuntimeRestartBudgetExceededError({ - message: `Restart budget exhausted for ${action.workload.id}`, - stackId: request.stackId, - workloadId: action.workload.id, - attempts, - }), - }); - yield* quiesceDependents(); - continue; - } - restartAttempts.set(budgetKey, attempts + 1); - } - const attempt = options.driver.start(action.key, action.workload); - const schedule: Schedule.Schedule = - Schedule.exponential(`${Math.max(0, action.workload.restart.backoffMs)} millis`).pipe( - Schedule.upTo({ times: Math.max(0, maxAttempts - 1) }), - ); - const attempted: Effect.Effect = - runMutation(attempt).pipe( - Effect.catchCause((cause: Cause.Cause) => { - const reason = cause.reasons.length === 1 ? cause.reasons[0] : undefined; - const failure = - reason !== undefined && Cause.isFailReason(reason) ? reason.error : undefined; - const retryable = failure !== undefined; - return Effect.fail(new RuntimeAttemptControlError(cause, retryable)); - }), - ); - const retryOptions: Effect.Retry.Options = { - schedule, - while: (error) => error.retryable, - }; - const outcome: Exit.Exit = - yield* Effect.exit(Effect.retry(attempted, retryOptions)); - if (Exit.isSuccess(outcome)) { - started.push(action.workload.id); - continue; - } - // A mixed Cause is not a retryable typed failure: preserving the complete Cause keeps - // defects and interruption visible to the Supervisor instead of turning them into a - // misleading budget-exhaustion status. - const maybeError = Cause.findErrorOption(outcome.cause); - if (Option.isNone(maybeError)) return yield* Effect.failCause(outcome.cause); - const error = maybeError.value; - if (Cause.hasDies(error.originalCause) || Cause.hasInterrupts(error.originalCause)) - return yield* Effect.failCause(error.originalCause); - const original = Cause.findErrorOption(error.originalCause); - if (Option.isNone(original)) return yield* Effect.failCause(error.originalCause); - failedIds.add(action.workload.id); - restartAttempts.set(budgetKey, maxAttempts); - failed.push({ workloadId: action.workload.id, error: original.value }); - yield* quiesceDependents(); - } - - const current = yield* options.driver.observe(request.stackId); - return { - observed: current, - started, - stopped, - removed, - failed, - blocked, - }; - }), - ); - - return { reconcile }; - }); diff --git a/packages/stack/src/supervisor/Reconciler.unit.test.ts b/packages/stack/src/supervisor/Reconciler.unit.test.ts deleted file mode 100644 index 85324368ae..0000000000 --- a/packages/stack/src/supervisor/Reconciler.unit.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; -import { StackIdSchema } from "../public/StackId.ts"; -import { planDesiredState } from "./DesiredState.ts"; -import type { ObservedWorkload } from "../runtime/RuntimeDriver.ts"; - -const stackId = StackIdSchema.make("a".repeat(64)); - -const workload = ( - id: string, - dependencies: ReadonlyArray = [], - specHash = id, -): PlannedWorkload => ({ - id, - capability: "database", - dependencies, - readiness: { mode: "tcp" }, - restart: { maxAttempts: 2, backoffMs: 0 }, - artifacts: { - native: { kind: "native", release: "test" }, - container: { kind: "container", image: `test/${id}` }, - }, - selected: { kind: "native", release: "test" }, - specHash, -}); - -const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ - runtime: { kind: "native" }, - activation: { - database: "eager", - rest: "eager", - auth: "eager", - realtime: "eager", - storage: "eager", - functions: "eager", - studio: "eager", - mail: "eager", - analytics: "eager", - pooler: "eager", - }, - startOrder: ["database"], - dependencies: { - database: [], - rest: [], - auth: [], - realtime: [], - storage: [], - functions: [], - studio: [], - mail: [], - analytics: [], - pooler: [], - }, - routes: [], - workloads, -}); - -const observed = ( - workloadId: string, - state: ObservedWorkload["state"], - specHash = workloadId, -): ObservedWorkload => ({ - stackId, - workloadId, - specHash, - state, -}); - -describe("desired workload planning", () => { - it("starts in topological order and stops/removes in reverse order", () => { - const workloads = [workload("a"), workload("b", ["a"]), workload("c", ["b"])]; - const plan = planFor(workloads); - const running = planDesiredState({ - stackId, - desiredLifecycle: "running", - plan, - observed: [], - }); - expect(running.starts.map(({ key }) => key.workloadId)).toEqual(["a", "b", "c"]); - - const stopped = planDesiredState({ - stackId, - desiredLifecycle: "stopped", - plan, - observed: workloads.map(({ id }) => observed(id, "ready")), - }); - expect(stopped.stops.map(({ key }) => key.workloadId)).toEqual(["c", "b", "a"]); - expect(stopped.removes.map(({ key }) => key.workloadId)).toEqual(["c", "b", "a"]); - - const destroying = planDesiredState({ - stackId, - desiredLifecycle: "destroying", - plan, - observed: workloads.map(({ id }) => observed(id, "ready")), - }); - expect(destroying.stops.map(({ key }) => key.workloadId)).toEqual(["c", "b", "a"]); - }); - - it("replaces a stale workload spec before starting the accepted key", () => { - const plan = planFor([workload("a", [], "new-hash")]); - const delta = planDesiredState({ - stackId, - desiredLifecycle: "running", - plan, - observed: [observed("a", "ready", "old-hash")], - }); - expect(delta.stops.map(({ key }) => key)).toEqual([ - { stackId, workloadId: "a", specHash: "old-hash" }, - ]); - expect(delta.removes.map(({ key }) => key)).toEqual([ - { stackId, workloadId: "a", specHash: "old-hash" }, - ]); - expect(delta.starts.map(({ key }) => key)).toEqual([ - { stackId, workloadId: "a", specHash: "new-hash" }, - ]); - }); - - it("cleans every duplicate exact resource child-first", () => { - const plan = planFor([workload("a"), workload("b", ["a"])]); - const delta = planDesiredState({ - stackId, - desiredLifecycle: "running", - plan, - observed: [ - observed("a", "ready", "old-a"), - observed("a", "ready", "a"), - observed("b", "ready", "old-b"), - observed("b", "ready", "b"), - ], - }); - expect(delta.starts).toHaveLength(0); - expect(delta.stops.map(({ key }) => key.workloadId)).toEqual(["b", "a"]); - expect(delta.removes.map(({ key }) => key.workloadId)).toEqual(["b", "a"]); - expect(delta.removes.map(({ key }) => key.specHash)).toEqual(["old-b", "old-a"]); - }); - - it("blocks only dependents of failed workloads", () => { - const plan = planFor([ - workload("root-failed"), - workload("dependent", ["root-failed"]), - workload("independent"), - ]); - const delta = planDesiredState({ - stackId, - desiredLifecycle: "running", - plan, - observed: [observed("root-failed", "failed")], - }); - expect(delta.starts.map(({ key }) => key.workloadId)).toEqual([ - "root-failed", - "dependent", - "independent", - ]); - expect(delta.blocked).toEqual([]); - }); -}); diff --git a/packages/stack/src/supervisor/SessionLauncher.ts b/packages/stack/src/supervisor/SessionLauncher.ts new file mode 100644 index 0000000000..f39d8620a5 --- /dev/null +++ b/packages/stack/src/supervisor/SessionLauncher.ts @@ -0,0 +1,95 @@ +import { Cause, Effect, Exit, Ref } from "effect"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { StackId } from "../public/StackId.ts"; +import { + RuntimeDriverError, + type RuntimeDriver, + type RuntimeWorkloadKey, +} from "../runtime/RuntimeDriver.ts"; + +interface SessionWorkload { + readonly key: RuntimeWorkloadKey; + readonly workload: PlannedWorkload; +} + +export interface SessionLauncher { + /** Starts the supplied dependency closure in topological order. */ + readonly launch: (plan: ExecutionPlan) => Effect.Effect; + /** Stops and removes every workload started in this session in reverse order. */ + readonly stop: Effect.Effect; + /** Clears the session after stack-wide runtime cleanup has completed. */ + readonly clear: Effect.Effect; +} + +const keyFor = (stackId: StackId, workload: PlannedWorkload): RuntimeWorkloadKey => ({ + stackId, + workloadId: workload.id, +}); + +const combine = ( + primary: Cause.Cause, + cleanup: Cause.Cause, +): Cause.Cause => + cleanup.reasons.length === 0 ? primary : Cause.combine(primary, cleanup); + +/** + * Owns only the workloads started by the current Supervisor session. A launch is one ordered + * attempt and a failure cleans that attempt's resources. + */ +export const makeSessionLauncher = (options: { + readonly stackId: StackId; + readonly driver: RuntimeDriver; +}): Effect.Effect => + Effect.gen(function* () { + const session = yield* Ref.make>([]); + + const cleanup = ( + entries: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + let cleanupCause: Cause.Cause = Cause.empty; + for (const entry of [...entries].reverse()) { + const stopped = yield* Effect.exit(options.driver.stop(entry.key)); + if (Exit.isFailure(stopped)) cleanupCause = combine(cleanupCause, stopped.cause); + const removed = yield* Effect.exit(options.driver.remove(entry.key)); + if (Exit.isFailure(removed)) cleanupCause = combine(cleanupCause, removed.cause); + else + yield* Ref.update(session, (current) => + current.filter((candidate) => candidate.key.workloadId !== entry.key.workloadId), + ); + } + if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); + }); + + const launch = (plan: ExecutionPlan): Effect.Effect => + Effect.gen(function* () { + const existing = new Set((yield* Ref.get(session)).map(({ key }) => key.workloadId)); + const attempted: SessionWorkload[] = []; + const outcome = yield* Effect.exit( + Effect.forEach( + plan.workloads, + (workload) => + Effect.gen(function* () { + if (existing.has(workload.id)) return; + const entry = { key: keyFor(options.stackId, workload), workload }; + attempted.push(entry); + yield* options.driver.start(entry.key, workload); + yield* Ref.update(session, (current) => [...current, entry]); + }), + { concurrency: 1, discard: true }, + ), + ); + if (Exit.isSuccess(outcome)) return; + const cleaned = yield* cleanup(attempted).pipe(Effect.exit); + if (Exit.isFailure(cleaned)) + return yield* Effect.failCause(combine(outcome.cause, cleaned.cause)); + return yield* Effect.failCause(outcome.cause); + }); + + const stop = Effect.suspend(() => Ref.get(session).pipe(Effect.flatMap(cleanup))); + return { + launch, + stop, + clear: Ref.set(session, []), + } satisfies SessionLauncher; + }); diff --git a/packages/stack/src/supervisor/StatusProjection.ts b/packages/stack/src/supervisor/StatusProjection.ts index 508c5e7ab9..042f6db959 100644 --- a/packages/stack/src/supervisor/StatusProjection.ts +++ b/packages/stack/src/supervisor/StatusProjection.ts @@ -62,9 +62,9 @@ const stackLifecycle = ( phase: ActualPhase, activeStates: ReadonlyArray, ): StackStatus["lifecycle"] => { + if (phase === "stopping") return "stopping"; if (state.desiredLifecycle === "unconfigured") return "unconfigured"; if (phase === "destroying" || state.desiredLifecycle === "destroying") return "destroying"; - if (phase === "stopping") return "stopping"; if (phase === "starting") return "starting"; if (state.desiredLifecycle === "stopped") return "stopped"; if (phase === "running") return "running"; diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 96e867aef1..5078804a92 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -1,4 +1,5 @@ import { + Cause, Context, Crypto, Deferred, @@ -12,7 +13,6 @@ import { Ref, Semaphore, Scope, - Stream, } from "effect"; import type { StackIdentity } from "../identity/Identity.ts"; import { rebuildExecutionPlan } from "../model/Compiler.ts"; @@ -37,10 +37,10 @@ import type { StackStatus } from "../public/Status.ts"; import type { StackId } from "../public/StackId.ts"; import type { LogQuery, StackLogBatch } from "../public/Logs.ts"; import type { EffectStackCredentials } from "../public/Credentials.ts"; -import type { RuntimeDriver, ObservedWorkload } from "../runtime/RuntimeDriver.ts"; +import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; -import { makeReconciler, type Reconciler } from "./Reconciler.ts"; +import { makeSessionLauncher, type SessionLauncher } from "./SessionLauncher.ts"; import { makeLifecycleController, type LifecycleBackend, @@ -157,11 +157,15 @@ export const makeSupervisor = ( options.runtimeFactory !== undefined ? yield* options.runtimeFactory.make(initial) : options.runtime; - const reconciler: Reconciler = yield* makeReconciler({ + const launcher: SessionLauncher = yield* makeSessionLauncher({ + stackId: options.stackId, driver: runtime.driver, }); const active = yield* Ref.make>(new Set()); const phase = yield* Ref.make("stopped"); + // A failed cleanup leaves the owner in `stopping` so callers can retry an exact cleanup. + // This marker is set by the backend cleanup boundary and read only by start failure handling. + const cleanupProven = yield* Ref.make(true); // A Supervisor created after a crash must clean exact runtime ephemera once before its first // fresh start. The marker is session-local and only advances after cleanup succeeds. const sessionInitialized = yield* Ref.make(false); @@ -179,6 +183,7 @@ export const makeSupervisor = ( const resetForSession = (input: LifecycleInput) => Effect.gen(function* () { activationOwned.clear(); + yield* launcher.clear; yield* initializeActivation(input.plan); }); const observe = () => @@ -217,10 +222,9 @@ export const makeSupervisor = ( }); // Admission rejects every overlapping lifecycle operation while execution serializes - // lifecycle and activation work against background failure supervision. + // lifecycle and activation work against runtime access. const admission = yield* Semaphore.make(1); - // Background failure supervision shares the execution gate with explicit lifecycle operations. - // This prevents a failure event from reconciling against a concurrently stopping lifecycle. + // Activation and lifecycle operations share one execution gate so they cannot race cleanup. const execution = yield* Semaphore.make(1); const supervisorScope = yield* Effect.scope; const ownedFibers = yield* FiberSet.make().pipe( @@ -281,6 +285,7 @@ export const makeSupervisor = ( // cannot make the next lifecycle request look like a conflict. yield* release; yield* Deferred.succeed(deferred, result); + yield* shutdownIfIdle; }).pipe(Effect.ensuring(release)); yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); return deferred; @@ -296,74 +301,63 @@ export const makeSupervisor = ( return yield* joinExit(result); }); - const reconcileBackend = ( + const launchBackend = ( input: LifecycleInput, session: "fresh" | "current", selectedOverride?: ReadonlySet, ): Effect.Effect => Effect.gen(function* () { if (session === "fresh") yield* resetForSession(input); - const reservation = - input.desiredLifecycle === "running" ? yield* runtime.ingress.acquire(input) : undefined; - if (input.desiredLifecycle !== "running") yield* runtime.ingress.close; - if (session === "fresh") yield* Ref.set(sessionInitialized, true); + if (input.desiredLifecycle !== "running") return; const selected = selectedOverride ?? (yield* Ref.get(active)); - const plan = - input.desiredLifecycle === "running" - ? activeExecutionPlan(input.plan, selected) - : input.plan; - const reconciled = yield* reconciler - .reconcile({ - stackId: options.stackId, - desiredLifecycle: input.desiredLifecycle, - plan, - }) + const plan = activeExecutionPlan(input.plan, selected); + const reservation = yield* runtime.ingress.acquire(input); + const launched = yield* launcher + .launch(plan) .pipe(Effect.mapError(mapReconcileError), Effect.exit); - if (Exit.isFailure(reconciled)) { - if (reservation?.fresh === true) yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* Effect.failCause(reconciled.cause); + if (Exit.isFailure(launched)) { + if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* Effect.failCause(launched.cause); } - const result = reconciled.value; - if (reservation !== undefined) { - const opened = yield* runtime.ingress - .open(input, reservation, ingressActivate) - .pipe(Effect.exit); - if (Exit.isFailure(opened)) { - if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* Effect.failCause(opened.cause); - } - } - if (result.failed.length > 0) { - // Keep independent workloads and the gateway reachable. The failed capability remains - // visible through status while start reports a typed reconciliation failure. - yield* Ref.set(phase, "running"); - yield* publish().pipe(Effect.ignore); - return yield* new StackReconciliationError({ - message: result.failed - .map(({ workloadId, error }) => `${workloadId}: ${error.message}`) - .join("; "), - }); + const opened = yield* runtime.ingress + .open(input, reservation, ingressActivate) + .pipe(Effect.exit); + if (Exit.isFailure(opened)) { + if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); + return yield* Effect.failCause(opened.cause); } + if (session === "fresh") yield* Ref.set(sessionInitialized, true); yield* publish(); }); + const cleanupRuntime = (destroy: boolean): Effect.Effect => + Effect.gen(function* () { + yield* Ref.set(cleanupProven, true); + const ingress = yield* runtime.ingress.close.pipe( + Effect.mapError(mapReconcileError), + Effect.exit, + ); + const launched = yield* launcher.stop.pipe(Effect.mapError(mapReconcileError), Effect.exit); + const driver = yield* runtime.driver + .cleanup({ stackId: options.stackId, destroy }) + .pipe(Effect.mapError(mapReconcileError), Effect.exit); + let cause: Cause.Cause = Cause.empty; + for (const result of [ingress, launched, driver]) + if (Exit.isFailure(result)) cause = Cause.combine(cause, result.cause); + if (cause.reasons.length > 0) { + yield* Ref.set(cleanupProven, false); + return yield* Effect.failCause(cause); + } + if (!destroy) { + yield* Ref.set(active, new Set()); + yield* publish(); + } + }); const backend: LifecycleBackend = { preflight: runtime.preflight, - reconcile: reconcileBackend, - cleanup: Effect.gen(function* () { - yield* runtime.ingress.close; - yield* runtime.driver - .cleanup({ stackId: options.stackId, destroy: false }) - .pipe(Effect.mapError(mapReconcileError)); - yield* Ref.set(active, new Set()); - yield* publish(); - }), - destroyData: Effect.gen(function* () { - yield* runtime.ingress.close; - yield* runtime.driver - .cleanup({ stackId: options.stackId, destroy: true }) - .pipe(Effect.mapError(mapReconcileError)); - }), + reconcile: launchBackend, + cleanup: cleanupRuntime(false), + destroyData: cleanupRuntime(true), }; const controller = yield* makeLifecycleController({ stackId: options.stackId, @@ -416,53 +410,16 @@ export const makeSupervisor = ( secrets: state.secrets, plan, }; - // Activation is accepted for this lifecycle session before reconciliation begins. This keeps a - // failed lazy start visible as failed rather than leaving the capability dormant. + // Activation is accepted for this lifecycle session before launching its dependency closure. + // A failed attempt removes only newly-created resources; the capability remains retryable. yield* Ref.set(active, next); - yield* reconcileBackend(input, "current", next); + yield* launchBackend(input, "current", next); yield* Ref.set(phase, "running"); yield* publish().pipe(Effect.ignore); const endpoint = yield* runtime.activate(capability, input); return { capability, endpoint }; }); - const superviseFailure = (failure: ObservedWorkload): Effect.Effect => - execution - .withPermit( - Effect.gen(function* () { - // Explicit lifecycle transitions own the execution gate. Failure events are consumed - // only while the accepted lifecycle is fully running; stopping sessions - // are fenced by the durable lifecycle and therefore skipped safely. - if ((yield* Ref.get(phase)) !== "running") return; - const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); - if ( - state === undefined || - state.desiredLifecycle !== "running" || - failure.state !== "failed" || - state.definition === undefined - ) - return; - const plan = yield* rebuildExecutionPlan(state.runtime, state.definition).pipe( - Effect.provideContext(options.context), - Effect.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), - ), - Effect.orElseSucceed(() => undefined), - ); - if (plan === undefined) return; - const input: LifecycleInput = { - stackId: options.stackId, - desiredLifecycle: "running", - state, - definition: state.definition, - secrets: state.secrets, - plan, - }; - yield* reconcileBackend(input, "current").pipe(Effect.ignore); - }), - ) - .pipe(Effect.ignoreCause); - const activationOwned = new Map< CapabilityName, | { @@ -566,19 +523,28 @@ export const makeSupervisor = ( const previous = yield* Ref.get(phase); const freshSession = !(yield* Ref.get(sessionInitialized)); if (freshSession || previous === "stopping") { - yield* backend.cleanup; + const cleaned = yield* backend.cleanup.pipe(Effect.exit); + if (Exit.isFailure(cleaned)) { + yield* Ref.set(phase, "stopping"); + yield* publish().pipe(Effect.ignore); + return yield* Effect.failCause(cleaned.cause); + } yield* Ref.set(phase, "starting"); yield* publish().pipe(Effect.ignore); } - yield* controller + const started = yield* controller .start({ config: startOptions?.config, freshSession, }) - .pipe( - Effect.provideContext(options.context), - Effect.tapError(() => restorePhase(previous)), - ); + .pipe(Effect.provideContext(options.context), Effect.exit); + if (Exit.isFailure(started)) { + if (freshSession) { + yield* Ref.set(phase, (yield* Ref.get(cleanupProven)) ? "stopped" : "stopping"); + yield* publish().pipe(Effect.ignore); + } else yield* restorePhase(previous); + return yield* Effect.failCause(started.cause); + } yield* Ref.set(phase, "running"); yield* publish(); }); @@ -817,11 +783,6 @@ export const makeSupervisor = ( destroy: () => operation(destroy), logs: (query: LogQuery) => operation(logs(query)), }); - yield* FiberSet.run( - ownedFibers, - runtime.driver.watchFailures.pipe(Stream.runForEach(superviseFailure)), - { startImmediately: true }, - ); return { identity: options.identity, stackId: options.stackId, diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index 203a31ba26..f0c9e7256a 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -35,6 +35,7 @@ interface BackendState { readonly calls: Array; readonly preflight: Array; failPreflight?: boolean; + failReconcile?: boolean; failDestroyData?: boolean; failCleanupOnce?: boolean; gate?: Deferred.Deferred; @@ -69,6 +70,8 @@ const backend = (state: BackendState): LifecycleBackend => ({ if (state.stopReconcileGate !== undefined) yield* Deferred.await(state.stopReconcileGate); } if (state.waitBeforeReconcile !== undefined) yield* Deferred.await(state.waitBeforeReconcile); + if (state.failReconcile) + return yield* new StackReconciliationError({ message: "reconcile failed" }); }), cleanup: Effect.gen(function* () { state.calls.push(`cleanup:${state.lastLifecycle ?? "invalid"}`); @@ -155,6 +158,21 @@ describe("durable lifecycle controller", () => { ), ); + it.live("persists stopped when a fresh running session cannot launch", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.start(); + fixture.state.failReconcile = true; + + const failed = yield* fixture.controller.start({ freshSession: true }).pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackReconciliationError); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + expect(fixture.state.calls.at(-1)).toBe("cleanup:running"); + }), + ), + ); + it.live("accepts explicit materialized defaults for a running stack", () => run( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/reconciler.integration.test.ts b/packages/stack/src/supervisor/reconciler.integration.test.ts deleted file mode 100644 index ceef090b08..0000000000 --- a/packages/stack/src/supervisor/reconciler.integration.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Ref, Stream } from "effect"; -import { RuntimeDriverError, RuntimeRestartBudgetExceededError } from "../runtime/RuntimeDriver.ts"; -import type { RuntimeDriver, ObservedWorkload } from "../runtime/RuntimeDriver.ts"; -import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; -import { StackIdSchema } from "../public/StackId.ts"; -import { makeReconciler } from "./Reconciler.ts"; - -const stackId = StackIdSchema.make("b".repeat(64)); -const workload = ( - id: string, - dependencies: ReadonlyArray = [], - restart = { maxAttempts: 1, backoffMs: 0 }, -): PlannedWorkload => ({ - id, - capability: "database", - dependencies, - readiness: { mode: "tcp" }, - restart, - artifacts: { - native: { kind: "native", release: "test" }, - container: { kind: "container", image: `test/${id}` }, - }, - selected: { kind: "native", release: "test" }, - specHash: id, -}); -const planFor = (workloads: ReadonlyArray): ExecutionPlan => ({ - runtime: { kind: "native" }, - activation: { - database: "eager", - rest: "eager", - auth: "eager", - realtime: "eager", - storage: "eager", - functions: "eager", - studio: "eager", - mail: "eager", - analytics: "eager", - pooler: "eager", - }, - startOrder: ["database"], - dependencies: { - database: [], - rest: [], - auth: [], - realtime: [], - storage: [], - functions: [], - studio: [], - mail: [], - analytics: [], - pooler: [], - }, - routes: [], - workloads, -}); - -const fakeDriver = (options: { - readonly fail?: ReadonlySet; - readonly never?: ReadonlySet; -}) => - Effect.gen(function* () { - const resources = yield* Ref.make>([]); - const starts = yield* Ref.make>([]); - const driver: RuntimeDriver = { - watchFailures: Stream.empty, - observe: () => Ref.get(resources), - start: (key, entry) => - Effect.gen(function* () { - yield* Ref.update(starts, (current) => [...current, entry.id]); - if (options.never?.has(entry.id)) return yield* Effect.never; - if (options.fail?.has(entry.id)) - return yield* new RuntimeDriverError({ - message: `failed ${entry.id}`, - stackId: key.stackId, - workloadId: entry.id, - }); - const ready: ObservedWorkload = { ...key, state: "ready" }; - yield* Ref.update(resources, (current) => [ - ...current.filter(({ workloadId }) => workloadId !== key.workloadId), - ready, - ]); - return ready; - }), - stop: (key) => - Ref.update(resources, (current) => - current.map((entry) => - entry.workloadId === key.workloadId ? { ...entry, state: "stopped" } : entry, - ), - ), - remove: (key) => - Ref.update(resources, (current) => - current.filter((entry) => entry.workloadId !== key.workloadId), - ), - cleanup: () => Effect.void, - }; - return { driver, resources, starts }; - }); - -describe("stack reconciler", () => { - it.live("keeps independent branches running and blocks failed dependents", () => - Effect.gen(function* () { - const { driver, starts } = yield* fakeDriver({ fail: new Set(["root"]) }); - const reconciler = yield* makeReconciler({ driver }); - const result = yield* reconciler.reconcile({ - stackId, - desiredLifecycle: "running", - plan: planFor([workload("root"), workload("dependent", ["root"]), workload("independent")]), - }); - expect(yield* Ref.get(starts)).toEqual(["root", "independent"]); - expect(result.failed.map(({ workloadId }) => workloadId)).toEqual(["root"]); - expect(result.blocked).toEqual([{ workloadId: "dependent", dependencyId: "root" }]); - }), - ); - - it.live("quiesces an already-ready dependent when its dependency exhausts", () => - Effect.gen(function* () { - const { driver, resources } = yield* fakeDriver({ fail: new Set(["root"]) }); - yield* Ref.set(resources, [ - { - stackId, - workloadId: "dependent", - specHash: "dependent", - state: "ready", - }, - { - stackId, - workloadId: "leaf", - specHash: "leaf", - state: "ready", - }, - { - stackId, - workloadId: "independent", - specHash: "independent", - state: "ready", - }, - ]); - const reconciler = yield* makeReconciler({ - driver, - }); - const result = yield* reconciler.reconcile({ - stackId, - desiredLifecycle: "running", - plan: planFor([ - workload("root"), - workload("dependent", ["root"]), - workload("leaf", ["dependent"]), - workload("independent"), - ]), - }); - expect(result.stopped).toContain("dependent"); - expect(result.removed).toContain("dependent"); - expect(result.stopped).toContain("leaf"); - expect(result.removed).toContain("leaf"); - expect((yield* Ref.get(resources)).map(({ workloadId }) => workloadId)).toContain( - "independent", - ); - }), - ); - - it.live("bounds repeated post-readiness crashes and resets after stop", () => - Effect.gen(function* () { - const resources = yield* Ref.make>([]); - const starts = yield* Ref.make(0); - const driver: RuntimeDriver = { - watchFailures: Stream.empty, - observe: () => Ref.get(resources), - start: (key) => - Effect.gen(function* () { - yield* Ref.update(starts, (count) => count + 1); - const ready = { ...key, state: "ready" as const }; - yield* Ref.set(resources, [ready]); - return ready; - }), - stop: () => Ref.set(resources, []), - remove: () => Effect.void, - cleanup: () => Effect.void, - }; - const reconciler = yield* makeReconciler({ - driver, - }); - const request = { - stackId, - desiredLifecycle: "running" as const, - plan: planFor([workload("post-ready", [], { maxAttempts: 2, backoffMs: 0 })]), - }; - const key = { - stackId, - workloadId: "post-ready", - specHash: "post-ready", - }; - yield* Ref.set(resources, [{ ...key, state: "ready" }]); - - for (let attempt = 0; attempt < 2; attempt += 1) { - yield* Ref.set(resources, [{ ...key, state: "failed", error: "crashed" }]); - const result = yield* reconciler.reconcile(request); - expect(result.started).toEqual(["post-ready"]); - } - yield* Ref.set(resources, [{ ...key, state: "failed", error: "crashed" }]); - const exhausted = yield* reconciler.reconcile(request); - expect(exhausted.failed[0]?.error).toBeInstanceOf(RuntimeRestartBudgetExceededError); - expect(yield* Ref.get(starts)).toBe(2); - - yield* reconciler.reconcile({ ...request, desiredLifecycle: "stopped" }); - yield* Ref.set(resources, [{ ...key, state: "failed", error: "crashed" }]); - const reset = yield* reconciler.reconcile(request); - expect(reset.started).toEqual(["post-ready"]); - expect(yield* Ref.get(starts)).toBe(3); - }), - ); - - it.live("serializes concurrent callers through one lifecycle semaphore", () => - Effect.gen(function* () { - const active = yield* Ref.make(0); - const maxActive = yield* Ref.make(0); - const resources = yield* Ref.make>([]); - const entered = yield* Deferred.make(); - const release = yield* Deferred.make(); - const driver: RuntimeDriver = { - watchFailures: Stream.empty, - observe: () => Ref.get(resources), - start: (key, _entry) => - Effect.gen(function* () { - yield* Ref.update(active, (value) => value + 1); - const current = yield* Ref.get(active); - yield* Ref.update(maxActive, (max) => Math.max(max, current)); - yield* Deferred.succeed(entered, undefined); - yield* Deferred.await(release); - const ready: ObservedWorkload = { ...key, state: "ready" }; - yield* Ref.update(resources, (entries) => [...entries, ready]); - return ready; - }).pipe(Effect.ensuring(Ref.update(active, (value) => value - 1))), - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, - }; - const reconciler = yield* makeReconciler({ driver }); - const request = { - stackId, - desiredLifecycle: "running" as const, - plan: planFor([workload("one")]), - }; - const first = yield* Effect.forkChild(reconciler.reconcile(request), { - startImmediately: true, - }); - yield* Deferred.await(entered); - const second = yield* Effect.forkChild(reconciler.reconcile(request), { - startImmediately: true, - }); - yield* Effect.yieldNow; - expect(yield* Ref.get(active)).toBe(1); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(first); - yield* Fiber.join(second); - expect(yield* Ref.get(maxActive)).toBe(1); - }), - ); - - it.live("releases lifecycle serialization when a waiting caller is interrupted", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const blocked = yield* Ref.make(true); - const starts = yield* Ref.make(0); - const driver: RuntimeDriver = { - watchFailures: Stream.empty, - observe: () => Effect.succeed([]), - start: (key) => { - const ready: ObservedWorkload = { ...key, state: "ready" }; - return Ref.update(starts, (count) => count + 1).pipe( - Effect.andThen(Deferred.succeed(started, undefined)), - Effect.andThen( - Effect.flatMap(Ref.get(blocked), (isBlocked) => - isBlocked ? Deferred.await(release) : Effect.void, - ), - ), - Effect.andThen(Effect.succeed(ready)), - ); - }, - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, - }; - const reconciler = yield* makeReconciler({ - driver, - }); - const request = { - stackId, - desiredLifecycle: "running" as const, - plan: planFor([workload("interruptible")]), - }; - const first = yield* Effect.forkChild(reconciler.reconcile(request), { - startImmediately: true, - }); - yield* Deferred.await(started); - yield* Fiber.interrupt(first); - yield* Ref.set(blocked, false); - yield* Deferred.succeed(release, undefined); - const second = yield* reconciler.reconcile(request); - expect(second.started).toEqual(["interruptible"]); - expect(yield* Ref.get(starts)).toBe(2); - }), - ); - - it.live("stops before removing workloads in reverse dependency order", () => - Effect.gen(function* () { - const trace = yield* Ref.make>([]); - const resources: ReadonlyArray = [ - { stackId, workloadId: "a", specHash: "a", state: "ready" }, - { stackId, workloadId: "b", specHash: "b", state: "ready" }, - ]; - const driver: RuntimeDriver = { - watchFailures: Stream.empty, - observe: () => Effect.succeed(resources), - start: () => Effect.die("unexpected start"), - stop: (key) => Ref.update(trace, (current) => [...current, `stop:${key.workloadId}`]), - remove: (key) => Ref.update(trace, (current) => [...current, `remove:${key.workloadId}`]), - cleanup: () => Effect.void, - }; - const reconciler = yield* makeReconciler({ - driver, - }); - yield* reconciler.reconcile({ - stackId, - desiredLifecycle: "stopped", - plan: planFor([workload("a"), workload("b", ["a"])]), - }); - expect(yield* Ref.get(trace)).toEqual(["stop:b", "stop:a", "remove:b", "remove:a"]); - }), - ); - - it.live("resets the restart budget after an explicit stop", () => - Effect.gen(function* () { - const { driver, starts } = yield* fakeDriver({ fail: new Set(["retryable"]) }); - const reconciler = yield* makeReconciler({ - driver, - }); - const request = { - stackId, - desiredLifecycle: "running" as const, - plan: planFor([workload("retryable")]), - }; - const first = yield* reconciler.reconcile(request); - expect(first.failed).toHaveLength(1); - expect(yield* Ref.get(starts)).toEqual(["retryable"]); - yield* reconciler.reconcile({ ...request, desiredLifecycle: "stopped" }); - yield* reconciler.reconcile(request); - expect(yield* Ref.get(starts)).toEqual(["retryable", "retryable"]); - }), - ); - - it.live("preserves defects in a mixed driver Cause instead of reporting budget exhaustion", () => - Effect.gen(function* () { - const driver: RuntimeDriver = { - watchFailures: Stream.empty, - observe: () => Effect.succeed([]), - start: () => - Effect.failCause( - Cause.combine( - Cause.fail( - new RuntimeDriverError({ message: "start failed", stackId, workloadId: "mixed" }), - ), - Cause.die("driver defect"), - ), - ), - stop: () => Effect.void, - remove: () => Effect.void, - cleanup: () => Effect.void, - }; - const reconciler = yield* makeReconciler({ - driver, - }); - const exit = yield* reconciler - .reconcile({ - stackId, - desiredLifecycle: "running", - plan: planFor([workload("mixed")]), - }) - .pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(Cause.hasDies(exit.cause)).toBe(true); - }), - ); -}); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 3a675082e6..425bc6f3f5 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -100,9 +100,7 @@ const makeFixture = ( readonly destroyGate?: Deferred.Deferred; readonly destroyStarted?: Deferred.Deferred; readonly destroyPreFenceFail?: Ref.Ref; - readonly failureQueue?: Queue.Queue; readonly startQueue?: Queue.Queue; - readonly failureObserved?: Deferred.Deferred; } = {}, ) => Effect.gen(function* () { @@ -159,19 +157,7 @@ const makeFixture = ( const failDestroy = yield* Ref.make(false); let gateStopCleanup = false; const driver: RuntimeDriver = { - watchFailures: - fixtureOptions.failureQueue === undefined - ? Stream.empty - : Stream.fromQueue(fixtureOptions.failureQueue), - observe: () => - Ref.get(resources).pipe( - Effect.tap((current) => - fixtureOptions.failureObserved === undefined || - !current.some((entry) => entry.state === "failed") - ? Effect.void - : Deferred.succeed(fixtureOptions.failureObserved, undefined), - ), - ), + observe: () => Ref.get(resources), start: (key, workload: PlannedWorkload) => Effect.gen(function* () { gateStopCleanup = true; @@ -192,6 +178,8 @@ const makeFixture = ( const remaining = yield* Ref.get(fixtureOptions.startFailures); if (remaining > 0) { yield* Ref.set(fixtureOptions.startFailures, remaining - 1); + if (fixtureOptions.stopFailFirst !== undefined) + yield* Ref.set(fixtureOptions.stopFailFirst, true); const failed = { ...key, state: "failed" as const, error: "injected start failure" }; yield* Ref.update(resources, (current) => [ ...current.filter((entry) => entry.workloadId !== key.workloadId), @@ -394,7 +382,7 @@ describe("Supervisor composition", () => { ), ); - it.live("keeps post-commit startup failure observable as starting", () => + it.live("persists stopped after startup ingress failure", () => run( Effect.gen(function* () { const fixture = yield* makeFixture({ @@ -415,13 +403,13 @@ describe("Supervisor composition", () => { Exit.isFailure(yield* fixture.supervisor.start({ config: {} }).pipe(Effect.exit)), ).toBe(true); const status = yield* fixture.supervisor.status; - expect(status.desiredLifecycle).toBe("running"); - expect(status.lifecycle).toBe("starting"); + expect(status.desiredLifecycle).toBe("stopped"); + expect(status.lifecycle).toBe("stopped"); }), ), ); - it.live("keeps post-commit start failure observable as starting", () => + it.live("persists stopped after a restart ingress failure", () => run( Effect.gen(function* () { const acquireCalls = yield* Ref.make(0); @@ -455,20 +443,19 @@ describe("Supervisor composition", () => { expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); const status = yield* fixture.supervisor.status; - expect(status.desiredLifecycle).toBe("running"); - expect(status.lifecycle).toBe("starting"); + expect(status.desiredLifecycle).toBe("stopped"); + expect(status.lifecycle).toBe("stopped"); expect(errorOf(yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit))?.tag).toBe( "StackNotRunningError", ); - const recovered = yield* fixture.supervisor.start(); - expect(recovered.lifecycle).toBe("running"); - expect(recovered.capabilities.find(({ name }) => name === "database")?.state).toBe("ready"); + const retry = yield* fixture.supervisor.start().pipe(Effect.exit); + expect(errorOf(retry)).toBeInstanceOf(StackLifecycleConflictError); expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); }), ), ); - it.live("recovers a stopped live owner after fresh ingress acquisition fails", () => + it.live("exits after a stopped-owner restart ingress failure", () => run( Effect.gen(function* () { const acquireCalls = yield* Ref.make(0); @@ -501,10 +488,10 @@ describe("Supervisor composition", () => { expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("starting"); - const recovered = yield* fixture.supervisor.start(); - expect(recovered.lifecycle).toBe("running"); - expect(recovered.capabilities.find(({ name }) => name === "database")?.state).toBe("ready"); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + expect(errorOf(yield* fixture.supervisor.start().pipe(Effect.exit))).toBeInstanceOf( + StackLifecycleConflictError, + ); }), ), ); @@ -545,7 +532,6 @@ describe("Supervisor composition", () => { { stackId: fixture.id, workloadId: "database:database", - specHash: "stale", state: "ready", }, ]); @@ -597,7 +583,7 @@ describe("Supervisor composition", () => { ), ); - it.live("keeps first-start cleanup retryable when cleanup fails", () => + it.live("keeps the owner for retryable first-start cleanup", () => run( Effect.gen(function* () { const stopFailFirst = yield* Ref.make(true); @@ -605,11 +591,32 @@ describe("Supervisor composition", () => { const failed = yield* fixture.supervisor.start({ config: {} }).pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("unconfigured"); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); expect(yield* Ref.get(fixture.calls)).toEqual([]); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); + }), + ), + ); - const status = yield* fixture.supervisor.start({ config: {} }); - expect(status.lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); + it.live("keeps the owner when eager launch cleanup is unproven", () => + run( + Effect.gen(function* () { + const startFailures = yield* Ref.make(1); + const stopFailFirst = yield* Ref.make(false); + const fixture = yield* makeFixture({ startFailures, stopFailFirst }); + const failed = yield* fixture.supervisor + .start({ config: { capabilities: { functions: { activation: "eager" } } } }) + .pipe(Effect.exit); + + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect(yield* Ref.get(fixture.calls)).toContain("cleanup:stop"); + + const retry = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(retry.ok).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); }), ), ); @@ -618,7 +625,9 @@ describe("Supervisor composition", () => { run( Effect.gen(function* () { const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); + yield* fixture.supervisor.start({ + config: { capabilities: { rest: { activation: "eager" } } }, + }); const running = yield* fixture.store .read(fixture.id) .pipe(Effect.provideContext(fixture.context)); @@ -867,6 +876,44 @@ describe("Supervisor composition", () => { ), ); + it.live("attempts runtime cleanup when session workload stop fails", () => + run( + Effect.gen(function* () { + const workloadStopFailFirst = yield* Ref.make(true); + const fixture = yield* makeFixture({ workloadStopFailFirst }); + yield* fixture.supervisor.start({ + config: { capabilities: { rest: { activation: "eager" } } }, + }); + yield* Ref.set(fixture.calls, []); + + const response = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(response.ok).toBe(false); + expect(yield* Ref.get(fixture.calls)).toContain("cleanup:stop"); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + }), + ), + ); + + it.live("stops the launched session in reverse dependency order", () => + run( + Effect.gen(function* () { + const timeline = yield* Ref.make>([]); + const fixture = yield* makeFixture({ timeline }); + yield* fixture.supervisor.start({ + config: { capabilities: { rest: { activation: "eager" } } }, + }); + yield* Ref.set(timeline, []); + + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + expect(yield* Ref.get(timeline)).toEqual([ + "stop:rest:rest", + "stop:database:database", + "cleanup:stop", + ]); + }), + ), + ); + it.live("keeps stopping state while an explicit stop is active", () => run( Effect.gen(function* () { @@ -881,8 +928,8 @@ describe("Supervisor composition", () => { yield* Deferred.succeed(stopGate, undefined); yield* Fiber.join(stopping); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - yield* fixture.supervisor.start(); - expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); + const restart = yield* fixture.supervisor.start().pipe(Effect.exit); + expect(errorOf(restart)).toBeInstanceOf(StackLifecycleConflictError); }), ), ); @@ -1243,114 +1290,32 @@ describe("Supervisor composition", () => { ), ); - it.live("restarts a failed eager workload without removing an active lazy workload", () => - run( - Effect.gen(function* () { - const failures = yield* Queue.unbounded(); - const starts = yield* Queue.unbounded(); - const fixture = yield* makeFixture({ failureQueue: failures, startQueue: starts }); - yield* fixture.supervisor.start({ config: { capabilities: { functions: {} } } }); - expect(yield* Queue.take(starts)).toBe("database:database"); - yield* fixture.supervisor.activate("functions"); - expect(yield* Queue.take(starts)).toBe("functions:edge-runtime"); - yield* Ref.set(fixture.calls, []); - const database = (yield* Ref.get(fixture.resources)).find( - ({ workloadId }) => workloadId === "database:database", - ); - if (database === undefined) return yield* Effect.die("database did not start"); - yield* Ref.update(fixture.resources, (current) => - current.map((entry) => - entry.workloadId === database.workloadId - ? { ...entry, state: "failed" as const, error: "crashed" } - : entry, - ), - ); - - yield* Queue.offer(failures, { ...database, state: "failed", error: "crashed" }); - expect(yield* Queue.take(starts)).toBe("database:database"); - - const resources = yield* Ref.get(fixture.resources); - expect( - resources.find(({ workloadId }) => workloadId === "functions:edge-runtime")?.state, - ).toBe("ready"); - expect(yield* Ref.get(fixture.calls)).not.toContain("stop:functions:edge-runtime"); - }), - ), - ); - - it.live("restarts a ready workload after an owner-scoped runtime failure", () => - run( - Effect.gen(function* () { - const failures = yield* Queue.unbounded(); - const starts = yield* Queue.unbounded(); - const fixture = yield* makeFixture({ failureQueue: failures, startQueue: starts }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - yield* Queue.take(starts); - const ready = (yield* Ref.get(fixture.resources)).find( - (entry) => entry.workloadId === "database:database", - ); - if (ready === undefined) return yield* Effect.die("database did not start"); - yield* Ref.update(fixture.resources, (current) => - current.map((entry) => - entry.workloadId === ready.workloadId ? { ...entry, state: "failed" as const } : entry, - ), - ); - yield* Queue.offer(failures, { ...ready, state: "failed", error: "crashed" }); - expect(yield* Queue.take(starts)).toBe("database:database"); - expect( - (yield* Ref.get(fixture.resources)).find( - (entry) => entry.workloadId === "database:database", - )?.state, - ).toBe("ready"); - }), - ), - ); - - it.live("keeps a post-readiness crash budget exhausted and visible in status", () => - run( - Effect.gen(function* () { - const failures = yield* Queue.unbounded(); - const starts = yield* Queue.unbounded(); - const failureObserved = yield* Deferred.make(); - const fixture = yield* makeFixture({ - failureQueue: failures, - startQueue: starts, - failureObserved, - }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const initialWorkload = yield* Queue.take(starts); - const ready = (yield* Ref.get(fixture.resources)).find( - (entry) => entry.workloadId === initialWorkload, - ); - if (ready === undefined) return yield* Effect.die("workload did not start"); - // The catalog default allows five post-ready attempts; the sixth crash is terminal. - for (let attempt = 0; attempt < 5; attempt += 1) { + it.live( + "reports a post-ready workload failure without restarting it or stopping unrelated services", + () => + run( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const fixture = yield* makeFixture({ startQueue: starts }); + yield* fixture.supervisor.start({ + config: { capabilities: { rest: { activation: "eager" } } }, + }); + yield* Queue.take(starts); + yield* Queue.take(starts); + yield* Ref.set(fixture.calls, []); yield* Ref.update(fixture.resources, (current) => current.map((entry) => - entry.workloadId === ready.workloadId + entry.workloadId === "database:database" ? { ...entry, state: "failed" as const, error: "crashed" } : entry, ), ); - yield* Queue.offer(failures, { ...ready, state: "failed", error: "crashed" }); - yield* Queue.take(starts); - } - yield* Ref.update(fixture.resources, (current) => - current.map((entry) => - entry.workloadId === ready.workloadId - ? { ...entry, state: "failed" as const, error: "crashed" } - : entry, - ), - ); - yield* Queue.offer(failures, { ...ready, state: "failed", error: "crashed" }); - // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- fixture event seam - yield* Deferred.await(failureObserved); - const status = yield* fixture.supervisor.status; - const capability = status.capabilities.find(({ name }) => name === "database"); - expect(capability?.state).toBe("failed"); - expect(capability?.error).toContain("crashed"); - }), - ), + const status = yield* fixture.supervisor.status; + expect(status.capabilities.find(({ name }) => name === "database")?.state).toBe("failed"); + expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("ready"); + expect(yield* Ref.get(fixture.calls)).toEqual([]); + }), + ), ); it.live("allows a failed activation to retry", () => @@ -1371,10 +1336,10 @@ describe("Supervisor composition", () => { ), ); - it.live("reports a failed lazy activation as failed instead of dormant", () => + it.live("reports a failed lazy activation as stopped until it is retried", () => run( Effect.gen(function* () { - const startFailures = yield* Ref.make(5); + const startFailures = yield* Ref.make(1); const fixture = yield* makeFixture({ startFailures }); yield* fixture.supervisor.start({ config: { capabilities: { functions: {} } } }); @@ -1384,7 +1349,13 @@ describe("Supervisor composition", () => { expect( (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") ?.state, - ).toBe("failed"); + ).toBe("stopped"); + const retry = yield* fixture.supervisor.activate("functions"); + expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); + expect( + (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") + ?.state, + ).toBe("ready"); }), ), ); From 40724232c2ad223fe894909ad8e7c9602f04dba2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 05:54:33 +0200 Subject: [PATCH 119/174] fix(stack): make lifecycle cleanup attempt scoped --- packages/stack/src/supervisor/Lifecycle.ts | 37 ++---- .../stack/src/supervisor/SessionLauncher.ts | 40 +++++- packages/stack/src/supervisor/Supervisor.ts | 97 ++++++++++++-- .../supervisor/lifecycle.integration.test.ts | 45 +++++-- .../supervisor/supervisor.integration.test.ts | 118 +++++++++++++++++- 5 files changed, 283 insertions(+), 54 deletions(-) diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 60427b2846..0af49f4b6d 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -46,7 +46,7 @@ export interface LifecycleBackend { ) => Effect.Effect; /** Removes runtime resources while retaining durable state/data (stop path). */ readonly cleanup: Effect.Effect; - /** Removes persistent data after exact runtime cleanup (destroy path). */ + /** Removes all exact runtime resources and persistent data (destroy path). */ readonly destroyData: Effect.Effect; } @@ -336,40 +336,23 @@ export const makeLifecycleController = ( const destroy = (): Effect.Effect => Effect.gen(function* () { const current = yield* read(); - if (current.desiredLifecycle === "unconfigured" && current.definition === undefined) { - yield* options.stateStore.cleanup(options.stackId); - return; - } - if (current.desiredLifecycle === "destroying" && current.definition === undefined) - return yield* invalidDefinition(options.stackId); const destroying: PersistedStackState = current.desiredLifecycle === "destroying" ? current : { ...current, desiredLifecycle: "destroying" }; if (destroying !== current) yield* options.stateStore.replace(options.stackId, destroying); - yield* destroyRuntime(destroying); + yield* destroyRuntime; }); - const destroyRuntime = ( - state: PersistedStackState, - ): Effect.Effect => - Effect.gen(function* () { - const candidate = yield* Effect.exit( - persistedCandidate(options.stackId, state, state.runtime), - ); - if (Exit.isSuccess(candidate)) { - const input = lifecycleInput(options.stackId, state, candidate.value); - yield* options.backend.reconcile(input, "current"); - yield* options.backend.cleanup; - yield* options.backend.destroyData; - } else { - // Preserve the stable maintenance guarantee: stop and destroy remain usable even when - // persisted configuration is no longer compilable. - yield* options.backend.cleanup; - yield* options.backend.destroyData; - } + const destroyRuntime: Effect.Effect = Effect.gen( + function* () { + // Destructive cleanup is the single runtime teardown path. It is exact and idempotent, + // so it also handles an unconfigured state left behind by an interrupted first start. + // Persisted configuration need not compile in order to remove runtime remnants. + yield* options.backend.destroyData; yield* options.stateStore.cleanup(options.stackId); - }); + }, + ); return { start, stop, destroy } satisfies LifecycleController; }); diff --git a/packages/stack/src/supervisor/SessionLauncher.ts b/packages/stack/src/supervisor/SessionLauncher.ts index f39d8620a5..cb59945021 100644 --- a/packages/stack/src/supervisor/SessionLauncher.ts +++ b/packages/stack/src/supervisor/SessionLauncher.ts @@ -14,13 +14,20 @@ interface SessionWorkload { export interface SessionLauncher { /** Starts the supplied dependency closure in topological order. */ - readonly launch: (plan: ExecutionPlan) => Effect.Effect; + readonly launch: (plan: ExecutionPlan) => Effect.Effect; /** Stops and removes every workload started in this session in reverse order. */ readonly stop: Effect.Effect; + /** Whether the most recent launch/rollback cleanup completed exactly. */ + readonly cleanupProven: Effect.Effect; /** Clears the session after stack-wide runtime cleanup has completed. */ readonly clear: Effect.Effect; } +/** Resources created by one launch attempt and a rollback scoped to that attempt. */ +export interface SessionLaunch { + readonly rollback: Effect.Effect; +} + const keyFor = (stackId: StackId, workload: PlannedWorkload): RuntimeWorkloadKey => ({ stackId, workloadId: workload.id, @@ -42,6 +49,7 @@ export const makeSessionLauncher = (options: { }): Effect.Effect => Effect.gen(function* () { const session = yield* Ref.make>([]); + const cleanupProven = yield* Ref.make(true); const cleanup = ( entries: ReadonlyArray, @@ -61,8 +69,9 @@ export const makeSessionLauncher = (options: { if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); }); - const launch = (plan: ExecutionPlan): Effect.Effect => + const launch = (plan: ExecutionPlan): Effect.Effect => Effect.gen(function* () { + yield* Ref.set(cleanupProven, true); const existing = new Set((yield* Ref.get(session)).map(({ key }) => key.workloadId)); const attempted: SessionWorkload[] = []; const outcome = yield* Effect.exit( @@ -79,8 +88,32 @@ export const makeSessionLauncher = (options: { { concurrency: 1, discard: true }, ), ); - if (Exit.isSuccess(outcome)) return; + if (Exit.isSuccess(outcome)) { + const rollback = Effect.gen(function* () { + const result = yield* cleanup(attempted).pipe(Effect.exit); + yield* Ref.set(cleanupProven, Exit.isSuccess(result)); + // A failed rollback is not a reusable session workload. The exact runtime cleanup + // boundary remains responsible for retrying any resource whose remove failed. + yield* Ref.update(session, (current) => + current.filter( + (candidate) => + !attempted.some((entry) => entry.key.workloadId === candidate.key.workloadId), + ), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + }); + return { rollback } satisfies SessionLaunch; + } const cleaned = yield* cleanup(attempted).pipe(Effect.exit); + yield* Ref.set(cleanupProven, Exit.isSuccess(cleaned)); + // Failed launch entries are never reusable. Exact stack cleanup will retry any resource + // whose stop/remove failed, while a later activation must attempt a fresh start. + yield* Ref.update(session, (current) => + current.filter( + (candidate) => + !attempted.some((entry) => entry.key.workloadId === candidate.key.workloadId), + ), + ); if (Exit.isFailure(cleaned)) return yield* Effect.failCause(combine(outcome.cause, cleaned.cause)); return yield* Effect.failCause(outcome.cause); @@ -90,6 +123,7 @@ export const makeSessionLauncher = (options: { return { launch, stop, + cleanupProven: Ref.get(cleanupProven), clear: Ref.set(session, []), } satisfies SessionLauncher; }); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 5078804a92..57f0094e6f 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -40,7 +40,11 @@ import type { EffectStackCredentials } from "../public/Credentials.ts"; import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; -import { makeSessionLauncher, type SessionLauncher } from "./SessionLauncher.ts"; +import { + makeSessionLauncher, + type SessionLaunch, + type SessionLauncher, +} from "./SessionLauncher.ts"; import { makeLifecycleController, type LifecycleBackend, @@ -166,6 +170,9 @@ export const makeSupervisor = ( // A failed cleanup leaves the owner in `stopping` so callers can retry an exact cleanup. // This marker is set by the backend cleanup boundary and read only by start failure handling. const cleanupProven = yield* Ref.make(true); + // A lazy activation rollback that cannot be proven leaves the owner fenced in stopping until + // an explicit stop retries exact cleanup. + const activationRollbackUnproven = yield* Ref.make(false); // A Supervisor created after a crash must clean exact runtime ephemera once before its first // fresh start. The marker is session-local and only advances after cleanup succeeds. const sessionInitialized = yield* Ref.make(false); @@ -183,6 +190,7 @@ export const makeSupervisor = ( const resetForSession = (input: LifecycleInput) => Effect.gen(function* () { activationOwned.clear(); + yield* Ref.set(activationRollbackUnproven, false); yield* launcher.clear; yield* initializeActivation(input.plan); }); @@ -305,10 +313,10 @@ export const makeSupervisor = ( input: LifecycleInput, session: "fresh" | "current", selectedOverride?: ReadonlySet, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { if (session === "fresh") yield* resetForSession(input); - if (input.desiredLifecycle !== "running") return; + if (input.desiredLifecycle !== "running") return undefined; const selected = selectedOverride ?? (yield* Ref.get(active)); const plan = activeExecutionPlan(input.plan, selected); const reservation = yield* runtime.ingress.acquire(input); @@ -317,17 +325,46 @@ export const makeSupervisor = ( .pipe(Effect.mapError(mapReconcileError), Effect.exit); if (Exit.isFailure(launched)) { if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); + if (session === "current" && !(yield* launcher.cleanupProven)) { + yield* Ref.set(activationRollbackUnproven, true); + yield* Ref.set(phase, "stopping"); + const closed = yield* runtime.ingress.close.pipe( + Effect.mapError(mapReconcileError), + Effect.exit, + ); + if (Exit.isFailure(closed)) + return yield* Effect.failCause(Cause.combine(launched.cause, closed.cause)); + yield* publish().pipe(Effect.ignore); + } return yield* Effect.failCause(launched.cause); } const opened = yield* runtime.ingress .open(input, reservation, ingressActivate) .pipe(Effect.exit); if (Exit.isFailure(opened)) { - if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); - return yield* Effect.failCause(opened.cause); + const rolledBack = yield* launched.value.rollback.pipe( + Effect.mapError(mapReconcileError), + Effect.exit, + ); + const closed = reservation.fresh + ? yield* runtime.ingress.close.pipe(Effect.mapError(mapReconcileError), Effect.exit) + : Exit.succeed(undefined); + let cause: Cause.Cause = opened.cause; + if (Exit.isFailure(rolledBack)) cause = Cause.combine(cause, rolledBack.cause); + if (Exit.isFailure(closed)) cause = Cause.combine(cause, closed.cause); + if (Exit.isFailure(rolledBack)) { + yield* Ref.set(cleanupProven, false); + if (session === "current") { + yield* Ref.set(activationRollbackUnproven, true); + yield* Ref.set(phase, "stopping"); + yield* publish().pipe(Effect.ignore); + } + } + return yield* Effect.failCause(cause); } if (session === "fresh") yield* Ref.set(sessionInitialized, true); yield* publish(); + return launched.value; }); const cleanupRuntime = (destroy: boolean): Effect.Effect => @@ -337,7 +374,9 @@ export const makeSupervisor = ( Effect.mapError(mapReconcileError), Effect.exit, ); - const launched = yield* launcher.stop.pipe(Effect.mapError(mapReconcileError), Effect.exit); + const launched = destroy + ? Exit.succeed(undefined) + : yield* launcher.stop.pipe(Effect.mapError(mapReconcileError), Effect.exit); const driver = yield* runtime.driver .cleanup({ stackId: options.stackId, destroy }) .pipe(Effect.mapError(mapReconcileError), Effect.exit); @@ -350,7 +389,10 @@ export const makeSupervisor = ( } if (!destroy) { yield* Ref.set(active, new Set()); + yield* Ref.set(activationRollbackUnproven, false); yield* publish(); + } else { + yield* launcher.clear; } }); const backend: LifecycleBackend = { @@ -377,6 +419,11 @@ export const makeSupervisor = ( stackId: options.stackId, message: `Cannot activate while ${lifecycle.kind} is in progress`, }); + if ((yield* Ref.get(activationRollbackUnproven)) || (yield* Ref.get(phase)) === "stopping") + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: "Cannot activate while exact cleanup is pending; stop the stack first", + }); const state = yield* read(); if (state === undefined) return yield* new StackStateInvalidError({ message: "Stack state is missing" }); @@ -395,7 +442,8 @@ export const makeSupervisor = ( (error) => new StackStateInvalidError({ message: error.message, cause: error }), ), ); - const next = new Set(yield* Ref.get(active)); + const previousActive = yield* Ref.get(active); + const next = new Set(previousActive); const visit = (name: CapabilityName): void => { if (next.has(name)) return; next.add(name); @@ -412,11 +460,33 @@ export const makeSupervisor = ( }; // Activation is accepted for this lifecycle session before launching its dependency closure. // A failed attempt removes only newly-created resources; the capability remains retryable. + const launched = yield* launchBackend(input, "current", next); yield* Ref.set(active, next); - yield* launchBackend(input, "current", next); yield* Ref.set(phase, "running"); yield* publish().pipe(Effect.ignore); - const endpoint = yield* runtime.activate(capability, input); + const activated = yield* runtime.activate(capability, input).pipe(Effect.exit); + if (Exit.isFailure(activated)) { + yield* Ref.set(active, previousActive); + if (launched !== undefined) { + const rolledBack = yield* launched.rollback.pipe( + Effect.mapError(mapReconcileError), + Effect.exit, + ); + if (Exit.isFailure(rolledBack)) { + yield* Ref.set(activationRollbackUnproven, true); + yield* Ref.set(phase, "stopping"); + const closed = yield* runtime.ingress.close.pipe( + Effect.mapError(mapReconcileError), + Effect.exit, + ); + let cause: Cause.Cause = Cause.combine(activated.cause, rolledBack.cause); + if (Exit.isFailure(closed)) cause = Cause.combine(cause, closed.cause); + return yield* Effect.failCause(cause); + } + } + return yield* Effect.failCause(activated.cause); + } + const endpoint = activated.value; return { capability, endpoint }; }); @@ -540,7 +610,14 @@ export const makeSupervisor = ( .pipe(Effect.provideContext(options.context), Effect.exit); if (Exit.isFailure(started)) { if (freshSession) { - yield* Ref.set(phase, (yield* Ref.get(cleanupProven)) ? "stopped" : "stopping"); + const durable = yield* read().pipe(Effect.exit); + const canReportStopped = + (yield* Ref.get(cleanupProven)) && + Exit.isSuccess(durable) && + durable.value !== undefined && + (durable.value.desiredLifecycle === "stopped" || + durable.value.desiredLifecycle === "unconfigured"); + yield* Ref.set(phase, canReportStopped ? "stopped" : "stopping"); yield* publish().pipe(Effect.ignore); } else yield* restorePhase(previous); return yield* Effect.failCause(started.cause); diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index f0c9e7256a..dd7b4b259b 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -37,6 +37,7 @@ interface BackendState { failPreflight?: boolean; failReconcile?: boolean; failDestroyData?: boolean; + failDestroyDataOnce?: boolean; failCleanupOnce?: boolean; gate?: Deferred.Deferred; preflightStarted?: Deferred.Deferred; @@ -82,6 +83,10 @@ const backend = (state: BackendState): LifecycleBackend => ({ }), destroyData: Effect.gen(function* () { state.calls.push(`destroy-data:${state.lastLifecycle ?? "invalid"}`); + if (state.failDestroyDataOnce) { + state.failDestroyDataOnce = false; + return yield* new StackReconciliationError({ message: "destroy-data failed" }); + } if (state.failDestroyData) return yield* new StackReconciliationError({ message: "destroy-data failed" }); }), @@ -303,11 +308,7 @@ describe("durable lifecycle controller", () => { yield* fixture.controller.start(); fixture.state.calls.length = 0; yield* fixture.controller.destroy(); - expect(fixture.state.calls).toEqual([ - "reconcile:destroying", - "cleanup:destroying", - "destroy-data:destroying", - ]); + expect(fixture.state.calls).toEqual(["destroy-data:running"]); expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); expect(yield* fs.exists(`${fixture.root}/${fixture.id}`)).toBe(false); const recreated = yield* fixture.store.initialize(fixture.id, { @@ -336,11 +337,35 @@ describe("durable lifecycle controller", () => { expect(yield* fixture.store.read(fixture.id)).toMatchObject({ desiredLifecycle: "destroying", }); - expect(fixture.state.calls).toEqual([ - "reconcile:destroying", - "cleanup:destroying", - "destroy-data:destroying", - ]); + expect(fixture.state.calls).toEqual(["destroy-data:running"]); + fixture.state.failDestroyData = false; + yield* fixture.controller.destroy(); + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + }), + ), + ); + + it.live("destroys exact runtime remnants from an unconfigured state", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.controller.destroy(); + expect(fixture.state.calls).toEqual(["destroy-data:invalid"]); + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + }), + ), + ); + + it.live("retries destructive cleanup after a transient failure", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + fixture.state.failDestroyDataOnce = true; + const first = yield* fixture.controller.destroy().pipe(Effect.exit); + expect(errorOf(first)).toBeInstanceOf(StackReconciliationError); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("destroying"); + yield* fixture.controller.destroy(); + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); }), ), ); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 425bc6f3f5..aa9ffe8fbe 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -100,6 +100,7 @@ const makeFixture = ( readonly destroyGate?: Deferred.Deferred; readonly destroyStarted?: Deferred.Deferred; readonly destroyPreFenceFail?: Ref.Ref; + readonly stoppedReplaceFail?: Ref.Ref; readonly startQueue?: Queue.Queue; } = {}, ) => @@ -109,14 +110,15 @@ const makeFixture = ( const id = yield* deriveStackId(identity); const baseStore = yield* makeStackStateStore({ stateRoot: root }); const destroyPreFenceFail = fixtureOptions.destroyPreFenceFail; + const stoppedReplaceFail = fixtureOptions.stoppedReplaceFail; const store = - destroyPreFenceFail === undefined + destroyPreFenceFail === undefined && stoppedReplaceFail === undefined ? baseStore : { ...baseStore, replace: (stackId: string, state: Parameters[1]) => Effect.gen(function* () { - if (state.desiredLifecycle === "destroying") { + if (state.desiredLifecycle === "destroying" && destroyPreFenceFail !== undefined) { const fail = yield* Ref.get(destroyPreFenceFail); if (fail) { yield* Ref.set(destroyPreFenceFail, false); @@ -125,6 +127,15 @@ const makeFixture = ( }); } } + if (state.desiredLifecycle === "stopped" && stoppedReplaceFail !== undefined) { + const fail = yield* Ref.get(stoppedReplaceFail); + if (fail) { + yield* Ref.set(stoppedReplaceFail, false); + return yield* new StackStateInvalidError({ + message: "injected stopped-state persistence failure", + }); + } + } return yield* baseStore.replace(stackId, state); }), }; @@ -621,6 +632,39 @@ describe("Supervisor composition", () => { ), ); + it.live("shuts down after a proven eager launch failure", () => + run( + Effect.gen(function* () { + const startFailures = yield* Ref.make(1); + const fixture = yield* makeFixture({ startFailures }); + const failed = yield* fixture.supervisor + .start({ config: { capabilities: { functions: { activation: "eager" } } } }) + .pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + yield* fixture.supervisor.shutdownIfIdle; + yield* fixture.supervisor.shutdown; + }), + ), + ); + + it.live("keeps stopping when publishing the stopped fence fails", () => + run( + Effect.gen(function* () { + const stoppedReplaceFail = yield* Ref.make(true); + const startFailures = yield* Ref.make(1); + const fixture = yield* makeFixture({ stoppedReplaceFail, startFailures }); + const failed = yield* fixture.supervisor + .start({ config: { capabilities: { functions: { activation: "eager" } } } }) + .pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + }), + ), + ); + it.live("returns persisted database, API, and storage credentials while running", () => run( Effect.gen(function* () { @@ -1332,11 +1376,14 @@ describe("Supervisor composition", () => { const retry = yield* fixture.supervisor.activate("functions"); expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); expect(yield* Ref.get(activationCalls)).toBe(2); + expect( + (yield* Ref.get(fixture.calls)).filter((call) => call === "start:functions:edge-runtime"), + ).toHaveLength(2); }), ), ); - it.live("reports a failed lazy activation as stopped until it is retried", () => + it.live("reports a failed lazy activation as dormant until it is retried", () => run( Effect.gen(function* () { const startFailures = yield* Ref.make(1); @@ -1349,13 +1396,76 @@ describe("Supervisor composition", () => { expect( (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") ?.state, - ).toBe("stopped"); + ).toBe("dormant"); const retry = yield* fixture.supervisor.activate("functions"); expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); expect( (yield* fixture.supervisor.status).capabilities.find(({ name }) => name === "functions") ?.state, ).toBe("ready"); + expect( + (yield* Ref.get(fixture.calls)).filter((call) => call === "start:functions:edge-runtime"), + ).toHaveLength(2); + }), + ), + ); + + it.live("rolls back a lazy launch when ingress opening fails", () => + run( + Effect.gen(function* () { + const openCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ + ingress: { + acquire: () => + Effect.succeed({ + assignments: {}, + privateAssignments: [], + hostListeners: [], + fresh: true, + }), + open: () => + Ref.updateAndGet(openCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 2 + ? Effect.fail(new GatewayActivationError({ message: "injected open failure" })) + : Effect.void, + ), + ), + close: Effect.void, + }, + }); + yield* fixture.supervisor.start({ + config: { capabilities: { functions: { activation: "lazy" } } }, + }); + const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(yield* Ref.get(fixture.resources)).toEqual([ + expect.objectContaining({ workloadId: "database:database", state: "ready" }), + ]); + yield* fixture.supervisor.activate("functions"); + expect( + (yield* Ref.get(fixture.calls)).filter((call) => call === "start:functions:edge-runtime"), + ).toHaveLength(2); + }), + ), + ); + + it.live("fences the owner when lazy rollback cannot be proven", () => + run( + Effect.gen(function* () { + const activationFailFirst = yield* Ref.make(true); + const workloadStopFailFirst = yield* Ref.make(true); + const fixture = yield* makeFixture({ activationFailFirst, workloadStopFailFirst }); + yield* fixture.supervisor.start({ + config: { capabilities: { functions: { activation: "lazy" } } }, + }); + const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect( + errorOf(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), + ).toBeInstanceOf(StackLifecycleConflictError); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); }), ), ); From d17d6682edf2f117ee0266920d7a865912284cf7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 06:06:45 +0200 Subject: [PATCH 120/174] fix(stack): simplify lifecycle fencing --- packages/stack/src/supervisor/Supervisor.ts | 62 ++++----- .../supervisor/supervisor.integration.test.ts | 119 ++++++++++++++++-- 2 files changed, 133 insertions(+), 48 deletions(-) diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 57f0094e6f..9cb6d00c0a 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -170,9 +170,6 @@ export const makeSupervisor = ( // A failed cleanup leaves the owner in `stopping` so callers can retry an exact cleanup. // This marker is set by the backend cleanup boundary and read only by start failure handling. const cleanupProven = yield* Ref.make(true); - // A lazy activation rollback that cannot be proven leaves the owner fenced in stopping until - // an explicit stop retries exact cleanup. - const activationRollbackUnproven = yield* Ref.make(false); // A Supervisor created after a crash must clean exact runtime ephemera once before its first // fresh start. The marker is session-local and only advances after cleanup succeeds. const sessionInitialized = yield* Ref.make(false); @@ -190,7 +187,6 @@ export const makeSupervisor = ( const resetForSession = (input: LifecycleInput) => Effect.gen(function* () { activationOwned.clear(); - yield* Ref.set(activationRollbackUnproven, false); yield* launcher.clear; yield* initializeActivation(input.plan); }); @@ -214,7 +210,6 @@ export const makeSupervisor = ( ); return status; }); - const publish = (): Effect.Effect => snapshot(); const restorePhase = (previous: ActualPhase): Effect.Effect => Effect.gen(function* () { const state = yield* read().pipe(Effect.orElseSucceed(() => undefined)); @@ -226,7 +221,6 @@ export const makeSupervisor = ( else next = "starting"; } yield* Ref.set(phase, next); - yield* publish().pipe(Effect.ignore); }); // Admission rejects every overlapping lifecycle operation while execution serializes @@ -324,19 +318,23 @@ export const makeSupervisor = ( .launch(plan) .pipe(Effect.mapError(mapReconcileError), Effect.exit); if (Exit.isFailure(launched)) { - if (reservation.fresh) yield* runtime.ingress.close.pipe(Effect.ignore); - if (session === "current" && !(yield* launcher.cleanupProven)) { - yield* Ref.set(activationRollbackUnproven, true); + const closed = reservation.fresh + ? yield* runtime.ingress.close.pipe(Effect.mapError(mapReconcileError), Effect.exit) + : Exit.succeed(undefined); + const launchCleanupProven = yield* launcher.cleanupProven; + let cause: Cause.Cause = launched.cause; + if (Exit.isFailure(closed)) cause = Cause.combine(cause, closed.cause); + if (session === "current" && (!launchCleanupProven || Exit.isFailure(closed))) { yield* Ref.set(phase, "stopping"); - const closed = yield* runtime.ingress.close.pipe( - Effect.mapError(mapReconcileError), - Effect.exit, - ); - if (Exit.isFailure(closed)) - return yield* Effect.failCause(Cause.combine(launched.cause, closed.cause)); - yield* publish().pipe(Effect.ignore); + if (!reservation.fresh && !Exit.isFailure(closed)) { + const fenced = yield* runtime.ingress.close.pipe( + Effect.mapError(mapReconcileError), + Effect.exit, + ); + if (Exit.isFailure(fenced)) cause = Cause.combine(cause, fenced.cause); + } } - return yield* Effect.failCause(launched.cause); + return yield* Effect.failCause(cause); } const opened = yield* runtime.ingress .open(input, reservation, ingressActivate) @@ -355,15 +353,12 @@ export const makeSupervisor = ( if (Exit.isFailure(rolledBack)) { yield* Ref.set(cleanupProven, false); if (session === "current") { - yield* Ref.set(activationRollbackUnproven, true); yield* Ref.set(phase, "stopping"); - yield* publish().pipe(Effect.ignore); } } return yield* Effect.failCause(cause); } if (session === "fresh") yield* Ref.set(sessionInitialized, true); - yield* publish(); return launched.value; }); @@ -389,8 +384,6 @@ export const makeSupervisor = ( } if (!destroy) { yield* Ref.set(active, new Set()); - yield* Ref.set(activationRollbackUnproven, false); - yield* publish(); } else { yield* launcher.clear; } @@ -419,7 +412,7 @@ export const makeSupervisor = ( stackId: options.stackId, message: `Cannot activate while ${lifecycle.kind} is in progress`, }); - if ((yield* Ref.get(activationRollbackUnproven)) || (yield* Ref.get(phase)) === "stopping") + if ((yield* Ref.get(phase)) === "stopping") return yield* new StackLifecycleConflictError({ stackId: options.stackId, message: "Cannot activate while exact cleanup is pending; stop the stack first", @@ -463,7 +456,6 @@ export const makeSupervisor = ( const launched = yield* launchBackend(input, "current", next); yield* Ref.set(active, next); yield* Ref.set(phase, "running"); - yield* publish().pipe(Effect.ignore); const activated = yield* runtime.activate(capability, input).pipe(Effect.exit); if (Exit.isFailure(activated)) { yield* Ref.set(active, previousActive); @@ -473,7 +465,6 @@ export const makeSupervisor = ( Effect.exit, ); if (Exit.isFailure(rolledBack)) { - yield* Ref.set(activationRollbackUnproven, true); yield* Ref.set(phase, "stopping"); const closed = yield* runtime.ingress.close.pipe( Effect.mapError(mapReconcileError), @@ -591,16 +582,19 @@ export const makeSupervisor = ( const startOperation = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { const previous = yield* Ref.get(phase); + if (previous === "stopping") + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: "Cannot start while exact cleanup is pending; stop the stack first", + }); const freshSession = !(yield* Ref.get(sessionInitialized)); - if (freshSession || previous === "stopping") { + if (freshSession) { const cleaned = yield* backend.cleanup.pipe(Effect.exit); if (Exit.isFailure(cleaned)) { yield* Ref.set(phase, "stopping"); - yield* publish().pipe(Effect.ignore); return yield* Effect.failCause(cleaned.cause); } yield* Ref.set(phase, "starting"); - yield* publish().pipe(Effect.ignore); } const started = yield* controller .start({ @@ -618,12 +612,10 @@ export const makeSupervisor = ( (durable.value.desiredLifecycle === "stopped" || durable.value.desiredLifecycle === "unconfigured"); yield* Ref.set(phase, canReportStopped ? "stopped" : "stopping"); - yield* publish().pipe(Effect.ignore); } else yield* restorePhase(previous); return yield* Effect.failCause(started.cause); } yield* Ref.set(phase, "running"); - yield* publish(); }); const start = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { @@ -634,7 +626,6 @@ export const makeSupervisor = ( Effect.gen(function* () { const previous = yield* Ref.get(phase); yield* Ref.set(phase, "stopping"); - yield* publish().pipe(Effect.ignore); const result = yield* controller .stop() .pipe(Effect.provideContext(options.context), Effect.exit); @@ -644,14 +635,12 @@ export const makeSupervisor = ( // The durable stopped fence is already committed, but cleanup did not complete. // Keep the observable phase stopping so callers cannot mistake this for a clean stop. yield* Ref.set(phase, "stopping"); - yield* publish().pipe(Effect.ignore); } else { yield* restorePhase(previous); } return yield* Effect.failCause(result.cause); } yield* Ref.set(phase, "stopped"); - yield* publish(); }); const shutdownIfIdle = admission.withPermit( Effect.gen(function* () { @@ -680,7 +669,6 @@ export const makeSupervisor = ( const destroyOperation = Effect.gen(function* () { const previous = yield* Ref.get(phase); yield* Ref.set(phase, "destroying"); - yield* publish().pipe(Effect.ignore); const result = yield* controller .destroy() .pipe(Effect.provideContext(options.context), Effect.exit); @@ -689,7 +677,6 @@ export const makeSupervisor = ( return yield* Effect.failCause(result.cause); } yield* Ref.set(phase, "stopped"); - yield* publish().pipe(Effect.ignore); return result.value; }); const destroy = submitLifecycle("destroy", destroyOperation, continueShutdown).pipe( @@ -697,9 +684,8 @@ export const makeSupervisor = ( ); const logs = (query?: LogQuery): Effect.Effect => Effect.gen(function* () { - // Capture lifecycle phase before reading the log store. Stop publishes the stopped - // phase only after cleanup has appended terminal records; a stopping snapshot therefore - // stays live and gives followers one more poll rather than racing a final batch. + // Capture lifecycle phase before reading the log store. A stopping snapshot stays live + // and gives followers one more poll rather than racing a final batch. const phaseAtRead = yield* Ref.get(phase); const cursor = query?.cursor?.opaque === "v1_0" ? undefined : query?.cursor; const scanned = yield* runtime.logStore diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index aa9ffe8fbe..c176e8899b 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -96,6 +96,7 @@ const makeFixture = ( readonly stopGate?: Deferred.Deferred; readonly stopStarted?: Deferred.Deferred; readonly workloadStopFailFirst?: Ref.Ref; + readonly workloadRemoveFailFirst?: Ref.Ref; readonly stopFailFirst?: Ref.Ref; readonly destroyGate?: Deferred.Deferred; readonly destroyStarted?: Deferred.Deferred; @@ -237,9 +238,22 @@ const makeFixture = ( ); }), remove: (key) => - Ref.update(resources, (current) => - current.filter((entry) => entry.workloadId !== key.workloadId), - ), + Effect.gen(function* () { + if (fixtureOptions.workloadRemoveFailFirst !== undefined) { + const fail = yield* Ref.get(fixtureOptions.workloadRemoveFailFirst); + if (fail) { + yield* Ref.set(fixtureOptions.workloadRemoveFailFirst, false); + return yield* new RuntimeDriverError({ + message: "injected workload remove failure", + stackId: key.stackId, + workloadId: key.workloadId, + }); + } + } + yield* Ref.update(resources, (current) => + current.filter((entry) => entry.workloadId !== key.workloadId), + ); + }), cleanup: ({ destroy }) => Effect.gen(function* () { if (destroy && (yield* Ref.get(failDestroy))) @@ -649,7 +663,40 @@ describe("Supervisor composition", () => { ), ); - it.live("keeps stopping when publishing the stopped fence fails", () => + it.live("keeps stopping when fresh-ingress cleanup fails, then recovers on explicit stop", () => + run( + Effect.gen(function* () { + const closeFailures = yield* Ref.make(2); + const fixture = yield* makeFixture({ + ingress: { + acquire: () => + Effect.succeed({ + assignments: {}, + privateAssignments: [], + hostListeners: [], + fresh: true, + }), + open: () => Effect.void, + close: Effect.gen(function* () { + const remaining = yield* Ref.get(closeFailures); + if (remaining > 0) { + yield* Ref.set(closeFailures, remaining - 1); + return yield* new StackReconciliationError({ message: "injected close failure" }); + } + }), + }, + }); + const failed = yield* fixture.supervisor.start({ config: {} }).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(false); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("unconfigured"); + }), + ), + ); + + it.live("keeps stopping when persisting the stopped fence fails", () => run( Effect.gen(function* () { const stoppedReplaceFail = yield* Ref.make(true); @@ -883,7 +930,7 @@ describe("Supervisor composition", () => { ), ); - it.live("publishes stopping while stop cleanup is still in progress", () => + it.live("reports stopping while stop cleanup is still in progress", () => run( Effect.gen(function* () { const stopGate = yield* Deferred.make(); @@ -978,7 +1025,7 @@ describe("Supervisor composition", () => { ), ); - it.live("publishes destroying while persistent data cleanup is in progress", () => + it.live("reports destroying while persistent data cleanup is in progress", () => run( Effect.gen(function* () { const destroyGate = yield* Deferred.make(); @@ -997,6 +1044,20 @@ describe("Supervisor composition", () => { ), ); + it.live("destroys observed remnants from an unconfigured supervisor", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* Ref.set(fixture.resources, [ + { stackId: fixture.id, workloadId: "database:database", state: "ready" }, + ]); + yield* fixture.supervisor.destroy; + expect(yield* Ref.get(fixture.resources)).toEqual([]); + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + }), + ), + ); + it.live("passes log query through the Supervisor log batch", () => run( Effect.gen(function* () { @@ -1098,20 +1159,25 @@ describe("Supervisor composition", () => { ), ); - it.live("retries unproven stopped cleanup before starting again", () => + it.live("requires explicit stop cleanup before starting again", () => run( Effect.gen(function* () { const stopFailFirst = yield* Ref.make(false); const fixture = yield* makeFixture({ stopFailFirst }); yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); yield* Ref.set(stopFailFirst, true); - yield* fixture.supervisor.maintenanceHandlers.stop; - yield* Ref.set(fixture.calls, []); + const failedStop = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(failedStop.ok).toBe(false); + expect(errorOf(yield* fixture.supervisor.start().pipe(Effect.exit))).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + yield* Ref.set(fixture.calls, []); const started = yield* fixture.supervisor.start(); expect(started.lifecycle).toBe("running"); - expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop", "start:database:database"]); + expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); }), ), ); @@ -1465,7 +1531,40 @@ describe("Supervisor composition", () => { expect( errorOf(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), ).toBeInstanceOf(StackLifecycleConflictError); + expect(errorOf(yield* fixture.supervisor.start().pipe(Effect.exit))).toBeInstanceOf( + StackLifecycleConflictError, + ); expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + yield* fixture.supervisor.shutdown; + const successor = yield* makeSupervisor({ + identity, + stackId: fixture.id, + ownerSessionId: "successor-session", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + expect((yield* successor.start()).lifecycle).toBe("running"); + expect((yield* successor.maintenanceHandlers.stop).ok).toBe(true); + }), + ), + ); + + it.live("fences the owner when lazy rollback remove fails", () => + run( + Effect.gen(function* () { + const activationFailFirst = yield* Ref.make(true); + const workloadRemoveFailFirst = yield* Ref.make(true); + const fixture = yield* makeFixture({ activationFailFirst, workloadRemoveFailFirst }); + yield* fixture.supervisor.start({ + config: { capabilities: { functions: { activation: "lazy" } } }, + }); + const failed = yield* fixture.supervisor.activate("functions").pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); }), ), ); From 267f6694239d319aced8b0f2ad1b57ea758be966 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 06:15:34 +0200 Subject: [PATCH 121/174] fix(stack): unify activation cleanup attempts --- packages/stack/src/supervisor/Supervisor.ts | 94 +++++++------- .../supervisor/supervisor.integration.test.ts | 115 ++++++++++++++++++ 2 files changed, 163 insertions(+), 46 deletions(-) diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 9cb6d00c0a..73b2c95b65 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -40,11 +40,7 @@ import type { EffectStackCredentials } from "../public/Credentials.ts"; import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; -import { - makeSessionLauncher, - type SessionLaunch, - type SessionLauncher, -} from "./SessionLauncher.ts"; +import { makeSessionLauncher, type SessionLauncher } from "./SessionLauncher.ts"; import { makeLifecycleController, type LifecycleBackend, @@ -62,6 +58,11 @@ interface ActivationResult { readonly endpoint: { readonly host: string; readonly port: number }; } +interface SupervisorLaunchAttempt { + /** Rolls back only workloads and ingress acquired by this launch. */ + readonly rollback: Effect.Effect; +} + /** Runtime construction is injected so catalog/artifact resolution can evolve independently. */ export interface SupervisorRuntime { readonly driver: RuntimeDriver; @@ -241,6 +242,31 @@ export const makeSupervisor = ( result: LifecycleResult; }>; const lifecycleActive = yield* Ref.make(undefined); + const ensureActivationAllowed = (): Effect.Effect< + PersistedStackState, + GatewayActivationError | StackError + > => + Effect.gen(function* () { + const lifecycle = yield* Ref.get(lifecycleActive); + if (lifecycle !== undefined) + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: `Cannot activate while ${lifecycle.kind} is in progress`, + }); + if ((yield* Ref.get(phase)) === "stopping") + return yield* new StackLifecycleConflictError({ + stackId: options.stackId, + message: "Cannot activate while exact cleanup is pending; stop the stack first", + }); + const state = yield* read(); + if (state === undefined) + return yield* new StackStateInvalidError({ message: "Stack state is missing" }); + if (state.desiredLifecycle !== "running") + return yield* new StackNotRunningError({ + message: "Stack must be running before activation", + }); + return state; + }); const shutdownSignal = yield* Deferred.make(); const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); const ensureAcceptingOperations = Deferred.poll(shutdownSignal).pipe( @@ -307,7 +333,7 @@ export const makeSupervisor = ( input: LifecycleInput, session: "fresh" | "current", selectedOverride?: ReadonlySet, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { if (session === "fresh") yield* resetForSession(input); if (input.desiredLifecycle !== "running") return undefined; @@ -336,20 +362,26 @@ export const makeSupervisor = ( } return yield* Effect.failCause(cause); } - const opened = yield* runtime.ingress - .open(input, reservation, ingressActivate) - .pipe(Effect.exit); - if (Exit.isFailure(opened)) { - const rolledBack = yield* launched.value.rollback.pipe( + const rollback: Effect.Effect = Effect.gen(function* () { + const workload = yield* launched.value.rollback.pipe( Effect.mapError(mapReconcileError), Effect.exit, ); const closed = reservation.fresh ? yield* runtime.ingress.close.pipe(Effect.mapError(mapReconcileError), Effect.exit) : Exit.succeed(undefined); + let cause: Cause.Cause = Cause.empty; + if (Exit.isFailure(workload)) cause = Cause.combine(cause, workload.cause); + if (Exit.isFailure(closed)) cause = Cause.combine(cause, closed.cause); + if (cause.reasons.length > 0) return yield* Effect.failCause(cause); + }); + const opened = yield* runtime.ingress + .open(input, reservation, ingressActivate) + .pipe(Effect.exit); + if (Exit.isFailure(opened)) { + const rolledBack = yield* rollback.pipe(Effect.exit); let cause: Cause.Cause = opened.cause; if (Exit.isFailure(rolledBack)) cause = Cause.combine(cause, rolledBack.cause); - if (Exit.isFailure(closed)) cause = Cause.combine(cause, closed.cause); if (Exit.isFailure(rolledBack)) { yield* Ref.set(cleanupProven, false); if (session === "current") { @@ -359,7 +391,7 @@ export const makeSupervisor = ( return yield* Effect.failCause(cause); } if (session === "fresh") yield* Ref.set(sessionInitialized, true); - return launched.value; + return { rollback } satisfies SupervisorLaunchAttempt; }); const cleanupRuntime = (destroy: boolean): Effect.Effect => @@ -406,24 +438,7 @@ export const makeSupervisor = ( capability: CapabilityName, ): Effect.Effect => Effect.gen(function* () { - const lifecycle = yield* Ref.get(lifecycleActive); - if (lifecycle !== undefined) - return yield* new StackLifecycleConflictError({ - stackId: options.stackId, - message: `Cannot activate while ${lifecycle.kind} is in progress`, - }); - if ((yield* Ref.get(phase)) === "stopping") - return yield* new StackLifecycleConflictError({ - stackId: options.stackId, - message: "Cannot activate while exact cleanup is pending; stop the stack first", - }); - const state = yield* read(); - if (state === undefined) - return yield* new StackStateInvalidError({ message: "Stack state is missing" }); - if (state.desiredLifecycle !== "running") - return yield* new StackNotRunningError({ - message: "Stack must be running before activation", - }); + const state = yield* ensureActivationAllowed(); const definition = state.definition; if (definition === undefined || !definition.capabilities[capability].enabled) return yield* new GatewayActivationError({ @@ -460,18 +475,10 @@ export const makeSupervisor = ( if (Exit.isFailure(activated)) { yield* Ref.set(active, previousActive); if (launched !== undefined) { - const rolledBack = yield* launched.rollback.pipe( - Effect.mapError(mapReconcileError), - Effect.exit, - ); + const rolledBack = yield* launched.rollback.pipe(Effect.exit); if (Exit.isFailure(rolledBack)) { yield* Ref.set(phase, "stopping"); - const closed = yield* runtime.ingress.close.pipe( - Effect.mapError(mapReconcileError), - Effect.exit, - ); let cause: Cause.Cause = Cause.combine(activated.cause, rolledBack.cause); - if (Exit.isFailure(closed)) cause = Cause.combine(cause, closed.cause); return yield* Effect.failCause(cause); } } @@ -503,14 +510,9 @@ export const makeSupervisor = ( capability: CapabilityName, ): Effect.Effect => Effect.gen(function* () { - const lifecycle = yield* Ref.get(lifecycleActive); - if (lifecycle !== undefined) - return yield* new StackLifecycleConflictError({ - stackId: options.stackId, - message: `Cannot activate while ${lifecycle.kind} is in progress`, - }); const token = yield* admission.withPermit( Effect.gen(function* () { + yield* ensureActivationAllowed(); const current = activationOwned.get(capability); if (current?._tag === "ready") return { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index c176e8899b..87f5fe8152 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -24,6 +24,7 @@ import { GatewayActivationError, PortUnavailableError, StackLifecycleConflictError, + StackNotRunningError, StackReconciliationError, StackStateInvalidError, StackMustBeStoppedError, @@ -1516,6 +1517,88 @@ describe("Supervisor composition", () => { ), ); + it.live("fences fresh ingress when open failure cleanup is not proven", () => + run( + Effect.gen(function* () { + const openCalls = yield* Ref.make(0); + const closeFailures = yield* Ref.make(0); + const fixture = yield* makeFixture({ + ingress: { + acquire: () => + Effect.succeed({ + assignments: {}, + privateAssignments: [], + hostListeners: [], + fresh: true, + }), + open: () => + Ref.updateAndGet(openCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 2 + ? Effect.fail(new GatewayActivationError({ message: "injected open failure" })) + : Effect.void, + ), + ), + close: Effect.gen(function* () { + const remaining = yield* Ref.get(closeFailures); + if (remaining > 0) { + yield* Ref.set(closeFailures, remaining - 1); + return yield* new StackReconciliationError({ message: "injected close failure" }); + } + }), + }, + }); + yield* fixture.supervisor.start({ + config: { capabilities: { functions: { activation: "lazy" } } }, + }); + yield* Ref.set(closeFailures, 1); + expect( + Exit.isFailure(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), + ).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + }), + ), + ); + + it.live("rolls back fresh ingress after activation failure and retries", () => + run( + Effect.gen(function* () { + const activationFailFirst = yield* Ref.make(true); + const openCalls = yield* Ref.make(0); + const closeCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ + activationFailFirst, + ingress: { + acquire: () => + Effect.succeed({ + assignments: {}, + privateAssignments: [], + hostListeners: [], + fresh: true, + }), + open: () => Ref.update(openCalls, (count) => count + 1), + close: Ref.update(closeCalls, (count) => count + 1), + }, + }); + yield* fixture.supervisor.start({ + config: { capabilities: { functions: { activation: "lazy" } } }, + }); + expect( + Exit.isFailure(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), + ).toBe(true); + expect(yield* Ref.get(closeCalls)).toBe(2); + expect(yield* Ref.get(fixture.resources)).toEqual([ + expect.objectContaining({ workloadId: "database:database", state: "ready" }), + ]); + const retry = yield* fixture.supervisor.activate("functions"); + expect(retry.endpoint).toEqual({ host: "127.0.0.1", port: 9999 }); + expect(yield* Ref.get(openCalls)).toBe(3); + }), + ), + ); + it.live("fences the owner when lazy rollback cannot be proven", () => run( Effect.gen(function* () { @@ -1569,6 +1652,38 @@ describe("Supervisor composition", () => { ), ); + it.live("validates lifecycle before cached lazy activation results", () => + run( + Effect.gen(function* () { + const activationFailFirst = yield* Ref.make(false); + const workloadRemoveFailFirst = yield* Ref.make(false); + const fixture = yield* makeFixture({ activationFailFirst, workloadRemoveFailFirst }); + yield* fixture.supervisor.start({ + config: { + capabilities: { + functions: { activation: "lazy" }, + auth: { activation: "lazy" }, + }, + }, + }); + yield* fixture.supervisor.activate("functions"); + yield* Ref.set(activationFailFirst, true); + yield* Ref.set(workloadRemoveFailFirst, true); + expect(Exit.isFailure(yield* fixture.supervisor.activate("auth").pipe(Effect.exit))).toBe( + true, + ); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect( + errorOf(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), + ).toBeInstanceOf(StackLifecycleConflictError); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + expect( + errorOf(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), + ).toBeInstanceOf(StackNotRunningError); + }), + ), + ); + it.live("rejects activation while an explicit lifecycle transition is active", () => run( Effect.gen(function* () { From e2929a5f2c0e18438cef6729b38afc8824dec18a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 06:16:31 +0200 Subject: [PATCH 122/174] test(stack): cover ingress rollback cleanup --- packages/stack/src/supervisor/supervisor.integration.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 87f5fe8152..9b85f8626d 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1555,6 +1555,9 @@ describe("Supervisor composition", () => { expect( Exit.isFailure(yield* fixture.supervisor.activate("functions").pipe(Effect.exit)), ).toBe(true); + expect(yield* Ref.get(fixture.resources)).toEqual([ + expect.objectContaining({ workloadId: "database:database", state: "ready" }), + ]); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); From 0a253b003ff21e8513f7bce2d527c59ca0edd771 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 06:18:46 +0200 Subject: [PATCH 123/174] test(stack): assert successor eager launch --- packages/stack/src/supervisor/supervisor.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 9b85f8626d..39512dc3d6 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1631,7 +1631,9 @@ describe("Supervisor composition", () => { context: fixture.context, runtime: fixture.runtime, }); + yield* Ref.set(fixture.calls, []); expect((yield* successor.start()).lifecycle).toBe("running"); + expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); expect((yield* successor.maintenanceHandlers.stop).ok).toBe(true); }), ), From 35bc303254301782a1d9b2c861a78a934d543454 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 06:25:07 +0200 Subject: [PATCH 124/174] fix(stack): preserve ingress reservation identity --- packages/stack/src/supervisor/Ingress.ts | 9 ++++++-- .../supervisor/ingress.integration.test.ts | 23 +++++++++++++++++++ .../supervisor/supervisor.integration.test.ts | 7 ++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 812b8a18f2..53d8a23b69 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -30,6 +30,8 @@ import { bindHostListener, isHttpPortField } from "./HostListener.ts"; interface SupervisorIngressReservation extends PortReservation { /** False when this accepted definition already owns the exact listeners and gateway. */ readonly fresh: boolean; + /** Stable identity for the reservation across non-fresh reacquisition views. */ + readonly ownershipToken: symbol; } export interface SupervisorIngress { @@ -184,7 +186,6 @@ export const makeSupervisorIngress = ( // changes are rejected while running and a stopped session closes this reservation, // so a live reservation can always be reused without a configuration fingerprint. if (existing !== undefined) return { ...existing.reservation, fresh: false }; - if (existing !== undefined) yield* closeCurrent(existing); const reservationScope = Scope.forkUnsafe(ownerScope); const reservation = yield* coordinator .planAndReserve(options.stackId, listenerIntents(input), { @@ -197,6 +198,7 @@ export const makeSupervisorIngress = ( const owned: SupervisorIngressReservation = { ...reservation, fresh: true, + ownershipToken: Symbol(), }; yield* Ref.set(current, { input, reservation: owned, scope: reservationScope }); return owned; @@ -232,7 +234,10 @@ export const makeSupervisorIngress = ( lock.withPermit( Effect.gen(function* () { const entry = yield* Ref.get(current); - if (entry === undefined || entry.reservation !== reservation) + if ( + entry === undefined || + entry.reservation.ownershipToken !== reservation.ownershipToken + ) return yield* new GatewayActivationError({ message: "Gateway reservation is no longer current", }); diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index a9475319bd..914c9e88c7 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -171,6 +171,29 @@ describe("Supervisor ingress", () => { const response = yield* request(api.port); expect(response.status).toBe(200); expect(response.body).toBe("forwarded"); + const reused = yield* ingress.acquire(input); + expect(reused.fresh).toBe(false); + yield* ingress.open(input, reused, (capability) => + Effect.succeed({ + capability, + endpoint: { host: "127.0.0.1", port: backendAddress.port }, + }), + ); + const reusedResponse = yield* request(api.port); + expect(reusedResponse.status).toBe(200); + expect(reusedResponse.body).toBe("forwarded"); + yield* ingress.close; + const reacquired = yield* ingress.acquire(input); + expect(reacquired.fresh).toBe(true); + const stale = yield* ingress + .open(input, reservation, (capability) => + Effect.succeed({ + capability, + endpoint: { host: "127.0.0.1", port: backendAddress.port }, + }), + ) + .pipe(Effect.exit); + expect(Exit.isFailure(stale)).toBe(true); yield* ingress.close; }), ), diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 39512dc3d6..611ded61e9 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -347,6 +347,7 @@ const makeFixture = ( privateAssignments: [], hostListeners: [], fresh: false, + ownershipToken: Symbol(), }), open: () => Effect.void, close: Effect.void, @@ -450,6 +451,7 @@ describe("Supervisor composition", () => { privateAssignments: [], hostListeners: [], fresh: true, + ownershipToken: Symbol(), }) : Effect.fail( new PortUnavailableError({ @@ -496,6 +498,7 @@ describe("Supervisor composition", () => { privateAssignments: [], hostListeners: [], fresh: true, + ownershipToken: Symbol(), }) : Effect.fail( new PortUnavailableError({ @@ -676,6 +679,7 @@ describe("Supervisor composition", () => { privateAssignments: [], hostListeners: [], fresh: true, + ownershipToken: Symbol(), }), open: () => Effect.void, close: Effect.gen(function* () { @@ -1489,6 +1493,7 @@ describe("Supervisor composition", () => { privateAssignments: [], hostListeners: [], fresh: true, + ownershipToken: Symbol(), }), open: () => Ref.updateAndGet(openCalls, (count) => count + 1).pipe( @@ -1530,6 +1535,7 @@ describe("Supervisor composition", () => { privateAssignments: [], hostListeners: [], fresh: true, + ownershipToken: Symbol(), }), open: () => Ref.updateAndGet(openCalls, (count) => count + 1).pipe( @@ -1580,6 +1586,7 @@ describe("Supervisor composition", () => { privateAssignments: [], hostListeners: [], fresh: true, + ownershipToken: Symbol(), }), open: () => Ref.update(openCalls, (count) => count + 1), close: Ref.update(closeCalls, (count) => count + 1), From 85b84ac1f43f79a6a5714b0ae7e75ccf8070a79f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 06:56:33 +0200 Subject: [PATCH 125/174] refactor(stack): prune stale public surface --- .../shared/telemetry/error-actionability.ts | 2 - ...7-simplified-managed-stack-architecture.md | 45 +++++------ packages/stack/README.md | 6 ++ packages/stack/src/public/Capability.ts | 1 - packages/stack/src/public/Logs.ts | 8 -- .../public/effect-stack.integration.test.ts | 2 +- .../stack/src/public/whole-stack.e2e.test.ts | 74 +++++++++++++++++-- packages/stack/src/runtime/ContainerEngine.ts | 2 +- packages/stack/src/supervisor/LogStore.ts | 22 ++++-- .../stack/src/supervisor/SessionLauncher.ts | 2 +- 10 files changed, 115 insertions(+), 49 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 4c3d1e2c24..f262daf59f 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -848,13 +848,11 @@ const externalActionabilityByTag: Record = { StackLifecycleConflictError: () => actionability.startStack, StackStateInvalidError: () => actionability.invalidConfig, StackStateFormatUnsupportedError: () => actionability.invalidConfig, - StackStateGenerationMismatchError: () => actionability.startStack, StackUpgradeRequiredError: () => actionability.startStack, StackSecretMismatchError: () => actionability.invalidConfig, InvalidJwtSigningMaterialError: () => actionability.invalidConfig, PortUnavailableError: () => actionability.invalidConfig, GatewayAuthenticationError: () => actionability.startStack, - GatewayStaleGenerationError: () => actionability.startStack, GatewayActivationError: () => actionability.startStack, StackPreparationError: () => actionability.startStack, ArtifactIntegrityError: () => actionability.externalNetwork, diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index d9928be833..9b10fb2c70 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -32,28 +32,28 @@ The same handle lazily launches a fresh Supervisor on its next start. A Supervisor launched for a mutation also exits when that mutation cannot leave running intent behind. In particular, a failed start before the running state is committed releases its control endpoint and ownership lease after reporting the -failure. A successful start keeps the Supervisor alive; an idempotent start joins -that current session without resetting lazy activation or publishing a synthetic -starting transition. +failure. A successful start keeps the Supervisor alive; a sequential idempotent +start returns the current status without resetting lazy activation or publishing +a synthetic starting transition. -A restart that has committed stopped intent retries exact runtime cleanup before -its temporary Supervisor may exit. If cleanup still cannot be proven, status -remains `stopping` and the owner stays available for a retryable `stop()`; a -cleaned stopped stack never retains that owner. +If exact runtime cleanup cannot be proven, status remains `stopping` and the +owner stays available for a retryable `stop()`; a cleaned stopped stack never +retains that owner. There is no durable generation counter and runtime resources are never adopted across owner sessions. The exclusive stack lease and `ownerSessionId` identify -the only writer and current ephemeral resources. Explicit start, restart, stop, -or destroy first removes exact stack-owned remnants and creates fresh resources; +the only writer and current ephemeral resources. Explicit start, stop, or destroy +first removes exact stack-owned remnants and creates fresh resources; failure to complete or validate cleanup fails closed. Persistent data, sticky ports, secrets, definitions, logs, and artifacts remain identity-scoped. Before a native cold start creates anything, it verifies every persisted public and private port is bindable. PostgreSQL lock evidence containing a live or unclassifiable owner PID fails closed; a lock naming a process that no longer -exists is left for PostgreSQL's own stale-lock recovery. A live restart -preflights configuration without trying to bind ports that the current -Supervisor intentionally owns. Cold recovery never adopts those resources. +exists is left for PostgreSQL's own stale-lock recovery. A live stop/start +composition preflights configuration without trying to bind ports that the +current Supervisor intentionally owns. Cold recovery never adopts those +resources. Every managed document records one concrete runtime selection. Native and container runtimes never mix. Container state records the selected Docker or @@ -66,20 +66,21 @@ when both metadata and lock are absent. Mutations use the live owner or acquire the lease and launch a fresh Supervisor. Ambiguous ownership fails closed. The stable maintenance protocol contains only probe and stop. Same-release -callers use Effect RPC for status, logs, preparation, activation, start, -restart, and destroy. An incompatible owner can always be stopped; explicit -restart may stop it, wait for lease release, launch the current Supervisor, and -start again. The first admitted lifecycle operation runs to completion; -equivalent callers join it and conflicting callers fail immediately rather than -queueing. +callers use Effect RPC for status, logs, activation, start, and destroy; artifact +preparation runs directly in the caller scope and never uses the Supervisor. An +incompatible owner can always be stopped, after which the caller may launch the +current Supervisor and start again. The first admitted lifecycle operation runs +to completion; concurrent lifecycle mutations fail immediately with a conflict +rather than joining or queueing. PostgreSQL is the only eager capability by default. Start prepares and launches only the eager dependency closure. Every other enabled capability prepares and starts when traffic first reaches its stable gateway route. Explicit `stack.prepare(...)` remains available for callers that intentionally want to warm selected artifacts, but it is not part of ordinary CLI start. Preparation -is owned by a temporary Supervisor, survives cancellation of the caller that -requested it, and releases control and ownership after the transfer completes. +is caller-owned: cancellation stops that caller's unfinished transfers while +completed cache entries remain. It never acquires control or ownership and does +not create runtime resources. ## Why this replaces ADR-0015 @@ -94,11 +95,11 @@ Tests follow consumed boundaries: - stack integration covers identity, sticky ports, durable lifecycle, ownership, stale-owner recovery, and interrupted cleanup; -- supervisor integration covers detached ownership, RPC, stop, restart, and +- supervisor integration covers detached ownership, RPC, stop, and destroy; and - one shared stack-package E2E journey runs in native and Docker modes, starts with PostgreSQL alone, activates every other service through realistic - traffic, verifies cross-service behavior and migrations, then exercises + traffic, verifies cross-service behavior, then exercises stop/start and retained offline observability. CLI handler integration covers only argument translation, output, telemetry, diff --git a/packages/stack/README.md b/packages/stack/README.md index 1aa245d6ad..58fc3b1823 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -24,5 +24,11 @@ Docker modes. It begins from the PostgreSQL-only default, progressively activate realistic traffic, and verifies stop/start cycles, stable ports, and persistent data. The CLI is not involved in these runtime tests. +Callers can warm selected native artifacts or container images with `stack.prepare(...)` while a +stack is stopped; preparation is cache-only and cancellation does not affect completed entries. +Each capability may opt into eager activation in `StackConfig`; omitted settings keep every +non-PostgreSQL capability lazy. `followLogs(...)` provides filterable live entries through a +stateless client-polled cursor. + Database reset is intentionally outside the current API. Applying migrations, declarative schemas, and seeds remains the caller's responsibility. diff --git a/packages/stack/src/public/Capability.ts b/packages/stack/src/public/Capability.ts index 7668c3883f..9037b5f18a 100644 --- a/packages/stack/src/public/Capability.ts +++ b/packages/stack/src/public/Capability.ts @@ -19,7 +19,6 @@ export type CapabilityName = Schema.Schema.Type; export const CapabilityStateSchema = Schema.Literals([ "disabled", "dormant", - "preparing", "starting", "ready", "stopping", diff --git a/packages/stack/src/public/Logs.ts b/packages/stack/src/public/Logs.ts index d28204a63b..0763e3d84b 100644 --- a/packages/stack/src/public/Logs.ts +++ b/packages/stack/src/public/Logs.ts @@ -13,14 +13,6 @@ export const LogQuerySchema = Schema.Struct({ }); export type LogQuery = Schema.Schema.Type; -export const LogOptionsSchema = Schema.Struct({ - capabilities: Schema.optionalKey(Schema.Array(CapabilityNameSchema)), - follow: Schema.optionalKey(Schema.Boolean), - cursor: Schema.optionalKey(LogCursorSchema), - tail: Schema.optionalKey(Schema.Finite), -}); -export type LogOptions = Schema.Schema.Type; - export const StackLogEntrySchema = Schema.Struct({ cursor: LogCursorSchema, timestamp: Schema.String, diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 8db1b95377..8e54c21eb7 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -365,7 +365,7 @@ describe("Effect stack lifecycle handoff", () => { return yield* new StackOwnershipConflictError({ message: "temporary Supervisor is still shutting down", }); - }).pipe(Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 })))); + }).pipe(Effect.retry(Schedule.recurs(200))); expect((yield* stack.status()).lifecycle).toBe("unconfigured"); }), ), diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 32fb2617f1..a172ca0eef 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -59,9 +59,9 @@ const dockerOwnedResourceCount = async ( return result.stdout.trim().length === 0 ? 0 : result.stdout.trim().split("\n").length; }; -const supervisorPid = async (stackId: string): Promise => { +const supervisorPids = async (stackId: string): Promise> => { const result = await execFile("ps", ["-axo", "pid=,command="], { encoding: "utf8" }); - const matches = result.stdout.split("\n").flatMap((line) => { + return result.stdout.split("\n").flatMap((line) => { const match = /^\s*(\d+)\s+(.+)$/u.exec(line); if (match === null) return []; const pid = Number(match[1]); @@ -71,6 +71,10 @@ const supervisorPid = async (stackId: string): Promise => { ? [pid] : []; }); +}; + +const supervisorPid = async (stackId: string): Promise => { + const matches = await supervisorPids(stackId); if (matches.length !== 1) throw new Error(`Expected one Supervisor process for ${stackId}, found ${matches.length}`); const pid = matches[0]; @@ -473,8 +477,29 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom }, }); + const initialRunning = await stack.status(); + expect(initialRunning.runtime).toEqual(mode.runtime); + expect(projectRoot.length).toBeGreaterThan(0); + expectDefaultLazyState(initialRunning); + + // Preparation is an explicit cache-only operation. It runs after the helper's + // initial session is stopped, so the test proves it creates no owner, listener, + // or workload and that the next start can reuse the warmed REST artifact. + await stack.stop(); + const warmed = await stack.prepare({ capabilities: ["rest"] }); + expect(warmed.capabilities).toEqual( + expect.arrayContaining([expect.objectContaining({ capability: "rest" })]), + ); + expect((await stack.status()).lifecycle).toBe("stopped"); + expect(await supervisorPids(stack.id)).toHaveLength(0); + await expectEndpointsRefused( + Object.values(initialRunning.endpoints).filter( + (value): value is StackEndpoint => value !== undefined, + ), + ); + const initial = await stack.start(); + expectDefaultLazyState(initial); const credentials = await stack.credentials(); - const initial = await stack.status(); const api = endpoint(initial, "api"); const pooler = endpoint(initial, "pooler"); const studio = endpoint(initial, "studio"); @@ -483,9 +508,8 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom (value): value is StackEndpoint => value !== undefined, ); - expect(initial.runtime).toEqual(mode.runtime); - expect(projectRoot.length).toBeGreaterThan(0); - expectDefaultLazyState(initial); + // The warmed capability remains lazy: only its artifact is cached, not its + // workload. The first request below still performs the normal activation. // Direct SQL creates the table and enables Realtime's publication for it. await databaseQuery( @@ -507,6 +531,18 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom ); expect(directRows).toEqual([{ id: 1, payload: markers.first }]); + // Log following is a filtered, client-polled view over the same retained + // cursor API. Consume one database entry and explicitly cancel the iterator; + // no server-side subscription or handle-owned resource is involved. + expect((await stack.logs({ capabilities: ["database"], tail: 1 })).entries).not.toHaveLength(0); + const logIterator = stack + .followLogs({ capabilities: ["database"], tail: 1 }) + [Symbol.asyncIterator](); + const logEntry = await logIterator.next(); + expect(logEntry.done).toBe(false); + if (!logEntry.done) expect(logEntry.value.source).toBe("database"); + await logIterator.return?.(); + // PostgREST reads the SQL-created row, then writes through the authenticated user token. const restPath = `/rest/v1/${table}?select=id,payload&order=id`; let restRows: unknown = undefined; @@ -893,6 +929,31 @@ describe("managed Supabase stack whole-stack E2E", () => { runWholeStackScenario(mode), ); + test( + `honors per-capability eager activation in ${mode.name} mode`, + { timeout: E2E_TIMEOUT_MS }, + async () => { + await using stack: TestStack = await createTestStack({ + name: `stack-eager-${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`, + runtime: mode.runtime, + config: { + capabilities: { + rest: { activation: "eager" }, + auth: { activation: "eager" }, + }, + }, + }); + const status = await stack.status(); + expect(capabilityState(status, "database")).toBe("ready"); + expect(capabilityState(status, "rest")).toBe("ready"); + expect(capabilityState(status, "auth")).toBe("ready"); + for (const name of CAPABILITY_NAMES) { + if (name === "database" || name === "rest" || name === "auth") continue; + expect(capabilityState(status, name), `${name} should remain dormant`).toBe("dormant"); + } + }, + ); + test( `supports optional image and vector workloads in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, @@ -1024,6 +1085,7 @@ describe("managed Supabase stack whole-stack E2E", () => { const stopped = await stack.status(); expect(stopped.lifecycle).toBe("stopped"); expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); + expect(await supervisorPids(stack.id)).toHaveLength(0); await expectRuntimeInputsAbsent(projectRoot, stack.id); await expectEndpointsRefused(endpointSnapshot); if (mode.runtime.kind === "container") { diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 31b45e407d..75bcde2be6 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -16,7 +16,7 @@ export interface ContainerHostRoute { readonly host: string; readonly gateway?: string; } -export class ContainerExecutableNotFoundError extends Data.TaggedError( +class ContainerExecutableNotFoundError extends Data.TaggedError( "ContainerExecutableNotFoundError", )<{ readonly executable: string; readonly message: string }> {} class ContainerRoutingUnsupportedError extends Data.TaggedError( diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index ba04f86eeb..51507b66f8 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -13,10 +13,10 @@ import { import { StackLogEntrySchema, type LogCursor, - type LogOptions, type StackLogEntry, type StackLogSource, } from "../public/Logs.ts"; +import type { CapabilityName } from "../public/Capability.ts"; import { redactKnownSecrets } from "../state/SecretStore.ts"; const CURSOR_PREFIX = "v1_"; @@ -46,19 +46,27 @@ export interface LogRecord { readonly message: string; } +/** Internal retained-log query; follow is only used by the Supervisor log store stream. */ +interface LogStoreQuery { + readonly capabilities?: ReadonlyArray; + readonly follow?: boolean; + readonly cursor?: LogCursor; + readonly tail?: number; +} + export interface LogStore { readonly path: string; readonly append: (record: LogRecord) => Effect.Effect; /** Returns retained records selected by cursor and capability filter. */ readonly read: ( - options?: LogOptions, + options?: LogStoreQuery, ) => Effect.Effect, LogStoreError>; /** * Returns retained records followed by live records when `follow` is true. * Subscription creation and retained snapshotting happen in one critical * section with append, so no entry can be lost or observed twice at handoff. */ - readonly stream: (options?: LogOptions) => Stream.Stream; + readonly stream: (options?: LogStoreQuery) => Stream.Stream; } interface LogDocument { @@ -96,7 +104,7 @@ const decodeCursor = ( const selected = ( entries: ReadonlyArray, - options: LogOptions | undefined, + options: LogStoreQuery | undefined, path: string, ): Effect.Effect, LogStoreError> => Effect.map(decodeCursor(path, options?.cursor), (after) => { @@ -182,7 +190,7 @@ const readDocument = ( export const readRetainedLogs = ( fs: FileSystem.FileSystem, path: string, - options?: LogOptions, + options?: LogStoreQuery, ): Effect.Effect, LogStoreError> => readDocument(fs, path).pipe( Effect.flatMap((document) => selected(document.entries, options, path)), @@ -379,10 +387,10 @@ export const makeLogStore = ( }), ); - const read = (readOptions?: LogOptions) => + const read = (readOptions?: LogStoreQuery) => semaphore.withPermit(selected(document.entries, readOptions, options.path)); - const stream = (streamOptions?: LogOptions): Stream.Stream => + const stream = (streamOptions?: LogStoreQuery): Stream.Stream => Stream.unwrap( Effect.gen(function* () { const handoff = yield* semaphore.withPermit( diff --git a/packages/stack/src/supervisor/SessionLauncher.ts b/packages/stack/src/supervisor/SessionLauncher.ts index cb59945021..b97e2fb05b 100644 --- a/packages/stack/src/supervisor/SessionLauncher.ts +++ b/packages/stack/src/supervisor/SessionLauncher.ts @@ -24,7 +24,7 @@ export interface SessionLauncher { } /** Resources created by one launch attempt and a rollback scoped to that attempt. */ -export interface SessionLaunch { +interface SessionLaunch { readonly rollback: Effect.Effect; } From 9d7124a1e1ecd6657b34b11a252ebf02c49828ad Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 09:10:07 +0200 Subject: [PATCH 126/174] refactor(stack): tighten lifecycle and observability surfaces --- .../commands/start/start.integration.test.ts | 8 +- apps/cli/src/shared/output/normalize-error.ts | 137 ----------- .../output/normalize-error.unit.test.ts | 179 ++------------- .../shared/telemetry/error-actionability.ts | 158 +------------ packages/stack/src/gateway/HttpGateway.ts | 3 +- packages/stack/src/public/EffectStack.ts | 150 +++++------- packages/stack/src/public/Errors.ts | 215 ++++++++++-------- .../public/effect-stack.integration.test.ts | 11 +- .../stack/src/public/whole-stack.e2e.test.ts | 125 +++++++++- packages/stack/src/runtime/NativeProcess.ts | 18 +- packages/stack/src/runtime/NativeRuntime.ts | 4 +- .../stack/src/runtime/ProductionRuntime.ts | 16 +- .../container-runtime.integration.test.ts | 4 - .../native-runtime.integration.test.ts | 99 ++++---- .../production-runtime.integration.test.ts | 74 +++--- packages/stack/src/supervisor/Lifecycle.ts | 61 +---- packages/stack/src/supervisor/LogStore.ts | 80 +------ .../stack/src/supervisor/StatusProjection.ts | 9 +- packages/stack/src/supervisor/Supervisor.ts | 77 +++---- .../supervisor/ingress.integration.test.ts | 2 - .../supervisor/lifecycle.integration.test.ts | 62 +++-- .../observability.integration.test.ts | 38 +--- .../supervisor/supervisor.integration.test.ts | 45 ++-- 23 files changed, 526 insertions(+), 1049 deletions(-) diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index 6f765d2fad..075fee2938 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -3,7 +3,7 @@ import { Cause, Effect, Exit, Layer, Option } from "effect"; import { start, type StartOperations, type StartStack } from "./start.handler.ts"; import { CAPABILITY_NAMES, - ServiceStartError, + StackRuntimeError, StackIdSchema, StackMustBeStoppedError, } from "@supabase/stack/effect"; @@ -14,7 +14,7 @@ describe("start handler", () => { it.live("preserves the runtime startup failure tag through the command boundary", () => { const out = mockOutput({ interactive: false }); const stack: StartStack = { - start: () => Effect.fail(new ServiceStartError({ message: "auth failed" })), + start: () => Effect.fail(new StackRuntimeError({ message: "auth failed" })), }; const operations: StartOperations = { createStack: () => Effect.succeed(stack), @@ -30,8 +30,8 @@ describe("start handler", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); - expect(failure).toBeInstanceOf(ServiceStartError); - if (failure instanceof ServiceStartError) expect(failure.message).toBe("auth failed"); + expect(failure).toBeInstanceOf(StackRuntimeError); + if (failure instanceof StackRuntimeError) expect(failure.message).toBe("auth failed"); } }), ), diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index f91fe98dd9..ce64c56c0c 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -31,149 +31,12 @@ const readRawString = (value: ErrorRecord, key: string): string | undefined => { return typeof field === "string" ? field : undefined; }; -const readCauseMessage = (value: ErrorRecord): string | undefined => { - const cause = value["cause"]; - if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); - if (typeof cause === "string" && cause.trim().length > 0) return cause.trim(); - if (isErrorRecord(cause)) { - return readString(cause, "message") ?? readString(cause, "detail"); - } - return undefined; -}; - const mappedError = ( error: ErrorRecord, context?: CliErrorSuggestionContext, ): NormalizedCliError | undefined => { const tag = readString(error, "_tag"); switch (tag) { - case "NoRunningStackError": - return { - code: tag, - message: "No local Supabase stack is running for this project.", - detail: "The CLI could not find a running stack for the current working directory.", - suggestion: - "Run `supabase start` in this project, or change into a directory with a running stack.", - }; - - case "DaemonStartError": - return { - code: tag, - message: readString(error, "message") ?? "Failed to start the Supabase daemon.", - suggestion: "Check local resources and try `supabase start` again.", - }; - case "DaemonUpgradeRequired": { - const oldCliVersion = readString(error, "oldCliVersion") ?? "an older CLI"; - const newCliVersion = readString(error, "newCliVersion") ?? "the current CLI"; - return { - code: tag, - message: `The local Supabase stack is running under ${oldCliVersion}, but this CLI is ${newCliVersion}.`, - suggestion: "Run `supabase start` to restart the stack with the current CLI.", - }; - } - case "StackUnavailableError": { - const phase = readString(error, "phase"); - const detail = readString(error, "detail"); - const message = - phase === "starting" - ? "The local Supabase stack is still starting." - : phase === "stopping" - ? "The local Supabase stack is still stopping." - : phase === "failed" - ? "The local Supabase stack failed to start." - : "The local Supabase stack is unavailable."; - const suggestion = - phase === "starting" - ? "Wait for `supabase start` to finish, then try again." - : phase === "stopping" - ? "Wait for the current stop operation to finish, then try again." - : phase === "failed" - ? "Run `supabase start` again to recreate the local stack." - : "Run `supabase start`, then retry the command."; - return { - code: tag, - message, - ...(detail === undefined ? {} : { detail }), - suggestion, - }; - } - case "StackRpcTransportError": { - const endpoint = readString(error, "endpoint") ?? "the local stack endpoint"; - const procedure = readString(error, "procedure") ?? "the requested operation"; - const cause = readCauseMessage(error); - return { - code: tag, - message: "Could not communicate with the local Supabase stack.", - detail: `RPC ${procedure} at ${endpoint} failed${cause === undefined ? "." : `: ${cause}`}`, - suggestion: "Check that the stack is running, then retry the command.", - }; - } - case "StackRpcProtocolError": { - const endpoint = readString(error, "endpoint") ?? "the local stack endpoint"; - const procedure = readString(error, "procedure") ?? "the requested operation"; - const detail = readString(error, "detail") ?? "the response did not match the RPC protocol"; - return { - code: tag, - message: "The local Supabase stack returned an invalid RPC response.", - detail: `RPC ${procedure} at ${endpoint} failed protocol validation: ${detail}`, - suggestion: "Restart the stack with `supabase start`, then retry the command.", - }; - } - case "StopTimeout": { - const endpoint = readString(error, "endpoint") ?? "the local stack endpoint"; - const lastState = readString(error, "lastState"); - return { - code: tag, - message: "Timed out waiting for the local Supabase stack to stop.", - detail: `The stack at ${endpoint} did not stop before the timeout${ - lastState === undefined ? "." : ` (last state: ${lastState}).` - }`, - suggestion: "Check `supabase status`, then retry `supabase stop`.", - }; - } - case "ControlBindError": - return { - code: tag, - message: "Could not start the local Supabase stack control service.", - suggestion: - "Check for another local process using the stack control port, then retry `supabase start`.", - }; - case "ControlTransportError": - return { - code: tag, - message: "Could not communicate with the local Supabase stack control service.", - suggestion: "Run `supabase start` to restore the local stack, then retry the command.", - }; - case "ControlProtocolError": - return { - code: tag, - message: "The local Supabase stack control service returned an invalid response.", - suggestion: "Restart the stack with `supabase start`, then retry the command.", - }; - case "ControlProtocolMismatchError": - return { - code: tag, - message: "The local Supabase stack uses an incompatible control protocol.", - suggestion: "Restart the stack with `supabase start`, then retry the command.", - }; - case "ControlAddressConflictError": - return { - code: tag, - message: "The local Supabase stack control endpoint is occupied by another process.", - suggestion: "Stop the conflicting local stack or process, then retry `supabase start`.", - }; - case "ControlStopConflictError": - return { - code: tag, - message: "The local Supabase stack changed owners while it was stopping.", - suggestion: "Retry `supabase stop` to stop the current owner.", - }; - case "ControlMaintenanceBusyError": - return { - code: tag, - message: "The local Supabase stack is being maintained by another command.", - suggestion: "Wait for that command to finish, then retry this command.", - }; case "MissingOption": { // Mirror Go Cobra's `required flag(s) "X" not set` wording. Effect CLI's // default `Missing required flag: --X` differs and would break scripts diff --git a/apps/cli/src/shared/output/normalize-error.unit.test.ts b/apps/cli/src/shared/output/normalize-error.unit.test.ts index 6adcdb132f..6f66c852d8 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -12,157 +12,6 @@ const testRoot = Command.make("supabase").pipe( ); describe("normalizeCliError", () => { - test("maps NoRunningStackError to a user-facing message", () => { - const error = { - _tag: "NoRunningStackError", - cwd: "/tmp/project", - }; - - const normalized = normalizeCliError(error); - - expect(normalized).toEqual({ - code: "NoRunningStackError", - message: "No local Supabase stack is running for this project.", - detail: "The CLI could not find a running stack for the current working directory.", - suggestion: - "Run `supabase start` in this project, or change into a directory with a running stack.", - }); - }); - - test("maps DaemonUpgradeRequired to an actionable start instruction", () => { - expect( - normalizeCliError({ - _tag: "DaemonUpgradeRequired", - oldCliVersion: "2.60.0", - newCliVersion: "2.61.0", - }), - ).toEqual({ - code: "DaemonUpgradeRequired", - message: "The local Supabase stack is running under 2.60.0, but this CLI is 2.61.0.", - suggestion: "Run `supabase start` to restart the stack with the current CLI.", - }); - }); - - test("maps an unavailable starting stack to a wait-and-retry instruction", () => { - expect( - normalizeCliError({ - _tag: "StackUnavailableError", - phase: "starting", - }), - ).toEqual({ - code: "StackUnavailableError", - message: "The local Supabase stack is still starting.", - suggestion: "Wait for `supabase start` to finish, then try again.", - }); - }); - - test("maps an unavailable stopping stack to a stop completion instruction", () => { - expect( - normalizeCliError({ - _tag: "StackUnavailableError", - phase: "stopping", - }), - ).toEqual({ - code: "StackUnavailableError", - message: "The local Supabase stack is still stopping.", - suggestion: "Wait for the current stop operation to finish, then try again.", - }); - }); - - test("maps RPC transport failures with the procedure and endpoint", () => { - expect( - normalizeCliError({ - _tag: "StackRpcTransportError", - endpoint: "http://127.0.0.1:54321", - procedure: "GetInfo", - cause: new Error("ECONNRESET"), - }), - ).toEqual({ - code: "StackRpcTransportError", - message: "Could not communicate with the local Supabase stack.", - detail: "RPC GetInfo at http://127.0.0.1:54321 failed: ECONNRESET", - suggestion: "Check that the stack is running, then retry the command.", - }); - }); - - test("maps RPC protocol failures with the procedure, endpoint, and detail", () => { - expect( - normalizeCliError({ - _tag: "StackRpcProtocolError", - endpoint: "http://127.0.0.1:54321", - procedure: "GetInfo", - detail: "Invalid GetInfo response", - }), - ).toEqual({ - code: "StackRpcProtocolError", - message: "The local Supabase stack returned an invalid RPC response.", - detail: - "RPC GetInfo at http://127.0.0.1:54321 failed protocol validation: Invalid GetInfo response", - suggestion: "Restart the stack with `supabase start`, then retry the command.", - }); - }); - - test("maps stop timeouts with the endpoint and last observed state", () => { - expect( - normalizeCliError({ - _tag: "StopTimeout", - endpoint: "http://127.0.0.1:54321", - ownerSessionId: "session-123", - lastState: "stopping", - }), - ).toEqual({ - code: "StopTimeout", - message: "Timed out waiting for the local Supabase stack to stop.", - detail: - "The stack at http://127.0.0.1:54321 did not stop before the timeout (last state: stopping).", - suggestion: "Check `supabase status`, then retry `supabase stop`.", - }); - }); - - test.each([ - [ - "ControlBindError", - "Could not start the local Supabase stack control service.", - "Check for another local process using the stack control port, then retry `supabase start`.", - ], - [ - "ControlTransportError", - "Could not communicate with the local Supabase stack control service.", - "Run `supabase start` to restore the local stack, then retry the command.", - ], - [ - "ControlProtocolError", - "The local Supabase stack control service returned an invalid response.", - "Restart the stack with `supabase start`, then retry the command.", - ], - [ - "ControlProtocolMismatchError", - "The local Supabase stack uses an incompatible control protocol.", - "Restart the stack with `supabase start`, then retry the command.", - ], - [ - "ControlAddressConflictError", - "The local Supabase stack control endpoint is occupied by another process.", - "Stop the conflicting local stack or process, then retry `supabase start`.", - ], - [ - "ControlStopConflictError", - "The local Supabase stack changed owners while it was stopping.", - "Retry `supabase stop` to stop the current owner.", - ], - [ - "ControlMaintenanceBusyError", - "The local Supabase stack is being maintained by another command.", - "Wait for that command to finish, then retry this command.", - ], - ])("maps %s to an actionable control-plane error", (tag, message, suggestion) => { - expect(normalizeCliError({ _tag: tag })).toEqual({ - code: tag, - message, - suggestion, - }); - }); - test("falls back to tagged error fields when no explicit mapping exists", () => { const error = { _tag: "ExampleError", @@ -595,26 +444,26 @@ describe("normalizeCliError", () => { }); test("normalizes a cause via its first failure", () => { - const normalized = normalizeCause(Cause.fail({ _tag: "NoRunningStackError", cwd: "/tmp" })); + const normalized = normalizeCause( + Cause.fail({ _tag: "StackRuntimeError", message: "service failed to start" }), + ); - expect(normalized.message).toBe("No local Supabase stack is running for this project."); + expect(normalized).toEqual({ + code: "StackRuntimeError", + message: "service failed to start", + }); }); test("formats text output with detail and suggestion", () => { const text = formatCliError({ - code: "NoRunningStackError", - message: "No local Supabase stack is running for this project.", - detail: "The CLI could not find a running stack for the current working directory.", - suggestion: - "Run `supabase start` in this project, or change into a directory with a running stack.", + code: "StackRuntimeError", + message: "The local Supabase stack failed to start.", + detail: "The auth workload exited before becoming ready.", + suggestion: "Inspect the stack logs, then retry `supabase start`.", }); - expect(text).toContain("No local Supabase stack is running for this project."); - expect(text).toContain( - "Detail: The CLI could not find a running stack for the current working directory.", - ); - expect(text).toContain( - "Suggestion: Run `supabase start` in this project, or change into a directory with a running stack.", - ); + expect(text).toContain("The local Supabase stack failed to start."); + expect(text).toContain("Detail: The auth workload exited before becoming ready."); + expect(text).toContain("Suggestion: Inspect the stack logs, then retry `supabase start`."); }); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index f262daf59f..f205289b34 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -830,12 +830,6 @@ const externalActionabilityByTag: Record = { }), SchemaError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), - // @supabase/stack — StackError is a tagged error with a structured `code` - // field. - StackError: (error) => - readString(error, "code") === "PORT_ALLOCATION" - ? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" } - : actionability.unknown, InvalidStackIdentityError: () => actionability.invalidInput, InvalidProjectRootError: () => actionability.invalidInput, InvalidStackConfigError: () => actionability.invalidConfig, @@ -843,7 +837,6 @@ const externalActionabilityByTag: Record = { StackNotFoundError: () => actionability.invalidInput, StackOwnershipConflictError: () => actionability.invalidInput, StackRuntimeMismatchError: () => actionability.invalidConfig, - StackDefinitionRequiredError: () => actionability.startStack, StackMustBeStoppedError: () => actionability.stopStack, StackLifecycleConflictError: () => actionability.startStack, StackStateInvalidError: () => actionability.invalidConfig, @@ -852,14 +845,12 @@ const externalActionabilityByTag: Record = { StackSecretMismatchError: () => actionability.invalidConfig, InvalidJwtSigningMaterialError: () => actionability.invalidConfig, PortUnavailableError: () => actionability.invalidConfig, - GatewayAuthenticationError: () => actionability.startStack, GatewayActivationError: () => actionability.startStack, StackPreparationError: () => actionability.startStack, ArtifactIntegrityError: () => actionability.externalNetwork, ContainerPullError: () => actionability.externalNetwork, - StackReconciliationError: () => actionability.startStack, - ServiceStartError: () => actionability.startStack, - ServiceReadinessError: () => actionability.startStack, + StackRuntimeError: () => actionability.startStack, + StackCleanupError: () => actionability.stopStack, ContainerEngineError: () => actionability.dockerNotRunning, StackDestructionError: () => actionability.stopStack, BinaryNotFoundError: () => actionability.invalidConfig, @@ -875,102 +866,15 @@ const externalActionabilityByTag: Record = { error["daemonDown"] === true ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } : { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }, - StackBuildError: (error) => { - const reason = readString(error, "reason"); - if (reason === "invalid_config") { - return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; - } - if (reason === "docker_not_running") { - return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; - } - if (reason === "asset_preparation") { - return { ...actionability.externalNetwork, fingerprint_suffix: "asset_preparation" }; - } - return { ...actionability.impossibleState, fingerprint_suffix: "internal_build" }; - }, - PortConflictError: () => ({ - ...actionability.invalidConfig, - fingerprint_suffix: "port_conflict", - }), PortAllocationError: () => ({ ...actionability.invalidConfig, fingerprint_suffix: "port_allocation", }), - AtomicClaimUnsupportedError: () => ({ - ...actionability.invalidInput, - fingerprint_suffix: "managed_identity", - }), StackNotRunningError: () => actionability.startStack, - StackReadinessError: () => actionability.startStack, - StackUnavailableError: () => actionability.startStack, - StackRpcTransportError: () => ({ - ...actionability.startStack, - fingerprint_suffix: "daemon_transport", - }), StackRpcProtocolError: () => ({ ...actionability.impossibleState, fingerprint_suffix: "daemon_protocol", }), - NoRunningStackError: () => actionability.startStack, - DaemonUpgradeRequired: () => ({ - ...actionability.startStack, - fingerprint_suffix: "daemon_upgrade_required", - }), - UpgradePreflightError: () => ({ - ...actionability.startStack, - fingerprint_suffix: "daemon_upgrade_preflight", - }), - UpgradeRestartError: () => ({ - ...actionability.startStack, - fingerprint_suffix: "daemon_upgrade_restart", - }), - StopTimeout: () => ({ - ...actionability.stopStack, - fingerprint_suffix: "daemon_stop_timeout", - }), - InvalidControlOwnershipIdError: () => ({ - ...actionability.impossibleState, - fingerprint_suffix: "managed_control_ownership", - }), - ControlBindError: () => ({ - ...actionability.startStack, - fingerprint_suffix: "managed_control_bind", - }), - ControlTransportError: () => ({ - ...actionability.startStack, - fingerprint_suffix: "managed_control_transport", - }), - ControlProtocolError: () => ({ - ...actionability.impossibleState, - fingerprint_suffix: "managed_control_protocol", - }), - ControlProtocolMismatchError: () => ({ - ...actionability.impossibleState, - fingerprint_suffix: "managed_control_protocol", - }), - ControlAddressConflictError: () => ({ - ...actionability.startStack, - fingerprint_suffix: "managed_control_address_conflict", - }), - ControlStopConflictError: () => ({ - ...actionability.impossibleState, - fingerprint_suffix: "managed_control_stop_conflict", - }), - ControlMaintenanceBusyError: () => ({ - error_kind: CliErrorKind.UserActionable, - error_category: CliErrorCategory.InvalidConfig, - has_suggestion: true, - suggestion_type: CliSuggestionType.RunCommand, - fingerprint_suffix: "managed_control_maintenance_busy", - }), - InvalidManagedStackDocumentError: () => ({ - ...actionability.invalidConfig, - fingerprint_suffix: "managed_document", - }), - ManagedStackControlRequiredError: () => ({ - ...actionability.stopStack, - fingerprint_suffix: "managed_control_required", - }), ManagedStackAttachedError: () => ({ ...actionability.stopStack, fingerprint_suffix: "managed_attached", @@ -1013,20 +917,6 @@ export function isClassifiedExternalErrorTag(tag: string): boolean { return Object.hasOwn(externalActionabilityByTag, tag); } -/** - * A wrapper's preserved `cause`, but only when classifying it cannot degrade - * the result: the cause must carry its own declaration or a known external - * adapter tag, otherwise the wrapper's own classification is more truthful. - */ -function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { - const cause = error["cause"]; - if (!isErrorRecord(cause)) return undefined; - if (readDeclaration(cause) !== undefined) return cause; - const causeTag = readErrorTag(cause); - if (causeTag !== undefined && Object.hasOwn(externalActionabilityByTag, causeTag)) return cause; - return undefined; -} - function classifyShowHelp(error: ErrorRecord, depth: number): CliErrorActionability | undefined { const errors = error["errors"]; if (!Array.isArray(errors)) return undefined; @@ -1096,23 +986,6 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { return classifyAtDepth(error["cause"], depth + 1); } - // @supabase/stack wrapper errors preserve the underlying tagged failure in - // `cause`; classify it when it is more specific than the wrapper (e.g. a - // daemon-down DockerPullError inside an asset-preparation StackBuildError, - // or a user's CliConfigParseError inside a reason-less StackBuildError). - // Explicit `invalid_config` StackBuildErrors are deliberate user-facing - // config verdicts and are never overridden by their cause. - if ( - isErrorRecord(error) && - tag === "StackBuildError" && - readString(error, "reason") !== "invalid_config" - ) { - const cause = classifiableCause(error); - if (cause !== undefined) { - return classifyAtDepth(cause, depth + 1); - } - } - // DownloadError recurses ONLY into local filesystem causes (PlatformError: // unwritable cache, extraction failure). HTTP causes stay on the wrapper — // the HttpClientError adapter's 401/403 → auth/permission policy is @@ -1124,33 +997,6 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { } } - // ManagedStackInitializationError is only a wrapper: the real provisioning - // failure (a Docker pull, a config parse, ...) is preserved in `cause`, and - // the generic initialization verdict would hide the actionable one. - if (isErrorRecord(error) && tag === "ManagedStackInitializationError") { - const cause = classifiableCause(error); - if (cause !== undefined) return classifyAtDepth(cause, depth + 1); - } - - // @supabase/stack's public wrapper is itself a tagged error. Handle it - // before generic tag dispatch so a preserved classifiable cause (for - // example DockerPullError or a native exception) is not hidden by the - // wrapper's own generic StackError adapter. - if (isErrorRecord(error) && tag === "StackError") { - const cause = classifiableCause(error); - if (cause !== undefined) return classifyAtDepth(cause, depth + 1); - // toStackError wraps arbitrary thrown errors with code "UNKNOWN"; a - // native JS exception cause is a stack-internal crash and must land in - // the internal-bug bucket, matching the top-level native-exception rule. - if (isNativeJsExceptionName(readErrorName(error["cause"]))) { - return classifyAtDepth(error["cause"], depth + 1); - } - const classify = externalActionabilityByTag["StackError"]; - if (classify !== undefined) { - return toActionability(classify(error), "tag", "StackError"); - } - } - if (tag !== undefined && isErrorRecord(error)) { // Own-property lookup: a sanitized tag like "constructor" must not pick // up Object.prototype members as adapters. diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index 479c624c1f..f1f110a965 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -11,7 +11,7 @@ import { import { Socket } from "node:net"; import type { Duplex } from "node:stream"; import type { Fiber } from "effect/Fiber"; -import { GatewayActivationError, GatewayAuthenticationError } from "../public/Errors.ts"; +import { GatewayActivationError } from "../public/Errors.ts"; import type { ActivationResult, BackendEndpoint, @@ -287,7 +287,6 @@ const mapFailure = (cause: Cause.Cause): number => { if (Option.isSome(error) && error.value instanceof GatewayRouteNotFoundError) return 404; if (Option.isSome(error) && error.value instanceof GatewayActivationError) return 503; if (Option.isSome(error) && error.value instanceof GatewayBackendError) return 502; - if (Option.isSome(error) && error.value instanceof GatewayAuthenticationError) return 503; return 503; }; diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 803cd18ee2..38e6762288 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -39,7 +39,6 @@ import { InvalidProjectRootError, InvalidStackConfigError, StackVersionUnsupportedError, - StackDefinitionRequiredError, StackDestructionError, StackNotFoundError, StackNotRunningError, @@ -51,9 +50,8 @@ import { ContainerPullError, StackSecretMismatchError, InvalidJwtSigningMaterialError, - StackReconciliationError, - ServiceStartError, - ServiceReadinessError, + StackRuntimeError, + StackCleanupError, ContainerEngineError, StackStateInvalidError, StackStateFormatUnsupportedError, @@ -61,7 +59,6 @@ import { StackMustBeStoppedError, PortAllocationError, PortUnavailableError, - GatewayAuthenticationError, GatewayActivationError, type CreateStackError, type OpenStackError, @@ -76,6 +73,12 @@ import { type StackError, type StackErrorTag, isStackErrorTag, + STACK_STATUS_ERROR_TAGS, + STACK_CREDENTIALS_ERROR_TAGS, + STACK_START_ERROR_TAGS, + STACK_STOP_ERROR_TAGS, + STACK_LOGS_ERROR_TAGS, + DESTROY_STACK_ERROR_TAGS, } from "./Errors.ts"; import { ownerLockExists, @@ -237,7 +240,6 @@ const stackErrorFactories = { StackNotFoundError: (message: string) => new StackNotFoundError({ message }), StackOwnershipConflictError: (message: string) => new StackOwnershipConflictError({ message }), StackRuntimeMismatchError: (message: string) => new StackRuntimeMismatchError({ message }), - StackDefinitionRequiredError: (message: string) => new StackDefinitionRequiredError({ message }), StackNotRunningError: (message: string) => new StackNotRunningError({ message }), StackMustBeStoppedError: (message: string) => new StackMustBeStoppedError({ message }), StackLifecycleConflictError: (message: string) => new StackLifecycleConflictError({ message }), @@ -250,14 +252,12 @@ const stackErrorFactories = { new InvalidJwtSigningMaterialError({ message }), PortAllocationError: (message: string) => new PortAllocationError({ message }), PortUnavailableError: (message: string) => new PortUnavailableError({ message }), - GatewayAuthenticationError: (message: string) => new GatewayAuthenticationError({ message }), GatewayActivationError: (message: string) => new GatewayActivationError({ message }), StackPreparationError: (message: string) => new StackPreparationError({ message }), ArtifactIntegrityError: (message: string) => new ArtifactIntegrityError({ message }), ContainerPullError: (message: string) => new ContainerPullError({ message }), - StackReconciliationError: (message: string) => new StackReconciliationError({ message }), - ServiceStartError: (message: string) => new ServiceStartError({ message }), - ServiceReadinessError: (message: string) => new ServiceReadinessError({ message }), + StackRuntimeError: (message: string) => new StackRuntimeError({ message }), + StackCleanupError: (message: string) => new StackCleanupError({ message }), ContainerEngineError: (message: string) => new ContainerEngineError({ message }), StackDestructionError: (message: string) => new StackDestructionError({ message }), } satisfies Record StackError>; @@ -295,84 +295,40 @@ const errorForRpc = (error: ControlError): StackError => { return new StackStateInvalidError({ message: error.message }); }; -const statusError = (error: ControlError): StackStatusError => { - const mapped = errorForRpc(error); - return mapped instanceof StackNotFoundError || - mapped instanceof StackOwnershipConflictError || - mapped instanceof StackStateInvalidError || - mapped instanceof StackStateFormatUnsupportedError || - mapped instanceof StackUpgradeRequiredError - ? mapped - : new StackStateInvalidError({ message: mapped.message }); -}; - -const credentialsError = (error: ControlError): StackCredentialsError => { - const mapped = errorForRpc(error); - return mapped instanceof StackNotFoundError || - mapped instanceof StackNotRunningError || - mapped instanceof StackOwnershipConflictError || - mapped instanceof StackSecretMismatchError || - mapped instanceof InvalidJwtSigningMaterialError - ? mapped - : new StackNotRunningError({ message: mapped.message }); -}; - -const startError = (error: ControlError): StackStartError => { - const mapped = errorForRpc(error); - return mapped instanceof InvalidStackConfigError || - mapped instanceof StackDefinitionRequiredError || - mapped instanceof StackVersionUnsupportedError || - mapped instanceof StackNotRunningError || - mapped instanceof StackMustBeStoppedError || - mapped instanceof StackLifecycleConflictError || - mapped instanceof StackStateInvalidError || - mapped instanceof StackStateFormatUnsupportedError || - mapped instanceof StackUpgradeRequiredError || - mapped instanceof StackSecretMismatchError || - mapped instanceof InvalidJwtSigningMaterialError || - mapped instanceof PortAllocationError || - mapped instanceof PortUnavailableError || - mapped instanceof StackPreparationError || - mapped instanceof ArtifactIntegrityError || - mapped instanceof ContainerPullError || - mapped instanceof StackReconciliationError || - mapped instanceof ServiceStartError || - mapped instanceof ServiceReadinessError || - mapped instanceof ContainerEngineError || - mapped instanceof StackOwnershipConflictError - ? mapped - : new StackStateInvalidError({ message: mapped.message }); -}; - -const stopError = (error: ControlError): StackStopError => { - const mapped = errorForRpc(error); - return mapped instanceof StackOwnershipConflictError || - mapped instanceof StackLifecycleConflictError - ? mapped - : new StackLifecycleConflictError({ message: mapped.message }); -}; +const isNarrowError = >( + error: StackError, + tags: Tags, +): error is Extract => tags.some((tag) => tag === error._tag); -const logsError = (error: ControlError): StackLogsError => { +const narrowError = >( + error: ControlError, + tags: Tags, + fallback: (message: string) => Extract, +): Extract => { const mapped = errorForRpc(error); - return mapped instanceof StackNotFoundError || - mapped instanceof StackNotRunningError || - mapped instanceof StackStateInvalidError || - mapped instanceof StackOwnershipConflictError || - mapped instanceof StackLifecycleConflictError - ? mapped - : new StackStateInvalidError({ message: mapped.message }); + return isNarrowError(mapped, tags) ? mapped : fallback(mapped.message); }; -const destroyError = (error: ControlError): DestroyStackError => { - const mapped = errorForRpc(error); - return mapped instanceof StackDestructionError || - mapped instanceof StackNotFoundError || - mapped instanceof StackOwnershipConflictError || - mapped instanceof StackLifecycleConflictError || - mapped instanceof ContainerEngineError - ? mapped - : new StackDestructionError({ message: mapped.message }); -}; +const statusError = (error: ControlError): StackStatusError => + narrowError(error, STACK_STATUS_ERROR_TAGS, (message) => new StackStateInvalidError({ message })); +const credentialsError = (error: ControlError): StackCredentialsError => + narrowError( + error, + STACK_CREDENTIALS_ERROR_TAGS, + (message) => new StackNotRunningError({ message }), + ); +const startError = (error: ControlError): StackStartError => + narrowError(error, STACK_START_ERROR_TAGS, (message) => new StackStateInvalidError({ message })); +const stopError = (error: ControlError): StackStopError => + narrowError( + error, + STACK_STOP_ERROR_TAGS, + (message) => new StackLifecycleConflictError({ message }), + ); +const logsError = (error: ControlError): StackLogsError => + narrowError(error, STACK_LOGS_ERROR_TAGS, (message) => new StackStateInvalidError({ message })); +const destroyError = (error: ControlError): DestroyStackError => + narrowError(error, DESTROY_STACK_ERROR_TAGS, (message) => new StackDestructionError({ message })); /** Internal control-transport seam used by public lifecycle integration tests. */ export const makeHandle = ( @@ -551,13 +507,33 @@ export const makeHandle = ( ), ); }; - const start = (options?: StartStackOptions) => - invoke( + const start = (startOptions?: StartStackOptions) => { + const waitForRelease = options.waitForRelease; + const readPersistedState = options.readPersistedState; + return invoke( (rpc) => - options?.config === undefined ? rpc.start({}) : rpc.start({ config: options.config }), + startOptions?.config === undefined + ? rpc.start({}) + : rpc.start({ config: startOptions.config }), startError, true, + ).pipe( + Effect.tapError(() => + waitForRelease === undefined || readPersistedState === undefined + ? Effect.void + : readPersistedState().pipe( + Effect.flatMap((state) => + Option.isSome(state) && + (state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured") + ? waitForRelease().pipe(Effect.ignore) + : Effect.void, + ), + Effect.ignore, + ), + ), ); + }; const logsStateError = (error: StackError): StackLogsError => error instanceof StackOwnershipConflictError || error instanceof StackStateInvalidError ? error diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 8da56fc77f..25e4f32dcb 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -28,9 +28,6 @@ export class StackRuntimeMismatchError extends Data.TaggedError( "StackRuntimeMismatchError", ) {} -export class StackDefinitionRequiredError extends Data.TaggedError( - "StackDefinitionRequiredError", -) {} export class StackNotRunningError extends Data.TaggedError( "StackNotRunningError", ) {} @@ -64,9 +61,6 @@ export class PortUnavailableError extends Data.TaggedError( "PortUnavailableError", ) {} -export class GatewayAuthenticationError extends Data.TaggedError( - "GatewayAuthenticationError", -) {} export class GatewayActivationError extends Data.TaggedError( "GatewayActivationError", ) {} @@ -79,13 +73,8 @@ export class ArtifactIntegrityError extends Data.TaggedError( ) {} export class ContainerPullError extends Data.TaggedError("ContainerPullError") {} -export class StackReconciliationError extends Data.TaggedError( - "StackReconciliationError", -) {} -export class ServiceStartError extends Data.TaggedError("ServiceStartError") {} -export class ServiceReadinessError extends Data.TaggedError( - "ServiceReadinessError", -) {} +export class StackRuntimeError extends Data.TaggedError("StackRuntimeError") {} +export class StackCleanupError extends Data.TaggedError("StackCleanupError") {} export class ContainerEngineError extends Data.TaggedError( "ContainerEngineError", ) {} @@ -102,7 +91,6 @@ export const STACK_ERROR_TAGS = [ "StackNotFoundError", "StackOwnershipConflictError", "StackRuntimeMismatchError", - "StackDefinitionRequiredError", "StackNotRunningError", "StackMustBeStoppedError", "StackLifecycleConflictError", @@ -113,14 +101,12 @@ export const STACK_ERROR_TAGS = [ "InvalidJwtSigningMaterialError", "PortAllocationError", "PortUnavailableError", - "GatewayAuthenticationError", "GatewayActivationError", "StackPreparationError", "ArtifactIntegrityError", "ContainerPullError", - "StackReconciliationError", - "ServiceStartError", - "ServiceReadinessError", + "StackRuntimeError", + "StackCleanupError", "ContainerEngineError", "StackDestructionError", ] as const; @@ -138,7 +124,6 @@ export type StackError = | StackNotFoundError | StackOwnershipConflictError | StackRuntimeMismatchError - | StackDefinitionRequiredError | StackNotRunningError | StackMustBeStoppedError | StackLifecycleConflictError @@ -149,92 +134,122 @@ export type StackError = | InvalidJwtSigningMaterialError | PortAllocationError | PortUnavailableError - | GatewayAuthenticationError | GatewayActivationError | StackPreparationError | ArtifactIntegrityError | ContainerPullError - | StackReconciliationError - | ServiceStartError - | ServiceReadinessError + | StackRuntimeError + | StackCleanupError | ContainerEngineError | StackDestructionError; -export type CreateStackError = - | InvalidStackIdentityError - | InvalidProjectRootError - | StackOwnershipConflictError - | StackRuntimeMismatchError - | ContainerEngineError - | StackStateInvalidError - | StackStateFormatUnsupportedError; -export type OpenStackError = - | StackNotFoundError - | StackOwnershipConflictError - | StackRuntimeMismatchError - | InvalidProjectRootError - | StackStateInvalidError - | StackStateFormatUnsupportedError - | StackUpgradeRequiredError; -export type StackDiscoveryError = - | InvalidStackIdentityError - | InvalidProjectRootError - | StackStateInvalidError - | StackStateFormatUnsupportedError; -export type StackStatusError = - | StackNotFoundError - | StackOwnershipConflictError - | StackLifecycleConflictError - | StackStateInvalidError - | StackStateFormatUnsupportedError - | StackUpgradeRequiredError; -export type StackCredentialsError = - | StackNotFoundError - | StackNotRunningError - | StackOwnershipConflictError - | StackLifecycleConflictError - | StackSecretMismatchError - | InvalidJwtSigningMaterialError; -export type PrepareStackError = - | StackPreparationError - | ArtifactIntegrityError - | ContainerPullError - | StackOwnershipConflictError - | StackStateInvalidError - | StackLifecycleConflictError; -export type StackStartError = - | InvalidStackConfigError - | StackDefinitionRequiredError - | StackVersionUnsupportedError - | StackOwnershipConflictError - | StackNotRunningError - | StackMustBeStoppedError - | StackLifecycleConflictError - | StackStateInvalidError - | StackStateFormatUnsupportedError - | StackUpgradeRequiredError - | StackSecretMismatchError - | InvalidJwtSigningMaterialError - | PortAllocationError - | PortUnavailableError - | StackPreparationError - | ArtifactIntegrityError - | ContainerPullError - | StackReconciliationError - | ServiceStartError - | ServiceReadinessError - | ContainerEngineError; +type ErrorByTag = Extract; + +export const CREATE_STACK_ERROR_TAGS = [ + "InvalidStackIdentityError", + "InvalidProjectRootError", + "StackOwnershipConflictError", + "StackRuntimeMismatchError", + "ContainerEngineError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", +] as const satisfies ReadonlyArray; +export type CreateStackError = ErrorByTag<(typeof CREATE_STACK_ERROR_TAGS)[number]>; + +export const OPEN_STACK_ERROR_TAGS = [ + "StackNotFoundError", + "StackOwnershipConflictError", + "StackRuntimeMismatchError", + "InvalidProjectRootError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", + "StackUpgradeRequiredError", +] as const satisfies ReadonlyArray; +export type OpenStackError = ErrorByTag<(typeof OPEN_STACK_ERROR_TAGS)[number]>; + +export const STACK_DISCOVERY_ERROR_TAGS = [ + "InvalidStackIdentityError", + "InvalidProjectRootError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", +] as const satisfies ReadonlyArray; +export type StackDiscoveryError = ErrorByTag<(typeof STACK_DISCOVERY_ERROR_TAGS)[number]>; + +export const STACK_STATUS_ERROR_TAGS = [ + "StackNotFoundError", + "StackOwnershipConflictError", + "StackLifecycleConflictError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", + "StackUpgradeRequiredError", +] as const satisfies ReadonlyArray; +export type StackStatusError = ErrorByTag<(typeof STACK_STATUS_ERROR_TAGS)[number]>; + +export const STACK_CREDENTIALS_ERROR_TAGS = [ + "StackNotFoundError", + "StackNotRunningError", + "StackOwnershipConflictError", + "StackLifecycleConflictError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", +] as const satisfies ReadonlyArray; +export type StackCredentialsError = ErrorByTag<(typeof STACK_CREDENTIALS_ERROR_TAGS)[number]>; + +export const PREPARE_STACK_ERROR_TAGS = [ + "StackPreparationError", + "ArtifactIntegrityError", + "ContainerPullError", + "StackOwnershipConflictError", + "StackStateInvalidError", + "StackLifecycleConflictError", +] as const satisfies ReadonlyArray; +export type PrepareStackError = ErrorByTag<(typeof PREPARE_STACK_ERROR_TAGS)[number]>; + +export const STACK_START_ERROR_TAGS = [ + "InvalidStackConfigError", + "StackVersionUnsupportedError", + "StackOwnershipConflictError", + "StackNotRunningError", + "StackMustBeStoppedError", + "StackLifecycleConflictError", + "StackStateInvalidError", + "StackStateFormatUnsupportedError", + "StackUpgradeRequiredError", + "StackSecretMismatchError", + "InvalidJwtSigningMaterialError", + "PortAllocationError", + "PortUnavailableError", + "StackPreparationError", + "ArtifactIntegrityError", + "ContainerPullError", + "StackRuntimeError", + "StackCleanupError", + "ContainerEngineError", +] as const satisfies ReadonlyArray; +export type StackStartError = ErrorByTag<(typeof STACK_START_ERROR_TAGS)[number]>; + /** Stable maintenance stop reports cleanup failures as lifecycle conflicts with their message. */ -export type StackStopError = StackOwnershipConflictError | StackLifecycleConflictError; -export type StackLogsError = - | StackNotFoundError - | StackNotRunningError - | StackStateInvalidError - | StackOwnershipConflictError - | StackLifecycleConflictError; -export type DestroyStackError = - | StackDestructionError - | StackNotFoundError - | StackOwnershipConflictError - | StackLifecycleConflictError - | ContainerEngineError; +export const STACK_STOP_ERROR_TAGS = [ + "StackOwnershipConflictError", + "StackLifecycleConflictError", +] as const satisfies ReadonlyArray; +export type StackStopError = ErrorByTag<(typeof STACK_STOP_ERROR_TAGS)[number]>; + +export const STACK_LOGS_ERROR_TAGS = [ + "StackNotFoundError", + "StackNotRunningError", + "StackStateInvalidError", + "StackOwnershipConflictError", + "StackLifecycleConflictError", +] as const satisfies ReadonlyArray; +export type StackLogsError = ErrorByTag<(typeof STACK_LOGS_ERROR_TAGS)[number]>; + +export const DESTROY_STACK_ERROR_TAGS = [ + "StackDestructionError", + "StackNotFoundError", + "StackOwnershipConflictError", + "StackLifecycleConflictError", + "ContainerEngineError", + "StackCleanupError", +] as const satisfies ReadonlyArray; +export type DestroyStackError = ErrorByTag<(typeof DESTROY_STACK_ERROR_TAGS)[number]>; diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 8e54c21eb7..719775b464 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -12,7 +12,6 @@ import { Path, Redacted, Ref, - Schedule, Schema, Scope, Stream, @@ -358,14 +357,8 @@ describe("Effect stack lifecycle handoff", () => { .pipe(Effect.exit); expect(Exit.isFailure(started)).toBe(true); - yield* Effect.gen(function* () { - const owner = yield* readOwnerMetadata(env.stateRoot, stack.id, env); - const locked = yield* ownerLockExists(env.stateRoot, stack.id); - if (owner !== undefined || locked) - return yield* new StackOwnershipConflictError({ - message: "temporary Supervisor is still shutting down", - }); - }).pipe(Effect.retry(Schedule.recurs(200))); + expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); + expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); expect((yield* stack.status()).lifecycle).toBe("unconfigured"); }), ), diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index a172ca0eef..3cd229d4c2 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -40,6 +40,26 @@ const RUNTIME_CASES = [ { name: "native", runtime: { kind: "native" as const } }, { name: "Docker", runtime: { kind: "container" as const, engine: "docker" as const } }, ] as const; +const BASE_WORKLOAD_IDS = [ + "analytics:analytics", + "auth:auth", + "database:database", + "functions:edge-runtime", + "mail:mail", + "pooler:pooler", + "realtime:realtime", + "rest:rest", + "storage:storage", + "studio:pgmeta", + "studio:studio", +] as const; +const OPTIONAL_STORAGE_ANALYTICS_WORKLOAD_IDS = [ + "database:database", + "analytics:vector", + "analytics:analytics", + "storage:imgproxy", + "storage:storage", +] as const; const dockerOwnedResourceCount = async ( stackId: string, @@ -59,6 +79,61 @@ const dockerOwnedResourceCount = async ( return result.stdout.trim().length === 0 ? 0 : result.stdout.trim().split("\n").length; }; +const dockerOwnedWorkloadIds = async (stackId: string): Promise> => { + const listed = await execFile( + "docker", + ["ps", "-aq", "--filter", `label=com.supabase.stack.stackId=${stackId}`], + { encoding: "utf8" }, + ); + const ids = listed.stdout + .trim() + .split("\n") + .filter((value) => value.length > 0); + if (ids.length === 0) return []; + const inspected = await execFile( + "docker", + ["inspect", "--format", '{{index .Config.Labels "com.supabase.stack.workloadId"}}', ...ids], + { encoding: "utf8" }, + ); + return [ + ...new Set( + inspected.stdout + .trim() + .split("\n") + .filter((value) => value.length > 0), + ), + ].sort(); +}; + +/** Native launchers carry the exact stack/workload marker in their command line. */ +const nativeWorkloadIds = async (stackId: string): Promise> => { + const result = await execFile("ps", ["-axo", "pid=,command="], { encoding: "utf8" }); + const marker = `supabase-stack-id=${stackId}`; + return [ + ...new Set( + result.stdout.split("\n").flatMap((line) => { + if (!line.includes(marker)) return []; + const match = /supabase-workload-id=([^\s]+)/u.exec(line); + return match?.[1] === undefined ? [] : [match[1]]; + }), + ), + ].sort(); +}; + +const ownedWorkloadIds = async ( + mode: (typeof RUNTIME_CASES)[number], + stackId: string, +): Promise> => + mode.runtime.kind === "container" ? dockerOwnedWorkloadIds(stackId) : nativeWorkloadIds(stackId); + +const expectOwnedWorkloads = async ( + mode: (typeof RUNTIME_CASES)[number], + stackId: string, + expected: ReadonlyArray, +): Promise => { + expect(await ownedWorkloadIds(mode, stackId)).toEqual([...expected].sort()); +}; + const supervisorPids = async (stackId: string): Promise> => { const result = await execFile("ps", ["-axo", "pid=,command="], { encoding: "utf8" }); return result.stdout.split("\n").flatMap((line) => { @@ -312,6 +387,7 @@ const serviceHeaders = (credentials: PromiseStackCredentials): Record ` Deno.serve(async () => { + console.log("${marker}"); let publishableKey: unknown; try { publishableKey = JSON.parse( @@ -463,7 +539,11 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const functionSlug = `cross_service_${identity}`; const email = `${identity}@example.test`; const password = "SupabaseStackE2e!123"; - const markers = { first: `first-${identity}`, second: `second-${identity}` }; + const markers = { + first: `first-${identity}`, + second: `second-${identity}`, + live: `live-${identity}`, + }; let projectRoot = ""; await using stack: TestStack = await createTestStack({ @@ -481,6 +561,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom expect(initialRunning.runtime).toEqual(mode.runtime); expect(projectRoot.length).toBeGreaterThan(0); expectDefaultLazyState(initialRunning); + await expectOwnedWorkloads(mode, stack.id, ["database:database"]); // Preparation is an explicit cache-only operation. It runs after the helper's // initial session is stopped, so the test proves it creates no owner, listener, @@ -499,6 +580,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom ); const initial = await stack.start(); expectDefaultLazyState(initial); + await expectOwnedWorkloads(mode, stack.id, ["database:database"]); const credentials = await stack.credentials(); const api = endpoint(initial, "api"); const pooler = endpoint(initial, "pooler"); @@ -699,6 +781,36 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom }), ); + // followLogs is a client-side poller. Capture the current cursor and subscribe + // before producing a unique console marker through the real edge runtime. + const beforeLiveLogs = await stack.logs({ capabilities: ["functions"] }); + const liveIterator = stack + .followLogs({ capabilities: ["functions"], cursor: beforeLiveLogs.cursor }) + [Symbol.asyncIterator](); + try { + const liveNext = liveIterator.next(); + await writeFile( + join(projectRoot, "supabase", "functions", functionSlug, "index.ts"), + functionSource(table, markers.live), + ); + await jsonObject(await request(api.url, functionPath, { headers: apiHeaders(credentials) })); + let liveEntry = await liveNext; + for (let attempt = 0; attempt < 100; attempt += 1) { + if (liveEntry.done) break; + if (liveEntry.value.source === "functions" && liveEntry.value.message.includes(markers.live)) + break; + liveEntry = await liveIterator.next(); + } + expect(liveEntry.done).toBe(false); + if (!liveEntry.done) { + expect(liveEntry.value.source).toBe("functions"); + expect(liveEntry.value.message).toContain(markers.live); + } + } finally { + await liveIterator.return?.(); + } + expect((await liveIterator.next()).done).toBe(true); + // Mailpit UI traffic lazily activates the mail capability. SMTP delivery is // covered by the explicit SMTP configuration scenario below. await activate(stack, "mail", async () => { @@ -737,6 +849,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom expect(ready.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), ); + await expectOwnedWorkloads(mode, stack.id, BASE_WORKLOAD_IDS); const idempotentStart = await stack.start(); expect(idempotentStart.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), @@ -820,6 +933,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const stopped = await stack.status(); expect(stopped.lifecycle).toBe("stopped"); expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); + await expectOwnedWorkloads(mode, stack.id, []); const retainedLogs: StackLogEntry[] = (await stack.logs()).entries.slice(); expect(retainedLogs.length).toBeGreaterThan(0); await expectRuntimeInputsAbsent(projectRoot, initial.id); @@ -832,6 +946,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const started = await stack.start(); expectDefaultLazyState(started); + await expectOwnedWorkloads(mode, stack.id, ["database:database"]); expect( Object.fromEntries( Object.entries(started.endpoints).map(([name, value]) => [name, value?.port]), @@ -865,8 +980,10 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom expect(final.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), ); + await expectOwnedWorkloads(mode, stack.id, BASE_WORKLOAD_IDS); await stack.stop(); + await expectOwnedWorkloads(mode, stack.id, []); const reconfigured = await stack.start({ config: { capabilities: { studio: { enabled: false } }, @@ -947,6 +1064,7 @@ describe("managed Supabase stack whole-stack E2E", () => { expect(capabilityState(status, "database")).toBe("ready"); expect(capabilityState(status, "rest")).toBe("ready"); expect(capabilityState(status, "auth")).toBe("ready"); + await expectOwnedWorkloads(mode, stack.id, ["database:database", "rest:rest", "auth:auth"]); for (const name of CAPABILITY_NAMES) { if (name === "database" || name === "rest" || name === "auth") continue; expect(capabilityState(status, name), `${name} should remain dormant`).toBe("dormant"); @@ -1016,6 +1134,7 @@ describe("managed Supabase stack whole-stack E2E", () => { expect( await queryAnalyticsMarker(api, analyticsApiKey, "supabase-stack-vector"), ).toBeGreaterThan(0); + await expectOwnedWorkloads(mode, stack.id, OPTIONAL_STORAGE_ANALYTICS_WORKLOAD_IDS); }); await activate(stack, "mail", async () => { await request(mailUi.url, "/api/v1/messages?limit=100"); @@ -1086,6 +1205,7 @@ describe("managed Supabase stack whole-stack E2E", () => { expect(stopped.lifecycle).toBe("stopped"); expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); expect(await supervisorPids(stack.id)).toHaveLength(0); + await expectOwnedWorkloads(mode, stack.id, []); await expectRuntimeInputsAbsent(projectRoot, stack.id); await expectEndpointsRefused(endpointSnapshot); if (mode.runtime.kind === "container") { @@ -1100,6 +1220,7 @@ describe("managed Supabase stack whole-stack E2E", () => { await stack.start(); const restarted = await stack.status(); expectDefaultLazyState(restarted); + await expectOwnedWorkloads(mode, stack.id, ["database:database"]); expect( Object.values(restarted.endpoints).filter( (value): value is StackEndpoint => value !== undefined, @@ -1117,6 +1238,8 @@ describe("managed Supabase stack whole-stack E2E", () => { expect(await dockerOwnedResourceCount(stackId, "networks")).toBe(0); expect(await dockerOwnedResourceCount(stackId, "volumes")).toBe(0); } + if (stackId === undefined) throw new Error("Stack id was not assigned"); + await expectOwnedWorkloads(mode, stackId, []); }, ); } diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index add812a2d5..c0eb2dd705 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -33,6 +33,12 @@ export interface NativeProcessLauncher { readonly args: ReadonlyArray; } +/** Stable command-line marker used by diagnostics to identify owned processes. */ +export interface NativeProcessIdentity { + readonly stackId: string; + readonly workloadId: string; +} + export class NativeProcessError extends Data.TaggedError("NativeProcessError")<{ readonly message: string; readonly executable?: string; @@ -81,13 +87,23 @@ const mapProcessError = (error: unknown, spec: NativeProcessSpec): NativeProcess export const spawnNativeProcess = ( spec: NativeProcessSpec, launcher: NativeProcessLauncher = defaultNativeProcessLauncher(), + identity?: NativeProcessIdentity, ): Effect.Effect< NativeProcess, NativeProcessError, import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner | Scope.Scope > => Effect.gen(function* () { - const handle: ChildProcessHandle = yield* ChildProcess.make(launcher.command, launcher.args, { + const launcherArgs = + identity === undefined + ? launcher.args + : [ + ...launcher.args, + "--", + `supabase-stack-id=${identity.stackId}`, + `supabase-workload-id=${identity.workloadId}`, + ]; + const handle: ChildProcessHandle = yield* ChildProcess.make(launcher.command, launcherArgs, { cwd: spec.cwd, detached: true, stdin: "ignore", diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index ed7536afe1..b5905a2e5b 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -292,7 +292,7 @@ export const makeNativeRuntime = ( const startupScope = yield* Scope.fork(resource.scope, "parallel"); let started: NativeProcess | undefined; const run = Effect.gen(function* () { - const process = yield* spawnNativeProcess(spec, launcher).pipe( + const process = yield* spawnNativeProcess(spec, launcher, resource.key).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), Scope.provide(startupScope), Effect.mapError((error) => processError(error, resource.key, `${phase} spawn`)), @@ -429,7 +429,7 @@ export const makeNativeRuntime = ( const resolved = yield* options.resolveProcess(key, workload); for (const startup of resolved.startup) yield* runStartupProcess(resource, startup, launcher, "startup"); - const process = yield* spawnNativeProcess(resolved.main, launcher).pipe( + const process = yield* spawnNativeProcess(resolved.main, launcher, key).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), Scope.provide(resource.scope), Effect.mapError((error) => processError(error, key, "spawn")), diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index e145c37b14..86b5b19074 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -11,7 +11,6 @@ import { Ref, Scope, Schedule, - Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { eagerCapabilities, type PlannedWorkload } from "../model/ExecutionPlan.ts"; @@ -271,14 +270,6 @@ const dynamicLogStore = (base: LogStore, knownSecrets: Ref.Ref entries.map((entry) => redactEntry(entry, known)))), ), ), - stream: (options) => - Stream.unwrap( - secrets.pipe( - Effect.map((known) => - base.stream(options).pipe(Stream.map((entry) => redactEntry(entry, known))), - ), - ), - ), }; }; @@ -665,10 +656,7 @@ export const makeProductionRuntimeFactory = ( }; }); - const preflight = ( - input: LifecycleInput, - mode: "cold" | "live", - ): Effect.Effect => + const preflight = (input: LifecycleInput): Effect.Effect => Effect.gen(function* () { if (!runtimeMatches(input.state.runtime, state.runtime)) return yield* new StackRuntimeMismatchError({ @@ -706,7 +694,7 @@ export const makeProductionRuntimeFactory = ( ); yield* Ref.set(hostRoute, route); } - if (input.state.runtime.kind === "native" && mode === "cold") { + if (input.state.runtime.kind === "native") { for (const assignment of input.state.ports) { const listener = input.definition.listeners[assignment.field]; yield* checkHostPort(listener.address, assignment.port, assignment.field).pipe( diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 93be78233e..658b93b346 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -194,7 +194,6 @@ const memoryLogStore = (records: LogRecord[]): LogStore => ({ ...record, })).pipe(Effect.tap((entry) => Effect.sync(() => records.push(entry)))), read: () => Effect.succeed([]), - stream: () => Stream.empty, }); describe("container runtime", () => { @@ -735,7 +734,6 @@ describe("container runtime", () => { path: logError.path, append: () => Effect.fail(logError), read: () => Effect.succeed([]), - stream: () => Stream.empty, }, resolveWorkload: () => Effect.succeed({ @@ -780,7 +778,6 @@ describe("container runtime", () => { path: logError.path, append: () => Effect.fail(logError), read: () => Effect.succeed([]), - stream: () => Stream.empty, }, resolveWorkload: () => Effect.succeed({ @@ -1268,7 +1265,6 @@ describe("container runtime", () => { path: logError.path, append: () => Effect.fail(logError), read: () => Effect.succeed([]), - stream: () => Stream.empty, }; const runtime = yield* makeContainerRuntime({ engine: { diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 6a4dae7212..66d17ae585 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -63,6 +63,22 @@ const processPlan = (main: A) => ({ startup: [], main }); const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); +const signalOnLog = ( + base: LogStore, + message: string, + signal: Deferred.Deferred, +): LogStore => ({ + ...base, + append: (record) => + base + .append(record) + .pipe( + Effect.tap((entry) => + entry.message === message ? Deferred.succeed(signal, undefined) : Effect.void, + ), + ), +}); + // Native launcher tests create real process trees. Keep their timeout local to // this suite so file-level parallelism does not turn the default 5s guard into // a false failure while leaving the rest of the integration suite unchanged. @@ -102,33 +118,31 @@ describe("native runtime", { timeout: 15_000 }, () => { path: path.join(root, "logs.json"), knownSecrets: ["secret-value"], }); + const outputLogged = yield* Deferred.make(); + const signaledLogStore = signalOnLog(logStore, "[REDACTED]", outputLogged); let startedProcess: NativeProcess | undefined; const runtime = yield* makeNativeRuntime({ resolveProcess: (_key, _entry) => Effect.succeed(processPlan(fixtureProcess("secret-value"))), - logStore, + logStore: signaledLogStore, waitForReadiness: (_key, _workload, process) => Effect.sync(() => { startedProcess = process; }), }); const key = keyFor("one"); - const logs = yield* logStore - .stream({ follow: true }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkChild({ startImmediately: true })); const ready = yield* runtime.start(key, workload("one")); expect(ready.state).toBe("ready"); - const firstLog = (yield* Fiber.join(logs))[0]; - expect(firstLog?.message).toBe("[REDACTED]"); + yield* Deferred.await(outputLogged); const values = yield* runtime.observe(stackId); expect(values).toHaveLength(1); yield* runtime.stop(key); expect((yield* runtime.observe(stackId))[0]?.state).toBe("stopped"); expect(startedProcess).toBeDefined(); if (startedProcess !== undefined) expect(yield* startedProcess.isRunning).toBe(false); + expect((yield* logStore.read()).map((entry) => entry.message)).toContain("[REDACTED]"); yield* runtime.remove(key); expect(yield* runtime.observe(stackId)).toEqual([]); - expect((yield* logStore.read()).map((entry) => entry.message)).toContain("[REDACTED]"); }), ), ); @@ -173,15 +187,11 @@ describe("native runtime", { timeout: 15_000 }, () => { const readinessEntered = yield* Deferred.make(); const readinessRelease = yield* Deferred.make(); let startedProcess: NativeProcess | undefined; - const output = yield* logStore.stream({ follow: true }).pipe( - Stream.filter((entry) => entry.message === "main-started"), - Stream.take(1), - Stream.runDrain, - Effect.forkChild({ startImmediately: true }), - ); + const outputReady = yield* Deferred.make(); + const signaledLogStore = signalOnLog(logStore, "main-started", outputReady); const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("main-started"))), - logStore, + logStore: signaledLogStore, waitForReadiness: (_key, _workload, process) => Effect.sync(() => { startedProcess = process; @@ -195,7 +205,7 @@ describe("native runtime", { timeout: 15_000 }, () => { .start(key, workload("exit-observer")) .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(readinessEntered); - yield* Fiber.join(output); + yield* Deferred.await(outputReady); yield* Deferred.succeed(readinessRelease, undefined); const ready = yield* Fiber.join(caller); expect(ready.state).toBe("ready"); @@ -314,7 +324,6 @@ describe("native runtime", { timeout: 15_000 }, () => { new LogStoreError({ path: "memory://failing-log-store", message: "disk full" }), ), read: () => Effect.succeed([]), - stream: () => Stream.empty, }; const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed(processPlan(fixtureProcess("log-failure"))), @@ -337,11 +346,8 @@ describe("native runtime", { timeout: 15_000 }, () => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-startup-" }); const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); - const logs = yield* logStore.stream({ follow: true }).pipe( - Stream.takeUntil((entry) => entry.message === "main"), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); + const mainLogged = yield* Deferred.make(); + const signaledLogStore = signalOnLog(logStore, "main", mainLogged); let observedReady = false; const runtime = yield* makeNativeRuntime({ resolveProcess: () => @@ -349,7 +355,7 @@ describe("native runtime", { timeout: 15_000 }, () => { startup: [oneShotProcess("startup")], main: fixtureProcess("main"), }), - logStore, + logStore: signaledLogStore, waitForReadiness: () => Effect.sync(() => { observedReady = true; @@ -359,7 +365,8 @@ describe("native runtime", { timeout: 15_000 }, () => { const ready = yield* runtime.start(key, workload("startup-order")); expect(ready.state).toBe("ready"); expect(observedReady).toBe(true); - const messages = (yield* Fiber.join(logs)).map((entry) => entry.message); + yield* Deferred.await(mainLogged); + const messages = (yield* logStore.read()).map((entry) => entry.message); expect(messages.indexOf("startup")).toBeGreaterThanOrEqual(0); expect(messages).toContain("startup-stderr"); expect(messages.indexOf("main")).toBeGreaterThan(messages.indexOf("startup")); @@ -542,12 +549,8 @@ describe("native runtime", { timeout: 15_000 }, () => { expect(spawnSync("mkfifo", [readyPath]).status).toBe(0); expect(spawnSync("mkfifo", [releasePath]).status).toBe(0); const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); - const postStarted = yield* logStore.stream({ follow: true }).pipe( - Stream.filter((entry) => entry.message === "post-blocked"), - Stream.take(1), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); + const postStartedSignal = yield* Deferred.make(); + const signaledLogStore = signalOnLog(logStore, "post-blocked", postStartedSignal); const postReadiness = { executable: process.execPath, args: [ @@ -565,14 +568,14 @@ describe("native runtime", { timeout: 15_000 }, () => { const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed({ startup: [], postReadiness: [postReadiness], main }), - logStore, + logStore: signaledLogStore, waitForReadiness: () => Effect.void, }); const key = keyFor("post-readiness-main-exit"); const caller = yield* runtime .start(key, workload("post-readiness-main-exit")) .pipe(Effect.forkChild({ startImmediately: true })); - yield* Fiber.join(postStarted); + yield* Deferred.await(postStartedSignal); const releaseMain = spawnSync( process.execPath, ["-e", `require("node:fs").writeFileSync(${JSON.stringify(readyPath)}, "x")`], @@ -610,12 +613,8 @@ describe("native runtime", { timeout: 15_000 }, () => { }); const pidPath = path.join(root, "pid"); const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); - const started = yield* logStore.stream({ follow: true }).pipe( - Stream.filter((entry) => entry.message === "post-started"), - Stream.take(1), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); + const startedSignal = yield* Deferred.make(); + const signaledLogStore = signalOnLog(logStore, "post-started", startedSignal); const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed({ @@ -631,14 +630,14 @@ describe("native runtime", { timeout: 15_000 }, () => { ], main: fixtureProcess("never-main"), }), - logStore, + logStore: signaledLogStore, waitForReadiness: () => Effect.void, }); const key = keyFor("post-readiness-interrupted"); const caller = yield* runtime .start(key, workload("post-readiness-interrupted")) .pipe(Effect.forkChild({ startImmediately: true })); - yield* Fiber.join(started); + yield* Deferred.await(startedSignal); yield* runtime.stop(key); const result = yield* Fiber.join(caller).pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); @@ -675,7 +674,6 @@ describe("native runtime", { timeout: 15_000 }, () => { }), ), read: () => Effect.succeed([]), - stream: () => Stream.empty, }; const runtime = yield* makeNativeRuntime({ resolveProcess: () => @@ -744,6 +742,8 @@ describe("native runtime", { timeout: 15_000 }, () => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-drain-" }); const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); + const startupOutputSignal = yield* Deferred.make(); + const signaledLogStore = signalOnLog(logStore, "startup-late", startupOutputSignal); const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed({ @@ -755,18 +755,13 @@ describe("native runtime", { timeout: 15_000 }, () => { ], main: fixtureProcess("drained-main"), }), - logStore, + logStore: signaledLogStore, waitForReadiness: () => Effect.void, }); - const startupOutput = yield* logStore.stream({ follow: true }).pipe( - Stream.takeUntil((entry) => entry.message === "startup-late"), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); const caller = yield* runtime .start(keyFor("startup-drain"), workload("startup-drain")) .pipe(Effect.forkChild({ startImmediately: true })); - yield* Fiber.join(startupOutput); + yield* Deferred.await(startupOutputSignal); const ready = yield* Fiber.join(caller); expect(ready.state).toBe("ready"); yield* runtime.remove(keyFor("startup-drain")); @@ -782,9 +777,8 @@ describe("native runtime", { timeout: 15_000 }, () => { const pidPath = path.join(root, "pid"); const stoppedPath = path.join(root, "stopped"); const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); - const started = yield* logStore - .stream({ follow: true }) - .pipe(Stream.take(1), Stream.runCollect, Effect.forkChild({ startImmediately: true })); + const startedSignal = yield* Deferred.make(); + const signaledLogStore = signalOnLog(logStore, "started", startedSignal); const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed({ @@ -799,14 +793,14 @@ describe("native runtime", { timeout: 15_000 }, () => { ], main: fixtureProcess("never-main"), }), - logStore, + logStore: signaledLogStore, waitForReadiness: () => Effect.void, }); const key = keyFor("startup-interrupted"); const caller = yield* runtime .start(key, workload("startup-interrupted")) .pipe(Effect.forkChild({ startImmediately: true })); - yield* Fiber.join(started); + yield* Deferred.await(startedSignal); yield* runtime.stop(key); yield* Fiber.interrupt(caller); expect(Number.isSafeInteger(Number.parseInt(yield* fs.readFileString(pidPath), 10))).toBe( @@ -830,7 +824,6 @@ describe("native runtime", { timeout: 15_000 }, () => { return yield* Deferred.await(appendFailure); }), read: () => Effect.succeed([]), - stream: () => Stream.empty, }; const runtime = yield* makeNativeRuntime({ resolveProcess: () => diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index cc0021efca..f8682f2deb 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -131,7 +131,6 @@ const memoryLogStore = (entries: StackLogEntry[]): LogStore => ({ return entry; }), read: () => Effect.succeed(entries), - stream: () => Stream.fromIterable(entries), }); const ingress: SupervisorIngress = { @@ -560,7 +559,6 @@ describe("production runtime composition", () => { const runtime = yield* factory.make(current.value); const input = { stackId, - desiredLifecycle: "running" as const, state: current.value, definition: compiled.definition, secrets: current.value.secrets, @@ -1127,17 +1125,13 @@ describe("production runtime composition", () => { bootstrapDatabase: () => Effect.void, }); const runtime = yield* factory.make(current.value); - yield* runtime.preflight( - { - stackId, - desiredLifecycle: "stopped", - state: current.value, - definition: compiled.definition, - secrets, - plan: compiled.executionPlan, - }, - "cold", - ); + yield* runtime.preflight({ + stackId, + state: current.value, + definition: compiled.definition, + secrets, + plan: compiled.executionPlan, + }); expect(yield* fs.exists(functionsRoot)).toBe(false); expect(prepared).toEqual(["database:database"]); yield* runtime.driver.cleanup({ stackId, destroy: false }); @@ -1205,17 +1199,13 @@ describe("production runtime composition", () => { }); const runtime = yield* factory.make(current.value); const result = yield* runtime - .preflight( - { - stackId, - desiredLifecycle: "running", - state: current.value, - definition: compiled.definition, - secrets, - plan: compiled.executionPlan, - }, - "cold", - ) + .preflight({ + stackId, + state: current.value, + definition: compiled.definition, + secrets, + plan: compiled.executionPlan, + }) .pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); if (Exit.isFailure(result)) { @@ -1286,17 +1276,13 @@ describe("production runtime composition", () => { }); const runtime = yield* factory.make(current.value); const result = yield* runtime - .preflight( - { - stackId, - desiredLifecycle: "running", - state: current.value, - definition: compiled.definition, - secrets, - plan: compiled.executionPlan, - }, - "cold", - ) + .preflight({ + stackId, + state: current.value, + definition: compiled.definition, + secrets, + plan: compiled.executionPlan, + }) .pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); if (Exit.isFailure(result)) { @@ -1360,17 +1346,13 @@ describe("production runtime composition", () => { bootstrapDatabase: () => Effect.void, }); const runtime = yield* factory.make(current.value); - yield* runtime.preflight( - { - stackId, - desiredLifecycle: "running", - state: candidate, - definition: compiled.definition, - secrets: candidate.secrets, - plan: compiled.executionPlan, - }, - "cold", - ); + yield* runtime.preflight({ + stackId, + state: candidate, + definition: compiled.definition, + secrets: candidate.secrets, + plan: compiled.executionPlan, + }); const logStore = runtime.logStore; expect(logStore).toBeDefined(); if (logStore === undefined) return; diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 0af49f4b6d..28eccc10dd 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -6,7 +6,6 @@ import type { StackConfig } from "../public/Config.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import type { StackId } from "../public/StackId.ts"; import { - StackDefinitionRequiredError, StackLifecycleConflictError, StackMustBeStoppedError, StackStateInvalidError, @@ -26,7 +25,6 @@ import { */ export interface LifecycleInput { readonly stackId: StackId; - readonly desiredLifecycle: "running" | "stopped" | "destroying"; readonly state: PersistedStackState; readonly definition: StackDefinition; readonly secrets: PersistedSecretValues; @@ -35,12 +33,9 @@ export interface LifecycleInput { export interface LifecycleBackend { /** Must complete all runtime/resource validation before the controller writes accepted intent. */ - readonly preflight: ( - input: LifecycleInput, - mode: "cold" | "live", - ) => Effect.Effect; + readonly preflight: (input: LifecycleInput) => Effect.Effect; /** Applies the desired lifecycle to runtime resources for one accepted definition. */ - readonly reconcile: ( + readonly launch: ( input: LifecycleInput, session: "fresh" | "current", ) => Effect.Effect; @@ -88,9 +83,6 @@ const missingState = (stackId: StackId): StackStateInvalidError => message: "Stack state is missing; refusing lifecycle mutation", }); -const invalidDefinition = (stackId: StackId): StackDefinitionRequiredError => - new StackDefinitionRequiredError({ stackId, message: "A complete stack definition is required" }); - const lifecycleConflict = (message: string): StackLifecycleConflictError => new StackLifecycleConflictError({ message }); @@ -166,31 +158,12 @@ const materializeCandidate = ( }; }); -const persistedCandidate = ( - stackId: StackId, - state: PersistedStackState, - runtime: StackRuntime, -): Effect.Effect => - Effect.gen(function* () { - const definition = state.definition; - if (definition === undefined) return yield* invalidDefinition(stackId); - return { - definition, - secrets: state.secrets, - plan: yield* rebuildExecutionPlan(runtime, definition), - }; - }); - const lifecycleInput = ( stackId: StackId, state: PersistedStackState, candidate: Candidate, - desiredLifecycle: LifecycleInput["desiredLifecycle"] = state.desiredLifecycle === "unconfigured" - ? "running" - : state.desiredLifecycle, ): LifecycleInput => ({ stackId, - desiredLifecycle, state, definition: candidate.definition, secrets: candidate.secrets, @@ -254,39 +227,33 @@ export const makeLifecycleController = ( }); const freshSession = startOptions?.freshSession === true; if (freshSession) - yield* options.backend.preflight( - lifecycleInput(options.stackId, initial, candidate), - "cold", - ); - const reconciled = yield* options.backend - .reconcile( + yield* options.backend.preflight(lifecycleInput(options.stackId, initial, candidate)); + const launched = yield* options.backend + .launch( lifecycleInput(options.stackId, initial, candidate), freshSession ? "fresh" : "current", ) .pipe(Effect.exit); - if (Exit.isFailure(reconciled) && freshSession) { + if (Exit.isFailure(launched) && freshSession) { const stopped = { ...initial, desiredLifecycle: "stopped" as const }; const persisted = yield* options.stateStore .replace(options.stackId, stopped) .pipe(Effect.exit); const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); - let cause = reconciled.cause; + let cause = launched.cause; if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); return yield* Effect.failCause(cause); } - if (Exit.isFailure(reconciled)) return yield* Effect.failCause(reconciled.cause); + if (Exit.isFailure(launched)) return yield* Effect.failCause(launched.cause); return initial; } - yield* options.backend.preflight( - lifecycleInput(options.stackId, initial, candidate), - "cold", - ); + yield* options.backend.preflight(lifecycleInput(options.stackId, initial, candidate)); const next = stateWithCandidate(initial, candidate, "running"); yield* options.stateStore.replace(options.stackId, next); const started = yield* options.backend - .reconcile(lifecycleInput(options.stackId, next, candidate), "fresh") + .launch(lifecycleInput(options.stackId, next, candidate), "fresh") .pipe(Effect.exit); if (Exit.isSuccess(started)) return next; @@ -316,19 +283,11 @@ export const makeLifecycleController = ( } if (current.desiredLifecycle === "destroying") return yield* lifecycleConflict("Stack is being destroyed"); - const candidate = yield* Effect.exit( - persistedCandidate(options.stackId, current, current.runtime), - ); const stopped: PersistedStackState = current.desiredLifecycle === "stopped" ? current : { ...current, desiredLifecycle: "stopped" }; if (stopped !== current) yield* options.stateStore.replace(options.stackId, stopped); - if (Exit.isSuccess(candidate)) - yield* options.backend.reconcile( - lifecycleInput(options.stackId, stopped, candidate.value, "stopped"), - "current", - ); yield* options.backend.cleanup; return stopped; }); diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index 51507b66f8..33fd8a487c 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -1,15 +1,4 @@ -import { - Data, - DateTime, - Effect, - FileSystem, - Path, - Predicate, - PubSub, - Schema, - Semaphore, - Stream, -} from "effect"; +import { Data, DateTime, Effect, FileSystem, Path, Predicate, Schema, Semaphore } from "effect"; import { StackLogEntrySchema, type LogCursor, @@ -46,10 +35,9 @@ export interface LogRecord { readonly message: string; } -/** Internal retained-log query; follow is only used by the Supervisor log store stream. */ +/** Internal retained-log query. */ interface LogStoreQuery { readonly capabilities?: ReadonlyArray; - readonly follow?: boolean; readonly cursor?: LogCursor; readonly tail?: number; } @@ -61,12 +49,6 @@ export interface LogStore { readonly read: ( options?: LogStoreQuery, ) => Effect.Effect, LogStoreError>; - /** - * Returns retained records followed by live records when `follow` is true. - * Subscription creation and retained snapshotting happen in one critical - * section with append, so no entry can be lost or observed twice at handoff. - */ - readonly stream: (options?: LogStoreQuery) => Stream.Stream; } interface LogDocument { @@ -341,8 +323,6 @@ export const makeLogStore = ( if (redactedLoaded.changed || boundedLoaded.document.entries.length !== loaded.entries.length) yield* persist(fs, options.path, boundedLoaded.document.entries); const semaphore = yield* Semaphore.make(1); - const pubsub = yield* PubSub.unbounded(); - const append = (record: LogRecord) => semaphore.withPermit( Effect.gen(function* () { @@ -382,7 +362,6 @@ export const makeLogStore = ( retainedBytes = boundedNext.bytes; yield* persist(fs, options.path, document.entries); } - yield* PubSub.publish(pubsub, entry); return entry; }), ); @@ -390,64 +369,9 @@ export const makeLogStore = ( const read = (readOptions?: LogStoreQuery) => semaphore.withPermit(selected(document.entries, readOptions, options.path)); - const stream = (streamOptions?: LogStoreQuery): Stream.Stream => - Stream.unwrap( - Effect.gen(function* () { - const handoff = yield* semaphore.withPermit( - Effect.gen(function* () { - const retainedEntries = yield* selected( - document.entries, - streamOptions, - options.path, - ); - const requestedCursor = yield* decodeCursor(options.path, streamOptions?.cursor); - const lastRetainedCursor = document.entries.at(-1)?.cursor.opaque; - const retainedCursor = - lastRetainedCursor === undefined - ? undefined - : yield* parseOpaqueCursor(options.path, lastRetainedCursor); - const handoffCursor = - retainedCursor === undefined - ? requestedCursor - : requestedCursor === undefined - ? retainedCursor - : Math.max(retainedCursor, requestedCursor); - if (streamOptions?.follow !== true) - return { retainedEntries, handoffCursor, subscription: undefined } as const; - const subscription = yield* PubSub.subscribe(pubsub); - return { retainedEntries, handoffCursor, subscription } as const; - }), - ); - const retainedStream = Stream.fromIterable(handoff.retainedEntries); - if (handoff.subscription === undefined) return retainedStream; - const capabilities = - streamOptions?.capabilities === undefined - ? undefined - : new Set(streamOptions.capabilities); - const liveStream = Stream.fromEffectRepeat(PubSub.take(handoff.subscription)).pipe( - Stream.filter((entry) => { - if ( - handoff.handoffCursor !== undefined && - Number.parseInt(entry.cursor.opaque.slice(CURSOR_PREFIX.length), 36) <= - handoff.handoffCursor - ) - return false; - if (capabilities === undefined) return true; - return ( - entry.source !== "gateway" && - entry.source !== "supervisor" && - capabilities.has(entry.source) - ); - }), - ); - return Stream.concat(retainedStream, liveStream); - }), - ).pipe(Stream.scoped); - return { path: options.path, append, read, - stream, }; }); diff --git a/packages/stack/src/supervisor/StatusProjection.ts b/packages/stack/src/supervisor/StatusProjection.ts index 042f6db959..30e5ed2d3e 100644 --- a/packages/stack/src/supervisor/StatusProjection.ts +++ b/packages/stack/src/supervisor/StatusProjection.ts @@ -60,7 +60,6 @@ const capabilityError = ( const stackLifecycle = ( state: PersistedStackState, phase: ActualPhase, - activeStates: ReadonlyArray, ): StackStatus["lifecycle"] => { if (phase === "stopping") return "stopping"; if (state.desiredLifecycle === "unconfigured") return "unconfigured"; @@ -68,9 +67,6 @@ const stackLifecycle = ( if (phase === "starting") return "starting"; if (state.desiredLifecycle === "stopped") return "stopped"; if (phase === "running") return "running"; - if (activeStates.some(({ state }) => state === "starting")) return "starting"; - if (activeStates.length > 0 && activeStates.every(({ state }) => state === "ready")) - return "running"; return "stopped"; }; @@ -114,12 +110,9 @@ export const statusFor = ( }, }; }, {}); - const activeStates = capabilities.filter( - ({ name }) => active.has(name) && definition?.capabilities[name].enabled, - ); return { id, - lifecycle: stackLifecycle(state, phase, activeStates), + lifecycle: stackLifecycle(state, phase), desiredLifecycle: state.desiredLifecycle, runtime: state.runtime, endpoints, diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 73b2c95b65..5174d4f1f2 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -29,7 +29,8 @@ import { GatewayActivationError, StackLifecycleConflictError, StackNotRunningError, - StackReconciliationError, + StackRuntimeError, + StackCleanupError, StackStateInvalidError, type StackError, } from "../public/Errors.ts"; @@ -71,10 +72,7 @@ export interface SupervisorRuntime { runtime: StackRuntime, workloads: ReadonlyArray, ) => Effect.Effect, StackError>; - readonly preflight: ( - input: LifecycleInput, - mode: "cold" | "live", - ) => Effect.Effect; + readonly preflight: (input: LifecycleInput) => Effect.Effect; readonly activate: ( capability: CapabilityName, input: LifecycleInput, @@ -138,9 +136,17 @@ const stateErrorMessage = (error: StackError | { readonly message?: string }): s const credentialHost = (address: string): string => address.includes(":") && !address.startsWith("[") ? `[${address}]` : address; -const mapReconcileError = (error: unknown): StackError => { +const mapRuntimeError = (error: unknown): StackError => { + if (error instanceof StackStateInvalidError) return error; + return new StackRuntimeError({ + message: error instanceof Error ? error.message : String(error), + cause: error, + }); +}; + +const mapCleanupError = (error: unknown): StackError => { if (error instanceof StackStateInvalidError) return error; - return new StackReconciliationError({ + return new StackCleanupError({ message: error instanceof Error ? error.message : String(error), cause: error, }); @@ -192,7 +198,7 @@ export const makeSupervisor = ( yield* initializeActivation(input.plan); }); const observe = () => - runtime.driver.observe(options.stackId).pipe(Effect.mapError(mapReconcileError)); + runtime.driver.observe(options.stackId).pipe(Effect.mapError(mapRuntimeError)); const observedForStatus = () => Ref.get(phase).pipe( Effect.flatMap((current) => (current === "running" ? observe() : Effect.succeed([]))), @@ -285,7 +291,6 @@ export const makeSupervisor = ( const submitLifecycle = ( kind: LifecycleKind, effect: Effect.Effect, - onWaiterInterrupt?: (owned: LifecycleResult) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () { const result = yield* Effect.uninterruptibleMask((restore) => @@ -313,17 +318,12 @@ export const makeSupervisor = ( // cannot make the next lifecycle request look like a conflict. yield* release; yield* Deferred.succeed(deferred, result); - yield* shutdownIfIdle; }).pipe(Effect.ensuring(release)); yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); return deferred; }), ); - const awaitResult = - onWaiterInterrupt === undefined - ? Deferred.await(owned) - : Deferred.await(owned).pipe(Effect.onInterrupt(() => onWaiterInterrupt(owned))); - return yield* restore(awaitResult); + return yield* restore(Deferred.await(owned)); }), ); return yield* joinExit(result); @@ -336,16 +336,16 @@ export const makeSupervisor = ( ): Effect.Effect => Effect.gen(function* () { if (session === "fresh") yield* resetForSession(input); - if (input.desiredLifecycle !== "running") return undefined; + if (input.state.desiredLifecycle !== "running") return undefined; const selected = selectedOverride ?? (yield* Ref.get(active)); const plan = activeExecutionPlan(input.plan, selected); const reservation = yield* runtime.ingress.acquire(input); const launched = yield* launcher .launch(plan) - .pipe(Effect.mapError(mapReconcileError), Effect.exit); + .pipe(Effect.mapError(mapRuntimeError), Effect.exit); if (Exit.isFailure(launched)) { const closed = reservation.fresh - ? yield* runtime.ingress.close.pipe(Effect.mapError(mapReconcileError), Effect.exit) + ? yield* runtime.ingress.close.pipe(Effect.mapError(mapRuntimeError), Effect.exit) : Exit.succeed(undefined); const launchCleanupProven = yield* launcher.cleanupProven; let cause: Cause.Cause = launched.cause; @@ -354,7 +354,7 @@ export const makeSupervisor = ( yield* Ref.set(phase, "stopping"); if (!reservation.fresh && !Exit.isFailure(closed)) { const fenced = yield* runtime.ingress.close.pipe( - Effect.mapError(mapReconcileError), + Effect.mapError(mapRuntimeError), Effect.exit, ); if (Exit.isFailure(fenced)) cause = Cause.combine(cause, fenced.cause); @@ -364,11 +364,11 @@ export const makeSupervisor = ( } const rollback: Effect.Effect = Effect.gen(function* () { const workload = yield* launched.value.rollback.pipe( - Effect.mapError(mapReconcileError), + Effect.mapError(mapRuntimeError), Effect.exit, ); const closed = reservation.fresh - ? yield* runtime.ingress.close.pipe(Effect.mapError(mapReconcileError), Effect.exit) + ? yield* runtime.ingress.close.pipe(Effect.mapError(mapRuntimeError), Effect.exit) : Exit.succeed(undefined); let cause: Cause.Cause = Cause.empty; if (Exit.isFailure(workload)) cause = Cause.combine(cause, workload.cause); @@ -398,15 +398,15 @@ export const makeSupervisor = ( Effect.gen(function* () { yield* Ref.set(cleanupProven, true); const ingress = yield* runtime.ingress.close.pipe( - Effect.mapError(mapReconcileError), + Effect.mapError(mapCleanupError), Effect.exit, ); const launched = destroy ? Exit.succeed(undefined) - : yield* launcher.stop.pipe(Effect.mapError(mapReconcileError), Effect.exit); + : yield* launcher.stop.pipe(Effect.mapError(mapCleanupError), Effect.exit); const driver = yield* runtime.driver .cleanup({ stackId: options.stackId, destroy }) - .pipe(Effect.mapError(mapReconcileError), Effect.exit); + .pipe(Effect.mapError(mapCleanupError), Effect.exit); let cause: Cause.Cause = Cause.empty; for (const result of [ingress, launched, driver]) if (Exit.isFailure(result)) cause = Cause.combine(cause, result.cause); @@ -422,7 +422,7 @@ export const makeSupervisor = ( }); const backend: LifecycleBackend = { preflight: runtime.preflight, - reconcile: launchBackend, + launch: launchBackend, cleanup: cleanupRuntime(false), destroyData: cleanupRuntime(true), }; @@ -460,7 +460,6 @@ export const makeSupervisor = ( visit(capability); const input: LifecycleInput = { stackId: options.stackId, - desiredLifecycle: "running", state, definition, secrets: state.secrets, @@ -621,7 +620,7 @@ export const makeSupervisor = ( }); const start = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { - yield* submitLifecycle("start", startOperation(startOptions), continueShutdown); + yield* submitLifecycle("start", startOperation(startOptions)); return yield* snapshot(); }); const stopOperation = () => @@ -644,28 +643,26 @@ export const makeSupervisor = ( } yield* Ref.set(phase, "stopped"); }); - const shutdownIfIdle = admission.withPermit( + const signalShutdownIfIdle = (): Effect.Effect => Effect.gen(function* () { + const lifecycle = yield* admission.withPermit(Ref.get(lifecycleActive)); + if (lifecycle !== undefined) { + yield* Deferred.await(lifecycle.result); + return yield* signalShutdownIfIdle(); + } const state = yield* read().pipe(Effect.exit); if (Exit.isFailure(state)) return; - const lifecycle = yield* Ref.get(lifecycleActive); const currentPhase = yield* Ref.get(phase); if ( - lifecycle === undefined && currentPhase !== "stopping" && (state.value === undefined || state.value.desiredLifecycle === "stopped" || state.value.desiredLifecycle === "unconfigured") ) yield* signalShutdown; - }), - ); - const continueShutdown = (owned: Deferred.Deferred, never>) => - FiberSet.run(ownedFibers, Deferred.await(owned).pipe(Effect.andThen(shutdownIfIdle)), { - startImmediately: true, - }).pipe(Effect.asVoid); - // An interrupted stop waiter still requests shutdown after the owned operation succeeds. - const stopWithShutdown = submitLifecycle("stop", stopOperation(), continueShutdown); + }); + const shutdownIfIdle = signalShutdownIfIdle(); + const stopWithShutdown = submitLifecycle("stop", stopOperation()); const operation = (effect: Effect.Effect) => effect.pipe(Effect.mapError((error) => rpcError(rpcTag(error), stateErrorMessage(error)))); const destroyOperation = Effect.gen(function* () { @@ -681,9 +678,7 @@ export const makeSupervisor = ( yield* Ref.set(phase, "stopped"); return result.value; }); - const destroy = submitLifecycle("destroy", destroyOperation, continueShutdown).pipe( - Effect.asVoid, - ); + const destroy = submitLifecycle("destroy", destroyOperation).pipe(Effect.asVoid); const logs = (query?: LogQuery): Effect.Effect => Effect.gen(function* () { // Capture lifecycle phase before reading the log store. A stopping snapshot stays live diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 914c9e88c7..071f5fbfd5 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -252,7 +252,6 @@ describe("Supervisor ingress", () => { const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); const reservation = yield* ingress.acquire({ stackId, - desiredLifecycle: "running", state, definition: compiled.definition, secrets: {}, @@ -262,7 +261,6 @@ describe("Supervisor ingress", () => { .open( { stackId, - desiredLifecycle: "running", state, definition: compiled.definition, secrets: {}, diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index dd7b4b259b..ea5cddb8f3 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Deferred, Effect, Exit, FileSystem, Option, Redacted } from "effect"; import { deriveStackId } from "../identity/Identity.ts"; import { - StackReconciliationError, + StackRuntimeError, + StackCleanupError, StackStateInvalidError, StackMustBeStoppedError, } from "../public/Errors.ts"; @@ -35,16 +36,16 @@ interface BackendState { readonly calls: Array; readonly preflight: Array; failPreflight?: boolean; - failReconcile?: boolean; + failLaunch?: boolean; failDestroyData?: boolean; failDestroyDataOnce?: boolean; failCleanupOnce?: boolean; gate?: Deferred.Deferred; preflightStarted?: Deferred.Deferred; - waitBeforeReconcile?: Deferred.Deferred; - stopReconcileStarted?: Deferred.Deferred; - stopReconcileGate?: Deferred.Deferred; - startReconcileStarted?: Deferred.Deferred; + waitBeforeLaunch?: Deferred.Deferred; + stopLaunchStarted?: Deferred.Deferred; + stopLaunchGate?: Deferred.Deferred; + startLaunchStarted?: Deferred.Deferred; lastLifecycle?: PersistedStackState["desiredLifecycle"]; } @@ -55,40 +56,33 @@ const backend = (state: BackendState): LifecycleBackend => ({ state.preflight.push(input); if (state.preflightStarted !== undefined) yield* Deferred.succeed(state.preflightStarted, undefined); - if (state.failPreflight) - return yield* new StackReconciliationError({ message: "preflight failed" }); + if (state.failPreflight) return yield* new StackRuntimeError({ message: "preflight failed" }); if (state.gate !== undefined) yield* Deferred.await(state.gate); }), - reconcile: (input) => + launch: (input) => Effect.gen(function* () { state.lastLifecycle = input.state.desiredLifecycle; - state.calls.push(`reconcile:${input.state.desiredLifecycle}`); - if (input.state.desiredLifecycle === "running" && state.startReconcileStarted !== undefined) - yield* Deferred.succeed(state.startReconcileStarted, undefined); - if (input.state.desiredLifecycle === "stopped") { - if (state.stopReconcileStarted !== undefined) - yield* Deferred.succeed(state.stopReconcileStarted, undefined); - if (state.stopReconcileGate !== undefined) yield* Deferred.await(state.stopReconcileGate); - } - if (state.waitBeforeReconcile !== undefined) yield* Deferred.await(state.waitBeforeReconcile); - if (state.failReconcile) - return yield* new StackReconciliationError({ message: "reconcile failed" }); + state.calls.push(`launch:${input.state.desiredLifecycle}`); + if (input.state.desiredLifecycle === "running" && state.startLaunchStarted !== undefined) + yield* Deferred.succeed(state.startLaunchStarted, undefined); + if (state.waitBeforeLaunch !== undefined) yield* Deferred.await(state.waitBeforeLaunch); + if (state.failLaunch) return yield* new StackRuntimeError({ message: "launch failed" }); }), cleanup: Effect.gen(function* () { state.calls.push(`cleanup:${state.lastLifecycle ?? "invalid"}`); if (state.failCleanupOnce) { state.failCleanupOnce = false; - return yield* new StackReconciliationError({ message: "cleanup failed" }); + return yield* new StackCleanupError({ message: "cleanup failed" }); } }), destroyData: Effect.gen(function* () { state.calls.push(`destroy-data:${state.lastLifecycle ?? "invalid"}`); if (state.failDestroyDataOnce) { state.failDestroyDataOnce = false; - return yield* new StackReconciliationError({ message: "destroy-data failed" }); + return yield* new StackCleanupError({ message: "destroy-data failed" }); } if (state.failDestroyData) - return yield* new StackReconciliationError({ message: "destroy-data failed" }); + return yield* new StackCleanupError({ message: "destroy-data failed" }); }), }); @@ -128,7 +122,7 @@ describe("durable lifecycle controller", () => { const result = yield* fixture.controller.start(); expect(result.desiredLifecycle).toBe("running"); expect(result.definition).toBeDefined(); - expect(fixture.state.calls).toEqual(["preflight", "reconcile:running"]); + expect(fixture.state.calls).toEqual(["preflight", "launch:running"]); expect(yield* fixture.store.read(fixture.id)).toEqual(result); }), ), @@ -158,7 +152,7 @@ describe("durable lifecycle controller", () => { fixture.state.calls.length = 0; const second = yield* fixture.controller.start({ config: { capabilities: { rest: {} } } }); expect(second).toEqual(first); - expect(fixture.state.calls).toEqual(["reconcile:running"]); + expect(fixture.state.calls).toEqual(["launch:running"]); }), ), ); @@ -168,10 +162,10 @@ describe("durable lifecycle controller", () => { Effect.gen(function* () { const fixture = yield* makeFixture(); yield* fixture.controller.start(); - fixture.state.failReconcile = true; + fixture.state.failLaunch = true; const failed = yield* fixture.controller.start({ freshSession: true }).pipe(Effect.exit); - expect(errorOf(failed)).toBeInstanceOf(StackReconciliationError); + expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); expect(fixture.state.calls.at(-1)).toBe("cleanup:running"); }), @@ -250,11 +244,11 @@ describe("durable lifecycle controller", () => { const first = yield* fixture.controller.stop(); expect(first.desiredLifecycle).toBe("stopped"); expect(first.definition).toBeDefined(); - expect(fixture.state.calls).toEqual(["reconcile:stopped", "cleanup:stopped"]); + expect(fixture.state.calls).toEqual(["cleanup:running"]); fixture.state.calls.length = 0; const second = yield* fixture.controller.stop(); expect(second).toEqual(first); - expect(fixture.state.calls).toEqual(["reconcile:stopped", "cleanup:stopped"]); + expect(fixture.state.calls).toEqual(["cleanup:running"]); }), ), ); @@ -267,13 +261,13 @@ describe("durable lifecycle controller", () => { fixture.state.calls.length = 0; fixture.state.failCleanupOnce = true; const first = yield* fixture.controller.stop().pipe(Effect.exit); - expect(errorOf(first)).toBeInstanceOf(StackReconciliationError); + expect(errorOf(first)).toBeInstanceOf(StackCleanupError); const stopped = yield* fixture.store.read(fixture.id); expect(stopped?.desiredLifecycle).toBe("stopped"); fixture.state.calls.length = 0; const second = yield* fixture.controller.stop(); expect(second.desiredLifecycle).toBe("stopped"); - expect(fixture.state.calls).toEqual(["reconcile:stopped", "cleanup:stopped"]); + expect(fixture.state.calls).toEqual(["cleanup:running"]); }), ), ); @@ -289,7 +283,7 @@ describe("durable lifecycle controller", () => { const exit = yield* fixture.controller .start({ config: { capabilities: { rest: {} } } }) .pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(StackReconciliationError); + expect(errorOf(exit)).toBeInstanceOf(StackRuntimeError); expect(yield* fixture.store.read(fixture.id)).toMatchObject({ desiredLifecycle: "stopped", definition: first.definition, @@ -333,7 +327,7 @@ describe("durable lifecycle controller", () => { fixture.state.calls.length = 0; fixture.state.failDestroyData = true; const exit = yield* fixture.controller.destroy().pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(StackReconciliationError); + expect(errorOf(exit)).toBeInstanceOf(StackCleanupError); expect(yield* fixture.store.read(fixture.id)).toMatchObject({ desiredLifecycle: "destroying", }); @@ -362,7 +356,7 @@ describe("durable lifecycle controller", () => { const fixture = yield* makeFixture(); fixture.state.failDestroyDataOnce = true; const first = yield* fixture.controller.destroy().pipe(Effect.exit); - expect(errorOf(first)).toBeInstanceOf(StackReconciliationError); + expect(errorOf(first)).toBeInstanceOf(StackCleanupError); expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("destroying"); yield* fixture.controller.destroy(); expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); diff --git a/packages/stack/src/supervisor/observability.integration.test.ts b/packages/stack/src/supervisor/observability.integration.test.ts index dbde618391..5ed0531d81 100644 --- a/packages/stack/src/supervisor/observability.integration.test.ts +++ b/packages/stack/src/supervisor/observability.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Path, Stream } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; import { LogStoreError, makeLogStore } from "./LogStore.ts"; const withPlatform = (effect: Effect.Effect) => @@ -41,10 +41,6 @@ describe("observability", () => { expect( (yield* store.read({ capabilities: ["auth"] })).map((entry) => entry.message), ).toEqual(["two"]); - expect((yield* Stream.runCollect(store.stream())).map((entry) => entry.message)).toEqual([ - "two", - "three", - ]); const invalidCursor = yield* store .read({ cursor: { opaque: "not-a-cursor" } }) .pipe(Effect.exit); @@ -122,36 +118,4 @@ describe("observability", () => { }), ), ); - - it.live("subscribes before append so retained-to-live handoff has no gap", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-log-handoff-" }); - const store = yield* makeLogStore({ - path: path.join(root, "logs.json"), - knownSecrets: ["live-secret"], - }); - yield* store.append({ source: "database", stream: "stdout", message: "retained" }); - const subscribed = yield* Deferred.make(); - const values = yield* store.stream({ follow: true }).pipe( - Stream.tap(() => Deferred.succeed(subscribed, void 0)), - Stream.take(2), - Stream.runCollect, - Effect.forkChild({ startImmediately: true }), - ); - yield* Deferred.await(subscribed); - yield* store.append({ - source: "database", - stream: "stdout", - message: "live live-secret", - }); - expect((yield* Fiber.join(values)).map((entry) => entry.message)).toEqual([ - "retained", - "live [REDACTED]", - ]); - }), - ), - ); }); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 611ded61e9..962d2dfc21 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -13,7 +13,6 @@ import { Redacted, Ref, Scope, - Stream, } from "effect"; import { Headers } from "effect/unstable/http"; import { Rpc } from "effect/unstable/rpc"; @@ -25,7 +24,8 @@ import { PortUnavailableError, StackLifecycleConflictError, StackNotRunningError, - StackReconciliationError, + StackRuntimeError, + StackCleanupError, StackStateInvalidError, StackMustBeStoppedError, } from "../public/Errors.ts"; @@ -91,7 +91,7 @@ const makeFixture = ( readonly activationFailFirst?: Ref.Ref; readonly startFailures?: Ref.Ref; readonly preflightFailFirst?: Ref.Ref; - readonly preflightModes?: Ref.Ref>; + readonly preflightCalls?: Ref.Ref; readonly preflightGate?: Deferred.Deferred; readonly preflightStarted?: Deferred.Deferred; readonly stopGate?: Deferred.Deferred; @@ -307,10 +307,10 @@ const makeFixture = ( ), ); }), - preflight: (_input, mode) => + preflight: (_input) => Effect.gen(function* () { - if (fixtureOptions.preflightModes !== undefined) - yield* Ref.update(fixtureOptions.preflightModes, (current) => [...current, mode]); + if (fixtureOptions.preflightCalls !== undefined) + yield* Ref.update(fixtureOptions.preflightCalls, (count) => count + 1); if (fixtureOptions.preflightStarted !== undefined) yield* Deferred.succeed(fixtureOptions.preflightStarted, undefined); if (fixtureOptions.preflightGate !== undefined) @@ -319,7 +319,7 @@ const makeFixture = ( const fail = yield* Ref.get(fixtureOptions.preflightFailFirst); if (fail) { yield* Ref.set(fixtureOptions.preflightFailFirst, false); - return yield* new StackReconciliationError({ message: "injected preflight failure" }); + return yield* new StackRuntimeError({ message: "injected preflight failure" }); } } }), @@ -359,7 +359,6 @@ const makeFixture = ( Ref.update(logOptions, (current) => [...current, options]).pipe( Effect.andThen(Ref.get(logEntries)), ), - stream: () => Stream.empty, }, }; const context = yield* Effect.context< @@ -476,6 +475,7 @@ describe("Supervisor composition", () => { expect(errorOf(yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit))?.tag).toBe( "StackNotRunningError", ); + yield* fixture.supervisor.shutdownIfIdle; const retry = yield* fixture.supervisor.start().pipe(Effect.exit); expect(errorOf(retry)).toBeInstanceOf(StackLifecycleConflictError); expect(yield* Ref.get(fixture.calls)).toContain("start:database:database"); @@ -518,6 +518,7 @@ describe("Supervisor composition", () => { expect(Exit.isFailure(yield* fixture.supervisor.start().pipe(Effect.exit))).toBe(true); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + yield* fixture.supervisor.shutdownIfIdle; expect(errorOf(yield* fixture.supervisor.start().pipe(Effect.exit))).toBeInstanceOf( StackLifecycleConflictError, ); @@ -584,17 +585,19 @@ describe("Supervisor composition", () => { ), ); - it.live("uses cold preflight when starting after a stop", () => + it.live("skips preflight for a live session and preflights a new owner", () => run( Effect.gen(function* () { - const preflightModes = yield* Ref.make>([]); - const fixture = yield* makeFixture({ preflightModes }); + const preflightCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ preflightCalls }); yield* fixture.supervisor.start({ config: {} }); - yield* Ref.set(preflightModes, []); + yield* Ref.set(preflightCalls, 0); + yield* fixture.supervisor.start(); + expect(yield* Ref.get(preflightCalls)).toBe(0); yield* fixture.supervisor.maintenanceHandlers.stop; yield* fixture.supervisor.start(); - expect(yield* Ref.get(preflightModes)).toEqual(["cold"]); + expect(yield* Ref.get(preflightCalls)).toBe(1); const successor = yield* makeSupervisor({ identity, @@ -605,9 +608,9 @@ describe("Supervisor composition", () => { context: fixture.context, runtime: fixture.runtime, }); - yield* Ref.set(preflightModes, []); + yield* Ref.set(preflightCalls, 0); yield* successor.start(); - expect(yield* Ref.get(preflightModes)).toEqual(["cold"]); + expect(yield* Ref.get(preflightCalls)).toBe(1); }), ), ); @@ -686,7 +689,7 @@ describe("Supervisor composition", () => { const remaining = yield* Ref.get(closeFailures); if (remaining > 0) { yield* Ref.set(closeFailures, remaining - 1); - return yield* new StackReconciliationError({ message: "injected close failure" }); + return yield* new StackCleanupError({ message: "injected close failure" }); } }), }, @@ -1024,6 +1027,7 @@ describe("Supervisor composition", () => { yield* Deferred.succeed(stopGate, undefined); yield* Fiber.join(stopping); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + yield* fixture.supervisor.shutdownIfIdle; const restart = yield* fixture.supervisor.start().pipe(Effect.exit); expect(errorOf(restart)).toBeInstanceOf(StackLifecycleConflictError); }), @@ -1262,6 +1266,8 @@ describe("Supervisor composition", () => { yield* Deferred.await(preflightStarted); yield* Fiber.interrupt(waiter); yield* Deferred.succeed(preflightGate, undefined); + yield* Effect.yieldNow; + yield* fixture.supervisor.shutdownIfIdle; yield* fixture.supervisor.shutdown.pipe( Effect.timeoutOrElse({ duration: "1 second", @@ -1549,7 +1555,7 @@ describe("Supervisor composition", () => { const remaining = yield* Ref.get(closeFailures); if (remaining > 0) { yield* Ref.set(closeFailures, remaining - 1); - return yield* new StackReconciliationError({ message: "injected close failure" }); + return yield* new StackCleanupError({ message: "injected close failure" }); } }), }, @@ -1628,6 +1634,7 @@ describe("Supervisor composition", () => { StackLifecycleConflictError, ); expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + yield* fixture.supervisor.shutdownIfIdle; yield* fixture.supervisor.shutdown; const successor = yield* makeSupervisor({ identity, @@ -1750,6 +1757,8 @@ describe("Supervisor composition", () => { yield* Deferred.await(stopStarted); yield* Fiber.interrupt(waiter); yield* Deferred.succeed(stopGate, undefined); + yield* Effect.yieldNow; + yield* fixture.supervisor.shutdownIfIdle; yield* fixture.supervisor.shutdown; expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); }), @@ -1767,6 +1776,8 @@ describe("Supervisor composition", () => { yield* Deferred.await(destroyStarted); yield* Fiber.interrupt(waiter); yield* Deferred.succeed(destroyGate, undefined); + yield* Effect.yieldNow; + yield* fixture.supervisor.shutdownIfIdle; yield* fixture.supervisor.shutdown; expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); }), From e855ba8d9ac3d5b007a6b712bfea386ee9d06de0 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 09:46:29 +0200 Subject: [PATCH 127/174] refactor(stack): consolidate runtime ownership --- .../stack/src/entrypoints/supervisor-node.ts | 65 +++----- packages/stack/src/model/Compiler.ts | 3 +- packages/stack/src/model/ExecutionPlan.ts | 23 ++- .../stack/src/preparation/RuntimeArtifacts.ts | 38 ++--- .../runtime-artifacts.integration.test.ts | 6 +- packages/stack/src/public/EffectStack.ts | 135 +++------------- .../public/effect-stack.integration.test.ts | 151 +++++++++--------- .../src/runtime/ContainerEngineResolver.ts | 35 +++- .../stack/src/runtime/ProductionRuntime.ts | 30 ++-- .../production-runtime.integration.test.ts | 79 --------- packages/stack/src/state/SecretStore.ts | 7 +- packages/stack/src/supervisor/Ingress.ts | 14 +- .../stack/src/supervisor/LaunchProtocol.ts | 43 +++++ packages/stack/src/supervisor/Launcher.ts | 33 +--- packages/stack/src/supervisor/Lifecycle.ts | 5 +- packages/stack/src/supervisor/LogStore.ts | 29 ++++ packages/stack/src/supervisor/Supervisor.ts | 87 +++------- .../supervisor/supervisor.integration.test.ts | 24 --- 18 files changed, 317 insertions(+), 490 deletions(-) create mode 100644 packages/stack/src/supervisor/LaunchProtocol.ts diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index 5694e0efdb..64f71d3098 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -10,49 +10,16 @@ import { type StackRuntimeEnvironmentValue, } from "../state/Ownership.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; -import { StackIdSchema } from "../public/StackId.ts"; import { makeSupervisor } from "../supervisor/Supervisor.ts"; import { makeProductionRuntimeFactory } from "../runtime/ProductionRuntime.ts"; import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; -import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; -import { StackOwnershipConflictError } from "../public/Errors.ts"; - -const IdentitySchema = Schema.Struct({ - projectRoot: Schema.String, - checkoutRoot: Schema.String, - workspaceId: Schema.String, - checkoutId: Schema.String, - branchContext: Schema.String, - localProjectKey: Schema.String, - stackName: Schema.String, -}); - -const SupervisorArgsSchema = Schema.Struct({ - stateRoot: Schema.String, - artifactCacheRoot: Schema.optional(Schema.String), - tempRoot: Schema.String, - platform: Schema.Literals(["posix", "windows"] as const), - stackId: StackIdSchema, - ownerSessionId: OwnerSessionIdSchema, - rpcRelease: Schema.String, - identity: IdentitySchema, -}); - -const ReadySchema = Schema.Union([ - Schema.Struct({ - ok: Schema.Literal(true), - stackId: StackIdSchema, - ownerSessionId: Schema.String, - }), - Schema.Struct({ - ok: Schema.Literal(false), - code: Schema.Literals(["ownership-conflict", "failed"] as const), - message: Schema.String, - }), -]); - -type SupervisorArgs = Schema.Schema.Type; +import { StackOwnershipConflictError, StackStateInvalidError } from "../public/Errors.ts"; +import { + SupervisorArgsSchema, + SupervisorReadySchema, + type SupervisorArgs, +} from "../supervisor/LaunchProtocol.ts"; class SupervisorReadinessError extends Data.TaggedError("SupervisorReadinessError")<{ readonly message: string; @@ -66,7 +33,7 @@ const reportSupervisorFailure = (error: unknown): void => { // written directly at this standalone process boundary. NodeFs.writeSync( 3, - `${Schema.encodeSync(Schema.fromJsonString(ReadySchema))({ ok: false, code: conflict ? "ownership-conflict" : "failed", message: error instanceof Error ? error.message : "Supervisor failed" })}\n`, + `${Schema.encodeSync(Schema.fromJsonString(SupervisorReadySchema))({ ok: false, code: conflict ? "ownership-conflict" : "failed", message: error instanceof Error ? error.message : "Supervisor failed" })}\n`, ); NodeFs.closeSync(3); } catch { @@ -76,10 +43,12 @@ const reportSupervisorFailure = (error: unknown): void => { }; const writeReadiness = ( - value: Schema.Schema.Type, + value: Schema.Schema.Type, ): Effect.Effect => Effect.gen(function* () { - const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(ReadySchema))(value).pipe( + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(SupervisorReadySchema))( + value, + ).pipe( Effect.mapError( (cause) => new SupervisorReadinessError({ @@ -132,14 +101,22 @@ const runSupervisor = (args: SupervisorArgs) => stateStore: store, context, }); + const initial = yield* store.read(args.stackId).pipe( + Effect.provideContext(context), + Effect.flatMap((state) => + state === undefined + ? Effect.fail(new StackStateInvalidError({ message: "Stack state is missing" })) + : Effect.succeed(state), + ), + ); + const runtime = yield* runtimeFactory.make(initial); const supervisor = yield* makeSupervisor({ - identity: args.identity, stackId: args.stackId, ownerSessionId, rpcRelease: args.rpcRelease || STACK_RPC_RELEASE, stateStore: store, context, - runtimeFactory, + runtime, }); yield* startControlServer({ endpoint: lease.metadata.endpoint, diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 6dca597c41..33544ac6dc 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -39,6 +39,7 @@ import type { MaterializedSettings, } from "./CapabilityModule.ts"; import type { SecretGenerator, SecretJwtSigning } from "../state/SecretStore.ts"; +import { AUTH_JWT_SECRET_SLOT } from "../state/SecretStore.ts"; interface SecretSlot { readonly slot: string; @@ -92,8 +93,6 @@ export interface SecretSlotInput { readonly generator?: SecretGenerator; } -const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; - /** Internal credentials are not user settings but still need durable managed slots. */ const INTERNAL_MANAGED_SECRET_SLOTS = ["secret:database.internal.password"] as const; diff --git a/packages/stack/src/model/ExecutionPlan.ts b/packages/stack/src/model/ExecutionPlan.ts index e096da5f3d..0a6c6c76bf 100644 --- a/packages/stack/src/model/ExecutionPlan.ts +++ b/packages/stack/src/model/ExecutionPlan.ts @@ -74,15 +74,26 @@ export interface ExecutionPlan { readonly workloads: ReadonlyArray; } -export const eagerCapabilities = (plan: ExecutionPlan): Set => { - const active = new Set(); +/** Return the requested capabilities and every transitive dependency. */ +export const dependencyClosure = ( + plan: ExecutionPlan, + roots: Iterable, +): Set => { + const closure = new Set(); const visit = (name: CapabilityName): void => { - if (active.has(name)) return; - active.add(name); + if (closure.has(name)) return; + closure.add(name); for (const dependency of plan.dependencies[name]) visit(dependency); }; - for (const name of CAPABILITY_NAMES) if (plan.activation[name] === "eager") visit(name); - return active; + for (const root of roots) visit(root); + return closure; +}; + +export const eagerCapabilities = (plan: ExecutionPlan): Set => { + return dependencyClosure( + plan, + CAPABILITY_NAMES.filter((name) => plan.activation[name] === "eager"), + ); }; export const activeExecutionPlan = ( diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 51959b2147..e09a5d0b08 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -14,14 +14,11 @@ import { type PreparedArtifact, } from "./ArtifactStore.ts"; import { makeSlimServicesSource, slimServicesChecksum } from "./SlimServicesSource.ts"; +import { type ContainerEngine, type ContainerEngineKind } from "../runtime/ContainerEngine.ts"; import { - makeProcessCommandRunner, - type ContainerEngine, - type ContainerPlatform, - type ContainerEngineKind, -} from "../runtime/ContainerEngine.ts"; -import { makeDockerEngine } from "../runtime/DockerEngine.ts"; -import { makePodmanEngine } from "../runtime/PodmanEngine.ts"; + resolveContainerEngine, + type ContainerEngineResolverShape, +} from "../runtime/ContainerEngineResolver.ts"; /** Result of preparing one workload's immutable runtime artifact. */ export interface PreparedWorkloadArtifact { @@ -40,8 +37,6 @@ export interface RuntimeArtifactPreparer { runtime: StackRuntime, workload: PlannedWorkload, ) => Effect.Effect; - /** Selected production container engine, when this preparer was built for containers. */ - readonly containerEngine?: ContainerEngine; } export type RuntimeArtifactPreparationError = @@ -186,10 +181,11 @@ export const makeProductionRuntimeArtifactPreparer = (options: { readonly stateRoot: string; /** Optional cache root shared across disposable stack state roots. */ readonly artifactCacheRoot?: string; - readonly platform?: ContainerPlatform; readonly runtime?: StackRuntime; - /** Optional already-selected engine for caller-owned preparation. */ + /** Optional already-selected engine owned by the production runtime. */ readonly containerEngine?: ContainerEngine; + /** Test seam for caller-owned preparation when no engine was supplied. */ + readonly containerEngineResolver?: ContainerEngineResolverShape; }): Effect.Effect< RuntimeArtifactPreparer, RuntimeArtifactPreparationError, @@ -221,30 +217,14 @@ export const makeProductionRuntimeArtifactPreparer = (options: { artifacts.set(artifactKey(artifact), artifact); return slimServicesChecksum(artifact); }; - const platform = - options.platform ?? - ({ - os: - process.platform === "darwin" - ? "darwin" - : process.platform === "win32" - ? "windows" - : "linux", - desktop: process.platform === "darwin", - } satisfies ContainerPlatform); const selectedEngine = options.runtime?.kind === "container" ? options.runtime.engine : undefined; const containerEngine = options.containerEngine ?? (selectedEngine === undefined ? undefined - : yield* makeProcessCommandRunner({ executable: selectedEngine }).pipe( + : yield* resolveContainerEngine(selectedEngine, options.containerEngineResolver).pipe( Effect.mapError(() => error(`Unable to configure ${selectedEngine} artifact engine`)), - Effect.map((runner) => - selectedEngine === "docker" - ? makeDockerEngine({ runner, platform }) - : makePodmanEngine({ runner, platform }), - ), )); const containers = selectedEngine === undefined || containerEngine === undefined @@ -256,5 +236,5 @@ export const makeProductionRuntimeArtifactPreparer = (options: { ...(store === undefined ? {} : { native: { store, checksum } }), containers, }); - return containerEngine === undefined ? preparer : { ...preparer, containerEngine }; + return preparer; }); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index b00493fc17..c670668167 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -74,17 +74,15 @@ describe("runtime artifact preparation", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-artifact-runtime-" }); - const preparer = yield* makeProductionRuntimeArtifactPreparer({ + yield* makeProductionRuntimeArtifactPreparer({ stateRoot: root, runtime: { kind: "container", engine: "podman" }, }); - expect(preparer.containerEngine?.kind).toBe("podman"); expect(yield* fs.exists(path.join(root, "artifacts"))).toBe(false); - const native = yield* makeProductionRuntimeArtifactPreparer({ + yield* makeProductionRuntimeArtifactPreparer({ stateRoot: root, runtime: { kind: "native" }, }); - expect(native.containerEngine).toBeUndefined(); const sharedRoot = path.join(root, "shared-artifacts"); const isolatedStateRoot = path.join(root, "isolated-state"); yield* makeProductionRuntimeArtifactPreparer({ diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 38e6762288..79a7e33180 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -22,7 +22,7 @@ import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/u import type { StackIdentity } from "../identity/Identity.ts"; import { resolveStackIdentity, deriveStackId } from "../identity/Identity.ts"; import { compileStack, rebuildExecutionPlan, type StackDefinition } from "../model/Compiler.ts"; -import type { ExecutionPlan } from "../model/ExecutionPlan.ts"; +import { dependencyClosure, type ExecutionPlan } from "../model/ExecutionPlan.ts"; import type { PersistedStackIdentity, PersistedStackState } from "../state/StackState.ts"; import { toPersistedIdentity } from "../state/StackState.ts"; import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; @@ -101,17 +101,8 @@ import { ContainerEngineResolver, type ContainerEngineResolverShape, } from "../runtime/ContainerEngineResolver.ts"; -import { - ContainerEngineProtocolError, - makeProcessCommandRunner, - type ContainerEngineKind, - type ContainerPlatform, - type ContainerEngine, -} from "../runtime/ContainerEngine.ts"; import { statusFor } from "../supervisor/StatusProjection.ts"; -import { makeDockerEngine } from "../runtime/DockerEngine.ts"; -import { makePodmanEngine } from "../runtime/PodmanEngine.ts"; -import { readRetainedLogs } from "../supervisor/LogStore.ts"; +import { EMPTY_LOG_CURSOR, readRetainedLogs, selectLogBatch } from "../supervisor/LogStore.ts"; import { makeProductionRuntimeArtifactPreparer, type PreparedWorkloadArtifact, @@ -171,43 +162,6 @@ export interface EffectStack { const optionOf = (value: A | undefined): Option.Option => value === undefined ? Option.none() : Option.some(value); -const defaultContainerPlatform = (): ContainerPlatform => { - // Host details are read only at this composition boundary. The real - // container adapters reject unsupported Podman routing during preflight. - if (process.platform === "darwin") return { os: "darwin", desktop: true }; - if (process.platform === "win32") return { os: "windows", desktop: true }; - return { os: "linux", desktop: false }; -}; - -const defaultContainerEngineResolver = (): ContainerEngineResolverShape => ({ - resolve: (kind) => - Effect.gen(function* () { - const platform = defaultContainerPlatform(); - const runner = yield* makeProcessCommandRunner({ executable: kind }); - return kind === "docker" - ? makeDockerEngine({ runner, platform }) - : makePodmanEngine({ runner, platform }); - }), -}); - -const resolveContainerEngine = ( - kind: ContainerEngineKind, - resolver: ContainerEngineResolverShape | undefined, - spawner: ChildProcessSpawnerValue, -): Effect.Effect => - (resolver ?? defaultContainerEngineResolver()).resolve(kind).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.mapError( - (error) => - new ContainerEngineError({ - message: - error instanceof ContainerEngineProtocolError - ? `${error.operation}: ${error.message}` - : error.message, - }), - ), - ); - const descriptor = (state: PersistedStackState): StackDescriptor => ({ id: StackIdSchema.make(state.identity.stackId), projectRoot: state.identity.projectRoot, @@ -333,11 +287,6 @@ const destroyError = (error: ControlError): DestroyStackError => /** Internal control-transport seam used by public lifecycle integration tests. */ export const makeHandle = ( id: StackId, - metadata: { - readonly endpoint?: Parameters[0]; - readonly ownerSessionId?: string; - readonly rpcRelease?: string; - }, options: { readonly resolveOwner?: ( launch: boolean, @@ -352,23 +301,12 @@ export const makeHandle = ( readonly prepare?: ( options?: PrepareStackOptions, ) => Effect.Effect; - readonly containerEngineResolver?: ContainerEngineResolverShape; } = {}, ): Effect.Effect => Effect.sync(() => { const readOfflineState = options.readOfflineState; const readPersistedState = options.readPersistedState; const readLogs = options.readLogs; - const client: ReturnType | undefined = - metadata.endpoint !== undefined && - metadata.ownerSessionId !== undefined && - metadata.rpcRelease !== undefined - ? makeControlClient(metadata.endpoint, { - stackId: id, - ownerSessionId: metadata.ownerSessionId, - rpcRelease: metadata.rpcRelease, - }) - : undefined; const resolveClient = ( launch: boolean, ): Effect.Effect, StackError> => @@ -388,11 +326,9 @@ export const makeHandle = ( ), ), ) - : client !== undefined - ? Effect.succeed(client) - : Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ); + : Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ); const mapRpcClientFailure = ( error: RpcClientError, mapError: (error: ControlError) => E, @@ -815,24 +751,11 @@ const handleDependencies = (options: { plan = compiled.executionPlan; } const selected = new Set(); - const visit = (name: CapabilityName): Effect.Effect => { - if (selected.has(name)) return Effect.void; - const capability = definition.capabilities[name]; - if (!capability.enabled) - return Effect.fail( - new StackPreparationError({ - stackId: options.id, - capability: name, - message: `Capability ${name} is disabled`, - }), - ); - selected.add(name); - return Effect.forEach(plan.dependencies[name], visit, { discard: true }); - }; if (prepareOptions?.capabilities === undefined) { for (const name of CAPABILITY_NAMES) if (definition.capabilities[name].enabled) selected.add(name); } else { + const requested: CapabilityName[] = []; for (const name of prepareOptions.capabilities) { if (!isCapabilityName(name)) return yield* new StackPreparationError({ @@ -840,22 +763,25 @@ const handleDependencies = (options: { capability: String(name), message: `Unknown capability ${String(name)}`, }); - yield* visit(name); + requested.push(name); + } + for (const name of dependencyClosure(plan, requested)) { + if (!definition.capabilities[name].enabled) + return yield* new StackPreparationError({ + stackId: options.id, + capability: name, + message: `Capability ${name} is disabled`, + }); + selected.add(name); } } const workloads = plan.workloads.filter((workload) => selected.has(workload.capability)); - const containerEngine = - state.runtime.kind === "container" - ? yield* resolveContainerEngine( - state.runtime.engine, - options.containerEngineResolver, - options.spawner, - ) - : undefined; const preparer = yield* makeProductionRuntimeArtifactPreparer({ stateRoot: options.environment.stateRoot, runtime: state.runtime, - ...(containerEngine === undefined ? {} : { containerEngine }), + ...(options.containerEngineResolver === undefined + ? {} + : { containerEngineResolver: options.containerEngineResolver }), }).pipe( Effect.provideService(FileSystem.FileSystem, options.fileSystem), Effect.provideService(Path.Path, options.path), @@ -904,24 +830,9 @@ const handleDependencies = (options: { : { cursor: query.cursor }, ).pipe( Effect.map((scanned) => { - const capabilities = - query?.capabilities === undefined ? undefined : new Set(query.capabilities); - const filtered = scanned.filter((entry) => - capabilities === undefined - ? true - : entry.source !== "gateway" && - entry.source !== "supervisor" && - capabilities.has(entry.source), - ); - const entries = - query?.tail === undefined - ? filtered - : query.tail <= 0 - ? [] - : filtered.slice(-Math.floor(query.tail)); + const selected = selectLogBatch(scanned, query, query?.cursor ?? EMPTY_LOG_CURSOR); return { - entries, - cursor: scanned.at(-1)?.cursor ?? query?.cursor ?? { opaque: "v1_0" }, + ...selected, running: false, } satisfies StackLogBatch; }), @@ -1017,7 +928,7 @@ export const createStack = ( spawner, containerEngineResolver, }); - return yield* makeHandle(stackId, {}, dependencies); + return yield* makeHandle(stackId, dependencies); }); export const openStack = ( @@ -1052,7 +963,7 @@ export const openStack = ( spawner, containerEngineResolver, }); - return yield* makeHandle(id, {}, dependencies); + return yield* makeHandle(id, dependencies); }); export const findStack = ( diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 719775b464..d5f015c6bf 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -236,7 +236,9 @@ describe("Effect stack lifecycle handoff", () => { }, onShutdownReady: Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeHandle(stackId, owner); + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some(owner)), + }); const first = yield* stack.logs({ capabilities: ["auth"], tail: 1 }); expect(first.entries).toEqual([initialAuth]); expect(first.cursor).toEqual(unrelatedDatabase.cursor); @@ -294,30 +296,26 @@ describe("Effect stack lifecycle handoff", () => { }, ]; const burst = entries.slice(1); - const stack = yield* makeHandle( - stackId, - {}, - { - resolveOwner: () => Effect.succeed(Option.none()), - readPersistedState: () => Effect.succeed(Option.some(stoppedState())), - readOfflineState: () => Effect.succeed(Option.none()), - readLogs: (query?: LogQuery) => - Ref.getAndUpdate(calls, (current) => current + 1).pipe( - Effect.map((index) => { - const batchEntries = index === 0 ? [entries[0]] : burst; - const visibleEntries = - query?.tail === undefined - ? batchEntries - : batchEntries.slice(-Math.floor(query.tail)); - return { - entries: visibleEntries, - cursor: entries.at(index === 0 ? 0 : 3)?.cursor ?? { opaque: "v1_0" }, - running: index === 0, - }; - }), - ), - }, - ); + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.none()), + readPersistedState: () => Effect.succeed(Option.some(stoppedState())), + readOfflineState: () => Effect.succeed(Option.none()), + readLogs: (query?: LogQuery) => + Ref.getAndUpdate(calls, (current) => current + 1).pipe( + Effect.map((index) => { + const batchEntries = index === 0 ? [entries[0]] : burst; + const visibleEntries = + query?.tail === undefined + ? batchEntries + : batchEntries.slice(-Math.floor(query.tail)); + return { + entries: visibleEntries, + cursor: entries.at(index === 0 ? 0 : 3)?.cursor ?? { opaque: "v1_0" }, + running: index === 0, + }; + }), + ), + }); const followed = yield* stack .followLogs({ capabilities: ["auth"], tail: 1 }) .pipe(Stream.runCollect, Effect.exit); @@ -370,15 +368,11 @@ describe("Effect stack lifecycle handoff", () => { const ownership = new StackOwnershipConflictError({ message: `owner ${artifact} is still present`, }); - const stack = yield* makeHandle( - stackId, - {}, - { - resolveOwner: () => Effect.succeed(Option.none()), - readOfflineState: () => Effect.fail(ownership), - readLogs: () => Effect.fail(ownership), - }, - ); + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.none()), + readOfflineState: () => Effect.fail(ownership), + readLogs: () => Effect.fail(ownership), + }); const status = yield* stack.status().pipe(Effect.exit); expect(Exit.isFailure(status)).toBe(true); const logs = yield* stack.logs().pipe(Effect.exit); @@ -395,28 +389,24 @@ describe("Effect stack lifecycle handoff", () => { const ownership = new StackOwnershipConflictError({ message: "Supervisor is still shutting down", }); - const stack = yield* makeHandle( - stackId, - {}, - { - resolveOwner: () => Effect.succeed(Option.none()), - readPersistedState: () => Effect.succeed(Option.some(stoppedState())), - readOfflineState: () => - Ref.getAndUpdate(readAttempts, (current) => current + 1).pipe( - Effect.flatMap((attempt) => - attempt === 0 ? Effect.fail(ownership) : Effect.succeed(Option.none()), - ), - ), - readLogs: () => - Ref.update(attempts, (current) => current + 1).pipe( - Effect.as({ - entries: [], - cursor: { opaque: "v1_0" }, - running: false, - }), + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.none()), + readPersistedState: () => Effect.succeed(Option.some(stoppedState())), + readOfflineState: () => + Ref.getAndUpdate(readAttempts, (current) => current + 1).pipe( + Effect.flatMap((attempt) => + attempt === 0 ? Effect.fail(ownership) : Effect.succeed(Option.none()), ), - }, - ); + ), + readLogs: () => + Ref.update(attempts, (current) => current + 1).pipe( + Effect.as({ + entries: [], + cursor: { opaque: "v1_0" }, + running: false, + }), + ), + }); const batch = yield* stack.logs(); expect(batch.entries).toEqual([]); expect(yield* Ref.get(attempts)).toBe(1); @@ -762,10 +752,15 @@ describe("Effect stack lifecycle handoff", () => { stop: Effect.succeed({ ok: true, op: "stop" } as const), }, }); - const stack = yield* makeHandle(stackId, { + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, endpoint, ownerSessionId, rpcRelease: STACK_RPC_RELEASE, + }; + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some(owner)), }); yield* stack.start(); yield* stack.start({ config: {} }); @@ -812,17 +807,13 @@ describe("Effect stack lifecycle handoff", () => { }, }); let launched = false; - const stack = yield* makeHandle( - stackId, - {}, - { - resolveOwner: (launch) => - Effect.sync(() => { - launched ||= launch; - return launched ? Option.some(owner) : Option.none(); - }), - }, - ); + const stack = yield* makeHandle(stackId, { + resolveOwner: (launch) => + Effect.sync(() => { + launched ||= launch; + return launched ? Option.some(owner) : Option.none(); + }), + }); expect((yield* stack.start()).lifecycle).toBe("running"); expect(launched).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), @@ -865,9 +856,16 @@ describe("Effect stack lifecycle handoff", () => { onShutdownReady: Deferred.succeed(responseSent, undefined).pipe(Effect.asVoid), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); const stack = yield* makeHandle(stackId, { - endpoint, - ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, + resolveOwner: () => + Effect.succeed( + Option.some({ + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + ), }); const destroyFiber = yield* Effect.forkChild(stack.destroy(), { startImmediately: true }); yield* Deferred.await(responseSent); @@ -885,9 +883,16 @@ describe("Effect stack lifecycle handoff", () => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-" }); const stack = yield* makeHandle(stackId, { - endpoint: { kind: "unix", path: path.join(root, "missing.sock") }, - ownerSessionId: "session", - rpcRelease: STACK_RPC_RELEASE, + resolveOwner: () => + Effect.succeed( + Option.some({ + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint: { kind: "unix", path: path.join(root, "missing.sock") }, + ownerSessionId: "session", + rpcRelease: STACK_RPC_RELEASE, + }), + ), }); const result = yield* stack.destroy().pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); diff --git a/packages/stack/src/runtime/ContainerEngineResolver.ts b/packages/stack/src/runtime/ContainerEngineResolver.ts index 9b5bb14508..92abf8d55d 100644 --- a/packages/stack/src/runtime/ContainerEngineResolver.ts +++ b/packages/stack/src/runtime/ContainerEngineResolver.ts @@ -1,10 +1,14 @@ import { Context, Effect } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import type { - ContainerEngine, - ContainerEngineFailure, - ContainerEngineKind, +import { + makeProcessCommandRunner, + type ContainerEngine, + type ContainerEngineFailure, + type ContainerEngineKind, + type ContainerPlatform, } from "./ContainerEngine.ts"; +import { makeDockerEngine } from "./DockerEngine.ts"; +import { makePodmanEngine } from "./PodmanEngine.ts"; /** * Host-composition seam for selecting one concrete container engine. The @@ -21,3 +25,26 @@ export class ContainerEngineResolver extends Context.Service< ContainerEngineResolver, ContainerEngineResolverShape >()("@supabase/stack/ContainerEngineResolver") {} + +const hostContainerPlatform = (): ContainerPlatform => { + if (process.platform === "darwin") return { os: "darwin", desktop: true }; + if (process.platform === "win32") return { os: "windows", desktop: true }; + return { os: "linux", desktop: false }; +}; + +const defaultResolver: ContainerEngineResolverShape = { + resolve: (kind) => + Effect.gen(function* () { + const runner = yield* makeProcessCommandRunner({ executable: kind }); + const platform = hostContainerPlatform(); + return kind === "docker" + ? makeDockerEngine({ runner, platform }) + : makePodmanEngine({ runner, platform }); + }), +}; + +export const resolveContainerEngine = ( + kind: ContainerEngineKind, + resolver?: ContainerEngineResolverShape, +): Effect.Effect => + (resolver ?? defaultResolver).resolve(kind); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 86b5b19074..3525513e47 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -41,7 +41,7 @@ import { makeSupervisorIngress, type SupervisorIngress } from "../supervisor/Ing import { checkHostPort } from "../supervisor/HostListener.ts"; import { makeLogStore, type LogStore, type LogRecord } from "../supervisor/LogStore.ts"; import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; -import type { SupervisorRuntime, SupervisorRuntimeFactory } from "../supervisor/Supervisor.ts"; +import type { SupervisorRuntime } from "../supervisor/Supervisor.ts"; import { makeProductionRuntimeArtifactPreparer, type PreparedWorkloadArtifact, @@ -67,6 +67,7 @@ import { makeContainerRuntime, type ContainerWorkloadResolution } from "./Contai import { DEFAULT_READINESS_DEADLINE, probeReadiness } from "./ReadinessProbe.ts"; import { parseGoDuration } from "../model/capabilities/database.ts"; import type { ContainerEngine, ContainerHostRoute } from "./ContainerEngine.ts"; +import { resolveContainerEngine } from "./ContainerEngineResolver.ts"; import { bootstrapDatabaseAt } from "./PostgresDatabaseSession.ts"; import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; import { validateMaterializedSecrets } from "../state/MaterializedSettings.ts"; @@ -78,6 +79,10 @@ import { type RuntimeContext = FileSystem.FileSystem | Path.Path | Crypto.Crypto; +interface SupervisorRuntimeFactory { + readonly make: (state: PersistedStackState) => Effect.Effect; +} + export interface ProductionRuntimeFactoryOptions { readonly stateRoot: string; readonly artifactCacheRoot?: string; @@ -543,23 +548,25 @@ export const makeProductionRuntimeFactory = ( Effect.mapError((error) => preparationError("Unable to open stack logs", error)), )); const logs = dynamicLogStore(baseLogs, knownSecrets); + const selectedEngine = + initial.runtime.kind === "container" ? initial.runtime.engine : undefined; + const containerEngine = + options.containerEngine ?? + (selectedEngine === undefined + ? undefined + : yield* resolveContainerEngine(selectedEngine).pipe( + Effect.mapError((error) => + preparationError(`Unable to configure ${selectedEngine} artifact engine`, error), + ), + )); const preparer = options.artifactPreparer ?? (yield* makeProductionRuntimeArtifactPreparer({ stateRoot: options.stateRoot, artifactCacheRoot: options.artifactCacheRoot, runtime: initial.runtime, - platform: { - os: - process.platform === "darwin" - ? "darwin" - : process.platform === "win32" - ? "windows" - : "linux", - desktop: process.platform === "darwin", - }, + ...(containerEngine === undefined ? {} : { containerEngine }), })); - const containerEngine = options.containerEngine ?? preparer.containerEngine; const serveTemplate = yield* Effect.cached(bootstrapContent); const bootstrapDatabase = options.bootstrapDatabase ?? ((state: PersistedStackState) => bootstrapDatabaseAt(state)); @@ -996,7 +1003,6 @@ export const makeProductionRuntimeFactory = ( ); return { driver: baseDriver, - prepare: prepareArtifacts, preflight, activate, ingress, diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index f8682f2deb..c895d93d71 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -42,7 +42,6 @@ import { } from "../public/Errors.ts"; import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; -import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { compileStack } from "../model/Compiler.ts"; import type { ContainerContainerSpec, @@ -82,25 +81,6 @@ const stateFor = ( secrets, }); -const workloadFor = ( - id: string, - capability: PlannedWorkload["capability"], - selected: PlannedWorkload["selected"], -): PlannedWorkload => ({ - id, - capability, - dependencies: [], - readiness: { mode: "tcp" }, - artifacts: { - native: { kind: "native", release: "17.6.1.167" }, - container: { - kind: "container", - image: "ghcr.io/supabase/cli/postgres:17.6.1.167", - }, - }, - selected, -}); - const stateStoreFor = ( current: { value: PersistedStackState }, onRead?: () => void, @@ -1005,65 +985,6 @@ describe("production runtime composition", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("exposes artifact-only preparation without starting workloads or mutating state", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-prepare-" }); - const current = { value: stateFor({}) }; - const preparedCalls: string[] = []; - const preparer: RuntimeArtifactPreparer = { - prepare: (_runtime, workload) => - Effect.sync(() => { - preparedCalls.push(workload.id); - return { - workloadId: workload.id, - capability: workload.capability, - version: "17.6.1.167", - outcome: workload.id.endsWith("download") - ? ("downloaded" as const) - : ("cached" as const), - artifactRoot: "/tmp/prepared", - }; - }), - }; - const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - artifactPreparer: preparer, - envFileOwner: envFiles, - functionsBootstrapOwner: bootstrap, - logStore: memoryLogStore([]), - bootstrapDatabase: () => Effect.void, - }); - const runtime = yield* factory.make(current.value); - if (runtime.prepare === undefined) return yield* Effect.die("prepare seam missing"); - const before = current.value; - const result = yield* runtime.prepare({ kind: "native" }, [ - workloadFor("database:database", "database", { - kind: "native", - release: "17.6.1.167", - }), - workloadFor("rest:download", "rest", { - kind: "native", - release: "17.6.1.167", - }), - ]); - expect(result.map(({ workloadId, outcome }) => [workloadId, outcome])).toEqual([ - ["database:database", "cached"], - ["rest:download", "downloaded"], - ]); - expect(preparedCalls).toEqual(["database:database", "rest:download"]); - expect(current.value).toEqual(before); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - it.live("does not create the Functions root during preflight", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index 5541c42822..de90b39b7b 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -9,6 +9,12 @@ import { import type { DesiredStackLifecycle } from "../public/Status.ts"; import type { PersistedSecretValues } from "./StackState.ts"; +export const AUTH_PUBLISHABLE_KEY_SLOT = "secret:auth.settings.publishable_key"; +export const AUTH_SECRET_KEY_SLOT = "secret:auth.settings.secret_key"; +export const AUTH_ANON_KEY_SLOT = "secret:auth.settings.anon_key"; +export const AUTH_SERVICE_ROLE_KEY_SLOT = "secret:auth.settings.service_role_key"; +export const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; + type SecretPolicy = "managed" | "passthrough"; export type SecretJwtSigning = @@ -58,7 +64,6 @@ const readSecret = (value: Redacted.Redacted): string => { }; const validSlot = (slot: string): boolean => /^[A-Za-z0-9_.:/-]+$/.test(slot); -const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; const JWT_ISSUER = "supabase-demo"; const JWT_HMAC_EXPIRY = 1_983_812_996; const JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 53d8a23b69..6c8d918b08 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -26,6 +26,12 @@ import type { StackStateStore } from "../state/StackStateStore.ts"; import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; import type { PortField } from "../public/Status.ts"; import { bindHostListener, isHttpPortField } from "./HostListener.ts"; +import { + AUTH_ANON_KEY_SLOT, + AUTH_PUBLISHABLE_KEY_SLOT, + AUTH_SECRET_KEY_SLOT, + AUTH_SERVICE_ROLE_KEY_SLOT, +} from "../state/SecretStore.ts"; interface SupervisorIngressReservation extends PortReservation { /** False when this accepted definition already owns the exact listeners and gateway. */ @@ -92,10 +98,10 @@ const defaultApiMaterial = ( state: LifecycleInput["state"], ): Effect.Effect => { const get = (slot: string): string | undefined => state.secrets[slot]?.value; - const publishableKey = get("secret:auth.settings.publishable_key"); - const secretKey = get("secret:auth.settings.secret_key"); - const anonJwt = get("secret:auth.settings.anon_key"); - const serviceRoleJwt = get("secret:auth.settings.service_role_key"); + const publishableKey = get(AUTH_PUBLISHABLE_KEY_SLOT); + const secretKey = get(AUTH_SECRET_KEY_SLOT); + const anonJwt = get(AUTH_ANON_KEY_SLOT); + const serviceRoleJwt = get(AUTH_SERVICE_ROLE_KEY_SLOT); if ( publishableKey === undefined || secretKey === undefined || diff --git a/packages/stack/src/supervisor/LaunchProtocol.ts b/packages/stack/src/supervisor/LaunchProtocol.ts new file mode 100644 index 0000000000..0160aff030 --- /dev/null +++ b/packages/stack/src/supervisor/LaunchProtocol.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect"; +import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; +import { StackIdSchema } from "../public/StackId.ts"; + +const IdentitySchema = Schema.Struct({ + projectRoot: Schema.String, + checkoutRoot: Schema.String, + workspaceId: Schema.String, + checkoutId: Schema.String, + branchContext: Schema.String, + localProjectKey: Schema.String, + stackName: Schema.String, +}); + +/** JSON payload shared by the detached launcher and Supervisor entrypoint. */ +export const SupervisorArgsSchema = Schema.Struct({ + stateRoot: Schema.String, + artifactCacheRoot: Schema.optional(Schema.String), + tempRoot: Schema.String, + platform: Schema.Literals(["posix", "windows"] as const), + stackId: StackIdSchema, + ownerSessionId: OwnerSessionIdSchema, + rpcRelease: Schema.String, + identity: IdentitySchema, +}); + +export type SupervisorArgs = Schema.Schema.Type; + +/** One-line readiness frame written by the Supervisor on fd3. */ +export const SupervisorReadySchema = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + stackId: StackIdSchema, + ownerSessionId: Schema.String, + }), + Schema.Struct({ + ok: Schema.Literal(false), + code: Schema.Literals(["ownership-conflict", "failed"] as const), + message: Schema.String, + }), +]); + +export type SupervisorReady = Schema.Schema.Type; diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 01fb50659a..8027762dfd 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -28,6 +28,11 @@ import type { StackStateStore } from "../state/StackStateStore.ts"; import { makeControlClient } from "../control/ControlServer.ts"; import { isMaintenanceTransportFailure } from "../control/MaintenanceProtocol.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; +import { + SupervisorReadySchema, + type SupervisorReady, + type SupervisorArgs, +} from "./LaunchProtocol.ts"; import { StackOwnershipConflictError, StackStateInvalidError, @@ -62,19 +67,6 @@ export const defaultRuntimeEnvironment = (): StackRuntimeEnvironmentValue => { }; }; -const ReadySchema = Schema.Union([ - Schema.Struct({ - ok: Schema.Literal(true), - stackId: StackIdSchema, - ownerSessionId: Schema.String, - }), - Schema.Struct({ - ok: Schema.Literal(false), - code: Schema.Literals(["ownership-conflict", "failed"] as const), - message: Schema.String, - }), -]); -type ReadyMessage = Schema.Schema.Type; type ReadinessResult = | { readonly kind: "ready"; readonly stackId: StackId; readonly ownerSessionId: string } | { readonly kind: "ownership-conflict" } @@ -85,16 +77,7 @@ type ChildResult = | { readonly kind: "ownership-conflict" } | { readonly kind: "failed"; readonly message: string }; -interface LaunchPayload { - readonly stateRoot: string; - readonly artifactCacheRoot?: string; - readonly tempRoot: string; - readonly platform: "posix" | "windows"; - readonly stackId: StackId; - readonly ownerSessionId: string; - readonly rpcRelease: string; - readonly identity: StackIdentity; -} +type LaunchPayload = SupervisorArgs; const mapFailure = (message: string) => new StackStateInvalidError({ message }); const SUPERVISOR_READINESS_TIMEOUT_MS = 30_000; @@ -123,7 +106,7 @@ const decodeReady = (bytes: Uint8Array): Effect.Effect => + const classify = (value: SupervisorReady): Effect.Effect => value.ok ? Effect.succeed({ kind: "ready", @@ -133,7 +116,7 @@ const decodeReady = (bytes: Uint8Array): Effect.Effect mapFailure("Invalid supervisor readiness frame")), Effect.flatMap(classify), ); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 28eccc10dd..bb79bba2f6 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -122,7 +122,6 @@ const materializeCandidate = ( state: PersistedStackState, runtime: StackRuntime, config: StackConfig | undefined, - secretLifecycle: PersistedStackState["desiredLifecycle"] = state.desiredLifecycle, ): Effect.Effect => Effect.gen(function* () { if (config === undefined && state.definition !== undefined) { @@ -130,7 +129,7 @@ const materializeCandidate = ( const resolved = yield* resolveSecrets( declarationsFromPersisted(state.secrets), state.desiredLifecycle === "unconfigured" ? undefined : state.secrets, - secretLifecycle, + state.desiredLifecycle, ); return { definition: state.definition, @@ -149,7 +148,7 @@ const materializeCandidate = ( const resolved = yield* resolveSecrets( declarationsFromCompiled(compiled), state.desiredLifecycle === "unconfigured" ? undefined : state.secrets, - secretLifecycle, + state.desiredLifecycle, ); return { definition: compiled.definition, diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index 33fd8a487c..bf4f407597 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -42,6 +42,35 @@ interface LogStoreQuery { readonly tail?: number; } +/** Cursor returned when a retained log file has no entries to advance to. */ +export const EMPTY_LOG_CURSOR: LogCursor = { opaque: "v1_0" }; + +/** Apply public log filtering and pagination to a retained read. */ +export const selectLogBatch = ( + scanned: ReadonlyArray, + query?: Pick, + cursor: LogCursor = EMPTY_LOG_CURSOR, +): { readonly entries: ReadonlyArray; readonly cursor: LogCursor } => { + const capabilities = query?.capabilities === undefined ? undefined : new Set(query.capabilities); + const filtered = scanned.filter((entry) => + capabilities === undefined + ? true + : entry.source !== "gateway" && + entry.source !== "supervisor" && + capabilities.has(entry.source), + ); + const entries = + query?.tail === undefined + ? filtered + : query.tail <= 0 + ? [] + : filtered.slice(-Math.floor(query.tail)); + return { + entries, + cursor: scanned.at(-1)?.cursor ?? cursor, + }; +}; + export interface LogStore { readonly path: string; readonly append: (record: LogRecord) => Effect.Effect; diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 5174d4f1f2..c96396dc43 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -14,17 +14,15 @@ import { Semaphore, Scope, } from "effect"; -import type { StackIdentity } from "../identity/Identity.ts"; import { rebuildExecutionPlan } from "../model/Compiler.ts"; import { activeExecutionPlan, + dependencyClosure, eagerCapabilities, type ExecutionPlan, - type PlannedWorkload, } from "../model/ExecutionPlan.ts"; import type { CapabilityName } from "../public/Capability.ts"; import type { StackConfig } from "../public/Config.ts"; -import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, StackLifecycleConflictError, @@ -47,17 +45,19 @@ import { type LifecycleBackend, type LifecycleInput, } from "./Lifecycle.ts"; -import type { LogStore } from "./LogStore.ts"; +import { EMPTY_LOG_CURSOR, selectLogBatch, type LogStore } from "./LogStore.ts"; import type { SupervisorIngress } from "./Ingress.ts"; import { StackRpcGroup, type StackRpcError, type StackRpcHandlers } from "../control/StackRpc.ts"; import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; -import type { PreparedWorkloadArtifact } from "../preparation/RuntimeArtifacts.ts"; import { statusFor, type ActualPhase } from "./StatusProjection.ts"; +import { + AUTH_ANON_KEY_SLOT, + AUTH_PUBLISHABLE_KEY_SLOT, + AUTH_SECRET_KEY_SLOT, + AUTH_SERVICE_ROLE_KEY_SLOT, +} from "../state/SecretStore.ts"; -interface ActivationResult { - readonly capability: CapabilityName; - readonly endpoint: { readonly host: string; readonly port: number }; -} +import type { ActivationResult } from "../gateway/Gateway.ts"; interface SupervisorLaunchAttempt { /** Rolls back only workloads and ingress acquired by this launch. */ @@ -67,11 +67,6 @@ interface SupervisorLaunchAttempt { /** Runtime construction is injected so catalog/artifact resolution can evolve independently. */ export interface SupervisorRuntime { readonly driver: RuntimeDriver; - /** Verifies and prepares immutable workload artifacts without changing lifecycle state. */ - readonly prepare: ( - runtime: StackRuntime, - workloads: ReadonlyArray, - ) => Effect.Effect, StackError>; readonly preflight: (input: LifecycleInput) => Effect.Effect; readonly activate: ( capability: CapabilityName, @@ -82,14 +77,7 @@ export interface SupervisorRuntime { readonly logStore: LogStore; } -export interface SupervisorRuntimeFactory { - readonly make: (state: PersistedStackState) => Effect.Effect; -} - export interface Supervisor { - readonly identity: StackIdentity; - readonly stackId: StackId; - readonly ownerSessionId: string; readonly status: Effect.Effect; readonly start: (options?: { readonly config?: StackConfig; @@ -110,24 +98,14 @@ export interface Supervisor { readonly rpcHandlers: StackRpcHandlers; } -type SupervisorRuntimeInput = - | { - readonly runtime: SupervisorRuntime; - readonly runtimeFactory?: never; - } - | { - readonly runtimeFactory: SupervisorRuntimeFactory; - readonly runtime?: never; - }; - export type SupervisorOptions = { - readonly identity: StackIdentity; readonly stackId: StackId; readonly ownerSessionId: string; readonly rpcRelease: string; readonly stateStore: StackStateStore; readonly context: Context.Context; -} & SupervisorRuntimeInput; + readonly runtime: SupervisorRuntime; +}; const rpcError = (tag: StackRpcError["tag"], message: string): StackRpcError => ({ tag, message }); const stateErrorMessage = (error: StackError | { readonly message?: string }): string => @@ -164,10 +142,7 @@ export const makeSupervisor = ( const initial = yield* read(); if (initial === undefined) return yield* new StackStateInvalidError({ message: "Stack state is missing" }); - const runtime = - options.runtimeFactory !== undefined - ? yield* options.runtimeFactory.make(initial) - : options.runtime; + const runtime = options.runtime; const launcher: SessionLauncher = yield* makeSessionLauncher({ stackId: options.stackId, driver: runtime.driver, @@ -451,13 +426,7 @@ export const makeSupervisor = ( ), ); const previousActive = yield* Ref.get(active); - const next = new Set(previousActive); - const visit = (name: CapabilityName): void => { - if (next.has(name)) return; - next.add(name); - for (const dependency of plan.dependencies[name]) visit(dependency); - }; - visit(capability); + const next = new Set([...previousActive, ...dependencyClosure(plan, [capability])]); const input: LifecycleInput = { stackId: options.stackId, state, @@ -692,25 +661,10 @@ export const makeSupervisor = ( (error) => new StackStateInvalidError({ message: error.message, cause: error }), ), ); - const capabilities = - query?.capabilities === undefined ? undefined : new Set(query.capabilities); - const filtered = scanned.filter((entry) => - capabilities === undefined - ? true - : entry.source !== "gateway" && - entry.source !== "supervisor" && - capabilities.has(entry.source), - ); - const entries = - query?.tail === undefined - ? filtered - : query.tail <= 0 - ? [] - : filtered.slice(-Math.floor(query.tail)); + const selected = selectLogBatch(scanned, query, query?.cursor ?? EMPTY_LOG_CURSOR); const running = phaseAtRead !== "stopped"; return { - entries, - cursor: scanned.at(-1)?.cursor ?? query?.cursor ?? { opaque: "v1_0" }, + ...selected, running, } satisfies StackLogBatch; }); @@ -782,10 +736,10 @@ export const makeSupervisor = ( databasePassword, )}@${databaseHost}:${databaseAssignment.port}/postgres`; - const publishableKey = yield* requiredSecret("secret:auth.settings.publishable_key"); - const secretKey = yield* requiredSecret("secret:auth.settings.secret_key"); - const anonJwt = yield* requiredSecret("secret:auth.settings.anon_key"); - const serviceRoleJwt = yield* requiredSecret("secret:auth.settings.service_role_key"); + const publishableKey = yield* requiredSecret(AUTH_PUBLISHABLE_KEY_SLOT); + const secretKey = yield* requiredSecret(AUTH_SECRET_KEY_SLOT); + const anonJwt = yield* requiredSecret(AUTH_ANON_KEY_SLOT); + const serviceRoleJwt = yield* requiredSecret(AUTH_SERVICE_ROLE_KEY_SLOT); const base: EffectStackCredentials = { database: { @@ -844,9 +798,6 @@ export const makeSupervisor = ( logs: (query: LogQuery) => operation(logs(query)), }); return { - identity: options.identity, - stackId: options.stackId, - ownerSessionId: options.ownerSessionId, status, start, destroy, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 962d2dfc21..082da20a2a 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -79,9 +79,6 @@ const makeFixture = ( readonly ingress?: SupervisorIngress; readonly timeline?: Ref.Ref>; readonly runtime?: StackRuntime; - readonly prepareOutcome?: "cached" | "downloaded" | "pulled"; - readonly prepareGate?: Deferred.Deferred; - readonly prepareStarted?: Deferred.Deferred; readonly startGate?: Deferred.Deferred; readonly startStarted?: Deferred.Deferred; readonly startFinished?: Deferred.Deferred; @@ -290,23 +287,6 @@ const makeFixture = ( }; const runtime: SupervisorRuntime = { driver, - prepare: (_runtime, workloads) => - Effect.gen(function* () { - if (fixtureOptions.prepareStarted !== undefined) - yield* Deferred.succeed(fixtureOptions.prepareStarted, undefined); - if (fixtureOptions.prepareGate !== undefined) - yield* Deferred.await(fixtureOptions.prepareGate); - return yield* Effect.forEach(workloads, (workload) => - Ref.update(calls, (current) => [...current, `prepare:${workload.id}`]).pipe( - Effect.as({ - workloadId: workload.id, - capability: workload.capability, - version: "test", - outcome: fixtureOptions.prepareOutcome ?? "cached", - }), - ), - ); - }), preflight: (_input) => Effect.gen(function* () { if (fixtureOptions.preflightCalls !== undefined) @@ -365,7 +345,6 @@ const makeFixture = ( FileSystem.FileSystem | Path.Path | import("effect").Crypto.Crypto >(); const supervisor = yield* makeSupervisor({ - identity, stackId: id, ownerSessionId: "owner-session", rpcRelease: "test-release", @@ -567,7 +546,6 @@ describe("Supervisor composition", () => { ]); const successor = yield* makeSupervisor({ - identity, stackId: fixture.id, ownerSessionId: "successor-session", rpcRelease: "test-release", @@ -600,7 +578,6 @@ describe("Supervisor composition", () => { expect(yield* Ref.get(preflightCalls)).toBe(1); const successor = yield* makeSupervisor({ - identity, stackId: fixture.id, ownerSessionId: "successor-session", rpcRelease: "test-release", @@ -1637,7 +1614,6 @@ describe("Supervisor composition", () => { yield* fixture.supervisor.shutdownIfIdle; yield* fixture.supervisor.shutdown; const successor = yield* makeSupervisor({ - identity, stackId: fixture.id, ownerSessionId: "successor-session", rpcRelease: "test-release", From ad7ff8f70c97f9a1ef96b45049924dac6a9966f8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 10:14:30 +0200 Subject: [PATCH 128/174] refactor(stack): simplify runtime and log construction --- .../stack/src/entrypoints/supervisor-node.ts | 13 +- packages/stack/src/public/EffectStack.ts | 8 +- .../stack/src/runtime/ProductionRuntime.ts | 841 +++++++++--------- .../production-runtime.integration.test.ts | 39 +- packages/stack/src/supervisor/Launcher.ts | 8 +- packages/stack/src/supervisor/LogStore.ts | 20 +- packages/stack/src/supervisor/Supervisor.ts | 5 +- .../observability.integration.test.ts | 5 +- 8 files changed, 436 insertions(+), 503 deletions(-) diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index 64f71d3098..7dda48e7d4 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -14,7 +14,7 @@ import { makeSupervisor } from "../supervisor/Supervisor.ts"; import { makeProductionRuntimeFactory } from "../runtime/ProductionRuntime.ts"; import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; -import { StackOwnershipConflictError, StackStateInvalidError } from "../public/Errors.ts"; +import { StackOwnershipConflictError } from "../public/Errors.ts"; import { SupervisorArgsSchema, SupervisorReadySchema, @@ -91,7 +91,7 @@ const runSupervisor = (args: SupervisorArgs) => environment, }); const context = yield* Effect.context(); - const runtimeFactory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: args.stateRoot, ...(args.artifactCacheRoot === undefined ? {} @@ -101,15 +101,6 @@ const runSupervisor = (args: SupervisorArgs) => stateStore: store, context, }); - const initial = yield* store.read(args.stackId).pipe( - Effect.provideContext(context), - Effect.flatMap((state) => - state === undefined - ? Effect.fail(new StackStateInvalidError({ message: "Stack state is missing" })) - : Effect.succeed(state), - ), - ); - const runtime = yield* runtimeFactory.make(initial); const supervisor = yield* makeSupervisor({ stackId: args.stackId, ownerSessionId, diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 79a7e33180..729844c640 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -583,7 +583,9 @@ export const makeHandle = ( const options = { ...baseQuery, ...(first && query?.tail !== undefined ? { tail: query.tail } : {}), - ...(cursor === undefined || cursor.opaque === "v1_0" ? {} : { cursor }), + ...(cursor === undefined || cursor.opaque === EMPTY_LOG_CURSOR.opaque + ? {} + : { cursor }), }; const request = logs(options); const delayed = first @@ -825,12 +827,12 @@ const handleDependencies = (options: { readRetainedLogs( options.fileSystem, paths.logs, - query?.cursor === undefined || query.cursor.opaque === "v1_0" + query?.cursor === undefined || query.cursor.opaque === EMPTY_LOG_CURSOR.opaque ? undefined : { cursor: query.cursor }, ).pipe( Effect.map((scanned) => { - const selected = selectLogBatch(scanned, query, query?.cursor ?? EMPTY_LOG_CURSOR); + const selected = selectLogBatch(scanned, query); return { ...selected, running: false, diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 3525513e47..80802e8644 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -79,10 +79,6 @@ import { type RuntimeContext = FileSystem.FileSystem | Path.Path | Crypto.Crypto; -interface SupervisorRuntimeFactory { - readonly make: (state: PersistedStackState) => Effect.Effect; -} - export interface ProductionRuntimeFactoryOptions { readonly stateRoot: string; readonly artifactCacheRoot?: string; @@ -467,7 +463,7 @@ export const withOwnedRuntimeFileCleanup = ( export const makeProductionRuntimeFactory = ( options: ProductionRuntimeFactoryOptions, ): Effect.Effect< - SupervisorRuntimeFactory, + SupervisorRuntime, StackError, | Scope.Scope | FileSystem.FileSystem @@ -476,7 +472,7 @@ export const makeProductionRuntimeFactory = ( | ChildProcessSpawner.ChildProcessSpawner > => Effect.gen(function* () { - const initial = yield* currentStateReader(options); + const state = yield* currentStateReader(options); const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -541,15 +537,14 @@ export const makeProductionRuntimeFactory = ( stateRoot: options.stateRoot, stackId: options.stackId, })); - const knownSecrets = yield* Ref.make>(new Set(stateSecrets(initial))); + const knownSecrets = yield* Ref.make>(new Set(stateSecrets(state))); const baseLogs = options.logStore ?? - (yield* makeLogStore({ path: paths.logs, knownSecrets: stateSecrets(initial) }).pipe( + (yield* makeLogStore({ path: paths.logs, knownSecrets: stateSecrets(state) }).pipe( Effect.mapError((error) => preparationError("Unable to open stack logs", error)), )); const logs = dynamicLogStore(baseLogs, knownSecrets); - const selectedEngine = - initial.runtime.kind === "container" ? initial.runtime.engine : undefined; + const selectedEngine = state.runtime.kind === "container" ? state.runtime.engine : undefined; const containerEngine = options.containerEngine ?? (selectedEngine === undefined @@ -564,450 +559,418 @@ export const makeProductionRuntimeFactory = ( (yield* makeProductionRuntimeArtifactPreparer({ stateRoot: options.stateRoot, artifactCacheRoot: options.artifactCacheRoot, - runtime: initial.runtime, + runtime: state.runtime, ...(containerEngine === undefined ? {} : { containerEngine }), })); const serveTemplate = yield* Effect.cached(bootstrapContent); const bootstrapDatabase = options.bootstrapDatabase ?? ((state: PersistedStackState) => bootstrapDatabaseAt(state)); - return { - make: (state: PersistedStackState): Effect.Effect => - Effect.gen(function* () { - const artifacts = new Map(); - const hostRoute = yield* Ref.make(undefined); + const artifacts = new Map(); + const hostRoute = yield* Ref.make(undefined); - const freshState = (key: Pick) => - currentStateReader(options).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - Effect.flatMap((fresh) => - runtimeMatches(fresh.runtime, state.runtime) - ? Effect.succeed(fresh) - : Effect.fail( - driverError(key, "Persisted runtime changed while owner was active"), - ), - ), - ); - const prepare = (runtime: StackRuntime, workload: PlannedWorkload) => { - const key = artifactKey(runtime, workload); - const cached = artifacts.get(key); - return cached === undefined - ? preparer.prepare(runtime, workload).pipe( - Effect.mapError((error) => - error instanceof ArtifactIntegrityError || error instanceof ContainerPullError - ? error - : preparationError(`Unable to prepare ${workload.id}`, error), - ), - Effect.tap((prepared) => Effect.sync(() => artifacts.set(key, prepared))), + const freshState = (key: Pick) => + currentStateReader(options).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + Effect.flatMap((fresh) => + runtimeMatches(fresh.runtime, state.runtime) + ? Effect.succeed(fresh) + : Effect.fail(driverError(key, "Persisted runtime changed while owner was active")), + ), + ); + const prepare = (runtime: StackRuntime, workload: PlannedWorkload) => { + const key = artifactKey(runtime, workload); + const cached = artifacts.get(key); + return cached === undefined + ? preparer.prepare(runtime, workload).pipe( + Effect.mapError((error) => + error instanceof ArtifactIntegrityError || error instanceof ContainerPullError + ? error + : preparationError(`Unable to prepare ${workload.id}`, error), + ), + Effect.tap((prepared) => Effect.sync(() => artifacts.set(key, prepared))), + ) + : Effect.succeed(cached); + }; + const prepareArtifacts = (runtime: StackRuntime, workloads: ReadonlyArray) => + Effect.forEach(workloads, (workload) => prepare(runtime, workload), { + concurrency: 4, + }); + const functionsPath = (): Effect.Effect => + serveTemplate.pipe(Effect.flatMap((content) => functionsBootstrap.write({ content }))); + const runtimeInputs = ( + workload: PlannedWorkload, + fresh: PersistedStackState, + host: ContainerHostRoute | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const material = yield* inputOwner.resolve(fresh, { + includePooler: workload.id === "pooler:pooler", + }); + const templates = material.auth?.templates; + const apiListener = fresh.definition?.listeners.api; + const apiAssignment = fresh.ports.find((assignment) => assignment.field === "api"); + const templateBaseUrl = + workload.id !== "auth:auth" || templates === undefined || templates.length === 0 + ? undefined + : apiListener?.enabled !== true || apiAssignment === undefined + ? yield* preparationError( + "Configured Auth email templates require a public API listener", ) - : Effect.succeed(cached); - }; - const prepareArtifacts = ( - runtime: StackRuntime, - workloads: ReadonlyArray, - ) => - Effect.forEach(workloads, (workload) => prepare(runtime, workload), { - concurrency: 4, - }); - const functionsPath = (): Effect.Effect => - serveTemplate.pipe(Effect.flatMap((content) => functionsBootstrap.write({ content }))); - const runtimeInputs = ( - workload: PlannedWorkload, - fresh: PersistedStackState, - host: ContainerHostRoute | undefined, - ): Effect.Effect => - Effect.gen(function* () { - const material = yield* inputOwner.resolve(fresh, { - includePooler: workload.id === "pooler:pooler", - }); - const templates = material.auth?.templates; - const apiListener = fresh.definition?.listeners.api; - const apiAssignment = fresh.ports.find((assignment) => assignment.field === "api"); - const templateBaseUrl = - workload.id !== "auth:auth" || templates === undefined || templates.length === 0 - ? undefined - : apiListener?.enabled !== true || apiAssignment === undefined - ? yield* preparationError( - "Configured Auth email templates require a public API listener", - ) - : `http://${urlHost(host?.host ?? apiListener.address)}:${apiAssignment.port}`; - const auth = - material.auth === undefined - ? undefined - : { - ...material.auth, - ...(templateBaseUrl === undefined ? {} : { templateBaseUrl }), - }; - const functions = - workload.id === "functions:edge-runtime" - ? { - bootstrapPath: yield* functionsPath(), - bootstrapContainerPath: FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, - ...(material.functions?.secrets === undefined - ? {} - : { secrets: material.functions.secrets }), - } - : undefined; - return { - ...(auth === undefined ? {} : { auth }), - ...(workload.id.startsWith("analytics:") && material.analytics !== undefined - ? { analytics: material.analytics } - : {}), - ...(workload.id === "pooler:pooler" && material.pooler !== undefined - ? { pooler: material.pooler } - : {}), - database: { dataPath: pathService.join(paths.data, "database") }, - storage: { dataPath: pathService.join(paths.data, "storage") }, - ...(functions === undefined ? {} : { functions }), - ...(host === undefined ? {} : { hostRoute: host }), + : `http://${urlHost(host?.host ?? apiListener.address)}:${apiAssignment.port}`; + const auth = + material.auth === undefined + ? undefined + : { + ...material.auth, + ...(templateBaseUrl === undefined ? {} : { templateBaseUrl }), }; - }); + const functions = + workload.id === "functions:edge-runtime" + ? { + bootstrapPath: yield* functionsPath(), + bootstrapContainerPath: FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, + ...(material.functions?.secrets === undefined + ? {} + : { secrets: material.functions.secrets }), + } + : undefined; + return { + ...(auth === undefined ? {} : { auth }), + ...(workload.id.startsWith("analytics:") && material.analytics !== undefined + ? { analytics: material.analytics } + : {}), + ...(workload.id === "pooler:pooler" && material.pooler !== undefined + ? { pooler: material.pooler } + : {}), + database: { dataPath: pathService.join(paths.data, "database") }, + storage: { dataPath: pathService.join(paths.data, "storage") }, + ...(functions === undefined ? {} : { functions }), + ...(host === undefined ? {} : { hostRoute: host }), + }; + }); - const preflight = (input: LifecycleInput): Effect.Effect => - Effect.gen(function* () { - if (!runtimeMatches(input.state.runtime, state.runtime)) - return yield* new StackRuntimeMismatchError({ - message: "Lifecycle runtime does not match persisted runtime", - }); - // Remember candidate secrets before any runtime preflight work can emit logs. - yield* rememberSecrets(knownSecrets, input.state.secrets); - yield* rememberSecrets(knownSecrets, input.secrets); - // Eagerly validate the candidate before any engine/artifact work. Start-time checks - // below revalidate the fresh persisted definition because it is runtime authority. - yield* validateDatabaseReadinessBudget(input.definition, input.plan.workloads); - yield* Effect.forEach(input.plan.workloads, (workload) => - validateMaterializedSecrets(input.state, workload.capability), - ); - yield* Effect.forEach(input.plan.workloads, (workload) => - Effect.gen(function* () { - if (runtimeSpecFor(workload) === undefined) - return yield* preparationError( - `Unknown runtime specification for ${workload.id}`, - ); - if (workload.selected.kind !== input.state.runtime.kind) - return yield* preparationError(`Runtime artifact mismatch for ${workload.id}`); - }), - ); - if (input.state.runtime.kind === "container") { - if ( - containerEngine === undefined || - containerEngine.kind !== input.state.runtime.engine - ) - return yield* preparationError("Selected container engine is unavailable"); - const route = yield* containerEngine.preflight.pipe( - Effect.mapError((error) => - preparationError("Container host route preflight failed", error), + const preflight = (input: LifecycleInput): Effect.Effect => + Effect.gen(function* () { + if (!runtimeMatches(input.state.runtime, state.runtime)) + return yield* new StackRuntimeMismatchError({ + message: "Lifecycle runtime does not match persisted runtime", + }); + // Remember candidate secrets before any runtime preflight work can emit logs. + yield* rememberSecrets(knownSecrets, input.state.secrets); + yield* rememberSecrets(knownSecrets, input.secrets); + // Eagerly validate the candidate before any engine/artifact work. Start-time checks + // below revalidate the fresh persisted definition because it is runtime authority. + yield* validateDatabaseReadinessBudget(input.definition, input.plan.workloads); + yield* Effect.forEach(input.plan.workloads, (workload) => + validateMaterializedSecrets(input.state, workload.capability), + ); + yield* Effect.forEach(input.plan.workloads, (workload) => + Effect.gen(function* () { + if (runtimeSpecFor(workload) === undefined) + return yield* preparationError(`Unknown runtime specification for ${workload.id}`); + if (workload.selected.kind !== input.state.runtime.kind) + return yield* preparationError(`Runtime artifact mismatch for ${workload.id}`); + }), + ); + if (input.state.runtime.kind === "container") { + if (containerEngine === undefined || containerEngine.kind !== input.state.runtime.engine) + return yield* preparationError("Selected container engine is unavailable"); + const route = yield* containerEngine.preflight.pipe( + Effect.mapError((error) => + preparationError("Container host route preflight failed", error), + ), + ); + yield* Ref.set(hostRoute, route); + } + if (input.state.runtime.kind === "native") { + for (const assignment of input.state.ports) { + const listener = input.definition.listeners[assignment.field]; + yield* checkHostPort(listener.address, assignment.port, assignment.field).pipe( + Effect.mapError( + (error) => + new PortUnavailableError({ + field: assignment.field, + port: assignment.port, + message: `Persisted ${assignment.field} port is unavailable`, + cause: error, + }), + ), + ); + } + for (const assignment of input.state.privatePorts) + yield* checkHostPort( + "127.0.0.1", + assignment.port, + `${assignment.workloadId}:${assignment.binding}`, + ); + yield* checkNativeDatabaseLockEvidence( + fileSystem, + pathService.join(paths.data, "database", "postmaster.pid"), + ); + } + const eager = eagerCapabilities(input.plan); + yield* prepareArtifacts( + input.state.runtime, + input.plan.workloads.filter((workload) => eager.has(workload.capability)), + ); + }); + + const activate = ( + capability: import("../public/Capability.ts").CapabilityName, + input: LifecycleInput, + ): Effect.Effect< + { readonly host: string; readonly port: number }, + GatewayActivationError | StackError + > => + Effect.gen(function* () { + const workload = input.plan.workloads.find( + (entry) => entry.capability === capability && entry.readiness.portField !== undefined, + ); + if (workload === undefined) + return yield* new GatewayActivationError({ + message: `Capability ${capability} has no workload`, + }); + const fresh = yield* freshState({ + stackId: options.stackId, + workloadId: workload.id, + }).pipe(Effect.mapError((error) => new GatewayActivationError({ message: error.message }))); + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return yield* new GatewayActivationError({ + message: `Unknown runtime specification for ${workload.id}`, + }); + yield* validatePrivateAssignments(fresh, workload).pipe( + Effect.mapError((error) => new GatewayActivationError({ message: error.message })), + ); + const endpoint = spec.privateEndpoint(fresh, spec.readiness.binding, "native"); + if (endpoint === undefined) + return yield* new GatewayActivationError({ + message: `Missing private endpoint for ${workload.id}`, + }); + return endpoint; + }); + + const waitForReadiness = (key: RuntimeWorkloadKey, workload: PlannedWorkload) => + freshState(key).pipe( + Effect.flatMap((fresh) => readinessFor(fresh, workload)), + Effect.mapError((error) => mapDriverError(key, error)), + ); + const bootstrapWorkloadDatabase = (key: RuntimeWorkloadKey, workload: PlannedWorkload) => + workload.bootstrap === "database" + ? freshState(key).pipe( + Effect.flatMap((fresh) => + readinessDeadlineFor(fresh, workload).pipe( + Effect.flatMap((deadline) => + Effect.timeout( + Effect.retry( + Effect.suspend(() => bootstrapDatabase(fresh)), + { + schedule: Schedule.spaced("100 millis"), + while: (error) => + error instanceof DatabaseBootstrapError && error.retryable === true, + }, + ), + deadline, ), + ), + ), + ), + Effect.mapError((error) => mapDriverError(key, error)), + ) + : Effect.void; + + let driver: RuntimeDriver; + if (state.runtime.kind === "native") { + driver = yield* makeNativeRuntime({ + resolveProcess: (key, workload) => + freshState(key).pipe( + Effect.flatMap((fresh) => + Effect.gen(function* () { + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return yield* driverError( + key, + `Unknown runtime specification for ${workload.id}`, + ); + const readinessBudget = yield* readinessDeadlineFor(fresh, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), ); - yield* Ref.set(hostRoute, route); - } - if (input.state.runtime.kind === "native") { - for (const assignment of input.state.ports) { - const listener = input.definition.listeners[assignment.field]; - yield* checkHostPort(listener.address, assignment.port, assignment.field).pipe( - Effect.mapError( - (error) => - new PortUnavailableError({ - field: assignment.field, - port: assignment.port, - message: `Persisted ${assignment.field} port is unavailable`, - cause: error, - }), - ), + // Revalidate the fresh persisted definition before spawning; preflight checks + // the candidate definition, while this state is the runtime authority. + const inputs = yield* runtimeInputs(workload, fresh, undefined).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + yield* validatePrivateAssignments(fresh, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + const prepared = yield* prepare(fresh.runtime, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + if (prepared.artifactRoot === undefined) + return yield* driverError( + key, + `Native artifact root is unavailable for ${workload.id}`, ); + const endpoint = spec.privateEndpoint(fresh, spec.readiness.binding, "native"); + if (endpoint === undefined) + return yield* driverError( + key, + `Missing private port assignment for ${workload.id}`, + ); + const resolvedNativeProcess = spec.nativeProcess( + prepared.artifactRoot, + fresh, + workload, + endpoint.port, + inputs, + ); + const environment = spec.env(fresh, workload, endpoint.port, "native", inputs); + let migration: NativeProcessSpec | undefined; + if (isDatabaseWorkload(workload)) { + const dataPath = inputs.database?.dataPath; + if (dataPath === undefined) + return yield* driverError( + key, + "Resolved database data path is unavailable for native migration", + ); + migration = yield* prepareNativeDatabaseMigration({ + key, + dataPath, + artifactRoot: prepared.artifactRoot, + environment, + endpointPort: endpoint.port, + fileSystem, + pathService, + }); } - for (const assignment of input.state.privatePorts) - yield* checkHostPort( - "127.0.0.1", - assignment.port, - `${assignment.workloadId}:${assignment.binding}`, + return { + startup: spec + .nativeStartupProcesses( + prepared.artifactRoot, + fresh, + workload, + endpoint.port, + inputs, + ) + .map((startup: NativeProcessSpec) => ({ + ...startup, + timeout: + startup.timeout ?? + (isDatabaseWorkload(workload) ? readinessBudget : Duration.minutes(5)), + env: { ...environment, ...startup.env }, + })), + ...(migration === undefined ? {} : { postReadiness: [migration] }), + main: { ...resolvedNativeProcess, env: environment }, + }; + }), + ), + ), + waitForReadiness, + bootstrapDatabase: bootstrapWorkloadDatabase, + logStore: logs, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), + Effect.provideService(Scope.Scope, ownerScope), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.mapError((error) => preparationError("Unable to initialize native runtime", error)), + ); + } else { + if (containerEngine === undefined || containerEngine.kind !== state.runtime.engine) + return yield* preparationError("Selected container engine is unavailable"); + driver = yield* makeContainerRuntime({ + engine: containerEngine, + ownerSessionId: options.ownerSessionId, + resolveWorkload: (key, workload) => + freshState(key).pipe( + Effect.flatMap((fresh) => + Effect.gen(function* () { + const spec = runtimeSpecFor(workload); + if (spec === undefined) + return yield* driverError( + key, + `Unknown runtime specification for ${workload.id}`, ); - yield* checkNativeDatabaseLockEvidence( - fileSystem, - pathService.join(paths.data, "database", "postmaster.pid"), + // Revalidate the fresh persisted definition before creating a container; + // preflight checks the candidate definition, while this state is authoritative. + yield* readinessDeadlineFor(fresh, workload).pipe( + Effect.mapError((error) => mapDriverError(key, error)), ); - } - const eager = eagerCapabilities(input.plan); - yield* prepareArtifacts( - input.state.runtime, - input.plan.workloads.filter((workload) => eager.has(workload.capability)), - ); - }); - - const activate = ( - capability: import("../public/Capability.ts").CapabilityName, - input: LifecycleInput, - ): Effect.Effect< - { readonly host: string; readonly port: number }, - GatewayActivationError | StackError - > => - Effect.gen(function* () { - const workload = input.plan.workloads.find( - (entry) => - entry.capability === capability && entry.readiness.portField !== undefined, - ); - if (workload === undefined) - return yield* new GatewayActivationError({ - message: `Capability ${capability} has no workload`, - }); - const fresh = yield* freshState({ - stackId: options.stackId, - workloadId: workload.id, - }).pipe( - Effect.mapError((error) => new GatewayActivationError({ message: error.message })), - ); - const spec = runtimeSpecFor(workload); - if (spec === undefined) - return yield* new GatewayActivationError({ - message: `Unknown runtime specification for ${workload.id}`, - }); - yield* validatePrivateAssignments(fresh, workload).pipe( - Effect.mapError((error) => new GatewayActivationError({ message: error.message })), - ); - const endpoint = spec.privateEndpoint(fresh, spec.readiness.binding, "native"); - if (endpoint === undefined) - return yield* new GatewayActivationError({ - message: `Missing private endpoint for ${workload.id}`, - }); - return endpoint; - }); - - const waitForReadiness = (key: RuntimeWorkloadKey, workload: PlannedWorkload) => - freshState(key).pipe( - Effect.flatMap((fresh) => readinessFor(fresh, workload)), - Effect.mapError((error) => mapDriverError(key, error)), - ); - const bootstrapWorkloadDatabase = (key: RuntimeWorkloadKey, workload: PlannedWorkload) => - workload.bootstrap === "database" - ? freshState(key).pipe( - Effect.flatMap((fresh) => - readinessDeadlineFor(fresh, workload).pipe( - Effect.flatMap((deadline) => - Effect.timeout( - Effect.retry( - Effect.suspend(() => bootstrapDatabase(fresh)), - { - schedule: Schedule.spaced("100 millis"), - while: (error) => - error instanceof DatabaseBootstrapError && error.retryable === true, - }, - ), - deadline, - ), - ), - ), - ), + let route = yield* Ref.get(hostRoute); + if (route === undefined) { + route = yield* containerEngine.preflight.pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + yield* Ref.set(hostRoute, route); + } + const inputs = yield* runtimeInputs(workload, fresh, route).pipe( Effect.mapError((error) => mapDriverError(key, error)), - ) - : Effect.void; - - let driver: RuntimeDriver; - if (state.runtime.kind === "native") { - driver = yield* makeNativeRuntime({ - resolveProcess: (key, workload) => - freshState(key).pipe( - Effect.flatMap((fresh) => - Effect.gen(function* () { - const spec = runtimeSpecFor(workload); - if (spec === undefined) - return yield* driverError( - key, - `Unknown runtime specification for ${workload.id}`, - ); - const readinessBudget = yield* readinessDeadlineFor(fresh, workload).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - // Revalidate the fresh persisted definition before spawning; preflight checks - // the candidate definition, while this state is the runtime authority. - const inputs = yield* runtimeInputs(workload, fresh, undefined).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - yield* validatePrivateAssignments(fresh, workload).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - const prepared = yield* prepare(fresh.runtime, workload).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - if (prepared.artifactRoot === undefined) - return yield* driverError( - key, - `Native artifact root is unavailable for ${workload.id}`, - ); - const endpoint = spec.privateEndpoint( - fresh, - spec.readiness.binding, - "native", - ); - if (endpoint === undefined) - return yield* driverError( - key, - `Missing private port assignment for ${workload.id}`, - ); - const resolvedNativeProcess = spec.nativeProcess( - prepared.artifactRoot, - fresh, - workload, - endpoint.port, - inputs, - ); - const environment = spec.env( - fresh, - workload, - endpoint.port, - "native", - inputs, - ); - let migration: NativeProcessSpec | undefined; - if (isDatabaseWorkload(workload)) { - const dataPath = inputs.database?.dataPath; - if (dataPath === undefined) - return yield* driverError( - key, - "Resolved database data path is unavailable for native migration", - ); - migration = yield* prepareNativeDatabaseMigration({ - key, - dataPath, - artifactRoot: prepared.artifactRoot, - environment, - endpointPort: endpoint.port, - fileSystem, - pathService, - }); - } - return { - startup: spec - .nativeStartupProcesses( - prepared.artifactRoot, - fresh, - workload, - endpoint.port, - inputs, - ) - .map((startup: NativeProcessSpec) => ({ - ...startup, - timeout: - startup.timeout ?? - (isDatabaseWorkload(workload) - ? readinessBudget - : Duration.minutes(5)), - env: { ...environment, ...startup.env }, - })), - ...(migration === undefined ? {} : { postReadiness: [migration] }), - main: { ...resolvedNativeProcess, env: environment }, - }; - }), - ), - ), - waitForReadiness, - bootstrapDatabase: bootstrapWorkloadDatabase, - logStore: logs, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), - Effect.provideService(Scope.Scope, ownerScope), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - Effect.mapError((error) => - preparationError("Unable to initialize native runtime", error), - ), - ); - } else { - if (containerEngine === undefined || containerEngine.kind !== state.runtime.engine) - return yield* preparationError("Selected container engine is unavailable"); - driver = yield* makeContainerRuntime({ - engine: containerEngine, - ownerSessionId: options.ownerSessionId, - resolveWorkload: (key, workload) => - freshState(key).pipe( - Effect.flatMap((fresh) => - Effect.gen(function* () { - const spec = runtimeSpecFor(workload); - if (spec === undefined) - return yield* driverError( - key, - `Unknown runtime specification for ${workload.id}`, - ); - // Revalidate the fresh persisted definition before creating a container; - // preflight checks the candidate definition, while this state is authoritative. - yield* readinessDeadlineFor(fresh, workload).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - let route = yield* Ref.get(hostRoute); - if (route === undefined) { - route = yield* containerEngine.preflight.pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - yield* Ref.set(hostRoute, route); - } - const inputs = yield* runtimeInputs(workload, fresh, route).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); - const resolution = yield* resolveContainerResolutionFor( - fresh, - workload, - inputs, - ).pipe(Effect.mapError((error) => mapDriverError(key, error))); - if (resolution === undefined) - return yield* driverError( - key, - `Unknown container runtime specification for ${workload.id}`, - ); - const envFile = yield* envFiles - .write({ - workloadId: workload.id, - values: resolution.env, - }) - .pipe(Effect.mapError((error) => mapDriverError(key, error))); - const volume = - workload.id === "database:database" - ? { target: "/var/lib/postgresql/data", readOnly: false } - : workload.id === "storage:storage" - ? { - target: "/mnt", - readOnly: false, - ownerWorkloadId: "storage:storage", - } - : workload.id === "storage:imgproxy" - ? { - target: "/mnt", - readOnly: true, - ownerWorkloadId: "storage:storage", - } - : undefined; - const { env: _env, ...withoutEnv } = resolution; - return { - ...withoutEnv, - envFile, - waitForReadiness, - ...(volume === undefined ? {} : { volume }), - } satisfies ContainerWorkloadResolution; - }), - ), - ), - waitForReadiness, - bootstrapDatabase: bootstrapWorkloadDatabase, - logStore: logs, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - } - const baseDriver = withOwnedRuntimeFileCleanup( - driver, - envFiles, - functionsBootstrap, - inputOwner, - ); - return { - driver: baseDriver, - preflight, - activate, - ingress, - logStore: logs, - } satisfies SupervisorRuntime; - }), - } satisfies SupervisorRuntimeFactory; + ); + yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( + Effect.mapError((error) => mapDriverError(key, error)), + ); + const resolution = yield* resolveContainerResolutionFor( + fresh, + workload, + inputs, + ).pipe(Effect.mapError((error) => mapDriverError(key, error))); + if (resolution === undefined) + return yield* driverError( + key, + `Unknown container runtime specification for ${workload.id}`, + ); + const envFile = yield* envFiles + .write({ + workloadId: workload.id, + values: resolution.env, + }) + .pipe(Effect.mapError((error) => mapDriverError(key, error))); + const volume = + workload.id === "database:database" + ? { target: "/var/lib/postgresql/data", readOnly: false } + : workload.id === "storage:storage" + ? { + target: "/mnt", + readOnly: false, + ownerWorkloadId: "storage:storage", + } + : workload.id === "storage:imgproxy" + ? { + target: "/mnt", + readOnly: true, + ownerWorkloadId: "storage:storage", + } + : undefined; + const { env: _env, ...withoutEnv } = resolution; + return { + ...withoutEnv, + envFile, + waitForReadiness, + ...(volume === undefined ? {} : { volume }), + } satisfies ContainerWorkloadResolution; + }), + ), + ), + waitForReadiness, + bootstrapDatabase: bootstrapWorkloadDatabase, + logStore: logs, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + } + const baseDriver = withOwnedRuntimeFileCleanup( + driver, + envFiles, + functionsBootstrap, + inputOwner, + ); + return { + driver: baseDriver, + preflight, + activate, + ingress, + logStore: logs, + } satisfies SupervisorRuntime; }); diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index c895d93d71..c1ffa31055 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -344,7 +344,7 @@ describe("production runtime composition", () => { const createdSpecs: ContainerContainerSpec[] = []; let attempts = 0; const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -375,7 +375,6 @@ describe("production runtime composition", () => { : Effect.void; }, }); - const runtime = yield* factory.make(current.value); const ready = yield* runtime.driver.start( { stackId, @@ -438,7 +437,7 @@ describe("production runtime composition", () => { if (database === undefined) return yield* Effect.die("Expected database workload"); let attempts = 0; const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -462,7 +461,6 @@ describe("production runtime composition", () => { return Effect.fail(new DatabaseBootstrapError({ message: "invalid password" })); }, }); - const runtime = yield* factory.make(current.value); const result = yield* runtime.driver .start( { @@ -525,7 +523,7 @@ describe("production runtime composition", () => { }, } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -536,7 +534,6 @@ describe("production runtime composition", () => { logStore: memoryLogStore([]), bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); const input = { stackId, state: current.value, @@ -608,7 +605,7 @@ describe("production runtime composition", () => { }, } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -628,7 +625,6 @@ describe("production runtime composition", () => { logStore: memoryLogStore([]), bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); const realtime = compiled.executionPlan.workloads.find( (workload) => workload.id === "realtime:realtime", ); @@ -697,7 +693,7 @@ describe("production runtime composition", () => { ), ); const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -717,7 +713,6 @@ describe("production runtime composition", () => { logStore: memoryLogStore([]), bootstrapDatabase: bootstrap, }); - const runtime = yield* factory.make(current.value); const database = compiled.executionPlan.workloads.find( (workload) => workload.id === "database:database", ); @@ -815,7 +810,7 @@ describe("production runtime composition", () => { const context = yield* Effect.context< FileSystem.FileSystem | Path.Path | Crypto.Crypto >(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -835,7 +830,6 @@ describe("production runtime composition", () => { logStore: memoryLogStore([]), bootstrapDatabase: bootstrap, }); - const runtime = yield* factory.make(current.value); const database = compiled.executionPlan.workloads.find( (workload) => workload.id === "database:database", ); @@ -1021,7 +1015,7 @@ describe("production runtime composition", () => { expect(yield* fs.exists(functionsRoot)).toBe(false); const prepared: string[] = []; const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1045,7 +1039,6 @@ describe("production runtime composition", () => { }, bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); yield* runtime.preflight({ stackId, state: current.value, @@ -1097,7 +1090,7 @@ describe("production runtime composition", () => { }, } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1118,7 +1111,6 @@ describe("production runtime composition", () => { }, bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); const result = yield* runtime .preflight({ stackId, @@ -1174,7 +1166,7 @@ describe("production runtime composition", () => { `${process.pid}\n`, ); const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1195,7 +1187,6 @@ describe("production runtime composition", () => { }, bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); const result = yield* runtime .preflight({ stackId, @@ -1245,7 +1236,7 @@ describe("production runtime composition", () => { definition: compiled.definition, } satisfies PersistedStackState; const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1266,7 +1257,6 @@ describe("production runtime composition", () => { logStore: memoryLogStore(entries), bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); yield* runtime.preflight({ stackId, state: candidate, @@ -1443,7 +1433,7 @@ describe("production runtime composition", () => { const createdSpecs: ContainerContainerSpec[] = []; const copiedFiles: Array> = []; const engine = ownerInputContainerEngine(createdSpecs, copiedFiles); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1464,7 +1454,6 @@ describe("production runtime composition", () => { logStore: memoryLogStore([]), bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); const auth = compiled.executionPlan.workloads.find( (workload) => workload.id === "auth:auth", ); @@ -1672,7 +1661,7 @@ describe("production runtime composition", () => { } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); const createdSpecs: ContainerContainerSpec[] = []; - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1693,7 +1682,6 @@ describe("production runtime composition", () => { logStore: memoryLogStore([]), bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); const rest = compiled.executionPlan.workloads.find( (workload) => workload.id === "rest:rest", ); @@ -1732,7 +1720,7 @@ describe("production runtime composition", () => { content: "export default 1", }); const context = yield* Effect.context(); - const factory = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntimeFactory({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1743,7 +1731,6 @@ describe("production runtime composition", () => { logStore: memoryLogStore([]), bootstrapDatabase: () => Effect.void, }); - const runtime = yield* factory.make(current.value); yield* runtime.driver.stop({ stackId, workloadId: "database:database", diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 8027762dfd..797882a353 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -77,8 +77,6 @@ type ChildResult = | { readonly kind: "ownership-conflict" } | { readonly kind: "failed"; readonly message: string }; -type LaunchPayload = SupervisorArgs; - const mapFailure = (message: string) => new StackStateInvalidError({ message }); const SUPERVISOR_READINESS_TIMEOUT_MS = 30_000; const SUPERVISOR_REREAD_INTERVAL_MS = 25; @@ -94,7 +92,7 @@ export interface LauncherOptions { } const encodeLaunchPayload = ( - payload: LaunchPayload, + payload: SupervisorArgs, ): Effect.Effect => Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(payload).pipe( Effect.mapError(() => mapFailure("Unable to encode supervisor launch arguments")), @@ -216,7 +214,7 @@ const observeOwner = ( const launchAndAwait = ( options: LauncherOptions, - payload: LaunchPayload, + payload: SupervisorArgs, paths: { readonly stackRoot: string }, ): Effect.Effect< OwnerMetadata, @@ -382,7 +380,7 @@ export const ensureSupervisor = ( mapFailure(`Unable to allocate owner session id: ${error.message}`), ), ); - const payload: LaunchPayload = { + const payload: SupervisorArgs = { stateRoot: options.environment.stateRoot, ...(options.environment.artifactCacheRoot === undefined ? {} diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index bf4f407597..0122cf9111 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -2,10 +2,10 @@ import { Data, DateTime, Effect, FileSystem, Path, Predicate, Schema, Semaphore import { StackLogEntrySchema, type LogCursor, + type LogQuery, type StackLogEntry, type StackLogSource, } from "../public/Logs.ts"; -import type { CapabilityName } from "../public/Capability.ts"; import { redactKnownSecrets } from "../state/SecretStore.ts"; const CURSOR_PREFIX = "v1_"; @@ -37,9 +37,7 @@ export interface LogRecord { /** Internal retained-log query. */ interface LogStoreQuery { - readonly capabilities?: ReadonlyArray; readonly cursor?: LogCursor; - readonly tail?: number; } /** Cursor returned when a retained log file has no entries to advance to. */ @@ -48,8 +46,7 @@ export const EMPTY_LOG_CURSOR: LogCursor = { opaque: "v1_0" }; /** Apply public log filtering and pagination to a retained read. */ export const selectLogBatch = ( scanned: ReadonlyArray, - query?: Pick, - cursor: LogCursor = EMPTY_LOG_CURSOR, + query?: Pick, ): { readonly entries: ReadonlyArray; readonly cursor: LogCursor } => { const capabilities = query?.capabilities === undefined ? undefined : new Set(query.capabilities); const filtered = scanned.filter((entry) => @@ -67,14 +64,14 @@ export const selectLogBatch = ( : filtered.slice(-Math.floor(query.tail)); return { entries, - cursor: scanned.at(-1)?.cursor ?? cursor, + cursor: scanned.at(-1)?.cursor ?? query?.cursor ?? EMPTY_LOG_CURSOR, }; }; export interface LogStore { readonly path: string; readonly append: (record: LogRecord) => Effect.Effect; - /** Returns retained records selected by cursor and capability filter. */ + /** Returns retained records after the supplied cursor. */ readonly read: ( options?: LogStoreQuery, ) => Effect.Effect, LogStoreError>; @@ -119,17 +116,10 @@ const selected = ( path: string, ): Effect.Effect, LogStoreError> => Effect.map(decodeCursor(path, options?.cursor), (after) => { - const capabilities = - options?.capabilities === undefined ? undefined : new Set(options.capabilities); return entries.filter((entry) => { const value = Number.parseInt(entry.cursor.opaque.slice(CURSOR_PREFIX.length), 36); if (after !== undefined && value <= after) return false; - if (capabilities === undefined) return true; - return ( - entry.source !== "gateway" && - entry.source !== "supervisor" && - capabilities.has(entry.source) - ); + return true; }); }); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index c96396dc43..5128233a0b 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -653,7 +653,8 @@ export const makeSupervisor = ( // Capture lifecycle phase before reading the log store. A stopping snapshot stays live // and gives followers one more poll rather than racing a final batch. const phaseAtRead = yield* Ref.get(phase); - const cursor = query?.cursor?.opaque === "v1_0" ? undefined : query?.cursor; + const cursor = + query?.cursor?.opaque === EMPTY_LOG_CURSOR.opaque ? undefined : query?.cursor; const scanned = yield* runtime.logStore .read(cursor === undefined ? undefined : { cursor }) .pipe( @@ -661,7 +662,7 @@ export const makeSupervisor = ( (error) => new StackStateInvalidError({ message: error.message, cause: error }), ), ); - const selected = selectLogBatch(scanned, query, query?.cursor ?? EMPTY_LOG_CURSOR); + const selected = selectLogBatch(scanned, query); const running = phaseAtRead !== "stopped"; return { ...selected, diff --git a/packages/stack/src/supervisor/observability.integration.test.ts b/packages/stack/src/supervisor/observability.integration.test.ts index 5ed0531d81..84cec271c2 100644 --- a/packages/stack/src/supervisor/observability.integration.test.ts +++ b/packages/stack/src/supervisor/observability.integration.test.ts @@ -1,7 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; -import { LogStoreError, makeLogStore } from "./LogStore.ts"; +import { LogStoreError, makeLogStore, selectLogBatch } from "./LogStore.ts"; const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -38,8 +38,9 @@ describe("observability", () => { second.cursor, third.cursor, ]); + const scanned = yield* store.read(); expect( - (yield* store.read({ capabilities: ["auth"] })).map((entry) => entry.message), + selectLogBatch(scanned, { capabilities: ["auth"] }).entries.map((entry) => entry.message), ).toEqual(["two"]); const invalidCursor = yield* store .read({ cursor: { opaque: "not-a-cursor" } }) From 65e77f6597afeb819af052debaa53103e1ad21a1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 10:19:29 +0200 Subject: [PATCH 129/174] refactor(stack): remove runtime factory terminology --- .../stack/src/entrypoints/supervisor-node.ts | 4 +-- .../stack/src/runtime/ProductionRuntime.ts | 8 ++--- .../production-runtime.integration.test.ts | 30 +++++++++---------- packages/stack/src/supervisor/LogStore.ts | 14 ++++----- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index 7dda48e7d4..e2805c59ea 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -11,7 +11,7 @@ import { } from "../state/Ownership.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import { makeSupervisor } from "../supervisor/Supervisor.ts"; -import { makeProductionRuntimeFactory } from "../runtime/ProductionRuntime.ts"; +import { makeProductionRuntime } from "../runtime/ProductionRuntime.ts"; import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; import { StackOwnershipConflictError } from "../public/Errors.ts"; @@ -91,7 +91,7 @@ const runSupervisor = (args: SupervisorArgs) => environment, }); const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: args.stateRoot, ...(args.artifactCacheRoot === undefined ? {} diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 80802e8644..0e540ed2c9 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -79,7 +79,7 @@ import { type RuntimeContext = FileSystem.FileSystem | Path.Path | Crypto.Crypto; -export interface ProductionRuntimeFactoryOptions { +export interface ProductionRuntimeOptions { readonly stateRoot: string; readonly artifactCacheRoot?: string; readonly stackId: StackId; @@ -118,7 +118,7 @@ const stateSecrets = (state: PersistedStackState): ReadonlyArray => .map((entry) => entry.value) .filter((value) => value.length > 0); -const currentStateReader = (options: ProductionRuntimeFactoryOptions) => +const currentStateReader = (options: ProductionRuntimeOptions) => options.stateStore.read(options.stackId).pipe( Effect.provideContext(options.context), Effect.flatMap((state) => @@ -460,8 +460,8 @@ export const withOwnedRuntimeFileCleanup = ( }; /** Composes concrete runtime owners around one persisted stack identity. */ -export const makeProductionRuntimeFactory = ( - options: ProductionRuntimeFactoryOptions, +export const makeProductionRuntime = ( + options: ProductionRuntimeOptions, ): Effect.Effect< SupervisorRuntime, StackError, diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index c1ffa31055..6556ef4df2 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -29,7 +29,7 @@ import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; import { - makeProductionRuntimeFactory, + makeProductionRuntime, NATIVE_DATABASE_MIGRATION_MARKER, readinessDeadlineFor, withOwnedRuntimeFileCleanup, @@ -297,7 +297,7 @@ const ownerInputContainerEngine = ( }; }; -describe("production runtime composition", () => { +describe("production runtime", () => { it.live("retries a transient database bootstrap connection failure", () => Effect.scoped( Effect.gen(function* () { @@ -344,7 +344,7 @@ describe("production runtime composition", () => { const createdSpecs: ContainerContainerSpec[] = []; let attempts = 0; const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -437,7 +437,7 @@ describe("production runtime composition", () => { if (database === undefined) return yield* Effect.die("Expected database workload"); let attempts = 0; const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -523,7 +523,7 @@ describe("production runtime composition", () => { }, } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -605,7 +605,7 @@ describe("production runtime composition", () => { }, } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -693,7 +693,7 @@ describe("production runtime composition", () => { ), ); const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -810,7 +810,7 @@ describe("production runtime composition", () => { const context = yield* Effect.context< FileSystem.FileSystem | Path.Path | Crypto.Crypto >(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1015,7 +1015,7 @@ describe("production runtime composition", () => { expect(yield* fs.exists(functionsRoot)).toBe(false); const prepared: string[] = []; const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1090,7 +1090,7 @@ describe("production runtime composition", () => { }, } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1166,7 +1166,7 @@ describe("production runtime composition", () => { `${process.pid}\n`, ); const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1236,7 +1236,7 @@ describe("production runtime composition", () => { definition: compiled.definition, } satisfies PersistedStackState; const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1433,7 +1433,7 @@ describe("production runtime composition", () => { const createdSpecs: ContainerContainerSpec[] = []; const copiedFiles: Array> = []; const engine = ownerInputContainerEngine(createdSpecs, copiedFiles); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1661,7 +1661,7 @@ describe("production runtime composition", () => { } satisfies { value: PersistedStackState }; const context = yield* Effect.context(); const createdSpecs: ContainerContainerSpec[] = []; - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", @@ -1720,7 +1720,7 @@ describe("production runtime composition", () => { content: "export default 1", }); const context = yield* Effect.context(); - const runtime = yield* makeProductionRuntimeFactory({ + const runtime = yield* makeProductionRuntime({ stateRoot: root, stackId, ownerSessionId: "owner", diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index 0122cf9111..7c24e75bb2 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -115,13 +115,13 @@ const selected = ( options: LogStoreQuery | undefined, path: string, ): Effect.Effect, LogStoreError> => - Effect.map(decodeCursor(path, options?.cursor), (after) => { - return entries.filter((entry) => { - const value = Number.parseInt(entry.cursor.opaque.slice(CURSOR_PREFIX.length), 36); - if (after !== undefined && value <= after) return false; - return true; - }); - }); + Effect.map(decodeCursor(path, options?.cursor), (after) => + after === undefined + ? entries + : entries.filter( + (entry) => Number.parseInt(entry.cursor.opaque.slice(CURSOR_PREFIX.length), 36) > after, + ), + ); const validateDocument = ( path: string, From f20d12651a048f02be3be2c6a3a9e86feba61605 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 11:58:24 +0200 Subject: [PATCH 130/174] chore(stack): preserve lifecycle and runtime error details --- .../stack/src/control/MaintenanceProtocol.ts | 8 +- .../stack/src/preparation/RuntimeArtifacts.ts | 20 ++- .../runtime-artifacts.integration.test.ts | 64 ++++++++- packages/stack/src/public/EffectStack.ts | 16 ++- packages/stack/src/public/Errors.ts | 9 ++ .../public/effect-stack.integration.test.ts | 62 ++++++++ .../src/public/promise.integration.test.ts | 13 +- .../stack/src/runtime/ProductionRuntime.ts | 39 +++++- packages/stack/src/supervisor/Lifecycle.ts | 37 ++++- packages/stack/src/supervisor/LogStore.ts | 22 ++- packages/stack/src/supervisor/Supervisor.ts | 60 ++++++-- .../supervisor/lifecycle.integration.test.ts | 37 +++++ .../observability.integration.test.ts | 3 +- .../supervisor/supervisor.integration.test.ts | 132 +++++++++++++++++- 14 files changed, 475 insertions(+), 47 deletions(-) diff --git a/packages/stack/src/control/MaintenanceProtocol.ts b/packages/stack/src/control/MaintenanceProtocol.ts index 37d37d5638..f35598d037 100644 --- a/packages/stack/src/control/MaintenanceProtocol.ts +++ b/packages/stack/src/control/MaintenanceProtocol.ts @@ -1,5 +1,6 @@ import { Data, Effect, Predicate, Schema } from "effect"; import { isStackId, StackIdSchema } from "../public/StackId.ts"; +import { STACK_ERROR_TAGS } from "../public/Errors.ts"; /** The stable protocol is intentionally frozen independently of Stack RPC releases. */ const MAINTENANCE_PROTOCOL = "maintenance-v1" as const; @@ -61,6 +62,7 @@ const MaintenanceErrorCodeSchema = Schema.Literals([ "operation-failed", "unsupported-release", ] as const); +const MaintenanceStackErrorTagSchema = Schema.Literals(STACK_ERROR_TAGS); export const MaintenanceResponseSchema = Schema.Union([ Schema.Struct({ @@ -73,7 +75,11 @@ export const MaintenanceResponseSchema = Schema.Union([ Schema.Struct({ ok: Schema.Literal(true), op: Schema.Literal("stop") }), Schema.Struct({ ok: Schema.Literal(false), - error: Schema.Struct({ tag: MaintenanceErrorCodeSchema, message: Schema.String }), + error: Schema.Struct({ + tag: MaintenanceErrorCodeSchema, + message: Schema.String, + stackErrorTag: Schema.optionalKey(MaintenanceStackErrorTagSchema), + }), }), ]); export type MaintenanceResponse = Schema.Schema.Type; diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index e09a5d0b08..4884fc2144 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -6,7 +6,11 @@ import { type NativeWorkloadArtifact, } from "../model/WorkloadCatalog.ts"; import type { StackRuntime } from "../public/Runtime.ts"; -import { ContainerPullError, StackPreparationError } from "../public/Errors.ts"; +import { + ContainerEngineError, + ContainerPullError, + StackPreparationError, +} from "../public/Errors.ts"; import { makeArtifactStore, type ArtifactStore, @@ -42,6 +46,7 @@ export interface RuntimeArtifactPreparer { export type RuntimeArtifactPreparationError = | ArtifactStoreError | StackPreparationError + | ContainerEngineError | ContainerPullError; export interface RuntimeArtifactPreparerOptions { @@ -149,8 +154,8 @@ export const makeRuntimeArtifactPreparer = ( Effect.mapError((cause) => cause instanceof ContainerPullError ? cause - : error("Container image preparation failed", { - workload: workload.id, + : new ContainerEngineError({ + message: cause instanceof Error ? cause.message : "Container engine operation failed", engine: runtime.engine, cause, }), @@ -224,7 +229,14 @@ export const makeProductionRuntimeArtifactPreparer = (options: { (selectedEngine === undefined ? undefined : yield* resolveContainerEngine(selectedEngine, options.containerEngineResolver).pipe( - Effect.mapError(() => error(`Unable to configure ${selectedEngine} artifact engine`)), + Effect.mapError( + (cause) => + new ContainerEngineError({ + message: `Unable to configure ${selectedEngine} artifact engine`, + engine: selectedEngine, + cause, + }), + ), )); const containers = selectedEngine === undefined || containerEngine === undefined diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index c670668167..02849d7319 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ArtifactRequest, ArtifactStore, PreparedArtifact } from "./ArtifactStore.ts"; import type { @@ -13,6 +13,8 @@ import { makeProductionRuntimeArtifactPreparer, makeRuntimeArtifactPreparer, } from "./RuntimeArtifacts.ts"; +import { ContainerEngineError } from "../public/Errors.ts"; +import { ContainerEngineProtocolError } from "../runtime/ContainerEngine.ts"; const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload => ({ id: "database:database", @@ -96,6 +98,35 @@ describe("runtime artifact preparation", () => { ), ); + it.live("surfaces selected-engine resolver failures as container engine errors", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-artifact-engine-" }); + const resolver = { + resolve: () => + Effect.fail( + new ContainerEngineProtocolError({ + operation: "probe", + message: "podman is not installed", + }), + ), + }; + const exit = yield* makeProductionRuntimeArtifactPreparer({ + stateRoot: root, + runtime: { kind: "container", engine: "podman" }, + containerEngineResolver: resolver, + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = Exit.isFailure(exit) + ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) + : undefined; + expect(error).toBeInstanceOf(ContainerEngineError); + expect(error).toMatchObject({ engine: "podman" }); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it("resolves native catalog metadata, checksum, and one ArtifactRequest", () => { const requests: ArtifactRequest[] = []; const store: ArtifactStore = { @@ -172,6 +203,37 @@ describe("runtime artifact preparation", () => { expect(dockerCalls).toEqual([]); }); + it("reports unavailable container engines separately from artifact preparation", () => { + const engine = containerEngine(true, []); + const unavailable: ContainerEngine = { + ...engine, + probe: Effect.fail( + new ContainerEngineProtocolError({ + operation: "probe", + message: "docker daemon is unavailable", + }), + ), + }; + const runtime = makeRuntimeArtifactPreparer({ + containers: { docker: unavailable }, + }); + const exit = Effect.runSyncExit( + runtime.prepare( + { kind: "container", engine: "docker" }, + nativeWorkload({ + kind: "container", + image: "ghcr.io/supabase/cli/postgres:17.6.1.167", + }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + const error = Exit.isFailure(exit) + ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) + : undefined; + expect(error).toBeInstanceOf(ContainerEngineError); + expect(error).toMatchObject({ engine: "docker", message: "docker daemon is unavailable" }); + }); + it("fails strict runtime/artifact mismatches before touching a store or engine", () => { let called = false; const runtime = makeRuntimeArtifactPreparer({ diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 729844c640..e0f305970a 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -60,6 +60,7 @@ import { PortAllocationError, PortUnavailableError, GatewayActivationError, + InvalidLogCursorError, type CreateStackError, type OpenStackError, type StackDiscoveryError, @@ -198,6 +199,7 @@ const stackErrorFactories = { StackMustBeStoppedError: (message: string) => new StackMustBeStoppedError({ message }), StackLifecycleConflictError: (message: string) => new StackLifecycleConflictError({ message }), StackStateInvalidError: (message: string) => new StackStateInvalidError({ message }), + InvalidLogCursorError: (message: string) => new InvalidLogCursorError({ message }), StackStateFormatUnsupportedError: (message: string) => new StackStateFormatUnsupportedError({ message }), StackUpgradeRequiredError: (message: string) => new StackUpgradeRequiredError({ message }), @@ -221,6 +223,8 @@ const errorForRpc = (error: ControlError): StackError => { Predicate.isTagged(error, "StackOwnershipConflictError") || Predicate.isTagged(error, "StackLifecycleConflictError") || Predicate.isTagged(error, "StackStateInvalidError") || + Predicate.isTagged(error, "InvalidLogCursorError") || + Predicate.isTagged(error, "StackCleanupError") || Predicate.isTagged(error, "StackStateFormatUnsupportedError") || Predicate.isTagged(error, "StackUpgradeRequiredError") ) @@ -486,6 +490,15 @@ export const makeHandle = ( } if (!response.value.ok) { yield* Fiber.interrupt(closeFiber); + if ( + response.value.error.tag === "operation-failed" && + response.value.error.stackErrorTag !== undefined && + isStackErrorTag(response.value.error.stackErrorTag) + ) { + return yield* stackErrorFactories[response.value.error.stackErrorTag]( + response.value.error.message, + ); + } return yield* new StackLifecycleConflictError({ message: response.value.error.message }); } yield* Fiber.join(closeFiber).pipe(Effect.ignore); @@ -703,6 +716,7 @@ const handleDependencies = (options: { cause instanceof StackPreparationError || cause instanceof ArtifactIntegrityError || cause instanceof ContainerPullError || + cause instanceof ContainerEngineError || cause instanceof StackOwnershipConflictError || cause instanceof StackStateInvalidError || cause instanceof StackLifecycleConflictError @@ -841,7 +855,7 @@ const handleDependencies = (options: { ), ), Effect.mapError((error) => - error instanceof StackOwnershipConflictError + error instanceof StackOwnershipConflictError || error instanceof InvalidLogCursorError ? error : new StackStateInvalidError({ message: error.message, cause: error }), ), diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index 25e4f32dcb..b80c190909 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -41,6 +41,9 @@ export class StackLifecycleConflictError extends Data.TaggedError( export class StackStateInvalidError extends Data.TaggedError( "StackStateInvalidError", ) {} +export class InvalidLogCursorError extends Data.TaggedError( + "InvalidLogCursorError", +) {} export class StackStateFormatUnsupportedError extends Data.TaggedError( "StackStateFormatUnsupportedError", ) {} @@ -95,6 +98,7 @@ export const STACK_ERROR_TAGS = [ "StackMustBeStoppedError", "StackLifecycleConflictError", "StackStateInvalidError", + "InvalidLogCursorError", "StackStateFormatUnsupportedError", "StackUpgradeRequiredError", "StackSecretMismatchError", @@ -128,6 +132,7 @@ export type StackError = | StackMustBeStoppedError | StackLifecycleConflictError | StackStateInvalidError + | InvalidLogCursorError | StackStateFormatUnsupportedError | StackUpgradeRequiredError | StackSecretMismatchError @@ -199,6 +204,7 @@ export const PREPARE_STACK_ERROR_TAGS = [ "StackPreparationError", "ArtifactIntegrityError", "ContainerPullError", + "ContainerEngineError", "StackOwnershipConflictError", "StackStateInvalidError", "StackLifecycleConflictError", @@ -232,6 +238,8 @@ export type StackStartError = ErrorByTag<(typeof STACK_START_ERROR_TAGS)[number] export const STACK_STOP_ERROR_TAGS = [ "StackOwnershipConflictError", "StackLifecycleConflictError", + "StackStateInvalidError", + "StackCleanupError", ] as const satisfies ReadonlyArray; export type StackStopError = ErrorByTag<(typeof STACK_STOP_ERROR_TAGS)[number]>; @@ -239,6 +247,7 @@ export const STACK_LOGS_ERROR_TAGS = [ "StackNotFoundError", "StackNotRunningError", "StackStateInvalidError", + "InvalidLogCursorError", "StackOwnershipConflictError", "StackLifecycleConflictError", ] as const satisfies ReadonlyArray; diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index d5f015c6bf..ab69b17a2a 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -39,6 +39,7 @@ import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts import { compileStack } from "../model/Compiler.ts"; import { StackDestructionError, + StackCleanupError, StackOwnershipConflictError, StackPreparationError, StackRuntimeMismatchError, @@ -135,6 +136,67 @@ const withRuntimeRoot = (effect: (project: string) => Effect.Effect { + it.live("preserves stop cleanup error identity through maintenance transport", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-effect-stack-stop-error-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "stop-error-session"; + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + const ownerScope = yield* Scope.make(); + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => Effect.succeed(runningStatus), + destroy: () => Effect.void, + logs: () => emptyLogs(), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ + ok: false, + error: { + tag: "operation-failed", + message: "injected stop cleanup failure", + stackErrorTag: "StackCleanupError", + }, + }), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some(owner)), + }); + const stopped = yield* stack.stop().pipe(Effect.exit); + expect(Exit.isFailure(stopped)).toBe(true); + if (Exit.isFailure(stopped)) + expect(Option.getOrUndefined(Cause.findErrorOption(stopped.cause))).toBeInstanceOf( + StackCleanupError, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("hands off filtered logs through a real owner stop", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index 25056a10f5..b369958556 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -13,7 +13,7 @@ import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./Effe import type { StackLogEntry } from "./Logs.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; -import { InvalidStackConfigError } from "./Errors.ts"; +import { InvalidLogCursorError, InvalidStackConfigError } from "./Errors.ts"; import { adaptEffectStack, makePromiseApi, type PromiseStack } from "./PromiseStack.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -106,6 +106,17 @@ describe("Promise stack facade", () => { expect(followed.every((entry) => entry.source === "auth")).toBe(true); }); + it("preserves malformed log cursor errors through the Promise facade", async () => { + const source: EffectStack = { + ...effectStack(), + logs: () => Effect.fail(new InvalidLogCursorError({ message: "Log cursor is invalid" })), + }; + const stack = adaptEffectStack(source); + await expect(stack.logs({ cursor: { opaque: "not-a-cursor" } })).rejects.toBeInstanceOf( + InvalidLogCursorError, + ); + }); + it("unwraps every credential secret without a lifecycle close operation", async () => { const stack: PromiseStack = adaptEffectStack(effectStack()); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 0e540ed2c9..1743fc8b89 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -31,6 +31,7 @@ import { GatewayActivationError, StackPreparationError, ArtifactIntegrityError, + ContainerEngineError, ContainerPullError, PortUnavailableError, StackRuntimeMismatchError, @@ -66,7 +67,11 @@ import { makeNativeRuntime } from "./NativeRuntime.ts"; import { makeContainerRuntime, type ContainerWorkloadResolution } from "./ContainerRuntime.ts"; import { DEFAULT_READINESS_DEADLINE, probeReadiness } from "./ReadinessProbe.ts"; import { parseGoDuration } from "../model/capabilities/database.ts"; -import type { ContainerEngine, ContainerHostRoute } from "./ContainerEngine.ts"; +import type { + ContainerEngine, + ContainerEngineKind, + ContainerHostRoute, +} from "./ContainerEngine.ts"; import { resolveContainerEngine } from "./ContainerEngineResolver.ts"; import { bootstrapDatabaseAt } from "./PostgresDatabaseSession.ts"; import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; @@ -551,7 +556,11 @@ export const makeProductionRuntime = ( ? undefined : yield* resolveContainerEngine(selectedEngine).pipe( Effect.mapError((error) => - preparationError(`Unable to configure ${selectedEngine} artifact engine`, error), + containerEngineError( + selectedEngine, + `Unable to configure ${selectedEngine} artifact engine`, + error, + ), ), )); const preparer = @@ -584,7 +593,9 @@ export const makeProductionRuntime = ( return cached === undefined ? preparer.prepare(runtime, workload).pipe( Effect.mapError((error) => - error instanceof ArtifactIntegrityError || error instanceof ContainerPullError + error instanceof ArtifactIntegrityError || + error instanceof ContainerPullError || + error instanceof ContainerEngineError ? error : preparationError(`Unable to prepare ${workload.id}`, error), ), @@ -674,11 +685,15 @@ export const makeProductionRuntime = ( }), ); if (input.state.runtime.kind === "container") { - if (containerEngine === undefined || containerEngine.kind !== input.state.runtime.engine) - return yield* preparationError("Selected container engine is unavailable"); + const engineKind = input.state.runtime.engine; + if (containerEngine === undefined || containerEngine.kind !== engineKind) + return yield* containerEngineError( + engineKind, + "Selected container engine is unavailable", + ); const route = yield* containerEngine.preflight.pipe( Effect.mapError((error) => - preparationError("Container host route preflight failed", error), + containerEngineError(engineKind, "Container host route preflight failed", error), ), ); yield* Ref.set(hostRoute, route); @@ -881,7 +896,10 @@ export const makeProductionRuntime = ( ); } else { if (containerEngine === undefined || containerEngine.kind !== state.runtime.engine) - return yield* preparationError("Selected container engine is unavailable"); + return yield* containerEngineError( + state.runtime.engine, + "Selected container engine is unavailable", + ); driver = yield* makeContainerRuntime({ engine: containerEngine, ownerSessionId: options.ownerSessionId, @@ -974,3 +992,10 @@ export const makeProductionRuntime = ( logStore: logs, } satisfies SupervisorRuntime; }); + +const containerEngineError = ( + engine: ContainerEngineKind, + message: string, + cause?: unknown, +): ContainerEngineError => + new ContainerEngineError({ engine, message, ...(cause === undefined ? {} : { cause }) }); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index bb79bba2f6..2d1fe0cec6 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -225,8 +225,27 @@ export const makeLifecycleController = ( guidance: "Use stop() followed by start() to apply stopped-time changes", }); const freshSession = startOptions?.freshSession === true; - if (freshSession) - yield* options.backend.preflight(lifecycleInput(options.stackId, initial, candidate)); + if (freshSession) { + const preflighted = yield* options.backend + .preflight(lifecycleInput(options.stackId, initial, candidate)) + .pipe(Effect.exit); + if (Exit.isFailure(preflighted)) { + const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); + const stopped = + Exit.isSuccess(current) && current.value !== undefined + ? { ...current.value, desiredLifecycle: "stopped" as const } + : { ...initial, desiredLifecycle: "stopped" as const }; + const persisted = yield* options.stateStore + .replace(options.stackId, stopped) + .pipe(Effect.exit); + const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); + let cause = preflighted.cause; + if (Exit.isFailure(current)) cause = Cause.combine(cause, current.cause); + if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); + if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); + return yield* Effect.failCause(cause); + } + } const launched = yield* options.backend .launch( lifecycleInput(options.stackId, initial, candidate), @@ -234,12 +253,17 @@ export const makeLifecycleController = ( ) .pipe(Effect.exit); if (Exit.isFailure(launched) && freshSession) { - const stopped = { ...initial, desiredLifecycle: "stopped" as const }; + const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); + const stopped = + Exit.isSuccess(current) && current.value !== undefined + ? { ...current.value, desiredLifecycle: "stopped" as const } + : { ...initial, desiredLifecycle: "stopped" as const }; const persisted = yield* options.stateStore .replace(options.stackId, stopped) .pipe(Effect.exit); const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); let cause = launched.cause; + if (Exit.isFailure(current)) cause = Cause.combine(cause, current.cause); if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); return yield* Effect.failCause(cause); @@ -259,12 +283,17 @@ export const makeLifecycleController = ( // A failed cold launch never leaves a durable running intent behind. Cleanup is attempted // before publishing the stopped state; if cleanup is not proven, the stopped fence remains // durable and the Supervisor stays available for an explicit retry. - const stopped = { ...next, desiredLifecycle: "stopped" as const }; + const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); + const stopped = + Exit.isSuccess(current) && current.value !== undefined + ? { ...current.value, desiredLifecycle: "stopped" as const } + : { ...next, desiredLifecycle: "stopped" as const }; const persisted = yield* options.stateStore .replace(options.stackId, stopped) .pipe(Effect.exit); const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); let cause = started.cause; + if (Exit.isFailure(current)) cause = Cause.combine(cause, current.cause); if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); return yield* Effect.failCause(cause); diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index 7c24e75bb2..9faf049758 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -6,6 +6,7 @@ import { type StackLogEntry, type StackLogSource, } from "../public/Logs.ts"; +import { InvalidLogCursorError } from "../public/Errors.ts"; import { redactKnownSecrets } from "../state/SecretStore.ts"; const CURSOR_PREFIX = "v1_"; @@ -74,7 +75,7 @@ export interface LogStore { /** Returns retained records after the supplied cursor. */ readonly read: ( options?: LogStoreQuery, - ) => Effect.Effect, LogStoreError>; + ) => Effect.Effect, LogStoreError | InvalidLogCursorError>; } interface LogDocument { @@ -102,19 +103,28 @@ const parseOpaqueCursor = (path: string, opaque: string): Effect.Effect => { + if (cursor === undefined) return Effect.map(Effect.void, () => undefined); + return parseOpaqueCursor(path, cursor.opaque).pipe( + Effect.mapError((error) => new InvalidLogCursorError({ message: error.message, cause: error })), + ); +}; + const decodeCursor = ( path: string, cursor: LogCursor | undefined, -): Effect.Effect => { - if (cursor === undefined) return Effect.as(Effect.void, undefined); - return parseOpaqueCursor(path, cursor.opaque); +): Effect.Effect => { + return parseQueryCursor(path, cursor); }; const selected = ( entries: ReadonlyArray, options: LogStoreQuery | undefined, path: string, -): Effect.Effect, LogStoreError> => +): Effect.Effect, InvalidLogCursorError> => Effect.map(decodeCursor(path, options?.cursor), (after) => after === undefined ? entries @@ -192,7 +202,7 @@ export const readRetainedLogs = ( fs: FileSystem.FileSystem, path: string, options?: LogStoreQuery, -): Effect.Effect, LogStoreError> => +): Effect.Effect, LogStoreError | InvalidLogCursorError> => readDocument(fs, path).pipe( Effect.flatMap((document) => selected(document.entries, options, path)), ); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 5128233a0b..41b1004210 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -9,6 +9,7 @@ import { FiberSet, Option, Path, + Predicate, Redacted, Ref, Semaphore, @@ -25,18 +26,22 @@ import type { CapabilityName } from "../public/Capability.ts"; import type { StackConfig } from "../public/Config.ts"; import { GatewayActivationError, + ContainerEngineError, + InvalidLogCursorError, StackLifecycleConflictError, StackNotRunningError, StackRuntimeError, StackCleanupError, StackStateInvalidError, + isStackErrorTag, + type StackErrorTag, type StackError, } from "../public/Errors.ts"; import type { StackStatus } from "../public/Status.ts"; import type { StackId } from "../public/StackId.ts"; import type { LogQuery, StackLogBatch } from "../public/Logs.ts"; import type { EffectStackCredentials } from "../public/Credentials.ts"; -import type { RuntimeDriver } from "../runtime/RuntimeDriver.ts"; +import { RuntimeDriverError, type RuntimeDriver } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import { makeSessionLauncher, type SessionLauncher } from "./SessionLauncher.ts"; @@ -116,6 +121,9 @@ const credentialHost = (address: string): string => const mapRuntimeError = (error: unknown): StackError => { if (error instanceof StackStateInvalidError) return error; + if (error instanceof ContainerEngineError) return error; + if (error instanceof RuntimeDriverError && error.cause instanceof ContainerEngineError) + return error.cause; return new StackRuntimeError({ message: error instanceof Error ? error.message : String(error), cause: error, @@ -131,6 +139,12 @@ const mapCleanupError = (error: unknown): StackError => { }; const rpcTag = (error: StackError): StackRpcError["tag"] => error._tag; +const maintenanceStackErrorTag = (error: unknown): StackErrorTag | undefined => + Predicate.hasProperty(error, "_tag") && + typeof error._tag === "string" && + isStackErrorTag(error._tag) + ? error._tag + : undefined; /** Compose one owner process around the durable lifecycle controller and a runtime driver. */ export const makeSupervisor = ( @@ -614,21 +628,29 @@ export const makeSupervisor = ( }); const signalShutdownIfIdle = (): Effect.Effect => Effect.gen(function* () { - const lifecycle = yield* admission.withPermit(Ref.get(lifecycleActive)); + const lifecycle = yield* Ref.get(lifecycleActive); if (lifecycle !== undefined) { yield* Deferred.await(lifecycle.result); return yield* signalShutdownIfIdle(); } - const state = yield* read().pipe(Effect.exit); - if (Exit.isFailure(state)) return; - const currentPhase = yield* Ref.get(phase); - if ( - currentPhase !== "stopping" && - (state.value === undefined || - state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured") - ) - yield* signalShutdown; + yield* admission.withPermit( + Effect.gen(function* () { + // Recheck ownership after admission: a lifecycle may have started between the + // initial observation and this critical section. Keep the permit while making the + // final state/phase decision and signalling shutdown so no new start can slip in. + if ((yield* Ref.get(lifecycleActive)) !== undefined) return; + const state = yield* read().pipe(Effect.exit); + if (Exit.isFailure(state)) return; + const currentPhase = yield* Ref.get(phase); + if ( + currentPhase !== "stopping" && + (state.value === undefined || + state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured") + ) + yield* signalShutdown; + }), + ); }); const shutdownIfIdle = signalShutdownIfIdle(); const stopWithShutdown = submitLifecycle("stop", stopOperation()); @@ -658,8 +680,10 @@ export const makeSupervisor = ( const scanned = yield* runtime.logStore .read(cursor === undefined ? undefined : { cursor }) .pipe( - Effect.mapError( - (error) => new StackStateInvalidError({ message: error.message, cause: error }), + Effect.mapError((error) => + error instanceof InvalidLogCursorError + ? error + : new StackStateInvalidError({ message: error.message, cause: error }), ), ); const selected = selectLogBatch(scanned, query); @@ -683,7 +707,13 @@ export const makeSupervisor = ( Effect.catch((error) => Effect.succeed({ ok: false, - error: { tag: "operation-failed", message: stateErrorMessage(error) }, + error: { + tag: "operation-failed", + message: stateErrorMessage(error), + ...(maintenanceStackErrorTag(error) === undefined + ? {} + : { stackErrorTag: maintenanceStackErrorTag(error) }), + }, } satisfies MaintenanceResponse), ), ), diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index ea5cddb8f3..a1bb9c9fb2 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -7,6 +7,7 @@ import { StackCleanupError, StackStateInvalidError, StackMustBeStoppedError, + type StackError, } from "../public/Errors.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import type { PersistedStackState } from "../state/StackState.ts"; @@ -43,6 +44,7 @@ interface BackendState { gate?: Deferred.Deferred; preflightStarted?: Deferred.Deferred; waitBeforeLaunch?: Deferred.Deferred; + launchMutation?: () => Effect.Effect; stopLaunchStarted?: Deferred.Deferred; stopLaunchGate?: Deferred.Deferred; startLaunchStarted?: Deferred.Deferred; @@ -66,6 +68,7 @@ const backend = (state: BackendState): LifecycleBackend => ({ if (input.state.desiredLifecycle === "running" && state.startLaunchStarted !== undefined) yield* Deferred.succeed(state.startLaunchStarted, undefined); if (state.waitBeforeLaunch !== undefined) yield* Deferred.await(state.waitBeforeLaunch); + if (state.launchMutation !== undefined) yield* state.launchMutation(); if (state.failLaunch) return yield* new StackRuntimeError({ message: "launch failed" }); }), cleanup: Effect.gen(function* () { @@ -172,6 +175,40 @@ describe("durable lifecycle controller", () => { ), ); + it.live("preserves sticky ports allocated during a failed cold launch", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + fixture.state.failLaunch = true; + fixture.state.launchMutation = () => + Effect.provide( + fixture.store.read(fixture.id).pipe( + Effect.flatMap((current) => + current === undefined + ? Effect.fail(new StackStateInvalidError({ message: "state disappeared" })) + : fixture.store.replace(fixture.id, { + ...current, + ports: [{ field: "api", port: 54_321, intent: "automatic" }], + privatePorts: [{ workloadId: "rest:rest", binding: "http", port: 54_322 }], + }), + ), + Effect.asVoid, + ), + layer, + ); + + const failed = yield* fixture.controller.start().pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); + const stopped = yield* fixture.store.read(fixture.id); + expect(stopped?.desiredLifecycle).toBe("stopped"); + expect(stopped?.ports).toEqual([{ field: "api", port: 54_321, intent: "automatic" }]); + expect(stopped?.privatePorts).toEqual([ + { workloadId: "rest:rest", binding: "http", port: 54_322 }, + ]); + }), + ), + ); + it.live("accepts explicit materialized defaults for a running stack", () => run( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/observability.integration.test.ts b/packages/stack/src/supervisor/observability.integration.test.ts index 84cec271c2..71c8396091 100644 --- a/packages/stack/src/supervisor/observability.integration.test.ts +++ b/packages/stack/src/supervisor/observability.integration.test.ts @@ -1,6 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { InvalidLogCursorError } from "../public/Errors.ts"; import { LogStoreError, makeLogStore, selectLogBatch } from "./LogStore.ts"; const withPlatform = (effect: Effect.Effect) => @@ -45,7 +46,7 @@ describe("observability", () => { const invalidCursor = yield* store .read({ cursor: { opaque: "not-a-cursor" } }) .pipe(Effect.exit); - expect(errorOf(invalidCursor)).toBeInstanceOf(LogStoreError); + expect(errorOf(invalidCursor)).toBeInstanceOf(InvalidLogCursorError); expect(yield* fs.readFileString(path.join(root, "logs.json"))).not.toContain("top-secret"); expect((yield* fs.stat(path.join(root, "logs.json"))).mode & 0o077).toBe(0); }), diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 082da20a2a..4376fe931e 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -17,10 +17,11 @@ import { import { Headers } from "effect/unstable/http"; import { Rpc } from "effect/unstable/rpc"; import { RequestId } from "effect/unstable/rpc/RpcMessage"; -import type { LogQuery, StackLogEntry } from "../public/Logs.ts"; +import type { LogQuery, StackLogBatch, StackLogEntry } from "../public/Logs.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, + InvalidLogCursorError, PortUnavailableError, StackLifecycleConflictError, StackNotRunningError, @@ -74,6 +75,24 @@ const invokeCredentials = ( return value; }); +const invokeLogs = ( + supervisor: Supervisor, + query: LogQuery, +): Effect.Effect => + Effect.gen(function* () { + const handler = yield* StackRpcGroup.accessHandler("logs").pipe( + Effect.provide(StackRpcGroup.toLayerHandler("logs", supervisor.rpcHandlers.logs)), + ); + const value = yield* handler(query, { + client: new Rpc.ServerClient(1), + requestId: RequestId(1), + headers: Headers.empty, + }); + if (Deferred.isDeferred(value)) + return yield* Deferred.await(value); + return value; + }); + const makeFixture = ( fixtureOptions: { readonly ingress?: SupervisorIngress; @@ -101,6 +120,9 @@ const makeFixture = ( readonly destroyPreFenceFail?: Ref.Ref; readonly stoppedReplaceFail?: Ref.Ref; readonly startQueue?: Queue.Queue; + readonly shutdownReadArmed?: Ref.Ref; + readonly shutdownReadStarted?: Deferred.Deferred; + readonly shutdownReadGate?: Deferred.Deferred; } = {}, ) => Effect.gen(function* () { @@ -110,7 +132,7 @@ const makeFixture = ( const baseStore = yield* makeStackStateStore({ stateRoot: root }); const destroyPreFenceFail = fixtureOptions.destroyPreFenceFail; const stoppedReplaceFail = fixtureOptions.stoppedReplaceFail; - const store = + const persistedStore = destroyPreFenceFail === undefined && stoppedReplaceFail === undefined ? baseStore : { @@ -138,6 +160,26 @@ const makeFixture = ( return yield* baseStore.replace(stackId, state); }), }; + const store = + fixtureOptions.shutdownReadArmed === undefined || + fixtureOptions.shutdownReadStarted === undefined || + fixtureOptions.shutdownReadGate === undefined + ? persistedStore + : { + ...persistedStore, + read: (stackId: string) => + Effect.gen(function* () { + const armed = yield* Ref.modify(fixtureOptions.shutdownReadArmed!, (value) => [ + value, + false, + ]); + if (armed) { + yield* Deferred.succeed(fixtureOptions.shutdownReadStarted!, undefined); + yield* Deferred.await(fixtureOptions.shutdownReadGate!); + } + return yield* persistedStore.read(stackId); + }), + }; yield* store.initialize(id, { format: "supabase-stack-state-v1", identity: { ...identity, stackId: id }, @@ -336,9 +378,11 @@ const makeFixture = ( path: "memory://logs", append: () => Effect.succeed(entry), read: (options) => - Ref.update(logOptions, (current) => [...current, options]).pipe( - Effect.andThen(Ref.get(logEntries)), - ), + options?.cursor?.opaque === "not-a-cursor" + ? Effect.fail(new InvalidLogCursorError({ message: "Log cursor is invalid" })) + : Ref.update(logOptions, (current) => [...current, options]).pipe( + Effect.andThen(Ref.get(logEntries)), + ), }, }; const context = yield* Effect.context< @@ -360,6 +404,18 @@ const run = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); describe("Supervisor composition", () => { + it.live("reports malformed log cursors as invalid caller input", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const result = yield* invokeLogs(fixture.supervisor, { + cursor: { opaque: "not-a-cursor" }, + }).pipe(Effect.exit); + expect(errorOf(result)).toMatchObject({ tag: "InvalidLogCursorError" }); + }), + ), + ); + it.live("rejects new work after owner shutdown begins", () => run( Effect.gen(function* () { @@ -372,6 +428,30 @@ describe("Supervisor composition", () => { ), ); + it.live("does not admit a lifecycle while idle shutdown makes its final decision", () => + run( + Effect.gen(function* () { + const readArmed = yield* Ref.make(false); + const readStarted = yield* Deferred.make(); + const readGate = yield* Deferred.make(); + const fixture = yield* makeFixture({ + shutdownReadArmed: readArmed, + shutdownReadStarted: readStarted, + shutdownReadGate: readGate, + }); + yield* Ref.set(readArmed, true); + const shutdown = yield* Effect.forkChild(fixture.supervisor.shutdownIfIdle); + yield* Deferred.await(readStarted); + const start = yield* Effect.forkChild(fixture.supervisor.start({ config: {} })); + yield* Deferred.succeed(readGate, undefined); + yield* Fiber.join(shutdown); + expect(errorOf(yield* Fiber.join(start).pipe(Effect.exit))).toBeInstanceOf( + StackLifecycleConflictError, + ); + }), + ), + ); + it.live("starts through the composed lifecycle and reports observed readiness", () => run( Effect.gen(function* () { @@ -647,6 +727,42 @@ describe("Supervisor composition", () => { ), ); + it.live("persists stopped after a proven fresh-session preflight failure", () => + run( + Effect.gen(function* () { + const preflightFailFirst = yield* Ref.make(false); + const fixture = yield* makeFixture({ preflightFailFirst }); + yield* fixture.supervisor.start({ config: {} }); + yield* Ref.set(preflightFailFirst, true); + + const successor = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "successor-session", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + const failed = yield* successor.start().pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + expect((yield* successor.status).lifecycle).toBe("stopped"); + yield* successor.shutdownIfIdle; + yield* successor.shutdown; + + const retryOwner = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "retry-session", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + expect((yield* retryOwner.start()).lifecycle).toBe("running"); + }), + ), + ); + it.live("keeps stopping when fresh-ingress cleanup fails, then recovers on explicit stop", () => run( Effect.gen(function* () { @@ -945,7 +1061,11 @@ describe("Supervisor composition", () => { const response = yield* fixture.supervisor.maintenanceHandlers.stop; expect(response).toEqual({ ok: false, - error: { tag: "operation-failed", message: "injected stop cleanup failure" }, + error: { + tag: "operation-failed", + message: "injected stop cleanup failure", + stackErrorTag: "StackCleanupError", + }, }); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); }), From ffdad599016420603ddc26b702029a05cb830174 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 13:08:41 +0200 Subject: [PATCH 131/174] chore(stack): preserve runtime failures and lifecycle state --- .../stack/src/control/MaintenanceProtocol.ts | 3 +- .../control-transport.integration.test.ts | 26 ++++ packages/stack/src/public/EffectStack.ts | 16 +-- .../public/effect-stack.integration.test.ts | 125 +++++++++++++++++- .../src/public/promise.integration.test.ts | 13 +- .../stack/src/runtime/ContainerRuntime.ts | 25 +++- .../container-runtime.integration.test.ts | 30 +++++ packages/stack/src/supervisor/Lifecycle.ts | 104 +++++++-------- packages/stack/src/supervisor/LogStore.ts | 9 +- packages/stack/src/supervisor/Supervisor.ts | 13 +- .../supervisor/lifecycle.integration.test.ts | 38 +++++- .../supervisor/supervisor.integration.test.ts | 64 +++++++++ 12 files changed, 367 insertions(+), 99 deletions(-) diff --git a/packages/stack/src/control/MaintenanceProtocol.ts b/packages/stack/src/control/MaintenanceProtocol.ts index f35598d037..527b435d9b 100644 --- a/packages/stack/src/control/MaintenanceProtocol.ts +++ b/packages/stack/src/control/MaintenanceProtocol.ts @@ -1,6 +1,5 @@ import { Data, Effect, Predicate, Schema } from "effect"; import { isStackId, StackIdSchema } from "../public/StackId.ts"; -import { STACK_ERROR_TAGS } from "../public/Errors.ts"; /** The stable protocol is intentionally frozen independently of Stack RPC releases. */ const MAINTENANCE_PROTOCOL = "maintenance-v1" as const; @@ -62,7 +61,7 @@ const MaintenanceErrorCodeSchema = Schema.Literals([ "operation-failed", "unsupported-release", ] as const); -const MaintenanceStackErrorTagSchema = Schema.Literals(STACK_ERROR_TAGS); +const MaintenanceStackErrorTagSchema = Schema.String; export const MaintenanceResponseSchema = Schema.Union([ Schema.Struct({ diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 04288cb046..45f94e4c14 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -255,6 +255,32 @@ const makeDestroyRequestFrame = (): Effect.Effect { + it.live("accepts unknown maintenance stack error tags for forward compatibility", () => + withServer( + ({ endpoint, stackId, ownerSessionId }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const response = yield* client.stop(); + expect(response).toMatchObject({ + ok: false, + error: { tag: "operation-failed", stackErrorTag: "FutureStackError" }, + }); + }), + () => ({ + maintenanceHandlers: { + stop: Effect.succeed({ + ok: false, + error: { + tag: "operation-failed", + message: "future owner failure", + stackErrorTag: "FutureStackError", + }, + }), + }, + }), + ), + ); + it.live("reevaluates owner shutdown after a failed start response", () => withServer( ({ endpoint, stackId, ownerSessionId, completion }) => diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index e0f305970a..662aa18e4d 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -218,17 +218,13 @@ const stackErrorFactories = { StackDestructionError: (message: string) => new StackDestructionError({ message }), } satisfies Record StackError>; +const isPublicStackError = (error: unknown): error is StackError => + Predicate.hasProperty(error, "_tag") && + typeof error._tag === "string" && + isStackErrorTag(error._tag); + const errorForRpc = (error: ControlError): StackError => { - if ( - Predicate.isTagged(error, "StackOwnershipConflictError") || - Predicate.isTagged(error, "StackLifecycleConflictError") || - Predicate.isTagged(error, "StackStateInvalidError") || - Predicate.isTagged(error, "InvalidLogCursorError") || - Predicate.isTagged(error, "StackCleanupError") || - Predicate.isTagged(error, "StackStateFormatUnsupportedError") || - Predicate.isTagged(error, "StackUpgradeRequiredError") - ) - return error; + if (isPublicStackError(error)) return error; if ( Predicate.isTagged(error, "RpcClientError") || Predicate.isTagged(error, "SocketError") || diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index ab69b17a2a..0c1bdf20d9 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -40,6 +40,8 @@ import { compileStack } from "../model/Compiler.ts"; import { StackDestructionError, StackCleanupError, + ContainerEngineError, + InvalidLogCursorError, StackOwnershipConflictError, StackPreparationError, StackRuntimeMismatchError, @@ -60,7 +62,10 @@ import { CAPABILITY_NAMES } from "./Capability.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; import { makeDockerEngine } from "../runtime/DockerEngine.ts"; -import type { ContainerCommandResult, ContainerCommandRunner } from "../runtime/ContainerEngine.ts"; +import { + type ContainerCommandResult, + type ContainerCommandRunner, +} from "../runtime/ContainerEngine.ts"; import { ContainerEngineResolver } from "../runtime/ContainerEngineResolver.ts"; const stackId = StackIdSchema.make( @@ -322,6 +327,59 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("propagates malformed log cursors through the public control handle", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-cursor-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "cursor-session"; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => Effect.succeed(runningStatus), + destroy: () => Effect.void, + logs: () => + Effect.fail({ tag: "InvalidLogCursorError", message: "Log cursor is invalid" }), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some(owner)), + }); + const result = yield* stack.logs({ cursor: { opaque: "not-a-cursor" } }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBeInstanceOf( + InvalidLogCursorError, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("delivers final followed entries once and completes after stop", () => withRuntimeRoot((_project) => // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- test-only handle seam @@ -663,6 +721,71 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live( + "surfaces a container engine failure during public start", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-effect-stack-start-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "start-error-session"; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => + Effect.fail({ + tag: "ContainerEngineError", + message: "Container engine command failed while starting database", + }), + destroy: () => Effect.void, + logs: () => emptyLogs(), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some(owner)), + }); + const result = yield* stack.start().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(ContainerEngineError); + expect(failure.value.message).toContain("Container engine command failed"); + } + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + 240_000, + ); + it.live("cancels unfinished direct preparation while retaining completed artifacts", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index b369958556..25056a10f5 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -13,7 +13,7 @@ import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./Effe import type { StackLogEntry } from "./Logs.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; -import { InvalidLogCursorError, InvalidStackConfigError } from "./Errors.ts"; +import { InvalidStackConfigError } from "./Errors.ts"; import { adaptEffectStack, makePromiseApi, type PromiseStack } from "./PromiseStack.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -106,17 +106,6 @@ describe("Promise stack facade", () => { expect(followed.every((entry) => entry.source === "auth")).toBe(true); }); - it("preserves malformed log cursor errors through the Promise facade", async () => { - const source: EffectStack = { - ...effectStack(), - logs: () => Effect.fail(new InvalidLogCursorError({ message: "Log cursor is invalid" })), - }; - const stack = adaptEffectStack(source); - await expect(stack.logs({ cursor: { opaque: "not-a-cursor" } })).rejects.toBeInstanceOf( - InvalidLogCursorError, - ); - }); - it("unwraps every credential secret without a lifecycle close operation", async () => { const stack: PromiseStack = adaptEffectStack(effectStack()); diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 5d0688c655..b9eb67d45a 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -38,6 +38,7 @@ import { type RuntimeDriver, type RuntimeWorkloadKey, } from "./RuntimeDriver.ts"; +import { ContainerEngineError } from "../public/Errors.ts"; export interface ContainerRuntimeOptions { readonly engine: ContainerEngine; @@ -212,6 +213,16 @@ const toDriverError = ( cause: error, }); +const toContainerEngineError = ( + engine: ContainerEngine["kind"], + error: unknown, +): ContainerEngineError => + new ContainerEngineError({ + engine, + message: error instanceof Error ? error.message : String(error), + cause: error, + }); + const containerArtifact = (workload: PlannedWorkload): ContainerArtifact | undefined => workload.selected.kind === "container" ? workload.selected : undefined; @@ -258,7 +269,11 @@ export const makeContainerRuntime = ( key: Pick, effect: Effect.Effect, ): Effect.Effect => - effect.pipe(Effect.mapError((error) => toDriverError(key, error))); + effect.pipe( + Effect.mapError((error) => + toDriverError(key, toContainerEngineError(options.engine.kind, error)), + ), + ); const stopLogs = (resource: ContainerRuntimeResource): Effect.Effect => resource.logFiber === undefined ? Effect.void : Fiber.interrupt(resource.logFiber); @@ -311,7 +326,9 @@ export const makeContainerRuntime = ( }) .pipe(Effect.asVoid), ), - Effect.mapError((error) => toDriverError(resource.key, error)), + Effect.mapError((error) => + toDriverError(resource.key, toContainerEngineError(options.engine.kind, error)), + ), Effect.catch((error) => reportFailure( resource, @@ -434,7 +451,9 @@ export const makeContainerRuntime = ( }) .pipe(Effect.asVoid), ), - Effect.mapError((error) => toDriverError(key, error)), + Effect.mapError((error) => + toDriverError(key, toContainerEngineError(options.engine.kind, error)), + ), ); logFiber = yield* Effect.forkIn(consume, runtimeScope); } diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 658b93b346..1aa37b00c9 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -25,6 +25,7 @@ import { makePodmanEngine, serializePodmanCommand } from "./PodmanEngine.ts"; import { makeContainerRuntime } from "./ContainerRuntime.ts"; import { RuntimeDriverError, type RuntimeWorkloadKey } from "./RuntimeDriver.ts"; import { LogStoreError, type LogRecord, type LogStore } from "../supervisor/LogStore.ts"; +import { ContainerEngineError } from "../public/Errors.ts"; const makeControlledCommandRunner = ( options: Omit & { readonly executable?: string }, @@ -63,6 +64,9 @@ const commandResult = (value: unknown): ContainerCommandResult => ({ exitCode: 0, }); +const errorOf = (exit: Exit.Exit): E | undefined => + Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; + interface FakeContainerState { resources: Array; imagePresent: boolean; @@ -2155,4 +2159,30 @@ describe("container runtime", () => { expect(failedState.calls).not.toContain("create-container"); }), ); + + it.live("preserves container engine identity for runtime operations after preflight", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + inspectImageFailure: new ContainerCommandError({ + operation: "inspect-image", + message: "daemon rejected image inspection", + }), + }; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + }); + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const failure = errorOf(result); + expect(failure).toBeInstanceOf(RuntimeDriverError); + expect(failure?.cause).toBeInstanceOf(ContainerEngineError); + expect(failure?.cause).toMatchObject({ message: "daemon rejected image inspection" }); + }), + ); }); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 2d1fe0cec6..396b3e83fa 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -206,45 +206,72 @@ export const makeLifecycleController = ( if (initial.desiredLifecycle === "destroying") return yield* lifecycleConflict("Stack is being destroyed"); - const candidate = yield* materializeCandidate(initial, initial.runtime, supplied); + const freshSession = + initial.desiredLifecycle === "running" && startOptions?.freshSession === true; + const persistStoppedAfterFailure = ( + primary: Cause.Cause, + cleanup: boolean, + ): Effect.Effect => + Effect.gen(function* () { + const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); + let cause = primary; + if (Exit.isFailure(current)) { + cause = Cause.combine(cause, current.cause); + } else if (current.value === undefined) { + cause = Cause.combine(cause, Cause.fail(missingState(options.stackId))); + } else { + const persisted = yield* options.stateStore + .replace(options.stackId, { + ...current.value, + desiredLifecycle: "stopped" as const, + }) + .pipe(Effect.exit); + if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); + } + if (cleanup) { + const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); + if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); + } + return yield* Effect.failCause(cause); + }); + + const materialized = yield* materializeCandidate(initial, initial.runtime, supplied).pipe( + Effect.exit, + ); + if (Exit.isFailure(materialized)) { + if (freshSession) return yield* persistStoppedAfterFailure(materialized.cause, false); + return yield* Effect.failCause(materialized.cause); + } + const candidate = materialized.value; if (initial.desiredLifecycle === "running") { if ( supplied !== undefined && (initial.definition === undefined || !sameDefinition(candidate.definition, initial.definition)) - ) - return yield* new StackMustBeStoppedError({ + ) { + const error = new StackMustBeStoppedError({ stackId: options.stackId, message: "Running stack input changed; stop the stack before applying it", guidance: "Use stop() followed by start() to apply stopped-time changes", }); - if (supplied !== undefined && !sameSecrets(candidate.secrets, initial.secrets)) - return yield* new StackMustBeStoppedError({ + if (freshSession) return yield* persistStoppedAfterFailure(Cause.fail(error), false); + return yield* error; + } + if (supplied !== undefined && !sameSecrets(candidate.secrets, initial.secrets)) { + const error = new StackMustBeStoppedError({ stackId: options.stackId, message: "Running stack secrets changed; stop the stack before applying them", guidance: "Use stop() followed by start() to apply stopped-time changes", }); - const freshSession = startOptions?.freshSession === true; + if (freshSession) return yield* persistStoppedAfterFailure(Cause.fail(error), false); + return yield* error; + } if (freshSession) { const preflighted = yield* options.backend .preflight(lifecycleInput(options.stackId, initial, candidate)) .pipe(Effect.exit); - if (Exit.isFailure(preflighted)) { - const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); - const stopped = - Exit.isSuccess(current) && current.value !== undefined - ? { ...current.value, desiredLifecycle: "stopped" as const } - : { ...initial, desiredLifecycle: "stopped" as const }; - const persisted = yield* options.stateStore - .replace(options.stackId, stopped) - .pipe(Effect.exit); - const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); - let cause = preflighted.cause; - if (Exit.isFailure(current)) cause = Cause.combine(cause, current.cause); - if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); - if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); - return yield* Effect.failCause(cause); - } + if (Exit.isFailure(preflighted)) + return yield* persistStoppedAfterFailure(preflighted.cause, false); } const launched = yield* options.backend .launch( @@ -252,22 +279,8 @@ export const makeLifecycleController = ( freshSession ? "fresh" : "current", ) .pipe(Effect.exit); - if (Exit.isFailure(launched) && freshSession) { - const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); - const stopped = - Exit.isSuccess(current) && current.value !== undefined - ? { ...current.value, desiredLifecycle: "stopped" as const } - : { ...initial, desiredLifecycle: "stopped" as const }; - const persisted = yield* options.stateStore - .replace(options.stackId, stopped) - .pipe(Effect.exit); - const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); - let cause = launched.cause; - if (Exit.isFailure(current)) cause = Cause.combine(cause, current.cause); - if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); - if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); - return yield* Effect.failCause(cause); - } + if (Exit.isFailure(launched) && freshSession) + return yield* persistStoppedAfterFailure(launched.cause, true); if (Exit.isFailure(launched)) return yield* Effect.failCause(launched.cause); return initial; } @@ -283,20 +296,7 @@ export const makeLifecycleController = ( // A failed cold launch never leaves a durable running intent behind. Cleanup is attempted // before publishing the stopped state; if cleanup is not proven, the stopped fence remains // durable and the Supervisor stays available for an explicit retry. - const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); - const stopped = - Exit.isSuccess(current) && current.value !== undefined - ? { ...current.value, desiredLifecycle: "stopped" as const } - : { ...next, desiredLifecycle: "stopped" as const }; - const persisted = yield* options.stateStore - .replace(options.stackId, stopped) - .pipe(Effect.exit); - const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); - let cause = started.cause; - if (Exit.isFailure(current)) cause = Cause.combine(cause, current.cause); - if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); - if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); - return yield* Effect.failCause(cause); + return yield* persistStoppedAfterFailure(started.cause, true); }); }; diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index 9faf049758..b26b6ca99f 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -103,7 +103,7 @@ const parseOpaqueCursor = (path: string, opaque: string): Effect.Effect => { @@ -113,13 +113,6 @@ const parseQueryCursor = ( ); }; -const decodeCursor = ( - path: string, - cursor: LogCursor | undefined, -): Effect.Effect => { - return parseQueryCursor(path, cursor); -}; - const selected = ( entries: ReadonlyArray, options: LogStoreQuery | undefined, diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 41b1004210..522adb5467 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -704,18 +704,17 @@ export const makeSupervisor = ( stop: stopWithShutdown.pipe( Effect.provideContext(options.context), Effect.as({ ok: true, op: "stop" } satisfies MaintenanceResponse), - Effect.catch((error) => - Effect.succeed({ + Effect.catch((error) => { + const stackErrorTag = maintenanceStackErrorTag(error); + return Effect.succeed({ ok: false, error: { tag: "operation-failed", message: stateErrorMessage(error), - ...(maintenanceStackErrorTag(error) === undefined - ? {} - : { stackErrorTag: maintenanceStackErrorTag(error) }), + ...(stackErrorTag === undefined ? {} : { stackErrorTag }), }, - } satisfies MaintenanceResponse), - ), + } satisfies MaintenanceResponse); + }), ), }; const credentials: Effect.Effect = Effect.gen( diff --git a/packages/stack/src/supervisor/lifecycle.integration.test.ts b/packages/stack/src/supervisor/lifecycle.integration.test.ts index a1bb9c9fb2..c986c83156 100644 --- a/packages/stack/src/supervisor/lifecycle.integration.test.ts +++ b/packages/stack/src/supervisor/lifecycle.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, FileSystem, Option, Redacted } from "effect"; +import { Cause, Deferred, Effect, Exit, FileSystem, Option, Redacted, Ref } from "effect"; import { deriveStackId } from "../identity/Identity.ts"; import { StackRuntimeError, @@ -11,7 +11,7 @@ import { } from "../public/Errors.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; import { makeLifecycleController, type LifecycleBackend, @@ -45,6 +45,7 @@ interface BackendState { preflightStarted?: Deferred.Deferred; waitBeforeLaunch?: Deferred.Deferred; launchMutation?: () => Effect.Effect; + failCurrentRead?: Ref.Ref; stopLaunchStarted?: Deferred.Deferred; stopLaunchGate?: Deferred.Deferred; startLaunchStarted?: Deferred.Deferred; @@ -105,13 +106,26 @@ const makeFixture = (runtime: StackRuntime = { kind: "native" }) => secrets: {}, }); const state: BackendState = { calls: [], preflight: [] }; + const persistedStore: StackStateStore = { + ...store, + read: (stackId) => + Effect.gen(function* () { + if (state.failCurrentRead !== undefined && (yield* Ref.get(state.failCurrentRead))) { + yield* Ref.set(state.failCurrentRead, false); + return yield* new StackStateInvalidError({ + message: "injected current-state read failure", + }); + } + return yield* store.read(stackId); + }), + }; const controller = yield* makeLifecycleController({ stackId: id, runtime, - stateStore: store, + stateStore: persistedStore, backend: backend(state), }); - return { id, root, store, state, controller }; + return { id, root, store: persistedStore, state, controller }; }); const run = (effect: Effect.Effect) => @@ -209,6 +223,22 @@ describe("durable lifecycle controller", () => { ), ); + it.live("does not write a stale stopped state when current-state read fails", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + fixture.state.failLaunch = true; + const failCurrentRead = yield* Ref.make(false); + fixture.state.failCurrentRead = failCurrentRead; + fixture.state.launchMutation = () => Ref.set(failCurrentRead, true); + const failed = yield* fixture.controller.start().pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); + expect(fixture.state.calls).toContain("cleanup:running"); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); + }), + ), + ); + it.live("accepts explicit materialized defaults for a running stack", () => run( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 4376fe931e..80382fe504 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -29,6 +29,7 @@ import { StackCleanupError, StackStateInvalidError, StackMustBeStoppedError, + StackVersionUnsupportedError, } from "../public/Errors.ts"; import { RuntimeDriverError, @@ -763,6 +764,69 @@ describe("Supervisor composition", () => { ), ); + it.live("persists stopped after a proven fresh-session materialization failure", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: {} }); + yield* Ref.set(fixture.calls, []); + const successor = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "materialization-successor", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + const failed = yield* successor + .start({ config: { capabilities: { database: { version: "99" } } } }) + .pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(errorOf(failed)).toBeInstanceOf(StackVersionUnsupportedError); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + expect((yield* successor.status).lifecycle).toBe("stopped"); + expect(yield* Ref.get(fixture.calls)).toEqual(["cleanup:stop"]); + yield* successor.shutdownIfIdle; + yield* successor.shutdown; + const retryOwner = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "materialization-retry", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + expect((yield* retryOwner.start()).lifecycle).toBe("running"); + }), + ), + ); + + it.live("persists stopped after a proven fresh-session changed-input rejection", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: {} }); + const successor = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "changed-input-successor", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + const failed = yield* successor + .start({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } } }) + .pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(errorOf(failed)).toBeInstanceOf(StackMustBeStoppedError); + expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("stopped"); + expect((yield* successor.status).lifecycle).toBe("stopped"); + yield* successor.shutdownIfIdle; + yield* successor.shutdown; + }), + ), + ); + it.live("keeps stopping when fresh-ingress cleanup fails, then recovers on explicit stop", () => run( Effect.gen(function* () { From eb7b25147f23a77fdccb102e3d5aa79d38353d06 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 13:28:07 +0200 Subject: [PATCH 132/174] chore(stack): preserve follower failures and cold start state --- .../stack/src/runtime/ContainerRuntime.ts | 21 ++- .../stack/src/runtime/ProductionRuntime.ts | 11 +- .../container-runtime.integration.test.ts | 122 +++++++++++++++++- packages/stack/src/supervisor/Lifecycle.ts | 53 ++++---- packages/stack/src/supervisor/Supervisor.ts | 2 +- .../supervisor/supervisor.integration.test.ts | 43 +++++- 6 files changed, 213 insertions(+), 39 deletions(-) diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index b9eb67d45a..2ce60b3dfe 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -4,6 +4,7 @@ import { Effect, Exit, Fiber, + Option, Ref, Scope, Semaphore, @@ -289,12 +290,15 @@ export const makeContainerRuntime = ( const current = resources.get(resourceKey(resource.key)); if (resource.stopRequested || current !== resource || resource.state === "failed") return Effect.void; - const failure = new RuntimeDriverError({ - message, - stackId: resource.key.stackId, - workloadId: resource.key.workloadId, - cause: error, - }); + const failure = + error instanceof RuntimeDriverError + ? error + : new RuntimeDriverError({ + message, + stackId: resource.key.stackId, + workloadId: resource.key.workloadId, + cause: error, + }); resources.set(resourceKey(resource.key), { ...resource, state: "failed", @@ -355,7 +359,10 @@ export const makeContainerRuntime = ( : reportFailure( resource, Exit.isFailure(result) - ? result.cause + ? Option.getOrElse( + Cause.findErrorOption(result.cause), + () => new Error("Container wait failed"), + ) : new Error(`Container exited with code ${String(result.value)}`), ), ), diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 1743fc8b89..ab45f0890d 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -921,7 +921,16 @@ export const makeProductionRuntime = ( let route = yield* Ref.get(hostRoute); if (route === undefined) { route = yield* containerEngine.preflight.pipe( - Effect.mapError((error) => mapDriverError(key, error)), + Effect.mapError((error) => + mapDriverError( + key, + containerEngineError( + containerEngine.kind, + "Container host route preflight failed", + error, + ), + ), + ), ); yield* Ref.set(hostRoute, route); } diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 1aa37b00c9..9d6bd9caf3 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Option, Stream } from "effect"; +import { + Cause, + Crypto, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Option, + Path, + Stream, +} from "effect"; import * as TestClock from "effect/testing/TestClock"; import { NodeServices } from "@effect/platform-node"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; @@ -26,6 +37,10 @@ import { makeContainerRuntime } from "./ContainerRuntime.ts"; import { RuntimeDriverError, type RuntimeWorkloadKey } from "./RuntimeDriver.ts"; import { LogStoreError, type LogRecord, type LogStore } from "../supervisor/LogStore.ts"; import { ContainerEngineError } from "../public/Errors.ts"; +import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { makeSupervisor, type SupervisorRuntime } from "../supervisor/Supervisor.ts"; +import type { SupervisorIngress } from "../supervisor/Ingress.ts"; +import { deriveStackId } from "../identity/Identity.ts"; const makeControlledCommandRunner = ( options: Omit & { readonly executable?: string }, @@ -1242,7 +1257,8 @@ describe("container runtime", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) { expect(error.value).toBeInstanceOf(RuntimeDriverError); - expect(error.value.message).toContain("Container log stream failed"); + expect(error.value.message).toBe("restart-session follower disconnected"); + expect(error.value.cause).toBeInstanceOf(ContainerEngineError); } } expect(state.resources.some((resource) => resource.kind === "workload")).toBe(false); @@ -2185,4 +2201,106 @@ describe("container runtime", () => { expect(failure?.cause).toMatchObject({ message: "daemon rejected image inspection" }); }), ); + + it.live("reports container engine identity when a log follower fails before readiness", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-container-follower-" }); + const testStackId = yield* deriveStackId({ + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "container-follower", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.initialize(testStackId, { + format: "supabase-stack-state-v1", + identity: { + stackId: testStackId, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + branchContext: "ordinary-workspace", + localProjectKey: ".", + stackName: "container-follower", + }, + runtime: { kind: "container", engine: "docker" }, + desiredLifecycle: "unconfigured", + ports: [], + privatePorts: [], + secrets: {}, + }); + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const engine: ContainerEngine = { + ...fakeContainerEngine(state), + waitContainer: () => Effect.never, + streamLogs: () => + Stream.fail( + new ContainerEngineProtocolError({ + operation: "logs", + message: "follower disconnected before readiness", + }), + ), + }; + const logStore = memoryLogStore([]); + const driver = yield* makeContainerRuntime({ + engine, + ownerSessionId: "owner-session", + logStore, + resolveWorkload: () => Effect.succeed({ waitForReadiness: () => Effect.never }), + }); + const ingress: SupervisorIngress = { + acquire: () => + Effect.succeed({ + assignments: {}, + privateAssignments: [], + hostListeners: [], + fresh: false, + ownershipToken: Symbol(), + }), + open: () => Effect.void, + close: Effect.void, + }; + const runtime: SupervisorRuntime = { + driver, + preflight: () => Effect.void, + activate: () => Effect.succeed({ host: "127.0.0.1", port: 9999 }), + ingress, + logStore, + }; + const context = yield* Effect.context(); + const supervisor = yield* makeSupervisor({ + stackId: testStackId, + ownerSessionId: "owner-session", + rpcRelease: "test-release", + stateStore: store, + context, + runtime, + }); + const result = yield* supervisor.start().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(ContainerEngineError); + expect(failure.value.message).toContain("follower disconnected before readiness"); + } + } + yield* supervisor.shutdownIfIdle; + yield* supervisor.shutdown; + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 396b3e83fa..888d1c1df7 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -196,6 +196,32 @@ export const makeLifecycleController = ( : Effect.succeed(state), ), ); + const persistStoppedAfterFailure = ( + primary: Cause.Cause, + cleanup: boolean, + ): Effect.Effect => + Effect.gen(function* () { + const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); + let cause = primary; + if (Exit.isFailure(current)) { + cause = Cause.combine(cause, current.cause); + } else if (current.value === undefined) { + cause = Cause.combine(cause, Cause.fail(missingState(options.stackId))); + } else { + const persisted = yield* options.stateStore + .replace(options.stackId, { + ...current.value, + desiredLifecycle: "stopped" as const, + }) + .pipe(Effect.exit); + if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); + } + if (cleanup) { + const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); + if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); + } + return yield* Effect.failCause(cause); + }); const start = ( startOptions?: LifecycleStartOptions, @@ -208,33 +234,6 @@ export const makeLifecycleController = ( const freshSession = initial.desiredLifecycle === "running" && startOptions?.freshSession === true; - const persistStoppedAfterFailure = ( - primary: Cause.Cause, - cleanup: boolean, - ): Effect.Effect => - Effect.gen(function* () { - const current = yield* options.stateStore.read(options.stackId).pipe(Effect.exit); - let cause = primary; - if (Exit.isFailure(current)) { - cause = Cause.combine(cause, current.cause); - } else if (current.value === undefined) { - cause = Cause.combine(cause, Cause.fail(missingState(options.stackId))); - } else { - const persisted = yield* options.stateStore - .replace(options.stackId, { - ...current.value, - desiredLifecycle: "stopped" as const, - }) - .pipe(Effect.exit); - if (Exit.isFailure(persisted)) cause = Cause.combine(cause, persisted.cause); - } - if (cleanup) { - const cleaned = yield* options.backend.cleanup.pipe(Effect.exit); - if (Exit.isFailure(cleaned)) cause = Cause.combine(cause, cleaned.cause); - } - return yield* Effect.failCause(cause); - }); - const materialized = yield* materializeCandidate(initial, initial.runtime, supplied).pipe( Effect.exit, ); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 522adb5467..9e07f655d6 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -587,7 +587,7 @@ export const makeSupervisor = ( }) .pipe(Effect.provideContext(options.context), Effect.exit); if (Exit.isFailure(started)) { - if (freshSession) { + if (freshSession || previous === "stopped") { const durable = yield* read().pipe(Effect.exit); const canReportStopped = (yield* Ref.get(cleanupProven)) && diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 80382fe504..c164f4b294 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -120,6 +120,7 @@ const makeFixture = ( readonly destroyStarted?: Deferred.Deferred; readonly destroyPreFenceFail?: Ref.Ref; readonly stoppedReplaceFail?: Ref.Ref; + readonly startReadFail?: Ref.Ref; readonly startQueue?: Queue.Queue; readonly shutdownReadArmed?: Ref.Ref; readonly shutdownReadStarted?: Deferred.Deferred; @@ -161,7 +162,7 @@ const makeFixture = ( return yield* baseStore.replace(stackId, state); }), }; - const store = + const runtimeStore = fixtureOptions.shutdownReadArmed === undefined || fixtureOptions.shutdownReadStarted === undefined || fixtureOptions.shutdownReadGate === undefined @@ -181,6 +182,23 @@ const makeFixture = ( return yield* persistedStore.read(stackId); }), }; + const store = + fixtureOptions.startReadFail === undefined + ? runtimeStore + : { + ...runtimeStore, + read: (stackId: string) => + Effect.gen(function* () { + const fail = yield* Ref.get(fixtureOptions.startReadFail!); + if (fail) { + yield* Ref.set(fixtureOptions.startReadFail!, false); + return yield* new StackStateInvalidError({ + message: "injected start state reread failure", + }); + } + return yield* runtimeStore.read(stackId); + }), + }; yield* store.initialize(id, { format: "supabase-stack-state-v1", identity: { ...identity, stackId: id }, @@ -231,6 +249,8 @@ const makeFixture = ( const remaining = yield* Ref.get(fixtureOptions.startFailures); if (remaining > 0) { yield* Ref.set(fixtureOptions.startFailures, remaining - 1); + if (fixtureOptions.startReadFail !== undefined) + yield* Ref.set(fixtureOptions.startReadFail, true); if (fixtureOptions.stopFailFirst !== undefined) yield* Ref.set(fixtureOptions.stopFailFirst, true); const failed = { ...key, state: "failed" as const, error: "injected start failure" }; @@ -728,6 +748,27 @@ describe("Supervisor composition", () => { ), ); + it.live("reports stopping after a cold launch failure loses its state reread", () => + run( + Effect.gen(function* () { + const startFailures = yield* Ref.make(0); + const startReadFail = yield* Ref.make(false); + const fixture = yield* makeFixture({ startFailures, startReadFail }); + const config = { capabilities: { functions: { activation: "eager" as const } } }; + yield* fixture.supervisor.start({ config }); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + yield* Ref.set(startFailures, 1); + + const failed = yield* fixture.supervisor.start({ config }).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + }), + ), + ); + it.live("persists stopped after a proven fresh-session preflight failure", () => run( Effect.gen(function* () { From 62564c9eba260667075a7544e998f460391d3765 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 13:48:13 +0200 Subject: [PATCH 133/174] chore(stack): settle final lifecycle failure paths --- .../stack/src/runtime/ContainerRuntime.ts | 8 +- .../container-runtime.integration.test.ts | 35 ++++++++ packages/stack/src/supervisor/Supervisor.ts | 6 +- .../supervisor/supervisor.integration.test.ts | 90 +++++++++---------- 4 files changed, 81 insertions(+), 58 deletions(-) diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 2ce60b3dfe..b8ab12fccc 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -333,13 +333,7 @@ export const makeContainerRuntime = ( Effect.mapError((error) => toDriverError(resource.key, toContainerEngineError(options.engine.kind, error)), ), - Effect.catch((error) => - reportFailure( - resource, - error, - `Container log stream failed for ${resource.key.workloadId}`, - ), - ), + Effect.catch((error) => reportFailure(resource, error)), ); return Effect.gen(function* () { resource.logFiber = yield* Effect.forkIn(consume, runtimeScope); diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 9d6bd9caf3..3cb882b6a1 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -499,6 +499,40 @@ describe("container runtime", () => { }), ); + it.live("preserves the container engine cause when startup wait fails", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const protocol = new ContainerEngineProtocolError({ + operation: "wait-container", + message: "wait endpoint failed", + }); + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + waitContainer: () => Effect.fail(protocol), + }, + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + startup: [{ entrypoint: "/usr/local/bin/auth", command: ["migrate"] }], + }), + }); + + const result = yield* runtime.start(key, workload()).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const error = errorOf(result); + expect(error).toBeInstanceOf(RuntimeDriverError); + expect(error?.cause).toBeInstanceOf(ContainerEngineError); + }), + ); + it.live("publishes an unexpected container workload exit after readiness", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -2298,6 +2332,7 @@ describe("container runtime", () => { expect(failure.value.message).toContain("follower disconnected before readiness"); } } + expect((yield* supervisor.status).lifecycle).toBe("stopped"); yield* supervisor.shutdownIfIdle; yield* supervisor.shutdown; }), diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 9e07f655d6..a6cc60f6c1 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -214,7 +214,7 @@ export const makeSupervisor = ( if (state?.desiredLifecycle === "destroying") next = "destroying"; else if (state?.desiredLifecycle === "running") { if (currentPhase === "running" || previous === "running") next = "running"; - else next = "starting"; + else next = "stopping"; } yield* Ref.set(phase, next); }); @@ -592,8 +592,8 @@ export const makeSupervisor = ( const canReportStopped = (yield* Ref.get(cleanupProven)) && Exit.isSuccess(durable) && - durable.value !== undefined && - (durable.value.desiredLifecycle === "stopped" || + (durable.value === undefined || + durable.value.desiredLifecycle === "stopped" || durable.value.desiredLifecycle === "unconfigured"); yield* Ref.set(phase, canReportStopped ? "stopped" : "stopping"); } else yield* restorePhase(previous); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index c164f4b294..81512b3801 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -120,7 +120,6 @@ const makeFixture = ( readonly destroyStarted?: Deferred.Deferred; readonly destroyPreFenceFail?: Ref.Ref; readonly stoppedReplaceFail?: Ref.Ref; - readonly startReadFail?: Ref.Ref; readonly startQueue?: Queue.Queue; readonly shutdownReadArmed?: Ref.Ref; readonly shutdownReadStarted?: Deferred.Deferred; @@ -182,23 +181,7 @@ const makeFixture = ( return yield* persistedStore.read(stackId); }), }; - const store = - fixtureOptions.startReadFail === undefined - ? runtimeStore - : { - ...runtimeStore, - read: (stackId: string) => - Effect.gen(function* () { - const fail = yield* Ref.get(fixtureOptions.startReadFail!); - if (fail) { - yield* Ref.set(fixtureOptions.startReadFail!, false); - return yield* new StackStateInvalidError({ - message: "injected start state reread failure", - }); - } - return yield* runtimeStore.read(stackId); - }), - }; + const store = runtimeStore; yield* store.initialize(id, { format: "supabase-stack-state-v1", identity: { ...identity, stackId: id }, @@ -249,8 +232,6 @@ const makeFixture = ( const remaining = yield* Ref.get(fixtureOptions.startFailures); if (remaining > 0) { yield* Ref.set(fixtureOptions.startFailures, remaining - 1); - if (fixtureOptions.startReadFail !== undefined) - yield* Ref.set(fixtureOptions.startReadFail, true); if (fixtureOptions.stopFailFirst !== undefined) yield* Ref.set(fixtureOptions.stopFailFirst, true); const failed = { ...key, state: "failed" as const, error: "injected start failure" }; @@ -748,27 +729,6 @@ describe("Supervisor composition", () => { ), ); - it.live("reports stopping after a cold launch failure loses its state reread", () => - run( - Effect.gen(function* () { - const startFailures = yield* Ref.make(0); - const startReadFail = yield* Ref.make(false); - const fixture = yield* makeFixture({ startFailures, startReadFail }); - const config = { capabilities: { functions: { activation: "eager" as const } } }; - yield* fixture.supervisor.start({ config }); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - yield* Ref.set(startFailures, 1); - - const failed = yield* fixture.supervisor.start({ config }).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect(errorOf(failed)).toBeInstanceOf(StackRuntimeError); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); - expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }), - ), - ); - it.live("persists stopped after a proven fresh-session preflight failure", () => run( Effect.gen(function* () { @@ -906,14 +866,27 @@ describe("Supervisor composition", () => { run( Effect.gen(function* () { const stoppedReplaceFail = yield* Ref.make(true); - const startFailures = yield* Ref.make(1); - const fixture = yield* makeFixture({ stoppedReplaceFail, startFailures }); - const failed = yield* fixture.supervisor - .start({ config: { capabilities: { functions: { activation: "eager" } } } }) - .pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); + const fixture = yield* makeFixture({ stoppedReplaceFail }); + yield* fixture.supervisor.start({ config: {} }); + const successor = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "stopped-fence-successor", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }); + const failed = yield* successor.maintenanceHandlers.stop; + expect(failed.ok).toBe(false); expect((yield* fixture.store.read(fixture.id))?.desiredLifecycle).toBe("running"); - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + expect((yield* successor.status).lifecycle).toBe("stopping"); + expect(errorOf(yield* successor.activate("functions").pipe(Effect.exit))).toBeInstanceOf( + StackLifecycleConflictError, + ); + expect((yield* successor.maintenanceHandlers.stop).ok).toBe(true); + expect((yield* successor.status).lifecycle).toBe("stopped"); + yield* successor.shutdownIfIdle; + yield* fixture.supervisor.shutdownIfIdle; }), ), ); @@ -1269,6 +1242,27 @@ describe("Supervisor composition", () => { ), ); + it.live("shuts down after a start rejects missing state following destroy", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: {} }); + yield* fixture.supervisor.destroy; + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + + const failed = yield* fixture.supervisor.start().pipe(Effect.exit); + expect(errorOf(failed)).toBeInstanceOf(StackStateInvalidError); + yield* fixture.supervisor.shutdownIfIdle; + yield* fixture.supervisor.shutdown.pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.die("destroyed Supervisor did not shut down"), + }), + ); + }), + ), + ); + it.live("passes log query through the Supervisor log batch", () => run( Effect.gen(function* () { From 81560c6b6ab975a7d2c89923c5e4d10304399879 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 14:25:45 +0200 Subject: [PATCH 134/174] chore(stack): close final runtime review gaps --- .../stack/src/preparation/RuntimeArtifacts.ts | 16 +-- .../runtime-artifacts.integration.test.ts | 19 ++- packages/stack/src/public/EffectStack.ts | 97 +++++++-------- packages/stack/src/public/Errors.ts | 13 +- .../public/effect-stack.integration.test.ts | 78 ++++++++++++ .../src/public/promise.integration.test.ts | 26 +++- packages/stack/src/runtime/ContainerEngine.ts | 9 -- .../stack/src/runtime/ProductionRuntime.ts | 47 +++++++- .../container-runtime.integration.test.ts | 36 ------ .../production-runtime.integration.test.ts | 87 ++++++++++++++ packages/stack/src/supervisor/Supervisor.ts | 20 ++-- .../supervisor/supervisor.integration.test.ts | 113 ++++++++++++++++++ 12 files changed, 419 insertions(+), 142 deletions(-) diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 4884fc2144..0501afcbdb 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -18,7 +18,7 @@ import { type PreparedArtifact, } from "./ArtifactStore.ts"; import { makeSlimServicesSource, slimServicesChecksum } from "./SlimServicesSource.ts"; -import { type ContainerEngine, type ContainerEngineKind } from "../runtime/ContainerEngine.ts"; +import type { ContainerEngine } from "../runtime/ContainerEngine.ts"; import { resolveContainerEngine, type ContainerEngineResolverShape, @@ -58,7 +58,7 @@ export interface RuntimeArtifactPreparerOptions { ) => Effect.Effect; readonly platform?: { readonly os: string; readonly arch: string }; }; - readonly containers?: Readonly>>; + readonly containerEngine?: ContainerEngine; } const error = (message: string, fields: Readonly> = {}) => @@ -113,8 +113,8 @@ export const makeRuntimeArtifactPreparer = ( workload: workload.id, }), ); - const engine = options.containers?.[runtime.engine]; - if (engine === undefined) + const engine = options.containerEngine; + if (engine === undefined || engine.kind !== runtime.engine) return Effect.fail( error("Container runtime has no configured container engine", { workload: workload.id, @@ -238,15 +238,9 @@ export const makeProductionRuntimeArtifactPreparer = (options: { }), ), )); - const containers = - selectedEngine === undefined || containerEngine === undefined - ? undefined - : selectedEngine === "docker" - ? { docker: containerEngine } - : { podman: containerEngine }; const preparer = makeRuntimeArtifactPreparer({ ...(store === undefined ? {} : { native: { store, checksum } }), - containers, + ...(containerEngine === undefined ? {} : { containerEngine }), }); return preparer; }); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 02849d7319..e4510f6f7a 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -40,8 +40,12 @@ const prepared = (request: ArtifactRequest): PreparedArtifact => ({ outcome: "downloaded", }); -const containerEngine = (present: boolean, calls: string[]): ContainerEngine => ({ - kind: "docker", +const containerEngine = ( + present: boolean, + calls: string[], + kind: ContainerEngine["kind"] = "docker", +): ContainerEngine => ({ + kind, executable: "docker", preflight: Effect.succeed({ host: "host.docker.internal" }), probe: Effect.sync(() => { @@ -161,7 +165,7 @@ describe("runtime artifact preparation", () => { const engine = containerEngine(false, calls); const runtime = makeRuntimeArtifactPreparer({ native: { store: { prepare: () => Effect.die("unused") } }, - containers: { docker: engine, podman: engine }, + containerEngine: engine, }); const result = Effect.runSync( runtime.prepare( @@ -181,14 +185,10 @@ describe("runtime artifact preparation", () => { }); it("uses the persisted container engine identity", () => { - const dockerCalls: string[] = []; const podmanCalls: string[] = []; const runtime = makeRuntimeArtifactPreparer({ native: { store: { prepare: () => Effect.die("unused") } }, - containers: { - docker: containerEngine(true, dockerCalls), - podman: containerEngine(true, podmanCalls), - }, + containerEngine: containerEngine(true, podmanCalls, "podman"), }); Effect.runSync( runtime.prepare( @@ -200,7 +200,6 @@ describe("runtime artifact preparation", () => { ), ); expect(podmanCalls).toContain("probe"); - expect(dockerCalls).toEqual([]); }); it("reports unavailable container engines separately from artifact preparation", () => { @@ -215,7 +214,7 @@ describe("runtime artifact preparation", () => { ), }; const runtime = makeRuntimeArtifactPreparer({ - containers: { docker: unavailable }, + containerEngine: unavailable, }); const exit = Effect.runSyncExit( runtime.prepare( diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 662aa18e4d..32b15c267f 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -73,7 +73,9 @@ import { type DestroyStackError, type StackError, type StackErrorTag, + isStackError, isStackErrorTag, + PREPARE_STACK_ERROR_TAGS, STACK_STATUS_ERROR_TAGS, STACK_CREDENTIALS_ERROR_TAGS, STACK_START_ERROR_TAGS, @@ -218,13 +220,8 @@ const stackErrorFactories = { StackDestructionError: (message: string) => new StackDestructionError({ message }), } satisfies Record StackError>; -const isPublicStackError = (error: unknown): error is StackError => - Predicate.hasProperty(error, "_tag") && - typeof error._tag === "string" && - isStackErrorTag(error._tag); - const errorForRpc = (error: ControlError): StackError => { - if (isPublicStackError(error)) return error; + if (isStackError(error)) return error; if ( Predicate.isTagged(error, "RpcClientError") || Predicate.isTagged(error, "SocketError") || @@ -429,15 +426,16 @@ export const makeHandle = ( readOfflineState().pipe( Effect.mapError(statusError), Effect.flatMap((state) => - Option.isSome(state) && - (state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured") - ? statusFor(state.value, [], new Set(), "stopped").pipe( - Effect.mapError(statusError), - ) - : Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ), + Option.isNone(state) + ? Effect.fail(new StackNotFoundError({ message: "Stack state was not found" })) + : state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured" + ? statusFor(state.value, [], new Set(), "stopped").pipe( + Effect.mapError(statusError), + ) + : Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ), ), ), ), @@ -471,7 +469,9 @@ export const makeHandle = ( ); }; const logsStateError = (error: StackError): StackLogsError => - error instanceof StackOwnershipConflictError || error instanceof StackStateInvalidError + error instanceof StackNotFoundError || + error instanceof StackOwnershipConflictError || + error instanceof StackStateInvalidError ? error : new StackStateInvalidError({ message: error.message, cause: error }); const stopOwner = (owner: ReturnType) => @@ -547,17 +547,16 @@ export const makeHandle = ( ); if (readOfflineState === undefined || readPersistedState === undefined) return Effect.fail(ownershipError); - const ownerStopped = readPersistedState().pipe( - Effect.map( - (state) => - Option.isSome(state) && - (state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured"), - ), - Effect.mapError(logsStateError), - ); + const ownerStopped = readPersistedState().pipe(Effect.mapError(logsStateError)); return ownerStopped.pipe( - Effect.flatMap((teardown) => { + Effect.flatMap((state) => { + if (Option.isNone(state)) + return Effect.fail( + new StackNotFoundError({ message: "Stack state was not found" }), + ); + const teardown = + state.value.desiredLifecycle === "stopped" || + state.value.desiredLifecycle === "unconfigured"; if (!teardown) return Effect.fail(ownershipError); return Effect.suspend(() => // During an owner stop the control socket can close before its metadata/lease are @@ -589,14 +588,14 @@ export const makeHandle = ( followLogs: (query) => Stream.paginate({ cursor: query?.cursor, first: true }, ({ cursor, first }) => { const { cursor: _initialCursor, tail: _tail, ...baseQuery } = query ?? {}; - const options = { + const pollQuery = { ...baseQuery, ...(first && query?.tail !== undefined ? { tail: query.tail } : {}), ...(cursor === undefined || cursor.opaque === EMPTY_LOG_CURSOR.opaque ? {} : { cursor }), }; - const request = logs(options); + const request = logs(pollQuery); const delayed = first ? request : Effect.schedule(Effect.void, Schedule.duration("100 millis")).pipe( @@ -708,33 +707,22 @@ const handleDependencies = (options: { ); const readPersistedState = () => provide(options.store.read(options.id)).pipe(Effect.map(optionOf)); - const directPrepareError = (cause: unknown): PrepareStackError => - cause instanceof StackPreparationError || - cause instanceof ArtifactIntegrityError || - cause instanceof ContainerPullError || - cause instanceof ContainerEngineError || - cause instanceof StackOwnershipConflictError || - cause instanceof StackStateInvalidError || - cause instanceof StackLifecycleConflictError - ? cause - : new StackPreparationError({ - stackId: options.id, - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); + const directPrepareError = (cause: unknown): PrepareStackError => { + if (isStackError(cause) && isNarrowError(cause, PREPARE_STACK_ERROR_TAGS)) return cause; + return new StackPreparationError({ + stackId: options.id, + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); + }; const prepare = ( prepareOptions?: PrepareStackOptions, ): Effect.Effect => Effect.scoped( Effect.gen(function* () { - const state = yield* options.store - .read(options.id) - .pipe( - Effect.provideService(FileSystem.FileSystem, options.fileSystem), - Effect.provideService(Path.Path, options.path), - Effect.provideService(Crypto.Crypto, options.crypto), - Effect.mapError(directPrepareError), - ); + const state = yield* provide(options.store.read(options.id)).pipe( + Effect.mapError(directPrepareError), + ); if (state === undefined) return yield* new StackStateInvalidError({ stackId: options.id, @@ -744,9 +732,7 @@ const handleDependencies = (options: { let plan: ExecutionPlan; if (prepareOptions?.config === undefined && state.definition !== undefined) { definition = state.definition; - plan = yield* rebuildExecutionPlan(state.runtime, definition).pipe( - Effect.provideService(Crypto.Crypto, options.crypto), - ); + plan = yield* rebuildExecutionPlan(state.runtime, definition); } else { const compiled = yield* compileStack( { @@ -755,10 +741,7 @@ const handleDependencies = (options: { config: prepareOptions?.config, }, state.definition === undefined ? undefined : { definition: state.definition }, - ).pipe( - Effect.provideService(Path.Path, options.path), - Effect.provideService(Crypto.Crypto, options.crypto), - ); + ).pipe(Effect.provideService(Path.Path, options.path)); definition = compiled.definition; plan = compiled.executionPlan; } diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index b80c190909..cf47a84a52 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -1,4 +1,4 @@ -import { Data } from "effect"; +import { Data, Predicate } from "effect"; /** * Error context is intentionally open while operation-specific fields settle @@ -148,6 +148,11 @@ export type StackError = | ContainerEngineError | StackDestructionError; +export const isStackError = (value: unknown): value is StackError => + Predicate.hasProperty(value, "_tag") && + typeof value._tag === "string" && + isStackErrorTag(value._tag); + type ErrorByTag = Extract; export const CREATE_STACK_ERROR_TAGS = [ @@ -201,12 +206,16 @@ export const STACK_CREDENTIALS_ERROR_TAGS = [ export type StackCredentialsError = ErrorByTag<(typeof STACK_CREDENTIALS_ERROR_TAGS)[number]>; export const PREPARE_STACK_ERROR_TAGS = [ + "InvalidStackConfigError", + "StackVersionUnsupportedError", + "InvalidProjectRootError", "StackPreparationError", "ArtifactIntegrityError", "ContainerPullError", "ContainerEngineError", "StackOwnershipConflictError", "StackStateInvalidError", + "StackStateFormatUnsupportedError", "StackLifecycleConflictError", ] as const satisfies ReadonlyArray; export type PrepareStackError = ErrorByTag<(typeof PREPARE_STACK_ERROR_TAGS)[number]>; @@ -250,6 +259,7 @@ export const STACK_LOGS_ERROR_TAGS = [ "InvalidLogCursorError", "StackOwnershipConflictError", "StackLifecycleConflictError", + "StackUpgradeRequiredError", ] as const satisfies ReadonlyArray; export type StackLogsError = ErrorByTag<(typeof STACK_LOGS_ERROR_TAGS)[number]>; @@ -260,5 +270,6 @@ export const DESTROY_STACK_ERROR_TAGS = [ "StackLifecycleConflictError", "ContainerEngineError", "StackCleanupError", + "StackUpgradeRequiredError", ] as const satisfies ReadonlyArray; export type DestroyStackError = ErrorByTag<(typeof DESTROY_STACK_ERROR_TAGS)[number]>; diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 0c1bdf20d9..b6a35a1894 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/prefer-schema-over-json -- malformed caller/state fixtures exercise public validation boundaries. import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { @@ -41,11 +42,15 @@ import { StackDestructionError, StackCleanupError, ContainerEngineError, + InvalidStackConfigError, InvalidLogCursorError, StackOwnershipConflictError, StackPreparationError, StackRuntimeMismatchError, StackStateInvalidError, + StackStateFormatUnsupportedError, + StackNotFoundError, + StackVersionUnsupportedError, StackUpgradeRequiredError, } from "./Errors.ts"; import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; @@ -598,6 +603,79 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("preserves input and version errors from prepare", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- exercise unknown caller input + const invalid = yield* stack + .prepare({ + config: JSON.parse('{"capabilities":{"rest":{"settings":{"unknown":true}}}}'), + }) + .pipe(Effect.exit); + expect(Exit.isFailure(invalid)).toBe(true); + if (Exit.isFailure(invalid)) + expect(Option.getOrUndefined(Cause.findErrorOption(invalid.cause))).toBeInstanceOf( + InvalidStackConfigError, + ); + + const unsupported = yield* stack + .prepare({ config: { capabilities: { database: { version: "99" } } } }) + .pipe(Effect.exit); + expect(Exit.isFailure(unsupported)).toBe(true); + if (Exit.isFailure(unsupported)) + expect(Option.getOrUndefined(Cause.findErrorOption(unsupported.cause))).toBeInstanceOf( + StackVersionUnsupportedError, + ); + }), + ), + ); + + it.live("preserves unsupported persisted state format from prepare", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project }); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- malformed persisted state fixture + yield* fs.writeFileString( + paths.stateDocument, + JSON.stringify({ format: "supabase-stack-v0" }), + ); + + const result = yield* stack.prepare().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBeInstanceOf( + StackStateFormatUnsupportedError, + ); + }), + ), + ); + + it.live("reports a retained handle as not found after destroy", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const stack = yield* createStack({ projectRoot: project }); + yield* stack.destroy(); + + const status = yield* stack.status().pipe(Effect.exit); + expect(Exit.isFailure(status)).toBe(true); + if (Exit.isFailure(status)) + expect(Option.getOrUndefined(Cause.findErrorOption(status.cause))).toBeInstanceOf( + StackNotFoundError, + ); + const logs = yield* stack.logs().pipe(Effect.exit); + expect(Exit.isFailure(logs)).toBe(true); + if (Exit.isFailure(logs)) + expect(Option.getOrUndefined(Cause.findErrorOption(logs.cause))).toBeInstanceOf( + StackNotFoundError, + ); + }), + ), + ); + it.live("treats an omitted container engine as Docker for runtime identity", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index 25056a10f5..752b2ac1a2 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -13,7 +13,7 @@ import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./Effe import type { StackLogEntry } from "./Logs.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; -import { InvalidStackConfigError } from "./Errors.ts"; +import { InvalidStackConfigError, StackVersionUnsupportedError } from "./Errors.ts"; import { adaptEffectStack, makePromiseApi, type PromiseStack } from "./PromiseStack.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -95,6 +95,30 @@ describe("Promise stack facade", () => { } }); + it("preserves prepare validation tags", async () => { + const root = await mkdtemp(join(tmpdir(), "supabase-promise-prepare-errors-")); + try { + const project = join(root, "project"); + await mkdir(project); + const api = makePromiseApi(NodeServices.layer, { + stateRoot: join(root, "managed", "stacks"), + tempRoot: tmpdir(), + platform: "posix", + }); + const stack = await api.createStack({ projectRoot: project, runtime: { kind: "native" } }); + await expect( + stack.prepare({ + config: JSON.parse('{"capabilities":{"rest":{"settings":{"unknown":true}}}}'), + }), + ).rejects.toBeInstanceOf(InvalidStackConfigError); + await expect( + stack.prepare({ config: { capabilities: { database: { version: "99" } } } }), + ).rejects.toBeInstanceOf(StackVersionUnsupportedError); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("returns log batches and follows from their cursor", async () => { const stack = adaptEffectStack(effectStack()); const first = await stack.logs({ capabilities: ["auth"], tail: 20 }); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 75bcde2be6..6f72e64471 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -734,12 +734,3 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai streamLogs, }; }; -export interface SelectContainerEngineOptions { - readonly preference: ContainerEngineKind; - readonly docker: ContainerEngine; - readonly podman: ContainerEngine; -} -export const selectContainerEngine = ( - options: SelectContainerEngineOptions, -): Effect.Effect => - options.preference === "docker" ? Effect.succeed(options.docker) : Effect.succeed(options.podman); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index ab45f0890d..df3eb5bc47 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -7,6 +7,7 @@ import { Effect, Exit, FileSystem, + Option, Path, Ref, Scope, @@ -40,7 +41,12 @@ import { } from "../public/Errors.ts"; import { makeSupervisorIngress, type SupervisorIngress } from "../supervisor/Ingress.ts"; import { checkHostPort } from "../supervisor/HostListener.ts"; -import { makeLogStore, type LogStore, type LogRecord } from "../supervisor/LogStore.ts"; +import { + LogStoreError, + makeLogStore, + type LogStore, + type LogRecord, +} from "../supervisor/LogStore.ts"; import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; import type { SupervisorRuntime } from "../supervisor/Supervisor.ts"; import { @@ -106,6 +112,12 @@ export interface ProductionRuntimeOptions { const preparationError = (message: string, cause?: unknown): StackPreparationError => new StackPreparationError({ message, ...(cause === undefined ? {} : { cause }) }); +const unavailableLogStore = (error: LogStoreError, path: string): LogStore => ({ + path, + append: () => Effect.fail(error), + read: () => Effect.fail(error), +}); + const driverError = ( key: Pick, message: string, @@ -543,11 +555,29 @@ export const makeProductionRuntime = ( stackId: options.stackId, })); const knownSecrets = yield* Ref.make>(new Set(stateSecrets(state))); - const baseLogs = - options.logStore ?? - (yield* makeLogStore({ path: paths.logs, knownSecrets: stateSecrets(state) }).pipe( - Effect.mapError((error) => preparationError("Unable to open stack logs", error)), - )); + const logStoreInitialization = yield* Effect.exit( + options.logStore === undefined + ? makeLogStore({ path: paths.logs, knownSecrets: stateSecrets(state) }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + ) + : Effect.succeed(options.logStore), + ); + const baseLogs: LogStore = Exit.isSuccess(logStoreInitialization) + ? logStoreInitialization.value + : unavailableLogStore( + Option.getOrElse( + Cause.findErrorOption(logStoreInitialization.cause), + () => new LogStoreError({ path: paths.logs, message: "Unable to open stack logs" }), + ), + paths.logs, + ); + const logStoreInitializationFailure = Exit.isFailure(logStoreInitialization) + ? Option.getOrElse( + Cause.findErrorOption(logStoreInitialization.cause), + () => new LogStoreError({ path: paths.logs, message: "Unable to open stack logs" }), + ) + : undefined; const logs = dynamicLogStore(baseLogs, knownSecrets); const selectedEngine = state.runtime.kind === "container" ? state.runtime.engine : undefined; const containerEngine = @@ -663,6 +693,11 @@ export const makeProductionRuntime = ( const preflight = (input: LifecycleInput): Effect.Effect => Effect.gen(function* () { + if (logStoreInitializationFailure !== undefined) + return yield* preparationError( + "Unable to open stack logs", + logStoreInitializationFailure, + ); if (!runtimeMatches(input.state.runtime, state.runtime)) return yield* new StackRuntimeMismatchError({ message: "Lifecycle runtime does not match persisted runtime", diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 3cb882b6a1..ba3101acc7 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -20,7 +20,6 @@ import { ContainerCommandError, ContainerEngineProtocolError, makeProcessCommandRunner, - selectContainerEngine, type ContainerProcessRequest, type ContainerCommandResult, type ContainerContainerSpec, @@ -249,41 +248,6 @@ describe("container runtime", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("selects only the explicitly requested engine without probing", () => - Effect.gen(function* () { - const dockerCalls: string[] = []; - const podmanCalls: string[] = []; - const docker = makeDockerEngine({ - runner: makeControlledCommandRunner({ - run: (request) => - Effect.sync(() => { - dockerCalls.push(request.args[0] ?? ""); - return commandResult({ ok: true }); - }), - }), - platform: { os: "linux", desktop: false }, - }); - const podman = makePodmanEngine({ - runner: makeControlledCommandRunner({ - run: (request) => - Effect.sync(() => { - podmanCalls.push(request.args[0] ?? ""); - return commandResult({ ok: true }); - }), - }), - platform: { os: "linux", rootless: true }, - }); - expect((yield* selectContainerEngine({ preference: "docker", docker, podman })).kind).toBe( - "docker", - ); - expect((yield* selectContainerEngine({ preference: "podman", docker, podman })).kind).toBe( - "podman", - ); - expect(dockerCalls).toEqual([]); - expect(podmanCalls).toEqual([]); - }), - ); - it.live("serializes an entrypoint override before the image", () => Effect.sync(() => { const spec: ContainerContainerSpec = { diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 6556ef4df2..a7624f48d5 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -27,6 +27,7 @@ import type { StackStateStore } from "../state/StackStateStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; +import { LogStoreError } from "../supervisor/LogStore.ts"; import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; import { makeProductionRuntime, @@ -34,6 +35,7 @@ import { readinessDeadlineFor, withOwnedRuntimeFileCleanup, } from "./ProductionRuntime.ts"; +import { makeSupervisor } from "../supervisor/Supervisor.ts"; import { RuntimeDriverError, type RuntimeDriver } from "./RuntimeDriver.ts"; import { InvalidStackConfigError, @@ -96,6 +98,25 @@ const stateStoreFor = ( cleanup: () => Effect.die("unused"), }); +const mutableStateStoreFor = (current: { + value: PersistedStackState | undefined; +}): StackStateStore => ({ + read: () => Effect.succeed(current.value), + initialize: () => Effect.die("unused"), + replace: (_stackId, state) => + Effect.sync(() => { + current.value = state; + }), + replaceUnlocked: (_stackId, state) => + Effect.sync(() => { + current.value = state; + }), + cleanup: () => + Effect.sync(() => { + current.value = undefined; + }), +}); + const memoryLogStore = (entries: StackLogEntry[]): LogStore => ({ path: "memory://production-runtime", append: (record) => @@ -298,6 +319,72 @@ const ownerInputContainerEngine = ( }; describe("production runtime", () => { + it.live("keeps cleanup available when retained logs are corrupted", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-bad-logs-" }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + }); + const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); + yield* fs.makeDirectory(path.dirname(paths.logs), { recursive: true }); + yield* fs.writeFileString(paths.logs, "not-json\n"); + const current = { + value: { + ...stateFor({}, { kind: "container", engine: "docker" }), + desiredLifecycle: "running" as const, + definition: compiled.definition, + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + }, + } satisfies { value: PersistedStackState | undefined }; + const store = mutableStateStoreFor(current); + const context = yield* Effect.context(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "bad-logs-owner", + stateStore: store, + context, + ingress, + artifactPreparer: artifacts, + containerEngine: ownerInputContainerEngine([]), + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + }); + const supervisor = yield* makeSupervisor({ + stackId, + ownerSessionId: "bad-logs-owner", + rpcRelease: "test-release", + stateStore: store, + context, + runtime, + }); + const failed = yield* supervisor.start().pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + if (Exit.isFailure(failed)) { + const error = Cause.findErrorOption(failed.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackPreparationError); + expect(error.value.cause).toBeInstanceOf(LogStoreError); + } + } + expect((yield* supervisor.maintenanceHandlers.stop).ok).toBe(true); + yield* supervisor.destroy; + expect(current.value).toBeUndefined(); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("retries a transient database bootstrap connection failure", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index a6cc60f6c1..818387ebb6 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -33,6 +33,7 @@ import { StackRuntimeError, StackCleanupError, StackStateInvalidError, + isStackError, isStackErrorTag, type StackErrorTag, type StackError, @@ -122,8 +123,7 @@ const credentialHost = (address: string): string => const mapRuntimeError = (error: unknown): StackError => { if (error instanceof StackStateInvalidError) return error; if (error instanceof ContainerEngineError) return error; - if (error instanceof RuntimeDriverError && error.cause instanceof ContainerEngineError) - return error.cause; + if (error instanceof RuntimeDriverError && isStackError(error.cause)) return error.cause; return new StackRuntimeError({ message: error instanceof Error ? error.message : String(error), cause: error, @@ -322,10 +322,9 @@ export const makeSupervisor = ( input: LifecycleInput, session: "fresh" | "current", selectedOverride?: ReadonlySet, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { if (session === "fresh") yield* resetForSession(input); - if (input.state.desiredLifecycle !== "running") return undefined; const selected = selectedOverride ?? (yield* Ref.get(active)); const plan = activeExecutionPlan(input.plan, selected); const reservation = yield* runtime.ingress.acquire(input); @@ -456,13 +455,11 @@ export const makeSupervisor = ( const activated = yield* runtime.activate(capability, input).pipe(Effect.exit); if (Exit.isFailure(activated)) { yield* Ref.set(active, previousActive); - if (launched !== undefined) { - const rolledBack = yield* launched.rollback.pipe(Effect.exit); - if (Exit.isFailure(rolledBack)) { - yield* Ref.set(phase, "stopping"); - let cause: Cause.Cause = Cause.combine(activated.cause, rolledBack.cause); - return yield* Effect.failCause(cause); - } + const rolledBack = yield* launched.rollback.pipe(Effect.exit); + if (Exit.isFailure(rolledBack)) { + yield* Ref.set(phase, "stopping"); + const cause: Cause.Cause = Cause.combine(activated.cause, rolledBack.cause); + return yield* Effect.failCause(cause); } return yield* Effect.failCause(activated.cause); } @@ -580,6 +577,7 @@ export const makeSupervisor = ( } yield* Ref.set(phase, "starting"); } + if (!freshSession && previous === "stopped") yield* Ref.set(phase, "starting"); const started = yield* controller .start({ config: startOptions?.config, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 81512b3801..dffd0c332a 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -30,6 +30,7 @@ import { StackStateInvalidError, StackMustBeStoppedError, StackVersionUnsupportedError, + ArtifactIntegrityError, } from "../public/Errors.ts"; import { RuntimeDriverError, @@ -107,6 +108,7 @@ const makeFixture = ( readonly activationCalls?: Ref.Ref; readonly activationFailFirst?: Ref.Ref; readonly startFailures?: Ref.Ref; + readonly startFailureCause?: ArtifactIntegrityError; readonly preflightFailFirst?: Ref.Ref; readonly preflightCalls?: Ref.Ref; readonly preflightGate?: Deferred.Deferred; @@ -246,6 +248,16 @@ const makeFixture = ( }); } } + if ( + fixtureOptions.startFailureCause !== undefined && + key.workloadId === "functions:edge-runtime" + ) + return yield* new RuntimeDriverError({ + message: fixtureOptions.startFailureCause.message, + stackId: key.stackId, + workloadId: key.workloadId, + cause: fixtureOptions.startFailureCause, + }); const ready = { ...key, state: "ready" as const }; yield* Ref.update(resources, (current) => [ ...current.filter((entry) => entry.workloadId !== key.workloadId), @@ -613,6 +625,53 @@ describe("Supervisor composition", () => { ), ); + it.live("reports starting while relaunching a stopped owner", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + yield* fixture.supervisor.start({ config: {} }); + expect((yield* fixture.supervisor.maintenanceHandlers.stop).ok).toBe(true); + + const blocked = yield* Ref.make(false); + const launchStarted = yield* Deferred.make(); + const launchGate = yield* Deferred.make(); + const baseDriver = fixture.runtime.driver; + const driver: RuntimeDriver = { + ...baseDriver, + start: (key, workload) => + Effect.gen(function* () { + if (yield* Ref.get(blocked)) { + yield* Deferred.succeed(launchStarted, undefined); + yield* Deferred.await(launchGate); + } + return yield* baseDriver.start(key, workload); + }), + }; + const successor = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "stopped-relaunch-successor", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: { ...fixture.runtime, driver }, + }); + yield* successor.start({ config: {} }); + expect((yield* successor.maintenanceHandlers.stop).ok).toBe(true); + yield* Ref.set(blocked, true); + + const starting = yield* Effect.forkChild(successor.start(), { startImmediately: true }); + yield* Deferred.await(launchStarted); + expect((yield* successor.status).lifecycle).toBe("starting"); + expect((yield* successor.logs()).running).toBe(true); + yield* Deferred.succeed(launchGate, undefined); + expect((yield* Fiber.join(starting)).lifecycle).toBe("running"); + yield* successor.maintenanceHandlers.stop; + yield* successor.shutdownIfIdle; + yield* fixture.supervisor.shutdownIfIdle; + }), + ), + ); + it.live("cleans stale runtime resources before the first start of a new supervisor", () => run( Effect.gen(function* () { @@ -1418,6 +1477,60 @@ describe("Supervisor composition", () => { ), ); + it.live("preserves lazy artifact preparation failures during activation", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const artifactFailure = new ArtifactIntegrityError({ + message: "functions artifact checksum mismatch", + }); + const baseDriver = fixture.runtime.driver; + const driver: RuntimeDriver = { + ...baseDriver, + start: (key, workload) => + key.workloadId === "functions:edge-runtime" + ? Effect.fail( + new RuntimeDriverError({ + message: artifactFailure.message, + stackId: key.stackId, + workloadId: key.workloadId, + cause: artifactFailure, + }), + ) + : baseDriver.start(key, workload), + }; + const supervisor = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "artifact-failure-supervisor", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: { ...fixture.runtime, driver }, + }); + yield* supervisor.start({ + config: { + capabilities: { + rest: { enabled: false }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { activation: "lazy" }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const result = yield* supervisor.activate("functions").pipe(Effect.exit); + expect(errorOf(result)).toBeInstanceOf(ArtifactIntegrityError); + yield* supervisor.maintenanceHandlers.stop; + yield* supervisor.shutdownIfIdle; + yield* fixture.supervisor.shutdownIfIdle; + }), + ), + ); + it.live("keeps accepted start work alive when its waiter is interrupted", () => run( Effect.gen(function* () { From 4bb21007b27e9987d7b90efa4764343123a6b65f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 14:45:09 +0200 Subject: [PATCH 135/174] chore(stack): preserve typed log initialization causes --- .../stack/src/preparation/RuntimeArtifacts.ts | 10 ++++++- packages/stack/src/public/EffectStack.ts | 25 ++++++----------- packages/stack/src/public/Errors.ts | 2 -- .../stack/src/runtime/ProductionRuntime.ts | 28 ++++++------------- .../production-runtime.integration.test.ts | 13 +++++++++ packages/stack/src/supervisor/Supervisor.ts | 3 +- .../supervisor/supervisor.integration.test.ts | 11 -------- 7 files changed, 40 insertions(+), 52 deletions(-) diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 0501afcbdb..88fc1a055d 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -114,13 +114,21 @@ export const makeRuntimeArtifactPreparer = ( }), ); const engine = options.containerEngine; - if (engine === undefined || engine.kind !== runtime.engine) + if (engine === undefined) return Effect.fail( error("Container runtime has no configured container engine", { workload: workload.id, engine: runtime.engine, }), ); + if (engine.kind !== runtime.engine) + return Effect.fail( + error("Configured container engine does not match runtime", { + workload: workload.id, + engine: runtime.engine, + configuredEngine: engine.kind, + }), + ); const image = workload.selected.image; return engine.probe.pipe( Effect.andThen(engine.inspectImage(image)), diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 32b15c267f..9ecd42f69b 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -304,6 +304,9 @@ export const makeHandle = ( const readOfflineState = options.readOfflineState; const readPersistedState = options.readPersistedState; const readLogs = options.readLogs; + const isStoppedState = (state: PersistedStackState): boolean => + state.desiredLifecycle === "stopped" || state.desiredLifecycle === "unconfigured"; + const stackNotFound = () => new StackNotFoundError({ message: "Stack state was not found" }); const resolveClient = ( launch: boolean, ): Effect.Effect, StackError> => @@ -427,9 +430,8 @@ export const makeHandle = ( Effect.mapError(statusError), Effect.flatMap((state) => Option.isNone(state) - ? Effect.fail(new StackNotFoundError({ message: "Stack state was not found" })) - : state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured" + ? Effect.fail(stackNotFound()) + : isStoppedState(state.value) ? statusFor(state.value, [], new Set(), "stopped").pipe( Effect.mapError(statusError), ) @@ -457,9 +459,7 @@ export const makeHandle = ( ? Effect.void : readPersistedState().pipe( Effect.flatMap((state) => - Option.isSome(state) && - (state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured") + Option.isSome(state) && isStoppedState(state.value) ? waitForRelease().pipe(Effect.ignore) : Effect.void, ), @@ -512,9 +512,7 @@ export const makeHandle = ( : readOfflineState().pipe( Effect.mapError(stopError), Effect.flatMap((state) => - Option.isSome(state) && - (state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured") + Option.isSome(state) && isStoppedState(state.value) ? Effect.void : resolveClient(true).pipe( Effect.mapError(stopError), @@ -550,13 +548,8 @@ export const makeHandle = ( const ownerStopped = readPersistedState().pipe(Effect.mapError(logsStateError)); return ownerStopped.pipe( Effect.flatMap((state) => { - if (Option.isNone(state)) - return Effect.fail( - new StackNotFoundError({ message: "Stack state was not found" }), - ); - const teardown = - state.value.desiredLifecycle === "stopped" || - state.value.desiredLifecycle === "unconfigured"; + if (Option.isNone(state)) return Effect.fail(stackNotFound()); + const teardown = isStoppedState(state.value); if (!teardown) return Effect.fail(ownershipError); return Effect.suspend(() => // During an owner stop the control socket can close before its metadata/lease are diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index cf47a84a52..ab83fe9ee4 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -213,10 +213,8 @@ export const PREPARE_STACK_ERROR_TAGS = [ "ArtifactIntegrityError", "ContainerPullError", "ContainerEngineError", - "StackOwnershipConflictError", "StackStateInvalidError", "StackStateFormatUnsupportedError", - "StackLifecycleConflictError", ] as const satisfies ReadonlyArray; export type PrepareStackError = ErrorByTag<(typeof PREPARE_STACK_ERROR_TAGS)[number]>; diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index df3eb5bc47..48ca1acd83 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -7,9 +7,9 @@ import { Effect, Exit, FileSystem, - Option, Path, Ref, + Result, Scope, Schedule, } from "effect"; @@ -555,29 +555,17 @@ export const makeProductionRuntime = ( stackId: options.stackId, })); const knownSecrets = yield* Ref.make>(new Set(stateSecrets(state))); - const logStoreInitialization = yield* Effect.exit( + const logStoreInitialization = yield* Effect.result( options.logStore === undefined - ? makeLogStore({ path: paths.logs, knownSecrets: stateSecrets(state) }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - ) + ? makeLogStore({ path: paths.logs, knownSecrets: stateSecrets(state) }) : Effect.succeed(options.logStore), ); - const baseLogs: LogStore = Exit.isSuccess(logStoreInitialization) - ? logStoreInitialization.value - : unavailableLogStore( - Option.getOrElse( - Cause.findErrorOption(logStoreInitialization.cause), - () => new LogStoreError({ path: paths.logs, message: "Unable to open stack logs" }), - ), - paths.logs, - ); - const logStoreInitializationFailure = Exit.isFailure(logStoreInitialization) - ? Option.getOrElse( - Cause.findErrorOption(logStoreInitialization.cause), - () => new LogStoreError({ path: paths.logs, message: "Unable to open stack logs" }), - ) + const logStoreInitializationFailure = Result.isFailure(logStoreInitialization) + ? logStoreInitialization.failure : undefined; + const baseLogs: LogStore = Result.isSuccess(logStoreInitialization) + ? logStoreInitialization.success + : unavailableLogStore(logStoreInitialization.failure, paths.logs); const logs = dynamicLogStore(baseLogs, knownSecrets); const selectedEngine = state.runtime.kind === "container" ? state.runtime.engine : undefined; const containerEngine = diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index a7624f48d5..aff1d93dbc 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -41,6 +41,7 @@ import { InvalidStackConfigError, PortUnavailableError, StackPreparationError, + StackStateInvalidError, } from "../public/Errors.ts"; import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; @@ -378,7 +379,19 @@ describe("production runtime", () => { expect(error.value.cause).toBeInstanceOf(LogStoreError); } } + const logs = yield* supervisor.logs().pipe(Effect.exit); + expect(Exit.isFailure(logs)).toBe(true); + if (Exit.isFailure(logs)) { + const error = Cause.findErrorOption(logs.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackStateInvalidError); + expect(error.value.cause).toBeInstanceOf(LogStoreError); + } + } + expect(yield* fs.readFileString(paths.logs)).toBe("not-json\n"); expect((yield* supervisor.maintenanceHandlers.stop).ok).toBe(true); + expect(yield* fs.readFileString(paths.logs)).toBe("not-json\n"); yield* supervisor.destroy; expect(current.value).toBeUndefined(); }), diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 818387ebb6..393895978e 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -575,9 +575,8 @@ export const makeSupervisor = ( yield* Ref.set(phase, "stopping"); return yield* Effect.failCause(cleaned.cause); } - yield* Ref.set(phase, "starting"); } - if (!freshSession && previous === "stopped") yield* Ref.set(phase, "starting"); + if (freshSession || previous === "stopped") yield* Ref.set(phase, "starting"); const started = yield* controller .start({ config: startOptions?.config, diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index dffd0c332a..39658a332a 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -108,7 +108,6 @@ const makeFixture = ( readonly activationCalls?: Ref.Ref; readonly activationFailFirst?: Ref.Ref; readonly startFailures?: Ref.Ref; - readonly startFailureCause?: ArtifactIntegrityError; readonly preflightFailFirst?: Ref.Ref; readonly preflightCalls?: Ref.Ref; readonly preflightGate?: Deferred.Deferred; @@ -248,16 +247,6 @@ const makeFixture = ( }); } } - if ( - fixtureOptions.startFailureCause !== undefined && - key.workloadId === "functions:edge-runtime" - ) - return yield* new RuntimeDriverError({ - message: fixtureOptions.startFailureCause.message, - stackId: key.stackId, - workloadId: key.workloadId, - cause: fixtureOptions.startFailureCause, - }); const ready = { ...key, state: "ready" as const }; yield* Ref.update(resources, (current) => [ ...current.filter((entry) => entry.workloadId !== key.workloadId), From 18c44931068ad3085efc7d91256224a790511236 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 15:51:45 +0200 Subject: [PATCH 136/174] test(stack): cover all-eager runtime startup --- .../stack/src/public/whole-stack.e2e.test.ts | 72 +++++++++++++++---- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 3cd229d4c2..913e088cd3 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -60,6 +60,21 @@ const OPTIONAL_STORAGE_ANALYTICS_WORKLOAD_IDS = [ "storage:imgproxy", "storage:storage", ] as const; +const ALL_EAGER_WORKLOAD_IDS = [ + "analytics:analytics", + "analytics:vector", + "auth:auth", + "database:database", + "functions:edge-runtime", + "mail:mail", + "pooler:pooler", + "realtime:realtime", + "rest:rest", + "storage:imgproxy", + "storage:storage", + "studio:pgmeta", + "studio:studio", +] as const; const dockerOwnedResourceCount = async ( stackId: string, @@ -497,6 +512,27 @@ const optionalWorkloadConfig = ( listeners: { smtp: { enabled: true } }, }); +const allEagerConfig = (analyticsApiKey: string): PromiseStackConfig => ({ + capabilities: { + rest: { activation: "eager" }, + auth: { activation: "eager" }, + realtime: { activation: "eager" }, + storage: { + activation: "eager", + settings: { image_transformation: { enabled: true } }, + }, + functions: { activation: "eager" }, + studio: { activation: "eager" }, + mail: { activation: "eager" }, + analytics: { + activation: "eager", + settings: { vector_port: 9001, api_key: analyticsApiKey }, + }, + pooler: { activation: "eager" }, + }, + listeners: { smtp: { enabled: true } }, +}); + const queryAnalyticsMarker = async ( api: StackEndpoint, analyticsApiKey: string, @@ -1047,28 +1083,36 @@ describe("managed Supabase stack whole-stack E2E", () => { ); test( - `honors per-capability eager activation in ${mode.name} mode`, + `starts every capability eagerly in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, async () => { + const analyticsApiKey = `analytics-key-${crypto.randomUUID()}`; await using stack: TestStack = await createTestStack({ name: `stack-eager-${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`, runtime: mode.runtime, - config: { - capabilities: { - rest: { activation: "eager" }, - auth: { activation: "eager" }, - }, + config: allEagerConfig(analyticsApiKey), + setupProject: async (root) => { + await mkdir(join(root, "supabase", "functions"), { recursive: true }); }, }); const status = await stack.status(); - expect(capabilityState(status, "database")).toBe("ready"); - expect(capabilityState(status, "rest")).toBe("ready"); - expect(capabilityState(status, "auth")).toBe("ready"); - await expectOwnedWorkloads(mode, stack.id, ["database:database", "rest:rest", "auth:auth"]); - for (const name of CAPABILITY_NAMES) { - if (name === "database" || name === "rest" || name === "auth") continue; - expect(capabilityState(status, name), `${name} should remain dormant`).toBe("dormant"); - } + expect(status.lifecycle).toBe("running"); + expect(status.endpoints.smtp).toBeDefined(); + expect( + status.capabilities.map(({ name, state, activation }) => ({ name, state, activation })), + ).toEqual(CAPABILITY_NAMES.map((name) => ({ name, state: "ready", activation: "eager" }))); + await expectOwnedWorkloads(mode, stack.id, ALL_EAGER_WORKLOAD_IDS); + + const credentials = await stack.credentials(); + expect(await databaseQuery(credentials.database.url, "SELECT 1 AS value")).toEqual([ + { value: 1 }, + ]); + + await stack.stop(); + const stopped = await stack.status(); + expect(stopped.lifecycle).toBe("stopped"); + expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); + await expectOwnedWorkloads(mode, stack.id, []); }, ); From b7a212d7bb73e4573cb2c7cd888dec7934a1ea9e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 17:40:07 +0200 Subject: [PATCH 137/174] chore(stack): simplify structural runtime boundaries --- ...7-simplified-managed-stack-architecture.md | 28 +++- packages/stack/src/control/ControlServer.ts | 10 +- packages/stack/src/control/FrameCodec.ts | 128 ++++++++++++++++++ .../stack/src/control/MaintenanceProtocol.ts | 122 ----------------- .../control-transport.integration.test.ts | 10 +- packages/stack/src/gateway/RouteCatalog.ts | 17 +-- packages/stack/src/gateway/TcpGateway.ts | 10 +- packages/stack/src/index.ts | 1 - packages/stack/src/model/CapabilityModule.ts | 4 +- .../stack/src/model/capabilities/analytics.ts | 4 +- packages/stack/src/model/capabilities/auth.ts | 2 +- .../stack/src/model/capabilities/database.ts | 2 +- .../stack/src/model/capabilities/functions.ts | 2 +- packages/stack/src/model/capabilities/mail.ts | 2 +- .../stack/src/model/capabilities/pooler.ts | 2 +- .../stack/src/model/capabilities/realtime.ts | 2 +- packages/stack/src/model/capabilities/rest.ts | 2 +- .../stack/src/model/capabilities/storage.ts | 4 +- .../stack/src/model/capabilities/studio.ts | 4 +- .../runtime-artifacts.integration.test.ts | 2 +- packages/stack/src/public/PromiseStack.ts | 2 +- packages/stack/src/public/Testing.ts | 55 ++------ .../src/public/testing.integration.test.ts | 29 +++- .../stack/src/runtime/ProductionRuntime.ts | 7 +- .../container-runtime.integration.test.ts | 2 +- .../native-runtime.integration.test.ts | 2 +- .../workload-runtime.integration.test.ts | 6 +- packages/stack/src/state/PortCoordinator.ts | 52 +++++-- packages/stack/src/state/PortRegistry.ts | 59 -------- .../stack/src/state/ports.integration.test.ts | 59 ++++---- packages/stack/src/supervisor/Ingress.ts | 7 +- .../supervisor/ingress.integration.test.ts | 13 +- 32 files changed, 308 insertions(+), 343 deletions(-) create mode 100644 packages/stack/src/control/FrameCodec.ts delete mode 100644 packages/stack/src/state/PortRegistry.ts diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 9b10fb2c70..bf3fdfea29 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -15,6 +15,13 @@ handles communicate with it through same-release Effect RPC and a small release-stable maintenance protocol for probe and stop. CLI handlers call this facade and do not maintain a second metadata or PID-based liveness path. +The public API has two deliberate entrypoints: Effect-native operations are +available from `@supabase/stack/effect`, while the package root exposes the +Promise handle and root Promise functions (`createStack`, `openStack`, +`findStack`, `listStacks`, and `inspectStack`). Test callers use the public +`createTestStack` helper from `@supabase/stack/testing`; it owns an isolated +project root and exact-identity cleanup through `await using`. + The managed document is private, unreleased state under the user-level managed root. It is not a repository contract, service registry, compatibility facade, or independently versioned database schema. Platform entrypoints only provide @@ -56,8 +63,11 @@ current Supervisor intentionally owns. Cold recovery never adopts those resources. Every managed document records one concrete runtime selection. Native and -container runtimes never mix. Container state records the selected Docker or -Podman engine; it never stores an unresolved or mode-less choice. +container runtimes never mix. Container callers select Docker or Podman +explicitly; state records that exact engine and never stores an unresolved or +mode-less choice. Capability releases and workload artifacts are persisted as +exact version pins (including their concrete native release and container image) +rather than ranges or floating tags. Read-only discovery never acquires ownership. Owner metadata points to a Unix domain socket on POSIX or a named pipe on Windows, and the ownership lock is the @@ -82,6 +92,20 @@ is caller-owned: cancellation stops that caller's unfinished transfers while completed cache entries remain. It never acquires control or ownership and does not create runtime resources. +Artifact cache trust begins only after a candidate has passed checksum and +runtime-path validation, had its metadata written, and been atomically +published. A cache hit revalidates the metadata, required path kinds and +containment, and executable shape, but does not cryptographically revalidate +published contents. The cache is user-owned and immutable by contract; manual +post-publication changes may execute successfully or fail later at workload +start. A partially published or malformed entry is never trusted merely +because its directory exists. + +Effect failures remain operation-specific tagged errors (for example, +configuration, preparation, gateway, port, and lifecycle failures), preserving +typed recovery and actionable context at the Effect boundary. The Promise +facade translates those failures into native rejections only at its outer edge. + ## Why this replaces ADR-0015 ADR-0015 proposed exported contract fixtures, a repository boundary, and diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index dd6fe3260e..5f2c63e4e9 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -21,20 +21,22 @@ import * as Socket from "effect/unstable/socket/Socket"; import * as SocketServer from "effect/unstable/socket/SocketServer"; import type { ControlEndpoint } from "../state/Ownership.ts"; import { - CONTROL_PREFACE_MAX_BYTES, decodeFrame, - decodePreface, encodeFrame, - encodePreface, encodeRawFrame, FrameDecoder, + type JsonValue, +} from "./FrameCodec.ts"; +import { + CONTROL_PREFACE_MAX_BYTES, + decodePreface, + encodePreface, MAINTENANCE_MAX_CONCURRENT_REQUESTS, MAINTENANCE_REQUEST_DEADLINE_MS, MaintenanceRequestSchema, MaintenanceProtocolError, MaintenanceResponseSchema, ownerSessionIdIsValid, - type JsonValue, type MaintenanceRequest, type MaintenanceResponse, } from "./MaintenanceProtocol.ts"; diff --git a/packages/stack/src/control/FrameCodec.ts b/packages/stack/src/control/FrameCodec.ts new file mode 100644 index 0000000000..e8f5b4b63e --- /dev/null +++ b/packages/stack/src/control/FrameCodec.ts @@ -0,0 +1,128 @@ +import { Effect, Schema } from "effect"; +import { MaintenanceProtocolError } from "./MaintenanceProtocol.ts"; + +export const MAINTENANCE_MAX_FRAME_BYTES = 64 * 1024; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { fatal: true }); + +/** JSON values accepted by the maintenance framing layer. RPC payload schemas remain in RpcGroup. */ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue }; + +const encodeJson = (value: JsonValue): Effect.Effect => + Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe( + Effect.map((text) => textEncoder.encode(text)), + Effect.mapError( + () => new MaintenanceProtocolError({ message: "Unable to encode control JSON" }), + ), + ); + +/** Encode one bounded uint32-big-endian frame containing JSON. */ +export const encodeFrame = ( + value: JsonValue, +): Effect.Effect => + encodeJson(value).pipe( + Effect.flatMap((body) => { + if (body.byteLength > MAINTENANCE_MAX_FRAME_BYTES) { + return Effect.fail( + new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), + ); + } + const frame = new Uint8Array(body.byteLength + 4); + new DataView(frame.buffer).setUint32(0, body.byteLength, false); + frame.set(body, 4); + return Effect.succeed(frame); + }), + ); + +export const decodeFrame = ( + frame: Uint8Array, +): Effect.Effect => + Effect.gen(function* () { + if (frame.byteLength < 4) { + return yield* new MaintenanceProtocolError({ message: "Control frame is truncated" }); + } + const length = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false); + if (length > MAINTENANCE_MAX_FRAME_BYTES) { + return yield* new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }); + } + if (frame.byteLength !== length + 4) { + return yield* new MaintenanceProtocolError({ message: "Control frame length mismatch" }); + } + const text = yield* Effect.try({ + try: () => textDecoder.decode(frame.slice(4)), + catch: () => new MaintenanceProtocolError({ message: "Invalid control JSON" }), + }); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( + Effect.mapError(() => new MaintenanceProtocolError({ message: "Invalid control JSON" })), + ); + if (!isJsonValue(parsed)) { + return yield* new MaintenanceProtocolError({ message: "Control JSON value is invalid" }); + } + return parsed; + }); + +const isJsonValue = (value: unknown): value is JsonValue => { + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.every(isJsonValue); + if (typeof value !== "object") return false; + return Object.values(value).every(isJsonValue); +}; + +/** Incremental bounded frame decoder. Instantiate one per connection execution. */ +export class FrameDecoder { + private buffer = new Uint8Array(0); + + push(chunk: Uint8Array): Effect.Effect, MaintenanceProtocolError> { + if (chunk.byteLength === 0) return Effect.succeed([]); + const combined = new Uint8Array(this.buffer.byteLength + chunk.byteLength); + combined.set(this.buffer); + combined.set(chunk, this.buffer.byteLength); + this.buffer = combined; + const frames: Uint8Array[] = []; + while (this.buffer.byteLength >= 4) { + const length = new DataView(this.buffer.buffer, this.buffer.byteOffset, 4).getUint32( + 0, + false, + ); + if (length > MAINTENANCE_MAX_FRAME_BYTES) { + return Effect.fail( + new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), + ); + } + if (this.buffer.byteLength < length + 4) break; + frames.push(this.buffer.slice(0, length + 4)); + this.buffer = this.buffer.slice(length + 4); + } + return Effect.succeed(frames); + } + + get bufferedBytes(): number { + return this.buffer.byteLength; + } +} + +/** Encode a raw RPC serialization payload in the same bounded frame format. */ +export const encodeRawFrame = ( + value: Uint8Array | string, +): Effect.Effect => + Effect.sync(() => (typeof value === "string" ? textEncoder.encode(value) : value)).pipe( + Effect.flatMap((body) => { + if (body.byteLength > MAINTENANCE_MAX_FRAME_BYTES) { + return Effect.fail( + new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), + ); + } + const frame = new Uint8Array(body.byteLength + 4); + new DataView(frame.buffer).setUint32(0, body.byteLength, false); + frame.set(body, 4); + return Effect.succeed(frame); + }), + ); diff --git a/packages/stack/src/control/MaintenanceProtocol.ts b/packages/stack/src/control/MaintenanceProtocol.ts index 527b435d9b..22b1f8b1a7 100644 --- a/packages/stack/src/control/MaintenanceProtocol.ts +++ b/packages/stack/src/control/MaintenanceProtocol.ts @@ -3,7 +3,6 @@ import { isStackId, StackIdSchema } from "../public/StackId.ts"; /** The stable protocol is intentionally frozen independently of Stack RPC releases. */ const MAINTENANCE_PROTOCOL = "maintenance-v1" as const; -export const MAINTENANCE_MAX_FRAME_BYTES = 64 * 1024; export const MAINTENANCE_MAX_CONCURRENT_REQUESTS = 16; export const MAINTENANCE_REQUEST_DEADLINE_MS = 5_000; export const CONTROL_PREFACE_MAX_BYTES = 256; @@ -170,124 +169,3 @@ export const decodePreface = ( consumed: newline + 1, }; }); - -/** JSON values accepted by the maintenance framing layer. RPC payload schemas remain in RpcGroup. */ -export type JsonValue = - | null - | boolean - | number - | string - | ReadonlyArray - | { readonly [key: string]: JsonValue }; - -const encodeJson = (value: JsonValue): Effect.Effect => - Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe( - Effect.map((text) => textEncoder.encode(text)), - Effect.mapError( - () => new MaintenanceProtocolError({ message: "Unable to encode control JSON" }), - ), - ); - -/** Encode one bounded uint32-big-endian frame containing JSON. */ -export const encodeFrame = ( - value: JsonValue, -): Effect.Effect => - encodeJson(value).pipe( - Effect.flatMap((body) => { - if (body.byteLength > MAINTENANCE_MAX_FRAME_BYTES) { - return Effect.fail( - new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), - ); - } - const frame = new Uint8Array(body.byteLength + 4); - new DataView(frame.buffer).setUint32(0, body.byteLength, false); - frame.set(body, 4); - return Effect.succeed(frame); - }), - ); - -export const decodeFrame = ( - frame: Uint8Array, -): Effect.Effect => - Effect.gen(function* () { - if (frame.byteLength < 4) { - return yield* new MaintenanceProtocolError({ message: "Control frame is truncated" }); - } - const length = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false); - if (length > MAINTENANCE_MAX_FRAME_BYTES) { - return yield* new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }); - } - if (frame.byteLength !== length + 4) { - return yield* new MaintenanceProtocolError({ message: "Control frame length mismatch" }); - } - const text = yield* Effect.try({ - try: () => textDecoder.decode(frame.slice(4)), - catch: () => new MaintenanceProtocolError({ message: "Invalid control JSON" }), - }); - const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( - Effect.mapError(() => new MaintenanceProtocolError({ message: "Invalid control JSON" })), - ); - if (!isJsonValue(parsed)) { - return yield* new MaintenanceProtocolError({ message: "Control JSON value is invalid" }); - } - return parsed; - }); - -const isJsonValue = (value: unknown): value is JsonValue => { - if (value === null || typeof value === "string" || typeof value === "boolean") return true; - if (typeof value === "number") return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - if (typeof value !== "object") return false; - return Object.values(value).every(isJsonValue); -}; - -/** Incremental bounded frame decoder. Instantiate one per connection execution. */ -export class FrameDecoder { - private buffer = new Uint8Array(0); - - push(chunk: Uint8Array): Effect.Effect, MaintenanceProtocolError> { - if (chunk.byteLength === 0) return Effect.succeed([]); - const combined = new Uint8Array(this.buffer.byteLength + chunk.byteLength); - combined.set(this.buffer); - combined.set(chunk, this.buffer.byteLength); - this.buffer = combined; - const frames: Uint8Array[] = []; - while (this.buffer.byteLength >= 4) { - const length = new DataView(this.buffer.buffer, this.buffer.byteOffset, 4).getUint32( - 0, - false, - ); - if (length > MAINTENANCE_MAX_FRAME_BYTES) { - return Effect.fail( - new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), - ); - } - if (this.buffer.byteLength < length + 4) break; - frames.push(this.buffer.slice(0, length + 4)); - this.buffer = this.buffer.slice(length + 4); - } - return Effect.succeed(frames); - } - - get bufferedBytes(): number { - return this.buffer.byteLength; - } -} - -/** Encode a raw RPC serialization payload in the same bounded frame format. */ -export const encodeRawFrame = ( - value: Uint8Array | string, -): Effect.Effect => - Effect.sync(() => (typeof value === "string" ? textEncoder.encode(value) : value)).pipe( - Effect.flatMap((body) => { - if (body.byteLength > MAINTENANCE_MAX_FRAME_BYTES) { - return Effect.fail( - new MaintenanceProtocolError({ message: "Control frame exceeds size limit" }), - ); - } - const frame = new Uint8Array(body.byteLength + 4); - new DataView(frame.buffer).setUint32(0, body.byteLength, false); - frame.set(body, 4); - return Effect.succeed(frame); - }), - ); diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 45f94e4c14..76da24d75f 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -26,16 +26,18 @@ import { type ControlServerOptions, } from "./ControlServer.ts"; import { - CONTROL_PREFACE_MAX_BYTES, decodeFrame, - decodePreface, encodeFrame, - encodePreface, encodeRawFrame, FrameDecoder, - MaintenanceProtocolError, MAINTENANCE_MAX_FRAME_BYTES, type JsonValue, +} from "./FrameCodec.ts"; +import { + CONTROL_PREFACE_MAX_BYTES, + decodePreface, + encodePreface, + MaintenanceProtocolError, } from "./MaintenanceProtocol.ts"; import type { StackRpcError, StackRpcHandlers } from "./StackRpc.ts"; diff --git a/packages/stack/src/gateway/RouteCatalog.ts b/packages/stack/src/gateway/RouteCatalog.ts index b5b966c567..411f1ba943 100644 --- a/packages/stack/src/gateway/RouteCatalog.ts +++ b/packages/stack/src/gateway/RouteCatalog.ts @@ -4,13 +4,14 @@ import type { PortField } from "../public/Status.ts"; import type { GatewayHeaderTransform, GatewayHeaders, + GatewayProxyRoute, GatewayRoute, GatewayRouteRequest, } from "./Gateway.ts"; export interface GatewayRouteCatalog { readonly http: ReadonlyMap>; - readonly tcp: ReadonlyMap>; + readonly tcp: ReadonlyMap>; } /** Owner-resolved API material used only for forwarding transformations. */ @@ -151,20 +152,20 @@ const prefixRoute = ( match: (request: GatewayRouteRequest) => boolean = (request) => pathStartsWith(prefix)(pathAndQuery(request.path)[0]), upstreamHeaders?: GatewayHeaderTransform, -): GatewayRoute => ({ +): GatewayProxyRoute => ({ capability, match, upstreamPath, ...(upstreamHeaders === undefined ? {} : { upstreamHeaders }), }); -const directRoute = (capability: CapabilityName, binding?: string): GatewayRoute => ({ +const directRoute = (capability: CapabilityName, binding?: string): GatewayProxyRoute => ({ capability, ...(binding === undefined ? {} : { binding }), match: () => true, }); -const realtimeWebsocketRoute = (material?: GatewayApiMaterial): GatewayRoute => +const realtimeWebsocketRoute = (material?: GatewayApiMaterial): GatewayProxyRoute => prefixRoute( "realtime", "/realtime/v1", @@ -278,11 +279,11 @@ export const routeCatalogFor = ( material?: GatewayApiMaterial, ): GatewayRouteCatalog => { const http = new Map(); - const tcp = new Map(); - const append = ( - target: Map, + const tcp = new Map(); + const append = ( + target: Map, field: PortField, - routes: ReadonlyArray, + routes: ReadonlyArray, ) => { const current = target.get(field) ?? []; current.push(...routes); diff --git a/packages/stack/src/gateway/TcpGateway.ts b/packages/stack/src/gateway/TcpGateway.ts index c375ff42c5..3f12acc433 100644 --- a/packages/stack/src/gateway/TcpGateway.ts +++ b/packages/stack/src/gateway/TcpGateway.ts @@ -5,18 +5,16 @@ import { GatewayActivationError } from "../public/Errors.ts"; import type { ActivationResult, BackendEndpoint, - GatewayRoute, GatewayProxyRoute, GatewayRouteRequest, } from "./Gateway.ts"; -import { isGatewayProxyRoute } from "./Gateway.ts"; import type { HostListener } from "../state/PortCoordinator.ts"; export interface TcpGatewayOptions { readonly address?: string; readonly port?: number; readonly listener?: HostListener; - readonly routes: ReadonlyArray; + readonly routes: ReadonlyArray; readonly activate: ( capability: import("../public/Capability.ts").CapabilityName, ) => Effect.Effect; @@ -34,7 +32,7 @@ export interface TcpGateway { readonly server: Server; } -const routeFor = (routes: ReadonlyArray): GatewayRoute | undefined => +const routeFor = (routes: ReadonlyArray): GatewayProxyRoute | undefined => routes.find((route) => route.match({ path: "/", headers: {} })); const tunnel = ( @@ -121,10 +119,6 @@ const handleConnection = ( source.destroy(); return; } - if (!isGatewayProxyRoute(route)) { - source.destroy(); - return; - } const view: GatewayRouteRequest = { path: "/", headers: {} }; const operation = options.activate(route.capability).pipe( Effect.flatMap((result) => diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index c934bc97e5..a270b63e58 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -7,7 +7,6 @@ export { } from "./public/PromiseStack.ts"; export type { PromiseStack, - PromiseStackApi, PromiseStackConfig, PromiseStartStackOptions, PromisePrepareStackOptions, diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index 4cc4cb246c..9d662bd6a8 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -20,7 +20,7 @@ export interface WorkloadSpec { /** Closed internal lifecycle marker for work that must run before dependents are ready. */ readonly bootstrap?: "database"; readonly dependencies: ReadonlyArray; - readonly readiness: Readonly<{ readonly mode: "http" | "tcp"; readonly portField?: PortField }>; + readonly readiness: Readonly<{ readonly portField?: PortField }>; /** Exact artifacts selected for this workload release. */ readonly artifacts: Readonly<{ readonly native: NativeArtifact; @@ -100,7 +100,7 @@ export const workload = ( capability, ...(options.bootstrap === undefined ? {} : { bootstrap: options.bootstrap }), dependencies: options.dependencies ?? [], - readiness: options.readiness ?? { mode: "tcp" }, + readiness: options.readiness ?? {}, artifacts, }; }; diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index 41d9fcb754..f8c34f450d 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -31,11 +31,11 @@ export const AnalyticsModule: CapabilityModule = { "v1.50.6": release("v1.50.6", [ workload("analytics", "analytics", { dependencies: ["database:database"], - readiness: { mode: "http", portField: "api" }, + readiness: { portField: "api" }, }), workload("vector", "analytics", { dependencies: ["analytics:analytics"], - readiness: { mode: "tcp" }, + readiness: {}, }), ]), }, diff --git a/packages/stack/src/model/capabilities/auth.ts b/packages/stack/src/model/capabilities/auth.ts index 454ff04418..9ca3a2052c 100644 --- a/packages/stack/src/model/capabilities/auth.ts +++ b/packages/stack/src/model/capabilities/auth.ts @@ -398,7 +398,7 @@ export const AuthModule: CapabilityModule = { "v2.196.0": release("v2.196.0", [ workload("auth", "auth", { dependencies: ["database:database"], - readiness: { mode: "http", portField: "api" }, + readiness: { portField: "api" }, }), ]), }, diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index 6f438a5c0b..c48115dc80 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -138,7 +138,7 @@ const defaults: DatabaseSettings = { const postgres17Release = release("17.6.1.167", [ workload("database", "database", { bootstrap: "database", - readiness: { mode: "tcp", portField: "database" }, + readiness: { portField: "database" }, }), ]); diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index 3187990259..0f6fc8224f 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -60,7 +60,7 @@ export const FunctionsModule: CapabilityModule = { "v1.74.3": release("v1.74.3", [ workload("edge-runtime", "functions", { dependencies: ["database:database"], - readiness: { mode: "http", portField: "functionsInspector" }, + readiness: { portField: "functionsInspector" }, }), ]), }, diff --git a/packages/stack/src/model/capabilities/mail.ts b/packages/stack/src/model/capabilities/mail.ts index 8024a33325..b09e52e76c 100644 --- a/packages/stack/src/model/capabilities/mail.ts +++ b/packages/stack/src/model/capabilities/mail.ts @@ -17,7 +17,7 @@ export const MailModule: CapabilityModule = { releases: { "v1.30.2": release("v1.30.2", [ workload("mail", "mail", { - readiness: { mode: "http", portField: "mailUi" }, + readiness: { portField: "mailUi" }, }), ]), }, diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts index 93f3ece4ff..9191007eab 100644 --- a/packages/stack/src/model/capabilities/pooler.ts +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -30,7 +30,7 @@ export const PoolerModule: CapabilityModule = { "v2.9.12": release("v2.9.12", [ workload("pooler", "pooler", { dependencies: ["database:database"], - readiness: { mode: "tcp", portField: "pooler" }, + readiness: { portField: "pooler" }, }), ]), }, diff --git a/packages/stack/src/model/capabilities/realtime.ts b/packages/stack/src/model/capabilities/realtime.ts index 24c51215f7..166c3df72b 100644 --- a/packages/stack/src/model/capabilities/realtime.ts +++ b/packages/stack/src/model/capabilities/realtime.ts @@ -28,7 +28,7 @@ export const RealtimeModule: CapabilityModule = { "v2.130.0": release("v2.130.0", [ workload("realtime", "realtime", { dependencies: ["database:database"], - readiness: { mode: "http", portField: "api" }, + readiness: { portField: "api" }, }), ]), }, diff --git a/packages/stack/src/model/capabilities/rest.ts b/packages/stack/src/model/capabilities/rest.ts index 325e80d0ce..479bf879d2 100644 --- a/packages/stack/src/model/capabilities/rest.ts +++ b/packages/stack/src/model/capabilities/rest.ts @@ -36,7 +36,7 @@ export const RestModule: CapabilityModule = { "v16.2": release("v16.2", [ workload("rest", "rest", { dependencies: ["database:database"], - readiness: { mode: "http", portField: "api" }, + readiness: { portField: "api" }, }), ]), }, diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index 03accefb59..7da29c74ab 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -106,10 +106,10 @@ export const StorageModule: CapabilityModule = { "v1.72.1": release("v1.72.1", [ workload("storage", "storage", { dependencies: ["database:database"], - readiness: { mode: "http", portField: "api" }, + readiness: { portField: "api" }, }), workload("imgproxy", "storage", { - readiness: { mode: "http" }, + readiness: {}, }), ]), }, diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts index e8a20d91e2..f2f08f0b88 100644 --- a/packages/stack/src/model/capabilities/studio.ts +++ b/packages/stack/src/model/capabilities/studio.ts @@ -19,11 +19,11 @@ export const StudioModule: CapabilityModule = { "2026.08.24-sha-8ec45b2": release("2026.08.24-sha-8ec45b2", [ workload("studio", "studio", { dependencies: ["studio:pgmeta", "analytics:analytics"], - readiness: { mode: "http", portField: "studio" }, + readiness: { portField: "studio" }, }), workload("pgmeta", "studio", { dependencies: ["database:database"], - readiness: { mode: "http" }, + readiness: {}, }), ]), }, diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index e4510f6f7a..868c85d48c 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -20,7 +20,7 @@ const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload id: "database:database", capability: "database", dependencies: [], - readiness: { mode: "tcp" }, + readiness: {}, artifacts: { native: { kind: "native", release: "17.6.1.167" }, container: { diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index cc34547e14..425de44335 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -70,7 +70,7 @@ export interface PromiseStack { readonly followLogs: (query?: LogQuery) => AsyncIterable; } -export interface PromiseStackApi { +interface PromiseStackApi { readonly createStack: (options: CreateStackOptions) => Promise; readonly openStack: (id: StackId) => Promise; readonly findStack: (options: FindStackOptions) => Promise; diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index a61220a98d..be072e1fe0 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource builds an isolated root. import { join } from "node:path"; import { NodeServices } from "@effect/platform-node"; -import { Data, Effect, Schedule } from "effect"; +import { Data } from "effect"; import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; import { makePromiseApi, @@ -19,11 +19,6 @@ import type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; class TestStackReadinessError extends Data.TaggedError("TestStackReadinessError")<{ readonly message: string; }> {} -class RetryReadiness extends Data.TaggedError("RetryReadiness")<{ - readonly message: string; - readonly terminal?: boolean; -}> {} - export interface CreateTestStackOptions { readonly config?: PromiseStackConfig; readonly name?: string; @@ -63,11 +58,9 @@ const isolatedEnvironment = (projectRoot: string): StackRuntimeEnvironmentValue stateRoot: join(projectRoot, ".supabase", "managed", "stacks"), }); -const waitForReadiness = async ( - stack: PromiseStack, +const validateStartedStatus = ( initial: Awaited>, config: PromiseStackConfig | undefined, - signal?: AbortSignal, ) => { const disabledCapabilities = new Set( Object.entries(config?.capabilities ?? {}).flatMap(([name, capability]) => @@ -126,42 +119,13 @@ const waitForReadiness = async ( return undefined; }; if (ready(initial)) return; - const initialFailure = terminalFailure(initial); - if (initialFailure !== undefined) throw initialFailure; - const poll = Effect.tryPromise({ - try: () => { - signal?.throwIfAborted(); - return stack.status(); - }, - catch: (cause) => - new TestStackReadinessError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }).pipe( - Effect.flatMap( - (next): Effect.Effect>, RetryReadiness> => { - if (ready(next)) return Effect.succeed(next); - const failure = terminalFailure(next); - return Effect.fail( - new RetryReadiness({ - message: failure?.message ?? "Stack is not ready yet", - ...(failure === undefined ? {} : { terminal: true }), - }), - ); - }, - ), - Effect.retry({ - schedule: Schedule.spaced("100 millis").pipe(Schedule.upTo({ duration: "120 seconds" })), - while: (error) => error instanceof RetryReadiness && error.terminal !== true, - }), + const failure = terminalFailure(initial); + throw ( + failure ?? + new TestStackReadinessError({ + message: `Stack did not become ready after start (lifecycle ${initial.lifecycle})`, + }) ); - try { - await Effect.runPromise(poll); - } catch (error) { - if (error instanceof RetryReadiness) - throw new TestStackReadinessError({ message: error.message }); - throw error; - } }; const cleanup = async ( @@ -217,9 +181,8 @@ export const createTestStackWith = async ( ? undefined : ({ config: options.config } satisfies PromiseStartStackOptions), ); - const readyStack = stack; try { - await waitForReadiness(readyStack, started, options.config, AbortSignal.timeout(120_000)); + validateStartedStatus(started, options.config); } catch (error) { if (error instanceof TestStackReadinessError) throw error; throw new TestStackReadinessError({ message: String(error) }); diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index d1fd335879..58ef939d14 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -88,7 +88,12 @@ const fakeStack = ( start: async () => { events.push("start"); if (failStart) throw new Error("startup failed"); - return status("starting", includeApi, functionsState); + return status( + reachesReadiness ? "running" : "stopped", + includeApi, + functionsState, + failedCapability, + ); }, stop: async () => undefined, destroy: async () => { @@ -136,19 +141,25 @@ describe("test stack resource", () => { expect(removed).toEqual([]); }); - it("fails and cleans up when readiness status ends before becoming ready", async () => { + it("fails and cleans up when start returns before the stack is ready", async () => { const events: Array = []; const removed: Array = []; const operations: TestStackOperations = { createRoot: async () => "/tmp/stack-test-unready", - createStack: async () => fakeStack(events, false, false), + createStack: async () => ({ + ...fakeStack(events), + start: async () => { + events.push("start"); + return status("starting"); + }, + }), removeRoot: async (root) => { removed.push(root); }, }; await expect( createTestStackWith({ config: { capabilities: { database: {} } } }, operations), - ).rejects.toThrow("Stack lifecycle stopped before stack became ready"); + ).rejects.toThrow("Stack did not become ready after start (lifecycle starting)"); expect(events).toEqual(["start", "destroy"]); expect(removed).toEqual(["/tmp/stack-test-unready"]); }); @@ -292,7 +303,10 @@ describe("test stack resource", () => { createRoot: async () => "/tmp/stack-test-lifecycle-stopped", createStack: async () => ({ ...fakeStack(events), - status: async () => status("stopped"), + start: async () => { + events.push("start"); + return status("stopped"); + }, }), removeRoot: async () => undefined, }; @@ -306,7 +320,10 @@ describe("test stack resource", () => { createRoot: async () => "/tmp/stack-test-lifecycle-stopping", createStack: async () => ({ ...fakeStack(events), - status: async () => status("stopping"), + start: async () => { + events.push("start"); + return status("stopping"); + }, }), removeRoot: async () => undefined, }; diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 48ca1acd83..246765c21d 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -20,7 +20,6 @@ import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, } from "../functions/FunctionsBootstrap.ts"; -import { makePortRegistry } from "../state/PortRegistry.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { redactKnownSecrets } from "../state/SecretStore.ts"; @@ -532,15 +531,11 @@ export const makeProductionRuntime = ( ); // Register this child scope before constructing drivers: LIFO cleanup // stops workloads, closes owner fibers, then removes generated files. - const registry = yield* makePortRegistry({ - stateRoot: options.stateRoot, - store: options.stateStore, - }); const ingress = options.ingress ?? (yield* makeSupervisorIngress({ stackId: options.stackId, - registry, + stateRoot: options.stateRoot, store: options.stateStore, context: options.context, resolveAuthTemplates: inputOwner.resolveAuthTemplates, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index ba3101acc7..9aee213d9f 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -64,7 +64,7 @@ const workload = (selected: PlannedWorkload["selected"] = containerArtifact): Pl id: key.workloadId, capability: key.workloadId.startsWith("functions:") ? "functions" : "database", dependencies: [], - readiness: { mode: "tcp" }, + readiness: {}, artifacts: { native: { kind: "native", release: "1" }, container: containerArtifact, diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 66d17ae585..af19580c1c 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -29,7 +29,7 @@ const workload = (id: string, bootstrap?: "database"): PlannedWorkload => ({ capability: "database", ...(bootstrap === undefined ? {} : { bootstrap }), dependencies: [], - readiness: { mode: "tcp" }, + readiness: {}, artifacts: { native: { kind: "native", release: "test" }, container: { kind: "container", image: `test/${id}` }, diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index d2d05832f4..1132c1daf2 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -6,7 +6,6 @@ import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { deriveStackId } from "../identity/Identity.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { makePortCoordinator, type ListenerIntents } from "../state/PortCoordinator.ts"; -import { makePortRegistry } from "../state/PortRegistry.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -94,7 +93,7 @@ const planned = (id: string): PlannedWorkload => { id, capability, dependencies: [], - readiness: { mode: "tcp" }, + readiness: {}, artifacts: { native: { kind: "native", release: entry.nativeVersion }, container: { kind: "container", image: entry.containerImage }, @@ -225,8 +224,7 @@ describe("workload runtime catalog", () => { ports: [], privatePorts: [], }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const reservation = yield* makePortCoordinator(registry, store).planAndReserve( + const reservation = yield* makePortCoordinator({ stateRoot: root, store }).planAndReserve( stackId, disabledListenerIntents, { diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 0e99b5762d..52fb8821d0 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -17,7 +17,7 @@ import type { PrivatePortAssignment, } from "./StackState.ts"; import type { StackStateStore } from "./StackStateStore.ts"; -import type { PortRegistry } from "./PortRegistry.ts"; +import { withRegistryLock } from "./StackStateStore.ts"; interface ListenerIntent { readonly enabled: boolean; @@ -59,6 +59,8 @@ export interface PortReservation { } export interface PortCoordinatorOptions { + readonly stateRoot: string; + readonly store: StackStateStore; /** Binds and retains a host listener. The enclosing Scope owns its release. */ readonly bindHost?: ( address: string, @@ -107,22 +109,45 @@ const automaticConflict = (port: number, field: PortField) => message: `Port ${port} for ${field} is reserved by another stack's automatic assignment`, }); -export const makePortCoordinator = ( - registry: PortRegistry, - store: StackStateStore, - options: PortCoordinatorOptions = {}, -): PortCoordinator => ({ +const idPattern = /^[0-9a-f]{64}$/; + +const readAuthoritativeStates = (options: PortCoordinatorOptions) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(options.stateRoot); + const exists = yield* fs + .exists(root) + .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); + if (!exists) return []; + const entries = yield* fs + .readDirectory(root) + .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); + const ids = entries.filter((entry) => idPattern.test(entry)); + const values = yield* Effect.forEach(ids, (stackId) => + options.store + .read(stackId) + .pipe(Effect.map((state) => (state === undefined ? undefined : { stackId, state }))), + ); + return values.filter( + (entry): entry is { readonly stackId: string; readonly state: PersistedStackState } => + entry !== undefined, + ); + }); + +export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordinator => ({ planAndReserve: (stackId, listenerIntents, planOptions = {}) => Effect.gen(function* () { - const committed = yield* registry.withLock( + const committed = yield* withRegistryLock( + options.stateRoot, Effect.gen(function* () { - const current = yield* store.read(stackId); + const current = yield* options.store.read(stackId); if (current === undefined) return yield* new StackStateInvalidError({ message: "Cannot allocate ports for an unconfigured stack", }); const lifecycle = current.desiredLifecycle === "running" ? "running" : "stopped"; - const allStates = yield* registry.states; + const allStates = yield* readAuthoritativeStates(options); const usedAutomaticPublic = new Set(); const usedLivePublic = new Set(); const usedReservedPublic = new Set(); @@ -255,7 +280,7 @@ export const makePortCoordinator = ( ports: assignments, privatePorts: privateAssignments, }; - yield* store.replaceUnlocked(stackId, next); + yield* options.store.replaceUnlocked(stackId, next); return { current, next, lifecycle, byField, privateAssignments }; }), ); @@ -266,9 +291,10 @@ export const makePortCoordinator = ( const assignment = committed.byField[field]; return intent.enabled && assignment !== undefined ? [{ field, intent, assignment }] : []; }); - const rollbackFreshAutomatic = registry.withLock( + const rollbackFreshAutomatic = withRegistryLock( + options.stateRoot, Effect.gen(function* () { - const latest = yield* store.read(stackId); + const latest = yield* options.store.read(stackId); if (latest === undefined) return yield* new StackStateInvalidError({ message: "Stack state disappeared" }); const priorByField = new Map( @@ -281,7 +307,7 @@ export const makePortCoordinator = ( const privatePorts = committed.next.privatePorts.filter((entry) => priorBindings.has(bindingKey(entry)), ); - yield* store.replaceUnlocked(stackId, { ...latest, ports, privatePorts }); + yield* options.store.replaceUnlocked(stackId, { ...latest, ports, privatePorts }); }), ); if (committed.lifecycle === "running") { diff --git a/packages/stack/src/state/PortRegistry.ts b/packages/stack/src/state/PortRegistry.ts deleted file mode 100644 index 4887900492..0000000000 --- a/packages/stack/src/state/PortRegistry.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Crypto, Effect, FileSystem, Path } from "effect"; -import { - InvalidProjectRootError, - StackStateFormatUnsupportedError, - StackStateInvalidError, -} from "../public/Errors.ts"; -import type { PersistedStackState } from "./StackState.ts"; -import { withRegistryLock, type StackStateStore } from "./StackStateStore.ts"; - -type PortRegistryError = - | InvalidProjectRootError - | StackStateInvalidError - | StackStateFormatUnsupportedError; - -export interface PortRegistry { - /** Serializes one complete port transaction with other processes. */ - readonly withLock: ( - action: Effect.Effect, - ) => Effect.Effect; - /** Enumerates authoritative per-stack state documents; this method never writes a registry. */ - readonly states: Effect.Effect< - ReadonlyArray<{ readonly stackId: string; readonly state: PersistedStackState }>, - PortRegistryError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto - >; -} - -const idPattern = /^[0-9a-f]{64}$/; - -export const makePortRegistry = (options: { - readonly stateRoot: string; - readonly store: StackStateStore; -}): Effect.Effect => - Effect.succeed({ - withLock: (action: Effect.Effect) => - withRegistryLock(options.stateRoot, action), - states: Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = path.resolve(options.stateRoot); - const exists = yield* fs - .exists(root) - .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); - if (!exists) return []; - const entries = yield* fs - .readDirectory(root) - .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); - const ids = entries.filter((entry) => idPattern.test(entry)); - const values = yield* Effect.forEach(ids, (stackId) => - options.store - .read(stackId) - .pipe(Effect.map((state) => (state === undefined ? undefined : { stackId, state }))), - ); - return values.filter( - (entry): entry is { readonly stackId: string; readonly state: PersistedStackState } => - entry !== undefined, - ); - }), - }); diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index e6a292b7dc..c943fe4d13 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -6,7 +6,6 @@ import { createServer, type Server } from "node:net"; import { createServer as createHttpServer } from "node:http"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { PortAllocationError, PortUnavailableError } from "../public/Errors.ts"; -import { makePortRegistry } from "./PortRegistry.ts"; import { makePortCoordinator, type HostListener, type ListenerIntents } from "./PortCoordinator.ts"; import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; import { bindHostListener } from "../supervisor/HostListener.ts"; @@ -153,8 +152,7 @@ describe("sticky port coordination", () => { const b = yield* deriveStackId(bIdentity); yield* store.initialize(a, baseState(a, aIdentity)); yield* store.initialize(b, baseState(b, bIdentity)); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store); + const coordinator = makePortCoordinator({ stateRoot: root, store }); const first = yield* coordinator.planAndReserve(a, intents("automatic")); const repeat = yield* coordinator.planAndReserve(a, intents("automatic")); expect(repeat.assignments).toEqual(first.assignments); @@ -178,8 +176,7 @@ describe("sticky port coordination", () => { const b = yield* deriveStackId(bIdentity); yield* store.initialize(a, baseState(a, aIdentity)); yield* store.initialize(b, baseState(b, bIdentity)); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store); + const coordinator = makePortCoordinator({ stateRoot: root, store }); const automatic = yield* coordinator.planAndReserve(a, intents("automatic")); const occupied = automatic.assignments.api?.port; expect(occupied).toBeTypeOf("number"); @@ -216,8 +213,9 @@ describe("sticky port coordination", () => { ports: [{ field: "api", port: 55436, intent: "automatic" }], privatePorts: [], }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store, { + const coordinator = makePortCoordinator({ + stateRoot: root, + store, bindHost: makeTestHostListener, }); const exactToDifferent = yield* coordinator.planAndReserve(exactId, { @@ -250,8 +248,9 @@ describe("sticky port coordination", () => { ports: [{ field: "api", port: 55439, intent: "exact" }], privatePorts: [], }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store, { + const coordinator = makePortCoordinator({ + stateRoot: root, + store, bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve(stackId, { @@ -281,8 +280,9 @@ describe("sticky port coordination", () => { ], privatePorts: [], }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store, { + const coordinator = makePortCoordinator({ + stateRoot: root, + store, bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve( @@ -313,8 +313,9 @@ describe("sticky port coordination", () => { yield* store.initialize(stackId, { ...baseState(stackId, identity, "running"), }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store, { + const coordinator = makePortCoordinator({ + stateRoot: root, + store, bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve(stackId, disabledIntents(), {}); @@ -339,8 +340,7 @@ describe("sticky port coordination", () => { privatePorts: [], }); yield* store.initialize(d, baseState(d, dIdentity)); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store); + const coordinator = makePortCoordinator({ stateRoot: root, store }); yield* coordinator.planAndReserve(d, intents(55432)); const stopped = yield* store.read(d); if (stopped === undefined) return; @@ -362,8 +362,7 @@ describe("sticky port coordination", () => { const identity = makeIdentity(root, "duplicate"); const stackId = yield* deriveStackId(identity); yield* store.initialize(stackId, baseState(stackId, identity)); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store); + const coordinator = makePortCoordinator({ stateRoot: root, store }); const exit = yield* coordinator .planAndReserve(stackId, { ...disabledIntents(), @@ -396,8 +395,9 @@ describe("sticky port coordination", () => { ports: [{ field: "api", port: occupiedPort, intent: "exact" as const }] as const, }; yield* store.replace(stackId, running); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store, { + const coordinator = makePortCoordinator({ + stateRoot: root, + store, bindHost: bindHostListener, }); const exit = yield* coordinator @@ -434,8 +434,9 @@ describe("sticky port coordination", () => { { field: "database", port: 55451, intent: "automatic" }, ], }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store, { + const coordinator = makePortCoordinator({ + stateRoot: root, + store, bindHost: (address, port, field) => bound.has(port) ? Effect.fail(new PortUnavailableError({ port, field, message: "already bound" })) @@ -474,10 +475,11 @@ describe("sticky port coordination", () => { { field: "database", port: 55453, intent: "automatic" }, ], }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); const bound = new Set(); let attempts = 0; - const coordinator = makePortCoordinator(registry, store, { + const coordinator = makePortCoordinator({ + stateRoot: root, + store, bindHost: (address, port, field) => { attempts += 1; if (attempts === 2) @@ -515,8 +517,9 @@ describe("sticky port coordination", () => { { field: "database", port: 55434, intent: "automatic" }, ], }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store, { + const coordinator = makePortCoordinator({ + stateRoot: root, + store, bindHost: (address, port, field) => Effect.fail(new PortUnavailableError({ port, field, message: `race at ${address}` })), }); @@ -537,8 +540,7 @@ describe("sticky port coordination", () => { const identity = makeIdentity(root, "private"); const stackId = yield* deriveStackId(identity); yield* store.initialize(stackId, baseState(stackId, identity)); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store); + const coordinator = makePortCoordinator({ stateRoot: root, store }); const bindings = [ { workloadId: "mail:mail", binding: "ui" }, { workloadId: "mail:mail", binding: "smtp" }, @@ -575,8 +577,7 @@ describe("sticky port coordination", () => { const b = yield* deriveStackId(bIdentity); yield* store.initialize(a, baseState(a, aIdentity)); yield* store.initialize(b, baseState(b, bIdentity)); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const coordinator = makePortCoordinator(registry, store); + const coordinator = makePortCoordinator({ stateRoot: root, store }); const first = yield* coordinator.planAndReserve(a, disabledIntents(), { privateBindings: [{ workloadId: "database:database", binding: "primary" }], }); diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 6c8d918b08..014c1bc01c 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -21,7 +21,6 @@ import { type PortCoordinator, type PortReservation, } from "../state/PortCoordinator.ts"; -import type { PortRegistry } from "../state/PortRegistry.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; import type { PortField } from "../public/Status.ts"; @@ -59,7 +58,7 @@ export interface SupervisorIngress { export interface SupervisorIngressOptions { readonly stackId: string; - readonly registry: PortRegistry; + readonly stateRoot: string; readonly store: StackStateStore; readonly portCoordinator?: PortCoordinator; readonly context: Context.Context; @@ -179,7 +178,9 @@ export const makeSupervisorIngress = ( >(undefined); const coordinator = options.portCoordinator ?? - makePortCoordinator(options.registry, options.store, { + makePortCoordinator({ + stateRoot: options.stateRoot, + store: options.store, bindHost: options.bindHost ?? bindHostListener, }); const acquire = ( diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 071f5fbfd5..095d683f70 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -14,7 +14,6 @@ import { compileStack } from "../model/Compiler.ts"; import { GatewayActivationError, StackPreparationError } from "../public/Errors.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import { makePortRegistry } from "../state/PortRegistry.ts"; import { makeSupervisorIngress } from "./Ingress.ts"; import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; @@ -127,10 +126,9 @@ describe("Supervisor ingress", () => { })), secrets: {}, }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); const ingress = yield* makeSupervisorIngress({ stackId, - registry, + stateRoot: root, store, context, apiMaterial: () => @@ -247,8 +245,7 @@ describe("Supervisor ingress", () => { })), secrets: {}, }); - const registry = yield* makePortRegistry({ stateRoot: root, store }); - const ingress = yield* makeSupervisorIngress({ stackId, registry, store, context }); + const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, store, context }); const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); const reservation = yield* ingress.acquire({ stackId, @@ -336,10 +333,9 @@ describe("Supervisor ingress", () => { secrets: {}, }; yield* store.initialize(stackId, persisted); - const registry = yield* makePortRegistry({ stateRoot: root, store }); const ingress = yield* makeSupervisorIngress({ stackId, - registry, + stateRoot: root, store, context, apiMaterial: () => @@ -442,11 +438,10 @@ describe("Supervisor ingress", () => { secrets: {}, }; yield* store.initialize(stackId, persisted); - const registry = yield* makePortRegistry({ stateRoot: root, store }); const activated: string[] = []; const ingress = yield* makeSupervisorIngress({ stackId, - registry, + stateRoot: root, store, context, apiMaterial: () => From 47ac17e4f8bdd5bad46f3e698528eda052081888 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 17:49:54 +0200 Subject: [PATCH 138/174] docs(stack): clarify container engine selection defaults --- .../adr/0017-simplified-managed-stack-architecture.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index bf3fdfea29..2b5af55340 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -63,11 +63,12 @@ current Supervisor intentionally owns. Cold recovery never adopts those resources. Every managed document records one concrete runtime selection. Native and -container runtimes never mix. Container callers select Docker or Podman -explicitly; state records that exact engine and never stores an unresolved or -mode-less choice. Capability releases and workload artifacts are persisted as -exact version pins (including their concrete native release and container image) -rather than ranges or floating tags. +container runtimes never mix. Omission defaults to Docker; callers may +explicitly select Docker or Podman. There is no probing or auto-detection, and +persisted state records the resolved exact engine. Capability releases and +workload artifacts are persisted as exact version pins (including their +concrete native release and container image) rather than ranges or floating +tags. Read-only discovery never acquires ownership. Owner metadata points to a Unix domain socket on POSIX or a named pipe on Windows, and the ownership lock is the From c82584821149eb2e2c948c5e8f3374bba3fdf7e7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 18:08:32 +0200 Subject: [PATCH 139/174] test(stack): consolidate lifecycle coverage at EffectStack --- .../public/effect-stack.integration.test.ts | 99 +++++++++++++++++-- .../supervisor/supervisor.integration.test.ts | 69 +------------ 2 files changed, 93 insertions(+), 75 deletions(-) diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index b6a35a1894..14c32fd083 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -44,6 +44,7 @@ import { ContainerEngineError, InvalidStackConfigError, InvalidLogCursorError, + StackLifecycleConflictError, StackOwnershipConflictError, StackPreparationError, StackRuntimeMismatchError, @@ -62,7 +63,6 @@ import { type EffectStack, type PrepareStackResult, } from "./EffectStack.ts"; -import * as effectApi from "../effect.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; @@ -245,6 +245,7 @@ describe("Effect stack lifecycle handoff", () => { unrelatedDatabase, ]); const ownerRunning = yield* Ref.make(true); + const ownerAvailable = yield* Ref.make(true); const readLogs = (query: LogQuery): Effect.Effect => Effect.gen(function* () { if (query.cursor !== undefined) @@ -309,8 +310,13 @@ describe("Effect stack lifecycle handoff", () => { onShutdownReady: Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); const stack = yield* makeHandle(stackId, { - resolveOwner: () => Effect.succeed(Option.some(owner)), + resolveOwner: () => + Ref.get(ownerAvailable).pipe( + Effect.map((available) => (available ? Option.some(owner) : Option.none())), + ), + readOfflineState: () => Effect.succeed(Option.some(stoppedState())), }); + expect((yield* stack.start()).lifecycle).toBe("running"); const first = yield* stack.logs({ capabilities: ["auth"], tail: 1 }); expect(first.entries).toEqual([initialAuth]); expect(first.cursor).toEqual(unrelatedDatabase.cursor); @@ -326,8 +332,10 @@ describe("Effect stack lifecycle handoff", () => { yield* Deferred.await(stopped); const followed = yield* Fiber.join(followedFiber); expect(Array.from(followed)).toEqual([finalAuth]); + yield* Ref.set(ownerAvailable, false); yield* Scope.close(ownerScope, Exit.void); yield* Fiber.join(stopFiber); + expect((yield* stack.status()).lifecycle).toBe("stopped"); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -474,6 +482,10 @@ describe("Effect stack lifecycle handoff", () => { Effect.gen(function* () { const env = yield* StackRuntimeEnvironment; const stack = yield* createStack({ projectRoot: project, runtime: { kind: "native" } }); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const state = yield* store.read(stack.id); + if (state === undefined) return yield* Effect.die("stack state was not initialized"); + yield* store.replace(stack.id, { ...state, desiredLifecycle: "stopped" }); const started = yield* stack .start({ config: { capabilities: { database: { version: "15" } } } }) @@ -482,7 +494,9 @@ describe("Effect stack lifecycle handoff", () => { expect(Exit.isFailure(started)).toBe(true); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); - expect((yield* stack.status()).lifecycle).toBe("unconfigured"); + expect((yield* stack.status()).lifecycle).toBe("stopped"); + const reopened = yield* openStack(stack.id); + expect((yield* reopened.status()).lifecycle).toBe("stopped"); }), ), ); @@ -864,6 +878,81 @@ describe("Effect stack lifecycle handoff", () => { 240_000, ); + it.live("surfaces concurrent lifecycle conflicts through the public start handle", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "ss-cstart-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "concurrent-start-session"; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const startEntered = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + let starts = 0; + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => + Effect.gen(function* () { + starts += 1; + if (starts === 1) { + yield* Deferred.succeed(startEntered, undefined); + yield* Deferred.await(releaseStart); + return runningStatus; + } + return yield* Effect.fail({ + tag: "StackLifecycleConflictError", + message: "A lifecycle transition is already in progress", + } as const); + }), + destroy: () => Effect.void, + logs: () => emptyLogs(), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some(owner)), + }); + const first = yield* Effect.forkChild(stack.start(), { startImmediately: true }); + yield* Deferred.await(startEntered); + const second = yield* stack.start().pipe(Effect.exit); + expect(Exit.isFailure(second)).toBe(true); + if (Exit.isFailure(second)) { + const failure = Cause.findErrorOption(second.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) + expect(failure.value).toBeInstanceOf(StackLifecycleConflictError); + } + yield* Deferred.succeed(releaseStart, undefined); + expect(Exit.isSuccess(yield* Fiber.join(first).pipe(Effect.exit))).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("cancels unfinished direct preparation while retaining completed artifacts", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -976,10 +1065,6 @@ describe("Effect stack lifecycle handoff", () => { ), ); - it("does not publish the internal handle through the Effect barrel", () => { - expect(Object.hasOwn(effectApi, "makeHandle")).toBe(false); - }); - it.live("omits undefined optional RPC payload keys", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 39658a332a..ad446110fe 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -17,7 +17,7 @@ import { import { Headers } from "effect/unstable/http"; import { Rpc } from "effect/unstable/rpc"; import { RequestId } from "effect/unstable/rpc/RpcMessage"; -import type { LogQuery, StackLogBatch, StackLogEntry } from "../public/Logs.ts"; +import type { LogQuery, StackLogEntry } from "../public/Logs.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { GatewayActivationError, @@ -77,24 +77,6 @@ const invokeCredentials = ( return value; }); -const invokeLogs = ( - supervisor: Supervisor, - query: LogQuery, -): Effect.Effect => - Effect.gen(function* () { - const handler = yield* StackRpcGroup.accessHandler("logs").pipe( - Effect.provide(StackRpcGroup.toLayerHandler("logs", supervisor.rpcHandlers.logs)), - ); - const value = yield* handler(query, { - client: new Rpc.ServerClient(1), - requestId: RequestId(1), - headers: Headers.empty, - }); - if (Deferred.isDeferred(value)) - return yield* Deferred.await(value); - return value; - }); - const makeFixture = ( fixtureOptions: { readonly ingress?: SupervisorIngress; @@ -407,18 +389,6 @@ const run = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); describe("Supervisor composition", () => { - it.live("reports malformed log cursors as invalid caller input", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const result = yield* invokeLogs(fixture.supervisor, { - cursor: { opaque: "not-a-cursor" }, - }).pipe(Effect.exit); - expect(errorOf(result)).toMatchObject({ tag: "InvalidLogCursorError" }); - }), - ), - ); - it.live("rejects new work after owner shutdown begins", () => run( Effect.gen(function* () { @@ -1333,43 +1303,6 @@ describe("Supervisor composition", () => { ), ); - it.live("reports stopped after a clean stop", () => - run( - Effect.gen(function* () { - const fixture = yield* makeFixture(); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const response = yield* fixture.supervisor.maintenanceHandlers.stop; - expect(response.ok).toBe(true); - yield* fixture.supervisor.shutdownIfIdle; - expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); - }), - ), - ); - - it.live("keeps followers live through stop and returns final logs once", () => - run( - Effect.gen(function* () { - const stopGate = yield* Deferred.make(); - const stopStarted = yield* Deferred.make(); - const fixture = yield* makeFixture({ stopGate, stopStarted }); - yield* fixture.supervisor.start({ config: { capabilities: { rest: {} } } }); - const stopping = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); - yield* Deferred.await(stopStarted); - const duringStop = yield* fixture.supervisor.logs(); - expect(duringStop.running).toBe(true); - expect(duringStop.entries).toHaveLength(1); - yield* Deferred.succeed(stopGate, undefined); - const response = yield* Fiber.join(stopping); - expect(response.ok).toBe(true); - yield* fixture.supervisor.shutdownIfIdle; - const batch = yield* fixture.supervisor.logs(); - expect(batch.entries).toHaveLength(2); - expect(batch.entries.filter((entry) => entry.message === "stopped")).toHaveLength(1); - expect(batch.running).toBe(false); - }), - ), - ); - it.live("keeps running state and explicit stop guidance for changed start input", () => run( Effect.gen(function* () { From b90ef61256159450ce5bc6b7a5ebbeae1207a8a6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 18:16:57 +0200 Subject: [PATCH 140/174] test(stack): preserve supervisor log phase coverage --- .../stack/src/supervisor/supervisor.integration.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index ad446110fe..a54cc6b0a4 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1138,10 +1138,17 @@ describe("Supervisor composition", () => { const stop = yield* Effect.forkChild(fixture.supervisor.maintenanceHandlers.stop); yield* Deferred.await(stopStarted); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopping"); + const duringStop = yield* fixture.supervisor.logs(); + expect(duringStop.running).toBe(true); + expect(duringStop.entries).toHaveLength(1); yield* Deferred.succeed(stopGate, undefined); expect((yield* Fiber.join(stop)).ok).toBe(true); expect((yield* fixture.supervisor.status).lifecycle).toBe("stopped"); + const afterStop = yield* fixture.supervisor.logs(); + expect(afterStop.running).toBe(false); + expect(afterStop.entries).toHaveLength(2); + expect(afterStop.entries.filter(({ message }) => message === "stopped")).toHaveLength(1); }), ), ); From a26383e87110987d806bc7f959262b03e85c736a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 18:32:49 +0200 Subject: [PATCH 141/174] refactor(stack): simplify artifact cache validation --- .../stack/src/preparation/ArtifactStore.ts | 319 +++++++++--------- .../preparation/artifacts.integration.test.ts | 136 +++++++- 2 files changed, 286 insertions(+), 169 deletions(-) diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 3c640f0bbf..f19d80c127 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -12,7 +12,7 @@ import { } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; -import { digestHex, validateRelativePath, validateSha256, verifySha256 } from "./Integrity.ts"; +import { validateRelativePath, validateSha256, verifySha256 } from "./Integrity.ts"; /** * A concrete artifact identity. `key` is deliberately private to preparation and may contain @@ -67,10 +67,12 @@ export interface ArtifactStore { ) => Effect.Effect; } -const ARTIFACT_FORMAT = "supabase-stack-artifact-v2"; +const ARTIFACT_FORMAT = "supabase-stack-artifact-v3"; const METADATA_NAME = ".artifact.json"; const EXECUTABLE_MODE = 0o755; +type ArtifactPathKind = "file" | "directory" | "symlink"; + const artifactError = (message: string, fields: Readonly> = {}) => new StackPreparationError({ ...fields, message }); @@ -82,7 +84,10 @@ const ArtifactMetadataSchema = Schema.Struct({ key: Schema.String, sha256: Schema.String, requiredRuntimePaths: Schema.Array(Schema.String), - requiredRuntimeDigests: Schema.Record(Schema.String, Schema.String), + requiredRuntimeKinds: Schema.Record( + Schema.String, + Schema.Literals(["file", "directory", "symlink"]), + ), executablePath: Schema.optional(Schema.String), }); type ArtifactMetadata = Schema.Schema.Type; @@ -150,139 +155,16 @@ const requestFlightKey = (request: ArtifactRequest, expectedSha256: string): str const metadataFor = ( request: ArtifactRequest, sha256: string, - requiredRuntimeDigests: Readonly>, + requiredRuntimeKinds: Readonly>, ): ArtifactMetadata => ({ format: ARTIFACT_FORMAT, key: request.key, sha256, requiredRuntimePaths: [...request.requiredRuntimePaths], - requiredRuntimeDigests, + requiredRuntimeKinds, ...(request.executablePath === undefined ? {} : { executablePath: request.executablePath }), }); -const digestRuntimePaths = ( - fs: FileSystem.FileSystem, - crypto: Crypto.Crypto, - path: Path.Path, - root: string, - relativePaths: ReadonlyArray, -): Effect.Effect>, ArtifactIntegrityError> => - Effect.gen(function* () { - const realRoot = yield* fs - .realPath(root) - .pipe( - Effect.mapError((cause) => - metadataError("Unable to resolve runtime path root", { path: root, cause }), - ), - ); - const digest = (bytes: Uint8Array, candidate: string) => - crypto.digest("SHA-256", bytes).pipe( - Effect.mapError((cause) => - metadataError("Unable to digest required runtime path", { - path: candidate, - cause, - }), - ), - ); - const digestPath = (relative: string): Effect.Effect => - Effect.gen(function* () { - const candidate = path.resolve(root, relative); - const realCandidate = yield* fs.realPath(candidate).pipe( - Effect.mapError((cause) => - metadataError("Unable to resolve required runtime path", { - path: candidate, - cause, - }), - ), - ); - if (!pathAtOrBelow(realRoot, realCandidate, path.sep)) - return yield* metadataError("Required runtime path escapes its installation directory", { - path: relative, - }); - const info = yield* fs - .stat(candidate) - .pipe( - Effect.mapError((cause) => - metadataError("Unable to inspect required runtime path", { path: candidate, cause }), - ), - ); - if (info.type === "File") { - const bytes = yield* fs - .readFile(candidate) - .pipe( - Effect.mapError((cause) => - metadataError("Unable to read required runtime path", { path: candidate, cause }), - ), - ); - return yield* digest(bytes, candidate).pipe(Effect.map(digestHex)); - } - if (info.type !== "Directory") - return yield* metadataError("Required runtime path must be a file or directory", { - path: relative, - type: info.type, - }); - const children = yield* fs.readDirectory(candidate, { recursive: true }).pipe( - Effect.mapError((cause) => - metadataError("Unable to inspect required runtime directory", { - path: candidate, - cause, - }), - ), - ); - const records: Array = []; - for (const child of children.sort()) { - const childPath = path.join(candidate, child); - const realChild = yield* fs.realPath(childPath).pipe( - Effect.mapError((cause) => - metadataError("Unable to resolve required runtime path", { - path: childPath, - cause, - }), - ), - ); - if (!pathAtOrBelow(realRoot, realChild, path.sep)) - return yield* metadataError( - "Required runtime path escapes its installation directory", - { - path: child, - }, - ); - const childInfo = yield* fs.stat(childPath).pipe( - Effect.mapError((cause) => - metadataError("Unable to inspect required runtime path", { - path: childPath, - cause, - }), - ), - ); - if (childInfo.type === "File") { - const bytes = yield* fs - .readFile(childPath) - .pipe( - Effect.mapError((cause) => - metadataError("Unable to read required runtime path", { path: childPath, cause }), - ), - ); - const childDigest = yield* digest(bytes, childPath).pipe(Effect.map(digestHex)); - records.push(`${child}\u0000file\u0000${childDigest}`); - } else if (childInfo.type === "Directory") { - records.push(`${child}\u0000directory`); - } else { - return yield* metadataError("Required runtime path must contain files or directories", { - path: child, - type: childInfo.type, - }); - } - } - const directoryBytes = new TextEncoder().encode(records.join("\n")); - return yield* digest(directoryBytes, candidate).pipe(Effect.map(digestHex)); - }); - const entries = yield* Effect.forEach(relativePaths, (relative) => - digestPath(relative).pipe(Effect.map((digest) => [relative, digest] as const)), - ); - return Object.fromEntries(entries); - }); - const encodeMetadata = (metadata: ArtifactMetadata): Effect.Effect => Schema.encodeEffect(Schema.fromJsonString(ArtifactMetadataSchema))(metadata).pipe( Effect.mapError((cause) => @@ -388,11 +270,134 @@ const ensureSafeRoot = ( return realRoot; }); +const inspectBasicKind = ( + fs: FileSystem.FileSystem, + candidate: string, +): Effect.Effect => + fs.readLink(candidate).pipe( + Effect.as("symlink" as const), + Effect.catch(() => + fs.stat(candidate).pipe( + Effect.mapError((cause) => + metadataError("Unable to inspect required runtime path", { path: candidate, cause }), + ), + Effect.flatMap((info) => { + if (info.type === "File") return Effect.succeed("file" as const); + if (info.type === "Directory") return Effect.succeed("directory" as const); + return Effect.fail( + metadataError("Required runtime path must be a file or directory", { + path: candidate, + type: info.type, + }), + ); + }), + ), + ), + ); + +const resolveContainedPath = ( + fs: FileSystem.FileSystem, + path: Path.Path, + candidate: string, + realRoot: string, +): Effect.Effect => + fs.realPath(candidate).pipe( + Effect.mapError((cause) => + metadataError("Required runtime path cannot be resolved", { path: candidate, cause }), + ), + Effect.flatMap((realCandidate) => + pathAtOrBelow(realRoot, realCandidate, path.sep) + ? Effect.succeed(realCandidate) + : Effect.fail( + metadataError("Required runtime path escapes its installation directory", { + path: candidate, + }), + ), + ), + ); + +const ensureSymlinkTargetShape = ( + fs: FileSystem.FileSystem, + candidate: string, +): Effect.Effect => + fs.stat(candidate).pipe( + Effect.mapError((cause) => + metadataError("Unable to inspect required runtime path target", { path: candidate, cause }), + ), + Effect.flatMap((info) => + info.type === "File" || info.type === "Directory" + ? Effect.void + : Effect.fail( + metadataError("Required runtime path must resolve to a file or directory", { + path: candidate, + type: info.type, + }), + ), + ), + ); + +const inspectFreshPath = ( + fs: FileSystem.FileSystem, + path: Path.Path, + candidate: string, + realRoot: string, +): Effect.Effect => + Effect.gen(function* () { + const kind = yield* inspectBasicKind(fs, candidate); + yield* resolveContainedPath(fs, path, candidate, realRoot); + if (kind === "symlink") yield* ensureSymlinkTargetShape(fs, candidate); + return kind; + }); + +const validateFreshRuntimePaths = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + realRoot: string, + relativePaths: ReadonlyArray, +): Effect.Effect>, ArtifactIntegrityError> => + Effect.gen(function* () { + const inspectDirectory = (relative: string) => + Effect.gen(function* () { + const candidate = path.resolve(root, relative); + const kind = yield* inspectFreshPath(fs, path, candidate, realRoot); + const traversable = + kind === "directory" || + (kind === "symlink" && + (yield* fs.stat(candidate).pipe( + Effect.mapError((cause) => + metadataError("Unable to inspect required runtime path target", { + path: candidate, + cause, + }), + ), + Effect.map((info) => info.type === "Directory"), + ))); + if (traversable) { + const children = yield* fs.readDirectory(candidate, { recursive: true }).pipe( + Effect.mapError((cause) => + metadataError("Unable to inspect required runtime directory", { + path: candidate, + cause, + }), + ), + ); + for (const child of children.sort()) { + yield* inspectFreshPath(fs, path, path.join(candidate, child), realRoot); + } + } + return [relative, kind] as const; + }); + const entries = yield* Effect.forEach(relativePaths, inspectDirectory); + return Object.fromEntries(entries); + }); + const ensureSafePaths = ( fs: FileSystem.FileSystem, path: Path.Path, root: string, realRoot: string, + metadata: ArtifactMetadata, relativePaths: ReadonlyArray, ): Effect.Effect => Effect.gen(function* () { @@ -413,16 +418,15 @@ const ensureSafePaths = ( return yield* metadataError("Cached artifact is missing a required runtime path", { path: relative, }); - const realCandidate = yield* fs - .realPath(candidate) - .pipe( - Effect.mapError((cause) => - metadataError("Cached artifact path cannot be resolved", { path: candidate, cause }), - ), - ); - if (!pathWithin(realRoot, realCandidate, path.sep)) - return yield* metadataError("Cached artifact path escapes its installation directory", { + const actualKind = yield* inspectBasicKind(fs, candidate); + yield* resolveContainedPath(fs, path, candidate, realRoot); + if (actualKind === "symlink") yield* ensureSymlinkTargetShape(fs, candidate); + const expectedKind = metadata.requiredRuntimeKinds[relative]; + if (expectedKind === undefined || actualKind !== expectedKind) + return yield* metadataError("Cached artifact runtime path changed basic kind", { path: relative, + expected: expectedKind, + actual: actualKind, }); } }); @@ -464,10 +468,17 @@ const verifyMetadata = ( metadata.requiredRuntimePaths.every( (entry, index) => entry === request.requiredRuntimePaths[index], ); + const kindEntries = Object.keys(metadata.requiredRuntimeKinds); + const sameKinds = + kindEntries.length === request.requiredRuntimePaths.length && + request.requiredRuntimePaths.every( + (entry) => metadata.requiredRuntimeKinds[entry] !== undefined, + ); if ( metadata.key !== request.key || metadata.sha256 !== expectedSha256 || !samePaths || + !sameKinds || metadata.executablePath !== request.executablePath ) return Effect.fail( @@ -476,20 +487,6 @@ const verifyMetadata = ( return Effect.void; }; -const verifyRuntimeDigests = ( - metadata: ArtifactMetadata, - actual: Readonly>, -): Effect.Effect => { - const expectedEntries = Object.entries(metadata.requiredRuntimeDigests); - const actualEntries = Object.entries(actual); - if ( - expectedEntries.length !== actualEntries.length || - expectedEntries.some(([path, digest]) => actual[path] !== digest) - ) - return Effect.fail(metadataError("Cached artifact runtime path failed integrity verification")); - return Effect.void; -}; - const writeBytesSync = ( fs: FileSystem.FileSystem, path: string, @@ -567,15 +564,10 @@ const makeArtifactOperation = ( const realRoot = cachedRoot ?? (yield* ensureSafeRoot(fs, path, target, cacheRoot)); const metadata = yield* readMetadata(fs, metadataPath); yield* verifyMetadata(request, expectedSha256, metadata); - yield* ensureSafePaths(fs, path, target, realRoot, request.requiredRuntimePaths); - const runtimeDigests = yield* digestRuntimePaths( - fs, - crypto, - path, - target, - request.requiredRuntimePaths, - ); - yield* verifyRuntimeDigests(metadata, runtimeDigests); + // Published content is intentionally not rehashed on cache hits. Metadata and cheap + // structural checks protect the cache boundary; content tampering may execute or fail + // later when the workload starts. + yield* ensureSafePaths(fs, path, target, realRoot, metadata, request.requiredRuntimePaths); if (request.executablePath !== undefined) { yield* ensureExecutableFile(fs, path, target, request.executablePath); const executable = path.resolve(target, request.executablePath); @@ -632,19 +624,18 @@ const makeArtifactOperation = ( }), ), ); - const runtimeDigests = yield* digestRuntimePaths( + const runtimeKinds = yield* validateFreshRuntimePaths( fs, - crypto, path, temporary, + temporaryRoot, request.requiredRuntimePaths, ); yield* writeMetadataSync( fs, path.join(temporary, METADATA_NAME), - metadataFor(request, expectedSha256, runtimeDigests), + metadataFor(request, expectedSha256, runtimeKinds), ); - yield* ensureSafePaths(fs, path, temporary, temporaryRoot, request.requiredRuntimePaths); if (request.executablePath !== undefined) { yield* ensureExecutableFile(fs, path, temporary, request.executablePath); const executable = path.resolve(temporary, request.executablePath); diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index c8a99db8c6..1caecfcd85 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -111,7 +111,7 @@ describe("verified native artifact preparation", () => { ), ); - it.live("rejects a tampered required executable on a cache hit", () => + it.live("reuses a cache hit after required file contents are modified", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -121,13 +121,13 @@ describe("verified native artifact preparation", () => { const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); const published = yield* store.prepare(request); yield* fs.writeFileString(`${published.path}/bin/postgres`, "tampered executable"); - const exit = yield* store.prepare(request).pipe(Effect.exit); - expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + const cached = yield* store.prepare(request); + expect(cached.outcome).toBe("cached"); }), ), ); - it.live("verifies required directory contents and internal symlinks on a cache hit", () => + it.live("reuses cached directories without hashing their contents", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -163,7 +163,89 @@ describe("verified native artifact preparation", () => { expect((yield* store.prepare(directoryRequest)).outcome).toBe("cached"); yield* fs.writeFileString(`${prepared.path}/share/runtime/config`, "tampered config"); - const exit = yield* store.prepare(directoryRequest).pipe(Effect.exit); + expect((yield* store.prepare(directoryRequest)).outcome).toBe("cached"); + }), + ), + ); + + it.live("rejects a missing required path on a cache hit", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-missing-", + }); + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + const published = yield* store.prepare(request); + yield* fs.remove(`${published.path}/etc/postgres.conf`); + + const exit = yield* store.prepare(request).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + }), + ), + ); + + it.live("rejects a required path whose basic kind changes on a cache hit", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-kind-", + }); + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + const published = yield* store.prepare(request); + yield* fs.remove(`${published.path}/bin/postgres`); + yield* fs.makeDirectory(`${published.path}/bin/postgres`); + + const exit = yield* store.prepare(request).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + }), + ), + ); + + it.live("rejects a cache-hit symlink that escapes the artifact root", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-link-escape-", + }); + const outside = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-link-outside-", + }); + const outsideExecutable = `${outside}/postgres`; + yield* fs.writeFileString(outsideExecutable, "outside"); + const symlinkRequest: ArtifactRequest = { + ...request, + key: "database/postgres-cache-link", + }; + const source: ArtifactSource = { + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres.real`, "native postgres"); + yield* fs.symlink("postgres.real", `${destination}/bin/postgres`); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const published = yield* store.prepare(symlinkRequest); + yield* fs.remove(`${published.path}/bin/postgres`); + yield* fs.symlink(outsideExecutable, `${published.path}/bin/postgres`); + + const exit = yield* store.prepare(symlinkRequest).pipe(Effect.exit); expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); }), @@ -320,6 +402,50 @@ describe("verified native artifact preparation", () => { ), ); + it.live("rejects an escaping symlink nested in a fresh required directory", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-nested-link-escape-", + }); + const outside = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-nested-link-outside-", + }); + const outsideConfig = `${outside}/config`; + yield* fs.writeFileString(outsideConfig, "outside"); + const directoryRequest: ArtifactRequest = { + ...request, + key: "database/postgres-nested-link", + requiredRuntimePaths: ["bin/postgres", "share/runtime"], + }; + const source: ArtifactSource = { + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/share/runtime`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.symlink(outsideConfig, `${destination}/share/runtime/config`); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const exit = yield* store.prepare(directoryRequest).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + expect(yield* fs.exists(`${root}/database/postgres-nested-link`)).toBe(false); + }), + ), + ); + it.live("cancels the owner when the store scope closes", () => withPlatform( Effect.gen(function* () { From bf5f1bff438e589dfadb05f86c971239720edd41 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 18:57:13 +0200 Subject: [PATCH 142/174] refactor(stack): harden artifact cache probes --- .../stack/src/preparation/ArtifactStore.ts | 159 ++++++++++++------ .../preparation/artifacts.integration.test.ts | 99 ++++++++++- 2 files changed, 204 insertions(+), 54 deletions(-) diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index f19d80c127..3a8b4985e2 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -73,6 +73,12 @@ const EXECUTABLE_MODE = 0o755; type ArtifactPathKind = "file" | "directory" | "symlink"; +type InspectedArtifactPath = { + readonly kind: ArtifactPathKind; + readonly realPath: string; + readonly linkText?: string; +}; + const artifactError = (message: string, fields: Readonly> = {}) => new StackPreparationError({ ...fields, message }); @@ -270,20 +276,46 @@ const ensureSafeRoot = ( return realRoot; }); +const hasErrnoCode = (value: unknown): value is { readonly code?: unknown } => + typeof value === "object" && value !== null && "code" in value; + +const isReadLinkNonSymlink = (cause: PlatformError.PlatformError): boolean => + cause instanceof PlatformError.PlatformError && + cause.reason instanceof PlatformError.SystemError && + cause.reason.method === "readLink" && + hasErrnoCode(cause.reason.cause) && + cause.reason.cause.code === "EINVAL"; + const inspectBasicKind = ( fs: FileSystem.FileSystem, candidate: string, -): Effect.Effect => +): Effect.Effect => fs.readLink(candidate).pipe( - Effect.as("symlink" as const), - Effect.catch(() => - fs.stat(candidate).pipe( - Effect.mapError((cause) => + Effect.map((linkText): InspectedArtifactPath => ({ + kind: "symlink", + realPath: candidate, + linkText, + })), + Effect.catch((cause): Effect.Effect => { + if (!isReadLinkNonSymlink(cause)) + return Effect.fail( metadataError("Unable to inspect required runtime path", { path: candidate, cause }), + ); + return fs.stat(candidate).pipe( + Effect.mapError((statCause) => + metadataError("Unable to inspect required runtime path", { + path: candidate, + cause: statCause, + }), ), Effect.flatMap((info) => { - if (info.type === "File") return Effect.succeed("file" as const); - if (info.type === "Directory") return Effect.succeed("directory" as const); + if (info.type === "File") + return Effect.succeed({ kind: "file", realPath: candidate }); + if (info.type === "Directory") + return Effect.succeed({ + kind: "directory", + realPath: candidate, + }); return Effect.fail( metadataError("Required runtime path must be a file or directory", { path: candidate, @@ -291,8 +323,8 @@ const inspectBasicKind = ( }), ); }), - ), - ), + ); + }), ); const resolveContainedPath = ( @@ -318,18 +350,18 @@ const resolveContainedPath = ( const ensureSymlinkTargetShape = ( fs: FileSystem.FileSystem, - candidate: string, + realPath: string, ): Effect.Effect => - fs.stat(candidate).pipe( + fs.stat(realPath).pipe( Effect.mapError((cause) => - metadataError("Unable to inspect required runtime path target", { path: candidate, cause }), + metadataError("Unable to inspect required runtime path target", { path: realPath, cause }), ), Effect.flatMap((info) => info.type === "File" || info.type === "Directory" ? Effect.void : Effect.fail( metadataError("Required runtime path must resolve to a file or directory", { - path: candidate, + path: realPath, type: info.type, }), ), @@ -341,12 +373,26 @@ const inspectFreshPath = ( path: Path.Path, candidate: string, realRoot: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const kind = yield* inspectBasicKind(fs, candidate); - yield* resolveContainedPath(fs, path, candidate, realRoot); - if (kind === "symlink") yield* ensureSymlinkTargetShape(fs, candidate); - return kind; + const inspected = yield* inspectBasicKind(fs, candidate); + const realPath = yield* resolveContainedPath(fs, path, candidate, realRoot); + if (inspected.kind === "symlink") { + yield* ensureSymlinkTargetShape(fs, realPath); + const linkText = yield* fs.readLink(candidate).pipe( + Effect.mapError((cause) => + metadataError("Required runtime path symlink changed during validation", { + path: candidate, + cause, + }), + ), + ); + if (linkText !== inspected.linkText) + return yield* metadataError("Required runtime path symlink changed during validation", { + path: candidate, + }); + } + return { ...inspected, realPath }; }); const validateFreshRuntimePaths = ( @@ -355,19 +401,19 @@ const validateFreshRuntimePaths = ( root: string, realRoot: string, relativePaths: ReadonlyArray, -): Effect.Effect>, ArtifactIntegrityError> => +): Effect.Effect>, ArtifactIntegrityError> => Effect.gen(function* () { const inspectDirectory = (relative: string) => Effect.gen(function* () { const candidate = path.resolve(root, relative); - const kind = yield* inspectFreshPath(fs, path, candidate, realRoot); + const inspected = yield* inspectFreshPath(fs, path, candidate, realRoot); const traversable = - kind === "directory" || - (kind === "symlink" && - (yield* fs.stat(candidate).pipe( + inspected.kind === "directory" || + (inspected.kind === "symlink" && + (yield* fs.stat(inspected.realPath).pipe( Effect.mapError((cause) => metadataError("Unable to inspect required runtime path target", { - path: candidate, + path: inspected.realPath, cause, }), ), @@ -383,10 +429,10 @@ const validateFreshRuntimePaths = ( ), ); for (const child of children.sort()) { - yield* inspectFreshPath(fs, path, path.join(candidate, child), realRoot); + yield* inspectFreshPath(fs, path, path.join(inspected.realPath, child), realRoot); } } - return [relative, kind] as const; + return [relative, inspected] as const; }); const entries = yield* Effect.forEach(relativePaths, inspectDirectory); return Object.fromEntries(entries); @@ -399,8 +445,9 @@ const ensureSafePaths = ( realRoot: string, metadata: ArtifactMetadata, relativePaths: ReadonlyArray, -): Effect.Effect => +): Effect.Effect>, ArtifactIntegrityError> => Effect.gen(function* () { + const entries: Array = []; for (const relative of relativePaths) { const candidate = path.resolve(root, relative); if (!pathWithin(path.resolve(root), candidate, path.sep)) @@ -418,26 +465,23 @@ const ensureSafePaths = ( return yield* metadataError("Cached artifact is missing a required runtime path", { path: relative, }); - const actualKind = yield* inspectBasicKind(fs, candidate); - yield* resolveContainedPath(fs, path, candidate, realRoot); - if (actualKind === "symlink") yield* ensureSymlinkTargetShape(fs, candidate); + const inspected = yield* inspectFreshPath(fs, path, candidate, realRoot); const expectedKind = metadata.requiredRuntimeKinds[relative]; - if (expectedKind === undefined || actualKind !== expectedKind) + if (expectedKind === undefined || inspected.kind !== expectedKind) return yield* metadataError("Cached artifact runtime path changed basic kind", { path: relative, expected: expectedKind, - actual: actualKind, + actual: inspected.kind, }); + entries.push([relative, inspected]); } + return Object.fromEntries(entries); }); const ensureExecutableFile = ( fs: FileSystem.FileSystem, - path: Path.Path, - root: string, - relative: string, + executable: string, ): Effect.Effect => { - const executable = path.resolve(root, relative); return fs.stat(executable).pipe( Effect.mapError((cause) => metadataError("Cached artifact executable cannot be inspected", { @@ -567,19 +611,21 @@ const makeArtifactOperation = ( // Published content is intentionally not rehashed on cache hits. Metadata and cheap // structural checks protect the cache boundary; content tampering may execute or fail // later when the workload starts. - yield* ensureSafePaths(fs, path, target, realRoot, metadata, request.requiredRuntimePaths); + const safePaths = yield* ensureSafePaths( + fs, + path, + target, + realRoot, + metadata, + request.requiredRuntimePaths, + ); if (request.executablePath !== undefined) { - yield* ensureExecutableFile(fs, path, target, request.executablePath); - const executable = path.resolve(target, request.executablePath); - yield* mapFs( - executable, - "set executable artifact mode", - fs.chmod(executable, EXECUTABLE_MODE), - ).pipe( - Effect.mapError((error) => - metadataError(error.message, { path: executable, cause: error }), - ), - ); + const executable = safePaths[request.executablePath]; + if (executable === undefined) + return yield* metadataError("Cached artifact executable path is not recorded", { + path: request.executablePath, + }); + yield* ensureExecutableFile(fs, executable.realPath); } return { key: request.key, @@ -624,25 +670,32 @@ const makeArtifactOperation = ( }), ), ); - const runtimeKinds = yield* validateFreshRuntimePaths( + const runtimePaths = yield* validateFreshRuntimePaths( fs, path, temporary, temporaryRoot, request.requiredRuntimePaths, ); + const runtimeKinds = Object.fromEntries( + Object.entries(runtimePaths).map(([relative, inspected]) => [relative, inspected.kind]), + ); yield* writeMetadataSync( fs, path.join(temporary, METADATA_NAME), metadataFor(request, expectedSha256, runtimeKinds), ); if (request.executablePath !== undefined) { - yield* ensureExecutableFile(fs, path, temporary, request.executablePath); - const executable = path.resolve(temporary, request.executablePath); + const executable = runtimePaths[request.executablePath]; + if (executable === undefined) + return yield* metadataError("Fresh artifact executable path is not recorded", { + path: request.executablePath, + }); + yield* ensureExecutableFile(fs, executable.realPath); yield* mapFs( - executable, + executable.realPath, "set executable artifact mode", - fs.chmod(executable, EXECUTABLE_MODE), + fs.chmod(executable.realPath, EXECUTABLE_MODE), ); } const nowExists = yield* mapFs(target, "inspect concurrent artifact", fs.exists(target)); diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index 1caecfcd85..b6d1ed130a 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Option } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Option, PlatformError } from "effect"; import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; import { makeArtifactStore, type ArtifactRequest, type ArtifactSource } from "./ArtifactStore.ts"; @@ -168,6 +168,103 @@ describe("verified native artifact preparation", () => { ), ); + it.live("rejects a non-EINVAL readLink failure on a cache hit", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-readlink-error-", + }); + const publisher = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + yield* publisher.prepare(request); + const failingFs: FileSystem.FileSystem = { + ...fs, + readLink: (candidate) => + candidate.endsWith("/bin/postgres") + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: candidate, + cause: { code: "EACCES" }, + }), + ) + : fs.readLink(candidate), + }; + const checker = yield* makeArtifactStore({ + cacheRoot: root, + source: sourceWriting(), + }).pipe(Effect.provideService(FileSystem.FileSystem, failingFs)); + + const exit = yield* checker.prepare(request).pipe(Effect.exit); + const error = errorOf(exit); + + expect(error).toBeInstanceOf(ArtifactIntegrityError); + expect(error?.message).toContain("Unable to inspect required runtime path"); + }), + ), + ); + + it.live("does not recursively enumerate required directories on a cache hit", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-no-recursion-", + }); + const directoryRequest: ArtifactRequest = { + ...request, + key: "database/postgres-cache-no-recursion", + requiredRuntimePaths: ["bin/postgres", "share/runtime"], + }; + const source: ArtifactSource = { + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/share/runtime`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.writeFileString(`${destination}/share/runtime/config`, "config"); + return archive; + }).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const publisher = yield* makeArtifactStore({ cacheRoot: root, source }); + yield* publisher.prepare(directoryRequest); + const guardedFs: FileSystem.FileSystem = { + ...fs, + readDirectory: (candidate, options) => + candidate.endsWith("/share/runtime") + ? Effect.fail( + PlatformError.systemError({ + _tag: "Busy", + module: "FileSystem", + method: "readDirectory", + pathOrDescriptor: candidate, + cause: { code: "EBUSY" }, + }), + ) + : fs.readDirectory(candidate, options), + }; + const checker = yield* makeArtifactStore({ + cacheRoot: root, + source, + }).pipe(Effect.provideService(FileSystem.FileSystem, guardedFs)); + + const cached = yield* checker.prepare(directoryRequest); + + expect(cached.outcome).toBe("cached"); + }), + ), + ); + it.live("rejects a missing required path on a cache hit", () => withPlatform( Effect.gen(function* () { From 728c05d9a72f2acf2db2544b54242af49b9c7989 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 20:23:02 +0200 Subject: [PATCH 143/174] chore(stack): apply final runtime cleanup fixes --- ...7-simplified-managed-stack-architecture.md | 5 +- packages/stack/README.md | 3 + packages/stack/src/public/EffectStack.ts | 143 +++++++----------- packages/stack/src/public/PromiseStack.ts | 32 +--- .../public/effect-stack.integration.test.ts | 97 ++++++++---- packages/stack/src/runtime/ContainerEngine.ts | 34 +++++ .../stack/src/runtime/ContainerRuntime.ts | 17 ++- packages/stack/src/runtime/DockerEngine.ts | 74 +++------ packages/stack/src/runtime/NativeRuntime.ts | 22 ++- packages/stack/src/runtime/PodmanEngine.ts | 74 +++------ .../container-runtime.integration.test.ts | 31 ++++ .../workload-runtime.integration.test.ts | 14 +- packages/stack/src/state/Ownership.ts | 13 +- packages/stack/src/state/PortCoordinator.ts | 31 ++++ .../src/state/ownership.integration.test.ts | 31 ++++ .../stack/src/state/ports.integration.test.ts | 68 ++++++++- packages/stack/src/supervisor/Ingress.ts | 3 +- packages/stack/src/supervisor/Launcher.ts | 30 ++-- packages/stack/src/supervisor/Supervisor.ts | 22 +-- 19 files changed, 433 insertions(+), 311 deletions(-) diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 2b5af55340..060b78c780 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -64,8 +64,9 @@ resources. Every managed document records one concrete runtime selection. Native and container runtimes never mix. Omission defaults to Docker; callers may -explicitly select Docker or Podman. There is no probing or auto-detection, and -persisted state records the resolved exact engine. Capability releases and +explicitly select Docker or Podman. There is no probing or auto-detection; +Podman is supported only on local Linux hosts. Persisted state records the +resolved exact engine. Capability releases and workload artifacts are persisted as exact version pins (including their concrete native release and container image) rather than ranges or floating tags. diff --git a/packages/stack/README.md b/packages/stack/README.md index 58fc3b1823..bfc762f8c0 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -24,6 +24,9 @@ Docker modes. It begins from the PostgreSQL-only default, progressively activate realistic traffic, and verifies stop/start cycles, stable ports, and persistent data. The CLI is not involved in these runtime tests. +Podman is supported only on local Linux hosts and must be selected explicitly; the runtime does not +auto-detect container engines. + Callers can warm selected native artifacts or container images with `stack.prepare(...)` while a stack is stopped; preparation is cache-only and cancellation does not affect completed entries. Each capability may opt into eager activation in `StackConfig`; omitted settings keep every diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 9ecd42f69b..053ff70f03 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -11,7 +11,6 @@ import { Path, Predicate, Schedule, - Scope, Schema, Stream, } from "effect"; @@ -282,53 +281,42 @@ const destroyError = (error: ControlError): DestroyStackError => narrowError(error, DESTROY_STACK_ERROR_TAGS, (message) => new StackDestructionError({ message })); /** Internal control-transport seam used by public lifecycle integration tests. */ -export const makeHandle = ( - id: StackId, - options: { - readonly resolveOwner?: ( - launch: boolean, - ) => Effect.Effect, StackError>; - readonly readOfflineState?: () => Effect.Effect, StackError>; - readonly readPersistedState?: () => Effect.Effect< - Option.Option, - StackError - >; - readonly readLogs?: (query?: LogQuery) => Effect.Effect; - readonly waitForRelease?: () => Effect.Effect; - readonly prepare?: ( - options?: PrepareStackOptions, - ) => Effect.Effect; - } = {}, -): Effect.Effect => +export interface HandleDependencies { + readonly resolveOwner: ( + launch: boolean, + ) => Effect.Effect, StackError>; + readonly readOfflineState: () => Effect.Effect, StackError>; + readonly readPersistedState: () => Effect.Effect, StackError>; + readonly readLogs: (query?: LogQuery) => Effect.Effect; + readonly waitForRelease: () => Effect.Effect; + readonly prepare: ( + options?: PrepareStackOptions, + ) => Effect.Effect; +} + +export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Effect => Effect.sync(() => { - const readOfflineState = options.readOfflineState; - const readPersistedState = options.readPersistedState; - const readLogs = options.readLogs; const isStoppedState = (state: PersistedStackState): boolean => state.desiredLifecycle === "stopped" || state.desiredLifecycle === "unconfigured"; const stackNotFound = () => new StackNotFoundError({ message: "Stack state was not found" }); const resolveClient = ( launch: boolean, ): Effect.Effect, StackError> => - options.resolveOwner !== undefined - ? options.resolveOwner(launch).pipe( - Effect.flatMap((owner) => - Option.isSome(owner) - ? Effect.succeed( - makeControlClient(owner.value.endpoint, { - stackId: id, - ownerSessionId: owner.value.ownerSessionId, - rpcRelease: owner.value.rpcRelease, - }), - ) - : Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ), - ), - ) - : Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ); + options.resolveOwner(launch).pipe( + Effect.flatMap((owner) => + Option.isSome(owner) + ? Effect.succeed( + makeControlClient(owner.value.endpoint, { + stackId: id, + ownerSessionId: owner.value.ownerSessionId, + rpcRelease: owner.value.rpcRelease, + }), + ) + : Effect.fail( + new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), + ), + ), + ); const mapRpcClientFailure = ( error: RpcClientError, mapError: (error: ControlError) => E, @@ -423,10 +411,9 @@ export const makeHandle = ( ); const status = () => { const rpcStatus = invoke((rpc) => rpc.status(undefined), statusError); - if (readOfflineState === undefined) return rpcStatus; return rpcStatus.pipe( - Effect.catchTag("StackOwnershipConflictError", () => - readOfflineState().pipe( + Effect.catchTag("StackOwnershipConflictError", (ownershipError) => + options.readOfflineState().pipe( Effect.mapError(statusError), Effect.flatMap((state) => Option.isNone(state) @@ -439,13 +426,12 @@ export const makeHandle = ( new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), ), ), + Effect.catchTag("StackOwnershipConflictError", () => Effect.fail(ownershipError)), ), ), ); }; const start = (startOptions?: StartStackOptions) => { - const waitForRelease = options.waitForRelease; - const readPersistedState = options.readPersistedState; return invoke( (rpc) => startOptions?.config === undefined @@ -455,16 +441,14 @@ export const makeHandle = ( true, ).pipe( Effect.tapError(() => - waitForRelease === undefined || readPersistedState === undefined - ? Effect.void - : readPersistedState().pipe( - Effect.flatMap((state) => - Option.isSome(state) && isStoppedState(state.value) - ? waitForRelease().pipe(Effect.ignore) - : Effect.void, - ), - Effect.ignore, - ), + options.readPersistedState().pipe( + Effect.flatMap((state) => + Option.isSome(state) && isStoppedState(state.value) + ? options.waitForRelease().pipe(Effect.ignore) + : Effect.void, + ), + Effect.ignore, + ), ), ); }; @@ -498,29 +482,20 @@ export const makeHandle = ( return yield* new StackLifecycleConflictError({ message: response.value.error.message }); } yield* Fiber.join(closeFiber).pipe(Effect.ignore); - if (options.waitForRelease !== undefined) yield* options.waitForRelease(); + yield* options.waitForRelease(); }).pipe(Effect.mapError(stopError)); const stop = () => resolveClient(false).pipe( Effect.mapError(stopError), Effect.flatMap(stopOwner), Effect.catchTag("StackOwnershipConflictError", () => - (readOfflineState === undefined - ? Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ) - : readOfflineState().pipe( - Effect.mapError(stopError), - Effect.flatMap((state) => - Option.isSome(state) && isStoppedState(state.value) - ? Effect.void - : resolveClient(true).pipe( - Effect.mapError(stopError), - Effect.flatMap(stopOwner), - ), - ), - ) - ).pipe( + options.readOfflineState().pipe( + Effect.mapError(stopError), + Effect.flatMap((state) => + Option.isSome(state) && isStoppedState(state.value) + ? Effect.void + : resolveClient(true).pipe(Effect.mapError(stopError), Effect.flatMap(stopOwner)), + ), // Ownership artifacts that block the offline fast path may be // stale. Let ensureSupervisor arbitrate the lease; a live owner // remains protected and returns a typed conflict. @@ -532,20 +507,11 @@ export const makeHandle = ( ); const prepare = ( prepareOptions?: PrepareStackOptions, - ): Effect.Effect => - options.prepare === undefined - ? Effect.fail(new StackPreparationError({ message: "Stack preparation is unavailable" })) - : options.prepare(prepareOptions); + ): Effect.Effect => options.prepare(prepareOptions); const logs = (query?: LogQuery): Effect.Effect => invoke((rpc) => rpc.logs(query ?? {}), logsError).pipe( Effect.catchTag("StackOwnershipConflictError", (ownershipError) => { - if (readLogs === undefined) - return Effect.fail( - new StackOwnershipConflictError({ message: "No Supervisor owns this stack" }), - ); - if (readOfflineState === undefined || readPersistedState === undefined) - return Effect.fail(ownershipError); - const ownerStopped = readPersistedState().pipe(Effect.mapError(logsStateError)); + const ownerStopped = options.readPersistedState().pipe(Effect.mapError(logsStateError)); return ownerStopped.pipe( Effect.flatMap((state) => { if (Option.isNone(state)) return Effect.fail(stackNotFound()); @@ -555,10 +521,7 @@ export const makeHandle = ( // During an owner stop the control socket can close before its metadata/lease are // released. Re-check ownership before each read and retry only that typed // transition; a live owner or other log failure remains visible. - readOfflineState().pipe( - Effect.mapError(logsStateError), - Effect.andThen(readLogs(query)), - ), + options.readLogs(query), ).pipe( Effect.retry({ schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 })), @@ -870,7 +833,7 @@ export const createStack = ( ): Effect.Effect< EffectStack, CreateStackError, - Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService + FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService > => Effect.gen(function* () { const env = yield* environment(); @@ -924,7 +887,7 @@ export const openStack = ( ): Effect.Effect< EffectStack, OpenStackError, - Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService + FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawnerService > => Effect.gen(function* () { const env = yield* environment(); diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 425de44335..1c395879c7 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -1,17 +1,5 @@ import { NodeServices } from "@effect/platform-node"; -import { - Crypto, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Redacted, - Scope, - Schema, - Stream, -} from "effect"; +import { Crypto, Effect, FileSystem, Layer, Option, Path, Redacted, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createStack as createEffectStack, @@ -80,7 +68,6 @@ interface PromiseStackApi { type PlatformLayer = typeof NodeServices.layer; type RuntimeRequirements = - | Scope.Scope | FileSystem.FileSystem | Path.Path | Crypto.Crypto @@ -168,8 +155,6 @@ export const adaptEffectStack = (effectStack: EffectStack): PromiseStack => { }; }; -const makeScope = () => Effect.runPromise(Scope.make()); - export const makePromiseApi = ( platformLayer: PlatformLayer = NodeServices.layer, runtimeEnvironment?: StackRuntimeEnvironmentValue, @@ -178,19 +163,8 @@ export const makePromiseApi = ( runtimeEnvironment === undefined ? platformLayer : Layer.mergeAll(platformLayer, Layer.succeed(StackRuntimeEnvironment, runtimeEnvironment)); - const run = async ( - effect: Effect.Effect, - scope?: Scope.Scope, - ): Promise => { - const ownedScope = scope === undefined; - const actualScope = scope ?? (await makeScope()); - try { - return await Effect.runPromise( - effect.pipe(Effect.provide(providedLayer), Effect.provideService(Scope.Scope, actualScope)), - ); - } finally { - if (ownedScope) await Effect.runPromise(Scope.close(actualScope, Exit.void)); - } + const run = async (effect: Effect.Effect): Promise => { + return await Effect.runPromise(effect.pipe(Effect.provide(providedLayer))); }; const createOrOpen = async ( diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 14c32fd083..29923021c2 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -61,10 +61,11 @@ import { makeHandle, openStack, type EffectStack, + type HandleDependencies, type PrepareStackResult, } from "./EffectStack.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; -import { StackIdSchema } from "./StackId.ts"; +import { StackIdSchema, type StackId } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; import { makeDockerEngine } from "../runtime/DockerEngine.ts"; import { @@ -123,6 +124,18 @@ const stoppedState = (): PersistedStackState => ({ const emptyLogs = () => Effect.succeed({ entries: [], cursor: { opaque: "v1_0" }, running: false } as const); +const makeTestHandle = (id: StackId, overrides: Partial = {}) => + makeHandle(id, { + resolveOwner: () => Effect.succeed(Option.none()), + readOfflineState: () => Effect.succeed(Option.none()), + readPersistedState: () => Effect.succeed(Option.none()), + readLogs: emptyLogs, + waitForRelease: () => Effect.void, + prepare: () => + Effect.fail(new StackPreparationError({ message: "test preparation unavailable" })), + ...overrides, + }); + const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => Effect.scoped( Effect.gen(function* () { @@ -194,7 +207,7 @@ describe("Effect stack lifecycle handoff", () => { }), }, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.some(owner)), }); const stopped = yield* stack.stop().pipe(Effect.exit); @@ -309,7 +322,7 @@ describe("Effect stack lifecycle handoff", () => { }, onShutdownReady: Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Ref.get(ownerAvailable).pipe( Effect.map((available) => (available ? Option.some(owner) : Option.none())), @@ -380,7 +393,7 @@ describe("Effect stack lifecycle handoff", () => { stop: Effect.succeed({ ok: true, op: "stop" }), }, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.some(owner)), }); const result = yield* stack.logs({ cursor: { opaque: "not-a-cursor" } }).pipe(Effect.exit); @@ -429,7 +442,7 @@ describe("Effect stack lifecycle handoff", () => { }, ]; const burst = entries.slice(1); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.none()), readPersistedState: () => Effect.succeed(Option.some(stoppedState())), readOfflineState: () => Effect.succeed(Option.none()), @@ -507,7 +520,7 @@ describe("Effect stack lifecycle handoff", () => { const ownership = new StackOwnershipConflictError({ message: `owner ${artifact} is still present`, }); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.none()), readOfflineState: () => Effect.fail(ownership), readLogs: () => Effect.fail(ownership), @@ -520,36 +533,66 @@ describe("Effect stack lifecycle handoff", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("preserves an unreachable owner error when offline status is still guarded", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const path = yield* Path.Path; + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint: { kind: "unix" as const, path: path.join(project, "missing-owner.sock") }, + ownerSessionId: "unreachable-owner", + rpcRelease: STACK_RPC_RELEASE, + }; + let ownerReads = 0; + const stack = yield* makeTestHandle(stackId, { + resolveOwner: () => + Effect.sync(() => { + ownerReads += 1; + return ownerReads === 1 ? Option.some(owner) : Option.none(); + }), + readOfflineState: () => + Effect.fail( + new StackOwnershipConflictError({ message: "Owner metadata still exists" }), + ), + }); + const result = yield* stack.status().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(error).toBeInstanceOf(StackOwnershipConflictError); + expect(error?.message).toContain("Stack owner is unreachable"); + } + }), + ), + ); + it.live("waits for owner teardown before falling back to offline logs", () => withRuntimeRoot((_project) => Effect.gen(function* () { const attempts = yield* Ref.make(0); - const readAttempts = yield* Ref.make(0); const ownership = new StackOwnershipConflictError({ message: "Supervisor is still shutting down", }); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.none()), readPersistedState: () => Effect.succeed(Option.some(stoppedState())), - readOfflineState: () => - Ref.getAndUpdate(readAttempts, (current) => current + 1).pipe( + readLogs: () => + Ref.getAndUpdate(attempts, (current) => current + 1).pipe( Effect.flatMap((attempt) => - attempt === 0 ? Effect.fail(ownership) : Effect.succeed(Option.none()), + attempt === 0 + ? Effect.fail(ownership) + : Effect.succeed({ + entries: [], + cursor: { opaque: "v1_0" }, + running: false, + }), ), ), - readLogs: () => - Ref.update(attempts, (current) => current + 1).pipe( - Effect.as({ - entries: [], - cursor: { opaque: "v1_0" }, - running: false, - }), - ), }); const batch = yield* stack.logs(); expect(batch.entries).toEqual([]); - expect(yield* Ref.get(attempts)).toBe(1); - expect(yield* Ref.get(readAttempts)).toBe(2); + expect(yield* Ref.get(attempts)).toBe(2); }), ), ); @@ -860,7 +903,7 @@ describe("Effect stack lifecycle handoff", () => { stop: Effect.succeed({ ok: true, op: "stop" }), }, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.some(owner)), }); const result = yield* stack.start().pipe(Effect.exit); @@ -934,7 +977,7 @@ describe("Effect stack lifecycle handoff", () => { stop: Effect.succeed({ ok: true, op: "stop" }), }, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.some(owner)), }); const first = yield* Effect.forkChild(stack.start(), { startImmediately: true }); @@ -1107,7 +1150,7 @@ describe("Effect stack lifecycle handoff", () => { ownerSessionId, rpcRelease: STACK_RPC_RELEASE, }; - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.some(owner)), }); yield* stack.start(); @@ -1155,7 +1198,7 @@ describe("Effect stack lifecycle handoff", () => { }, }); let launched = false; - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: (launch) => Effect.sync(() => { launched ||= launch; @@ -1203,7 +1246,7 @@ describe("Effect stack lifecycle handoff", () => { maintenanceHandlers, onShutdownReady: Deferred.succeed(responseSent, undefined).pipe(Effect.asVoid), }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed( Option.some({ @@ -1230,7 +1273,7 @@ describe("Effect stack lifecycle handoff", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-" }); - const stack = yield* makeHandle(stackId, { + const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed( Option.some({ diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 6f72e64471..26342c3b28 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -59,6 +59,40 @@ export type ContainerLabels = | ContainerNetworkLabels | ContainerWorkloadLabels | ContainerVolumeLabels; + +export const CONTAINER_LABEL_PREFIX = "com.supabase.stack"; +export const CONTAINER_LABEL_KEYS = { + stackId: `${CONTAINER_LABEL_PREFIX}.stackId`, + ownerSessionId: `${CONTAINER_LABEL_PREFIX}.ownerSessionId`, + workloadId: `${CONTAINER_LABEL_PREFIX}.workloadId`, + startup: `${CONTAINER_LABEL_PREFIX}.startup`, + role: `${CONTAINER_LABEL_PREFIX}.role`, +}; + +/** Shapes byte-identical label argv for Docker and Podman. */ +export const containerLabels = (value: ContainerLabels): ReadonlyArray => { + const pairs: ReadonlyArray = + value.role === "network" + ? [ + ["stackId", value.stackId], + ["ownerSessionId", value.ownerSessionId], + ["role", value.role], + ] + : value.role === "volume" + ? [ + ["stackId", value.stackId], + ["workloadId", value.workloadId], + ["role", value.role], + ] + : [ + ["stackId", value.stackId], + ["ownerSessionId", value.ownerSessionId], + ["workloadId", value.workloadId], + ["startup", value.startup === true ? "true" : "false"], + ["role", value.role], + ]; + return pairs.flatMap(([key, label]) => ["--label", `${CONTAINER_LABEL_PREFIX}.${key}=${label}`]); +}; export interface ContainerResource { readonly id: string; readonly name: string; diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index b8ab12fccc..e2c7a6d971 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -105,7 +105,7 @@ interface ContainerVolumeRequest { interface ContainerRuntimeResourceIds { readonly container: string; - readonly state: "running" | "stopped" | "failed"; + state: "starting" | "running" | "stopped" | "failed"; readonly error?: string; } @@ -392,11 +392,13 @@ export const makeContainerRuntime = ( state: local?.state === "failed" ? "failed" - : entry.state === "running" - ? "ready" - : entry.state === "stopped" - ? "stopped" - : "starting", + : local?.state === "starting" + ? "starting" + : entry.state === "running" + ? "ready" + : entry.state === "stopped" + ? "stopped" + : "starting", ...(local?.error === undefined ? {} : { error: local.error }), }; }), @@ -748,7 +750,7 @@ export const makeContainerRuntime = ( workload, ownerSessionId: options.ownerSessionId, container: container.id, - state: "running", + state: "starting", failure, stopRequested: false, }; @@ -795,6 +797,7 @@ export const makeContainerRuntime = ( ? Effect.failCause(ready.cause) : Effect.failCause(Cause.combine(ready.cause, cleanupCause)); } + resource.state = "running"; const result: ObservedWorkload = { ...key, state: "ready" }; return result; }); diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index 6cec8ebd6a..4d8362c48c 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -1,44 +1,15 @@ import { makeContainerEngineCodecs, makeContainerEngineCore, + CONTAINER_LABEL_KEYS, + containerLabels, type ContainerCommand, type ContainerEngine, type ContainerEngineOptions, type ContainerLogOptions, - type ContainerLabels, type ContainerProcessRequest, } from "./ContainerEngine.ts"; -const stackLabel = "com.supabase.stack.stackId"; -const ownerLabel = "com.supabase.stack.ownerSessionId"; -const workloadLabel = "com.supabase.stack.workloadId"; -const startupLabel = "com.supabase.stack.startup"; -const roleLabel = "com.supabase.stack.role"; - -const labels = (value: ContainerLabels): ReadonlyArray => { - const pairs = - value.role === "network" - ? [ - ["stackId", value.stackId], - ["ownerSessionId", value.ownerSessionId], - ["role", value.role], - ] - : value.role === "volume" - ? [ - ["stackId", value.stackId], - ["workloadId", value.workloadId], - ["role", value.role], - ] - : [ - ["stackId", value.stackId], - ["ownerSessionId", value.ownerSessionId], - ["workloadId", value.workloadId], - ["startup", value.startup === true ? "true" : "false"], - ["role", value.role], - ]; - return pairs.flatMap(([key, label]) => ["--label", `com.supabase.stack.${key}=${String(label)}`]); -}; - const jsonLabel = (label: string): string => `{{json (.Label "${label}")}}`; // Emit a closed set of JSON scalar fields. In particular, do not decode the @@ -46,30 +17,27 @@ const jsonLabel = (label: string): string => `{{json (.Label "${label}")}}`; const containerFormat = [ "{{json .ID}}", "{{json .Names}}", - jsonLabel(stackLabel), - jsonLabel(ownerLabel), - jsonLabel(workloadLabel), - jsonLabel(startupLabel), - jsonLabel(roleLabel), + jsonLabel(CONTAINER_LABEL_KEYS.stackId), + jsonLabel(CONTAINER_LABEL_KEYS.ownerSessionId), + jsonLabel(CONTAINER_LABEL_KEYS.workloadId), + jsonLabel(CONTAINER_LABEL_KEYS.startup), + jsonLabel(CONTAINER_LABEL_KEYS.role), "{{json .State}}", ].join("\\t"); const networkFormat = [ "{{json .ID}}", "{{json .Name}}", - jsonLabel(stackLabel), - jsonLabel(ownerLabel), - jsonLabel(roleLabel), + jsonLabel(CONTAINER_LABEL_KEYS.stackId), + jsonLabel(CONTAINER_LABEL_KEYS.ownerSessionId), + jsonLabel(CONTAINER_LABEL_KEYS.role), ].join("\\t"); const volumeFormat = [ "{{json .Name}}", - jsonLabel(stackLabel), - jsonLabel(workloadLabel), - jsonLabel(roleLabel), + jsonLabel(CONTAINER_LABEL_KEYS.stackId), + jsonLabel(CONTAINER_LABEL_KEYS.workloadId), + jsonLabel(CONTAINER_LABEL_KEYS.role), ].join("\\t"); -const commandLabels = (spec: { readonly labels: ContainerLabels }): ReadonlyArray => - labels(spec.labels); - export const serializeDockerCommand = (command: ContainerCommand): ContainerProcessRequest => { switch (command.operation) { case "probe": @@ -86,7 +54,7 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc "ps", "--all", "--filter", - `label=${stackLabel}=${command.stackId}`, + `label=${CONTAINER_LABEL_KEYS.stackId}=${command.stackId}`, "--format", containerFormat, ], @@ -97,7 +65,7 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc "network", "ls", "--filter", - `label=${stackLabel}=${command.stackId}`, + `label=${CONTAINER_LABEL_KEYS.stackId}=${command.stackId}`, "--format", networkFormat, ], @@ -108,17 +76,21 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc "volume", "ls", "--filter", - `label=${stackLabel}=${command.stackId}`, + `label=${CONTAINER_LABEL_KEYS.stackId}=${command.stackId}`, "--format", volumeFormat, ], }; case "create-network": - return { args: ["network", "create", ...commandLabels(command.spec), command.spec.name] }; + return { + args: ["network", "create", ...containerLabels(command.spec.labels), command.spec.name], + }; case "remove-network": return { args: ["network", "rm", command.id] }; case "create-volume": - return { args: ["volume", "create", ...commandLabels(command.spec), command.spec.name] }; + return { + args: ["volume", "create", ...containerLabels(command.spec.labels), command.spec.name], + }; case "remove-volume": return { args: ["volume", "rm", command.id] }; case "create-container": { @@ -154,7 +126,7 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc "--network", command.spec.network, ...networkAliases, - ...commandLabels(command.spec), + ...containerLabels(command.spec.labels), ...bindMounts, ...volumeMounts, ...publications, diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index b5905a2e5b..8807b98f26 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -397,19 +397,15 @@ export const makeNativeRuntime = ( ); }).pipe(Effect.exit); const closed = yield* Scope.close(startupScope, Exit.void).pipe(Effect.exit); - if (Exit.isFailure(result) && Exit.isFailure(terminated) && Exit.isFailure(closed)) - return yield* Effect.failCause( - Cause.combine(result.cause, Cause.combine(terminated.cause, closed.cause)), - ); - if (Exit.isFailure(result) && Exit.isFailure(terminated)) - return yield* Effect.failCause(Cause.combine(result.cause, terminated.cause)); - if (Exit.isFailure(result) && Exit.isFailure(closed)) - return yield* Effect.failCause(Cause.combine(result.cause, closed.cause)); - if (Exit.isFailure(terminated) && Exit.isFailure(closed)) - return yield* Effect.failCause(Cause.combine(terminated.cause, closed.cause)); - if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); - if (Exit.isFailure(terminated)) return yield* Effect.failCause(terminated.cause); - if (Exit.isFailure(closed)) return yield* Effect.failCause(closed.cause); + let failureCause: Cause.Cause | undefined; + for (const candidate of [result, terminated, closed]) { + if (!Exit.isFailure(candidate)) continue; + failureCause = + failureCause === undefined + ? candidate.cause + : Cause.combine(failureCause, candidate.cause); + } + if (failureCause !== undefined) return yield* Effect.failCause(failureCause); }), ); }); diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index 7c250aa505..2c32bf5783 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -1,44 +1,15 @@ import { makeContainerEngineCodecs, makeContainerEngineCore, + CONTAINER_LABEL_KEYS, + containerLabels, type ContainerCommand, type ContainerEngine, type ContainerEngineOptions, type ContainerLogOptions, - type ContainerLabels, type ContainerProcessRequest, } from "./ContainerEngine.ts"; -const stackLabel = "com.supabase.stack.stackId"; -const ownerLabel = "com.supabase.stack.ownerSessionId"; -const workloadLabel = "com.supabase.stack.workloadId"; -const startupLabel = "com.supabase.stack.startup"; -const roleLabel = "com.supabase.stack.role"; - -const labels = (value: ContainerLabels): ReadonlyArray => { - const pairs = - value.role === "network" - ? [ - ["stackId", value.stackId], - ["ownerSessionId", value.ownerSessionId], - ["role", value.role], - ] - : value.role === "volume" - ? [ - ["stackId", value.stackId], - ["workloadId", value.workloadId], - ["role", value.role], - ] - : [ - ["stackId", value.stackId], - ["ownerSessionId", value.ownerSessionId], - ["workloadId", value.workloadId], - ["startup", value.startup === true ? "true" : "false"], - ["role", value.role], - ]; - return pairs.flatMap(([key, label]) => ["--label", `com.supabase.stack.${key}=${String(label)}`]); -}; - const templateLabel = (label: string): string => `{{.Label "${label}"}}`; const templateMapLabel = (label: string): string => `{{index .Labels "${label}"}}`; @@ -47,30 +18,27 @@ const templateMapLabel = (label: string): string => `{{index .Labels "${label}"} const containerFormat = [ "{{.ID}}", "{{.Names}}", - templateLabel(stackLabel), - templateLabel(ownerLabel), - templateLabel(workloadLabel), - templateLabel(startupLabel), - templateLabel(roleLabel), + templateLabel(CONTAINER_LABEL_KEYS.stackId), + templateLabel(CONTAINER_LABEL_KEYS.ownerSessionId), + templateLabel(CONTAINER_LABEL_KEYS.workloadId), + templateLabel(CONTAINER_LABEL_KEYS.startup), + templateLabel(CONTAINER_LABEL_KEYS.role), "{{.State}}", ].join("\\t"); const networkFormat = [ "{{.ID}}", "{{.Name}}", - templateMapLabel(stackLabel), - templateMapLabel(ownerLabel), - templateMapLabel(roleLabel), + templateMapLabel(CONTAINER_LABEL_KEYS.stackId), + templateMapLabel(CONTAINER_LABEL_KEYS.ownerSessionId), + templateMapLabel(CONTAINER_LABEL_KEYS.role), ].join("\\t"); const volumeFormat = [ "{{.Name}}", - templateMapLabel(stackLabel), - templateMapLabel(workloadLabel), - templateMapLabel(roleLabel), + templateMapLabel(CONTAINER_LABEL_KEYS.stackId), + templateMapLabel(CONTAINER_LABEL_KEYS.workloadId), + templateMapLabel(CONTAINER_LABEL_KEYS.role), ].join("\\t"); -const commandLabels = (spec: { readonly labels: ContainerLabels }): ReadonlyArray => - labels(spec.labels); - export const serializePodmanCommand = (command: ContainerCommand): ContainerProcessRequest => { switch (command.operation) { case "probe": @@ -87,7 +55,7 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc "ps", "--all", "--filter", - `label=${stackLabel}=${command.stackId}`, + `label=${CONTAINER_LABEL_KEYS.stackId}=${command.stackId}`, "--format", containerFormat, ], @@ -98,7 +66,7 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc "network", "ls", "--filter", - `label=${stackLabel}=${command.stackId}`, + `label=${CONTAINER_LABEL_KEYS.stackId}=${command.stackId}`, "--format", networkFormat, ], @@ -109,17 +77,21 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc "volume", "ls", "--filter", - `label=${stackLabel}=${command.stackId}`, + `label=${CONTAINER_LABEL_KEYS.stackId}=${command.stackId}`, "--format", volumeFormat, ], }; case "create-network": - return { args: ["network", "create", ...commandLabels(command.spec), command.spec.name] }; + return { + args: ["network", "create", ...containerLabels(command.spec.labels), command.spec.name], + }; case "remove-network": return { args: ["network", "rm", command.id] }; case "create-volume": - return { args: ["volume", "create", ...commandLabels(command.spec), command.spec.name] }; + return { + args: ["volume", "create", ...containerLabels(command.spec.labels), command.spec.name], + }; case "remove-volume": return { args: ["volume", "rm", command.id] }; case "create-container": { @@ -151,7 +123,7 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc "--network", command.spec.network, ...networkAliases, - ...commandLabels(command.spec), + ...containerLabels(command.spec.labels), ...bindMounts, ...volumeMounts, ...publications, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 9aee213d9f..10bd129662 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -890,6 +890,37 @@ describe("container runtime", () => { }), ); + it.live("reports a running container as starting until readiness completes", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const readinessEntered = yield* Deferred.make(); + const readinessRelease = yield* Deferred.make(); + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + waitForReadiness: () => + Effect.gen(function* () { + yield* Deferred.succeed(readinessEntered, undefined); + yield* Deferred.await(readinessRelease); + }), + }); + const starting = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readinessEntered); + const observed = yield* runtime.observe(stackId); + expect(observed).toEqual([{ ...key, state: "starting" }]); + yield* Deferred.succeed(readinessRelease, undefined); + expect((yield* Fiber.join(starting)).state).toBe("ready"); + }), + ); + it.live("allows stop to interrupt a blocked container readiness", () => Effect.gen(function* () { const state: FakeContainerState = { diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 1132c1daf2..764c020ca0 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -224,13 +224,13 @@ describe("workload runtime catalog", () => { ports: [], privatePorts: [], }); - const reservation = yield* makePortCoordinator({ stateRoot: root, store }).planAndReserve( - stackId, - disabledListenerIntents, - { - privateBindings: privateBindingIntentsFor(compiled.executionPlan), - }, - ); + const reservation = yield* makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: () => Effect.void, + }).planAndReserve(stackId, disabledListenerIntents, { + privateBindings: privateBindingIntentsFor(compiled.executionPlan), + }); const pgmetaPrimary = reservation.privateAssignments.find( ({ workloadId, binding }) => workloadId === "studio:pgmeta" && binding === "primary", ); diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index c8bd122c86..d39ded9409 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -601,16 +601,12 @@ export const acquireOwnership = (options: { : error, ), ); - // The old control document/socket can only be removed after the stale + // The old control document can only be removed after the stale // lease has been revalidated; the canonical lock remains until install. if (existing !== undefined) { yield* fs .remove(paths.controlMetadata, { force: true }) .pipe(Effect.catchTag("PlatformError", () => Effect.void)); - if (options.environment.platform === "posix" && metadata.endpoint.kind === "unix") - yield* fs - .remove(metadata.endpoint.path, { force: true }) - .pipe(Effect.catchTag("PlatformError", () => Effect.void)); } yield* installLease({ fs, @@ -627,6 +623,13 @@ export const acquireOwnership = (options: { : error, ), ); + // Only the process that atomically installed the canonical lock has + // lease authority to remove a stale Unix control socket. A loser + // must not unlink the winner's newly bound endpoint. + if (options.environment.platform === "posix" && metadata.endpoint.kind === "unix") + yield* fs + .remove(metadata.endpoint.path, { force: true }) + .pipe(Effect.catchTag("PlatformError", () => Effect.void)); }); yield* installed.pipe( Effect.ensuring( diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 52fb8821d0..fbb81ea3ad 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -61,6 +61,12 @@ export interface PortReservation { export interface PortCoordinatorOptions { readonly stateRoot: string; readonly store: StackStateStore; + /** Probes a fresh automatic candidate without retaining a listener. */ + readonly checkHostPort: ( + address: string, + port: number, + field: string, + ) => Effect.Effect; /** Binds and retains a host listener. The enclosing Scope owns its release. */ readonly bindHost?: ( address: string, @@ -90,6 +96,7 @@ const PRIVATE_PORT_MIN = 30_000; const PRIVATE_PORT_MAX = 39_999; const PUBLIC_PORT_MIN = 40_000; const PUBLIC_PORT_MAX = 65_535; +const MAX_FAILED_HOST_PROBES = 16; const assignmentMap = (assignments: ReadonlyArray) => new Map(assignments.map((assignment) => [assignment.field, assignment])); @@ -188,6 +195,7 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi assignment = prior; } else { let selected: number | undefined; + let failedHostProbes = 0; for (let port = PUBLIC_PORT_MIN; port <= PUBLIC_PORT_MAX; port += 1) { if ( !usedAutomaticPublic.has(port) && @@ -196,6 +204,17 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi !usedReservedPrivate.has(port) && !usedByThisStack.has(port) ) { + const available = yield* options + .checkHostPort(intent.address, port, field) + .pipe( + Effect.as(true), + Effect.catchTag("PortUnavailableError", () => Effect.succeed(false)), + ); + if (!available) { + failedHostProbes += 1; + if (failedHostProbes >= MAX_FAILED_HOST_PROBES) break; + continue; + } selected = port; break; } @@ -243,6 +262,7 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi let port: number | undefined; if (prior !== undefined && !usedPrivateByThisStack.has(prior.port)) port = prior.port; else { + let failedHostProbes = 0; for ( let candidate = PRIVATE_PORT_MIN; candidate <= PRIVATE_PORT_MAX; @@ -254,6 +274,17 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi !usedPrivateByThisStack.has(candidate) && !usedAllByThisStack.has(candidate) ) { + const available = yield* options + .checkHostPort("127.0.0.1", candidate, `${intent.workloadId}:${intent.binding}`) + .pipe( + Effect.as(true), + Effect.catchTag("PortUnavailableError", () => Effect.succeed(false)), + ); + if (!available) { + failedHostProbes += 1; + if (failedHostProbes >= MAX_FAILED_HOST_PROBES) break; + continue; + } port = candidate; break; } diff --git a/packages/stack/src/state/ownership.integration.test.ts b/packages/stack/src/state/ownership.integration.test.ts index b83a4829f9..c6c9764f90 100644 --- a/packages/stack/src/state/ownership.integration.test.ts +++ b/packages/stack/src/state/ownership.integration.test.ts @@ -387,6 +387,37 @@ describe("stack ownership", () => { ), ); + it.live("removes a stale Unix control socket when no prior lease record exists", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-no-lock-socket-", + }); + const stackId = yield* deriveStackId(identity); + const environment: StackRuntimeEnvironmentValue = { + stateRoot: root, + tempRoot: root, + platform: "posix", + }; + const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); + const endpoint = controlEndpointFor(stackId, environment); + if (endpoint.kind !== "unix") return yield* Effect.die("expected Unix endpoint"); + yield* fs.makeDirectory(paths.runtime, { recursive: true }); + yield* fs.writeFileString(endpoint.path, "stale socket"); + const lease = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "new-owner", + rpcRelease: "rpc", + environment, + }); + expect(yield* fs.exists(endpoint.path)).toBe(false); + yield* lease.release; + }), + ), + ); + it.live("serializes concurrent recovery contenders", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index c943fe4d13..2d82738dfa 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -11,6 +11,7 @@ import { makeStackStateStore, type PersistedStackState } from "./StackStateStore import { bindHostListener } from "../supervisor/HostListener.ts"; const layer = NodeServices.layer; +const successfulHostPortCheck = (_address: string, _port: number, _field: string) => Effect.void; const intents = (port: "automatic" | number): ListenerIntents => ({ api: { enabled: true, address: "127.0.0.1", port }, database: { enabled: true, address: "127.0.0.1", port: "automatic" }, @@ -152,7 +153,11 @@ describe("sticky port coordination", () => { const b = yield* deriveStackId(bIdentity); yield* store.initialize(a, baseState(a, aIdentity)); yield* store.initialize(b, baseState(b, bIdentity)); - const coordinator = makePortCoordinator({ stateRoot: root, store }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + }); const first = yield* coordinator.planAndReserve(a, intents("automatic")); const repeat = yield* coordinator.planAndReserve(a, intents("automatic")); expect(repeat.assignments).toEqual(first.assignments); @@ -162,6 +167,29 @@ describe("sticky port coordination", () => { ), ); + it.live("skips foreign listeners while selecting fresh automatic ports", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-port-probe-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "probe"); + const id = yield* deriveStackId(identity); + yield* store.initialize(id, baseState(id, identity)); + const checkHostPort = (address: string, port: number, field: string) => + port === 40000 || port === 30000 + ? Effect.fail(new PortUnavailableError({ address, port, field, message: "occupied" })) + : Effect.void; + const coordinator = makePortCoordinator({ stateRoot: root, store, checkHostPort }); + const reservation = yield* coordinator.planAndReserve(id, intents("automatic"), { + privateBindings: [{ workloadId: "db", binding: "postgres" }], + }); + expect(reservation.assignments.api?.port).toBe(40001); + expect(reservation.privateAssignments[0]?.port).toBe(30001); + }), + ), + ); + it.live("rejects an exact request for another stack's automatic assignment", () => withPlatform( Effect.gen(function* () { @@ -176,7 +204,11 @@ describe("sticky port coordination", () => { const b = yield* deriveStackId(bIdentity); yield* store.initialize(a, baseState(a, aIdentity)); yield* store.initialize(b, baseState(b, bIdentity)); - const coordinator = makePortCoordinator({ stateRoot: root, store }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + }); const automatic = yield* coordinator.planAndReserve(a, intents("automatic")); const occupied = automatic.assignments.api?.port; expect(occupied).toBeTypeOf("number"); @@ -216,6 +248,7 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator({ stateRoot: root, store, + checkHostPort: successfulHostPortCheck, bindHost: makeTestHostListener, }); const exactToDifferent = yield* coordinator.planAndReserve(exactId, { @@ -251,6 +284,7 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator({ stateRoot: root, store, + checkHostPort: successfulHostPortCheck, bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve(stackId, { @@ -283,6 +317,7 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator({ stateRoot: root, store, + checkHostPort: successfulHostPortCheck, bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve( @@ -316,6 +351,7 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator({ stateRoot: root, store, + checkHostPort: successfulHostPortCheck, bindHost: makeTestHostListener, }); const result = yield* coordinator.planAndReserve(stackId, disabledIntents(), {}); @@ -340,7 +376,11 @@ describe("sticky port coordination", () => { privatePorts: [], }); yield* store.initialize(d, baseState(d, dIdentity)); - const coordinator = makePortCoordinator({ stateRoot: root, store }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + }); yield* coordinator.planAndReserve(d, intents(55432)); const stopped = yield* store.read(d); if (stopped === undefined) return; @@ -362,7 +402,11 @@ describe("sticky port coordination", () => { const identity = makeIdentity(root, "duplicate"); const stackId = yield* deriveStackId(identity); yield* store.initialize(stackId, baseState(stackId, identity)); - const coordinator = makePortCoordinator({ stateRoot: root, store }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + }); const exit = yield* coordinator .planAndReserve(stackId, { ...disabledIntents(), @@ -398,6 +442,7 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator({ stateRoot: root, store, + checkHostPort: successfulHostPortCheck, bindHost: bindHostListener, }); const exit = yield* coordinator @@ -437,6 +482,7 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator({ stateRoot: root, store, + checkHostPort: successfulHostPortCheck, bindHost: (address, port, field) => bound.has(port) ? Effect.fail(new PortUnavailableError({ port, field, message: "already bound" })) @@ -480,6 +526,7 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator({ stateRoot: root, store, + checkHostPort: successfulHostPortCheck, bindHost: (address, port, field) => { attempts += 1; if (attempts === 2) @@ -520,6 +567,7 @@ describe("sticky port coordination", () => { const coordinator = makePortCoordinator({ stateRoot: root, store, + checkHostPort: successfulHostPortCheck, bindHost: (address, port, field) => Effect.fail(new PortUnavailableError({ port, field, message: `race at ${address}` })), }); @@ -540,7 +588,11 @@ describe("sticky port coordination", () => { const identity = makeIdentity(root, "private"); const stackId = yield* deriveStackId(identity); yield* store.initialize(stackId, baseState(stackId, identity)); - const coordinator = makePortCoordinator({ stateRoot: root, store }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + }); const bindings = [ { workloadId: "mail:mail", binding: "ui" }, { workloadId: "mail:mail", binding: "smtp" }, @@ -577,7 +629,11 @@ describe("sticky port coordination", () => { const b = yield* deriveStackId(bIdentity); yield* store.initialize(a, baseState(a, aIdentity)); yield* store.initialize(b, baseState(b, bIdentity)); - const coordinator = makePortCoordinator({ stateRoot: root, store }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + }); const first = yield* coordinator.planAndReserve(a, disabledIntents(), { privateBindings: [{ workloadId: "database:database", binding: "primary" }], }); diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 014c1bc01c..9dfdbac025 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -24,7 +24,7 @@ import { import type { StackStateStore } from "../state/StackStateStore.ts"; import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; import type { PortField } from "../public/Status.ts"; -import { bindHostListener, isHttpPortField } from "./HostListener.ts"; +import { bindHostListener, checkHostPort, isHttpPortField } from "./HostListener.ts"; import { AUTH_ANON_KEY_SLOT, AUTH_PUBLISHABLE_KEY_SLOT, @@ -181,6 +181,7 @@ export const makeSupervisorIngress = ( makePortCoordinator({ stateRoot: options.stateRoot, store: options.store, + checkHostPort, bindHost: options.bindHost ?? bindHostListener, }); const acquire = ( diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 797882a353..28a9828b6e 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -69,12 +69,12 @@ export const defaultRuntimeEnvironment = (): StackRuntimeEnvironmentValue => { type ReadinessResult = | { readonly kind: "ready"; readonly stackId: StackId; readonly ownerSessionId: string } - | { readonly kind: "ownership-conflict" } + | { readonly kind: "ownership-conflict"; readonly message: string } | { readonly kind: "failed"; readonly message: string }; type ChildResult = | { readonly kind: "ready"; readonly metadata: OwnerMetadata } - | { readonly kind: "ownership-conflict" } + | { readonly kind: "ownership-conflict"; readonly message: string } | { readonly kind: "failed"; readonly message: string }; const mapFailure = (message: string) => new StackStateInvalidError({ message }); @@ -112,7 +112,7 @@ const decodeReady = (bytes: Uint8Array): Effect.Effect mapFailure("Invalid supervisor readiness frame")), @@ -301,14 +301,22 @@ const launchAndAwait = ( yield* Fiber.interrupt(ownerFiber); return yield* validateCompatibleOwner(options, current).pipe( Effect.as(current), - Effect.catchTag("StackStateInvalidError", () => - Effect.fail( - new StackOwnershipConflictError({ - message: "A live Supervisor owns this stack but is not responding", - stackId: options.stackId, - }), - ), - ), + Effect.catchTags({ + StackStateInvalidError: () => + Effect.fail( + new StackOwnershipConflictError({ + message: childResult.message, + stackId: options.stackId, + }), + ), + StackOwnershipConflictError: () => + Effect.fail( + new StackOwnershipConflictError({ + message: childResult.message, + stackId: options.stackId, + }), + ), + }), ); } return yield* Fiber.join(ownerFiber).pipe( diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 393895978e..d3a876876a 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -175,6 +175,17 @@ export const makeSupervisor = ( // The ingress opens during an explicit lifecycle operation. A one-shot handoff keeps a request // waiting for the handler instead of exposing a construction-time race. const activationHandler = yield* Deferred.make(); + const activationOwned = new Map< + CapabilityName, + | { + readonly _tag: "pending"; + readonly result: Deferred.Deferred< + Exit.Exit, + never + >; + } + | { readonly _tag: "ready"; readonly result: ActivationResult } + >(); const ingressActivate = ( capability: CapabilityName, ): Effect.Effect => @@ -467,17 +478,6 @@ export const makeSupervisor = ( return { capability, endpoint }; }); - const activationOwned = new Map< - CapabilityName, - | { - readonly _tag: "pending"; - readonly result: Deferred.Deferred< - Exit.Exit, - never - >; - } - | { readonly _tag: "ready"; readonly result: ActivationResult } - >(); type ActivationExit = Exit.Exit; type ActivationToken = | { readonly _tag: "exit"; readonly result: ActivationExit } From 84b3a7bd9bb5a790d01df9094e58aa794f359f37 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 21:07:14 +0200 Subject: [PATCH 144/174] chore(stack): close final review gaps --- .../workload-runtime.integration.test.ts | 2 + packages/stack/src/state/PortCoordinator.ts | 29 ++++--- .../stack/src/state/ports.integration.test.ts | 78 +++++++++++++++++- packages/stack/src/supervisor/Launcher.ts | 21 ++--- .../supervisor/handles.integration.test.ts | 80 ++++++++++++++++++- 5 files changed, 182 insertions(+), 28 deletions(-) diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 764c020ca0..a6f23fef2e 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -6,6 +6,7 @@ import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { deriveStackId } from "../identity/Identity.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { makePortCoordinator, type ListenerIntents } from "../state/PortCoordinator.ts"; +import { bindHostListener } from "../supervisor/HostListener.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -228,6 +229,7 @@ describe("workload runtime catalog", () => { stateRoot: root, store, checkHostPort: () => Effect.void, + bindHost: bindHostListener, }).planAndReserve(stackId, disabledListenerIntents, { privateBindings: privateBindingIntentsFor(compiled.executionPlan), }); diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index fbb81ea3ad..0de3ce2307 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -68,7 +68,7 @@ export interface PortCoordinatorOptions { field: string, ) => Effect.Effect; /** Binds and retains a host listener. The enclosing Scope owns its release. */ - readonly bindHost?: ( + readonly bindHost: ( address: string, port: number, field: PortField, @@ -196,6 +196,7 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi } else { let selected: number | undefined; let failedHostProbes = 0; + let probeBudgetExhausted = false; for (let port = PUBLIC_PORT_MIN; port <= PUBLIC_PORT_MAX; port += 1) { if ( !usedAutomaticPublic.has(port) && @@ -212,7 +213,10 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi ); if (!available) { failedHostProbes += 1; - if (failedHostProbes >= MAX_FAILED_HOST_PROBES) break; + if (failedHostProbes >= MAX_FAILED_HOST_PROBES) { + probeBudgetExhausted = true; + break; + } continue; } selected = port; @@ -222,7 +226,9 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi if (selected === undefined) return yield* new PortAllocationError({ field, - message: "No automatic host port is available", + message: probeBudgetExhausted + ? `No automatic public host port is available after ${MAX_FAILED_HOST_PROBES} occupied candidates` + : "No automatic host port is available", }); assignment = { field, port: selected, intent: "automatic" }; usedAutomaticPublic.add(selected); @@ -260,6 +266,7 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi }); const prior = existingPrivate.get(key); let port: number | undefined; + let probeBudgetExhausted = false; if (prior !== undefined && !usedPrivateByThisStack.has(prior.port)) port = prior.port; else { let failedHostProbes = 0; @@ -282,7 +289,10 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi ); if (!available) { failedHostProbes += 1; - if (failedHostProbes >= MAX_FAILED_HOST_PROBES) break; + if (failedHostProbes >= MAX_FAILED_HOST_PROBES) { + probeBudgetExhausted = true; + break; + } continue; } port = candidate; @@ -293,7 +303,9 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi if (port === undefined) return yield* new PortAllocationError({ field: `${intent.workloadId}:${intent.binding}`, - message: "No automatic private port is available", + message: probeBudgetExhausted + ? `No automatic private port is available after ${MAX_FAILED_HOST_PROBES} occupied candidates` + : "No automatic private port is available", }); privateAssignments.push({ workloadId: intent.workloadId, @@ -342,11 +354,6 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi }), ); if (committed.lifecycle === "running") { - const bindHost = options.bindHost; - if (bindHost === undefined) { - const first = enabledAssignments[0]; - return yield* unavailable(first?.assignment.port ?? 0, first?.field ?? "api"); - } const parentScope = yield* Scope.Scope; const acquired = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -354,7 +361,7 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi const bound = yield* Effect.exit( restore( Effect.forEach(enabledAssignments, ({ field, intent, assignment }) => - bindHost(intent.address, assignment.port, field).pipe( + options.bindHost(intent.address, assignment.port, field).pipe( Effect.catchTag("PortUnavailableError", () => Effect.fail(unavailable(assignment.port, field)), ), diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 2d82738dfa..350aac51fd 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -157,6 +157,7 @@ describe("sticky port coordination", () => { stateRoot: root, store, checkHostPort: successfulHostPortCheck, + bindHost: makeTestHostListener, }); const first = yield* coordinator.planAndReserve(a, intents("automatic")); const repeat = yield* coordinator.planAndReserve(a, intents("automatic")); @@ -180,7 +181,12 @@ describe("sticky port coordination", () => { port === 40000 || port === 30000 ? Effect.fail(new PortUnavailableError({ address, port, field, message: "occupied" })) : Effect.void; - const coordinator = makePortCoordinator({ stateRoot: root, store, checkHostPort }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort, + bindHost: makeTestHostListener, + }); const reservation = yield* coordinator.planAndReserve(id, intents("automatic"), { privateBindings: [{ workloadId: "db", binding: "postgres" }], }); @@ -190,6 +196,71 @@ describe("sticky port coordination", () => { ), ); + it.live("reports public probe-budget exhaustion distinctly", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-probe-limit-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "public-probe-limit"); + const id = yield* deriveStackId(identity); + yield* store.initialize(id, baseState(id, identity)); + const checkHostPort = (address: string, port: number, field: string) => + Effect.fail(new PortUnavailableError({ address, port, field, message: "occupied" })); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort, + bindHost: makeTestHostListener, + }); + const result = yield* coordinator + .planAndReserve(id, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: "automatic" }, + }) + .pipe(Effect.exit); + const error = errorOf(result); + expect(error).toBeInstanceOf(PortAllocationError); + expect(error?.message).toContain("public"); + expect(error?.message).toContain("16"); + }), + ), + ); + + it.live("reports private probe-budget exhaustion distinctly", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-private-probe-limit-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "private-probe-limit"); + const id = yield* deriveStackId(identity); + yield* store.initialize(id, baseState(id, identity)); + const checkHostPort = (address: string, port: number, field: string) => + Effect.fail(new PortUnavailableError({ address, port, field, message: "occupied" })); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort, + bindHost: makeTestHostListener, + }); + const result = yield* coordinator + .planAndReserve(id, disabledIntents(), { + privateBindings: [{ workloadId: "db", binding: "postgres" }], + }) + .pipe(Effect.exit); + const error = errorOf(result); + expect(error).toBeInstanceOf(PortAllocationError); + expect(error?.message).toContain("private"); + expect(error?.message).toContain("16"); + }), + ), + ); + it.live("rejects an exact request for another stack's automatic assignment", () => withPlatform( Effect.gen(function* () { @@ -208,6 +279,7 @@ describe("sticky port coordination", () => { stateRoot: root, store, checkHostPort: successfulHostPortCheck, + bindHost: makeTestHostListener, }); const automatic = yield* coordinator.planAndReserve(a, intents("automatic")); const occupied = automatic.assignments.api?.port; @@ -380,6 +452,7 @@ describe("sticky port coordination", () => { stateRoot: root, store, checkHostPort: successfulHostPortCheck, + bindHost: makeTestHostListener, }); yield* coordinator.planAndReserve(d, intents(55432)); const stopped = yield* store.read(d); @@ -406,6 +479,7 @@ describe("sticky port coordination", () => { stateRoot: root, store, checkHostPort: successfulHostPortCheck, + bindHost: makeTestHostListener, }); const exit = yield* coordinator .planAndReserve(stackId, { @@ -592,6 +666,7 @@ describe("sticky port coordination", () => { stateRoot: root, store, checkHostPort: successfulHostPortCheck, + bindHost: makeTestHostListener, }); const bindings = [ { workloadId: "mail:mail", binding: "ui" }, @@ -633,6 +708,7 @@ describe("sticky port coordination", () => { stateRoot: root, store, checkHostPort: successfulHostPortCheck, + bindHost: makeTestHostListener, }); const first = yield* coordinator.planAndReserve(a, disabledIntents(), { privateBindings: [{ workloadId: "database:database", binding: "primary" }], diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 28a9828b6e..89367dcf78 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -299,23 +299,16 @@ const launchAndAwait = ( if (current !== undefined) { // The watcher is only a launch-time helper once the winner is visible. yield* Fiber.interrupt(ownerFiber); + const childConflict = new StackOwnershipConflictError({ + message: childResult.message, + stackId: options.stackId, + }); + const preserveChildConflict = () => Effect.fail(childConflict); return yield* validateCompatibleOwner(options, current).pipe( Effect.as(current), Effect.catchTags({ - StackStateInvalidError: () => - Effect.fail( - new StackOwnershipConflictError({ - message: childResult.message, - stackId: options.stackId, - }), - ), - StackOwnershipConflictError: () => - Effect.fail( - new StackOwnershipConflictError({ - message: childResult.message, - stackId: options.stackId, - }), - ), + StackStateInvalidError: preserveChildConflict, + StackOwnershipConflictError: preserveChildConflict, }), ); } diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index f41f40cc52..5e61fdc584 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -13,10 +13,11 @@ import { Redacted, Result, Schema, + Sink, Scope, Stream, } from "effect"; -import { ChildProcess } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { createStack, @@ -29,6 +30,7 @@ import type { StackStatus } from "../public/Status.ts"; import { deriveStackId, resolveStackIdentity } from "../identity/Identity.ts"; import { defaultRuntimeEnvironment, + ensureSupervisor, SUPERVISOR_DISPATCH_SENTINEL, supervisorEntrypointFor, type StackRuntimeEnvironmentValue, @@ -37,6 +39,7 @@ import { StackIdSchema } from "../public/StackId.ts"; import type { StackId } from "../public/StackId.ts"; import { acquireOwnership, + controlEndpointFor, publishOwnership, readOwnerMetadata, StackRuntimeEnvironment, @@ -45,7 +48,11 @@ import { makeStackStateStore } from "../state/StackStateStore.ts"; import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; -import { StackPreparationError, StackRuntimeMismatchError } from "../public/Errors.ts"; +import { + StackOwnershipConflictError, + StackPreparationError, + StackRuntimeMismatchError, +} from "../public/Errors.ts"; import type { ContainerEngine } from "../runtime/ContainerEngine.ts"; import { ContainerEngineResolver } from "../runtime/ContainerEngineResolver.ts"; @@ -154,6 +161,75 @@ describe("managed stack handles", { timeout: 30_000 }, () => { expect(supervisorEntrypointFor(import.meta.url)).not.toBe(SUPERVISOR_DISPATCH_SENTINEL); }); + it.live("preserves the child ownership detail for metadata without a lease lock", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + const runtime: StackRuntimeEnvironmentValue = { + ...env, + platform: "windows", + tempRoot: project, + }; + const stack = yield* createStack({ projectRoot: project }); + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId: stack.id, + ownerSessionId: "stale-owner", + endpoint: controlEndpointFor(stack.id, runtime), + rpcRelease: STACK_RPC_RELEASE, + }; + yield* fs.writeFileString(paths.controlMetadata, JSON.stringify(owner)); + const frame = new TextEncoder().encode( + `${JSON.stringify({ + ok: false, + code: "ownership-conflict", + message: "Owner metadata exists without a lease lock; refusing recovery", + })}\n`, + ); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: (fd) => (fd === 3 ? Stream.succeed(frame) : Stream.empty), + unref: Effect.succeed(Effect.void), + }), + ), + ); + const result = yield* ensureSupervisor({ + identity, + stackId: stack.id, + stateStore: store, + environment: runtime, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.exit, + ); + yield* fs.remove(paths.controlMetadata, { force: true }); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(StackOwnershipConflictError); + expect(failure.value.message).toContain("without a lease lock"); + } + } + }), + ), + ); + it.live("persists Docker for an omitted container engine without probing", () => withRuntimeRoot((project) => Effect.gen(function* () { From 509db7e52f03dd27359b0dc1b283b5672e22bb4e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 21:22:27 +0200 Subject: [PATCH 145/174] chore(stack): close final runtime cleanup gaps --- packages/stack/src/public/EffectStack.ts | 3 ++ .../public/effect-stack.integration.test.ts | 30 +++++++++++ .../stack/src/runtime/ContainerRuntime.ts | 51 ++++++++----------- packages/stack/src/supervisor/Ingress.ts | 23 +++------ 4 files changed, 60 insertions(+), 47 deletions(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 053ff70f03..798972332a 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -730,6 +730,9 @@ const handleDependencies = (options: { const preparer = yield* makeProductionRuntimeArtifactPreparer({ stateRoot: options.environment.stateRoot, runtime: state.runtime, + ...(options.environment.artifactCacheRoot === undefined + ? {} + : { artifactCacheRoot: options.environment.artifactCacheRoot }), ...(options.containerEngineResolver === undefined ? {} : { containerEngineResolver: options.containerEngineResolver }), diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 29923021c2..79d2ae52a0 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -660,6 +660,36 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("applies the configured artifact cache root during handle preparation", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-cache-" }); + const project = path.join(root, "project"); + yield* fs.makeDirectory(project); + const defaults = defaultRuntimeEnvironment(); + const stateRoot = path.join(root, "managed", "stacks"); + const artifactCacheRoot = path.join(root, "shared-artifacts"); + const configuredEnvironment: StackRuntimeEnvironmentValue = { + ...defaults, + stateRoot, + artifactCacheRoot, + tempRoot: "/tmp", + platform: "posix", + }; + const stack = yield* createStack({ + projectRoot: project, + runtime: { kind: "native" }, + }).pipe(Effect.provideService(StackRuntimeEnvironment, configuredEnvironment)); + const prepared = yield* stack.prepare({ capabilities: [] }); + expect(prepared.capabilities).toEqual([]); + expect(yield* fs.exists(artifactCacheRoot)).toBe(true); + expect(yield* fs.exists(path.join(stateRoot, "artifacts"))).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.live("preserves input and version errors from prepare", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index e2c7a6d971..906fdb9a9e 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -690,8 +690,6 @@ export const makeContainerRuntime = ( }); yield* guard; - let created = false; - let startedByUs = false; const container = yield* withEngine( key, options.engine.createContainer({ @@ -712,7 +710,6 @@ export const makeContainerRuntime = ( ...(resolution.command === undefined ? {} : { command: resolution.command }), } satisfies ContainerContainerSpec), ); - created = true; if (resolution.bootstrap !== undefined) { const copied = yield* Effect.exit( withEngine( @@ -738,26 +735,21 @@ export const makeContainerRuntime = ( } yield* guard; yield* withEngine(key, options.engine.startContainer(container.id)); - startedByUs = true; - const current = resources.get(resourceKey(key)); - const resource = - current !== undefined && current.container === container.id && current.state === "running" - ? current - : yield* Effect.gen(function* () { - const failure = yield* Deferred.make(); - const createdResource: ContainerRuntimeResource = { - key, - workload, - ownerSessionId: options.ownerSessionId, - container: container.id, - state: "starting", - failure, - stopRequested: false, - }; - resources.set(resourceKey(key), createdResource); - return createdResource; - }); - if (resource.logFiber === undefined) yield* attachLogs(resource, created ? "all" : 0); + const resource = yield* Effect.gen(function* () { + const failure = yield* Deferred.make(); + const createdResource: ContainerRuntimeResource = { + key, + workload, + ownerSessionId: options.ownerSessionId, + container: container.id, + state: "starting", + failure, + stopRequested: false, + }; + resources.set(resourceKey(key), createdResource); + return createdResource; + }); + yield* attachLogs(resource, "all"); yield* attachExitWatcher(resource); const readiness = resolution.waitForReadiness === undefined @@ -781,13 +773,12 @@ export const makeContainerRuntime = ( resource.stopRequested = true; yield* stopLogs(resource); yield* stopExitWatcher(resource); - const stopExit = - startedByUs || workload.bootstrap === "database" - ? yield* Effect.exit(withEngine(key, options.engine.stopContainer(container.id))) - : Exit.succeed(undefined); - const removeExit = created - ? yield* Effect.exit(withEngine(key, options.engine.removeContainer(container.id))) - : Exit.succeed(undefined); + const stopExit = yield* Effect.exit( + withEngine(key, options.engine.stopContainer(container.id)), + ); + const removeExit = yield* Effect.exit( + withEngine(key, options.engine.removeContainer(container.id)), + ); let cleanupCause: Cause.Cause = Cause.empty; if (Exit.isFailure(stopExit)) cleanupCause = Cause.combine(cleanupCause, stopExit.cause); if (Exit.isFailure(removeExit)) diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 9dfdbac025..3923bfe195 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -16,14 +16,11 @@ import { routeCatalogFor, type GatewayApiMaterial } from "../gateway/RouteCatalo import { GatewayRouteNotFoundError, makeGateway } from "../gateway/Gateway.ts"; import { makePortCoordinator, - type HostListener, type ListenerIntents, - type PortCoordinator, type PortReservation, } from "../state/PortCoordinator.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; -import type { PortField } from "../public/Status.ts"; import { bindHostListener, checkHostPort, isHttpPortField } from "./HostListener.ts"; import { AUTH_ANON_KEY_SLOT, @@ -60,7 +57,6 @@ export interface SupervisorIngressOptions { readonly stackId: string; readonly stateRoot: string; readonly store: StackStateStore; - readonly portCoordinator?: PortCoordinator; readonly context: Context.Context; /** Resolver may be replaced by the production credential owner. */ readonly apiMaterial?: ( @@ -75,11 +71,6 @@ export interface SupervisorIngressOptions { }>, StackPreparationError >; - readonly bindHost?: ( - address: string, - port: number, - field: PortField, - ) => Effect.Effect; } const listenerIntents = (input: LifecycleInput): ListenerIntents => ({ @@ -176,14 +167,12 @@ export const makeSupervisorIngress = ( } | undefined >(undefined); - const coordinator = - options.portCoordinator ?? - makePortCoordinator({ - stateRoot: options.stateRoot, - store: options.store, - checkHostPort, - bindHost: options.bindHost ?? bindHostListener, - }); + const coordinator = makePortCoordinator({ + stateRoot: options.stateRoot, + store: options.store, + checkHostPort, + bindHost: bindHostListener, + }); const acquire = ( input: LifecycleInput, ): Effect.Effect => From 82ed5ad964826a9c7729a5f5f4e9ef237876d784 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 21:52:48 +0200 Subject: [PATCH 146/174] fix(stack): retry fresh host port binds --- .../public/effect-stack.integration.test.ts | 2 - .../stack/src/public/whole-stack.e2e.test.ts | 2 +- .../stack/src/runtime/ContainerRuntime.ts | 6 +- packages/stack/src/state/PortCoordinator.ts | 456 ++++++++++-------- .../stack/src/state/ports.integration.test.ts | 166 +++++++ 5 files changed, 415 insertions(+), 217 deletions(-) diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 79d2ae52a0..39d37c6dda 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -675,8 +675,6 @@ describe("Effect stack lifecycle handoff", () => { ...defaults, stateRoot, artifactCacheRoot, - tempRoot: "/tmp", - platform: "posix", }; const stack = yield* createStack({ projectRoot: project, diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 913e088cd3..a37a4eeb17 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -601,7 +601,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom // Preparation is an explicit cache-only operation. It runs after the helper's // initial session is stopped, so the test proves it creates no owner, listener, - // or workload and that the next start can reuse the warmed REST artifact. + // or workload. await stack.stop(); const warmed = await stack.prepare({ capabilities: ["rest"] }); expect(warmed.capabilities).toEqual( diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 906fdb9a9e..0b987b8673 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -546,11 +546,7 @@ export const makeContainerRuntime = ( const startEffect = Effect.gen(function* () { yield* guard; const existing = resources.get(resourceKey(key)); - if ( - existing !== undefined && - existing.ownerSessionId === options.ownerSessionId && - existing.state === "running" - ) + if (existing !== undefined && existing.state === "running") return { ...key, state: "ready" } satisfies ObservedWorkload; if (existing !== undefined && existing.state !== "running") { existing.stopRequested = true; diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 0de3ce2307..fc05605dbd 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, Exit, FileSystem, Path, Scope, Schema } from "effect"; +import { Cause, Crypto, Effect, Exit, FileSystem, Option, Path, Scope, Schema } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import import type { Server as HttpServer } from "node:http"; // oxlint-disable-next-line effecttsgo/node-builtin-import @@ -145,68 +145,152 @@ const readAuthoritativeStates = (options: PortCoordinatorOptions) => export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordinator => ({ planAndReserve: (stackId, listenerIntents, planOptions = {}) => Effect.gen(function* () { - const committed = yield* withRegistryLock( - options.stateRoot, - Effect.gen(function* () { - const current = yield* options.store.read(stackId); - if (current === undefined) - return yield* new StackStateInvalidError({ - message: "Cannot allocate ports for an unconfigured stack", - }); - const lifecycle = current.desiredLifecycle === "running" ? "running" : "stopped"; - const allStates = yield* readAuthoritativeStates(options); - const usedAutomaticPublic = new Set(); - const usedLivePublic = new Set(); - const usedReservedPublic = new Set(); - const usedReservedPrivate = new Set(); - for (const entry of allStates) { - if (entry.stackId === stackId) continue; - for (const assignment of entry.state.ports) { - usedReservedPublic.add(assignment.port); - if (assignment.intent === "automatic") usedAutomaticPublic.add(assignment.port); - if (entry.state.desiredLifecycle === "running") usedLivePublic.add(assignment.port); + const excludedFreshPublic = new Set(); + let failedBindAttempts = 0; + while (true) { + const committed = yield* withRegistryLock( + options.stateRoot, + Effect.gen(function* () { + const current = yield* options.store.read(stackId); + if (current === undefined) + return yield* new StackStateInvalidError({ + message: "Cannot allocate ports for an unconfigured stack", + }); + const lifecycle = current.desiredLifecycle === "running" ? "running" : "stopped"; + const allStates = yield* readAuthoritativeStates(options); + const usedAutomaticPublic = new Set(); + const usedLivePublic = new Set(); + const usedReservedPublic = new Set(); + const usedReservedPrivate = new Set(); + for (const entry of allStates) { + if (entry.stackId === stackId) continue; + for (const assignment of entry.state.ports) { + usedReservedPublic.add(assignment.port); + if (assignment.intent === "automatic") usedAutomaticPublic.add(assignment.port); + if (entry.state.desiredLifecycle === "running") usedLivePublic.add(assignment.port); + } + for (const assignment of entry.state.privatePorts) + usedReservedPrivate.add(assignment.port); } - for (const assignment of entry.state.privatePorts) - usedReservedPrivate.add(assignment.port); - } - const existing = assignmentMap(current.ports); - const requestedPrivate = planOptions.privateBindings; - const privateIntents: ReadonlyArray = - requestedPrivate ?? - current.privatePorts.map(({ workloadId, binding }) => ({ - workloadId, - binding, - })); - const existingPrivate = new Map( - current.privatePorts.map((entry) => [bindingKey(entry), entry]), - ); + const existing = assignmentMap(current.ports); + const requestedPrivate = planOptions.privateBindings; + const privateIntents: ReadonlyArray = + requestedPrivate ?? + current.privatePorts.map(({ workloadId, binding }) => ({ + workloadId, + binding, + })); + const existingPrivate = new Map( + current.privatePorts.map((entry) => [bindingKey(entry), entry]), + ); - const assignments: HostPortAssignment[] = []; - const byField: Partial> = {}; - const usedByThisStack = new Set(); - for (const field of fields) { - const intent = listenerIntents[field]; - if (!intent.enabled) continue; - const prior = existing.get(field); - let assignment: HostPortAssignment; - if (intent.port === "automatic") { - if (prior?.intent === "automatic" && !usedByThisStack.has(prior.port)) { - assignment = prior; + const assignments: HostPortAssignment[] = []; + const byField: Partial> = {}; + const usedByThisStack = new Set(); + for (const field of fields) { + const intent = listenerIntents[field]; + if (!intent.enabled) continue; + const prior = existing.get(field); + let assignment: HostPortAssignment; + if (intent.port === "automatic") { + if (prior?.intent === "automatic" && !usedByThisStack.has(prior.port)) { + assignment = prior; + } else { + let selected: number | undefined; + let failedHostProbes = 0; + let probeBudgetExhausted = false; + for (let port = PUBLIC_PORT_MIN; port <= PUBLIC_PORT_MAX; port += 1) { + if ( + !usedAutomaticPublic.has(port) && + !usedLivePublic.has(port) && + !usedReservedPublic.has(port) && + !usedReservedPrivate.has(port) && + !usedByThisStack.has(port) && + !excludedFreshPublic.has(port) + ) { + const available = yield* options + .checkHostPort(intent.address, port, field) + .pipe( + Effect.as(true), + Effect.catchTag("PortUnavailableError", () => Effect.succeed(false)), + ); + if (!available) { + failedHostProbes += 1; + if (failedHostProbes >= MAX_FAILED_HOST_PROBES) { + probeBudgetExhausted = true; + break; + } + continue; + } + selected = port; + break; + } + } + if (selected === undefined) + return yield* new PortAllocationError({ + field, + message: probeBudgetExhausted + ? `No automatic public host port is available after ${MAX_FAILED_HOST_PROBES} occupied candidates` + : "No automatic host port is available", + }); + assignment = { field, port: selected, intent: "automatic" }; + usedAutomaticPublic.add(selected); + } } else { - let selected: number | undefined; + if (!validPort(intent.port)) return yield* unavailable(intent.port, field); + if (usedAutomaticPublic.has(intent.port)) + return yield* automaticConflict(intent.port, field); + if (usedReservedPrivate.has(intent.port)) + return yield* unavailable(intent.port, field); + if (usedByThisStack.has(intent.port)) return yield* unavailable(intent.port, field); + if (lifecycle === "running" && usedLivePublic.has(intent.port)) + return yield* unavailable(intent.port, field); + assignment = { field, port: intent.port, intent: "exact" }; + } + assignments.push(assignment); + byField[field] = assignment; + usedByThisStack.add(assignment.port); + } + + const privateAssignments: PrivatePortAssignment[] = []; + const usedPrivateByThisStack = new Set(); + const usedAllByThisStack = new Set(usedByThisStack); + for (const intent of privateIntents) { + if (intent.workloadId.length === 0 || intent.binding.length === 0) + return yield* new PortAllocationError({ + field: `${intent.workloadId}:${intent.binding}`, + message: "Private workload binding is invalid", + }); + const key = bindingKey(intent); + if (privateAssignments.some((entry) => bindingKey(entry) === key)) + return yield* new PortAllocationError({ + field: `${intent.workloadId}:${intent.binding}`, + message: "Duplicate private workload binding", + }); + const prior = existingPrivate.get(key); + let port: number | undefined; + let probeBudgetExhausted = false; + if (prior !== undefined && !usedPrivateByThisStack.has(prior.port)) port = prior.port; + else { let failedHostProbes = 0; - let probeBudgetExhausted = false; - for (let port = PUBLIC_PORT_MIN; port <= PUBLIC_PORT_MAX; port += 1) { + for ( + let candidate = PRIVATE_PORT_MIN; + candidate <= PRIVATE_PORT_MAX; + candidate += 1 + ) { if ( - !usedAutomaticPublic.has(port) && - !usedLivePublic.has(port) && - !usedReservedPublic.has(port) && - !usedReservedPrivate.has(port) && - !usedByThisStack.has(port) + !usedReservedPrivate.has(candidate) && + !usedReservedPublic.has(candidate) && + !usedPrivateByThisStack.has(candidate) && + !usedAllByThisStack.has(candidate) ) { const available = yield* options - .checkHostPort(intent.address, port, field) + .checkHostPort( + "127.0.0.1", + candidate, + `${intent.workloadId}:${intent.binding}`, + ) .pipe( Effect.as(true), Effect.catchTag("PortUnavailableError", () => Effect.succeed(false)), @@ -219,172 +303,126 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi } continue; } - selected = port; + port = candidate; break; } } - if (selected === undefined) - return yield* new PortAllocationError({ - field, - message: probeBudgetExhausted - ? `No automatic public host port is available after ${MAX_FAILED_HOST_PROBES} occupied candidates` - : "No automatic host port is available", - }); - assignment = { field, port: selected, intent: "automatic" }; - usedAutomaticPublic.add(selected); } - } else { - if (!validPort(intent.port)) return yield* unavailable(intent.port, field); - if (usedAutomaticPublic.has(intent.port)) - return yield* automaticConflict(intent.port, field); - if (usedReservedPrivate.has(intent.port)) - return yield* unavailable(intent.port, field); - if (usedByThisStack.has(intent.port)) return yield* unavailable(intent.port, field); - if (lifecycle === "running" && usedLivePublic.has(intent.port)) - return yield* unavailable(intent.port, field); - assignment = { field, port: intent.port, intent: "exact" }; - } - assignments.push(assignment); - byField[field] = assignment; - usedByThisStack.add(assignment.port); - } - - const privateAssignments: PrivatePortAssignment[] = []; - const usedPrivateByThisStack = new Set(); - const usedAllByThisStack = new Set(usedByThisStack); - for (const intent of privateIntents) { - if (intent.workloadId.length === 0 || intent.binding.length === 0) - return yield* new PortAllocationError({ - field: `${intent.workloadId}:${intent.binding}`, - message: "Private workload binding is invalid", - }); - const key = bindingKey(intent); - if (privateAssignments.some((entry) => bindingKey(entry) === key)) - return yield* new PortAllocationError({ - field: `${intent.workloadId}:${intent.binding}`, - message: "Duplicate private workload binding", + if (port === undefined) + return yield* new PortAllocationError({ + field: `${intent.workloadId}:${intent.binding}`, + message: probeBudgetExhausted + ? `No automatic private port is available after ${MAX_FAILED_HOST_PROBES} occupied candidates` + : "No automatic private port is available", + }); + privateAssignments.push({ + workloadId: intent.workloadId, + binding: intent.binding, + port, }); - const prior = existingPrivate.get(key); - let port: number | undefined; - let probeBudgetExhausted = false; - if (prior !== undefined && !usedPrivateByThisStack.has(prior.port)) port = prior.port; - else { - let failedHostProbes = 0; - for ( - let candidate = PRIVATE_PORT_MIN; - candidate <= PRIVATE_PORT_MAX; - candidate += 1 - ) { - if ( - !usedReservedPrivate.has(candidate) && - !usedReservedPublic.has(candidate) && - !usedPrivateByThisStack.has(candidate) && - !usedAllByThisStack.has(candidate) - ) { - const available = yield* options - .checkHostPort("127.0.0.1", candidate, `${intent.workloadId}:${intent.binding}`) - .pipe( - Effect.as(true), - Effect.catchTag("PortUnavailableError", () => Effect.succeed(false)), - ); - if (!available) { - failedHostProbes += 1; - if (failedHostProbes >= MAX_FAILED_HOST_PROBES) { - probeBudgetExhausted = true; - break; - } - continue; - } - port = candidate; - break; - } - } + usedPrivateByThisStack.add(port); + usedAllByThisStack.add(port); } - if (port === undefined) - return yield* new PortAllocationError({ - field: `${intent.workloadId}:${intent.binding}`, - message: probeBudgetExhausted - ? `No automatic private port is available after ${MAX_FAILED_HOST_PROBES} occupied candidates` - : "No automatic private port is available", - }); - privateAssignments.push({ - workloadId: intent.workloadId, - binding: intent.binding, - port, - }); - usedPrivateByThisStack.add(port); - usedAllByThisStack.add(port); - } - const next: PersistedStackState = { - ...current, - // Lifecycle is owned by Supervisor; port planning never mutates it. - desiredLifecycle: current.desiredLifecycle, - ports: assignments, - privatePorts: privateAssignments, - }; - yield* options.store.replaceUnlocked(stackId, next); - return { current, next, lifecycle, byField, privateAssignments }; - }), - ); + const next: PersistedStackState = { + ...current, + // Lifecycle is owned by Supervisor; port planning never mutates it. + desiredLifecycle: current.desiredLifecycle, + ports: assignments, + privatePorts: privateAssignments, + }; + yield* options.store.replaceUnlocked(stackId, next); + return { current, next, lifecycle, byField, privateAssignments }; + }), + ); - const hostListeners: HostListener[] = []; - const enabledAssignments = fields.flatMap((field) => { - const intent = listenerIntents[field]; - const assignment = committed.byField[field]; - return intent.enabled && assignment !== undefined ? [{ field, intent, assignment }] : []; - }); - const rollbackFreshAutomatic = withRegistryLock( - options.stateRoot, - Effect.gen(function* () { - const latest = yield* options.store.read(stackId); - if (latest === undefined) - return yield* new StackStateInvalidError({ message: "Stack state disappeared" }); - const priorByField = new Map( - committed.current.ports.map((entry) => [entry.field, entry]), - ); - const ports = committed.next.ports.filter( - (entry) => entry.intent !== "automatic" || priorByField.has(entry.field), - ); - const priorBindings = new Set(committed.current.privatePorts.map(bindingKey)); - const privatePorts = committed.next.privatePorts.filter((entry) => - priorBindings.has(bindingKey(entry)), - ); - yield* options.store.replaceUnlocked(stackId, { ...latest, ports, privatePorts }); - }), - ); - if (committed.lifecycle === "running") { - const parentScope = yield* Scope.Scope; - const acquired = yield* Effect.uninterruptibleMask((restore) => + const hostListeners: HostListener[] = []; + const enabledAssignments = fields.flatMap((field) => { + const intent = listenerIntents[field]; + const assignment = committed.byField[field]; + return intent.enabled && assignment !== undefined ? [{ field, intent, assignment }] : []; + }); + const priorByField = new Map(committed.current.ports.map((entry) => [entry.field, entry])); + const rollbackFreshAutomatic = withRegistryLock( + options.stateRoot, Effect.gen(function* () { - const listenerScope = yield* Scope.fork(parentScope, "sequential"); - const bound = yield* Effect.exit( - restore( - Effect.forEach(enabledAssignments, ({ field, intent, assignment }) => - options.bindHost(intent.address, assignment.port, field).pipe( - Effect.catchTag("PortUnavailableError", () => - Effect.fail(unavailable(assignment.port, field)), - ), - Effect.provideService(Scope.Scope, listenerScope), - ), - ), - ), + const latest = yield* options.store.read(stackId); + if (latest === undefined) + return yield* new StackStateInvalidError({ message: "Stack state disappeared" }); + const ports = committed.next.ports.filter((entry) => { + if (entry.intent !== "automatic") return true; + const prior = priorByField.get(entry.field); + return prior?.intent === "automatic" && prior.port === entry.port; + }); + const priorBindings = new Set(committed.current.privatePorts.map(bindingKey)); + const privatePorts = committed.next.privatePorts.filter((entry) => + priorBindings.has(bindingKey(entry)), ); - if (Exit.isFailure(bound)) { - yield* Scope.close(listenerScope, bound); - yield* rollbackFreshAutomatic; - return yield* Effect.failCause(bound.cause); - } - return bound.value; + yield* options.store.replaceUnlocked(stackId, { ...latest, ports, privatePorts }); }), ); - hostListeners.push(...acquired); - } + if (committed.lifecycle === "running") { + const parentScope = yield* Scope.Scope; + const bindingResult = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const listenerScope = yield* Scope.fork(parentScope, "sequential"); + const bound = yield* Effect.exit( + restore( + Effect.forEach(enabledAssignments, ({ field, intent, assignment }) => + options.bindHost(intent.address, assignment.port, field).pipe( + Effect.catchTag("PortUnavailableError", () => + Effect.fail(unavailable(assignment.port, field)), + ), + Effect.provideService(Scope.Scope, listenerScope), + ), + ), + ), + ); + if (Exit.isFailure(bound)) { + yield* Scope.close(listenerScope, bound); + const bindError = Option.getOrUndefined(Cause.findErrorOption(bound.cause)); + const failedAssignment = + bindError instanceof PortUnavailableError + ? enabledAssignments.find( + ({ field, assignment }) => + field === bindError.field && assignment.port === bindError.port, + ) + : undefined; + const prior = + failedAssignment === undefined + ? undefined + : priorByField.get(failedAssignment.field); + if ( + failedAssignment !== undefined && + failedAssignment.assignment.intent === "automatic" && + (prior === undefined || + prior.intent !== "automatic" || + prior.port !== failedAssignment.assignment.port) && + failedBindAttempts < MAX_FAILED_HOST_PROBES + ) { + const failedPort = failedAssignment.assignment.port; + yield* rollbackFreshAutomatic; + return { retryPort: failedPort }; + } + yield* rollbackFreshAutomatic; + return yield* Effect.failCause(bound.cause); + } + return { listeners: bound.value }; + }), + ); + if ("retryPort" in bindingResult && bindingResult.retryPort !== undefined) { + excludedFreshPublic.add(bindingResult.retryPort); + failedBindAttempts += 1; + continue; + } + hostListeners.push(...bindingResult.listeners); + } - return { - assignments: committed.byField, - privateAssignments: committed.privateAssignments, - hostListeners, - }; + return { + assignments: committed.byField, + privateAssignments: committed.privateAssignments, + hostListeners, + }; + } }), }); diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 350aac51fd..9f5c907f86 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -623,6 +623,172 @@ describe("sticky port coordination", () => { ), ); + it.live("retries a fresh automatic bind failure with the next candidate", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-bind-retry-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "bind-retry"); + const stackId = yield* deriveStackId(identity); + yield* store.initialize(stackId, baseState(stackId, identity, "running")); + const attempts: number[] = []; + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + bindHost: (address, port, field) => { + attempts.push(port); + return port === 40_000 + ? Effect.fail( + new PortUnavailableError({ port, field, message: "simulated bind race" }), + ) + : makeTestHostListener(address, port, field); + }, + }); + const reservation = yield* coordinator.planAndReserve(stackId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: "automatic" }, + }); + expect(attempts).toEqual([40_000, 40_001]); + expect(reservation.assignments.api?.port).toBe(40_001); + expect((yield* store.read(stackId))?.ports).toEqual([ + { field: "api", port: 40_001, intent: "automatic" }, + ]); + }), + ), + ); + + it.live("keeps exact and sticky automatic assignments after bind failure", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-bind-sticky-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const exactIdentity = makeIdentity(root, "bind-exact"); + const stickyIdentity = makeIdentity(root, "bind-sticky"); + const exactId = yield* deriveStackId(exactIdentity); + const stickyId = yield* deriveStackId(stickyIdentity); + yield* store.initialize(exactId, baseState(exactId, exactIdentity, "running")); + yield* store.initialize(stickyId, { + ...baseState(stickyId, stickyIdentity, "running"), + ports: [{ field: "api", port: 45_500, intent: "automatic" }], + }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + bindHost: (_address, port, field) => + Effect.fail(new PortUnavailableError({ port, field, message: "simulated bind race" })), + }); + const exact = yield* coordinator + .planAndReserve(exactId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: 45_501 }, + }) + .pipe(Effect.exit); + expect(errorOf(exact)).toBeInstanceOf(PortUnavailableError); + expect((yield* store.read(exactId))?.ports).toEqual([ + { field: "api", port: 45_501, intent: "exact" }, + ]); + const sticky = yield* coordinator + .planAndReserve(stickyId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: "automatic" }, + }) + .pipe(Effect.exit); + expect(errorOf(sticky)).toBeInstanceOf(PortUnavailableError); + expect((yield* store.read(stickyId))?.ports).toEqual([ + { field: "api", port: 45_500, intent: "automatic" }, + ]); + }), + ), + ); + + it.live("closes listeners acquired before a fresh bind retry", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-bind-retry-close-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "bind-retry-close"); + const stackId = yield* deriveStackId(identity); + yield* store.initialize(stackId, baseState(stackId, identity, "running")); + const bound = new Set(); + const closeEvents: number[] = []; + let failed = false; + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + bindHost: (address, port, field) => { + if (port === 40_001 && !failed) { + failed = true; + return Effect.fail( + new PortUnavailableError({ port, field, message: "simulated bind race" }), + ); + } + return makeTestHostListener(address, port, field, { + onClose: () => { + bound.delete(port); + closeEvents.push(port); + }, + }).pipe(Effect.tap(() => Effect.sync(() => bound.add(port)))); + }, + }); + const reservation = yield* coordinator.planAndReserve(stackId, intents("automatic")); + expect(reservation.hostListeners).toHaveLength(2); + expect(bound.size).toBe(2); + expect(closeEvents).toEqual([40_000]); + expect(reservation.assignments.api?.port).toBe(40_000); + expect(reservation.assignments.database?.port).toBe(40_002); + }), + ), + ); + + it.live("bounds fresh automatic bind retries and rolls back the reservation", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-port-bind-limit-", + }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const identity = makeIdentity(root, "bind-limit"); + const stackId = yield* deriveStackId(identity); + yield* store.initialize(stackId, baseState(stackId, identity, "running")); + const attempts: number[] = []; + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + bindHost: (_address, port, field) => { + attempts.push(port); + return Effect.fail( + new PortUnavailableError({ port, field, message: "always occupied" }), + ); + }, + }); + const result = yield* coordinator + .planAndReserve(stackId, { + ...disabledIntents(), + api: { enabled: true, address: "127.0.0.1", port: "automatic" }, + }) + .pipe(Effect.exit); + expect(errorOf(result)).toBeInstanceOf(PortUnavailableError); + expect(attempts).toHaveLength(17); + expect(attempts.at(-1)).toBe(40_016); + expect((yield* store.read(stackId))?.ports).toEqual([]); + }), + ), + ); + it.live("retains a chosen assignment when container publication races", () => withPlatform( Effect.gen(function* () { From 356360d4756f4df3bef79640492e8684bf123d83 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 22:09:09 +0200 Subject: [PATCH 147/174] test(stack): coordinate concurrent test stacks --- ...7-simplified-managed-stack-architecture.md | 12 ++- packages/stack/README.md | 5 + packages/stack/src/public/Testing.ts | 97 +++++++++++++++++-- .../src/public/testing.integration.test.ts | 79 +++++++++++++++ .../stack/src/public/whole-stack.e2e.test.ts | 85 +++++++++++++--- 5 files changed, 256 insertions(+), 22 deletions(-) diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 060b78c780..5a774a4e32 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -20,7 +20,12 @@ available from `@supabase/stack/effect`, while the package root exposes the Promise handle and root Promise functions (`createStack`, `openStack`, `findStack`, `listStacks`, and `inspectStack`). Test callers use the public `createTestStack` helper from `@supabase/stack/testing`; it owns an isolated -project root and exact-identity cleanup through `await using`. +project root and exact-identity cleanup through `await using`. Overlapping test +helpers share one process-scoped ephemeral coordination state root so automatic +ports coordinate like production while each project root and identity-scoped +data remain isolated. The coordination root is reference-counted and removed +after every owned stack has destroyed successfully; a failed destroy retains +its exact project and coordination state for recovery. The managed document is private, unreleased state under the user-level managed root. It is not a repository contract, service registry, compatibility facade, @@ -60,7 +65,10 @@ unclassifiable owner PID fails closed; a lock naming a process that no longer exists is left for PostgreSQL's own stale-lock recovery. A live stop/start composition preflights configuration without trying to bind ports that the current Supervisor intentionally owns. Cold recovery never adopts those -resources. +resources. If an authoritative bind loses a race for a fresh automatic public +assignment, planning retries with a bounded exclusion set; exact assignments +and previously persisted sticky automatic ports remain hard failures and are +never silently moved. Every managed document records one concrete runtime selection. Native and container runtimes never mix. Omission defaults to Docker; callers may diff --git a/packages/stack/README.md b/packages/stack/README.md index bfc762f8c0..3e258b1735 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -27,6 +27,11 @@ CLI is not involved in these runtime tests. Podman is supported only on local Linux hosts and must be selected explicitly; the runtime does not auto-detect container engines. +`createTestStack` gives each test stack an isolated project root and identity-scoped state. Concurrent +helpers in one process share a temporary coordination state root so automatic ports remain +exclusive, and the root is removed after the last successful `await using` disposal. A failed +destroy retains the affected project and shared coordination state for recovery. + Callers can warm selected native artifacts or container images with `stack.prepare(...)` while a stack is stopped; preparation is cache-only and cancellation does not affect completed entries. Each capability may opt into eager activation in `StackConfig`; omitted settings keep every diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index be072e1fe0..e7553ac885 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -30,7 +30,11 @@ export interface CreateTestStackOptions { readonly setupProject?: (projectRoot: string) => Promise; } -export type TestStack = PromiseStack & AsyncDisposable; +export type TestStack = PromiseStack & + AsyncDisposable & { + /** Ephemeral coordination state root shared by overlapping helpers in this process. */ + readonly stateRoot: string; + }; export interface TestStackOperations { readonly createRoot: () => Promise; @@ -39,6 +43,9 @@ export interface TestStackOperations { environment?: StackRuntimeEnvironmentValue, ) => Promise; readonly removeRoot: (root: string) => Promise; + /** Enables an ephemeral coordination root for the default and explicit real-stack operations. */ + readonly createStateRoot?: () => Promise; + readonly removeStateRoot?: (root: string) => Promise; } const defaultOperations: TestStackOperations = { @@ -46,16 +53,82 @@ const defaultOperations: TestStackOperations = { createStack: (options, environment) => makePromiseApi(NodeServices.layer, environment).createStack(options), removeRoot: (root) => rm(root, { recursive: true, force: true }), + createStateRoot: () => mkdtemp(join(tmpdir(), "supabase-stack-test-state-")), + removeStateRoot: (root) => rm(root, { recursive: true, force: true }), +}; + +interface StateRootLease { + readonly root: string; + references: number; +} + +interface StateRootLeaseState { + lease: StateRootLease | undefined; + creating: Promise | undefined; +} + +const stateRootLeases = new WeakMap(); + +const leaseStateFor = (operations: TestStackOperations): StateRootLeaseState => { + const existing = stateRootLeases.get(operations); + if (existing !== undefined) return existing; + const state: StateRootLeaseState = { lease: undefined, creating: undefined }; + stateRootLeases.set(operations, state); + return state; +}; + +const acquireStateRoot = async (operations: TestStackOperations): Promise => { + if (operations.createStateRoot === undefined || operations.removeStateRoot === undefined) + return undefined; + const state = leaseStateFor(operations); + if (state.lease !== undefined) { + state.lease.references += 1; + return state.lease.root; + } + if (state.creating === undefined) { + state.creating = operations + .createStateRoot() + .then((root) => { + const lease = { root, references: 0 } satisfies StateRootLease; + state.lease = lease; + return lease; + }) + .finally(() => { + state.creating = undefined; + }); + } + const lease = await state.creating; + lease.references += 1; + return lease.root; +}; + +const releaseStateRoot = async (operations: TestStackOperations, root: string): Promise => { + const state = stateRootLeases.get(operations); + const lease = state?.lease; + if (state === undefined || lease === undefined || lease.root !== root) return; + lease.references -= 1; + if (lease.references > 0) return; + state.lease = undefined; + const removeStateRoot = operations.removeStateRoot; + if (removeStateRoot === undefined) return; + try { + await removeStateRoot(root); + } catch (error) { + throw new Error(`Unable to remove shared test state root ${root}`, { cause: error }); + } }; // Native slim-services artifacts are immutable and expensive to download. Keep one // shared cache for test stacks while each stack's state/data roots remain disposable. const testArtifactCacheRoot = join(tmpdir(), "supabase-stack-test-artifacts"); -const isolatedEnvironment = (projectRoot: string): StackRuntimeEnvironmentValue => ({ +const isolatedEnvironment = ( + projectRoot: string, + stateRoot = join(projectRoot, ".supabase", "managed", "stacks"), +): StackRuntimeEnvironmentValue => ({ ...defaultRuntimeEnvironment(), artifactCacheRoot: testArtifactCacheRoot, - stateRoot: join(projectRoot, ".supabase", "managed", "stacks"), + stateRoot, }); const validateStartedStatus = ( @@ -132,6 +205,7 @@ const cleanup = async ( stack: PromiseStack | undefined, root: string, operations: TestStackOperations, + stateRoot?: string, primary?: unknown, ) => { let failure = primary; @@ -152,6 +226,13 @@ const cleanup = async ( if (failure === undefined) failure = error; } } + if (rootCanBeRemoved && stateRoot !== undefined) { + try { + await releaseStateRoot(operations, stateRoot); + } catch (error) { + if (failure === undefined) failure = error; + } + } if (!rootCanBeRemoved) { const reason = failure instanceof Error ? failure.message : String(failure); throw new Error(`${reason}; retained test stack root ${root}`, { cause: failure }); @@ -166,15 +247,18 @@ export const createTestStackWith = async ( ): Promise => { const projectRoot = await operations.createRoot(); let stack: PromiseStack | undefined; + let stateRoot: string | undefined; try { if (options.setupProject !== undefined) await options.setupProject(projectRoot); + stateRoot = await acquireStateRoot(operations); + const runtimeEnvironment = isolatedEnvironment(projectRoot, stateRoot); stack = await operations.createStack( { projectRoot, name: options.name, runtime: options.runtime, }, - isolatedEnvironment(projectRoot), + runtimeEnvironment, ); const started = await stack.start( options.config === undefined @@ -190,10 +274,11 @@ export const createTestStackWith = async ( const resource = stack; return { ...resource, - [Symbol.asyncDispose]: () => cleanup(resource, projectRoot, operations), + stateRoot: runtimeEnvironment.stateRoot, + [Symbol.asyncDispose]: () => cleanup(resource, projectRoot, operations, stateRoot), }; } catch (error) { - await cleanup(stack, projectRoot, operations, error); + await cleanup(stack, projectRoot, operations, stateRoot, error); throw error; } }; diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index 58ef939d14..490683381f 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -330,4 +330,83 @@ describe("test stack resource", () => { await expect(createTestStackWith({}, operations)).rejects.toThrow("stopping"); expect(events).toEqual(["start", "destroy"]); }); + + it("shares one coordination state root while test stacks overlap", async () => { + const roots = ["/tmp/stack-test-shared-a", "/tmp/stack-test-shared-b"]; + const environments: Array>[1]> = []; + const removedRoots: Array = []; + const removedStateRoots: Array = []; + let stateRootCreations = 0; + const operations: TestStackOperations = { + createRoot: async () => { + const root = roots.shift(); + if (root === undefined) throw new Error("No test root available"); + return root; + }, + createStateRoot: async () => { + stateRootCreations += 1; + return "/tmp/stack-test-shared-state"; + }, + createStack: async (_options, environment) => { + environments.push(environment); + return fakeStack([]); + }, + removeRoot: async (root) => { + removedRoots.push(root); + }, + removeStateRoot: async (root) => { + removedStateRoots.push(root); + }, + }; + const [first, second] = await Promise.all([ + createTestStackWith({}, operations), + createTestStackWith({}, operations), + ]); + expect(stateRootCreations).toBe(1); + expect(environments.map((environment) => environment?.stateRoot)).toEqual([ + "/tmp/stack-test-shared-state", + "/tmp/stack-test-shared-state", + ]); + await first[Symbol.asyncDispose](); + expect(removedStateRoots).toEqual([]); + await second[Symbol.asyncDispose](); + expect(removedRoots).toEqual(["/tmp/stack-test-shared-a", "/tmp/stack-test-shared-b"]); + expect(removedStateRoots).toEqual(["/tmp/stack-test-shared-state"]); + }); + + it("retains shared coordination state when one stack destroy fails", async () => { + const roots = ["/tmp/stack-test-retained-a", "/tmp/stack-test-retained-b"]; + const removedRoots: Array = []; + const removedStateRoots: Array = []; + const operations: TestStackOperations = { + createRoot: async () => { + const root = roots.shift(); + if (root === undefined) throw new Error("No test root available"); + return root; + }, + createStateRoot: async () => "/tmp/stack-test-retained-state", + createStack: async (options) => ({ + ...fakeStack([]), + destroy: async () => { + if (options.projectRoot.endsWith("-a")) throw new Error("destroy a failed"); + }, + }), + removeRoot: async (root) => { + removedRoots.push(root); + }, + removeStateRoot: async (root) => { + removedStateRoots.push(root); + }, + }; + const [first, second] = await Promise.all([ + createTestStackWith({}, operations), + createTestStackWith({}, operations), + ]); + await expect(first[Symbol.asyncDispose]()).rejects.toThrow( + "destroy a failed; retained test stack root /tmp/stack-test-retained-a", + ); + await second[Symbol.asyncDispose](); + expect(removedRoots).toEqual(["/tmp/stack-test-retained-b"]); + expect(removedStateRoots).toEqual([]); + }); }); diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index a37a4eeb17..440ed25544 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -1041,26 +1041,13 @@ describe("managed Supabase stack whole-stack E2E", () => { "recovers the native database after abrupt Supervisor termination", { timeout: E2E_TIMEOUT_MS }, async () => { - let projectRoot = ""; await using stack: TestStack = await createTestStack({ name: `stack-crash-recovery-${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`, runtime: { kind: "native" }, - setupProject: async (root) => { - projectRoot = root; - }, }); const before = await stack.status(); const database = endpoint(before, "database"); - const lockPath = join( - projectRoot, - ".supabase", - "managed", - "stacks", - stack.id, - "data", - "database", - "postmaster.pid", - ); + const lockPath = join(stack.stateRoot, stack.id, "data", "database", "postmaster.pid"); const databasePid = Number((await readFile(lockPath, "utf8")).split("\n", 1)[0]); if (!Number.isSafeInteger(databasePid) || databasePid <= 0) throw new Error("Native database lock did not contain a valid PID"); @@ -1078,6 +1065,76 @@ describe("managed Supabase stack whole-stack E2E", () => { ); for (const mode of RUNTIME_CASES) { + test( + `coordinates concurrent isolated stacks in ${mode.name} mode`, + { timeout: E2E_TIMEOUT_MS }, + async () => { + const identity = crypto.randomUUID().replaceAll("-", "").slice(0, 16); + const [firstStack, secondStack] = await Promise.all([ + createTestStack({ + name: `stack-concurrent-a-${identity}`, + runtime: mode.runtime, + }), + createTestStack({ + name: `stack-concurrent-b-${identity}`, + runtime: mode.runtime, + }), + ]); + await using first: TestStack = firstStack; + await using second: TestStack = secondStack; + + const [firstStatus, secondStatus] = await Promise.all([first.status(), second.status()]); + expect(firstStatus.lifecycle).toBe("running"); + expect(secondStatus.lifecycle).toBe("running"); + const firstApi = endpoint(firstStatus, "api"); + const secondApi = endpoint(secondStatus, "api"); + expect(firstApi.port).not.toBe(secondApi.port); + + const [firstCredentials, secondCredentials] = await Promise.all([ + first.credentials(), + second.credentials(), + ]); + const firstTable = `concurrent_${identity}_a`; + const secondTable = `concurrent_${identity}_b`; + const firstMarker = `marker-a-${identity}`; + const secondMarker = `marker-b-${identity}`; + await Promise.all([ + databaseQuery( + firstCredentials.database.url, + `CREATE TABLE public."${firstTable}" (payload text NOT NULL)`, + ), + databaseQuery( + secondCredentials.database.url, + `CREATE TABLE public."${secondTable}" (payload text NOT NULL)`, + ), + ]); + await Promise.all([ + databaseQuery( + firstCredentials.database.url, + `INSERT INTO public."${firstTable}" (payload) VALUES ($1)`, + [firstMarker], + ), + databaseQuery( + secondCredentials.database.url, + `INSERT INTO public."${secondTable}" (payload) VALUES ($1)`, + [secondMarker], + ), + ]); + const [firstRows, secondRows] = await Promise.all([ + databaseQuery( + firstCredentials.database.url, + `SELECT payload FROM public."${firstTable}"`, + ), + databaseQuery( + secondCredentials.database.url, + `SELECT payload FROM public."${secondTable}"`, + ), + ]); + expect(firstRows).toEqual([{ payload: firstMarker }]); + expect(secondRows).toEqual([{ payload: secondMarker }]); + }, + ); + test(`supports the complete user flow in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, () => runWholeStackScenario(mode), ); From 8e162525e97efef0a926826a05d81e6602b0abba Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 22:16:37 +0200 Subject: [PATCH 148/174] chore(stack): satisfy effect dependency lint --- packages/stack/src/public/EffectStack.ts | 72 +++++++++---------- .../public/effect-stack.integration.test.ts | 23 +++--- packages/stack/src/runtime/ContainerEngine.ts | 2 +- .../supervisor/handles.integration.test.ts | 16 ++--- 4 files changed, 53 insertions(+), 60 deletions(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 798972332a..1311a87236 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -285,10 +285,10 @@ export interface HandleDependencies { readonly resolveOwner: ( launch: boolean, ) => Effect.Effect, StackError>; - readonly readOfflineState: () => Effect.Effect, StackError>; - readonly readPersistedState: () => Effect.Effect, StackError>; + readonly readOfflineState: Effect.Effect, StackError>; + readonly readPersistedState: Effect.Effect, StackError>; readonly readLogs: (query?: LogQuery) => Effect.Effect; - readonly waitForRelease: () => Effect.Effect; + readonly waitForRelease: Effect.Effect; readonly prepare: ( options?: PrepareStackOptions, ) => Effect.Effect; @@ -413,7 +413,7 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff const rpcStatus = invoke((rpc) => rpc.status(undefined), statusError); return rpcStatus.pipe( Effect.catchTag("StackOwnershipConflictError", (ownershipError) => - options.readOfflineState().pipe( + options.readOfflineState.pipe( Effect.mapError(statusError), Effect.flatMap((state) => Option.isNone(state) @@ -441,10 +441,10 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff true, ).pipe( Effect.tapError(() => - options.readPersistedState().pipe( + options.readPersistedState.pipe( Effect.flatMap((state) => Option.isSome(state) && isStoppedState(state.value) - ? options.waitForRelease().pipe(Effect.ignore) + ? options.waitForRelease.pipe(Effect.ignore) : Effect.void, ), Effect.ignore, @@ -482,14 +482,14 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff return yield* new StackLifecycleConflictError({ message: response.value.error.message }); } yield* Fiber.join(closeFiber).pipe(Effect.ignore); - yield* options.waitForRelease(); + yield* options.waitForRelease; }).pipe(Effect.mapError(stopError)); const stop = () => resolveClient(false).pipe( Effect.mapError(stopError), Effect.flatMap(stopOwner), Effect.catchTag("StackOwnershipConflictError", () => - options.readOfflineState().pipe( + options.readOfflineState.pipe( Effect.mapError(stopError), Effect.flatMap((state) => Option.isSome(state) && isStoppedState(state.value) @@ -511,7 +511,7 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff const logs = (query?: LogQuery): Effect.Effect => invoke((rpc) => rpc.logs(query ?? {}), logsError).pipe( Effect.catchTag("StackOwnershipConflictError", (ownershipError) => { - const ownerStopped = options.readPersistedState().pipe(Effect.mapError(logsStateError)); + const ownerStopped = options.readPersistedState.pipe(Effect.mapError(logsStateError)); return ownerStopped.pipe( Effect.flatMap((state) => { if (Option.isNone(state)) return Effect.fail(stackNotFound()); @@ -656,13 +656,11 @@ const handleDependencies = (options: { ), ), ); - const readOfflineState = () => - ensureOffline().pipe( - Effect.andThen(provide(options.store.read(options.id))), - Effect.map(optionOf), - ); - const readPersistedState = () => - provide(options.store.read(options.id)).pipe(Effect.map(optionOf)); + const readOfflineState = ensureOffline().pipe( + Effect.andThen(provide(options.store.read(options.id))), + Effect.map(optionOf), + ); + const readPersistedState = provide(options.store.read(options.id)).pipe(Effect.map(optionOf)); const directPrepareError = (cause: unknown): PrepareStackError => { if (isStackError(cause) && isNarrowError(cause, PREPARE_STACK_ERROR_TAGS)) return cause; return new StackPreparationError({ @@ -798,29 +796,25 @@ const handleDependencies = (options: { : new StackStateInvalidError({ message: error.message, cause: error }), ), ); - const waitForRelease = () => - Effect.gen(function* () { - if ( - (yield* readOwnerMetadata( - options.environment.stateRoot, - options.id, - options.environment, - )) !== undefined - ) - return yield* new StackOwnershipConflictError({ - message: "Supervisor is still shutting down", - }); - if (yield* ownerLockExists(options.environment.stateRoot, options.id)) - return yield* new StackOwnershipConflictError({ - message: "Supervisor ownership lease is still held", - }); - }).pipe( - Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), - Effect.mapError((error) => new StackOwnershipConflictError({ message: error.message })), - Effect.provideService(FileSystem.FileSystem, options.fileSystem), - Effect.provideService(Path.Path, options.path), - Effect.provideService(Crypto.Crypto, options.crypto), - ); + const waitForRelease = Effect.gen(function* () { + if ( + (yield* readOwnerMetadata(options.environment.stateRoot, options.id, options.environment)) !== + undefined + ) + return yield* new StackOwnershipConflictError({ + message: "Supervisor is still shutting down", + }); + if (yield* ownerLockExists(options.environment.stateRoot, options.id)) + return yield* new StackOwnershipConflictError({ + message: "Supervisor ownership lease is still held", + }); + }).pipe( + Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 200 }))), + Effect.mapError((error) => new StackOwnershipConflictError({ message: error.message })), + Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), + ); return { resolveOwner, readOfflineState, diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 39d37c6dda..d1b7682dd7 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -127,10 +127,10 @@ const emptyLogs = () => const makeTestHandle = (id: StackId, overrides: Partial = {}) => makeHandle(id, { resolveOwner: () => Effect.succeed(Option.none()), - readOfflineState: () => Effect.succeed(Option.none()), - readPersistedState: () => Effect.succeed(Option.none()), + readOfflineState: Effect.succeed(Option.none()), + readPersistedState: Effect.succeed(Option.none()), readLogs: emptyLogs, - waitForRelease: () => Effect.void, + waitForRelease: Effect.void, prepare: () => Effect.fail(new StackPreparationError({ message: "test preparation unavailable" })), ...overrides, @@ -327,7 +327,7 @@ describe("Effect stack lifecycle handoff", () => { Ref.get(ownerAvailable).pipe( Effect.map((available) => (available ? Option.some(owner) : Option.none())), ), - readOfflineState: () => Effect.succeed(Option.some(stoppedState())), + readOfflineState: Effect.succeed(Option.some(stoppedState())), }); expect((yield* stack.start()).lifecycle).toBe("running"); const first = yield* stack.logs({ capabilities: ["auth"], tail: 1 }); @@ -444,8 +444,8 @@ describe("Effect stack lifecycle handoff", () => { const burst = entries.slice(1); const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.none()), - readPersistedState: () => Effect.succeed(Option.some(stoppedState())), - readOfflineState: () => Effect.succeed(Option.none()), + readPersistedState: Effect.succeed(Option.some(stoppedState())), + readOfflineState: Effect.succeed(Option.none()), readLogs: (query?: LogQuery) => Ref.getAndUpdate(calls, (current) => current + 1).pipe( Effect.map((index) => { @@ -522,7 +522,7 @@ describe("Effect stack lifecycle handoff", () => { }); const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.none()), - readOfflineState: () => Effect.fail(ownership), + readOfflineState: Effect.fail(ownership), readLogs: () => Effect.fail(ownership), }); const status = yield* stack.status().pipe(Effect.exit); @@ -551,10 +551,9 @@ describe("Effect stack lifecycle handoff", () => { ownerReads += 1; return ownerReads === 1 ? Option.some(owner) : Option.none(); }), - readOfflineState: () => - Effect.fail( - new StackOwnershipConflictError({ message: "Owner metadata still exists" }), - ), + readOfflineState: Effect.fail( + new StackOwnershipConflictError({ message: "Owner metadata still exists" }), + ), }); const result = yield* stack.status().pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); @@ -576,7 +575,7 @@ describe("Effect stack lifecycle handoff", () => { }); const stack = yield* makeTestHandle(stackId, { resolveOwner: () => Effect.succeed(Option.none()), - readPersistedState: () => Effect.succeed(Option.some(stoppedState())), + readPersistedState: Effect.succeed(Option.some(stoppedState())), readLogs: () => Ref.getAndUpdate(attempts, (current) => current + 1).pipe( Effect.flatMap((attempt) => diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 26342c3b28..4b28a53139 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -60,7 +60,7 @@ export type ContainerLabels = | ContainerWorkloadLabels | ContainerVolumeLabels; -export const CONTAINER_LABEL_PREFIX = "com.supabase.stack"; +const CONTAINER_LABEL_PREFIX = "com.supabase.stack"; export const CONTAINER_LABEL_KEYS = { stackId: `${CONTAINER_LABEL_PREFIX}.stackId`, ownerSessionId: `${CONTAINER_LABEL_PREFIX}.ownerSessionId`, diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 5e61fdc584..0c27ce4cd3 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -182,14 +182,14 @@ describe("managed stack handles", { timeout: 30_000 }, () => { endpoint: controlEndpointFor(stack.id, runtime), rpcRelease: STACK_RPC_RELEASE, }; - yield* fs.writeFileString(paths.controlMetadata, JSON.stringify(owner)); - const frame = new TextEncoder().encode( - `${JSON.stringify({ - ok: false, - code: "ownership-conflict", - message: "Owner metadata exists without a lease lock; refusing recovery", - })}\n`, - ); + const ownerJson = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(owner); + yield* fs.writeFileString(paths.controlMetadata, ownerJson); + const frameJson = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + ok: false, + code: "ownership-conflict", + message: "Owner metadata exists without a lease lock; refusing recovery", + }); + const frame = new TextEncoder().encode(`${frameJson}\n`); const spawner = ChildProcessSpawner.make(() => Effect.succeed( ChildProcessSpawner.makeHandle({ From 9c67381b4e30b669ef95543be01688c6a226d0c1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 3 Sep 2026 22:39:41 +0200 Subject: [PATCH 149/174] chore(stack): clarify automatic port retries --- .../stack/src/public/whole-stack.e2e.test.ts | 18 ++++++++- packages/stack/src/state/PortCoordinator.ts | 38 ++++++++++++++----- .../stack/src/state/ports.integration.test.ts | 17 ++++++++- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 440ed25544..bba2f1a630 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -1070,7 +1070,7 @@ describe("managed Supabase stack whole-stack E2E", () => { { timeout: E2E_TIMEOUT_MS }, async () => { const identity = crypto.randomUUID().replaceAll("-", "").slice(0, 16); - const [firstStack, secondStack] = await Promise.all([ + const creations = await Promise.allSettled([ createTestStack({ name: `stack-concurrent-a-${identity}`, runtime: mode.runtime, @@ -1080,6 +1080,22 @@ describe("managed Supabase stack whole-stack E2E", () => { runtime: mode.runtime, }), ]); + const firstResult = creations[0]; + const secondResult = creations[1]; + if (firstResult?.status !== "fulfilled" || secondResult?.status !== "fulfilled") { + await Promise.allSettled( + creations.flatMap((result) => + result.status === "fulfilled" ? [result.value[Symbol.asyncDispose]()] : [], + ), + ); + const firstRejection = creations.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (firstRejection !== undefined) throw firstRejection.reason; + throw new Error("Concurrent test stack creation returned an incomplete result"); + } + const firstStack = firstResult.value; + const secondStack = secondResult.value; await using first: TestStack = firstStack; await using second: TestStack = secondStack; diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index fc05605dbd..fb592c64c1 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -97,6 +97,7 @@ const PRIVATE_PORT_MAX = 39_999; const PUBLIC_PORT_MIN = 40_000; const PUBLIC_PORT_MAX = 65_535; const MAX_FAILED_HOST_PROBES = 16; +const MAX_FRESH_BIND_RETRIES = 16; const assignmentMap = (assignments: ReadonlyArray) => new Map(assignments.map((assignment) => [assignment.field, assignment])); @@ -363,15 +364,25 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi ); if (committed.lifecycle === "running") { const parentScope = yield* Scope.Scope; - const bindingResult = yield* Effect.uninterruptibleMask((restore) => + type BindingResult = + | { readonly retryPort: number } + | { readonly listeners: ReadonlyArray }; + const bindingResult: BindingResult = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const listenerScope = yield* Scope.fork(parentScope, "sequential"); const bound = yield* Effect.exit( restore( Effect.forEach(enabledAssignments, ({ field, intent, assignment }) => options.bindHost(intent.address, assignment.port, field).pipe( - Effect.catchTag("PortUnavailableError", () => - Effect.fail(unavailable(assignment.port, field)), + Effect.catchTag("PortUnavailableError", (error) => + Effect.fail( + new PortUnavailableError({ + field, + port: assignment.port, + message: error.message, + ...(error.cause === undefined ? {} : { cause: error.cause }), + }), + ), ), Effect.provideService(Scope.Scope, listenerScope), ), @@ -392,25 +403,34 @@ export const makePortCoordinator = (options: PortCoordinatorOptions): PortCoordi failedAssignment === undefined ? undefined : priorByField.get(failedAssignment.field); - if ( + const freshAutomaticFailure = failedAssignment !== undefined && failedAssignment.assignment.intent === "automatic" && (prior === undefined || prior.intent !== "automatic" || - prior.port !== failedAssignment.assignment.port) && - failedBindAttempts < MAX_FAILED_HOST_PROBES - ) { - const failedPort = failedAssignment.assignment.port; + prior.port !== failedAssignment.assignment.port) + ? failedAssignment + : undefined; + if (freshAutomaticFailure && failedBindAttempts < MAX_FRESH_BIND_RETRIES) { + const failedPort = freshAutomaticFailure.assignment.port; yield* rollbackFreshAutomatic; return { retryPort: failedPort }; } yield* rollbackFreshAutomatic; + if (freshAutomaticFailure) { + return yield* new PortUnavailableError({ + field: freshAutomaticFailure.field, + port: freshAutomaticFailure.assignment.port, + message: `Could not bind an automatic public host port for ${freshAutomaticFailure.field} after ${MAX_FRESH_BIND_RETRIES + 1} candidates`, + cause: bindError, + }); + } return yield* Effect.failCause(bound.cause); } return { listeners: bound.value }; }), ); - if ("retryPort" in bindingResult && bindingResult.retryPort !== undefined) { + if ("retryPort" in bindingResult) { excludedFreshPublic.add(bindingResult.retryPort); failedBindAttempts += 1; continue; diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 9f5c907f86..6711e7cb91 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -764,6 +764,7 @@ describe("sticky port coordination", () => { const stackId = yield* deriveStackId(identity); yield* store.initialize(stackId, baseState(stackId, identity, "running")); const attempts: number[] = []; + const bindCause = new Error("simulated host bind cause"); const coordinator = makePortCoordinator({ stateRoot: root, store, @@ -771,7 +772,12 @@ describe("sticky port coordination", () => { bindHost: (_address, port, field) => { attempts.push(port); return Effect.fail( - new PortUnavailableError({ port, field, message: "always occupied" }), + new PortUnavailableError({ + port, + field, + message: "always occupied", + cause: bindCause, + }), ); }, }); @@ -781,7 +787,14 @@ describe("sticky port coordination", () => { api: { enabled: true, address: "127.0.0.1", port: "automatic" }, }) .pipe(Effect.exit); - expect(errorOf(result)).toBeInstanceOf(PortUnavailableError); + const error = errorOf(result); + expect(error).toBeInstanceOf(PortUnavailableError); + expect(error?.message).toContain( + "Could not bind an automatic public host port for api after 17 candidates", + ); + expect(error?.field).toBe("api"); + expect(error?.port).toBe(40_016); + expect(error?.cause).toMatchObject({ cause: bindCause }); expect(attempts).toHaveLength(17); expect(attempts.at(-1)).toBe(40_016); expect((yield* store.read(stackId))?.ports).toEqual([]); From 05941b7f02a443d27ac581b8d40e6c4ad91a6f86 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 09:04:01 +0200 Subject: [PATCH 150/174] test(stack): share managed state root with test helpers --- ...7-simplified-managed-stack-architecture.md | 15 ++-- packages/stack/README.md | 10 ++- packages/stack/src/public/Testing.ts | 90 ++----------------- .../src/public/testing.integration.test.ts | 52 +++++------ .../stack/src/public/whole-stack.e2e.test.ts | 64 ++++++++++++- 5 files changed, 103 insertions(+), 128 deletions(-) diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 5a774a4e32..3e3406ed00 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -19,13 +19,14 @@ The public API has two deliberate entrypoints: Effect-native operations are available from `@supabase/stack/effect`, while the package root exposes the Promise handle and root Promise functions (`createStack`, `openStack`, `findStack`, `listStacks`, and `inspectStack`). Test callers use the public -`createTestStack` helper from `@supabase/stack/testing`; it owns an isolated -project root and exact-identity cleanup through `await using`. Overlapping test -helpers share one process-scoped ephemeral coordination state root so automatic -ports coordinate like production while each project root and identity-scoped -data remain isolated. The coordination root is reference-counted and removed -after every owned stack has destroyed successfully; a failed destroy retains -its exact project and coordination state for recovery. +`createTestStack` helper from `@supabase/stack/testing`; it owns a unique +temporary project root and exact-identity cleanup through `await using`, while +using the same managed state root as ordinary package and CLI callers. All +default callers therefore coordinate automatic ports through one registry; +helper project roots and identities remain isolated. Temporary test stacks are +excluded from the current CLI project-scoped listing because their project roots +differ, but remain visible to unfiltered package `listStacks()`. A failed +destroy retains its exact project root and managed state for recovery. The managed document is private, unreleased state under the user-level managed root. It is not a repository contract, service registry, compatibility facade, diff --git a/packages/stack/README.md b/packages/stack/README.md index 3e258b1735..e981b9ad07 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -27,10 +27,12 @@ CLI is not involved in these runtime tests. Podman is supported only on local Linux hosts and must be selected explicitly; the runtime does not auto-detect container engines. -`createTestStack` gives each test stack an isolated project root and identity-scoped state. Concurrent -helpers in one process share a temporary coordination state root so automatic ports remain -exclusive, and the root is removed after the last successful `await using` disposal. A failed -destroy retains the affected project and shared coordination state for recovery. +`createTestStack` gives each test stack a unique temporary project root and identity while sharing +the managed state root used by ordinary package and CLI callers. Automatic ports therefore +coordinate across all default callers. Helper project roots and identities remain isolated; a +temporary test stack is excluded from the current CLI project-scoped listing but appears in an +unfiltered package `listStacks()` result. A failed destroy retains the affected project root and +managed state for recovery. Callers can warm selected native artifacts or container images with `stack.prepare(...)` while a stack is stopped; preparation is cache-only and cancellation does not affect completed entries. diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index e7553ac885..140aaec1f6 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -32,7 +32,7 @@ export interface CreateTestStackOptions { export type TestStack = PromiseStack & AsyncDisposable & { - /** Ephemeral coordination state root shared by overlapping helpers in this process. */ + /** Managed state root shared with ordinary package and CLI stacks. */ readonly stateRoot: string; }; @@ -43,9 +43,6 @@ export interface TestStackOperations { environment?: StackRuntimeEnvironmentValue, ) => Promise; readonly removeRoot: (root: string) => Promise; - /** Enables an ephemeral coordination root for the default and explicit real-stack operations. */ - readonly createStateRoot?: () => Promise; - readonly removeStateRoot?: (root: string) => Promise; } const defaultOperations: TestStackOperations = { @@ -53,82 +50,15 @@ const defaultOperations: TestStackOperations = { createStack: (options, environment) => makePromiseApi(NodeServices.layer, environment).createStack(options), removeRoot: (root) => rm(root, { recursive: true, force: true }), - createStateRoot: () => mkdtemp(join(tmpdir(), "supabase-stack-test-state-")), - removeStateRoot: (root) => rm(root, { recursive: true, force: true }), -}; - -interface StateRootLease { - readonly root: string; - references: number; -} - -interface StateRootLeaseState { - lease: StateRootLease | undefined; - creating: Promise | undefined; -} - -const stateRootLeases = new WeakMap(); - -const leaseStateFor = (operations: TestStackOperations): StateRootLeaseState => { - const existing = stateRootLeases.get(operations); - if (existing !== undefined) return existing; - const state: StateRootLeaseState = { lease: undefined, creating: undefined }; - stateRootLeases.set(operations, state); - return state; -}; - -const acquireStateRoot = async (operations: TestStackOperations): Promise => { - if (operations.createStateRoot === undefined || operations.removeStateRoot === undefined) - return undefined; - const state = leaseStateFor(operations); - if (state.lease !== undefined) { - state.lease.references += 1; - return state.lease.root; - } - if (state.creating === undefined) { - state.creating = operations - .createStateRoot() - .then((root) => { - const lease = { root, references: 0 } satisfies StateRootLease; - state.lease = lease; - return lease; - }) - .finally(() => { - state.creating = undefined; - }); - } - const lease = await state.creating; - lease.references += 1; - return lease.root; -}; - -const releaseStateRoot = async (operations: TestStackOperations, root: string): Promise => { - const state = stateRootLeases.get(operations); - const lease = state?.lease; - if (state === undefined || lease === undefined || lease.root !== root) return; - lease.references -= 1; - if (lease.references > 0) return; - state.lease = undefined; - const removeStateRoot = operations.removeStateRoot; - if (removeStateRoot === undefined) return; - try { - await removeStateRoot(root); - } catch (error) { - throw new Error(`Unable to remove shared test state root ${root}`, { cause: error }); - } }; // Native slim-services artifacts are immutable and expensive to download. Keep one // shared cache for test stacks while each stack's state/data roots remain disposable. const testArtifactCacheRoot = join(tmpdir(), "supabase-stack-test-artifacts"); -const isolatedEnvironment = ( - projectRoot: string, - stateRoot = join(projectRoot, ".supabase", "managed", "stacks"), -): StackRuntimeEnvironmentValue => ({ +const testRuntimeEnvironment = (): StackRuntimeEnvironmentValue => ({ ...defaultRuntimeEnvironment(), artifactCacheRoot: testArtifactCacheRoot, - stateRoot, }); const validateStartedStatus = ( @@ -205,7 +135,6 @@ const cleanup = async ( stack: PromiseStack | undefined, root: string, operations: TestStackOperations, - stateRoot?: string, primary?: unknown, ) => { let failure = primary; @@ -226,13 +155,6 @@ const cleanup = async ( if (failure === undefined) failure = error; } } - if (rootCanBeRemoved && stateRoot !== undefined) { - try { - await releaseStateRoot(operations, stateRoot); - } catch (error) { - if (failure === undefined) failure = error; - } - } if (!rootCanBeRemoved) { const reason = failure instanceof Error ? failure.message : String(failure); throw new Error(`${reason}; retained test stack root ${root}`, { cause: failure }); @@ -247,11 +169,9 @@ export const createTestStackWith = async ( ): Promise => { const projectRoot = await operations.createRoot(); let stack: PromiseStack | undefined; - let stateRoot: string | undefined; try { if (options.setupProject !== undefined) await options.setupProject(projectRoot); - stateRoot = await acquireStateRoot(operations); - const runtimeEnvironment = isolatedEnvironment(projectRoot, stateRoot); + const runtimeEnvironment = testRuntimeEnvironment(); stack = await operations.createStack( { projectRoot, @@ -275,10 +195,10 @@ export const createTestStackWith = async ( return { ...resource, stateRoot: runtimeEnvironment.stateRoot, - [Symbol.asyncDispose]: () => cleanup(resource, projectRoot, operations, stateRoot), + [Symbol.asyncDispose]: () => cleanup(resource, projectRoot, operations), }; } catch (error) { - await cleanup(stack, projectRoot, operations, stateRoot, error); + await cleanup(stack, projectRoot, operations, error); throw error; } }; diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index 490683381f..44a76cb5ad 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { PromiseStack } from "./PromiseStack.ts"; import { createTestStackWith, type TestStackOperations } from "./Testing.ts"; +import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; @@ -229,7 +230,7 @@ describe("test stack resource", () => { ]); }); - it("passes an isolated runtime state root without mutating process environment", async () => { + it("uses the managed runtime state root without mutating process environment", async () => { const events: Array = []; // This read is the assertion that createTestStackWith leaves global environment untouched. // oxlint-disable-next-line effecttsgo/process-env -- test-only environment immutability assertion. @@ -246,8 +247,7 @@ describe("test stack resource", () => { }; const stack = await createTestStackWith({}, operations); await stack[Symbol.asyncDispose](); - expect(environment?.stateRoot).toBe("/tmp/stack-test-isolated-state/.supabase/managed/stacks"); - expect(environment?.stateRoot.startsWith("/tmp/stack-test-isolated-state/")).toBe(true); + expect(environment?.stateRoot).toBe(defaultRuntimeEnvironment().stateRoot); expect(environment?.artifactCacheRoot).toBe(join(tmpdir(), "supabase-stack-test-artifacts")); expect(environment?.artifactCacheRoot).not.toContain("stack-test-isolated-state"); // Compare against the snapshot above to prove no global environment mutation occurred. @@ -331,22 +331,16 @@ describe("test stack resource", () => { expect(events).toEqual(["start", "destroy"]); }); - it("shares one coordination state root while test stacks overlap", async () => { + it("uses the managed state root while test stacks overlap", async () => { const roots = ["/tmp/stack-test-shared-a", "/tmp/stack-test-shared-b"]; const environments: Array>[1]> = []; const removedRoots: Array = []; - const removedStateRoots: Array = []; - let stateRootCreations = 0; const operations: TestStackOperations = { createRoot: async () => { const root = roots.shift(); if (root === undefined) throw new Error("No test root available"); return root; }, - createStateRoot: async () => { - stateRootCreations += 1; - return "/tmp/stack-test-shared-state"; - }, createStack: async (_options, environment) => { environments.push(environment); return fakeStack([]); @@ -354,49 +348,42 @@ describe("test stack resource", () => { removeRoot: async (root) => { removedRoots.push(root); }, - removeStateRoot: async (root) => { - removedStateRoots.push(root); - }, }; const [first, second] = await Promise.all([ createTestStackWith({}, operations), createTestStackWith({}, operations), ]); - expect(stateRootCreations).toBe(1); expect(environments.map((environment) => environment?.stateRoot)).toEqual([ - "/tmp/stack-test-shared-state", - "/tmp/stack-test-shared-state", + defaultRuntimeEnvironment().stateRoot, + defaultRuntimeEnvironment().stateRoot, ]); await first[Symbol.asyncDispose](); - expect(removedStateRoots).toEqual([]); await second[Symbol.asyncDispose](); expect(removedRoots).toEqual(["/tmp/stack-test-shared-a", "/tmp/stack-test-shared-b"]); - expect(removedStateRoots).toEqual(["/tmp/stack-test-shared-state"]); }); - it("retains shared coordination state when one stack destroy fails", async () => { + it("retains the project root and managed state when one stack destroy fails", async () => { const roots = ["/tmp/stack-test-retained-a", "/tmp/stack-test-retained-b"]; const removedRoots: Array = []; - const removedStateRoots: Array = []; + const environments: Array>[1]> = []; const operations: TestStackOperations = { createRoot: async () => { const root = roots.shift(); if (root === undefined) throw new Error("No test root available"); return root; }, - createStateRoot: async () => "/tmp/stack-test-retained-state", - createStack: async (options) => ({ - ...fakeStack([]), - destroy: async () => { - if (options.projectRoot.endsWith("-a")) throw new Error("destroy a failed"); - }, - }), + createStack: async (options, environment) => { + environments.push(environment); + return { + ...fakeStack([]), + destroy: async () => { + if (options.projectRoot.endsWith("-a")) throw new Error("destroy a failed"); + }, + }; + }, removeRoot: async (root) => { removedRoots.push(root); }, - removeStateRoot: async (root) => { - removedStateRoots.push(root); - }, }; const [first, second] = await Promise.all([ createTestStackWith({}, operations), @@ -407,6 +394,9 @@ describe("test stack resource", () => { ); await second[Symbol.asyncDispose](); expect(removedRoots).toEqual(["/tmp/stack-test-retained-b"]); - expect(removedStateRoots).toEqual([]); + expect(environments.map((environment) => environment?.stateRoot)).toEqual([ + defaultRuntimeEnvironment().stateRoot, + defaultRuntimeEnvironment().stateRoot, + ]); }); }); diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index bba2f1a630..b7e3917198 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -9,12 +9,15 @@ import { PgClient } from "@effect/sql-pg"; import { Data, Effect, Redacted, Schedule, type Duration } from "effect"; import { execFile as execFileCallback } from "node:child_process"; -import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { connect as connectTcp } from "node:net"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { WebSocket } from "ws"; import { describe, expect, test } from "vitest"; +import { createStack, listStacks, type PromiseStack } from "../index.ts"; +import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; import { createTestStack, type TestStack } from "../testing.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import type { PromiseStackCredentials } from "./Credentials.ts"; @@ -1151,6 +1154,65 @@ describe("managed Supabase stack whole-stack E2E", () => { }, ); + test( + `coordinates with an ordinary package stack in ${mode.name} mode`, + { timeout: E2E_TIMEOUT_MS }, + async () => { + const identity = crypto.randomUUID().replaceAll("-", "").slice(0, 16); + const ordinaryRoot = await mkdtemp(join(tmpdir(), "supabase-stack-cli-consumer-")); + let ordinary: PromiseStack | undefined; + let helper: TestStack | undefined; + let primary: unknown; + try { + ordinary = await createStack({ + projectRoot: ordinaryRoot, + name: `stack-cli-consumer-${identity}`, + runtime: mode.runtime, + }); + await ordinary.start(); + helper = await createTestStack({ + name: `stack-helper-consumer-${identity}`, + runtime: mode.runtime, + }); + + const ordinaryStatus = await ordinary.status(); + const helperStatus = await helper.status(); + expect(ordinaryStatus.lifecycle).toBe("running"); + expect(helperStatus.lifecycle).toBe("running"); + expect(helper.stateRoot).toBe(defaultRuntimeEnvironment().stateRoot); + expect(endpoint(ordinaryStatus, "api").port).not.toBe(endpoint(helperStatus, "api").port); + + const scoped = await listStacks({ projectRoot: ordinaryRoot }); + expect(scoped.map(({ id }) => id)).toContain(ordinary.id); + expect(scoped.map(({ id }) => id)).not.toContain(helper.id); + const all = await listStacks(); + expect(all.map(({ id }) => id)).toEqual(expect.arrayContaining([ordinary.id, helper.id])); + } catch (error) { + primary = error; + } + + const [helperResult, ordinaryResult] = await Promise.allSettled([ + helper === undefined ? Promise.resolve() : helper[Symbol.asyncDispose](), + ordinary === undefined ? Promise.resolve() : ordinary.destroy(), + ]); + let cleanupFailure: unknown; + for (const result of [helperResult, ordinaryResult]) { + if (result.status === "rejected") { + cleanupFailure ??= result.reason; + } + } + if (ordinaryResult.status === "fulfilled") { + try { + await rm(ordinaryRoot, { recursive: true, force: true }); + } catch (error) { + cleanupFailure ??= error; + } + } + if (primary !== undefined) throw primary; + if (cleanupFailure !== undefined) throw cleanupFailure; + }, + ); + test(`supports the complete user flow in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, () => runWholeStackScenario(mode), ); From 277de03cbce7af3c672c40a629bbd738cda32fff Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 10:10:43 +0200 Subject: [PATCH 151/174] fix(stack): preserve port intent and refresh stale artifacts --- .../next/commands/restart/restart.handler.ts | 10 +- .../src/next/commands/start/start.handler.ts | 10 +- apps/cli/src/next/config/stack-config.ts | 107 +++++++++- .../src/next/config/stack-config.unit.test.ts | 83 ++++++++ .../functions/managed-functions-runtime.ts | 14 +- .../stack/src/preparation/ArtifactStore.ts | 187 +++++++++++------- .../preparation/artifacts.integration.test.ts | 46 ++++- 7 files changed, 363 insertions(+), 94 deletions(-) diff --git a/apps/cli/src/next/commands/restart/restart.handler.ts b/apps/cli/src/next/commands/restart/restart.handler.ts index b5dbff42e4..f58b323406 100644 --- a/apps/cli/src/next/commands/restart/restart.handler.ts +++ b/apps/cli/src/next/commands/restart/restart.handler.ts @@ -1,5 +1,5 @@ import { loadCliConfig } from "@supabase/config/effect"; -import type { CliConfig } from "@supabase/config"; +import type { CliConfig, LoadedCliConfig } from "@supabase/config"; import { Crypto, Effect, FileSystem, Option, Path, Scope } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -30,13 +30,17 @@ export interface RestartOperations { readonly openStack: (id: StackId) => Effect.Effect; readonly loadConfig: ( cwd: string, - ) => Effect.Effect; + ) => Effect.Effect< + LoadedCliConfig | CliConfig | undefined, + unknown, + FileSystem.FileSystem | Path.Path + >; } const defaultOperations: RestartOperations = { findStack, openStack, - loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), + loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded ?? undefined)), }; export const restart = Effect.fnUntraced(function* ( diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index 11427ccebe..1275b0cafa 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -6,7 +6,7 @@ import { type CreateStackOptions, } from "@supabase/stack/effect"; import { loadCliConfig } from "@supabase/config/effect"; -import type { CliConfig } from "@supabase/config"; +import type { CliConfig, LoadedCliConfig } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; import { CliProjectHome } from "../../config/cli-project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -29,12 +29,16 @@ export interface StartOperations { ) => Effect.Effect; readonly loadConfig: ( cwd: string, - ) => Effect.Effect; + ) => Effect.Effect< + LoadedCliConfig | CliConfig | undefined, + unknown, + FileSystem.FileSystem | Path.Path + >; } const defaultOperations: StartOperations = { createStack, - loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), + loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded ?? undefined)), }; export const start = Effect.fnUntraced(function* ( diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index c7e75305d7..10aafd6d92 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -70,6 +70,57 @@ const omitUndefined = (value: unknown): unknown => { ); }; +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A decoded config paired with its raw document so key presence survives schema defaults. */ +export interface CliConfigWithRawPresence { + readonly config: CliConfig; + readonly document?: Readonly>; +} + +const hasOwnPath = ( + root: Readonly> | undefined, + path: ReadonlyArray, +): boolean => { + let current: unknown = root; + for (const segment of path) { + if (!isRecord(current) || !Object.hasOwn(current, segment)) return false; + current = current[segment]; + } + return true; +}; + +const unwrapConfig = ( + input: CliConfig | CliConfigWithRawPresence | undefined, +): { + readonly config: CliConfig | undefined; + readonly document: Readonly> | undefined; +} => { + if (input === undefined) return { config: undefined, document: undefined }; + if ("config" in input) return { config: input.config, document: input.document }; + return { config: input, document: undefined }; +}; + +const configuredListener = ( + section: unknown, + port: number | undefined, + path: ReadonlyArray, + document: Readonly> | undefined, +) => { + if (section === undefined) return undefined; + if (document !== undefined && !hasOwnPath(document, path)) return undefined; + return port === undefined ? {} : { port }; +}; + +const dedicatedListener = ( + disabled: boolean, + section: unknown, + port: number | undefined, + path: ReadonlyArray, + document: Readonly> | undefined, +) => (disabled ? ({ enabled: false } as const) : configuredListener(section, port, path, document)); + const passwordRequirements = (value: string | undefined): AuthSettings["password_requirements"] => value === "" || value === "letters_digits" || @@ -322,9 +373,10 @@ const toAuthSettings = (auth: CliConfig["auth"] | undefined): AuthSettings | und /** Translates CLI exclusions to the stack's closed capability config. */ export function toStartStackConfig( - config: CliConfig | undefined, + input: CliConfig | CliConfigWithRawPresence | undefined, exclude: ReadonlyArray, ): Effect.Effect { + const { config, document } = unwrapConfig(input); const excluded = new Set(exclude); const db = config?.db; const api = config?.api; @@ -392,15 +444,50 @@ export function toStartStackConfig( pooler: capability("pooler", excluded, pooler?.enabled, poolerSettings), }, listeners: { - api: api === undefined ? undefined : { port: api.port }, - database: db === undefined ? undefined : { port: db.port }, - pooler: pooler === undefined ? undefined : { port: pooler.port }, - studio: studio === undefined ? undefined : { port: studio.port }, - mailUi: mail === undefined ? undefined : { port: mail.port }, - smtp: mail?.smtp_port === undefined ? undefined : { port: mail.smtp_port }, - pop3: mail?.pop3_port === undefined ? undefined : { port: mail.pop3_port }, - functionsInspector: - edgeRuntime === undefined ? undefined : { port: edgeRuntime.inspector_port }, + api: configuredListener(api, api?.port, ["api", "port"], document), + database: configuredListener(db, db?.port, ["db", "port"], document), + pooler: dedicatedListener( + disabledCapability("pooler", excluded, pooler?.enabled), + pooler, + pooler?.port, + ["db", "pooler", "port"], + document, + ), + studio: dedicatedListener( + disabledCapability("studio", excluded, studio?.enabled), + studio, + studio?.port, + ["studio", "port"], + document, + ), + mailUi: dedicatedListener( + disabledCapability("mail", excluded, mail?.enabled), + mail, + mail?.port, + ["local_smtp", "port"], + document, + ), + smtp: dedicatedListener( + disabledCapability("mail", excluded, mail?.enabled), + mail, + mail?.smtp_port, + ["local_smtp", "smtp_port"], + document, + ), + pop3: dedicatedListener( + disabledCapability("mail", excluded, mail?.enabled), + mail, + mail?.pop3_port, + ["local_smtp", "pop3_port"], + document, + ), + functionsInspector: dedicatedListener( + disabledCapability("functions", excluded, edgeRuntime?.enabled), + edgeRuntime, + edgeRuntime?.inspector_port, + ["edge_runtime", "inspector_port"], + document, + ), }, security: auth?.jwt_secret === undefined && diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index fb582e374b..d89bda1eda 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -6,10 +6,19 @@ import { isFunctionScopedPath, relativeFunctionPath, relativeGlobalFunctionPath, + type CliConfigWithRawPresence, toStartStackConfig, } from "./stack-config.ts"; describe("toStartStackConfig", () => { + const loadedConfig = ( + document: Record, + configInput: Record = document, + ): CliConfigWithRawPresence => ({ + config: Schema.decodeSync(CliConfigSchema)(configInput), + document, + }); + it("leaves runtime selection to the stack", () => { expect(Effect.runSync(toStartStackConfig(undefined, []))).toMatchObject({ capabilities: expect.any(Object), @@ -75,6 +84,80 @@ describe("toStartStackConfig", () => { } }); + it("leaves omitted listener ports automatic while preserving explicit defaults", () => { + const loaded = loadedConfig( + { + api: {}, + db: { port: 54322 }, + studio: {}, + local_smtp: {}, + edge_runtime: {}, + }, + { + api: {}, + db: { port: 54322 }, + studio: {}, + local_smtp: {}, + edge_runtime: {}, + }, + ); + const config = Effect.runSync(toStartStackConfig(loaded, [])); + + expect(config.listeners?.api).toBeUndefined(); + expect(config.listeners?.database).toEqual({ port: 54322 }); + expect(config.listeners?.studio).toBeUndefined(); + expect(config.listeners?.mailUi).toBeUndefined(); + expect(config.listeners?.smtp).toBeUndefined(); + expect(config.listeners?.pop3).toBeUndefined(); + expect(config.listeners?.functionsInspector).toBeUndefined(); + }); + + it("keeps an explicit default port instead of treating it as omitted", () => { + const loaded = loadedConfig({ api: { port: 54321 } }, { api: { port: 54321 } }); + const config = Effect.runSync(toStartStackConfig(loaded, [])); + + expect(config.listeners?.api).toEqual({ port: 54321 }); + }); + + it("disables dedicated listeners when their capabilities are disabled", () => { + const loaded = loadedConfig( + { + db: { pooler: { enabled: false } }, + studio: { enabled: false }, + local_smtp: { enabled: false }, + edge_runtime: { enabled: false }, + }, + { + db: { pooler: { enabled: false } }, + studio: { enabled: false }, + local_smtp: { enabled: false }, + edge_runtime: { enabled: false }, + }, + ); + const config = Effect.runSync(toStartStackConfig(loaded, [])); + + expect(config.listeners?.pooler).toEqual({ enabled: false }); + expect(config.listeners?.studio).toEqual({ enabled: false }); + expect(config.listeners?.mailUi).toEqual({ enabled: false }); + expect(config.listeners?.smtp).toEqual({ enabled: false }); + expect(config.listeners?.pop3).toEqual({ enabled: false }); + expect(config.listeners?.functionsInspector).toEqual({ enabled: false }); + }); + + it("disables excluded dedicated listeners without changing API listeners", () => { + const loaded = loadedConfig( + { api: { enabled: false, port: 54321 }, db: { port: 54322 }, studio: { port: 54323 } }, + { api: { enabled: false, port: 54321 }, db: { port: 54322 }, studio: { port: 54323 } }, + ); + const config = Effect.runSync(toStartStackConfig(loaded, ["studio", "pooler"])); + + expect(config.capabilities?.rest).toEqual({ enabled: false }); + expect(config.listeners?.api).toEqual({ port: 54321 }); + expect(config.listeners?.database).toEqual({ port: 54322 }); + expect(config.listeners?.studio).toEqual({ enabled: false }); + expect(config.listeners?.pooler).toEqual({ enabled: false }); + }); + it("translates Functions paths from the supabase directory to each function directory", () => { const loaded = Schema.decodeSync(CliConfigSchema)({ functions: { diff --git a/apps/cli/src/shared/functions/managed-functions-runtime.ts b/apps/cli/src/shared/functions/managed-functions-runtime.ts index 3c56cd4f93..171ffd3406 100644 --- a/apps/cli/src/shared/functions/managed-functions-runtime.ts +++ b/apps/cli/src/shared/functions/managed-functions-runtime.ts @@ -1,6 +1,6 @@ import { inferFunctionsManifest, loadCliConfig } from "@supabase/config/effect"; import { CliConfigSchema } from "@supabase/config"; -import type { CliConfig, FunctionsManifest } from "@supabase/config"; +import type { CliConfig, FunctionsManifest, LoadedCliConfig } from "@supabase/config"; import { Crypto, Data, @@ -60,7 +60,11 @@ export interface ServeManagedFunctionsOperations { ) => Effect.Effect; readonly loadConfig: ( cwd: string, - ) => Effect.Effect; + ) => Effect.Effect< + LoadedCliConfig | CliConfig | undefined, + unknown, + FileSystem.FileSystem | Path.Path + >; readonly loadManifest?: ( cwd: string, config: CliConfig, @@ -72,7 +76,7 @@ export interface ServeManagedFunctionsOperations { const defaultOperations: Required = { createStack, - loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded?.config)), + loadConfig: (cwd) => loadCliConfig(cwd).pipe(Effect.map((loaded) => loaded ?? undefined)), loadManifest: (cwd, config) => inferFunctionsManifest({ cwd, config }), readEnvFile: (pathname) => Effect.gen(function* () { @@ -182,7 +186,9 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( } } - const config = loadedConfig ?? (yield* Schema.decodeUnknownEffect(CliConfigSchema)({})); + const cliConfig = + loadedConfig !== undefined && "config" in loadedConfig ? loadedConfig.config : loadedConfig; + const config = cliConfig ?? (yield* Schema.decodeUnknownEffect(CliConfigSchema)({})); const hasFunctionFlags = options.noVerifyJwt === true || options.importMap !== undefined; const manifest = hasFunctionFlags ? yield* resolvedOperations.loadManifest(options.projectRoot, config) diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 3a8b4985e2..aa87cbd42f 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -4,8 +4,10 @@ import { Effect, Exit, FileSystem, + Option, Path, PlatformError, + Predicate, Schema, Scope, Semaphore, @@ -121,6 +123,14 @@ const pathWithin = (root: string, candidate: string, separator: string): boolean const pathAtOrBelow = (root: string, candidate: string, separator: string): boolean => candidate === root || candidate.startsWith(`${root}${separator}`); +const isNotFound = (cause: unknown): cause is PlatformError.PlatformError => + cause instanceof PlatformError.PlatformError && + cause.reason instanceof PlatformError.SystemError && + Predicate.isTagged(cause.reason, "NotFound"); + +const isMissingArtifactRoot = (error: ArtifactIntegrityError): boolean => + Predicate.hasProperty(error, "cause") && isNotFound(error.cause); + const validateKey = (key: string): Effect.Effect => validateRelativePath(key, "artifact key").pipe( Effect.flatMap(() => @@ -181,19 +191,23 @@ const encodeMetadata = (metadata: ArtifactMetadata): Effect.Effect => - fs.readFileString(metadataPath).pipe( - Effect.mapError((cause) => - metadataError("Cached artifact metadata is unreadable", { path: metadataPath, cause }), - ), - Effect.flatMap((text) => +): Effect.Effect, ArtifactIntegrityError> => + Effect.matchEffect(fs.readFileString(metadataPath), { + onFailure: (cause) => + isNotFound(cause) + ? Effect.succeed(Option.none()) + : Effect.fail( + metadataError("Cached artifact metadata is unreadable", { + path: metadataPath, + cause, + }), + ), + onSuccess: (text) => Schema.decodeEffect(Schema.fromJsonString(ArtifactMetadataSchema))(text).pipe( - Effect.mapError((cause) => - metadataError("Cached artifact metadata is malformed", { path: metadataPath, cause }), - ), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), ), - ), - ); + }); const ensureDirectory = ( fs: FileSystem.FileSystem, @@ -598,48 +612,97 @@ const makeArtifactOperation = ( const target = path.resolve(cacheRoot, request.key); const targetParent = path.dirname(target); yield* ensureDirectory(fs, path, targetParent, cacheRoot); - const targetExists = yield* mapFs(target, "inspect cached artifact", fs.exists(target)); - const cachedRoot = targetExists - ? yield* ensureSafeRoot(fs, path, target, cacheRoot) - : undefined; const metadataPath = path.join(target, METADATA_NAME); - const checkCached: Effect.Effect = Effect.gen( - function* () { - const realRoot = cachedRoot ?? (yield* ensureSafeRoot(fs, path, target, cacheRoot)); - const metadata = yield* readMetadata(fs, metadataPath); - yield* verifyMetadata(request, expectedSha256, metadata); - // Published content is intentionally not rehashed on cache hits. Metadata and cheap - // structural checks protect the cache boundary; content tampering may execute or fail - // later when the workload starts. - const safePaths = yield* ensureSafePaths( - fs, - path, - target, - realRoot, - metadata, - request.requiredRuntimePaths, + const checkCached: Effect.Effect< + Option.Option, + ArtifactIntegrityError + > = Effect.gen(function* () { + const realRoot = yield* ensureSafeRoot(fs, path, target, cacheRoot).pipe( + Effect.map(Option.some), + Effect.catch((error) => + isMissingArtifactRoot(error) ? Effect.succeed(Option.none()) : Effect.fail(error), + ), + ); + if (Option.isNone(realRoot)) return Option.none(); + const cachedMetadata = yield* readMetadata(fs, metadataPath); + if (Option.isNone(cachedMetadata)) return Option.none(); + const metadata = cachedMetadata.value; + yield* verifyMetadata(request, expectedSha256, metadata); + // Published content is intentionally not rehashed on cache hits. Metadata and cheap + // structural checks protect the cache boundary; content tampering may execute or fail + // later when the workload starts. + const safePaths = yield* ensureSafePaths( + fs, + path, + target, + realRoot.value, + metadata, + request.requiredRuntimePaths, + ); + if (request.executablePath !== undefined) { + const executable = safePaths[request.executablePath]; + if (executable === undefined) + return yield* metadataError("Cached artifact executable path is not recorded", { + path: request.executablePath, + }); + yield* ensureExecutableFile(fs, executable.realPath); + } + return Option.some({ + key: request.key, + path: target, + sha256: expectedSha256, + requiredRuntimePaths: [...request.requiredRuntimePaths], + ...(request.executablePath === undefined ? {} : { executablePath: request.executablePath }), + outcome: "cached" as const, + }); + }); + const inspectCache = (): Effect.Effect, ArtifactStoreError> => + Effect.gen(function* () { + const exists = yield* mapFs(target, "inspect cached artifact", fs.exists(target)); + if (!exists) return Option.none(); + const cached = yield* checkCached; + if (Option.isSome(cached)) return cached; + const stillExists = yield* mapFs(target, "inspect cached artifact", fs.exists(target)); + if (!stillExists) return Option.none(); + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + artifactError(`Unable to allocate artifact replacement name: ${cause.message}`, { + key: request.key, + cause, + }), + ), ); - if (request.executablePath !== undefined) { - const executable = safePaths[request.executablePath]; - if (executable === undefined) - return yield* metadataError("Cached artifact executable path is not recorded", { - path: request.executablePath, - }); - yield* ensureExecutableFile(fs, executable.realPath); + const replacement = path.join(targetParent, `.${path.basename(target)}.${token}.invalid`); + const moved = yield* Effect.acquireUseRelease( + fs.rename(target, replacement).pipe( + Effect.as(Option.some(replacement)), + Effect.catch((cause) => + isNotFound(cause) + ? Effect.succeed(Option.none()) + : Effect.fail( + artifactError(`Unable to replace invalid cached artifact: ${String(cause)}`, { + path: target, + cause, + }), + ), + ), + ), + (renamed) => Effect.succeed(Option.isSome(renamed)), + (renamed) => (Option.isSome(renamed) ? cleanup(fs, renamed.value) : Effect.void), + ); + if (moved) { + return Option.none(); } - return { - key: request.key, - path: target, - sha256: expectedSha256, - requiredRuntimePaths: [...request.requiredRuntimePaths], - ...(request.executablePath === undefined - ? {} - : { executablePath: request.executablePath }), - outcome: "cached" as const, - }; - }, - ); - if (targetExists) return yield* checkCached; + const raced = yield* mapFs(target, "inspect cached artifact", fs.exists(target)); + if (!raced) return Option.none(); + const concurrent = yield* checkCached; + if (Option.isSome(concurrent)) return concurrent; + return yield* metadataError("Concurrent cached artifact metadata is invalid", { + path: metadataPath, + }); + }); + const initial = yield* inspectCache(); + if (Option.isSome(initial)) return initial.value; const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => @@ -650,9 +713,9 @@ const makeArtifactOperation = ( ), ); const temporary = path.join(targetParent, `.${path.basename(target)}.${token}.tmp`); - yield* ensureDirectory(fs, path, temporary, cacheRoot); - const temporaryRoot = yield* ensureSafeRoot(fs, path, temporary, cacheRoot); const published = yield* Effect.gen(function* () { + yield* ensureDirectory(fs, path, temporary, cacheRoot); + const temporaryRoot = yield* ensureSafeRoot(fs, path, temporary, cacheRoot); const archive = yield* source.materialize(request, temporary).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -698,24 +761,16 @@ const makeArtifactOperation = ( fs.chmod(executable.realPath, EXECUTABLE_MODE), ); } - const nowExists = yield* mapFs(target, "inspect concurrent artifact", fs.exists(target)); - if (nowExists) { - const cached = yield* checkCached; - return cached; - } + const beforePublish = yield* inspectCache(); + if (Option.isSome(beforePublish)) return beforePublish.value; const rename = mapFs(temporary, "publish artifact", fs.rename(temporary, target)).pipe( Effect.as(undefined), ); - const recoverPublish = ( - error: StackPreparationError, - ): Effect.Effect => + const recoverPublish = (): Effect.Effect => Effect.gen(function* () { - const exists = yield* mapFs(target, "inspect concurrent artifact", fs.exists(target)); - if (exists) { - const cached = yield* checkCached; - return cached; - } - return yield* error; + const probe = yield* inspectCache(); + if (Option.isSome(probe)) return probe.value; + return yield* rename; }); const recovered: Effect.Effect = rename.pipe(Effect.catch(recoverPublish)); diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index b6d1ed130a..58158d55fe 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -78,7 +78,7 @@ describe("verified native artifact preparation", () => { ), ); - it.live("fails closed for unknown artifact metadata instead of executing the cached tree", () => + it.live("replaces a cached tree with unknown artifact metadata", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -94,19 +94,49 @@ describe("verified native artifact preparation", () => { ); let called = false; const source: ArtifactSource = { - materialize: () => + materialize: (entry, destination) => Effect.sync(() => { called = true; - return archive; - }), + return entry; + }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination))), }; const store = yield* makeArtifactStore({ cacheRoot: root, source }); - const exit = yield* store.prepare(request).pipe(Effect.exit); + const prepared = yield* store.prepare(request); - expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); - expect(called).toBe(false); - expect(yield* fs.readFileString(`${target}/bin/postgres`)).toBe("unverified postgres"); + expect(prepared.outcome).toBe("downloaded"); + expect(called).toBe(true); + expect(yield* fs.readFileString(`${target}/bin/postgres`)).toBe("native postgres"); + }), + ), + ); + + it.live("replaces a cached tree with missing artifact metadata", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-missing-metadata-", + }); + const target = `${root}/${request.key}`; + yield* fs.makeDirectory(`${target}/bin`, { recursive: true }); + yield* fs.writeFileString(`${target}/bin/postgres`, "unverified postgres"); + let called = false; + const source: ArtifactSource = { + materialize: (entry, destination) => + Effect.sync(() => { + called = true; + return entry; + }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination))), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + + const prepared = yield* store.prepare(request); + + expect(prepared.outcome).toBe("downloaded"); + expect(called).toBe(true); + expect(yield* fs.readFileString(`${target}/bin/postgres`)).toBe("native postgres"); + expect(yield* fs.exists(`${target}/.artifact.json`)).toBe(true); }), ), ); From a7d4f60d4ea6793b7ce381deec1eb0c8669caaf4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 11:58:53 +0200 Subject: [PATCH 152/174] fix(stack): harden lifecycle and observability --- .../scripts/build-binary.integration.test.ts | 75 ++++++ .../src/next/commands/start/start.e2e.test.ts | 101 ------- apps/cli/src/next/main.ts | 10 +- .../runtime/stack-e2e-cleanup.unit.test.ts | 45 +++- apps/cli/tests/e2e-global-setup.ts | 4 - .../fixtures/compiled-native-dispatch.ts | 5 + apps/cli/tests/helpers/running-stack.ts | 11 - apps/cli/tests/helpers/stack-e2e-cleanup.ts | 70 ++--- apps/cli/vitest.config.ts | 1 - packages/stack/src/identity/FolderIdentity.ts | 1 + packages/stack/src/identity/GitIdentity.ts | 2 +- .../stack/src/internal/supervisor-process.ts | 15 ++ packages/stack/src/public/EffectStack.ts | 14 +- packages/stack/src/public/Errors.ts | 177 +++++++----- .../public/effect-stack.integration.test.ts | 102 +++++++ .../stack/src/public/whole-stack.e2e.test.ts | 11 +- packages/stack/src/runtime/NativeProcess.ts | 13 +- packages/stack/src/runtime/ReadinessProbe.ts | 1 + packages/stack/src/runtime/RuntimeDriver.ts | 3 + packages/stack/src/runtime/native-launcher.ts | 179 +++++++------ .../native-runtime.integration.test.ts | 13 +- .../production-runtime.integration.test.ts | 2 + .../readiness-probe.integration.test.ts | 3 + packages/stack/src/state/PortCoordinator.ts | 9 +- packages/stack/src/state/StackStateStore.ts | 252 ++++++++++++++---- .../stack/src/state/ports.integration.test.ts | 32 +++ .../src/state/state-store.integration.test.ts | 50 ++++ packages/stack/src/supervisor/Launcher.ts | 16 +- packages/stack/src/supervisor/LogStore.ts | 55 +++- packages/stack/src/supervisor/Supervisor.ts | 31 ++- .../supervisor/handles.integration.test.ts | 8 +- .../observability.integration.test.ts | 97 ++++++- .../supervisor/supervisor.integration.test.ts | 80 +++++- 33 files changed, 1089 insertions(+), 399 deletions(-) delete mode 100644 apps/cli/src/next/commands/start/start.e2e.test.ts delete mode 100644 apps/cli/tests/e2e-global-setup.ts create mode 100644 apps/cli/tests/fixtures/compiled-native-dispatch.ts delete mode 100644 apps/cli/tests/helpers/running-stack.ts diff --git a/apps/cli/scripts/build-binary.integration.test.ts b/apps/cli/scripts/build-binary.integration.test.ts index 8073bee193..81babaf38f 100644 --- a/apps/cli/scripts/build-binary.integration.test.ts +++ b/apps/cli/scripts/build-binary.integration.test.ts @@ -3,6 +3,9 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { NATIVE_PROCESS_DISPATCH_SENTINEL } from "@supabase/stack/internal/supervisor"; const fixturePath = fileURLToPath( new URL("../tests/fixtures/compiled-libpg-query.ts", import.meta.url), @@ -10,6 +13,9 @@ const fixturePath = fileURLToPath( const versionFixturePath = fileURLToPath( new URL("../tests/fixtures/compiled-cli-version.ts", import.meta.url), ); +const nativeDispatchFixturePath = fileURLToPath( + new URL("../tests/fixtures/compiled-native-dispatch.ts", import.meta.url), +); const temporaryDirectories: string[] = []; afterEach(async () => { @@ -95,4 +101,73 @@ describe("compiled binary assets", () => { expect(probeExitCode, stderr).toBe(0); expect(stdout.trim()).toBe("7.8.9-beta.1"); }, 20_000); + + test("dispatches the embedded native launcher from a compiled binary", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "supabase-compiled-native-")); + temporaryDirectories.push(directory); + const executable = path.join(directory, "native-dispatch-probe"); + const reap = async (child: ReturnType | undefined): Promise => { + if (child === undefined || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + const done = () => { + child.off("exit", done); + child.off("error", done); + resolve(); + }; + child.once("exit", done); + child.once("error", done); + child.kill("SIGKILL"); + }); + }; + + let build: ReturnType | undefined; + let child: ReturnType | undefined; + let buildStderr: Promise | undefined; + let stdout: Promise | undefined; + let stderr: Promise | undefined; + try { + build = spawn( + "bun", + ["build", nativeDispatchFixturePath, "--compile", `--outfile=${executable}`], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + buildStderr = new Response(build.stderr!).text(); + const [buildExitCode] = (await once(build, "exit")) as [number | null, NodeJS.Signals | null]; + expect(buildExitCode, await buildStderr).toBe(0); + + child = spawn(executable, [NATIVE_PROCESS_DISPATCH_SENTINEL], { + cwd: directory, + env: {}, + stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"], + }); + stdout = new Response(child.stdout!).text(); + stderr = new Response(child.stderr!).text(); + const payload = JSON.stringify({ + executable: process.execPath, + args: ["-e", "process.stdout.write('native launcher dispatch ok\\n')"], + }); + const payloadFd = child.stdio[4]; + if ( + payloadFd == null || + typeof payloadFd === "number" || + !("end" in payloadFd) || + typeof payloadFd.end !== "function" + ) { + throw new Error("compiled native dispatch probe did not expose payload fd"); + } + payloadFd.end(payload); + const [exitCode] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null]; + + expect(exitCode, await stderr).toBe(0); + expect((await stdout).trim()).toBe("native launcher dispatch ok"); + } finally { + await reap(child); + await reap(build); + await Promise.all([ + buildStderr?.catch(() => ""), + stdout?.catch(() => ""), + stderr?.catch(() => ""), + ]); + } + }, 30_000); }); diff --git a/apps/cli/src/next/commands/start/start.e2e.test.ts b/apps/cli/src/next/commands/start/start.e2e.test.ts deleted file mode 100644 index 898331795e..0000000000 --- a/apps/cli/src/next/commands/start/start.e2e.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; -import { makeTempCliProject, makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; -import { cleanupRegisteredStackProjects } from "../../../../tests/helpers/stack-e2e-cleanup.ts"; - -const START_TIMEOUT_MS = 180_000; -const COMMAND_OPTIONS = { entrypoint: "next" as const }; -const LIGHTWEIGHT_DOCKER_ARGS = [ - "start", - "--detach", - "--mode", - "docker", - "--exclude", - "realtime", - "--exclude", - "storage", - "--exclude", - "imgproxy", - "--exclude", - "mailpit", - "--exclude", - "pgmeta", - "--exclude", - "studio", - "--exclude", - "analytics", - "--exclude", - "vector", - "--exclude", - "pooler", -] as const; - -// Lazy service activation crosses the real proxy, daemon, Docker network, and -// container lifecycle boundaries, so keep one golden-path Docker e2e test. -describe("supabase start lazy lifecycle (e2e)", () => { - let project: Awaited> | undefined; - let home: ReturnType | undefined; - - afterEach(async () => { - await cleanupRegisteredStackProjects(); - project = undefined; - home = undefined; - }); - - test( - "keeps an HTTP service dormant until its first proxied request", - { timeout: START_TIMEOUT_MS + 120_000 }, - async () => { - project = await makeTempCliProject("supabase-lazy-start-e2e-"); - home = makeTempHome(); - await mkdir(join(project.dir, "supabase"), { recursive: true }); - const projectId = basename(project.dir) - .replace(/[^a-z0-9]/giu, "") - .toLowerCase(); - await writeFile( - join(project.dir, "supabase", "config.toml"), - `project_id = "${projectId}"\n`, - ); - - const started = await runSupabase([...LIGHTWEIGHT_DOCKER_ARGS], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - exitTimeoutMs: START_TIMEOUT_MS, - }); - expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe(0); - - const before = await runSupabase(["status"], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - }); - expect(before.exitCode, `stdout:\n${before.stdout}\nstderr:\n${before.stderr}`).toBe(0); - expect(before.stdout).toContain("auth: Dormant"); - - const apiUrlMatch = - `${started.stdout}\n${started.stderr}\n${before.stdout}\n${before.stderr}`.match( - /API URL:\s+(https?:\/\/[^\s]+)/, - ); - if (apiUrlMatch?.[1] === undefined) { - throw new Error( - `Start/status output did not include an API URL.\nstdout:\n${before.stdout}\nstderr:\n${before.stderr}`, - ); - } - - const response = await fetch(`${apiUrlMatch[1]}/auth/v1/health`, { - signal: AbortSignal.timeout(60_000), - }); - expect(response.ok).toBe(true); - - const after = await runSupabase(["status"], { - ...COMMAND_OPTIONS, - cwd: project.dir, - home: home.dir, - }); - expect(after.exitCode, `stdout:\n${after.stdout}\nstderr:\n${after.stderr}`).toBe(0); - expect(after.stdout).toContain("auth: Healthy"); - }, - ); -}); diff --git a/apps/cli/src/next/main.ts b/apps/cli/src/next/main.ts index 4e0c77ce86..d79ed712bc 100644 --- a/apps/cli/src/next/main.ts +++ b/apps/cli/src/next/main.ts @@ -1,7 +1,13 @@ #!/usr/bin/env bun -import { runSupervisorProcessIfDispatched } from "@supabase/stack/internal/supervisor"; +import { + runNativeProcessIfDispatched, + runSupervisorProcessIfDispatched, +} from "@supabase/stack/internal/supervisor"; const args = process.argv.slice(2); -if (!(await runSupervisorProcessIfDispatched(args))) { +if ( + !(await runSupervisorProcessIfDispatched(args)) && + !(await runNativeProcessIfDispatched(args)) +) { await import("./cli/main.ts"); } diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts index a25d1f13bf..4deb7a0648 100644 --- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts +++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createStackE2eCleanupManager } from "../../../tests/helpers/stack-e2e-cleanup.ts"; +import { + captureStackRuntimeSnapshot, + createStackE2eCleanupManager, +} from "../../../tests/helpers/stack-e2e-cleanup.ts"; function permissionError(message = "permission denied") { return Object.assign(new Error(message), { code: "EACCES" }); @@ -40,6 +43,44 @@ function cleanupEnvironment( } describe("stack e2e cleanup manager", () => { + it("discovers current managed state by identity project root", () => { + const root = mkdtempSync(join(tmpdir(), "stack-e2e-cleanup-state-")); + const project = join(root, "project"); + const stackId = "a".repeat(64); + const stackDir = join(root, "home", "managed", "stacks", stackId); + const stateFile = join(stackDir, "state.json"); + + try { + mkdirSync(project, { recursive: true }); + mkdirSync(stackDir, { recursive: true }); + writeFileSync( + stateFile, + JSON.stringify({ + identity: { projectRoot: realpathSync(project) }, + }), + ); + writeFileSync( + join(stackDir, "control.json"), + JSON.stringify({ + format: "supabase-stack-owner-v1", + stackId, + ownerSessionId: "owner-session", + endpoint: { kind: "unix", path: "/tmp/supabase-stack.sock" }, + rpcRelease: "test", + }), + ); + + const normalizedStackDir = realpathSync(stackDir); + expect(captureStackRuntimeSnapshot(project, join(root, "home"))).toMatchObject({ + managedStacksRootExists: true, + documentFiles: [join(normalizedStackDir, "state.json")], + stackDirs: [normalizedStackDir], + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("cleans a registered stack project and associated home once", async () => { const calls: Array = []; const manager = createStackE2eCleanupManager( diff --git a/apps/cli/tests/e2e-global-setup.ts b/apps/cli/tests/e2e-global-setup.ts deleted file mode 100644 index 0c0d2a4ff7..0000000000 --- a/apps/cli/tests/e2e-global-setup.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** E2E setup intentionally performs no global stack warmup. Each scenario owns its test resource. */ -export default async function globalSetup() { - await Promise.resolve(); -} diff --git a/apps/cli/tests/fixtures/compiled-native-dispatch.ts b/apps/cli/tests/fixtures/compiled-native-dispatch.ts new file mode 100644 index 0000000000..f13cb7be0f --- /dev/null +++ b/apps/cli/tests/fixtures/compiled-native-dispatch.ts @@ -0,0 +1,5 @@ +import { runNativeProcessIfDispatched } from "@supabase/stack/internal/supervisor"; + +if (!(await runNativeProcessIfDispatched(process.argv.slice(2)))) { + throw new Error("native dispatch probe was not invoked"); +} diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts deleted file mode 100644 index e5ead08c82..0000000000 --- a/apps/cli/tests/helpers/running-stack.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Stack integration fixtures are provided by @supabase/stack/testing. - * The legacy daemon fixture was intentionally removed with the manager API. - */ -export async function makeRunningStackFixture(): Promise { - throw new Error("Use createTestStack from @supabase/stack/testing for stack scenarios."); -} - -export async function makeStoppedStackFixture(): Promise { - throw new Error("Use createTestStack from @supabase/stack/testing for stack scenarios."); -} diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts index 40fcfe517d..c5e1cc7624 100644 --- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts +++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts @@ -14,7 +14,7 @@ type StackProject = { homeDir?: string; }; -interface StackRuntimeSnapshot { +export interface StackRuntimeSnapshot { readonly managedStacksRootExists: boolean; readonly documentFiles: ReadonlyArray; readonly stackDirs: ReadonlyArray; @@ -128,17 +128,6 @@ function descendantPids( return [...visited]; } -function readDocumentPid(documentFile: string): number | undefined { - try { - const parsed = JSON.parse(readFileSync(documentFile, "utf8")) as { - readonly runtime?: { readonly pid?: number }; - }; - return parsed.runtime?.pid; - } catch { - return undefined; - } -} - function cleanupErrorDetail( pathname: string, error: unknown, @@ -276,7 +265,14 @@ async function cleanupProject( await cleanupOwnedPath(project.dir, project.cleanup, environment); } -function captureSnapshot(projectDir: string, homeDir?: string): StackRuntimeSnapshot { +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function captureStackRuntimeSnapshot( + projectDir: string, + homeDir?: string, +): StackRuntimeSnapshot { const normalized = normalizeDir(projectDir); const managedStacksRoot = homeDir === undefined ? undefined : path.join(normalizeDir(homeDir), "managed", "stacks"); @@ -297,17 +293,17 @@ function captureSnapshot(projectDir: string, homeDir?: string): StackRuntimeSnap continue; } const stackDir = path.join(managedStacksRoot, entry.name); - const documentFile = path.join(stackDir, "stack.json"); + const documentFile = path.join(stackDir, "state.json"); if (!existsSync(documentFile)) { continue; } try { - const document = JSON.parse(readFileSync(documentFile, "utf8")) as { - readonly workspace?: { readonly path?: string }; - }; + const document: unknown = JSON.parse(readFileSync(documentFile, "utf8")); + const identity = isRecord(document) && isRecord(document.identity) ? document.identity : null; + const documentProjectRoot = isRecord(identity) ? identity.projectRoot : undefined; if ( - document.workspace?.path === undefined || - normalizeDir(document.workspace.path) !== normalized + typeof documentProjectRoot !== "string" || + normalizeDir(documentProjectRoot) !== normalized ) { continue; } @@ -318,12 +314,12 @@ function captureSnapshot(projectDir: string, homeDir?: string): StackRuntimeSnap documentFiles.push(documentFile); } - const rootPids = documentFiles - .map(readDocumentPid) - .filter((pid): pid is number => pid != null && pid > 0); const table = parsePsTable(); - const descendants = descendantPids(rootPids, table); + // The supervisor no longer persists a PID in stack state. Its launch + // payload contains the exact project root, so use that command line as the + // owner root and include only its descendants. const commandPids = table.filter((row) => row.command.includes(normalized)).map((row) => row.pid); + const descendants = descendantPids(commandPids, table); return { managedStacksRootExists: stackDirs.length > 0, @@ -334,22 +330,13 @@ function captureSnapshot(projectDir: string, homeDir?: string): StackRuntimeSnap } async function waitForCleanup( - projectDir: string, - homeDir: string | undefined, + _projectDir: string, + _homeDir: string | undefined, snapshot: StackRuntimeSnapshot, ): Promise { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - const nextSnapshot = captureSnapshot(projectDir, homeDir); - const filesGone = nextSnapshot.documentFiles.length === 0; - const pidsGone = snapshot.trackedPids.every((pid) => !isProcessAlive(pid)); - if (filesGone && pidsGone) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - - return false; + // stopStack resolves only after the owner has shut down. Check exact PIDs + // once and let forceCleanup handle anything that remains alive. + return snapshot.trackedPids.every((pid) => !isProcessAlive(pid)); } async function forceCleanup( @@ -368,13 +355,6 @@ async function forceCleanup( rmSync(stackDir, { recursive: true, force: true }); } catch {} } - - if (homeDir !== undefined) { - const managedStacksRoot = path.join(normalizeDir(homeDir), "managed", "stacks"); - try { - rmSync(managedStacksRoot, { recursive: true, force: true }); - } catch {} - } } function createRealEnvironment(): CleanupEnvironment { @@ -387,7 +367,7 @@ function createRealEnvironment(): CleanupEnvironment { }); return { exitCode: result.exitCode }; }, - captureSnapshot, + captureSnapshot: captureStackRuntimeSnapshot, waitForCleanup, forceCleanup, removeProjectWithDocker, diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 992e5a6580..1367aa9621 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -84,7 +84,6 @@ export default defineConfig({ include: ["**/*.e2e.test.ts"], fileParallelism: false, maxWorkers: 1, - globalSetup: ["tests/e2e-global-setup.ts"], setupFiles: ["tests/e2e-setup.ts"], testTimeout: 120_000, hookTimeout: 120_000, diff --git a/packages/stack/src/identity/FolderIdentity.ts b/packages/stack/src/identity/FolderIdentity.ts index f63c32dc0d..e30feaf472 100644 --- a/packages/stack/src/identity/FolderIdentity.ts +++ b/packages/stack/src/identity/FolderIdentity.ts @@ -18,6 +18,7 @@ export const resolveFolderIdentity = ( new InvalidStackIdentityError({ projectRoot: canonicalProjectRoot, reason: "The canonical project root is empty", + message: "The canonical project root is empty", }), ) : Effect.succeed({ diff --git a/packages/stack/src/identity/GitIdentity.ts b/packages/stack/src/identity/GitIdentity.ts index aac6c0a461..d73f681a67 100644 --- a/packages/stack/src/identity/GitIdentity.ts +++ b/packages/stack/src/identity/GitIdentity.ts @@ -19,7 +19,7 @@ interface CheckoutMetadata { } const invalidMetadata = (path: string, reason: string): InvalidStackIdentityError => - new InvalidStackIdentityError({ path, reason }); + new InvalidStackIdentityError({ path, reason, message: reason }); const canonicalGitDirectory = ( fs: FileSystem.FileSystem, diff --git a/packages/stack/src/internal/supervisor-process.ts b/packages/stack/src/internal/supervisor-process.ts index 0e35523a81..257f62cd9a 100644 --- a/packages/stack/src/internal/supervisor-process.ts +++ b/packages/stack/src/internal/supervisor-process.ts @@ -1,5 +1,9 @@ import { SUPERVISOR_DISPATCH_SENTINEL } from "../supervisor/Launcher.ts"; import { runSupervisorProcess } from "../entrypoints/supervisor-node.ts"; +import { NATIVE_PROCESS_DISPATCH_SENTINEL } from "../runtime/NativeProcess.ts"; +import { runNativeLauncher } from "../runtime/native-launcher.ts"; + +export { NATIVE_PROCESS_DISPATCH_SENTINEL } from "../runtime/NativeProcess.ts"; /** * Supported process-entrypoint seam for embedders such as the CLI binary. @@ -10,3 +14,14 @@ export const runSupervisorProcessIfDispatched = (argv: ReadonlyArray): P if (argv[0] !== SUPERVISOR_DISPATCH_SENTINEL) return Promise.resolve(false); return runSupervisorProcess(argv.slice(1)).then(() => true); }; + +/** + * Runs the embedded native launcher when a compiled CLI receives its private + * dispatch marker. Returns false for ordinary CLI argv so callers can continue + * with their normal command entrypoint. + */ +export const runNativeProcessIfDispatched = (argv: ReadonlyArray): Promise => { + if (argv[0] !== NATIVE_PROCESS_DISPATCH_SENTINEL) return Promise.resolve(false); + runNativeLauncher(); + return Promise.resolve(true); +}; diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 1311a87236..2cc23d7a7e 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -24,7 +24,11 @@ import { compileStack, rebuildExecutionPlan, type StackDefinition } from "../mod import { dependencyClosure, type ExecutionPlan } from "../model/ExecutionPlan.ts"; import type { PersistedStackIdentity, PersistedStackState } from "../state/StackState.ts"; import { toPersistedIdentity } from "../state/StackState.ts"; -import { makeStackStateStore, type StackStateStore } from "../state/StackStateStore.ts"; +import { + isMissingStateRemnantError, + makeStackStateStore, + type StackStateStore, +} from "../state/StackStateStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { StackIdSchema, type StackId } from "./StackId.ts"; import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; @@ -453,9 +457,7 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff ); }; const logsStateError = (error: StackError): StackLogsError => - error instanceof StackNotFoundError || - error instanceof StackOwnershipConflictError || - error instanceof StackStateInvalidError + isNarrowError(error, STACK_LOGS_ERROR_TAGS) ? error : new StackStateInvalidError({ message: error.message, cause: error }); const stopOwner = (owner: ReturnType) => @@ -963,7 +965,9 @@ export const listStacks = ( const result: StackDescriptor[] = []; for (const entry of entries) { if (!Schema.is(StackIdSchema)(entry)) continue; - const state = yield* store.read(entry); + const state = yield* store + .read(entry) + .pipe(Effect.catchIf(isMissingStateRemnantError, () => Effect.void)); if ( state !== undefined && (projectRoot === undefined || state.identity.projectRoot === projectRoot) diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index ab83fe9ee4..df78d55499 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -1,89 +1,142 @@ import { Data, Predicate } from "effect"; +import type { StackId } from "./StackId.ts"; +import type { ContainerEngineKind } from "../runtime/ContainerEngine.ts"; -/** - * Error context is intentionally open while operation-specific fields settle - * with the runtime modules. The `_tag` on each class is the stable public - * discriminator used by Effect.catchTag and Promise rejection handlers. - */ -export type StackErrorFields = Readonly>; +/** Common context present on every public stack error. */ +interface ErrorFields { + readonly message: string; + readonly cause?: unknown; +} + +export interface IdentityErrorFields extends ErrorFields { + readonly path?: string; + readonly reason?: string; + readonly projectRoot?: string; + readonly stackId?: StackId; + readonly name?: string; +} + +export interface ProjectRootErrorFields extends ErrorFields { + readonly projectRoot?: string; + readonly stateRoot?: string; +} + +export interface ConfigErrorFields extends ErrorFields { + readonly stackId?: StackId; + readonly capability?: string; + readonly dependency?: string; + readonly version?: string; + readonly workload?: string; + readonly functionsRoot?: string; + readonly setting?: string; + readonly function?: string; + readonly environment?: string; + readonly provider?: string; + readonly target?: string; +} export class InvalidStackIdentityError extends Data.TaggedError( "InvalidStackIdentityError", -) {} +) {} export class InvalidProjectRootError extends Data.TaggedError( "InvalidProjectRootError", -) {} +) {} export class InvalidStackConfigError extends Data.TaggedError( "InvalidStackConfigError", -) {} +) {} export class StackVersionUnsupportedError extends Data.TaggedError( "StackVersionUnsupportedError", -) {} +) {} -export class StackNotFoundError extends Data.TaggedError("StackNotFoundError") {} -export class StackOwnershipConflictError extends Data.TaggedError( - "StackOwnershipConflictError", -) {} +export class StackNotFoundError extends Data.TaggedError("StackNotFoundError")< + ErrorFields & { readonly stackId?: StackId } +> {} +export class StackOwnershipConflictError extends Data.TaggedError("StackOwnershipConflictError")< + ErrorFields & { readonly stackId?: StackId } +> {} export class StackRuntimeMismatchError extends Data.TaggedError( "StackRuntimeMismatchError", -) {} +) {} -export class StackNotRunningError extends Data.TaggedError( - "StackNotRunningError", -) {} -export class StackMustBeStoppedError extends Data.TaggedError( - "StackMustBeStoppedError", -) {} -export class StackLifecycleConflictError extends Data.TaggedError( - "StackLifecycleConflictError", -) {} +export class StackNotRunningError extends Data.TaggedError("StackNotRunningError")< + ErrorFields & { readonly stackId?: StackId } +> {} +export class StackMustBeStoppedError extends Data.TaggedError("StackMustBeStoppedError")< + ErrorFields & { readonly slot?: string; readonly stackId?: StackId; readonly guidance?: string } +> {} +export class StackLifecycleConflictError extends Data.TaggedError("StackLifecycleConflictError")< + ErrorFields & { readonly stackId?: StackId } +> {} -export class StackStateInvalidError extends Data.TaggedError( - "StackStateInvalidError", -) {} -export class InvalidLogCursorError extends Data.TaggedError( - "InvalidLogCursorError", -) {} +export class StackStateInvalidError extends Data.TaggedError("StackStateInvalidError")< + ErrorFields & { + readonly stackId?: StackId; + readonly path?: string; + readonly code?: string; + readonly slot?: string; + } +> {} +export class InvalidLogCursorError extends Data.TaggedError("InvalidLogCursorError") {} export class StackStateFormatUnsupportedError extends Data.TaggedError( "StackStateFormatUnsupportedError", -) {} -export class StackUpgradeRequiredError extends Data.TaggedError( - "StackUpgradeRequiredError", -) {} -export class StackSecretMismatchError extends Data.TaggedError( - "StackSecretMismatchError", -) {} +) {} +export class StackUpgradeRequiredError extends Data.TaggedError("StackUpgradeRequiredError")< + ErrorFields & { readonly expectedRelease?: string; readonly actualRelease?: string } +> {} +export class StackSecretMismatchError extends Data.TaggedError("StackSecretMismatchError")< + ErrorFields & { readonly slot?: string } +> {} export class InvalidJwtSigningMaterialError extends Data.TaggedError( "InvalidJwtSigningMaterialError", -) {} +) {} -export class PortAllocationError extends Data.TaggedError( - "PortAllocationError", -) {} -export class PortUnavailableError extends Data.TaggedError( - "PortUnavailableError", -) {} +export class PortAllocationError extends Data.TaggedError("PortAllocationError")< + ErrorFields & { readonly port?: number; readonly field?: string } +> {} +export class PortUnavailableError extends Data.TaggedError("PortUnavailableError")< + ErrorFields & { + readonly port?: number; + readonly field?: string; + readonly address?: string; + } +> {} -export class GatewayActivationError extends Data.TaggedError( - "GatewayActivationError", -) {} +export class GatewayActivationError extends Data.TaggedError("GatewayActivationError")< + ErrorFields & { readonly capability?: string; readonly workload?: string } +> {} -export class StackPreparationError extends Data.TaggedError( - "StackPreparationError", -) {} -export class ArtifactIntegrityError extends Data.TaggedError( - "ArtifactIntegrityError", -) {} -export class ContainerPullError extends Data.TaggedError("ContainerPullError") {} +export class StackPreparationError extends Data.TaggedError("StackPreparationError")< + ConfigErrorFields & { + readonly path?: string; + readonly field?: string; + readonly value?: unknown; + readonly service?: string; + readonly platform?: string; + readonly image?: string; + readonly key?: string; + readonly name?: string; + } +> {} +export class ArtifactIntegrityError extends Data.TaggedError("ArtifactIntegrityError")< + ErrorFields & { + readonly expected?: string; + readonly actual?: string; + readonly path?: string; + readonly key?: string; + } +> {} +export class ContainerPullError extends Data.TaggedError("ContainerPullError")< + ErrorFields & { readonly workload?: string; readonly image?: string } +> {} -export class StackRuntimeError extends Data.TaggedError("StackRuntimeError") {} -export class StackCleanupError extends Data.TaggedError("StackCleanupError") {} -export class ContainerEngineError extends Data.TaggedError( - "ContainerEngineError", -) {} -export class StackDestructionError extends Data.TaggedError( - "StackDestructionError", -) {} +export class StackRuntimeError extends Data.TaggedError("StackRuntimeError")< + ErrorFields & { readonly stackId?: StackId; readonly workloadId?: string } +> {} +export class StackCleanupError extends Data.TaggedError("StackCleanupError") {} +export class ContainerEngineError extends Data.TaggedError("ContainerEngineError")< + ErrorFields & { readonly engine?: ContainerEngineKind } +> {} +export class StackDestructionError extends Data.TaggedError("StackDestructionError") {} /** Stable wire tags for errors produced by the managed stack runtime. */ export const STACK_ERROR_TAGS = [ diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index d1b7682dd7..755e5d4e0d 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -58,6 +58,7 @@ import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import { createStack, inspectStack, + listStacks, makeHandle, openStack, type EffectStack, @@ -596,6 +597,25 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("preserves an allowed error tag when reading offline logs", () => + Effect.gen(function* () { + const upgrade = new StackUpgradeRequiredError({ + message: "The retained log protocol is newer than this client", + expectedRelease: STACK_RPC_RELEASE, + actualRelease: "future", + }); + const stack = yield* makeTestHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.none()), + readPersistedState: Effect.succeed(Option.some(stoppedState())), + readLogs: () => Effect.fail(upgrade), + }); + const result = yield* stack.logs().pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) + expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBe(upgrade); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live( "guards offline operations against real lock-only and metadata ownership", () => @@ -760,6 +780,88 @@ describe("Effect stack lifecycle handoff", () => { ), ); + it.live("does not leave a runtime remnant when a retained handle starts after destroy", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + const stack = yield* createStack({ projectRoot: project }); + yield* stack.destroy(); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); + + const failed = yield* stack.start().pipe(Effect.exit); + + expect(Exit.isFailure(failed)).toBe(true); + expect(yield* fs.exists(paths.stackRoot)).toBe(false); + expect(yield* listStacks()).toEqual([]); + }), + ), + ); + + it.live("ignores a runtime-only missing-state sibling during list discovery", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + const valid = yield* createStack({ projectRoot: project }); + const orphanId = StackIdSchema.make("a".repeat(64)); + const orphanPaths = yield* resolveStackPaths({ + stateRoot: env.stateRoot, + stackId: orphanId, + }); + yield* fs.makeDirectory(orphanPaths.runtime, { recursive: true }); + + const listed = yield* listStacks(); + + expect(listed).toHaveLength(1); + expect(listed[0]?.id).toBe(valid.id); + }), + ), + ); + + it.live("recovers a same-identity runtime-only missing-state remnant during create", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + const identity = yield* resolveStackIdentity({ projectRoot: project }); + const id = yield* deriveStackId(identity); + const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: id }); + yield* fs.makeDirectory(paths.runtime, { recursive: true }); + + const recovered = yield* createStack({ projectRoot: project }); + + expect(recovered.id).toBe(id); + expect(yield* fs.exists(paths.stateDocument)).toBe(true); + }), + ), + ); + + it.live("keeps durable missing-state remnants fail-closed during list discovery", () => + withRuntimeRoot((project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const env = yield* StackRuntimeEnvironment; + yield* createStack({ projectRoot: project }); + const orphanId = StackIdSchema.make("b".repeat(64)); + const orphanPaths = yield* resolveStackPaths({ + stateRoot: env.stateRoot, + stackId: orphanId, + }); + yield* fs.makeDirectory(orphanPaths.data, { recursive: true }); + + const listed = yield* listStacks().pipe(Effect.exit); + + expect(Exit.isFailure(listed)).toBe(true); + expect( + Exit.isFailure(listed) + ? Option.getOrUndefined(Cause.findErrorOption(listed.cause)) + : undefined, + ).toBeInstanceOf(StackStateInvalidError); + }), + ), + ); + it.live("treats an omitted container engine as Docker for runtime identity", () => withRuntimeRoot((project) => Effect.gen(function* () { diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index b7e3917198..1a8d60b321 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -307,8 +307,11 @@ const expectEndpointsRefused = async (endpoints: ReadonlyArray): } }; -const expectRuntimeInputsAbsent = async (projectRoot: string, stackId: string): Promise => { - const stackRoot = join(projectRoot, ".supabase", "managed", "stacks", stackId); +const expectRuntimeInputsAbsent = async ( + stack: Pick, +): Promise => { + const stackRoot = join(stack.stateRoot, stack.id); + await access(stackRoot); for (const relativePath of ["runtime/env", "runtime/inputs", "runtime/functions"]) { const path = join(stackRoot, relativePath); try { @@ -975,7 +978,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom await expectOwnedWorkloads(mode, stack.id, []); const retainedLogs: StackLogEntry[] = (await stack.logs()).entries.slice(); expect(retainedLogs.length).toBeGreaterThan(0); - await expectRuntimeInputsAbsent(projectRoot, initial.id); + await expectRuntimeInputsAbsent(stack); await expectEndpointsRefused(initialEndpoints); if (mode.runtime.kind === "container") { expect(await dockerOwnedResourceCount(initial.id, "containers")).toBe(0); @@ -1385,7 +1388,7 @@ describe("managed Supabase stack whole-stack E2E", () => { expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); expect(await supervisorPids(stack.id)).toHaveLength(0); await expectOwnedWorkloads(mode, stack.id, []); - await expectRuntimeInputsAbsent(projectRoot, stack.id); + await expectRuntimeInputsAbsent(stack); await expectEndpointsRefused(endpointSnapshot); if (mode.runtime.kind === "container") { if (stackId === undefined) throw new Error("Stack id was not assigned"); diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index c0eb2dd705..368481b6fe 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -54,11 +54,20 @@ export interface NativeProcess { readonly kill: Effect.Effect; } -const launcherPath = fileURLToPath(new URL("./native-launcher.ts", import.meta.url)); +/** Private argv marker used when a compiled CLI dispatches its embedded native launcher. */ +export const NATIVE_PROCESS_DISPATCH_SENTINEL = "__supabase_stack_native__" as const; + +const isBunVirtualPath = (value: string): boolean => /(?:^|[\\/])\$bunfs(?:[\\/]|$)/.test(value); + +export const nativeLauncherEntrypointFor = (moduleUrl: string): string => { + if (isBunVirtualPath(moduleUrl)) return NATIVE_PROCESS_DISPATCH_SENTINEL; + const sourceEntrypoint = fileURLToPath(new URL("./native-launcher.ts", moduleUrl)); + return isBunVirtualPath(sourceEntrypoint) ? NATIVE_PROCESS_DISPATCH_SENTINEL : sourceEntrypoint; +}; export const defaultNativeProcessLauncher = (): NativeProcessLauncher => ({ command: process.execPath, - args: [launcherPath], + args: [nativeLauncherEntrypointFor(import.meta.url)], }); const encodeSpec = (spec: NativeProcessSpec): Uint8Array => diff --git a/packages/stack/src/runtime/ReadinessProbe.ts b/packages/stack/src/runtime/ReadinessProbe.ts index 4460b24a4c..cfde523cb2 100644 --- a/packages/stack/src/runtime/ReadinessProbe.ts +++ b/packages/stack/src/runtime/ReadinessProbe.ts @@ -36,6 +36,7 @@ const runtimeError = ( ): RuntimeDriverError => new RuntimeDriverError({ message, + target, cause, }); diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index 3c49a1bc42..f3c88264c6 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -1,6 +1,7 @@ import { Data, Effect } from "effect"; import type { StackId } from "../public/StackId.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; +import type { ReadinessTarget } from "./ReadinessProbe.ts"; /** The exact identity used when touching a private runtime resource. */ export interface RuntimeWorkloadKey { @@ -50,5 +51,7 @@ export class RuntimeDriverError extends Data.TaggedError("RuntimeDriverError")<{ readonly message: string; readonly stackId?: StackId; readonly workloadId?: string; + /** Private endpoint used by readiness failures, when applicable. */ + readonly target?: ReadinessTarget; readonly cause?: unknown; }> {} diff --git a/packages/stack/src/runtime/native-launcher.ts b/packages/stack/src/runtime/native-launcher.ts index 322db97ad0..e144b34c91 100644 --- a/packages/stack/src/runtime/native-launcher.ts +++ b/packages/stack/src/runtime/native-launcher.ts @@ -51,11 +51,6 @@ const decodeSpec = (bytes: Buffer): LaunchSpec | undefined => { } }; -let child: ReturnType | undefined; -let gracefulForwarded = false; -let groupTerminated = false; -let childExited = false; - const inheritedEnvironmentNames = [ "PATH", "HOME", @@ -81,88 +76,106 @@ const inheritedEnvironment = (): NodeJS.ProcessEnv => { return environment; }; -const terminateGroup = (signal: NodeJS.Signals): void => { - if (groupTerminated) return; - groupTerminated = true; - if (process.platform === "win32") { - spawn("taskkill", ["/pid", String(process.pid), "/T", "/F"], { stdio: "ignore" }); - return; - } - try { - // The launcher is the detached process-group leader, so this targets only - // the process tree created for this workload. - process.kill(-process.pid, signal); - } catch { - child?.kill(signal); - } -}; +/** + * Runs the standalone native launcher entrypoint. + * + * Keeping this work behind an explicit function lets a compiled CLI dispatch + * the launcher from its embedded module graph. Direct source execution still + * enters through the `import.meta.main` guard below. + */ +export const runNativeLauncher = (): void => { + let child: ReturnType | undefined; + let gracefulForwarded = false; + let groupTerminated = false; + let childExited = false; -// The supervisor normally signals the launcher process group, but forwarding -// graceful signals to the workload keeps the launcher alive long enough to -// observe the workload's exit and reap it. SIGKILL remains handled by the -// owner-pipe path and cannot be intercepted here. -const forwardSignal = (signal: NodeJS.Signals): void => { - if (groupTerminated || gracefulForwarded) return; - if (child === undefined) { - terminateGroup(signal); - return; - } - gracefulForwarded = true; + const terminateGroup = (signal: NodeJS.Signals): void => { + if (groupTerminated) return; + groupTerminated = true; + if (process.platform === "win32") { + spawn("taskkill", ["/pid", String(process.pid), "/T", "/F"], { stdio: "ignore" }); + return; + } + try { + // The launcher is the detached process-group leader, so this targets only + // the process tree created for this workload. + process.kill(-process.pid, signal); + } catch { + child?.kill(signal); + } + }; + + // The supervisor normally signals the launcher process group, but forwarding + // graceful signals to the workload keeps the launcher alive long enough to + // observe the workload's exit and reap it. SIGKILL remains handled by the + // owner-pipe path and cannot be intercepted here. + const forwardSignal = (signal: NodeJS.Signals): void => { + if (groupTerminated || gracefulForwarded) return; + if (child === undefined) { + terminateGroup(signal); + return; + } + gracefulForwarded = true; + try { + child.kill(signal); + } catch { + terminateGroup(signal); + } + }; + process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.on("SIGINT", () => forwardSignal("SIGINT")); + + // Register the owner pipe before waiting for the launch payload. EOF means + // the owner process disappeared without running a normal scope finalizer. + // Bun does not reliably surface EOF for a net.Socket created from this + // descriptor, while Node's filesystem stream can hold a libuv worker open + // after its owner exits. Select the descriptor adapter for the host runtime. + const ownerPipe = + process.versions.bun === undefined + ? new Socket({ fd: 3, readable: true, writable: false }) + : createReadStream(process.platform === "win32" ? "NUL" : "/dev/null", { + fd: 3, + autoClose: false, + }); + // Owner-loss is an abrupt supervisor crash, not an explicit graceful stop. + // Use guaranteed tree termination so descendants that trap SIGTERM cannot be + // orphaned after the owner pipe closes. + ownerPipe.on("end", () => terminateGroup("SIGKILL")); + ownerPipe.on("error", () => terminateGroup("SIGKILL")); + ownerPipe.on("close", () => { + if (!childExited) terminateGroup("SIGKILL"); + }); + ownerPipe.resume(); + + let payload: Buffer; try { - child.kill(signal); + // The parent writes a finite JSON payload and closes fd4. A synchronous + // read avoids a Bun pipe-read stream that can fail to deliver `end` after + // the parent closes the sink, while the owner stream remains registered for + // loss detection once the workload is running. + payload = readFileSync(4); } catch { - terminateGroup(signal); + process.exit(127); + } + const spec = decodeSpec(payload); + if (spec === undefined || groupTerminated) { + process.exit(127); + } else { + child = spawn(spec.executable, [...spec.args], { + cwd: spec.cwd, + env: { ...inheritedEnvironment(), ...spec.env }, + detached: false, + stdio: ["ignore", "inherit", "inherit"], + }); + child.on("error", () => process.exit(127)); + child.on("exit", (code) => { + childExited = true; + ownerPipe.destroy(); + process.exit(code ?? 1); + }); } }; -process.on("SIGTERM", () => forwardSignal("SIGTERM")); -process.on("SIGINT", () => forwardSignal("SIGINT")); - -// Register the owner pipe before waiting for the launch payload. EOF means -// the owner process disappeared without running a normal scope finalizer. -// Bun does not reliably surface EOF for a net.Socket created from this -// descriptor, while Node's filesystem stream can hold a libuv worker open -// after its owner exits. Select the descriptor adapter for the host runtime. -const ownerPipe = - process.versions.bun === undefined - ? new Socket({ fd: 3, readable: true, writable: false }) - : createReadStream(process.platform === "win32" ? "NUL" : "/dev/null", { - fd: 3, - autoClose: false, - }); -// Owner-loss is an abrupt supervisor crash, not an explicit graceful stop. -// Use guaranteed tree termination so descendants that trap SIGTERM cannot be -// orphaned after the owner pipe closes. -ownerPipe.on("end", () => terminateGroup("SIGKILL")); -ownerPipe.on("error", () => terminateGroup("SIGKILL")); -ownerPipe.on("close", () => { - if (!childExited) terminateGroup("SIGKILL"); -}); -ownerPipe.resume(); -let payload: Buffer; -try { - // The parent writes a finite JSON payload and closes fd4. A synchronous - // read avoids a Bun pipe-read stream that can fail to deliver `end` after - // the parent closes the sink, while the owner stream remains registered for - // loss detection once the workload is running. - payload = readFileSync(4); -} catch { - process.exit(127); -} -const spec = decodeSpec(payload); -if (spec === undefined || groupTerminated) { - process.exit(127); -} else { - child = spawn(spec.executable, [...spec.args], { - cwd: spec.cwd, - env: { ...inheritedEnvironment(), ...spec.env }, - detached: false, - stdio: ["ignore", "inherit", "inherit"], - }); - child.on("error", () => process.exit(127)); - child.on("exit", (code) => { - childExited = true; - ownerPipe.destroy(); - process.exit(code ?? 1); - }); +if (import.meta.main) { + runNativeLauncher(); } diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index af19580c1c..f62dca14f3 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -14,6 +14,8 @@ import { RuntimeDriverError } from "./RuntimeDriver.ts"; import { makeNativeRuntime } from "./NativeRuntime.ts"; import { defaultNativeProcessLauncher, + nativeLauncherEntrypointFor, + NATIVE_PROCESS_DISPATCH_SENTINEL, spawnNativeProcess, type NativeProcess, } from "./NativeProcess.ts"; @@ -83,6 +85,15 @@ const signalOnLog = ( // this suite so file-level parallelism does not turn the default 5s guard into // a false failure while leaving the rest of the integration suite unchanged. describe("native runtime", { timeout: 15_000 }, () => { + it("uses a private dispatch marker for compiled Bun launcher paths", () => { + expect( + nativeLauncherEntrypointFor("file:///$bunfs/packages/stack/src/runtime/NativeProcess.ts"), + ).toBe(NATIVE_PROCESS_DISPATCH_SENTINEL); + expect( + nativeLauncherEntrypointFor("C:\\$bunfs\\packages\\stack\\src\\runtime\\NativeProcess.ts"), + ).toBe(NATIVE_PROCESS_DISPATCH_SENTINEL); + }); + it.live("rejects container artifacts before invoking native process resolution", () => withPlatform( Effect.gen(function* () { @@ -476,7 +487,7 @@ describe("native runtime", { timeout: 15_000 }, () => { command: process.execPath, args: [ "-e", - `process.env.PGPASSWORD="host-secret"; await import(${JSON.stringify(launcherPath)})`, + `process.env.PGPASSWORD="host-secret"; const { runNativeLauncher } = await import(${JSON.stringify(launcherPath)}); runNativeLauncher()`, ], }; const runtime = yield* makeNativeRuntime({ diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index aff1d93dbc..9775264cb2 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -97,6 +97,7 @@ const stateStoreFor = ( replace: () => Effect.die("unused"), replaceUnlocked: () => Effect.die("unused"), cleanup: () => Effect.die("unused"), + recoverRuntimeRemnant: () => Effect.die("unused"), }); const mutableStateStoreFor = (current: { @@ -116,6 +117,7 @@ const mutableStateStoreFor = (current: { Effect.sync(() => { current.value = undefined; }), + recoverRuntimeRemnant: () => Effect.void, }); const memoryLogStore = (entries: StackLogEntry[]): LogStore => ({ diff --git a/packages/stack/src/runtime/readiness-probe.integration.test.ts b/packages/stack/src/runtime/readiness-probe.integration.test.ts index 1cdd9439b0..40ff9e6ad8 100644 --- a/packages/stack/src/runtime/readiness-probe.integration.test.ts +++ b/packages/stack/src/runtime/readiness-probe.integration.test.ts @@ -200,6 +200,9 @@ describe("private endpoint readiness probe", () => { if (Exit.isFailure(result)) { const cause = Option.getOrUndefined(Cause.findErrorOption(result.cause)); expect(cause).toMatchObject({ message: "Readiness HTTP status was not successful" }); + expect(cause).toMatchObject({ + target: { mode: "http", host: "127.0.0.1", port }, + }); } expect(count).toBe(1); }), diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index fb592c64c1..3dc707d49e 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -16,7 +16,7 @@ import type { PersistedStackState, PrivatePortAssignment, } from "./StackState.ts"; -import type { StackStateStore } from "./StackStateStore.ts"; +import { isMissingStateRemnantError, type StackStateStore } from "./StackStateStore.ts"; import { withRegistryLock } from "./StackStateStore.ts"; interface ListenerIntent { @@ -133,9 +133,10 @@ const readAuthoritativeStates = (options: PortCoordinatorOptions) => .pipe(Effect.mapError((error) => new StackStateInvalidError({ message: error.message }))); const ids = entries.filter((entry) => idPattern.test(entry)); const values = yield* Effect.forEach(ids, (stackId) => - options.store - .read(stackId) - .pipe(Effect.map((state) => (state === undefined ? undefined : { stackId, state }))), + options.store.read(stackId).pipe( + Effect.catchIf(isMissingStateRemnantError, () => Effect.void), + Effect.map((state) => (state === undefined ? undefined : { stackId, state })), + ), ); return values.filter( (entry): entry is { readonly stackId: string; readonly state: PersistedStackState } => diff --git a/packages/stack/src/state/StackStateStore.ts b/packages/stack/src/state/StackStateStore.ts index 6a5996364f..76ed5942a1 100644 --- a/packages/stack/src/state/StackStateStore.ts +++ b/packages/stack/src/state/StackStateStore.ts @@ -1,4 +1,17 @@ -import { Crypto, Data, Effect, Exit, FileSystem, Path, Predicate, Schedule, Schema } from "effect"; +import { + Crypto, + Data, + Effect, + Exit, + FileSystem, + Path, + PlatformError, + Predicate, + Schedule, + Schema, +} from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- native rmdir preserves non-recursive recovery semantics. +import { rmdir } from "node:fs/promises"; import { InvalidProjectRootError, StackStateFormatUnsupportedError, @@ -68,6 +81,14 @@ export interface StackStateStore { InvalidProjectRootError | StackStateInvalidError, FileSystem.FileSystem | Path.Path >; + /** Removes only an empty runtime-only remnant and its now-empty identity root. */ + readonly recoverRuntimeRemnant: ( + stackId: string, + ) => Effect.Effect< + void, + InvalidProjectRootError | StackStateInvalidError, + FileSystem.FileSystem | Path.Path + >; } const isRecord = (value: unknown): value is Readonly> => @@ -76,6 +97,19 @@ const isRecord = (value: unknown): value is Readonly> => const stateError = (message: string, cause?: unknown) => new StackStateInvalidError({ message, ...(cause === undefined ? {} : { cause }) }); +const isNotFoundPlatformError = (error: unknown): error is PlatformError.PlatformError => + error instanceof PlatformError.PlatformError && + error.reason instanceof PlatformError.SystemError && + Predicate.isTagged(error.reason, "NotFound"); + +const MISSING_STATE_REMNANT_CODE = "missing-state-remnant" as const; +const MISSING_STATE_REMNANT_MESSAGE = + "Stack state is missing beside identity-owned remnants; refusing to guess replacement state"; + +/** Identifies an empty identity directory left by an interrupted owner launch. */ +export const isMissingStateRemnantError = (error: unknown): error is StackStateInvalidError => + error instanceof StackStateInvalidError && error.code === MISSING_STATE_REMNANT_CODE; + const validateId = (stackId: string) => Schema.decodeEffect(StackIdSchema)(stackId).pipe( Effect.mapError((error) => stateError(`Invalid StackId: ${String(error)}`)), @@ -344,61 +378,85 @@ export const makeStackStateStore = (options: { ); }); - const read = ( - stackId: string, - ): Effect.Effect< - PersistedStackState | undefined, - InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto - > => + const inspectMissingState = ( + paths: StackPaths, + ): Effect.Effect => Effect.gen(function* () { - const paths = yield* pathsFor(stackId); - const exists = yield* fs - .exists(paths.stateDocument) + const rootExists = yield* fs + .exists(paths.stackRoot) .pipe( Effect.mapError((error) => - stateError(`Unable to inspect state document: ${error.message}`), + stateError(`Unable to inspect stack root: ${error.message}`), ), ); - if (!exists) { - const rootExists = yield* fs - .exists(paths.stackRoot) - .pipe( - Effect.mapError((error) => - stateError(`Unable to inspect stack root: ${error.message}`), + if (!rootExists) return undefined; + const entries = yield* fs.readDirectory(paths.stackRoot).pipe( + Effect.catchIf( + (error) => isNotFoundPlatformError(error), + () => Effect.void, + ), + Effect.mapError((error) => + stateError(`Unable to inspect stack remnants: ${error.message}`), + ), + ); + // The stack root may disappear after exists() while a concurrent destroy finishes. + if (entries === undefined) return undefined; + if (entries.length > 0) { + if (entries.length === 1 && entries[0] === "runtime") { + const runtimeInfo = yield* fs.stat(paths.runtime).pipe( + Effect.catchIf( + (error) => isNotFoundPlatformError(error), + () => Effect.void, ), - ); - if (!rootExists) return undefined; - const entries = yield* fs - .readDirectory(paths.stackRoot) - .pipe( Effect.mapError((error) => - stateError(`Unable to inspect stack remnants: ${error.message}`), + stateError(`Unable to inspect runtime remnant: ${error.message}`), ), ); - if (entries.length > 0) { - return yield* stateError( - "Stack state is missing beside identity-owned remnants; refusing to guess replacement state", - ); + if (runtimeInfo === undefined) return undefined; + if (runtimeInfo.type === "Directory") { + const runtimeEntries = yield* fs.readDirectory(paths.runtime).pipe( + Effect.catchIf( + (error) => isNotFoundPlatformError(error), + () => Effect.void, + ), + Effect.mapError((error) => + stateError(`Unable to inspect runtime remnant: ${error.message}`), + ), + ); + if (runtimeEntries === undefined) return undefined; + if (runtimeEntries.length === 0) + return yield* new StackStateInvalidError({ + message: MISSING_STATE_REMNANT_MESSAGE, + code: MISSING_STATE_REMNANT_CODE, + }); + } } - return undefined; + return yield* stateError(MISSING_STATE_REMNANT_MESSAGE); } - const info = yield* fs - .stat(paths.stateDocument) - .pipe( - Effect.mapError((error) => - stateError(`Unable to inspect state document: ${error.message}`), - ), - ); - if (info.type !== "File") - return yield* stateError("Stack state document is not a regular file"); - const text = yield* fs - .readFileString(paths.stateDocument) - .pipe( - Effect.mapError((error) => - stateError(`Unable to read state document: ${error.message}`), - ), - ); + return undefined; + }); + + const read = ( + stackId: string, + ): Effect.Effect< + PersistedStackState | undefined, + InvalidProjectRootError | StackStateInvalidError | StackStateFormatUnsupportedError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto + > => + Effect.gen(function* () { + const paths = yield* pathsFor(stackId); + const stateRead = yield* fs.readFileString(paths.stateDocument).pipe( + Effect.map((text) => ({ _tag: "present" as const, text })), + Effect.catch((error) => + isNotFoundPlatformError(error) + ? inspectMissingState(paths).pipe( + Effect.map((state) => ({ _tag: "missing" as const, state })), + ) + : Effect.fail(stateError(`Unable to read state document: ${String(error)}`)), + ), + ); + if (stateRead._tag === "missing") return stateRead.state; + const text = stateRead.text; const raw = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( Effect.mapError((error) => stateError(`Unable to parse state document: ${String(error)}`), @@ -420,7 +478,11 @@ export const makeStackStateStore = (options: { withRegistryLock( options.stateRoot, Effect.gen(function* () { - const existing = yield* read(stackId); + const existing = yield* read(stackId).pipe( + Effect.catchIf(isMissingStateRemnantError, () => + recoverRuntimeRemnantUnlocked(stackId).pipe(Effect.asVoid), + ), + ); if (existing !== undefined) return existing; const paths = yield* pathsFor(stackId); yield* validateState(stackId, candidate); @@ -455,6 +517,95 @@ export const makeStackStateStore = (options: { FileSystem.FileSystem | Path.Path | Crypto.Crypto > => withRegistryLock(options.stateRoot, replaceUnlocked(stackId, next)); + // FileSystem.remove({ recursive: false }) maps to fs.rm, which refuses to + // remove directories on Node. Native rmdir is intentionally used here so + // a concurrent child creation fails with ENOTEMPTY instead of recursively + // deleting a newly-created lease or control file. + const removeEmptyDirectory = (directory: string, label: string) => + Effect.tryPromise({ + try: () => rmdir(directory), + catch: (error) => + stateError( + `Unable to remove empty ${label} during recovery: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + }); + + const recoverRuntimeRemnantUnlocked = ( + stackId: string, + ): Effect.Effect< + void, + InvalidProjectRootError | StackStateInvalidError, + FileSystem.FileSystem | Path.Path + > => + Effect.gen(function* () { + const paths = yield* pathsFor(stackId); + const stateExists = yield* fs + .exists(paths.stateDocument) + .pipe( + Effect.mapError((error) => + stateError( + `Unable to inspect state document during remnant recovery: ${error.message}`, + ), + ), + ); + if (stateExists) + return yield* stateError("Cannot recover a runtime remnant beside persisted state"); + + const rootExists = yield* fs + .exists(paths.stackRoot) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect stack root during remnant recovery: ${error.message}`), + ), + ); + if (!rootExists) return; + const entries = yield* fs + .readDirectory(paths.stackRoot) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect stack remnants during recovery: ${error.message}`), + ), + ); + if (entries.length === 0) { + yield* removeEmptyDirectory(paths.stackRoot, "stack root"); + return; + } + if (entries.length !== 1 || entries[0] !== "runtime") + return yield* stateError("Stack state is missing beside durable identity remnants"); + + const runtimeInfo = yield* fs + .stat(paths.runtime) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect runtime remnant during recovery: ${error.message}`), + ), + ); + if (runtimeInfo.type !== "Directory") + return yield* stateError("Runtime remnant is not a directory"); + const runtimeEntries = yield* fs + .readDirectory(paths.runtime) + .pipe( + Effect.mapError((error) => + stateError(`Unable to inspect runtime remnant during recovery: ${error.message}`), + ), + ); + if (runtimeEntries.length > 0) + return yield* stateError("Runtime remnant is not empty; refusing recovery"); + + yield* removeEmptyDirectory(paths.runtime, "runtime remnant"); + yield* removeEmptyDirectory(paths.stackRoot, "stack root"); + }); + + const recoverRuntimeRemnant = ( + stackId: string, + ): Effect.Effect< + void, + InvalidProjectRootError | StackStateInvalidError, + FileSystem.FileSystem | Path.Path + > => withRegistryLock(options.stateRoot, recoverRuntimeRemnantUnlocked(stackId)); + const cleanup = ( stackId: string, ): Effect.Effect< @@ -476,7 +627,14 @@ export const makeStackStateStore = (options: { }), ); - return { read, initialize, replace, replaceUnlocked, cleanup } satisfies StackStateStore; + return { + read, + initialize, + replace, + replaceUnlocked, + cleanup, + recoverRuntimeRemnant, + } satisfies StackStateStore; }); export { PersistedStackStateSchema } from "./StackState.ts"; diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 6711e7cb91..0560fae8bd 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -5,9 +5,11 @@ import { createServer, type Server } from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer as createHttpServer } from "node:http"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; +import { StackIdSchema } from "../public/StackId.ts"; import { PortAllocationError, PortUnavailableError } from "../public/Errors.ts"; import { makePortCoordinator, type HostListener, type ListenerIntents } from "./PortCoordinator.ts"; import { makeStackStateStore, type PersistedStackState } from "./StackStateStore.ts"; +import { resolveStackPaths } from "./Paths.ts"; import { bindHostListener } from "../supervisor/HostListener.ts"; const layer = NodeServices.layer; @@ -168,6 +170,35 @@ describe("sticky port coordination", () => { ), ); + it.live("plans ports despite a runtime-only missing-state sibling", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-port-remnant-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const validIdentity = makeIdentity(root, "valid"); + const validId = yield* deriveStackId(validIdentity); + yield* store.initialize(validId, baseState(validId, validIdentity)); + const orphanId = StackIdSchema.make("a".repeat(64)); + const orphanPaths = yield* resolveStackPaths({ + stateRoot: root, + stackId: orphanId, + }); + yield* fs.makeDirectory(orphanPaths.runtime, { recursive: true }); + const coordinator = makePortCoordinator({ + stateRoot: root, + store, + checkHostPort: successfulHostPortCheck, + bindHost: makeTestHostListener, + }); + + const reservation = yield* coordinator.planAndReserve(validId, intents("automatic")); + + expect(reservation.assignments.api?.port).toBeTypeOf("number"); + }), + ), + ); + it.live("skips foreign listeners while selecting fresh automatic ports", () => withPlatform( Effect.gen(function* () { @@ -789,6 +820,7 @@ describe("sticky port coordination", () => { .pipe(Effect.exit); const error = errorOf(result); expect(error).toBeInstanceOf(PortUnavailableError); + if (!(error instanceof PortUnavailableError)) throw new Error("Expected port error"); expect(error?.message).toContain( "Could not bind an automatic public host port for api after 17 candidates", ); diff --git a/packages/stack/src/state/state-store.integration.test.ts b/packages/stack/src/state/state-store.integration.test.ts index a82250b74d..d441239707 100644 --- a/packages/stack/src/state/state-store.integration.test.ts +++ b/packages/stack/src/state/state-store.integration.test.ts @@ -327,6 +327,56 @@ describe("atomic stack state", () => { ), ); + it.live("treats a state document disappearing during read as an absent stack", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-read-race-" }); + const stackId = yield* deriveStackId(identity); + const store = yield* makeStackStateStore({ stateRoot: root }); + yield* store.initialize(stackId, state(stackId)); + const statePath = path.join(root, stackId, "state.json"); + const racingFs: FileSystem.FileSystem = { + ...fs, + readFileString: (candidate, encoding) => + candidate === statePath + ? fs.remove(candidate).pipe(Effect.andThen(fs.readFileString(candidate, encoding))) + : fs.readFileString(candidate, encoding), + }; + const racingStore = yield* makeStackStateStore({ stateRoot: root }).pipe( + Effect.provideService(FileSystem.FileSystem, racingFs), + ); + + expect(yield* racingStore.read(stackId)).toBeUndefined(); + }), + ), + ); + + it.live("does not recover a runtime remnant that contains an owner lock", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-live-remnant-" }); + const store = yield* makeStackStateStore({ stateRoot: root }); + const stackId = yield* deriveStackId(identity); + const runtime = path.join(root, stackId, "runtime"); + const ownerLock = path.join(runtime, "owner.lock"); + yield* fs.makeDirectory(runtime, { recursive: true }); + yield* fs.writeFileString(ownerLock, "live"); + + const readExit = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(readExit)).toBeInstanceOf(StackStateInvalidError); + const recovered = yield* store.recoverRuntimeRemnant(stackId).pipe(Effect.exit); + + expect(errorOf(recovered)).toBeInstanceOf(StackStateInvalidError); + expect(yield* fs.exists(ownerLock)).toBe(true); + expect(yield* fs.exists(runtime)).toBe(true); + }), + ), + ); + it.live("rejects unsupported state formats", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 89367dcf78..365d2eb6fe 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -24,7 +24,7 @@ import { type OwnerMetadata, type StackRuntimeEnvironmentValue, } from "../state/Ownership.ts"; -import type { StackStateStore } from "../state/StackStateStore.ts"; +import { isMissingStateRemnantError, type StackStateStore } from "../state/StackStateStore.ts"; import { makeControlClient } from "../control/ControlServer.ts"; import { isMaintenanceTransportFailure } from "../control/MaintenanceProtocol.ts"; import { STACK_RPC_RELEASE } from "../control/StackRpc.ts"; @@ -45,9 +45,12 @@ export type { StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; /** Private argv marker used when a compiled CLI dispatches its embedded Supervisor. */ export const SUPERVISOR_DISPATCH_SENTINEL = "__supabase_stack_supervisor__" as const; +const isBunVirtualPath = (value: string): boolean => /(?:^|[\\/])\$bunfs(?:[\\/]|$)/.test(value); + export const supervisorEntrypointFor = (moduleUrl: string): string => { + if (isBunVirtualPath(moduleUrl)) return SUPERVISOR_DISPATCH_SENTINEL; const sourceEntrypoint = fileURLToPath(new URL("../entrypoints/supervisor-node.ts", moduleUrl)); - return sourceEntrypoint.includes("/$bunfs/") ? SUPERVISOR_DISPATCH_SENTINEL : sourceEntrypoint; + return isBunVirtualPath(sourceEntrypoint) ? SUPERVISOR_DISPATCH_SENTINEL : sourceEntrypoint; }; /** Default host values are resolved only at the process composition boundary. */ @@ -353,6 +356,15 @@ export const ensureSupervisor = ( | ChildProcessSpawner.ChildProcessSpawner > => Effect.gen(function* () { + const state = yield* options.stateStore.read(options.stackId).pipe( + Effect.catchIf(isMissingStateRemnantError, () => + options.stateStore.recoverRuntimeRemnant(options.stackId).pipe(Effect.asVoid), + ), + Effect.mapError((error) => + mapFailure(error instanceof Error ? error.message : String(error)), + ), + ); + if (state === undefined) return yield* mapFailure("Stack state is missing"); const existing = yield* readOwnerMetadata( options.environment.stateRoot, options.stackId, diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index b26b6ca99f..b9081f2547 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -10,6 +10,10 @@ import { InvalidLogCursorError } from "../public/Errors.ts"; import { redactKnownSecrets } from "../state/SecretStore.ts"; const CURSOR_PREFIX = "v1_"; +/** Keep at most twice the configured retention before compacting the file. */ +const COMPACTION_FACTOR = 2; +const DEFAULT_LOG_MAX_ENTRIES = 1_000; +const DEFAULT_LOG_MAX_BYTES = 1024 * 1024; /** File or decoding failure while opening or writing retained logs. */ export class LogStoreError extends Data.TaggedError("LogStoreError")<{ @@ -41,6 +45,11 @@ interface LogStoreQuery { readonly cursor?: LogCursor; } +export interface RetainedLogReadOptions extends LogStoreQuery { + readonly maxEntries?: number; + readonly maxBytes?: number; +} + /** Cursor returned when a retained log file has no entries to advance to. */ export const EMPTY_LOG_CURSOR: LogCursor = { opaque: "v1_0" }; @@ -71,6 +80,10 @@ export const selectLogBatch = ( export interface LogStore { readonly path: string; + /** + * Appends one record. An individually oversized record is returned to the caller but is not + * retained, and does not advance the cursor or rewrite the retained log file. + */ readonly append: (record: LogRecord) => Effect.Effect; /** Returns retained records after the supplied cursor. */ readonly read: ( @@ -194,11 +207,17 @@ const readDocument = ( export const readRetainedLogs = ( fs: FileSystem.FileSystem, path: string, - options?: LogStoreQuery, + options?: RetainedLogReadOptions, ): Effect.Effect, LogStoreError | InvalidLogCursorError> => - readDocument(fs, path).pipe( - Effect.flatMap((document) => selected(document.entries, options, path)), - ); + Effect.gen(function* () { + const document = yield* readDocument(fs, path); + const maxEntries = validLimit(options?.maxEntries, DEFAULT_LOG_MAX_ENTRIES); + const maxBytes = validLimit(options?.maxBytes, DEFAULT_LOG_MAX_BYTES); + if (maxEntries < 1 || maxBytes < 2) + return yield* fileError(path, "Log retention limits must be positive"); + const boundedDocument = yield* bounded(document, maxEntries, maxBytes, path); + return yield* selected(boundedDocument.document.entries, options, path); + }); const redactDocument = ( document: LogDocument, @@ -294,8 +313,8 @@ export const makeLogStore = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const maxEntries = validLimit(options.maxEntries, 1_000); - const maxBytes = validLimit(options.maxBytes, 1024 * 1024); + const maxEntries = validLimit(options.maxEntries, DEFAULT_LOG_MAX_ENTRIES); + const maxBytes = validLimit(options.maxBytes, DEFAULT_LOG_MAX_BYTES); if (maxEntries < 1 || maxBytes < 1) return yield* fileError(options.path, "Log retention limits must be positive"); @@ -327,6 +346,9 @@ export const makeLogStore = ( ); let document = boundedLoaded.document; let retainedBytes = boundedLoaded.bytes; + // The on-disk document may temporarily overshoot the configured limits between periodic + // compactions. Keep reads strictly bounded so the public retention contract remains stable. + let visibleEntries = boundedLoaded.document.entries; const exists = yield* fs .exists(options.path) .pipe( @@ -362,10 +384,17 @@ export const makeLogStore = ( }; const encoded = yield* encodedEntry(entry, options.path); const entryBytes = new TextEncoder().encode(`${encoded}\n`).byteLength; + if (entryBytes > maxBytes) return entry; const nextBytes = retainedBytes + entryBytes; - if (document.entries.length + 1 <= maxEntries && nextBytes <= maxBytes) { - document = next; - retainedBytes = nextBytes; + const withinCompactionWindow = + document.entries.length + 1 <= maxEntries * COMPACTION_FACTOR && + nextBytes <= maxBytes * COMPACTION_FACTOR; + if (withinCompactionWindow) { + const nextVisible = + next.entries.length <= maxEntries && nextBytes <= maxBytes + ? next.entries + : (yield* bounded(next, maxEntries, maxBytes, options.path, nextBytes)).document + .entries; yield* Effect.scoped( Effect.gen(function* () { const file = yield* fs.open(options.path, { flag: "a", mode: 0o600 }); @@ -378,18 +407,22 @@ export const makeLogStore = ( : fileError(options.path, "Unable to append retained logs", error), ), ); + document = next; + retainedBytes = nextBytes; + visibleEntries = nextVisible; } else { const boundedNext = yield* bounded(next, maxEntries, maxBytes, options.path, nextBytes); + yield* persist(fs, options.path, boundedNext.document.entries); document = boundedNext.document; retainedBytes = boundedNext.bytes; - yield* persist(fs, options.path, document.entries); + visibleEntries = boundedNext.document.entries; } return entry; }), ); const read = (readOptions?: LogStoreQuery) => - semaphore.withPermit(selected(document.entries, readOptions, options.path)); + semaphore.withPermit(selected(visibleEntries, readOptions, options.path)); return { path: options.path, diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index d3a876876a..4246ced506 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -44,7 +44,7 @@ import type { LogQuery, StackLogBatch } from "../public/Logs.ts"; import type { EffectStackCredentials } from "../public/Credentials.ts"; import { RuntimeDriverError, type RuntimeDriver } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import type { StackStateStore } from "../state/StackStateStore.ts"; +import { isMissingStateRemnantError, type StackStateStore } from "../state/StackStateStore.ts"; import { makeSessionLauncher, type SessionLauncher } from "./SessionLauncher.ts"; import { makeLifecycleController, @@ -153,7 +153,13 @@ export const makeSupervisor = ( Effect.gen(function* () { const read = () => options.stateStore.read(options.stackId).pipe(Effect.provideContext(options.context)); - const initial = yield* read(); + const initial = yield* read().pipe( + Effect.catchIf(isMissingStateRemnantError, () => + options.stateStore + .recoverRuntimeRemnant(options.stackId) + .pipe(Effect.provideContext(options.context), Effect.asVoid), + ), + ); if (initial === undefined) return yield* new StackStateInvalidError({ message: "Stack state is missing" }); const runtime = options.runtime; @@ -248,8 +254,8 @@ export const makeSupervisor = ( result: LifecycleResult; }>; const lifecycleActive = yield* Ref.make(undefined); - const ensureActivationAllowed = (): Effect.Effect< - PersistedStackState, + const ensureActivationPhaseAllowed = (): Effect.Effect< + void, GatewayActivationError | StackError > => Effect.gen(function* () { @@ -264,6 +270,12 @@ export const makeSupervisor = ( stackId: options.stackId, message: "Cannot activate while exact cleanup is pending; stop the stack first", }); + }); + const ensureActivationStateAllowed = (): Effect.Effect< + PersistedStackState, + GatewayActivationError | StackError + > => + Effect.gen(function* () { const state = yield* read(); if (state === undefined) return yield* new StackStateInvalidError({ message: "Stack state is missing" }); @@ -273,6 +285,10 @@ export const makeSupervisor = ( }); return state; }); + const ensureActivationAllowed = (): Effect.Effect< + PersistedStackState, + GatewayActivationError | StackError + > => ensureActivationPhaseAllowed().pipe(Effect.andThen(ensureActivationStateAllowed())); const shutdownSignal = yield* Deferred.make(); const signalShutdown = Deferred.succeed(shutdownSignal, undefined).pipe(Effect.asVoid); const ensureAcceptingOperations = Deferred.poll(shutdownSignal).pipe( @@ -491,15 +507,18 @@ export const makeSupervisor = ( Effect.gen(function* () { const token = yield* admission.withPermit( Effect.gen(function* () { - yield* ensureActivationAllowed(); + yield* ensureActivationPhaseAllowed(); const current = activationOwned.get(capability); - if (current?._tag === "ready") + if (current?._tag === "ready" && (yield* Ref.get(phase)) === "running") return { _tag: "exit", result: Exit.succeed(current.result), } satisfies ActivationToken; if (current?._tag === "pending") return { _tag: "deferred", result: current.result } satisfies ActivationToken; + // Validate durable lifecycle state only for a new activation. Ready + // entries above are already fenced by the in-memory phase checks. + yield* ensureActivationStateAllowed(); const deferred = yield* Deferred.make< Exit.Exit, never diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 0c27ce4cd3..06b7521ea9 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -161,6 +161,12 @@ describe("managed stack handles", { timeout: 30_000 }, () => { expect(supervisorEntrypointFor(import.meta.url)).not.toBe(SUPERVISOR_DISPATCH_SENTINEL); }); + it("selects the private dispatch marker for Windows compiled Bun paths", () => { + expect( + supervisorEntrypointFor("C:\\$bunfs\\root\\packages\\stack\\src\\supervisor\\Launcher.ts"), + ).toBe(SUPERVISOR_DISPATCH_SENTINEL); + }); + it.live("preserves the child ownership detail for metadata without a lease lock", () => withRuntimeRoot((project) => Effect.gen(function* () { @@ -591,7 +597,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); - it.live("keeps the detached owner alive after concurrent caller processes exit", () => + it.live("concurrent caller processes share one stack identity after exit", () => withRuntimeRoot((project) => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/packages/stack/src/supervisor/observability.integration.test.ts b/packages/stack/src/supervisor/observability.integration.test.ts index 71c8396091..a940f0d129 100644 --- a/packages/stack/src/supervisor/observability.integration.test.ts +++ b/packages/stack/src/supervisor/observability.integration.test.ts @@ -2,7 +2,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; import { InvalidLogCursorError } from "../public/Errors.ts"; -import { LogStoreError, makeLogStore, selectLogBatch } from "./LogStore.ts"; +import { LogStoreError, makeLogStore, readRetainedLogs, selectLogBatch } from "./LogStore.ts"; const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); @@ -62,7 +62,100 @@ describe("observability", () => { const store = yield* makeLogStore({ path: path.join(root, "logs.json"), maxBytes: 300 }); yield* store.append({ source: "database", stream: "stdout", message: "x".repeat(200) }); expect(yield* store.read()).toEqual([]); - expect((yield* fs.stat(path.join(root, "logs.json"))).size).toBeLessThanOrEqual(300n); + expect(yield* fs.exists(path.join(root, "logs.json"))).toBe(false); + }), + ), + ); + + it.live("keeps reads bounded while compacting retained logs periodically", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-log-compaction-", + }); + const logPath = path.join(root, "logs.json"); + const store = yield* makeLogStore({ path: logPath, maxEntries: 2 }); + yield* store.append({ source: "database", stream: "stdout", message: "one" }); + yield* store.append({ source: "database", stream: "stdout", message: "two" }); + yield* store.append({ source: "database", stream: "stdout", message: "three" }); + + expect((yield* store.read()).map((entry) => entry.message)).toEqual(["two", "three"]); + expect( + (yield* readRetainedLogs(fs, logPath, { maxEntries: 2 })).map((entry) => entry.message), + ).toEqual(["two", "three"]); + // The third append is retained in the append-only window; compaction is deferred until + // the bounded overshoot is exhausted. + expect((yield* fs.readFileString(logPath)).trim().split("\n")).toHaveLength(3); + + yield* store.append({ source: "database", stream: "stdout", message: "four" }); + yield* store.append({ source: "database", stream: "stdout", message: "five" }); + expect((yield* store.read()).map((entry) => entry.message)).toEqual(["four", "five"]); + expect((yield* fs.readFileString(logPath)).trim().split("\n")).toHaveLength(2); + }), + ), + ); + + it.live("drops an oversized record without evicting retained logs or reusing cursors", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-log-cursor-" }); + const logPath = path.join(root, "logs.json"); + const firstStore = yield* makeLogStore({ path: logPath, maxBytes: 300 }); + const first = yield* firstStore.append({ + source: "database", + stream: "stdout", + message: "retained", + }); + const beforeOversized = yield* fs.readFileString(logPath); + + yield* firstStore.append({ + source: "database", + stream: "stdout", + message: "x".repeat(300), + }); + + expect(yield* fs.readFileString(logPath)).toBe(beforeOversized); + expect((yield* firstStore.read()).map((entry) => entry.cursor)).toEqual([first.cursor]); + + const secondStore = yield* makeLogStore({ path: logPath, maxBytes: 300 }); + const second = yield* secondStore.append({ + source: "database", + stream: "stdout", + message: "after restart", + }); + const followed = yield* secondStore.read({ cursor: first.cursor }); + + expect(second.cursor.opaque).not.toBe(first.cursor.opaque); + expect(followed.map((entry) => entry.cursor)).toEqual([second.cursor]); + }), + ), + ); + + it.live("keeps in-memory retention unchanged when compaction persistence fails", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-log-persist-failure-", + }); + const logPath = path.join(root, "logs.json"); + const store = yield* makeLogStore({ path: logPath, maxEntries: 1 }); + yield* store.append({ source: "database", stream: "stdout", message: "one" }); + yield* store.append({ source: "database", stream: "stdout", message: "two" }); + yield* fs.chmod(root, 0o500); + const failed = yield* store + .append({ source: "database", stream: "stdout", message: "three" }) + .pipe(Effect.exit); + yield* fs.chmod(root, 0o700); + + expect(Exit.isFailure(failed)).toBe(true); + expect((yield* store.read()).map((entry) => entry.message)).toEqual(["two"]); + expect((yield* fs.readFileString(logPath)).trim().split("\n")).toHaveLength(2); }), ), ); diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index a54cc6b0a4..75cb64361b 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -41,6 +41,7 @@ import type { EffectStackCredentials } from "../public/Credentials.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { deriveStackId } from "../identity/Identity.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; +import { resolveStackPaths } from "../state/Paths.ts"; import { StackRpcGroup, type StackRpcError } from "../control/StackRpc.ts"; import { makeSupervisor, type Supervisor, type SupervisorRuntime } from "./Supervisor.ts"; import type { SupervisorIngress } from "./Ingress.ts"; @@ -107,6 +108,7 @@ const makeFixture = ( readonly shutdownReadArmed?: Ref.Ref; readonly shutdownReadStarted?: Deferred.Deferred; readonly shutdownReadGate?: Deferred.Deferred; + readonly readCalls?: Ref.Ref; } = {}, ) => Effect.gen(function* () { @@ -164,7 +166,16 @@ const makeFixture = ( return yield* persistedStore.read(stackId); }), }; - const store = runtimeStore; + const store = + fixtureOptions.readCalls === undefined + ? runtimeStore + : { + ...runtimeStore, + read: (stackId: string) => + Ref.update(fixtureOptions.readCalls!, (count) => count + 1).pipe( + Effect.andThen(runtimeStore.read(stackId)), + ), + }; yield* store.initialize(id, { format: "supabase-stack-state-v1", identity: { ...identity, stackId: id }, @@ -382,7 +393,18 @@ const makeFixture = ( runtime, }); yield* Ref.set(calls, []); - return { supervisor, calls, logOptions, failDestroy, context, store, id, runtime, resources }; + return { + supervisor, + calls, + logOptions, + failDestroy, + context, + store, + id, + runtime, + resources, + root, + }; }); const run = (effect: Effect.Effect) => @@ -1288,6 +1310,30 @@ describe("Supervisor composition", () => { ), ); + it.live("removes a runtime remnant when state disappears before supervisor initialization", () => + run( + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const fs = yield* FileSystem.FileSystem; + const paths = yield* resolveStackPaths({ stateRoot: fixture.root, stackId: fixture.id }); + yield* fixture.store.cleanup(fixture.id); + yield* fs.makeDirectory(paths.runtime, { recursive: true }); + + const failed = yield* makeSupervisor({ + stackId: fixture.id, + ownerSessionId: "missing-state-session", + rpcRelease: "test-release", + stateStore: fixture.store, + context: fixture.context, + runtime: fixture.runtime, + }).pipe(Effect.exit); + + expect(errorOf(failed)).toBeInstanceOf(StackStateInvalidError); + expect(yield* fs.exists(paths.runtime)).toBe(false); + }), + ), + ); + it.live("passes log query through the Supervisor log batch", () => run( Effect.gen(function* () { @@ -1619,6 +1665,36 @@ describe("Supervisor composition", () => { ), ); + it.live("reuses a ready lazy activation without rereading durable state", () => + run( + Effect.gen(function* () { + const readCalls = yield* Ref.make(0); + const fixture = yield* makeFixture({ readCalls }); + yield* fixture.supervisor.start({ + config: { + capabilities: { + functions: { activation: "lazy" }, + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + analytics: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + yield* Ref.set(readCalls, 0); + + yield* fixture.supervisor.activate("functions"); + const afterFirst = yield* Ref.get(readCalls); + yield* fixture.supervisor.activate("functions"); + + expect(yield* Ref.get(readCalls)).toBe(afterFirst); + }), + ), + ); + it.live("keeps activated lazy workloads ready across an idempotent start", () => run( Effect.gen(function* () { From c0ce84800c68f8b407dfa303918ac60b8f44010c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 12:07:29 +0200 Subject: [PATCH 153/174] chore(repo): resolve merged quality checks --- apps/cli/src/legacy/commands/config/pull/pull.command.ts | 4 +--- apps/cli/src/legacy/commands/config/pull/pull.format.ts | 2 +- apps/cli/src/legacy/commands/config/pull/pull.plan.ts | 6 +++--- apps/cli/src/legacy/commands/config/pull/pull.scope.ts | 8 ++++---- packages/cli-test-helpers/package.json | 1 + pnpm-lock.yaml | 3 +++ 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/commands/config/pull/pull.command.ts b/apps/cli/src/legacy/commands/config/pull/pull.command.ts index e38bcf16c4..392017b179 100644 --- a/apps/cli/src/legacy/commands/config/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/config/pull/pull.command.ts @@ -39,9 +39,7 @@ const config = { export type LegacyConfigPullFlags = CliCommand.Command.Config.Infer; -// Exported so integration tests can drive the exact wiring -// `Command.withHandler` uses below (same precedent as `legacyConfigDiffHandler`). -export const legacyConfigPullHandler = (flags: LegacyConfigPullFlags) => +const legacyConfigPullHandler = (flags: LegacyConfigPullFlags) => legacyConfigPull(flags).pipe( // `--project-ref` accepts branch names here (CLI-2167 vocabulary), so its // value is only safe to log verbatim when it is actually ref-shaped — a diff --git a/apps/cli/src/legacy/commands/config/pull/pull.format.ts b/apps/cli/src/legacy/commands/config/pull/pull.format.ts index f512dffc25..f95b565d7b 100644 --- a/apps/cli/src/legacy/commands/config/pull/pull.format.ts +++ b/apps/cli/src/legacy/commands/config/pull/pull.format.ts @@ -43,7 +43,7 @@ export const LEGACY_CONFIG_PULL_PAYLOAD_VERSION = 1; * change `legacyPlanConfigPull` planned to write still ends up unwritten when * the run is a dry run or the user declined. */ -export type LegacyConfigPullChangeSkipReason = LegacyConfigPullSkipReason | "declined" | "dry_run"; +type LegacyConfigPullChangeSkipReason = LegacyConfigPullSkipReason | "declined" | "dry_run"; /** * The run's actual outcome, known only after the confirmation prompt (or diff --git a/apps/cli/src/legacy/commands/config/pull/pull.plan.ts b/apps/cli/src/legacy/commands/config/pull/pull.plan.ts index 08897686f9..51682b97a6 100644 --- a/apps/cli/src/legacy/commands/config/pull/pull.plan.ts +++ b/apps/cli/src/legacy/commands/config/pull/pull.plan.ts @@ -47,12 +47,12 @@ export type LegacyConfigPullSkipReason = | "unwritable" | "would_invalidate"; -export interface LegacyConfigPullSkip { +interface LegacyConfigPullSkip { readonly change: ConfigChange; readonly reason: LegacyConfigPullSkipReason; } -export interface LegacyConfigPullPlannedWrite { +interface LegacyConfigPullPlannedWrite { readonly change: ConfigChange; /** `change.path`, prefixed with `["remotes", label]` when the destination * is a `[remotes.*]` block — the exact path `applyConfigEdits` edits. */ @@ -60,7 +60,7 @@ export interface LegacyConfigPullPlannedWrite { readonly value: ConfigEditValue; } -export type LegacyConfigPullWarningKind = +type LegacyConfigPullWarningKind = | "dual_scope" | "duplicates_root" | "array_drift" diff --git a/apps/cli/src/legacy/commands/config/pull/pull.scope.ts b/apps/cli/src/legacy/commands/config/pull/pull.scope.ts index 8a46f1a276..3351047e36 100644 --- a/apps/cli/src/legacy/commands/config/pull/pull.scope.ts +++ b/apps/cli/src/legacy/commands/config/pull/pull.scope.ts @@ -39,11 +39,11 @@ import { legacySanitizeInlineName } from "../../../shared/legacy-http-errors.ts" * 5. Otherwise: the config root. */ -export interface LegacyConfigPullDestinationRoot { +interface LegacyConfigPullDestinationRoot { readonly kind: "root"; } -export interface LegacyConfigPullDestinationRemote { +interface LegacyConfigPullDestinationRemote { readonly kind: "remote"; /** * The `[remotes.(value: A) => + Ref.update(calls, (count) => count + 1).pipe(Effect.andThen(Effect.succeed(value))); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: ownerRelease, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: ownerRelease, + rpcHandlers: { + status: () => invoked(runningStatus), + credentials: () => invoked(credentials), + start: () => invoked(runningStatus), + destroy: () => invoked(undefined), + logs: () => invoked({ entries: [], cursor: { opaque: "v1_0" }, running: false }), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: ownerRelease, + }), + stop: Effect.succeed({ ok: true, op: "stop" }), + }, + onShutdownReady: Scope.close(ownerScope, Exit.void), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeTestHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), + }); + const status = yield* stack.status().pipe(Effect.exit); + const stackCredentials = yield* stack.credentials().pipe(Effect.exit); + const logs = yield* stack.logs().pipe(Effect.exit); + const assertUpgrade = (result: Exit.Exit) => { + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const failure = Cause.findErrorOption(result.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(StackUpgradeRequiredError); + expect(failure.value).toMatchObject({ + expectedRelease: STACK_RPC_RELEASE, + actualRelease: ownerRelease, + }); + } + } + }; + assertUpgrade(status); + assertUpgrade(stackCredentials); + assertUpgrade(logs); + expect(yield* Ref.get(calls)).toBe(0); + yield* stack.stop(); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("stops a freshly launched owner when the first start RPC is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-launch-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "fresh-launch-session"; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const startEntered = yield* Deferred.make(); + const stopCalls = yield* Ref.make(0); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => + Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), + destroy: () => Effect.void, + logs: () => emptyLogs(), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( + Effect.andThen(Effect.succeed({ ok: true, op: "stop" } as const)), + ), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeTestHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), + }); + const start = yield* Effect.forkChild(stack.start(), { startImmediately: true }); + yield* Deferred.await(startEntered); + yield* Fiber.interrupt(start); + expect(yield* Ref.get(stopCalls)).toBe(1); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("stops a freshly launched owner when destroy is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-effect-stack-destroy-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "fresh-destroy-session"; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const destroyEntered = yield* Deferred.make(); + const stopCalls = yield* Ref.make(0); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => Effect.succeed(runningStatus), + destroy: () => + Deferred.succeed(destroyEntered, undefined).pipe(Effect.andThen(Effect.never)), + logs: () => emptyLogs(), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( + Effect.andThen(Effect.succeed({ ok: true, op: "stop" } as const)), + ), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeTestHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), + readPersistedState: Effect.succeed(Option.some(stoppedState())), + }); + const destroy = yield* Effect.forkChild(stack.destroy(), { startImmediately: true }); + yield* Deferred.await(destroyEntered); + yield* Fiber.interrupt(destroy); + const interrupted = yield* Fiber.join(destroy).pipe(Effect.exit); + expect(Exit.isFailure(interrupted)).toBe(true); + expect(yield* Ref.get(stopCalls)).toBe(1); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("reports fresh-owner rollback failures after an interrupted start", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-effect-stack-rollback-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "rollback-failure-session"; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const startEntered = yield* Deferred.make(); + const stopCalls = yield* Ref.make(0); + const releaseCalls = yield* Ref.make(0); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => + Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), + destroy: () => Effect.void, + logs: () => emptyLogs(), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( + Effect.andThen( + Effect.succeed({ + ok: false, + error: { tag: "operation-failed", message: "injected rollback failure" }, + } as const), + ), + ), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeTestHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), + waitForRelease: Ref.update(releaseCalls, (calls) => calls + 1).pipe( + Effect.andThen( + Effect.fail(new StackOwnershipConflictError({ message: "release failed" })), + ), + ), + }); + const start = yield* Effect.forkChild(stack.start(), { startImmediately: true }); + yield* Deferred.await(startEntered); + yield* Fiber.interrupt(start); + const interrupted = yield* Fiber.join(start).pipe(Effect.exit); + expect(Exit.isFailure(interrupted)).toBe(true); + if (Exit.isFailure(interrupted)) { + const failure = Cause.findErrorOption(interrupted.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(StackCleanupError); + expect(failure.value.message).toContain( + "Unable to clean up freshly launched Supervisor", + ); + expect(failure.value.cause).toBeDefined(); + } + } + expect(yield* Ref.get(stopCalls)).toBe(1); + expect(yield* Ref.get(releaseCalls)).toBe(1); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("preserves an interrupted start when fresh-owner cleanup is refused", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "ss-ar-", + }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "admitted-rollback-session"; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const startEntered = yield* Deferred.make(); + const stopCalls = yield* Ref.make(0); + const releaseCalls = yield* Ref.make(0); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => + Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), + destroy: () => Effect.void, + logs: () => emptyLogs(), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( + Effect.andThen( + Effect.succeed({ + ok: false, + error: { + tag: "operation-failed", + message: "a lifecycle transition is already in progress", + stackErrorTag: "StackLifecycleConflictError", + }, + } as const), + ), + ), + }, + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeTestHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: true })), + waitForRelease: Ref.update(releaseCalls, (calls) => calls + 1).pipe( + Effect.andThen( + Effect.fail(new StackOwnershipConflictError({ message: "release was unexpected" })), + ), + ), + }); + const start = yield* Effect.forkChild(stack.start(), { startImmediately: true }); + yield* Deferred.await(startEntered); + yield* Fiber.interrupt(start); + const interrupted = yield* Fiber.join(start).pipe(Effect.exit); + expect(Exit.isFailure(interrupted)).toBe(true); + if (Exit.isFailure(interrupted)) { + const failure = Cause.findErrorOption(interrupted.cause); + expect(Option.isNone(failure)).toBe(true); + expect(Cause.hasInterrupts(interrupted.cause)).toBe(true); + } + expect(yield* Ref.get(stopCalls)).toBe(1); + expect(yield* Ref.get(releaseCalls)).toBe(0); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("does not stop an owner joined through a concurrent launch", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-effect-stack-join-" }); + const endpoint = { kind: "unix" as const, path: path.join(root, "control.sock") }; + const ownerSessionId = "joined-launch-session"; + const ownerScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(ownerScope, Exit.void)); + const startEntered = yield* Deferred.make(); + const stopCalls = yield* Ref.make(0); + const owner = { + format: "supabase-stack-owner-v1" as const, + stackId, + endpoint, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }; + yield* startControlServer({ + endpoint, + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + rpcHandlers: { + status: () => Effect.succeed(runningStatus), + credentials: () => Effect.succeed(credentials), + start: () => + Deferred.succeed(startEntered, undefined).pipe(Effect.andThen(Effect.never)), + destroy: () => Effect.void, + logs: () => emptyLogs(), + }, + maintenanceHandlers: { + probe: Effect.succeed({ + ok: true, + op: "probe", + stackId, + ownerSessionId, + rpcRelease: STACK_RPC_RELEASE, + }), + stop: Ref.update(stopCalls, (calls) => calls + 1).pipe( + Effect.andThen(Effect.succeed({ ok: true, op: "stop" } as const)), + ), + }, + onShutdownReady: Scope.close(ownerScope, Exit.void), + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const stack = yield* makeTestHandle(stackId, { + resolveOwner: () => Effect.succeed(Option.some({ owner, launched: false })), + }); + const start = yield* Effect.forkChild(stack.start(), { startImmediately: true }); + yield* Deferred.await(startEntered); + yield* Fiber.interrupt(start); + expect(yield* Ref.get(stopCalls)).toBe(0); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); }); diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 4b28a53139..673ecfba9c 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -190,7 +190,6 @@ export interface ContainerLogOptions { readonly tail?: "all" | 0; } export interface ContainerCommandRunner { - readonly executable: string; readonly run: ( request: ContainerProcessRequest, ) => Effect.Effect; @@ -371,7 +370,7 @@ export const makeProcessCommandRunner = ( ), ), ); - return { executable: options.executable, run, stream }; + return { run, stream }; }); export interface ContainerEngineCodecs { @@ -576,7 +575,6 @@ export interface ContainerEngineOptions { } export interface ContainerEngine { readonly kind: ContainerEngineKind; - readonly executable: string; readonly preflight: Effect.Effect; readonly probe: Effect.Effect; readonly inspectImage: ( @@ -700,7 +698,6 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai : Effect.succeed({ host: "host.containers.internal" }); return { kind: options.kind, - executable: options.runner.executable, preflight, probe: check("probe", { operation: "probe" }).pipe(Effect.flatMap(options.codecs.decodeProbe)), inspectImage: (image) => diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 0b987b8673..d9f4b6a5b4 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -103,16 +103,12 @@ interface ContainerVolumeRequest { readonly ownerWorkloadId?: string; } -interface ContainerRuntimeResourceIds { +interface ContainerRuntimeResource { readonly container: string; state: "starting" | "running" | "stopped" | "failed"; readonly error?: string; -} - -interface ContainerRuntimeResource extends ContainerRuntimeResourceIds { readonly key: RuntimeWorkloadKey; readonly workload: PlannedWorkload; - readonly ownerSessionId: string; readonly failure: Deferred.Deferred; logFiber?: Fiber.Fiber; watchFiber?: Fiber.Fiber; @@ -261,7 +257,7 @@ export const makeContainerRuntime = ( options.startupProcessTimeout ?? ("5 minutes" satisfies Duration.Input); const parentScope = yield* Scope.Scope; const runtimeScope = yield* Scope.fork(parentScope, "parallel"); - const lifecycle = yield* Semaphore.make(1); + const registration = yield* Semaphore.make(1); const resources = new Map(); const startGuards = new Map>(); const startFibers = new Map>(); @@ -282,6 +278,39 @@ export const makeContainerRuntime = ( const stopExitWatcher = (resource: ContainerRuntimeResource): Effect.Effect => resource.watchFiber === undefined ? Effect.void : Fiber.interrupt(resource.watchFiber); + const cleanupStartedContainer = ( + key: RuntimeWorkloadKey, + containerId: string, + ): Effect.Effect => + registration.withPermit( + Effect.uninterruptible( + Effect.gen(function* () { + const exact = (yield* withEngine(key, options.engine.listResources(key.stackId))).find( + (entry) => entry.kind === "workload" && entry.id === containerId, + ); + if (exact === undefined) return; + if (exact.state === "running") + yield* withEngine(key, options.engine.stopContainer(containerId)); + yield* withEngine(key, options.engine.removeContainer(containerId)); + }), + ), + ); + + const cleanupRegisteredResource = ( + resource: ContainerRuntimeResource, + ): Effect.Effect => + Effect.gen(function* () { + resource.stopRequested = true; + yield* stopLogs(resource); + yield* stopExitWatcher(resource); + const cleanup = yield* cleanupStartedContainer(resource.key, resource.container).pipe( + Effect.exit, + ); + const id = resourceKey(resource.key); + if (resources.get(id) === resource) resources.delete(id); + if (Exit.isFailure(cleanup)) return yield* Effect.failCause(cleanup.cause); + }); + const reportFailure = ( resource: ContainerRuntimeResource, error: unknown, @@ -572,15 +601,18 @@ export const makeContainerRuntime = ( const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); const volumeSpec = volumeRequest === undefined ? undefined : volumeSpecFor(key, volumeRequest); + const exactVolume = + volumeSpec === undefined + ? undefined + : entries.find( + (entry) => + entry.kind === "volume" && + entry.name === volumeSpec.name && + sameLabels(entry.labels, volumeSpec.labels), + ); if (volumeSpec !== undefined) { const volume = volumeSpec; - const exact = entries.find( - (entry) => - entry.kind === "volume" && - entry.name === volume.name && - sameLabels(entry.labels, volume.labels), - ); - if (exact === undefined) { + if (exactVolume === undefined) { const sameLabel = entries.find( (entry) => entry.kind === "volume" && sameLabels(entry.labels, volume.labels), ); @@ -661,12 +693,6 @@ export const makeContainerRuntime = ( if (volumeSpec !== undefined) { const volume = volumeSpec; - const exactVolume = entries.find( - (entry) => - entry.kind === "volume" && - entry.name === volume.name && - sameLabels(entry.labels, volume.labels), - ); if (exactVolume === undefined) yield* withEngine(key, options.engine.createVolume(volume)); } @@ -731,29 +757,43 @@ export const makeContainerRuntime = ( } yield* guard; yield* withEngine(key, options.engine.startContainer(container.id)); - const resource = yield* Effect.gen(function* () { - const failure = yield* Deferred.make(); - const createdResource: ContainerRuntimeResource = { - key, - workload, - ownerSessionId: options.ownerSessionId, - container: container.id, - state: "starting", - failure, - stopRequested: false, - }; - resources.set(resourceKey(key), createdResource); - return createdResource; - }); - yield* attachLogs(resource, "all"); - yield* attachExitWatcher(resource); - const readiness = - resolution.waitForReadiness === undefined - ? options.waitForReadiness === undefined - ? Effect.void - : options.waitForReadiness(key, workload, container) - : resolution.waitForReadiness(key, workload, container); - const startup = Effect.gen(function* () { + const registered = yield* Effect.uninterruptibleMask((restore) => + restore( + Effect.gen(function* () { + yield* guard; + const failure = yield* Deferred.make(); + yield* guard; + const createdResource: ContainerRuntimeResource = { + key, + workload, + container: container.id, + state: "starting", + failure, + stopRequested: false, + }; + resources.set(resourceKey(key), createdResource); + return createdResource; + }), + ).pipe(Effect.exit), + ); + if (Exit.isFailure(registered)) { + const cleanup = yield* cleanupStartedContainer(key, container.id).pipe(Effect.exit); + return yield* Effect.failCause( + Exit.isFailure(cleanup) + ? Cause.combine(registered.cause, cleanup.cause) + : registered.cause, + ); + } + const resource = registered.value; + const postRegistration = Effect.gen(function* () { + yield* attachLogs(resource, "all"); + yield* attachExitWatcher(resource); + const readiness = + resolution.waitForReadiness === undefined + ? options.waitForReadiness === undefined + ? Effect.void + : options.waitForReadiness(key, workload, container) + : resolution.waitForReadiness(key, workload, container); yield* Effect.raceFirst(readiness, Deferred.await(resource.failure)); if (workload.bootstrap === "database") { if (options.bootstrapDatabase === undefined) @@ -763,53 +803,61 @@ export const makeContainerRuntime = ( ); yield* options.bootstrapDatabase(key, workload, container); } + resource.state = "running"; + return { ...key, state: "ready" } satisfies ObservedWorkload; }); - const ready = yield* startup.pipe(Effect.exit); - if (Exit.isFailure(ready)) { - resource.stopRequested = true; - yield* stopLogs(resource); - yield* stopExitWatcher(resource); - const stopExit = yield* Effect.exit( - withEngine(key, options.engine.stopContainer(container.id)), - ); - const removeExit = yield* Effect.exit( - withEngine(key, options.engine.removeContainer(container.id)), + // The mask lets Exit observe ordinary failures from the restored work; interruptions stay + // pending and re-raise at mask exit, where the outer wrapper performs cleanup. + const completed = yield* Effect.uninterruptibleMask((restore) => + restore(postRegistration).pipe(Effect.exit), + ); + if (Exit.isFailure(completed)) { + const cleanup = yield* cleanupRegisteredResource(resource).pipe(Effect.exit); + return yield* Effect.failCause( + Exit.isFailure(cleanup) + ? Cause.combine(completed.cause, cleanup.cause) + : completed.cause, ); - let cleanupCause: Cause.Cause = Cause.empty; - if (Exit.isFailure(stopExit)) cleanupCause = Cause.combine(cleanupCause, stopExit.cause); - if (Exit.isFailure(removeExit)) - cleanupCause = Cause.combine(cleanupCause, removeExit.cause); - resources.delete(resourceKey(key)); - return yield* cleanupCause.reasons.length === 0 - ? Effect.failCause(ready.cause) - : Effect.failCause(Cause.combine(ready.cause, cleanupCause)); } - resource.state = "running"; - const result: ObservedWorkload = { ...key, state: "ready" }; - return result; + return completed.value; }); return Effect.gen(function* () { const inFlight = startFibers.get(id); if (inFlight !== undefined) return yield* Fiber.join(inFlight); const guardRef = yield* Ref.make(false); startGuards.set(id, guardRef); - // Track the whole lifecycle wait, not only the body after permit acquisition. Cleanup + // Track the whole registration wait, not only the body after permit acquisition. Cleanup // must be able to interrupt starts queued behind another activation as well. The permit // only covers forking the body; readiness and log following continue after it is released. const run = Effect.gen(function* () { - const body = yield* lifecycle.withPermit( + const body = yield* registration.withPermit( Effect.forkChild(startEffect, { startImmediately: true }), ); return yield* Fiber.join(body); }); const fiber = yield* Effect.forkChild(run, { startImmediately: true }); startFibers.set(id, fiber); - return yield* Fiber.join(fiber).pipe( - Effect.ensuring( - Effect.sync(() => { - if (startGuards.get(id) === guardRef) startGuards.delete(id); - if (startFibers.get(id) === fiber) startFibers.delete(id); - }), + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const joined = yield* restore(Fiber.join(fiber)).pipe(Effect.exit); + if (Exit.isFailure(joined)) { + yield* Fiber.interrupt(fiber); + const resource = resources.get(id); + if (resource !== undefined) { + const cleanup = yield* cleanupRegisteredResource(resource).pipe(Effect.exit); + if (Exit.isFailure(cleanup)) + return yield* Effect.failCause(Cause.combine(joined.cause, cleanup.cause)); + } + return yield* Effect.failCause(joined.cause); + } + return joined.value; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (startGuards.get(id) === guardRef) startGuards.delete(id); + if (startFibers.get(id) === fiber) startFibers.delete(id); + }), + ), ), ); }); @@ -835,7 +883,8 @@ export const makeContainerRuntime = ( yield* stopLogs(found); yield* stopExitWatcher(found); yield* withEngine(key, options.engine.stopContainer(found.container)); - resources.set(resourceKey(key), { ...found, state: "stopped" }); + const id = resourceKey(key); + if (resources.get(id) === found) resources.set(id, { ...found, state: "stopped" }); }) : (guard === undefined ? Effect.void : Ref.set(guard, true)).pipe( Effect.andThen(withEngine(key, options.engine.listResources(key.stackId))), @@ -858,10 +907,10 @@ export const makeContainerRuntime = ( }; const stop = (key: RuntimeWorkloadKey): Effect.Effect => - lifecycle.withPermit(stopInPermit(key)); + registration.withPermit(stopInPermit(key)); const remove = (key: RuntimeWorkloadKey): Effect.Effect => - lifecycle.withPermit( + registration.withPermit( Effect.gen(function* () { const found = resources.get(resourceKey(key)); if (found !== undefined) { @@ -900,7 +949,7 @@ export const makeContainerRuntime = ( ); const cleanup = (request: RuntimeCleanupRequest): Effect.Effect => - lifecycle.withPermit( + registration.withPermit( Effect.gen(function* () { // Stop and interrupt starts that have not registered a resource yet. Without this // fence, cleanup can observe an empty resource list while an activation is still inside @@ -919,8 +968,12 @@ export const makeContainerRuntime = ( if (fiber !== undefined) yield* Fiber.interrupt(fiber); } + const stackKey = { + stackId: request.stackId, + workloadId: "", + } satisfies RuntimeWorkloadKey; const entries = yield* withEngine( - { stackId: request.stackId, workloadId: "" }, + stackKey, options.engine.listResources(request.stackId), ); const owned = entries @@ -944,34 +997,14 @@ export const makeContainerRuntime = ( // stop does not prevent the remove attempt; all failures are returned together. for (const entry of owned.filter(isWorkloadResource)) { if (entry.state === "running") - yield* attempt( - withEngine( - { stackId: request.stackId, workloadId: "" }, - options.engine.stopContainer(entry.id), - ), - ); - yield* attempt( - withEngine( - { stackId: request.stackId, workloadId: "" }, - options.engine.removeContainer(entry.id), - ), - ); + yield* attempt(withEngine(stackKey, options.engine.stopContainer(entry.id))); + yield* attempt(withEngine(stackKey, options.engine.removeContainer(entry.id))); } for (const entry of owned.filter(isNetworkResource)) - yield* attempt( - withEngine( - { stackId: request.stackId, workloadId: "" }, - options.engine.removeNetwork(entry.id), - ), - ); + yield* attempt(withEngine(stackKey, options.engine.removeNetwork(entry.id))); if (request.destroy) for (const entry of owned.filter((candidate) => candidate.kind === "volume")) - yield* attempt( - withEngine( - { stackId: request.stackId, workloadId: "" }, - options.engine.removeVolume(entry.id), - ), - ); + yield* attempt(withEngine(stackKey, options.engine.removeVolume(entry.id))); for (const id of resources.keys()) if (id.startsWith(`${request.stackId}:`)) resources.delete(id); diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index 8807b98f26..098069b457 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -33,33 +33,30 @@ import { type NativeProcessSpec, } from "./NativeProcess.ts"; -type NativeWorkload = PlannedWorkload; - export interface NativeRuntimeOptions { /** * Resolves the process-ownership launcher. Development defaults use the - * bundled native-launcher entrypoint; packaged CLIs can provide their own - * command/entrypoint without changing process ownership semantics. + * bundled native-launcher entrypoint. */ readonly resolveLauncher?: ( key: RuntimeWorkloadKey, - workload: NativeWorkload, + workload: PlannedWorkload, ) => Effect.Effect; /** Resolves the complete native process plan for one workload. */ readonly resolveProcess: ( key: RuntimeWorkloadKey, - workload: NativeWorkload, + workload: PlannedWorkload, ) => Effect.Effect; /** Private readiness is resolved by the owning Supervisor/gateway seam. */ readonly waitForReadiness: ( key: RuntimeWorkloadKey, - workload: NativeWorkload, + workload: PlannedWorkload, process: NativeProcess, ) => Effect.Effect; /** Runs the one-shot initial database bootstrap after readiness. */ readonly bootstrapDatabase?: ( key: RuntimeWorkloadKey, - workload: NativeWorkload, + workload: PlannedWorkload, process?: NativeProcess, ) => Effect.Effect; readonly logStore?: LogStore; @@ -80,7 +77,7 @@ interface OutputAccumulator { interface Resource { readonly key: RuntimeWorkloadKey; - readonly workload: NativeWorkload; + readonly workload: PlannedWorkload; readonly scope: Scope.Closeable; readonly state: Ref.Ref; readonly output: Readonly<{ stdout: OutputAccumulator; stderr: OutputAccumulator }>; @@ -179,7 +176,7 @@ export const makeNativeRuntime = ( const pathService = yield* Path.Path; const parentScope = yield* Scope.Scope; const runtimeScope = yield* Scope.fork(parentScope, "parallel"); - const lifecycle = yield* Semaphore.make(1); + const registration = yield* Semaphore.make(1); const resources = new Map(); const cleanup = (resource: Resource): Effect.Effect => @@ -502,7 +499,7 @@ export const makeNativeRuntime = ( const start = ( key: RuntimeWorkloadKey, - workload: NativeWorkload, + workload: PlannedWorkload, ): Effect.Effect => { if (workload.selected.kind === "container") return Effect.fail( @@ -513,13 +510,15 @@ export const makeNativeRuntime = ( }), ); return Effect.flatMap( - lifecycle.withPermit( + // The permit serializes registration only; process readiness and log following continue + // after it is released, while stop/remove/cleanup hold it for their full operation. + registration.withPermit( Effect.gen(function* () { const id = resourceKey(key); const existing = resources.get(id); if (existing !== undefined) { const current = yield* Ref.get(existing.state); - if (current.state === "starting") return existing; + if (current.state === "starting" || current.state === "ready") return existing; yield* cleanup(existing); } const processScope = yield* Scope.fork(runtimeScope, "parallel"); @@ -604,7 +603,7 @@ export const makeNativeRuntime = ( }); const stop = (key: RuntimeWorkloadKey): Effect.Effect => - lifecycle.withPermit( + registration.withPermit( Effect.gen(function* () { const resource = resources.get(resourceKey(key)); if (resource === undefined) return; @@ -615,7 +614,7 @@ export const makeNativeRuntime = ( ); const remove = (key: RuntimeWorkloadKey): Effect.Effect => - lifecycle.withPermit( + registration.withPermit( Effect.gen(function* () { const resource = resources.get(resourceKey(key)); if (resource === undefined) return; @@ -628,7 +627,7 @@ export const makeNativeRuntime = ( const cleanupRuntime = ( request: RuntimeCleanupRequest, ): Effect.Effect => - lifecycle.withPermit( + registration.withPermit( Effect.gen(function* () { let cleanupCause: Cause.Cause = Cause.empty; const attempt = (effect: Effect.Effect) => diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 246765c21d..1658f147a0 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -61,7 +61,6 @@ import { } from "./RuntimeInputOwner.ts"; import type { NativeProcessSpec } from "./NativeProcess.ts"; import { - FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, resolveContainerResolutionFor, runtimeSpecFor, validatePrivateAssignments, @@ -491,7 +490,6 @@ export const makeProductionRuntime = ( const state = yield* currentStateReader(options); const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const ownerScope = yield* Scope.Scope; const paths = yield* resolveStackPaths({ stateRoot: options.stateRoot, @@ -514,23 +512,11 @@ export const makeProductionRuntime = ( })); // oxlint-enable effecttsgo/async-function // oxlint-enable effecttsgo/global-fetch-in-effect - const inputOwnerScope = yield* Scope.fork(ownerScope, "sequential"); const inputOwner = yield* makeRuntimeInputOwner({ stateRoot: options.stateRoot, stackId: options.stackId, fetchJson, - }).pipe(Effect.provideService(Scope.Scope, inputOwnerScope)); - yield* Scope.addFinalizer( - ownerScope, - Effect.gen(function* () { - yield* Scope.close(inputOwnerScope, Exit.void); - yield* inputOwner.cleanupAll.pipe( - Effect.catchTag("StackPreparationError", () => Effect.void), - ); - }), - ); - // Register this child scope before constructing drivers: LIFO cleanup - // stops workloads, closes owner fibers, then removes generated files. + }).pipe(Effect.provideService(Scope.Scope, ownerScope)); const ingress = options.ingress ?? (yield* makeSupervisorIngress({ @@ -628,9 +614,7 @@ export const makeProductionRuntime = ( host: ContainerHostRoute | undefined, ): Effect.Effect => Effect.gen(function* () { - const material = yield* inputOwner.resolve(fresh, { - includePooler: workload.id === "pooler:pooler", - }); + const material = yield* inputOwner.resolve(fresh, workload.id); const templates = material.auth?.templates; const apiListener = fresh.definition?.listeners.api; const apiAssignment = fresh.ports.find((assignment) => assignment.field === "api"); @@ -653,7 +637,6 @@ export const makeProductionRuntime = ( workload.id === "functions:edge-runtime" ? { bootstrapPath: yield* functionsPath(), - bootstrapContainerPath: FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, ...(material.functions?.secrets === undefined ? {} : { secrets: material.functions.secrets }), @@ -678,7 +661,7 @@ export const makeProductionRuntime = ( Effect.gen(function* () { if (logStoreInitializationFailure !== undefined) return yield* preparationError( - "Unable to open stack logs", + `Unable to open stack logs: ${logStoreInitializationFailure.message}`, logStoreInitializationFailure, ); if (!runtimeMatches(input.state.runtime, state.runtime)) @@ -691,8 +674,13 @@ export const makeProductionRuntime = ( // Eagerly validate the candidate before any engine/artifact work. Start-time checks // below revalidate the fresh persisted definition because it is runtime authority. yield* validateDatabaseReadinessBudget(input.definition, input.plan.workloads); + const candidateState: PersistedStackState = { + ...input.state, + definition: input.definition, + secrets: input.secrets, + }; yield* Effect.forEach(input.plan.workloads, (workload) => - validateMaterializedSecrets(input.state, workload.capability), + validateMaterializedSecrets(candidateState, workload.capability), ); yield* Effect.forEach(input.plan.workloads, (workload) => Effect.gen(function* () { @@ -719,6 +707,13 @@ export const makeProductionRuntime = ( if (input.state.runtime.kind === "native") { for (const assignment of input.state.ports) { const listener = input.definition.listeners[assignment.field]; + if ( + !listener.enabled || + (listener.port === "automatic" + ? assignment.intent !== "automatic" + : listener.port !== assignment.port) + ) + continue; yield* checkHostPort(listener.address, assignment.port, assignment.field).pipe( Effect.mapError( (error) => @@ -906,10 +901,6 @@ export const makeProductionRuntime = ( bootstrapDatabase: bootstrapWorkloadDatabase, logStore: logs, }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childSpawner), - Effect.provideService(Scope.Scope, ownerScope), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), Effect.mapError((error) => preparationError("Unable to initialize native runtime", error)), ); } else { @@ -931,8 +922,6 @@ export const makeProductionRuntime = ( key, `Unknown runtime specification for ${workload.id}`, ); - // Revalidate the fresh persisted definition before creating a container; - // preflight checks the candidate definition, while this state is authoritative. yield* readinessDeadlineFor(fresh, workload).pipe( Effect.mapError((error) => mapDriverError(key, error)), ); @@ -955,9 +944,6 @@ export const makeProductionRuntime = ( const inputs = yield* runtimeInputs(workload, fresh, route).pipe( Effect.mapError((error) => mapDriverError(key, error)), ); - yield* validateWorkloadRuntimeInputs(fresh, workload, inputs).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); const resolution = yield* resolveContainerResolutionFor( fresh, workload, @@ -994,7 +980,6 @@ export const makeProductionRuntime = ( return { ...withoutEnv, envFile, - waitForReadiness, ...(volume === undefined ? {} : { volume }), } satisfies ContainerWorkloadResolution; }), @@ -1003,7 +988,7 @@ export const makeProductionRuntime = ( waitForReadiness, bootstrapDatabase: bootstrapWorkloadDatabase, logStore: logs, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + }); } const baseDriver = withOwnedRuntimeFileCleanup( driver, diff --git a/packages/stack/src/runtime/RuntimeDriver.ts b/packages/stack/src/runtime/RuntimeDriver.ts index f3c88264c6..55fef48c5c 100644 --- a/packages/stack/src/runtime/RuntimeDriver.ts +++ b/packages/stack/src/runtime/RuntimeDriver.ts @@ -17,24 +17,25 @@ export interface RuntimeCleanupRequest { } /** Runtime states projected to callers and status inspection. */ -type ObservedWorkloadState = "absent" | "starting" | "ready" | "stopped" | "failed"; +type ObservedWorkloadState = "starting" | "ready" | "stopped" | "failed"; export interface ObservedWorkload extends RuntimeWorkloadKey { readonly state: ObservedWorkloadState; readonly error?: string; } -type RuntimeWorkload = PlannedWorkload; - export interface RuntimeDriver { /** Enumerates only private resources owned by this exact stack identity. */ readonly observe: ( stackId: StackId, ) => Effect.Effect, RuntimeDriverError>; - /** Starts one exact resource and returns after its readiness probe succeeds. */ + /** + * Starts one exact resource and returns after its readiness probe succeeds. Callers must use + * stop or cleanup for ownership teardown; caller interruption is not part of this contract. + */ readonly start: ( key: RuntimeWorkloadKey, - workload: RuntimeWorkload, + workload: PlannedWorkload, ) => Effect.Effect; /** Stops one exact resource; no other stack may be touched. */ readonly stop: (key: RuntimeWorkloadKey) => Effect.Effect; diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index d90d06e3f3..2e0f4630d7 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -55,10 +55,10 @@ interface RuntimeInputMaterial { } export interface RuntimeInputOwner { - /** Resolves all stack-owned inputs needed before a workload is created. */ + /** Resolves stack-owned inputs needed before a workload is created. */ readonly resolve: ( state: PersistedStackState, - options?: Readonly<{ readonly includePooler?: boolean }>, + workloadId: string, ) => Effect.Effect; /** Resolves one configured project-relative regular file without copying it. */ readonly resolveProjectFile: ( @@ -319,6 +319,19 @@ export const makeRuntimeInputOwner = ( }); }; + const ensureFunctionsRoot = ( + state: PersistedStackState, + ): Effect.Effect => { + const settings = settingsFor(state, "functions"); + const root = isRecord(settings) ? settingValue(state, settings.functions_root) : ""; + if (root.length === 0) return Effect.fail(failure("Persisted Functions root is missing")); + return mapFile( + root, + "create Functions root", + fs.makeDirectory(root, { recursive: true }), + ).pipe(Effect.asVoid); + }; + const resolveAuthTemplates = ( state: PersistedStackState, ): Effect.Effect, StackPreparationError> => @@ -608,43 +621,59 @@ export const makeRuntimeInputOwner = ( type OwnedResult = Deferred.Deferred, never>; const commonPending = new Map>(); const commonCompleted = new Map(); + const authPending = new Map>>(); + const authCompleted = new Map>(); const poolerPending = new Map>(); const poolerCompleted = new Map(); const keyFor = (state: PersistedStackState): string => `${options.stackId}\u0000${canonicalize(state.runtime)}\u0000${canonicalize(state.definition ?? {})}`; + const needsAuthMaterial = (state: PersistedStackState, workloadId: string): boolean => + (["rest", "auth", "realtime", "storage", "functions"] as const).some( + (capability) => + state.definition?.capabilities[capability].enabled === true && + (workloadId === `${capability}:${capability}` || + (capability === "functions" && workloadId === "functions:edge-runtime")), + ); const materializeCommon = ( state: PersistedStackState, + workloadId: string, + authMaterial: NonNullable | undefined, ): Effect.Effect => Effect.gen(function* () { - const jwtConsumers = ["rest", "auth", "realtime", "storage", "functions"] as const; - const resolvesAuthMaterial = jwtConsumers.some( - (capability) => state.definition?.capabilities[capability].enabled === true, - ); - const auth = resolvesAuthMaterial ? yield* resolveAuth(state) : undefined; - const analyticsSettings = settingsFor(state, "analytics"); - const gcpPath = isRecord(analyticsSettings) - ? settingValue(state, analyticsSettings.gcp_jwt_path) - : ""; - const analyticsEnabled = state.definition?.capabilities.analytics.enabled === true; - const vectorPort = isRecord(analyticsSettings) - ? settingValue(state, analyticsSettings.vector_port) - : ""; - const vectorConfigPath = - analyticsEnabled && vectorPort.length > 0 ? yield* writeVectorConfig(state) : undefined; - const analytics = - !analyticsEnabled || (gcpPath.length === 0 && vectorConfigPath === undefined) - ? undefined - : { - ...(gcpPath.length === 0 - ? {} - : { gcpJwtPath: yield* resolveProjectFile(state, gcpPath) }), - ...(vectorConfigPath === undefined ? {} : { vectorConfigPath }), - }; - const functions = - state.definition?.capabilities.functions.enabled !== true - ? undefined - : { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state) }; + if (workloadId === "studio:studio" || workloadId === "functions:edge-runtime") + yield* ensureFunctionsRoot(state); + const auth = needsAuthMaterial(state, workloadId) ? authMaterial : undefined; + const resolvesAnalyticsMaterial = + state.definition?.capabilities.analytics.enabled === true && + workloadId.startsWith("analytics:"); + const analytics = !resolvesAnalyticsMaterial + ? undefined + : yield* Effect.gen(function* () { + const analyticsSettings = settingsFor(state, "analytics"); + const gcpPath = isRecord(analyticsSettings) + ? settingValue(state, analyticsSettings.gcp_jwt_path) + : ""; + const vectorPort = isRecord(analyticsSettings) + ? settingValue(state, analyticsSettings.vector_port) + : ""; + const vectorConfigPath = + vectorPort.length > 0 ? yield* writeVectorConfig(state) : undefined; + return gcpPath.length === 0 && vectorConfigPath === undefined + ? undefined + : { + ...(gcpPath.length === 0 + ? {} + : { gcpJwtPath: yield* resolveProjectFile(state, gcpPath) }), + ...(vectorConfigPath === undefined ? {} : { vectorConfigPath }), + }; + }); + const resolvesFunctionsMaterial = + workloadId === "functions:edge-runtime" && + state.definition?.capabilities.functions.enabled === true; + const functions = resolvesFunctionsMaterial + ? { secrets: yield* resolveFunctionsEdgeRuntimeSecrets(state) } + : undefined; return { ...(auth === undefined ? {} : { auth }), ...(analytics === undefined ? {} : { analytics }), @@ -721,18 +750,21 @@ export const makeRuntimeInputOwner = ( const resolve = ( state: PersistedStackState, - options: Readonly<{ readonly includePooler?: boolean }> = {}, + workloadId: string, ): Effect.Effect => Effect.gen(function* () { - const key = keyFor(state); + const key = `${keyFor(state)}\u0000${workloadId}`; + const auth = needsAuthMaterial(state, workloadId) + ? yield* singleFlight(keyFor(state), authPending, authCompleted, resolveAuth(state)) + : undefined; const common = yield* singleFlight( key, commonPending, commonCompleted, - materializeCommon(state), + materializeCommon(state, workloadId, auth), ); if ( - options.includePooler !== true || + workloadId !== "pooler:pooler" || state.definition?.capabilities.pooler.enabled !== true ) return common; @@ -749,17 +781,23 @@ export const makeRuntimeInputOwner = ( const pending = yield* admission.withPermit( Effect.sync(() => { const common = [...commonPending.values()]; + const auth = [...authPending.values()]; const pooler = [...poolerPending.values()]; commonPending.clear(); commonCompleted.clear(); + authPending.clear(); + authCompleted.clear(); poolerPending.clear(); poolerCompleted.clear(); - return { common, pooler }; + return { common, auth, pooler }; }), ); yield* Effect.forEach(pending.common, (deferred) => Deferred.succeed(deferred, Exit.fail(failure("Runtime inputs were invalidated"))), ); + yield* Effect.forEach(pending.auth, (deferred) => + Deferred.succeed(deferred, Exit.fail(failure("Runtime inputs were invalidated"))), + ); yield* Effect.forEach(pending.pooler, (deferred) => Deferred.succeed(deferred, Exit.fail(failure("Runtime inputs were invalidated"))), ); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index d6a60bbf1b..e6b7ce123b 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -61,10 +61,9 @@ export interface WorkloadRuntimeInputs { /** Generation-scoped Vector config generated by the stack owner. */ readonly vectorConfigPath?: string; }>; - /** Stack-owned Edge Runtime bootstrap source and its private container target. */ + /** Stack-owned Edge Runtime bootstrap source. */ readonly functions?: Readonly<{ readonly bootstrapPath?: string; - readonly bootstrapContainerPath?: string; readonly secrets?: Readonly>; }>; /** Stack-owned native persistent data paths. Containers use their named volumes instead. */ @@ -165,16 +164,7 @@ type WorkloadRuntimeSpecDefinition = Omit< readonly binding?: WorkloadBindingName; }; }> & - Partial< - Pick< - WorkloadRuntimeSpec, - | "cwd" - | "privateEndpoint" - | "nativeProcess" - | "nativeStartupProcesses" - | "containerStartupProcesses" - > - >; + Partial>; export interface ContainerWorkloadResolution { readonly entrypoint?: string; @@ -589,7 +579,7 @@ const withRestSettings = ( runtime: WorkloadRuntimeKind, port: number, workload: PlannedWorkload, - _inputs: WorkloadRuntimeInputs = {}, + inputs: WorkloadRuntimeInputs = {}, ): Record => compactEnvironment({ ...common(workload, port), @@ -599,7 +589,7 @@ const withRestSettings = ( PGRST_DB_EXTRA_SEARCH_PATH: valueAt(state, "rest", "extra_search_path"), PGRST_DB_ANON_ROLE: "anon", PGRST_JWT_SECRET: usesResolvedJwks(state) - ? (_inputs.auth?.jwks ?? "") + ? (inputs.auth?.jwks ?? "") : secret(state, "secret:auth.settings.jwt_secret"), PGRST_SERVER_PORT: String(port), PGRST_DB_MAX_ROWS: valueAt(state, "rest", "max_rows"), @@ -1028,14 +1018,14 @@ const specs: Readonly> = { "database:database": { bindings: { primary: { containerPort: 5432 } }, args: (state, _workload, port) => databaseArgs(state, port, "native"), - env: (state, workload, port, runtime = "native", _inputs = {}) => + env: (state, workload, port, runtime = "native", inputs = {}) => compactEnvironment({ ...common(workload, port), ...capabilityEnv(state, "database", "POSTGRES"), PGDATA: runtime === "container" ? "/var/lib/postgresql/data" - : (_inputs.database?.dataPath ?? `${state.identity.projectRoot}/.supabase/db/data`), + : (inputs.database?.dataPath ?? `${state.identity.projectRoot}/.supabase/db/data`), POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), TZDIR: "/var/db/timezone/zoneinfo", }), @@ -1114,7 +1104,7 @@ const specs: Readonly> = { "storage:imgproxy": { bindings: { primary: { containerPort: 5001 } }, args: () => [], - env: (state, workload, port, runtime = "native", _inputs = {}) => ({ + env: (state, workload, port, runtime = "native") => ({ ...common(workload, port), IMGPROXY_BIND: `${runtime === "container" ? "0.0.0.0" : "127.0.0.1"}:${port}`, IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", @@ -1427,28 +1417,23 @@ export const runtimeSpecFor = (workload: PlannedWorkload): WorkloadRuntimeSpec | ...spec, containerPort: primary.containerPort, cwd: spec.cwd ?? ((state) => state.identity.projectRoot), - args: (state, currentWorkload, port, runtime = "native") => - spec.args(state, currentWorkload, port, runtime), - env: (state, currentWorkload, port, runtime = "native", inputs = {}) => - spec.env(state, currentWorkload, port, runtime, inputs), + args: spec.args, + env: spec.env, containerStartupProcesses: (state, currentWorkload, inputs = {}) => spec.containerStartupProcesses?.(state, currentWorkload, inputs) ?? [], readiness: { ...spec.readiness, binding: spec.readiness.binding ?? "primary" }, - privateEndpoint: - spec.privateEndpoint ?? - ((state, binding = "primary", runtime = "native") => - privateEndpointFor( - state, - workload.id, - spec.bindings, - binding, - runtime, - catalog.containerAlias, - )), + privateEndpoint: (state, binding = "primary", runtime = "native") => + privateEndpointFor( + state, + workload.id, + spec.bindings, + binding, + runtime, + catalog.containerAlias, + ), nativeProcess: (artifactRoot, state, currentWorkload, port, inputs = {}) => nativeProcessFor(artifactRoot, state, currentWorkload, port, spec, inputs), nativeStartupProcesses: (artifactRoot, state, currentWorkload, _port, inputs = {}) => - spec.nativeStartupProcesses?.(artifactRoot, state, currentWorkload, _port, inputs) ?? nativeStartupProcessesFor(artifactRoot, state, currentWorkload, inputs), }; }; @@ -1504,8 +1489,7 @@ export const containerResolutionFor = ( ? { bootstrap: { source: inputs.functions.bootstrapPath, - destination: - inputs.functions.bootstrapContainerPath ?? FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, + destination: FUNCTIONS_BOOTSTRAP_CONTAINER_PATH, }, } : {}), diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 10bd129662..21dc8cd188 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -41,10 +41,7 @@ import { makeSupervisor, type SupervisorRuntime } from "../supervisor/Supervisor import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import { deriveStackId } from "../identity/Identity.ts"; -const makeControlledCommandRunner = ( - options: Omit & { readonly executable?: string }, -): ContainerCommandRunner => ({ - executable: options.executable ?? "controlled-container-engine", +const makeControlledCommandRunner = (options: ContainerCommandRunner): ContainerCommandRunner => ({ run: options.run, ...(options.stream === undefined ? {} : { stream: options.stream }), }); @@ -98,7 +95,6 @@ const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { state.resources.find((resource) => resource.id === resourceId); return { kind: "docker", - executable: "controlled-container-engine", preflight: Effect.succeed({ host: "host.docker.internal" }), probe: Effect.void, inspectImage: () => @@ -214,6 +210,57 @@ const memoryLogStore = (records: LogRecord[]): LogStore => ({ read: () => Effect.succeed([]), }); +const pendingStartRace = (operation: "stop" | "remove") => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const startEntered = yield* Deferred.make(); + const releaseStart = yield* Deferred.make(); + let removeCalls = 0; + let stopCalls = 0; + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + startContainer: (id) => + Effect.gen(function* () { + yield* Deferred.succeed(startEntered, undefined); + yield* Deferred.await(releaseStart); + yield* base.startContainer(id); + }), + stopContainer: (id) => + Effect.gen(function* () { + stopCalls += 1; + yield* base.stopContainer(id); + }), + removeContainer: (id) => + Effect.gen(function* () { + removeCalls += 1; + yield* base.removeContainer(id); + }), + }, + ownerSessionId: "owner-session", + }); + const starting = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(startEntered); + yield* runtime[operation](key); + yield* Deferred.succeed(releaseStart, undefined); + const started = yield* Fiber.join(starting).pipe(Effect.exit); + expect(Exit.isFailure(started)).toBe(true); + expect(state.resources.filter((resource) => resource.kind === "workload")).toEqual([]); + expect(removeCalls).toBe(1); + expect(stopCalls).toBe(operation === "stop" ? 1 : 0); + yield* runtime.remove(key); + expect(removeCalls).toBe(1); + }); + describe("container runtime", () => { it.live("executes the exact command argv through a bounded process boundary", () => Effect.gen(function* () { @@ -921,6 +968,51 @@ describe("container runtime", () => { }), ); + it.live("cleans a resource interrupted after registration", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const readinessEntered = yield* Deferred.make(); + const readinessRelease = yield* Deferred.make(); + const cleanupEntered = yield* Deferred.make(); + let removeCalls = 0; + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + removeContainer: (id) => + Effect.gen(function* () { + removeCalls += 1; + yield* base.removeContainer(id); + yield* Deferred.succeed(cleanupEntered, undefined); + }), + }, + ownerSessionId: "owner-session", + waitForReadiness: () => + Effect.gen(function* () { + yield* Deferred.succeed(readinessEntered, undefined); + yield* Deferred.await(readinessRelease); + }), + }); + const starting = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readinessEntered); + yield* Fiber.interrupt(starting); + yield* Deferred.await(cleanupEntered); + expect(state.resources.filter((resource) => resource.kind === "workload")).toEqual([]); + expect(removeCalls).toBe(1); + yield* runtime.remove(key); + expect(removeCalls).toBe(1); + yield* Deferred.succeed(readinessRelease, undefined); + }), + ); + it.live("allows stop to interrupt a blocked container readiness", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -956,6 +1048,89 @@ describe("container runtime", () => { }), ); + it.live("does not resurrect a resource removed by a failed start", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const readinessEntered = yield* Deferred.make(); + const failReadiness = yield* Deferred.make(); + const removeEntered = yield* Deferred.make(); + const stopEntered = yield* Deferred.make(); + const releaseStop = yield* Deferred.make(); + const removed = yield* Deferred.make(); + let stopCalls = 0; + let removeCalls = 0; + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + stopContainer: (id) => + Effect.gen(function* () { + stopCalls += 1; + if (stopCalls === 1) { + yield* Deferred.succeed(stopEntered, undefined); + yield* Deferred.await(releaseStop); + } + yield* base.stopContainer(id); + }), + removeContainer: (id) => + Effect.gen(function* () { + removeCalls += 1; + if (removeCalls === 1) { + yield* Deferred.succeed(removeEntered, undefined); + } + yield* base.removeContainer(id); + yield* Deferred.succeed(removed, undefined); + }), + }, + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.succeed({ + waitForReadiness: () => + Effect.gen(function* () { + yield* Deferred.succeed(readinessEntered, undefined); + yield* Deferred.await(failReadiness); + return yield* new RuntimeDriverError({ + message: "readiness failed", + stackId: key.stackId, + workloadId: key.workloadId, + }); + }), + }), + }); + const starting = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readinessEntered); + const stopping = yield* runtime.stop(key).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(stopEntered); + yield* Deferred.succeed(failReadiness, undefined); + yield* Deferred.succeed(releaseStop, undefined); + yield* Deferred.await(removed); + expect(yield* runtime.observe(stackId)).toEqual([]); + yield* Fiber.join(stopping); + const failed = yield* Fiber.join(starting).pipe(Effect.exit); + expect(Exit.isFailure(failed)).toBe(true); + yield* runtime.remove(key); + expect(removeCalls).toBe(1); + const finalObserved = yield* runtime.observe(stackId); + expect(finalObserved).toEqual([]); + }), + ); + + it.live("does not register a container removed while start is pending", () => + pendingStartRace("remove"), + ); + + it.live("does not register a container stopped while start is pending", () => + pendingStartRace("stop"), + ); + it.live("does not let cleanup race an in-flight container creation", () => Effect.gen(function* () { const state: FakeContainerState = { @@ -995,6 +1170,56 @@ describe("container runtime", () => { }), ); + it.live("interrupts multiple pending starts during cleanup without deadlocking", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const authKey: RuntimeWorkloadKey = { ...key, workloadId: "auth:auth" }; + const databaseReadinessEntered = yield* Deferred.make(); + const authReadinessEntered = yield* Deferred.make(); + const waitForever = yield* Deferred.make(); + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + waitContainer: () => Deferred.await(waitForever).pipe(Effect.as(0)), + }, + ownerSessionId: "owner-session", + resolveWorkload: (requestKey) => + Effect.succeed({ + waitForReadiness: () => + Effect.gen(function* () { + yield* requestKey.workloadId === key.workloadId + ? Deferred.succeed(databaseReadinessEntered, undefined) + : Deferred.succeed(authReadinessEntered, undefined); + yield* Deferred.await(waitForever); + }), + }), + }); + const databaseStart = yield* runtime + .start(key, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + const authStart = yield* runtime + .start(authKey, workload()) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(databaseReadinessEntered); + yield* Deferred.await(authReadinessEntered); + + const completed = yield* runtime + .cleanup({ stackId, destroy: true }) + .pipe(Effect.as("completed"), Effect.timeout("1 second")); + expect(completed).toBe("completed"); + expect(Exit.isFailure(yield* Fiber.join(databaseStart).pipe(Effect.exit))).toBe(true); + expect(Exit.isFailure(yield* Fiber.join(authStart).pipe(Effect.exit))).toBe(true); + expect(state.resources.filter((resource) => resource.kind === "workload")).toEqual([]); + }), + ); + it.live("does not adopt an exact main container when starting a new session", () => Effect.gen(function* () { const existing: ContainerResource = { @@ -2312,7 +2537,6 @@ describe("container runtime", () => { const supervisor = yield* makeSupervisor({ stackId: testStackId, ownerSessionId: "owner-session", - rpcRelease: "test-release", stateStore: store, context, runtime, diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index f62dca14f3..d4f7c7d952 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -295,6 +295,29 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); + it.live("returns the existing observation when an exact key is already ready", () => + withPlatform( + Effect.gen(function* () { + let launches = 0; + const runtime = yield* makeNativeRuntime({ + resolveProcess: () => + Effect.sync(() => { + launches += 1; + return processPlan(fixtureProcess(`ready-${launches}`)); + }), + waitForReadiness: () => Effect.void, + }); + const key = keyFor("already-ready"); + const first = yield* runtime.start(key, workload("already-ready")); + const second = yield* runtime.start(key, workload("already-ready")); + expect(second).toEqual(first); + expect(launches).toBe(1); + yield* runtime.stop(key); + yield* runtime.remove(key); + }), + ), + ); + it.live("keeps a shared start alive when one caller is interrupted", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 9775264cb2..83c8f5c42e 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -24,6 +24,7 @@ import type { StackRuntime } from "../public/Runtime.ts"; import { NetworkPortSchema } from "../public/Status.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackStateStore } from "../state/StackStateStore.ts"; +import { resolveSecrets } from "../state/SecretStore.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; @@ -258,7 +259,6 @@ const ownerInputContainerEngine = ( }; return { kind: "docker", - executable: "test-container-engine", preflight: Effect.sync(() => { return { host: "host.docker.internal" }; }), @@ -322,6 +322,101 @@ const ownerInputContainerEngine = ( }; describe("production runtime", () => { + it.live("validates materialized secrets from the candidate definition and values", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-candidate-", + }); + const previous = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const previousSecrets = yield* resolveSecrets( + { declarations: previous.secrets }, + undefined, + "stopped", + ); + const candidate = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + capabilities: { + storage: { + enabled: true, + settings: { + s3_protocol: { + secret_access_key: Redacted.make("candidate-secret"), + }, + }, + }, + }, + }, + }); + const candidateSecrets = yield* resolveSecrets( + { declarations: candidate.secrets }, + undefined, + "stopped", + ); + const missing = Object.fromEntries( + Object.entries(candidateSecrets.persisted).filter( + ([slot]) => slot !== "secret:storage.settings.s3_protocol.secret_access_key", + ), + ); + const current = { + value: { + ...stateFor({}, { kind: "native" }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + definition: previous.definition, + secrets: previousSecrets.persisted, + }, + } satisfies { value: PersistedStackState }; + const context = yield* Effect.context(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "candidate-secrets", + stateStore: stateStoreFor(current), + context, + ingress, + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "cached" as const, + }), + }, + logStore: memoryLogStore([]), + }); + const result = yield* runtime + .preflight({ + stackId, + state: current.value, + definition: candidate.definition, + secrets: missing, + plan: candidate.executionPlan, + }) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(error).toBeInstanceOf(StackStateInvalidError); + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("keeps cleanup available when retained logs are corrupted", () => Effect.scoped( Effect.gen(function* () { @@ -366,7 +461,6 @@ describe("production runtime", () => { const supervisor = yield* makeSupervisor({ stackId, ownerSessionId: "bad-logs-owner", - rpcRelease: "test-release", stateStore: store, context, runtime, @@ -494,6 +588,105 @@ describe("production runtime", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("defers unreachable Auth OIDC resolution until Auth activation", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-oidc-lazy-", + }); + const readinessServer = createNetServer((socket) => socket.end()); + yield* listenForNativeReadiness(readinessServer); + const address = readinessServer.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Database readiness server did not expose an address"); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + capabilities: { + auth: { + enabled: true, + settings: { + third_party: { + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, + }, + }, + }, + }, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + const current = { + value: { + ...stateFor(resolved.persisted, { kind: "container", engine: "docker" }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + privatePorts: [ + { workloadId: "database:database", binding: "primary", port: address.port }, + ], + }, + } satisfies { value: PersistedStackState }; + const database = compiled.executionPlan.workloads.find( + ({ id }) => id === "database:database", + ); + const auth = compiled.executionPlan.workloads.find(({ id }) => id === "auth:auth"); + if (database === undefined || auth === undefined) + return yield* Effect.die("Expected database and Auth workloads"); + const context = yield* Effect.context(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "oidc-lazy", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: ownerInputContainerEngine([]), + artifactPreparer: { + prepare: (_runtime, workload) => + Effect.succeed({ + workloadId: workload.id, + capability: workload.capability, + version: "test", + outcome: "cached" as const, + image: workload.selected.kind === "container" ? workload.selected.image : undefined, + }), + }, + logStore: memoryLogStore([]), + fetchJson: () => Effect.fail(new StackPreparationError({ message: "OIDC unavailable" })), + bootstrapDatabase: () => Effect.void, + }); + const databaseReady = yield* runtime.driver.start( + { stackId, workloadId: database.id }, + database, + ); + expect(databaseReady.state).toBe("ready"); + const authResult = yield* runtime.driver + .start({ stackId, workloadId: auth.id }, auth) + .pipe(Effect.exit); + expect(Exit.isFailure(authResult)).toBe(true); + if (Exit.isFailure(authResult)) { + const error = Option.getOrUndefined(Cause.findErrorOption(authResult.cause)); + expect(error).toBeInstanceOf(RuntimeDriverError); + expect(error?.message).toContain("OIDC discovery request failed"); + expect(error?.cause).toBeInstanceOf(StackPreparationError); + } + yield* runtime.driver.stop({ stackId, workloadId: database.id }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("does not retry non-retryable database bootstrap failures", () => Effect.scoped( Effect.gen(function* () { @@ -1187,7 +1380,7 @@ describe("production runtime", () => { checkoutId: root, }, definition: compiled.definition, - ports: [{ field: "database", port, intent: "exact" }], + ports: [{ field: "database", port, intent: "automatic" }], secrets, }, } satisfies { value: PersistedStackState }; @@ -1227,6 +1420,35 @@ describe("production runtime", () => { const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); expect(error).toBeInstanceOf(PortUnavailableError); } + const changedPort = port === 65_535 ? 65_534 : port + 1; + const candidates = [ + { + ...compiled.definition, + listeners: { + ...compiled.definition.listeners, + database: { ...compiled.definition.listeners.database, enabled: false }, + }, + }, + { + ...compiled.definition, + listeners: { + ...compiled.definition.listeners, + database: { ...compiled.definition.listeners.database, port: changedPort }, + }, + }, + ]; + for (const definition of candidates) { + const accepted = yield* runtime + .preflight({ + stackId, + state: current.value, + definition, + secrets, + plan: compiled.executionPlan, + }) + .pipe(Effect.exit); + expect(Exit.isSuccess(accepted)).toBe(true); + } }), ).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 90164c9381..839acef2c5 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -21,6 +21,7 @@ import type { PersistedStackState } from "../state/StackState.ts"; import { resolveSecrets } from "../state/SecretStore.ts"; import { containerAliasFor } from "../model/WorkloadCatalog.ts"; import { makeRuntimeInputOwner } from "./RuntimeInputOwner.ts"; +import { resolveContainerResolutionFor } from "./WorkloadRuntimeSpec.ts"; const stackId = StackIdSchema.make("f".repeat(64)); @@ -155,7 +156,7 @@ describe("runtime input owner", () => { : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }, ), }); - const material = yield* owner.resolve(state); + const material = yield* owner.resolve(state, "auth:auth"); // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWT fixture expect(JSON.parse(material.auth?.jwtKeys ?? "[]")).toHaveLength(2); // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWKS fixture @@ -201,7 +202,7 @@ describe("runtime input owner", () => { }, }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(state).pipe(Effect.exit); + const failed = yield* owner.resolve(state, "auth:auth").pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); }), ), @@ -233,7 +234,7 @@ describe("runtime input owner", () => { : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; }), }); - const material = yield* owner.resolve(state); + const material = yield* owner.resolve(state, "auth:auth"); expect(requested).toEqual([ "https://securetoken.google.com/demo/.well-known/openid-configuration", "https://issuer.example/keys", @@ -257,7 +258,7 @@ describe("runtime input owner", () => { }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- inspect generated JWKS fixture - const jwks = JSON.parse((yield* owner.resolve(state)).auth?.jwks ?? "{}"); + const jwks = JSON.parse((yield* owner.resolve(state, "auth:auth")).auth?.jwks ?? "{}"); expect(jwks.keys).toEqual([ { kty: "oct", @@ -305,12 +306,110 @@ describe("runtime input owner", () => { stackId, fetchJson: () => Effect.die("OIDC fetch should not run"), }); - const material = yield* owner.resolve(state); + const material = yield* owner.resolve(state, "auth:auth"); expect(material.auth).toBeUndefined(); }), ), ); + it.live("resolves only material needed by the requested workload", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-workload-scope-", + }); + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, + }, + }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: () => Effect.fail(new StackPreparationError({ message: "OIDC unavailable" })), + }); + const database = yield* owner.resolve(state, "database:database"); + expect(database.auth).toBeUndefined(); + const auth = yield* owner.resolve(state, "auth:auth").pipe(Effect.exit); + expect(Exit.isFailure(auth)).toBe(true); + }), + ), + ); + + it.live("creates the configured Functions root only for mounted workloads", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-functions-root-" }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + capabilities: { + functions: { enabled: true }, + studio: { enabled: true }, + }, + }, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + const state = stateFor(root, compiled, resolved.persisted, { + kind: "container", + engine: "docker", + }); + const withPrivatePorts: PersistedStackState = { + ...state, + privatePorts: [ + { workloadId: "database:database", binding: "primary", port: 30_001 }, + { workloadId: "functions:edge-runtime", binding: "primary", port: 30_002 }, + { workloadId: "studio:studio", binding: "primary", port: 30_003 }, + ], + }; + const functionsRoot = compiled.definition?.capabilities.functions.settings.functions_root; + if (functionsRoot === undefined || functionsRoot === null) + return yield* Effect.die("Compiled Functions root is missing"); + const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); + yield* owner.resolve(withPrivatePorts, "database:database"); + expect(yield* fs.exists(functionsRoot)).toBe(false); + + const functionsMaterial = yield* owner.resolve(withPrivatePorts, "functions:edge-runtime"); + const functions = compiled.executionPlan.workloads.find( + ({ id }) => id === "functions:edge-runtime", + ); + if (functions === undefined) return yield* Effect.die("Functions workload is missing"); + const functionsResolution = yield* resolveContainerResolutionFor( + withPrivatePorts, + functions, + functionsMaterial, + ); + expect(yield* fs.exists(functionsRoot)).toBe(true); + expect(functionsResolution?.mounts[0]?.source).toBe(functionsRoot); + + yield* fs.remove(functionsRoot, { recursive: true, force: true }); + expect(yield* fs.exists(functionsRoot)).toBe(false); + const studioMaterial = yield* owner.resolve(withPrivatePorts, "studio:studio"); + const studio = compiled.executionPlan.workloads.find(({ id }) => id === "studio:studio"); + if (studio === undefined) return yield* Effect.die("Studio workload is missing"); + const studioResolution = yield* resolveContainerResolutionFor( + withPrivatePorts, + studio, + studioMaterial, + ); + expect(yield* fs.exists(functionsRoot)).toBe(true); + expect(studioResolution?.mounts[0]?.source).toBe(functionsRoot); + }), + ), + ); + it.live("resolves JWT material when Auth is disabled but Rest remains enabled", () => withPlatform( Effect.gen(function* () { @@ -330,7 +429,7 @@ describe("runtime input owner", () => { }, }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state); + const material = yield* owner.resolve(state, "rest:rest"); expect(material.auth?.jwks).toContain('"kty":"oct"'); }), ), @@ -361,7 +460,7 @@ describe("runtime input owner", () => { stackId, fetchJson: () => Effect.fail(new StackPreparationError({ message: "transport failure" })), }); - const failed = yield* owner.resolve(state).pipe(Effect.exit); + const failed = yield* owner.resolve(state, "auth:auth").pipe(Effect.exit); const error = errorOf(failed); expect(error?.message).toContain("OIDC discovery request failed"); // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- assert sanitized diagnostic payload @@ -396,7 +495,7 @@ describe("runtime input owner", () => { : { keys: [] }, ), }); - const failed = yield* owner.resolve(state).pipe(Effect.exit); + const failed = yield* owner.resolve(state, "auth:auth").pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("contains no keys"); }), ), @@ -422,8 +521,8 @@ describe("runtime input owner", () => { { workloadId: "analytics:vector", binding: "primary", port: 30_008 }, ], }; - const first = yield* owner.resolve(nativeState, { includePooler: true }); - const second = yield* owner.resolve(nativeState, { includePooler: true }); + const first = yield* owner.resolve(nativeState, "pooler:pooler"); + const second = yield* owner.resolve(nativeState, "pooler:pooler"); const tenant = first.pooler?.tenantPath; expect(second.pooler?.tenantPath).toBe(tenant); expect(tenant).toBeDefined(); @@ -433,10 +532,11 @@ describe("runtime input owner", () => { expect(content).toContain('"external_id" => "pooler-dev"'); expect(content).toContain('"db_port" => 30001'); expect(content).not.toContain('"db_password" => ""'); - expect(first.analytics?.vectorConfigPath).toBeDefined(); + const analytics = yield* owner.resolve(nativeState, "analytics:vector"); + expect(analytics.analytics?.vectorConfigPath).toBeDefined(); yield* owner.cleanupAll; expect(yield* fs.exists(path.dirname(tenant!))).toBe(false); - expect(yield* fs.exists(first.analytics?.vectorConfigPath ?? "")).toBe(false); + expect(yield* fs.exists(analytics.analytics?.vectorConfigPath ?? "")).toBe(false); }), ), ); @@ -455,7 +555,7 @@ describe("runtime input owner", () => { ...base, privatePorts: [{ workloadId: "analytics:vector", binding: "primary", port: 30_008 }], }; - const material = yield* owner.resolve(native); + const material = yield* owner.resolve(native, "analytics:vector"); const configPath = material.analytics?.vectorConfigPath; expect(configPath).toBeDefined(); expect(yield* fs.stat(configPath!)).toMatchObject({ type: "File" }); @@ -477,7 +577,7 @@ describe("runtime input owner", () => { expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "vector"))).toBe( false, ); - const rematerialized = yield* owner.resolve(native); + const rematerialized = yield* owner.resolve(native, "analytics:vector"); expect(rematerialized.analytics?.vectorConfigPath).toBeDefined(); expect(yield* fs.exists(rematerialized.analytics?.vectorConfigPath ?? "")).toBe(true); }), @@ -498,16 +598,16 @@ describe("runtime input owner", () => { privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const first = yield* owner.resolve(nativeState, { includePooler: true }); + const first = yield* owner.resolve(nativeState, "pooler:pooler"); const tenant = first.pooler?.tenantPath; expect(tenant).toBeDefined(); yield* owner.cleanupAll; - const common = yield* owner.resolve(nativeState); + const common = yield* owner.resolve(nativeState, "database:database"); expect(common.pooler).toBeUndefined(); expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( false, ); - const recreated = yield* owner.resolve(nativeState, { includePooler: true }); + const recreated = yield* owner.resolve(nativeState, "pooler:pooler"); expect(recreated.pooler?.tenantPath).toBeDefined(); expect(yield* fs.exists(recreated.pooler?.tenantPath ?? "")).toBe(true); }), @@ -545,7 +645,7 @@ describe("runtime input owner", () => { }; const containerState = state; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(containerState, { includePooler: true }); + const material = yield* owner.resolve(containerState, "pooler:pooler"); const tenant = material.pooler?.tenantPath; expect(tenant).toBeDefined(); const content = yield* fs.readFileString(tenant!); @@ -569,7 +669,7 @@ describe("runtime input owner", () => { capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(state, { includePooler: true }).pipe(Effect.exit); + const failed = yield* owner.resolve(state, "pooler:pooler").pipe(Effect.exit); expect(Exit.isFailure(failed)).toBe(true); }), ), @@ -596,7 +696,7 @@ describe("runtime input owner", () => { blank.definition.capabilities.pooler.settings.default_pool_size = " "; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); for (const invalid of [missing, blank]) { - const failed = yield* owner.resolve(invalid, { includePooler: true }).pipe(Effect.exit); + const failed = yield* owner.resolve(invalid, "pooler:pooler").pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("sizing settings are invalid"); expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( false, @@ -606,7 +706,7 @@ describe("runtime input owner", () => { ), ); - it.live("does not publish a Pooler file when Functions validation fails", () => + it.live("does not resolve Functions material while provisioning Pooler", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -628,10 +728,10 @@ describe("runtime input owner", () => { privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(nativeState, { includePooler: true }).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); + const material = yield* owner.resolve(nativeState, "pooler:pooler"); + expect(material.pooler?.tenantPath).toBeDefined(); expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( - false, + true, ); }), ), @@ -675,11 +775,11 @@ describe("runtime input owner", () => { }); }, }); - const first = yield* Effect.forkChild(owner.resolve(native, { includePooler: true }), { + const first = yield* Effect.forkChild(owner.resolve(native, "auth:auth"), { startImmediately: true, }); yield* Deferred.await(started); - const second = yield* Effect.forkChild(owner.resolve(native, { includePooler: true }), { + const second = yield* Effect.forkChild(owner.resolve(native, "auth:auth"), { startImmediately: true, }); expect(fetches).toBe(1); @@ -690,14 +790,79 @@ describe("runtime input owner", () => { ]); expect(fetches).toBe(2); expect(firstMaterial.auth?.jwks).toBe(secondMaterial.auth?.jwks); - expect(firstMaterial.pooler?.tenantPath).toBe(secondMaterial.pooler?.tenantPath); - const cached = yield* owner.resolve(native, { includePooler: true }); - expect(cached.pooler?.tenantPath).toBe(firstMaterial.pooler?.tenantPath); + const cached = yield* owner.resolve(native, "auth:auth"); + expect(cached.auth?.jwks).toBe(firstMaterial.auth?.jwks); expect(fetches).toBe(2); }), ), ); + it.live("shares OIDC material across JWT-consuming workloads", () => + withPlatform( + Effect.gen(function* () { + const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "runtime-input-auth-shared-", + }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let fetches = 0; + const state = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }, + }, + }); + const owner = yield* makeRuntimeInputOwner({ + stateRoot: root, + stackId, + fetchJson: (url) => { + fetches += 1; + return Effect.gen(function* () { + if (fetches === 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + return url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; + }); + }, + }); + const rest = yield* Effect.forkChild(owner.resolve(state, "rest:rest"), { + startImmediately: true, + }); + yield* Deferred.await(started); + const realtime = yield* Effect.forkChild(owner.resolve(state, "realtime:realtime"), { + startImmediately: true, + }); + expect(fetches).toBe(1); + yield* Deferred.succeed(release, undefined); + const [restMaterial, realtimeMaterial] = yield* Effect.all([ + Fiber.join(rest), + Fiber.join(realtime), + ]); + expect(fetches).toBe(2); + expect(restMaterial.auth?.jwks).toBe(realtimeMaterial.auth?.jwks); + expect(restMaterial.analytics).toBeUndefined(); + expect(realtimeMaterial.analytics).toBeUndefined(); + const changedState = yield* compiledState(root, { + capabilities: { + auth: { + settings: { + third_party: { workos: { enabled: true, issuer_url: "https://other.example" } }, + }, + }, + }, + }); + yield* owner.resolve(changedState, "rest:rest"); + expect(fetches).toBe(4); + }), + ), + ); + it.live("keeps the owner alive when an in-flight waiter is interrupted", () => withPlatform( Effect.gen(function* () { @@ -732,11 +897,11 @@ describe("runtime input owner", () => { }); }, }); - const first = yield* Effect.forkChild(owner.resolve(state), { + const first = yield* Effect.forkChild(owner.resolve(state, "auth:auth"), { startImmediately: true, }); yield* Deferred.await(started); - const second = yield* Effect.forkChild(owner.resolve(state), { + const second = yield* Effect.forkChild(owner.resolve(state, "auth:auth"), { startImmediately: true, }); expect(fetches).toBe(1); @@ -745,7 +910,7 @@ describe("runtime input owner", () => { const firstMaterial = yield* Fiber.join(first); expect(firstMaterial.auth?.jwks).toContain('"keys"'); expect(fetches).toBe(2); - const cached = yield* owner.resolve(state); + const cached = yield* owner.resolve(state, "auth:auth"); expect(cached.auth?.jwks).toBe(firstMaterial.auth?.jwks); expect(fetches).toBe(2); }), @@ -787,11 +952,11 @@ describe("runtime input owner", () => { }); }, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const materializing = yield* Effect.forkChild(owner.resolve(state), { + const materializing = yield* Effect.forkChild(owner.resolve(state, "auth:auth"), { startImmediately: true, }); yield* Deferred.await(started); - const queued = yield* Effect.forkChild(owner.resolve(state), { + const queued = yield* Effect.forkChild(owner.resolve(state, "auth:auth"), { startImmediately: true, }); expect(fetches).toBe(1); @@ -815,7 +980,7 @@ describe("runtime input owner", () => { capabilities: { analytics: { settings: { gcp_jwt_path: "gcp.json" } } }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state); + const material = yield* owner.resolve(state, "analytics:analytics"); expect(material.analytics?.gcpJwtPath).toBe( path.join(yield* fs.realPath(root), "gcp.json"), ); @@ -864,7 +1029,7 @@ describe("runtime input owner", () => { }, }; const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state); + const material = yield* owner.resolve(state, "analytics:analytics"); expect(material.analytics).toBeUndefined(); expect(material.functions).toBeUndefined(); }), @@ -954,7 +1119,7 @@ describe("runtime input owner", () => { }, }); const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(state); + const material = yield* owner.resolve(state, "functions:edge-runtime"); expect(material.functions?.secrets).toEqual({ API_TOKEN: "actual-value" }); const invalid = yield* compiledState(root, { capabilities: { @@ -966,7 +1131,7 @@ describe("runtime input owner", () => { }, }, }); - const failed = yield* owner.resolve(invalid).pipe(Effect.exit); + const failed = yield* owner.resolve(invalid, "functions:edge-runtime").pipe(Effect.exit); expect(errorOf(failed)?.message).toContain("secret name is reserved"); }), ), diff --git a/packages/stack/src/state/PortCoordinator.ts b/packages/stack/src/state/PortCoordinator.ts index 3dc707d49e..12b8b375d0 100644 --- a/packages/stack/src/state/PortCoordinator.ts +++ b/packages/stack/src/state/PortCoordinator.ts @@ -3,6 +3,8 @@ import { Cause, Crypto, Effect, Exit, FileSystem, Option, Path, Scope, Schema } import type { Server as HttpServer } from "node:http"; // oxlint-disable-next-line effecttsgo/node-builtin-import import type { Server as NetServer } from "node:net"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import type { Duplex } from "node:stream"; import { NetworkPortSchema, PORT_FIELDS, type PortField } from "../public/Status.ts"; import { PortAllocationError, @@ -40,6 +42,12 @@ export interface HostListener { readonly close: Effect.Effect; /** The exact bound listener may be adopted by a gateway without rebind. */ readonly binding: HostListenerBinding; + /** Sockets accepted since bind, shared with an adopting gateway for teardown. */ + readonly connections: HostListenerConnections; +} + +export interface HostListenerConnections { + readonly sockets: Set; } type HostListenerBinding = diff --git a/packages/stack/src/state/ports.integration.test.ts b/packages/stack/src/state/ports.integration.test.ts index 0560fae8bd..9f994bc8c2 100644 --- a/packages/stack/src/state/ports.integration.test.ts +++ b/packages/stack/src/state/ports.integration.test.ts @@ -116,6 +116,7 @@ const makeTestHostListener = ( port, field, binding: { kind: "http", server }, + connections: { sockets: new Set() }, close, } satisfies HostListener; }), @@ -135,6 +136,7 @@ const makeTestHostListener = ( port, field, binding: { kind: "tcp", server, allowHalfOpen: true }, + connections: { sockets: new Set() }, close, } satisfies HostListener; }), diff --git a/packages/stack/src/supervisor/HostListener.ts b/packages/stack/src/supervisor/HostListener.ts index c500313caf..a5c9f17698 100644 --- a/packages/stack/src/supervisor/HostListener.ts +++ b/packages/stack/src/supervisor/HostListener.ts @@ -3,8 +3,10 @@ import { Effect, Scope } from "effect"; import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer as createNetServer, type Server as NetServer } from "node:net"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import type { Duplex } from "node:stream"; import { PortUnavailableError } from "../public/Errors.ts"; -import { type HostListener } from "../state/PortCoordinator.ts"; +import { type HostListener, type HostListenerConnections } from "../state/PortCoordinator.ts"; import { PORT_FIELD_PROTOCOL, type PortField } from "../public/Status.ts"; export interface HostListenerBindOptions { @@ -19,18 +21,50 @@ export interface HostListenerBindOptions { ) => void; } -const closeServer = (server: HttpServer | NetServer): Effect.Effect => +interface ConnectionTracker extends HostListenerConnections { + readonly detach: () => void; +} + +const trackConnections = (server: HttpServer | NetServer): ConnectionTracker => { + const sockets = new Set(); + const onConnection = (socket: Duplex) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }; + server.on("connection", onConnection); + return { sockets, detach: () => server.off("connection", onConnection) }; +}; + +const closeServer = ( + server: HttpServer | NetServer, + tracker: ConnectionTracker, +): Effect.Effect => Effect.callback((resume) => { - if (!server.listening) { + const destroyConnections = () => { + for (const socket of tracker.sockets) socket.destroy(); + }; + const finish = () => { + tracker.detach(); resume(Effect.void); + }; + destroyConnections(); + if (!server.listening) { + finish(); return; } - server.close(() => resume(Effect.void)); + server.close(finish); return Effect.sync(() => { + destroyConnections(); + tracker.detach(); if (server.listening) server.close(() => undefined); }); }); +interface BoundServer { + readonly server: T; + readonly connections: ConnectionTracker; +} + const bind = ( server: T, address: string, @@ -38,23 +72,29 @@ const bind = ( field: string, listen: HostListenerBindOptions["listen"] = (value, host, number, onListening) => value.listen({ host, port: number }, onListening), -): Effect.Effect => - Effect.callback((resume) => { +): Effect.Effect, PortUnavailableError, Scope.Scope> => + Effect.callback, PortUnavailableError>((resume) => { + const tracker = trackConnections(server); let settled = false; const cleanup = () => { server.off("error", onError); }; - const onError = (cause: Error) => { - if (settled) return; - settled = true; + const teardown = () => { cleanup(); + tracker.detach(); const swallow = () => undefined; server.once("error", swallow); try { + for (const socket of tracker.sockets) socket.destroy(); server.close(() => server.off("error", swallow)); } catch { server.off("error", swallow); } + }; + const onError = (cause: Error) => { + if (settled) return; + settled = true; + teardown(); resume( Effect.fail( new PortUnavailableError({ @@ -72,7 +112,7 @@ const bind = ( if (settled) return; settled = true; cleanup(); - resume(Effect.succeed(server)); + resume(Effect.succeed({ server, connections: tracker })); }); } catch (cause) { onError(cause instanceof Error ? cause : new Error(String(cause))); @@ -80,18 +120,11 @@ const bind = ( return Effect.sync(() => { if (settled) return; settled = true; - cleanup(); // `listen` may still be completing when the waiting fiber is interrupted. Keep a temporary // error sink while asking Node to close the exact server so a late bind failure cannot become // an uncaught process error. `close` throws synchronously when no handle exists; in that case // the server has no owned resources left to release. - const swallow = () => undefined; - server.once("error", swallow); - try { - server.close(() => server.off("error", swallow)); - } catch { - server.off("error", swallow); - } + teardown(); }); }); @@ -101,7 +134,11 @@ export const checkHostPort = ( port: number, field: string, ): Effect.Effect => - Effect.scoped(bind(createNetServer(), address, port, field).pipe(Effect.flatMap(closeServer))); + Effect.scoped( + bind(createNetServer(), address, port, field).pipe( + Effect.flatMap(({ server, connections }) => closeServer(server, connections)), + ), + ); /** Bind and retain one public host listener for direct adoption by a gateway. */ export const bindHostListener = ( @@ -125,15 +162,16 @@ export const bindHostListenerWithOptions = ( field, options.listen, ).pipe( - Effect.flatMap((server) => + Effect.flatMap(({ server, connections }) => Effect.gen(function* () { - const close = yield* Effect.cached(closeServer(server)); + const close = yield* Effect.cached(closeServer(server, connections)); yield* Effect.addFinalizer(() => close); return { field, address, port, close, + connections, binding: { kind: "http", server }, } satisfies HostListener; }), @@ -146,15 +184,16 @@ export const bindHostListenerWithOptions = ( field, options.listen, ).pipe( - Effect.flatMap((server) => + Effect.flatMap(({ server, connections }) => Effect.gen(function* () { - const close = yield* Effect.cached(closeServer(server)); + const close = yield* Effect.cached(closeServer(server, connections)); yield* Effect.addFinalizer(() => close); return { field, address, port, close, + connections, binding: { kind: "tcp", server, allowHalfOpen: true }, } satisfies HostListener; }), diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 3923bfe195..0084fbecd3 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -9,13 +9,16 @@ import type { } from "../gateway/Gateway.ts"; import { GatewayActivationError, + PortUnavailableError, StackPreparationError, type StackError, } from "../public/Errors.ts"; +import type { PortField } from "../public/Status.ts"; import { routeCatalogFor, type GatewayApiMaterial } from "../gateway/RouteCatalog.ts"; import { GatewayRouteNotFoundError, makeGateway } from "../gateway/Gateway.ts"; import { makePortCoordinator, + type HostListener, type ListenerIntents, type PortReservation, } from "../state/PortCoordinator.ts"; @@ -58,6 +61,17 @@ export interface SupervisorIngressOptions { readonly stateRoot: string; readonly store: StackStateStore; readonly context: Context.Context; + /** Optional host-port seams for embedding tests; production uses native listeners. */ + readonly checkHostPort?: ( + address: string, + port: number, + field: string, + ) => Effect.Effect; + readonly bindHost?: ( + address: string, + port: number, + field: PortField, + ) => Effect.Effect; /** Resolver may be replaced by the production credential owner. */ readonly apiMaterial?: ( state: LifecycleInput["state"], @@ -170,8 +184,8 @@ export const makeSupervisorIngress = ( const coordinator = makePortCoordinator({ stateRoot: options.stateRoot, store: options.store, - checkHostPort, - bindHost: bindHostListener, + checkHostPort: options.checkHostPort ?? checkHostPort, + bindHost: options.bindHost ?? bindHostListener, }); const acquire = ( input: LifecycleInput, @@ -191,6 +205,9 @@ export const makeSupervisorIngress = ( .pipe( Effect.provideContext(options.context), Effect.provideService(Scope.Scope, reservationScope), + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : Scope.close(reservationScope, exit), + ), ); const owned: SupervisorIngressReservation = { ...reservation, diff --git a/packages/stack/src/supervisor/LaunchProtocol.ts b/packages/stack/src/supervisor/LaunchProtocol.ts index 0160aff030..26540ea334 100644 --- a/packages/stack/src/supervisor/LaunchProtocol.ts +++ b/packages/stack/src/supervisor/LaunchProtocol.ts @@ -2,16 +2,6 @@ import { Schema } from "effect"; import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; import { StackIdSchema } from "../public/StackId.ts"; -const IdentitySchema = Schema.Struct({ - projectRoot: Schema.String, - checkoutRoot: Schema.String, - workspaceId: Schema.String, - checkoutId: Schema.String, - branchContext: Schema.String, - localProjectKey: Schema.String, - stackName: Schema.String, -}); - /** JSON payload shared by the detached launcher and Supervisor entrypoint. */ export const SupervisorArgsSchema = Schema.Struct({ stateRoot: Schema.String, @@ -20,8 +10,6 @@ export const SupervisorArgsSchema = Schema.Struct({ platform: Schema.Literals(["posix", "windows"] as const), stackId: StackIdSchema, ownerSessionId: OwnerSessionIdSchema, - rpcRelease: Schema.String, - identity: IdentitySchema, }); export type SupervisorArgs = Schema.Schema.Type; diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 365d2eb6fe..dba3e11bfd 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -16,7 +16,6 @@ import { import { fileURLToPath } from "node:url"; import { ChildProcess } from "effect/unstable/process"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type { StackIdentity } from "../identity/Identity.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { StackIdSchema, type StackId } from "../public/StackId.ts"; import { @@ -80,6 +79,12 @@ type ChildResult = | { readonly kind: "ownership-conflict"; readonly message: string } | { readonly kind: "failed"; readonly message: string }; +export interface EnsureSupervisorResult { + readonly owner: OwnerMetadata; + /** True only when this caller won the launch race and owns the new session. */ + readonly launched: boolean; +} + const mapFailure = (message: string) => new StackStateInvalidError({ message }); const SUPERVISOR_READINESS_TIMEOUT_MS = 30_000; const SUPERVISOR_REREAD_INTERVAL_MS = 25; @@ -88,7 +93,6 @@ const SUPERVISOR_READINESS_REREAD_TIMES = Math.floor( ); export interface LauncherOptions { - readonly identity: StackIdentity; readonly stackId: StackId; readonly stateStore: StackStateStore; readonly environment: StackRuntimeEnvironmentValue; @@ -220,7 +224,7 @@ const launchAndAwait = ( payload: SupervisorArgs, paths: { readonly stackRoot: string }, ): Effect.Effect< - OwnerMetadata, + EnsureSupervisorResult, StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > => @@ -276,7 +280,7 @@ const launchAndAwait = ( return yield* mapFailure("Supervisor readiness arrived before owner metadata"); if ( metadata.ownerSessionId !== ready.ownerSessionId || - metadata.rpcRelease !== payload.rpcRelease + metadata.rpcRelease !== STACK_RPC_RELEASE ) return yield* mapFailure("Supervisor readiness metadata identity mismatch"); return { kind: "ready", metadata } satisfies ChildResult; @@ -308,7 +312,7 @@ const launchAndAwait = ( }); const preserveChildConflict = () => Effect.fail(childConflict); return yield* validateCompatibleOwner(options, current).pipe( - Effect.as(current), + Effect.map(() => ({ owner: current, launched: false }) satisfies EnsureSupervisorResult), Effect.catchTags({ StackStateInvalidError: preserveChildConflict, StackOwnershipConflictError: preserveChildConflict, @@ -330,6 +334,7 @@ const launchAndAwait = ( ), ), }), + Effect.map((owner) => ({ owner, launched: false }) satisfies EnsureSupervisorResult), ); } yield* Fiber.interrupt(ownerFiber); @@ -337,7 +342,10 @@ const launchAndAwait = ( yield* terminateLaunch; return yield* mapFailure(childResult.message); } - return childResult.metadata; + return { + owner: childResult.metadata, + launched: true, + } satisfies EnsureSupervisorResult; }).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(mapFailure(error.message)))); /** @@ -347,7 +355,7 @@ const launchAndAwait = ( export const ensureSupervisor = ( options: LauncherOptions, ): Effect.Effect< - OwnerMetadata, + EnsureSupervisorResult, StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, | FileSystem.FileSystem | Path.Path @@ -378,7 +386,7 @@ export const ensureSupervisor = ( // malformed metadata remains fail-closed in readOwnerMetadata. Effect.catchTag("StackStateInvalidError", () => Effect.succeed(false)), ); - if (compatible) return existing; + if (compatible) return { owner: existing, launched: false } satisfies EnsureSupervisorResult; } const stackId = yield* Schema.decodeEffect(StackIdSchema)(options.stackId).pipe( Effect.mapError((error) => mapFailure(`Invalid StackId: ${String(error)}`)), @@ -402,8 +410,6 @@ export const ensureSupervisor = ( platform: options.environment.platform, stackId: options.stackId, ownerSessionId, - rpcRelease: STACK_RPC_RELEASE, - identity: options.identity, }; yield* (yield* FileSystem.FileSystem) .makeDirectory(paths.runtime, { recursive: true }) @@ -413,6 +419,6 @@ export const ensureSupervisor = ( ), ); const owner = yield* launchAndAwait(options, payload, { stackRoot: paths.stackRoot }); - yield* validateCompatibleOwner(options, owner); + if (!owner.launched) yield* validateCompatibleOwner(options, owner.owner); return owner; }); diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 888d1c1df7..74bd71ab68 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -178,7 +178,6 @@ const stateWithCandidate = ( desiredLifecycle, definition: candidate.definition, secrets: candidate.secrets, - ports: state.ports, }); /** Creates one Supervisor-local lifecycle owner. Mutable coordination is allocated per Effect run. */ diff --git a/packages/stack/src/supervisor/LogStore.ts b/packages/stack/src/supervisor/LogStore.ts index b9081f2547..a2224a3a5d 100644 --- a/packages/stack/src/supervisor/LogStore.ts +++ b/packages/stack/src/supervisor/LogStore.ts @@ -177,7 +177,11 @@ const readDocument = ( ).pipe( Effect.flatMap((entries) => { const nextCursor = entries.reduce( - (next, entry) => Math.max(next, Number.parseInt(entry.cursor.opaque.slice(3), 36) + 1), + (next, entry) => + Math.max( + next, + Number.parseInt(entry.cursor.opaque.slice(CURSOR_PREFIX.length), 36) + 1, + ), 1, ); return validateDocument(sourcePath, { nextCursor, entries }); @@ -369,56 +373,67 @@ export const makeLogStore = ( const semaphore = yield* Semaphore.make(1); const append = (record: LogRecord) => semaphore.withPermit( - Effect.gen(function* () { - const cursor = opaqueCursor(document.nextCursor); - const entry = { - cursor, - timestamp: record.timestamp ?? DateTime.formatIso(DateTime.nowUnsafe()), - source: record.source, - stream: record.stream, - message: redactKnownSecrets(record.message, knownSecrets), - } satisfies StackLogEntry; - const next = { - nextCursor: document.nextCursor + 1, - entries: [...document.entries, entry], - }; - const encoded = yield* encodedEntry(entry, options.path); - const entryBytes = new TextEncoder().encode(`${encoded}\n`).byteLength; - if (entryBytes > maxBytes) return entry; - const nextBytes = retainedBytes + entryBytes; - const withinCompactionWindow = - document.entries.length + 1 <= maxEntries * COMPACTION_FACTOR && - nextBytes <= maxBytes * COMPACTION_FACTOR; - if (withinCompactionWindow) { - const nextVisible = - next.entries.length <= maxEntries && nextBytes <= maxBytes - ? next.entries - : (yield* bounded(next, maxEntries, maxBytes, options.path, nextBytes)).document - .entries; - yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(options.path, { flag: "a", mode: 0o600 }); - yield* file.writeAll(new TextEncoder().encode(`${encoded}\n`)); - }), - ).pipe( - Effect.mapError((error) => - error instanceof LogStoreError - ? error - : fileError(options.path, "Unable to append retained logs", error), - ), - ); - document = next; - retainedBytes = nextBytes; - visibleEntries = nextVisible; - } else { - const boundedNext = yield* bounded(next, maxEntries, maxBytes, options.path, nextBytes); - yield* persist(fs, options.path, boundedNext.document.entries); - document = boundedNext.document; - retainedBytes = boundedNext.bytes; - visibleEntries = boundedNext.document.entries; - } - return entry; - }), + // Persisting a record and advancing the in-memory cursor are one transaction. Without + // the mask, shutdown can interrupt after the file write but before `document = next`, + // causing the next append to reuse a cursor already present on disk. + Effect.uninterruptible( + Effect.gen(function* () { + const cursor = opaqueCursor(document.nextCursor); + const entry = { + cursor, + timestamp: record.timestamp ?? DateTime.formatIso(DateTime.nowUnsafe()), + source: record.source, + stream: record.stream, + message: redactKnownSecrets(record.message, knownSecrets), + } satisfies StackLogEntry; + const next = { + nextCursor: document.nextCursor + 1, + entries: [...document.entries, entry], + }; + const encoded = yield* encodedEntry(entry, options.path); + const entryBytes = new TextEncoder().encode(`${encoded}\n`).byteLength; + if (entryBytes > maxBytes) return entry; + const nextBytes = retainedBytes + entryBytes; + const withinCompactionWindow = + document.entries.length + 1 <= maxEntries * COMPACTION_FACTOR && + nextBytes <= maxBytes * COMPACTION_FACTOR; + if (withinCompactionWindow) { + const nextVisible = + next.entries.length <= maxEntries && nextBytes <= maxBytes + ? next.entries + : (yield* bounded(next, maxEntries, maxBytes, options.path, nextBytes)).document + .entries; + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(options.path, { flag: "a", mode: 0o600 }); + yield* file.writeAll(new TextEncoder().encode(`${encoded}\n`)); + }), + ).pipe( + Effect.mapError((error) => + error instanceof LogStoreError + ? error + : fileError(options.path, "Unable to append retained logs", error), + ), + ); + document = next; + retainedBytes = nextBytes; + visibleEntries = nextVisible; + } else { + const boundedNext = yield* bounded( + next, + maxEntries, + maxBytes, + options.path, + nextBytes, + ); + yield* persist(fs, options.path, boundedNext.document.entries); + document = boundedNext.document; + retainedBytes = boundedNext.bytes; + visibleEntries = boundedNext.document.entries; + } + return entry; + }), + ), ); const read = (readOptions?: LogStoreQuery) => diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 4246ced506..787e01e421 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -53,7 +53,12 @@ import { } from "./Lifecycle.ts"; import { EMPTY_LOG_CURSOR, selectLogBatch, type LogStore } from "./LogStore.ts"; import type { SupervisorIngress } from "./Ingress.ts"; -import { StackRpcGroup, type StackRpcError, type StackRpcHandlers } from "../control/StackRpc.ts"; +import { + STACK_RPC_RELEASE, + StackRpcGroup, + type StackRpcError, + type StackRpcHandlers, +} from "../control/StackRpc.ts"; import type { MaintenanceResponse } from "../control/MaintenanceProtocol.ts"; import { statusFor, type ActualPhase } from "./StatusProjection.ts"; import { @@ -107,13 +112,16 @@ export interface Supervisor { export type SupervisorOptions = { readonly stackId: StackId; readonly ownerSessionId: string; - readonly rpcRelease: string; readonly stateStore: StackStateStore; readonly context: Context.Context; readonly runtime: SupervisorRuntime; }; const rpcError = (tag: StackRpcError["tag"], message: string): StackRpcError => ({ tag, message }); +const credentialsUnavailable = rpcError( + "StackNotRunningError", + "Stack credentials are unavailable", +); const stateErrorMessage = (error: StackError | { readonly message?: string }): string => typeof error.message === "string" ? error.message : "Stack operation failed"; @@ -715,7 +723,7 @@ export const makeSupervisor = ( op: "probe", ownerSessionId: options.ownerSessionId, stackId: options.stackId, - rpcRelease: options.rpcRelease, + rpcRelease: STACK_RPC_RELEASE, } satisfies MaintenanceResponse), stop: stopWithShutdown.pipe( Effect.provideContext(options.context), @@ -744,9 +752,7 @@ export const makeSupervisor = ( actualPhase !== "running" || state.desiredLifecycle !== "running" ) - return yield* Effect.fail( - rpcError("StackNotRunningError", "Stack credentials are unavailable"), - ); + return yield* Effect.fail(credentialsUnavailable); const definition = state.definition; const databaseListener = definition?.listeners.database; @@ -757,15 +763,10 @@ export const makeSupervisor = ( !databaseListener.enabled || databaseAssignment === undefined ) - return yield* Effect.fail( - rpcError("StackNotRunningError", "Stack credentials are unavailable"), - ); + return yield* Effect.fail(credentialsUnavailable); const auth = definition.capabilities.auth; - if (!auth.enabled) - return yield* Effect.fail( - rpcError("StackNotRunningError", "Stack credentials are unavailable"), - ); + if (!auth.enabled) return yield* Effect.fail(credentialsUnavailable); const requiredSecret = (slot: string): Effect.Effect => { const value = state.secrets[slot]?.value; @@ -806,9 +807,7 @@ export const makeSupervisor = ( const apiListener = definition.listeners.api; const apiAssignment = state.ports.find(({ field }) => field === "api"); if (apiListener === undefined || !apiListener.enabled || apiAssignment === undefined) - return yield* Effect.fail( - rpcError("StackNotRunningError", "Stack credentials are unavailable"), - ); + return yield* Effect.fail(credentialsUnavailable); const accessKeyId = s3.access_key_id; const region = s3.region; if ( diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 06b7521ea9..27cca76247 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -126,7 +126,6 @@ const quoteModuleSpecifier = (value: string): string => const fakeContainerEngine = (kind: "docker" | "podman", calls: string[]): ContainerEngine => ({ kind, - executable: kind, preflight: Effect.succeed({ host: "host.containers.internal" }), probe: Effect.sync(() => { calls.push(`${kind}:probe`); @@ -178,7 +177,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { tempRoot: project, }; const stack = yield* createStack({ projectRoot: project }); - const identity = yield* resolveStackIdentity({ projectRoot: project }); const store = yield* makeStackStateStore({ stateRoot: env.stateRoot }); const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); const owner = { @@ -214,7 +212,6 @@ describe("managed stack handles", { timeout: 30_000 }, () => { ), ); const result = yield* ensureSupervisor({ - identity, stackId: stack.id, stateStore: store, environment: runtime, diff --git a/packages/stack/src/supervisor/host-listener.integration.test.ts b/packages/stack/src/supervisor/host-listener.integration.test.ts index fe8f76eeb0..cd537864d0 100644 --- a/packages/stack/src/supervisor/host-listener.integration.test.ts +++ b/packages/stack/src/supervisor/host-listener.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Fiber, Option } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer, type Server as HttpServer } from "node:http"; +import { connect as connectNet, type Server as NetServer, type Socket } from "node:net"; import { PortUnavailableError } from "../public/Errors.ts"; import { bindHostListener, bindHostListenerWithOptions } from "./HostListener.ts"; @@ -73,4 +74,48 @@ describe("host listener binding", () => { }), ), ); + + it.live("closes connections accepted before gateway adoption", () => + run( + Effect.gen(function* () { + const http = yield* bindHostListener("127.0.0.1", 0, "api"); + const tcp = yield* bindHostListener("127.0.0.1", 0, "database"); + const connect = (server: HttpServer | NetServer) => + Effect.callback((resume) => { + const address = server.address(); + if (typeof address !== "object" || address === null) { + resume(Effect.die("listener did not expose an address")); + return; + } + const socket = connectNet(address.port, "127.0.0.1"); + socket.once("connect", () => resume(Effect.succeed(socket))); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => socket.destroy()); + }); + const awaitClose = (socket: Socket) => + Effect.callback((resume) => { + if (socket.destroyed) { + resume(Effect.void); + return; + } + socket.once("close", () => resume(Effect.void)); + return Effect.sync(() => socket.destroy()); + }); + const httpSocket = yield* connect(http.binding.server); + const tcpSocket = yield* connect(tcp.binding.server); + const httpClosed = yield* Effect.forkChild(awaitClose(httpSocket), { + startImmediately: true, + }); + yield* http.close; + yield* Fiber.join(httpClosed); + const tcpClosed = yield* Effect.forkChild(awaitClose(tcpSocket), { + startImmediately: true, + }); + yield* tcp.close; + yield* Fiber.join(tcpClosed); + expect(httpSocket.destroyed).toBe(true); + expect(tcpSocket.destroyed).toBe(true); + }), + ), + ); }); diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 095d683f70..4303acc68f 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Context, Crypto, Effect, Exit, FileSystem, Path } from "effect"; +import { Context, Crypto, Effect, Exit, FileSystem, Path, Ref, Scope } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer as createHttpServer, @@ -9,12 +9,19 @@ import { type ServerResponse, // oxlint-disable-next-line effecttsgo/node-builtin-import } from "node:http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import type { Duplex } from "node:stream"; import { deriveStackId, type StackIdentity } from "../identity/Identity.ts"; import { compileStack } from "../model/Compiler.ts"; -import { GatewayActivationError, StackPreparationError } from "../public/Errors.ts"; +import { + GatewayActivationError, + PortUnavailableError, + StackPreparationError, +} from "../public/Errors.ts"; import { makeStackStateStore } from "../state/StackStateStore.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { makeSupervisorIngress } from "./Ingress.ts"; +import type { HostListener } from "../state/PortCoordinator.ts"; import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; const identity: StackIdentity = { @@ -68,6 +75,109 @@ const request = (port: number, path = "/rest/v1/items", method = "GET") => }); describe("Supervisor ingress", () => { + it.live("closes reservation scopes after repeated failed acquire attempts", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const context = Context.make(FileSystem.FileSystem, fs).pipe( + Context.add(Path.Path, path), + Context.add(Crypto.Crypto, crypto), + ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-ingress-scope-" }); + const projectRoot = path.join(root, "project"); + yield* fs.makeDirectory(projectRoot); + const stackIdentity = { + ...identity, + projectRoot, + checkoutRoot: projectRoot, + workspaceId: projectRoot, + checkoutId: projectRoot, + }; + const stackId = yield* deriveStackId(stackIdentity); + const databasePort = 50_000 + (Number.parseInt(stackId.slice(0, 4), 16) % 10_000); + const compiled = yield* compileStack({ + projectRoot, + runtime: { kind: "native" }, + config: { listeners: { database: { port: databasePort } } }, + }); + const store = yield* makeStackStateStore({ + stateRoot: path.join(root, "managed", "stacks"), + }); + yield* store.initialize(stackId, { + format: "supabase-stack-state-v1", + identity: { ...stackIdentity, stackId }, + runtime: { kind: "native" }, + desiredLifecycle: "running", + definition: compiled.definition, + ports: [], + privatePorts: privateBindingIntentsFor(compiled.executionPlan).map((binding, index) => ({ + ...binding, + port: 30_000 + index, + })), + secrets: {}, + }); + const apiCloseCount = yield* Ref.make(0); + const checkHostPort = () => Effect.void; + const bindHost = ( + address: string, + port: number, + field: HostListener["field"], + ): Effect.Effect => { + if (field === "database") + return Effect.fail( + new PortUnavailableError({ + field, + port, + message: "Injected database listener failure", + }), + ); + if (field !== "api") + return Effect.fail( + new PortUnavailableError({ + field, + port, + message: "Unexpected listener bind", + }), + ); + const close = Ref.update(apiCloseCount, (count) => count + 1); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => close); + return { + field, + address, + port, + close, + connections: { sockets: new Set() }, + binding: { kind: "http", server: createHttpServer() }, + } satisfies HostListener; + }); + }; + const ingress = yield* makeSupervisorIngress({ + stackId, + stateRoot: path.join(root, "managed", "stacks"), + store, + context, + checkHostPort, + bindHost, + }); + const state = yield* store.read(stackId).pipe(Effect.map((value) => value!)); + const input = { + stackId, + state, + definition: compiled.definition, + secrets: {}, + plan: compiled.executionPlan, + }; + for (let attempt = 1; attempt <= 3; attempt += 1) { + expect(Exit.isFailure(yield* ingress.acquire(input).pipe(Effect.exit))).toBe(true); + expect(yield* Ref.get(apiCloseCount)).toBe(attempt); + } + }), + ), + ); + it.live("adopts a coordinated listener and forwards a public request", () => run( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/observability.integration.test.ts b/packages/stack/src/supervisor/observability.integration.test.ts index a940f0d129..72926afc5b 100644 --- a/packages/stack/src/supervisor/observability.integration.test.ts +++ b/packages/stack/src/supervisor/observability.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Path } from "effect"; import { InvalidLogCursorError } from "../public/Errors.ts"; import { LogStoreError, makeLogStore, readRetainedLogs, selectLogBatch } from "./LogStore.ts"; @@ -135,6 +135,64 @@ describe("observability", () => { ), ); + it.live("does not reuse a cursor when an append is interrupted after its write", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-log-interrupt-" }); + const logPath = path.join(root, "logs.json"); + const writeCompleted = yield* Deferred.make(); + const interruptionRequested = yield* Deferred.make(); + const releaseWrite = yield* Deferred.make(); + const wrappedFs = { + ...fs, + open: (target: string, options?: Parameters[1]) => + fs.open(target, options).pipe( + Effect.map((file) => + target === logPath && options?.flag === "a" + ? new Proxy(file, { + get: (current, property, receiver) => + property === "writeAll" + ? (bytes: Uint8Array) => + current + .writeAll(bytes) + .pipe( + Effect.andThen(Deferred.succeed(writeCompleted, undefined)), + Effect.andThen(Deferred.await(releaseWrite)), + ) + : Reflect.get(current, property, receiver), + }) + : file, + ), + ), + } satisfies FileSystem.FileSystem; + const store = yield* makeLogStore({ path: logPath }).pipe( + Effect.provideService(FileSystem.FileSystem, wrappedFs), + ); + const interrupted = yield* Effect.forkChild( + store.append({ source: "auth", stream: "stdout", message: "interrupted" }), + ); + yield* Deferred.await(writeCompleted); + const interrupt = yield* Effect.forkChild( + Deferred.succeed(interruptionRequested, undefined).pipe( + Effect.andThen(Fiber.interrupt(interrupted)), + ), + ); + yield* Deferred.await(interruptionRequested); + yield* Deferred.succeed(releaseWrite, undefined); + yield* Fiber.join(interrupt); + const second = yield* store.append({ + source: "rest", + stream: "stderr", + message: "after interruption", + }); + expect(second.cursor.opaque).not.toBe("v1_1"); + expect((yield* store.read()).map((entry) => entry.cursor.opaque)).toEqual(["v1_1", "v1_2"]); + }), + ), + ); + it.live("keeps in-memory retention unchanged when compaction persistence fails", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index e1e405f4a5..4cf25ba5c6 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -387,7 +387,6 @@ const makeFixture = ( const supervisor = yield* makeSupervisor({ stackId: id, ownerSessionId: "owner-session", - rpcRelease: "test-release", stateStore: store, context, runtime, @@ -631,7 +630,6 @@ describe("Supervisor composition", () => { const successor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "stopped-relaunch-successor", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: { ...fixture.runtime, driver }, @@ -670,7 +668,6 @@ describe("Supervisor composition", () => { const successor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "successor-session", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -702,7 +699,6 @@ describe("Supervisor composition", () => { const successor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "successor-session", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -780,7 +776,6 @@ describe("Supervisor composition", () => { const successor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "successor-session", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -795,7 +790,6 @@ describe("Supervisor composition", () => { const retryOwner = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "retry-session", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -814,7 +808,6 @@ describe("Supervisor composition", () => { const successor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "materialization-successor", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -832,7 +825,6 @@ describe("Supervisor composition", () => { const retryOwner = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "materialization-retry", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -850,7 +842,6 @@ describe("Supervisor composition", () => { const successor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "changed-input-successor", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -911,7 +902,6 @@ describe("Supervisor composition", () => { const successor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "stopped-fence-successor", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -1322,7 +1312,6 @@ describe("Supervisor composition", () => { const failed = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "missing-state-session", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, @@ -1477,7 +1466,6 @@ describe("Supervisor composition", () => { const supervisor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "artifact-failure-supervisor", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: { ...fixture.runtime, driver }, @@ -1951,7 +1939,6 @@ describe("Supervisor composition", () => { const successor = yield* makeSupervisor({ stackId: fixture.id, ownerSessionId: "successor-session", - rpcRelease: "test-release", stateStore: fixture.store, context: fixture.context, runtime: fixture.runtime, From 102bb9cd6496ee6d06d5d7d8f8f4a29c556dd100 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 19:38:29 +0200 Subject: [PATCH 156/174] fix(stack): restore coverage provider --- packages/stack/package.json | 1 + packages/stack/vitest.config.ts | 8 ++++++++ pnpm-lock.yaml | 3 +++ 3 files changed, 12 insertions(+) diff --git a/packages/stack/package.json b/packages/stack/package.json index f2acda3cb7..b4ec0f7761 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -30,6 +30,7 @@ "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/ws": "catalog:", + "@vitest/coverage-istanbul": "catalog:", "esbuild": "^0.28.2", "typescript": "catalog:", "vitest": "catalog:", diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index c92f7b63b8..749e6468cb 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -3,6 +3,14 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { passWithNoTests: true, + coverage: { + enabled: false, + provider: "istanbul", + clean: false, + include: ["src/**/*.ts"], + reporter: ["text", "lcov"], + reportsDirectory: "coverage", + }, projects: [ { test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82dfe50f72..f136e1494d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -550,6 +550,9 @@ importers: '@types/ws': specifier: 'catalog:' version: 8.18.1 + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.11(supports-color@7.2.0)(vite@8.2.2(@types/node@26.3.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.11) esbuild: specifier: ^0.28.2 version: 0.28.2 From 6e364cf9a42254748a356c48f5a364ff1a5d440c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 19:56:57 +0200 Subject: [PATCH 157/174] test(stack): harden integration tests under CI load --- .../native-runtime.integration.test.ts | 30 ++----------------- .../supervisor/supervisor.integration.test.ts | 14 ++++----- packages/stack/vitest.config.ts | 1 + 3 files changed, 10 insertions(+), 35 deletions(-) diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index d4f7c7d952..43e31150ca 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -1033,7 +1033,6 @@ describe("native runtime", { timeout: 15_000 }, () => { const net = require("node:net"); const { spawn } = require("node:child_process"); process.stdout.on("error", () => {}); - process.on("SIGTERM", () => process.stdout.write("TARGET_SIGTERM\\n")); const server = net.createServer(); server.listen({ host: "127.0.0.1", port: 0 }, () => { const address = server.address(); @@ -1058,7 +1057,6 @@ describe("native runtime", { timeout: 15_000 }, () => { executable: ${JSON.stringify(runtime.command)}, args: ["-e", ${JSON.stringify(targetCode)}] })); - process.stdout.write("LAUNCHER_READY " + launcherProcess.pid + "\\n"); setInterval(() => {}, 1000); `; const owner = yield* ChildProcess.make(process.execPath, ["-e", ownerCode], { @@ -1072,23 +1070,16 @@ describe("native runtime", { timeout: 15_000 }, () => { Stream.runForEach((chunk) => Ref.update(stderr, (current) => current + chunk)), Effect.forkChild({ startImmediately: true }), ); - const launcherReady = yield* Deferred.make(); const targetReady = yield* Deferred.make(); const descendantReady = yield* Deferred.make(); - const targetSignal = yield* Deferred.make(); const outputFiber = yield* owner.stdout.pipe( Stream.decodeText, Stream.splitLines, Stream.runForEach((line) => Effect.gen(function* () { - if (line.startsWith("LAUNCHER_READY ")) - yield* Deferred.succeed(launcherReady, line); - else if (line.startsWith("TARGET_READY ")) - yield* Deferred.succeed(targetReady, line); + if (line.startsWith("TARGET_READY ")) yield* Deferred.succeed(targetReady, line); else if (line.startsWith("DESC_READY ")) yield* Deferred.succeed(descendantReady, line); - else if (line === "TARGET_SIGTERM") - yield* Deferred.succeed(targetSignal, undefined); }), ), Effect.forkChild({ startImmediately: true }), @@ -1105,14 +1096,11 @@ describe("native runtime", { timeout: 15_000 }, () => { }), }), ); - const launcherLine = yield* Deferred.await(launcherReady); const targetLine = yield* Deferred.await(targetReady); const targetPort = Number.parseInt(targetLine.slice("TARGET_READY ".length), 10); const descendantPort = Number.parseInt(output.slice("DESC_READY ".length), 10); expect(Number.isSafeInteger(targetPort)).toBe(true); expect(Number.isSafeInteger(descendantPort)).toBe(true); - const launcherPid = Number.parseInt(launcherLine.slice("LAUNCHER_READY ".length), 10); - expect(Number.isSafeInteger(launcherPid)).toBe(true); const targetSocket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port: targetPort }); const descendantSocket = yield* NodeSocket.makeNet({ host: "127.0.0.1", @@ -1132,22 +1120,10 @@ describe("native runtime", { timeout: 15_000 }, () => { }) .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); yield* Deferred.await(descendantClosedSignal); - expect( - spawnSync("kill", ["-TERM", `-${launcherPid}`], { encoding: "utf8" }).status, - ).toBe(0); - yield* Deferred.await(targetSignal).pipe( - Effect.timeoutOrElse({ - duration: "3 seconds", - orElse: () => - Effect.fail( - new ProcessTreeTestError({ message: "target did not receive SIGTERM" }), - ), - }), - ); yield* owner.kill({ killSignal: "SIGKILL" }); yield* Fiber.join(targetClosed).pipe( Effect.timeoutOrElse({ - duration: "3 seconds", + duration: "10 seconds", orElse: () => Effect.fail( new ProcessTreeTestError({ message: "target process tree did not close" }), @@ -1156,7 +1132,7 @@ describe("native runtime", { timeout: 15_000 }, () => { ); yield* Fiber.join(descendantClosed).pipe( Effect.timeoutOrElse({ - duration: "3 seconds", + duration: "10 seconds", orElse: () => Effect.fail( new ProcessTreeTestError({ message: "descendant process tree did not close" }), diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 4cf25ba5c6..7d25ef8cb2 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1598,16 +1598,14 @@ describe("Supervisor composition", () => { }; const first = yield* Effect.forkChild(fixture.supervisor.start({ config: firstConfig })); yield* Deferred.await(preflightStarted); - const second = yield* Effect.forkChild( - fixture.supervisor.start({ config: distinctConfig }), - ); + const secondExit = yield* fixture.supervisor + .start({ config: distinctConfig }) + .pipe(Effect.exit); + expect(Exit.isFailure(secondExit)).toBe(true); + expect(errorOf(secondExit)).toBeInstanceOf(StackLifecycleConflictError); yield* Deferred.succeed(preflightGate, undefined); - const [firstExit, secondExit] = yield* Effect.all([ - Fiber.join(first).pipe(Effect.exit), - Fiber.join(second).pipe(Effect.exit), - ]); + const firstExit = yield* Fiber.join(first).pipe(Effect.exit); expect(Exit.isFailure(firstExit)).toBe(true); - expect(errorOf(secondExit)).toBeInstanceOf(StackLifecycleConflictError); }), ), ); diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index 749e6468cb..3efa00ecbc 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ test: { name: "integration", include: ["**/*.integration.test.ts"], + testTimeout: 30_000, }, }, { From 2182c2f482ef85505542ff70e2077e74bb6211f1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 20:06:41 +0200 Subject: [PATCH 158/174] fix(stack): close late listener connections --- packages/stack/src/supervisor/HostListener.ts | 24 +++++++++++++------ .../host-listener.integration.test.ts | 21 ++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/stack/src/supervisor/HostListener.ts b/packages/stack/src/supervisor/HostListener.ts index a5c9f17698..223c120787 100644 --- a/packages/stack/src/supervisor/HostListener.ts +++ b/packages/stack/src/supervisor/HostListener.ts @@ -23,16 +23,29 @@ export interface HostListenerBindOptions { interface ConnectionTracker extends HostListenerConnections { readonly detach: () => void; + readonly close: () => void; } const trackConnections = (server: HttpServer | NetServer): ConnectionTracker => { const sockets = new Set(); + let closing = false; const onConnection = (socket: Duplex) => { + if (closing) { + socket.destroy(); + return; + } sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }; server.on("connection", onConnection); - return { sockets, detach: () => server.off("connection", onConnection) }; + return { + sockets, + close: () => { + closing = true; + for (const socket of sockets) socket.destroy(); + }, + detach: () => server.off("connection", onConnection), + }; }; const closeServer = ( @@ -40,21 +53,18 @@ const closeServer = ( tracker: ConnectionTracker, ): Effect.Effect => Effect.callback((resume) => { - const destroyConnections = () => { - for (const socket of tracker.sockets) socket.destroy(); - }; const finish = () => { tracker.detach(); resume(Effect.void); }; - destroyConnections(); + tracker.close(); if (!server.listening) { finish(); return; } server.close(finish); return Effect.sync(() => { - destroyConnections(); + tracker.close(); tracker.detach(); if (server.listening) server.close(() => undefined); }); @@ -81,11 +91,11 @@ const bind = ( }; const teardown = () => { cleanup(); + tracker.close(); tracker.detach(); const swallow = () => undefined; server.once("error", swallow); try { - for (const socket of tracker.sockets) socket.destroy(); server.close(() => server.off("error", swallow)); } catch { server.off("error", swallow); diff --git a/packages/stack/src/supervisor/host-listener.integration.test.ts b/packages/stack/src/supervisor/host-listener.integration.test.ts index cd537864d0..8d1b5fc13e 100644 --- a/packages/stack/src/supervisor/host-listener.integration.test.ts +++ b/packages/stack/src/supervisor/host-listener.integration.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Fiber, Option } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer, type Server as HttpServer } from "node:http"; +// oxlint-disable-next-line effecttsgo/node-builtin-import +import { PassThrough } from "node:stream"; import { connect as connectNet, type Server as NetServer, type Socket } from "node:net"; import { PortUnavailableError } from "../public/Errors.ts"; import { bindHostListener, bindHostListenerWithOptions } from "./HostListener.ts"; @@ -118,4 +120,23 @@ describe("host listener binding", () => { }), ), ); + + it.live("closes connections emitted while listener shutdown is in progress", () => + run( + Effect.gen(function* () { + const listener = yield* bindHostListener("127.0.0.1", 0, "api"); + if (listener.binding.kind !== "http") return yield* Effect.die("expected HTTP listener"); + const lateSocket = new PassThrough(); + const closing = yield* Effect.forkChild(listener.close, { startImmediately: true }); + listener.binding.server.emit("connection", lateSocket); + yield* Fiber.join(closing).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die("listener close timed out"), + }), + ); + expect(lateSocket.destroyed).toBe(true); + }), + ), + ); }); From 25aeaefae2d7fed9759f4042f05a4dbfd3354525 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 20:22:16 +0200 Subject: [PATCH 159/174] test(stack): await accepted listener connections --- .../src/gateway/gateway.integration.test.ts | 84 ++++++++++--------- packages/stack/src/supervisor/HostListener.ts | 24 ++---- .../host-listener.integration.test.ts | 68 +++++---------- 3 files changed, 76 insertions(+), 100 deletions(-) diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index 070aae60c6..72a5806cc6 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -310,32 +310,36 @@ describe("stack gateway", () => { withPlatform( Effect.gen(function* () { const listener = yield* bindHostListener("127.0.0.1", 0, "api"); - const address = listener.binding.server.address(); + if (listener.binding.kind !== "http") return; + const server = listener.binding.server; + const address = server.address(); if (typeof address !== "object" || address === null) return; - const socket = yield* Effect.callback((resume) => { - const client = connectNet(address.port, "127.0.0.1"); - client.once("connect", () => resume(Effect.succeed(client))); - client.once("error", (error) => resume(Effect.fail(error))); - return Effect.sync(() => client.destroy()); - }); + const accepted = yield* Effect.forkChild( + Effect.callback((resume) => { + const onConnection = (socket: Socket) => resume(Effect.succeed(socket)); + server.once("connection", onConnection); + return Effect.sync(() => server.off("connection", onConnection)); + }), + { startImmediately: true }, + ); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + const client = connectNet(address.port, "127.0.0.1"); + client.once("connect", () => resume(Effect.succeed(client))); + client.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => { + client.destroy(); + }); + }), + (client) => Effect.sync(() => client.destroy()), + ); + const socket = yield* Fiber.join(accepted).pipe(Effect.timeout("5 seconds")); const gateway = yield* makeHttpGateway({ listener, routes: [], activate: () => Effect.fail(new GatewayActivationError({ message: "not reached" })), }); - const closed = yield* Effect.forkChild( - Effect.callback((resume) => { - if (socket.destroyed) { - resume(Effect.void); - return; - } - socket.once("close", () => resume(Effect.void)); - return Effect.sync(() => socket.destroy()); - }), - { startImmediately: true }, - ); yield* gateway.close; - yield* Fiber.join(closed); expect(socket.destroyed).toBe(true); }), ), @@ -535,32 +539,36 @@ describe("stack gateway", () => { withPlatform( Effect.gen(function* () { const listener = yield* bindHostListener("127.0.0.1", 0, "database"); - const address = listener.binding.server.address(); + if (listener.binding.kind !== "tcp") return; + const server = listener.binding.server; + const address = server.address(); if (typeof address !== "object" || address === null) return; - const socket = yield* Effect.callback((resume) => { - const client = connectNet(address.port, "127.0.0.1"); - client.once("connect", () => resume(Effect.succeed(client))); - client.once("error", (error) => resume(Effect.fail(error))); - return Effect.sync(() => client.destroy()); - }); + const accepted = yield* Effect.forkChild( + Effect.callback((resume) => { + const onConnection = (socket: Socket) => resume(Effect.succeed(socket)); + server.once("connection", onConnection); + return Effect.sync(() => server.off("connection", onConnection)); + }), + { startImmediately: true }, + ); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + const client = connectNet(address.port, "127.0.0.1"); + client.once("connect", () => resume(Effect.succeed(client))); + client.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => { + client.destroy(); + }); + }), + (client) => Effect.sync(() => client.destroy()), + ); + const socket = yield* Fiber.join(accepted).pipe(Effect.timeout("5 seconds")); const gateway = yield* makeTcpGateway({ listener, routes: [], activate: () => Effect.fail(new GatewayActivationError({ message: "not reached" })), }); - const closed = yield* Effect.forkChild( - Effect.callback((resume) => { - if (socket.destroyed) { - resume(Effect.void); - return; - } - socket.once("close", () => resume(Effect.void)); - return Effect.sync(() => socket.destroy()); - }), - { startImmediately: true }, - ); yield* gateway.close; - yield* Fiber.join(closed); expect(socket.destroyed).toBe(true); }), ), diff --git a/packages/stack/src/supervisor/HostListener.ts b/packages/stack/src/supervisor/HostListener.ts index 223c120787..a5c9f17698 100644 --- a/packages/stack/src/supervisor/HostListener.ts +++ b/packages/stack/src/supervisor/HostListener.ts @@ -23,29 +23,16 @@ export interface HostListenerBindOptions { interface ConnectionTracker extends HostListenerConnections { readonly detach: () => void; - readonly close: () => void; } const trackConnections = (server: HttpServer | NetServer): ConnectionTracker => { const sockets = new Set(); - let closing = false; const onConnection = (socket: Duplex) => { - if (closing) { - socket.destroy(); - return; - } sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }; server.on("connection", onConnection); - return { - sockets, - close: () => { - closing = true; - for (const socket of sockets) socket.destroy(); - }, - detach: () => server.off("connection", onConnection), - }; + return { sockets, detach: () => server.off("connection", onConnection) }; }; const closeServer = ( @@ -53,18 +40,21 @@ const closeServer = ( tracker: ConnectionTracker, ): Effect.Effect => Effect.callback((resume) => { + const destroyConnections = () => { + for (const socket of tracker.sockets) socket.destroy(); + }; const finish = () => { tracker.detach(); resume(Effect.void); }; - tracker.close(); + destroyConnections(); if (!server.listening) { finish(); return; } server.close(finish); return Effect.sync(() => { - tracker.close(); + destroyConnections(); tracker.detach(); if (server.listening) server.close(() => undefined); }); @@ -91,11 +81,11 @@ const bind = ( }; const teardown = () => { cleanup(); - tracker.close(); tracker.detach(); const swallow = () => undefined; server.once("error", swallow); try { + for (const socket of tracker.sockets) socket.destroy(); server.close(() => server.off("error", swallow)); } catch { server.off("error", swallow); diff --git a/packages/stack/src/supervisor/host-listener.integration.test.ts b/packages/stack/src/supervisor/host-listener.integration.test.ts index 8d1b5fc13e..f1fca81eec 100644 --- a/packages/stack/src/supervisor/host-listener.integration.test.ts +++ b/packages/stack/src/supervisor/host-listener.integration.test.ts @@ -3,8 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Fiber, Option } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer, type Server as HttpServer } from "node:http"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -import { PassThrough } from "node:stream"; import { connect as connectNet, type Server as NetServer, type Socket } from "node:net"; import { PortUnavailableError } from "../public/Errors.ts"; import { bindHostListener, bindHostListenerWithOptions } from "./HostListener.ts"; @@ -83,60 +81,40 @@ describe("host listener binding", () => { const http = yield* bindHostListener("127.0.0.1", 0, "api"); const tcp = yield* bindHostListener("127.0.0.1", 0, "database"); const connect = (server: HttpServer | NetServer) => - Effect.callback((resume) => { + Effect.gen(function* () { const address = server.address(); if (typeof address !== "object" || address === null) { - resume(Effect.die("listener did not expose an address")); - return; - } - const socket = connectNet(address.port, "127.0.0.1"); - socket.once("connect", () => resume(Effect.succeed(socket))); - socket.once("error", (error) => resume(Effect.fail(error))); - return Effect.sync(() => socket.destroy()); - }); - const awaitClose = (socket: Socket) => - Effect.callback((resume) => { - if (socket.destroyed) { - resume(Effect.void); - return; + return yield* Effect.die("listener did not expose an address"); } - socket.once("close", () => resume(Effect.void)); - return Effect.sync(() => socket.destroy()); + const accepted = yield* Effect.forkChild( + Effect.callback((resume) => { + const onConnection = (socket: Socket) => resume(Effect.succeed(socket)); + server.once("connection", onConnection); + return Effect.sync(() => server.off("connection", onConnection)); + }), + { startImmediately: true }, + ); + yield* Effect.acquireRelease( + Effect.callback((resume) => { + const socket = connectNet(address.port, "127.0.0.1"); + socket.once("connect", () => resume(Effect.succeed(socket))); + socket.once("error", (error) => resume(Effect.fail(error))); + return Effect.sync(() => { + socket.destroy(); + }); + }), + (socket) => Effect.sync(() => socket.destroy()), + ); + const acceptedSocket = yield* Fiber.join(accepted).pipe(Effect.timeout("5 seconds")); + return acceptedSocket; }); const httpSocket = yield* connect(http.binding.server); const tcpSocket = yield* connect(tcp.binding.server); - const httpClosed = yield* Effect.forkChild(awaitClose(httpSocket), { - startImmediately: true, - }); yield* http.close; - yield* Fiber.join(httpClosed); - const tcpClosed = yield* Effect.forkChild(awaitClose(tcpSocket), { - startImmediately: true, - }); yield* tcp.close; - yield* Fiber.join(tcpClosed); expect(httpSocket.destroyed).toBe(true); expect(tcpSocket.destroyed).toBe(true); }), ), ); - - it.live("closes connections emitted while listener shutdown is in progress", () => - run( - Effect.gen(function* () { - const listener = yield* bindHostListener("127.0.0.1", 0, "api"); - if (listener.binding.kind !== "http") return yield* Effect.die("expected HTTP listener"); - const lateSocket = new PassThrough(); - const closing = yield* Effect.forkChild(listener.close, { startImmediately: true }); - listener.binding.server.emit("connection", lateSocket); - yield* Fiber.join(closing).pipe( - Effect.timeoutOrElse({ - duration: "10 seconds", - orElse: () => Effect.die("listener close timed out"), - }), - ); - expect(lateSocket.destroyed).toBe(true); - }), - ), - ); }); From 7c8fe2e23c8e77b2340168f1c1d22c798f3308a7 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 21:34:53 +0200 Subject: [PATCH 160/174] fix(stack): support Linux container callbacks --- packages/stack/src/functions/serve.main.ts | 1 + packages/stack/src/gateway/Gateway.ts | 17 ++- packages/stack/src/public/Testing.ts | 73 ++++++++-- .../src/public/testing.integration.test.ts | 92 ++++++++++++- .../stack/src/public/whole-stack.e2e.test.ts | 128 ++++++++++-------- packages/stack/src/runtime/ContainerEngine.ts | 44 +++++- .../stack/src/runtime/ContainerRuntime.ts | 86 +++++++----- packages/stack/src/runtime/DockerEngine.ts | 2 + packages/stack/src/runtime/PodmanEngine.ts | 2 + .../stack/src/runtime/ProductionRuntime.ts | 28 +++- .../container-runtime.integration.test.ts | 73 ++++++++++ packages/stack/src/supervisor/Ingress.ts | 65 +++++++-- .../supervisor/ingress.integration.test.ts | 46 ++++--- 13 files changed, 512 insertions(+), 145 deletions(-) diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index 8b34abc2d8..8c2e8ec4bc 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -244,6 +244,7 @@ Deno.serve({ }); return await worker.fetch(prepareUserRequest(request)); } catch (error) { + console.error("[functions] worker error", error); for (const [denoError, sbCode] of DENO_SB_ERROR_MAP.entries()) { if (denoError !== undefined && error instanceof denoError) return getResponse( diff --git a/packages/stack/src/gateway/Gateway.ts b/packages/stack/src/gateway/Gateway.ts index 7cbf9208f9..c7679b6317 100644 --- a/packages/stack/src/gateway/Gateway.ts +++ b/packages/stack/src/gateway/Gateway.ts @@ -86,14 +86,18 @@ export type GatewayRoute = GatewayProxyRoute | GatewayLocalRoute; export const isGatewayProxyRoute = (route: GatewayRoute): route is GatewayProxyRoute => route.capability !== undefined; +type GatewayHttpKey = PortField | "api:internal"; + export interface StackGateway { - readonly http: ReadonlyMap; + readonly http: ReadonlyMap; readonly tcp: ReadonlyMap; readonly close: Effect.Effect; } -interface HttpGatewayListenerOptions { +export interface HttpGatewayListenerOptions { readonly field: PortField; + /** Optional internal map key when multiple listeners serve one logical field. */ + readonly key?: GatewayHttpKey; readonly options: Omit; } @@ -115,7 +119,7 @@ export const makeGateway = ( options: StackGatewayOptions, ): Effect.Effect => Effect.gen(function* () { - const http = new Map(); + const http = new Map(); const tcp = new Map(); const closeValues = (values: Iterable<{ readonly close: Effect.Effect }>) => Effect.forEach(values, (gateway) => gateway.close.pipe(Effect.exit), { @@ -123,10 +127,11 @@ export const makeGateway = ( discard: true, }); for (const entry of options.http ?? []) { - if (http.has(entry.field)) { + const key = entry.key ?? entry.field; + if (http.has(key)) { yield* closeValues(http.values()); return yield* new GatewayActivationError({ - message: `Duplicate HTTP gateway listener ${entry.field}`, + message: `Duplicate HTTP gateway listener ${key}`, }); } const acquired = yield* Effect.exit( @@ -136,7 +141,7 @@ export const makeGateway = ( yield* closeValues(http.values()); return yield* Effect.failCause(acquired.cause); } - http.set(entry.field, acquired.value); + http.set(key, acquired.value); } for (const entry of options.tcp ?? []) { if (tcp.has(entry.field) || http.has(entry.field)) { diff --git a/packages/stack/src/public/Testing.ts b/packages/stack/src/public/Testing.ts index 140aaec1f6..c852191b8d 100644 --- a/packages/stack/src/public/Testing.ts +++ b/packages/stack/src/public/Testing.ts @@ -1,9 +1,9 @@ // oxlint-disable effecttsgo/async-function -- AsyncDisposable is the public test-resource contract. // oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource owns its exact temp directory. -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- test resource builds an isolated root. -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { NodeServices } from "@effect/platform-node"; import { Data } from "effect"; import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; @@ -45,8 +45,16 @@ export interface TestStackOperations { readonly removeRoot: (root: string) => Promise; } +type TestStackOperationsOverrides = Partial; + +const createTestProjectRoot = async (): Promise => { + const projectsRoot = join(dirname(defaultRuntimeEnvironment().stateRoot), "test-projects"); + await mkdir(projectsRoot, { recursive: true }); + return mkdtemp(join(projectsRoot, "supabase-stack-test-")); +}; + const defaultOperations: TestStackOperations = { - createRoot: () => mkdtemp(join(tmpdir(), "supabase-stack-test-")), + createRoot: createTestProjectRoot, createStack: (options, environment) => makePromiseApi(NodeServices.layer, environment).createStack(options), removeRoot: (root) => rm(root, { recursive: true, force: true }), @@ -131,6 +139,41 @@ const validateStartedStatus = ( ); }; +const STARTUP_DIAGNOSTIC_LOG_TAIL = 50; + +const withStartupDiagnostics = async (stack: PromiseStack, primary: unknown): Promise => { + const [snapshot, recentLogs] = await Promise.all([ + stack.status().catch(() => undefined), + stack.logs({ tail: STARTUP_DIAGNOSTIC_LOG_TAIL }).catch(() => undefined), + ]); + const capabilityStates = + snapshot === undefined + ? "unavailable" + : snapshot.capabilities + .map( + ({ name, state, error }) => + `${name}=${state}${error === undefined ? "" : ` (${error})`}`, + ) + .join(", "); + const logs = + recentLogs === undefined + ? "unavailable" + : recentLogs.entries + .slice(-STARTUP_DIAGNOSTIC_LOG_TAIL) + .map(({ source, stream, message }) => `${source}/${stream}: ${message}`) + .join("\n") || "none"; + const reason = primary instanceof Error ? primary.message : String(primary); + return new Error( + [ + reason, + `lifecycle=${snapshot?.lifecycle ?? "unavailable"}`, + `capabilities=${capabilityStates}`, + `recent logs:\n${logs}`, + ].join("; "), + { cause: primary }, + ); +}; + const cleanup = async ( stack: PromiseStack | undefined, root: string, @@ -165,14 +208,15 @@ const cleanup = async ( /** Internal seam used by integration tests; the package testing barrel exports only createTestStack. */ export const createTestStackWith = async ( options: CreateTestStackOptions = {}, - operations: TestStackOperations = defaultOperations, + operations: TestStackOperations | TestStackOperationsOverrides = defaultOperations, ): Promise => { - const projectRoot = await operations.createRoot(); + const resolvedOperations: TestStackOperations = { ...defaultOperations, ...operations }; + const projectRoot = await resolvedOperations.createRoot(); let stack: PromiseStack | undefined; try { if (options.setupProject !== undefined) await options.setupProject(projectRoot); const runtimeEnvironment = testRuntimeEnvironment(); - stack = await operations.createStack( + stack = await resolvedOperations.createStack( { projectRoot, name: options.name, @@ -180,25 +224,24 @@ export const createTestStackWith = async ( }, runtimeEnvironment, ); - const started = await stack.start( - options.config === undefined - ? undefined - : ({ config: options.config } satisfies PromiseStartStackOptions), - ); try { + const started = await stack.start( + options.config === undefined + ? undefined + : ({ config: options.config } satisfies PromiseStartStackOptions), + ); validateStartedStatus(started, options.config); } catch (error) { - if (error instanceof TestStackReadinessError) throw error; - throw new TestStackReadinessError({ message: String(error) }); + throw await withStartupDiagnostics(stack, error); } const resource = stack; return { ...resource, stateRoot: runtimeEnvironment.stateRoot, - [Symbol.asyncDispose]: () => cleanup(resource, projectRoot, operations), + [Symbol.asyncDispose]: () => cleanup(resource, projectRoot, resolvedOperations), }; } catch (error) { - await cleanup(stack, projectRoot, operations, error); + await cleanup(stack, projectRoot, resolvedOperations, error); throw error; } }; diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index 44a76cb5ad..9e5087cf2c 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { tmpdir } from "node:os"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- this fixture asserts exact root paths. -import { join } from "node:path"; +import { dirname, join, sep } from "node:path"; import type { PromiseStack } from "./PromiseStack.ts"; import { createTestStackWith, type TestStackOperations } from "./Testing.ts"; import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; @@ -135,13 +135,67 @@ describe("test stack resource", () => { removed.push(root); }, }; - await expect(createTestStackWith({}, operations)).rejects.toThrow( - "startup failed; retained test stack root /tmp/stack-test-failed", - ); + let failure: unknown; + try { + await createTestStackWith({}, operations); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + if (!(failure instanceof Error)) throw new Error("expected startup failure"); + expect(failure.message).toContain("startup failed"); + expect(failure.message).toContain("retained test stack root /tmp/stack-test-failed"); expect(events).toEqual(["start", "destroy"]); expect(removed).toEqual([]); }); + it("includes bounded startup diagnostics before cleanup removes a failed stack", async () => { + const events: Array = []; + const removed: Array = []; + const logQueries: Array[0]> = []; + const entries = Array.from({ length: 51 }, (_, index) => ({ + cursor: { opaque: `v1_${(index + 1).toString(36)}` }, + timestamp: "2026-01-01T00:00:00.000Z", + source: "pooler" as const, + stream: "stderr" as const, + message: index === 50 ? "pooler stderr" : `old-${index}`, + })); + const operations: TestStackOperations = { + createRoot: async () => "/tmp/stack-test-diagnostics", + createStack: async () => ({ + ...fakeStack(events), + start: async () => { + events.push("start"); + throw new Error("startup failed"); + }, + status: async () => status("starting"), + logs: async (query) => { + logQueries.push(query); + return { entries, cursor: { opaque: "v1_1" }, running: false }; + }, + }), + removeRoot: async (root) => { + removed.push(root); + }, + }; + + let failure: unknown; + try { + await createTestStackWith({}, operations); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + if (!(failure instanceof Error)) throw new Error("expected startup failure"); + expect(failure.message).toContain("startup failed"); + expect(failure.message).toContain("pooler stderr"); + expect(failure.message).not.toContain("old-0"); + expect(failure.cause).toEqual(expect.objectContaining({ message: "startup failed" })); + expect(logQueries).toEqual([{ tail: 50 }]); + expect(events).toEqual(["start", "destroy"]); + expect(removed).toEqual(["/tmp/stack-test-diagnostics"]); + }); + it("fails and cleans up when start returns before the stack is ready", async () => { const events: Array = []; const removed: Array = []; @@ -362,6 +416,36 @@ describe("test stack resource", () => { expect(removedRoots).toEqual(["/tmp/stack-test-shared-a", "/tmp/stack-test-shared-b"]); }); + it("creates auto roots under the managed state root and cleans up the exact root", async () => { + let setupRoot: string | undefined; + let createdRoot: string | undefined; + let removedRoot: string | undefined; + const stack = await createTestStackWith( + { + setupProject: async (root) => { + setupRoot = root; + }, + }, + { + createStack: async (options) => { + createdRoot = options.projectRoot; + return fakeStack([]); + }, + removeRoot: async (root) => { + removedRoot = root; + }, + }, + ); + const managedRoot = defaultRuntimeEnvironment().stateRoot; + const projectsRoot = join(dirname(managedRoot), "test-projects"); + expect(setupRoot).toBe(createdRoot); + expect(createdRoot?.startsWith(`${projectsRoot}${sep}`)).toBe(true); + if (!managedRoot.startsWith(`${tmpdir()}${sep}`)) + expect(createdRoot?.startsWith(`${tmpdir()}${sep}`)).toBe(false); + await stack[Symbol.asyncDispose](); + expect(removedRoot).toBe(createdRoot); + }); + it("retains the project root and managed state when one stack destroy fails", async () => { const roots = ["/tmp/stack-test-retained-a", "/tmp/stack-test-retained-b"]; const removedRoots: Array = []; diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 1a8d60b321..9cb9d92ae3 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -322,18 +322,23 @@ const expectRuntimeInputsAbsent = async ( } }; -const throwStudioProfileDiagnostics = async (stack: TestStack, cause: unknown): Promise => { +const throwCapabilityDiagnostics = async ( + stack: TestStack, + capabilityName: string, + operation: string, + cause: unknown, +): Promise => { let status: StackStatus | undefined; try { status = await stack.status(); } catch { // Preserve the original request failure when status is unavailable. } - const studio = status?.capabilities.find(({ name }) => name === "studio"); + const capability = status?.capabilities.find(({ name }) => name === capabilityName); const recentLogs: StackLogEntry[] = []; try { for (const entry of (await stack.logs()).entries) { - if (entry.source !== "studio" && entry.source !== "gateway") continue; + if (entry.source !== capabilityName && entry.source !== "gateway") continue; recentLogs.push(entry); if (recentLogs.length > 50) recentLogs.shift(); } @@ -341,14 +346,14 @@ const throwStudioProfileDiagnostics = async (stack: TestStack, cause: unknown): // Preserve the original request failure when logs are unavailable. } const reason = cause instanceof Error ? cause.message : String(cause); - const state = studio === undefined ? "unavailable" : studio.state; - const error = studio?.error === undefined ? "none" : studio.error; + const state = capability === undefined ? "unavailable" : capability.state; + const error = capability?.error === undefined ? "none" : capability.error; const logs = recentLogs.length === 0 ? "none" : recentLogs.map((entry) => `${entry.source}/${entry.stream}: ${entry.message}`).join("\n"); throw new Error( - `Studio profile request failed: ${reason}; Studio state=${state}; error=${error}; recent logs:\n${logs}`, + `${operation} failed: ${reason}; ${capabilityName} state=${state}; error=${error}; recent logs:\n${logs}`, { cause }, ); }; @@ -795,63 +800,70 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom // Functions are request-time discovered and call REST through SUPABASE_URL. const functionPath = `/functions/v1/${functionSlug}`; - let firstFunction: JsonObject = {}; - await activate(stack, "functions", async () => { - firstFunction = await jsonObject( - await request(api.url, functionPath, { headers: apiHeaders(credentials) }), - ); - }); - expect(firstFunction).toEqual( - expect.objectContaining({ - marker: markers.first, - functionSlug, - rows: expect.arrayContaining([{ id: 1, payload: markers.first }]), - }), - ); - await writeFile( - join(projectRoot, "supabase", "functions", functionSlug, "index.ts"), - functionSource(table, markers.second), - ); - const secondFunction = await jsonObject( - await request(api.url, functionPath, { headers: apiHeaders(credentials) }), - ); - expect(secondFunction).toEqual( - expect.objectContaining({ - marker: markers.second, - functionSlug, - rows: expect.arrayContaining([{ id: 1, payload: markers.first }]), - }), - ); - - // followLogs is a client-side poller. Capture the current cursor and subscribe - // before producing a unique console marker through the real edge runtime. - const beforeLiveLogs = await stack.logs({ capabilities: ["functions"] }); - const liveIterator = stack - .followLogs({ capabilities: ["functions"], cursor: beforeLiveLogs.cursor }) - [Symbol.asyncIterator](); try { - const liveNext = liveIterator.next(); + let firstFunction: JsonObject = {}; + await activate(stack, "functions", async () => { + firstFunction = await jsonObject( + await request(api.url, functionPath, { headers: apiHeaders(credentials) }), + ); + }); + expect(firstFunction).toEqual( + expect.objectContaining({ + marker: markers.first, + functionSlug, + rows: expect.arrayContaining([{ id: 1, payload: markers.first }]), + }), + ); await writeFile( join(projectRoot, "supabase", "functions", functionSlug, "index.ts"), - functionSource(table, markers.live), + functionSource(table, markers.second), ); - await jsonObject(await request(api.url, functionPath, { headers: apiHeaders(credentials) })); - let liveEntry = await liveNext; - for (let attempt = 0; attempt < 100; attempt += 1) { - if (liveEntry.done) break; - if (liveEntry.value.source === "functions" && liveEntry.value.message.includes(markers.live)) - break; - liveEntry = await liveIterator.next(); - } - expect(liveEntry.done).toBe(false); - if (!liveEntry.done) { - expect(liveEntry.value.source).toBe("functions"); - expect(liveEntry.value.message).toContain(markers.live); + const secondFunction = await jsonObject( + await request(api.url, functionPath, { headers: apiHeaders(credentials) }), + ); + expect(secondFunction).toEqual( + expect.objectContaining({ + marker: markers.second, + functionSlug, + rows: expect.arrayContaining([{ id: 1, payload: markers.first }]), + }), + ); + + // followLogs is a client-side poller. Capture the current cursor and subscribe + // before producing a unique console marker through the real edge runtime. + const beforeLiveLogs = await stack.logs({ capabilities: ["functions"] }); + const liveIterator = stack + .followLogs({ capabilities: ["functions"], cursor: beforeLiveLogs.cursor }) + [Symbol.asyncIterator](); + try { + const liveNext = liveIterator.next(); + await writeFile( + join(projectRoot, "supabase", "functions", functionSlug, "index.ts"), + functionSource(table, markers.live), + ); + await jsonObject(await request(api.url, functionPath, { headers: apiHeaders(credentials) })); + let liveEntry = await liveNext; + for (let attempt = 0; attempt < 100; attempt += 1) { + if (liveEntry.done) break; + if ( + liveEntry.value.source === "functions" && + liveEntry.value.message.includes(markers.live) + ) + break; + liveEntry = await liveIterator.next(); + } + expect(liveEntry.done).toBe(false); + if (!liveEntry.done) { + expect(liveEntry.value.source).toBe("functions"); + expect(liveEntry.value.message).toContain(markers.live); + } + } finally { + await liveIterator.return?.(); } - } finally { - await liveIterator.return?.(); + expect((await liveIterator.next()).done).toBe(true); + } catch (cause) { + await throwCapabilityDiagnostics(stack, "functions", "Functions flow", cause); } - expect((await liveIterator.next()).done).toBe(true); // Mailpit UI traffic lazily activates the mail capability. SMTP delivery is // covered by the explicit SMTP configuration scenario below. @@ -872,7 +884,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom }); }); } catch (cause) { - await throwStudioProfileDiagnostics(stack, cause); + await throwCapabilityDiagnostics(stack, "studio", "Studio profile request", cause); } // Pooler is a separate public TCP listener over the same database credentials. diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index 673ecfba9c..bfcc2b8c67 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -15,6 +15,8 @@ export interface ContainerPlatform { export interface ContainerHostRoute { readonly host: string; readonly gateway?: string; + /** Host address where the in-process gateway also binds for rootful Linux containers. */ + readonly bindAddress?: string; } class ContainerExecutableNotFoundError extends Data.TaggedError( "ContainerExecutableNotFoundError", @@ -151,6 +153,7 @@ export type ContainerCommand = | { readonly operation: "pull-image"; readonly image: string } | { readonly operation: "inspect-containers"; readonly stackId: StackId } | { readonly operation: "inspect-networks"; readonly stackId: StackId } + | { readonly operation: "inspect-network-gateway"; readonly id: string } | { readonly operation: "inspect-volumes"; readonly stackId: StackId } | { readonly operation: "create-network"; readonly spec: ContainerNetworkSpec } | { readonly operation: "remove-network"; readonly id: string } @@ -387,6 +390,9 @@ export interface ContainerEngineCodecs { readonly decodeNetworks: ( result: ContainerCommandResult, ) => Effect.Effect, ContainerEngineFailure>; + readonly decodeNetworkGateway: ( + result: ContainerCommandResult, + ) => Effect.Effect; readonly decodeVolumes: ( result: ContainerCommandResult, ) => Effect.Effect, ContainerEngineFailure>; @@ -522,6 +528,25 @@ export const makeContainerEngineCodecs = (options: { Effect.map((labels): ContainerResource => ({ id, name, kind: "network", labels })), ); }); + const decodeNetworkGateway: ContainerEngineCodecs["decodeNetworkGateway"] = (result) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(result.stdout).pipe( + Effect.mapError((cause) => protocol("inspect-network-gateway", cause)), + Effect.flatMap((value) => { + const entries = Array.isArray(value) ? value : [value]; + const gateway = entries.find( + (entry): entry is { readonly Gateway: string } => + typeof entry === "object" && + entry !== null && + "Gateway" in entry && + typeof entry.Gateway === "string" && + /^(?:\d{1,3}\.){3}\d{1,3}$/u.test(entry.Gateway) && + entry.Gateway.split(".").every((part: string) => Number(part) <= 255), + )?.Gateway; + return gateway === undefined + ? Effect.fail(protocol("inspect-network-gateway")) + : Effect.succeed(gateway); + }), + ); const decodeVolumes: ContainerEngineCodecs["decodeVolumes"] = (result) => decodeRows("inspect-volumes", result.stdout, 4, (values) => { const [name, stack, workload, role] = values; @@ -562,6 +587,7 @@ export const makeContainerEngineCodecs = (options: { ), decodeContainers, decodeNetworks, + decodeNetworkGateway, decodeVolumes, decodeCreate, decodeWait, @@ -587,6 +613,8 @@ export interface ContainerEngine { readonly createNetwork: ( spec: ContainerNetworkSpec, ) => Effect.Effect; + /** Resolves a rootful Docker network's IPv4 bridge gateway. */ + readonly resolveNetworkGateway?: (id: string) => Effect.Effect; readonly removeNetwork: (id: string) => Effect.Effect; readonly createVolume: ( spec: ContainerVolumeSpec, @@ -684,7 +712,9 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai }), ) : Effect.succeed( - options.platform.os === "linux" && options.platform.desktop !== true + options.platform.os === "linux" && + options.platform.desktop !== true && + options.platform.rootless !== true ? { host: "host.docker.internal", gateway: "host-gateway" } : { host: "host.docker.internal" }, ) @@ -725,6 +755,18 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai options.codecs.decodeCreate("create-network", result, spec, "network"), ), ), + resolveNetworkGateway: + options.kind === "docker" && + options.platform.os === "linux" && + options.platform.desktop !== true && + options.platform.rootless !== true && + options.platform.remote !== true + ? (id) => + check("inspect-network-gateway", { + operation: "inspect-network-gateway", + id, + }).pipe(Effect.flatMap(options.codecs.decodeNetworkGateway)) + : undefined, removeNetwork: (id) => noResult("remove-network", { operation: "remove-network", id }), createVolume: (spec) => check("create-volume", { operation: "create-volume", spec }).pipe( diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index d9f4b6a5b4..9f47fdfaf9 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -62,6 +62,10 @@ export interface ContainerRuntimeOptions { workload: PlannedWorkload, resource: ContainerResource, ) => Effect.Effect; + /** Updates container-to-host routing after the exact stack network is ready. */ + readonly onNetworkReady?: ( + network: ContainerResource, + ) => Effect.Effect; /** Persists exact container stdout/stderr lines while a workload is running. */ readonly logStore?: LogStore; } @@ -585,44 +589,19 @@ export const makeContainerRuntime = ( yield* withEngine(key, options.engine.stopContainer(existing.container)); resources.delete(resourceKey(key)); } - const resolution = yield* resolved; + const initialResolution = yield* resolved; yield* guard; - const requestedPublications = resolution.publications ?? []; - if (requestedPublications.some((publication) => !isValidPublication(publication))) + const initialPublications = initialResolution.publications ?? []; + if (initialPublications.some((publication) => !isValidPublication(publication))) return yield* toDriverError( key, new Error("Container publications must use valid loopback host ports"), ); - const publications = requestedPublications.filter(isValidPublication); - yield* withEngine(key, options.engine.preflight); - const volumeRequest = resolution.volume; - if (volumeRequest !== undefined && volumeRequest.target.length === 0) + const initialVolumeRequest = initialResolution.volume; + if (initialVolumeRequest !== undefined && initialVolumeRequest.target.length === 0) return yield* toDriverError(key, new Error("Container volume mapping is invalid")); + yield* withEngine(key, options.engine.preflight); const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); - const volumeSpec = - volumeRequest === undefined ? undefined : volumeSpecFor(key, volumeRequest); - const exactVolume = - volumeSpec === undefined - ? undefined - : entries.find( - (entry) => - entry.kind === "volume" && - entry.name === volumeSpec.name && - sameLabels(entry.labels, volumeSpec.labels), - ); - if (volumeSpec !== undefined) { - const volume = volumeSpec; - if (exactVolume === undefined) { - const sameLabel = entries.find( - (entry) => entry.kind === "volume" && sameLabels(entry.labels, volume.labels), - ); - const sameName = entries.find( - (entry) => entry.kind === "volume" && entry.name === volume.name, - ); - if (sameLabel !== undefined || sameName !== undefined) - return yield* toDriverError(key, new Error("Container volume identity collision")); - } - } const existingExact = entries.find( (entry) => entry.kind === "workload" && sameWorkloadIdentity(entry.labels, labels), ); @@ -691,6 +670,51 @@ export const makeContainerRuntime = ( options.engine.createNetwork({ name: networkName, labels: networkLabels }), )); + const routeChanged = + options.onNetworkReady === undefined + ? false + : yield* options.onNetworkReady(networkResource); + const resolution = routeChanged + ? yield* options.resolveWorkload === undefined + ? Effect.succeed({}) + : options.resolveWorkload(key, workload) + : initialResolution; + yield* guard; + const requestedPublications = resolution.publications ?? []; + if (requestedPublications.some((publication) => !isValidPublication(publication))) + return yield* toDriverError( + key, + new Error("Container publications must use valid loopback host ports"), + ); + const publications = requestedPublications.filter(isValidPublication); + const volumeRequest = resolution.volume; + if (volumeRequest !== undefined && volumeRequest.target.length === 0) + return yield* toDriverError(key, new Error("Container volume mapping is invalid")); + const volumeSpec = + volumeRequest === undefined ? undefined : volumeSpecFor(key, volumeRequest); + const exactVolume = + volumeSpec === undefined + ? undefined + : entries.find( + (entry) => + entry.kind === "volume" && + entry.name === volumeSpec.name && + sameLabels(entry.labels, volumeSpec.labels), + ); + if (volumeSpec !== undefined) { + const volume = volumeSpec; + if (exactVolume === undefined) { + const sameLabel = entries.find( + (entry) => entry.kind === "volume" && sameLabels(entry.labels, volume.labels), + ); + const sameName = entries.find( + (entry) => entry.kind === "volume" && entry.name === volume.name, + ); + if (sameLabel !== undefined || sameName !== undefined) + return yield* toDriverError(key, new Error("Container volume identity collision")); + } + } + if (volumeSpec !== undefined) { const volume = volumeSpec; if (exactVolume === undefined) diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index 4d8362c48c..e8ddc3932e 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -70,6 +70,8 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc networkFormat, ], }; + case "inspect-network-gateway": + return { args: ["network", "inspect", command.id, "--format", "{{json .IPAM.Config}}"] }; case "inspect-volumes": return { args: [ diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index 2c32bf5783..fcdf9e97d5 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -71,6 +71,8 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc networkFormat, ], }; + case "inspect-network-gateway": + return { args: ["network", "inspect", command.id, "--format", "{{json .Subnets}}"] }; case "inspect-volumes": return { args: [ diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 1658f147a0..d5d265bb86 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -517,6 +517,7 @@ export const makeProductionRuntime = ( stackId: options.stackId, fetchJson, }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const hostRoute = yield* Ref.make(undefined); const ingress = options.ingress ?? (yield* makeSupervisorIngress({ @@ -525,6 +526,8 @@ export const makeProductionRuntime = ( store: options.stateStore, context: options.context, resolveAuthTemplates: inputOwner.resolveAuthTemplates, + resolveInternalApiBindAddress: () => + Ref.get(hostRoute).pipe(Effect.map((route) => route?.bindAddress)), })); const envFiles = options.envFileOwner ?? @@ -575,8 +578,6 @@ export const makeProductionRuntime = ( options.bootstrapDatabase ?? ((state: PersistedStackState) => bootstrapDatabaseAt(state)); const artifacts = new Map(); - const hostRoute = yield* Ref.make(undefined); - const freshState = (key: Pick) => currentStateReader(options).pipe( Effect.mapError((error) => mapDriverError(key, error)), @@ -987,6 +988,29 @@ export const makeProductionRuntime = ( ), waitForReadiness, bootstrapDatabase: bootstrapWorkloadDatabase, + onNetworkReady: (network) => { + const resolveGateway = containerEngine.resolveNetworkGateway; + if (resolveGateway === undefined) return Effect.succeed(false); + return resolveGateway(network.id).pipe( + Effect.mapError( + (error) => + new RuntimeDriverError({ + message: "Unable to resolve container network gateway", + stackId: options.stackId, + workloadId: network.name, + cause: error, + }), + ), + Effect.flatMap((gateway) => + Effect.gen(function* () { + const current = yield* Ref.get(hostRoute); + if (current?.host === gateway && current.bindAddress === gateway) return false; + yield* Ref.set(hostRoute, { host: gateway, bindAddress: gateway }); + return true; + }), + ), + ); + }, logStore: logs, }); } diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 21dc8cd188..66ff71bf6e 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -414,6 +414,41 @@ describe("container runtime", () => { }), ); + it.live("resolves a rootful Docker network gateway without enabling host binds elsewhere", () => + Effect.gen(function* () { + const requests: ContainerProcessRequest[] = []; + const docker = makeDockerEngine({ + runner: makeControlledCommandRunner({ + run: (request) => + Effect.sync(() => { + requests.push(request); + return commandResult([{ Gateway: "172.18.0.1" }]); + }), + }), + platform: { os: "linux", desktop: false, rootless: false }, + }); + expect(docker.resolveNetworkGateway).toBeDefined(); + expect(yield* docker.resolveNetworkGateway!("network-id")).toBe("172.18.0.1"); + expect(requests[0]?.args).toEqual([ + "network", + "inspect", + "network-id", + "--format", + "{{json .IPAM.Config}}", + ]); + const desktop = makeDockerEngine({ + runner: makeControlledCommandRunner({ run: () => Effect.succeed(commandResult([])) }), + platform: { os: "darwin", desktop: true }, + }); + expect(desktop.resolveNetworkGateway).toBeUndefined(); + const rootless = makeDockerEngine({ + runner: makeControlledCommandRunner({ run: () => Effect.succeed(commandResult([])) }), + platform: { os: "linux", rootless: true }, + }); + expect(rootless.resolveNetworkGateway).toBeUndefined(); + }), + ); + it.live("runs only container artifacts with exact labels, private backend and RO mounts", () => Effect.gen(function* () { const commands: ContainerProcessRequest[] = []; @@ -468,6 +503,44 @@ describe("container runtime", () => { }), ); + it.live("resolves workload inputs again after the network gateway is ready", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + let routeReady = false; + let resolutions = 0; + const runtime = yield* makeContainerRuntime({ + engine: fakeContainerEngine(state), + ownerSessionId: "owner-session", + resolveWorkload: () => + Effect.sync(() => { + resolutions += 1; + return routeReady + ? { hostRoute: { host: "172.18.0.1", bindAddress: "172.18.0.1" } } + : {}; + }), + onNetworkReady: () => + Effect.sync(() => { + routeReady = true; + return true; + }), + }); + const ready = yield* runtime.start(key, workload()); + expect(ready.state).toBe("ready"); + expect(resolutions).toBe(2); + expect(state.createdSpecs.at(-1)?.hostRoute).toEqual({ + host: "172.18.0.1", + bindAddress: "172.18.0.1", + }); + yield* runtime.cleanup({ stackId, destroy: true }); + }), + ); + it.live("runs container startup migrations before the main workload", () => Effect.gen(function* () { const state: FakeContainerState = { diff --git a/packages/stack/src/supervisor/Ingress.ts b/packages/stack/src/supervisor/Ingress.ts index 0084fbecd3..4b84f2fe07 100644 --- a/packages/stack/src/supervisor/Ingress.ts +++ b/packages/stack/src/supervisor/Ingress.ts @@ -5,6 +5,7 @@ import type { GatewayRoute, GatewayProxyRoute, GatewayRouteRequest, + HttpGatewayListenerOptions, StackGateway, } from "../gateway/Gateway.ts"; import { @@ -72,6 +73,8 @@ export interface SupervisorIngressOptions { port: number, field: PortField, ) => Effect.Effect; + /** Resolves an internal host bind address for container callbacks when required. */ + readonly resolveInternalApiBindAddress?: () => Effect.Effect; /** Resolver may be replaced by the production credential owner. */ readonly apiMaterial?: ( state: LifecycleInput["state"], @@ -305,10 +308,11 @@ export const makeSupervisorIngress = ( ); }, }; - const http = reservation.hostListeners + const http: HttpGatewayListenerOptions[] = reservation.hostListeners .filter((listener) => isHttpPortField(listener.field)) .map((listener) => ({ field: listener.field, + key: listener.field, options: { listener, routes: @@ -322,6 +326,36 @@ export const makeSupervisorIngress = ( ) => routeBackend(input, reservation, route, result), }, })); + const internalApiAddress = + input.definition.listeners.api.enabled && + reservation.assignments.api !== undefined && + options.resolveInternalApiBindAddress !== undefined + ? yield* options.resolveInternalApiBindAddress() + : undefined; + let internalApi: HostListener | undefined; + if (internalApiAddress !== undefined && reservation.assignments.api !== undefined) { + internalApi = yield* (options.bindHost ?? bindHostListener)( + internalApiAddress, + reservation.assignments.api.port, + "api", + ).pipe(Effect.provideService(Scope.Scope, entry.scope)); + http.push({ + field: "api", + key: "api:internal", + options: { + listener: internalApi, + routes: + templateRoute !== undefined + ? [templateRoute, ...(catalog.http.get("api") ?? [])] + : (catalog.http.get("api") ?? []), + resolveBackend: ( + route: GatewayProxyRoute, + _request: GatewayRouteRequest, + result: ActivationResult, + ) => routeBackend(input, reservation, route, result), + }, + }); + } const tcp = reservation.hostListeners .filter((listener) => !isHttpPortField(listener.field)) .map((listener) => ({ @@ -336,18 +370,25 @@ export const makeSupervisorIngress = ( ) => routeBackend(input, reservation, route, result), }, })); - const gateway = yield* makeGateway({ - http, - tcp, - activate: (capability) => - activate(capability).pipe( - Effect.mapError((error) => - error instanceof GatewayActivationError - ? error - : new GatewayActivationError({ message: error.message, cause: error }), + const gatewayResult = yield* Effect.exit( + makeGateway({ + http, + tcp, + activate: (capability) => + activate(capability).pipe( + Effect.mapError((error) => + error instanceof GatewayActivationError + ? error + : new GatewayActivationError({ message: error.message, cause: error }), + ), ), - ), - }).pipe(Effect.provideService(Scope.Scope, entry.scope)); + }).pipe(Effect.provideService(Scope.Scope, entry.scope)), + ); + if (Exit.isFailure(gatewayResult)) { + if (internalApi !== undefined) yield* internalApi.close.pipe(Effect.ignore); + return yield* Effect.failCause(gatewayResult.cause); + } + const gateway = gatewayResult.value; yield* Ref.set(current, { input, reservation, diff --git a/packages/stack/src/supervisor/ingress.integration.test.ts b/packages/stack/src/supervisor/ingress.integration.test.ts index 4303acc68f..d8603d5a87 100644 --- a/packages/stack/src/supervisor/ingress.integration.test.ts +++ b/packages/stack/src/supervisor/ingress.integration.test.ts @@ -21,6 +21,7 @@ import { import { makeStackStateStore } from "../state/StackStateStore.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { makeSupervisorIngress } from "./Ingress.ts"; +import { bindHostListener } from "./HostListener.ts"; import type { HostListener } from "../state/PortCoordinator.ts"; import { privateBindingIntentsFor } from "../runtime/WorkloadRuntimeSpec.ts"; @@ -52,23 +53,20 @@ const listenBackend = (server: ReturnType) => () => closeServer(server), ).pipe(Effect.as(server)); -const request = (port: number, path = "/rest/v1/items", method = "GET") => +const request = (port: number, path = "/rest/v1/items", method = "GET", host = "127.0.0.1") => Effect.callback<{ readonly status: number; readonly body: string }, Error>((resume) => { - const client = requestHttp( - { host: "127.0.0.1", port, path, method }, - (response: IncomingMessage) => { - const chunks: Buffer[] = []; - response.on("data", (chunk: Buffer) => chunks.push(chunk)); - response.once("end", () => - resume( - Effect.succeed({ - status: response.statusCode ?? 0, - body: Buffer.concat(chunks).toString(), - }), - ), - ); - }, - ); + const client = requestHttp({ host, port, path, method }, (response: IncomingMessage) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.once("end", () => + resume( + Effect.succeed({ + status: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + }), + ), + ); + }); client.once("error", (error) => resume(Effect.fail(error))); client.end(); return Effect.sync(() => client.destroy()); @@ -236,6 +234,16 @@ describe("Supervisor ingress", () => { })), secrets: {}, }); + const listenerCloseCount = yield* Ref.make(0); + const bindHost = (address: string, port: number, field: HostListener["field"]) => + bindHostListener(address, port, field).pipe( + Effect.map((listener) => ({ + ...listener, + close: listener.close.pipe( + Effect.andThen(Ref.update(listenerCloseCount, (count) => count + 1)), + ), + })), + ); const ingress = yield* makeSupervisorIngress({ stackId, stateRoot: root, @@ -248,6 +256,8 @@ describe("Supervisor ingress", () => { anonJwt: "anon-jwt", serviceRoleJwt: "service-jwt", }), + bindHost, + resolveInternalApiBindAddress: () => Effect.succeed("::1"), }); const input = { stackId, @@ -279,6 +289,9 @@ describe("Supervisor ingress", () => { const response = yield* request(api.port); expect(response.status).toBe(200); expect(response.body).toBe("forwarded"); + const internalResponse = yield* request(api.port, "/rest/v1/items", "GET", "::1"); + expect(internalResponse.status).toBe(200); + expect(internalResponse.body).toBe("forwarded"); const reused = yield* ingress.acquire(input); expect(reused.fresh).toBe(false); yield* ingress.open(input, reused, (capability) => @@ -291,6 +304,7 @@ describe("Supervisor ingress", () => { expect(reusedResponse.status).toBe(200); expect(reusedResponse.body).toBe("forwarded"); yield* ingress.close; + expect(yield* Ref.get(listenerCloseCount)).toBe(2); const reacquired = yield* ingress.acquire(input); expect(reacquired.fresh).toBe(true); const stale = yield* ingress From c5b5726498b9c91fe77a5cf7ff33fc960051ba7c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 21:41:22 +0200 Subject: [PATCH 161/174] fix(stack): cancel abandoned lazy activation --- packages/stack/src/gateway/HttpGateway.ts | 25 +++++++++++- .../src/gateway/gateway.integration.test.ts | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index 13c6751cbe..330a1a7bad 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -290,6 +290,28 @@ const mapFailure = (cause: Cause.Cause): number => { return 503; }; +const cancelOnRequestClose = ( + request: IncomingMessage, + response: ServerResponse, + fiber: Fiber, +): void => { + let active = true; + const cleanup = () => { + if (!active) return; + active = false; + request.off("aborted", cancel); + response.off("close", cancel); + }; + const cancel = () => { + if (!active || response.writableEnded) return; + cleanup(); + fiber.interruptUnsafe(); + }; + request.once("aborted", cancel); + response.once("close", cancel); + fiber.addObserver(cleanup); +}; + const handleRequest = ( request: IncomingMessage, response: ServerResponse, @@ -339,8 +361,9 @@ const handleRequest = ( // Node invokes this handler outside Effect; use the owner-scoped FiberSet // runtime so cancellation of the gateway interrupts in-flight activation. const fiber = runFork(activation); + cancelOnRequestClose(request, response, fiber); fiber.addObserver((exit) => { - if (!Exit.isFailure(exit) || response.writableEnded) return; + if (!Exit.isFailure(exit) || response.writableEnded || response.destroyed) return; if (response.headersSent) { response.destroy(); return; diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index 72a5806cc6..b24b960924 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -190,6 +190,44 @@ describe("stack gateway", () => { ), ); + it.live("interrupts lazy activation when the client disconnects before proxying", () => + withPlatform( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const gateway = yield* makeHttpGateway({ + address: "127.0.0.1", + port: 0, + routes: [{ capability: "rest", match: () => true }], + activate: () => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))); + return { capability: "rest" as const, endpoint: { host: "127.0.0.1", port: 1 } }; + }), + }); + let request: ClientRequest | undefined; + const requestFiber = yield* Effect.forkChild( + Effect.callback((resume) => { + request = requestHttp( + { host: "127.0.0.1", port: gateway.port, path: "/rest/v1/items" }, + (response) => response.resume(), + ); + request.once("error", () => resume(Effect.void)); + request.end(); + return Effect.sync(() => request?.destroy()); + }), + { startImmediately: true }, + ); + yield* Deferred.await(started); + request?.destroy(); + yield* Deferred.await(interrupted).pipe(Effect.timeout("5 seconds")); + yield* Fiber.interrupt(requestFiber); + yield* gateway.close; + }), + ), + ); + it.live("routes an HTTP request after lazy activation and forwards the response", () => withPlatform( Effect.gen(function* () { From a37872b4e2846cce64017efad2ada9d66536e0f4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 22:32:44 +0200 Subject: [PATCH 162/174] fix(stack): expose pooler input to containers --- packages/stack/src/gateway/gateway.integration.test.ts | 5 +++-- packages/stack/src/runtime/RuntimeInputOwner.ts | 7 ++++--- .../src/runtime/runtime-input-owner.integration.test.ts | 1 + 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/stack/src/gateway/gateway.integration.test.ts b/packages/stack/src/gateway/gateway.integration.test.ts index b24b960924..38a6f6633b 100644 --- a/packages/stack/src/gateway/gateway.integration.test.ts +++ b/packages/stack/src/gateway/gateway.integration.test.ts @@ -202,8 +202,9 @@ describe("stack gateway", () => { activate: () => Effect.gen(function* () { yield* Deferred.succeed(started, undefined); - yield* Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))); - return { capability: "rest" as const, endpoint: { host: "127.0.0.1", port: 1 } }; + return yield* Effect.never.pipe( + Effect.ensuring(Deferred.succeed(interrupted, undefined)), + ); }), }); let request: ClientRequest | undefined; diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 2e0f4630d7..8bd9144019 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -527,6 +527,7 @@ export const makeRuntimeInputOwner = ( maxClientConn, }); const target = path.join(poolerRoot, "pooler_tenant.exs"); + const fileMode = runtime === "native" ? 0o600 : 0o644; return Effect.gen(function* () { const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError(() => failure("Unable to allocate pooler tenant file")), @@ -544,7 +545,7 @@ export const makeRuntimeInputOwner = ( const file = yield* mapFile( temporary, "create pooler tenant file", - fs.open(temporary, { flag: "w", mode: 0o600 }), + fs.open(temporary, { flag: "w", mode: fileMode }), ); yield* mapFile( temporary, @@ -554,9 +555,9 @@ export const makeRuntimeInputOwner = ( yield* mapFile(temporary, "sync pooler tenant file", file.sync); }), ); - yield* mapFile(temporary, "secure pooler tenant file", fs.chmod(temporary, 0o600)); + yield* mapFile(temporary, "secure pooler tenant file", fs.chmod(temporary, fileMode)); yield* mapFile(target, "publish pooler tenant file", fs.rename(temporary, target)); - yield* mapFile(target, "secure published pooler tenant file", fs.chmod(target, 0o600)); + yield* mapFile(target, "secure published pooler tenant file", fs.chmod(target, fileMode)); return target; }).pipe( Effect.ensuring( diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 839acef2c5..ed0084760d 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -648,6 +648,7 @@ describe("runtime input owner", () => { const material = yield* owner.resolve(containerState, "pooler:pooler"); const tenant = material.pooler?.tenantPath; expect(tenant).toBeDefined(); + expect((yield* fs.stat(tenant!)).mode & 0o777).toBe(0o644); const content = yield* fs.readFileString(tenant!); expect(content).toContain('"external_id" => "tenant-custom"'); expect(content).toContain(`"db_host" => "${containerAliasFor("database:database")}"`); From bee20fddfb4e15aa7142647604af1dec6d8244b5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 22:32:44 +0200 Subject: [PATCH 163/174] fix(stack): gracefully stop postgres after owner loss --- .../stack/src/public/whole-stack.e2e.test.ts | 31 ++++- packages/stack/src/runtime/NativeProcess.ts | 21 ++- packages/stack/src/runtime/native-launcher.ts | 71 +++++++++- .../native-runtime.integration.test.ts | 131 ++++++++++++++++++ 4 files changed, 245 insertions(+), 9 deletions(-) diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 9cb9d92ae3..c5c85bf9e1 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -175,6 +175,30 @@ const supervisorPid = async (stackId: string): Promise => { return pid; }; +const nativeDatabaseSharedMemoryId = async (lockPath: string): Promise => { + const lines = (await readFile(lockPath, "utf8")).split("\n"); + const sharedMemoryId = Number(lines[6]?.trim().split(/\s+/u)[1]); + if (!Number.isSafeInteger(sharedMemoryId) || sharedMemoryId < 0) + throw new Error("Native database lock did not contain a valid shared-memory ID"); + return sharedMemoryId; +}; + +const nativeDatabaseSharedMemoryExists = async ( + sharedMemoryId: number, +): Promise => { + if (process.platform !== "linux" && process.platform !== "darwin") return undefined; + try { + const result = await execFile("ipcs", ["-m"], { encoding: "utf8" }); + return result.stdout + .split("\n") + .some((line) => line.trim().split(/\s+/u)[1] === String(sharedMemoryId)); + } catch (cause) { + if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") + return undefined; + throw cause; + } +}; + const waitForProcessExit = (pid: number): Promise => { const exited = Effect.try({ try: () => { @@ -1069,10 +1093,15 @@ describe("managed Supabase stack whole-stack E2E", () => { const databasePid = Number((await readFile(lockPath, "utf8")).split("\n", 1)[0]); if (!Number.isSafeInteger(databasePid) || databasePid <= 0) throw new Error("Native database lock did not contain a valid PID"); + const sharedMemoryId = await nativeDatabaseSharedMemoryId(lockPath); + const sharedMemoryAvailable = await nativeDatabaseSharedMemoryExists(sharedMemoryId); + if (sharedMemoryAvailable !== undefined) expect(sharedMemoryAvailable).toBe(true); process.kill(await supervisorPid(stack.id), "SIGKILL"); await waitForProcessExit(databasePid); + if (sharedMemoryAvailable !== undefined) + expect(await nativeDatabaseSharedMemoryExists(sharedMemoryId)).toBe(false); await expect(connect(database)).rejects.toThrow(); - await access(lockPath); + await expect(access(lockPath)).rejects.toThrow(); const recovered = await stack.start(); expectDefaultLazyState(recovered); diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index 368481b6fe..cc1674e5a8 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -1,4 +1,4 @@ -import { Data, Effect, Option, Scope, Stream, type Duration } from "effect"; +import { Data, Duration, Effect, Option, Scope, Stream } from "effect"; import { fileURLToPath } from "node:url"; import { ChildProcess } from "effect/unstable/process"; import type { PlatformError } from "effect/PlatformError"; @@ -70,15 +70,30 @@ export const defaultNativeProcessLauncher = (): NativeProcessLauncher => ({ args: [nativeLauncherEntrypointFor(import.meta.url)], }); -const encodeSpec = (spec: NativeProcessSpec): Uint8Array => - new TextEncoder().encode( +const encodeSpec = (spec: NativeProcessSpec): Uint8Array => { + const timeout = + spec.gracefulStopSignal === undefined + ? Option.none() + : Duration.fromInput(spec.gracefulStopTimeout ?? "2 seconds").pipe( + Option.filter(Duration.isFinite), + Option.map(Duration.toMillis), + Option.filter((millis) => Number.isFinite(millis) && millis >= 0), + ); + return new TextEncoder().encode( JSON.stringify({ executable: spec.executable, args: spec.args ?? [], env: spec.env, cwd: spec.cwd, + ...(Option.isSome(timeout) + ? { + gracefulStopSignal: spec.gracefulStopSignal, + gracefulStopTimeoutMs: timeout.value, + } + : {}), }), ); +}; const mapProcessError = (error: unknown, spec: NativeProcessSpec): NativeProcessError => new NativeProcessError({ diff --git a/packages/stack/src/runtime/native-launcher.ts b/packages/stack/src/runtime/native-launcher.ts index e144b34c91..8364a4a1a7 100644 --- a/packages/stack/src/runtime/native-launcher.ts +++ b/packages/stack/src/runtime/native-launcher.ts @@ -1,4 +1,5 @@ // oxlint-disable effecttsgo/process-env -- standalone launcher reads inherited allowlisted variables before Effect starts. +// oxlint-disable effecttsgo/global-timers -- standalone launcher grace deadline runs before Effect starts. // Standalone launcher boundary: this process must remain usable before the // Effect runtime exists and therefore talks to the host directly. // oxlint-disable-next-line effecttsgo/node-builtin-import @@ -13,6 +14,8 @@ interface LaunchSpec { readonly args: ReadonlyArray; readonly env?: Readonly>; readonly cwd?: string; + readonly gracefulStopSignal?: "SIGTERM" | "SIGINT"; + readonly gracefulStopTimeoutMs?: number; } const isRecord = (candidate: unknown): candidate is Record => @@ -40,11 +43,32 @@ const decodeSpec = (bytes: Buffer): LaunchSpec | undefined => { env[name] = entry; } } + if ( + value.gracefulStopSignal !== undefined && + value.gracefulStopSignal !== "SIGTERM" && + value.gracefulStopSignal !== "SIGINT" + ) + return undefined; + if ( + value.gracefulStopTimeoutMs !== undefined && + (typeof value.gracefulStopTimeoutMs !== "number" || + !Number.isFinite(value.gracefulStopTimeoutMs) || + value.gracefulStopTimeoutMs < 0) + ) + return undefined; + if ((value.gracefulStopSignal === undefined) !== (value.gracefulStopTimeoutMs === undefined)) + return undefined; return { executable: value.executable, args, cwd: value.cwd, env, + ...(value.gracefulStopSignal === undefined + ? {} + : { gracefulStopSignal: value.gracefulStopSignal }), + ...(value.gracefulStopTimeoutMs === undefined + ? {} + : { gracefulStopTimeoutMs: value.gracefulStopTimeoutMs }), }; } catch { return undefined; @@ -86,6 +110,11 @@ const inheritedEnvironment = (): NodeJS.ProcessEnv => { export const runNativeLauncher = (): void => { let child: ReturnType | undefined; let gracefulForwarded = false; + let ownerLost = false; + let ownerLossGraceful = false; + let gracefulTimeout: ReturnType | undefined; + let specGracefulStopSignal: LaunchSpec["gracefulStopSignal"]; + let specGracefulStopTimeoutMs: LaunchSpec["gracefulStopTimeoutMs"]; let groupTerminated = false; let childExited = false; @@ -125,6 +154,31 @@ export const runNativeLauncher = (): void => { process.on("SIGTERM", () => forwardSignal("SIGTERM")); process.on("SIGINT", () => forwardSignal("SIGINT")); + const handleOwnerLoss = (): void => { + if (ownerLost || groupTerminated) return; + ownerLost = true; + if ( + child === undefined || + specGracefulStopSignal === undefined || + specGracefulStopTimeoutMs === undefined + ) { + terminateGroup("SIGKILL"); + return; + } + let sent = false; + try { + sent = child.kill(specGracefulStopSignal); + } catch { + sent = false; + } + if (!sent) { + terminateGroup("SIGKILL"); + return; + } + ownerLossGraceful = true; + gracefulTimeout = setTimeout(() => terminateGroup("SIGKILL"), specGracefulStopTimeoutMs); + }; + // Register the owner pipe before waiting for the launch payload. EOF means // the owner process disappeared without running a normal scope finalizer. // Bun does not reliably surface EOF for a net.Socket created from this @@ -138,12 +192,12 @@ export const runNativeLauncher = (): void => { autoClose: false, }); // Owner-loss is an abrupt supervisor crash, not an explicit graceful stop. - // Use guaranteed tree termination so descendants that trap SIGTERM cannot be - // orphaned after the owner pipe closes. - ownerPipe.on("end", () => terminateGroup("SIGKILL")); - ownerPipe.on("error", () => terminateGroup("SIGKILL")); + // Apply a workload's bounded graceful policy when present, then guarantee + // tree termination so descendants cannot be orphaned after owner loss. + ownerPipe.on("end", handleOwnerLoss); + ownerPipe.on("error", handleOwnerLoss); ownerPipe.on("close", () => { - if (!childExited) terminateGroup("SIGKILL"); + if (!childExited) handleOwnerLoss(); }); ownerPipe.resume(); @@ -161,6 +215,8 @@ export const runNativeLauncher = (): void => { if (spec === undefined || groupTerminated) { process.exit(127); } else { + specGracefulStopSignal = spec.gracefulStopSignal; + specGracefulStopTimeoutMs = spec.gracefulStopTimeoutMs; child = spawn(spec.executable, [...spec.args], { cwd: spec.cwd, env: { ...inheritedEnvironment(), ...spec.env }, @@ -170,6 +226,11 @@ export const runNativeLauncher = (): void => { child.on("error", () => process.exit(127)); child.on("exit", (code) => { childExited = true; + if (gracefulTimeout !== undefined) clearTimeout(gracefulTimeout); + if (ownerLossGraceful) { + terminateGroup("SIGKILL"); + return; + } ownerPipe.destroy(); process.exit(code ?? 1); }); diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 43e31150ca..af487102b5 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -1146,6 +1146,137 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); + const ownerLossTargetCode = (signalAware: boolean): string => { + const descendantCode = ` + const net = require("node:net"); + const server = net.createServer(); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address(); + if (typeof address === "object" && address !== null) { + process.stdout.write("DESC_READY " + address.port + "\\n"); + } + }); + `; + return ` + const net = require("node:net"); + const { spawn } = require("node:child_process"); + process.stdout.on("error", () => {}); + const server = net.createServer(); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address(); + if (typeof address === "object" && address !== null) { + process.stdout.write("TARGET_READY " + address.port + "\\n"); + } + const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendantCode)}], { + stdio: ["ignore", "pipe", "inherit"] + }); + child.stdout.on("data", (chunk) => { + if (!process.stdout.destroyed) process.stdout.write(chunk); + }); + }); + process.once("SIGINT", () => { + ${signalAware ? 'process.stdout.write("TARGET_SIGINT\\n", () => { server.close(); process.exit(0); });' : 'process.stdout.write("TARGET_SIGINT\\n");'} + }); + `; + }; + + const runOwnerLossScenario = (options: { + readonly targetCode: string; + readonly gracefulStopTimeoutMs: number; + }) => + Effect.gen(function* () { + const launcherArgs = defaultNativeProcessLauncher().args; + const ownerCode = ` + const { spawn } = require("node:child_process"); + const launcherProcess = spawn(${JSON.stringify(process.execPath)}, ${JSON.stringify(launcherArgs)}, { + detached: true, + stdio: ["ignore", "inherit", "inherit", "pipe", "pipe"] + }); + launcherProcess.stdio[4].end(JSON.stringify({ + executable: ${JSON.stringify(process.execPath)}, + args: ["-e", ${JSON.stringify(options.targetCode)}], + gracefulStopSignal: "SIGINT", + gracefulStopTimeoutMs: ${String(options.gracefulStopTimeoutMs)} + })); + setInterval(() => {}, 1000); + `; + const owner = yield* ChildProcess.make(process.execPath, ["-e", ownerCode], { + stdout: "pipe", + stderr: "pipe", + detached: true, + }); + const stderr = yield* Ref.make(""); + yield* owner.stderr.pipe( + Stream.decodeText, + Stream.runForEach((chunk) => Ref.update(stderr, (current) => current + chunk)), + Effect.forkChild({ startImmediately: true }), + ); + const targetReady = yield* Deferred.make(); + const descendantReady = yield* Deferred.make(); + const signalDelivered = yield* Deferred.make(); + const outputFiber = yield* owner.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => + Effect.gen(function* () { + if (line.startsWith("TARGET_READY ")) yield* Deferred.succeed(targetReady, line); + else if (line.startsWith("DESC_READY ")) yield* Deferred.succeed(descendantReady, line); + else if (line === "TARGET_SIGINT") yield* Deferred.succeed(signalDelivered, undefined); + }), + ), + Effect.forkChild({ startImmediately: true }), + ); + const diagnostics = yield* Ref.get(stderr); + const targetLine = yield* Deferred.await(targetReady).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => Effect.fail(new ProcessTreeTestError({ message: diagnostics })), + }), + ); + const descendantLine = yield* Deferred.await(descendantReady).pipe( + Effect.timeoutOrElse({ + duration: "3 seconds", + orElse: () => Effect.fail(new ProcessTreeTestError({ message: diagnostics })), + }), + ); + const targetPort = Number.parseInt(targetLine.slice("TARGET_READY ".length), 10); + const descendantPort = Number.parseInt(descendantLine.slice("DESC_READY ".length), 10); + expect(Number.isSafeInteger(targetPort)).toBe(true); + expect(Number.isSafeInteger(descendantPort)).toBe(true); + const targetSocket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port: targetPort }); + const descendantSocket = yield* NodeSocket.makeNet({ + host: "127.0.0.1", + port: descendantPort, + }); + const targetOpened = yield* Deferred.make(); + const targetClosed = yield* targetSocket + .runRaw(() => Effect.void, { onOpen: Deferred.succeed(targetOpened, undefined) }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(targetOpened); + const descendantOpened = yield* Deferred.make(); + const descendantClosed = yield* descendantSocket + .runRaw(() => Effect.void, { onOpen: Deferred.succeed(descendantOpened, undefined) }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(descendantOpened); + yield* owner.kill({ killSignal: "SIGKILL" }); + yield* Deferred.await(signalDelivered).pipe(Effect.timeout("3 seconds")); + yield* Fiber.join(targetClosed).pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(descendantClosed).pipe(Effect.timeout("5 seconds")); + yield* Fiber.interrupt(outputFiber); + }); + + it.live("delivers graceful owner-loss SIGINT and reaps descendants", () => + withPlatform( + runOwnerLossScenario({ targetCode: ownerLossTargetCode(true), gracefulStopTimeoutMs: 5_000 }), + ), + ); + + it.live("force-kills an owner-loss workload after its graceful timeout", () => + withPlatform( + runOwnerLossScenario({ targetCode: ownerLossTargetCode(false), gracefulStopTimeoutMs: 100 }), + ), + ); + it.live("terminates descendants after a native workload exits normally", () => withPlatform( Effect.gen(function* () { From 20e7e8cf1d2279c913922580264a39d40e8adc52 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 23:09:55 +0200 Subject: [PATCH 164/174] fix(stack): preserve configured function paths --- .../edge-runtime.service.integration.test.ts | 41 +++++++++++++++++++ apps/cli/src/shared/functions/serve.ts | 16 ++------ .../serve-main-resolver.integration.test.ts | 24 +++++++++++ .../src/functions/serve-main-resolver.ts | 9 ++-- packages/stack/src/functions/serve.main.ts | 9 ++++ 5 files changed, 83 insertions(+), 16 deletions(-) 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 index da71d043ae..0f0c9ee3e8 100644 --- 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 @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { edgeRuntimeNofileUlimit } from "../../../../shared/stack-constants.ts"; +import { toDockerPath } from "../../../../shared/functions/functions-docker.ts"; import { Deferred, Effect, Exit, Sink, Stream } from "effect"; import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { afterEach, beforeEach, vi } from "vitest"; @@ -248,6 +249,46 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); + it.effect("sets the functions root to the mounted container path", () => + Effect.gen(function* () { + const slug = "hello"; + const functionsDir = join(tempWorkdir.current, "supabase", "functions"); + const entrypoint = join(functionsDir, slug, "index.ts"); + mkdirSync(join(functionsDir, slug), { recursive: true }); + writeFileSync(entrypoint, "Deno.serve(() => new Response('ok'));"); + + const fnConfig = { + enabled: true, + verify_jwt: true, + import_map: "", + entrypoint, + static_files: [], + env: {}, + }; + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer({ + ...baseInput(tempWorkdir.current), + configDeclaredFunctions: { [slug]: fnConfig }, + configFunctions: { [slug]: fnConfig }, + rawConfigFunctions: { [slug]: fnConfig }, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const entries = envEntries(mock.runCall!); + expect(entries).toContain(`SUPABASE_INTERNAL_FUNCTIONS_ROOT=${toDockerPath(functionsDir)}`); + const configPrefix = "SUPABASE_INTERNAL_FUNCTIONS_CONFIG="; + const configEntry = entries.find((entry) => entry.startsWith(configPrefix)); + expect(configEntry).toBeDefined(); + expect(JSON.parse(configEntry!.slice(configPrefix.length))).toMatchObject({ + hello: { entrypointPath: toDockerPath(entrypoint) }, + }); + }), + ); + it.effect("omits --workdir when no bind mounts the project root into the container (#6035)", () => Effect.gen(function* () { const mock = mockDockerSpawner(); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index f45c8b92d5..2ce6c88abf 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -5,7 +5,7 @@ import { } from "@supabase/config/effect"; import { edgeRuntimeNofileUlimit } from "../stack-constants.ts"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { basename, isAbsolute, join, relative, resolve } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import { Effect, Option, Redacted, Stream } from "effect"; import { parseDotEnv } from "../../legacy/shared/legacy-dotenv.ts"; import { Output } from "../output/output.service.ts"; @@ -316,15 +316,10 @@ const parseFunctionEnvFile = Effect.fnUntraced(function* (pathname: string) { }); function toFunctionContainerConfig( - workdir: string, config: ResolvedDeployFunctionConfig, envFile: Readonly>, ): ServeFunctionContainerConfig { - const toContainerPath = (pathname: string) => { - const resolvedPath = resolve(pathname); - const relativePath = relative(workdir, resolvedPath); - return relativePath.length === 0 ? basename(resolvedPath) : relativePath.replaceAll("\\", "/"); - }; + const toContainerPath = (pathname: string) => toDockerPath(resolve(pathname)); return { // The Go serve path defaults verifyJWT to true when verify_jwt is not set in @@ -697,11 +692,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo input.discoverFunctionEnvFiles && Option.isNone(input.envFile) ? yield* parseFunctionEnvFile(join(functionsDir, config.slug, ".env")) : {}; - functionsConfig[config.slug] = toFunctionContainerConfig( - input.projectRoot, - config, - functionEnv, - ); + functionsConfig[config.slug] = toFunctionContainerConfig(config, functionEnv); } const binds = [...functionBinds.values()]; @@ -725,6 +716,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo `SUPABASE_INTERNAL_JWT_SECRET=${input.authArtifacts.jwtSecret}`, `SUPABASE_JWKS=${input.authArtifacts.jwks}`, `SUPABASE_INTERNAL_HOST_PORT=${input.config.apiPort}`, + `SUPABASE_INTERNAL_FUNCTIONS_ROOT=${toDockerPath(functionsDir)}`, `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${JSON.stringify(functionsConfig)}`, ...(input.debug ? ["SUPABASE_INTERNAL_DEBUG=true"] : []), ]; diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts index 4e157fc983..4334924158 100644 --- a/packages/stack/src/functions/serve-main-resolver.integration.test.ts +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -174,6 +174,30 @@ describe("Edge Runtime request-time function resolver", () => { } }); + it("accepts an absolute entrypoint inside the functions root without a slug directory", async () => { + const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-absolute-entrypoint-")); + try { + const entrypoint = join(root, "legacy", "index.ts"); + await mkdir(join(root, "legacy"), { recursive: true }); + await writeFile(entrypoint, "export default 1"); + + await expect( + resolveFunctionConfig({ + root, + slug: "hello", + overrides: { hello: { entrypointPath: entrypoint } }, + fs: nodeFileSystem, + }), + ).resolves.toMatchObject({ + entrypointPath: entrypoint, + importMapPath: "", + verifyJWT: true, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("applies global defaults to newly discovered functions while preserving overrides", async () => { const root = await mkdtemp(join(tmpdir(), "stack-functions-resolver-defaults-")); try { diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index e035332ba6..9de2608a2f 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -128,16 +128,17 @@ export const resolveFunctionConfig = async (options: { }; if (override.enabled === false) return undefined; const functionDirectory = join(canonicalRoot, slug); - const directoryInfo = await optionalInfo(fs, functionDirectory); - if (directoryInfo === undefined || !directoryInfo.isDirectory) return undefined; - if (!(await safeRealPath(fs, canonicalRoot, functionDirectory))) return undefined; - const rawEntrypoint = override?.entrypointPath && override.entrypointPath.length > 0 ? override.entrypointPath : override.entrypoint && override.entrypoint.length > 0 ? override.entrypoint : "index.ts"; + if (!rawEntrypoint.startsWith("/")) { + const directoryInfo = await optionalInfo(fs, functionDirectory); + if (directoryInfo === undefined || !directoryInfo.isDirectory) return undefined; + if (!(await safeRealPath(fs, canonicalRoot, functionDirectory))) return undefined; + } const entrypointPath = relativePath(functionDirectory, rawEntrypoint); if (!(await safeRealPath(fs, canonicalRoot, entrypointPath))) return undefined; const entrypointInfo = await optionalInfo(fs, entrypointPath); diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index 8c2e8ec4bc..f23a791bd0 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -71,6 +71,15 @@ const parseConfig = (): FunctionOverrides => { return {}; }; const configured = parseConfig(); +if (Deno.env.get("SUPABASE_INTERNAL_DEBUG") === "true") { + const debugConfig = Object.fromEntries( + Object.entries(configured).map(([name, config]) => [ + name, + Object.fromEntries(Object.entries(config).filter(([key]) => key !== "env")), + ]), + ); + console.log("Functions config:", JSON.stringify(debugConfig, null, 2)); +} const getResponse = (payload: any, status: number, customHeaders = {}) => { const headers = { ...customHeaders }; From b7e5bc3755063616b2170b37c75a4b511ea602f6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 23:33:32 +0200 Subject: [PATCH 165/174] fix(cli): map function paths to container mounts --- .../edge-runtime.service.integration.test.ts | 58 ++++++++++++++++++- .../start/start.slim-images.e2e.test.ts | 31 +++++++++- apps/cli/src/shared/functions/serve.ts | 34 +++++++++-- 3 files changed, 116 insertions(+), 7 deletions(-) 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 index 0f0c9ee3e8..c75315f2e2 100644 --- 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 @@ -1,4 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; @@ -289,6 +296,55 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); + it.effect("maps canonical function paths to the mounted root target", () => + Effect.gen(function* () { + const slug = "hello"; + const canonicalWorkdir = join(tempWorkdir.current, "canonical"); + const linkedWorkdir = join(tempWorkdir.current, "linked"); + const canonicalFunctionsDir = join(canonicalWorkdir, "supabase", "functions"); + const mountedFunctionsDir = join(linkedWorkdir, "supabase", "functions"); + mkdirSync(join(canonicalFunctionsDir, slug), { recursive: true }); + symlinkSync(canonicalWorkdir, linkedWorkdir, "dir"); + const canonicalEntrypoint = join( + realpathSync(canonicalWorkdir), + "supabase", + "functions", + slug, + "index.ts", + ); + writeFileSync(canonicalEntrypoint, "Deno.serve(() => new Response('ok'));"); + + const fnConfig = { + enabled: true, + verify_jwt: true, + import_map: "", + entrypoint: canonicalEntrypoint, + static_files: [], + env: {}, + }; + const mock = mockDockerSpawner(); + const out = mockOutput(); + + yield* legacyStartEdgeRuntimeContainer({ + ...baseInput(linkedWorkdir), + configDeclaredFunctions: { [slug]: fnConfig }, + configFunctions: { [slug]: fnConfig }, + rawConfigFunctions: { [slug]: fnConfig }, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(out.layer), + ); + + const entries = envEntries(mock.runCall!); + const configPrefix = "SUPABASE_INTERNAL_FUNCTIONS_CONFIG="; + const configEntry = entries.find((entry) => entry.startsWith(configPrefix)); + expect(configEntry).toBeDefined(); + expect(JSON.parse(configEntry!.slice(configPrefix.length))).toMatchObject({ + hello: { entrypointPath: toDockerPath(join(mountedFunctionsDir, slug, "index.ts")) }, + }); + }), + ); + it.effect("omits --workdir when no bind mounts the project root into the container (#6035)", () => Effect.gen(function* () { const mock = mockDockerSpawner(); diff --git a/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts index 801f50b8f4..0454ae2d7a 100644 --- a/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts +++ b/apps/cli/src/legacy/commands/start/start.slim-images.e2e.test.ts @@ -105,6 +105,31 @@ async function containerHealthStatus(name: string): Promise { return stdout.trim(); } +async function edgeRuntimeFailureDiagnostics(name: string): Promise { + let mounts = ""; + try { + const { stdout } = await execFileAsync("docker", [ + "inspect", + name, + "--format", + "{{json .Mounts}}", + ]); + mounts = stdout.trim() || "[]"; + } catch (error) { + mounts = ``; + } + + let logs = ""; + try { + const { stdout, stderr } = await execFileAsync("docker", ["logs", name]); + logs = `${stdout}${stderr}`.trim() || ""; + } catch (error) { + logs = ``; + } + + return `edge runtime Mounts: ${mounts}\nedge runtime logs:\n${logs}`; +} + async function runWgetInImage( image: string, args: ReadonlyArray, @@ -258,7 +283,11 @@ describe("supabase start slim images (e2e)", () => { body: JSON.stringify({ name: "Functions" }), }); const body = await invoked.text(); - expect(invoked.ok, body).toBe(true); + if (!invoked.ok) { + throw new Error( + `Functions request failed (${invoked.status}): ${body}\n${await edgeRuntimeFailureDiagnostics(edgeRuntimeContainer)}`, + ); + } expect(JSON.parse(body)).toEqual({ message: "Hello Functions!" }); }, ); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 2ce6c88abf..0d68c6b4d5 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -4,8 +4,8 @@ import { type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config/effect"; import { edgeRuntimeNofileUlimit } from "../stack-constants.ts"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { isAbsolute, join, resolve } from "node:path"; +import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { Effect, Option, Redacted, Stream } from "effect"; import { parseDotEnv } from "../../legacy/shared/legacy-dotenv.ts"; import { Output } from "../output/output.service.ts"; @@ -318,8 +318,21 @@ const parseFunctionEnvFile = Effect.fnUntraced(function* (pathname: string) { function toFunctionContainerConfig( config: ResolvedDeployFunctionConfig, envFile: Readonly>, + pathMappings: ReadonlyArray<{ readonly hostRoot: string; readonly containerRoot: string }>, ): ServeFunctionContainerConfig { - const toContainerPath = (pathname: string) => toDockerPath(resolve(pathname)); + const toContainerPath = (pathname: string) => { + const resolvedPath = resolve(pathname); + for (const mapping of pathMappings) { + const relativePath = relative(mapping.hostRoot, resolvedPath); + if ( + relativePath === "" || + (!isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${sep}`)) + ) { + return join(mapping.containerRoot, relativePath).replaceAll("\\", "/"); + } + } + return toDockerPath(resolvedPath); + }; return { // The Go serve path defaults verifyJWT to true when verify_jwt is not set in @@ -649,6 +662,17 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ); const functionsDir = join(input.projectRoot, functionsDirName); + const functionsRoot = resolve(functionsDir); + const containerFunctionsRoot = toDockerPath(functionsRoot); + const canonicalFunctionsRoot = yield* Effect.promise(() => + realpath(functionsRoot).catch(() => functionsRoot), + ); + const pathMappings = [ + { hostRoot: functionsRoot, containerRoot: containerFunctionsRoot }, + ...(canonicalFunctionsRoot === functionsRoot + ? [] + : [{ hostRoot: canonicalFunctionsRoot, containerRoot: containerFunctionsRoot }]), + ]; const functionBinds = new Map(); const emittedScopeWarnings = new Set(); const functionsConfig: Record = {}; @@ -692,7 +716,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo input.discoverFunctionEnvFiles && Option.isNone(input.envFile) ? yield* parseFunctionEnvFile(join(functionsDir, config.slug, ".env")) : {}; - functionsConfig[config.slug] = toFunctionContainerConfig(config, functionEnv); + functionsConfig[config.slug] = toFunctionContainerConfig(config, functionEnv, pathMappings); } const binds = [...functionBinds.values()]; @@ -716,7 +740,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo `SUPABASE_INTERNAL_JWT_SECRET=${input.authArtifacts.jwtSecret}`, `SUPABASE_JWKS=${input.authArtifacts.jwks}`, `SUPABASE_INTERNAL_HOST_PORT=${input.config.apiPort}`, - `SUPABASE_INTERNAL_FUNCTIONS_ROOT=${toDockerPath(functionsDir)}`, + `SUPABASE_INTERNAL_FUNCTIONS_ROOT=${containerFunctionsRoot}`, `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${JSON.stringify(functionsConfig)}`, ...(input.debug ? ["SUPABASE_INTERNAL_DEBUG=true"] : []), ]; From 5ca75312ed0b1a79e27f5526aa57dd231a777188 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 4 Sep 2026 23:49:40 +0200 Subject: [PATCH 166/174] fix(stack): use a stable container functions root --- .../edge-runtime.service.integration.test.ts | 18 ++++++++++-------- .../src/shared/functions/serve-main-deps.ts | 1 + apps/cli/src/shared/functions/serve.ts | 11 ++++++++--- .../stack/src/functions/serve-main-deps.ts | 2 ++ .../stack/src/runtime/WorkloadRuntimeSpec.ts | 3 ++- 5 files changed, 23 insertions(+), 12 deletions(-) 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 index c75315f2e2..33d99255c4 100644 --- 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 @@ -10,7 +10,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { edgeRuntimeNofileUlimit } from "../../../../shared/stack-constants.ts"; -import { toDockerPath } from "../../../../shared/functions/functions-docker.ts"; +import { FUNCTIONS_CONTAINER_ROOT } from "../../../../shared/functions/serve-main-deps.ts"; import { Deferred, Effect, Exit, Sink, Stream } from "effect"; import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { afterEach, beforeEach, vi } from "vitest"; @@ -222,7 +222,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { }), ); - it.effect("sets --workdir once an enabled function mounts the project root (#6035)", () => + it.effect("uses the fixed functions root without changing the container workdir", () => Effect.gen(function* () { const slug = "hello"; const entrypoint = join(tempWorkdir.current, "supabase", "functions", slug, "index.ts"); @@ -252,7 +252,10 @@ describe("legacyStartEdgeRuntimeContainer", () => { ); const args = mock.runCall!.args; - expect(args[args.indexOf("--workdir") + 1]).toBe(tempWorkdir.current); + expect(args).not.toContain("--workdir"); + expect(args).toContain( + `${tempWorkdir.current}/supabase/functions:${FUNCTIONS_CONTAINER_ROOT}:ro`, + ); }), ); @@ -286,23 +289,22 @@ describe("legacyStartEdgeRuntimeContainer", () => { ); const entries = envEntries(mock.runCall!); - expect(entries).toContain(`SUPABASE_INTERNAL_FUNCTIONS_ROOT=${toDockerPath(functionsDir)}`); + expect(entries).toContain(`SUPABASE_INTERNAL_FUNCTIONS_ROOT=${FUNCTIONS_CONTAINER_ROOT}`); const configPrefix = "SUPABASE_INTERNAL_FUNCTIONS_CONFIG="; const configEntry = entries.find((entry) => entry.startsWith(configPrefix)); expect(configEntry).toBeDefined(); expect(JSON.parse(configEntry!.slice(configPrefix.length))).toMatchObject({ - hello: { entrypointPath: toDockerPath(entrypoint) }, + hello: { entrypointPath: `${FUNCTIONS_CONTAINER_ROOT}/hello/index.ts` }, }); }), ); - it.effect("maps canonical function paths to the mounted root target", () => + it.effect("maps canonical function paths to the fixed mounted root", () => Effect.gen(function* () { const slug = "hello"; const canonicalWorkdir = join(tempWorkdir.current, "canonical"); const linkedWorkdir = join(tempWorkdir.current, "linked"); const canonicalFunctionsDir = join(canonicalWorkdir, "supabase", "functions"); - const mountedFunctionsDir = join(linkedWorkdir, "supabase", "functions"); mkdirSync(join(canonicalFunctionsDir, slug), { recursive: true }); symlinkSync(canonicalWorkdir, linkedWorkdir, "dir"); const canonicalEntrypoint = join( @@ -340,7 +342,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const configEntry = entries.find((entry) => entry.startsWith(configPrefix)); expect(configEntry).toBeDefined(); expect(JSON.parse(configEntry!.slice(configPrefix.length))).toMatchObject({ - hello: { entrypointPath: toDockerPath(join(mountedFunctionsDir, slug, "index.ts")) }, + hello: { entrypointPath: `${FUNCTIONS_CONTAINER_ROOT}/${slug}/index.ts` }, }); }), ); diff --git a/apps/cli/src/shared/functions/serve-main-deps.ts b/apps/cli/src/shared/functions/serve-main-deps.ts index 46ae6502d2..b2cafa2dc5 100644 --- a/apps/cli/src/shared/functions/serve-main-deps.ts +++ b/apps/cli/src/shared/functions/serve-main-deps.ts @@ -2,6 +2,7 @@ // stack-owned and intentionally private from the @supabase/stack export map. export { dirname, + FUNCTIONS_CONTAINER_ROOT, join, STATUS_CODE, STATUS_TEXT, diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 0d68c6b4d5..c5442775f7 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -28,6 +28,7 @@ import { runChildProcess, toDockerPath, } from "./functions-docker.ts"; +import { FUNCTIONS_CONTAINER_ROOT } from "./serve-main-deps.ts"; const dockerRuntimeServerPort = 8081; const dockerRuntimeInspectorPort = 8083; @@ -663,7 +664,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const functionsDir = join(input.projectRoot, functionsDirName); const functionsRoot = resolve(functionsDir); - const containerFunctionsRoot = toDockerPath(functionsRoot); + const containerFunctionsRoot = FUNCTIONS_CONTAINER_ROOT; const canonicalFunctionsRoot = yield* Effect.promise(() => realpath(functionsRoot).catch(() => functionsRoot), ); @@ -692,8 +693,12 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo }, }), )) { - const key = formatDockerBind(bind); - functionBinds.set(key, bind); + const mappedBind = + resolve(bind.hostPath) === functionsRoot + ? { ...bind, containerPath: containerFunctionsRoot } + : bind; + const key = formatDockerBind(mappedBind); + functionBinds.set(key, mappedBind); } const missingSourceWarning = bindWarnings.find((warning) => warning.includes("failed to read file:"), diff --git a/packages/stack/src/functions/serve-main-deps.ts b/packages/stack/src/functions/serve-main-deps.ts index 31a83a7e68..9d6f699a9f 100644 --- a/packages/stack/src/functions/serve-main-deps.ts +++ b/packages/stack/src/functions/serve-main-deps.ts @@ -1,4 +1,6 @@ /** Minimal runtime dependencies bundled into the Edge Runtime bootstrap. */ +export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; + export const STATUS_CODE = { OK: 200, Unauthorized: 401, diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index e6b7ce123b..200ae18c4a 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -23,6 +23,8 @@ import { parseFileSize } from "../model/capabilities/storage.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; import { Effect, type Duration } from "effect"; import { StackPreparationError, StackStateInvalidError } from "../public/Errors.ts"; +import { FUNCTIONS_CONTAINER_ROOT } from "../functions/serve-main-deps.ts"; +export { FUNCTIONS_CONTAINER_ROOT } from "../functions/serve-main-deps.ts"; type WorkloadRuntimeKind = "native" | "container"; @@ -281,7 +283,6 @@ export const validateWorkloadRuntimeInputs = ( } }); -export const FUNCTIONS_CONTAINER_ROOT = "/__supabase_functions"; export const FUNCTIONS_BOOTSTRAP_CONTAINER_PATH = "/root"; const compactEnvironment = ( From db7ab4e5e9f5447262fdbc1c4b1ad8134d15bde5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 5 Sep 2026 01:34:58 +0200 Subject: [PATCH 167/174] fix(stack): finalize runtime catalog and ownership --- .github/workflows/test.yml | 45 ++++-- .../commands/functions/serve/SIDE_EFFECTS.md | 135 ++++-------------- .../functions/serve/serve.integration.test.ts | 49 ++++++- .../functions/managed-functions-runtime.ts | 11 +- .../runtime/stack-e2e-cleanup.unit.test.ts | 1 + packages/stack/src/model/CapabilityModule.ts | 16 ++- packages/stack/src/model/Compiler.ts | 4 +- packages/stack/src/model/WorkloadCatalog.ts | 85 +++++++---- .../stack/src/model/capabilities/analytics.ts | 7 +- packages/stack/src/model/capabilities/auth.ts | 7 +- .../stack/src/model/capabilities/database.ts | 28 ++-- .../stack/src/model/capabilities/functions.ts | 7 +- packages/stack/src/model/capabilities/mail.ts | 7 +- .../stack/src/model/capabilities/pooler.ts | 7 +- .../stack/src/model/capabilities/realtime.ts | 7 +- packages/stack/src/model/capabilities/rest.ts | 7 +- .../stack/src/model/capabilities/storage.ts | 7 +- .../stack/src/model/capabilities/studio.ts | 7 +- .../src/model/catalog.integration.test.ts | 38 +++-- .../src/model/compiler.integration.test.ts | 13 +- .../runtime-artifacts.integration.test.ts | 28 ++-- packages/stack/src/public/Errors.ts | 1 + .../public/effect-stack.integration.test.ts | 40 +++++- .../stack/src/public/whole-stack.e2e.test.ts | 41 +++++- .../src/runtime/DatabaseBootstrapCatalog.ts | 8 +- .../src/runtime/PostgresDatabaseSession.ts | 3 +- .../stack/src/runtime/RuntimeInputOwner.ts | 3 +- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 15 +- .../container-runtime.integration.test.ts | 2 +- .../production-runtime.integration.test.ts | 6 +- .../workload-runtime.integration.test.ts | 12 +- packages/stack/src/state/Ownership.ts | 23 +-- packages/stack/src/state/SecretStore.ts | 1 + .../src/state/ownership.integration.test.ts | 41 ++++-- packages/stack/src/supervisor/Launcher.ts | 10 +- packages/stack/src/supervisor/Supervisor.ts | 8 +- .../supervisor/handles.integration.test.ts | 12 +- .../supervisor/supervisor.integration.test.ts | 7 +- 38 files changed, 464 insertions(+), 285 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de3dec7ff4..8cc068553b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -148,7 +148,7 @@ jobs: (github.event_name == 'merge_group' || inputs.force || github.event.pull_request.draft == false) - name: Run end-to-end tests (shard ${{ matrix.shard }}/3) + name: Run CLI end-to-end tests (shard ${{ matrix.shard }}/3) runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: fail-fast: false @@ -186,16 +186,39 @@ jobs: run: pnpm exec turbo run supabase#build - name: Run end-to-end tests - run: pnpm exec turbo run test:e2e:run --only --concurrency=1 -- --shard=${{ matrix.shard }}/3 + run: pnpm exec turbo run test:e2e:run --only --concurrency=1 --filter=supabase --filter=@supabase/cli-e2e -- --shard=${{ matrix.shard }}/3 env: CLI_HARNESS_TARGET: ts-legacy SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go - # Summary job that gates branch protection. The matrix `test-e2e` job - # produces per-shard check names (`Run end-to-end tests (shard N/3)`), so - # this job preserves the original `Run end-to-end tests` check name that - # branch protection rules already require. It succeeds iff every shard - # succeeded (or skipped — `success()` is true for skipped jobs). + test-stack-e2e: + if: | + !startsWith(github.head_ref, 'release-notes/') && + (github.event_name == 'merge_group' || + inputs.force || + github.event.pull_request.draft == false) + name: Run stack end-to-end tests (${{ matrix.runtime }}) + runs-on: blacksmith-8vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + runtime: [native, container] + steps: + - name: Checkout + uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Run stack end-to-end tests + run: pnpm --filter @supabase/stack test:e2e:run + env: + SUPABASE_STACK_E2E_RUNTIME: ${{ matrix.runtime }} + + # Summary job that gates branch protection. It preserves the original check + # name and succeeds iff every CLI shard and stack runtime succeeded. test-e2e-summary: if: | always() && @@ -204,13 +227,13 @@ jobs: inputs.force || github.event.pull_request.draft == false) name: Run end-to-end tests - needs: test-e2e + needs: [test-e2e, test-stack-e2e] runs-on: ubuntu-latest steps: - name: Verify all shards succeeded run: | - if [ "${{ needs.test-e2e.result }}" = "failure" ] || [ "${{ needs.test-e2e.result }}" = "cancelled" ]; then - echo "::error ::One or more e2e shards failed: ${{ needs.test-e2e.result }}" + if [ "${{ needs.test-e2e.result }}" = "failure" ] || [ "${{ needs.test-e2e.result }}" = "cancelled" ] || [ "${{ needs.test-stack-e2e.result }}" = "failure" ] || [ "${{ needs.test-stack-e2e.result }}" = "cancelled" ]; then + echo "::error ::One or more e2e jobs failed: cli=${{ needs.test-e2e.result }}, stack=${{ needs.test-stack-e2e.result }}" exit 1 fi - echo "All e2e shards reported: ${{ needs.test-e2e.result }}" + echo "All e2e jobs reported: cli=${{ needs.test-e2e.result }}, stack=${{ needs.test-stack-e2e.result }}" diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md index 58f457fff3..8b93d9c4e6 100644 --- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md @@ -1,119 +1,38 @@ # `supabase functions serve` -## Files Read +`functions serve` is a client of the managed `@supabase/stack` runtime. It does not create, +restart, watch, or remove an Edge Runtime container directly. -| Path | Format | When | -| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | -| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — project dotenv (`legacyResolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents | -| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | -| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | -| `/supabase/functions//.env` | dotenv | for each enabled Function when `--env-file` is unset; values override the shared fallback for that Function only | -| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | -| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | -| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | -| `` | JSON | when `auth.signing_keys_path` is configured | -| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | +## Files read -## Files Written +- `supabase/config.toml` and the project configuration inputs used by the shared config loader. +- `supabase/functions/**` while inferring the Functions manifest when function-specific flags need + it. The running stack reads function sources from this root on each request, so edits do not + restart the service. +- The explicit `--env-file`, when supplied. Its values are persisted as redacted managed-stack + settings. Without this flag, the Functions runtime owns its normal environment-file discovery. -| Path | Format | When | -| ---------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/telemetry.json` | JSON | always, at command exit via `Effect.ensuring` | -| `/supabase/.temp/start-secrets/supabase_edge_runtime_/env/docker.env` | dotenv | per start, when single-line container env exists; passed via `--env-file`; mode `0600`; removed after the run | -| `/supabase/.temp/start-secrets/supabase_edge_runtime_/multiline-env/…` | shell + raw | per start, only when an env value contains a newline; bind-mounted read-only into the container; mode `0600`; removed after the run | +The command writes no project-local runtime files. Managed state, logs, artifacts, secrets, and +runtime resources are owned by `@supabase/stack` under the configured Supabase home. -The env files hold secrets (JWT secret, anon/service-role keys, JWKS), so they are -written owner-only (`0600`, in `0700` directories) under the project's own -`supabase/.temp/` (gitignored) — a deterministic, persistent path rather than -`os.tmpdir()`, so `supabase stop` and a failed-start rollback can reclaim it via -`legacyCleanupStartSecrets` even when this command's own cleanup was bypassed -(e.g. `SIGKILL`). +## Runtime behavior -## API Routes +1. Validate flags and translate the CLI configuration to a stack configuration. +2. Create or open the default managed stack identity and call `stack.start()`. +3. Wait until the stack gateway is running and the Functions capability is either ready or dormant. + A dormant Functions service remains lazy and activates on its first request. +4. Print the Functions gateway URL and follow only Functions logs. +5. Stop following logs on `SIGINT`, `SIGTERM`, or stdin shutdown. -Management API: none. When a third-party auth provider (`auth.third_party.*`) is -enabled, two outbound HTTPS GETs are made per start to build `SUPABASE_JWKS`: +Exiting this command does not stop the managed stack. An explicit stack stop or destroy owns that +lifecycle. Stopped-time configuration changes and incompatible live-owner releases are reported as +typed errors with restart guidance. -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------------- | ---- | ------------ | ---------------------- | -| `GET` | `/.well-known/openid-configuration` | none | `—` | `jwks_uri` | -| `GET` | `` (from discovery) | none | `—` | `keys` | +## Output and telemetry -Both fetches use a 10s timeout and are best-effort: failure logs nothing and falls -back to local keys. No scheme/host validation is performed on the discovered URLs. +- Text output reports the managed-stack lifecycle, gateway URL, and live Functions log entries. +- JSON output uses the shared command output/error boundary. +- The legacy command wrapper records and flushes its normal command telemetry on success or failure. -## Environment Variables - -| Variable | Purpose | Required? | -| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) | -| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) | -| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no | -| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) | -| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` and historical `.temp/edge-runtime-version` pins stay on docker.io | no | -| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no | -| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | -| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker create` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading | no | - -## Exit Codes - -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | clean shutdown after `SIGINT`, `SIGTERM`, or stdin close | -| `1` | local DB container is not running, or the Docker daemon is unreachable (surfaces from the DB inspect as `failed to inspect service: …` plus the Docker Desktop install suggestion) | -| `1` | invalid inspect flag combination, or a `Config.Validate` failure anywhere in `config.toml` (not just project/auth config) | -| `1` | env file, signing key, import map, or function bind resolution failure | -| `1` | edge-runtime container startup, log streaming, or restart loop failure | - -## Telemetry Events Fired - -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | - -## Output - -### `--output-format text` - -Writes lifecycle text to stderr / stdout while the command is running: - -- `Setting up Edge Functions runtime...` before each container start -- `Skipped serving Function: ` for disabled functions -- `File change detected: ()` when a watched file triggers a restart -- live `docker logs -f --timestamps` output from the edge-runtime container -- `Stopped serving supabase/functions` on clean shutdown - -### `--output-format json` - -Long-running raw log / error output only; there is no final success payload object for this command. - -### `--output-format stream-json` - -Long-running raw log / error events only; there is no terminal `result` event on success. - -## Notes - -- Any legacy Function name positional arguments are accepted and ignored. The command always - serves every discovered Function, preserving invocations such as - `supabase functions serve `. -- Environment precedence is `--env-file` over automatic discovery. Without the flag, - `supabase/functions/.env` supplies values shared by every Function and each - `supabase/functions//.env` overrides matching values for that Function only. -- The hidden `--all` flag is still parsed but ignored; the native port always serves every discovered function. -- Each restart re-reads config, rebuilds per-function bind mounts, recreates the `supabase_edge_runtime_` container, and best-effort reloads Kong afterwards. -- The command creates or reuses Docker resources derived from the resolved project id: - - container: `supabase_edge_runtime_` - - named volume: `supabase_edge_runtime_` (mounted at `/root/.cache/deno`) - - network: `supabase_network_` unless `--network-id` overrides it -- Inspector mode exposes the configured `edge_runtime.inspector_port` on the host and sets `SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0`. -- Config `env()` interpolation uses a project environment resolved by the command itself (ambient `process.env` layered under `.env..local` / `.env.local` / `.env.` / `.env`) and passed into `loadCliConfig`. The command does not move/hide any project files. One `process.env` mutation exists: the shared config pipeline (`legacyLoadLocalProjectContext`, shared with `deploy`/`download`/`start`) installs a project-dotenv-only `BITBUCKET_CLONE_DIR` into `process.env`. -- Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing. -- Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook). -- A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container — a crashed container is never auto-restarted. -- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory. -- Existing local values declared under an import map's `scopes` are explicit read-only Docker mounts and may resolve outside the nearest Git root; each distinct out-of-root host path prints one `WARN` during bring-up, deduplicated across Functions sharing an import map. Such out-of-root mounts are excluded from the file-watch set per Function, so a scope target contributes no watch root of its own and cannot enlarge or destabilise the watcher; a path that another Function reaches through its ordinary binds is still watched. Other file-valued binds are watched through their immediate parent non-recursively, while directory binds remain recursive. Missing targets retain serve's existing skip behavior. -- **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`. +Any positional Function names and the hidden `--all` flag remain accepted for CLI compatibility; +the managed runtime serves all functions discovered under `supabase/functions`. diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 725b3dd74e..57b792855e 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "@effect/vitest"; import { CliConfigSchema, type FunctionsManifest } from "@supabase/config"; import type { StackConfig, StackId, StackStatus } from "@supabase/stack/effect"; import { CAPABILITY_NAMES, StackIdSchema } from "@supabase/stack/effect"; -import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; +import { Deferred, Effect, Fiber, Layer, Option, Redacted, Schema, Stream } from "effect"; import { mockLegacyCliSettings, mockLegacyTelemetryStateLayer, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; import type { LegacyFunctionsServeFlags } from "./serve.handler.ts"; import { legacyFunctionsServe } from "./serve.handler.ts"; import type { @@ -106,6 +106,7 @@ describe("legacy functions serve", () => { Effect.provide( Layer.mergeAll( output.layer, + mockProcessControl().layer, BunServices.layer, mockLegacyCliSettings({ workdir: "/tmp/project" }), mockLegacyTelemetryStateLayer, @@ -131,4 +132,48 @@ describe("legacy functions serve", () => { ), ); }); + + it.live("stops following logs when process shutdown is requested", () => + Effect.gen(function* () { + const following = yield* Deferred.make(); + const shutdown = yield* Deferred.make(); + const stack: ManagedFunctionsStack = { + start: () => Effect.succeed(status("running")), + status: () => Effect.succeed(status("running")), + logs: () => Effect.die("logs must not be read"), + followLogs: () => + Stream.fromEffect(Deferred.succeed(following, undefined)).pipe( + Stream.map(() => ({ + cursor: { opaque: "1" }, + timestamp: "now", + source: "functions" as const, + stream: "stdout" as const, + message: "following", + })), + Stream.concat(Stream.never), + ), + }; + const operations: ServeManagedFunctionsOperations = { + createStack: () => Effect.succeed(stack), + loadConfig: () => Effect.succeed(Schema.decodeUnknownSync(CliConfigSchema)({})), + loadManifest: () => Effect.succeed({}), + }; + const processControl = mockProcessControl({ awaitShutdown: Deferred.await(shutdown) }); + const fiber = yield* legacyFunctionsServe(baseFlags(), operations).pipe( + Effect.provide( + Layer.mergeAll( + mockOutput({ interactive: false }).layer, + processControl.layer, + BunServices.layer, + mockLegacyCliSettings({ workdir: "/tmp/project" }), + mockLegacyTelemetryStateLayer, + ), + ), + Effect.forkChild, + ); + yield* Deferred.await(following); + yield* Deferred.succeed(shutdown, undefined); + yield* Fiber.join(fiber).pipe(Effect.timeout("1 second")); + }), + ); }); diff --git a/apps/cli/src/shared/functions/managed-functions-runtime.ts b/apps/cli/src/shared/functions/managed-functions-runtime.ts index ac61c2197c..3c0cc49b37 100644 --- a/apps/cli/src/shared/functions/managed-functions-runtime.ts +++ b/apps/cli/src/shared/functions/managed-functions-runtime.ts @@ -24,6 +24,7 @@ import { StackUpgradeRequiredError, } from "@supabase/stack/effect"; import { Output } from "../output/output.service.ts"; +import { ProcessControl } from "../runtime/process-control.service.ts"; import { isFunctionScopedPath, relativeFunctionPath, @@ -146,6 +147,7 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( operations: ServeManagedFunctionsOperations = defaultOperations, ) { const output = yield* Output; + const processControl = yield* ProcessControl; const resolvedOperations: Required = { ...defaultOperations, ...operations, @@ -315,9 +317,12 @@ export const serveManagedFunctions = Effect.fnUntraced(function* ( lifecycle: ready.lifecycle, }); if (apiUrl !== undefined) yield* output.info(`${apiUrl}/functions/v1`); - yield* stack - .followLogs({ capabilities: ["functions"] }) - .pipe(Stream.runForEach((entry) => output.info(`[${entry.source}] ${entry.message}`))); + yield* Effect.raceFirst( + stack + .followLogs({ capabilities: ["functions"] }) + .pipe(Stream.runForEach((entry) => output.info(`[${entry.source}] ${entry.message}`))), + processControl.awaitShutdown, + ); }), ); }); diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts index 4deb7a0648..37e8959edd 100644 --- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts +++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts @@ -65,6 +65,7 @@ describe("stack e2e cleanup manager", () => { format: "supabase-stack-owner-v1", stackId, ownerSessionId: "owner-session", + leasePort: 45_001, endpoint: { kind: "unix", path: "/tmp/supabase-stack.sock" }, rpcRelease: "test", }), diff --git a/packages/stack/src/model/CapabilityModule.ts b/packages/stack/src/model/CapabilityModule.ts index 9d662bd6a8..4b79fce0e2 100644 --- a/packages/stack/src/model/CapabilityModule.ts +++ b/packages/stack/src/model/CapabilityModule.ts @@ -1,7 +1,7 @@ import type { Redacted, Schema } from "effect"; import type { CapabilityName, ActivationMode } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; -import { catalogEntryFor } from "./WorkloadCatalog.ts"; +import { catalogReleaseFor } from "./WorkloadCatalog.ts"; /** Logical artifact descriptors. Download/image resolution belongs to preparation. */ export interface NativeArtifact { @@ -86,14 +86,18 @@ export const workload = ( readonly bootstrap?: WorkloadSpec["bootstrap"]; readonly dependencies?: ReadonlyArray; readonly readiness?: WorkloadSpec["readiness"]; + readonly version?: string; } = {}, ): WorkloadSpec => { - const catalog = catalogEntryFor(`${capability}:${name}`); - if (catalog === undefined) - throw new Error(`Missing workload artifact declaration: ${capability}:${name}`); + const workloadId = `${capability}:${name}`; + const selected = catalogReleaseFor(workloadId, options.version); + if (selected === undefined) + throw new Error( + `Missing workload artifact release: ${workloadId}${options.version === undefined ? "" : `@${options.version}`}`, + ); const artifacts = { - native: { kind: "native" as const, release: catalog.nativeVersion }, - container: { kind: "container" as const, image: catalog.containerImage }, + native: { kind: "native" as const, release: selected.version }, + container: { kind: "container" as const, image: selected.containerImage }, }; return { name, diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 4c748e26ab..51fda92418 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -29,7 +29,7 @@ import { } from "./ExecutionPlan.ts"; import type { CapabilityModule, MaterializedSettings } from "./CapabilityModule.ts"; import type { SecretGenerator, SecretJwtSigning } from "../state/SecretStore.ts"; -import { AUTH_JWT_SECRET_SLOT } from "../state/SecretStore.ts"; +import { AUTH_JWT_SECRET_SLOT, DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; interface SecretSlot { readonly slot: string; @@ -66,7 +66,7 @@ export interface SecretSlotInput { } /** Internal credentials are not user settings but still need durable managed slots. */ -const INTERNAL_MANAGED_SECRET_SLOTS = ["secret:database.internal.password"] as const; +const INTERNAL_MANAGED_SECRET_SLOTS = [DATABASE_INTERNAL_PASSWORD_SLOT] as const; export interface CompiledStack { readonly definition: StackDefinition; diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 1be69a1473..0d8904f7ba 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -31,8 +31,9 @@ export interface NativeWorkloadProcess { export interface WorkloadCatalogEntry { readonly workloadId: string; readonly service: string; - readonly nativeVersion: string; - readonly containerImage: string; + readonly defaultVersion: string; + /** Supported exact versions mapped to their matching container image. */ + readonly releases: Readonly>; readonly requiredRuntimePaths: ReadonlyArray; readonly executablePath: string; /** Stable, stack-local DNS identity used by container dependants. */ @@ -44,19 +45,23 @@ export interface WorkloadCatalogEntry { const native = ( workloadId: string, service: string, - nativeVersion: string, + defaultVersion: string, containerImage: string, executablePath: string, requiredRuntimePaths: ReadonlyArray = [executablePath], options: { + readonly additionalReleases?: Readonly>; readonly containerAlias?: string; readonly nativeProcess?: NativeWorkloadProcess; } = {}, ): WorkloadCatalogEntry => ({ workloadId, service, - nativeVersion, - containerImage, + defaultVersion, + releases: { + [defaultVersion]: containerImage, + ...options.additionalReleases, + }, requiredRuntimePaths, executablePath, containerAlias: options.containerAlias ?? `supabase-${service}`, @@ -68,8 +73,8 @@ const workloadCatalog = { "database:database": native( "database:database", "postgres", - "17.6.1.167", - "ghcr.io/supabase/cli/postgres:17.6.1.167", + "17.6.1.168", + "ghcr.io/supabase/cli/postgres:17.6.1.168", "share/supabase-cli/bin/supabase-postgres-init.sh", [ "bin/postgres", @@ -80,7 +85,12 @@ const workloadCatalog = { "share/supabase-cli/migrations", "lib", ], - { containerAlias: "supabase-database" }, + { + additionalReleases: { + "15.14.1.168": "ghcr.io/supabase/cli/postgres:15.14.1.168", + }, + containerAlias: "supabase-database", + }, ), "rest:rest": native( "rest:rest", @@ -102,16 +112,16 @@ const workloadCatalog = { "realtime:realtime": native( "realtime:realtime", "realtime", - "v2.130.0", - "ghcr.io/supabase/cli/realtime:v2.130.0", + "v2.134.5", + "ghcr.io/supabase/cli/realtime:v2.134.5", "bin/server", ["bin/migrate", "bin/realtime", "bin/server"], ), "storage:storage": native( "storage:storage", "storage", - "v1.72.1", - "ghcr.io/supabase/cli/storage:v1.72.1", + "v1.73.0", + "ghcr.io/supabase/cli/storage:v1.73.0", "app/dist/start/server.js", ["node/bin/node", "app/dist/start/server.js", "app/dist/scripts/migrate-call.js"], { @@ -132,8 +142,8 @@ const workloadCatalog = { "functions:edge-runtime": native( "functions:edge-runtime", "edge-runtime", - "v1.74.3", - "ghcr.io/supabase/cli/edge-runtime:v1.74.3", + "v1.76.2", + "ghcr.io/supabase/cli/edge-runtime:v1.76.2", "bin/edge-runtime", ["bin/edge-runtime"], { containerAlias: "supabase-functions" }, @@ -141,8 +151,8 @@ const workloadCatalog = { "studio:studio": native( "studio:studio", "studio", - "2026.08.24-sha-8ec45b2", - "ghcr.io/supabase/cli/studio:2026.08.24-sha-8ec45b2", + "2026.09.04-sha-5a67366", + "ghcr.io/supabase/cli/studio:2026.09.04-sha-5a67366", "app/apps/studio/server.js", ["node/bin/node", "app/apps/studio/docker-entrypoint.mjs", "app/apps/studio/server.js"], { @@ -180,8 +190,8 @@ const workloadCatalog = { "analytics:analytics": native( "analytics:analytics", "analytics", - "v1.50.6", - "ghcr.io/supabase/cli/analytics:v1.50.6", + "v1.50.9", + "ghcr.io/supabase/cli/analytics:v1.50.9", "bin/logflare", ["bin/logflare"], ), @@ -216,16 +226,40 @@ export const targetForPlatform = (platform: { return undefined; }; -/** Returns the catalog entry for a workload. */ -export const catalogEntryFor = (workloadId: string): WorkloadCatalogEntry | undefined => - WORKLOAD_CATALOG[workloadId]; +/** Returns the catalog entry for a workload. Known workload IDs are always present. */ +export function catalogEntryFor(workloadId: WorkloadId): WorkloadCatalogEntry; +export function catalogEntryFor(workloadId: string): WorkloadCatalogEntry | undefined; +export function catalogEntryFor(workloadId: string): WorkloadCatalogEntry | undefined { + return WORKLOAD_CATALOG[workloadId]; +} + +export interface WorkloadCatalogRelease { + readonly version: string; + readonly containerImage: string; +} + +/** Returns one exact native/container release pair for a workload. */ +export const catalogReleaseFor = ( + workloadId: string, + version?: string, +): WorkloadCatalogRelease | undefined => { + const entry = catalogEntryFor(workloadId); + if (entry === undefined) return undefined; + const selected = version ?? entry.defaultVersion; + const containerImage = entry.releases[selected]; + return containerImage === undefined ? undefined : { version: selected, containerImage }; +}; /** Resolves the container alias for a catalog workload identity. */ export const containerAliasFor = (workloadId: WorkloadId): string => workloadCatalog[workloadId].containerAlias; -const artifactFor = (entry: WorkloadCatalogEntry, target: NativeTarget): NativeWorkloadArtifact => { - const version = entry.nativeVersion; +const artifactFor = ( + entry: WorkloadCatalogEntry, + release: WorkloadCatalogRelease, + target: NativeTarget, +): NativeWorkloadArtifact => { + const version = release.version; const releaseTag = `${entry.service}-${version}`; const assetName = `${releaseTag}-${target}`; const base = `https://github.com/supabase/slim-services/releases/download/${releaseTag}`; @@ -272,7 +306,8 @@ export const resolveNativeArtifactForWorkload = ( platform: `${platform.os}/${platform.arch}`, }), ); - if (workload.artifacts.native.release !== entry.nativeVersion) + const release = catalogReleaseFor(workload.id, workload.artifacts.native.release); + if (release === undefined) return Effect.fail( new StackPreparationError({ message: `Unsupported native release ${workload.artifacts.native.release}`, @@ -281,5 +316,5 @@ export const resolveNativeArtifactForWorkload = ( version: workload.artifacts.native.release, }), ); - return Effect.succeed(artifactFor(entry, target)); + return Effect.succeed(artifactFor(entry, release, target)); }; diff --git a/packages/stack/src/model/capabilities/analytics.ts b/packages/stack/src/model/capabilities/analytics.ts index f8c34f450d..32219f92b9 100644 --- a/packages/stack/src/model/capabilities/analytics.ts +++ b/packages/stack/src/model/capabilities/analytics.ts @@ -1,7 +1,10 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; import { NetworkPortSchema } from "../../public/Status.ts"; +const version = catalogEntryFor("analytics:analytics").defaultVersion; + const Secret = Schema.Redacted(Schema.String); export const AnalyticsSettingsSchema = Schema.Struct({ backend: Schema.optionalKey(Schema.Literals(["postgres", "bigquery"] as const)), @@ -25,10 +28,10 @@ export const AnalyticsModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "v1.50.6", + defaultVersion: version, dependencies: ["database"], releases: { - "v1.50.6": release("v1.50.6", [ + [version]: release(version, [ workload("analytics", "analytics", { dependencies: ["database:database"], readiness: { portField: "api" }, diff --git a/packages/stack/src/model/capabilities/auth.ts b/packages/stack/src/model/capabilities/auth.ts index 9ca3a2052c..1b0758a6d5 100644 --- a/packages/stack/src/model/capabilities/auth.ts +++ b/packages/stack/src/model/capabilities/auth.ts @@ -1,7 +1,10 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; import { NetworkPortSchema } from "../../public/Status.ts"; +const version = catalogEntryFor("auth:auth").defaultVersion; + const Secret = Schema.Redacted(Schema.String); const OptionalString = Schema.optionalKey(Schema.String); const OptionalBoolean = Schema.optionalKey(Schema.Boolean); @@ -392,10 +395,10 @@ export const AuthModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "v2.196.0", + defaultVersion: version, dependencies: ["database"], releases: { - "v2.196.0": release("v2.196.0", [ + [version]: release(version, [ workload("auth", "auth", { dependencies: ["database:database"], readiness: { portField: "api" }, diff --git a/packages/stack/src/model/capabilities/database.ts b/packages/stack/src/model/capabilities/database.ts index c48115dc80..91370d1d57 100644 --- a/packages/stack/src/model/capabilities/database.ts +++ b/packages/stack/src/model/capabilities/database.ts @@ -1,5 +1,6 @@ import { Duration, Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; const settingsFields = { effective_cache_size: Schema.optionalKey(Schema.String), @@ -135,12 +136,22 @@ const defaults: DatabaseSettings = { vault: {}, }; -const postgres17Release = release("17.6.1.167", [ - workload("database", "database", { - bootstrap: "database", - readiness: { portField: "database" }, +const databaseCatalog = catalogEntryFor("database:database"); +const databaseReleases = Object.fromEntries( + Object.keys(databaseCatalog.releases).flatMap((version) => { + const selected = release(version, [ + workload("database", "database", { + bootstrap: "database", + readiness: { portField: "database" }, + version, + }), + ]); + return [ + [version, selected], + [version.split(".")[0], selected], + ]; }), -]); +); export const DatabaseModule: CapabilityModule = { name: "database", @@ -148,12 +159,9 @@ export const DatabaseModule: CapabilityModule = { defaultSettings: defaults, defaultEnabled: true, defaultActivation: "eager", - defaultVersion: "17.6.1.167", + defaultVersion: databaseCatalog.defaultVersion, dependencies: [], - releases: { - "17": postgres17Release, - "17.6.1.167": postgres17Release, - }, + releases: databaseReleases, routes: [{ listener: "database", protocol: "tcp" }], secretPolicy: () => "passthrough", managedSecretSlots: [], diff --git a/packages/stack/src/model/capabilities/functions.ts b/packages/stack/src/model/capabilities/functions.ts index 0f6fc8224f..4e2ced3be2 100644 --- a/packages/stack/src/model/capabilities/functions.ts +++ b/packages/stack/src/model/capabilities/functions.ts @@ -1,5 +1,8 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; + +const version = catalogEntryFor("functions:edge-runtime").defaultVersion; const Secret = Schema.Redacted(Schema.String); const FunctionSlug = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]+$/)); @@ -54,10 +57,10 @@ export const FunctionsModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "v1.74.3", + defaultVersion: version, dependencies: ["database"], releases: { - "v1.74.3": release("v1.74.3", [ + [version]: release(version, [ workload("edge-runtime", "functions", { dependencies: ["database:database"], readiness: { portField: "functionsInspector" }, diff --git a/packages/stack/src/model/capabilities/mail.ts b/packages/stack/src/model/capabilities/mail.ts index b09e52e76c..1bb38c126c 100644 --- a/packages/stack/src/model/capabilities/mail.ts +++ b/packages/stack/src/model/capabilities/mail.ts @@ -1,5 +1,8 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; + +const version = catalogEntryFor("mail:mail").defaultVersion; export const MailSettingsSchema = Schema.Struct({ admin_email: Schema.optionalKey(Schema.String), @@ -12,10 +15,10 @@ export const MailModule: CapabilityModule = { defaultSettings: { admin_email: "admin@email.com", sender_name: "Admin" }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "v1.30.2", + defaultVersion: version, dependencies: [], releases: { - "v1.30.2": release("v1.30.2", [ + [version]: release(version, [ workload("mail", "mail", { readiness: { portField: "mailUi" }, }), diff --git a/packages/stack/src/model/capabilities/pooler.ts b/packages/stack/src/model/capabilities/pooler.ts index 9191007eab..7bef548b4d 100644 --- a/packages/stack/src/model/capabilities/pooler.ts +++ b/packages/stack/src/model/capabilities/pooler.ts @@ -1,5 +1,8 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; + +const version = catalogEntryFor("pooler:pooler").defaultVersion; const Secret = Schema.Redacted(Schema.String); export const PoolerSettingsSchema = Schema.Struct({ @@ -24,10 +27,10 @@ export const PoolerModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "v2.9.12", + defaultVersion: version, dependencies: ["database"], releases: { - "v2.9.12": release("v2.9.12", [ + [version]: release(version, [ workload("pooler", "pooler", { dependencies: ["database:database"], readiness: { portField: "pooler" }, diff --git a/packages/stack/src/model/capabilities/realtime.ts b/packages/stack/src/model/capabilities/realtime.ts index 166c3df72b..142151f78b 100644 --- a/packages/stack/src/model/capabilities/realtime.ts +++ b/packages/stack/src/model/capabilities/realtime.ts @@ -1,5 +1,8 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; + +const version = catalogEntryFor("realtime:realtime").defaultVersion; const Secret = Schema.Redacted(Schema.String); @@ -22,10 +25,10 @@ export const RealtimeModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "v2.130.0", + defaultVersion: version, dependencies: ["database"], releases: { - "v2.130.0": release("v2.130.0", [ + [version]: release(version, [ workload("realtime", "realtime", { dependencies: ["database:database"], readiness: { portField: "api" }, diff --git a/packages/stack/src/model/capabilities/rest.ts b/packages/stack/src/model/capabilities/rest.ts index 479bf879d2..597f973738 100644 --- a/packages/stack/src/model/capabilities/rest.ts +++ b/packages/stack/src/model/capabilities/rest.ts @@ -1,5 +1,8 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; + +const version = catalogEntryFor("rest:rest").defaultVersion; export const RestSettingsSchema = Schema.Struct({ schemas: Schema.optionalKey(Schema.Array(Schema.String)), @@ -30,10 +33,10 @@ export const RestModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "v16.2", + defaultVersion: version, dependencies: ["database"], releases: { - "v16.2": release("v16.2", [ + [version]: release(version, [ workload("rest", "rest", { dependencies: ["database:database"], readiness: { portField: "api" }, diff --git a/packages/stack/src/model/capabilities/storage.ts b/packages/stack/src/model/capabilities/storage.ts index 7da29c74ab..3ee1ba2c6b 100644 --- a/packages/stack/src/model/capabilities/storage.ts +++ b/packages/stack/src/model/capabilities/storage.ts @@ -1,5 +1,8 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; + +const version = catalogEntryFor("storage:storage").defaultVersion; const Bucket = Schema.Struct({ public: Schema.optionalKey(Schema.Boolean), @@ -100,10 +103,10 @@ export const StorageModule: CapabilityModule = { }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "v1.72.1", + defaultVersion: version, dependencies: ["database"], releases: { - "v1.72.1": release("v1.72.1", [ + [version]: release(version, [ workload("storage", "storage", { dependencies: ["database:database"], readiness: { portField: "api" }, diff --git a/packages/stack/src/model/capabilities/studio.ts b/packages/stack/src/model/capabilities/studio.ts index f2f08f0b88..bbc3abce39 100644 --- a/packages/stack/src/model/capabilities/studio.ts +++ b/packages/stack/src/model/capabilities/studio.ts @@ -1,5 +1,8 @@ import { Schema } from "effect"; import { release, workload, type CapabilityModule } from "../CapabilityModule.ts"; +import { catalogEntryFor } from "../WorkloadCatalog.ts"; + +const version = catalogEntryFor("studio:studio").defaultVersion; const Secret = Schema.Redacted(Schema.String); export const StudioSettingsSchema = Schema.Struct({ @@ -13,10 +16,10 @@ export const StudioModule: CapabilityModule = { defaultSettings: { api_url: "", openai_api_key: undefined }, defaultEnabled: true, defaultActivation: "lazy", - defaultVersion: "2026.08.24-sha-8ec45b2", + defaultVersion: version, dependencies: ["rest", "analytics"], releases: { - "2026.08.24-sha-8ec45b2": release("2026.08.24-sha-8ec45b2", [ + [version]: release(version, [ workload("studio", "studio", { dependencies: ["studio:pgmeta", "analytics:analytics"], readiness: { portField: "studio" }, diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index e293b47057..6e1965e1ff 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -6,12 +6,17 @@ import { CAPABILITY_NAMES } from "../public/Capability.ts"; import { CAPABILITY_MODULES } from "./ExecutionPlan.ts"; import { workload } from "./CapabilityModule.ts"; import { + catalogEntryFor, + catalogReleaseFor, resolveNativeArtifactForWorkload, targetForPlatform, WORKLOAD_CATALOG, } from "./WorkloadCatalog.ts"; import { StackPreparationError } from "../public/Errors.ts"; +const databaseCatalog = catalogEntryFor("database:database"); +const defaultDatabaseMajor = databaseCatalog.defaultVersion.split(".")[0]; + const compile = (config: Parameters[0]["config"]) => compileStack({ projectRoot: "/tmp/catalog-project", runtime: { kind: "native" }, config }).pipe( Effect.provide(NodeServices.layer), @@ -27,9 +32,10 @@ const compileContainer = (config: Parameters[0]["config"]) describe("complete workload catalog", () => { it("uses the catalog release for both runtime artifact identities", () => { const selected = workload("rest", "rest"); + const catalog = catalogReleaseFor("rest:rest"); expect(selected.artifacts).toEqual({ - native: { kind: "native", release: WORKLOAD_CATALOG["rest:rest"]?.nativeVersion }, - container: { kind: "container", image: WORKLOAD_CATALOG["rest:rest"]?.containerImage }, + native: { kind: "native", release: catalog?.version }, + container: { kind: "container", image: catalog?.containerImage }, }); }); @@ -98,7 +104,9 @@ describe("complete workload catalog", () => { expect(workload.artifacts.native.release).toBe(capability.version); } - const databaseAlias = yield* compile({ capabilities: { database: { version: "17" } } }); + const databaseAlias = yield* compile({ + capabilities: { database: { version: defaultDatabaseMajor } }, + }); const database = databaseAlias.executionPlan.workloads.find( (entry) => entry.id === "database:database", ); @@ -107,9 +115,7 @@ describe("complete workload catalog", () => { expect(databaseAlias.definition.capabilities.database.version).toBe( database.artifacts.native.release, ); - expect(database.artifacts.native.release).toBe( - WORKLOAD_CATALOG["database:database"]?.nativeVersion, - ); + expect(database.artifacts.native.release).toBe(databaseCatalog.defaultVersion); }), ); @@ -155,14 +161,14 @@ describe("complete workload catalog", () => { const workload = result.executionPlan.workloads.find((entry) => entry.id === id); expect(workload).toBeDefined(); if (workload === undefined) continue; - expect(workload.artifacts.native.release).toBe(catalog.nativeVersion); + expect(workload.artifacts.native.release).toBe(catalog.defaultVersion); const artifact = yield* resolveNativeArtifactForWorkload(workload, { os: "darwin", arch: "arm64", }); - expect(artifact.releaseTag).toBe(`${catalog.service}-${catalog.nativeVersion}`); + expect(artifact.releaseTag).toBe(`${catalog.service}-${catalog.defaultVersion}`); expect(artifact.downloadUrl).toBe( - `https://github.com/supabase/slim-services/releases/download/${catalog.service}-${catalog.nativeVersion}/${catalog.service}-${catalog.nativeVersion}-darwin-arm64.tar.zst`, + `https://github.com/supabase/slim-services/releases/download/${catalog.service}-${catalog.defaultVersion}/${catalog.service}-${catalog.defaultVersion}-darwin-arm64.tar.zst`, ); } }), @@ -226,19 +232,21 @@ describe("complete workload catalog", () => { const images = new Map( result.executionPlan.workloads.map((entry) => [entry.id, entry.artifacts.container.image]), ); - expect(images.get("mail:mail")).toBe(WORKLOAD_CATALOG["mail:mail"]?.containerImage); + expect(images.get("mail:mail")).toBe(catalogReleaseFor("mail:mail")?.containerImage); expect(images.get("storage:imgproxy")).toBe( - WORKLOAD_CATALOG["storage:imgproxy"]?.containerImage, + catalogReleaseFor("storage:imgproxy")?.containerImage, ); expect(images.get("analytics:vector")).toBe( - WORKLOAD_CATALOG["analytics:vector"]?.containerImage, + catalogReleaseFor("analytics:vector")?.containerImage, ); }), ); it.live("derives native database artifacts from the planned release", () => Effect.gen(function* () { - const compiled = yield* compile({ capabilities: { database: { version: "17" } } }); + const compiled = yield* compile({ + capabilities: { database: { version: defaultDatabaseMajor } }, + }); const database = compiled.executionPlan.workloads.find( ({ id }) => id === "database:database", ); @@ -248,9 +256,9 @@ describe("complete workload catalog", () => { os: "linux", arch: "x64", }); - expect(artifact.version).toBe(WORKLOAD_CATALOG["database:database"]?.nativeVersion); + expect(artifact.version).toBe(databaseCatalog.defaultVersion); expect(artifact.downloadUrl).toContain( - `postgres-${WORKLOAD_CATALOG["database:database"]?.nativeVersion}-linux-amd64.tar.zst`, + `postgres-${databaseCatalog.defaultVersion}-linux-amd64.tar.zst`, ); }), ); diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index c57d5dcd0c..2b2e27554b 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -6,6 +6,7 @@ import { canonicalize, compileStack, rebuildExecutionPlan, sameDefinition } from import { resolveThirdPartyIssuer } from "./capabilities/auth-third-party.ts"; import { DEFAULT_DATABASE_HEALTH_TIMEOUT } from "./capabilities/database.ts"; import { parseFileSize } from "./capabilities/storage.ts"; +import { catalogEntryFor } from "./WorkloadCatalog.ts"; const layer = NodeServices.layer; const compile = ( @@ -643,10 +644,10 @@ describe("closed capability compiler", () => { it.live("resolves the supported database major to its supported release", () => Effect.gen(function* () { - const expected = { - 17: "17.6.1.167", - } as const; - for (const [major, release] of Object.entries(expected)) { + for (const [release, image] of Object.entries( + catalogEntryFor("database:database").releases, + )) { + const major = release.split(".")[0]; const result = yield* compile({ capabilities: { database: { version: major } } }); expect(result.definition.capabilities.database.version).toBe(release); expect( @@ -655,7 +656,7 @@ describe("closed capability compiler", () => { native: { kind: "native", release }, container: { kind: "container", - image: `ghcr.io/supabase/cli/postgres:${release}`, + image, }, }); } @@ -673,7 +674,7 @@ describe("closed capability compiler", () => { it.live("rejects unsupported historical database releases", () => Effect.gen(function* () { - for (const version of ["13", "13.3.0", "14", "15"]) { + for (const version of ["13", "13.3.0", "14"]) { const result = yield* compile({ capabilities: { database: { version } } }).pipe( Effect.exit, ); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 3285f1e680..05a8fecfea 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -15,6 +15,10 @@ import { } from "./RuntimeArtifacts.ts"; import { ContainerEngineError } from "../public/Errors.ts"; import { ContainerEngineProtocolError } from "../runtime/ContainerEngine.ts"; +import { catalogReleaseFor } from "../model/WorkloadCatalog.ts"; + +const databaseRelease = catalogReleaseFor("database:database"); +if (databaseRelease === undefined) throw new Error("Missing default database release"); const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload => ({ id: "database:database", @@ -22,10 +26,10 @@ const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload dependencies: [], readiness: {}, artifacts: { - native: { kind: "native", release: "17.6.1.167" }, + native: { kind: "native", release: databaseRelease.version }, container: { kind: "container", - image: "ghcr.io/supabase/cli/postgres:17.6.1.167", + image: databaseRelease.containerImage, }, }, selected, @@ -149,13 +153,15 @@ describe("runtime artifact preparation", () => { const result = Effect.runSync( runtime.prepare( { kind: "native" }, - nativeWorkload({ kind: "native", release: "17.6.1.167" }), + nativeWorkload({ kind: "native", release: databaseRelease.version }), ), ); expect(result.outcome).toBe("downloaded"); - expect(result.version).toBe("17.6.1.167"); + expect(result.version).toBe(databaseRelease.version); expect(requests).toHaveLength(1); - expect(requests[0]?.key).toContain("slim-services/postgres/17.6.1.167/darwin-arm64"); + expect(requests[0]?.key).toContain( + `slim-services/postgres/${databaseRelease.version}/darwin-arm64`, + ); expect(requests[0]?.sha256).toBe("a".repeat(64)); }); @@ -171,15 +177,15 @@ describe("runtime artifact preparation", () => { { kind: "container", engine: "docker" }, nativeWorkload({ kind: "container", - image: "ghcr.io/supabase/cli/postgres:17.6.1.167", + image: databaseRelease.containerImage, }), ), ); expect(result.outcome).toBe("pulled"); expect(calls).toEqual([ "probe", - "inspect:ghcr.io/supabase/cli/postgres:17.6.1.167", - "pull:ghcr.io/supabase/cli/postgres:17.6.1.167", + `inspect:${databaseRelease.containerImage}`, + `pull:${databaseRelease.containerImage}`, ]); }); @@ -194,7 +200,7 @@ describe("runtime artifact preparation", () => { { kind: "container", engine: "podman" }, nativeWorkload({ kind: "container", - image: "ghcr.io/supabase/cli/postgres:17.6.1.167", + image: databaseRelease.containerImage, }), ), ); @@ -220,7 +226,7 @@ describe("runtime artifact preparation", () => { { kind: "container", engine: "docker" }, nativeWorkload({ kind: "container", - image: "ghcr.io/supabase/cli/postgres:17.6.1.167", + image: databaseRelease.containerImage, }), ), ); @@ -251,7 +257,7 @@ describe("runtime artifact preparation", () => { { kind: "native" }, nativeWorkload({ kind: "container", - image: "ghcr.io/supabase/cli/postgres:17.6.1.167", + image: databaseRelease.containerImage, }), ), ); diff --git a/packages/stack/src/public/Errors.ts b/packages/stack/src/public/Errors.ts index e8bc1bfe15..10383d4818 100644 --- a/packages/stack/src/public/Errors.ts +++ b/packages/stack/src/public/Errors.ts @@ -249,6 +249,7 @@ export const STACK_STATUS_ERROR_TAGS = [ export type StackStatusError = ErrorByTag<(typeof STACK_STATUS_ERROR_TAGS)[number]>; export const STACK_CREDENTIALS_ERROR_TAGS = [ + "InvalidStackConfigError", "StackNotFoundError", "StackNotRunningError", "StackOwnershipConflictError", diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 0837e6222f..d7c41021c2 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -38,6 +38,7 @@ import { toPersistedIdentity, type PersistedStackState } from "../state/StackSta import { startControlServer } from "../control/ControlServer.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; import { compileStack } from "../model/Compiler.ts"; +import { catalogEntryFor } from "../model/WorkloadCatalog.ts"; import { StackDestructionError, StackCleanupError, @@ -77,6 +78,10 @@ import { } from "../runtime/ContainerEngine.ts"; import { ContainerEngineResolver } from "../runtime/ContainerEngineResolver.ts"; +const defaultDatabaseVersion = catalogEntryFor("database:database").defaultVersion; +const defaultDatabaseMajor = defaultDatabaseVersion.split(".")[0]; +const defaultRestVersion = catalogEntryFor("rest:rest").defaultVersion; + const stackId = StackIdSchema.make( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); @@ -177,6 +182,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; const ownerScope = yield* Scope.make(); @@ -295,6 +301,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -373,6 +380,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -506,7 +514,7 @@ describe("Effect stack lifecycle handoff", () => { yield* store.replace(stack.id, { ...state, desiredLifecycle: "stopped" }); const started = yield* stack - .start({ config: { capabilities: { database: { version: "15" } } } }) + .start({ config: { capabilities: { database: { version: "unsupported" } } } }) .pipe(Effect.exit); expect(Exit.isFailure(started)).toBe(true); @@ -547,6 +555,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint: { kind: "unix" as const, path: path.join(project, "missing-owner.sock") }, ownerSessionId: "unreachable-owner", + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; let ownerReads = 0; @@ -739,7 +748,7 @@ describe("Effect stack lifecycle handoff", () => { ), ); - it.live("preserves unsupported persisted state format from prepare", () => + it.live("preserves unsupported persisted state format from prepare and start", () => withRuntimeRoot((project) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -758,6 +767,13 @@ describe("Effect stack lifecycle handoff", () => { expect(Option.getOrUndefined(Cause.findErrorOption(result.cause))).toBeInstanceOf( StackStateFormatUnsupportedError, ); + + const started = yield* stack.start().pipe(Effect.exit); + expect(Exit.isFailure(started)).toBe(true); + if (Exit.isFailure(started)) + expect(Option.getOrUndefined(Cause.findErrorOption(started.cause))).toBeInstanceOf( + StackStateFormatUnsupportedError, + ); }), ), ); @@ -1003,7 +1019,7 @@ describe("Effect stack lifecycle handoff", () => { const persisted = yield* compileStack({ projectRoot: project, runtime: { kind: "container", engine: "docker" }, - config: { capabilities: { database: { version: "17" } } }, + config: { capabilities: { database: { version: defaultDatabaseMajor } } }, }).pipe( Effect.provideService(Path.Path, path), Effect.provideService(Crypto.Crypto, crypto), @@ -1017,8 +1033,8 @@ describe("Effect stack lifecycle handoff", () => { capabilities: ["rest"], }); expect(prepared.capabilities).toEqual([ - { capability: "database", version: "17.6.1.167", outcome: "cached" }, - { capability: "rest", version: "v16.2", outcome: "cached" }, + { capability: "database", version: defaultDatabaseVersion, outcome: "cached" }, + { capability: "rest", version: defaultRestVersion, outcome: "cached" }, ]); expect(calls.filter((call) => call.startsWith("image ls"))).toHaveLength(2); }), @@ -1044,6 +1060,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -1110,6 +1127,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -1250,7 +1268,7 @@ describe("Effect stack lifecycle handoff", () => { expect(Exit.isFailure(canceled)).toBe(true); const cached = yield* stack.prepare({ capabilities: ["database"] }); expect(cached.capabilities).toEqual([ - { capability: "database", version: "17.6.1.167", outcome: "cached" }, + { capability: "database", version: defaultDatabaseVersion, outcome: "cached" }, ]); expect(yield* fs.readFileString(paths.stateDocument)).toBe(before); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); @@ -1316,6 +1334,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; const stack = yield* makeTestHandle(stackId, { @@ -1341,6 +1360,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -1423,6 +1443,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }, launched: false, @@ -1454,6 +1475,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint: { kind: "unix", path: path.join(root, "missing.sock") }, ownerSessionId: "session", + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }, launched: false, @@ -1798,6 +1820,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: ownerRelease, }; yield* startControlServer({ @@ -1870,6 +1893,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -1928,6 +1952,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -1990,6 +2015,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -2073,6 +2099,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ @@ -2151,6 +2178,7 @@ describe("Effect stack lifecycle handoff", () => { stackId, endpoint, ownerSessionId, + leasePort: 45_001, rpcRelease: STACK_RPC_RELEASE, }; yield* startControlServer({ diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index c5c85bf9e1..1a67311d07 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -19,6 +19,7 @@ import { describe, expect, test } from "vitest"; import { createStack, listStacks, type PromiseStack } from "../index.ts"; import { defaultRuntimeEnvironment } from "../supervisor/Launcher.ts"; import { createTestStack, type TestStack } from "../testing.ts"; +import { catalogEntryFor } from "../model/WorkloadCatalog.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import type { PromiseStackCredentials } from "./Credentials.ts"; import type { StackLogEntry } from "./Logs.ts"; @@ -39,10 +40,26 @@ const LAZY_STUDIO_ACTIVATION_TIMEOUT_MS = 180_000; // Pooler is activated by the first TCP connection and may take longer than the normal // query deadline while its migration and tenant bootstrap processes run. const LAZY_POOLER_ACTIVATION_TIMEOUT = "30 seconds"; -const RUNTIME_CASES = [ +const ALL_RUNTIME_CASES = [ { name: "native", runtime: { kind: "native" as const } }, { name: "Docker", runtime: { kind: "container" as const, engine: "docker" as const } }, ] as const; +// Test registration must select the CI matrix case before an Effect program exists. +// oxlint-disable-next-line effecttsgo/process-env +const SELECTED_RUNTIME = process.env["SUPABASE_STACK_E2E_RUNTIME"]; +if ( + SELECTED_RUNTIME !== undefined && + SELECTED_RUNTIME !== "native" && + SELECTED_RUNTIME !== "container" +) + throw new Error(`Unsupported stack E2E runtime: ${SELECTED_RUNTIME}`); +const RUNTIME_CASES = ALL_RUNTIME_CASES.filter( + ({ runtime }) => SELECTED_RUNTIME === undefined || runtime.kind === SELECTED_RUNTIME, +); +const databaseCatalog = catalogEntryFor("database:database"); +const NON_DEFAULT_DATABASE_RELEASES = Object.keys(databaseCatalog.releases).filter( + (version) => version !== databaseCatalog.defaultVersion, +); const BASE_WORKLOAD_IDS = [ "analytics:analytics", "auth:auth", @@ -1079,7 +1096,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom }; describe("managed Supabase stack whole-stack E2E", () => { - test( + test.skipIf(SELECTED_RUNTIME === "container")( "recovers the native database after abrupt Supervisor termination", { timeout: E2E_TIMEOUT_MS }, async () => { @@ -1112,6 +1129,26 @@ describe("managed Supabase stack whole-stack E2E", () => { ); for (const mode of RUNTIME_CASES) { + for (const databaseVersion of NON_DEFAULT_DATABASE_RELEASES) { + const major = databaseVersion.split(".")[0]; + test( + `starts PostgreSQL ${major} in ${mode.name} mode`, + { timeout: E2E_TIMEOUT_MS }, + async () => { + await using stack: TestStack = await createTestStack({ + name: `stack-postgres-${major}-${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`, + runtime: mode.runtime, + config: { capabilities: { database: { version: major } } }, + }); + const rows = await databaseQuery( + (await stack.credentials()).database.url, + "SELECT current_setting('server_version') AS version", + ); + expect(rows).toEqual([{ version: expect.stringMatching(new RegExp(`^${major}\\.`)) }]); + }, + ); + } + test( `coordinates concurrent isolated stacks in ${mode.name} mode`, { timeout: E2E_TIMEOUT_MS }, diff --git a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts index 85b52d4532..73d4794d2e 100644 --- a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts +++ b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts @@ -2,9 +2,7 @@ import { Effect, Redacted } from "effect"; import type { DatabaseBootstrapOptions } from "../model/DatabaseBootstrap.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { StackPreparationError } from "../public/Errors.ts"; - -const DATABASE_PASSWORD_SLOT = "secret:database.internal.password"; -const JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; +import { AUTH_JWT_SECRET_SLOT, DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; /** * The only SQL revision owned by the runtime bootstrap. The slim Postgres @@ -39,11 +37,11 @@ export const databaseBootstrapPlan = ( "A materialized stack definition is required for database bootstrap", ); - const databasePassword = secretValue(state, DATABASE_PASSWORD_SLOT); + const databasePassword = secretValue(state, DATABASE_INTERNAL_PASSWORD_SLOT); if (databasePassword === undefined) return yield* missingMaterial("Managed database password is unavailable for bootstrap"); - const jwtSecret = secretValue(state, JWT_SECRET_SLOT); + const jwtSecret = secretValue(state, AUTH_JWT_SECRET_SLOT); if (jwtSecret === undefined) return yield* missingMaterial("Managed JWT secret is unavailable for database bootstrap"); diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 2d52778cb1..5d69f2b055 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -10,6 +10,7 @@ import { runDatabaseBootstrap, } from "../model/DatabaseBootstrap.ts"; import type { PersistedStackState } from "../state/StackState.ts"; +import { DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; @@ -189,7 +190,7 @@ export const bootstrapDatabaseAt = ( return yield* new StackPreparationError({ message: "A persisted database private port is required for bootstrap", }); - const password = state.secrets["secret:database.internal.password"]?.value; + const password = state.secrets[DATABASE_INTERNAL_PASSWORD_SLOT]?.value; if (typeof password !== "string" || password.length === 0) return yield* new StackPreparationError({ message: "Managed database password is unavailable for bootstrap", diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index 8bd9144019..fd4f1d433e 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -18,6 +18,7 @@ import { resolveStackPaths } from "../state/Paths.ts"; import { isRecord, settingValue, settingsFor } from "../state/MaterializedSettings.ts"; import { base64UrlEncode, + DATABASE_INTERNAL_PASSWORD_SLOT, resolveSigningKeyMaterial, type ResolvedSigningKeyMaterial, } from "../state/SecretStore.ts"; @@ -492,7 +493,7 @@ export const makeRuntimeInputOwner = ( state: PersistedStackState, runtime: "native" | "container", ): Effect.Effect => { - const password = state.secrets["secret:database.internal.password"]?.value ?? ""; + const password = state.secrets[DATABASE_INTERNAL_PASSWORD_SLOT]?.value ?? ""; if (password.length === 0) return Effect.fail(failure("Persisted database secret is missing")); const settings = settingsFor(state, "pooler"); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 200ae18c4a..3584df6db6 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -24,6 +24,7 @@ import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party. import { Effect, type Duration } from "effect"; import { StackPreparationError, StackStateInvalidError } from "../public/Errors.ts"; import { FUNCTIONS_CONTAINER_ROOT } from "../functions/serve-main-deps.ts"; +import { DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; export { FUNCTIONS_CONTAINER_ROOT } from "../functions/serve-main-deps.ts"; type WorkloadRuntimeKind = "native" | "container"; @@ -364,7 +365,7 @@ const dbUrl = ( database = "postgres", ): string => { const port = runtime === "container" ? 5432 : dbPort(state); - return `postgresql://${role}:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${port}/${database}`; + return `postgresql://${role}:${secret(state, DATABASE_INTERNAL_PASSWORD_SLOT)}@${dbHost(runtime)}:${port}/${database}`; }; const usesResolvedJwks = (state: PersistedStackState): boolean => { @@ -989,7 +990,7 @@ const analyticsEnv = ( DB_DATABASE: "_supabase", DB_SCHEMA: "_analytics", DB_USERNAME: "supabase_admin", - DB_PASSWORD: secret(state, "secret:database.internal.password"), + DB_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), LOGFLARE_SUPABASE_MODE: "true", LOGFLARE_SINGLE_TENANT: "true", LOGFLARE_PRIVATE_ACCESS_TOKEN: valueAt(state, "analytics", "api_key"), @@ -1027,7 +1028,7 @@ const specs: Readonly> = { runtime === "container" ? "/var/lib/postgresql/data" : (inputs.database?.dataPath ?? `${state.identity.projectRoot}/.supabase/db/data`), - POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), + POSTGRES_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), TZDIR: "/var/db/timezone/zoneinfo", }), containerArgs: (state, _workload, port) => databaseArgs(state, port, "container"), @@ -1062,7 +1063,7 @@ const specs: Readonly> = { DB_HOST: dbHost(runtime), DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), DB_USER: "supabase_admin", - DB_PASSWORD: secret(state, "secret:database.internal.password"), + DB_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), DB_NAME: "postgres", DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), @@ -1178,7 +1179,7 @@ const specs: Readonly> = { runtime === "container" ? FUNCTIONS_CONTAINER_ROOT : functionsRoot(state), OPENAI_API_KEY: secret(state, "secret:studio.settings.openai_api_key"), CURRENT_CLI_VERSION: "local", - POSTGRES_PASSWORD: secret(state, "secret:database.internal.password"), + POSTGRES_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), POSTGRES_USER_READ_WRITE: "postgres", PGRST_DB_SCHEMAS: "public,graphql_public", PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", @@ -1201,7 +1202,7 @@ const specs: Readonly> = { PG_META_DB_PORT: String(runtime === "container" ? 5432 : dbPort(state)), PG_META_DB_NAME: "postgres", PG_META_DB_USER: "postgres", - PG_META_DB_PASSWORD: secret(state, "secret:database.internal.password"), + PG_META_DB_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), }), containerArgs: () => [], readiness: { protocol: "http", path: "/health" }, @@ -1300,7 +1301,7 @@ const specs: Readonly> = { runtime === "native" && valueAt(state, "pooler", "pool_mode") !== "session" ? String(primaryPort) : "6543", - DATABASE_URL: `ecto://postgres:${secret(state, "secret:database.internal.password")}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, + DATABASE_URL: `ecto://postgres:${secret(state, DATABASE_INTERNAL_PASSWORD_SLOT)}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), REGION: "local", TENANT_ID: valueAt(state, "pooler", "tenant_id"), diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 66ff71bf6e..db7b743269 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -299,7 +299,7 @@ describe("container runtime", () => { Effect.sync(() => { const spec: ContainerContainerSpec = { name: "auth-init", - image: "ghcr.io/supabase/cli/auth:v2.196.0", + image: "registry.example.test/auth:fixture", labels: { stackId, ownerSessionId: "owner", diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 83c8f5c42e..58afcd4ed7 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -912,7 +912,7 @@ describe("production runtime", () => { Effect.succeed({ workloadId: "realtime:realtime", capability: "realtime" as const, - version: "v2.130.0", + version: compiled.definition.capabilities.realtime.version, outcome: "cached" as const, artifactRoot, }), @@ -1000,7 +1000,7 @@ describe("production runtime", () => { Effect.succeed({ workloadId: "database:database", capability: "database" as const, - version: "17.6.1.167", + version: compiled.definition.capabilities.database.version, outcome: "cached" as const, artifactRoot, }), @@ -1117,7 +1117,7 @@ describe("production runtime", () => { Effect.succeed({ workloadId: "database:database", capability: "database" as const, - version: "17.6.1.167", + version: compiled.definition.capabilities.database.version, outcome: "cached" as const, artifactRoot, }), diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index a6f23fef2e..d41b04ed02 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -1,7 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Redacted, Schema } from "effect"; -import { WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; +import { catalogReleaseFor, WORKLOAD_CATALOG } from "../model/WorkloadCatalog.ts"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { deriveStackId } from "../identity/Identity.ts"; import type { PersistedStackState } from "../state/StackState.ts"; @@ -85,8 +85,8 @@ const state: PersistedStackState = { }; const planned = (id: string): PlannedWorkload => { - const entry = WORKLOAD_CATALOG[id]; - if (entry === undefined) throw new Error(`Missing test catalog entry: ${id}`); + const release = catalogReleaseFor(id); + if (release === undefined) throw new Error(`Missing test catalog release: ${id}`); const separator = id.indexOf(":"); const capability = CAPABILITY_NAMES.find((name) => name === id.slice(0, separator)); if (capability === undefined) throw new Error(`Missing test capability: ${id}`); @@ -96,10 +96,10 @@ const planned = (id: string): PlannedWorkload => { dependencies: [], readiness: {}, artifacts: { - native: { kind: "native", release: entry.nativeVersion }, - container: { kind: "container", image: entry.containerImage }, + native: { kind: "native", release: release.version }, + container: { kind: "container", image: release.containerImage }, }, - selected: { kind: "native", release: entry.nativeVersion }, + selected: { kind: "native", release: release.version }, }; }; diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index d39ded9409..9545c0fff7 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -60,6 +60,7 @@ const OwnerMetadataSchema = Schema.Struct({ format: Schema.Literal(OWNERSHIP_FORMAT), stackId: StackIdSchema, ownerSessionId: OwnerSessionIdSchema, + leasePort: NetworkPortSchema, endpoint: ControlEndpointSchema, rpcRelease: Schema.String, }); @@ -253,12 +254,13 @@ const metadataFrom = (value: unknown): Effect.Effect, + leasePort: number, ): ControlEndpoint => { const root = environment.tempRoot.replace(/[\\/]+$/, ""); const token = String(stackId); if (environment.platform === "windows") - return { kind: "pipe", name: `\\\\.\\pipe\\supabase-stack-${token}` }; - return { kind: "unix", path: `${root}/supabase-stack-${token}.sock` }; + return { kind: "pipe", name: `\\\\.\\pipe\\supabase-stack-${token}-${leasePort}` }; + return { kind: "unix", path: `${root}/supabase-stack-${token}-${leasePort}.sock` }; }; /** Reads and validates complete owner metadata without probing or mutating. */ @@ -302,7 +304,7 @@ export const readOwnerMetadata = ( const metadata = yield* metadataFrom(parsed); if (metadata.stackId !== validId) return yield* stateError("Owner metadata StackId does not match its directory"); - const expected = controlEndpointFor(validId, environment); + const expected = controlEndpointFor(validId, environment, metadata.leasePort); if (metadata.endpoint.kind !== expected.kind) return yield* stateError("Owner metadata endpoint kind does not match this runtime"); if ( @@ -540,13 +542,6 @@ export const acquireOwnership = (options: { Effect.mapError((error) => stateError(String(error))), ); const lockPath = path.join(paths.runtime, "owner.lock"); - const metadata: OwnerMetadata = { - format: OWNERSHIP_FORMAT, - stackId: validId, - ownerSessionId: options.ownerSessionId, - endpoint: controlEndpointFor(validId, options.environment), - rpcRelease: options.rpcRelease, - }; const scope = yield* Scope.Scope; return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -579,6 +574,14 @@ export const acquireOwnership = (options: { : error, ), ); + const metadata: OwnerMetadata = { + format: OWNERSHIP_FORMAT, + stackId: validId, + ownerSessionId: options.ownerSessionId, + leasePort: held.port, + endpoint: controlEndpointFor(validId, options.environment, held.port), + rpcRelease: options.rpcRelease, + }; const acquire = Effect.gen(function* () { const port = held.port; const lock: OwnerLock = { diff --git a/packages/stack/src/state/SecretStore.ts b/packages/stack/src/state/SecretStore.ts index de90b39b7b..2d338c1684 100644 --- a/packages/stack/src/state/SecretStore.ts +++ b/packages/stack/src/state/SecretStore.ts @@ -14,6 +14,7 @@ export const AUTH_SECRET_KEY_SLOT = "secret:auth.settings.secret_key"; export const AUTH_ANON_KEY_SLOT = "secret:auth.settings.anon_key"; export const AUTH_SERVICE_ROLE_KEY_SLOT = "secret:auth.settings.service_role_key"; export const AUTH_JWT_SECRET_SLOT = "secret:auth.settings.jwt_secret"; +export const DATABASE_INTERNAL_PASSWORD_SLOT = "secret:database.internal.password"; type SecretPolicy = "managed" | "passthrough"; diff --git a/packages/stack/src/state/ownership.integration.test.ts b/packages/stack/src/state/ownership.integration.test.ts index c6c9764f90..c12f4a0984 100644 --- a/packages/stack/src/state/ownership.integration.test.ts +++ b/packages/stack/src/state/ownership.integration.test.ts @@ -149,7 +149,9 @@ describe("stack ownership", () => { environment, }); expect(yield* ownerLockExists(root, stackId)).toBe(true); - expect(lease.metadata.endpoint).toEqual(controlEndpointFor(stackId, environment)); + expect(lease.metadata.endpoint).toEqual( + controlEndpointFor(stackId, environment, lease.metadata.leasePort), + ); yield* publishOwnership(lease); expect(yield* readOwnerMetadata(root, stackId, environment)).toEqual(lease.metadata); @@ -281,7 +283,9 @@ describe("stack ownership", () => { rpcRelease: "stack-rpc-v1@0.1.0", environment, }); - expect(successor.metadata.endpoint).toEqual(controlEndpointFor(stackId, environment)); + expect(successor.metadata.endpoint).toEqual( + controlEndpointFor(stackId, environment, successor.metadata.leasePort), + ); expect(yield* ownerLockExists(root, stackId)).toBe(true); yield* publishOwnership(successor); expect((yield* readOwnerMetadata(root, stackId, environment))?.ownerSessionId).toBe( @@ -349,7 +353,7 @@ describe("stack ownership", () => { const address = stale.address(); if (address === null || typeof address === "string") return yield* new StackStateInvalidError({ message: "missing lease port" }); - const staleEndpoint = controlEndpointFor(stackId, environment); + const staleEndpoint = controlEndpointFor(stackId, environment, address.port); if (staleEndpoint.kind !== "unix") return yield* new StackStateInvalidError({ message: "expected unix endpoint" }); yield* fs.writeFileString( @@ -366,6 +370,7 @@ describe("stack ownership", () => { format: "supabase-stack-owner-v1", stackId, ownerSessionId: "old-session", + leasePort: address.port, endpoint: staleEndpoint, rpcRelease: "stack-rpc-v1@0.1.0", }), @@ -387,13 +392,11 @@ describe("stack ownership", () => { ), ); - it.live("removes a stale Unix control socket when no prior lease record exists", () => + it.live("uses a new control endpoint while a prior lease is still alive", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-stack-no-lock-socket-", - }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-live-owner-" }); const stackId = yield* deriveStackId(identity); const environment: StackRuntimeEnvironmentValue = { stateRoot: root, @@ -401,19 +404,26 @@ describe("stack ownership", () => { platform: "posix", }; const paths = yield* resolveStackPaths({ stateRoot: root, stackId }); - const endpoint = controlEndpointFor(stackId, environment); - if (endpoint.kind !== "unix") return yield* Effect.die("expected Unix endpoint"); - yield* fs.makeDirectory(paths.runtime, { recursive: true }); - yield* fs.writeFileString(endpoint.path, "stale socket"); - const lease = yield* acquireOwnership({ + const first = yield* acquireOwnership({ + stateRoot: root, + stackId, + ownerSessionId: "old-owner", + rpcRelease: "rpc", + environment, + }); + yield* fs.remove(paths.runtime, { recursive: true }); + const replacement = yield* acquireOwnership({ stateRoot: root, stackId, ownerSessionId: "new-owner", rpcRelease: "rpc", environment, }); - expect(yield* fs.exists(endpoint.path)).toBe(false); - yield* lease.release; + expect(replacement.metadata.leasePort).not.toBe(first.metadata.leasePort); + expect(replacement.metadata.endpoint).not.toEqual(first.metadata.endpoint); + yield* first.release; + expect(yield* ownerLockExists(root, stackId)).toBe(true); + yield* replacement.release; }), ), ); @@ -510,7 +520,8 @@ describe("stack ownership", () => { format: "supabase-stack-owner-v1", stackId, ownerSessionId: "orphaned-owner", - endpoint: controlEndpointFor(stackId, environment), + leasePort: 45_001, + endpoint: controlEndpointFor(stackId, environment, 45_001), rpcRelease: "rpc", }; yield* fs.writeFileString(paths.controlMetadata, jsonText(metadata)); diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index dba3e11bfd..84558174a9 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -34,6 +34,7 @@ import { } from "./LaunchProtocol.ts"; import { StackOwnershipConflictError, + StackStateFormatUnsupportedError, StackStateInvalidError, StackUpgradeRequiredError, } from "../public/Errors.ts"; @@ -356,7 +357,10 @@ export const ensureSupervisor = ( options: LauncherOptions, ): Effect.Effect< EnsureSupervisorResult, - StackOwnershipConflictError | StackStateInvalidError | StackUpgradeRequiredError, + | StackOwnershipConflictError + | StackStateFormatUnsupportedError + | StackStateInvalidError + | StackUpgradeRequiredError, | FileSystem.FileSystem | Path.Path | Crypto.Crypto @@ -369,7 +373,9 @@ export const ensureSupervisor = ( options.stateStore.recoverRuntimeRemnant(options.stackId).pipe(Effect.asVoid), ), Effect.mapError((error) => - mapFailure(error instanceof Error ? error.message : String(error)), + error instanceof StackStateFormatUnsupportedError + ? error + : mapFailure(error instanceof Error ? error.message : String(error)), ), ); if (state === undefined) return yield* mapFailure("Stack state is missing"); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 787e01e421..3ef4eb74cb 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -66,6 +66,7 @@ import { AUTH_PUBLISHABLE_KEY_SLOT, AUTH_SECRET_KEY_SLOT, AUTH_SERVICE_ROLE_KEY_SLOT, + DATABASE_INTERNAL_PASSWORD_SLOT, } from "../state/SecretStore.ts"; import type { ActivationResult } from "../gateway/Gateway.ts"; @@ -766,7 +767,10 @@ export const makeSupervisor = ( return yield* Effect.fail(credentialsUnavailable); const auth = definition.capabilities.auth; - if (!auth.enabled) return yield* Effect.fail(credentialsUnavailable); + if (!auth.enabled) + return yield* Effect.fail( + rpcError("InvalidStackConfigError", "Stack credentials require Auth to be enabled"), + ); const requiredSecret = (slot: string): Effect.Effect => { const value = state.secrets[slot]?.value; @@ -777,7 +781,7 @@ export const makeSupervisor = ( : Effect.succeed(value); }; - const databasePassword = yield* requiredSecret("secret:database.internal.password"); + const databasePassword = yield* requiredSecret(DATABASE_INTERNAL_PASSWORD_SLOT); const databaseHost = credentialHost(databaseListener.address); const databaseUrl = `postgresql://${encodeURIComponent("postgres")}:${encodeURIComponent( databasePassword, diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 27cca76247..7bcedb9bc6 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -48,6 +48,7 @@ import { makeStackStateStore } from "../state/StackStateStore.ts"; import { makeControlClient, startControlServer } from "../control/ControlServer.ts"; import { resolveStackPaths } from "../state/Paths.ts"; import { STACK_RPC_RELEASE, type StackRpcHandlers } from "../control/StackRpc.ts"; +import { catalogReleaseFor } from "../model/WorkloadCatalog.ts"; import { StackOwnershipConflictError, StackPreparationError, @@ -56,6 +57,9 @@ import { import type { ContainerEngine } from "../runtime/ContainerEngine.ts"; import { ContainerEngineResolver } from "../runtime/ContainerEngineResolver.ts"; +const databaseRelease = catalogReleaseFor("database:database"); +if (databaseRelease === undefined) throw new Error("Missing default database release"); + const withRuntimeRoot = (effect: (project: string) => Effect.Effect) => Effect.scoped( Effect.gen(function* () { @@ -183,7 +187,8 @@ describe("managed stack handles", { timeout: 30_000 }, () => { format: "supabase-stack-owner-v1" as const, stackId: stack.id, ownerSessionId: "stale-owner", - endpoint: controlEndpointFor(stack.id, runtime), + leasePort: 45_001, + endpoint: controlEndpointFor(stack.id, runtime, 45_001), rpcRelease: STACK_RPC_RELEASE, }; const ownerJson = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(owner); @@ -281,10 +286,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { }).pipe(Effect.provideService(ContainerEngineResolver, resolver)); const prepared = yield* stack.prepare({ capabilities: ["database"] }); expect(prepared.capabilities).toHaveLength(1); - expect(calls).toEqual([ - "podman:probe", - "podman:inspect:ghcr.io/supabase/cli/postgres:17.6.1.167", - ]); + expect(calls).toEqual(["podman:probe", `podman:inspect:${databaseRelease.containerImage}`]); expect(dockerCalls).toEqual([]); }), ), diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 7d25ef8cb2..9970fbd443 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1063,7 +1063,7 @@ describe("Supervisor composition", () => { .pipe(Effect.provideContext(fixture.context)); const authDisabled = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(authDisabled)).toEqual( - expect.objectContaining({ tag: "StackNotRunningError" }), + expect.objectContaining({ tag: "InvalidStackConfigError" }), ); if (state.definition === undefined) return yield* new StackStateInvalidError({ message: "running definition is missing" }); @@ -1092,9 +1092,12 @@ describe("Supervisor composition", () => { ), ), }; + yield* fixture.store + .replace(fixture.id, missingSecret) + .pipe(Effect.provideContext(fixture.context)); const missingExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(missingExit)).toEqual( - expect.objectContaining({ tag: "StackNotRunningError" }), + expect.objectContaining({ tag: "StackSecretMismatchError" }), ); const storageSecretMissing = { ...missingSecret, From 3bdf39fcca1ad2abd33aba02d0c440d77a8532da Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 5 Sep 2026 01:59:58 +0200 Subject: [PATCH 168/174] refactor(stack): remove redundant runtime mechanisms --- ...7-simplified-managed-stack-architecture.md | 5 +- packages/stack/README.md | 4 +- packages/stack/src/model/Compiler.ts | 5 +- packages/stack/src/model/DatabaseBootstrap.ts | 236 ++++---------- packages/stack/src/model/WorkloadCatalog.ts | 26 +- .../database-bootstrap.integration.test.ts | 307 +++++++++--------- .../runtime-artifacts.integration.test.ts | 3 +- .../slim-services.integration.test.ts | 1 - packages/stack/src/public/EffectStack.ts | 6 +- .../public/effect-stack.integration.test.ts | 2 + packages/stack/src/runtime/ContainerEngine.ts | 123 +++++-- .../stack/src/runtime/ContainerRuntime.ts | 14 +- .../src/runtime/DatabaseBootstrapCatalog.ts | 34 +- packages/stack/src/runtime/DockerEngine.ts | 86 +---- packages/stack/src/runtime/PodmanEngine.ts | 81 +---- .../src/runtime/PostgresDatabaseSession.ts | 11 +- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 2 - .../container-runtime.integration.test.ts | 50 +-- ...base-bootstrap-catalog.integration.test.ts | 17 +- .../workload-runtime.integration.test.ts | 10 +- packages/stack/src/state/Ownership.ts | 8 - packages/stack/src/supervisor/Supervisor.ts | 19 +- .../supervisor/handles.integration.test.ts | 1 + .../supervisor/supervisor.integration.test.ts | 20 +- 24 files changed, 405 insertions(+), 666 deletions(-) diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 3e3406ed00..6bf20f630e 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -72,8 +72,9 @@ and previously persisted sticky automatic ports remain hard failures and are never silently moved. Every managed document records one concrete runtime selection. Native and -container runtimes never mix. Omission defaults to Docker; callers may -explicitly select Docker or Podman. There is no probing or auto-detection; +container runtimes never mix. An omitted runtime selects native; when a +container runtime is selected, an omitted engine defaults to Docker. Callers +may explicitly select Docker or Podman. There is no probing or auto-detection; Podman is supported only on local Linux hosts. Persisted state records the resolved exact engine. Capability releases and workload artifacts are persisted as exact version pins (including their diff --git a/packages/stack/README.md b/packages/stack/README.md index e981b9ad07..4c7e95d832 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -41,4 +41,6 @@ non-PostgreSQL capability lazy. `followLogs(...)` provides filterable live entri stateless client-polled cursor. Database reset is intentionally outside the current API. Applying migrations, declarative schemas, -and seeds remains the caller's responsibility. +and seeds remains the caller's responsibility. The runtime bootstrap only reconciles the `_realtime` +schema owner, closed database role passwords, and JWT settings in one transaction; the slim database +artifact owns its initialization and migrations. diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 51fda92418..56f0a0bb05 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -459,9 +459,8 @@ const releaseFor = ( const enabledSettings = ( name: CapabilityName, - capabilities: Readonly>, + raw: unknown, ): { enabled: boolean; activation: "eager" | "lazy"; settings: unknown; raw: unknown } => { - const raw = capabilities[name]; if (name === "database") return { enabled: true, activation: "eager", settings: extract(raw, "settings") ?? {}, raw }; if (raw === undefined || raw === null) { @@ -505,7 +504,7 @@ const materializeCapability = ( InvalidStackConfigError | StackVersionUnsupportedError, Path.Path > => { - const selected = enabledSettings(module.name, { [module.name]: raw }); + const selected = enabledSettings(module.name, raw); const mergedInput = merge(module.defaultSettings, selected.settings); const materialized = module.materialize?.(mergedInput, projectRoot) ?? mergedInput; const normalized = normalizeFunctions diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index 6733ae5b3c..8135caa3c2 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -1,22 +1,10 @@ -import { Data, Effect, Redacted, Schema } from "effect"; +import { Data, Effect, Redacted } from "effect"; /** A small runtime-neutral SQL boundary used by the internal database bootstrap. */ export type DatabaseSqlValue = string | number | boolean | null; -interface DatabaseRow { - readonly [column: string]: unknown; -} - /** Login roles provisioned by the managed database template. */ -type DatabaseBootstrapRole = - | "postgres" - | "authenticator" - | "pgbouncer" - | "supabase_auth_admin" - | "supabase_storage_admin" - | "supabase_replication_admin" - | "supabase_read_only_user"; -const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray = [ +const DATABASE_BOOTSTRAP_ROLES = [ "postgres", "authenticator", "pgbouncer", @@ -24,7 +12,8 @@ const DATABASE_BOOTSTRAP_ROLES: ReadonlyArray = [ "supabase_storage_admin", "supabase_replication_admin", "supabase_read_only_user", -]; +] as const; +type DatabaseBootstrapRole = (typeof DATABASE_BOOTSTRAP_ROLES)[number]; export type DatabaseBootstrapSetting = | { @@ -36,28 +25,17 @@ export type DatabaseBootstrapSetting = readonly value: number; }; -/** Values come from resolved managed secret slots and never become SQL text. */ -export interface DatabaseBootstrapCredentials { - readonly roles?: Readonly>>>; -} - -interface DatabaseBootstrapSettings { +export interface DatabaseBootstrapOptions { + /** One managed password shared by the closed login roles. */ + readonly databasePassword: Redacted.Redacted; + /** Managed JWT material applied to the database settings on each invocation. */ readonly jwtSecret: Redacted.Redacted; readonly jwtExpiry: number; } -export interface DatabaseBootstrapOptions { - /** Ordered plan resolved for the pinned database release. */ - readonly revisions: ReadonlyArray; - readonly credentials?: DatabaseBootstrapCredentials; - /** Configuration values are reconciled on every invocation, like role passwords. */ - readonly settings?: DatabaseBootstrapSettings; -} - export class DatabaseBootstrapError extends Data.TaggedError("DatabaseBootstrapError")<{ readonly message: string; readonly statement?: string; - readonly revision?: string; /** Whether retrying the database operation may succeed once the server settles. */ readonly retryable?: boolean; readonly cause?: unknown; @@ -80,10 +58,6 @@ export interface DatabaseTransaction { readonly setDatabaseSetting: ( setting: DatabaseBootstrapSetting, ) => Effect.Effect; - readonly query: ( - statement: string, - parameters?: ReadonlyArray, - ) => Effect.Effect, DatabaseBootstrapError>; } export interface DatabaseSession { @@ -97,157 +71,69 @@ export interface DatabaseSession { ) => Effect.Effect; } -interface DatabaseBootstrapRevision { - readonly id: string; - readonly statement: string; -} - -const TRACKING_SCHEMA = "supabase_internal"; -const TRACKING_TABLE = `${TRACKING_SCHEMA}.bootstrap_revisions`; - -const TRACKING_SCHEMA_STATEMENT = `CREATE SCHEMA IF NOT EXISTS ${TRACKING_SCHEMA};`; -const TRACKING_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} ( - revision text PRIMARY KEY, - applied_at timestamptz NOT NULL DEFAULT now() - );`; -const APPLIED_REVISIONS_STATEMENT = ` - SELECT revision FROM ${TRACKING_TABLE} ORDER BY revision; -`; -const RECORD_REVISION_STATEMENT = ` - INSERT INTO ${TRACKING_TABLE} (revision) VALUES ($1) - ON CONFLICT (revision) DO NOTHING; -`; +const REALTIME_SCHEMA_STATEMENT = + "CREATE SCHEMA IF NOT EXISTS _realtime;\nALTER SCHEMA _realtime OWNER TO postgres;"; const ADVISORY_LOCK_STATEMENT = `SELECT pg_advisory_xact_lock(hashtext('supabase_internal.bootstrap'));`; -const RevisionRowSchema = Schema.Struct({ revision: Schema.String }); - -const statementError = (error: DatabaseBootstrapError, statement: string, revision?: string) => +const statementError = (error: DatabaseBootstrapError, statement: string) => new DatabaseBootstrapError({ message: error.message, statement, - ...(revision === undefined ? {} : { revision }), ...(error.retryable === undefined ? {} : { retryable: error.retryable }), ...(error.cause === undefined ? {} : { cause: error.cause }), }); -/** Runs all unapplied internal revisions, recording each only after it succeeds. */ +/** Reconciles the runtime-owned schema, roles, and settings in one transaction. */ export const runDatabaseBootstrap = ( session: DatabaseSession, options: DatabaseBootstrapOptions, ): Effect.Effect => - Effect.gen(function* () { - const ids = new Set(); - for (const revision of options.revisions) { - if (revision.id.trim().length === 0 || ids.has(revision.id)) - return yield* new DatabaseBootstrapError({ - message: "Database bootstrap revision ids must be non-empty and unique", - revision: revision.id, - }); - ids.add(revision.id); - } - yield* session.transaction((transaction) => - Effect.gen(function* () { - yield* transaction - .execute(ADVISORY_LOCK_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); - yield* transaction - .execute(TRACKING_SCHEMA_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, TRACKING_SCHEMA_STATEMENT))); - yield* transaction - .execute(TRACKING_TABLE_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, TRACKING_TABLE_STATEMENT))); - }), - ); - for (const revision of options.revisions) { - yield* session.transaction((transaction) => - Effect.gen(function* () { - // Re-check under a transaction-scoped advisory lock. This prevents - // two owners from both applying a non-idempotent revision after - // observing the same pre-lock snapshot. - yield* transaction - .execute(ADVISORY_LOCK_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); - const rows = yield* transaction - .query(APPLIED_REVISIONS_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, APPLIED_REVISIONS_STATEMENT))); - const applied = new Set(); - for (const row of rows) { - const decoded = yield* Schema.decodeUnknownEffect(RevisionRowSchema)(row).pipe( - Effect.mapError( - (cause) => - new DatabaseBootstrapError({ - message: `Bootstrap tracking row is malformed: ${String(cause)}`, - statement: APPLIED_REVISIONS_STATEMENT, - }), - ), - ); - applied.add(decoded.revision); - } - if (applied.has(revision.id)) return; - yield* transaction - .execute(revision.statement) - .pipe( - Effect.mapError((error) => statementError(error, revision.statement, revision.id)), - ); - yield* transaction - .execute(RECORD_REVISION_STATEMENT, [revision.id]) - .pipe( - Effect.mapError((error) => - statementError(error, RECORD_REVISION_STATEMENT, revision.id), - ), - ); - }), - ); - } - if (options.credentials?.roles !== undefined || options.settings !== undefined) { - yield* session.transaction((transaction) => - Effect.gen(function* () { - yield* transaction - .execute(ADVISORY_LOCK_STATEMENT) - .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); - if (options.credentials?.roles !== undefined) { - for (const role of DATABASE_BOOTSTRAP_ROLES) { - const password = options.credentials.roles[role]; - if (password === undefined) continue; - yield* transaction.setRolePassword(role, password).pipe( - Effect.mapError( - () => - new DatabaseBootstrapError({ - message: `Unable to configure internal database role ${role}`, - }), - ), - ); - } - } - if (options.settings !== undefined) { - yield* transaction - .setDatabaseSetting({ - name: "app.settings.jwt_secret", - value: options.settings.jwtSecret, - }) - .pipe( - Effect.mapError( - () => - new DatabaseBootstrapError({ - message: "Unable to configure database JWT secret", - }), - ), - ); - yield* transaction - .setDatabaseSetting({ - name: "app.settings.jwt_exp", - value: options.settings.jwtExpiry, - }) - .pipe( - Effect.mapError( - () => - new DatabaseBootstrapError({ - message: "Unable to configure database JWT expiry", - }), - ), - ); - } - }), - ); - } - }); + session.transaction((transaction) => + Effect.gen(function* () { + yield* transaction + .execute(ADVISORY_LOCK_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, ADVISORY_LOCK_STATEMENT))); + yield* transaction + .execute(REALTIME_SCHEMA_STATEMENT) + .pipe(Effect.mapError((error) => statementError(error, REALTIME_SCHEMA_STATEMENT))); + for (const role of DATABASE_BOOTSTRAP_ROLES) { + yield* transaction.setRolePassword(role, options.databasePassword).pipe( + Effect.mapError( + (error) => + new DatabaseBootstrapError({ + message: `Unable to configure internal database role ${role}`, + ...(error.retryable === undefined ? {} : { retryable: error.retryable }), + }), + ), + ); + } + yield* transaction + .setDatabaseSetting({ + name: "app.settings.jwt_secret", + value: options.jwtSecret, + }) + .pipe( + Effect.mapError( + (error) => + new DatabaseBootstrapError({ + message: "Unable to configure database JWT secret", + ...(error.retryable === undefined ? {} : { retryable: error.retryable }), + }), + ), + ); + yield* transaction + .setDatabaseSetting({ + name: "app.settings.jwt_exp", + value: options.jwtExpiry, + }) + .pipe( + Effect.mapError( + (error) => + new DatabaseBootstrapError({ + message: "Unable to configure database JWT expiry", + ...(error.retryable === undefined ? {} : { retryable: error.retryable }), + }), + ), + ); + }), + ); diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 0d8904f7ba..f46535da69 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -18,7 +18,6 @@ export interface NativeWorkloadArtifact { readonly requiredRuntimePaths: ReadonlyArray; readonly executablePath: string; readonly nativeProcess?: NativeWorkloadProcess; - readonly containerAlias: string; } /** Artifact-root-relative process metadata for native Node workloads. */ @@ -29,7 +28,6 @@ export interface NativeWorkloadProcess { } export interface WorkloadCatalogEntry { - readonly workloadId: string; readonly service: string; readonly defaultVersion: string; /** Supported exact versions mapped to their matching container image. */ @@ -43,7 +41,6 @@ export interface WorkloadCatalogEntry { } const native = ( - workloadId: string, service: string, defaultVersion: string, containerImage: string, @@ -55,7 +52,6 @@ const native = ( readonly nativeProcess?: NativeWorkloadProcess; } = {}, ): WorkloadCatalogEntry => ({ - workloadId, service, defaultVersion, releases: { @@ -71,7 +67,6 @@ const native = ( /** The single authoritative private workload identity table. */ const workloadCatalog = { "database:database": native( - "database:database", "postgres", "17.6.1.168", "ghcr.io/supabase/cli/postgres:17.6.1.168", @@ -93,7 +88,6 @@ const workloadCatalog = { }, ), "rest:rest": native( - "rest:rest", "postgrest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", @@ -101,16 +95,10 @@ const workloadCatalog = { ["bin/postgrest"], { containerAlias: "supabase-rest" }, ), - "auth:auth": native( - "auth:auth", - "auth", - "v2.196.0", - "ghcr.io/supabase/cli/auth:v2.196.0", + "auth:auth": native("auth", "v2.196.0", "ghcr.io/supabase/cli/auth:v2.196.0", "bin/auth", [ "bin/auth", - ["bin/auth"], - ), + ]), "realtime:realtime": native( - "realtime:realtime", "realtime", "v2.134.5", "ghcr.io/supabase/cli/realtime:v2.134.5", @@ -118,7 +106,6 @@ const workloadCatalog = { ["bin/migrate", "bin/realtime", "bin/server"], ), "storage:storage": native( - "storage:storage", "storage", "v1.73.0", "ghcr.io/supabase/cli/storage:v1.73.0", @@ -133,14 +120,12 @@ const workloadCatalog = { }, ), "storage:imgproxy": native( - "storage:imgproxy", "imgproxy", "v3.8.0", "ghcr.io/supabase/cli/imgproxy:v3.8.0", "bin/imgproxy", ), "functions:edge-runtime": native( - "functions:edge-runtime", "edge-runtime", "v1.76.2", "ghcr.io/supabase/cli/edge-runtime:v1.76.2", @@ -149,7 +134,6 @@ const workloadCatalog = { { containerAlias: "supabase-functions" }, ), "studio:studio": native( - "studio:studio", "studio", "2026.09.04-sha-5a67366", "ghcr.io/supabase/cli/studio:2026.09.04-sha-5a67366", @@ -164,7 +148,6 @@ const workloadCatalog = { }, ), "studio:pgmeta": native( - "studio:pgmeta", "pgmeta", "v0.99.0", "ghcr.io/supabase/cli/pgmeta:v0.99.0", @@ -179,7 +162,6 @@ const workloadCatalog = { }, ), "mail:mail": native( - "mail:mail", "mailpit", "v1.30.2", "ghcr.io/supabase/cli/mailpit:v1.30.2", @@ -188,7 +170,6 @@ const workloadCatalog = { { containerAlias: "supabase-mail" }, ), "analytics:analytics": native( - "analytics:analytics", "analytics", "v1.50.9", "ghcr.io/supabase/cli/analytics:v1.50.9", @@ -196,7 +177,6 @@ const workloadCatalog = { ["bin/logflare"], ), "analytics:vector": native( - "analytics:vector", "vector", "0.53.0", "ghcr.io/supabase/cli/vector:0.53.0", @@ -204,7 +184,6 @@ const workloadCatalog = { ["bin/vector", "share/doc/vector/config/vector.yaml"], ), "pooler:pooler": native( - "pooler:pooler", "pooler", "v2.9.12", "ghcr.io/supabase/cli/pooler:v2.9.12", @@ -277,7 +256,6 @@ const artifactFor = ( requiredRuntimePaths: entry.requiredRuntimePaths, executablePath: entry.executablePath, ...(entry.nativeProcess === undefined ? {} : { nativeProcess: entry.nativeProcess }), - containerAlias: entry.containerAlias, }; }; diff --git a/packages/stack/src/model/database-bootstrap.integration.test.ts b/packages/stack/src/model/database-bootstrap.integration.test.ts index 4e5b9aa4fa..221ea0f237 100644 --- a/packages/stack/src/model/database-bootstrap.integration.test.ts +++ b/packages/stack/src/model/database-bootstrap.integration.test.ts @@ -2,201 +2,192 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Redacted, Semaphore } from "effect"; import { DatabaseBootstrapError, - type DatabaseBootstrapSetting, - type DatabaseBootstrapCredentials, type DatabaseSession, runDatabaseBootstrap, } from "./DatabaseBootstrap.ts"; -const revisions = [ - { id: "roles", statement: "CREATE ROLE anon" }, - { id: "extensions", statement: "CREATE EXTENSION pgcrypto" }, - { id: "schemas", statement: "CREATE SCHEMA extensions" }, +const ROLE_NAMES = [ + "postgres", + "authenticator", + "pgbouncer", + "supabase_auth_admin", + "supabase_storage_admin", + "supabase_replication_admin", + "supabase_read_only_user", ] as const; -const makeSession = ( - options: { readonly failRevision?: string; readonly failPassword?: boolean } = {}, -) => +type SettingValue = string | number; +type BootstrapState = { + readonly schemas: Set; + readonly schemaOwners: Map; + readonly passwords: Map; + readonly settings: Map; +}; + +const makeSession = (options: { readonly failOnce?: "role" | "setting" } = {}) => Effect.gen(function* () { const lock = yield* Semaphore.make(1); - let failed = false; - const applied: string[] = []; + const state: BootstrapState = { + schemas: new Set(), + schemaOwners: new Map(), + passwords: new Map(), + settings: new Map(), + }; const operations: string[] = []; - const successfulRevisions: string[] = []; - const passwords: Array<[string, string]> = []; - const settings: Array<[string, string | number]> = []; + let transactions = 0; + let failed = false; + const session: DatabaseSession = { - execute: (statement) => - Effect.sync(() => { - operations.push(statement.trim().split("\n")[0] ?? ""); - }), + execute: () => Effect.void, transaction: (use) => lock.withPermit( Effect.gen(function* () { - const pending: string[] = []; - const tx = { - execute: ( - statement: string, - parameters?: ReadonlyArray, - ) => + transactions += 1; + const pending: BootstrapState = { + schemas: new Set(state.schemas), + schemaOwners: new Map(state.schemaOwners), + passwords: new Map(state.passwords), + settings: new Map(state.settings), + }; + const shouldFail = (kind: typeof options.failOnce) => { + if (options.failOnce !== kind || failed) return false; + failed = true; + return true; + }; + const transaction = { + execute: (statement: string) => Effect.gen(function* () { - operations.push(statement.trim().split("\n")[0] ?? ""); - const id = parameters?.[0]; - if (typeof id === "string" && statement.includes("INSERT")) pending.push(id); - const revision = revisions.find((entry) => - statement.includes(entry.statement.trim().split("\n")[0] ?? ""), - ); - if (revision !== undefined && revision.id === options.failRevision && !failed) { - failed = true; + yield* Effect.sync(() => operations.push(statement)); + if (statement.includes("CREATE SCHEMA IF NOT EXISTS _realtime;")) + yield* Effect.sync(() => pending.schemas.add("_realtime")); + if (statement.includes("ALTER SCHEMA _realtime OWNER TO postgres;")) + yield* Effect.sync(() => pending.schemaOwners.set("_realtime", "postgres")); + }), + setRolePassword: (role: string, password: Redacted.Redacted) => + Effect.gen(function* () { + yield* Effect.sync(() => operations.push(`ALTER ROLE ${role} PASSWORD`)); + if (shouldFail("role")) return yield* new DatabaseBootstrapError({ - message: `failed ${revision.id}`, - revision: revision.id, + message: "role failed secret-password", }); - } - if (revision !== undefined) successfulRevisions.push(revision.id); + yield* Effect.sync(() => pending.passwords.set(role, Redacted.value(password))); }), - setRolePassword: ( - role: - | "postgres" - | "authenticator" - | "pgbouncer" - | "supabase_auth_admin" - | "supabase_storage_admin" - | "supabase_replication_admin" - | "supabase_read_only_user", - password: Redacted.Redacted, - ) => - options.failPassword - ? Effect.fail( - new DatabaseBootstrapError({ message: "password rejected secret-password" }), - ) - : Effect.sync(() => passwords.push([role, Redacted.value(password)])), - setDatabaseSetting: (setting: DatabaseBootstrapSetting) => - Effect.sync(() => { - settings.push([ - setting.name, - setting.name === "app.settings.jwt_secret" - ? Redacted.value(setting.value) - : setting.value, - ]); + setDatabaseSetting: (setting: { + readonly name: string; + readonly value: Redacted.Redacted | number; + }) => + Effect.gen(function* () { + yield* Effect.sync(() => + operations.push(`ALTER DATABASE postgres SET ${setting.name}`), + ); + if (shouldFail("setting")) + return yield* new DatabaseBootstrapError({ + message: "setting failed secret-jwt", + }); + yield* Effect.sync(() => + pending.settings.set( + setting.name, + typeof setting.value === "number" + ? setting.value + : Redacted.value(setting.value), + ), + ); }), - query: () => Effect.succeed(applied.map((revision) => ({ revision }))), }; - yield* use(tx); - applied.push(...pending); + yield* use(transaction); + state.schemas.clear(); + pending.schemas.forEach((schema) => state.schemas.add(schema)); + state.schemaOwners.clear(); + pending.schemaOwners.forEach((owner, schema) => state.schemaOwners.set(schema, owner)); + state.passwords.clear(); + pending.passwords.forEach((password, role) => state.passwords.set(role, password)); + state.settings.clear(); + pending.settings.forEach((value, name) => state.settings.set(name, value)); }), ), }; - return { session, applied, operations, passwords, settings, successfulRevisions }; + return { + session, + state, + operations, + get transactions() { + return transactions; + }, + }; }); +const options = (password: string, jwtSecret: string, jwtExpiry = 3600) => ({ + databasePassword: Redacted.make(password), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry, +}); + describe("database bootstrap", () => { - it.live("applies ordered revisions once and keeps credentials outside SQL", () => + it.live("reconciles the schema, login roles, and settings on every invocation", () => Effect.gen(function* () { - const state = yield* makeSession(); - const credentials: DatabaseBootstrapCredentials = { - roles: { postgres: Redacted.make("secret-password") }, - }; - yield* runDatabaseBootstrap(state.session, { - revisions, - credentials, - settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, - }); - yield* runDatabaseBootstrap(state.session, { - revisions, - credentials, - settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, - }); - expect(state.applied).toEqual(revisions.map(({ id }) => id)); - // Credential reconciliation is intentionally a separate idempotent - // phase. It runs on each invocation so a changed managed password is - // applied even when every schema revision is already recorded. - expect(state.passwords).toEqual([ - ["postgres", "secret-password"], - ["postgres", "secret-password"], - ]); - expect(state.settings).toEqual([ - ["app.settings.jwt_secret", "secret-jwt"], - ["app.settings.jwt_exp", 3600], - ["app.settings.jwt_secret", "secret-jwt"], - ["app.settings.jwt_exp", 3600], - ]); - expect(state.operations.join(" ")).not.toContain("secret-password"); + const runtime = yield* makeSession(); + yield* runDatabaseBootstrap(runtime.session, options("password-a", "jwt-a")); + runtime.state.schemaOwners.set("_realtime", "other"); + yield* runDatabaseBootstrap(runtime.session, options("password-b", "jwt-b", 7200)); + + expect(runtime.state.schemas).toEqual(new Set(["_realtime"])); + expect(runtime.state.schemaOwners).toEqual(new Map([["_realtime", "postgres"]])); + expect([...runtime.state.passwords.entries()]).toEqual( + ROLE_NAMES.map((role) => [role, "password-b"]), + ); + expect(runtime.state.settings).toEqual( + new Map([ + ["app.settings.jwt_secret", "jwt-b"], + ["app.settings.jwt_exp", 7200], + ]), + ); + expect(runtime.transactions).toBe(2); + expect(runtime.operations[0]).toContain("pg_advisory_xact_lock"); + expect(runtime.operations.join(" ")).not.toContain("password-a"); + expect(runtime.operations.join(" ")).not.toContain("password-b"); + expect(runtime.operations.join(" ")).not.toContain("jwt-a"); + expect(runtime.operations.join(" ")).not.toContain("jwt-b"); }), ); - it.live("does not record a failed revision and retries it later", () => + it.live("rolls back every bootstrap change and can retry after a transaction failure", () => Effect.gen(function* () { - const state = yield* makeSession({ failRevision: "schemas" }); - const first = yield* runDatabaseBootstrap(state.session, { - revisions, - }).pipe(Effect.exit); + const runtime = yield* makeSession({ failOnce: "role" }); + const first = yield* runDatabaseBootstrap( + runtime.session, + options("secret-password", "secret-jwt"), + ).pipe(Effect.exit); expect(Exit.isFailure(first)).toBe(true); - expect(state.applied).toEqual(["roles", "extensions"]); - yield* runDatabaseBootstrap(state.session, { revisions }); - expect(state.applied).toEqual(revisions.map(({ id }) => id)); - expect(state.successfulRevisions.filter((entry) => entry === "schemas")).toHaveLength(1); + if (Exit.isFailure(first)) expect(Cause.pretty(first.cause)).not.toContain("secret-password"); + expect(runtime.state.schemas).toEqual(new Set()); + expect(runtime.state.schemaOwners).toEqual(new Map()); + expect(runtime.state.passwords).toEqual(new Map()); + expect(runtime.state.settings).toEqual(new Map()); + + yield* runDatabaseBootstrap(runtime.session, options("secret-password", "secret-jwt")); + expect(runtime.state.schemas).toEqual(new Set(["_realtime"])); + expect(runtime.state.schemaOwners).toEqual(new Map([["_realtime", "postgres"]])); + expect(runtime.state.passwords.size).toBe(ROLE_NAMES.length); + expect(runtime.state.settings.get("app.settings.jwt_secret")).toBe("secret-jwt"); }), ); - it.live("keeps managed passwords out of SQL and bootstrap errors", () => + it.live("maps setting failures without exposing JWT material", () => Effect.gen(function* () { - const state = yield* makeSession({ failPassword: true }); - const result = yield* runDatabaseBootstrap(state.session, { - revisions: [], - credentials: { roles: { postgres: Redacted.make("secret-password") } }, - }).pipe(Effect.exit); + const runtime = yield* makeSession({ failOnce: "setting" }); + const result = yield* runDatabaseBootstrap( + runtime.session, + options("secret-password", "secret-jwt"), + ).pipe(Effect.exit); expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) + if (Exit.isFailure(result)) { expect(Cause.pretty(result.cause)).not.toContain("secret-password"); - expect(state.operations.join(" ")).not.toContain("secret-password"); - }), - ); - - it.live("applies the managed password to every login role", () => - Effect.gen(function* () { - const state = yield* makeSession(); - const password = Redacted.make("secret-password"); - yield* runDatabaseBootstrap(state.session, { - revisions: [], - credentials: { - roles: { - postgres: password, - authenticator: password, - pgbouncer: password, - supabase_auth_admin: password, - supabase_storage_admin: password, - supabase_replication_admin: password, - supabase_read_only_user: password, - }, - }, - settings: { jwtSecret: Redacted.make("secret-jwt"), jwtExpiry: 3600 }, - }); - expect(state.passwords).toEqual([ - ["postgres", "secret-password"], - ["authenticator", "secret-password"], - ["pgbouncer", "secret-password"], - ["supabase_auth_admin", "secret-password"], - ["supabase_storage_admin", "secret-password"], - ["supabase_replication_admin", "secret-password"], - ["supabase_read_only_user", "secret-password"], - ]); - expect(state.operations.join(" ")).not.toContain("secret-password"); - }), - ); - - it.live("serializes concurrent callers before applying a revision", () => - Effect.gen(function* () { - const state = yield* makeSession(); - yield* Effect.all( - [ - runDatabaseBootstrap(state.session, { revisions }), - runDatabaseBootstrap(state.session, { revisions }), - ], - { concurrency: "unbounded", discard: true }, - ); - expect(state.applied).toEqual(revisions.map(({ id }) => id)); - expect(state.operations.filter((entry) => entry === "CREATE ROLE anon")).toHaveLength(1); + expect(Cause.pretty(result.cause)).not.toContain("secret-jwt"); + } + expect(runtime.state.schemas).toEqual(new Set()); + expect(runtime.state.schemaOwners).toEqual(new Map()); + expect(runtime.state.passwords).toEqual(new Map()); + expect(runtime.state.settings).toEqual(new Map()); }), ); }); diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 05a8fecfea..88492cf486 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Stream } from "effect"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import type { ArtifactRequest, ArtifactStore, PreparedArtifact } from "./ArtifactStore.ts"; import type { @@ -74,6 +74,7 @@ const containerEngine = ( waitContainer: () => Effect.succeed(0), stopContainer: () => Effect.void, removeContainer: () => Effect.void, + streamLogs: () => Stream.empty, }); describe("runtime artifact preparation", () => { diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 9a753d4ed6..48d481b5f5 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -27,7 +27,6 @@ const artifact: NativeWorkloadArtifact = { checksumUrl: "https://example.test/SHA256SUMS", requiredRuntimePaths: ["bin/demo"], executablePath: "bin/demo", - containerAlias: "supabase-demo", }; const request: ArtifactRequest = { key: "demo/v1", diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 40f0fc5e0d..13202a6344 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -426,7 +426,6 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff call: (rpc: StackRpcClient) => Effect.Effect, mapError: (error: ControlError) => E, launch = false, - cleanupFreshOwner = false, ): Effect.Effect => { const rpcCall: Effect.Effect = resolveClient( launch, @@ -434,9 +433,7 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff Effect.flatMap(({ client, resolution }) => Effect.scoped(client.rpc.pipe(Effect.flatMap(call))).pipe( Effect.onExit((result) => - cleanupFreshOwner && resolution.launched - ? cleanupLaunchedOwner(resolution, result) - : Effect.void, + launch ? cleanupLaunchedOwner(resolution, result) : Effect.void, ), ), ), @@ -564,7 +561,6 @@ export const makeHandle = (id: StackId, options: HandleDependencies): Effect.Eff : rpc.start({ config: startOptions.config }), startError, true, - true, ).pipe( Effect.tapError(() => options.readPersistedState.pipe( diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index d7c41021c2..9c38c70795 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -1003,6 +1003,7 @@ describe("Effect stack lifecycle handoff", () => { }); return Effect.succeed({ stdout: "", stderr: "", exitCode: 0 }); }, + stream: () => Stream.empty, }; const engine = makeDockerEngine({ runner, platform: { os: "linux" } }); const stack = yield* createStack({ @@ -1238,6 +1239,7 @@ describe("Effect stack lifecycle handoff", () => { ); return Effect.succeed({ stdout: "", stderr: "", exitCode: 0 }); }, + stream: () => Stream.empty, }; const engine = makeDockerEngine({ runner, diff --git a/packages/stack/src/runtime/ContainerEngine.ts b/packages/stack/src/runtime/ContainerEngine.ts index bfcc2b8c67..860ae5a21f 100644 --- a/packages/stack/src/runtime/ContainerEngine.ts +++ b/packages/stack/src/runtime/ContainerEngine.ts @@ -14,7 +14,6 @@ export interface ContainerPlatform { } export interface ContainerHostRoute { readonly host: string; - readonly gateway?: string; /** Host address where the in-process gateway also binds for rootful Linux containers. */ readonly bindAddress?: string; } @@ -72,7 +71,7 @@ export const CONTAINER_LABEL_KEYS = { }; /** Shapes byte-identical label argv for Docker and Podman. */ -export const containerLabels = (value: ContainerLabels): ReadonlyArray => { +const containerLabels = (value: ContainerLabels): ReadonlyArray => { const pairs: ReadonlyArray = value.role === "network" ? [ @@ -137,7 +136,6 @@ export interface ContainerContainerSpec { readonly mounts: ReadonlyArray; readonly volumeMounts: ReadonlyArray; readonly publications: ReadonlyArray; - readonly hostRoute?: ContainerHostRoute; readonly role: "workload"; /** Optional image entrypoint override used by service-owned init processes. */ readonly entrypoint?: string; @@ -170,6 +168,90 @@ export type ContainerCommand = | { readonly operation: "wait-container"; readonly id: string } | { readonly operation: "stop-container"; readonly id: string } | { readonly operation: "remove-container"; readonly id: string }; + +type CommonContainerCommand = Extract< + ContainerCommand, + | { readonly operation: "create-network" } + | { readonly operation: "remove-network" } + | { readonly operation: "create-volume" } + | { readonly operation: "remove-volume" } + | { readonly operation: "create-container" } + | { readonly operation: "copy-container" } + | { readonly operation: "start-container" } + | { readonly operation: "wait-container" } + | { readonly operation: "stop-container" } + | { readonly operation: "remove-container" } +>; + +export const serializeCommonContainerCommand = ( + command: CommonContainerCommand, +): ContainerProcessRequest => { + switch (command.operation) { + case "create-network": + return { + args: ["network", "create", ...containerLabels(command.spec.labels), command.spec.name], + }; + case "remove-network": + return { args: ["network", "rm", command.id] }; + case "create-volume": + return { + args: ["volume", "create", ...containerLabels(command.spec.labels), command.spec.name], + }; + case "remove-volume": + return { args: ["volume", "rm", command.id] }; + case "create-container": { + const bindMounts = command.spec.mounts.flatMap((mount) => [ + "--mount", + `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, + ]); + const volumeMounts = command.spec.volumeMounts.flatMap((mount) => [ + "--mount", + `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, + ]); + const publications = command.spec.publications.flatMap((port) => [ + "--publish", + `${port.address}:${port.hostPort}:${port.containerPort}`, + ]); + const environment = + command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; + const networkAliases = + command.spec.networkAliases === undefined + ? [] + : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); + const entrypoint = + command.spec.entrypoint === undefined ? [] : ["--entrypoint", command.spec.entrypoint]; + return { + args: [ + "create", + "--name", + command.spec.name, + "--network", + command.spec.network, + ...networkAliases, + ...containerLabels(command.spec.labels), + ...bindMounts, + ...volumeMounts, + ...publications, + ...environment, + ...entrypoint, + command.spec.image, + ...(command.spec.command ?? []), + ], + }; + } + case "copy-container": + return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; + case "start-container": + return { args: ["start", command.id] }; + case "wait-container": + return { args: ["wait", command.id] }; + case "stop-container": + return { args: ["stop", command.id] }; + case "remove-container": + return { args: ["rm", "--force", command.id] }; + } +}; + export interface ContainerCommandResult { readonly stdout: string; readonly stderr: string; @@ -197,7 +279,7 @@ export interface ContainerCommandRunner { request: ContainerProcessRequest, ) => Effect.Effect; /** Follows one exact process's stdout/stderr until it exits. */ - readonly stream?: ( + readonly stream: ( request: ContainerProcessRequest, ) => Stream.Stream; } @@ -405,17 +487,12 @@ export interface ContainerEngineCodecs { readonly decodeWait: ( result: ContainerCommandResult, ) => Effect.Effect; - readonly serializeLogs: ( - id: string, - options: ContainerLogOptions | undefined, - ) => ContainerProcessRequest; } export const makeContainerEngineCodecs = (options: { readonly engineName: "Docker" | "Podman"; readonly scalarFormat: "json" | "raw"; readonly serialize: ContainerEngineCodecs["serialize"]; - readonly serializeLogs: ContainerEngineCodecs["serializeLogs"]; }): ContainerEngineCodecs => { const protocol = (operation: string, cause?: unknown): ContainerEngineProtocolError => new ContainerEngineProtocolError({ @@ -579,7 +656,6 @@ export const makeContainerEngineCodecs = (options: { }; return { serialize: options.serialize, - serializeLogs: options.serializeLogs, decodeProbe: (result) => scalar("probe", result.stdout).pipe(Effect.asVoid), decodeImage: (result) => Effect.forEach(lines(result.stdout), (line) => scalar("inspect-image", line)).pipe( @@ -635,7 +711,7 @@ export interface ContainerEngine { readonly stopContainer: (id: string) => Effect.Effect; readonly removeContainer: (id: string) => Effect.Effect; /** Follows one exact container and emits complete stdout/stderr lines. */ - readonly streamLogs?: ( + readonly streamLogs: ( id: string, options?: ContainerLogOptions, ) => Stream.Stream; @@ -662,13 +738,6 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai logOptions?: ContainerLogOptions, ): Stream.Stream => { const source = options.runner.stream; - if (source === undefined) - return Stream.fail( - new ContainerEngineProtocolError({ - operation: "logs", - message: "Container engine log streaming is unavailable", - }), - ); interface LineState { readonly stdout: { readonly decoder: TextDecoder; remainder: string }; readonly stderr: { readonly decoder: TextDecoder; remainder: string }; @@ -696,7 +765,15 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai accumulator.remainder = ""; return [{ stream: streamName, message }]; }); - return source(options.codecs.serializeLogs(id, logOptions)).pipe( + return source({ + args: [ + "logs", + "--follow", + "--tail", + logOptions?.tail === undefined ? "all" : String(logOptions.tail), + id, + ], + }).pipe( Stream.mapAccum(stateFor, (state, chunk) => [state, split(state, chunk)] as const, { onHalt: flush, }), @@ -711,13 +788,7 @@ export const makeContainerEngineCore = (options: ContainerEngineOptions): Contai message: "Docker remote daemon has no verified host route", }), ) - : Effect.succeed( - options.platform.os === "linux" && - options.platform.desktop !== true && - options.platform.rootless !== true - ? { host: "host.docker.internal", gateway: "host-gateway" } - : { host: "host.docker.internal" }, - ) + : Effect.succeed({ host: "host.docker.internal" }) : options.platform.remote === true || options.platform.os !== "linux" ? Effect.fail( new ContainerRoutingUnsupportedError({ diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 9f47fdfaf9..68e3238c67 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -17,7 +17,6 @@ import type { StackId } from "../public/StackId.ts"; import type { LogStore } from "../supervisor/LogStore.ts"; import { type ContainerContainerSpec, - type ContainerHostRoute, type ContainerEngine, type ContainerEngineFailure, type ContainerLabels, @@ -78,7 +77,6 @@ export interface ContainerWorkloadResolution { /** Path to an owned env file; secret bytes are kept out of engine argv. */ readonly envFile?: string; readonly networkAliases?: ReadonlyArray; - readonly hostRoute?: ContainerHostRoute; /** Private host-loopback publications used by the in-process gateway. */ readonly publications?: ReadonlyArray; /** Service-owned one-shot processes run before a newly-created main container. */ @@ -346,12 +344,6 @@ export const makeContainerRuntime = ( ): Effect.Effect => { const logStore = options.logStore; if (logStore === undefined) return Effect.void; - if (options.engine.streamLogs === undefined) - return reportFailure( - resource, - new Error("Container log streaming is unavailable"), - `Container log stream failed for ${resource.key.workloadId}`, - ); const stream = options.engine.streamLogs(resource.container, { tail }); const consume = stream.pipe( Stream.runForEach((line) => @@ -466,9 +458,6 @@ export const makeContainerRuntime = ( ...(context.resolution.envFile === undefined ? {} : { envFile: context.resolution.envFile }), - ...(context.resolution.hostRoute === undefined - ? {} - : { hostRoute: context.resolution.hostRoute }), }; let logFiber: Fiber.Fiber | undefined; const acquire = withEngine(key, options.engine.createContainer(specification)); @@ -476,7 +465,7 @@ export const makeContainerRuntime = ( const use = (container: ContainerResource): Effect.Effect => Effect.gen(function* () { yield* withEngine(key, options.engine.startContainer(container.id)); - if (logStore !== undefined && options.engine.streamLogs !== undefined) { + if (logStore !== undefined) { const consume = options.engine.streamLogs(container.id, { tail: "all" }).pipe( Stream.runForEach((line) => logStore @@ -751,7 +740,6 @@ export const makeContainerRuntime = ( ...(resolution.networkAliases === undefined ? {} : { networkAliases: resolution.networkAliases }), - ...(resolution.hostRoute === undefined ? {} : { hostRoute: resolution.hostRoute }), ...(resolution.entrypoint === undefined ? {} : { entrypoint: resolution.entrypoint }), ...(resolution.command === undefined ? {} : { command: resolution.command }), } satisfies ContainerContainerSpec), diff --git a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts index 73d4794d2e..fc279b33d1 100644 --- a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts +++ b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts @@ -4,16 +4,6 @@ import type { PersistedStackState } from "../state/StackState.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { AUTH_JWT_SECRET_SLOT, DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; -/** - * The only SQL revision owned by the runtime bootstrap. The slim Postgres - * artifact owns its bundled init scripts and migrations; this revision only - * reconciles the schema ownership that used to be applied by the CLI. - */ -export const DATABASE_BOOTSTRAP_REVISION = { - id: "database-realtime-schema-owner", - statement: "CREATE SCHEMA IF NOT EXISTS _realtime;\nALTER SCHEMA _realtime OWNER TO postgres;", -} as const; - const missingMaterial = (message: string) => new StackPreparationError({ message }); const secretValue = (state: PersistedStackState, slot: string): string | undefined => { @@ -25,8 +15,8 @@ const secretValue = (state: PersistedStackState, slot: string): string | undefin * Builds the initial database bootstrap from fully materialized state. * * This helper intentionally does not read artifact SQL files or perform - * caller-driven reset/migration/seed work. It returns only the closed role - * credentials, database settings, and one idempotent `_realtime` revision. + * caller-driven reset/migration/seed work. It returns only the managed + * material required by the fixed runtime bootstrap reconciliation. */ export const databaseBootstrapPlan = ( state: PersistedStackState, @@ -54,23 +44,9 @@ export const databaseBootstrapPlan = ( ) return yield* missingMaterial("Auth JWT expiry must be a finite positive integer"); - const password = Redacted.make(databasePassword); return { - revisions: [DATABASE_BOOTSTRAP_REVISION], - credentials: { - roles: { - postgres: password, - authenticator: password, - pgbouncer: password, - supabase_auth_admin: password, - supabase_storage_admin: password, - supabase_replication_admin: password, - supabase_read_only_user: password, - }, - }, - settings: { - jwtSecret: Redacted.make(jwtSecret), - jwtExpiry, - }, + databasePassword: Redacted.make(databasePassword), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry, } satisfies DatabaseBootstrapOptions; }); diff --git a/packages/stack/src/runtime/DockerEngine.ts b/packages/stack/src/runtime/DockerEngine.ts index e8ddc3932e..f5842b8ae4 100644 --- a/packages/stack/src/runtime/DockerEngine.ts +++ b/packages/stack/src/runtime/DockerEngine.ts @@ -2,11 +2,10 @@ import { makeContainerEngineCodecs, makeContainerEngineCore, CONTAINER_LABEL_KEYS, - containerLabels, + serializeCommonContainerCommand, type ContainerCommand, type ContainerEngine, type ContainerEngineOptions, - type ContainerLogOptions, type ContainerProcessRequest, } from "./ContainerEngine.ts"; @@ -83,89 +82,11 @@ export const serializeDockerCommand = (command: ContainerCommand): ContainerProc volumeFormat, ], }; - case "create-network": - return { - args: ["network", "create", ...containerLabels(command.spec.labels), command.spec.name], - }; - case "remove-network": - return { args: ["network", "rm", command.id] }; - case "create-volume": - return { - args: ["volume", "create", ...containerLabels(command.spec.labels), command.spec.name], - }; - case "remove-volume": - return { args: ["volume", "rm", command.id] }; - case "create-container": { - const bindMounts = command.spec.mounts.flatMap((mount) => [ - "--mount", - `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, - ]); - const volumeMounts = command.spec.volumeMounts.flatMap((mount) => [ - "--mount", - `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, - ]); - const publications = command.spec.publications.flatMap((port) => [ - "--publish", - `${port.address}:${port.hostPort}:${port.containerPort}`, - ]); - const hostRoute = - command.spec.hostRoute?.gateway === undefined - ? [] - : ["--add-host", `${command.spec.hostRoute.host}:${command.spec.hostRoute.gateway}`]; - const environment = - command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; - const networkAliases = - command.spec.networkAliases === undefined - ? [] - : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); - const entrypoint = - command.spec.entrypoint === undefined ? [] : ["--entrypoint", command.spec.entrypoint]; - return { - args: [ - "create", - "--name", - command.spec.name, - "--network", - command.spec.network, - ...networkAliases, - ...containerLabels(command.spec.labels), - ...bindMounts, - ...volumeMounts, - ...publications, - ...hostRoute, - ...environment, - ...entrypoint, - command.spec.image, - ...(command.spec.command ?? []), - ], - }; - } - case "copy-container": - return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; - case "start-container": - return { args: ["start", command.id] }; - case "wait-container": - return { args: ["wait", command.id] }; - case "stop-container": - return { args: ["stop", command.id] }; - case "remove-container": - return { args: ["rm", "--force", command.id] }; + default: + return serializeCommonContainerCommand(command); } }; -const serializeDockerLogs = ( - id: string, - options: ContainerLogOptions | undefined, -): ContainerProcessRequest => ({ - args: [ - "logs", - "--follow", - "--tail", - options?.tail === undefined ? "all" : String(options.tail), - id, - ], -}); - export const makeDockerEngine = ( options: Omit, ): ContainerEngine => @@ -176,6 +97,5 @@ export const makeDockerEngine = ( engineName: "Docker", scalarFormat: "json", serialize: serializeDockerCommand, - serializeLogs: serializeDockerLogs, }), }); diff --git a/packages/stack/src/runtime/PodmanEngine.ts b/packages/stack/src/runtime/PodmanEngine.ts index fcdf9e97d5..55e03591cd 100644 --- a/packages/stack/src/runtime/PodmanEngine.ts +++ b/packages/stack/src/runtime/PodmanEngine.ts @@ -2,11 +2,10 @@ import { makeContainerEngineCodecs, makeContainerEngineCore, CONTAINER_LABEL_KEYS, - containerLabels, + serializeCommonContainerCommand, type ContainerCommand, type ContainerEngine, type ContainerEngineOptions, - type ContainerLogOptions, type ContainerProcessRequest, } from "./ContainerEngine.ts"; @@ -84,84 +83,11 @@ export const serializePodmanCommand = (command: ContainerCommand): ContainerProc volumeFormat, ], }; - case "create-network": - return { - args: ["network", "create", ...containerLabels(command.spec.labels), command.spec.name], - }; - case "remove-network": - return { args: ["network", "rm", command.id] }; - case "create-volume": - return { - args: ["volume", "create", ...containerLabels(command.spec.labels), command.spec.name], - }; - case "remove-volume": - return { args: ["volume", "rm", command.id] }; - case "create-container": { - const bindMounts = command.spec.mounts.flatMap((mount) => [ - "--mount", - `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, - ]); - const volumeMounts = command.spec.volumeMounts.flatMap((mount) => [ - "--mount", - `type=volume,src=${mount.volume},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`, - ]); - const publications = command.spec.publications.flatMap((port) => [ - "--publish", - `${port.address}:${port.hostPort}:${port.containerPort}`, - ]); - const environment = - command.spec.envFile === undefined ? [] : ["--env-file", command.spec.envFile]; - const networkAliases = - command.spec.networkAliases === undefined - ? [] - : command.spec.networkAliases.flatMap((alias) => ["--network-alias", alias]); - const entrypoint = - command.spec.entrypoint === undefined ? [] : ["--entrypoint", command.spec.entrypoint]; - return { - args: [ - "create", - "--name", - command.spec.name, - "--network", - command.spec.network, - ...networkAliases, - ...containerLabels(command.spec.labels), - ...bindMounts, - ...volumeMounts, - ...publications, - ...environment, - ...entrypoint, - command.spec.image, - ...(command.spec.command ?? []), - ], - }; - } - case "copy-container": - return { args: ["cp", command.source, `${command.id}:${command.destination}`] }; - case "start-container": - return { args: ["start", command.id] }; - case "wait-container": - return { args: ["wait", command.id] }; - case "stop-container": - return { args: ["stop", command.id] }; - case "remove-container": - return { args: ["rm", "--force", command.id] }; + default: + return serializeCommonContainerCommand(command); } }; -const serializePodmanLogs = ( - id: string, - options: ContainerLogOptions | undefined, -): ContainerProcessRequest => ({ - args: [ - "logs", - "--follow", - "--tail", - options?.tail === undefined ? "all" : String(options.tail), - id, - ], -}); - export const makePodmanEngine = ( options: Omit, ): ContainerEngine => @@ -172,6 +98,5 @@ export const makePodmanEngine = ( engineName: "Podman", scalarFormat: "raw", serialize: serializePodmanCommand, - serializeLogs: serializePodmanLogs, }), }); diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 5d69f2b055..79f173c68a 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -10,7 +10,6 @@ import { runDatabaseBootstrap, } from "../model/DatabaseBootstrap.ts"; import type { PersistedStackState } from "../state/StackState.ts"; -import { DATABASE_INTERNAL_PASSWORD_SLOT } from "../state/SecretStore.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; @@ -94,7 +93,6 @@ export const makeDatabaseSessionFromSqlClient = ( const transaction: DatabaseTransaction = { execute: executeWith, - query: queryWith, setRolePassword: (role, password) => generated("SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text) AS statement", [ role, @@ -190,17 +188,12 @@ export const bootstrapDatabaseAt = ( return yield* new StackPreparationError({ message: "A persisted database private port is required for bootstrap", }); - const password = state.secrets[DATABASE_INTERNAL_PASSWORD_SLOT]?.value; - if (typeof password !== "string" || password.length === 0) - return yield* new StackPreparationError({ - message: "Managed database password is unavailable for bootstrap", - }); yield* Effect.scoped( Effect.gen(function* () { const session = yield* makePostgresDatabaseSession({ host: "127.0.0.1", port, - password: Redacted.make(password), + password: plan.databasePassword, }); yield* ensureInternalDatabase( session, @@ -208,7 +201,7 @@ export const bootstrapDatabaseAt = ( host: "127.0.0.1", port, database: INTERNAL_DATABASE, - password: Redacted.make(password), + password: plan.databasePassword, }), ); yield* runDatabaseBootstrap(session, plan); diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 3584df6db6..1c993629e8 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -181,7 +181,6 @@ export interface ContainerWorkloadResolution { readonly hostPort: number; readonly containerPort: number; }>; - readonly hostRoute?: ContainerHostRoute; readonly bootstrap?: Readonly<{ readonly source: string; readonly destination: string }>; } @@ -1495,7 +1494,6 @@ export const containerResolutionFor = ( }, } : {}), - ...(inputs.hostRoute === undefined ? {} : { hostRoute: inputs.hostRoute }), }; }; diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index db7b743269..5ce695f172 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -41,9 +41,11 @@ import { makeSupervisor, type SupervisorRuntime } from "../supervisor/Supervisor import type { SupervisorIngress } from "../supervisor/Ingress.ts"; import { deriveStackId } from "../identity/Identity.ts"; -const makeControlledCommandRunner = (options: ContainerCommandRunner): ContainerCommandRunner => ({ +const makeControlledCommandRunner = ( + options: Pick & Partial>, +): ContainerCommandRunner => ({ run: options.run, - ...(options.stream === undefined ? {} : { stream: options.stream }), + stream: options.stream ?? (() => Stream.empty), }); const stackId = StackIdSchema.make("a".repeat(64)); @@ -196,6 +198,7 @@ const fakeContainerEngine = (state: FakeContainerState): ContainerEngine => { state.calls.push(`remove:${resourceId}`); state.resources = state.resources.filter((resource) => resource.id !== resourceId); }), + streamLogs: () => Stream.empty, }; }; @@ -284,12 +287,14 @@ describe("container runtime", () => { executable: process.execPath, baseArgs: ["-e", "process.stdout.write('follower-line\\n'); setInterval(() => {}, 1000)"], }); - const follower = yield* runner.stream!({ - args: ["logs", "--follow", "--tail", "0", "container-id"], - }).pipe( - Stream.runForEach(() => Deferred.succeed(firstChunk, undefined)), - Effect.forkChild({ startImmediately: true }), - ); + const follower = yield* runner + .stream({ + args: ["logs", "--follow", "--tail", "0", "container-id"], + }) + .pipe( + Stream.runForEach(() => Deferred.succeed(firstChunk, undefined)), + Effect.forkChild({ startImmediately: true }), + ); yield* Deferred.await(firstChunk); yield* Fiber.interrupt(follower); }).pipe(Effect.provide(NodeServices.layer)), @@ -394,7 +399,7 @@ describe("container runtime", () => { runner, platform: { os: "linux", desktop: false }, }); - expect((yield* linux.preflight).gateway).toBe("host-gateway"); + expect((yield* linux.preflight).host).toBe("host.docker.internal"); const podman = makePodmanEngine({ runner, platform: { os: "linux", rootless: true }, @@ -520,9 +525,7 @@ describe("container runtime", () => { resolveWorkload: () => Effect.sync(() => { resolutions += 1; - return routeReady - ? { hostRoute: { host: "172.18.0.1", bindAddress: "172.18.0.1" } } - : {}; + return { command: [routeReady ? "updated" : "initial"] }; }), onNetworkReady: () => Effect.sync(() => { @@ -533,10 +536,7 @@ describe("container runtime", () => { const ready = yield* runtime.start(key, workload()); expect(ready.state).toBe("ready"); expect(resolutions).toBe(2); - expect(state.createdSpecs.at(-1)?.hostRoute).toEqual({ - host: "172.18.0.1", - bindAddress: "172.18.0.1", - }); + expect(state.createdSpecs.at(-1)?.command).toEqual(["updated"]); yield* runtime.cleanup({ stackId, destroy: true }); }), ); @@ -1775,7 +1775,7 @@ describe("container runtime", () => { }), ); - it.live("keeps Docker and Podman command codecs independent and closed", () => + it.live("preserves Docker and Podman command behavior", () => Effect.gen(function* () { const workload = { name: "backend", @@ -1787,20 +1787,19 @@ describe("container runtime", () => { role: "workload" as const, }, network: "private", - mounts: [], - volumeMounts: [], + mounts: [{ source: "/tmp/backend", target: "/app/backend", readOnly: true }], + volumeMounts: [{ volume: "backend-data", target: "/var/lib/backend", readOnly: false }], publications: [{ address: "127.0.0.1" as const, hostPort: 54321, containerPort: 8000 }], envFile: "/tmp/supabase-owned.env", networkAliases: ["supabase-database"], command: ["serve", "--port", "8000"], - hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, role: "workload" as const, }; const docker = serializeDockerCommand({ operation: "create-container", spec: workload }); - expect(docker.args).toContain("--add-host"); - expect(docker.args).toContain("host.docker.internal:host-gateway"); expect(docker.args).toContain("--publish"); expect(docker.args).toContain("127.0.0.1:54321:8000"); + expect(docker.args).toContain("type=bind,src=/tmp/backend,dst=/app/backend,ro"); + expect(docker.args).toContain("type=volume,src=backend-data,dst=/var/lib/backend"); expect(docker.args).toContain("--network-alias"); expect(docker.args).toContain("supabase-database"); expect(docker.args).toContain("--env-file"); @@ -1813,13 +1812,14 @@ describe("container runtime", () => { }); expect(podmanCreate.args).toContain("--env-file"); expect(podmanCreate.args).toContain("127.0.0.1:54321:8000"); + expect(podmanCreate.args).toContain("type=bind,src=/tmp/backend,dst=/app/backend,ro"); + expect(podmanCreate.args).toContain("type=volume,src=backend-data,dst=/var/lib/backend"); expect(podmanCreate.args).toContain("--network-alias"); expect(podmanCreate.args).toContain("/tmp/supabase-owned.env"); expect(podmanCreate.args.join(" ")).not.toContain("value"); expect(podmanCreate.args.slice(-3)).toEqual(["serve", "--port", "8000"]); const podman = serializePodmanCommand({ operation: "inspect-networks", stackId }); expect(podman.args.join(" ")).toContain("{{index .Labels"); - expect(podman.args.join(" ")).not.toContain("host-gateway"); expect( serializeDockerCommand({ operation: "copy-container", @@ -1910,8 +1910,8 @@ describe("container runtime", () => { runner, platform: { os: "linux", rootless: true }, }); - const dockerLogs = yield* Stream.runCollect(docker.streamLogs!("container-id", { tail: 0 })); - const podmanLogs = yield* Stream.runCollect(podman.streamLogs!("podman-id")); + const dockerLogs = yield* Stream.runCollect(docker.streamLogs("container-id", { tail: 0 })); + const podmanLogs = yield* Stream.runCollect(podman.streamLogs("podman-id")); expect(dockerLogs).toEqual([ { stream: "stdout", message: "first" }, { stream: "stderr", message: "error" }, diff --git a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts index 0a885e3ba2..59bc895034 100644 --- a/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts +++ b/packages/stack/src/runtime/database-bootstrap-catalog.integration.test.ts @@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Option, Redacted } from "effect"; import { compileStack } from "../model/Compiler.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { StackPreparationError } from "../public/Errors.ts"; -import { DATABASE_BOOTSTRAP_REVISION, databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; +import { databaseBootstrapPlan } from "./DatabaseBootstrapCatalog.ts"; const stackId = "a".repeat(64); @@ -43,20 +43,13 @@ const errorOf = (exit: Exit.Exit): E | undefined => Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; describe("database bootstrap catalog", () => { - it.live("creates one revision and reconciles all closed roles and settings", () => + it.live("returns the managed database material required for reconciliation", () => Effect.gen(function* () { const state = yield* compileDefinition; const plan = yield* databaseBootstrapPlan(state); - expect(plan.revisions).toEqual([DATABASE_BOOTSTRAP_REVISION]); - expect(Object.keys(plan.credentials?.roles ?? {})).toHaveLength(7); - for (const password of Object.values(plan.credentials?.roles ?? {})) - expect(password === undefined ? undefined : Redacted.value(password)).toBe( - "database-secret", - ); - expect(plan.settings?.jwtExpiry).toBe(3600); - expect( - plan.settings === undefined ? undefined : Redacted.value(plan.settings.jwtSecret), - ).toBe("jwt-secret"); + expect(Redacted.value(plan.databasePassword)).toBe("database-secret"); + expect(Redacted.value(plan.jwtSecret)).toBe("jwt-secret"); + expect(plan.jwtExpiry).toBe(3600); }), ); diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index d41b04ed02..9df922656b 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -625,7 +625,7 @@ describe("workload runtime catalog", () => { ); expect( runtimeSpecFor(functions)?.env(configured, functions, 9000, "container", { - hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + hostRoute: { host: "host.docker.internal" }, }), ).toMatchObject({ SUPABASE_URL: "http://host.docker.internal:54321" }); expect(rest?.env(configured, planned("rest:rest"), 3000, "native")).toMatchObject({ @@ -786,7 +786,7 @@ describe("workload runtime catalog", () => { { address: "127.0.0.1", hostPort: 30017, containerPort: 4000 }, ]); const resolution = containerResolutionFor(configured, functions, { - hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + hostRoute: { host: "host.docker.internal" }, }); expect(resolution?.command.join(" ")).toContain( `--main-service=${FUNCTIONS_BOOTSTRAP_CONTAINER_PATH}`, @@ -808,10 +808,6 @@ describe("workload runtime catalog", () => { Object.keys(resolution?.env ?? {}).some((key) => key.startsWith("FUNCTIONS_FUNCTIONS_")), ).toBe(false); expect(resolution?.env.EDGE_RUNTIME_PORT).toBe("9000"); - expect(resolution?.hostRoute).toEqual({ - host: "host.docker.internal", - gateway: "host-gateway", - }); const bootstrapResolution = containerResolutionFor(configured, functions, { functions: { bootstrapPath: "/tmp/functions/4/main.ts" }, }); @@ -831,7 +827,7 @@ describe("workload runtime catalog", () => { const studio = runtimeSpecFor(planned("studio:studio")); expect( studio?.env(configured, planned("studio:studio"), 3000, "container", { - hostRoute: { host: "host.docker.internal", gateway: "host-gateway" }, + hostRoute: { host: "host.docker.internal" }, }), ).toMatchObject({ SUPABASE_URL: "http://host.docker.internal:54321", diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index 9545c0fff7..fa53eb5e3e 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -279,14 +279,6 @@ export const readOwnerMetadata = ( const paths = yield* resolveStackPaths({ stateRoot, stackId: validId }).pipe( Effect.mapError((error) => stateError(String(error))), ); - const exists = yield* fs - .exists(paths.controlMetadata) - .pipe( - Effect.mapError((error) => - stateError(`Unable to inspect owner metadata: ${error.message}`), - ), - ); - if (!exists) return undefined; const raw = yield* fs.readFileString(paths.controlMetadata).pipe( Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index 3ef4eb74cb..d553f477bf 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -758,13 +758,21 @@ export const makeSupervisor = ( const definition = state.definition; const databaseListener = definition?.listeners.database; const databaseAssignment = state.ports.find(({ field }) => field === "database"); + if (definition === undefined) + return yield* Effect.fail( + rpcError("InvalidStackConfigError", "Stack credentials require a stack definition"), + ); if ( - definition === undefined || databaseListener === undefined || !databaseListener.enabled || databaseAssignment === undefined ) - return yield* Effect.fail(credentialsUnavailable); + return yield* Effect.fail( + rpcError( + "InvalidStackConfigError", + "Stack credentials require an enabled database listener and assigned database port", + ), + ); const auth = definition.capabilities.auth; if (!auth.enabled) @@ -811,7 +819,12 @@ export const makeSupervisor = ( const apiListener = definition.listeners.api; const apiAssignment = state.ports.find(({ field }) => field === "api"); if (apiListener === undefined || !apiListener.enabled || apiAssignment === undefined) - return yield* Effect.fail(credentialsUnavailable); + return yield* Effect.fail( + rpcError( + "InvalidStackConfigError", + "Stack credentials require an enabled API listener and assigned API port", + ), + ); const accessKeyId = s3.access_key_id; const region = s3.region; if ( diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index 7bcedb9bc6..ea2506a8eb 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -154,6 +154,7 @@ const fakeContainerEngine = (kind: "docker" | "podman", calls: string[]): Contai waitContainer: () => Effect.succeed(0), stopContainer: () => Effect.void, removeContainer: () => Effect.void, + streamLogs: () => Stream.empty, }); describe("managed stack handles", { timeout: 30_000 }, () => { diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 9970fbd443..992d87df6e 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -1124,7 +1124,25 @@ describe("Supervisor composition", () => { .pipe(Effect.provideContext(fixture.context)); const listenerExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); expect(errorOf(listenerExit)).toEqual( - expect.objectContaining({ tag: "StackNotRunningError" }), + expect.objectContaining({ tag: "InvalidStackConfigError" }), + ); + + const disabledDatabase = { + ...complete, + definition: { + ...complete.definition, + listeners: { + ...complete.definition.listeners, + database: { ...complete.definition.listeners.database, enabled: false }, + }, + }, + }; + yield* fixture.store + .replace(fixture.id, disabledDatabase) + .pipe(Effect.provideContext(fixture.context)); + const disabledDatabaseExit = yield* invokeCredentials(fixture.supervisor).pipe(Effect.exit); + expect(errorOf(disabledDatabaseExit)).toEqual( + expect.objectContaining({ tag: "InvalidStackConfigError" }), ); }), ), From 5868dd9c1706ace6c1efe692083c531db3ec0f59 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 5 Sep 2026 02:27:13 +0200 Subject: [PATCH 169/174] refactor(stack): scope input preparation to workload startup --- ...7-simplified-managed-stack-architecture.md | 6 + .../stack/src/runtime/ProductionRuntime.ts | 3 +- .../stack/src/runtime/RuntimeInputOwner.ts | 161 +++----------- .../production-runtime.integration.test.ts | 89 ++++++++ .../runtime-input-owner.integration.test.ts | 210 +----------------- 5 files changed, 138 insertions(+), 331 deletions(-) diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 6bf20f630e..eb42cf4815 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -104,6 +104,12 @@ is caller-owned: cancellation stops that caller's unfinished transfers while completed cache entries remain. It never acquires control or ownership and does not create runtime resources. +Runtime input materialization belongs to the workload startup operation. The Supervisor serializes +workload startup with runtime cleanup, so the input owner caches only completed values and leaves an +in-progress resolution in the startup operation's scope. Interrupting native startup therefore interrupts +OIDC fetches and file materialization; successful Pooler and Vector files remain owned until the +runtime cleanup boundary removes them. + Artifact cache trust begins only after a candidate has passed checksum and runtime-path validation, had its metadata written, and been atomically published. A cache hit revalidates the metadata, required path kinds and diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index d5d265bb86..8ded6107d2 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -490,7 +490,6 @@ export const makeProductionRuntime = ( const state = yield* currentStateReader(options); const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const ownerScope = yield* Scope.Scope; const paths = yield* resolveStackPaths({ stateRoot: options.stateRoot, stackId: options.stackId, @@ -516,7 +515,7 @@ export const makeProductionRuntime = ( stateRoot: options.stateRoot, stackId: options.stackId, fetchJson, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); + }); const hostRoute = yield* Ref.make(undefined); const ingress = options.ingress ?? diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index fd4f1d433e..a32576a8d2 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -1,16 +1,4 @@ -import { - Crypto, - Deferred, - Effect, - Exit, - FileSystem, - FiberSet, - Path, - PlatformError, - Scope, - Schema, - Semaphore, -} from "effect"; +import { Crypto, Effect, FileSystem, Path, PlatformError, Schema } from "effect"; import type { PersistedStackState } from "../state/StackState.ts"; import type { StackId } from "../public/StackId.ts"; import { StackPreparationError } from "../public/Errors.ts"; @@ -56,7 +44,13 @@ interface RuntimeInputMaterial { } export interface RuntimeInputOwner { - /** Resolves stack-owned inputs needed before a workload is created. */ + /** + * Resolves stack-owned inputs needed before a workload is created. + * + * The Supervisor serializes workload startup and runtime cleanup. This owner therefore keeps + * only completed material in its caches; the caller owns an in-progress resolution and its + * interruption. + */ readonly resolve: ( state: PersistedStackState, workloadId: string, @@ -274,16 +268,12 @@ export const makeRuntimeInputOwner = ( ): Effect.Effect< RuntimeInputOwner, StackPreparationError, - FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope + FileSystem.FileSystem | Path.Path | Crypto.Crypto > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const admission = yield* Semaphore.make(1); - const execution = yield* Semaphore.make(1); - const ownerScope = yield* Effect.scope; - const ownedFibers = yield* FiberSet.make().pipe(Effect.provideService(Scope.Scope, ownerScope)); const stackPaths = yield* resolveStackPaths(options).pipe( Effect.mapError((cause) => failure("Unable to resolve runtime input paths", { cause })), ); @@ -618,14 +608,8 @@ export const makeRuntimeInputOwner = ( }); }; - const joinExit = (result: Exit.Exit): Effect.Effect => - Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); - type OwnedResult = Deferred.Deferred, never>; - const commonPending = new Map>(); const commonCompleted = new Map(); - const authPending = new Map>>(); const authCompleted = new Map>(); - const poolerPending = new Map>(); const poolerCompleted = new Map(); const keyFor = (state: PersistedStackState): string => `${options.stackId}\u0000${canonicalize(state.runtime)}\u0000${canonicalize(state.definition ?? {})}`; @@ -683,71 +667,16 @@ export const makeRuntimeInputOwner = ( }; }); - const singleFlight = ( + const resolveCached = ( key: string, - pending: Map>, completed: Map, materialize: Effect.Effect, ): Effect.Effect => - Effect.gen(function* () { - const result = yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - type Admission = - | { readonly kind: "ready"; readonly value: A } - | { readonly kind: "waiting"; readonly deferred: OwnedResult }; - const owned = yield* admission.withPermit( - Effect.gen(function* () { - const ready = completed.get(key); - if (ready !== undefined) return { kind: "ready", value: ready } satisfies Admission; - const current = pending.get(key); - if (current !== undefined) - return { kind: "waiting", deferred: current } satisfies Admission; - const deferred = yield* Deferred.make, never>(); - pending.set(key, deferred); - const publish = (exit: Exit.Exit) => - Effect.uninterruptible( - Effect.gen(function* () { - const completion = yield* admission.withPermit( - Effect.sync(() => { - if (pending.get(key) !== deferred) - return Exit.fail( - failure("Runtime input materialization was invalidated"), - ); - pending.delete(key); - if (Exit.isSuccess(exit)) completed.set(key, exit.value); - return exit; - }), - ); - yield* Deferred.succeed(deferred, completion); - }), - ); - let entered = false; - const materializing = Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - entered = true; - const stillCurrent = yield* admission.withPermit( - Effect.sync(() => pending.get(key) === deferred), - ); - if (!stillCurrent) - return yield* failure("Runtime input materialization was invalidated"); - return yield* restore(materialize); - }).pipe(Effect.onExit(publish)), - ); - const owner = execution.withPermit(materializing).pipe( - // An owner interrupted while queued on execution never enters - // `materializing`; this fallback still wakes its waiters. - Effect.onExit((exit) => (entered ? Effect.void : publish(exit))), - ); - yield* FiberSet.run(ownedFibers, owner, { startImmediately: true }); - return { kind: "waiting", deferred } satisfies Admission; - }), - ); - return owned.kind === "ready" - ? Exit.succeed(owned.value) - : yield* restore(Deferred.await(owned.deferred)); - }), - ); - return yield* joinExit(result); + Effect.suspend(() => { + const ready = completed.get(key); + return ready === undefined + ? materialize.pipe(Effect.tap((value) => Effect.sync(() => completed.set(key, value)))) + : Effect.succeed(ready); }); const resolve = ( @@ -757,11 +686,10 @@ export const makeRuntimeInputOwner = ( Effect.gen(function* () { const key = `${keyFor(state)}\u0000${workloadId}`; const auth = needsAuthMaterial(state, workloadId) - ? yield* singleFlight(keyFor(state), authPending, authCompleted, resolveAuth(state)) + ? yield* resolveCached(keyFor(state), authCompleted, resolveAuth(state)) : undefined; - const common = yield* singleFlight( + const common = yield* resolveCached( key, - commonPending, commonCompleted, materializeCommon(state, workloadId, auth), ); @@ -770,51 +698,28 @@ export const makeRuntimeInputOwner = ( state.definition?.capabilities.pooler.enabled !== true ) return common; - const tenantPath = yield* singleFlight( + const tenantPath = yield* resolveCached( key, - poolerPending, poolerCompleted, writePoolerTenant(state, state.runtime.kind), ); return { ...common, pooler: { tenantPath } }; }); - const cleanupAll = execution.withPermit( - Effect.gen(function* () { - const pending = yield* admission.withPermit( - Effect.sync(() => { - const common = [...commonPending.values()]; - const auth = [...authPending.values()]; - const pooler = [...poolerPending.values()]; - commonPending.clear(); - commonCompleted.clear(); - authPending.clear(); - authCompleted.clear(); - poolerPending.clear(); - poolerCompleted.clear(); - return { common, auth, pooler }; - }), - ); - yield* Effect.forEach(pending.common, (deferred) => - Deferred.succeed(deferred, Exit.fail(failure("Runtime inputs were invalidated"))), - ); - yield* Effect.forEach(pending.auth, (deferred) => - Deferred.succeed(deferred, Exit.fail(failure("Runtime inputs were invalidated"))), - ); - yield* Effect.forEach(pending.pooler, (deferred) => - Deferred.succeed(deferred, Exit.fail(failure("Runtime inputs were invalidated"))), - ); - yield* mapFile( - poolerRoot, - "clean pooler tenant files", - fs.remove(poolerRoot, { recursive: true, force: true }), - ); - yield* mapFile( - vectorRoot, - "clean Vector configs", - fs.remove(vectorRoot, { recursive: true, force: true }), - ); - }), - ); + const cleanupAll = Effect.gen(function* () { + commonCompleted.clear(); + authCompleted.clear(); + poolerCompleted.clear(); + yield* mapFile( + poolerRoot, + "clean pooler tenant files", + fs.remove(poolerRoot, { recursive: true, force: true }), + ); + yield* mapFile( + vectorRoot, + "clean Vector configs", + fs.remove(vectorRoot, { recursive: true, force: true }), + ); + }); return { resolve, diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 58afcd4ed7..078aab6f09 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -3,10 +3,12 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, + Deferred, Duration, Effect, Exit, FileSystem, + Fiber, Path, Crypto, Redacted, @@ -687,6 +689,93 @@ describe("production runtime", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("interrupts native input materialization before cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-oidc-stop-", + }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined)); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + config: { + capabilities: { + auth: { + enabled: true, + settings: { + third_party: { + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, + }, + }, + }, + }, + }); + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + const current = { + value: { + ...stateFor(resolved.persisted, { kind: "native" }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + }, + } satisfies { value: PersistedStackState }; + const auth = compiled.executionPlan.workloads.find(({ id }) => id === "auth:auth"); + if (auth === undefined) return yield* Effect.die("Expected Auth workload"); + const context = yield* Effect.context(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "oidc-stop", + stateStore: stateStoreFor(current), + context, + ingress, + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + artifactPreparer: artifacts, + logStore: memoryLogStore([]), + fetchJson: (url) => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return url.endsWith("openid-configuration") + ? { jwks_uri: "https://issuer.example/keys" } + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; + }).pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), + }); + const key = { stackId, workloadId: auth.id }; + const starting = yield* Effect.forkChild(runtime.driver.start(key, auth), { + startImmediately: true, + }); + yield* Deferred.await(started); + yield* runtime.driver.stop(key); + expect(yield* Deferred.isDone(interrupted)).toBe(true); + // The release is only a guard for the interrupted implementation under test. A correct + // owner propagates cancellation from NativeRuntime.startFiber without this handoff. + yield* Deferred.succeed(release, undefined); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + const startExit = yield* Fiber.join(starting).pipe(Effect.exit); + expect(Exit.isFailure(startExit)).toBe(true); + expect(yield* runtime.driver.observe(stackId)).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("does not retry non-retryable database bootstrap failures", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index ed0084760d..60b6e42b74 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -1,17 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Deferred, - Effect, - Exit, - FileSystem, - Fiber, - Option, - Path, - Redacted, - Scope, -} from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Redacted } from "effect"; import { generateKeyPairSync } from "node:crypto"; import { compileStack, type CompiledStack } from "../model/Compiler.ts"; import { StackPreparationError } from "../public/Errors.ts"; @@ -738,74 +727,12 @@ describe("runtime input owner", () => { ), ); - it.live("single-flights cold equivalent resolutions and reuses the completed material", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-single-flight-" }); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let fetches = 0; - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, - }, - }, - pooler: { enabled: true }, - }, - }); - const native = { - ...state, - privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], - }; - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: (url) => { - fetches += 1; - return Effect.gen(function* () { - if (fetches === 1) { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - } - return url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; - }); - }, - }); - const first = yield* Effect.forkChild(owner.resolve(native, "auth:auth"), { - startImmediately: true, - }); - yield* Deferred.await(started); - const second = yield* Effect.forkChild(owner.resolve(native, "auth:auth"), { - startImmediately: true, - }); - expect(fetches).toBe(1); - yield* Deferred.succeed(release, undefined); - const [firstMaterial, secondMaterial] = yield* Effect.all([ - Fiber.join(first), - Fiber.join(second), - ]); - expect(fetches).toBe(2); - expect(firstMaterial.auth?.jwks).toBe(secondMaterial.auth?.jwks); - const cached = yield* owner.resolve(native, "auth:auth"); - expect(cached.auth?.jwks).toBe(firstMaterial.auth?.jwks); - expect(fetches).toBe(2); - }), - ), - ); - it.live("shares OIDC material across JWT-consuming workloads", () => withPlatform( Effect.gen(function* () { const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ prefix: "runtime-input-auth-shared-", }); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); let fetches = 0; const state = yield* compiledState(root, { capabilities: { @@ -821,31 +748,18 @@ describe("runtime input owner", () => { stackId, fetchJson: (url) => { fetches += 1; - return Effect.gen(function* () { - if (fetches === 1) { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - } - return url.endsWith("openid-configuration") + return Effect.succeed( + url.endsWith("openid-configuration") ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; - }); + : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }, + ); }, }); - const rest = yield* Effect.forkChild(owner.resolve(state, "rest:rest"), { - startImmediately: true, - }); - yield* Deferred.await(started); - const realtime = yield* Effect.forkChild(owner.resolve(state, "realtime:realtime"), { - startImmediately: true, - }); - expect(fetches).toBe(1); - yield* Deferred.succeed(release, undefined); - const [restMaterial, realtimeMaterial] = yield* Effect.all([ - Fiber.join(rest), - Fiber.join(realtime), - ]); + const restMaterial = yield* owner.resolve(state, "rest:rest"); + const cachedRestMaterial = yield* owner.resolve(state, "rest:rest"); + const realtimeMaterial = yield* owner.resolve(state, "realtime:realtime"); expect(fetches).toBe(2); + expect(cachedRestMaterial.auth?.jwks).toBe(restMaterial.auth?.jwks); expect(restMaterial.auth?.jwks).toBe(realtimeMaterial.auth?.jwks); expect(restMaterial.analytics).toBeUndefined(); expect(realtimeMaterial.analytics).toBeUndefined(); @@ -864,112 +778,6 @@ describe("runtime input owner", () => { ), ); - it.live("keeps the owner alive when an in-flight waiter is interrupted", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-single-flight-interrupt-", - }); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let fetches = 0; - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, - }, - }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: (url) => { - fetches += 1; - return Effect.gen(function* () { - if (fetches === 1) { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - } - return url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; - }); - }, - }); - const first = yield* Effect.forkChild(owner.resolve(state, "auth:auth"), { - startImmediately: true, - }); - yield* Deferred.await(started); - const second = yield* Effect.forkChild(owner.resolve(state, "auth:auth"), { - startImmediately: true, - }); - expect(fetches).toBe(1); - yield* Fiber.interrupt(second); - yield* Deferred.succeed(release, undefined); - const firstMaterial = yield* Fiber.join(first); - expect(firstMaterial.auth?.jwks).toContain('"keys"'); - expect(fetches).toBe(2); - const cached = yield* owner.resolve(state, "auth:auth"); - expect(cached.auth?.jwks).toBe(firstMaterial.auth?.jwks); - expect(fetches).toBe(2); - }), - ), - ); - - it.live("completes materializing and queued owners when their scope closes", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-owner-scope-close-", - }); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let fetches = 0; - const state = yield* compiledState(root, { - capabilities: { - auth: { - settings: { - third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, - }, - }, - }, - }); - const ownerScope = yield* Scope.make(); - const owner = yield* makeRuntimeInputOwner({ - stateRoot: root, - stackId, - fetchJson: (url) => { - fetches += 1; - return Effect.gen(function* () { - if (fetches === 1) { - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - } - return url.endsWith("openid-configuration") - ? { jwks_uri: "https://issuer.example/keys" } - : { keys: [{ kty: "RSA", n: "n", e: "AQAB" }] }; - }); - }, - }).pipe(Effect.provideService(Scope.Scope, ownerScope)); - const materializing = yield* Effect.forkChild(owner.resolve(state, "auth:auth"), { - startImmediately: true, - }); - yield* Deferred.await(started); - const queued = yield* Effect.forkChild(owner.resolve(state, "auth:auth"), { - startImmediately: true, - }); - expect(fetches).toBe(1); - yield* Scope.close(ownerScope, Exit.void); - const materializingExit = yield* Fiber.join(materializing).pipe(Effect.exit); - const queuedExit = yield* Fiber.join(queued).pipe(Effect.exit); - expect(Exit.isFailure(materializingExit)).toBe(true); - expect(Exit.isFailure(queuedExit)).toBe(true); - }), - ), - ); - it.live("resolves the configured Analytics service-account file without copying it", () => withPlatform( Effect.gen(function* () { From d3ee71bff739489f0c0b47b44efc7cb13489e289 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 5 Sep 2026 03:27:35 +0200 Subject: [PATCH 170/174] perf(stack): start independent workloads concurrently Reuse validated native artifacts without fetching upstream checksums. Keep input materialization and shared container setup scoped to startup while preserving dependency readiness and rollback. --- ...7-simplified-managed-stack-architecture.md | 38 +- .../stack/src/preparation/ArtifactStore.ts | 58 ++- .../stack/src/preparation/RuntimeArtifacts.ts | 18 +- .../src/preparation/SlimServicesSource.ts | 295 ++++++------ .../preparation/artifacts.integration.test.ts | 55 +-- .../runtime-artifacts.integration.test.ts | 136 +++++- .../slim-services.integration.test.ts | 34 +- .../stack/src/runtime/ContainerRuntime.ts | 442 +++++++++--------- .../stack/src/runtime/ProductionRuntime.ts | 93 ++-- .../container-runtime.integration.test.ts | 92 ++++ .../stack/src/supervisor/SessionLauncher.ts | 60 ++- .../session-launcher.integration.test.ts | 174 +++++++ 12 files changed, 983 insertions(+), 512 deletions(-) create mode 100644 packages/stack/src/supervisor/session-launcher.integration.test.ts diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index eb42cf4815..eb5ea7b20b 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -101,23 +101,33 @@ starts when traffic first reaches its stable gateway route. Explicit `stack.prepare(...)` remains available for callers that intentionally want to warm selected artifacts, but it is not part of ordinary CLI start. Preparation is caller-owned: cancellation stops that caller's unfinished transfers while -completed cache entries remain. It never acquires control or ownership and does -not create runtime resources. - -Runtime input materialization belongs to the workload startup operation. The Supervisor serializes -workload startup with runtime cleanup, so the input owner caches only completed values and leaves an -in-progress resolution in the startup operation's scope. Interrupting native startup therefore interrupts -OIDC fetches and file materialization; successful Pooler and Vector files remain owned until the -runtime cleanup boundary removes them. +completed cache entries remain. Preparation never acquires control or ownership +and does not create runtime resources. + +Within a lifecycle operation, dependency-ready workloads start concurrently, +with at most four starts in flight; each wave completes before its dependants +start. Container setup is serialized through resource registration; readiness +checks and long-lived followers run outside that setup boundary. + +Runtime input materialization belongs to the workload startup operation. The +Supervisor serializes lifecycle operations with cleanup, so the input owner +caches only completed values and leaves unfinished resolution in the startup +operation's scope. One shared-input gate serializes materialization among +concurrent workload starts. Interrupting native startup therefore interrupts +OIDC fetches and file materialization; successful Pooler and Vector files remain +owned until the runtime cleanup boundary removes them. Artifact cache trust begins only after a candidate has passed checksum and runtime-path validation, had its metadata written, and been atomically -published. A cache hit revalidates the metadata, required path kinds and -containment, and executable shape, but does not cryptographically revalidate -published contents. The cache is user-owned and immutable by contract; manual -post-publication changes may execute successfully or fail later at workload -start. A partially published or malformed entry is never trusted merely -because its directory exists. +published. Exact-version artifact metadata is authoritative on a cache hit: the +store revalidates its format, key, SHA-256 shape, ordered required paths and +kinds, containment, and executable shape without fetching an upstream digest. +On a cache miss, the source resolves the trusted digest once; downloaded bytes +are verified before decoding or extraction, then structurally validated and +atomically published. The cache is user-owned and immutable by contract; +manual post-publication changes may execute successfully or fail later at +workload start. A partially published or malformed entry is never trusted +merely because its directory exists. Effect failures remain operation-specific tagged errors (for example, configuration, preparation, gateway, port, and lifecycle failures), preserving diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index aa87cbd42f..e3adebb5c0 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -21,8 +21,8 @@ import { validateRelativePath, validateSha256, verifySha256 } from "./Integrity. * subdirectories, but never an absolute or traversing path. */ export interface ArtifactRequest { + /** Immutable published identity; a reused key intentionally reuses its persisted digest. */ readonly key: string; - readonly sha256: string; /** Relative paths that a runtime may use after installation. */ readonly requiredRuntimePaths: ReadonlyArray; /** The relative executable path, if this artifact starts a native workload. */ @@ -36,9 +36,12 @@ export interface ArtifactRequest { * the store itself remains independent of transport and archive formats. */ export interface ArtifactSource { + /** Resolves the published digest only when the store has no valid cached artifact. */ + readonly checksum: (request: ArtifactRequest) => Effect.Effect; readonly materialize: ( request: ArtifactRequest, destination: string, + expectedSha256: string, ) => Effect.Effect< Uint8Array, StackPreparationError, @@ -140,12 +143,9 @@ const validateKey = (key: string): Effect.Effect => ), ); -const validateRequest = ( - request: ArtifactRequest, -): Effect.Effect => +const validateRequest = (request: ArtifactRequest): Effect.Effect => Effect.gen(function* () { yield* validateKey(request.key); - const sha256 = yield* validateSha256(request.sha256); const seen = new Set(); for (const relative of request.requiredRuntimePaths) { yield* validateRelativePath(relative, "required runtime path"); @@ -160,13 +160,10 @@ const validateRequest = ( path: request.executablePath, }); } - return sha256; }); -const requestFlightKey = (request: ArtifactRequest, expectedSha256: string): string => - [request.key, expectedSha256, ...request.requiredRuntimePaths, request.executablePath ?? ""].join( - "\u0000", - ); +const requestFlightKey = (request: ArtifactRequest): string => + [request.key, ...request.requiredRuntimePaths, request.executablePath ?? ""].join("\u0000"); const metadataFor = ( request: ArtifactRequest, @@ -518,9 +515,16 @@ const ensureExecutableFile = ( const verifyMetadata = ( request: ArtifactRequest, - expectedSha256: string, metadata: ArtifactMetadata, -): Effect.Effect => { +): Effect.Effect => { + const sha256 = validateSha256(metadata.sha256).pipe( + Effect.mapError((cause) => + metadataError("Cached artifact metadata contains an invalid SHA-256", { + key: request.key, + cause, + }), + ), + ); const samePaths = metadata.requiredRuntimePaths.length === request.requiredRuntimePaths.length && metadata.requiredRuntimePaths.every( @@ -534,7 +538,6 @@ const verifyMetadata = ( ); if ( metadata.key !== request.key || - metadata.sha256 !== expectedSha256 || !samePaths || !sameKinds || metadata.executablePath !== request.executablePath @@ -542,7 +545,7 @@ const verifyMetadata = ( return Effect.fail( metadataError("Cached artifact metadata does not match the request", { key: request.key }), ); - return Effect.void; + return sha256; }; const writeBytesSync = ( @@ -606,7 +609,6 @@ const makeArtifactOperation = ( cacheRoot: string, source: ArtifactSource, request: ArtifactRequest, - expectedSha256: string, ): Effect.Effect => Effect.gen(function* () { const target = path.resolve(cacheRoot, request.key); @@ -627,7 +629,7 @@ const makeArtifactOperation = ( const cachedMetadata = yield* readMetadata(fs, metadataPath); if (Option.isNone(cachedMetadata)) return Option.none(); const metadata = cachedMetadata.value; - yield* verifyMetadata(request, expectedSha256, metadata); + const sha256 = yield* verifyMetadata(request, metadata); // Published content is intentionally not rehashed on cache hits. Metadata and cheap // structural checks protect the cache boundary; content tampering may execute or fail // later when the workload starts. @@ -650,7 +652,7 @@ const makeArtifactOperation = ( return Option.some({ key: request.key, path: target, - sha256: expectedSha256, + sha256, requiredRuntimePaths: [...request.requiredRuntimePaths], ...(request.executablePath === undefined ? {} : { executablePath: request.executablePath }), outcome: "cached" as const, @@ -704,6 +706,21 @@ const makeArtifactOperation = ( const initial = yield* inspectCache(); if (Option.isSome(initial)) return initial.value; + // Artifact keys identify immutable published versions. Once a valid entry exists, its + // persisted digest is authoritative; only a cache miss consults the upstream checksum. + const expectedSha256 = yield* source.checksum(request).pipe( + Effect.flatMap((sha256) => + validateSha256(sha256).pipe( + Effect.mapError((cause) => + metadataError("Artifact source returned an invalid SHA-256", { + key: request.key, + cause, + }), + ), + ), + ), + ); + const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => artifactError(`Unable to allocate artifact temporary name: ${cause.message}`, { @@ -716,7 +733,7 @@ const makeArtifactOperation = ( const published = yield* Effect.gen(function* () { yield* ensureDirectory(fs, path, temporary, cacheRoot); const temporaryRoot = yield* ensureSafeRoot(fs, path, temporary, cacheRoot); - const archive = yield* source.materialize(request, temporary).pipe( + const archive = yield* source.materialize(request, temporary, expectedSha256).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(Crypto.Crypto, crypto), @@ -840,11 +857,11 @@ export const makeArtifactStore = ( const inFlight = new Map(); const prepare = (request: ArtifactRequest) => Effect.gen(function* () { - const expectedSha256 = yield* validateRequest(request); + yield* validateRequest(request); const target = path.resolve(cacheRoot, request.key); if (!pathWithin(cacheRoot, target, path.sep)) return yield* artifactError("Artifact key escapes cache root", { key: request.key }); - const flightKey = requestFlightKey(request, expectedSha256); + const flightKey = requestFlightKey(request); const operation = makeArtifactOperation( fs, path, @@ -853,7 +870,6 @@ export const makeArtifactStore = ( cacheRoot, options.source, request, - expectedSha256, ); const joined = yield* lifecycle.withPermit( Effect.gen(function* () { diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 88fc1a055d..12a4680288 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -17,7 +17,7 @@ import { type ArtifactStoreError, type PreparedArtifact, } from "./ArtifactStore.ts"; -import { makeSlimServicesSource, slimServicesChecksum } from "./SlimServicesSource.ts"; +import { makeSlimServicesSource } from "./SlimServicesSource.ts"; import type { ContainerEngine } from "../runtime/ContainerEngine.ts"; import { resolveContainerEngine, @@ -52,10 +52,8 @@ export type RuntimeArtifactPreparationError = export interface RuntimeArtifactPreparerOptions { readonly native?: { readonly store: ArtifactStore; - /** Injected in tests; production defaults to the published SHA256SUMS boundary. */ - readonly checksum?: ( - artifact: NativeWorkloadArtifact, - ) => Effect.Effect; + /** Registers catalog metadata for the source that resolves a cache-miss request. */ + readonly onArtifactResolved?: (artifact: NativeWorkloadArtifact) => void; readonly platform?: { readonly os: string; readonly arch: string }; }; readonly containerEngine?: ContainerEngine; @@ -94,12 +92,9 @@ export const makeRuntimeArtifactPreparer = ( ); return Effect.gen(function* () { const artifact = yield* resolveNativeArtifactForWorkload(workload, native.platform); - const checksum = yield* native.checksum === undefined - ? slimServicesChecksum(artifact) - : native.checksum(artifact); + native.onArtifactResolved?.(artifact); const request = { key: artifactKey(artifact), - sha256: checksum, requiredRuntimePaths: artifact.requiredRuntimePaths, executablePath: artifact.executablePath, }; @@ -226,9 +221,8 @@ export const makeProductionRuntimeArtifactPreparer = (options: { cacheRoot, source: makeSlimServicesSource((request) => artifacts.get(request.key)), }); - const checksum = (artifact: NativeWorkloadArtifact) => { + const registerArtifact = (artifact: NativeWorkloadArtifact) => { artifacts.set(artifactKey(artifact), artifact); - return slimServicesChecksum(artifact); }; const selectedEngine = options.runtime?.kind === "container" ? options.runtime.engine : undefined; @@ -247,7 +241,7 @@ export const makeProductionRuntimeArtifactPreparer = (options: { ), )); const preparer = makeRuntimeArtifactPreparer({ - ...(store === undefined ? {} : { native: { store, checksum } }), + ...(store === undefined ? {} : { native: { store, onArtifactResolved: registerArtifact } }), ...(containerEngine === undefined ? {} : { containerEngine }), }); return preparer; diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 52c0843176..ecc8efc0a2 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -302,152 +302,159 @@ export const makeSlimServicesSource = ( fetchRequest: Fetcher = fetcher, tarBoundary: TarBoundary = systemTarBoundary, decompressor: ZstdDecompressor = nodeZstdDecompressor, -): ArtifactSource => ({ - materialize: (request, destination) => { +): ArtifactSource => { + const resolveArtifact = ( + request: ArtifactRequest, + ): Effect.Effect => { const artifact = resolve(request); if (artifact === undefined) return Effect.fail( new StackPreparationError({ message: `No slim-services source for ${request.key}` }), ); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const manifestBytes = yield* fetchBytes(artifact.manifestUrl, fetchRequest); - const manifestText = new TextDecoder().decode(manifestBytes); - const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( - manifestText, - ).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ message: "Slim-services manifest is invalid", cause }), - ), - ); - if ( - typeof manifest !== "object" || - manifest === null || - !("service" in manifest) || - !("version" in manifest) || - !("target" in manifest) || - manifest.service !== artifact.service || - manifest.version !== artifact.version || - manifest.target !== artifact.target - ) - return yield* new StackPreparationError({ - message: "Slim-services manifest does not match the catalog artifact", - service: artifact.service, - version: artifact.version, - target: artifact.target, - }); - const entrypoint = "entrypoint" in manifest ? manifest.entrypoint : undefined; - const command = "cmd" in manifest ? manifest.cmd : undefined; - if ( - (entrypoint !== undefined && - (!Array.isArray(entrypoint) || - !entrypoint.every((value) => typeof value === "string") || - entrypoint.some(unsafeManifestCommand))) || - (command !== undefined && - (!Array.isArray(command) || - !command.every((value) => typeof value === "string") || - command.some(unsafeManifestCommand))) - ) - return yield* new StackPreparationError({ - message: "Slim-services manifest command is invalid", - service: artifact.service, - version: artifact.version, - }); - const listedChecksum = yield* slimServicesChecksum(artifact, fetchRequest); - if (listedChecksum !== request.sha256.toLowerCase()) - return yield* new StackPreparationError({ - message: "Slim-services checksum does not match the artifact request", - service: artifact.service, - version: artifact.version, - }); - const compressed = yield* fetchBytes(artifact.downloadUrl, fetchRequest); - const crypto = yield* Crypto.Crypto; - yield* verifySha256(compressed, request.sha256).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Slim-services archive digest does not match the artifact request", - service: artifact.service, - version: artifact.version, - cause, - }), - ), - ); - const archive = yield* decompressor.decompress(compressed); - const archivePath = path.join(destination, ".slim-services.tar"); - yield* fs.writeFile(archivePath, archive).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to stage slim-services archive", - cause, - }), - ), - ); - const members = yield* tarBoundary.list(archivePath).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to list slim-services archive", - cause, - }), - ), - ); - const unsafeMember = members - .split(/\r?\n/u) - .map((member) => member.trim()) - .find(unsafeArchivePath); - if (unsafeMember !== undefined) - return yield* new StackPreparationError({ - message: "Slim-services archive contains an unsafe path", - path: unsafeMember, - }); - const links = yield* tarBoundary.links(archivePath).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to inspect slim-services archive links", - cause, - }), - ), - ); - const unsafeLink = links - .split(/\r?\n/u) - .map((line) => { - const arrow = line.indexOf(" -> "); - const hardLink = line.indexOf(" link to "); - const marker = arrow >= 0 ? arrow : hardLink; - if (marker < 0) return undefined; - const member = line.slice(0, marker).trim().split(/\s+/u).at(-1) ?? ""; - const target = line.slice(marker + (arrow >= 0 ? 4 : 9)).trim(); - return archiveLinkEscapes(member, target) ? target : undefined; - }) - .find((target): target is string => target !== undefined); - if (unsafeLink !== undefined) - return yield* new StackPreparationError({ - message: "Slim-services archive contains an unsafe link target", - path: unsafeLink, - }); - const exitCode = yield* tarBoundary.extract(archivePath, destination).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to extract slim-services archive", - cause, - }), - ), - ); - if (exitCode !== 0) - return yield* new StackPreparationError({ - message: `Slim-services archive extraction exited with code ${exitCode}`, - }); - yield* normalizeSlimServicesLayout(artifact, destination); - yield* validateExtractedTree(fs, path, destination); - yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); - return compressed; - }); - }, -}); + return Effect.succeed(artifact); + }; + return { + checksum: (request) => + resolveArtifact(request).pipe( + Effect.flatMap((artifact) => slimServicesChecksum(artifact, fetchRequest)), + ), + materialize: (request, destination, expectedSha256) => + Effect.gen(function* () { + const artifact = yield* resolveArtifact(request); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const manifestBytes = yield* fetchBytes(artifact.manifestUrl, fetchRequest); + const manifestText = new TextDecoder().decode(manifestBytes); + const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + manifestText, + ).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Slim-services manifest is invalid", + cause, + }), + ), + ); + if ( + typeof manifest !== "object" || + manifest === null || + !("service" in manifest) || + !("version" in manifest) || + !("target" in manifest) || + manifest.service !== artifact.service || + manifest.version !== artifact.version || + manifest.target !== artifact.target + ) + return yield* new StackPreparationError({ + message: "Slim-services manifest does not match the catalog artifact", + service: artifact.service, + version: artifact.version, + target: artifact.target, + }); + const entrypoint = "entrypoint" in manifest ? manifest.entrypoint : undefined; + const command = "cmd" in manifest ? manifest.cmd : undefined; + if ( + (entrypoint !== undefined && + (!Array.isArray(entrypoint) || + !entrypoint.every((value) => typeof value === "string") || + entrypoint.some(unsafeManifestCommand))) || + (command !== undefined && + (!Array.isArray(command) || + !command.every((value) => typeof value === "string") || + command.some(unsafeManifestCommand))) + ) + return yield* new StackPreparationError({ + message: "Slim-services manifest command is invalid", + service: artifact.service, + version: artifact.version, + }); + const compressed = yield* fetchBytes(artifact.downloadUrl, fetchRequest); + const crypto = yield* Crypto.Crypto; + yield* verifySha256(compressed, expectedSha256).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Slim-services archive digest does not match the artifact request", + service: artifact.service, + version: artifact.version, + cause, + }), + ), + ); + const archive = yield* decompressor.decompress(compressed); + const archivePath = path.join(destination, ".slim-services.tar"); + yield* fs.writeFile(archivePath, archive).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to stage slim-services archive", + cause, + }), + ), + ); + const members = yield* tarBoundary.list(archivePath).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to list slim-services archive", + cause, + }), + ), + ); + const unsafeMember = members + .split(/\r?\n/u) + .map((member) => member.trim()) + .find(unsafeArchivePath); + if (unsafeMember !== undefined) + return yield* new StackPreparationError({ + message: "Slim-services archive contains an unsafe path", + path: unsafeMember, + }); + const links = yield* tarBoundary.links(archivePath).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to inspect slim-services archive links", + cause, + }), + ), + ); + const unsafeLink = links + .split(/\r?\n/u) + .map((line) => { + const arrow = line.indexOf(" -> "); + const hardLink = line.indexOf(" link to "); + const marker = arrow >= 0 ? arrow : hardLink; + if (marker < 0) return undefined; + const member = line.slice(0, marker).trim().split(/\s+/u).at(-1) ?? ""; + const target = line.slice(marker + (arrow >= 0 ? 4 : 9)).trim(); + return archiveLinkEscapes(member, target) ? target : undefined; + }) + .find((target): target is string => target !== undefined); + if (unsafeLink !== undefined) + return yield* new StackPreparationError({ + message: "Slim-services archive contains an unsafe link target", + path: unsafeLink, + }); + const exitCode = yield* tarBoundary.extract(archivePath, destination).pipe( + Effect.mapError( + (cause) => + new StackPreparationError({ + message: "Unable to extract slim-services archive", + cause, + }), + ), + ); + if (exitCode !== 0) + return yield* new StackPreparationError({ + message: `Slim-services archive extraction exited with code ${exitCode}`, + }); + yield* normalizeSlimServicesLayout(artifact, destination); + yield* validateExtractedTree(fs, path, destination); + yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); + return compressed; + }), + }; +}; diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index 58158d55fe..0fb3e062f7 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -9,7 +9,6 @@ const archive = new TextEncoder().encode("archive"); const archiveSha256 = "0eb3e36bfb24dcd9bb1d1bece1531216b59539a8fde17ee80224af0653c92aa3"; const request: ArtifactRequest = { key: "database/postgres", - sha256: archiveSha256, requiredRuntimePaths: ["bin/postgres", "etc/postgres.conf"], executablePath: "bin/postgres", }; @@ -21,6 +20,7 @@ const withPlatform = (effect: Effect.Effect) => Effect.scoped(effect).pipe(Effect.provide(layer)); const sourceWriting = (bytes: Uint8Array = archive): ArtifactSource => ({ + checksum: () => Effect.succeed(archiveSha256), materialize: (_request, destination) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -60,20 +60,27 @@ describe("verified native artifact preparation", () => { const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-artifact-cache-", }); - let calls = 0; + let checksumCalls = 0; + let materializeCalls = 0; const source: ArtifactSource = { + checksum: () => + Effect.sync(() => { + checksumCalls += 1; + return archiveSha256; + }), materialize: (entry, destination) => Effect.sync(() => { - calls += 1; + materializeCalls += 1; return entry; - }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination))), + }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination, archiveSha256))), }; const store = yield* makeArtifactStore({ cacheRoot: root, source }); const first = yield* store.prepare(request); const second = yield* store.prepare(request); expect(first.outcome).toBe("downloaded"); expect(second.outcome).toBe("cached"); - expect(calls).toBe(1); + expect(checksumCalls).toBe(1); + expect(materializeCalls).toBe(1); }), ), ); @@ -94,11 +101,12 @@ describe("verified native artifact preparation", () => { ); let called = false; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (entry, destination) => Effect.sync(() => { called = true; return entry; - }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination))), + }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination, archiveSha256))), }; const store = yield* makeArtifactStore({ cacheRoot: root, source }); @@ -123,11 +131,12 @@ describe("verified native artifact preparation", () => { yield* fs.writeFileString(`${target}/bin/postgres`, "unverified postgres"); let called = false; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (entry, destination) => Effect.sync(() => { called = true; return entry; - }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination))), + }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination, archiveSha256))), }; const store = yield* makeArtifactStore({ cacheRoot: root, source }); @@ -170,6 +179,7 @@ describe("verified native artifact preparation", () => { requiredRuntimePaths: ["bin/postgres", "share/runtime"], }; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); @@ -249,6 +259,7 @@ describe("verified native artifact preparation", () => { requiredRuntimePaths: ["bin/postgres", "share/runtime"], }; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); @@ -349,6 +360,7 @@ describe("verified native artifact preparation", () => { key: "database/postgres-cache-link", }; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); @@ -379,25 +391,6 @@ describe("verified native artifact preparation", () => { ), ); - it.live("fails closed for a conflicting request without replacing the published tree", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-stack-artifact-conflict-", - }); - const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); - const published = yield* store.prepare(request); - const conflict = yield* store - .prepare({ ...request, sha256: "1".repeat(64) }) - .pipe(Effect.exit); - expect(errorOf(conflict)).toBeInstanceOf(ArtifactIntegrityError); - expect(yield* fs.exists(`${published.path}/bin/postgres`)).toBe(true); - expect(yield* fs.exists(`${published.path}/.artifact-source`)).toBe(false); - }), - ), - ); - it.live("rejects a checksum mismatch and removes only its temporary tree", () => withPlatform( Effect.gen(function* () { @@ -426,6 +419,7 @@ describe("verified native artifact preparation", () => { prefix: "supabase-stack-artifact-executable-directory-", }); const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.makeDirectory(`${destination}/bin/postgres`, { recursive: true }); @@ -461,6 +455,7 @@ describe("verified native artifact preparation", () => { prefix: "supabase-stack-artifact-internal-link-", }); const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); @@ -502,6 +497,7 @@ describe("verified native artifact preparation", () => { const outsideExecutable = `${outside}/postgres`; yield* fs.writeFileString(outsideExecutable, "outside"); const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); @@ -547,6 +543,7 @@ describe("verified native artifact preparation", () => { requiredRuntimePaths: ["bin/postgres", "share/runtime"], }; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); @@ -582,6 +579,7 @@ describe("verified native artifact preparation", () => { }); const started = yield* Deferred.make(); const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (_entry, destination) => Effect.gen(function* () { yield* fs.writeFileString(`${destination}/partial`, "partial"); @@ -622,6 +620,7 @@ describe("verified native artifact preparation", () => { const release = yield* Deferred.make(); let calls = 0; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (entry, destination) => Effect.gen(function* () { calls += 1; @@ -668,6 +667,7 @@ describe("verified native artifact preparation", () => { const release = yield* Deferred.make(); let calls = 0; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (entry, destination) => Effect.gen(function* () { calls += 1; @@ -712,6 +712,7 @@ describe("verified native artifact preparation", () => { const release = yield* Deferred.make(); let calls = 0; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: (entry, destination) => Effect.gen(function* () { calls += 1; @@ -756,6 +757,7 @@ describe("verified native artifact preparation", () => { const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-artifact-path-" }); let called = false; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: () => Effect.sync(() => { called = true; @@ -785,6 +787,7 @@ describe("verified native artifact preparation", () => { yield* fs.symlink(outside, `${root}/nested`); let called = false; const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), materialize: () => Effect.sync(() => { called = true; diff --git a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts index 88492cf486..7b7b15a4f6 100644 --- a/packages/stack/src/preparation/runtime-artifacts.integration.test.ts +++ b/packages/stack/src/preparation/runtime-artifacts.integration.test.ts @@ -2,7 +2,13 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Stream } from "effect"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; -import type { ArtifactRequest, ArtifactStore, PreparedArtifact } from "./ArtifactStore.ts"; +import { + makeArtifactStore, + type ArtifactRequest, + type ArtifactSource, + type ArtifactStore, + type PreparedArtifact, +} from "./ArtifactStore.ts"; import type { ContainerEngine, ContainerNetworkSpec, @@ -13,7 +19,7 @@ import { makeProductionRuntimeArtifactPreparer, makeRuntimeArtifactPreparer, } from "./RuntimeArtifacts.ts"; -import { ContainerEngineError } from "../public/Errors.ts"; +import { ContainerEngineError, StackPreparationError } from "../public/Errors.ts"; import { ContainerEngineProtocolError } from "../runtime/ContainerEngine.ts"; import { catalogReleaseFor } from "../model/WorkloadCatalog.ts"; @@ -38,12 +44,38 @@ const nativeWorkload = (selected: PlannedWorkload["selected"]): PlannedWorkload const prepared = (request: ArtifactRequest): PreparedArtifact => ({ key: request.key, path: "/tmp/prepared-database", - sha256: request.sha256, + sha256: "a".repeat(64), requiredRuntimePaths: request.requiredRuntimePaths, executablePath: request.executablePath, outcome: "downloaded", }); +const archive = new TextEncoder().encode("archive"); +const archiveSha256 = "0eb3e36bfb24dcd9bb1d1bece1531216b59539a8fde17ee80224af0653c92aa3"; + +const nativeSource = (): ArtifactSource => ({ + checksum: () => Effect.succeed(archiveSha256), + materialize: (request, destination) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const relative of request.requiredRuntimePaths) { + const target = path.join(destination, relative); + if (relative === request.executablePath) { + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, "native executable"); + } else { + yield* fs.makeDirectory(target, { recursive: true }); + } + } + return archive; + }).pipe( + Effect.mapError( + (cause) => new StackPreparationError({ message: "native fixture failed", cause }), + ), + ), +}); + const containerEngine = ( present: boolean, calls: string[], @@ -135,7 +167,7 @@ describe("runtime artifact preparation", () => { ), ); - it("resolves native catalog metadata, checksum, and one ArtifactRequest", () => { + it("resolves native catalog metadata and one ArtifactRequest", () => { const requests: ArtifactRequest[] = []; const store: ArtifactStore = { prepare: (request) => @@ -147,7 +179,6 @@ describe("runtime artifact preparation", () => { const runtime = makeRuntimeArtifactPreparer({ native: { store, - checksum: () => Effect.succeed("a".repeat(64)), platform: { os: "darwin", arch: "arm64" }, }, }); @@ -163,9 +194,99 @@ describe("runtime artifact preparation", () => { expect(requests[0]?.key).toContain( `slim-services/postgres/${databaseRelease.version}/darwin-arm64`, ); - expect(requests[0]?.sha256).toBe("a".repeat(64)); }); + it.live("reuses a published native artifact without contacting its source again", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-artifact-offline-" }); + const firstStore = yield* makeArtifactStore({ + cacheRoot: root, + source: nativeSource(), + }); + const workload = nativeWorkload({ + kind: "native", + release: databaseRelease.version, + }); + const firstPreparer = makeRuntimeArtifactPreparer({ + native: { + store: firstStore, + platform: { os: "darwin", arch: "arm64" }, + }, + }); + const first = yield* firstPreparer.prepare({ kind: "native" }, workload); + expect(first.outcome).toBe("downloaded"); + const offline: ArtifactSource = { + checksum: () => + Effect.fail(new StackPreparationError({ message: "checksum network unavailable" })), + materialize: () => + Effect.fail(new StackPreparationError({ message: "artifact network unavailable" })), + }; + const secondStore = yield* makeArtifactStore({ cacheRoot: root, source: offline }); + const secondPreparer = makeRuntimeArtifactPreparer({ + native: { + store: secondStore, + platform: { os: "darwin", arch: "arm64" }, + }, + }); + const second = yield* secondPreparer.prepare({ kind: "native" }, workload); + expect(second.outcome).toBe("cached"); + expect(second.artifactRoot).toBe(first.artifactRoot); + expect(yield* fs.exists(`${first.artifactRoot}/.artifact.json`)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("does not trust malformed native cache metadata while offline", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-artifact-offline-invalid-", + }); + const firstStore = yield* makeArtifactStore({ + cacheRoot: root, + source: nativeSource(), + }); + const workload = nativeWorkload({ + kind: "native", + release: databaseRelease.version, + }); + const preparer = makeRuntimeArtifactPreparer({ + native: { + store: firstStore, + platform: { os: "darwin", arch: "arm64" }, + }, + }); + const first = yield* preparer.prepare({ kind: "native" }, workload); + yield* fs.writeFileString(`${first.artifactRoot}/.artifact.json`, "malformed"); + + const offline: ArtifactSource = { + checksum: () => + Effect.fail(new StackPreparationError({ message: "checksum network unavailable" })), + materialize: () => + Effect.fail(new StackPreparationError({ message: "artifact network unavailable" })), + }; + const secondStore = yield* makeArtifactStore({ cacheRoot: root, source: offline }); + const exit = yield* makeRuntimeArtifactPreparer({ + native: { + store: secondStore, + platform: { os: "darwin", arch: "arm64" }, + }, + }) + .prepare({ kind: "native" }, workload) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = Exit.isFailure(exit) + ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) + : undefined; + expect(error).toBeInstanceOf(StackPreparationError); + expect(error).toMatchObject({ message: "checksum network unavailable" }); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it("probes and pulls an absent container image without creating or starting a container", () => { const calls: string[] = []; const engine = containerEngine(false, calls); @@ -247,10 +368,9 @@ describe("runtime artifact preparation", () => { prepare: () => Effect.sync(() => { called = true; - return prepared({ key: "x", sha256: "a".repeat(64), requiredRuntimePaths: [] }); + return prepared({ key: "x", requiredRuntimePaths: [] }); }), }, - checksum: () => Effect.succeed("a".repeat(64)), }, }); const exit = Effect.runSyncExit( diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 48d481b5f5..5607a82133 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -30,7 +30,6 @@ const artifact: NativeWorkloadArtifact = { }; const request: ArtifactRequest = { key: "demo/v1", - sha256: "0".repeat(64), requiredRuntimePaths: ["bin/demo"], executablePath: "bin/demo", }; @@ -211,7 +210,7 @@ describe("slim-services artifact source", () => { const source = makeSlimServicesSource(() => artifact, fetcher); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-source-" }); - yield* source.materialize({ ...request, sha256: expected }, destination); + yield* source.materialize(request, destination, expected); expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); }).pipe(Effect.provide(NodeServices.layer)), ), @@ -238,9 +237,7 @@ describe("slim-services artifact source", () => { const source = makeSlimServicesSource(() => artifact, fetcher); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-unsafe-" }); - const failed = yield* source - .materialize({ ...request, sha256: expected }, destination) - .pipe(Effect.exit); + const failed = yield* source.materialize(request, destination, expected).pipe(Effect.exit); expect(errorOf(failed)).toBeDefined(); expect(yield* fs.exists(`${destination}/outside`)).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), @@ -273,8 +270,9 @@ describe("slim-services artifact source", () => { const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-links-" }); yield* makeSlimServicesSource(() => artifact, fetcher).materialize( - { ...request, sha256: expected }, + request, destination, + expected, ); expect(yield* fs.readFileString(`${destination}/bin/current`)).toBe("demo"); @@ -295,7 +293,7 @@ describe("slim-services artifact source", () => { return Promise.resolve(new Response(malformed)); }; const failed = yield* makeSlimServicesSource(() => artifact, malformedFetcher) - .materialize({ ...request, sha256: malformedDigest }, destination) + .materialize(request, destination, malformedDigest) .pipe(Effect.exit); expect(errorOf(failed)).toBeDefined(); }).pipe(Effect.provide(NodeServices.layer)), @@ -330,7 +328,7 @@ describe("slim-services artifact source", () => { prefix: "slim-services-link-escape-", }); const failed = yield* makeSlimServicesSource(() => artifact, fetcher) - .materialize({ ...request, sha256: expected }, destination) + .materialize(request, destination, expected) .pipe(Effect.exit); expect(errorOf(failed)).toBeDefined(); expect(yield* fs.exists(`${destination}/outside`)).toBe(false); @@ -368,7 +366,11 @@ describe("slim-services artifact source", () => { prefix: "slim-services-interrupt-", }); const fiber = yield* Effect.forkChild( - makeSlimServicesSource(() => artifact, fetcher).materialize(request, destination), + makeSlimServicesSource(() => artifact, fetcher).materialize( + request, + destination, + "0".repeat(64), + ), { startImmediately: true }, ); yield* Deferred.await(started); @@ -417,7 +419,7 @@ describe("slim-services artifact source", () => { fetcher, systemTarBoundary, decompressor, - ).materialize({ ...request, sha256: expected }, destination), + ).materialize(request, destination, expected), { startImmediately: true }, ); yield* Deferred.await(started); @@ -450,8 +452,9 @@ describe("slim-services artifact source", () => { const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-pax-" }); yield* makeSlimServicesSource(() => artifact, fetcher).materialize( - { ...request, sha256: expected }, + request, destination, + expected, ); expect(yield* fs.exists(`${destination}/${longName}`)).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), @@ -465,9 +468,13 @@ describe("slim-services artifact source", () => { const crypto = yield* Crypto.Crypto; const expected = digestHex(yield* crypto.digest("SHA-256", archive)); const checksums = `${expected} demo-v1.0.0-linux-amd64.tar.zst\n`; + let checksumRequests = 0; const fetcher: FetchLike = (input) => { const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) return Promise.resolve(new Response(checksums)); + if (url.endsWith("SHA256SUMS")) { + checksumRequests += 1; + return Promise.resolve(new Response(checksums)); + } if (url.endsWith("manifest.json")) return Promise.resolve( new Response( @@ -480,10 +487,11 @@ describe("slim-services artifact source", () => { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-store-" }); const store = yield* makeArtifactStore({ cacheRoot: root, source }); - const prepared = yield* store.prepare({ ...request, sha256: expected }); + const prepared = yield* store.prepare(request); expect(prepared.outcome).toBe("downloaded"); expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); expect(yield* fs.exists(`${prepared.path}/.artifact.json`)).toBe(true); + expect(checksumRequests).toBe(1); }).pipe(Effect.provide(NodeServices.layer)), ), ); diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index 68e3238c67..9c633dc3dd 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -260,6 +260,9 @@ export const makeContainerRuntime = ( const parentScope = yield* Scope.Scope; const runtimeScope = yield* Scope.fork(parentScope, "parallel"); const registration = yield* Semaphore.make(1); + // Serialize setup through resource registration so concurrent workloads cannot both create + // the shared network. Readiness and long-lived followers run after this permit is released. + const setup = yield* Semaphore.make(1); const resources = new Map(); const startGuards = new Map>(); const startFibers = new Map>(); @@ -570,233 +573,244 @@ export const makeContainerRuntime = ( const existing = resources.get(resourceKey(key)); if (existing !== undefined && existing.state === "running") return { ...key, state: "ready" } satisfies ObservedWorkload; - if (existing !== undefined && existing.state !== "running") { - existing.stopRequested = true; - yield* stopLogs(existing); - yield* stopExitWatcher(existing); - if (existing.state === "failed") - yield* withEngine(key, options.engine.stopContainer(existing.container)); - resources.delete(resourceKey(key)); - } - const initialResolution = yield* resolved; - yield* guard; - const initialPublications = initialResolution.publications ?? []; - if (initialPublications.some((publication) => !isValidPublication(publication))) - return yield* toDriverError( - key, - new Error("Container publications must use valid loopback host ports"), - ); - const initialVolumeRequest = initialResolution.volume; - if (initialVolumeRequest !== undefined && initialVolumeRequest.target.length === 0) - return yield* toDriverError(key, new Error("Container volume mapping is invalid")); - yield* withEngine(key, options.engine.preflight); - const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); - const existingExact = entries.find( - (entry) => entry.kind === "workload" && sameWorkloadIdentity(entry.labels, labels), - ); - const namedCollision = entries.find( - (entry) => entry.kind === "workload" && entry.name === nameFor(key, "workload"), - ); - const collision = - namedCollision !== undefined && isWorkloadResource(namedCollision) - ? namedCollision - : undefined; - if ( - existingExact === undefined && - namedCollision !== undefined && - (collision === undefined || - collision.labels.stackId !== key.stackId || - collision.labels.workloadId !== key.workloadId || - collision.labels.role !== "workload") - ) - return yield* toDriverError( - key, - new Error("Container name is owned by another workload"), - ); + const setupResult = yield* setup.withPermit( + Effect.gen(function* () { + if (existing !== undefined && existing.state !== "running") { + existing.stopRequested = true; + yield* stopLogs(existing); + yield* stopExitWatcher(existing); + if (existing.state === "failed") + yield* withEngine(key, options.engine.stopContainer(existing.container)); + resources.delete(resourceKey(key)); + } + const initialResolution = yield* resolved; + yield* guard; + const initialPublications = initialResolution.publications ?? []; + if (initialPublications.some((publication) => !isValidPublication(publication))) + return yield* toDriverError( + key, + new Error("Container publications must use valid loopback host ports"), + ); + const initialVolumeRequest = initialResolution.volume; + if (initialVolumeRequest !== undefined && initialVolumeRequest.target.length === 0) + return yield* toDriverError(key, new Error("Container volume mapping is invalid")); + yield* withEngine(key, options.engine.preflight); + const entries = yield* withEngine(key, options.engine.listResources(key.stackId)); + const existingExact = entries.find( + (entry) => entry.kind === "workload" && sameWorkloadIdentity(entry.labels, labels), + ); + const namedCollision = entries.find( + (entry) => entry.kind === "workload" && entry.name === nameFor(key, "workload"), + ); + const collision = + namedCollision !== undefined && isWorkloadResource(namedCollision) + ? namedCollision + : undefined; + if ( + existingExact === undefined && + namedCollision !== undefined && + (collision === undefined || + collision.labels.stackId !== key.stackId || + collision.labels.workloadId !== key.workloadId || + collision.labels.role !== "workload") + ) + return yield* toDriverError( + key, + new Error("Container name is owned by another workload"), + ); - const exactNetwork = entries - .filter(isNetworkResource) - .find((entry) => sameNetworkIdentity(entry.labels, networkLabels)); - const namedNetworkCollision = entries.find( - (entry) => entry.kind === "network" && entry.name === networkName, - ); - const networkCollision = - namedNetworkCollision !== undefined && isNetworkResource(namedNetworkCollision) - ? namedNetworkCollision - : undefined; - if ( - exactNetwork === undefined && - namedNetworkCollision !== undefined && - (networkCollision === undefined || networkCollision.labels.stackId !== key.stackId) - ) - return yield* toDriverError( - key, - new Error("Container network name is owned by another stack"), - ); + const exactNetwork = entries + .filter(isNetworkResource) + .find((entry) => sameNetworkIdentity(entry.labels, networkLabels)); + const namedNetworkCollision = entries.find( + (entry) => entry.kind === "network" && entry.name === networkName, + ); + const networkCollision = + namedNetworkCollision !== undefined && isNetworkResource(namedNetworkCollision) + ? namedNetworkCollision + : undefined; + if ( + exactNetwork === undefined && + namedNetworkCollision !== undefined && + (networkCollision === undefined || networkCollision.labels.stackId !== key.stackId) + ) + return yield* toDriverError( + key, + new Error("Container network name is owned by another stack"), + ); - // A new owner never adopts a durable container. Remove any exact identity first; - // lifecycle ownership has already fenced the stack before this activation. - if (existingExact !== undefined) { - if (existingExact.state === "running") - yield* withEngine(key, options.engine.stopContainer(existingExact.id)); - yield* withEngine(key, options.engine.removeContainer(existingExact.id)); - } - yield* withEngine(key, options.engine.inspectImage(artifact.image)).pipe( - Effect.flatMap((inspected) => - inspected.present - ? Effect.void - : withEngine(key, options.engine.pullImage(artifact.image)), - ), - ); - yield* guard; + // A new owner never adopts a durable container. Remove any exact identity first; + // lifecycle ownership has already fenced the stack before this activation. + if (existingExact !== undefined) { + if (existingExact.state === "running") + yield* withEngine(key, options.engine.stopContainer(existingExact.id)); + yield* withEngine(key, options.engine.removeContainer(existingExact.id)); + } + yield* withEngine(key, options.engine.inspectImage(artifact.image)).pipe( + Effect.flatMap((inspected) => + inspected.present + ? Effect.void + : withEngine(key, options.engine.pullImage(artifact.image)), + ), + ); + yield* guard; - if (exactNetwork === undefined && networkCollision !== undefined) - yield* withEngine(key, options.engine.removeNetwork(networkCollision.id)); - const networkResource = - exactNetwork ?? - (yield* withEngine( - key, - options.engine.createNetwork({ name: networkName, labels: networkLabels }), - )); + if (exactNetwork === undefined && networkCollision !== undefined) + yield* withEngine(key, options.engine.removeNetwork(networkCollision.id)); + const networkResource = + exactNetwork ?? + (yield* withEngine( + key, + options.engine.createNetwork({ name: networkName, labels: networkLabels }), + )); - const routeChanged = - options.onNetworkReady === undefined - ? false - : yield* options.onNetworkReady(networkResource); - const resolution = routeChanged - ? yield* options.resolveWorkload === undefined - ? Effect.succeed({}) - : options.resolveWorkload(key, workload) - : initialResolution; - yield* guard; - const requestedPublications = resolution.publications ?? []; - if (requestedPublications.some((publication) => !isValidPublication(publication))) - return yield* toDriverError( - key, - new Error("Container publications must use valid loopback host ports"), - ); - const publications = requestedPublications.filter(isValidPublication); - const volumeRequest = resolution.volume; - if (volumeRequest !== undefined && volumeRequest.target.length === 0) - return yield* toDriverError(key, new Error("Container volume mapping is invalid")); - const volumeSpec = - volumeRequest === undefined ? undefined : volumeSpecFor(key, volumeRequest); - const exactVolume = - volumeSpec === undefined - ? undefined - : entries.find( - (entry) => - entry.kind === "volume" && - entry.name === volumeSpec.name && - sameLabels(entry.labels, volumeSpec.labels), + const routeChanged = + options.onNetworkReady === undefined + ? false + : yield* options.onNetworkReady(networkResource); + const resolution = routeChanged + ? yield* options.resolveWorkload === undefined + ? Effect.succeed({}) + : options.resolveWorkload(key, workload) + : initialResolution; + yield* guard; + const requestedPublications = resolution.publications ?? []; + if (requestedPublications.some((publication) => !isValidPublication(publication))) + return yield* toDriverError( + key, + new Error("Container publications must use valid loopback host ports"), ); - if (volumeSpec !== undefined) { - const volume = volumeSpec; - if (exactVolume === undefined) { - const sameLabel = entries.find( - (entry) => entry.kind === "volume" && sameLabels(entry.labels, volume.labels), - ); - const sameName = entries.find( - (entry) => entry.kind === "volume" && entry.name === volume.name, - ); - if (sameLabel !== undefined || sameName !== undefined) - return yield* toDriverError(key, new Error("Container volume identity collision")); - } - } + const publications = requestedPublications.filter(isValidPublication); + const volumeRequest = resolution.volume; + if (volumeRequest !== undefined && volumeRequest.target.length === 0) + return yield* toDriverError(key, new Error("Container volume mapping is invalid")); + const volumeSpec = + volumeRequest === undefined ? undefined : volumeSpecFor(key, volumeRequest); + const exactVolume = + volumeSpec === undefined + ? undefined + : entries.find( + (entry) => + entry.kind === "volume" && + entry.name === volumeSpec.name && + sameLabels(entry.labels, volumeSpec.labels), + ); + if (volumeSpec !== undefined) { + const volume = volumeSpec; + if (exactVolume === undefined) { + const sameLabel = entries.find( + (entry) => entry.kind === "volume" && sameLabels(entry.labels, volume.labels), + ); + const sameName = entries.find( + (entry) => entry.kind === "volume" && entry.name === volume.name, + ); + if (sameLabel !== undefined || sameName !== undefined) + return yield* toDriverError( + key, + new Error("Container volume identity collision"), + ); + } + } - if (volumeSpec !== undefined) { - const volume = volumeSpec; - if (exactVolume === undefined) - yield* withEngine(key, options.engine.createVolume(volume)); - } + if (volumeSpec !== undefined) { + const volume = volumeSpec; + if (exactVolume === undefined) + yield* withEngine(key, options.engine.createVolume(volume)); + } - if (collision !== undefined && collision.id !== existingExact?.id) { - if (collision.state === "running") - yield* withEngine(key, options.engine.stopContainer(collision.id)); - yield* withEngine(key, options.engine.removeContainer(collision.id)); - } + if (collision !== undefined && collision.id !== existingExact?.id) { + if (collision.state === "running") + yield* withEngine(key, options.engine.stopContainer(collision.id)); + yield* withEngine(key, options.engine.removeContainer(collision.id)); + } - for (const startup of resolution.startup ?? []) - yield* runStartupProcess(key, workload, startup, { - artifact, - network: networkResource, - resolution, - ...(volumeRequest === undefined ? {} : { volumeRequest }), - }); - yield* guard; + for (const startup of resolution.startup ?? []) + yield* runStartupProcess(key, workload, startup, { + artifact, + network: networkResource, + resolution, + ...(volumeRequest === undefined ? {} : { volumeRequest }), + }); + yield* guard; - const container = yield* withEngine( - key, - options.engine.createContainer({ - name: nameFor(key, "workload"), - image: artifact.image, - labels, - network: networkResource.id, - mounts: resolution.mounts ?? [], - publications, - volumeMounts: volumeRequest === undefined ? [] : [volumeMountFor(key, volumeRequest)], - role: "workload", - ...(resolution.envFile === undefined ? {} : { envFile: resolution.envFile }), - ...(resolution.networkAliases === undefined - ? {} - : { networkAliases: resolution.networkAliases }), - ...(resolution.entrypoint === undefined ? {} : { entrypoint: resolution.entrypoint }), - ...(resolution.command === undefined ? {} : { command: resolution.command }), - } satisfies ContainerContainerSpec), - ); - if (resolution.bootstrap !== undefined) { - const copied = yield* Effect.exit( - withEngine( + const container = yield* withEngine( key, - options.engine.copyToContainer( - container.id, - resolution.bootstrap.source, - resolution.bootstrap.destination, - ), - ), - ); - if (Exit.isFailure(copied)) { - const removed = yield* Effect.exit( - withEngine(key, options.engine.removeContainer(container.id)), + options.engine.createContainer({ + name: nameFor(key, "workload"), + image: artifact.image, + labels, + network: networkResource.id, + mounts: resolution.mounts ?? [], + publications, + volumeMounts: + volumeRequest === undefined ? [] : [volumeMountFor(key, volumeRequest)], + role: "workload", + ...(resolution.envFile === undefined ? {} : { envFile: resolution.envFile }), + ...(resolution.networkAliases === undefined + ? {} + : { networkAliases: resolution.networkAliases }), + ...(resolution.entrypoint === undefined + ? {} + : { entrypoint: resolution.entrypoint }), + ...(resolution.command === undefined ? {} : { command: resolution.command }), + } satisfies ContainerContainerSpec), ); - const cleanupCause = Exit.isFailure(removed) ? removed.cause : Cause.empty; - return yield* Effect.failCause( - cleanupCause.reasons.length === 0 - ? copied.cause - : Cause.combine(copied.cause, cleanupCause), + if (resolution.bootstrap !== undefined) { + const copied = yield* Effect.exit( + withEngine( + key, + options.engine.copyToContainer( + container.id, + resolution.bootstrap.source, + resolution.bootstrap.destination, + ), + ), + ); + if (Exit.isFailure(copied)) { + const removed = yield* Effect.exit( + withEngine(key, options.engine.removeContainer(container.id)), + ); + const cleanupCause = Exit.isFailure(removed) ? removed.cause : Cause.empty; + return yield* Effect.failCause( + cleanupCause.reasons.length === 0 + ? copied.cause + : Cause.combine(copied.cause, cleanupCause), + ); + } + } + yield* guard; + yield* withEngine(key, options.engine.startContainer(container.id)); + const registered = yield* Effect.uninterruptibleMask((restore) => + restore( + Effect.gen(function* () { + yield* guard; + const failure = yield* Deferred.make(); + yield* guard; + const createdResource: ContainerRuntimeResource = { + key, + workload, + container: container.id, + state: "starting", + failure, + stopRequested: false, + }; + resources.set(resourceKey(key), createdResource); + return createdResource; + }), + ).pipe(Effect.exit), ); - } - } - yield* guard; - yield* withEngine(key, options.engine.startContainer(container.id)); - const registered = yield* Effect.uninterruptibleMask((restore) => - restore( - Effect.gen(function* () { - yield* guard; - const failure = yield* Deferred.make(); - yield* guard; - const createdResource: ContainerRuntimeResource = { - key, - workload, - container: container.id, - state: "starting", - failure, - stopRequested: false, - }; - resources.set(resourceKey(key), createdResource); - return createdResource; - }), - ).pipe(Effect.exit), + if (Exit.isFailure(registered)) { + const cleanup = yield* cleanupStartedContainer(key, container.id).pipe(Effect.exit); + return yield* Effect.failCause( + Exit.isFailure(cleanup) + ? Cause.combine(registered.cause, cleanup.cause) + : registered.cause, + ); + } + return { resource: registered.value, resolution, container }; + }), ); - if (Exit.isFailure(registered)) { - const cleanup = yield* cleanupStartedContainer(key, container.id).pipe(Effect.exit); - return yield* Effect.failCause( - Exit.isFailure(cleanup) - ? Cause.combine(registered.cause, cleanup.cause) - : registered.cause, - ); - } - const resource = registered.value; + const { resource, resolution, container } = setupResult; const postRegistration = Effect.gen(function* () { yield* attachLogs(resource, "all"); yield* attachExitWatcher(resource); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 8ded6107d2..a9780e3aa8 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -12,6 +12,7 @@ import { Result, Scope, Schedule, + Semaphore, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { eagerCapabilities, type PlannedWorkload } from "../model/ExecutionPlan.ts"; @@ -577,6 +578,10 @@ export const makeProductionRuntime = ( options.bootstrapDatabase ?? ((state: PersistedStackState) => bootstrapDatabaseAt(state)); const artifacts = new Map(); + // Runtime input materialization writes shared files and populates completed caches. Keep + // that short preparation boundary serialized while allowing the actual workloads to start + // concurrently after their inputs are ready. + const runtimeInputGate = yield* Semaphore.make(1); const freshState = (key: Pick) => currentStateReader(options).pipe( Effect.mapError((error) => mapDriverError(key, error)), @@ -613,49 +618,51 @@ export const makeProductionRuntime = ( fresh: PersistedStackState, host: ContainerHostRoute | undefined, ): Effect.Effect => - Effect.gen(function* () { - const material = yield* inputOwner.resolve(fresh, workload.id); - const templates = material.auth?.templates; - const apiListener = fresh.definition?.listeners.api; - const apiAssignment = fresh.ports.find((assignment) => assignment.field === "api"); - const templateBaseUrl = - workload.id !== "auth:auth" || templates === undefined || templates.length === 0 - ? undefined - : apiListener?.enabled !== true || apiAssignment === undefined - ? yield* preparationError( - "Configured Auth email templates require a public API listener", - ) - : `http://${urlHost(host?.host ?? apiListener.address)}:${apiAssignment.port}`; - const auth = - material.auth === undefined - ? undefined - : { - ...material.auth, - ...(templateBaseUrl === undefined ? {} : { templateBaseUrl }), - }; - const functions = - workload.id === "functions:edge-runtime" - ? { - bootstrapPath: yield* functionsPath(), - ...(material.functions?.secrets === undefined - ? {} - : { secrets: material.functions.secrets }), - } - : undefined; - return { - ...(auth === undefined ? {} : { auth }), - ...(workload.id.startsWith("analytics:") && material.analytics !== undefined - ? { analytics: material.analytics } - : {}), - ...(workload.id === "pooler:pooler" && material.pooler !== undefined - ? { pooler: material.pooler } - : {}), - database: { dataPath: pathService.join(paths.data, "database") }, - storage: { dataPath: pathService.join(paths.data, "storage") }, - ...(functions === undefined ? {} : { functions }), - ...(host === undefined ? {} : { hostRoute: host }), - }; - }); + runtimeInputGate.withPermit( + Effect.gen(function* () { + const material = yield* inputOwner.resolve(fresh, workload.id); + const templates = material.auth?.templates; + const apiListener = fresh.definition?.listeners.api; + const apiAssignment = fresh.ports.find((assignment) => assignment.field === "api"); + const templateBaseUrl = + workload.id !== "auth:auth" || templates === undefined || templates.length === 0 + ? undefined + : apiListener?.enabled !== true || apiAssignment === undefined + ? yield* preparationError( + "Configured Auth email templates require a public API listener", + ) + : `http://${urlHost(host?.host ?? apiListener.address)}:${apiAssignment.port}`; + const auth = + material.auth === undefined + ? undefined + : { + ...material.auth, + ...(templateBaseUrl === undefined ? {} : { templateBaseUrl }), + }; + const functions = + workload.id === "functions:edge-runtime" + ? { + bootstrapPath: yield* functionsPath(), + ...(material.functions?.secrets === undefined + ? {} + : { secrets: material.functions.secrets }), + } + : undefined; + return { + ...(auth === undefined ? {} : { auth }), + ...(workload.id.startsWith("analytics:") && material.analytics !== undefined + ? { analytics: material.analytics } + : {}), + ...(workload.id === "pooler:pooler" && material.pooler !== undefined + ? { pooler: material.pooler } + : {}), + database: { dataPath: pathService.join(paths.data, "database") }, + storage: { dataPath: pathService.join(paths.data, "storage") }, + ...(functions === undefined ? {} : { functions }), + ...(host === undefined ? {} : { hostRoute: host }), + }; + }), + ); const preflight = (input: LifecycleInput): Effect.Effect => Effect.gen(function* () { diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index 5ce695f172..edf6262b4f 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -541,6 +541,98 @@ describe("container runtime", () => { }), ); + it.live("creates one shared network when workloads start concurrently", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const networkEntered = yield* Deferred.make(); + const releaseNetwork = yield* Deferred.make(); + let networkCreates = 0; + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + createNetwork: (spec) => + Effect.gen(function* () { + networkCreates += 1; + yield* Deferred.succeed(networkEntered, undefined); + yield* Deferred.await(releaseNetwork); + return yield* base.createNetwork(spec); + }), + }, + ownerSessionId: "owner-session", + }); + const secondKey = { ...key, workloadId: "rest:rest" }; + const secondWorkload = { + ...workload(), + id: secondKey.workloadId, + capability: "rest" as const, + }; + const first = yield* Effect.forkChild(runtime.start(key, workload()), { + startImmediately: true, + }); + yield* Deferred.await(networkEntered); + const second = yield* Effect.forkChild(runtime.start(secondKey, secondWorkload), { + startImmediately: true, + }); + yield* Deferred.succeed(releaseNetwork, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + expect(networkCreates).toBe(1); + yield* runtime.cleanup({ stackId, destroy: true }); + }), + ); + + it.live("interrupts a setup blocked on shared network creation during cleanup", () => + Effect.gen(function* () { + const state: FakeContainerState = { + resources: [], + imagePresent: true, + calls: [], + createdSpecs: [], + nextId: 1, + }; + const networkEntered = yield* Deferred.make(); + const releaseNetwork = yield* Deferred.make(); + const base = fakeContainerEngine(state); + const runtime = yield* makeContainerRuntime({ + engine: { + ...base, + createNetwork: (spec) => + Effect.gen(function* () { + yield* Deferred.succeed(networkEntered, undefined); + yield* Deferred.await(releaseNetwork); + return yield* base.createNetwork(spec); + }), + }, + ownerSessionId: "owner-session", + }); + const starting = yield* Effect.forkChild(runtime.start(key, workload()), { + startImmediately: true, + }); + yield* Deferred.await(networkEntered); + const secondKey = { ...key, workloadId: "rest:rest" }; + const secondWorkload = { + ...workload(), + id: secondKey.workloadId, + capability: "rest" as const, + }; + const queued = yield* Effect.forkChild(runtime.start(secondKey, secondWorkload), { + startImmediately: true, + }); + yield* runtime.cleanup({ stackId, destroy: true }); + yield* Deferred.succeed(releaseNetwork, undefined); + expect(Exit.isFailure(yield* Fiber.join(starting).pipe(Effect.exit))).toBe(true); + expect(Exit.isFailure(yield* Fiber.join(queued).pipe(Effect.exit))).toBe(true); + expect(state.resources).toEqual([]); + }), + ); + it.live("runs container startup migrations before the main workload", () => Effect.gen(function* () { const state: FakeContainerState = { diff --git a/packages/stack/src/supervisor/SessionLauncher.ts b/packages/stack/src/supervisor/SessionLauncher.ts index b97e2fb05b..4c0050ac05 100644 --- a/packages/stack/src/supervisor/SessionLauncher.ts +++ b/packages/stack/src/supervisor/SessionLauncher.ts @@ -13,7 +13,7 @@ interface SessionWorkload { } export interface SessionLauncher { - /** Starts the supplied dependency closure in topological order. */ + /** Starts the supplied dependency closure in dependency-ready waves. */ readonly launch: (plan: ExecutionPlan) => Effect.Effect; /** Stops and removes every workload started in this session in reverse order. */ readonly stop: Effect.Effect; @@ -28,6 +28,8 @@ interface SessionLaunch { readonly rollback: Effect.Effect; } +const START_CONCURRENCY = 4; + const keyFor = (stackId: StackId, workload: PlannedWorkload): RuntimeWorkloadKey => ({ stackId, workloadId: workload.id, @@ -40,8 +42,8 @@ const combine = ( cleanup.reasons.length === 0 ? primary : Cause.combine(primary, cleanup); /** - * Owns only the workloads started by the current Supervisor session. A launch is one ordered - * attempt and a failure cleans that attempt's resources. + * Owns only the workloads started by the current Supervisor session. A launch starts + * dependency-ready waves and a failure cleans that attempt's resources. */ export const makeSessionLauncher = (options: { readonly stackId: StackId; @@ -72,22 +74,46 @@ export const makeSessionLauncher = (options: { const launch = (plan: ExecutionPlan): Effect.Effect => Effect.gen(function* () { yield* Ref.set(cleanupProven, true); - const existing = new Set((yield* Ref.get(session)).map(({ key }) => key.workloadId)); const attempted: SessionWorkload[] = []; - const outcome = yield* Effect.exit( - Effect.forEach( - plan.workloads, - (workload) => - Effect.gen(function* () { - if (existing.has(workload.id)) return; - const entry = { key: keyFor(options.stackId, workload), workload }; - attempted.push(entry); - yield* options.driver.start(entry.key, workload); - yield* Ref.update(session, (current) => [...current, entry]); + const planEntries = plan.workloads.map((workload) => ({ + key: keyFor(options.stackId, workload), + workload, + })); + const ready = new Set((yield* Ref.get(session)).map(({ key }) => key.workloadId)); + let remaining = planEntries.filter((entry) => !ready.has(entry.workload.id)); + let outcome: Exit.Exit = Exit.succeed(undefined); + while (remaining.length > 0 && Exit.isSuccess(outcome)) { + const wave = remaining.filter((entry) => + entry.workload.dependencies.every((dependency) => ready.has(dependency)), + ); + if (wave.length === 0) { + outcome = Exit.fail( + new RuntimeDriverError({ + message: "No workload is ready to start; dependencies are unsatisfied", + stackId: options.stackId, }), - { concurrency: 1, discard: true }, - ), - ); + ); + break; + } + outcome = yield* Effect.exit( + Effect.forEach( + wave, + (entry) => + Effect.gen(function* () { + // Record the resource immediately before starting it. This includes an + // in-flight start when a sibling fails, without treating queued work as owned. + attempted.push(entry); + yield* options.driver.start(entry.key, entry.workload); + }), + { concurrency: START_CONCURRENCY, discard: true }, + ), + ); + if (Exit.isSuccess(outcome)) { + for (const entry of wave) ready.add(entry.workload.id); + yield* Ref.update(session, (current) => [...current, ...wave]); + remaining = remaining.filter((entry) => !ready.has(entry.workload.id)); + } + } if (Exit.isSuccess(outcome)) { const rollback = Effect.gen(function* () { const result = yield* cleanup(attempted).pipe(Effect.exit); diff --git a/packages/stack/src/supervisor/session-launcher.integration.test.ts b/packages/stack/src/supervisor/session-launcher.integration.test.ts new file mode 100644 index 0000000000..a0d2f3e250 --- /dev/null +++ b/packages/stack/src/supervisor/session-launcher.integration.test.ts @@ -0,0 +1,174 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, Exit, Fiber, Option } from "effect"; +import type { CapabilityName } from "../public/Capability.ts"; +import { CAPABILITY_NAMES } from "../public/Capability.ts"; +import { StackIdSchema } from "../public/StackId.ts"; +import type { ExecutionPlan, PlannedWorkload } from "../model/ExecutionPlan.ts"; +import { RuntimeDriverError, type RuntimeDriver } from "../runtime/RuntimeDriver.ts"; +import { makeNativeRuntime } from "../runtime/NativeRuntime.ts"; +import { makeSessionLauncher } from "./SessionLauncher.ts"; + +const stackId = StackIdSchema.make("b".repeat(64)); + +const workload = (id: string, dependencies: ReadonlyArray = []): PlannedWorkload => ({ + id, + capability: "database", + dependencies, + readiness: {}, + artifacts: { + native: { kind: "native", release: "test" }, + container: { kind: "container", image: "test/image" }, + }, + selected: { kind: "native", release: "test" }, +}); + +const plan = (workloads: ReadonlyArray): ExecutionPlan => { + const activation = { + database: "eager", + rest: "lazy", + auth: "lazy", + realtime: "lazy", + storage: "lazy", + functions: "lazy", + studio: "lazy", + mail: "lazy", + analytics: "lazy", + pooler: "lazy", + } satisfies { [Name in CapabilityName]: "eager" | "lazy" }; + const dependencies = { + database: [], + rest: [], + auth: [], + realtime: [], + storage: [], + functions: [], + studio: [], + mail: [], + analytics: [], + pooler: [], + } satisfies { [Name in CapabilityName]: ReadonlyArray }; + return { + runtime: { kind: "native" }, + activation, + startOrder: CAPABILITY_NAMES, + dependencies, + routes: [], + workloads, + }; +}; + +const ready = (workload: PlannedWorkload) => ({ + stackId, + workloadId: workload.id, + state: "ready" as const, +}); + +const withPlatform = (effect: Effect.Effect) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +describe("session launcher", () => { + it.live("starts independent workloads in parallel before their dependants", () => + Effect.gen(function* () { + const databaseEntered = yield* Deferred.make(); + const releaseDatabase = yield* Deferred.make(); + const mailEntered = yield* Deferred.make(); + const calls: string[] = []; + const database = workload("database:database"); + const mail = workload("mail:mail"); + const rest = workload("rest:rest", [database.id]); + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: (_key, current) => + Effect.gen(function* () { + calls.push(`start:${current.id}`); + if (current.id === database.id) { + yield* Deferred.succeed(databaseEntered, undefined); + yield* Deferred.await(releaseDatabase); + } + if (current.id === mail.id) yield* Deferred.succeed(mailEntered, undefined); + return ready(current); + }), + stop: (key) => Effect.sync(() => calls.push(`stop:${key.workloadId}`)), + remove: (key) => Effect.sync(() => calls.push(`remove:${key.workloadId}`)), + cleanup: () => Effect.void, + }; + const launcher = yield* makeSessionLauncher({ stackId, driver }); + const launching = yield* Effect.forkChild(launcher.launch(plan([database, mail, rest])), { + startImmediately: true, + }); + + yield* Deferred.await(databaseEntered); + yield* Deferred.await(mailEntered); + expect(calls).toContain("start:database:database"); + expect(calls).toContain("start:mail:mail"); + expect(calls).not.toContain("start:rest:rest"); + + yield* Deferred.succeed(releaseDatabase, undefined); + yield* Fiber.join(launching); + expect(calls).toContain("start:rest:rest"); + }), + ); + + it.live("interrupts and removes an in-flight native workload when a sibling fails", () => + withPlatform( + Effect.gen(function* () { + const databaseEntered = yield* Deferred.make(); + const databaseInterrupted = yield* Deferred.make(); + const database = workload("database:database"); + const mail = workload("mail:mail"); + const driver = yield* makeNativeRuntime({ + resolveProcess: (key) => { + if (key.workloadId === database.id) + return Effect.gen(function* () { + yield* Deferred.succeed(databaseEntered, undefined); + return yield* Effect.never; + }).pipe(Effect.ensuring(Deferred.succeed(databaseInterrupted, undefined))); + return Deferred.await(databaseEntered).pipe( + Effect.andThen( + Effect.fail( + new RuntimeDriverError({ + message: "mail failed", + stackId, + workloadId: key.workloadId, + }), + ), + ), + ); + }, + waitForReadiness: () => Effect.never, + }); + const launcher = yield* makeSessionLauncher({ stackId, driver }); + const launching = yield* Effect.forkChild(launcher.launch(plan([database, mail])), { + startImmediately: true, + }); + yield* Deferred.await(databaseEntered); + const result = yield* Fiber.join(launching).pipe(Effect.exit); + + expect(Exit.isFailure(result)).toBe(true); + yield* Deferred.await(databaseInterrupted); + expect(yield* driver.observe(stackId)).toEqual([]); + }), + ), + ); + + it.live("fails with a typed error when no pending workload can become ready", () => + Effect.gen(function* () { + const driver: RuntimeDriver = { + observe: () => Effect.succeed([]), + start: () => Effect.die("unreachable"), + stop: () => Effect.die("unreachable"), + remove: () => Effect.die("unreachable"), + cleanup: () => Effect.void, + }; + const launcher = yield* makeSessionLauncher({ stackId, driver }); + const result = yield* launcher + .launch(plan([workload("cycle:a", ["cycle:b"]), workload("cycle:b", ["cycle:a"])])) + .pipe(Effect.exit); + const error = Exit.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) + : undefined; + expect(error).toBeInstanceOf(RuntimeDriverError); + }), + ); +}); From 27355b4ca7f8b258f583f12aa186a5c09a42aa5c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 5 Sep 2026 10:46:07 +0200 Subject: [PATCH 171/174] perf(stack): prefetch lazy artifacts with observable progress --- ...7-simplified-managed-stack-architecture.md | 59 +- .../2026-08-16-stack-simplification-design.md | 2 +- packages/stack/README.md | 41 +- .../control-transport.integration.test.ts | 30 + packages/stack/src/index.ts | 11 +- packages/stack/src/model/Compiler.ts | 4 +- .../src/model/compiler.integration.test.ts | 8 +- .../stack/src/preparation/ArtifactStore.ts | 92 +-- .../stack/src/preparation/RuntimeArtifacts.ts | 202 +++--- .../src/preparation/SlimServicesSource.ts | 7 +- .../preparation/artifacts.integration.test.ts | 104 +-- packages/stack/src/public/Config.ts | 5 + packages/stack/src/public/EffectStack.ts | 17 +- packages/stack/src/public/PromiseStack.ts | 1 + packages/stack/src/public/Status.ts | 21 + .../public/effect-stack.integration.test.ts | 73 ++- .../src/public/promise.integration.test.ts | 28 +- .../public/public-model.integration.test.ts | 13 +- .../src/public/testing.integration.test.ts | 1 + .../stack/src/public/whole-stack.e2e.test.ts | 13 + .../stack/src/runtime/ProductionRuntime.ts | 245 ++++++- .../container-runtime.integration.test.ts | 3 + .../production-runtime.integration.test.ts | 600 ++++++++++++++++++ packages/stack/src/state/StackState.ts | 3 +- .../src/state/state-store.integration.test.ts | 10 + .../stack/src/supervisor/StatusProjection.ts | 8 +- packages/stack/src/supervisor/Supervisor.ts | 28 +- .../supervisor/handles.integration.test.ts | 2 + .../supervisor/supervisor.integration.test.ts | 183 ++++++ 29 files changed, 1483 insertions(+), 331 deletions(-) diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index eb5ea7b20b..98619d9375 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -88,21 +88,58 @@ when both metadata and lock are absent. Mutations use the live owner or acquire the lease and launch a fresh Supervisor. Ambiguous ownership fails closed. The stable maintenance protocol contains only probe and stop. Same-release -callers use Effect RPC for status, logs, activation, start, and destroy; artifact -preparation runs directly in the caller scope and never uses the Supervisor. An -incompatible owner can always be stopped, after which the caller may launch the -current Supervisor and start again. The first admitted lifecycle operation runs +callers use Effect RPC for status, logs, activation, start, and destroy; explicit +artifact preparation runs directly in the caller scope and never uses the +Supervisor. An incompatible owner can always be stopped, after which the caller +may launch the current Supervisor and start again. The first admitted lifecycle operation runs to completion; concurrent lifecycle mutations fail immediately with a conflict rather than joining or queueing. PostgreSQL is the only eager capability by default. Start prepares and launches -only the eager dependency closure. Every other enabled capability prepares and -starts when traffic first reaches its stable gateway route. Explicit -`stack.prepare(...)` remains available for callers that intentionally want to -warm selected artifacts, but it is not part of ordinary CLI start. Preparation -is caller-owned: cancellation stops that caller's unfinished transfers while -completed cache entries remain. Preparation never acquires control or ownership -and does not create runtime resources. +only the eager dependency closure, so the PostgreSQL readiness barrier remains +the default startup path. Capabilities configured for lazy activation start when +traffic first reaches their stable gateway route. Lazy artifact preparation is controlled +by the top-level `StackConfig.preparation` setting, which defaults to +`"background"`. After a successful start, background mode prepares all enabled +lazy artifacts with bounded concurrency without launching their processes. +That work is one Supervisor-owned session task: stop and destroy cancel and +await it, foreground activation joins shared preparations, and completed cache +entries remain available without automatic pruning. `"on-demand"` skips the +background task for callers that want full lazy preparation. Eager capability +activation remains independent of this setting. +That runtime session owns the shared preparation fibers. + +In both modes, lazy activation prepares the requested dependency closure with +bounded concurrency before starting its workloads. Explicit +`stack.prepare(...)` remains available as a cache-only warmup for callers that +want to prepare selected artifacts while stopped or running. The preparation +setting is persisted in the stack definition and survives opening and restart; +changing it for a running stack follows the existing stop-before-change +configuration rule. Preparation never creates runtime resources; +ArtifactStore publishes cache entries in the caller's scope, and runtime-driver +cleanup closes transfer resources. Caller-owned explicit preparation +cancellation only stops that caller's unfinished transfers while completed +cache entries remain. + +Preparation uses bounded Effect concurrency to overlap transfers with native +decompression and archive subprocesses. An Effect RPC worker pool would add +coordination without improving this existing native and subprocess work: +decompression already uses Bun's native worker pool, while archive listing and +extraction run in subprocesses. Revisit this only if measurement identifies a +CPU-bound bottleneck. + +Live status exposes the current session's artifact preparation through its artifacts array. Entries +identify the workload and capability and move through `queued`, `preparing`, `downloading`, +`ready`, and `failed`; the latter carries an actionable error for a later retry. `preparing` +includes validation, transfer verification, and extraction around the download. Artifact status is +observational and does not imply that the capability's process is running. During stopping or +destroying, the owner may continue to expose active preparation until teardown clears it. Offline +status supplies no live artifact states and does not scan the cache to recreate a progress snapshot. + +Explicit `stack.prepare(...)` accepts a synchronous `onProgress` callback for +caller-owned preparation. It receives the same phase values, including `ready` when an artifact is +available while its capability remains dormant. The callback's transfer is local to that invocation +and is not reconstructed by a separate status request. Within a lifecycle operation, dependency-ready workloads start concurrently, with at most four starts in flight; each wave completes before its dependants diff --git a/docs/superpowers/specs/2026-08-16-stack-simplification-design.md b/docs/superpowers/specs/2026-08-16-stack-simplification-design.md index bdc2caaa7a..95f536fd4d 100644 --- a/docs/superpowers/specs/2026-08-16-stack-simplification-design.md +++ b/docs/superpowers/specs/2026-08-16-stack-simplification-design.md @@ -1,6 +1,6 @@ # `@supabase/stack` Simplification Design -**Status:** approved for implementation +**Status:** superseded — see [ADR-0017](../../adr/0017-simplified-managed-stack-architecture.md) for the current maintained design **Date:** 2026-08-16 **Baseline:** PR #6218 at `bb502dfa7971d44f41a30d402fd08a54d7a2c6dd` diff --git a/packages/stack/README.md b/packages/stack/README.md index 4c7e95d832..70a3edc942 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -16,8 +16,38 @@ listener. Status and retained logs remain available directly from durable state later start on the same handle launches a fresh Supervisor. With no configuration override, all capabilities are enabled, PostgreSQL is the only eager capability, and every other capability is lazy. Starting the stack therefore launches only -PostgreSQL; traffic through the stack's listeners activates REST, Auth, Realtime, Storage, -Functions, Studio, Mail, Analytics, or Pooler on demand for the current running session. +PostgreSQL by default; capabilities configured as eager join its startup dependency closure. +The remaining lazy capabilities activate through the stack's listeners on demand for the current +running session. + +Artifact preparation is controlled independently from capability activation through the optional +top-level `preparation` setting: + +```ts +await stack.start({ config: { preparation: "on-demand" } }); +``` + +The default `"background"` mode prepares all enabled lazy artifacts after PostgreSQL has started, +without launching those services. A single Supervisor-owned background operation runs with bounded +concurrency, is canceled and awaited by `stop()` or `destroy()`, and keeps completed cache entries. +`"on-demand"` skips that background work for callers that want full lazy preparation. In either +mode, activating a lazy service prepares its requested dependency closure concurrently, while +explicit `stack.prepare(...)` remains available as a cache-only warmup. Eager capabilities remain +independent of this preparation policy. The setting is persisted with the stack definition and +survives `openStack()` and restart. Changing it for a running stack follows the existing +stop-before-change configuration rule. + +Running status includes an artifacts array for the current session. Each entry identifies a +workload and capability and reports `queued`, `preparing`, `downloading`, `ready`, or +`failed`; `failed` includes an error message and can be retried by activating the capability again. +`preparing` covers validation, verification, and extraction around the transfer. During stopping +or destroying, status may retain active preparation until teardown clears it. Once stopped, status +reports an empty array even when completed artifacts remain in the cache. + +Explicit `stack.prepare(...)` accepts a synchronous `onProgress` callback for +caller-owned preparation. It receives the same phase values, including `ready` when an artifact is +available while its capability remains dormant. This transfer-local callback is not reconstructed by +a separate status request. The package's end-to-end contract is exercised through the same public Stack API in native and Docker modes. It begins from the PostgreSQL-only default, progressively activates every service with @@ -35,10 +65,11 @@ unfiltered package `listStacks()` result. A failed destroy retains the affected managed state for recovery. Callers can warm selected native artifacts or container images with `stack.prepare(...)` while a -stack is stopped; preparation is cache-only and cancellation does not affect completed entries. +stack is stopped or running; explicit preparation is cache-only and cancellation does not affect +completed entries. Each capability may opt into eager activation in `StackConfig`; omitted settings keep every -non-PostgreSQL capability lazy. `followLogs(...)` provides filterable live entries through a -stateless client-polled cursor. +non-PostgreSQL capability lazy. Prepared artifacts are not automatically pruned. `followLogs(...)` +provides filterable live entries through a stateless client-polled cursor. Database reset is intentionally outside the current API. Applying migrations, declarative schemas, and seeds remains the caller's responsibility. The runtime bootstrap only reconciles the `_realtime` diff --git a/packages/stack/src/control/control-transport.integration.test.ts b/packages/stack/src/control/control-transport.integration.test.ts index 954ab0d91a..438b311924 100644 --- a/packages/stack/src/control/control-transport.integration.test.ts +++ b/packages/stack/src/control/control-transport.integration.test.ts @@ -114,6 +114,7 @@ const withServer = ( activation: "eager", state: "stopped", })), + artifacts: [], }; const defaultRpcHandlers: StackRpcHandlers = { status: () => Effect.succeed(status), @@ -435,6 +436,35 @@ describe("control transport", () => { ), ); + it.live("round-trips artifact preparation state alongside dormant capabilities", () => + withServer( + ({ endpoint, stackId, ownerSessionId }) => + Effect.gen(function* () { + const client = makeControlClient(endpoint, { stackId, ownerSessionId }); + const rpc = yield* client.rpc; + const observed = yield* rpc.status(undefined); + expect(observed.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); + expect(observed.artifacts).toEqual([ + { workloadId: "rest:rest", capability: "rest", state: "downloading" }, + ]); + }), + ({ status }) => ({ + rpcHandlers: { + status: () => + Effect.succeed({ + ...status, + lifecycle: "running", + desiredLifecycle: "running", + capabilities: status.capabilities.map((capability) => + capability.name === "rest" ? { ...capability, state: "dormant" } : capability, + ), + artifacts: [{ workloadId: "rest:rest", capability: "rest", state: "downloading" }], + }), + }, + }), + ), + ); + it.live("round-trips an RPC log batch larger than the maintenance frame cap", () => withServer( ({ endpoint, stackId, ownerSessionId }) => diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index a270b63e58..9f9ca11994 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -23,6 +23,8 @@ export type { NetworkPort, StackEndpoint, StackStatus, + ArtifactPreparationState, + ArtifactPreparationStatus, StackDescriptor, StackInspection, } from "./public/index.ts"; @@ -30,12 +32,19 @@ export { StackIdSchema, isStackId } from "./public/StackId.ts"; export type { StackId } from "./public/StackId.ts"; export { StackRuntimeSchema, RuntimeEngineSchema } from "./public/Runtime.ts"; export type { StackRuntime, RuntimeEngine, StackRuntimePreference } from "./public/Runtime.ts"; -export { StackEndpointsSchema, CapabilityVersionsSchema } from "./public/Status.ts"; +export { + StackEndpointsSchema, + CapabilityVersionsSchema, + ArtifactPreparationStateSchema, + ArtifactPreparationStatusSchema, +} from "./public/Status.ts"; export { CapabilityNameSchema, CapabilityStatusSchema, ActivationModeSchema, } from "./public/Capability.ts"; +export { PreparationModeSchema } from "./public/Config.ts"; +export type { PreparationMode } from "./public/Config.ts"; export { LogCursorSchema, LogQuerySchema, diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 56f0a0bb05..a346f0aa6c 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -1,6 +1,6 @@ import { Duration, Effect, Path, Redacted, Schema } from "effect"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "../public/Errors.ts"; -import { StackConfigSchema, type StackConfig } from "../public/Config.ts"; +import { StackConfigSchema, type StackConfig, type PreparationMode } from "../public/Config.ts"; import type { JwtSigning } from "../public/Config.ts"; import type { CapabilityName } from "../public/Capability.ts"; import type { PortField } from "../public/Status.ts"; @@ -36,6 +36,7 @@ interface SecretSlot { } export interface StackDefinition { + readonly preparation: PreparationMode; readonly capabilities: MaterializedCapabilities; readonly listeners: Readonly>; readonly security: Readonly<{ @@ -690,6 +691,7 @@ export const compileStack = ( }, }; const definition: StackDefinition = { + preparation: config.preparation ?? "background", capabilities, listeners, security, diff --git a/packages/stack/src/model/compiler.integration.test.ts b/packages/stack/src/model/compiler.integration.test.ts index 2b2e27554b..e652fcfb27 100644 --- a/packages/stack/src/model/compiler.integration.test.ts +++ b/packages/stack/src/model/compiler.integration.test.ts @@ -58,9 +58,10 @@ describe("closed capability compiler", () => { }), ); - it.live("defaults only the database capability to eager activation", () => + it.live("defaults preparation to background and accepts on-demand mode", () => Effect.gen(function* () { const result = yield* compile({}); + expect(result.definition.preparation).toBe("background"); expect(result.executionPlan.activation).toEqual({ database: "eager", rest: "lazy", @@ -81,6 +82,11 @@ describe("closed capability compiler", () => { const disabledPooler = yield* compile({ capabilities: { pooler: { enabled: false } } }); expect(disabledPooler.definition.capabilities.pooler.enabled).toBe(false); expect(disabledPooler.definition.listeners.pooler.enabled).toBe(true); + const onDemand = yield* compile({ preparation: "on-demand" }); + expect(onDemand.definition.preparation).toBe("on-demand"); + + const invalid = yield* compile({ preparation: "invalid" } as never).pipe(Effect.exit); + expect(failureOf(invalid)).toBeInstanceOf(InvalidStackConfigError); }), ); diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index e3adebb5c0..e863695926 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -1,17 +1,4 @@ -import { - Crypto, - Deferred, - Effect, - Exit, - FileSystem, - Option, - Path, - PlatformError, - Predicate, - Schema, - Scope, - Semaphore, -} from "effect"; +import { Crypto, Effect, FileSystem, Option, Path, PlatformError, Predicate, Schema } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; import { validateRelativePath, validateSha256, verifySha256 } from "./Integrity.ts"; @@ -42,6 +29,7 @@ export interface ArtifactSource { request: ArtifactRequest, destination: string, expectedSha256: string, + onProgress?: (state: "downloading" | "preparing") => void, ) => Effect.Effect< Uint8Array, StackPreparationError, @@ -69,6 +57,7 @@ export type ArtifactStoreError = StackPreparationError | ArtifactIntegrityError; export interface ArtifactStore { readonly prepare: ( request: ArtifactRequest, + onProgress?: (state: "downloading" | "preparing") => void, ) => Effect.Effect; } @@ -162,9 +151,6 @@ const validateRequest = (request: ArtifactRequest): Effect.Effect - [request.key, ...request.requiredRuntimePaths, request.executablePath ?? ""].join("\u0000"); - const metadataFor = ( request: ArtifactRequest, sha256: string, @@ -609,6 +595,7 @@ const makeArtifactOperation = ( cacheRoot: string, source: ArtifactSource, request: ArtifactRequest, + onProgress?: (state: "downloading" | "preparing") => void, ): Effect.Effect => Effect.gen(function* () { const target = path.resolve(cacheRoot, request.key); @@ -733,14 +720,16 @@ const makeArtifactOperation = ( const published = yield* Effect.gen(function* () { yield* ensureDirectory(fs, path, temporary, cacheRoot); const temporaryRoot = yield* ensureSafeRoot(fs, path, temporary, cacheRoot); - const archive = yield* source.materialize(request, temporary, expectedSha256).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - // The source owns the exact tar process boundary; the store only supplies the - // already-owned process service captured by its constructor. - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ); + const archive = yield* source + .materialize(request, temporary, expectedSha256, onProgress) + .pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + // The source owns the exact tar process boundary; the store only supplies the + // already-owned process service captured by its constructor. + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); yield* verifySha256(archive, expectedSha256).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.mapError((error) => @@ -809,11 +798,7 @@ export const makeArtifactStore = ( ): Effect.Effect< ArtifactStore, StackPreparationError, - | FileSystem.FileSystem - | Path.Path - | Crypto.Crypto - | Scope.Scope - | ChildProcessSpawner.ChildProcessSpawner + FileSystem.FileSystem | Path.Path | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -847,22 +832,16 @@ export const makeArtifactStore = ( if (rootInfo.type !== "Directory") return yield* artifactError("Artifact cache root must be a directory", { path: cacheRoot }); yield* mapFs(cacheRoot, "secure artifact cache root", fs.chmod(cacheRoot, 0o700)); - const ownerScope = yield* Scope.make("parallel"); - const parentScope = yield* Scope.Scope; - yield* Scope.addFinalizer(parentScope, Scope.close(ownerScope, Exit.void)); - const lifecycle = yield* Semaphore.make(1); - type InFlight = { - readonly result: Deferred.Deferred; - }; - const inFlight = new Map(); - const prepare = (request: ArtifactRequest) => + const prepare = ( + request: ArtifactRequest, + onProgress?: (state: "downloading" | "preparing") => void, + ) => Effect.gen(function* () { yield* validateRequest(request); const target = path.resolve(cacheRoot, request.key); if (!pathWithin(cacheRoot, target, path.sep)) return yield* artifactError("Artifact key escapes cache root", { key: request.key }); - const flightKey = requestFlightKey(request); - const operation = makeArtifactOperation( + return yield* makeArtifactOperation( fs, path, crypto, @@ -870,37 +849,8 @@ export const makeArtifactStore = ( cacheRoot, options.source, request, + onProgress, ); - const joined = yield* lifecycle.withPermit( - Effect.gen(function* () { - const existing = inFlight.get(flightKey); - if (existing !== undefined) { - return existing; - } - const result = yield* Deferred.make(); - const record: InFlight = { result }; - inFlight.set(flightKey, record); - yield* Effect.forkIn( - Effect.gen(function* () { - const exit = yield* Effect.exit(operation); - yield* lifecycle.withPermit( - Effect.uninterruptible( - Effect.gen(function* () { - inFlight.delete(flightKey); - yield* Deferred.done(record.result, exit); - }), - ), - ); - }), - ownerScope, - ); - return record; - }), - ); - // Waiting is intentionally independent from the owner. Interrupting any caller only - // stops its await; the Supervisor-owned preparation continues until completion or store - // scope closure so a later caller can join the same result. - return yield* Deferred.await(joined.result); }); return { prepare }; }); diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 12a4680288..7bd8ae98aa 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, FileSystem, Path, Scope } from "effect"; +import { Crypto, Effect, FileSystem, Path } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { PlannedWorkload } from "../model/ExecutionPlan.ts"; import { @@ -6,6 +6,7 @@ import { type NativeWorkloadArtifact, } from "../model/WorkloadCatalog.ts"; import type { StackRuntime } from "../public/Runtime.ts"; +import type { ArtifactPreparationStatus } from "../public/Status.ts"; import { ContainerEngineError, ContainerPullError, @@ -36,10 +37,16 @@ export interface PreparedWorkloadArtifact { readonly image?: string; } +export type RuntimeArtifactPreparationProgress = ArtifactPreparationStatus; +export type RuntimeArtifactPreparationProgressListener = ( + progress: RuntimeArtifactPreparationProgress, +) => void; + export interface RuntimeArtifactPreparer { readonly prepare: ( runtime: StackRuntime, workload: PlannedWorkload, + onProgress?: RuntimeArtifactPreparationProgressListener, ) => Effect.Effect; } @@ -76,96 +83,125 @@ const containerVersion = (image: string): string => { */ export const makeRuntimeArtifactPreparer = ( options: RuntimeArtifactPreparerOptions, -): RuntimeArtifactPreparer => ({ - prepare: (runtime, workload) => { - if (runtime.kind === "native") { - const native = options.native; - if (native === undefined) +): RuntimeArtifactPreparer => { + const prepare = ( + runtime: StackRuntime, + workload: PlannedWorkload, + onProgress?: RuntimeArtifactPreparationProgressListener, + ): Effect.Effect => + Effect.suspend(() => { + const report = (state: ArtifactPreparationStatus["state"], cause?: unknown): void => { + onProgress?.({ + workloadId: workload.id, + capability: workload.capability, + state, + ...(cause === undefined + ? {} + : { + error: + cause instanceof Error + ? cause.message + : typeof cause === "string" + ? cause + : (JSON.stringify(cause) ?? "Unknown preparation failure"), + }), + }); + }; + report("preparing"); + if (runtime.kind === "native") { + const native = options.native; + if (native === undefined) + return Effect.fail( + error("Native runtime has no configured artifact store", { workload: workload.id }), + ); + if (workload.selected.kind !== "native") + return Effect.fail( + error("Native runtime received a container workload artifact", { + workload: workload.id, + }), + ); + return Effect.gen(function* () { + const artifact = yield* resolveNativeArtifactForWorkload(workload, native.platform); + native.onArtifactResolved?.(artifact); + const request = { + key: artifactKey(artifact), + requiredRuntimePaths: artifact.requiredRuntimePaths, + executablePath: artifact.executablePath, + }; + const prepared = yield* native.store.prepare(request, (state) => report(state)); + report("ready"); + return nativeResult(workload, artifact, prepared); + }).pipe(Effect.tapError((cause) => Effect.sync(() => report("failed", cause)))); + } + if (workload.selected.kind !== "container") return Effect.fail( - error("Native runtime has no configured artifact store", { workload: workload.id }), + error("Container runtime received a native workload artifact", { + workload: workload.id, + }), ); - if (workload.selected.kind !== "native") + const engine = options.containerEngine; + if (engine === undefined) return Effect.fail( - error("Native runtime received a container workload artifact", { + error("Container runtime has no configured container engine", { workload: workload.id, + engine: runtime.engine, }), ); - return Effect.gen(function* () { - const artifact = yield* resolveNativeArtifactForWorkload(workload, native.platform); - native.onArtifactResolved?.(artifact); - const request = { - key: artifactKey(artifact), - requiredRuntimePaths: artifact.requiredRuntimePaths, - executablePath: artifact.executablePath, - }; - const prepared = yield* native.store.prepare(request); - return nativeResult(workload, artifact, prepared); - }); - } - if (workload.selected.kind !== "container") - return Effect.fail( - error("Container runtime received a native workload artifact", { - workload: workload.id, - }), - ); - const engine = options.containerEngine; - if (engine === undefined) - return Effect.fail( - error("Container runtime has no configured container engine", { - workload: workload.id, - engine: runtime.engine, - }), - ); - if (engine.kind !== runtime.engine) - return Effect.fail( - error("Configured container engine does not match runtime", { - workload: workload.id, - engine: runtime.engine, - configuredEngine: engine.kind, - }), - ); - const image = workload.selected.image; - return engine.probe.pipe( - Effect.andThen(engine.inspectImage(image)), - Effect.flatMap((inspection) => - inspection.present - ? Effect.succeed({ - workloadId: workload.id, - capability: workload.capability, - version: containerVersion(image), - outcome: "cached", - image, - }) - : engine.pullImage(image).pipe( - Effect.mapError( - (cause) => - new ContainerPullError({ - message: `Unable to pull container image ${image}`, - workload: workload.id, - cause, - }), - ), - Effect.as({ + if (engine.kind !== runtime.engine) + return Effect.fail( + error("Configured container engine does not match runtime", { + workload: workload.id, + engine: runtime.engine, + configuredEngine: engine.kind, + }), + ); + const image = workload.selected.image; + return engine.probe.pipe( + Effect.andThen(engine.inspectImage(image)), + Effect.flatMap((inspection) => + inspection.present + ? Effect.succeed({ workloadId: workload.id, capability: workload.capability, version: containerVersion(image), - outcome: "pulled" as const, + outcome: "cached", image, + }) + : Effect.sync(() => report("downloading")).pipe( + Effect.andThen(engine.pullImage(image)), + Effect.mapError( + (cause) => + new ContainerPullError({ + message: `Unable to pull container image ${image}`, + workload: workload.id, + cause, + }), + ), + Effect.as({ + workloadId: workload.id, + capability: workload.capability, + version: containerVersion(image), + outcome: "pulled" as const, + image, + }), + ), + ), + Effect.tap(() => Effect.sync(() => report("ready"))), + Effect.mapError((cause) => + cause instanceof ContainerPullError + ? cause + : new ContainerEngineError({ + message: + cause instanceof Error ? cause.message : "Container engine operation failed", + engine: runtime.engine, + cause, }), - ), - ), - Effect.mapError((cause) => - cause instanceof ContainerPullError - ? cause - : new ContainerEngineError({ - message: cause instanceof Error ? cause.message : "Container engine operation failed", - engine: runtime.engine, - cause, - }), - ), - ); - }, -}); + ), + Effect.tapError((cause) => Effect.sync(() => report("failed", cause))), + ); + }); + return { prepare }; +}; const nativeResult = ( workload: PlannedWorkload, @@ -197,11 +233,7 @@ export const makeProductionRuntimeArtifactPreparer = (options: { }): Effect.Effect< RuntimeArtifactPreparer, RuntimeArtifactPreparationError, - | Path.Path - | FileSystem.FileSystem - | Crypto.Crypto - | Scope.Scope - | ChildProcessSpawner.ChildProcessSpawner + Path.Path | FileSystem.FileSystem | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner > => Effect.gen(function* () { if (options.stateRoot.trim().length === 0) diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index ecc8efc0a2..077c6094ce 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -318,7 +318,7 @@ export const makeSlimServicesSource = ( resolveArtifact(request).pipe( Effect.flatMap((artifact) => slimServicesChecksum(artifact, fetchRequest)), ), - materialize: (request, destination, expectedSha256) => + materialize: (request, destination, expectedSha256, onProgress) => Effect.gen(function* () { const artifact = yield* resolveArtifact(request); const fs = yield* FileSystem.FileSystem; @@ -369,7 +369,9 @@ export const makeSlimServicesSource = ( service: artifact.service, version: artifact.version, }); - const compressed = yield* fetchBytes(artifact.downloadUrl, fetchRequest); + const compressed = yield* Effect.sync(() => onProgress?.("downloading")).pipe( + Effect.andThen(fetchBytes(artifact.downloadUrl, fetchRequest)), + ); const crypto = yield* Crypto.Crypto; yield* verifySha256(compressed, expectedSha256).pipe( Effect.provideService(Crypto.Crypto, crypto), @@ -383,6 +385,7 @@ export const makeSlimServicesSource = ( }), ), ); + yield* Effect.sync(() => onProgress?.("preparing")); const archive = yield* decompressor.decompress(compressed); const archivePath = path.join(destination, ".slim-services.tar"); yield* fs.writeFile(archivePath, archive).pipe( diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index 0fb3e062f7..2eecf1a9d7 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -570,7 +570,7 @@ describe("verified native artifact preparation", () => { ), ); - it.live("cancels the owner when the store scope closes", () => + it.live("cancels caller-owned preparation when the caller is interrupted", () => withPlatform( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -595,13 +595,10 @@ describe("verified native artifact preparation", () => { ), ), }; - yield* Effect.scoped( - Effect.gen(function* () { - const store = yield* makeArtifactStore({ cacheRoot: root, source }); - yield* Effect.forkChild(store.prepare(request)); - yield* Deferred.await(started); - }), - ); + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const preparation = yield* Effect.forkChild(store.prepare(request)); + yield* Deferred.await(started); + yield* Fiber.interrupt(preparation); const entries = yield* fs.readDirectory(root, { recursive: true }); expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); expect(yield* fs.exists(`${root}/database/postgres`)).toBe(false); @@ -609,97 +606,6 @@ describe("verified native artifact preparation", () => { ), ); - it.live("continues a sole preparation after its waiter is interrupted", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-stack-artifact-waiters-", - }); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let calls = 0; - const source: ArtifactSource = { - checksum: () => Effect.succeed(archiveSha256), - materialize: (entry, destination) => - Effect.gen(function* () { - calls += 1; - yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); - yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); - yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); - yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - return archive; - }).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: `materialization failed: ${cause.message}`, - cause, - }), - ), - ), - }; - const store = yield* makeArtifactStore({ cacheRoot: root, source }); - const first = yield* Effect.forkChild(store.prepare(request)); - yield* Deferred.await(started); - yield* Fiber.interrupt(first); - const second = yield* Effect.forkChild(store.prepare(request)); - yield* Deferred.succeed(release, undefined); - const result = yield* Fiber.join(second); - expect(result.outcome).toBe("downloaded"); - expect(calls).toBe(1); - const cached = yield* store.prepare(request); - expect(cached.outcome).toBe("cached"); - }), - ), - ); - - it.live("shares one in-flight preparation within a store instance", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-stack-artifact-flight-", - }); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - let calls = 0; - const source: ArtifactSource = { - checksum: () => Effect.succeed(archiveSha256), - materialize: (entry, destination) => - Effect.gen(function* () { - calls += 1; - yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); - yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); - yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); - yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(release); - return archive; - }).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: `materialization failed: ${cause.message}`, - cause, - }), - ), - ), - }; - const store = yield* makeArtifactStore({ cacheRoot: root, source }); - const first = yield* Effect.forkChild(store.prepare(request)); - const second = yield* Effect.forkChild(store.prepare(request)); - yield* Deferred.await(started); - yield* Deferred.succeed(release, undefined); - const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); - expect(calls).toBe(1); - expect(results[0].path).toBe(results[1].path); - }), - ), - ); - it.live("allows separate stores to duplicate work and converge safely", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/public/Config.ts b/packages/stack/src/public/Config.ts index ecae5466ee..b7ba5f0ac9 100644 --- a/packages/stack/src/public/Config.ts +++ b/packages/stack/src/public/Config.ts @@ -20,6 +20,9 @@ import { PoolerSettingsSchema, type PoolerSettings } from "../model/capabilities import { ActivationModeSchema, type ActivationMode } from "./Capability.ts"; import { NetworkPortSchema, PORT_FIELDS, type PortField } from "./Status.ts"; +export const PreparationModeSchema = Schema.Literals(["background", "on-demand"] as const); +type PreparationMode = Schema.Schema.Type; + export const ListenerConfigSchema = Schema.Union([ Schema.Struct({ enabled: Schema.Literal(false) }), Schema.Struct({ @@ -85,6 +88,7 @@ export const StackSecurityConfigSchema = Schema.Struct({ }); export const StackConfigSchema = Schema.Struct({ + preparation: Schema.optionalKey(PreparationModeSchema), capabilities: Schema.optionalKey(StackCapabilitiesConfigSchema), listeners: Schema.optionalKey(StackListenersConfigSchema), security: Schema.optionalKey(StackSecurityConfigSchema), @@ -118,5 +122,6 @@ export type { AnalyticsSettings, PoolerSettings, ActivationMode, + PreparationMode, }; export { NetworkPortSchema } from "./Status.ts"; diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 13202a6344..51eead026f 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -33,7 +33,12 @@ import { resolveStackPaths } from "../state/Paths.ts"; import { StackIdSchema, type StackId } from "./StackId.ts"; import type { StackRuntime, StackRuntimePreference } from "./Runtime.ts"; import type { StackConfig } from "./Config.ts"; -import { type StackStatus, type StackDescriptor, type StackInspection } from "./Status.ts"; +import { + type ArtifactPreparationStatus, + type StackStatus, + type StackDescriptor, + type StackInspection, +} from "./Status.ts"; import { CAPABILITY_NAMES, type CapabilityName } from "./Capability.ts"; import type { LogQuery, StackLogBatch, StackLogEntry } from "./Logs.ts"; import type { EffectStackCredentials } from "./Credentials.ts"; @@ -120,6 +125,8 @@ export interface StartStackOptions { export interface PrepareStackOptions { readonly config?: StackConfig; readonly capabilities?: ReadonlyArray; + /** Synchronous progress observer for this caller-owned preparation. */ + readonly onProgress?: (status: ArtifactPreparationStatus) => void; } export interface CreateStackOptions { readonly projectRoot: string; @@ -838,6 +845,12 @@ const handleDependencies = (options: { } } const workloads = plan.workloads.filter((workload) => selected.has(workload.capability)); + for (const workload of workloads) + prepareOptions?.onProgress?.({ + workloadId: workload.id, + capability: workload.capability, + state: "queued", + }); const preparer = yield* makeProductionRuntimeArtifactPreparer({ stateRoot: options.environment.stateRoot, runtime: state.runtime, @@ -855,7 +868,7 @@ const handleDependencies = (options: { ); const artifacts = yield* Effect.forEach( workloads, - (workload) => preparer.prepare(state.runtime, workload), + (workload) => preparer.prepare(state.runtime, workload, prepareOptions?.onProgress), { concurrency: 4 }, ); const byCapability = new Map>(); diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 1c395879c7..069302b491 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -135,6 +135,7 @@ export const adaptEffectStack = (effectStack: EffectStack): PromiseStack => { ...(options.capabilities === undefined ? {} : { capabilities: options.capabilities }), + ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }), ...(config === undefined ? {} : { config }), }, ), diff --git a/packages/stack/src/public/Status.ts b/packages/stack/src/public/Status.ts index 15d5e70e4d..b20f257f5d 100644 --- a/packages/stack/src/public/Status.ts +++ b/packages/stack/src/public/Status.ts @@ -3,6 +3,7 @@ import { StackIdSchema, type StackId } from "./StackId.ts"; import { ActivationModeSchema, CAPABILITY_NAMES, + CapabilityNameSchema, CapabilityStatusSchema, type ActivationMode, type CapabilityName, @@ -90,6 +91,24 @@ export const CapabilityVersionsSchema = Schema.Struct({ pooler: Schema.optionalKey(Schema.String), }); +/** Observable state of preparing one workload artifact for a running stack. */ +export const ArtifactPreparationStateSchema = Schema.Literals([ + "queued", + "preparing", + "downloading", + "ready", + "failed", +] as const); +export type ArtifactPreparationState = Schema.Schema.Type; + +export const ArtifactPreparationStatusSchema = Schema.Struct({ + workloadId: Schema.String, + capability: CapabilityNameSchema, + state: ArtifactPreparationStateSchema, + error: Schema.optionalKey(Schema.String), +}); +export type ArtifactPreparationStatus = Schema.Schema.Type; + const CompleteCapabilityStatusesSchema = Schema.Array(CapabilityStatusSchema).pipe( Schema.decode({ decode: SchemaGetter.checkEffect((capabilities) => @@ -115,6 +134,7 @@ export const StackStatusSchema = Schema.Struct({ endpoints: StackEndpointsSchema, versions: CapabilityVersionsSchema, capabilities: CompleteCapabilityStatusesSchema, + artifacts: Schema.Array(ArtifactPreparationStatusSchema), }); export interface StackStatus { @@ -125,6 +145,7 @@ export interface StackStatus { readonly endpoints: Readonly>>; readonly versions: Readonly>>; readonly capabilities: ReadonlyArray; + readonly artifacts: ReadonlyArray; } export const StackDescriptorSchema = Schema.Struct({ diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 9c38c70795..3e61ed5dfe 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -70,7 +70,7 @@ import { } from "./EffectStack.ts"; import { CAPABILITY_NAMES } from "./Capability.ts"; import { StackIdSchema, type StackId } from "./StackId.ts"; -import type { StackStatus } from "./Status.ts"; +import type { ArtifactPreparationStatus, StackStatus } from "./Status.ts"; import { makeDockerEngine } from "../runtime/DockerEngine.ts"; import { type ContainerCommandResult, @@ -98,6 +98,7 @@ const runningStatus: StackStatus = { activation: name === "functions" ? "lazy" : "eager", state: name === "pooler" ? "disabled" : "ready", })), + artifacts: [], }; const credentials = { @@ -1029,15 +1030,29 @@ describe("Effect stack lifecycle handoff", () => { ...state, definition: persisted.definition, }); + const before = yield* stack.status(); + const progress: Array = []; const prepared = yield* stack.prepare({ config: { capabilities: { rest: { settings: { schemas: ["private"] } } } }, capabilities: ["rest"], + onProgress: (status) => progress.push(status), }); expect(prepared.capabilities).toEqual([ { capability: "database", version: defaultDatabaseVersion, outcome: "cached" }, { capability: "rest", version: defaultRestVersion, outcome: "cached" }, ]); expect(calls.filter((call) => call.startsWith("image ls"))).toHaveLength(2); + expect(progress).toEqual( + expect.arrayContaining([ + { workloadId: "database:database", capability: "database", state: "queued" }, + { workloadId: "rest:rest", capability: "rest", state: "queued" }, + { workloadId: "database:database", capability: "database", state: "preparing" }, + { workloadId: "rest:rest", capability: "rest", state: "preparing" }, + { workloadId: "database:database", capability: "database", state: "ready" }, + { workloadId: "rest:rest", capability: "rest", state: "ready" }, + ]), + ); + expect(yield* stack.status()).toEqual(before); }), ), ); @@ -1196,9 +1211,9 @@ describe("Effect stack lifecycle handoff", () => { }, } satisfies Crypto.Crypto; const firstPublished = yield* Deferred.make(); - const secondStarted = yield* Deferred.make(); - const secondCancelled = yield* Deferred.make(); - const secondRelease = yield* Deferred.make(); + const secondDownloadStarted = yield* Deferred.make(); + const secondDownloadCancelled = yield* Deferred.make(); + const secondDownloadRelease = yield* Deferred.make(); const present = new Set(); const runner: ContainerCommandRunner = { run: (request) => { @@ -1210,18 +1225,25 @@ describe("Effect stack lifecycle handoff", () => { exitCode: 0, }); if (command === "image" && subcommand === "ls" && image !== undefined) { - if (image.includes("postgrest")) - return Deferred.succeed(secondStarted, undefined).pipe( - Effect.andThen(Deferred.await(secondRelease)), - Effect.onInterrupt(() => Deferred.succeed(secondCancelled, undefined)), - Effect.as({ stdout: "", stderr: "", exitCode: 0 }), - ); return Effect.succeed({ stdout: present.has(image) ? '"cached"\n' : "", stderr: "", exitCode: 0, }); } + if ( + command === "image" && + subcommand === "pull" && + image !== undefined && + image.includes("postgrest") + ) + return Deferred.succeed(secondDownloadStarted, undefined).pipe( + Effect.andThen(Deferred.await(secondDownloadRelease)), + Effect.onInterrupt(() => + Deferred.succeed(secondDownloadCancelled, undefined).pipe(Effect.asVoid), + ), + Effect.as({ stdout: "", stderr: "", exitCode: 0 }), + ); if (command === "image" && subcommand === "pull" && image !== undefined) return Effect.yieldNow.pipe( Effect.andThen( @@ -1258,20 +1280,43 @@ describe("Effect stack lifecycle handoff", () => { const stateStore = yield* makeStackStateStore({ stateRoot: env.stateRoot }); const paths = yield* resolveStackPaths({ stateRoot: env.stateRoot, stackId: stack.id }); const before = yield* fs.readFileString(paths.stateDocument); + const progress: Array = []; const preparation = yield* Effect.forkChild( - stack.prepare({ capabilities: ["database", "rest"] }), + stack.prepare({ + capabilities: ["database", "rest"], + onProgress: (status) => progress.push(status), + }), { startImmediately: true }, ); yield* Deferred.await(firstPublished).pipe(Effect.timeout("5 seconds"), Effect.orDie); - yield* Deferred.await(secondStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); + yield* Deferred.await(secondDownloadStarted).pipe( + Effect.timeout("5 seconds"), + Effect.orDie, + ); + expect( + progress.some( + ({ workloadId, state }) => workloadId === "rest:rest" && state === "downloading", + ), + ).toBe(true); yield* Fiber.interrupt(preparation); - yield* Deferred.await(secondCancelled).pipe(Effect.timeout("5 seconds"), Effect.orDie); + yield* Deferred.await(secondDownloadCancelled).pipe( + Effect.timeout("5 seconds"), + Effect.orDie, + ); const canceled = yield* Fiber.join(preparation).pipe(Effect.exit); expect(Exit.isFailure(canceled)).toBe(true); - const cached = yield* stack.prepare({ capabilities: ["database"] }); + expect( + progress.some(({ workloadId, state }) => workloadId === "rest:rest" && state === "ready"), + ).toBe(false); + const retryProgress: Array = []; + const cached = yield* stack.prepare({ + capabilities: ["database"], + onProgress: (status) => retryProgress.push(status), + }); expect(cached.capabilities).toEqual([ { capability: "database", version: defaultDatabaseVersion, outcome: "cached" }, ]); + expect(retryProgress.some(({ state }) => state === "downloading")).toBe(false); expect(yield* fs.readFileString(paths.stateDocument)).toBe(before); expect(yield* readOwnerMetadata(env.stateRoot, stack.id, env)).toBeUndefined(); expect(yield* ownerLockExists(env.stateRoot, stack.id)).toBe(false); diff --git a/packages/stack/src/public/promise.integration.test.ts b/packages/stack/src/public/promise.integration.test.ts index 752b2ac1a2..1235bbfb24 100644 --- a/packages/stack/src/public/promise.integration.test.ts +++ b/packages/stack/src/public/promise.integration.test.ts @@ -12,7 +12,7 @@ import { Effect, Redacted, Stream } from "effect"; import type { EffectStack, PrepareStackOptions, StartStackOptions } from "./EffectStack.ts"; import type { StackLogEntry } from "./Logs.ts"; import { StackIdSchema } from "./StackId.ts"; -import type { StackStatus } from "./Status.ts"; +import type { ArtifactPreparationStatus, StackStatus } from "./Status.ts"; import { InvalidStackConfigError, StackVersionUnsupportedError } from "./Errors.ts"; import { adaptEffectStack, makePromiseApi, type PromiseStack } from "./PromiseStack.ts"; import { compileStack } from "../model/Compiler.ts"; @@ -42,6 +42,7 @@ const status: StackStatus = { }, versions: {}, capabilities, + artifacts: [], }; const effectStack = (): EffectStack => @@ -179,6 +180,31 @@ describe("Promise stack facade", () => { expect(finalized).toBe(true); }); + it("forwards prepare progress through the Promise facade", async () => { + const progress: Array = []; + const stack = adaptEffectStack({ + ...effectStack(), + prepare: (options?: PrepareStackOptions) => + Effect.sync(() => { + options?.onProgress?.({ + workloadId: "rest:rest", + capability: "rest", + state: "downloading", + }); + return { capabilities: [] }; + }), + }); + + await expect(stack.prepare({ onProgress: (status) => progress.push(status) })).resolves.toEqual( + { + capabilities: [], + }, + ); + expect(progress).toEqual([ + { workloadId: "rest:rest", capability: "rest", state: "downloading" }, + ]); + }); + it("completes an empty follower immediately", async () => { const stack = adaptEffectStack(effectStack()); const logs = await stack.logs(); diff --git a/packages/stack/src/public/public-model.integration.test.ts b/packages/stack/src/public/public-model.integration.test.ts index 2443269666..3f4d675457 100644 --- a/packages/stack/src/public/public-model.integration.test.ts +++ b/packages/stack/src/public/public-model.integration.test.ts @@ -22,8 +22,15 @@ const STATUS_FIXTURE = { capabilities: CAPABILITY_NAMES.map((name) => ({ name, activation: "lazy", - state: "disabled", + state: name === "rest" ? "dormant" : "disabled", })), + artifacts: [ + { + workloadId: "rest:rest", + capability: "rest", + state: "downloading", + }, + ], }; describe("public stack model", () => { @@ -32,6 +39,10 @@ describe("public stack model", () => { Effect.map((status) => { expect(status.capabilities).toHaveLength(10); expect(new Set(status.capabilities.map(({ name }) => name)).size).toBe(10); + expect(status.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); + expect(status.artifacts).toEqual([ + { workloadId: "rest:rest", capability: "rest", state: "downloading" }, + ]); }), ), ); diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index 9e5087cf2c..609368c9fa 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -51,6 +51,7 @@ const status = ( : "stopped", ...(failedCapability === name ? { state: "failed", error: `${name} failed` } : {}), })), + artifacts: [], }); const stream = (values: ReadonlyArray): AsyncIterable => ({ diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index 1a67311d07..e4cb449fee 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -275,6 +275,13 @@ const capabilityState = (status: StackStatus, name: string): string | undefined const expectDefaultLazyState = (status: StackStatus): void => { expect(status.lifecycle).toBe("running"); expect(capabilityState(status, "database")).toBe("ready"); + expect(status.artifacts).toContainEqual( + expect.objectContaining({ + workloadId: "database:database", + capability: "database", + state: "ready", + }), + ); for (const name of CAPABILITY_NAMES) { if (name === "database") continue; expect(capabilityState(status, name), `${name} should remain dormant`).toBe("dormant"); @@ -944,6 +951,11 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom expect(ready.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( CAPABILITY_NAMES.map((name) => ({ name, state: "ready" })), ); + for (const workloadId of ["studio:pgmeta", "studio:studio"] as const) { + expect(ready.artifacts).toContainEqual( + expect.objectContaining({ workloadId, capability: "studio", state: "ready" }), + ); + } await expectOwnedWorkloads(mode, stack.id, BASE_WORKLOAD_IDS); const idempotentStart = await stack.start(); expect(idempotentStart.capabilities.map(({ name, state }) => ({ name, state }))).toEqual( @@ -1028,6 +1040,7 @@ const runWholeStackScenario = async (mode: (typeof RUNTIME_CASES)[number]): Prom const stopped = await stack.status(); expect(stopped.lifecycle).toBe("stopped"); expect(stopped.capabilities.every(({ state }) => state === "stopped")).toBe(true); + expect(stopped.artifacts).toEqual([]); await expectOwnedWorkloads(mode, stack.id, []); const retainedLogs: StackLogEntry[] = (await stack.logs()).entries.slice(); expect(retainedLogs.length).toBeGreaterThan(0); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index a9780e3aa8..68268f020f 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -7,6 +7,7 @@ import { Effect, Exit, FileSystem, + Fiber, Path, Ref, Result, @@ -16,7 +17,7 @@ import { } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { eagerCapabilities, type PlannedWorkload } from "../model/ExecutionPlan.ts"; -import type { StackDefinition } from "../model/Compiler.ts"; +import { rebuildExecutionPlan, type StackDefinition } from "../model/Compiler.ts"; import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, @@ -28,12 +29,12 @@ import type { PersistedStackState } from "../state/StackState.ts"; import type { PersistedSecretValues } from "../state/StackState.ts"; import type { StackId } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; +import type { ArtifactPreparationStatus } from "../public/Status.ts"; +import type { CapabilityName } from "../public/Capability.ts"; import { GatewayActivationError, StackPreparationError, - ArtifactIntegrityError, ContainerEngineError, - ContainerPullError, PortUnavailableError, StackRuntimeMismatchError, StackStateInvalidError, @@ -51,6 +52,8 @@ import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; import type { SupervisorRuntime } from "../supervisor/Supervisor.ts"; import { makeProductionRuntimeArtifactPreparer, + type RuntimeArtifactPreparationProgress, + type RuntimeArtifactPreparationProgressListener, type PreparedWorkloadArtifact, type RuntimeArtifactPreparer, } from "../preparation/RuntimeArtifacts.ts"; @@ -441,22 +444,40 @@ export const withOwnedRuntimeFileCleanup = ( envFiles: RuntimeEnvFileOwner, functionsBootstrap: FunctionsBootstrapOwner, inputOwner?: RuntimeInputOwner, + preparationCleanup?: Effect.Effect, ): RuntimeDriver => { const cleanupFiles = (stackId: StackId): Effect.Effect => Effect.gen(function* () { const key = { stackId, workloadId: "" }; let cleanupCause: Cause.Cause = Cause.empty; - const attempts = [ - envFiles.cleanupAll, - functionsBootstrap.cleanupAll, - ...(inputOwner === undefined ? [] : [inputOwner.cleanupAll]), + const attempts: ReadonlyArray> = [ + ...(preparationCleanup === undefined + ? [] + : [ + preparationCleanup.pipe( + Effect.mapError((error) => + driverError(key, "Unable to clean runtime preparation", error), + ), + ), + ]), + envFiles.cleanupAll.pipe( + Effect.mapError((error) => driverError(key, "Unable to clean runtime files", error)), + ), + functionsBootstrap.cleanupAll.pipe( + Effect.mapError((error) => driverError(key, "Unable to clean runtime files", error)), + ), + ...(inputOwner === undefined + ? [] + : [ + inputOwner.cleanupAll.pipe( + Effect.mapError((error) => + driverError(key, "Unable to clean runtime files", error), + ), + ), + ]), ]; for (const attempt of attempts) { - const result = yield* Effect.exit( - attempt.pipe( - Effect.mapError((error) => driverError(key, "Unable to clean runtime files", error)), - ), - ); + const result = yield* Effect.exit(attempt); if (Exit.isFailure(result)) cleanupCause = Cause.combine(cleanupCause, result.cause); } if (cleanupCause.reasons.length > 0) return yield* Effect.failCause(cleanupCause); @@ -578,6 +599,38 @@ export const makeProductionRuntime = ( options.bootstrapDatabase ?? ((state: PersistedStackState) => bootstrapDatabaseAt(state)); const artifacts = new Map(); + const preparationStatuses = new Map(); + const recordPreparationProgress = (progress: RuntimeArtifactPreparationProgress): void => { + preparationStatuses.set(progress.workloadId, { + workloadId: progress.workloadId, + capability: progress.capability, + state: progress.state, + ...(progress.error === undefined ? {} : { error: progress.error }), + }); + }; + const queuePreparation = (workload: PlannedWorkload): void => { + if (artifacts.has(artifactKey(state.runtime, workload))) return; + const current = preparationStatuses.get(workload.id); + if (current?.state === "preparing" || current?.state === "downloading") return; + recordPreparationProgress({ + workloadId: workload.id, + capability: workload.capability, + state: "queued", + }); + }; + const preparationGate = yield* Semaphore.make(1); + const parentScope = yield* Scope.Scope; + let preparationScope: Scope.Scope | undefined; + const preparationInFlight = new Map< + string, + Fiber.Fiber + >(); + yield* Scope.addFinalizer( + parentScope, + Effect.suspend(() => + preparationScope === undefined ? Effect.void : Scope.close(preparationScope, Exit.void), + ), + ); // Runtime input materialization writes shared files and populates completed caches. Keep // that short preparation boundary serialized while allowing the actual workloads to start // concurrently after their inputs are ready. @@ -591,26 +644,147 @@ export const makeProductionRuntime = ( : Effect.fail(driverError(key, "Persisted runtime changed while owner was active")), ), ); - const prepare = (runtime: StackRuntime, workload: PlannedWorkload) => { - const key = artifactKey(runtime, workload); - const cached = artifacts.get(key); - return cached === undefined - ? preparer.prepare(runtime, workload).pipe( - Effect.mapError((error) => - error instanceof ArtifactIntegrityError || - error instanceof ContainerPullError || - error instanceof ContainerEngineError - ? error - : preparationError(`Unable to prepare ${workload.id}`, error), - ), - Effect.tap((prepared) => Effect.sync(() => artifacts.set(key, prepared))), - ) - : Effect.succeed(cached); + const prepareOne = ( + runtime: StackRuntime, + workload: PlannedWorkload, + onProgress?: RuntimeArtifactPreparationProgressListener, + ) => { + return Effect.suspend(() => { + const key = artifactKey(runtime, workload); + const cached = artifacts.get(key); + return cached === undefined + ? Effect.sync(() => + onProgress?.({ + workloadId: workload.id, + capability: workload.capability, + state: "preparing", + }), + ).pipe( + Effect.andThen(preparer.prepare(runtime, workload, onProgress)), + Effect.tap((prepared) => + Effect.sync(() => { + artifacts.set(key, prepared); + onProgress?.({ + workloadId: workload.id, + capability: workload.capability, + state: "ready", + }); + }), + ), + Effect.tapError((error) => + Effect.sync(() => + onProgress?.({ + workloadId: workload.id, + capability: workload.capability, + state: "failed", + error: error.message, + }), + ), + ), + ) + : Effect.succeed(cached); + }); }; - const prepareArtifacts = (runtime: StackRuntime, workloads: ReadonlyArray) => - Effect.forEach(workloads, (workload) => prepare(runtime, workload), { + const prepare = ( + runtime: StackRuntime, + workload: PlannedWorkload, + onProgress?: RuntimeArtifactPreparationProgressListener, + ) => + Effect.gen(function* () { + const key = artifactKey(runtime, workload); + const joined = yield* Effect.uninterruptible( + preparationGate.withPermit( + Effect.gen(function* () { + const existing = preparationInFlight.get(key); + if (existing !== undefined) return existing; + const scope = preparationScope ?? (preparationScope = yield* Scope.make("parallel")); + let fiber: Fiber.Fiber | undefined; + const owner = prepareOne(runtime, workload, onProgress).pipe( + Effect.ensuring( + Effect.sync(() => { + if (preparationInFlight.get(key) === fiber) preparationInFlight.delete(key); + }), + ), + ); + fiber = yield* Effect.forkIn(owner, scope, { startImmediately: false }); + preparationInFlight.set(key, fiber); + return fiber; + }), + ), + ); + return yield* Fiber.join(joined); + }); + const prepareArtifacts = ( + runtime: StackRuntime, + workloads: ReadonlyArray, + onProgress?: RuntimeArtifactPreparationProgressListener, + ) => + Effect.forEach(workloads, (workload) => prepare(runtime, workload, onProgress), { concurrency: 4, }); + const prepareFor = ( + input: LifecycleInput, + selected: ReadonlySet, + ): Effect.Effect => + Effect.gen(function* () { + const workloads = input.plan.workloads.filter((workload) => + selected.has(workload.capability), + ); + for (const workload of workloads) queuePreparation(workload); + yield* prepareArtifacts(input.state.runtime, workloads, recordPreparationProgress); + }); + const logPreparationFailure = (message: string): Effect.Effect => + logs.append({ source: "supervisor", stream: "internal", message }).pipe(Effect.ignore); + const prefetch = (persisted: PersistedStackState): Effect.Effect => + Effect.gen(function* () { + if (persisted.definition === undefined || persisted.definition.preparation === "on-demand") + return; + const plan = yield* rebuildExecutionPlan(persisted.runtime, persisted.definition).pipe( + Effect.mapError((error) => + preparationError("Unable to plan background preparation", error), + ), + ); + const workloads = plan.workloads.filter( + (workload) => + persisted.definition?.capabilities[workload.capability].activation === "lazy" && + !artifacts.has(artifactKey(persisted.runtime, workload)), + ); + for (const workload of workloads) queuePreparation(workload); + yield* Effect.forEach( + workloads, + (workload) => + prepare(persisted.runtime, workload, recordPreparationProgress).pipe( + Effect.catch((error) => + logPreparationFailure( + `Background preparation failed for ${workload.id}: ${error.message}`, + ), + ), + ), + { concurrency: 2, discard: true }, + ); + }).pipe( + Effect.catch((error) => + logPreparationFailure( + `Background preparation failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ), + ); + const cleanupPreparation: Effect.Effect = Effect.gen(function* () { + const scope = yield* preparationGate.withPermit( + Effect.sync(() => { + const current = preparationScope; + preparationScope = undefined; + preparationInFlight.clear(); + return current; + }), + ); + if (scope !== undefined) yield* Scope.close(scope, Exit.interrupt()); + yield* Effect.sync(() => { + preparationInFlight.clear(); + preparationStatuses.clear(); + artifacts.clear(); + }); + }).pipe(Effect.uninterruptible); const functionsPath = (): Effect.Effect => serveTemplate.pipe(Effect.flatMap((content) => functionsBootstrap.write({ content }))); const runtimeInputs = ( @@ -745,14 +919,15 @@ export const makeProductionRuntime = ( ); } const eager = eagerCapabilities(input.plan); - yield* prepareArtifacts( - input.state.runtime, - input.plan.workloads.filter((workload) => eager.has(workload.capability)), + const eagerWorkloads = input.plan.workloads.filter((workload) => + eager.has(workload.capability), ); + for (const workload of eagerWorkloads) queuePreparation(workload); + yield* prepareArtifacts(input.state.runtime, eagerWorkloads, recordPreparationProgress); }); const activate = ( - capability: import("../public/Capability.ts").CapabilityName, + capability: CapabilityName, input: LifecycleInput, ): Effect.Effect< { readonly host: string; readonly port: number }, @@ -1025,10 +1200,14 @@ export const makeProductionRuntime = ( envFiles, functionsBootstrap, inputOwner, + cleanupPreparation, ); return { driver: baseDriver, preflight, + prepare: prepareFor, + prefetch, + artifacts: Effect.sync(() => [...preparationStatuses.values()]), activate, ingress, logStore: logs, diff --git a/packages/stack/src/runtime/container-runtime.integration.test.ts b/packages/stack/src/runtime/container-runtime.integration.test.ts index edf6262b4f..a8f5e6f2fd 100644 --- a/packages/stack/src/runtime/container-runtime.integration.test.ts +++ b/packages/stack/src/runtime/container-runtime.integration.test.ts @@ -2694,6 +2694,9 @@ describe("container runtime", () => { const runtime: SupervisorRuntime = { driver, preflight: () => Effect.void, + prepare: () => Effect.void, + prefetch: () => Effect.void, + artifacts: Effect.succeed([]), activate: () => Effect.succeed({ host: "127.0.0.1", port: 9999 }), ingress, logStore, diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 078aab6f09..785f49a080 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -21,6 +21,7 @@ import { createServer } from "node:http"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer as createNetServer } from "node:net"; import type { StackLogEntry } from "../public/Logs.ts"; +import type { CapabilityName } from "../public/Capability.ts"; import { StackIdSchema } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import { NetworkPortSchema } from "../public/Status.ts"; @@ -48,6 +49,8 @@ import { } from "../public/Errors.ts"; import { DatabaseBootstrapError } from "../model/DatabaseBootstrap.ts"; import type { RuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; +import { makeRuntimeArtifactPreparer } from "../preparation/RuntimeArtifacts.ts"; +import { makeArtifactStore, type ArtifactSource } from "../preparation/ArtifactStore.ts"; import { compileStack } from "../model/Compiler.ts"; import type { ContainerContainerSpec, @@ -56,6 +59,7 @@ import type { ContainerNetworkSpec, ContainerVolumeSpec, } from "./ContainerEngine.ts"; +import { ContainerEngineProtocolError } from "./ContainerEngine.ts"; import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, @@ -689,6 +693,602 @@ describe("production runtime", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("shares background preparation with a concurrent studio activation closure", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-shared-preparation-", + }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + preparation: "background", + capabilities: { + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + mail: { enabled: false }, + pooler: { enabled: false }, + rest: { activation: "eager" }, + analytics: { activation: "eager" }, + }, + }, + }); + const studioWorkloads = compiled.executionPlan.workloads.filter( + (workload) => workload.capability === "studio", + ); + const studioImages = new Set( + studioWorkloads.map((workload) => + workload.selected.kind === "container" ? workload.selected.image : "", + ), + ); + if (studioImages.has("") || studioImages.size === 0) + return yield* Effect.die("Expected container Studio workloads"); + const closure: ReadonlySet = new Set([ + "database", + "rest", + "analytics", + "studio", + ]); + const closureWorkloads = compiled.executionPlan.workloads.filter( + (workload) => + closure.has(workload.capability) && + !studioImages.has( + workload.selected.kind === "container" ? workload.selected.image : "", + ), + ); + if (closureWorkloads.length < 2) + return yield* Effect.die("Expected at least two non-Studio closure workloads"); + const blocked = yield* Deferred.make(); + const release = yield* Deferred.make(); + const releaseClosure = yield* Deferred.make(); + const newClosureWorkStarted = yield* Deferred.make(); + let blockedPullsStarted = 0; + let closurePullsStarted = 0; + const pulls = new Map(); + const createdSpecs: ContainerContainerSpec[] = []; + const baseEngine = ownerInputContainerEngine(createdSpecs); + const engine: ContainerEngine = { + ...baseEngine, + inspectImage: () => Effect.succeed({ present: false }), + pullImage: (image) => + Effect.gen(function* () { + pulls.set(image, (pulls.get(image) ?? 0) + 1); + if (studioImages.has(image)) { + blockedPullsStarted += 1; + if (blockedPullsStarted === studioImages.size) + yield* Deferred.succeed(blocked, undefined); + yield* Deferred.await(release); + } else { + closurePullsStarted += 1; + if (closurePullsStarted === closureWorkloads.length) + yield* Deferred.succeed(newClosureWorkStarted, undefined); + yield* Deferred.await(releaseClosure); + } + }), + }; + const current = { + value: { + ...stateFor({}, { kind: "container", engine: "docker" }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + }, + } satisfies { value: PersistedStackState }; + const context = yield* Effect.context(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "shared-preparation", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: engine, + artifactPreparer: makeRuntimeArtifactPreparer({ containerEngine: engine }), + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + logStore: memoryLogStore([]), + }); + const input: LifecycleInput = { + stackId, + state: current.value, + definition: compiled.definition, + secrets: current.value.secrets, + plan: compiled.executionPlan, + }; + const background = yield* Effect.forkChild(runtime.prefetch(current.value), { + startImmediately: true, + }); + yield* Deferred.await(blocked); + expect(yield* runtime.artifacts).toEqual( + expect.arrayContaining( + studioWorkloads.map((workload) => + expect.objectContaining({ + workloadId: workload.id, + state: "downloading", + }), + ), + ), + ); + const foreground = yield* Effect.forkChild(runtime.prepare(input, closure), { + startImmediately: true, + }); + const interruptedWaiter = yield* Effect.forkChild(runtime.prepare(input, closure), { + startImmediately: true, + }); + yield* Deferred.await(newClosureWorkStarted); + const interrupted = yield* Fiber.interrupt(interruptedWaiter).pipe(Effect.exit); + expect(Exit.isSuccess(interrupted)).toBe(true); + const waiterExit = yield* Fiber.join(interruptedWaiter).pipe(Effect.exit); + expect(Exit.isFailure(waiterExit)).toBe(true); + expect(yield* Deferred.isDone(blocked)).toBe(true); + expect(closurePullsStarted).toBe(closureWorkloads.length); + yield* Deferred.succeed(releaseClosure, undefined); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(background); + yield* Fiber.join(foreground); + for (const workload of compiled.executionPlan.workloads.filter((entry) => + closure.has(entry.capability), + )) { + if (workload.selected.kind === "container") + expect(pulls.get(workload.selected.image)).toBe(1); + } + expect(createdSpecs).toHaveLength(0); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + expect(yield* runtime.artifacts).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live( + "keeps background preparation best effort and retries a failed artifact on activation", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-preparation-retry-", + }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + preparation: "background", + capabilities: { + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const rest = compiled.executionPlan.workloads.find( + (workload) => workload.capability === "rest", + ); + const analytics = compiled.executionPlan.workloads.find( + (workload) => workload.capability === "analytics", + ); + if ( + rest === undefined || + analytics === undefined || + rest.selected.kind !== "container" || + analytics.selected.kind !== "container" + ) + return yield* Effect.die("Expected REST and analytics container workloads"); + const restImage = rest.selected.image; + const pullAttempts = new Map(); + const logs: StackLogEntry[] = []; + const createdSpecs: ContainerContainerSpec[] = []; + const baseEngine = ownerInputContainerEngine(createdSpecs); + const engine: ContainerEngine = { + ...baseEngine, + inspectImage: () => Effect.succeed({ present: false }), + pullImage: (image) => + Effect.gen(function* () { + const attempt = pullAttempts.get(image) ?? 0; + pullAttempts.set(image, attempt + 1); + if (image === restImage && attempt === 0) + return yield* new ContainerEngineProtocolError({ + operation: "pull-image", + message: "temporary image registry failure", + }); + }), + }; + const current = { + value: { + ...stateFor({}, { kind: "container", engine: "docker" }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + }, + } satisfies { value: PersistedStackState }; + const context = yield* Effect.context< + FileSystem.FileSystem | Path.Path | Crypto.Crypto + >(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "preparation-retry", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: engine, + artifactPreparer: makeRuntimeArtifactPreparer({ containerEngine: engine }), + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + logStore: memoryLogStore(logs), + }); + const background = yield* runtime.prefetch(current.value); + expect(background).toBeUndefined(); + expect(pullAttempts.get(restImage)).toBe(1); + expect(pullAttempts.get(analytics.selected.image)).toBe(1); + const statusesAfterBackground = yield* runtime.artifacts; + expect(statusesAfterBackground).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + workloadId: rest.id, + state: "failed", + error: expect.stringContaining("Unable to pull container image"), + }), + expect.objectContaining({ workloadId: analytics.id, state: "ready" }), + ]), + ); + expect( + logs.some((entry) => + entry.message.includes(`Background preparation failed for ${rest.id}`), + ), + ).toBe(true); + const input: LifecycleInput = { + stackId, + state: current.value, + definition: compiled.definition, + secrets: current.value.secrets, + plan: compiled.executionPlan, + }; + yield* runtime.prepare(input, new Set(["rest"])); + expect(pullAttempts.get(restImage)).toBe(2); + expect( + (yield* runtime.artifacts).find(({ workloadId }) => workloadId === rest.id), + ).toEqual(expect.objectContaining({ workloadId: rest.id, state: "ready" })); + expect(createdSpecs).toHaveLength(0); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + expect(yield* runtime.artifacts).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("reports eager artifact progress while preflight is waiting for a pull", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-eager-preparation-", + }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + config: { + preparation: "on-demand", + capabilities: { + auth: { enabled: false }, + realtime: { enabled: false }, + storage: { enabled: false }, + functions: { enabled: false }, + studio: { enabled: false }, + mail: { enabled: false }, + pooler: { enabled: false }, + }, + }, + }); + const database = compiled.executionPlan.workloads.find( + (workload) => workload.capability === "database", + ); + if (database === undefined || database.selected.kind !== "container") + return yield* Effect.die("Expected container database workload"); + const databaseImage = database.selected.image; + const resolved = yield* resolveSecrets( + { declarations: compiled.secrets }, + undefined, + "stopped", + ); + const current = { + value: { + ...stateFor(resolved.persisted, { kind: "container", engine: "docker" }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + definition: compiled.definition, + secrets: resolved.persisted, + }, + } satisfies { value: PersistedStackState }; + const pullStarted = yield* Deferred.make(); + const release = yield* Deferred.make(); + const createdSpecs: ContainerContainerSpec[] = []; + let pullCount = 0; + const baseEngine = ownerInputContainerEngine(createdSpecs); + const engine: ContainerEngine = { + ...baseEngine, + inspectImage: () => Effect.succeed({ present: false }), + pullImage: (image) => + image === databaseImage + ? Effect.gen(function* () { + pullCount += 1; + yield* Deferred.succeed(pullStarted, undefined); + yield* Deferred.await(release); + }) + : Effect.void, + }; + const context = yield* Effect.context(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "eager-preparation", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: engine, + artifactPreparer: makeRuntimeArtifactPreparer({ containerEngine: engine }), + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + logStore: memoryLogStore([]), + }); + const input: LifecycleInput = { + stackId, + state: current.value, + definition: compiled.definition, + secrets: resolved.persisted, + plan: compiled.executionPlan, + }; + const preflight = yield* Effect.forkChild(runtime.preflight(input), { + startImmediately: true, + }); + yield* Deferred.await(pullStarted); + expect(yield* runtime.artifacts).toEqual([ + expect.objectContaining({ workloadId: database.id, state: "downloading" }), + ]); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(preflight); + expect(yield* runtime.artifacts).toEqual([ + expect.objectContaining({ workloadId: database.id, state: "ready" }), + ]); + expect(createdSpecs).toHaveLength(0); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + expect(yield* runtime.artifacts).toEqual([]); + yield* runtime.prefetch(current.value); + expect(pullCount).toBe(1); + expect(yield* runtime.artifacts).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("cancels an in-flight native artifact transfer during runtime cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-native-artifact-stop-", + }); + const transferStarted = yield* Deferred.make(); + const transferInterrupted = yield* Deferred.make(); + const archiveSha256 = "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"; + const checksumFailure = new StackPreparationError({ + message: "published checksum lookup is temporarily unavailable", + }); + let checksumCalls = 0; + const source: ArtifactSource = { + checksum: () => + Effect.sync(() => { + checksumCalls += 1; + return checksumCalls; + }).pipe( + Effect.flatMap((calls) => + calls === 1 ? Effect.fail(checksumFailure) : Effect.succeed(archiveSha256), + ), + ), + materialize: (_request, destination, _expectedSha256, onProgress) => + Effect.gen(function* () { + const sourceFile = path.join(destination, "partial-source"); + onProgress?.("downloading"); + yield* fs.writeFileString(sourceFile, "partial"); + yield* Deferred.succeed(transferStarted, undefined); + return yield* Effect.never; + }).pipe( + Effect.ensuring(Deferred.succeed(transferInterrupted, undefined)), + Effect.mapError( + (cause) => new StackPreparationError({ message: "native transfer failed", cause }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: path.join(root, "artifacts"), source }); + const preparer = makeRuntimeArtifactPreparer({ + native: { + store, + platform: { os: "darwin", arch: "arm64" }, + }, + }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const current = { + value: { + ...stateFor({}, { kind: "native" }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + }, + } satisfies { value: PersistedStackState }; + const workload = compiled.executionPlan.workloads.find( + (entry) => entry.capability === "database", + ); + if (workload === undefined) return yield* Effect.die("Expected database workload"); + const context = yield* Effect.context(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "native-artifact-stop", + stateStore: stateStoreFor(current), + context, + ingress, + envFileOwner: envFiles, + functionsBootstrapOwner: bootstrap, + artifactPreparer: preparer, + logStore: memoryLogStore([]), + }); + const input: LifecycleInput = { + stackId, + state: current.value, + definition: compiled.definition, + secrets: current.value.secrets, + plan: compiled.executionPlan, + }; + const firstAttempt = yield* runtime + .prepare(input, new Set([workload.capability])) + .pipe(Effect.exit); + expect(Exit.isFailure(firstAttempt)).toBe(true); + if (Exit.isFailure(firstAttempt)) { + const error = Cause.findErrorOption(firstAttempt.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(StackPreparationError); + expect(error.value.message).toBe(checksumFailure.message); + } + } + expect(yield* runtime.artifacts).toEqual([ + expect.objectContaining({ + workloadId: workload.id, + state: "failed", + error: checksumFailure.message, + }), + ]); + const preparing = yield* Effect.forkChild( + runtime.prepare(input, new Set([workload.capability])), + { startImmediately: true }, + ); + yield* Deferred.await(transferStarted); + expect(yield* runtime.artifacts).toEqual([ + expect.objectContaining({ workloadId: workload.id, state: "downloading" }), + ]); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + expect(yield* Deferred.isDone(transferInterrupted)).toBe(true); + const result = yield* Fiber.join(preparing).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + const entries = yield* fs.readDirectory(root, { recursive: true }); + expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); + expect(yield* runtime.artifacts).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("cancels an in-flight container image pull during runtime cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-production-container-pull-stop-", + }); + const pullStarted = yield* Deferred.make(); + const pullInterrupted = yield* Deferred.make(); + const createdSpecs: ContainerContainerSpec[] = []; + const baseEngine = ownerInputContainerEngine(createdSpecs); + const engine: ContainerEngine = { + ...baseEngine, + inspectImage: () => Effect.succeed({ present: false }), + pullImage: () => + Effect.gen(function* () { + yield* Deferred.succeed(pullStarted, undefined); + return yield* Effect.never; + }).pipe(Effect.ensuring(Deferred.succeed(pullInterrupted, undefined))), + }; + const preparer = makeRuntimeArtifactPreparer({ containerEngine: engine }); + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "container", engine: "docker" }, + }); + const current = { + value: { + ...stateFor({}, { kind: "container", engine: "docker" }), + identity: { + ...stateFor({}).identity, + projectRoot: root, + checkoutRoot: root, + workspaceId: root, + checkoutId: root, + }, + desiredLifecycle: "running" as const, + definition: compiled.definition, + }, + } satisfies { value: PersistedStackState }; + const workload = compiled.executionPlan.workloads.find( + (entry) => entry.capability === "database", + ); + if (workload === undefined) return yield* Effect.die("Expected database workload"); + const context = yield* Effect.context(); + const runtime = yield* makeProductionRuntime({ + stateRoot: root, + stackId, + ownerSessionId: "container-pull-stop", + stateStore: stateStoreFor(current), + context, + ingress, + containerEngine: engine, + artifactPreparer: preparer, + logStore: memoryLogStore([]), + }); + const input: LifecycleInput = { + stackId, + state: current.value, + definition: compiled.definition, + secrets: current.value.secrets, + plan: compiled.executionPlan, + }; + const preparing = yield* Effect.forkChild( + runtime.prepare(input, new Set([workload.capability])), + { startImmediately: true }, + ); + yield* Deferred.await(pullStarted); + expect(yield* runtime.artifacts).toEqual([ + expect.objectContaining({ workloadId: workload.id, state: "downloading" }), + ]); + yield* runtime.driver.cleanup({ stackId, destroy: false }); + expect(yield* Deferred.isDone(pullInterrupted)).toBe(true); + const result = yield* Fiber.join(preparing).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(createdSpecs).toHaveLength(0); + expect(yield* runtime.artifacts).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("interrupts native input materialization before cleanup", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/state/StackState.ts b/packages/stack/src/state/StackState.ts index a43c1de72a..211c99d041 100644 --- a/packages/stack/src/state/StackState.ts +++ b/packages/stack/src/state/StackState.ts @@ -221,7 +221,8 @@ const validateModuleSettingsByName = ( }; const isDefinitionShape = (input: unknown): input is StackDefinition => { - if (!hasExactKeys(input, ["capabilities", "listeners", "security"])) return false; + if (!hasExactKeys(input, ["preparation", "capabilities", "listeners", "security"])) return false; + if (input.preparation !== "background" && input.preparation !== "on-demand") return false; const capabilities = input.capabilities; if (!hasExactKeys(capabilities, CAPABILITY_NAMES)) return false; for (const name of CAPABILITY_NAMES) { diff --git a/packages/stack/src/state/state-store.integration.test.ts b/packages/stack/src/state/state-store.integration.test.ts index d441239707..b6988bce44 100644 --- a/packages/stack/src/state/state-store.integration.test.ts +++ b/packages/stack/src/state/state-store.integration.test.ts @@ -188,6 +188,7 @@ describe("atomic stack state", () => { projectRoot: "/tmp/project", runtime: { kind: "native" }, config: { + preparation: "on-demand", capabilities: { auth: { settings: { @@ -203,6 +204,7 @@ describe("atomic stack state", () => { }, }); const complete = state(stackId, compiled.definition); + expect(complete.definition?.preparation).toBe("on-demand"); yield* store.initialize(stackId, complete); expect(yield* store.read(stackId)).toEqual(complete); const materialized = { ...complete }; @@ -267,6 +269,14 @@ describe("atomic stack state", () => { const missingDefaultExit = yield* store.read(stackId).pipe(Effect.exit); expect(errorOf(missingDefaultExit)).toBeInstanceOf(StackStateInvalidError); + const invalidPreparation = { + ...encoded, + definition: { ...encoded.definition, preparation: "invalid" }, + }; + yield* fs.writeFileString(statePath, yield* jsonText(invalidPreparation)); + const invalidPreparationExit = yield* store.read(stackId).pipe(Effect.exit); + expect(errorOf(invalidPreparationExit)).toBeInstanceOf(StackStateInvalidError); + const invalidRecordKey = { ...encoded, definition: { diff --git a/packages/stack/src/supervisor/StatusProjection.ts b/packages/stack/src/supervisor/StatusProjection.ts index 30e5ed2d3e..470c5b1b43 100644 --- a/packages/stack/src/supervisor/StatusProjection.ts +++ b/packages/stack/src/supervisor/StatusProjection.ts @@ -6,7 +6,11 @@ import { } from "../public/Capability.ts"; import { StackStateInvalidError } from "../public/Errors.ts"; import { StackIdSchema } from "../public/StackId.ts"; -import { PORT_FIELD_PROTOCOL, type StackStatus } from "../public/Status.ts"; +import { + PORT_FIELD_PROTOCOL, + type ArtifactPreparationStatus, + type StackStatus, +} from "../public/Status.ts"; import type { ObservedWorkload } from "../runtime/RuntimeDriver.ts"; import type { PersistedStackState } from "../state/StackState.ts"; @@ -75,6 +79,7 @@ export const statusFor = ( observed: ReadonlyArray, active: ReadonlySet, phase: ActualPhase, + artifacts: ReadonlyArray = [], ): Effect.Effect => Schema.decodeEffect(StackIdSchema)(state.identity.stackId).pipe( Effect.mapError( @@ -118,6 +123,7 @@ export const statusFor = ( endpoints, versions, capabilities, + artifacts, } satisfies StackStatus; }), ); diff --git a/packages/stack/src/supervisor/Supervisor.ts b/packages/stack/src/supervisor/Supervisor.ts index d553f477bf..3b7c99ba93 100644 --- a/packages/stack/src/supervisor/Supervisor.ts +++ b/packages/stack/src/supervisor/Supervisor.ts @@ -6,6 +6,7 @@ import { Effect, Exit, FileSystem, + Fiber, FiberSet, Option, Path, @@ -38,7 +39,7 @@ import { type StackErrorTag, type StackError, } from "../public/Errors.ts"; -import type { StackStatus } from "../public/Status.ts"; +import type { ArtifactPreparationStatus, StackStatus } from "../public/Status.ts"; import type { StackId } from "../public/StackId.ts"; import type { LogQuery, StackLogBatch } from "../public/Logs.ts"; import type { EffectStackCredentials } from "../public/Credentials.ts"; @@ -80,6 +81,15 @@ interface SupervisorLaunchAttempt { export interface SupervisorRuntime { readonly driver: RuntimeDriver; readonly preflight: (input: LifecycleInput) => Effect.Effect; + /** Prepares artifacts before launching a newly selected workload closure. */ + readonly prepare: ( + input: LifecycleInput, + selected: ReadonlySet, + ) => Effect.Effect; + /** Best-effort preparation of lazy artifacts after a stack reaches running. */ + readonly prefetch: (state: PersistedStackState) => Effect.Effect; + /** Current in-memory preparation state; completed cache entries outlive the session. */ + readonly artifacts: Effect.Effect>; readonly activate: ( capability: CapabilityName, input: LifecycleInput, @@ -229,6 +239,7 @@ export const makeSupervisor = ( yield* observedForStatus(), yield* Ref.get(active), yield* Ref.get(phase), + yield* runtime.artifacts, ); return status; }); @@ -254,6 +265,16 @@ export const makeSupervisor = ( const ownedFibers = yield* FiberSet.make().pipe( Effect.provideService(Scope.Scope, supervisorScope), ); + const backgroundPreparation = yield* Ref.make | undefined>(undefined); + const startBackgroundPreparation = (state: PersistedStackState): Effect.Effect => + Effect.gen(function* () { + const existing = yield* Ref.get(backgroundPreparation); + if (existing !== undefined) return; + const fiber = yield* Effect.forkIn(runtime.prefetch(state), supervisorScope, { + startImmediately: true, + }); + yield* Ref.set(backgroundPreparation, fiber); + }); const joinExit = (result: Exit.Exit): Effect.Effect => Exit.isSuccess(result) ? Effect.succeed(result.value) : Effect.failCause(result.cause); type LifecycleKind = "start" | "stop" | "destroy"; @@ -363,6 +384,7 @@ export const makeSupervisor = ( if (session === "fresh") yield* resetForSession(input); const selected = selectedOverride ?? (yield* Ref.get(active)); const plan = activeExecutionPlan(input.plan, selected); + yield* runtime.prepare(input, selected); const reservation = yield* runtime.ingress.acquire(input); const launched = yield* launcher .launch(plan) @@ -421,6 +443,9 @@ export const makeSupervisor = ( const cleanupRuntime = (destroy: boolean): Effect.Effect => Effect.gen(function* () { yield* Ref.set(cleanupProven, true); + const background = yield* Ref.get(backgroundPreparation); + if (background !== undefined) yield* Fiber.interrupt(background); + yield* Ref.set(backgroundPreparation, undefined); const ingress = yield* runtime.ingress.close.pipe( Effect.mapError(mapCleanupError), Effect.exit, @@ -625,6 +650,7 @@ export const makeSupervisor = ( return yield* Effect.failCause(started.cause); } yield* Ref.set(phase, "running"); + yield* startBackgroundPreparation(started.value); }); const start = (startOptions?: { readonly config?: StackConfig }) => Effect.gen(function* () { diff --git a/packages/stack/src/supervisor/handles.integration.test.ts b/packages/stack/src/supervisor/handles.integration.test.ts index ea2506a8eb..c030bfcf85 100644 --- a/packages/stack/src/supervisor/handles.integration.test.ts +++ b/packages/stack/src/supervisor/handles.integration.test.ts @@ -384,6 +384,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { runtime: { kind: "native" }, endpoints: {}, versions: {}, + artifacts: [], capabilities: CAPABILITY_NAMES.map((name) => ({ name, activation: "eager", @@ -474,6 +475,7 @@ describe("managed stack handles", { timeout: 30_000 }, () => { runtime: { kind: "native" }, endpoints: {}, versions: {}, + artifacts: [], capabilities: CAPABILITY_NAMES.map((name) => ({ name, activation: "eager", diff --git a/packages/stack/src/supervisor/supervisor.integration.test.ts b/packages/stack/src/supervisor/supervisor.integration.test.ts index 992d87df6e..9bdf439142 100644 --- a/packages/stack/src/supervisor/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor/supervisor.integration.test.ts @@ -19,6 +19,7 @@ import { Rpc } from "effect/unstable/rpc"; import { RequestId } from "effect/unstable/rpc/RpcMessage"; import type { LogQuery, StackLogEntry } from "../public/Logs.ts"; import type { StackRuntime } from "../public/Runtime.ts"; +import type { ArtifactPreparationStatus, StackStatus } from "../public/Status.ts"; import { GatewayActivationError, InvalidLogCursorError, @@ -109,6 +110,12 @@ const makeFixture = ( readonly shutdownReadStarted?: Deferred.Deferred; readonly shutdownReadGate?: Deferred.Deferred; readonly readCalls?: Ref.Ref; + readonly prefetchStarted?: Deferred.Deferred; + readonly prefetchFinished?: Deferred.Deferred; + readonly prefetchGate?: Deferred.Deferred; + readonly prefetchInterrupted?: Deferred.Deferred; + readonly prefetchCalls?: Ref.Ref; + readonly artifactStatuses?: Ref.Ref>; } = {}, ) => Effect.gen(function* () { @@ -341,6 +348,41 @@ const makeFixture = ( } } }), + prepare: () => Effect.void, + prefetch: (state) => { + if (state.definition?.preparation === "on-demand") return Effect.void; + return Effect.gen(function* () { + if (fixtureOptions.prefetchCalls !== undefined) + yield* Ref.update(fixtureOptions.prefetchCalls, (count) => count + 1); + if (fixtureOptions.artifactStatuses !== undefined) + yield* Ref.set(fixtureOptions.artifactStatuses, [ + { workloadId: "rest:rest", capability: "rest", state: "downloading" }, + ]); + if (fixtureOptions.prefetchStarted !== undefined) + yield* Deferred.succeed(fixtureOptions.prefetchStarted, undefined); + if (fixtureOptions.prefetchGate !== undefined) + yield* Deferred.await(fixtureOptions.prefetchGate); + if (fixtureOptions.artifactStatuses !== undefined) + yield* Ref.set(fixtureOptions.artifactStatuses, [ + { workloadId: "rest:rest", capability: "rest", state: "ready" }, + ]); + if (fixtureOptions.prefetchFinished !== undefined) + yield* Deferred.succeed(fixtureOptions.prefetchFinished, undefined); + }).pipe( + Effect.onInterrupt(() => + Effect.gen(function* () { + if (fixtureOptions.artifactStatuses !== undefined) + yield* Ref.set(fixtureOptions.artifactStatuses, []); + if (fixtureOptions.prefetchInterrupted !== undefined) + yield* Deferred.succeed(fixtureOptions.prefetchInterrupted, undefined); + }), + ), + ); + }, + artifacts: + fixtureOptions.artifactStatuses === undefined + ? Effect.succeed([]) + : Ref.get(fixtureOptions.artifactStatuses), activate: () => Effect.gen(function* () { if (fixtureOptions.activationCalls !== undefined) @@ -605,6 +647,147 @@ describe("Supervisor composition", () => { ), ); + it.live("returns database readiness while background preparation remains observable", () => + run( + Effect.gen(function* () { + const prefetchStarted = yield* Deferred.make(); + const prefetchGate = yield* Deferred.make(); + const prefetchInterrupted = yield* Deferred.make(); + const prefetchCalls = yield* Ref.make(0); + const artifactStatuses = yield* Ref.make>([]); + const fixture = yield* makeFixture({ + prefetchStarted, + prefetchGate, + prefetchInterrupted, + prefetchCalls, + artifactStatuses, + }); + const startReturned = yield* Deferred.make(); + yield* Effect.forkChild( + fixture.supervisor + .start({ config: {} }) + .pipe( + Effect.tap((status) => Deferred.succeed(startReturned, status).pipe(Effect.asVoid)), + ), + { startImmediately: true }, + ); + const started = yield* Deferred.await(startReturned).pipe( + Effect.timeout("5 seconds"), + Effect.orDie, + ); + expect(started.capabilities.find(({ name }) => name === "database")?.state).toBe("ready"); + expect(started.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); + yield* Deferred.await(prefetchStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); + const during = yield* fixture.supervisor.status; + expect(during.capabilities.find(({ name }) => name === "rest")?.state).toBe("dormant"); + expect(during.artifacts).toEqual([ + { workloadId: "rest:rest", capability: "rest", state: "downloading" }, + ]); + + const repeated = yield* fixture.supervisor.start(); + expect(repeated.artifacts).toEqual(during.artifacts); + expect(yield* Ref.get(prefetchCalls)).toBe(1); + + const stopped = yield* fixture.supervisor.maintenanceHandlers.stop; + expect(stopped.ok).toBe(true); + yield* Deferred.await(prefetchInterrupted).pipe(Effect.timeout("5 seconds"), Effect.orDie); + }), + ), + ); + + it.live("keeps accepted start work alive when its caller is interrupted", () => + run( + Effect.gen(function* () { + const startStarted = yield* Deferred.make(); + const startGate = yield* Deferred.make(); + const prefetchStarted = yield* Deferred.make(); + const prefetchGate = yield* Deferred.make(); + const prefetchFinished = yield* Deferred.make(); + const artifactStatuses = yield* Ref.make>([]); + const fixture = yield* makeFixture({ + startStarted, + startGate, + prefetchStarted, + prefetchGate, + prefetchFinished, + artifactStatuses, + }); + const waiter = yield* Effect.forkChild(fixture.supervisor.start({ config: {} }), { + startImmediately: true, + }); + yield* Deferred.await(startStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); + yield* Fiber.interrupt(waiter); + const interrupted = yield* Fiber.join(waiter).pipe(Effect.exit); + expect(Exit.isFailure(interrupted)).toBe(true); + yield* Deferred.succeed(startGate, undefined); + yield* Deferred.await(prefetchStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); + const during = yield* fixture.supervisor.status; + expect(during.lifecycle).toBe("running"); + expect(during.artifacts).toEqual([ + { workloadId: "rest:rest", capability: "rest", state: "downloading" }, + ]); + yield* Deferred.succeed(prefetchGate, undefined); + yield* Deferred.await(prefetchFinished).pipe(Effect.timeout("5 seconds"), Effect.orDie); + expect((yield* fixture.supervisor.status).lifecycle).toBe("running"); + yield* fixture.supervisor.maintenanceHandlers.stop; + }), + ), + ); + + it.live("interrupts background preparation before destroying the stack", () => + run( + Effect.gen(function* () { + const prefetchStarted = yield* Deferred.make(); + const prefetchGate = yield* Deferred.make(); + const prefetchInterrupted = yield* Deferred.make(); + const fixture = yield* makeFixture({ + prefetchStarted, + prefetchGate, + prefetchInterrupted, + }); + yield* fixture.supervisor.start({ config: {} }); + yield* Deferred.await(prefetchStarted).pipe(Effect.timeout("5 seconds"), Effect.orDie); + const destroying = yield* Effect.forkChild(fixture.supervisor.destroy, { + startImmediately: true, + }); + yield* Deferred.await(prefetchInterrupted).pipe(Effect.timeout("5 seconds"), Effect.orDie); + yield* Fiber.join(destroying); + expect(yield* fixture.store.read(fixture.id)).toBeUndefined(); + }), + ), + ); + + it.live("skips background preparation for on-demand and failed starts", () => + run( + Effect.gen(function* () { + const onDemandStarted = yield* Deferred.make(); + const onDemandCalls = yield* Ref.make(0); + const onDemand = yield* makeFixture({ + prefetchStarted: onDemandStarted, + prefetchCalls: onDemandCalls, + }); + yield* onDemand.supervisor.start({ config: { preparation: "on-demand" } }); + expect(Option.isNone(yield* Deferred.poll(onDemandStarted))).toBe(true); + expect(yield* Ref.get(onDemandCalls)).toBe(0); + + const failedStarted = yield* Deferred.make(); + const failedCalls = yield* Ref.make(0); + const startFailures = yield* Ref.make(1); + const failed = yield* makeFixture({ + prefetchStarted: failedStarted, + prefetchCalls: failedCalls, + startFailures, + }); + const result = yield* failed.supervisor + .start({ config: { capabilities: { functions: { activation: "eager" } } } }) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + expect(Option.isNone(yield* Deferred.poll(failedStarted))).toBe(true); + expect(yield* Ref.get(failedCalls)).toBe(0); + }), + ), + ); + it.live("reports starting while relaunching a stopped owner", () => run( Effect.gen(function* () { From 3338a949644d57c9e776c8aa5b3b7b272ebe5362 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 5 Sep 2026 12:56:25 +0200 Subject: [PATCH 172/174] refactor(stack): delegate service preparation to runtime artifacts --- .../functions/serve/serve.integration.test.ts | 1 + packages/stack/src/model/WorkloadCatalog.ts | 81 ++--- .../src/model/catalog.integration.test.ts | 33 +- .../src/preparation/SlimServicesSource.ts | 70 ---- .../slim-services.integration.test.ts | 83 ----- packages/stack/src/runtime/NativeProcess.ts | 2 - packages/stack/src/runtime/NativeRuntime.ts | 58 +--- .../stack/src/runtime/ProductionRuntime.ts | 117 +------ .../stack/src/runtime/RuntimeInputOwner.ts | 173 +--------- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 135 ++------ .../native-runtime.integration.test.ts | 310 +----------------- .../production-runtime.integration.test.ts | 241 +++----------- .../runtime-input-owner.integration.test.ts | 195 ----------- .../workload-runtime.integration.test.ts | 99 +----- 14 files changed, 135 insertions(+), 1463 deletions(-) diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 57b792855e..8a2e169e9a 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -51,6 +51,7 @@ const status = (lifecycle: StackStatus["lifecycle"]): StackStatus => ({ activation: name === "functions" ? "lazy" : "eager", state: lifecycle === "running" ? (name === "functions" ? "dormant" : "ready") : "stopped", })), + artifacts: [], }); describe("legacy functions serve", () => { diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index f46535da69..7ab6b3db88 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -17,14 +17,6 @@ export interface NativeWorkloadArtifact { readonly checksumUrl: string; readonly requiredRuntimePaths: ReadonlyArray; readonly executablePath: string; - readonly nativeProcess?: NativeWorkloadProcess; -} - -/** Artifact-root-relative process metadata for native Node workloads. */ -export interface NativeWorkloadProcess { - readonly executablePath: string; - readonly args: ReadonlyArray; - readonly cwd: string; } export interface WorkloadCatalogEntry { @@ -36,8 +28,6 @@ export interface WorkloadCatalogEntry { readonly executablePath: string; /** Stable, stack-local DNS identity used by container dependants. */ readonly containerAlias: string; - /** Present when the artifact is launched through an interpreter. */ - readonly nativeProcess?: NativeWorkloadProcess; } const native = ( @@ -49,7 +39,6 @@ const native = ( options: { readonly additionalReleases?: Readonly>; readonly containerAlias?: string; - readonly nativeProcess?: NativeWorkloadProcess; } = {}, ): WorkloadCatalogEntry => ({ service, @@ -61,7 +50,6 @@ const native = ( requiredRuntimePaths, executablePath, containerAlias: options.containerAlias ?? `supabase-${service}`, - ...(options.nativeProcess === undefined ? {} : { nativeProcess: options.nativeProcess }), }); /** The single authoritative private workload identity table. */ @@ -69,20 +57,13 @@ const workloadCatalog = { "database:database": native( "postgres", "17.6.1.168", - "ghcr.io/supabase/cli/postgres:17.6.1.168", - "share/supabase-cli/bin/supabase-postgres-init.sh", - [ - "bin/postgres", - "bin/pg_isready", - "bin/psql", - "share/supabase-cli/bin/supabase-postgres-init.sh", - "share/supabase-cli/config/pgsodium_getkey.sh", - "share/supabase-cli/migrations", - "lib", - ], + "ghcr.io/supabase/cli/postgres:17.6.1.168@sha256:88747ff441dc313edab7f2bc14f2586d7fee5cc7c44d6425729756d1ef7e0f89", + "bin/supabase-postgres-start", + ["bin/supabase-postgres-start"], { additionalReleases: { - "15.14.1.168": "ghcr.io/supabase/cli/postgres:15.14.1.168", + "15.14.1.168": + "ghcr.io/supabase/cli/postgres:15.14.1.168@sha256:f8cd66cf9d464374fe8932a319c6bb0ab03520643ee038b4e84bbdb8ad03bd8f", }, containerAlias: "supabase-database", }, @@ -101,23 +82,16 @@ const workloadCatalog = { "realtime:realtime": native( "realtime", "v2.134.5", - "ghcr.io/supabase/cli/realtime:v2.134.5", + "ghcr.io/supabase/cli/realtime:v2.134.5@sha256:ebed084dc805ca8d9dc0ab37a84f858c82c1ccd9afcf4bff332941ea0d089347", "bin/server", - ["bin/migrate", "bin/realtime", "bin/server"], + ["bin/server", "bin/prepare"], ), "storage:storage": native( "storage", "v1.73.0", - "ghcr.io/supabase/cli/storage:v1.73.0", - "app/dist/start/server.js", - ["node/bin/node", "app/dist/start/server.js", "app/dist/scripts/migrate-call.js"], - { - nativeProcess: { - executablePath: "node/bin/node", - args: ["app/dist/start/server.js"], - cwd: "app", - }, - }, + "ghcr.io/supabase/cli/storage:v1.73.0@sha256:c645e251ecdb393b370413697f5a5ed48c853d1c068c4698fece8d2914f5d8aa", + "bin/storage", + ["bin/storage", "bin/prepare"], ), "storage:imgproxy": native( "imgproxy", @@ -136,30 +110,16 @@ const workloadCatalog = { "studio:studio": native( "studio", "2026.09.04-sha-5a67366", - "ghcr.io/supabase/cli/studio:2026.09.04-sha-5a67366", - "app/apps/studio/server.js", - ["node/bin/node", "app/apps/studio/docker-entrypoint.mjs", "app/apps/studio/server.js"], - { - nativeProcess: { - executablePath: "node/bin/node", - args: ["app/apps/studio/docker-entrypoint.mjs"], - cwd: "app", - }, - }, + "ghcr.io/supabase/cli/studio:2026.09.04-sha-5a67366@sha256:b33221c99519400d648b98e0c31004f8897104c3970d0ef169392c3f3d22c22c", + "bin/studio", + ["bin/studio"], ), "studio:pgmeta": native( "pgmeta", "v0.99.0", - "ghcr.io/supabase/cli/pgmeta:v0.99.0", - "app/dist/server/server.js", - ["node/bin/node", "app/dist/server/server.js"], - { - nativeProcess: { - executablePath: "node/bin/node", - args: ["app/dist/server/server.js"], - cwd: "app", - }, - }, + "ghcr.io/supabase/cli/pgmeta:v0.99.0@sha256:efe57d66dafa2921f7c1af3283e40432dd66eba190ea84d3d9d1eda629c64ff0", + "bin/pgmeta", + ["bin/pgmeta"], ), "mail:mail": native( "mailpit", @@ -172,9 +132,9 @@ const workloadCatalog = { "analytics:analytics": native( "analytics", "v1.50.9", - "ghcr.io/supabase/cli/analytics:v1.50.9", + "ghcr.io/supabase/cli/analytics:v1.50.9@sha256:5a1bc151a4e53f84012a1b459db9e64bdd72945b115633896e8df004861c988f", "bin/logflare", - ["bin/logflare"], + ["bin/logflare", "bin/prepare"], ), "analytics:vector": native( "vector", @@ -186,9 +146,9 @@ const workloadCatalog = { "pooler:pooler": native( "pooler", "v2.9.12", - "ghcr.io/supabase/cli/pooler:v2.9.12", + "ghcr.io/supabase/cli/pooler:v2.9.12@sha256:eb011c14a6dd569f795397215887357d6ce45ebadf387efd32af7bb81afb7a73", "bin/server", - ["bin/migrate", "bin/supavisor", "bin/server"], + ["bin/server", "bin/prepare", "bin/provision-tenant"], ), } satisfies Readonly>; @@ -255,7 +215,6 @@ const artifactFor = ( checksumUrl: `${base}/SHA256SUMS`, requiredRuntimePaths: entry.requiredRuntimePaths, executablePath: entry.executablePath, - ...(entry.nativeProcess === undefined ? {} : { nativeProcess: entry.nativeProcess }), }; }; diff --git a/packages/stack/src/model/catalog.integration.test.ts b/packages/stack/src/model/catalog.integration.test.ts index 6e1965e1ff..324564d401 100644 --- a/packages/stack/src/model/catalog.integration.test.ts +++ b/packages/stack/src/model/catalog.integration.test.ts @@ -41,15 +41,15 @@ describe("complete workload catalog", () => { it("matches the executable paths shipped by slim-services archives", () => { const expected = { - "database:database": "share/supabase-cli/bin/supabase-postgres-init.sh", + "database:database": "bin/supabase-postgres-start", "rest:rest": "bin/postgrest", "auth:auth": "bin/auth", "realtime:realtime": "bin/server", - "storage:storage": "app/dist/start/server.js", + "storage:storage": "bin/storage", "storage:imgproxy": "bin/imgproxy", "functions:edge-runtime": "bin/edge-runtime", - "studio:studio": "app/apps/studio/server.js", - "studio:pgmeta": "app/dist/server/server.js", + "studio:studio": "bin/studio", + "studio:pgmeta": "bin/pgmeta", "mail:mail": "bin/mailpit", "analytics:analytics": "bin/logflare", "analytics:vector": "bin/vector", @@ -60,33 +60,28 @@ describe("complete workload catalog", () => { expect(WORKLOAD_CATALOG[id]?.requiredRuntimePaths).toContain(executable); } expect(WORKLOAD_CATALOG["realtime:realtime"]?.requiredRuntimePaths).toEqual([ - "bin/migrate", - "bin/realtime", "bin/server", + "bin/prepare", ]); expect(WORKLOAD_CATALOG["database:database"]?.requiredRuntimePaths).toEqual([ - "bin/postgres", - "bin/pg_isready", - "bin/psql", - "share/supabase-cli/bin/supabase-postgres-init.sh", - "share/supabase-cli/config/pgsodium_getkey.sh", - "share/supabase-cli/migrations", - "lib", + "bin/supabase-postgres-start", ]); expect(WORKLOAD_CATALOG["storage:storage"]?.requiredRuntimePaths).toEqual([ - "node/bin/node", - "app/dist/start/server.js", - "app/dist/scripts/migrate-call.js", + "bin/storage", + "bin/prepare", + ]); + expect(WORKLOAD_CATALOG["analytics:analytics"]?.requiredRuntimePaths).toEqual([ + "bin/logflare", + "bin/prepare", ]); - expect(WORKLOAD_CATALOG["analytics:analytics"]?.requiredRuntimePaths).toEqual(["bin/logflare"]); expect(WORKLOAD_CATALOG["analytics:vector"]?.requiredRuntimePaths).toEqual([ "bin/vector", "share/doc/vector/config/vector.yaml", ]); expect(WORKLOAD_CATALOG["pooler:pooler"]?.requiredRuntimePaths).toEqual([ - "bin/migrate", - "bin/supavisor", "bin/server", + "bin/prepare", + "bin/provision-tenant", ]); }); diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 077c6094ce..87e926f810 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -228,75 +228,6 @@ const validateExtractedTree = ( ), ); -/** - * Postgres slim archives keep init scripts below `migrations/`. Older archives - * may also contain a real historical sibling directory, which would make the - * native init script run every migration twice. Remove only that exact sibling - * path so the native archive has the same layout as the container image. - */ -export const normalizeSlimServicesLayout = ( - artifact: NativeWorkloadArtifact, - destination: string, -): Effect.Effect => { - if (artifact.service !== "postgres") return Effect.void; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exists = (candidate: string): Effect.Effect => - fs.exists(candidate).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to inspect Postgres slim init-scripts layout", - path: candidate, - cause, - }), - ), - ); - const target = path.join(destination, "share/supabase-cli/migrations/init-scripts"); - const alias = path.join(destination, "share/supabase-cli/init-scripts"); - const targetExists = yield* exists(target); - if (!targetExists) - return yield* new StackPreparationError({ - message: "Postgres slim artifact is missing its init-scripts directory", - path: "share/supabase-cli/migrations/init-scripts", - }); - const aliasExists = yield* exists(alias); - if (!aliasExists) return; - const root = yield* fs.realPath(destination).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to inspect Postgres slim artifact root", - cause, - }), - ), - ); - const realAlias = yield* fs.realPath(alias).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to validate Postgres slim init-scripts layout", - cause, - }), - ), - ); - if (pathEscapesRoot(path, root, realAlias)) - return yield* new StackPreparationError({ - message: "Postgres slim init-scripts alias resolves outside its artifact root", - }); - yield* fs.remove(alias, { recursive: true, force: true }).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ - message: "Unable to remove Postgres slim init-scripts alias", - cause, - }), - ), - ); - }); -}; - export const makeSlimServicesSource = ( resolve: (request: ArtifactRequest) => NativeWorkloadArtifact | undefined, fetchRequest: Fetcher = fetcher, @@ -454,7 +385,6 @@ export const makeSlimServicesSource = ( return yield* new StackPreparationError({ message: `Slim-services archive extraction exited with code ${exitCode}`, }); - yield* normalizeSlimServicesLayout(artifact, destination); yield* validateExtractedTree(fs, path, destination); yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); return compressed; diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 5607a82133..fd05301f38 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -7,7 +7,6 @@ import { digestHex } from "./Integrity.ts"; import { StackPreparationError } from "../public/Errors.ts"; import { makeSlimServicesSource, - normalizeSlimServicesLayout, slimServicesChecksum, systemTarBoundary, type ZstdDecompressor, @@ -106,88 +105,6 @@ const requestUrl = (input: Parameters[0]): string => typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; describe("slim-services artifact source", () => { - it.live("keeps Postgres init scripts only under the migrations directory", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const destination = yield* fs.makeTempDirectoryScoped({ - prefix: "slim-services-postgres-", - }); - const initScripts = `${destination}/share/supabase-cli/migrations/init-scripts`; - yield* fs.makeDirectory(initScripts, { recursive: true }); - const postgresArtifact = { ...artifact, service: "postgres" }; - yield* normalizeSlimServicesLayout(postgresArtifact, destination); - expect(yield* fs.exists(`${destination}/share/supabase-cli/init-scripts`)).toBe(false); - expect(yield* fs.exists(initScripts)).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("removes an extracted duplicate Postgres init-script tree", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const destination = yield* fs.makeTempDirectoryScoped({ - prefix: "slim-services-postgres-duplicate-", - }); - const target = `${destination}/share/supabase-cli/migrations/init-scripts`; - const alias = `${destination}/share/supabase-cli/init-scripts`; - yield* fs.makeDirectory(target, { recursive: true }); - yield* fs.makeDirectory(alias, { recursive: true }); - yield* fs.writeFileString(`${target}/migration.sql`, "target"); - yield* fs.writeFileString(`${alias}/migration.sql`, "duplicate"); - yield* normalizeSlimServicesLayout({ ...artifact, service: "postgres" }, destination); - expect(yield* fs.exists(alias)).toBe(false); - expect(yield* fs.readFileString(`${target}/migration.sql`)).toBe("target"); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("removes a contained Postgres init-scripts symlink without touching its target", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const destination = yield* fs.makeTempDirectoryScoped({ - prefix: "slim-services-postgres-symlink-", - }); - const target = `${destination}/share/supabase-cli/migrations/init-scripts`; - const alias = `${destination}/share/supabase-cli/init-scripts`; - yield* fs.makeDirectory(target, { recursive: true }); - yield* fs.writeFileString(`${target}/migration.sql`, "target"); - yield* fs.symlink("migrations/init-scripts", alias); - yield* normalizeSlimServicesLayout({ ...artifact, service: "postgres" }, destination); - expect(yield* fs.exists(alias)).toBe(false); - expect(yield* fs.readFileString(`${target}/migration.sql`)).toBe("target"); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - it.live("rejects a Postgres init-scripts symlink that escapes its artifact root", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const destination = yield* fs.makeTempDirectoryScoped({ - prefix: "slim-services-postgres-symlink-escape-", - }); - const outside = yield* fs.makeTempDirectoryScoped({ - prefix: "slim-services-postgres-symlink-outside-", - }); - const target = `${destination}/share/supabase-cli/migrations/init-scripts`; - const alias = `${destination}/share/supabase-cli/init-scripts`; - yield* fs.makeDirectory(target, { recursive: true }); - yield* fs.makeDirectory(outside, { recursive: true }); - yield* fs.symlink(outside, alias); - const result = yield* normalizeSlimServicesLayout( - { ...artifact, service: "postgres" }, - destination, - ).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - expect(errorOf(result)).toBeInstanceOf(StackPreparationError); - expect(yield* fs.exists(alias)).toBe(true); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - it.live("verifies checksums and extracts a manifest-matched archive using injected fetch", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/NativeProcess.ts b/packages/stack/src/runtime/NativeProcess.ts index cc1674e5a8..0ef8710fb8 100644 --- a/packages/stack/src/runtime/NativeProcess.ts +++ b/packages/stack/src/runtime/NativeProcess.ts @@ -19,8 +19,6 @@ export interface NativeProcessSpec { readonly gracefulStopTimeout?: Duration.Input; /** Maximum time allowed for this service-owned one-shot process. */ readonly timeout?: Duration.Input; - /** Exact lifecycle witness written after a successful one-shot process. */ - readonly successMarker?: string; } /** diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index 098069b457..ff7e8b3968 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -1,16 +1,4 @@ -import { - Cause, - Deferred, - Effect, - Exit, - Fiber, - FileSystem, - Path, - Ref, - Scope, - Semaphore, - Stream, -} from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scope, Semaphore, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { ExitCode } from "effect/unstable/process/ChildProcessSpawner"; import type * as ChildProcessSpawnerService from "effect/unstable/process/ChildProcessSpawner"; @@ -66,8 +54,6 @@ export interface NativeRuntimeOptions { interface NativeProcessPlan { readonly startup: ReadonlyArray; readonly main: NativeProcessSpec; - /** One-shot processes that require the long-lived workload to be ready. */ - readonly postReadiness?: ReadonlyArray; } interface OutputAccumulator { @@ -168,12 +154,10 @@ export const makeNativeRuntime = ( ): Effect.Effect< RuntimeDriver, RuntimeDriverError, - ChildProcessSpawnerService.ChildProcessSpawner | Scope.Scope | FileSystem.FileSystem | Path.Path + ChildProcessSpawnerService.ChildProcessSpawner | Scope.Scope > => Effect.gen(function* () { const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; const parentScope = yield* Scope.Scope; const runtimeScope = yield* Scope.fork(parentScope, "parallel"); const registration = yield* Semaphore.make(1); @@ -255,35 +239,12 @@ export const makeNativeRuntime = ( }).pipe(Effect.forkIn(resource.scope), Effect.asVoid); }; - const writeSuccessMarker = (markerPath: string): Effect.Effect => - Effect.gen(function* () { - const parent = pathService.dirname(markerPath); - yield* fileSystem.makeDirectory(parent, { recursive: true }); - const temporary = yield* fileSystem.makeTempFile({ - directory: parent, - prefix: ".supabase-stack-marker-", - }); - yield* fileSystem.writeFileString(temporary, "completed\n"); - yield* fileSystem.chmod(temporary, 0o600); - yield* fileSystem - .rename(temporary, markerPath) - .pipe(Effect.ensuring(fileSystem.remove(temporary, { force: true }).pipe(Effect.ignore))); - }).pipe( - Effect.mapError( - (error) => - new NativeProcessError({ - message: error instanceof Error ? error.message : String(error), - cause: error, - }), - ), - ); - /** Runs one short-lived, service-owned startup process in its own scope. */ const runStartupProcess = ( resource: Resource, spec: NativeProcessSpec, launcher: NativeProcessLauncher | undefined, - phase: "startup" | "post-readiness", + phase: "startup", ): Effect.Effect => Effect.gen(function* () { const startupScope = yield* Scope.fork(resource.scope, "parallel"); @@ -358,16 +319,6 @@ export const makeNativeRuntime = ( resource.key, `Native ${phase} process exited with code ${String(result.value)} for ${resource.key.workloadId}`, ); - if (spec.successMarker !== undefined) - yield* writeSuccessMarker(spec.successMarker).pipe( - Effect.mapError((error) => - driverError( - resource.key, - `Native ${phase} success marker failed for ${resource.key.workloadId}: ${error.message}`, - error, - ), - ), - ); }); yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -450,9 +401,6 @@ export const makeNativeRuntime = ( const preReady = Effect.gen(function* () { yield* readiness(key, workload, process); yield* ensureNotStopped(); - for (const postReadiness of resolved.postReadiness ?? []) - yield* runStartupProcess(resource, postReadiness, launcher, "post-readiness"); - yield* ensureNotStopped(); if (workload.bootstrap === "database") { if (options.bootstrapDatabase === undefined) return yield* driverError(key, "Database bootstrap resolver is not configured"); diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 68268f020f..177c528389 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -1,6 +1,5 @@ import { Cause, - Config, Context, Crypto, Duration, @@ -167,8 +166,6 @@ const urlHost = (host: string): string => { }; const DATABASE_WORKLOAD_ID = "database:database"; -/** Atomic lifecycle witness for a successfully migrated native cluster. */ -export const NATIVE_DATABASE_MIGRATION_MARKER = ".supabase-stack-migration-complete"; const checkNativeDatabaseLockEvidence = ( fileSystem: FileSystem.FileSystem, lockPath: string, @@ -331,91 +328,6 @@ const readinessFor = ( ); }; -interface NativeDatabaseMigrationOptions { - readonly key: Pick; - readonly dataPath: string; - readonly artifactRoot: string; - readonly environment: Readonly>; - readonly endpointPort: number; - readonly fileSystem: FileSystem.FileSystem; - readonly pathService: Path.Path; -} - -/** Builds the one-shot native PostgreSQL migration process for an unmarked data directory. */ -const prepareNativeDatabaseMigration = ( - options: NativeDatabaseMigrationOptions, -): Effect.Effect => - Effect.gen(function* () { - const { key, dataPath, artifactRoot, environment, endpointPort, fileSystem, pathService } = - options; - const markerPath = pathService.join(dataPath, NATIVE_DATABASE_MIGRATION_MARKER); - const migrated = yield* fileSystem - .exists(markerPath) - .pipe( - Effect.mapError((error) => - driverError(key, `Unable to inspect native database data path: ${error.message}`, error), - ), - ); - if (migrated) return undefined; - const pgVersionPath = pathService.join(dataPath, "PG_VERSION"); - const initialized = yield* fileSystem - .exists(pgVersionPath) - .pipe( - Effect.mapError((error) => - driverError(key, `Unable to inspect native database data path: ${error.message}`, error), - ), - ); - const postmasterOptionsPath = pathService.join(dataPath, "postmaster.opts"); - const completeInitialization = initialized - ? yield* fileSystem - .exists(postmasterOptionsPath) - .pipe( - Effect.mapError((error) => - driverError( - key, - `Unable to inspect native database data path: ${error.message}`, - error, - ), - ), - ) - : false; - if (initialized && !completeInitialization) - yield* fileSystem - .remove(dataPath, { recursive: true, force: true }) - .pipe( - Effect.mapError((error) => - driverError( - key, - `Unable to remove incomplete native database data path: ${error.message}`, - error, - ), - ), - ); - const migrationsPath = pathService.join(artifactRoot, "share/supabase-cli/migrations"); - const artifactBin = pathService.join(artifactRoot, "bin"); - const hostPath = yield* Config.string("PATH").pipe( - Config.withDefault(""), - Effect.mapError((error) => driverError(key, "Unable to read host PATH", error)), - ); - return { - executable: pathService.join(migrationsPath, "migrate.sh"), - cwd: migrationsPath, - // Migration can legitimately outlive readiness on a cold machine; its marker is - // written only after success, so rerunning remains idempotent when the marker is absent. - timeout: "15 minutes", - successMarker: markerPath, - env: { - ...environment, - PATH: hostPath.length === 0 ? artifactBin : `${artifactBin}:${hostPath}`, - POSTGRES_HOST: "127.0.0.1", - POSTGRES_PORT: String(endpointPort), - POSTGRES_DB: "postgres", - POSTGRES_USER: "supabase_admin", - POSTGRES_PASSWORD: environment.POSTGRES_PASSWORD ?? "", - }, - }; - }); - declare const SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; // Release builds inject the already-bundled Edge Runtime entrypoint. The @@ -827,9 +739,6 @@ export const makeProductionRuntime = ( ...(workload.id.startsWith("analytics:") && material.analytics !== undefined ? { analytics: material.analytics } : {}), - ...(workload.id === "pooler:pooler" && material.pooler !== undefined - ? { pooler: material.pooler } - : {}), database: { dataPath: pathService.join(paths.data, "database") }, storage: { dataPath: pathService.join(paths.data, "storage") }, ...(functions === undefined ? {} : { functions }), @@ -1003,9 +912,6 @@ export const makeProductionRuntime = ( key, `Unknown runtime specification for ${workload.id}`, ); - const readinessBudget = yield* readinessDeadlineFor(fresh, workload).pipe( - Effect.mapError((error) => mapDriverError(key, error)), - ); // Revalidate the fresh persisted definition before spawning; preflight checks // the candidate definition, while this state is the runtime authority. const inputs = yield* runtimeInputs(workload, fresh, undefined).pipe( @@ -1039,24 +945,6 @@ export const makeProductionRuntime = ( inputs, ); const environment = spec.env(fresh, workload, endpoint.port, "native", inputs); - let migration: NativeProcessSpec | undefined; - if (isDatabaseWorkload(workload)) { - const dataPath = inputs.database?.dataPath; - if (dataPath === undefined) - return yield* driverError( - key, - "Resolved database data path is unavailable for native migration", - ); - migration = yield* prepareNativeDatabaseMigration({ - key, - dataPath, - artifactRoot: prepared.artifactRoot, - environment, - endpointPort: endpoint.port, - fileSystem, - pathService, - }); - } return { startup: spec .nativeStartupProcesses( @@ -1068,12 +956,9 @@ export const makeProductionRuntime = ( ) .map((startup: NativeProcessSpec) => ({ ...startup, - timeout: - startup.timeout ?? - (isDatabaseWorkload(workload) ? readinessBudget : Duration.minutes(5)), + timeout: startup.timeout ?? Duration.minutes(5), env: { ...environment, ...startup.env }, })), - ...(migration === undefined ? {} : { postReadiness: [migration] }), main: { ...resolvedNativeProcess, env: environment }, }; }), diff --git a/packages/stack/src/runtime/RuntimeInputOwner.ts b/packages/stack/src/runtime/RuntimeInputOwner.ts index a32576a8d2..22812b3d78 100644 --- a/packages/stack/src/runtime/RuntimeInputOwner.ts +++ b/packages/stack/src/runtime/RuntimeInputOwner.ts @@ -6,12 +6,10 @@ import { resolveStackPaths } from "../state/Paths.ts"; import { isRecord, settingValue, settingsFor } from "../state/MaterializedSettings.ts"; import { base64UrlEncode, - DATABASE_INTERNAL_PASSWORD_SLOT, resolveSigningKeyMaterial, type ResolvedSigningKeyMaterial, } from "../state/SecretStore.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; -import { containerAliasFor } from "../model/WorkloadCatalog.ts"; import { canonicalize } from "../model/Compiler.ts"; /** A parsed JSON document fetched by the owner for OIDC discovery. */ @@ -39,7 +37,6 @@ interface RuntimeInputMaterial { /** Session-scoped Vector config owned by this stack runtime. */ readonly vectorConfigPath?: string; }>; - readonly pooler?: Readonly<{ readonly tenantPath?: string }>; readonly functions?: Readonly<{ readonly secrets: Readonly> }>; } @@ -85,13 +82,6 @@ const mapFile = ( Effect.mapError((error) => failure(`Unable to ${operation}`, { path: target, cause: error })), ); -const finiteSetting = (value: string): number | undefined => { - const trimmed = value.trim(); - if (trimmed.length === 0) return undefined; - const parsed = Number(trimmed); - return Number.isFinite(parsed) ? parsed : undefined; -}; - const relativeEscape = (path: Path.Path, root: string, candidate: string): boolean => { const relative = path.relative(root, candidate); return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); @@ -134,68 +124,6 @@ const symmetricJwk = (secret: string): Readonly> => ({ k: base64UrlEncode(new TextEncoder().encode(secret)), }); -const elixirString = (value: string): string => { - let output = '"'; - const characters = [...value]; - for (let index = 0; index < characters.length; index += 1) { - const character = characters[index] ?? ""; - const code = character.codePointAt(0) ?? 0; - if (character === "\\") output += "\\\\"; - else if (character === '"') output += '\\"'; - else if (character === "#" && characters[index + 1] === "{") output += "\\#"; - else if (character === "\n") output += "\\n"; - else if (character === "\r") output += "\\r"; - else if (character === "\t") output += "\\t"; - else if (code < 0x20) output += `\\x${code.toString(16).padStart(2, "0")}`; - else output += character; - } - return `${output}"`; -}; - -const poolerTenantScript = (input: { - readonly externalId: string; - readonly dbHost: string; - readonly dbPort: number; - readonly dbPassword: string; - readonly poolMode: "transaction" | "session"; - readonly defaultPoolSize: number; - readonly maxClientConn: number; -}): string => `{: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" => ${elixirString(input.externalId)}, - "db_host" => ${elixirString(input.dbHost)}, - "db_port" => ${String(input.dbPort)}, - "db_database" => "postgres", - "require_user" => false, - "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)", - "default_max_clients" => ${String(input.maxClientConn)}, - "default_pool_size" => ${String(input.defaultPoolSize)}, - "default_parameter_status" => %{"server_version" => version}, - "users" => [%{ - "db_user" => "pgbouncer", - "db_password" => ${elixirString(input.dbPassword)}, - "mode_type" => ${elixirString(input.poolMode)}, - "pool_size" => ${String(input.defaultPoolSize)}, - "is_manager" => true - }] -} - -case Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do - nil -> - {:ok, _} = Supavisor.Tenants.create_tenant(params) - existing -> - existing = Supavisor.Repo.preload(existing, :users) - {:ok, _} = Supavisor.Tenants.update_tenant(existing, params) -end -`; - /** Resolves materialized Edge Runtime secrets to their caller-visible names. */ const resolveFunctionsEdgeRuntimeSecrets = ( state: PersistedStackState, @@ -277,7 +205,6 @@ export const makeRuntimeInputOwner = ( const stackPaths = yield* resolveStackPaths(options).pipe( Effect.mapError((cause) => failure("Unable to resolve runtime input paths", { cause })), ); - const poolerRoot = path.join(stackPaths.runtime, "inputs", "pooler"); const vectorRoot = path.join(stackPaths.runtime, "inputs", "vector"); const resolveProjectFile = ( @@ -479,87 +406,6 @@ export const makeRuntimeInputOwner = ( }; }); - const writePoolerTenant = ( - state: PersistedStackState, - runtime: "native" | "container", - ): Effect.Effect => { - const password = state.secrets[DATABASE_INTERNAL_PASSWORD_SLOT]?.value ?? ""; - if (password.length === 0) - return Effect.fail(failure("Persisted database secret is missing")); - const settings = settingsFor(state, "pooler"); - const poolMode = settingValue(state, isRecord(settings) ? settings.pool_mode : undefined); - if (poolMode !== "transaction" && poolMode !== "session") - return Effect.fail(failure("Persisted Pooler mode is invalid")); - const tenantId = settingValue(state, isRecord(settings) ? settings.tenant_id : undefined); - if (tenantId.length === 0) - return Effect.fail(failure("Persisted Pooler tenant id is missing")); - const defaultPoolSize = finiteSetting( - settingValue(state, isRecord(settings) ? settings.default_pool_size : undefined), - ); - const maxClientConn = finiteSetting( - settingValue(state, isRecord(settings) ? settings.max_client_conn : undefined), - ); - if (defaultPoolSize === undefined || maxClientConn === undefined) - return Effect.fail(failure("Persisted Pooler sizing settings are invalid")); - const nativeAssignment = state.privatePorts.find( - (entry) => entry.workloadId === "database:database" && entry.binding === "primary", - ); - const dbHost = runtime === "container" ? containerAliasFor("database:database") : "127.0.0.1"; - const dbPort = runtime === "container" ? 5432 : nativeAssignment?.port; - if (dbPort === undefined) - return Effect.fail(failure("Persisted native database assignment is missing")); - const content = poolerTenantScript({ - externalId: tenantId, - dbHost, - dbPort, - dbPassword: password, - poolMode, - defaultPoolSize, - maxClientConn, - }); - const target = path.join(poolerRoot, "pooler_tenant.exs"); - const fileMode = runtime === "native" ? 0o600 : 0o644; - return Effect.gen(function* () { - const token = yield* crypto.randomUUIDv4.pipe( - Effect.mapError(() => failure("Unable to allocate pooler tenant file")), - ); - const temporary = path.join(poolerRoot, `.pooler_tenant.exs.${token}.tmp`); - return yield* Effect.gen(function* () { - yield* mapFile( - poolerRoot, - "create pooler tenant directory", - fs.makeDirectory(poolerRoot, { recursive: true, mode: 0o700 }), - ); - yield* mapFile(poolerRoot, "secure pooler tenant directory", fs.chmod(poolerRoot, 0o700)); - yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* mapFile( - temporary, - "create pooler tenant file", - fs.open(temporary, { flag: "w", mode: fileMode }), - ); - yield* mapFile( - temporary, - "write pooler tenant file", - file.writeAll(new TextEncoder().encode(content)), - ); - yield* mapFile(temporary, "sync pooler tenant file", file.sync); - }), - ); - yield* mapFile(temporary, "secure pooler tenant file", fs.chmod(temporary, fileMode)); - yield* mapFile(target, "publish pooler tenant file", fs.rename(temporary, target)); - yield* mapFile(target, "secure published pooler tenant file", fs.chmod(target, fileMode)); - return target; - }).pipe( - Effect.ensuring( - fs - .remove(temporary, { force: true }) - .pipe(Effect.catchTag("PlatformError", () => Effect.void)), - ), - ); - }); - }; - const writeVectorConfig = ( state: PersistedStackState, ): Effect.Effect => { @@ -610,7 +456,6 @@ export const makeRuntimeInputOwner = ( const commonCompleted = new Map(); const authCompleted = new Map>(); - const poolerCompleted = new Map(); const keyFor = (state: PersistedStackState): string => `${options.stackId}\u0000${canonicalize(state.runtime)}\u0000${canonicalize(state.definition ?? {})}`; const needsAuthMaterial = (state: PersistedStackState, workloadId: string): boolean => @@ -693,27 +538,11 @@ export const makeRuntimeInputOwner = ( commonCompleted, materializeCommon(state, workloadId, auth), ); - if ( - workloadId !== "pooler:pooler" || - state.definition?.capabilities.pooler.enabled !== true - ) - return common; - const tenantPath = yield* resolveCached( - key, - poolerCompleted, - writePoolerTenant(state, state.runtime.kind), - ); - return { ...common, pooler: { tenantPath } }; + return common; }); const cleanupAll = Effect.gen(function* () { commonCompleted.clear(); authCompleted.clear(); - poolerCompleted.clear(); - yield* mapFile( - poolerRoot, - "clean pooler tenant files", - fs.remove(poolerRoot, { recursive: true, force: true }), - ); yield* mapFile( vectorRoot, "clean Vector configs", diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 1c993629e8..b4b1c04020 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -14,11 +14,7 @@ import type { ContainerMount, ContainerStartupProcess, } from "./ContainerEngine.ts"; -import { - catalogEntryFor, - containerAliasFor, - type NativeWorkloadProcess, -} from "../model/WorkloadCatalog.ts"; +import { catalogEntryFor, containerAliasFor } from "../model/WorkloadCatalog.ts"; import { parseFileSize } from "../model/capabilities/storage.ts"; import { resolveThirdPartyIssuer } from "../model/capabilities/auth-third-party.ts"; import { Effect, type Duration } from "effect"; @@ -72,8 +68,6 @@ export interface WorkloadRuntimeInputs { /** Stack-owned native persistent data paths. Containers use their named volumes instead. */ readonly database?: Readonly<{ readonly dataPath?: string }>; readonly storage?: Readonly<{ readonly dataPath?: string }>; - /** Owner-resolved tenant provisioning file for the Pooler container. */ - readonly pooler?: Readonly<{ readonly tenantPath?: string }>; /** Host route used by containers to reach StackGateway. */ readonly hostRoute?: ContainerHostRoute; } @@ -239,17 +233,6 @@ export const validateWorkloadRuntimeInputs = ( workload: workload.id, }); } - if (workload.id === "pooler:pooler" && inputs.pooler?.tenantPath !== undefined) { - const tenantPath = inputs.pooler.tenantPath; - if ( - tenantPath.length === 0 || - [...tenantPath].some((character) => character.charCodeAt(0) < 0x20) - ) - return yield* new StackPreparationError({ - message: "Resolved Pooler tenant path is invalid", - workload: workload.id, - }); - } if (workload.id === "auth:auth") { if ( signing?.kind === "jwks-file" && @@ -482,20 +465,6 @@ const nativeProcessFor = ( inputs: WorkloadRuntimeInputs = {}, ): NativeProcessResolution => { const catalog = catalogEntryFor(workload.id); - const metadata: NativeWorkloadProcess | undefined = catalog?.nativeProcess; - if (metadata !== undefined) { - const metadataArgs = metadata.args.map((arg) => - arg.startsWith("app/") || arg.startsWith("share/") ? artifactPath(artifactRoot, arg) : arg, - ); - return { - executable: artifactPath(artifactRoot, metadata.executablePath), - args: nativeArgsFor(workload, metadataArgs, inputs), - cwd: artifactPath(artifactRoot, metadata.cwd), - ...(workload.id === "database:database" - ? { gracefulStopSignal: "SIGINT", gracefulStopTimeout: "15 seconds" } - : {}), - }; - } const executablePath = catalog?.executablePath; const resolvedPort = privatePortFor(state, workload.id, "primary") ?? port; const args = spec @@ -519,7 +488,7 @@ const nativeStartupProcessesFor = ( artifactRoot: string, _state: PersistedStackState, workload: PlannedWorkload, - inputs: WorkloadRuntimeInputs = {}, + _inputs: WorkloadRuntimeInputs = {}, ): ReadonlyArray => { const artifact = (relative: string): string => artifactPath(artifactRoot, relative); const cwd = artifactRoot; @@ -527,49 +496,16 @@ const nativeStartupProcessesFor = ( case "auth:auth": return [{ executable: artifact("bin/auth"), args: ["migrate"], cwd }]; case "storage:storage": - return [ - { - executable: artifact("node/bin/node"), - args: [artifact("app/dist/scripts/migrate-call.js")], - cwd: artifact("app"), - }, - ]; + return [{ executable: artifact("bin/prepare"), args: [], cwd }]; case "realtime:realtime": - return [ - { executable: artifact("bin/migrate"), args: [], cwd }, - { - executable: artifact("bin/realtime"), - args: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], - cwd, - }, - ]; + return [{ executable: artifact("bin/prepare"), args: [], cwd }]; case "analytics:analytics": + return [{ executable: artifact("bin/prepare"), args: [], cwd }]; + case "pooler:pooler": return [ - { - executable: artifact("bin/logflare"), - args: ["eval", "Logflare.Release.migrate"], - cwd, - }, - ]; - case "pooler:pooler": { - const migrate = { executable: artifact("bin/migrate"), args: [], cwd }; - const tenantPath = inputs.pooler?.tenantPath; - if (tenantPath === undefined) return [migrate]; - return [ - migrate, - { - executable: "/bin/sh", - args: [ - "-c", - 'exec "$1" eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"', - "supavisor", - artifact("bin/supavisor"), - ], - cwd, - env: { SUPABASE_POOLER_TENANT_PATH: tenantPath }, - }, + { executable: artifact("bin/prepare"), args: [], cwd }, + { executable: artifact("bin/provision-tenant"), args: [], cwd }, ]; - } default: return []; } @@ -1027,6 +963,8 @@ const specs: Readonly> = { runtime === "container" ? "/var/lib/postgresql/data" : (inputs.database?.dataPath ?? `${state.identity.projectRoot}/.supabase/db/data`), + POSTGRES_USER: "supabase_admin", + POSTGRES_DB: "postgres", POSTGRES_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), TZDIR: "/var/db/timezone/zoneinfo", }), @@ -1082,13 +1020,7 @@ const specs: Readonly> = { }), containerEntrypoint: "/usr/bin/tini", containerArgs: () => ["-s", "-g", "--", "/app/bin/server"], - containerStartupProcesses: () => [ - { entrypoint: "/app/bin/migrate", command: [] }, - { - entrypoint: "/app/bin/realtime", - command: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], - }, - ], + containerStartupProcesses: () => [{ entrypoint: "/app/bin/prepare", command: [] }], readiness: { protocol: "http", path: "/healthcheck" }, }, "storage:storage": { @@ -1097,9 +1029,7 @@ const specs: Readonly> = { env: (state, workload, port, runtime = "native", inputs = {}) => withStorageSettings(state, runtime, port, workload, inputs), containerArgs: () => [], - containerStartupProcesses: () => [ - { entrypoint: "/node/bin/node", command: ["dist/scripts/migrate-call.js"] }, - ], + containerStartupProcesses: () => [{ entrypoint: "/slim-runtime/bin/prepare", command: [] }], readiness: { protocol: "http", path: "/status" }, }, "storage:imgproxy": { @@ -1279,7 +1209,7 @@ const specs: Readonly> = { "pooler:pooler": { bindings: { primary: { containerPort: 6543 }, admin: { containerPort: 4000 } }, args: () => ["start"], - env: (state, workload, port, runtime = "native", inputs = {}) => { + env: (state, workload, port, runtime = "native", _inputs = {}) => { const adminPort = runtime === "container" ? 4000 : privatePortFor(state, "pooler:pooler", "admin"); const primaryPort = privatePortFor(state, "pooler:pooler", "primary"); @@ -1301,6 +1231,9 @@ const specs: Readonly> = { ? String(primaryPort) : "6543", DATABASE_URL: `ecto://postgres:${secret(state, DATABASE_INTERNAL_PASSWORD_SLOT)}@${dbHost(runtime)}:${runtime === "container" ? 5432 : dbPort(state)}/_supabase`, + POSTGRES_HOST: dbHost(runtime), + POSTGRES_PORT: String(runtime === "container" ? 5432 : dbPort(state)), + POSTGRES_PASSWORD: secret(state, DATABASE_INTERNAL_PASSWORD_SLOT), API_JWT_SECRET: secret(state, "secret:auth.settings.jwt_secret"), REGION: "local", TENANT_ID: valueAt(state, "pooler", "tenant_id"), @@ -1311,41 +1244,15 @@ const specs: Readonly> = { DEFAULT_POOL_SIZE: valueAt(state, "pooler", "default_pool_size"), MAX_CLIENT_CONN: valueAt(state, "pooler", "max_client_conn"), POOL_MODE: valueAt(state, "pooler", "pool_mode"), - ...(runtime === "container" && inputs.pooler?.tenantPath !== undefined - ? { SUPABASE_POOLER_TENANT_PATH: "/app/pooler_tenant.exs" } - : {}), }; }, - // Mirror native convergence explicitly: migrate before the main server. - // Tenant provisioning remains a separate owner/bootstrap phase. + // Mirror native convergence explicitly: prepare and provision before the main server. containerEntrypoint: "/usr/bin/tini", - containerStartupProcesses: (_state, _workload, inputs = {}) => { - const migrate = { entrypoint: "/app/bin/migrate", command: [] }; - const tenantPath = inputs.pooler?.tenantPath; - return tenantPath === undefined - ? [migrate] - : [ - migrate, - { - entrypoint: "/usr/bin/sh", - command: [ - "-c", - 'exec /app/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"', - ], - }, - ]; - }, + containerStartupProcesses: () => [ + { entrypoint: "/app/bin/prepare", command: [] }, + { entrypoint: "/app/bin/provision-tenant", command: [] }, + ], containerArgs: () => ["-s", "-g", "--", "/app/bin/server"], - containerMounts: (_state, _workload, inputs = {}) => - inputs.pooler?.tenantPath === undefined - ? [] - : [ - { - source: inputs.pooler.tenantPath, - target: "/app/pooler_tenant.exs", - readOnly: true, - }, - ], readiness: { protocol: "http", path: "/api/health", binding: "admin" }, }, }; diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index af487102b5..53633daf2d 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -409,66 +409,6 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); - it.live("runs post-readiness startup processes before reporting ready", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-post-ready-" }); - const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); - const postReadiness = { - executable: process.execPath, - args: [ - "-e", - 'process.stdout.write("post-ready\\n"); process.stderr.write("post-ready-stderr\\n")', - ], - }; - const runtime = yield* makeNativeRuntime({ - resolveProcess: () => - Effect.succeed({ - startup: [], - postReadiness: [postReadiness], - main: fixtureProcess("main"), - }), - logStore, - waitForReadiness: () => Effect.void, - }); - const key = keyFor("post-readiness"); - const ready = yield* runtime.start(key, workload("post-readiness")); - expect(ready.state).toBe("ready"); - const messages = (yield* logStore.read()).map((entry) => entry.message); - expect(messages).toContain("post-ready"); - expect(messages).toContain("post-ready-stderr"); - yield* runtime.remove(key); - }), - ), - ); - - it.live("writes a completion witness only after a successful post-readiness process", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-native-marker-" }); - const marker = path.join(root, "data", "migration-complete"); - const runtime = yield* makeNativeRuntime({ - resolveProcess: () => - Effect.succeed({ - startup: [], - postReadiness: [{ ...oneShotProcess("migrated"), successMarker: marker }], - main: fixtureProcess("main"), - }), - waitForReadiness: () => Effect.void, - }); - const key = keyFor("marker"); - const ready = yield* runtime.start(key, workload("marker")); - expect(ready.state).toBe("ready"); - expect(yield* fs.readFileString(marker)).toBe("completed\n"); - yield* runtime.stop(key); - }), - ), - ); - it.live("bounds a native one-shot before it can report readiness", () => withPlatform( Effect.gen(function* () { @@ -540,208 +480,6 @@ describe("native runtime", { timeout: 15_000 }, () => { ), ); - it.live("does not report ready when post-readiness startup fails", () => - withPlatform( - Effect.gen(function* () { - let readinessCalled = false; - const runtime = yield* makeNativeRuntime({ - resolveProcess: () => - Effect.succeed({ - startup: [], - postReadiness: [oneShotProcess("post-ready-failed", 7)], - main: fixtureProcess("main"), - }), - waitForReadiness: () => - Effect.sync(() => { - readinessCalled = true; - }), - }); - const result = yield* runtime - .start(keyFor("post-readiness-failed"), workload("post-readiness-failed")) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) - expect(Cause.pretty(result.cause)).toContain( - "Native post-readiness process exited with code 7", - ); - expect(readinessCalled).toBe(true); - expect(yield* runtime.observe(stackId)).toEqual([]); - }), - ), - ); - - it.live("fails when the main workload exits while post-readiness is blocked", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-native-post-ready-main-exit-", - }); - const readyPath = path.join(root, "main-ready"); - const releasePath = path.join(root, "post-release"); - expect(spawnSync("mkfifo", [readyPath]).status).toBe(0); - expect(spawnSync("mkfifo", [releasePath]).status).toBe(0); - const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); - const postStartedSignal = yield* Deferred.make(); - const signaledLogStore = signalOnLog(logStore, "post-blocked", postStartedSignal); - const postReadiness = { - executable: process.execPath, - args: [ - "-e", - `const fs = require("node:fs"); process.stdout.write("post-blocked\\n"); const fd = fs.openSync(${JSON.stringify(releasePath)}, "r"); fs.readSync(fd, Buffer.alloc(1), 0, 1, null); fs.closeSync(fd); process.stdout.write("post-complete\\n")`, - ], - }; - const main = { - executable: process.execPath, - args: [ - "-e", - `const fs = require("node:fs"); const fd = fs.openSync(${JSON.stringify(readyPath)}, "r"); fs.readSync(fd, Buffer.alloc(1), 0, 1, null); fs.closeSync(fd); process.stdout.write("main-exited\\n"); process.exit(0)`, - ], - }; - const runtime = yield* makeNativeRuntime({ - resolveProcess: () => - Effect.succeed({ startup: [], postReadiness: [postReadiness], main }), - logStore: signaledLogStore, - waitForReadiness: () => Effect.void, - }); - const key = keyFor("post-readiness-main-exit"); - const caller = yield* runtime - .start(key, workload("post-readiness-main-exit")) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(postStartedSignal); - const releaseMain = spawnSync( - process.execPath, - ["-e", `require("node:fs").writeFileSync(${JSON.stringify(readyPath)}, "x")`], - { encoding: "utf8", timeout: 5_000 }, - ); - expect(releaseMain.error).toBeUndefined(); - expect(releaseMain.status).toBe(0); - const result = yield* Fiber.join(caller).pipe( - Effect.exit, - Effect.timeoutOrElse({ - duration: "5 seconds", - orElse: () => - Effect.fail( - new ProcessTreeTestError({ - message: "Native caller lifecycle result timed out", - }), - ), - }), - ); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) - expect(Cause.pretty(result.cause)).toContain("exited before readiness"); - expect(yield* runtime.observe(stackId)).toEqual([]); - }), - ), - ); - - it.live("interrupts a post-readiness startup process through its exact child scope", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-native-post-interrupt-", - }); - const pidPath = path.join(root, "pid"); - const logStore = yield* makeLogStore({ path: path.join(root, "logs.json") }); - const startedSignal = yield* Deferred.make(); - const signaledLogStore = signalOnLog(logStore, "post-started", startedSignal); - const runtime = yield* makeNativeRuntime({ - resolveProcess: () => - Effect.succeed({ - startup: [], - postReadiness: [ - { - executable: process.execPath, - args: [ - "-e", - `require("node:fs").writeFileSync(${JSON.stringify(pidPath)},String(process.pid)); process.stdout.write("post-started\\n"); setInterval(()=>{},1000)`, - ], - }, - ], - main: fixtureProcess("never-main"), - }), - logStore: signaledLogStore, - waitForReadiness: () => Effect.void, - }); - const key = keyFor("post-readiness-interrupted"); - const caller = yield* runtime - .start(key, workload("post-readiness-interrupted")) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(startedSignal); - yield* runtime.stop(key); - const result = yield* Fiber.join(caller).pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - const pid = Number.parseInt(yield* fs.readFileString(pidPath), 10); - expect(Number.isSafeInteger(pid)).toBe(true); - const processState = yield* Effect.sync(() => { - const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { - encoding: "utf8", - }); - return result.stdout.trim(); - }); - expect(processState === "" || processState.startsWith("Z")).toBe(true); - }), - ), - ); - - it.live("interrupts a blocked post-readiness child when log persistence fails", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-native-post-log-failure-", - }); - const pidPath = path.join(root, "pid"); - const stoppedPath = path.join(root, "stopped"); - const logStore: LogStore = { - path: "memory://native-post-log-failure", - append: () => - Effect.fail( - new LogStoreError({ - path: "memory://native-post-log-failure", - message: "disk full", - }), - ), - read: () => Effect.succeed([]), - }; - const runtime = yield* makeNativeRuntime({ - resolveProcess: () => - Effect.succeed({ - startup: [], - postReadiness: [ - { - executable: process.execPath, - args: [ - "-e", - `const fs=require("node:fs"); fs.writeFileSync(${JSON.stringify(pidPath)},String(process.pid)); const stop=()=>{fs.writeFileSync(${JSON.stringify(stoppedPath)},"stopped"); process.exit(0)}; process.on("SIGTERM",stop); process.on("SIGINT",stop); process.stdout.write("post-log-failure\\n"); setInterval(()=>{},1000);`, - ], - }, - ], - main: { - executable: process.execPath, - args: ["-e", "setInterval(() => {}, 1000)"], - }, - }), - logStore, - waitForReadiness: () => Effect.void, - }); - const result = yield* runtime - .start(keyFor("post-log-failure"), workload("post-log-failure")) - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - const pid = Number.parseInt(yield* fs.readFileString(pidPath), 10); - expect(Number.isSafeInteger(pid)).toBe(true); - expect(yield* fs.readFileString(stoppedPath)).toBe("stopped"); - expect(yield* runtime.observe(stackId)).toEqual([]); - }), - ), - ); - it.live("does not spawn the native main process when startup exits nonzero", () => withPlatform( Effect.gen(function* () { @@ -887,54 +625,26 @@ describe("native runtime", { timeout: 15_000 }, () => { it.live("runs database bootstrap before reporting readiness", () => withPlatform( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-native-post-ready-bootstrap-", - }); - const postCompletePath = path.join(root, "post-complete"); - let applied = false; - let postCompletedBeforeBootstrap = false; + let readinessCalled = false; + let bootstrapCalledAfterReadiness = false; const runtime = yield* makeNativeRuntime({ resolveProcess: () => Effect.succeed({ startup: [], - postReadiness: [ - { - executable: process.execPath, - args: [ - "-e", - `require("node:fs").writeFileSync(${JSON.stringify(postCompletePath)}, "done"); process.stdout.write("post-complete\\n")`, - ], - }, - ], main: fixtureProcess("database"), }), - waitForReadiness: () => Effect.void, + waitForReadiness: () => + Effect.sync(() => { + readinessCalled = true; + }), bootstrapDatabase: () => - fs.exists(postCompletePath).pipe( - Effect.mapError( - (error) => - new RuntimeDriverError({ - message: "Unable to inspect post-readiness marker", - stackId, - workloadId: "database:bootstrap", - cause: error, - }), - ), - Effect.tap((completed) => - Effect.sync(() => { - postCompletedBeforeBootstrap = completed; - applied = true; - }), - ), - Effect.asVoid, - ), + Effect.sync(() => { + bootstrapCalledAfterReadiness = readinessCalled; + }), }); const key = keyFor("bootstrap"); const ready = yield* runtime.start(key, workload("bootstrap", "database")); - expect(applied).toBe(true); - expect(postCompletedBeforeBootstrap).toBe(true); + expect(bootstrapCalledAfterReadiness).toBe(true); expect(ready.state).toBe("ready"); yield* runtime.stop(key); yield* runtime.remove(key); diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 785f49a080..0143cbd143 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -35,7 +35,6 @@ import { LogStoreError } from "../supervisor/LogStore.ts"; import type { LifecycleInput } from "../supervisor/Lifecycle.ts"; import { makeProductionRuntime, - NATIVE_DATABASE_MIGRATION_MARKER, readinessDeadlineFor, withOwnedRuntimeFileCleanup, } from "./ProductionRuntime.ts"; @@ -159,31 +158,32 @@ const writeNativeDatabaseFixture = ( path: Path.Path, root: string, eventsPath: string, - failFirstMigration = false, ) => Effect.gen(function* () { - const initDirectory = path.join(root, "share/supabase-cli/bin"); - const migrationDirectory = path.join(root, "share/supabase-cli/migrations"); - yield* fs.makeDirectory(initDirectory, { recursive: true }); - yield* fs.makeDirectory(migrationDirectory, { recursive: true }); - const main = path.join(initDirectory, "supabase-postgres-init.sh"); + const binDirectory = path.join(root, "bin"); + yield* fs.makeDirectory(binDirectory, { recursive: true }); + const main = path.join(binDirectory, "supabase-postgres-start"); + const helperScript = [ + 'const fs = require("node:fs");', + 'const net = require("node:net");', + "const args = process.argv.slice(1);", + 'const port = Number(args[args.indexOf("-p") + 1]);', + `fs.appendFileSync(${JSON.stringify(eventsPath)}, "postgres-start|data=" + process.env.PGDATA + "|user=" + process.env.POSTGRES_USER + "|db=" + process.env.POSTGRES_DB + "|password=" + process.env.POSTGRES_PASSWORD + "|args=" + args.join(" ") + "\\n");`, + "const server = net.createServer((socket) => socket.end());", + 'server.listen(port, "127.0.0.1");', + "const stop = () => server.close(() => process.exit(0));", + 'process.on("SIGTERM", stop);', + 'process.on("SIGINT", stop);', + ].join(""); yield* fs.writeFileString( main, - "#!/bin/sh\ntrap 'exit 0' TERM INT\nwhile :; do sleep 1; done\n", - ); - yield* fs.chmod(main, 0o755); - const migrate = path.join(migrationDirectory, "migrate.sh"); - const firstMigrationPath = `${eventsPath}.migration-attempted`; - yield* fs.writeFileString( - migrate, `#!/bin/sh set -eu -${failFirstMigration ? `if [ ! -e ${JSON.stringify(firstMigrationPath)} ]; then touch ${JSON.stringify(firstMigrationPath)}; exit 7; fi` : ""} -printf 'migration|host=%s|port=%s|db=%s|user=%s|password=%s|path=%s\\n' "$POSTGRES_HOST" "$POSTGRES_PORT" "$POSTGRES_DB" "$POSTGRES_USER" "$POSTGRES_PASSWORD" "$PATH" >> ${JSON.stringify(eventsPath)} +exec ${JSON.stringify(process.execPath)} -e ${JSON.stringify(helperScript)} -- "$@" `, ); - yield* fs.chmod(migrate, 0o755); - return { main, migrate }; + yield* fs.chmod(main, 0o755); + return { main }; }); const writeNativeRealtimeFixture = ( @@ -203,6 +203,19 @@ printf 'realtime-migrate|RELEASE_DISTRIBUTION=%s\\n' "\${RELEASE_DISTRIBUTION:-m `, ); yield* fs.chmod(migrate, 0o755); + const prepare = path.join(bin, "prepare"); + yield* fs.writeFileString( + prepare, + `#!/bin/sh +set -eu +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)" +"$SCRIPT_DIR/migrate" +if [ "\${SEED_SELF_HOST:-}" = true ]; then + "$SCRIPT_DIR/realtime" eval 'Realtime.Release.seeds(Realtime.Repo)' +fi +`, + ); + yield* fs.chmod(prepare, 0o755); const realtime = path.join(bin, "realtime"); yield* fs.writeFileString(realtime, "#!/bin/sh\nexit 0\n"); yield* fs.chmod(realtime, 0o755); @@ -1627,7 +1640,7 @@ describe("production runtime", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.live("runs canonical PostgreSQL migrations before bootstrap on first native boot", () => + it.live("starts the canonical PostgreSQL helper before bootstrap", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1635,12 +1648,18 @@ describe("production runtime", () => { const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-production-pg-first-" }); const eventsPath = path.join(root, "events"); const artifactRoot = path.join(root, "artifact"); + yield* fs.writeFileString(eventsPath, ""); yield* writeNativeDatabaseFixture(fs, path, artifactRoot, eventsPath); - const readinessServer = createNetServer((socket) => socket.end()); + const readinessServer = createNetServer(); yield* listenForNativeReadiness(readinessServer); const address = readinessServer.address(); if (typeof address !== "object" || address === null) return yield* Effect.die("Native readiness server did not expose an address"); + yield* Effect.callback((resume) => { + readinessServer.close((error) => + error === undefined ? resume(Effect.void) : resume(Effect.fail(error)), + ); + }); const compiled = yield* compileStack({ projectRoot: root, runtime: { kind: "native" }, @@ -1709,17 +1728,13 @@ describe("production runtime", () => { database, ); expect(ready.state).toBe("ready"); - expect( - yield* fs.exists(path.join(databaseDataPath, NATIVE_DATABASE_MIGRATION_MARKER)), - ).toBe(true); const events = yield* fs.readFileString(eventsPath); - expect(events).toContain("migration|host=127.0.0.1"); - expect(events).toContain("port=" + String(address.port)); - expect(events).toContain("db=postgres"); + expect(events).toContain(`postgres-start|data=${databaseDataPath}`); expect(events).toContain("user=supabase_admin"); + expect(events).toContain("db=postgres"); expect(events).toContain("password=db-secret"); - expect(events).toContain(`path=${path.join(artifactRoot, "bin")}:`); - expect(events.indexOf("migration")).toBeLessThan(events.indexOf("bootstrap")); + expect(events).toContain(`args=-p ${address.port}`); + expect(events.indexOf("postgres-start")).toBeLessThan(events.indexOf("bootstrap")); yield* runtime.driver.stop({ stackId, workloadId: database.id, @@ -1728,144 +1743,6 @@ describe("production runtime", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.live( - "retries canonical PostgreSQL migrations when PG_VERSION exists without completion marker", - () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-production-pg-existing-", - }); - const eventsPath = path.join(root, "events"); - const artifactRoot = path.join(root, "artifact"); - yield* writeNativeDatabaseFixture(fs, path, artifactRoot, eventsPath, true); - const readinessServer = createNetServer((socket) => socket.end()); - yield* listenForNativeReadiness(readinessServer); - const address = readinessServer.address(); - if (typeof address !== "object" || address === null) - return yield* Effect.die("Native readiness server did not expose an address"); - const compiled = yield* compileStack({ - projectRoot: root, - runtime: { kind: "native" }, - }); - const current = { - value: { - ...stateFor({ - "secret:database.internal.password": { policy: "managed", value: "db-secret" }, - "secret:auth.settings.jwt_secret": { policy: "managed", value: "jwt-secret" }, - }), - identity: { - ...stateFor({}).identity, - projectRoot: root, - checkoutRoot: root, - workspaceId: root, - checkoutId: root, - }, - desiredLifecycle: "running" as const, - definition: compiled.definition, - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: address.port }, - ], - }, - } satisfies { value: PersistedStackState }; - const runtimePaths = yield* resolveStackPaths({ stateRoot: root, stackId }); - const databaseDataPath = path.join(runtimePaths.data, "database"); - yield* fs.makeDirectory(databaseDataPath, { recursive: true }); - yield* fs.writeFileString(path.join(databaseDataPath, "PG_VERSION"), "17\n"); - // A completed initdb has both PG_VERSION and postmaster.opts. The - // missing migration marker must not erase its durable contents. - yield* fs.writeFileString(path.join(databaseDataPath, "postmaster.opts"), "postgres\n"); - const durableMarker = path.join(databaseDataPath, "durable-user-data"); - yield* fs.writeFileString(durableMarker, "preserve-me"); - const bootstrap = () => - Effect.gen(function* () { - const previous = yield* fs - .readFileString(eventsPath) - .pipe(Effect.catchTag("PlatformError", () => Effect.succeed(""))); - yield* fs.writeFileString(eventsPath, `${previous}bootstrap\n`); - }).pipe( - Effect.mapError( - (cause) => - new StackPreparationError({ message: "bootstrap fixture failed", cause }), - ), - ); - const context = yield* Effect.context< - FileSystem.FileSystem | Path.Path | Crypto.Crypto - >(); - const runtime = yield* makeProductionRuntime({ - stateRoot: root, - stackId, - ownerSessionId: "owner", - stateStore: stateStoreFor(current), - context, - ingress, - artifactPreparer: { - prepare: () => - Effect.succeed({ - workloadId: "database:database", - capability: "database" as const, - version: compiled.definition.capabilities.database.version, - outcome: "cached" as const, - artifactRoot, - }), - }, - logStore: memoryLogStore([]), - bootstrapDatabase: bootstrap, - }); - const database = compiled.executionPlan.workloads.find( - (workload) => workload.id === "database:database", - ); - if (database === undefined) return yield* Effect.die("Expected database workload"); - const failed = yield* runtime.driver - .start( - { - stackId, - workloadId: database.id, - }, - database, - ) - .pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - expect(yield* fs.readFileString(durableMarker)).toBe("preserve-me"); - expect( - yield* fs.exists(path.join(databaseDataPath, NATIVE_DATABASE_MIGRATION_MARKER)), - ).toBe(false); - yield* runtime.driver.start( - { - stackId, - workloadId: database.id, - }, - database, - ); - const events = yield* fs.readFileString(eventsPath); - expect(events).toContain("migration|host=127.0.0.1"); - expect(events).toContain("bootstrap\n"); - expect( - yield* fs.exists(path.join(databaseDataPath, NATIVE_DATABASE_MIGRATION_MARKER)), - ).toBe(true); - yield* runtime.driver.stop({ - stackId, - workloadId: database.id, - }); - yield* fs.writeFileString(eventsPath, ""); - yield* runtime.driver.start( - { - stackId, - workloadId: database.id, - }, - database, - ); - expect(yield* fs.readFileString(eventsPath)).toBe("bootstrap\n"); - yield* runtime.driver.stop({ - stackId, - workloadId: database.id, - }); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - it.live("wires persisted database and generic readiness deadlines", () => Effect.gen(function* () { const configured = yield* compileStack({ @@ -2542,29 +2419,15 @@ describe("production runtime", () => { const poolerStartupSpecs = createdSpecs.filter( (spec) => spec.labels.workloadId === pooler.id && spec.labels.startup === true, ); - expect(poolerSpec?.mounts).toContainEqual({ - source: expect.stringContaining("pooler_tenant.exs"), - target: "/app/pooler_tenant.exs", - readOnly: true, - }); - expect( - poolerStartupSpecs.some((spec) => - spec.command?.join(" ").includes("/app/bin/supavisor eval"), - ), - ).toBe(true); - const tenantPath = poolerSpec?.mounts.find( - (mount) => mount.target === "/app/pooler_tenant.exs", - )?.source; - if (tenantPath === undefined) - return yield* Effect.die("Pooler tenant mount was not captured"); - expect(yield* fs.exists(tenantPath)).toBe(true); + expect(poolerSpec?.mounts).toEqual([]); + expect(poolerStartupSpecs.map((spec) => spec.entrypoint)).toEqual([ + "/app/bin/prepare", + "/app/bin/provision-tenant", + ]); yield* runtime.driver.stop({ stackId, workloadId: pooler.id, }); - // Runtime inputs are session-owned and remain available across an individual workload - // restart; the owner cleanup at session end removes the flat path. - expect(yield* fs.exists(tenantPath)).toBe(true); yield* runtime.driver.start( { stackId, @@ -2572,15 +2435,7 @@ describe("production runtime", () => { }, pooler, ); - const secondSpec = createdSpecs.findLast((spec) => spec.labels.workloadId === pooler.id); - const secondTenantPath = secondSpec?.mounts.find( - (mount) => mount.target === "/app/pooler_tenant.exs", - )?.source; - if (secondTenantPath === undefined) - return yield* Effect.die("Second Pooler tenant mount was not captured"); - expect(yield* fs.exists(secondTenantPath)).toBe(true); yield* runtime.driver.cleanup({ stackId, destroy: false }); - expect(yield* fs.exists(secondTenantPath)).toBe(false); expect(yield* fs.exists(firstBootstrapPath)).toBe(false); yield* runtime.driver.start( { diff --git a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts index 60b6e42b74..da5014f479 100644 --- a/packages/stack/src/runtime/runtime-input-owner.integration.test.ts +++ b/packages/stack/src/runtime/runtime-input-owner.integration.test.ts @@ -8,7 +8,6 @@ import { StackIdSchema } from "../public/StackId.ts"; import type { StackRuntime } from "../public/Runtime.ts"; import type { PersistedStackState } from "../state/StackState.ts"; import { resolveSecrets } from "../state/SecretStore.ts"; -import { containerAliasFor } from "../model/WorkloadCatalog.ts"; import { makeRuntimeInputOwner } from "./RuntimeInputOwner.ts"; import { resolveContainerResolutionFor } from "./WorkloadRuntimeSpec.ts"; @@ -490,46 +489,6 @@ describe("runtime input owner", () => { ), ); - it.live("writes a session-scoped owner-only Pooler tenant file and cleans it", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-" }); - const state = yield* compiledState(root, { - capabilities: { - analytics: { settings: { vector_port: 9001 } }, - pooler: { enabled: true, settings: { pool_mode: "session" } }, - }, - }); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const nativeState: PersistedStackState = { - ...state, - privatePorts: [ - { workloadId: "database:database", binding: "primary", port: 30_001 }, - { workloadId: "analytics:vector", binding: "primary", port: 30_008 }, - ], - }; - const first = yield* owner.resolve(nativeState, "pooler:pooler"); - const second = yield* owner.resolve(nativeState, "pooler:pooler"); - const tenant = first.pooler?.tenantPath; - expect(second.pooler?.tenantPath).toBe(tenant); - expect(tenant).toBeDefined(); - expect((yield* fs.stat(tenant!)).mode & 0o777).toBe(0o600); - const content = yield* fs.readFileString(tenant!); - expect(content).toContain('"db_host" => "127.0.0.1"'); - expect(content).toContain('"external_id" => "pooler-dev"'); - expect(content).toContain('"db_port" => 30001'); - expect(content).not.toContain('"db_password" => ""'); - const analytics = yield* owner.resolve(nativeState, "analytics:vector"); - expect(analytics.analytics?.vectorConfigPath).toBeDefined(); - yield* owner.cleanupAll; - expect(yield* fs.exists(path.dirname(tenant!))).toBe(false); - expect(yield* fs.exists(analytics.analytics?.vectorConfigPath ?? "")).toBe(false); - }), - ), - ); - it.live("writes and cleans session-scoped Vector config material", () => withPlatform( Effect.gen(function* () { @@ -573,160 +532,6 @@ describe("runtime input owner", () => { ), ); - it.live("does not recreate cleaned Pooler material for non-Pooler work", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-lazy-" }); - const state = yield* compiledState(root, { - capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, - }); - const nativeState: PersistedStackState = { - ...state, - privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], - }; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const first = yield* owner.resolve(nativeState, "pooler:pooler"); - const tenant = first.pooler?.tenantPath; - expect(tenant).toBeDefined(); - yield* owner.cleanupAll; - const common = yield* owner.resolve(nativeState, "database:database"); - expect(common.pooler).toBeUndefined(); - expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( - false, - ); - const recreated = yield* owner.resolve(nativeState, "pooler:pooler"); - expect(recreated.pooler?.tenantPath).toBeDefined(); - expect(yield* fs.exists(recreated.pooler?.tenantPath ?? "")).toBe(true); - }), - ), - ); - - it.live("renders literal Elixir interpolation and container database endpoint", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "runtime-input-pooler-container-", - }); - const base = yield* compiledState(root, { - capabilities: { - pooler: { - enabled: true, - settings: { - tenant_id: "tenant-custom", - pool_mode: "transaction", - }, - }, - }, - }); - const state = { - ...base, - runtime: { kind: "container", engine: "docker" } as const, - secrets: { - ...base.secrets, - "secret:database.internal.password": { - policy: "managed" as const, - value: "#{System.halt()}", - }, - }, - }; - const containerState = state; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(containerState, "pooler:pooler"); - const tenant = material.pooler?.tenantPath; - expect(tenant).toBeDefined(); - expect((yield* fs.stat(tenant!)).mode & 0o777).toBe(0o644); - const content = yield* fs.readFileString(tenant!); - expect(content).toContain('"external_id" => "tenant-custom"'); - expect(content).toContain(`"db_host" => "${containerAliasFor("database:database")}"`); - expect(content).toContain('"db_port" => 5432'); - expect(content).toContain("\\#{System.halt()}"); - expect(content).toContain("Supavisor.Repo.preload(existing, :users)"); - expect(content).toContain("Supavisor.Tenants.update_tenant(existing, params)"); - }), - ), - ); - - it.live("fails native Pooler resolution without the persisted database assignment", () => - withPlatform( - Effect.gen(function* () { - const root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "runtime-input-pooler-no-port-", - }); - const state = yield* compiledState(root, { - capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, - }); - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const failed = yield* owner.resolve(state, "pooler:pooler").pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - }), - ), - ); - - it.live("fails closed when persisted Pooler sizing is missing or blank", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-sizing-" }); - const base = yield* compiledState(root, { - capabilities: { pooler: { enabled: true, settings: { pool_mode: "session" } } }, - }); - const native = { - ...base, - privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], - }; - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- clone dynamic persisted fixture - const missing = JSON.parse(JSON.stringify(native)); - delete missing.definition.capabilities.pooler.settings.default_pool_size; - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- clone dynamic persisted fixture - const blank = JSON.parse(JSON.stringify(native)); - blank.definition.capabilities.pooler.settings.default_pool_size = " "; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - for (const invalid of [missing, blank]) { - const failed = yield* owner.resolve(invalid, "pooler:pooler").pipe(Effect.exit); - expect(errorOf(failed)?.message).toContain("sizing settings are invalid"); - expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( - false, - ); - } - }), - ), - ); - - it.live("does not resolve Functions material while provisioning Pooler", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "runtime-input-pooler-invalid-" }); - const state = yield* compiledState(root, { - capabilities: { - pooler: { enabled: true }, - functions: { - enabled: true, - settings: { - edge_runtime: { secrets: { SUPABASE_BAD: Redacted.make("x") } }, - }, - }, - }, - }); - const nativeState = { - ...state, - privatePorts: [{ workloadId: "database:database", binding: "primary", port: 30_001 }], - }; - const owner = yield* makeRuntimeInputOwner({ stateRoot: root, stackId }); - const material = yield* owner.resolve(nativeState, "pooler:pooler"); - expect(material.pooler?.tenantPath).toBeDefined(); - expect(yield* fs.exists(path.join(root, stackId, "runtime", "inputs", "pooler"))).toBe( - true, - ); - }), - ), - ); - it.live("shares OIDC material across JWT-consuming workloads", () => withPlatform( Effect.gen(function* () { diff --git a/packages/stack/src/runtime/workload-runtime.integration.test.ts b/packages/stack/src/runtime/workload-runtime.integration.test.ts index 9df922656b..45b9b32174 100644 --- a/packages/stack/src/runtime/workload-runtime.integration.test.ts +++ b/packages/stack/src/runtime/workload-runtime.integration.test.ts @@ -399,67 +399,26 @@ describe("workload runtime catalog", () => { ]); const storage = planned("storage:storage"); expect(runtimeSpecFor(storage)?.nativeStartupProcesses(root, state, storage, 5000)).toEqual([ - { - executable: `${root}/node/bin/node`, - args: [`${root}/app/dist/scripts/migrate-call.js`], - cwd: `${root}/app`, - }, + { executable: `${root}/bin/prepare`, args: [], cwd: root }, ]); const realtime = planned("realtime:realtime"); expect(runtimeSpecFor(realtime)?.nativeStartupProcesses(root, state, realtime, 4000)).toEqual([ - { executable: `${root}/bin/migrate`, args: [], cwd: root }, - { - executable: `${root}/bin/realtime`, - args: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], - cwd: root, - }, + { executable: `${root}/bin/prepare`, args: [], cwd: root }, ]); const analytics = planned("analytics:analytics"); expect(runtimeSpecFor(analytics)?.nativeStartupProcesses(root, state, analytics, 4000)).toEqual( - [ - { - executable: `${root}/bin/logflare`, - args: ["eval", "Logflare.Release.migrate"], - cwd: root, - }, - ], + [{ executable: `${root}/bin/prepare`, args: [], cwd: root }], ); const pooler = planned("pooler:pooler"); expect(runtimeSpecFor(pooler)?.nativeStartupProcesses(root, state, pooler, 6543)).toEqual([ - { executable: `${root}/bin/migrate`, args: [], cwd: root }, + { executable: `${root}/bin/prepare`, args: [], cwd: root }, + { executable: `${root}/bin/provision-tenant`, args: [], cwd: root }, ]); - const tenantInput = { pooler: { tenantPath: "/tmp/pooler-tenant.exs" } }; - const tenantStartup = runtimeSpecFor(pooler)?.nativeStartupProcesses( - root, - state, - pooler, - 6543, - tenantInput, - ); - expect(tenantStartup).toHaveLength(2); - expect(tenantStartup?.[1]).toEqual({ - executable: "/bin/sh", - args: [ - "-c", - 'exec "$1" eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"', - "supavisor", - `${root}/bin/supavisor`, - ], - cwd: root, - env: { SUPABASE_POOLER_TENANT_PATH: "/tmp/pooler-tenant.exs" }, + expect(runtimeSpecFor(pooler)?.env(state, pooler, 6543, "container")).toMatchObject({ + POSTGRES_HOST: "supabase-database", + POSTGRES_PORT: "5432", + POSTGRES_PASSWORD: "postgres", }); - expect(tenantStartup?.[1]?.args.join(" ")).not.toContain("secret"); - - const nativeTenantEnv = runtimeSpecFor(pooler)?.env(state, pooler, 6543, "native", tenantInput); - expect(nativeTenantEnv).not.toHaveProperty("SUPABASE_POOLER_TENANT_PATH"); - const containerTenantEnv = runtimeSpecFor(pooler)?.env( - state, - pooler, - 6543, - "container", - tenantInput, - ); - expect(containerTenantEnv?.SUPABASE_POOLER_TENANT_PATH).toBe("/app/pooler_tenant.exs"); expect(containerResolutionFor(state, auth)?.command).toEqual([]); expect(containerResolutionFor(state, auth)?.startup).toEqual([ @@ -467,7 +426,7 @@ describe("workload runtime catalog", () => { ]); expect(containerResolutionFor(state, storage)?.command).toEqual([]); expect(containerResolutionFor(state, storage)?.startup).toEqual([ - { entrypoint: "/node/bin/node", command: ["dist/scripts/migrate-call.js"] }, + { entrypoint: "/slim-runtime/bin/prepare", command: [] }, ]); expect(containerResolutionFor(state, realtime)?.entrypoint).toBe("/usr/bin/tini"); expect(containerResolutionFor(state, realtime)?.command).toEqual([ @@ -477,11 +436,7 @@ describe("workload runtime catalog", () => { "/app/bin/server", ]); expect(containerResolutionFor(state, realtime)?.startup).toEqual([ - { entrypoint: "/app/bin/migrate", command: [] }, - { - entrypoint: "/app/bin/realtime", - command: ["eval", "Realtime.Release.seeds(Realtime.Repo)"], - }, + { entrypoint: "/app/bin/prepare", command: [] }, ]); expect(containerResolutionFor(state, analytics)?.command).toEqual([]); expect(containerResolutionFor(state, pooler)?.entrypoint).toBe("/usr/bin/tini"); @@ -492,22 +447,9 @@ describe("workload runtime catalog", () => { "/app/bin/server", ]); expect(containerResolutionFor(state, pooler)?.startup).toEqual([ - { entrypoint: "/app/bin/migrate", command: [] }, - ]); - const poolerWithTenant = containerResolutionFor(state, pooler, tenantInput); - expect(poolerWithTenant?.command).toEqual(["-s", "-g", "--", "/app/bin/server"]); - expect(poolerWithTenant?.startup).toEqual([ - { entrypoint: "/app/bin/migrate", command: [] }, - { - entrypoint: "/usr/bin/sh", - command: ["-c", 'exec /app/bin/supavisor eval "$(cat "$SUPABASE_POOLER_TENANT_PATH")"'], - }, - ]); - expect(poolerWithTenant?.mounts).toEqual([ - { source: "/tmp/pooler-tenant.exs", target: "/app/pooler_tenant.exs", readOnly: true }, + { entrypoint: "/app/bin/prepare", command: [] }, + { entrypoint: "/app/bin/provision-tenant", command: [] }, ]); - expect(poolerWithTenant?.env.SUPABASE_POOLER_TENANT_PATH).toBe("/app/pooler_tenant.exs"); - expect(poolerWithTenant?.command.join(" ")).not.toContain("secret"); }); it.live( @@ -858,9 +800,9 @@ describe("workload runtime catalog", () => { expect( studio?.nativeProcess(nodeArtifactRoot, configured, planned("studio:studio"), 3000), ).toEqual({ - executable: "/tmp/native-artifact/node/bin/node", - args: ["/tmp/native-artifact/app/apps/studio/docker-entrypoint.mjs"], - cwd: "/tmp/native-artifact/app", + executable: "/tmp/native-artifact/bin/studio", + args: [], + cwd: "/tmp/supabase-runtime-spec", }); expect( runtimeSpecFor(planned("database:database"))?.nativeProcess( @@ -1097,15 +1039,6 @@ describe("workload runtime catalog", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.live("validates owner-resolved Pooler tenant paths before runtime creation", () => - Effect.gen(function* () { - const failed = yield* validateWorkloadRuntimeInputs(state, planned("pooler:pooler"), { - pooler: { tenantPath: "/tmp/tenant\n.exs" }, - }).pipe(Effect.exit); - expect(Exit.isFailure(failed)).toBe(true); - }), - ); - it("uses Auth's local JWT secret for every internal JWT consumer", () => { const consumers = [ ["auth:auth", "GOTRUE_JWT_SECRET"], From c25f8da2b2e10467bd7be4ab413787d09476ae9b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 5 Sep 2026 13:00:43 +0200 Subject: [PATCH 173/174] chore(stack): remove obsolete preparation test import --- packages/stack/src/preparation/slim-services.integration.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index fd05301f38..2d21fa67fb 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -4,7 +4,6 @@ import { Cause, Crypto, Deferred, Effect, Exit, Fiber, FileSystem, Option } from import { zstdCompress } from "node:zlib"; import { makeArtifactStore, type ArtifactRequest } from "./ArtifactStore.ts"; import { digestHex } from "./Integrity.ts"; -import { StackPreparationError } from "../public/Errors.ts"; import { makeSlimServicesSource, slimServicesChecksum, From 1944456f2e67688ce98cb33b3fafb1a813ade0b3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Sat, 5 Sep 2026 19:50:23 +0200 Subject: [PATCH 174/174] fix(stack): allow cold native services to finish loading --- ...7-simplified-managed-stack-architecture.md | 6 ++ packages/stack/README.md | 3 + .../stack/src/runtime/ProductionRuntime.ts | 10 +- .../production-runtime.integration.test.ts | 101 +++++++++++++++++- 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md index 98619d9375..2675fb28d3 100644 --- a/docs/adr/0017-simplified-managed-stack-architecture.md +++ b/docs/adr/0017-simplified-managed-stack-architecture.md @@ -146,6 +146,12 @@ with at most four starts in flight; each wave completes before its dependants start. Container setup is serialized through resource registration; readiness checks and long-lived followers run outside that setup boundary. +Native workloads use a two-minute readiness budget because a cold process can +spend more than 30 seconds loading shared libraries before serving requests. +Container workloads retain the 30-second budget, while PostgreSQL uses its +configured `health_timeout`; every probe remains bounded and returns as soon as +its endpoint is healthy. + Runtime input materialization belongs to the workload startup operation. The Supervisor serializes lifecycle operations with cleanup, so the input owner caches only completed values and leaves unfinished resolution in the startup diff --git a/packages/stack/README.md b/packages/stack/README.md index 70a3edc942..71416641a9 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -19,6 +19,9 @@ capability, and every other capability is lazy. Starting the stack therefore lau PostgreSQL by default; capabilities configured as eager join its startup dependency closure. The remaining lazy capabilities activate through the stack's listeners on demand for the current running session. +Native workloads have a two-minute readiness budget to allow cold starts to load shared libraries; +container workloads retain a 30-second budget, and PostgreSQL uses its configured `health_timeout`. +Each readiness probe returns immediately when its endpoint becomes healthy. Artifact preparation is controlled independently from capability activation through the optional top-level `preparation` setting: diff --git a/packages/stack/src/runtime/ProductionRuntime.ts b/packages/stack/src/runtime/ProductionRuntime.ts index 177c528389..ba37d07d89 100644 --- a/packages/stack/src/runtime/ProductionRuntime.ts +++ b/packages/stack/src/runtime/ProductionRuntime.ts @@ -166,6 +166,8 @@ const urlHost = (host: string): string => { }; const DATABASE_WORKLOAD_ID = "database:database"; +// Native cold starts can spend more than 30 seconds loading shared libraries before serving. +const NATIVE_READINESS_DEADLINE = Duration.minutes(2); const checkNativeDatabaseLockEvidence = ( fileSystem: FileSystem.FileSystem, lockPath: string, @@ -250,7 +252,9 @@ export const readinessDeadlineFor = ( ): Effect.Effect => isDatabaseWorkload(workload) ? configuredDatabaseReadinessDeadline(state.definition) - : Effect.succeed(DEFAULT_READINESS_DEADLINE); + : Effect.succeed( + state.runtime.kind === "native" ? NATIVE_READINESS_DEADLINE : DEFAULT_READINESS_DEADLINE, + ); const validateDatabaseReadinessBudget = ( definition: StackDefinition, @@ -873,7 +877,9 @@ export const makeProductionRuntime = ( const waitForReadiness = (key: RuntimeWorkloadKey, workload: PlannedWorkload) => freshState(key).pipe( Effect.flatMap((fresh) => readinessFor(fresh, workload)), - Effect.mapError((error) => mapDriverError(key, error)), + Effect.mapError((error) => + driverError(key, `Readiness check failed for ${workload.id}: ${error.message}`, error), + ), ); const bootstrapWorkloadDatabase = (key: RuntimeWorkloadKey, workload: PlannedWorkload) => workload.bootstrap === "database" diff --git a/packages/stack/src/runtime/production-runtime.integration.test.ts b/packages/stack/src/runtime/production-runtime.integration.test.ts index 0143cbd143..50debf1f23 100644 --- a/packages/stack/src/runtime/production-runtime.integration.test.ts +++ b/packages/stack/src/runtime/production-runtime.integration.test.ts @@ -16,8 +16,9 @@ import { Option, Stream, } from "effect"; +import * as TestClock from "effect/testing/TestClock"; // oxlint-disable-next-line effecttsgo/node-builtin-import -import { createServer } from "node:http"; +import { createServer, type ServerResponse } from "node:http"; // oxlint-disable-next-line effecttsgo/node-builtin-import import { createServer as createNetServer } from "node:net"; import type { StackLogEntry } from "../public/Logs.ts"; @@ -65,6 +66,7 @@ import { } from "../functions/FunctionsBootstrap.ts"; import type { RuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; import { makeRuntimeEnvFileOwner } from "./RuntimeEnvFile.ts"; +import { probeReadiness } from "./ReadinessProbe.ts"; const stackId = StackIdSchema.make("a".repeat(64)); @@ -1781,10 +1783,105 @@ describe("production runtime", () => { expect(Duration.toMillis(yield* readinessDeadlineFor(defaultState, defaultDatabase))).toBe( 120_000, ); - expect(Duration.toMillis(yield* readinessDeadlineFor(configuredState, generic))).toBe(30_000); + expect(Duration.toMillis(yield* readinessDeadlineFor(configuredState, generic))).toBe( + 120_000, + ); + const configuredContainerState = { + ...stateFor({}, { kind: "container", engine: "docker" }), + definition: configured.definition, + }; + expect( + Duration.toMillis(yield* readinessDeadlineFor(configuredContainerState, generic)), + ).toBe(30_000); }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("allows cold native services to become ready after library loading", () => + Effect.scoped( + Effect.gen(function* () { + const root = "/tmp/production-runtime-native-readiness-delay"; + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const rest = compiled.executionPlan.workloads.find(({ id }) => id === "rest:rest"); + if (rest === undefined) return yield* Effect.die("Expected REST workload"); + const deadline = yield* readinessDeadlineFor( + { ...stateFor({}), definition: compiled.definition }, + rest, + ); + const received = yield* Deferred.make(); + const context = yield* Effect.context(); + let response: ServerResponse | undefined; + const server = createServer((_request, nextResponse) => { + response = nextResponse; + Effect.runSyncWith(context)(Deferred.succeed(received, undefined)); + }); + yield* listenForNativeReadiness(server); + const address = server.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Native readiness server did not expose an address"); + const port = address.port; + const fiber = yield* Effect.forkChild( + probeReadiness({ mode: "http", host: "127.0.0.1", port }, { deadline }), + { startImmediately: true }, + ); + yield* Deferred.await(received); + yield* TestClock.adjust(Duration.seconds(52)); + const pending = yield* Effect.sync(() => fiber.pollUnsafe() === undefined); + response?.end("ok"); + const result = yield* Fiber.join(fiber).pipe(Effect.exit); + expect(pending).toBe(true); + expect(Exit.isSuccess(result)).toBe(true); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("bounds native HTTP readiness at two minutes and cancels the request", () => + Effect.scoped( + Effect.gen(function* () { + const root = "/tmp/production-runtime-native-readiness-bound"; + const compiled = yield* compileStack({ + projectRoot: root, + runtime: { kind: "native" }, + }); + const rest = compiled.executionPlan.workloads.find(({ id }) => id === "rest:rest"); + if (rest === undefined) return yield* Effect.die("Expected REST workload"); + const deadline = yield* readinessDeadlineFor( + { ...stateFor({}), definition: compiled.definition }, + rest, + ); + expect(Duration.toMillis(deadline)).toBe(120_000); + const received = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const context = yield* Effect.context(); + const server = createServer((_request, response) => { + response.once("close", () => { + Effect.runSyncWith(context)(Deferred.succeed(closed, undefined)); + }); + Effect.runSyncWith(context)(Deferred.succeed(received, undefined)); + }); + yield* listenForNativeReadiness(server); + const address = server.address(); + if (typeof address !== "object" || address === null) + return yield* Effect.die("Native readiness server did not expose an address"); + const fiber = yield* Effect.forkChild( + probeReadiness({ mode: "http", host: "127.0.0.1", port: address.port }, { deadline }), + { startImmediately: true }, + ); + yield* Deferred.await(received); + yield* TestClock.adjust(Duration.seconds(121)); + const result = yield* Fiber.join(fiber).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)); + expect(error).toMatchObject({ message: "Readiness deadline exceeded" }); + } + yield* Deferred.await(closed); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("rejects an invalid database readiness budget before creating a workload", () => Effect.scoped( Effect.gen(function* () {