From b1d9870647444edd2197d9e9a94388606d0b06ee Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 8 Oct 2026 09:33:03 +0200 Subject: [PATCH] feat(storage): purge soft-deleted buckets Co-Authored-By: Claude Fable 5.1 Assisted-by: Claude Fable 5.1 via Claude Code --- .changeset/cli-buckets-purge.md | 5 + .changeset/storage-purge-bucket.md | 5 + packages/cli/README.md | 20 +++ packages/cli/src/lib/buckets/purge.ts | 79 +++++++++++ packages/cli/src/specs.yaml | 28 ++++ packages/cli/test/lib/buckets/purge.test.ts | 127 ++++++++++++++++++ packages/storage/src/lib/bucket/purge.ts | 68 ++++++++++ packages/storage/src/server.ts | 5 + packages/storage/src/test/integration.test.ts | 57 ++++++++ shared/headers.ts | 5 + 10 files changed, 399 insertions(+) create mode 100644 .changeset/cli-buckets-purge.md create mode 100644 .changeset/storage-purge-bucket.md create mode 100644 packages/cli/src/lib/buckets/purge.ts create mode 100644 packages/cli/test/lib/buckets/purge.test.ts create mode 100644 packages/storage/src/lib/bucket/purge.ts diff --git a/.changeset/cli-buckets-purge.md b/.changeset/cli-buckets-purge.md new file mode 100644 index 00000000..a190d687 --- /dev/null +++ b/.changeset/cli-buckets-purge.md @@ -0,0 +1,5 @@ +--- +"@tigrisdata/cli": minor +--- + +`tigris buckets purge` permanently destroys one or more soft-deleted buckets (comma-separated), after a confirmation that names them; `--yes` skips it. Each bucket is purged in turn, failures are reported per bucket without stopping the rest, and the exit code is non-zero if any failed. Purged buckets can no longer be restored. diff --git a/.changeset/storage-purge-bucket.md b/.changeset/storage-purge-bucket.md new file mode 100644 index 00000000..b72ae62e --- /dev/null +++ b/.changeset/storage-purge-bucket.md @@ -0,0 +1,5 @@ +--- +"@tigrisdata/storage": minor +--- + +`purgeBucket(name)` permanently destroys a soft-deleted bucket before its retention period expires — the counterpart of `restoreBucket`. The bucket must already be soft-deleted (the gateway rejects a live one) and can no longer be restored afterwards; list the candidates with `listBuckets({ deleted: true })`. diff --git a/packages/cli/README.md b/packages/cli/README.md index fba035c4..8c300184 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -578,6 +578,7 @@ Create, inspect, update, and delete buckets. Buckets are top-level containers th | `tigris buckets get` (g) | Show details for a bucket including access level, region, tier, and custom domain | | `tigris buckets delete` (d) | Delete one or more buckets by name. The bucket must be empty or delete-protection must be off | | `tigris buckets restore` | Restore a soft-deleted bucket within its retention window. List recoverable buckets with "tigris buckets list --deleted" | +| `tigris buckets purge` (pg) | Permanently destroy one or more soft-deleted buckets before their retention period expires. This cannot be undone — they can no longer be restored with "tigris buckets restore". List them with "tigris buckets list --deleted" | | `tigris buckets set` (s) | Update settings on an existing bucket such as access level, default tier, location, caching, or custom domain | | `tigris buckets enable-snapshots` | Enable snapshots on an existing bucket, converting it to a snapshot bucket | | `tigris buckets disable-snapshots` | Disable snapshots on an existing bucket, converting it back to a regular bucket. Rejected while the bucket has dependent forks | @@ -737,6 +738,25 @@ tigris buckets restore tigris buckets restore my-bucket ``` +#### `tigris buckets purge` (pg) + +Permanently destroy one or more soft-deleted buckets before their retention period expires. This cannot be undone — they can no longer be restored with "tigris buckets restore". List them with "tigris buckets list --deleted" + +``` +tigris buckets purge [flags] +``` + +| Flag | Description | +|------|-------------| +| `-y, --yes` | Skip confirmation prompts | +| `--force` | Skip confirmation prompts (alias for --yes) | + +**Examples:** +```bash +tigris buckets purge my-bucket --yes +tigris buckets purge bucket-a,bucket-b --yes +``` + #### `tigris buckets set` (s) Update settings on an existing bucket such as access level, default tier, location, caching, or custom domain diff --git a/packages/cli/src/lib/buckets/purge.ts b/packages/cli/src/lib/buckets/purge.ts new file mode 100644 index 00000000..7bee71d2 --- /dev/null +++ b/packages/cli/src/lib/buckets/purge.ts @@ -0,0 +1,79 @@ +import { getStorageConfig } from '@auth/provider.js'; +import { purgeBucket } from '@tigrisdata/storage'; +import { + exitWithError, + failWithError, + getSuccessNextActions, + printNextActions, +} from '@utils/exit.js'; +import { confirm, requireInteractive } from '@utils/interactive.js'; +import { + msg, + printFailure, + printStart, + printSuccess, +} from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +const context = msg('buckets', 'purge'); + +export default async function purge(options: Record) { + printStart(context); + + const format = getFormat(options); + + const names = getOption(options, ['name']); + const force = getOption(options, ['yes', 'y', 'force']); + + if (!names) { + failWithError(context, 'Bucket name is required'); + } + + const bucketNames = Array.isArray(names) ? names : [names]; + + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm( + bucketNames.length === 1 + ? `Permanently destroy soft-deleted bucket '${bucketNames[0]}'? It can no longer be restored.` + : `Permanently destroy ${bucketNames.length} soft-deleted buckets: ${bucketNames.join(', ')}? They can no longer be restored.` + ); + if (!confirmed) { + console.log('Aborted'); + return; + } + } + + const config = await getStorageConfig(); + + const purged: string[] = []; + const errors: { name: string; error: string }[] = []; + for (const name of bucketNames) { + const { error } = await purgeBucket(name, { config }); + + if (error) { + printFailure(context, error.message, { name }); + errors.push({ name, error: error.message }); + } else { + purged.push(name); + printSuccess(context, { name }); + } + } + + if (format === 'json') { + const nextActions = getSuccessNextActions(context); + const output: Record = { + action: 'purged', + names: purged, + errors, + }; + if (nextActions.length > 0) output.nextActions = nextActions; + console.log(JSON.stringify(output)); + } + + if (errors.length > 0) { + exitWithError(errors[0].error, context); + } + + printNextActions(context); +} diff --git a/packages/cli/src/specs.yaml b/packages/cli/src/specs.yaml index 5f04fc0c..9a6688c9 100644 --- a/packages/cli/src/specs.yaml +++ b/packages/cli/src/specs.yaml @@ -1132,6 +1132,34 @@ commands: required: true examples: - my-bucket + # purge + - name: purge + group: Basics + description: Permanently destroy one or more soft-deleted buckets before their retention period expires. This cannot be undone — they can no longer be restored with "tigris buckets restore". List them with "tigris buckets list --deleted" + helpText: Permanently destroy soft-deleted buckets + alias: pg + examples: + - "tigris buckets purge my-bucket --yes" + - "tigris buckets purge bucket-a,bucket-b --yes" + messages: + onStart: 'Purging bucket...' + onSuccess: "Bucket '{{name}}' purged" + onFailure: "Failed to purge bucket '{{name}}'" + nextActions: + - command: 'tigris buckets list --deleted' + description: 'List the remaining soft-deleted buckets' + arguments: + - *yes_argument + - name: name + description: Name of the soft-deleted bucket or comma separated list of buckets + type: positional + required: true + multiple: true + examples: + - my-bucket + - name: force + type: flag + description: Skip confirmation prompts (alias for --yes) # set - name: set group: Basics diff --git a/packages/cli/test/lib/buckets/purge.test.ts b/packages/cli/test/lib/buckets/purge.test.ts new file mode 100644 index 00000000..b155d4bc --- /dev/null +++ b/packages/cli/test/lib/buckets/purge.test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as YAML from 'yaml'; + +vi.mock('@auth/provider.js', () => ({ + getStorageConfig: vi.fn(async () => ({ endpoint: 'https://t3.example' })), +})); +vi.mock('@tigrisdata/storage', () => ({ + purgeBucket: vi.fn(async (bucket: string) => ({ + data: { bucket, purged: true }, + })), +})); +vi.mock('@utils/interactive.js', () => ({ + confirm: vi.fn(async () => true), + requireInteractive: vi.fn(), +})); + +import { purgeBucket } from '@tigrisdata/storage'; +import { confirm, requireInteractive } from '@utils/interactive.js'; +import purge from '../../../src/lib/buckets/purge.js'; +import type { Specs } from '../../../src/types.js'; +import { setSpecs } from '../../../src/utils/specs.js'; + +setSpecs( + YAML.parse(readFileSync(join(process.cwd(), 'src/specs.yaml'), 'utf8'), { + schema: 'core', + }) as Specs +); + +class ExitSignal extends Error {} + +let stdout: string[]; +let stderr: string[]; + +beforeEach(() => { + // Clears calls, not implementations: per-test overrides below use `…Once`. + vi.clearAllMocks(); + stdout = []; + stderr = []; + vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args) => { + stderr.push(args.join(' ')); + }); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new ExitSignal(); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('buckets purge', () => { + it('purges one bucket after a confirmation that says it is final', async () => { + await purge({ name: 'old-bucket' }); + + expect(requireInteractive).toHaveBeenCalledWith( + 'Use --yes to skip confirmation' + ); + expect(confirm).toHaveBeenCalledWith( + "Permanently destroy soft-deleted bucket 'old-bucket'? It can no longer be restored." + ); + expect(purgeBucket).toHaveBeenCalledWith('old-bucket', { + config: { endpoint: 'https://t3.example' }, + }); + }); + + it('purges every bucket in a comma-separated list, in order', async () => { + // The spec marks `name` as multiple, so the CLI hands the handler an array. + await purge({ name: ['a', 'b', 'c'], yes: true, json: true }); + + expect(confirm).not.toHaveBeenCalled(); + expect(vi.mocked(purgeBucket).mock.calls.map(([name]) => name)).toEqual([ + 'a', + 'b', + 'c', + ]); + expect(JSON.parse(stdout[0])).toMatchObject({ + action: 'purged', + names: ['a', 'b', 'c'], + errors: [], + }); + }); + + it('names every bucket in the confirmation when several are given', async () => { + await purge({ name: ['a', 'b'] }); + expect(confirm).toHaveBeenCalledWith( + 'Permanently destroy 2 soft-deleted buckets: a, b? They can no longer be restored.' + ); + }); + + it('keeps going after a failure, reports it, and exits non-zero', async () => { + vi.mocked(purgeBucket) + .mockResolvedValueOnce({ data: { bucket: 'a', purged: true } }) + .mockResolvedValueOnce({ + error: new Error('bucket is not in soft-delete state'), + }) + .mockResolvedValueOnce({ data: { bucket: 'c', purged: true } }); + + await expect( + purge({ name: ['a', 'b', 'c'], yes: true, json: true }) + ).rejects.toThrow(ExitSignal); + + expect(purgeBucket).toHaveBeenCalledTimes(3); + expect(JSON.parse(stdout[0])).toMatchObject({ + names: ['a', 'c'], + errors: [{ name: 'b', error: 'bucket is not in soft-delete state' }], + }); + expect(stderr.join('\n')).toContain('bucket is not in soft-delete state'); + }); + + it('does nothing when the confirmation is declined', async () => { + vi.mocked(confirm).mockResolvedValueOnce(false); + await purge({ name: 'old-bucket' }); + expect(purgeBucket).not.toHaveBeenCalled(); + expect(stdout).toContain('Aborted'); + }); + + it('requires a name', async () => { + await expect(purge({ yes: true })).rejects.toThrow(ExitSignal); + expect(stderr.join('\n')).toContain('Bucket name is required'); + expect(purgeBucket).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/storage/src/lib/bucket/purge.ts b/packages/storage/src/lib/bucket/purge.ts new file mode 100644 index 00000000..cb6aaf79 --- /dev/null +++ b/packages/storage/src/lib/bucket/purge.ts @@ -0,0 +1,68 @@ +import { DeleteBucketCommand } from '@aws-sdk/client-s3'; +import type { HttpRequest } from '@aws-sdk/types'; +import { TigrisHeaders } from '@shared/index'; +import { createTigrisClient } from '../tigris-client'; +import type { TigrisStorageConfig, TigrisStorageResponse } from '../types'; + +export type PurgeBucketOptions = { + config?: Omit; +}; + +export type PurgeBucketResponse = { + bucket: string; + purged: boolean; +}; + +/** + * Permanently destroy a soft-deleted bucket before its retention period + * expires — the counterpart of `restoreBucket`. + * + * The bucket must already be soft-deleted; the gateway rejects the call for a + * live bucket rather than deleting it. Once purged it can no longer be + * restored. List the candidates with `listBuckets({ deleted: true })`. + */ +export async function purgeBucket( + bucketName: string, + options?: PurgeBucketOptions +): Promise> { + if (!bucketName) { + return { error: new Error('Bucket name is required') }; + } + + const { data: tigrisClient, error } = createTigrisClient( + options?.config, + true + ); + + if (error) { + return { error }; + } + + const command = new DeleteBucketCommand({ + Bucket: bucketName, + }); + + // A plain DeleteBucket on a soft-deleted bucket is a no-op; this header + // turns it into the hard delete. + command.middlewareStack.add( + (next) => async (args) => { + const req = args.request as HttpRequest; + req.headers[TigrisHeaders.FORCE_HARD_DELETE] = 'true'; + return next(args); + }, + { + name: 'X-Tigris-Force-Hard-Delete-Middleware', + step: 'build', + override: true, + } + ); + + try { + await tigrisClient.send(command); + return { data: { bucket: bucketName, purged: true } }; + } catch (error) { + return { + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/packages/storage/src/server.ts b/packages/storage/src/server.ts index 6475ad2a..f8228b70 100644 --- a/packages/storage/src/server.ts +++ b/packages/storage/src/server.ts @@ -24,6 +24,11 @@ export { type ListBucketsResponse, listBuckets, } from './lib/bucket/list'; +export { + type PurgeBucketOptions, + type PurgeBucketResponse, + purgeBucket, +} from './lib/bucket/purge'; export { type RemoveBucketOptions, removeBucket } from './lib/bucket/remove'; export { type RestoreBucketOptions, diff --git a/packages/storage/src/test/integration.test.ts b/packages/storage/src/test/integration.test.ts index d73542c5..3bf2666c 100644 --- a/packages/storage/src/test/integration.test.ts +++ b/packages/storage/src/test/integration.test.ts @@ -10,6 +10,7 @@ import { import { createBucket } from '../lib/bucket/create'; import { getBucketInfo } from '../lib/bucket/info'; import { listBuckets } from '../lib/bucket/list'; +import { purgeBucket } from '../lib/bucket/purge'; import { removeBucket } from '../lib/bucket/remove'; import { restoreBucket } from '../lib/bucket/restore'; import { @@ -1140,6 +1141,62 @@ describe.skipIf(skipTests)('Tigris Storage Integration Tests', () => { }, POLL) .not.toContain(bucket); }); + + it('should purge a soft-deleted bucket for good', async () => { + const bucket = `test-soft-purge-${ts}`.toLowerCase(); + bucketsToCleanup.push(bucket); + + const created = await createBucket(bucket, { config }); + expect(created.error).toBeUndefined(); + + const enabled = await updateBucket(bucket, { + softDelete: { enabled: true, retentionDays: 7 }, + config, + }); + expect(enabled.error).toBeUndefined(); + + // Soft delete (no force) so there is something to purge. + const removed = await removeBucket(bucket, { config }); + expect( + removed.error, + `soft delete failed: ${removed.error?.message}` + ).toBeUndefined(); + + await expect + .poll(async () => { + const buckets = await listAllBuckets({ deleted: true }); + return buckets.map((b) => b.name); + }, POLL) + .toContain(bucket); + + const purged = await purgeBucket(bucket, { config }); + expect( + purged.error, + `purge failed: ${purged.error?.message}` + ).toBeUndefined(); + expect(purged.data).toEqual({ bucket, purged: true }); + + // Gone from the deleted view... + await expect + .poll(async () => { + const buckets = await listAllBuckets({ deleted: true }); + return buckets.map((b) => b.name); + }, POLL) + .not.toContain(bucket); + + // ...and not back among the live buckets either. + const live = await listAllBuckets(); + expect(live.map((b) => b.name)).not.toContain(bucket); + }); + + it('should refuse to purge a bucket that is not soft-deleted', async () => { + const result = await purgeBucket(softDeleteBucket, { config }); + expect(result.error?.message).toContain('soft-delete state'); + + // Still there, untouched. + const live = await listAllBuckets(); + expect(live.map((b) => b.name)).toContain(softDeleteBucket); + }); }); describe('end-to-end workflow', () => { diff --git a/shared/headers.ts b/shared/headers.ts index c01df5e4..c594fb9c 100644 --- a/shared/headers.ts +++ b/shared/headers.ts @@ -18,6 +18,11 @@ export enum TigrisHeaders { RESTORE_TYPE = 'X-Tigris-Restore-Type', /** The soft-deleted version to bring back. */ RESTORE_VERSION = 'X-Tigris-Restore-Version', + /** + * Turns a DeleteBucket on a soft-deleted bucket into the hard delete that + * destroys it for good. The gateway rejects it for a live bucket. + */ + FORCE_HARD_DELETE = 'X-Tigris-Force-Hard-Delete', BUCKET_LIST_SOURCE = 'X-Tigris-List-Source', // tigris or shadow SCHEDULE_MIGRATION = 'X-Tigris-Schedule-Migration',