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: 9 additions & 8 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1344,21 +1344,22 @@ prisma-cli bucket create --name my-store
prisma-cli bucket create --branch preview --json
```

### `prisma-cli bucket delete <bucketId>`
### `prisma-cli bucket delete <bucketId> --confirm <bucketId>`

Purpose:

- delete a bucket and all its access keys
- permanently delete a bucket, all objects stored in it, and all its access keys

Behavior:

- requires auth
- treats `<bucketId>` as the bucket id to delete
- removes the bucket and all its access keys through the Management API
- requires `--confirm <bucketId>` to match the positional argument exactly;
exits with code 2 and `CONFIRMATION_REQUIRED` if the flag is missing or
does not match
- cascades the deletion through the Management API: all stored objects are
removed and then all access keys are revoked before the bucket is destroyed
- fails with `BUCKET_NOT_FOUND` when the bucket id does not exist
- deletion requires the bucket to be empty; deleting a bucket that still
contains objects currently fails with an internal Management API error
rather than a conflict error

In `--json`, `result` uses this shape:

Expand All @@ -1371,8 +1372,8 @@ In `--json`, `result` uses this shape:
Examples:

```bash
prisma-cli bucket delete bkt_123
prisma-cli bucket delete bkt_123 --json
prisma-cli bucket delete bkt_123 --confirm bkt_123
prisma-cli bucket delete bkt_123 --confirm bkt_123 --json
```

### `prisma-cli bucket key list <bucketId>`
Expand Down
7 changes: 4 additions & 3 deletions docs/product/resource-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,10 @@ Rules:
block on stdout: `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`,
`S3_BUCKET`
- `bucket list` and `bucket key list` never print or return secret values
- successful bucket deletion also revokes all of the bucket's access keys;
deletion requires the bucket to be empty and currently fails with an
internal Management API error when it is not
- `bucket delete` cascades: all stored objects are removed and all access
keys are revoked before the bucket is destroyed; the command requires
`--confirm <bucket-id>` matching the positional argument to prevent
accidental data loss

The beta package exposes:

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/bucket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,19 @@ function createBucketDeleteCommand(runtime: CliRuntime): Command {
);

command.argument("<bucketId>", "Bucket id");
command.addOption(
new Option("--confirm <bucket-id>", "Exact bucket id to confirm deletion"),
);
addGlobalFlags(command);

command.action(async (bucketId: string, options) => {
const confirm = (options as { confirm?: string }).confirm;

await runCommand<BucketDeleteResult>(
runtime,
"bucket.delete",
options as Record<string, unknown>,
(context) => runBucketDelete(context, bucketId),
(context) => runBucketDelete(context, bucketId, { confirm }),
{
renderHuman: (context, descriptor, result) =>
renderBucketDelete(context, descriptor, result),
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/controllers/bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ interface BucketCreateFlags extends BucketCommandFlags {
name?: string;
}

interface BucketDeleteFlags {
confirm?: string;
}

interface BucketKeyCreateFlags {
role?: string;
name?: string;
Expand Down Expand Up @@ -118,6 +122,7 @@ export async function runBucketCreate(
export async function runBucketDelete(
context: CommandContext,
bucketId: string,
flags: BucketDeleteFlags,
): Promise<CommandSuccess<BucketDeleteResult>> {
const id = bucketId.trim();
if (!id) {
Expand All @@ -132,6 +137,19 @@ export async function runBucketDelete(
});
}

if (flags.confirm !== id) {
throw new CliError({
code: "CONFIRMATION_REQUIRED",
domain: "bucket",
summary: "Confirm bucket deletion",
why: "Deleting this bucket permanently removes all objects and access keys.",
fix: `Rerun with --confirm ${id}.`,
exitCode: 2,
nextSteps: [`prisma-cli bucket delete ${id} --confirm ${id}`],
meta: { expectedConfirm: id, receivedConfirm: flags.confirm ?? null },
});
}

const provider = await requireBucketProviderOnly(context);
await provider.deleteBucket(id, { signal: context.runtime.signal });

Expand Down
72 changes: 70 additions & 2 deletions packages/cli/tests/bucket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ describe("bucket commands", () => {
const { cwd, stateDir } = await setupLinkedProject();

const result = await executeCli({
argv: ["bucket", "delete", "bkt_123", "--json"],
argv: ["bucket", "delete", "bkt_123", "--confirm", "bkt_123", "--json"],
cwd,
stateDir,
fixturePath,
Expand All @@ -217,11 +217,79 @@ describe("bucket commands", () => {
});
});

it("requires --confirm matching the bucket id before deletion", async () => {
const { cwd, stateDir } = await setupLinkedProject();

const noConfirm = await executeCli({
argv: ["bucket", "delete", "bkt_123", "--json"],
cwd,
stateDir,
fixturePath,
});
const noConfirmPayload = JSON.parse(noConfirm.stdout);

expect(noConfirm.exitCode).toBe(2);
expect(noConfirmPayload).toMatchObject({
ok: false,
command: "bucket.delete",
error: {
code: "CONFIRMATION_REQUIRED",
domain: "bucket",
meta: { expectedConfirm: "bkt_123", receivedConfirm: null },
},
});

const list = await executeCli({
argv: ["bucket", "list", "--json"],
cwd,
stateDir,
fixturePath,
});
const listPayload = JSON.parse(list.stdout);
expect(listPayload.result.items).toContainEqual(
expect.objectContaining({ id: "bkt_123" }),
);
});

it("rejects --confirm that does not match the bucket id", async () => {
const { cwd, stateDir } = await setupLinkedProject();

const wrongConfirm = await executeCli({
argv: ["bucket", "delete", "bkt_123", "--confirm", "bkt_456", "--json"],
cwd,
stateDir,
fixturePath,
});
const wrongPayload = JSON.parse(wrongConfirm.stdout);

expect(wrongConfirm.exitCode).toBe(2);
expect(wrongPayload).toMatchObject({
ok: false,
command: "bucket.delete",
error: {
code: "CONFIRMATION_REQUIRED",
domain: "bucket",
meta: { expectedConfirm: "bkt_123", receivedConfirm: "bkt_456" },
},
});

const list = await executeCli({
argv: ["bucket", "list", "--json"],
cwd,
stateDir,
fixturePath,
});
const listPayload = JSON.parse(list.stdout);
expect(listPayload.result.items).toContainEqual(
expect.objectContaining({ id: "bkt_123" }),
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("fails with BUCKET_NOT_FOUND when deleting an unknown bucket", async () => {
const { cwd, stateDir } = await setupLinkedProject();

const result = await executeCli({
argv: ["bucket", "delete", "bkt_999", "--json"],
argv: ["bucket", "delete", "bkt_999", "--confirm", "bkt_999", "--json"],
cwd,
stateDir,
fixturePath,
Expand Down
Loading