Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
`<invoking-workdir>/supabase/.temp/start-secrets/<containerName>` 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
`<labeled-workdir>/supabase/.temp/start-secrets/<containerName>` 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.
171 changes: 101 additions & 70 deletions apps/cli/src/legacy/commands/start/start.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string>();

// Go's `config.Load` reads `supabase/.temp/storage-migration` (written by
// `supabase link`) into `Config.Storage.TargetMigration` whenever present
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<LegacyContainerIdName> = [];
yield* legacyDockerRemoveAll(spawner, filterValue, false, (containers) => {
Comment thread
7ttp marked this conversation as resolved.
Comment thread
7ttp marked this conversation as resolved.
// 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)),
Comment thread
7ttp marked this conversation as resolved.
),
);
}

const bringUpResult = yield* bringUp;

// Only reached when Postgres itself became healthy (or the volume already
Expand Down
Loading
Loading