diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 6dc07b8140..c8982c2b75 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -175,7 +175,9 @@ not implemented. | `0` | `--ignore-health-check` set and one or more containers timed out — the failure is printed and swallowed, no rollback | | `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | | `1` | malformed `config.toml` / `Config.Validate` failure | +| `1` | stopped Postgres detected but the project id sanitizes to empty — aborts before recovery removes any containers | | `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | +| `1` | stopped-stack recovery cannot list, stop, or prune current-project containers, or prune matching networks — aborts before startup; named volumes are preserved | | `1` | image pull exhausted across every registry candidate | | `1` | network, volume, container create, or container start failure (including a port conflict) — rolls back everything created so far | | `1` | health check timeout **without** `--ignore-health-check` — rolls back | @@ -254,6 +256,15 @@ text above is suppressed in these modes — stdout stays payload-only. table). - `--preview` is a hidden, parsed-but-inert flag, matching Go exactly (never read by Go's own `start.Run`). -- The already-running check is a plain container-existence check (`docker container -inspect` on the Postgres container), not a health check — matching Go's - `AssertSupabaseDbIsRunning` naming despite what it actually verifies. +- The already-running check uses `docker container inspect` on the Postgres container, + not a health check — matching Go's `AssertSupabaseDbIsRunning` check. For a verified + stopped container outside Bitbucket Pipelines, `start` removes all current-project + containers — including running siblings — and unused networks, preserves named volumes, + and continues normal startup. After container removal succeeds, it deletes + `/supabase/.temp/start-secrets/` only for containers + whose workdir label matches the invoking workdir, or whose missing label uses that + workdir as a fallback. Removed containers labeled with another workdir keep their + `/supabase/.temp/start-secrets/` directory. +- Docker status `created` is not considered a recoverable stopped stack: the container and + named volume are preserved because the volume may not have completed its first database + initialization, and `start` reports the existing not-running status instead. diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index c60cc99ed9..ad4f81c7f5 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -27,6 +27,8 @@ import { legacyResolveStudioApiUrl } from "../../shared/legacy-api-url.ts"; import { legacyIsBitbucketPipeline } from "../../shared/legacy-bitbucket-pipeline.ts"; import { legacyAqua, legacyYellow } from "../../shared/legacy-colors.ts"; import { + legacyApiTlsCertReadErrorMessage, + legacyApiTlsKeyReadErrorMessage, legacyResolveApiTlsPath, legacyResolveEmailTemplateContentPath, } from "../../shared/legacy-config-validate.ts"; @@ -58,7 +60,9 @@ import { import { legacyInspectContainerState, legacyListContainersByLabel, + type LegacyContainerIdName, } from "../../shared/legacy-docker-lifecycle.ts"; +import { legacyDockerRemoveAll } from "../../shared/legacy-docker-remove-all.ts"; import { legacyEnvOverride, legacyEnvOverrideApiMaxRows, @@ -94,6 +98,7 @@ import { type LegacyLocalProjectContext, } from "../../shared/legacy-local-project-context.ts"; import { legacySeedBucketsRun } from "../../shared/legacy-seed-buckets.ts"; +import { legacyCleanupStartSecrets } from "../../shared/legacy-start-secrets-cleanup.ts"; import { LegacyStatusDbInspectError, LegacyStatusDbNotReadyError, @@ -928,20 +933,25 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta return legacyStatusValuesFromState(state, new Map()); }); - // 3. Go's `AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144-146`) — - // a bare `ContainerInspect` EXISTENCE check, true even for a merely - // present-but-stopped container. "Not found" is the ONE outcome that means - // "proceed to bring up the stack"; any OTHER inspect failure (e.g. the - // Docker daemon itself being unreachable) must propagate and fail `start` - // outright, matching Go's `!errors.Is(err, utils.ErrNotRunning)` branch. - const alreadyRunning = yield* legacyInspectContainerState(spawner, dbContainerId).pipe( - Effect.map(() => true), + const isBitbucketPipeline = legacyIsBitbucketPipeline(); + + // 3. Missing proceeds to startup; other inspect failures propagate. + // Unlike Go, verified stopped stacks are recovered unless Bitbucket's lack of named volumes + // makes removing the Postgres container destructive. + const inspectDbState = legacyInspectContainerState(spawner, dbContainerId).pipe( Effect.catch((error) => - isContainerNotFoundMessage(error.message) ? Effect.succeed(false) : Effect.fail(error), + isContainerNotFoundMessage(error.message) ? Effect.succeed(undefined) : Effect.fail(error), ), ); - - if (alreadyRunning) { + const dbState = yield* inspectDbState; + const isRecoverableStoppedState = ( + state: { readonly running: boolean; readonly status: string } | undefined, + ) => + // `created` may own a just-provisioned volume that Postgres never initialized. + state?.running === false && state.status.length > 0 && state.status !== "created"; + const shouldRecoverStoppedStack = isRecoverableStoppedState(dbState) && !isBitbucketPipeline; + + const reportAlreadyRunningStatus = Effect.fnUntraced(function* () { // `start.go:55`: printed unconditionally in Go (which has no JSON output // mode to protect); gated here on text mode for internal consistency // with every other supplementary stderr line this handler prints (see @@ -1006,7 +1016,10 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const { values: statusValues } = yield* buildStatusValues(excluded); yield* output.success("", statusValues); } - return; + }); + + if (dbState !== undefined && !shouldRecoverStoppedStack) { + return yield* reportAlreadyRunningStatus(); } // 4. Go's `flags.LoadProjectRef`/`services.CheckVersions` best-effort @@ -1211,6 +1224,19 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta search: false, }); const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); + // Resolve once during preflight so a missing function source cannot fail only after stopped + // containers have been removed. Studio consumes the cached binds later during bring-up. + const studioFunctionBinds = gates.studio + ? yield* resolveFunctionBindMounts( + projectId, + cliConfig.workdir, + `${cliConfig.workdir}/supabase`, + { configDeclaredFunctions, configFunctions, rawConfigFunctions }, + Option.none(), + Option.none(), + cliConfig.workdir, + ) + : new Set(); // Go's `config.Load` reads `supabase/.temp/storage-migration` (written by // `supabase link`) into `Config.Storage.TargetMigration` whenever present @@ -1233,7 +1259,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const networkId = Option.isSome(networkIdFlag) ? networkIdFlag.value : localNetworkId(projectId); - const isBitbucketPipeline = legacyIsBitbucketPipeline(); // Go's `DockerStart` unconditionally appends the Linux-only // `host.docker.internal:host-gateway` extra host for every container it // starts (`docker_linux.go`; empty on darwin/windows, where Docker @@ -1312,6 +1337,39 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta config.api.tls.key_path, projectEnvValues, ); + // Go's `NewConfig` seeds these from the embedded defaults, then `Validate` + // replaces them from disk before `start.Run` can mutate Docker. + let tlsCertContent = LEGACY_KONG_LOCAL_TLS_CERT; + let tlsKeyContent = LEGACY_KONG_LOCAL_TLS_KEY; + if ( + apiEnabled && + apiTlsEnabled && + apiTlsCertPath !== undefined && + apiTlsCertPath.length > 0 && + apiTlsKeyPath !== undefined && + apiTlsKeyPath.length > 0 + ) { + tlsCertContent = yield* fs + .readFileString(legacyResolveApiTlsPath(cliConfig.workdir, apiTlsCertPath)) + .pipe( + Effect.mapError( + (cause) => + new LegacyStartInvalidConfigError({ + message: legacyApiTlsCertReadErrorMessage(cause), + }), + ), + ); + tlsKeyContent = yield* fs + .readFileString(legacyResolveApiTlsPath(cliConfig.workdir, apiTlsKeyPath)) + .pipe( + Effect.mapError( + (cause) => + new LegacyStartInvalidConfigError({ + message: legacyApiTlsKeyReadErrorMessage(cause), + }), + ), + ); + } // Same generic-Viper-override gap as `apiTlsEnabled` above, for Realtime's // two `SUPABASE_REALTIME_*` fields — both the Realtime container spec @@ -1658,47 +1716,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } case "kong": { - // Go's `NewConfig` seeds `Api.Tls.{CertContent,KeyContent}` - // unconditionally from the embedded default cert/key - // (`pkg/config/config.go:452-455`); `Validate` only overwrites them - // from disk when API itself is enabled, TLS is enabled, AND both - // `cert_path`/`key_path` are set (`config.go:1006-1027` — the whole - // disk-read branch is nested inside `if c.Api.Enabled`). So a - // `[api.tls] enabled = true` project with no custom paths still - // gets a real cert/key here — never empty strings — matching - // Kong's own unconditional write of these fields to - // `/home/kong/localhost.{crt,key}` (`start.go:585-601`). - let tlsCertContent: string = LEGACY_KONG_LOCAL_TLS_CERT; - let tlsKeyContent: string = LEGACY_KONG_LOCAL_TLS_KEY; - if ( - apiEnabled && - apiTlsEnabled && - apiTlsCertPath !== undefined && - apiTlsCertPath.length > 0 && - apiTlsKeyPath !== undefined && - apiTlsKeyPath.length > 0 - ) { - tlsCertContent = yield* fs - .readFileString(legacyResolveApiTlsPath(cliConfig.workdir, apiTlsCertPath)) - .pipe( - Effect.mapError( - (cause) => - new LegacyStartInvalidConfigError({ - message: `failed to read api tls cert: ${String(cause)}`, - }), - ), - ); - tlsKeyContent = yield* fs - .readFileString(legacyResolveApiTlsPath(cliConfig.workdir, apiTlsKeyPath)) - .pipe( - Effect.mapError( - (cause) => - new LegacyStartInvalidConfigError({ - message: `failed to read api tls key: ${String(cause)}`, - }), - ), - ); - } return { spec: legacyBuildKongContainerSpec({ image, @@ -1830,28 +1847,15 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta }; case "studio": { - // Go's `start.go:1149-1159` computes Studio's function bind mounts - // via `serve.PopulatePerFunctionConfigs` unconditionally whenever - // Studio is enabled — NOT gated on `Config.EdgeRuntime.Enabled` — - // so function sources/import maps/static assets stay mounted for - // Studio's local function management even when Edge Runtime itself - // is disabled or excluded. - const functionBinds = yield* resolveFunctionBindMounts( - projectId, - cliConfig.workdir, - `${cliConfig.workdir}/supabase`, - { configDeclaredFunctions, configFunctions, rawConfigFunctions }, - Option.none(), - Option.none(), - cliConfig.workdir, - ); return { spec: legacyBuildStudioContainerSpec({ image, containerName: studioContainerName, networkId, port: values.studioPort, - functionBinds: [...functionBinds], + // Go computes these whenever Studio is enabled, independently of Edge Runtime. + // They are resolved during preflight above so recovery teardown remains reversible. + functionBinds: [...studioFunctionBinds], env: { dbPassword, workdir: cliConfig.workdir, @@ -2333,6 +2337,33 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta ), ); + if (shouldRecoverStoppedStack) { + // Recheck after preflight in case Docker or another process restarted the stack. + const recheckedState = yield* inspectDbState; + if (recheckedState !== undefined && !isRecoverableStoppedState(recheckedState)) { + return yield* reportAlreadyRunningStatus(); + } + // legacyCliProjectFilterValue("") targets every CLI-managed project; never use it here. + if (projectId.length === 0) { + return yield* Effect.fail( + new LegacyStartInvalidConfigError({ + message: "Invalid config: project_id must contain at least one alphanumeric character.", + }), + ); + } + let removedContainers: ReadonlyArray = []; + yield* legacyDockerRemoveAll(spawner, filterValue, false, (containers) => { + // Recovery only trusts its own workdir; empty labels use the existing fallback. + removedContainers = containers.filter( + (container) => container.workdir.length === 0 || container.workdir === cliConfig.workdir, + ); + }).pipe( + Effect.ensuring( + Effect.suspend(() => legacyCleanupStartSecrets(removedContainers, cliConfig.workdir)), + ), + ); + } + const bringUpResult = yield* bringUp; // Only reached when Postgres itself became healthy (or the volume already diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index cf600de22e..946bea790e 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -173,6 +173,8 @@ function mockStartContainerCliSpawner( const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; const STARTING_STATE = '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; +const STOPPED_STATE = '{"Running":false,"Status":"exited"}'; +const CREATED_STATE = '{"Running":false,"Status":"created"}'; function containerNameFromCreateArgs(args: ReadonlyArray): string { const nameIndex = args.indexOf("--name"); @@ -190,11 +192,8 @@ function rollbackWasAttempted(spawned: ReadonlyArray): boolean { } /** - * Stateful default route: a container only inspects successfully once it has - * actually been "created" — mirrors real Docker semantics and is what makes - * `legacyStart`'s own "already running" existence check correctly report - * `false` before bring-up and `true` for any container this same run created - * (e.g. Postgres's own post-create health wait). + * Stateful default route: only created containers inspect successfully, + * mirroring Docker across initial state detection and post-create health waits. */ function defaultRoute(opts: { readonly neverHealthy?: ReadonlySet } = {}) { const created = new Set(); @@ -569,9 +568,6 @@ describe("legacy start integration", () => { it.live( "fails when the already-running DB container stops running before the health re-check", () => { - // The FIRST inspect (`AssertSupabaseDbIsRunning`) only needs to prove the container - // exists; the SECOND inspect (Go's `status.Run` re-check, `!ignoreHealthCheck`) is what - // actually gates on `Running`/`Health` — a container can transition between the two. let inspectCalls = 0; const { layer, child } = setup({ route: (args) => { @@ -738,6 +734,440 @@ describe("legacy start integration", () => { ); }); + describe("stopped project recovery", () => { + it.live("recreates stopped project containers without pruning the database volume", () => { + const workdir = tempRoot.current; + const route = defaultRoute(); + let recovering = false; + const { layer, out, child } = setup({ + route: (args) => { + if ( + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_db_demo" && + !recovering + ) { + return { stdout: [STOPPED_STATE] }; + } + if (args[0] === "ps" && args.includes("--all")) { + recovering = true; + return { + stdout: [ + `db-id\tsupabase_db_demo\t${workdir}`, + `kong-id\tsupabase_kong_demo\t${workdir}`, + ], + }; + } + return route(args); + }, + }); + + return Effect.gen(function* () { + yield* legacyStart(flags()); + + expect(out.stderrText).not.toContain("is already running"); + expect(createdContainerNames(child.spawned)).toContain("supabase_db_demo"); + expect( + child.spawned + .filter((spawn) => spawn.args[0] === "stop") + .map((spawn) => spawn.args[1]) + .sort(), + ).toEqual(["db-id", "kong-id"]); + expect( + child.spawned.some( + (spawn) => + spawn.args[0] === "ps" && + spawn.args.includes("--all") && + spawn.args.includes("label=com.supabase.cli.project=demo"), + ), + ).toBe(true); + expect( + child.spawned.some( + (spawn) => + spawn.args[0] === "container" && + spawn.args[1] === "prune" && + spawn.args.includes("label=com.supabase.cli.project=demo"), + ), + ).toBe(true); + expect( + child.spawned.some( + (spawn) => + spawn.args[0] === "network" && + spawn.args[1] === "prune" && + spawn.args.includes("label=com.supabase.cli.project=demo"), + ), + ).toBe(true); + expect( + child.spawned.some((spawn) => spawn.args[0] === "volume" && spawn.args[1] === "prune"), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not remove containers when the project id sanitizes to empty", () => { + const { layer, child } = setup({ + configContents: 'project_id = "!!!"\n', + route: (args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === "supabase_db_") { + return { stdout: [STOPPED_STATE] }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + } + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || spawn.args[1] === "prune", + ), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("preserves a stopped Bitbucket database container", () => { + const previous = process.env["BITBUCKET_CLONE_DIR"]; + process.env["BITBUCKET_CLONE_DIR"] = "/opt/atlassian/pipelines/agent/build"; + const { layer, child } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { stdout: [STOPPED_STATE] }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotRunningError"); + } + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || + spawn.args[0] === "stop" || + spawn.args[1] === "prune" || + spawn.args[0] === "create", + ), + ).toBe(false); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["BITBUCKET_CLONE_DIR"]; + else process.env["BITBUCKET_CLONE_DIR"] = previous; + }), + ), + ); + }); + + it.live("does not remove containers when re-inspect returns an unknown state", () => { + let dbInspects = 0; + const { layer, child } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + dbInspects += 1; + return { stdout: [dbInspects === 1 ? STOPPED_STATE : ""] }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || spawn.args[1] === "prune", + ), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not recover a created database container with unknown volume state", () => { + const { layer, child } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { stdout: [CREATED_STATE] }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStatusDbNotRunningError"); + expect(serialized).toContain("container is not running: created"); + } + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || + spawn.args[0] === "stop" || + spawn.args[1] === "prune" || + spawn.args[0] === "create", + ), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("validates custom TLS files before removing a stopped stack", () => { + const workdir = tempRoot.current; + const certPath = join(workdir, "supabase", "certs", "server.crt"); + const keyPath = join(workdir, "supabase", "certs", "server.key"); + mkdirSync(join(workdir, "supabase", "certs"), { recursive: true }); + writeFileSync(certPath, "-----BEGIN CERTIFICATE-----"); + writeFileSync(keyPath, "-----BEGIN PRIVATE KEY-----"); + + const { layer, child } = setup({ + configContents: + 'project_id = "demo"\n[api.tls]\nenabled = true\ncert_path = "certs/server.crt"\nkey_path = "certs/server.key"\n', + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + if (existsSync(certPath)) rmSync(certPath); + return { stdout: [STOPPED_STATE] }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStartInvalidConfigError"); + expect(serialized).toContain("failed to read TLS cert"); + } + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || + spawn.args[0] === "stop" || + spawn.args[1] === "prune" || + spawn.args[0] === "create", + ), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("validates function bind mounts before removing a stopped stack", () => { + const workdir = tempRoot.current; + const entrypointPath = join(workdir, "supabase", "functions", "foo", "index.ts"); + mkdirSync(join(workdir, "supabase", "functions", "foo"), { recursive: true }); + writeFileSync(entrypointPath, "export {};\n"); + + const { layer, child } = setup({ + configContents: + 'project_id = "demo"\n[functions.foo]\nentrypoint = "./functions/foo/index.ts"\n', + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + if (existsSync(entrypointPath)) rmSync(entrypointPath); + return { stdout: [STOPPED_STATE] }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || + spawn.args[0] === "stop" || + spawn.args[1] === "prune" || + spawn.args[0] === "create", + ), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("removes remaining project containers when the stopped database disappears", () => { + const workdir = tempRoot.current; + const route = defaultRoute(); + let dbInspects = 0; + const { layer, child } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === "supabase_db_demo") { + dbInspects += 1; + if (dbInspects === 1) return { stdout: [STOPPED_STATE] }; + if (dbInspects === 2) { + return { + exitCode: 1, + stderr: ["Error: No such container: supabase_db_demo"], + }; + } + } + if (args[0] === "ps" && args.includes("--all")) { + return { stdout: [`kong-id\tsupabase_kong_demo\t${workdir}`] }; + } + return route(args); + }, + }); + + return Effect.gen(function* () { + yield* legacyStart(flags()); + + expect(createdContainerNames(child.spawned)).toContain("supabase_db_demo"); + expect( + child.spawned.filter((spawn) => spawn.args[0] === "stop").map((spawn) => spawn.args[1]), + ).toEqual(["kong-id"]); + expect( + child.spawned.some( + (spawn) => + spawn.args[0] === "container" && + spawn.args[1] === "prune" && + spawn.args.includes("label=com.supabase.cli.project=demo"), + ), + ).toBe(true); + expect( + child.spawned.some( + (spawn) => + spawn.args[0] === "network" && + spawn.args[1] === "prune" && + spawn.args.includes("label=com.supabase.cli.project=demo"), + ), + ).toBe(true); + expect( + child.spawned.some((spawn) => spawn.args[0] === "volume" && spawn.args[1] === "prune"), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("cleans only current-workdir secrets when recovery fails", () => { + const workdir = tempRoot.current; + const staleSecretDir = join( + workdir, + "supabase", + ".temp", + "start-secrets", + "supabase_db_demo", + ); + const staleSecret = join(staleSecretDir, "stale-secret"); + mkdirSync(staleSecretDir, { recursive: true }); + writeFileSync(staleSecret, "stale"); + const foreignWorkdir = join(workdir, "foreign"); + const foreignSecretDir = join( + foreignWorkdir, + "supabase", + ".temp", + "start-secrets", + "supabase_kong_demo", + ); + const foreignSecret = join(foreignSecretDir, "stale-secret"); + mkdirSync(foreignSecretDir, { recursive: true }); + writeFileSync(foreignSecret, "foreign"); + + const { layer, child } = setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { stdout: [STOPPED_STATE] }; + } + if (args[0] === "ps" && args.includes("--all")) { + return { + stdout: [ + `db-id\tsupabase_db_demo\t${workdir}`, + `kong-id\tsupabase_kong_demo\t${foreignWorkdir}`, + ], + }; + } + if (args[0] === "network" && args[1] === "prune") { + return { exitCode: 1 }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyDockerRemoveAllNetworkPruneError"); + } + expect(existsSync(staleSecret)).toBe(false); + expect(existsSync(foreignSecret)).toBe(true); + expect(createdContainerNames(child.spawned)).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps a stopped stack intact when a later config field fails to parse", () => { + const { layer, child } = setup({ + configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "not-a-duration"\n', + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { stdout: [STOPPED_STATE] }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + } + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || spawn.args[1] === "prune", + ), + ).toBe(false); + expect(child.spawned.some((spawn) => spawn.args[0] === "stop")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "reports status instead of tearing down when the stack recovers before teardown", + () => { + let dbInspects = 0; + const { layer, out, child } = setup({ + route: (args) => { + if ( + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_db_demo" + ) { + dbInspects += 1; + return { stdout: [dbInspects === 1 ? STOPPED_STATE : HEALTHY_STATE] }; + } + if (args[0] === "ps") { + return { stdout: ["supabase_db_demo"] }; + } + return { exitCode: 0 }; + }, + }); + + return Effect.gen(function* () { + yield* legacyStart(flags()); + + expect(out.stderrText).toContain("is already running"); + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || + spawn.args[1] === "prune", + ), + ).toBe(false); + expect(child.spawned.some((spawn) => spawn.args[0] === "stop")).toBe(false); + expect(createdContainerNames(child.spawned)).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + }); + describe("config load / validation failures", () => { it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) `os.Chdir`s the diff --git a/apps/cli/src/legacy/commands/start/start.live.test.ts b/apps/cli/src/legacy/commands/start/start.live.test.ts index 8b816d9e87..c54aef9e62 100644 --- a/apps/cli/src/legacy/commands/start/start.live.test.ts +++ b/apps/cli/src/legacy/commands/start/start.live.test.ts @@ -9,6 +9,7 @@ import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts import { legacySanitizeProjectId, legacyServiceContainerName, + localDbContainerId, } from "../../shared/legacy-docker-ids.ts"; import { LEGACY_SERVICE_CATALOG } from "../../shared/legacy-service-catalog.ts"; @@ -16,6 +17,7 @@ const execFileAsync = promisify(execFile); const START_TIMEOUT_MS = 280_000; const SHORT_LIVE_TIMEOUT_MS = 30_000; +const LIFECYCLE_OVERHEAD_MS = 90_000; /** * `--exclude` values for the 3 heaviest/least-relevant services — same intent @@ -74,8 +76,8 @@ describeLive("supabase start (live)", () => { }); test( - "starts a reduced real local stack, running only the non-excluded containers", - { timeout: START_TIMEOUT_MS }, + "recreates a stopped real stack and preserves database data", + { timeout: START_TIMEOUT_MS * 2 + LIFECYCLE_OVERHEAD_MS }, async () => { projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-")); // No `project_id` override, so the cli resolves it from the workdir @@ -84,6 +86,17 @@ describeLive("supabase start (live)", () => { // alphanumeric/`-`), but mirrors the port's actual resolution rather // than assuming that stays true. const projectId = legacySanitizeProjectId(path.basename(projectDir)); + const projectFilter = `label=com.supabase.cli.project=${projectId}`; + const dbContainerId = localDbContainerId(projectId); + const startArgs = [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + ]; const init = await runSupabaseLive(["init"], { cwd: projectDir, @@ -91,12 +104,73 @@ describeLive("supabase start (live)", () => { }); expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); - const start = await runSupabaseLive( - ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], - { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, - ); + const start = await runSupabaseLive(startArgs, { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + const persistedValue = "survived-stopped-container-recovery"; + await execFileAsync("docker", [ + "exec", + dbContainerId, + "psql", + "-U", + "postgres", + "-d", + "postgres", + "-v", + "ON_ERROR_STOP=1", + "-c", + `CREATE TABLE start_restart_regression (value text NOT NULL); INSERT INTO start_restart_regression VALUES ('${persistedValue}');`, + ]); + + const { stdout: containerIdOutput } = await execFileAsync("docker", [ + "ps", + "--filter", + projectFilter, + "--format", + "{{.ID}}", + ]); + const containerIds = splitNonEmptyLines(containerIdOutput); + expect(containerIds.length).toBeGreaterThan(0); + await execFileAsync("docker", ["stop", "--time", "0", ...containerIds], { + timeout: SHORT_LIVE_TIMEOUT_MS, + }); + + const { stdout: stoppedState } = await execFileAsync("docker", [ + "container", + "inspect", + dbContainerId, + "--format", + "{{json .State}}", + ]); + expect(JSON.parse(stoppedState.trim())).toMatchObject({ + Running: false, + Status: "exited", + }); + + const restart = await runSupabaseLive(startArgs, { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + expect(restart.exitCode, `stdout:\n${restart.stdout}\nstderr:\n${restart.stderr}`).toBe(0); + expect(restart.stderr).not.toContain("is already running"); + expect(restart.stderr).not.toContain("container is not running"); + + const { stdout: persistedData } = await execFileAsync("docker", [ + "exec", + dbContainerId, + "psql", + "-U", + "postgres", + "-d", + "postgres", + "-Atc", + "SELECT value FROM start_restart_regression;", + ]); + expect(persistedData.trim()).toBe(persistedValue); + // The real Docker daemon must agree with the CLI's own exit code: every // non-excluded service is actually running under this project's label, // AND every excluded service is genuinely absent — not merely reported @@ -104,7 +178,7 @@ describeLive("supabase start (live)", () => { const { stdout: psOutput } = await execFileAsync("docker", [ "ps", "--filter", - `label=com.supabase.cli.project=${projectId}`, + projectFilter, "--format", "{{.Names}}", ]);