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
5 changes: 5 additions & 0 deletions .changeset/cli-buckets-purge.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/storage-purge-bucket.md
Original file line number Diff line number Diff line change
@@ -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 })`.
20 changes: 20 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -737,6 +738,25 @@ tigris buckets restore <name>
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 <name> [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
Expand Down
79 changes: 79 additions & 0 deletions packages/cli/src/lib/buckets/purge.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
printStart(context);

const format = getFormat(options);

const names = getOption<string | string[]>(options, ['name']);
const force = getOption<boolean>(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<string, unknown> = {
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);
}
28 changes: 28 additions & 0 deletions packages/cli/src/specs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions packages/cli/test/lib/buckets/purge.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
68 changes: 68 additions & 0 deletions packages/storage/src/lib/bucket/purge.ts
Original file line number Diff line number Diff line change
@@ -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<TigrisStorageConfig, 'bucket'>;
};

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<TigrisStorageResponse<PurgeBucketResponse, Error>> {
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)),
};
}
}
5 changes: 5 additions & 0 deletions packages/storage/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading