diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 9d8e4be993..5cb8d0284b 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -16,7 +16,7 @@ default `max_worker_processes` to 17 to leave room for those workers; `postgresq | Command | Purpose | | ------------------------ | --------------------------------------------------------------------------------- | -| `supabase stack destroy` | Permanently delete one stack and its data. | +| `supabase stack destroy` | Permanently delete stacks and their data. | | `supabase stack list` | List persisted managed local stacks. | | `supabase stack prepare` | Download artifacts without starting services. | | `supabase stack start` | Create or resume the project's stack. | @@ -316,7 +316,9 @@ failures make the command fail and identify the affected stack IDs with their er success or not-running summary is emitted when a stop fails. `supabase stack destroy --stack feature-a` permanently removes exactly that stack and its data after -confirmation. Use `--yes` for unattended execution. There is no bulk destroy option. +confirmation. Use `--yes` for unattended execution. Repeat `--stack-id` to destroy several stacks +after one confirmation; every id must resolve before anything is destroyed, and a stack that fails +to be destroyed does not stop the rest, though the command still fails. ## Storage and bucket seeding diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md index 266acaa3fb..8e06c6b960 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -1,21 +1,24 @@ # `supabase stack destroy` -Permanently removes the selected managed namespace, its service registrations, +Permanently removes each selected managed namespace, its service registrations, owned database data, port claims, and attached jobs. Caller-owned Storage upload files remain. The experimental feature flag controls command registration. ## Selection and confirmation Select the current project/branch/name, `--stack `, or `--stack-id `. -The selectors are mutually exclusive; a missing target fails. Explicit legacy -`-o/--output` is rejected in favor of `--output-format`. +The selectors are mutually exclusive; a missing target fails. `--stack-id` repeats +to select several stacks; every id resolves before confirmation, so ids that are not +found fail together, each named, before anything is destroyed, and ids naming the +same stack select it once. +Explicit legacy `-o/--output` is rejected in favor of `--output-format`. -Interactive text mode asks for confirmation and states that Storage uploads are -preserved. Non-interactive and machine-output runs require `--yes`. With `--yes` -the command prints no question; stderr states which stack and data are destroyed -and that Storage uploads are preserved. Rejection or cancellation does not open or -destroy a stack. Discovery may create/chmod the registry directory to 0700 but -does not launch an owner. +Interactive text mode asks one question for all selected stacks and states that +Storage uploads are preserved. Non-interactive and machine-output runs require +`--yes`. With `--yes` the command prints no question; stderr states which stacks and +data are destroyed and that Storage uploads are preserved. Rejection or cancellation +does not open or destroy a stack. Discovery may create/chmod the registry directory +to 0700 but does not launch an owner. A full `--stack-id` that is not registered under the state root makes destroy list stack-labelled containers on Docker and Podman before confirming; an engine @@ -36,9 +39,10 @@ cleanup holds the lease. The deleted stack's data in the state root's shared database volume stays, and containers of other state roots are never touched; for a deleted `SUPABASE_HOME`, run destroy with that `SUPABASE_HOME`. -After confirmation the command opens the selected handle and destroys its entire -namespace. Destruction may start an owner to clean up a stopped namespace. -Cleanup failures remain errors; the command does not claim success on failure. +After confirmation the command opens each selected handle in turn and destroys its +entire namespace; a stack that fails to be destroyed does not stop the rest. +Destruction may start an owner to clean up a stopped namespace. Cleanup failures +remain errors; the command does not claim success on failure. Shutdown is one-way: a failed destroy leaves the stack registered and its owner exits; the next command retries. @@ -83,9 +87,14 @@ deleting data; its configured ports are released with their listeners. Text prints `Stack destroyed.` (`Removed the containers stack left behind.` for a deleted stack's containers). JSON and stream-json success data contain -`destroyed` (`true`) and `id`. Exit 0 on destruction, 1 on invalid flags, missing -selection, rejected/cancelled confirmation, an unreachable engine, or cleanup -failure, and 130 on interruption. A saved stack whose state cannot be decoded +`destroyed` (`true`) and `id`. More than one `--stack-id` flag, even when they name +one stack, instead reports `destroyed` (`true`) and `stacks`, one `id` entry per +stack; when any of them fails, the command fails once with each failed stack in the +error detail and a suggestion that retries them (after starting the engine when it +was unreachable for all of them), and the JSON/stream-json error envelope lists the +stacks it did destroy in `destroyed_stacks`. Exit 0 on destruction, 1 on invalid +flags, missing selection, rejected/cancelled confirmation, an unreachable engine, +or cleanup failure, and 130 on interruption. A saved stack whose state cannot be decoded (for example one saved by an older CLI with a service kind this CLI no longer knows) fails with an error naming its directory under the state root; removing that directory discards the stack. Its leftover containers are then removed diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts index 32f52ea8a1..68e08290bf 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -1,6 +1,8 @@ +import { Layer } from "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"; +import { machineErrorContextLayer } from "../../../../shared/output/machine-error-context.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackDestroy } from "./destroy.handler.ts"; @@ -14,9 +16,9 @@ const config = { ), stackId: Flag.string("stack-id").pipe( Flag.withDescription( - "Destroy an existing stack by id or unique id prefix. A full id, such as a container's `com.supabase.stack` label, also selects the containers a deleted stack left behind.", + "Destroy an existing stack by id or unique id prefix; repeat to select several. A full id, such as a container's `com.supabase.stack` label, also selects the containers a deleted stack left behind.", ), - Flag.optional, + Flag.atLeast(0), ), } as const; @@ -32,12 +34,17 @@ export const stackDestroyCommand = Command.make("destroy", config).pipe( command: "supabase stack destroy --stack feature-a --yes", description: "Permanently destroy the feature-a stack", }, + { + command: "supabase stack destroy --stack-id 019532ab --stack-id 019532cd --yes", + description: "Permanently destroy multiple stacks by id or unique id prefix", + }, ]), Command.withHandler((flags) => stackDestroy(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), // `stdinLayer` satisfies `promptYesNo`'s `Stdin` requirement. destroy either rejects a // non-TTY run up front or short-circuits the prompt via `--yes`, so the layer is here for - // the effect's type requirements only. - Command.provide(stdinLayer), + // the effect's type requirements only. `machineErrorContextLayer` lists the stacks a + // partly failed batch did destroy on the JSON/stream-json error envelope. + Command.provide(Layer.mergeAll(stdinLayer, machineErrorContextLayer)), ); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 030feafbe7..9ea38b338f 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -1,5 +1,6 @@ import { Cause, Effect, Exit, Option, Path, Result } from "effect"; import type { StackError } from "@supabase/stack/effect"; +import { MachineErrorContext } from "../../../../shared/output/machine-error-context.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag, resolveYes } from "../../../../command-internal/global-flags.ts"; import { promptYesNo } from "../../../../command-internal/prompt-yes-no.ts"; @@ -45,7 +46,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( ); yield* validateStackTarget({ stack: Option.getOrUndefined(flags.stack), - stackId: Option.getOrUndefined(flags.stackId), + stackId: flags.stackId[0], }).pipe(Effect.mapError(mapTargetError((props) => new StackCommandDestroyError(props)))); const resolver = yield* StackTargetResolver; @@ -54,41 +55,73 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( stateRoot: path.join(settings.supabaseHome, "stacks"), cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), }; - const lookup = - Option.isSome(flags.stackId) && isStackId(flags.stackId.value) - ? yield* Effect.result(api.findDeleted({ ...locations, id: flags.stackId.value })) - : Result.succeedNone; - const deleted = Result.getOrElse(lookup, Option.none); - const detail = Result.isFailure(lookup) ? lookup.failure.message : undefined; - const target = Option.isSome(deleted) - ? undefined - : yield* resolver - .resolve({ - projectRoot: settings.workdir, - ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), - ...(Option.isSome(flags.stackId) ? { id: flags.stackId.value } : {}), - runtime: "auto", - }) - .pipe( - Effect.mapError( - mapTargetError( - (props) => - new StackCommandDestroyError({ - ...props, - ...(detail === undefined ? {} : { detail }), - }), - ), + const select = Effect.fnUntraced(function* (stackId: string | undefined) { + const lookup = + stackId !== undefined && isStackId(stackId) + ? yield* Effect.result(api.findDeleted({ ...locations, id: stackId })) + : Result.succeedNone; + const deleted = Result.getOrElse(lookup, Option.none); + if (Option.isSome(deleted)) + return { + id: deleted.value.id, + deleted, + scope: `the containers deleted stack ${deleted.value.id} left behind`, + }; + const detail = Result.isFailure(lookup) ? lookup.failure.message : undefined; + const target = yield* resolver + .resolve({ + projectRoot: settings.workdir, + ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), + ...(stackId === undefined ? {} : { id: stackId }), + runtime: "auto", + }) + .pipe( + Effect.mapError( + mapTargetError( + (props) => + new StackCommandDestroyError({ + ...props, + ...(detail === undefined ? {} : { detail }), + }), ), - ); - const id = Option.isSome(deleted) ? deleted.value.id : target?.id; - if (id === undefined) + ), + ); + if (target.id === undefined) + return yield* new StackCommandDestroyError({ + reason: "flags", + message: Option.isSome(flags.stack) + ? `No managed stack named "${flags.stack.value}" was found for this project.` + : "No managed stack was found for this project.", + suggestion: "Choose an existing --stack name or --stack-id.", + }); + return { + id: target.id, + deleted, + scope: `stack ${target.id} at ${target.projectRoot} and its owned data`, + }; + }); + /* Every id resolves before the prompt, so a missing one destroys nothing. */ + const [unresolved, resolved] = yield* Effect.partition( + flags.stackId.length === 0 ? [undefined] : flags.stackId, + (stackId) => select(stackId).pipe(Effect.mapError((error) => ({ stackId, error }))), + ); + const [missing] = unresolved; + if (missing !== undefined) { + if (unresolved.length === 1) return yield* missing.error; return yield* new StackCommandDestroyError({ reason: "flags", - message: Option.isSome(flags.stack) - ? `No managed stack named "${flags.stack.value}" was found for this project.` - : "No managed stack was found for this project.", - suggestion: "Choose an existing --stack name or --stack-id.", + message: `Failed to resolve ${unresolved.length} --stack-id values.`, + detail: unresolved + .map( + ({ stackId, error }) => + `${stackId}: ${error.message}${error.detail === undefined ? "" : `\n ${error.detail}`}`, + ) + .join("\n"), + suggestion: "Run `supabase stack list` to see managed stacks and their IDs.", + cause: unresolved, }); + } + const selected = [...new Map(resolved.map((entry) => [entry.id, entry])).values()]; const yes = yield* resolveYes; const tty = yield* Tty; if (!yes && (!tty.stdinIsTty || !output.interactive || output.format !== "text")) @@ -97,10 +130,7 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( message: "Destroying a stack requires confirmation; rerun with --yes.", suggestion: "Pass --yes when running non-interactively or in a machine-readable format.", }); - const scope = - target === undefined - ? `the containers deleted stack ${id} left behind` - : `stack ${id} at ${target.projectRoot} and its owned data`; + const scope = selected.map((entry) => entry.scope).join("; "); const preserved = "Storage upload files will be preserved."; if (yes) yield* output.raw(`Permanently destroying ${scope}. ${preserved}\n`, "stderr"); else { @@ -116,28 +146,63 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( message: "Stack destruction was not confirmed.", }); } - const stack = Option.isSome(deleted) - ? deleted.value - : yield* api.open({ ...locations, id }).pipe(Effect.mapError(destroyError(id))); - const destroying = yield* output.task(`Destroying stack ${id}...`); - yield* stack.destroy.pipe( - Effect.onExit((exit) => - Exit.isSuccess(exit) - ? destroying.clear - : Cause.hasInterruptsOnly(exit.cause) - ? destroying.cancel() - : destroying.fail(Option.getOrUndefined(Exit.findErrorOption(exit))?.message), + const batch = flags.stackId.length > 1; + const [failed, destroyed] = yield* Effect.partition(selected, ({ id, deleted }) => + Effect.gen(function* () { + const stack = Option.isSome(deleted) + ? deleted.value + : yield* api.open({ ...locations, id }).pipe(Effect.mapError(destroyError(id))); + const destroying = yield* output.task(`Destroying stack ${id}...`); + yield* stack.destroy.pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? destroying.clear + : Cause.hasInterruptsOnly(exit.cause) + ? destroying.cancel() + : destroying.fail(Option.getOrUndefined(Exit.findErrorOption(exit))?.message), + ), + Effect.mapError(destroyError(id)), + ); + if (!batch) yield* Effect.annotateCurrentSpan({ "stack.prompted": !yes }); + if (output.format === "text") + yield* output.raw( + Option.isSome(deleted) + ? `Removed the containers stack ${id} left behind.\n` + : `Stack ${id} destroyed.\n`, + ); + else if (!batch) yield* output.success("", { destroyed: true, id }); + return { id }; + }).pipe( + Effect.scoped, + Effect.mapError((error) => ({ id, error })), ), - Effect.mapError(destroyError(id)), ); - yield* Effect.annotateCurrentSpan({ "stack.prompted": !yes }); - if (output.format !== "text") yield* output.success("", { destroyed: true, id }); - else - yield* output.raw( - target === undefined - ? `Removed the containers stack ${id} left behind.\n` - : `Stack ${id} destroyed.\n`, - ); + if (batch) + yield* Effect.annotateCurrentSpan({ + "stack.prompted": !yes, + "stack.count": selected.length, + "stack.destroyed_count": destroyed.length, + }); + const [failure] = failed; + if (failure !== undefined) { + if (!batch) return yield* failure.error; + const machineErrorContext = yield* Effect.serviceOption(MachineErrorContext); + if (Option.isSome(machineErrorContext)) + yield* machineErrorContext.value.set({ destroyed_stacks: destroyed }); + const retry = `"supabase stack destroy ${failed.map(({ id }) => `--stack-id ${id}`).join(" ")} --yes"`; + const runtime = failed.every(({ error }) => error.reason === "runtime"); + return yield* new StackCommandDestroyError({ + reason: runtime ? "runtime" : "unknown", + message: `Failed to destroy ${failed.length} managed stack(s).`, + detail: failed.map(({ id, error }) => `${id}: ${error.message}`).join("\n"), + suggestion: runtime + ? `Start the container engine, then run ${retry} again; nothing was removed for those stacks.` + : `Resolve each error, then run ${retry} to retry the stacks that failed.`, + cause: failed, + }); + } + if (batch && output.format !== "text") + yield* output.success("", { destroyed: true, stacks: destroyed }); }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 5613044551..2be6e96216 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -1,18 +1,68 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { StackError } from "@supabase/stack/effect"; -import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Schema, Sink, Stdio, Stream } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { jsonOutputLayer } from "../../../../shared/output/output.layer.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { YesFlag } from "../../../../command-internal/global-flags.ts"; import { mockCommandSettings, mockTelemetryStateTracked, } from "../../../../../tests/helpers/command-mocks.ts"; -import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shared.ts"; +import { stackDestroyCommand } from "./destroy.command.ts"; import { stackDestroy } from "./destroy.handler.ts"; const live = Layer.provideMerge(stackApiLayer, BunServices.layer); +const refuseDestroy = ( + api: StackApi["Service"], + ids: ReadonlyArray, + unreachable: ReadonlyArray = [], +) => + Layer.succeed(StackApi, { + ...api, + open: (options) => + api.open(options).pipe( + Effect.map((stack) => { + const reason = unreachable.includes(stack.id) ? "runtime-unavailable" : undefined; + return ids.includes(stack.id) + ? { + ...stack, + destroy: Effect.fail( + new StackError({ + operation: "destroy", + message: "engine refused", + ...(reason === undefined ? {} : { reason }), + }), + ), + } + : stack; + }), + ), + }); +const runDestroy = (layer: Layer.Layer, argv: ReadonlyArray) => + Command.runWith(stackDestroyCommand.pipe(Command.provide(layer)), { version: "0.0.0-test" })( + argv, + ).pipe( + Effect.provide( + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + mockAnalytics().layer, + mockProcessControl().layer, + commandRuntimeLayer(["stack", "destroy"]), + ), + ), + ); const fixture = Effect.fn("StackDestroyTest.fixture")(function* ( yes: boolean, confirm?: { readonly answer: boolean }, @@ -52,7 +102,7 @@ const fixture = Effect.fn("StackDestroyTest.fixture")(function* ( output, telemetry, layer, - flags: { stack: Option.none(), stackId: Option.some(stack.id) }, + flags: { stack: Option.none(), stackId: [stack.id] }, }; }); @@ -149,7 +199,7 @@ describe("stack destroy", () => { runtime: "native", }); - yield* stackDestroy({ ...f.flags, stackId: Option.some(f.stack.id.slice(0, 8)) }).pipe( + yield* stackDestroy({ ...f.flags, stackId: [f.stack.id.slice(0, 8)] }).pipe( Effect.provide(f.layer), ); @@ -180,7 +230,7 @@ describe("stack destroy", () => { ), }); - yield* stackDestroy({ ...f.flags, stackId: Option.some(id) }).pipe( + yield* stackDestroy({ ...f.flags, stackId: [id] }).pipe( Effect.provide(Layer.merge(f.layer, api)), ); @@ -205,7 +255,7 @@ describe("stack destroy", () => { findDeleted: () => Effect.fail(new StackError({ operation: "find", message: unlisted })), }); - const error = yield* stackDestroy({ ...f.flags, stackId: Option.some(id) }).pipe( + const error = yield* stackDestroy({ ...f.flags, stackId: [id] }).pipe( Effect.provide(Layer.merge(f.layer, api)), Effect.flip, ); @@ -214,6 +264,18 @@ describe("stack destroy", () => { expect(error.message).toContain("was not found"); expect(error.suggestion).toContain("Run `supabase stack list`"); expect(error.detail).toBe(unlisted); + + const ids = [id, "e".repeat(64)]; + const batch = yield* stackDestroy({ ...f.flags, stackId: ids }).pipe( + Effect.provide(Layer.merge(f.layer, api)), + Effect.flip, + ); + + expect(batch.detail).toBe( + ids + .map((stackId) => `${stackId}: Stack ${stackId} was not found\n ${unlisted}`) + .join("\n"), + ); }).pipe(Effect.provide(live)), ); @@ -254,4 +316,281 @@ describe("stack destroy", () => { ); }).pipe(Effect.provide(live)), ); + + it.live("destroys each stack named by repeated --stack-id flags once", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const second = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "second", + runtime: "native", + }); + const third = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "third", + runtime: "native", + }); + const output = mockOutput({ format: "json" }); + + yield* runDestroy(Layer.merge(f.layer, output.layer), [ + "--stack-id", + f.stack.id, + "--stack-id", + second.id.slice(0, 8), + "--stack-id", + f.stack.id.slice(0, 8), + ]); + + expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ + third.id, + ]); + expect(output.stderrText).toBe( + `Permanently destroying stack ${f.stack.id} at ${f.projectRoot} and its owned data; stack ${second.id} at ${f.projectRoot} and its owned data. Storage upload files will be preserved.\n`, + ); + expect(output.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + destroyed: true, + stacks: [{ id: f.stack.id }, { id: second.id }], + }, + }), + ); + }).pipe(Effect.provide(live)), + ); + + it.live("keeps the batch result when repeated --stack-id flags name one stack", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const output = mockOutput({ format: "json" }); + + yield* runDestroy(Layer.merge(f.layer, output.layer), [ + "--stack-id", + f.stack.id, + "--stack-id", + f.stack.id.slice(0, 8), + ]); + + expect(output.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { destroyed: true, stacks: [{ id: f.stack.id }] }, + }), + ); + }).pipe(Effect.provide(live)), + ); + + it.live("rejects --stack combined with repeated --stack-id flags", () => + Effect.gen(function* () { + const f = yield* fixture(true); + + const error = yield* runDestroy(f.layer, [ + "--stack", + "other", + "--stack-id", + f.stack.id, + "--stack-id", + f.stack.id.slice(0, 8), + ]).pipe(Effect.flip); + + expect(error).toMatchObject({ + reason: "flags", + message: "--stack and --stack-id cannot be used together", + }); + expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ + f.stack.id, + ]); + }).pipe(Effect.provide(live)), + ); + + it.live("asks once for every selected stack and keeps them all when declined", () => + Effect.gen(function* () { + const f = yield* fixture(false, { answer: false }); + const second = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "second", + runtime: "native", + }); + + const error = yield* stackDestroy({ ...f.flags, stackId: [f.stack.id, second.id] }).pipe( + Effect.provide(f.layer), + Effect.flip, + ); + + expect(error.reason).toBe("cancelled"); + expect(f.output.promptConfirmCalls.map(({ message }) => message)).toEqual([ + `Permanently destroy stack ${f.stack.id} at ${f.projectRoot} and its owned data; stack ${second.id} at ${f.projectRoot} and its owned data? Storage upload files will be preserved.`, + ]); + expect( + (yield* f.api.discover(f.locations)).map(({ definition }) => definition.id).toSorted(), + ).toEqual([f.stack.id, second.id].toSorted()); + }).pipe(Effect.provide(live)), + ); + + it.live("destroys nothing and names every later --stack-id that is not found", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const missing = ["0000", "1111", "2222"] + .filter((prefix) => !f.stack.id.startsWith(prefix)) + .slice(0, 2); + + const error = yield* runDestroy(f.layer, [ + "--stack-id", + f.stack.id, + ...missing.flatMap((id) => ["--stack-id", id]), + ]).pipe(Effect.flip); + + expect(error).toMatchObject({ + reason: "flags", + message: "Failed to resolve 2 --stack-id values.", + detail: missing.map((id) => `${id}: Stack ${id} was not found`).join("\n"), + }); + expect(f.output.stderrText).toBe(""); + expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ + f.stack.id, + ]); + }).pipe(Effect.provide(live)), + ); + + it.live("fails a single stack with its own destroy error", () => + Effect.gen(function* () { + const f = yield* fixture(true); + + const error = yield* stackDestroy(f.flags).pipe( + Effect.provide(Layer.merge(f.layer, refuseDestroy(f.api, [f.stack.id]))), + Effect.flip, + ); + + expect(error).toMatchObject({ reason: "unknown", message: "engine refused" }); + expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ + f.stack.id, + ]); + }).pipe(Effect.provide(live)), + ); + + it.live("points a batch at the engine only when it was unreachable for every stack", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const second = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "second", + runtime: "native", + }); + const ids = [f.stack.id, second.id]; + const retry = `"supabase stack destroy --stack-id ${f.stack.id} --stack-id ${second.id} --yes"`; + const destroyWith = (unreachable: ReadonlyArray) => + stackDestroy({ ...f.flags, stackId: ids }).pipe( + Effect.provide(Layer.merge(f.layer, refuseDestroy(f.api, ids, unreachable))), + Effect.flip, + ); + + expect(yield* destroyWith([f.stack.id])).toMatchObject({ + reason: "unknown", + suggestion: `Resolve each error, then run ${retry} to retry the stacks that failed.`, + }); + expect(yield* destroyWith(ids)).toMatchObject({ + reason: "runtime", + suggestion: `Start the container engine, then run ${retry} again; nothing was removed for those stacks.`, + }); + expect( + (yield* f.api.discover(f.locations)).map(({ definition }) => definition.id).toSorted(), + ).toEqual(ids.toSorted()); + }).pipe(Effect.provide(live)), + ); + + it.live("destroys a registered stack and a deleted stack's containers in one batch", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const id = "d".repeat(64); + let removed = 0; + const api = Layer.succeed(StackApi, { + ...f.api, + findDeleted: (options) => + Effect.succeed( + options.id === id + ? Option.some({ + id, + destroy: Effect.sync(() => { + removed += 1; + }), + }) + : Option.none(), + ), + }); + + yield* stackDestroy({ ...f.flags, stackId: [f.stack.id, id] }).pipe( + Effect.provide(Layer.merge(f.layer, api)), + ); + + expect(removed).toBe(1); + expect(f.output.stderrText).toBe( + `Permanently destroying stack ${f.stack.id} at ${f.projectRoot} and its owned data; the containers deleted stack ${id} left behind. Storage upload files will be preserved.\n`, + ); + expect(f.output.stdoutText).toBe( + `Stack ${f.stack.id} destroyed.\nRemoved the containers stack ${id} left behind.\n`, + ); + expect(yield* f.api.discover(f.locations)).toEqual([]); + }).pipe(Effect.provide(live)), + ); + + it.live("keeps destroying after a failure and fails once with the stacks it destroyed", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const second = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "second", + runtime: "native", + }); + const stdout: Array = []; + const stdio = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => + Sink.forEach((item: string | Uint8Array) => + Effect.sync(() => { + stdout.push(typeof item === "string" ? item : new TextDecoder().decode(item)); + }), + ), + stderr: () => Sink.forEach(() => Effect.void), + }), + ); + const processControl = mockProcessControl(); + + yield* runDestroy( + Layer.merge( + f.layer, + Layer.mergeAll( + refuseDestroy(f.api, [f.stack.id]), + jsonOutputLayer.pipe(Layer.provide(stdio)), + processControl.layer, + ), + ), + ["--stack-id", f.stack.id, "--stack-id", second.id], + ); + + expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ + f.stack.id, + ]); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(stdout.join("")), + ).toEqual({ + destroyed_stacks: [{ id: second.id }], + _tag: "Error", + error: { + code: "ExperimentalStackDestroyError", + message: "Failed to destroy 1 managed stack(s).", + detail: `${f.stack.id}: engine refused`, + suggestion: `Resolve each error, then run "supabase stack destroy --stack-id ${f.stack.id} --yes" to retry the stacks that failed.`, + }, + }); + expect(processControl.exitCode).toBe(1); + }).pipe(Effect.provide(live)), + ); }); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts index 10dc8d656c..1062f8bbcb 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts @@ -67,7 +67,7 @@ const fixture = Effect.fn("StackDestroyRuntimeUnavailableTest.fixture")(function stack, output, layer, - flags: { stack: Option.none(), stackId: Option.some(stack.id) }, + flags: { stack: Option.none(), stackId: [stack.id] }, }; });