@aws-blocks/core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +32 -0
- package/dist/api.test.js +22 -0
- package/dist/bb-utils.d.ts +1 -0
- package/dist/bb-utils.d.ts.map +1 -1
- package/dist/bb-utils.js +3 -0
- package/dist/cdk/apigateway-account.d.ts +33 -0
- package/dist/cdk/apigateway-account.d.ts.map +1 -0
- package/dist/cdk/apigateway-account.js +60 -0
- package/dist/cdk/blocks-backend.d.ts +43 -9
- package/dist/cdk/blocks-backend.d.ts.map +1 -1
- package/dist/cdk/blocks-backend.js +69 -73
- package/dist/cdk/blocks-backend.test.js +78 -69
- package/dist/cdk/blocks-defaults.d.ts +92 -2
- package/dist/cdk/blocks-defaults.d.ts.map +1 -1
- package/dist/cdk/blocks-defaults.js +22 -2
- package/dist/cdk/blocks-defaults.test.js +21 -2
- package/dist/cdk/blocks-stack.test.js +94 -37
- package/dist/cdk/compute/compute-registry.d.ts +19 -0
- package/dist/cdk/compute/compute-registry.d.ts.map +1 -0
- package/dist/cdk/compute/compute-registry.js +38 -0
- package/dist/cdk/compute/compute.d.ts +2 -0
- package/dist/cdk/compute/compute.d.ts.map +1 -1
- package/dist/cdk/compute/compute.js +8 -0
- package/dist/cdk/compute/default-compute-factory.d.ts +34 -0
- package/dist/cdk/compute/default-compute-factory.d.ts.map +1 -0
- package/dist/cdk/compute/default-compute-factory.js +3 -0
- package/dist/cdk/config-registry.d.ts +34 -4
- package/dist/cdk/config-registry.d.ts.map +1 -1
- package/dist/cdk/config-registry.js +83 -25
- package/dist/cdk/config-registry.test.d.ts +2 -0
- package/dist/cdk/config-registry.test.d.ts.map +1 -0
- package/dist/cdk/config-registry.test.js +115 -0
- package/dist/cdk/index.d.ts +76 -7
- package/dist/cdk/index.d.ts.map +1 -1
- package/dist/cdk/index.js +99 -9
- package/dist/cdk/internal.d.ts +1 -0
- package/dist/cdk/internal.d.ts.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/common/config.d.ts +34 -0
- package/dist/common/config.d.ts.map +1 -1
- package/dist/common/config.js +45 -3
- package/dist/common/config.test.js +19 -0
- package/dist/common/index.d.ts +8 -0
- package/dist/common/index.d.ts.map +1 -1
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -0
- package/dist/hosting-secrets.d.ts +48 -0
- package/dist/hosting-secrets.d.ts.map +1 -0
- package/dist/hosting-secrets.js +57 -0
- package/dist/hosting-secrets.test.d.ts +2 -0
- package/dist/hosting-secrets.test.d.ts.map +1 -0
- package/dist/hosting-secrets.test.js +81 -0
- package/dist/hosting.d.ts +97 -6
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +91 -23
- package/dist/hosting.test.js +182 -10
- package/dist/index.cdk.d.ts +13 -12
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +13 -9
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -5
- package/dist/lambda-handler.d.ts +17 -0
- package/dist/lambda-handler.d.ts.map +1 -1
- package/dist/lambda-handler.js +52 -3
- package/dist/lambda-handler.test.js +196 -1
- package/dist/pipeline/index.d.ts +24 -2
- package/dist/pipeline/index.d.ts.map +1 -1
- package/dist/pipeline/index.js +41 -1
- package/dist/pipeline.test.d.ts +2 -0
- package/dist/pipeline.test.d.ts.map +1 -0
- package/dist/pipeline.test.js +47 -0
- package/dist/rpc.d.ts +21 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +34 -0
- package/dist/rpc.test.js +38 -1
- package/dist/scripts/config.d.ts +15 -0
- package/dist/scripts/config.d.ts.map +1 -0
- package/dist/scripts/config.js +29 -0
- package/dist/scripts/deploy.d.ts.map +1 -1
- package/dist/scripts/deploy.js +4 -0
- package/dist/scripts/dev-server.d.ts +8 -0
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +31 -0
- package/dist/scripts/generate-client.d.ts.map +1 -1
- package/dist/scripts/generate-client.js +9 -4
- package/dist/scripts/generate-client.test.js +19 -4
- package/dist/scripts/index.d.ts +13 -10
- package/dist/scripts/index.d.ts.map +1 -1
- package/dist/scripts/index.js +11 -8
- package/dist/scripts/preflight-credentials.d.ts +30 -0
- package/dist/scripts/preflight-credentials.d.ts.map +1 -0
- package/dist/scripts/preflight-credentials.js +108 -0
- package/dist/scripts/preflight-credentials.test.d.ts +2 -0
- package/dist/scripts/preflight-credentials.test.d.ts.map +1 -0
- package/dist/scripts/preflight-credentials.test.js +77 -0
- package/dist/scripts/sandbox-args.test.d.ts +2 -0
- package/dist/scripts/sandbox-args.test.d.ts.map +1 -0
- package/dist/scripts/sandbox-args.test.js +77 -0
- package/dist/scripts/sandbox-empty-buckets.test.d.ts +2 -0
- package/dist/scripts/sandbox-empty-buckets.test.d.ts.map +1 -0
- package/dist/scripts/sandbox-empty-buckets.test.js +171 -0
- package/dist/scripts/sandbox.d.ts +106 -0
- package/dist/scripts/sandbox.d.ts.map +1 -1
- package/dist/scripts/sandbox.js +255 -40
- package/dist/scripts/secret.d.ts +15 -0
- package/dist/scripts/secret.d.ts.map +1 -0
- package/dist/scripts/secret.js +29 -0
- package/dist/scripts/secret.test.d.ts +2 -0
- package/dist/scripts/secret.test.d.ts.map +1 -0
- package/dist/scripts/secret.test.js +28 -0
- package/dist/scripts/typegen.d.ts +15 -0
- package/dist/scripts/typegen.d.ts.map +1 -0
- package/dist/scripts/typegen.js +16 -0
- package/dist/secret-naming.d.ts +58 -0
- package/dist/secret-naming.d.ts.map +1 -0
- package/dist/secret-naming.js +67 -0
- package/dist/secret-naming.test.d.ts +2 -0
- package/dist/secret-naming.test.d.ts.map +1 -0
- package/dist/secret-naming.test.js +40 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
- package/src/api.test.ts +25 -0
- package/src/api.ts +39 -0
- package/src/bb-utils.ts +3 -0
- package/src/cdk/apigateway-account.ts +66 -0
- package/src/cdk/blocks-backend.test.ts +291 -272
- package/src/cdk/blocks-backend.ts +88 -83
- package/src/cdk/blocks-defaults.test.ts +25 -2
- package/src/cdk/blocks-defaults.ts +94 -2
- package/src/cdk/blocks-stack.test.ts +186 -118
- package/src/cdk/compute/compute-registry.ts +45 -0
- package/src/cdk/compute/compute.ts +10 -0
- package/src/cdk/compute/default-compute-factory.ts +38 -0
- package/src/cdk/config-registry.test.ts +135 -0
- package/src/cdk/config-registry.ts +92 -34
- package/src/cdk/index.ts +129 -10
- package/src/cdk/internal.ts +1 -0
- package/src/client/index.ts +1 -1
- package/src/common/config.test.ts +21 -0
- package/src/common/config.ts +47 -3
- package/src/common/index.ts +8 -0
- package/src/errors.ts +21 -0
- package/src/hosting-secrets.test.ts +106 -0
- package/src/hosting-secrets.ts +106 -0
- package/src/hosting.test.ts +272 -63
- package/src/hosting.ts +200 -68
- package/src/index.cdk.ts +80 -38
- package/src/index.ts +35 -16
- package/src/lambda-handler.test.ts +215 -1
- package/src/lambda-handler.ts +54 -2
- package/src/pipeline/index.ts +64 -11
- package/src/pipeline.test.ts +55 -0
- package/src/rpc.test.ts +44 -1
- package/src/rpc.ts +41 -0
- package/src/scripts/config.ts +35 -0
- package/src/scripts/deploy.ts +5 -0
- package/src/scripts/dev-server.ts +40 -0
- package/src/scripts/generate-client.test.ts +43 -8
- package/src/scripts/generate-client.ts +70 -65
- package/src/scripts/index.ts +20 -17
- package/src/scripts/preflight-credentials.test.ts +110 -0
- package/src/scripts/preflight-credentials.ts +131 -0
- package/src/scripts/sandbox-args.test.ts +92 -0
- package/src/scripts/sandbox-empty-buckets.test.ts +191 -0
- package/src/scripts/sandbox.ts +287 -39
- package/src/scripts/secret.test.ts +35 -0
- package/src/scripts/secret.ts +35 -0
- package/src/scripts/typegen.ts +18 -0
- package/src/secret-naming.test.ts +59 -0
- package/src/secret-naming.ts +99 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
|
|
7
|
+
import { buildSandboxDeployArgs, sandboxFailureRecoveryHint } from './sandbox.js';
|
|
8
|
+
import { buildCdkDeployArgs } from './deploy-stream.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Return the value that follows `flag` in `args`, failing with a clear message
|
|
12
|
+
* if the flag is absent. Reading `args[indexOf(flag) + 1]` directly silently
|
|
13
|
+
* reads `args[0]` when the flag is missing (indexOf returns -1), which turns a
|
|
14
|
+
* dropped flag into a confusing value mismatch instead of a "flag missing".
|
|
15
|
+
*
|
|
16
|
+
* NOTE: resolves only the FIRST occurrence of `flag`. `--context` legitimately
|
|
17
|
+
* appears twice in this argv (projectRoot, then sandboxMode=true), so do not
|
|
18
|
+
* use `valueAfter(args, '--context')` expecting the second value — it silently
|
|
19
|
+
* returns the first. Assert repeated flags with `args.includes(...)` instead.
|
|
20
|
+
*/
|
|
21
|
+
function valueAfter(args: string[], flag: string): string {
|
|
22
|
+
const i = args.indexOf(flag);
|
|
23
|
+
assert.ok(i !== -1, `expected ${flag} in: ${args.join(' ')}`);
|
|
24
|
+
return args[i + 1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('buildSandboxDeployArgs — express mode (sandbox only)', () => {
|
|
28
|
+
const args = buildSandboxDeployArgs({
|
|
29
|
+
outDir: '.blocks-sandbox',
|
|
30
|
+
projectRoot: '/app',
|
|
31
|
+
backendPath: 'aws-blocks/index.ts',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('enables CloudFormation Express Mode via --express', () => {
|
|
35
|
+
assert.ok(args.includes('--express'), `expected --express in: ${args.join(' ')}`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('pairs express mode with the direct deployment method', () => {
|
|
39
|
+
assert.strictEqual(valueAfter(args, '--method'), 'direct');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('leaves express-mode rollback at its default (off) — no --rollback', () => {
|
|
43
|
+
// Express Mode disables automatic rollback by default; we keep that default
|
|
44
|
+
// for the throwaway sandbox loop. Passing --rollback here would silently
|
|
45
|
+
// give up the speedup.
|
|
46
|
+
assert.ok(!args.includes('--rollback'), `sandbox must not force --rollback: ${args.join(' ')}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('deploys every stack so Lambda@Edge apps synth cleanly', () => {
|
|
50
|
+
assert.ok(args.includes('--all'), `expected --all in: ${args.join(' ')}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps the non-interactive deploy contract and sandbox context', () => {
|
|
54
|
+
assert.deepStrictEqual(args.slice(0, 4), ['exec', 'cdk', '--', 'deploy']);
|
|
55
|
+
assert.strictEqual(valueAfter(args, '--require-approval'), 'never');
|
|
56
|
+
assert.strictEqual(valueAfter(args, '--outputs-file'), '.blocks-sandbox/outputs.json');
|
|
57
|
+
assert.ok(args.includes('sandboxMode=true'), `expected sandboxMode=true in: ${args.join(' ')}`);
|
|
58
|
+
assert.strictEqual(valueAfter(args, '--app'), 'npm exec tsx -- -C cdk aws-blocks/index.ts');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('interpolates the provided outDir and projectRoot', () => {
|
|
62
|
+
const custom = buildSandboxDeployArgs({ outDir: 'out', projectRoot: '/other', backendPath: 'b.ts' });
|
|
63
|
+
assert.strictEqual(valueAfter(custom, '--outputs-file'), 'out/outputs.json');
|
|
64
|
+
assert.strictEqual(valueAfter(custom, '--context'), 'projectRoot=/other');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does NOT leak express mode into the production deploy path', () => {
|
|
68
|
+
// A production deploy must keep a reviewable change set and full
|
|
69
|
+
// stabilization with rollback: neither `--express` nor `--method direct`
|
|
70
|
+
// (both sandbox-only speedups) may appear in buildCdkDeployArgs.
|
|
71
|
+
const prod = buildCdkDeployArgs({ projectRoot: '/app', outputsFile: '.blocks-sandbox/outputs.json' });
|
|
72
|
+
assert.ok(!prod.includes('--express'), `production deploy must not use --express: ${prod.join(' ')}`);
|
|
73
|
+
assert.ok(!prod.includes('--method'), `production deploy must not use --method: ${prod.join(' ')}`);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('sandboxFailureRecoveryHint — no silent dead-end after a failed express deploy', () => {
|
|
78
|
+
const hint = sandboxFailureRecoveryHint();
|
|
79
|
+
|
|
80
|
+
it('points to the repo-native destroy + redeploy recovery', () => {
|
|
81
|
+
assert.match(hint, /npm run sandbox:destroy/);
|
|
82
|
+
assert.match(hint, /npm run sandbox\b/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('covers the manual UPDATE_ROLLBACK_FAILED escape hatch', () => {
|
|
86
|
+
assert.match(hint, /continue-update-rollback/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('explains why (rollback is off under express mode)', () => {
|
|
90
|
+
assert.match(hint, /rollback is off|automatic rollback/i);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import type { CloudFormationClient } from '@aws-sdk/client-cloudformation';
|
|
7
|
+
import type { S3Client } from '@aws-sdk/client-s3';
|
|
8
|
+
|
|
9
|
+
import { emptyBucket, listStackBucketNames, runDestroyWithRetries, toDeleteObjects } from './sandbox.js';
|
|
10
|
+
|
|
11
|
+
// The teardown bucket-emptying must delete BOTH live versions and delete
|
|
12
|
+
// markers — a versioned bucket left with either still blocks `cdk destroy`.
|
|
13
|
+
// This is the exact combination that, done wrong, silently no-ops.
|
|
14
|
+
describe('toDeleteObjects', () => {
|
|
15
|
+
it('combines versions and delete markers', () => {
|
|
16
|
+
const out = toDeleteObjects({
|
|
17
|
+
Versions: [
|
|
18
|
+
{ Key: 'a', VersionId: 'v1' },
|
|
19
|
+
{ Key: 'b', VersionId: 'v2' },
|
|
20
|
+
],
|
|
21
|
+
DeleteMarkers: [{ Key: 'a', VersionId: 'dm1' }],
|
|
22
|
+
});
|
|
23
|
+
assert.deepStrictEqual(out, [
|
|
24
|
+
{ Key: 'a', VersionId: 'v1' },
|
|
25
|
+
{ Key: 'b', VersionId: 'v2' },
|
|
26
|
+
{ Key: 'a', VersionId: 'dm1' },
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns [] for an empty bucket (no Versions/DeleteMarkers keys)', () => {
|
|
31
|
+
assert.deepStrictEqual(toDeleteObjects({}), []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('tolerates only versions or only delete markers', () => {
|
|
35
|
+
assert.deepStrictEqual(toDeleteObjects({ Versions: [{ Key: 'x', VersionId: 'v' }] }), [
|
|
36
|
+
{ Key: 'x', VersionId: 'v' },
|
|
37
|
+
]);
|
|
38
|
+
assert.deepStrictEqual(toDeleteObjects({ DeleteMarkers: [{ Key: 'y', VersionId: 'd' }] }), [
|
|
39
|
+
{ Key: 'y', VersionId: 'd' },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('drops entries without a Key rather than emitting a bad DeleteObjects payload', () => {
|
|
44
|
+
assert.deepStrictEqual(toDeleteObjects({ Versions: [{ VersionId: 'orphan' }] }), []);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// A fake AWS client: routes on the command class name and returns queued/canned
|
|
49
|
+
// responses. Cast to the real client type — this is test plumbing, not customer code.
|
|
50
|
+
type Send = (cmd: { constructor: { name: string }; input: any }) => Promise<any>;
|
|
51
|
+
|
|
52
|
+
describe('listStackBucketNames', () => {
|
|
53
|
+
it('follows NextToken across pages and returns only S3 buckets', async () => {
|
|
54
|
+
const seen: Array<string | undefined> = [];
|
|
55
|
+
const send: Send = async (cmd) => {
|
|
56
|
+
seen.push(cmd.input.NextToken);
|
|
57
|
+
if (cmd.input.NextToken === undefined) {
|
|
58
|
+
return {
|
|
59
|
+
StackResourceSummaries: [
|
|
60
|
+
{ ResourceType: 'AWS::S3::Bucket', PhysicalResourceId: 'b1' },
|
|
61
|
+
{ ResourceType: 'AWS::Lambda::Function', PhysicalResourceId: 'fn' },
|
|
62
|
+
],
|
|
63
|
+
NextToken: 't2',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { StackResourceSummaries: [{ ResourceType: 'AWS::S3::Bucket', PhysicalResourceId: 'b2' }] };
|
|
67
|
+
};
|
|
68
|
+
const cfn = { send } as unknown as CloudFormationClient;
|
|
69
|
+
const buckets = await listStackBucketNames(cfn, 'stack');
|
|
70
|
+
assert.deepStrictEqual(buckets, ['b1', 'b2']);
|
|
71
|
+
assert.deepStrictEqual(seen, [undefined, 't2']); // paged exactly twice
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('emptyBucket', () => {
|
|
76
|
+
it('deletes versions + markers, re-lists, and stops when empty', async () => {
|
|
77
|
+
let lists = 0;
|
|
78
|
+
const deletePayloads: unknown[] = [];
|
|
79
|
+
const send: Send = async (cmd) => {
|
|
80
|
+
if (cmd.constructor.name === 'ListObjectVersionsCommand') {
|
|
81
|
+
lists++;
|
|
82
|
+
return lists === 1
|
|
83
|
+
? { Versions: [{ Key: 'a', VersionId: 'v1' }], DeleteMarkers: [{ Key: 'a', VersionId: 'd1' }] }
|
|
84
|
+
: {};
|
|
85
|
+
}
|
|
86
|
+
if (cmd.constructor.name === 'DeleteObjectsCommand') {
|
|
87
|
+
deletePayloads.push(cmd.input.Delete.Objects);
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`unexpected ${cmd.constructor.name}`);
|
|
91
|
+
};
|
|
92
|
+
await emptyBucket({ send } as unknown as S3Client, 'bucket');
|
|
93
|
+
assert.strictEqual(lists, 2); // listed, deleted, re-listed → empty
|
|
94
|
+
assert.deepStrictEqual(deletePayloads, [
|
|
95
|
+
[
|
|
96
|
+
{ Key: 'a', VersionId: 'v1' },
|
|
97
|
+
{ Key: 'a', VersionId: 'd1' },
|
|
98
|
+
],
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('stops (no infinite loop) when DeleteObjects reports Errors', async () => {
|
|
103
|
+
let lists = 0;
|
|
104
|
+
let deletes = 0;
|
|
105
|
+
const send: Send = async (cmd) => {
|
|
106
|
+
if (cmd.constructor.name === 'ListObjectVersionsCommand') {
|
|
107
|
+
lists++;
|
|
108
|
+
return { Versions: [{ Key: 'locked', VersionId: 'v1' }] };
|
|
109
|
+
}
|
|
110
|
+
if (cmd.constructor.name === 'DeleteObjectsCommand') {
|
|
111
|
+
deletes++;
|
|
112
|
+
return { Errors: [{ Key: 'locked', Code: 'AccessDenied' }] };
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`unexpected ${cmd.constructor.name}`);
|
|
115
|
+
};
|
|
116
|
+
await emptyBucket({ send } as unknown as S3Client, 'bucket');
|
|
117
|
+
assert.strictEqual(lists, 1); // did NOT re-list after Errors
|
|
118
|
+
assert.strictEqual(deletes, 1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// The sev2 fix is the wiring: on a destroy failure, buckets get emptied BEFORE
|
|
123
|
+
// the retry. runDestroyWithRetries takes its side effects as deps so we can
|
|
124
|
+
// exercise that ordering without spawning cdk.
|
|
125
|
+
describe('runDestroyWithRetries', () => {
|
|
126
|
+
it('empties buckets before retrying a failed destroy', async () => {
|
|
127
|
+
const events: string[] = [];
|
|
128
|
+
let attempts = 0;
|
|
129
|
+
await runDestroyWithRetries({
|
|
130
|
+
runDestroy: () => {
|
|
131
|
+
attempts++;
|
|
132
|
+
events.push(`destroy#${attempts}`);
|
|
133
|
+
if (attempts === 1) throw new Error('bucket not empty');
|
|
134
|
+
},
|
|
135
|
+
listStackNames: () => {
|
|
136
|
+
events.push('list');
|
|
137
|
+
return ['stackA', 'stackB'];
|
|
138
|
+
},
|
|
139
|
+
emptyBuckets: async (names) => {
|
|
140
|
+
events.push(`empty:${names.join(',')}`);
|
|
141
|
+
},
|
|
142
|
+
sleep: async () => {
|
|
143
|
+
events.push('sleep');
|
|
144
|
+
},
|
|
145
|
+
retryDelays: [1],
|
|
146
|
+
});
|
|
147
|
+
assert.strictEqual(attempts, 2);
|
|
148
|
+
// buckets emptied (with the resolved stack names) before the retry destroy
|
|
149
|
+
assert.deepStrictEqual(events, ['destroy#1', 'list', 'empty:stackA,stackB', 'sleep', 'destroy#2']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not empty or sleep when the first destroy succeeds', async () => {
|
|
153
|
+
let emptied = false;
|
|
154
|
+
let slept = false;
|
|
155
|
+
let listed = false;
|
|
156
|
+
await runDestroyWithRetries({
|
|
157
|
+
runDestroy: () => {},
|
|
158
|
+
listStackNames: () => {
|
|
159
|
+
listed = true;
|
|
160
|
+
return [];
|
|
161
|
+
},
|
|
162
|
+
emptyBuckets: async () => {
|
|
163
|
+
emptied = true;
|
|
164
|
+
},
|
|
165
|
+
sleep: async () => {
|
|
166
|
+
slept = true;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
assert.strictEqual(emptied, false);
|
|
170
|
+
assert.strictEqual(slept, false);
|
|
171
|
+
assert.strictEqual(listed, false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('throws after exhausting retries', async () => {
|
|
175
|
+
let attempts = 0;
|
|
176
|
+
await assert.rejects(
|
|
177
|
+
runDestroyWithRetries({
|
|
178
|
+
runDestroy: () => {
|
|
179
|
+
attempts++;
|
|
180
|
+
throw new Error('still failing');
|
|
181
|
+
},
|
|
182
|
+
listStackNames: () => [],
|
|
183
|
+
emptyBuckets: async () => {},
|
|
184
|
+
sleep: async () => {},
|
|
185
|
+
retryDelays: [1, 1],
|
|
186
|
+
}),
|
|
187
|
+
/still failing/,
|
|
188
|
+
);
|
|
189
|
+
assert.strictEqual(attempts, 3); // initial attempt + 2 retries
|
|
190
|
+
});
|
|
191
|
+
});
|
package/src/scripts/sandbox.ts
CHANGED
|
@@ -6,12 +6,16 @@ import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
|
6
6
|
import { join, resolve, dirname } from "node:path";
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
8
|
import { ensureSecrets, loadEnvFile } from './ensure-secrets.js';
|
|
9
|
+
import { assertAwsCredentials } from './preflight-credentials.js';
|
|
9
10
|
import { applyExternalMigrations } from './external-migrations-step.js';
|
|
10
11
|
import { trackCommand } from '../telemetry/trackCommand.js';
|
|
11
12
|
import { buildAndSendEvent } from '../telemetry/client.js';
|
|
13
|
+
import { classifyError } from '../telemetry/trackCommand.js';
|
|
12
14
|
import { getCdkTelemetryEnv } from './cdk-telemetry-env.js';
|
|
13
15
|
import { runSync, spawnCommand } from './run-command.js';
|
|
14
16
|
import { terminateProcessTree } from './process-tree.js';
|
|
17
|
+
import type { CloudFormationClient } from '@aws-sdk/client-cloudformation';
|
|
18
|
+
import type { S3Client } from '@aws-sdk/client-s3';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* Import the backend definition to populate the Scope BB registry.
|
|
@@ -38,6 +42,85 @@ export interface SandboxOptions {
|
|
|
38
42
|
devCommand?: string;
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
export interface SandboxDeployArgsOptions {
|
|
46
|
+
/** Directory CDK writes stack outputs to (relative to the project root). */
|
|
47
|
+
outDir: string;
|
|
48
|
+
/** Project root passed to synth as `--context projectRoot=…` (usually `process.cwd()`). */
|
|
49
|
+
projectRoot: string;
|
|
50
|
+
/** Backend entry passed to `--app` so CDK synthesizes from the app definition. */
|
|
51
|
+
backendPath: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the `npm exec cdk -- deploy …` argv used by the sandbox deploy.
|
|
56
|
+
*
|
|
57
|
+
* Kept pure (no I/O) so the argv contract — in particular the flags below — can
|
|
58
|
+
* be asserted directly, the same way {@link buildCdkDeployArgs} is for the
|
|
59
|
+
* production path.
|
|
60
|
+
*
|
|
61
|
+
* - `--all`: an app that uses Lambda@Edge (e.g. a Next.js route with
|
|
62
|
+
* `export const runtime = 'edge'`) synthesizes a SECOND stack
|
|
63
|
+
* (`edge-lambda-stack-*`, region us-east-1) in addition to the main hosting
|
|
64
|
+
* stack. Without `--all`, CDK refuses with "specify which stacks to use".
|
|
65
|
+
* Deploying every stack in a sandbox app is the intended behavior.
|
|
66
|
+
* - `--method direct`: skip CloudFormation change-set creation and call
|
|
67
|
+
* UpdateStack directly. This is the baseline sandbox deployment method that
|
|
68
|
+
* `--express` builds on.
|
|
69
|
+
* - `--express` (**CloudFormation Express Mode — sandbox only**): the real
|
|
70
|
+
* speedup. CloudFormation reports each stack operation complete as soon as
|
|
71
|
+
* the resource's configuration is applied, WITHOUT waiting for full
|
|
72
|
+
* stabilization. Because it skips stabilization it also disables automatic
|
|
73
|
+
* rollback by default, so a failed deploy can leave the stack in a failed
|
|
74
|
+
* state — acceptable for the throwaway sandbox iteration loop, never for
|
|
75
|
+
* production. We leave rollback at Express Mode's default (off) and do NOT
|
|
76
|
+
* pass `--rollback`. The production deploy path (`deploy.ts`) deliberately
|
|
77
|
+
* uses neither `--express` nor `--method direct`: it keeps a reviewable
|
|
78
|
+
* change set and full stabilization with rollback, which are exactly the
|
|
79
|
+
* safety signals a production deploy should keep. Requires an aws-cdk CLI
|
|
80
|
+
* new enough to expose `--express`; our pinned `^2.1138.0` has it.
|
|
81
|
+
*/
|
|
82
|
+
export function buildSandboxDeployArgs({ outDir, projectRoot, backendPath }: SandboxDeployArgsOptions): string[] {
|
|
83
|
+
return [
|
|
84
|
+
"exec", "cdk", "--", "deploy",
|
|
85
|
+
"--all",
|
|
86
|
+
"--method", "direct",
|
|
87
|
+
"--express",
|
|
88
|
+
"--require-approval", "never",
|
|
89
|
+
"--outputs-file", `${outDir}/outputs.json`,
|
|
90
|
+
"--context", `projectRoot=${projectRoot}`,
|
|
91
|
+
"--context", "sandboxMode=true",
|
|
92
|
+
"--app", `npm exec tsx -- -C cdk ${backendPath}`,
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Operator-facing recovery guidance printed when a sandbox deploy fails.
|
|
98
|
+
*
|
|
99
|
+
* Express Mode (see {@link buildSandboxDeployArgs}) disables CloudFormation's
|
|
100
|
+
* automatic rollback, so a failed deploy does NOT clean up after itself: a
|
|
101
|
+
* failed *first* deploy leaves the stack in `CREATE_FAILED`/`ROLLBACK_COMPLETE`
|
|
102
|
+
* and a failed update can land in `UPDATE_ROLLBACK_FAILED`. In those states
|
|
103
|
+
* CloudFormation refuses the next `UpdateStack`, so a plain re-run of
|
|
104
|
+
* `npm run sandbox` would hit the same wall — a silent dead-end for the fast
|
|
105
|
+
* loop. This spells out the exact way out instead of leaving the operator to
|
|
106
|
+
* discover it via a cryptic CloudFormation error.
|
|
107
|
+
*
|
|
108
|
+
* Kept pure so it can be asserted in a unit test.
|
|
109
|
+
*/
|
|
110
|
+
export function sandboxFailureRecoveryHint(): string {
|
|
111
|
+
return [
|
|
112
|
+
"",
|
|
113
|
+
"ℹ️ Express Mode leaves failed sandbox stacks in place (automatic rollback is off).",
|
|
114
|
+
" If the next `npm run sandbox` reports the stack cannot be updated, recover with:",
|
|
115
|
+
"",
|
|
116
|
+
" npm run sandbox:destroy # tears the failed stack down (handles ROLLBACK_COMPLETE)",
|
|
117
|
+
" npm run sandbox # redeploy from a clean slate",
|
|
118
|
+
"",
|
|
119
|
+
" If destroy reports UPDATE_ROLLBACK_FAILED, first run:",
|
|
120
|
+
" aws cloudformation continue-update-rollback --stack-name <your-sandbox-stack>",
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
41
124
|
export async function startSandbox(options: SandboxOptions) {
|
|
42
125
|
const { backendPath, outDir = ".blocks-sandbox", clientPort = 3000, deployOnly = false, devCommand } = options;
|
|
43
126
|
const sandboxStartTime = Date.now();
|
|
@@ -49,6 +132,23 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
49
132
|
|
|
50
133
|
process.env.BLOCKS_STAGE = 'sandbox';
|
|
51
134
|
|
|
135
|
+
// Fail fast if AWS credentials are missing/expired, before spending ~10s in
|
|
136
|
+
// synth only to hit an opaque CDK credential error. Unlike deploy(), startSandbox
|
|
137
|
+
// isn't wrapped in trackCommand, so emit the FAIL event manually (mirroring the
|
|
138
|
+
// CDK-deploy failure path below) before rethrowing — otherwise a no-creds abort
|
|
139
|
+
// sends no telemetry.
|
|
140
|
+
try {
|
|
141
|
+
await assertAwsCredentials('sandbox');
|
|
142
|
+
} catch (error) {
|
|
143
|
+
buildAndSendEvent({
|
|
144
|
+
command: 'sandbox',
|
|
145
|
+
state: 'FAIL',
|
|
146
|
+
duration: Date.now() - sandboxStartTime,
|
|
147
|
+
error: classifyError(error),
|
|
148
|
+
});
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
|
|
52
152
|
// Provision connection string to SSM SecureString.
|
|
53
153
|
// On first deploy, creates the parameter. On subsequent deploys, updates if changed.
|
|
54
154
|
// projectRoot is process.cwd() — the same value passed to cdk as --context
|
|
@@ -75,21 +175,9 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
75
175
|
try {
|
|
76
176
|
runSync(
|
|
77
177
|
"npm",
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// `export const runtime = 'edge'`) synthesizes a SECOND stack
|
|
82
|
-
// (`edge-lambda-stack-*`, region us-east-1) in addition to the main
|
|
83
|
-
// hosting stack. Without `--all`, CDK refuses with "specify which
|
|
84
|
-
// stacks to use". Deploying every stack in a sandbox app is the
|
|
85
|
-
// intended behavior, so select them all.
|
|
86
|
-
"--all",
|
|
87
|
-
"--require-approval", "never",
|
|
88
|
-
"--outputs-file", `${outDir}/outputs.json`,
|
|
89
|
-
"--context", `projectRoot=${process.cwd()}`,
|
|
90
|
-
"--context", "sandboxMode=true",
|
|
91
|
-
"--app", `npm exec tsx -- -C cdk ${backendPath}`,
|
|
92
|
-
],
|
|
178
|
+
// Argv (including sandbox-only CloudFormation Express Mode, `--express`)
|
|
179
|
+
// is built by buildSandboxDeployArgs — see its doc for why each flag exists.
|
|
180
|
+
buildSandboxDeployArgs({ outDir, projectRoot: process.cwd(), backendPath }),
|
|
93
181
|
{
|
|
94
182
|
stdio: "inherit",
|
|
95
183
|
env: { ...process.env, NODE_OPTIONS: "--conditions=cdk", ...getCdkTelemetryEnv('sandbox') },
|
|
@@ -103,6 +191,7 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
103
191
|
error: { code: 'CDK_DEPLOY_FAILED', phase: 'deploy' },
|
|
104
192
|
});
|
|
105
193
|
console.error("\n❌ Deployment failed.");
|
|
194
|
+
console.error(sandboxFailureRecoveryHint());
|
|
106
195
|
throw error;
|
|
107
196
|
}
|
|
108
197
|
|
|
@@ -234,6 +323,180 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
234
323
|
await new Promise(() => {});
|
|
235
324
|
}
|
|
236
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Flatten a ListObjectVersions response into the `{ Key, VersionId }[]` shape
|
|
328
|
+
* DeleteObjects expects, combining live versions AND delete markers. Exported
|
|
329
|
+
* for unit testing — getting this combination wrong (e.g. missing the delete
|
|
330
|
+
* markers) leaves a "versioned" bucket that still can't be deleted.
|
|
331
|
+
*/
|
|
332
|
+
export function toDeleteObjects(listed: {
|
|
333
|
+
Versions?: Array<{ Key?: string; VersionId?: string }>;
|
|
334
|
+
DeleteMarkers?: Array<{ Key?: string; VersionId?: string }>;
|
|
335
|
+
}): Array<{ Key: string; VersionId?: string }> {
|
|
336
|
+
return [...(listed.Versions ?? []), ...(listed.DeleteMarkers ?? [])]
|
|
337
|
+
.filter((v): v is { Key: string; VersionId?: string } => v.Key !== undefined)
|
|
338
|
+
.map((v) => ({ Key: v.Key, VersionId: v.VersionId }));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Collect a stack's S3 bucket physical names (paginated — a large stack has
|
|
342
|
+
* >100 resources, so a single page can miss the hosting bucket that blocks the
|
|
343
|
+
* delete). Returns [] if the stack is already gone / not accessible. */
|
|
344
|
+
export async function listStackBucketNames(cfn: CloudFormationClient, stackName: string): Promise<string[]> {
|
|
345
|
+
const { ListStackResourcesCommand } = await import('@aws-sdk/client-cloudformation');
|
|
346
|
+
const buckets: string[] = [];
|
|
347
|
+
let token: string | undefined;
|
|
348
|
+
do {
|
|
349
|
+
const res = await cfn.send(new ListStackResourcesCommand({ StackName: stackName, NextToken: token }));
|
|
350
|
+
for (const r of res.StackResourceSummaries ?? []) {
|
|
351
|
+
if (r.ResourceType === 'AWS::S3::Bucket' && r.PhysicalResourceId) buckets.push(r.PhysicalResourceId);
|
|
352
|
+
}
|
|
353
|
+
token = res.NextToken;
|
|
354
|
+
} while (token);
|
|
355
|
+
return buckets;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Empty a versioned bucket: delete every object version + delete marker in
|
|
359
|
+
* pages of 1000 (the DeleteObjects limit). Stops if a page reports per-key
|
|
360
|
+
* errors (object-lock / retention) so it can't loop forever. */
|
|
361
|
+
export async function emptyBucket(s3: S3Client, bucket: string): Promise<void> {
|
|
362
|
+
const { ListObjectVersionsCommand, DeleteObjectsCommand } = await import('@aws-sdk/client-s3');
|
|
363
|
+
while (true) {
|
|
364
|
+
const listed = await s3.send(new ListObjectVersionsCommand({ Bucket: bucket, MaxKeys: 1000 }));
|
|
365
|
+
const objects = toDeleteObjects(listed);
|
|
366
|
+
if (objects.length === 0) return;
|
|
367
|
+
const res = await s3.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: objects, Quiet: true } }));
|
|
368
|
+
if (res.Errors && res.Errors.length > 0) {
|
|
369
|
+
// A bucket we can't fully empty (object-lock / retention / AccessDenied)
|
|
370
|
+
// will keep blocking `cdk destroy`, so the stack won't tear down until it's
|
|
371
|
+
// resolved by hand. Log loudly with the first error code — this is the
|
|
372
|
+
// kind of silent skip that leaks a stack, so make it visible in CI output.
|
|
373
|
+
const first = res.Errors[0];
|
|
374
|
+
console.warn(
|
|
375
|
+
` ⚠️ ${bucket}: ${res.Errors.length} object(s) could NOT be deleted ` +
|
|
376
|
+
`(e.g. ${first.Key}: ${first.Code}). This bucket will block stack teardown ` +
|
|
377
|
+
`until cleared manually.`,
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Empty every versioned S3 bucket owned by the given stacks. `cdk destroy`
|
|
386
|
+
* relies on each bucket's `autoDeleteObjects` custom-resource Lambda to empty
|
|
387
|
+
* it on teardown — but that Lambda never provisions if the CREATE failed (e.g.
|
|
388
|
+
* a partial/aborted deploy), so the versioned bucket blocks the delete and the
|
|
389
|
+
* stack (and its IAM roles) leak. Emptying out-of-band before the retry lets
|
|
390
|
+
* the delete complete. Best-effort: any per-stack/per-bucket error is logged
|
|
391
|
+
* and skipped so teardown still proceeds.
|
|
392
|
+
*/
|
|
393
|
+
async function emptySandboxBuckets(stackNames: string[]): Promise<void> {
|
|
394
|
+
try {
|
|
395
|
+
const { CloudFormationClient } = await import('@aws-sdk/client-cloudformation');
|
|
396
|
+
const { S3Client } = await import('@aws-sdk/client-s3');
|
|
397
|
+
const cfn = new CloudFormationClient({});
|
|
398
|
+
// `followRegionRedirects` makes the client transparently retry against a
|
|
399
|
+
// bucket's real region on a PermanentRedirect, so a bucket that lives
|
|
400
|
+
// outside AWS_REGION (e.g. a us-east-1 Lambda@Edge stack's bucket) is still
|
|
401
|
+
// emptied instead of failing silently — closing the exact no-op this fix
|
|
402
|
+
// targets. It needs no GetBucketLocation permission.
|
|
403
|
+
const s3 = new S3Client({ followRegionRedirects: true });
|
|
404
|
+
for (const stackName of stackNames) {
|
|
405
|
+
let buckets: string[] = [];
|
|
406
|
+
try {
|
|
407
|
+
buckets = await listStackBucketNames(cfn, stackName);
|
|
408
|
+
} catch {
|
|
409
|
+
continue; // stack already gone / not accessible
|
|
410
|
+
}
|
|
411
|
+
for (const b of buckets) {
|
|
412
|
+
try {
|
|
413
|
+
await emptyBucket(s3, b);
|
|
414
|
+
} catch (e) {
|
|
415
|
+
console.warn(` ⚠️ could not empty ${b}: ${(e as Error).message}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (e) {
|
|
420
|
+
console.warn(` ⚠️ bucket-emptying step skipped: ${(e as Error).message}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Resolve the sandbox app's stack names via `cdk ls` (best-effort; [] on error). */
|
|
425
|
+
function listSandboxStackNames(backendPath: string, cdkEnv: NodeJS.ProcessEnv): string[] {
|
|
426
|
+
try {
|
|
427
|
+
const out = execFileSync(
|
|
428
|
+
'npm',
|
|
429
|
+
// Single-quote backendPath: CDK re-runs the --app string through a shell,
|
|
430
|
+
// so an unquoted path containing a space would split.
|
|
431
|
+
['exec', 'cdk', '--', 'ls', '--context', 'sandboxMode=true', '--app', `npm exec tsx -- -C cdk '${backendPath}'`],
|
|
432
|
+
// Capture stderr so a genuine synth/`cdk ls` failure can be logged below
|
|
433
|
+
// rather than being indistinguishable from "app has zero stacks".
|
|
434
|
+
{ env: cdkEnv, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
435
|
+
);
|
|
436
|
+
return out
|
|
437
|
+
.split('\n')
|
|
438
|
+
.map((s) => s.trim())
|
|
439
|
+
.filter(Boolean);
|
|
440
|
+
} catch (e) {
|
|
441
|
+
// Warn (don't stay silent): if this returns [] on a real failure, the retry
|
|
442
|
+
// skips bucket-emptying — the exact silent-no-op this change exists to avoid.
|
|
443
|
+
const detail = (e as { stderr?: string; message?: string }).stderr || (e as Error).message;
|
|
444
|
+
console.warn(` ⚠️ could not list sandbox stacks via 'cdk ls'; skipping bucket-emptying: ${detail}`);
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Injectable dependencies for {@link runDestroyWithRetries} — real ones in
|
|
450
|
+
* `destroySandbox`, fakes in tests so the retry/empty-before-retry wiring (the
|
|
451
|
+
* sev2 fix) is exercised without spawning cdk. */
|
|
452
|
+
export interface DestroyRetryDeps {
|
|
453
|
+
/** Run one `cdk destroy` attempt; throws on failure. */
|
|
454
|
+
runDestroy: () => void;
|
|
455
|
+
/** Resolve the app's stack names (for bucket enumeration). */
|
|
456
|
+
listStackNames: () => string[];
|
|
457
|
+
/** Empty the given stacks' versioned S3 buckets. */
|
|
458
|
+
emptyBuckets: (stackNames: string[]) => Promise<void>;
|
|
459
|
+
/** Sleep (VPC-ENI detach window). */
|
|
460
|
+
sleep: (ms: number) => Promise<void>;
|
|
461
|
+
/** Backoff between retries. Default: 1min, then 2min. */
|
|
462
|
+
retryDelays?: number[];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Retry `cdk destroy`, clearing the two known teardown blockers before each
|
|
467
|
+
* retry: (1) non-empty versioned S3 buckets — `cdk destroy` relies on each
|
|
468
|
+
* bucket's autoDeleteObjects Lambda, which never provisioned if the CREATE
|
|
469
|
+
* failed, so the bucket blocks the delete and the stack (+ its IAM roles) leaks;
|
|
470
|
+
* we empty them out-of-band — and (2) VPC ENIs that take 60-120s to detach (the
|
|
471
|
+
* backoff). Stack names are resolved once and reused (topology is stable across
|
|
472
|
+
* attempts). Factored out with injectable deps so this behavior is unit-testable.
|
|
473
|
+
*/
|
|
474
|
+
export async function runDestroyWithRetries(deps: DestroyRetryDeps): Promise<void> {
|
|
475
|
+
const retryDelays = deps.retryDelays ?? [60_000, 120_000];
|
|
476
|
+
let stackNames: string[] | undefined;
|
|
477
|
+
for (let attempt = 0; ; attempt++) {
|
|
478
|
+
try {
|
|
479
|
+
deps.runDestroy();
|
|
480
|
+
console.log(attempt === 0 ? '\n✅ Sandbox destroyed!' : '\n✅ Sandbox destroyed on retry!');
|
|
481
|
+
return;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (attempt < retryDelays.length) {
|
|
484
|
+
if (stackNames === undefined) stackNames = deps.listStackNames();
|
|
485
|
+
if (stackNames.length > 0) {
|
|
486
|
+
console.log('\n🧹 Emptying versioned S3 buckets before retry...');
|
|
487
|
+
await deps.emptyBuckets(stackNames);
|
|
488
|
+
}
|
|
489
|
+
const delaySec = retryDelays[attempt] / 1000;
|
|
490
|
+
console.log(`\n⏳ Stack deletion failed. Retrying in ${delaySec}s (waiting for resource cleanup)...`);
|
|
491
|
+
await deps.sleep(retryDelays[attempt]);
|
|
492
|
+
} else {
|
|
493
|
+
console.error('\n❌ Destroy failed after retries.');
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
237
500
|
export async function destroySandbox(backendPath: string) {
|
|
238
501
|
return trackCommand('sandbox:destroy', async () => {
|
|
239
502
|
console.log("🗑️ Destroying sandbox...");
|
|
@@ -247,31 +510,16 @@ export async function destroySandbox(backendPath: string) {
|
|
|
247
510
|
"exec", "cdk", "--", "destroy",
|
|
248
511
|
"--force",
|
|
249
512
|
"--context", "sandboxMode=true",
|
|
250
|
-
|
|
513
|
+
// Single-quote backendPath: CDK re-runs the --app string through a shell,
|
|
514
|
+
// so an unquoted path containing a space would split.
|
|
515
|
+
"--app", `npm exec tsx -- -C cdk '${backendPath}'`,
|
|
251
516
|
];
|
|
252
517
|
const cdkEnv = { ...process.env, NODE_OPTIONS: "--conditions=cdk", ...getCdkTelemetryEnv('sandbox') };
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
for (let attempt = 0; ; attempt++) {
|
|
261
|
-
try {
|
|
262
|
-
runSync("npm", cdkArgs, { stdio: "inherit", env: cdkEnv });
|
|
263
|
-
console.log(attempt === 0 ? "\n✅ Sandbox destroyed!" : "\n✅ Sandbox destroyed on retry!");
|
|
264
|
-
return;
|
|
265
|
-
} catch (error) {
|
|
266
|
-
if (attempt < retryDelays.length) {
|
|
267
|
-
const delaySec = retryDelays[attempt] / 1000;
|
|
268
|
-
console.log(`\n⏳ Stack deletion failed. Retrying in ${delaySec}s (waiting for resource cleanup)...`);
|
|
269
|
-
await new Promise(r => setTimeout(r, retryDelays[attempt]));
|
|
270
|
-
} else {
|
|
271
|
-
console.error("\n❌ Destroy failed after retries.");
|
|
272
|
-
throw error;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
518
|
+
await runDestroyWithRetries({
|
|
519
|
+
runDestroy: () => runSync('npm', cdkArgs, { stdio: 'inherit', env: cdkEnv }),
|
|
520
|
+
listStackNames: () => listSandboxStackNames(backendPath, cdkEnv),
|
|
521
|
+
emptyBuckets: emptySandboxBuckets,
|
|
522
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
523
|
+
});
|
|
276
524
|
});
|
|
277
525
|
}
|