Skip to content

Commit cf79696

Browse files
authored
feat: add --all flag to remove all secrets in sandbox (#2423)
* feat: enhance sandbox secret removal command to support removing all secrets * feat: add removeSecrets method to SecretClient and update related tests * chore: update changeset * fix: improve error handling in SandboxSecretRemoveCommand and update tests
1 parent 8ed3cc2 commit cf79696

File tree

8 files changed

+227
-16
lines changed

8 files changed

+227
-16
lines changed

.changeset/slow-yaks-matter.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@aws-amplify/backend-cli': minor
3+
'@aws-amplify/backend-secret': minor
4+
---
5+
6+
feat: add --all flag to remove all secrets in sandbox

packages/backend-secret/API.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type SecretClient = {
2626
listSecrets: (backendIdentifier: BackendIdentifier | AppId) => Promise<SecretListItem[]>;
2727
setSecret: (backendIdentifier: BackendIdentifier | AppId, secretName: string, secretValue: string) => Promise<SecretIdentifier>;
2828
removeSecret: (backendIdentifier: BackendIdentifier | AppId, secretName: string) => Promise<void>;
29+
removeSecrets: (backendIdentifier: BackendIdentifier | AppId, secretNames: string[]) => Promise<void>;
2930
};
3031

3132
// @public

packages/backend-secret/src/secret.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ export type SecretClient = {
6262
backendIdentifier: BackendIdentifier | AppId,
6363
secretName: string
6464
) => Promise<void>;
65+
66+
/**
67+
* Remove secrets.
68+
*/
69+
removeSecrets: (
70+
backendIdentifier: BackendIdentifier | AppId,
71+
secretNames: string[]
72+
) => Promise<void>;
6573
};
6674

6775
/**

packages/backend-secret/src/ssm_secret.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,82 @@ void describe('SSMSecret', () => {
262262
});
263263
});
264264

265+
void describe('removeSecrets', () => {
266+
const ssmClient = new SSM();
267+
const ssmSecretClient = new SSMSecretClient(ssmClient);
268+
const testSecretName2 = 'testSecretName2';
269+
const testSecretFullNamePath2 = `${testBranchPath}/${testSecretName2}`;
270+
const testSharedSecretFullNamePath2 = `${testSharedPath}/${testSecretName2}`;
271+
272+
void it('removes branch secrets', async () => {
273+
const mockDeleteParameters = mock.method(
274+
ssmClient,
275+
'deleteParameters',
276+
() =>
277+
Promise.resolve({
278+
DeletedParameters: [
279+
testBranchSecretFullNamePath,
280+
testSecretFullNamePath2,
281+
],
282+
})
283+
);
284+
285+
await ssmSecretClient.removeSecrets(testBackendIdentifier, [
286+
testSecretName,
287+
testSecretName2,
288+
]);
289+
assert.deepStrictEqual(mockDeleteParameters.mock.calls[0].arguments[0], {
290+
Names: [testBranchSecretFullNamePath, testSecretFullNamePath2],
291+
});
292+
});
293+
294+
void it('removes shared secrets', async () => {
295+
const mockDeleteParameters = mock.method(
296+
ssmClient,
297+
'deleteParameters',
298+
() =>
299+
Promise.resolve({
300+
DeletedParameters: [
301+
testBranchSecretFullNamePath,
302+
testSecretFullNamePath2,
303+
],
304+
})
305+
);
306+
307+
await ssmSecretClient.removeSecrets(testBackendId, [
308+
testSecretName,
309+
testSecretName2,
310+
]);
311+
assert.deepStrictEqual(mockDeleteParameters.mock.calls[0].arguments[0], {
312+
Names: [testSharedSecretFullNamePath, testSharedSecretFullNamePath2],
313+
});
314+
});
315+
316+
void it('does not remove invalid secrets', async () => {
317+
const mockDeleteParameters = mock.method(
318+
ssmClient,
319+
'deleteParameters',
320+
() =>
321+
Promise.resolve({
322+
DeletedParameters: [testBranchSecretFullNamePath],
323+
InvalidParameters: [testSecretFullNamePath2],
324+
})
325+
);
326+
327+
await assert.rejects(
328+
() =>
329+
ssmSecretClient.removeSecrets(testBackendIdentifier, [
330+
testSecretName,
331+
testSecretName2,
332+
]),
333+
new SecretError(`Failed to remove secrets: ${testSecretFullNamePath2}`)
334+
);
335+
assert.deepStrictEqual(mockDeleteParameters.mock.calls[0].arguments[0], {
336+
Names: [testBranchSecretFullNamePath, testSecretFullNamePath2],
337+
});
338+
});
339+
});
340+
265341
void describe('listSecrets', () => {
266342
const ssmClient = new SSM();
267343
const ssmSecretClient = new SSMSecretClient(ssmClient);

packages/backend-secret/src/ssm_secret.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,34 @@ export class SSMSecretClient implements SecretClient {
145145
throw SecretError.createInstance(err as Error);
146146
}
147147
};
148+
149+
/**
150+
* Remove secrets from SSM parameter store.
151+
*/
152+
public removeSecrets = async (
153+
backendIdentifier: BackendIdentifier | AppId,
154+
secretNames: string[]
155+
) => {
156+
const names = secretNames.map((secretName) =>
157+
ParameterPathConversions.toParameterFullPath(
158+
backendIdentifier,
159+
secretName
160+
)
161+
);
162+
try {
163+
const resp = await this.ssmClient.deleteParameters({
164+
Names: names,
165+
});
166+
if (resp.InvalidParameters && resp.InvalidParameters.length > 0) {
167+
throw new SecretError(
168+
`Failed to remove secrets: ${resp.InvalidParameters.join(', ')}`
169+
);
170+
}
171+
} catch (err) {
172+
if (err instanceof SecretError) {
173+
throw err;
174+
}
175+
throw SecretError.createInstance(err as Error);
176+
}
177+
};
148178
}

packages/backend-secret/src/ssm_secret_with_amplify_error_handling.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,23 @@ export class SSMSecretClientWithAmplifyErrorHandling implements SecretClient {
7373
}
7474
};
7575

76+
/**
77+
* Remove secrets from SSM parameter store.
78+
*/
79+
public removeSecrets = async (
80+
backendIdentifier: BackendIdentifier | AppId,
81+
secretNames: string[]
82+
) => {
83+
try {
84+
return await this.secretClient.removeSecrets(
85+
backendIdentifier,
86+
secretNames
87+
);
88+
} catch (e) {
89+
throw this.translateToAmplifyError(e, 'Remove');
90+
}
91+
};
92+
7693
private translateToAmplifyError = (
7794
error: unknown,
7895
apiName: string,

packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SandboxSecretRemoveCommand } from './sandbox_secret_remove_command.js';
88
import { printer } from '@aws-amplify/cli-core';
99

1010
const testSecretName = 'testSecretName';
11+
const testSecretName2 = 'testSecretName2';
1112
const testBackendId = 'testBackendId';
1213
const testSandboxName = 'testSandboxName';
1314

@@ -18,6 +19,14 @@ void describe('sandbox secret remove command', () => {
1819
'removeSecret',
1920
(): Promise<void> => Promise.resolve()
2021
);
22+
const secretsRemoveMock = mock.method(
23+
secretClient,
24+
'removeSecrets',
25+
(): Promise<void> => Promise.resolve()
26+
);
27+
const listSecretsMock = mock.method(secretClient, 'listSecrets', () =>
28+
Promise.resolve([{ name: testSecretName }, { name: testSecretName2 }])
29+
);
2130
const printMock = mock.method(printer, 'print');
2231

2332
const sandboxIdResolver: SandboxBackendIdResolver = {
@@ -77,13 +86,49 @@ void describe('sandbox secret remove command', () => {
7786
]);
7887
});
7988

89+
void it('remove all secrets', async () => {
90+
await commandRunner.runCommand('remove --all');
91+
assert.equal(listSecretsMock.mock.callCount(), 1);
92+
assert.deepStrictEqual(listSecretsMock.mock.calls[0].arguments, [
93+
{
94+
type: 'sandbox',
95+
namespace: testBackendId,
96+
name: testSandboxName,
97+
},
98+
]);
99+
100+
assert.equal(secretsRemoveMock.mock.callCount(), 1);
101+
assert.deepStrictEqual(secretsRemoveMock.mock.calls[0].arguments, [
102+
{
103+
type: 'sandbox',
104+
namespace: testBackendId,
105+
name: testSandboxName,
106+
},
107+
[testSecretName, testSecretName2],
108+
]);
109+
assert.equal(
110+
printMock.mock.calls[0].arguments,
111+
'Successfully removed all secrets'
112+
);
113+
});
114+
80115
void it('show --help', async () => {
81116
const output = await commandRunner.runCommand('remove --help');
82117
assert.match(output, /Remove a sandbox secret/);
83118
});
84119

85-
void it('throws error if no secret name argument', async () => {
86-
const output = await commandRunner.runCommand('remove');
87-
assert.match(output, /Not enough non-option arguments/);
120+
void it('throws error if no secret name argument and all flag', async () => {
121+
const output = await commandRunner.runCommand(`remove`);
122+
[
123+
/InvalidCommandInputError: Either secret-name or all flag must be provided/,
124+
/Resolution: Provide either secret-name or all flag/,
125+
].forEach((cmd) => assert.match(output, new RegExp(cmd)));
126+
});
127+
128+
void it('throws error if both --all flag and secret-name argument', async () => {
129+
assert.match(
130+
await commandRunner.runCommand(`remove ${testSecretName} --all`),
131+
/Arguments all and secret-name are mutually exclusive/
132+
);
88133
});
89134
});

packages/cli/src/commands/sandbox/sandbox-secret/sandbox_secret_remove_command.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SandboxBackendIdResolver } from '../sandbox_id_resolver.js';
44
import { ArgumentsKebabCase } from '../../../kebab_case.js';
55
import { SandboxCommandGlobalOptions } from '../option_types.js';
66
import { printer } from '@aws-amplify/cli-core';
7+
import { AmplifyUserError } from '@aws-amplify/platform-core';
78

89
/**
910
* Command to remove sandbox secret.
@@ -28,7 +29,7 @@ export class SandboxSecretRemoveCommand
2829
private readonly sandboxIdResolver: SandboxBackendIdResolver,
2930
private readonly secretClient: SecretClient
3031
) {
31-
this.command = 'remove <secret-name>';
32+
this.command = 'remove [secret-name]';
3233
this.describe = 'Remove a sandbox secret';
3334
}
3435

@@ -41,28 +42,55 @@ export class SandboxSecretRemoveCommand
4142
const sandboxBackendIdentifier = await this.sandboxIdResolver.resolve(
4243
args.identifier
4344
);
44-
await this.secretClient.removeSecret(
45-
sandboxBackendIdentifier,
46-
args.secretName
47-
);
48-
49-
printer.print(`Successfully removed secret ${args.secretName}`);
45+
if (args.secretName) {
46+
await this.secretClient.removeSecret(
47+
sandboxBackendIdentifier,
48+
args.secretName
49+
);
50+
printer.print(`Successfully removed secret ${args.secretName}`);
51+
} else if (args.all) {
52+
const secrets = await this.secretClient.listSecrets(
53+
sandboxBackendIdentifier
54+
);
55+
const names = secrets.map((secret) => secret.name);
56+
await this.secretClient.removeSecrets(sandboxBackendIdentifier, names);
57+
printer.print('Successfully removed all secrets');
58+
}
5059
};
5160

5261
/**
5362
* @inheritDoc
5463
*/
5564
builder = (yargs: Argv): Argv<SecretRemoveCommandOptionsKebabCase> => {
56-
return yargs.positional('secret-name', {
57-
describe: 'Name of the secret to remove',
58-
type: 'string',
59-
demandOption: true,
60-
});
65+
return yargs
66+
.option('all', {
67+
describe: 'Remove all secrets',
68+
type: 'boolean',
69+
conflicts: ['secret-name'],
70+
})
71+
.positional('secret-name', {
72+
describe: 'Name of the secret to remove',
73+
type: 'string',
74+
demandOption: false,
75+
})
76+
.check((argv) => {
77+
if (!argv.all && !argv['secret-name']) {
78+
throw new AmplifyUserError('InvalidCommandInputError', {
79+
message: 'Either secret-name or all flag must be provided',
80+
resolution: 'Provide either secret-name or all flag',
81+
});
82+
}
83+
return true;
84+
});
6185
};
6286
}
6387

6488
type SecretRemoveCommandOptionsKebabCase = ArgumentsKebabCase<
6589
{
66-
secretName: string;
90+
secretName: string | undefined;
91+
/**
92+
* Optional flag to remove all secrets.
93+
*/
94+
all?: boolean;
6795
} & SandboxCommandGlobalOptions
6896
>;

0 commit comments

Comments
 (0)