@aws-blocks/core 0.3.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.
Files changed (100) hide show
  1. package/README.md +13 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +32 -0
  4. package/dist/api.test.js +22 -0
  5. package/dist/bb-utils.d.ts +1 -0
  6. package/dist/bb-utils.d.ts.map +1 -1
  7. package/dist/bb-utils.js +3 -0
  8. package/dist/cdk/apigateway-account.d.ts +33 -0
  9. package/dist/cdk/apigateway-account.d.ts.map +1 -0
  10. package/dist/cdk/apigateway-account.js +60 -0
  11. package/dist/cdk/blocks-backend.d.ts +8 -0
  12. package/dist/cdk/blocks-backend.d.ts.map +1 -1
  13. package/dist/cdk/blocks-backend.js +17 -4
  14. package/dist/cdk/blocks-backend.test.js +17 -0
  15. package/dist/cdk/blocks-defaults.d.ts +65 -1
  16. package/dist/cdk/blocks-defaults.d.ts.map +1 -1
  17. package/dist/cdk/blocks-defaults.js +17 -1
  18. package/dist/cdk/blocks-defaults.test.js +17 -0
  19. package/dist/cdk/blocks-stack.test.js +21 -0
  20. package/dist/cdk/compute/compute-registry.d.ts +19 -0
  21. package/dist/cdk/compute/compute-registry.d.ts.map +1 -0
  22. package/dist/cdk/compute/compute-registry.js +38 -0
  23. package/dist/cdk/compute/compute.d.ts +2 -0
  24. package/dist/cdk/compute/compute.d.ts.map +1 -1
  25. package/dist/cdk/compute/compute.js +8 -0
  26. package/dist/cdk/compute/default-compute-factory.d.ts +1 -0
  27. package/dist/cdk/compute/default-compute-factory.d.ts.map +1 -1
  28. package/dist/cdk/config-registry.d.ts +34 -4
  29. package/dist/cdk/config-registry.d.ts.map +1 -1
  30. package/dist/cdk/config-registry.js +83 -25
  31. package/dist/cdk/config-registry.test.d.ts +2 -0
  32. package/dist/cdk/config-registry.test.d.ts.map +1 -0
  33. package/dist/cdk/config-registry.test.js +115 -0
  34. package/dist/cdk/index.d.ts +30 -2
  35. package/dist/cdk/index.d.ts.map +1 -1
  36. package/dist/cdk/index.js +42 -2
  37. package/dist/client/index.d.ts +1 -1
  38. package/dist/client/index.d.ts.map +1 -1
  39. package/dist/client/index.js +1 -1
  40. package/dist/common/config.d.ts +34 -0
  41. package/dist/common/config.d.ts.map +1 -1
  42. package/dist/common/config.js +45 -3
  43. package/dist/common/config.test.js +19 -0
  44. package/dist/common/index.d.ts +8 -0
  45. package/dist/common/index.d.ts.map +1 -1
  46. package/dist/errors.d.ts +16 -0
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js +20 -0
  49. package/dist/hosting.d.ts +9 -0
  50. package/dist/hosting.d.ts.map +1 -1
  51. package/dist/hosting.js +10 -1
  52. package/dist/hosting.test.js +24 -0
  53. package/dist/index.cdk.d.ts +2 -2
  54. package/dist/index.cdk.d.ts.map +1 -1
  55. package/dist/index.cdk.js +2 -2
  56. package/dist/index.d.ts +1 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/lambda-handler.d.ts +17 -0
  60. package/dist/lambda-handler.d.ts.map +1 -1
  61. package/dist/lambda-handler.js +52 -3
  62. package/dist/lambda-handler.test.js +130 -1
  63. package/dist/scripts/sandbox-empty-buckets.test.d.ts +2 -0
  64. package/dist/scripts/sandbox-empty-buckets.test.d.ts.map +1 -0
  65. package/dist/scripts/sandbox-empty-buckets.test.js +171 -0
  66. package/dist/scripts/sandbox.d.ts +54 -0
  67. package/dist/scripts/sandbox.d.ts.map +1 -1
  68. package/dist/scripts/sandbox.js +163 -25
  69. package/dist/version.d.ts +1 -1
  70. package/dist/version.js +1 -1
  71. package/package.json +3 -1
  72. package/src/api.test.ts +25 -0
  73. package/src/api.ts +39 -0
  74. package/src/bb-utils.ts +3 -0
  75. package/src/cdk/apigateway-account.ts +66 -0
  76. package/src/cdk/blocks-backend.test.ts +24 -0
  77. package/src/cdk/blocks-backend.ts +17 -4
  78. package/src/cdk/blocks-defaults.test.ts +21 -0
  79. package/src/cdk/blocks-defaults.ts +67 -1
  80. package/src/cdk/blocks-stack.test.ts +25 -0
  81. package/src/cdk/compute/compute-registry.ts +45 -0
  82. package/src/cdk/compute/compute.ts +10 -0
  83. package/src/cdk/compute/default-compute-factory.ts +1 -0
  84. package/src/cdk/config-registry.test.ts +135 -0
  85. package/src/cdk/config-registry.ts +92 -34
  86. package/src/cdk/index.ts +45 -2
  87. package/src/client/index.ts +1 -1
  88. package/src/common/config.test.ts +21 -0
  89. package/src/common/config.ts +47 -3
  90. package/src/common/index.ts +8 -0
  91. package/src/errors.ts +21 -0
  92. package/src/hosting.test.ts +28 -0
  93. package/src/hosting.ts +21 -1
  94. package/src/index.cdk.ts +4 -1
  95. package/src/index.ts +1 -1
  96. package/src/lambda-handler.test.ts +141 -1
  97. package/src/lambda-handler.ts +54 -2
  98. package/src/scripts/sandbox-empty-buckets.test.ts +191 -0
  99. package/src/scripts/sandbox.ts +185 -24
  100. package/src/version.ts +1 -1
@@ -3,10 +3,11 @@
3
3
 
4
4
  import { describe, it, beforeEach } from 'node:test';
5
5
  import assert from 'node:assert';
6
- import { createLambdaHandler, _resetCorsPatterns, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost } from './lambda-handler.js';
6
+ import { createLambdaHandler, _resetCorsPatterns, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost, TransientConfigError } from './lambda-handler.js';
7
7
  import type { LambdaContext } from './lambda-handler.js';
8
8
  import { registerRoute, clearRouteRegistry } from './raw-route.js';
9
9
  import { decodeRpcResponse } from './rpc.js';
10
+ import { _resetConfigCache, _setS3Fetcher } from './common/config.js';
10
11
  import type { BlocksContext } from './api.js';
11
12
 
12
13
  beforeEach(() => {
@@ -29,6 +30,145 @@ async function invoke(backend: any, event: any): Promise<any> {
29
30
  return handler(event) as any;
30
31
  }
31
32
 
33
+ // ── init self-heal (retry on failed initialization) ─────────────────────────
34
+
35
+ describe('createLambdaHandler — init self-heal', () => {
36
+ it('retries initialize() on a later request instead of caching a failed init', async () => {
37
+ let initCalls = 0;
38
+ const backend = {
39
+ api: (_ctx: BlocksContext) => ({
40
+ async echo(msg: string) {
41
+ return { msg };
42
+ },
43
+ }),
44
+ };
45
+ // Fail the first initialization (e.g. config not readable yet in the brief post-deploy
46
+ // window), then succeed. The SAME handler instance must recover — a cached rejected
47
+ // initPromise would poison the container and fail every subsequent request.
48
+ const backendFactory = async () => {
49
+ initCalls++;
50
+ if (initCalls === 1) throw new Error('transient init failure');
51
+ return backend;
52
+ };
53
+ const handler = createLambdaHandler(backendFactory);
54
+
55
+ // 1st request: init fails, so the handler rejects.
56
+ await assert.rejects(() => handler(makeEvent()) as any, /transient init failure/);
57
+
58
+ // 2nd request: init is retried and succeeds → 200 (not a re-thrown cached rejection).
59
+ const res = (await handler(makeEvent())) as any;
60
+ assert.strictEqual(res.statusCode, 200);
61
+ assert.strictEqual(initCalls, 2, 'initialize() must be retried on the next request, not cached');
62
+ });
63
+
64
+ it('recovers from a transient-empty (post-deploy 404) config load on the next request', async () => {
65
+ // Reproduces the poisoned-container bug: the first S3 load hits the transient
66
+ // post-deploy window (NoSuchKey), so config resolves empty; the handler must
67
+ // NOT lock in that empty config, and the NEXT request must re-fetch and pick
68
+ // up the now-present config.
69
+ _resetConfigCache();
70
+ process.env.BLOCKS_CONFIG_BUCKET = 'test-bucket';
71
+ process.env.BLOCKS_CONFIG_KEY = 'blocks-config.json';
72
+ delete process.env.SELFHEAL_KEY;
73
+
74
+ let fetchCall = 0;
75
+ const notFound = new Error('The specified key does not exist.');
76
+ (notFound as any).name = 'NoSuchKey';
77
+ _setS3Fetcher(async () => {
78
+ fetchCall++;
79
+ if (fetchCall === 1) throw notFound; // config not readable yet
80
+ return JSON.stringify({ SELFHEAL_KEY: 'ready' });
81
+ });
82
+
83
+ let backendImports = 0;
84
+ const handler = createLambdaHandler(async () => {
85
+ backendImports++;
86
+ return {
87
+ api: (_ctx: BlocksContext) => ({
88
+ async echo(msg: string) { return { msg, cfg: process.env.SELFHEAL_KEY }; },
89
+ }),
90
+ };
91
+ });
92
+
93
+ try {
94
+ // 1st request: transient-empty load → initialize() throws TransientConfigError
95
+ // BEFORE importing the backend (so we never lock in empty config), and the
96
+ // request rejects.
97
+ await assert.rejects(() => handler(makeEvent()) as any, TransientConfigError);
98
+ assert.strictEqual(backendImports, 0, 'backend must NOT be imported while config is unresolved');
99
+
100
+ // 2nd request: config now present → initialize() re-runs, re-fetches, and
101
+ // injects the config into process.env before importing the backend.
102
+ const res = (await handler(makeEvent())) as any;
103
+ assert.strictEqual(res.statusCode, 200);
104
+ const body = JSON.parse(res.body);
105
+ assert.strictEqual(body.result.cfg, 'ready', 'now-present config was injected on the retry');
106
+ assert.strictEqual(backendImports, 1, 'backend imported exactly once, on the successful retry');
107
+ assert.strictEqual(fetchCall, 2, 'S3 was re-fetched on the retry (transient miss is not cached)');
108
+ } finally {
109
+ _resetConfigCache();
110
+ delete process.env.BLOCKS_CONFIG_BUCKET;
111
+ delete process.env.BLOCKS_CONFIG_KEY;
112
+ delete process.env.SELFHEAL_KEY;
113
+ }
114
+ });
115
+
116
+ it('does NOT re-init for a config-less app (no bucket, local dev) — initialize runs once', async () => {
117
+ _resetConfigCache();
118
+ delete process.env.BLOCKS_CONFIG_BUCKET;
119
+ delete process.env.BLOCKS_CONFIG_KEY;
120
+
121
+ let backendImports = 0;
122
+ const handler = createLambdaHandler(async () => {
123
+ backendImports++;
124
+ return { api: (_ctx: BlocksContext) => ({ async echo(msg: string) { return { msg }; } }) };
125
+ });
126
+
127
+ try {
128
+ const r1 = (await handler(makeEvent())) as any;
129
+ const r2 = (await handler(makeEvent())) as any;
130
+ assert.strictEqual(r1.statusCode, 200);
131
+ assert.strictEqual(r2.statusCode, 200);
132
+ assert.strictEqual(backendImports, 1, 'no re-init for a genuinely config-less (local dev) app');
133
+ } finally {
134
+ _resetConfigCache();
135
+ }
136
+ });
137
+
138
+ it('does NOT re-init or spin for a genuinely-empty ({}) S3 config', async () => {
139
+ // A real, readable empty config must be treated as resolved — distinct from
140
+ // the transient 404 miss — so the container does not throw/retry forever nor
141
+ // re-fetch S3 on every request.
142
+ _resetConfigCache();
143
+ process.env.BLOCKS_CONFIG_BUCKET = 'test-bucket';
144
+ process.env.BLOCKS_CONFIG_KEY = 'blocks-config.json';
145
+
146
+ let fetchCall = 0;
147
+ _setS3Fetcher(async () => { fetchCall++; return JSON.stringify({}); });
148
+
149
+ let backendImports = 0;
150
+ const handler = createLambdaHandler(async () => {
151
+ backendImports++;
152
+ return { api: (_ctx: BlocksContext) => ({ async echo(msg: string) { return { msg }; } }) };
153
+ });
154
+
155
+ try {
156
+ const r1 = (await handler(makeEvent())) as any;
157
+ const r2 = (await handler(makeEvent())) as any;
158
+ const r3 = (await handler(makeEvent())) as any;
159
+ assert.strictEqual(r1.statusCode, 200);
160
+ assert.strictEqual(r2.statusCode, 200);
161
+ assert.strictEqual(r3.statusCode, 200);
162
+ assert.strictEqual(backendImports, 1, 'genuinely-empty config resolves; no re-init');
163
+ assert.strictEqual(fetchCall, 1, 'S3 fetched once and cached — no per-request re-fetch spin');
164
+ } finally {
165
+ _resetConfigCache();
166
+ delete process.env.BLOCKS_CONFIG_BUCKET;
167
+ delete process.env.BLOCKS_CONFIG_KEY;
168
+ }
169
+ });
170
+ });
171
+
32
172
  // ── RPC body tests ──────────────────────────────────────────────────────────
33
173
 
34
174
  describe('createLambdaHandler — RPC body handling', () => {
@@ -7,7 +7,7 @@ import { ApiError } from './errors.js';
7
7
  import { BLOCKS_RPC_PREFIX } from './constants.js';
8
8
  import { matchRoute, lockRouteRegistry } from './raw-route.js';
9
9
  import { registerBuiltinRoutes } from './builtin-routes.js';
10
- import { loadConfigToProcessEnv } from './common/config.js';
10
+ import { loadConfigToProcessEnv, isConfigResolved } from './common/config.js';
11
11
  import {
12
12
  parseRpcRequest,
13
13
  successResponse,
@@ -297,6 +297,26 @@ export function createLambdaHandler(backendFactory: () => Promise<any>) {
297
297
  async function initialize() {
298
298
  await loadConfigToProcessEnv();
299
299
 
300
+ // If the app has config coordinates (BLOCKS_CONFIG_BUCKET/KEY set) but the
301
+ // load didn't actually resolve the config, we're in the transient post-deploy
302
+ // S3 window where blocks-config.json isn't readable yet. loadConfigFromS3()
303
+ // deliberately does NOT cache that empty result, so throw a typed transient
304
+ // error here — BEFORE importing the backend — so the createLambdaHandler()
305
+ // catch resets initPromise and the next request re-runs initialize(), which
306
+ // re-fetches from S3 and picks up the now-present config. Without this throw,
307
+ // initialize() would succeed with empty config, `handler` would be assigned,
308
+ // and the `if (!handler)` guard below would never fire again, poisoning the
309
+ // container for its whole life (it would import the backend against empty
310
+ // process.env and serve "not configured" 500s forever).
311
+ //
312
+ // A genuinely config-less app does NOT throw: the no-bucket local-dev path
313
+ // and a real empty `{}` config both cache their result, so isConfigResolved()
314
+ // is true and the retry never spins. Once the blob is readable the successful
315
+ // load caches, so there is no unbounded per-request S3 re-fetch either.
316
+ if (process.env.BLOCKS_CONFIG_BUCKET && process.env.BLOCKS_CONFIG_KEY && !isConfigResolved()) {
317
+ throw new TransientConfigError();
318
+ }
319
+
300
320
  // Merge hosting-provided CORS origins into the main env var so the lazy
301
321
  // getCorsPatterns() sees a combined value on first access.
302
322
  // loadConfigToProcessEnv() won't override CORS_ALLOWED_ORIGINS if it's
@@ -318,7 +338,18 @@ export function createLambdaHandler(backendFactory: () => Promise<any>) {
318
338
 
319
339
  return async (event: any, context?: LambdaContext) => {
320
340
  if (!handler) {
321
- if (!initPromise) initPromise = initialize();
341
+ // Retry init on failure instead of caching the rejection. If initialize() throws (most often
342
+ // because loadConfigToProcessEnv() couldn't read blocks-config.json during the brief
343
+ // post-deploy window before it's readable), a memoized rejected promise would poison this
344
+ // container for its whole lifetime — every later request re-awaits the same rejection and 500s.
345
+ // Resetting initPromise lets the next invocation re-run initialize() (config.ts re-fetches,
346
+ // since it doesn't cache failures), so the handler self-heals once config is available.
347
+ if (!initPromise) {
348
+ initPromise = initialize().catch((err) => {
349
+ initPromise = null;
350
+ throw err;
351
+ });
352
+ }
322
353
  await initPromise;
323
354
  }
324
355
 
@@ -390,6 +421,27 @@ class HandlerTimeoutError extends Error {
390
421
  }
391
422
  }
392
423
 
424
+ /**
425
+ * Thrown by `initialize()` when the app has config coordinates
426
+ * (BLOCKS_CONFIG_BUCKET/KEY) but the S3 config load resolved empty because
427
+ * blocks-config.json wasn't readable yet — the transient window right after a
428
+ * deploy, before the BucketDeployment settles.
429
+ *
430
+ * It flows through the `createLambdaHandler()` init catch, which resets
431
+ * `initPromise` so the NEXT request re-runs `initialize()` and picks up the
432
+ * now-present config (config.ts does not cache a not-found result, so the retry
433
+ * re-fetches). A distinct type keeps the recovery path greppable and testable
434
+ * and separates it from real init failures, which surface with their own error.
435
+ *
436
+ * @internal Exported for testing only.
437
+ */
438
+ export class TransientConfigError extends Error {
439
+ constructor() {
440
+ super('[Blocks] Config not readable yet (transient post-deploy S3 window); will retry on next request');
441
+ this.name = 'TransientConfigError';
442
+ }
443
+ }
444
+
393
445
  /**
394
446
  * Extract the request path from a Lambda event, normalizing between
395
447
  * API Gateway v1 (REST) and v2 (HTTP API) event shapes.
@@ -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
+ });
@@ -14,6 +14,8 @@ import { classifyError } from '../telemetry/trackCommand.js';
14
14
  import { getCdkTelemetryEnv } from './cdk-telemetry-env.js';
15
15
  import { runSync, spawnCommand } from './run-command.js';
16
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';
17
19
 
18
20
  /**
19
21
  * Import the backend definition to populate the Scope BB registry.
@@ -321,6 +323,180 @@ export async function startSandbox(options: SandboxOptions) {
321
323
  await new Promise(() => {});
322
324
  }
323
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
+
324
500
  export async function destroySandbox(backendPath: string) {
325
501
  return trackCommand('sandbox:destroy', async () => {
326
502
  console.log("🗑️ Destroying sandbox...");
@@ -334,31 +510,16 @@ export async function destroySandbox(backendPath: string) {
334
510
  "exec", "cdk", "--", "destroy",
335
511
  "--force",
336
512
  "--context", "sandboxMode=true",
337
- "--app", `npm exec tsx -- -C cdk ${backendPath}`,
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}'`,
338
516
  ];
339
517
  const cdkEnv = { ...process.env, NODE_OPTIONS: "--conditions=cdk", ...getCdkTelemetryEnv('sandbox') };
340
- // Retry with backoff for VPC-dependent resources (e.g. Aurora clusters).
341
- // CloudFormation deletes the cluster first, but its ENIs take 60-120s to
342
- // detach from the VPC subnets asynchronously. The initial destroy fails
343
- // because the subnets still have attached ENIs; retrying after the cleanup
344
- // window lets the VPC delete succeed.
345
- const retryDelays = [60_000, 120_000]; // 1min, then 2min
346
-
347
- for (let attempt = 0; ; attempt++) {
348
- try {
349
- runSync("npm", cdkArgs, { stdio: "inherit", env: cdkEnv });
350
- console.log(attempt === 0 ? "\n✅ Sandbox destroyed!" : "\n✅ Sandbox destroyed on retry!");
351
- return;
352
- } catch (error) {
353
- if (attempt < retryDelays.length) {
354
- const delaySec = retryDelays[attempt] / 1000;
355
- console.log(`\n⏳ Stack deletion failed. Retrying in ${delaySec}s (waiting for resource cleanup)...`);
356
- await new Promise(r => setTimeout(r, retryDelays[attempt]));
357
- } else {
358
- console.error("\n❌ Destroy failed after retries.");
359
- throw error;
360
- }
361
- }
362
- }
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
+ });
363
524
  });
364
525
  }
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.3.0';
2
+ export const CORE_VERSION = '0.4.0';