Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ const config = {
Flag.optional,
),
stackId: Flag.string("stack-id").pipe(
Flag.withDescription("Destroy an existing stack by id or unique id prefix."),
Flag.optional,
Flag.withDescription(
"Destroy an existing stack by id or unique id prefix; repeat to select several.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you also update stack-commands.md and SIDE_EFFECTS.md ?

),
Flag.atLeast(0),
),
} as const;

Expand All @@ -30,6 +32,10 @@ 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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class StackCommandDestroyError extends Data.TaggedError("ExperimentalStac
| "lifecycle"
| "unknown";
readonly message: string;
readonly detail?: string;
readonly suggestion?: string;
readonly cause?: unknown;
}> {
Expand Down
235 changes: 183 additions & 52 deletions apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CommandSettings } from "../../../../config/command-settings.service.ts"
import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts";
import {
StackApi,
type StackTarget,
StackTargetError,
rejectStackOutput,
skippedRuntimeCleanupWarning,
Expand All @@ -32,8 +33,13 @@ const destroyError = (cause: StackError) =>
cause,
});

export interface StackDestroyInput {
readonly stack?: Option.Option<string> | string;
readonly stackId?: ReadonlyArray<string> | Option.Option<string> | string;
}

export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* (
flags: StackDestroyFlags,
flags: StackDestroyFlags | StackDestroyInput,
) {
const telemetryState = yield* TelemetryState;
const body = Effect.gen(function* () {
Expand All @@ -42,29 +48,74 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* (
const api = yield* StackApi;
const outputFlag = yield* Effect.serviceOption(OutputFlag);
yield* rejectStackOutput(outputFlag).pipe(Effect.mapError(mapTargetError));

const stackOption =
flags.stack !== undefined && Option.isOption(flags.stack)
? flags.stack
: typeof flags.stack === "string"
? Option.some(flags.stack)
: Option.none<string>();

const rawStackId = flags.stackId;
const stackIds: ReadonlyArray<string> =
rawStackId === undefined
? []
: Array.isArray(rawStackId)
? rawStackId
: typeof rawStackId === "string"
? [rawStackId]
: Option.isOption(rawStackId) && Option.isSome(rawStackId)
? [rawStackId.value]
: [];

yield* validateStackTarget({
stack: Option.getOrUndefined(flags.stack),
stackId: Option.getOrUndefined(flags.stackId),
stack: Option.getOrUndefined(stackOption),
stackId: stackIds,
}).pipe(Effect.mapError(mapTargetError));

const resolver = yield* StackTargetResolver;
const path = yield* Path.Path;
const target = 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));
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.",
});

const targets: StackTarget[] = [];
if (stackIds.length > 0) {
for (const id of stackIds) {
const target = yield* resolver
.resolve({
projectRoot: settings.workdir,
id,
runtime: "auto",
})
.pipe(Effect.mapError(mapTargetError));
targets.push(target);
}
} else {
const target = yield* resolver
.resolve({
projectRoot: settings.workdir,
...(Option.isSome(stackOption) ? { name: stackOption.value } : {}),
runtime: "auto",
})
.pipe(Effect.mapError(mapTargetError));
if (target.id === undefined)
return yield* new StackCommandDestroyError({
reason: "flags",
message: Option.isSome(stackOption)
? `No managed stack named "${stackOption.value}" was found for this project.`
: "No managed stack was found for this project.",
suggestion: "Choose an existing --stack name or --stack-id.",
});
targets.push(target);
}

const seen = new Set<string>();
const uniqueTargets: StackTarget[] = [];
for (const target of targets) {
if (target.id !== undefined && !seen.has(target.id)) {
seen.add(target.id);
uniqueTargets.push(target);
}
}

const yes = yield* resolveYes;
const tty = yield* Tty;
if (!yes && (!tty.stdinIsTty || !output.interactive || output.format !== "text"))
Expand All @@ -73,8 +124,13 @@ 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 = `stack ${target.id} at ${target.projectRoot} and its owned data`;

const preserved = "Storage upload files will be preserved.";
const scope =
uniqueTargets.length === 1
? `stack ${uniqueTargets[0]!.id} at ${uniqueTargets[0]!.projectRoot} and its owned data`
: `stacks ${uniqueTargets.map((t) => t.id).join(", ")} and their owned data`;

if (yes) yield* output.raw(`Permanently destroying ${scope}. ${preserved}\n`, "stderr");
else {
const confirmed = yield* promptYesNo(
Expand All @@ -89,38 +145,113 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* (
message: "Stack destruction was not confirmed.",
});
}
const stack = yield* api
.open({
id: target.id,
stateRoot: path.join(settings.supabaseHome, "stacks"),
cacheRoot: path.join(settings.supabaseHome, "cache", "stack"),
})
.pipe(Effect.mapError(destroyError));
const destroying = yield* output.task(`Destroying stack ${target.id}...`);
const result = 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),
);
yield* Effect.annotateCurrentSpan({
"stack.prompted": !yes,
"stack.runtime_cleanup": result.runtimeCleanup,
});
if (result.runtimeCleanup === "skipped")
yield* output.warn(skippedRuntimeCleanupWarning(`stack ${target.id}`, result));
if (output.format !== "text")
yield* output.success("", { destroyed: true, id: target.id, ...result });
else if (result.runtimeCleanup === "complete")
yield* output.raw(`Stack ${target.id} destroyed.\n`);
else
yield* output.raw(
`Stack ${target.id} was removed locally; its ${result.engine === "docker" ? "Docker" : "Podman"} resources remain until the commands above are run.\n`,
);

const successes: Array<{
readonly id: string;
readonly result: {
readonly runtimeCleanup: "complete" | "partial" | "skipped";
readonly engine?: "docker" | "podman";
readonly remainingCommands?: ReadonlyArray<string>;
};
}> = [];
const failures: Array<{
readonly id: string;
readonly error: StackCommandDestroyError;
}> = [];

for (const target of uniqueTargets) {
const targetId = target.id!;
const destroySingle = Effect.gen(function* () {
const stack = yield* api
.open({
id: targetId,
stateRoot: path.join(settings.supabaseHome, "stacks"),
cacheRoot: path.join(settings.supabaseHome, "cache", "stack"),
})
.pipe(Effect.mapError(destroyError));
const destroying = yield* output.task(`Destroying stack ${targetId}...`);
const result = 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),
);
return result;
});

const exit = yield* Effect.exit(destroySingle);
if (Exit.isSuccess(exit)) {
const result = exit.value;
successes.push({ id: targetId, result });
if (result.runtimeCleanup === "skipped")
yield* output.warn(skippedRuntimeCleanupWarning(`stack ${targetId}`, result));
if (output.format === "text") {
if (result.runtimeCleanup === "complete")
yield* output.raw(`Stack ${targetId} destroyed.\n`);
else
yield* output.raw(
`Stack ${targetId} was removed locally; its ${result.engine === "docker" ? "Docker" : "Podman"} resources remain until the commands above are run.\n`,
);
}
} else {
const error = Option.getOrElse(
Exit.findErrorOption(exit),
() =>
new StackCommandDestroyError({
reason: "unknown",
message: `Failed to destroy stack ${targetId}`,
}),
);
Comment on lines +201 to +208

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ig we should we keep the original error here? instead of a generic failure

failures.push({ id: targetId, error });
yield* output.error(`Failed to destroy stack ${targetId}: ${error.message}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo the task and final error already report this failure. so we prob can drop this extra error line on stdout as well..

}
}

if (uniqueTargets.length === 1 && successes.length === 1) {
yield* Effect.annotateCurrentSpan({
"stack.prompted": !yes,
"stack.runtime_cleanup": successes[0]!.result.runtimeCleanup,
});
} else {
yield* Effect.annotateCurrentSpan({
"stack.prompted": !yes,
"stack.count": uniqueTargets.length,
"stack.destroyed_count": successes.length,
"stack.failed_count": failures.length,
});
}

if (output.format !== "text") {
if (uniqueTargets.length === 1 && successes.length === 1) {
yield* output.success("", {
Comment thread
tirthraj01 marked this conversation as resolved.
destroyed: true,
id: successes[0]!.id,
...successes[0]!.result,
});
} else {
yield* output.success("", {
destroyed: failures.length === 0,
stacks: successes.map(({ id, result }) => ({ id, ...result })),
failures: failures.map(({ id, error }) => ({ id, message: error.message })),
});
}
Comment on lines +236 to +241

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this emit one error envelope? failed destroys currently print two JSON documents MachineErrorContext could carry the partial results, as stack start does here?:

const machineErrorContext = yield* Effect.serviceOption(MachineErrorContext);
if (Option.isSome(machineErrorContext))
yield* machineErrorContext.value.set({
stack_changes: rejected.changes,
recreate_command: rejected.command,
});
return yield* rejected.error;

}

if (failures.length > 0) {
if (failures.length === 1 && uniqueTargets.length === 1) {
return yield* failures[0]!.error;
}
return yield* new StackCommandDestroyError({
reason: "unknown",
message: `Failed to destroy ${failures.length} managed stack(s).`,
detail: failures.map(({ id, error }) => `${id}: ${error.message}`).join("\n"),
cause: failures,
});
}
});
return yield* body.pipe(Effect.ensuring(telemetryState.flush));
});
Loading
Loading