@aws-blocks/core 0.3.0 → 0.5.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 +25 -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 +11 -0
- package/dist/cdk/blocks-backend.d.ts.map +1 -1
- package/dist/cdk/blocks-backend.js +72 -13
- package/dist/cdk/blocks-backend.test.js +88 -1
- package/dist/cdk/blocks-defaults.d.ts +76 -1
- package/dist/cdk/blocks-defaults.d.ts.map +1 -1
- package/dist/cdk/blocks-defaults.js +17 -1
- package/dist/cdk/blocks-defaults.test.js +17 -0
- package/dist/cdk/blocks-stack.test.js +44 -3
- 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 +82 -2
- package/dist/cdk/compute/compute.d.ts.map +1 -1
- package/dist/cdk/compute/compute.js +64 -2
- package/dist/cdk/compute/default-compute-factory.d.ts +1 -0
- package/dist/cdk/compute/default-compute-factory.d.ts.map +1 -1
- 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 +127 -0
- package/dist/cdk/dashboard-registry.d.ts +41 -0
- package/dist/cdk/dashboard-registry.d.ts.map +1 -0
- package/dist/cdk/dashboard-registry.js +61 -0
- package/dist/cdk/index.d.ts +90 -6
- package/dist/cdk/index.d.ts.map +1 -1
- package/dist/cdk/index.js +135 -13
- package/dist/cdk/internal.d.ts +3 -1
- package/dist/cdk/internal.d.ts.map +1 -1
- package/dist/cdk/internal.js +4 -1
- package/dist/cdk/tracer-registry.d.ts +31 -0
- package/dist/cdk/tracer-registry.d.ts.map +1 -0
- package/dist/cdk/tracer-registry.js +49 -0
- package/dist/cdk/vpc-requirements-registry.d.ts +33 -0
- package/dist/cdk/vpc-requirements-registry.d.ts.map +1 -0
- package/dist/cdk/vpc-requirements-registry.js +46 -0
- package/dist/cdk/vpc-types.d.ts +151 -0
- package/dist/cdk/vpc-types.d.ts.map +1 -0
- package/dist/cdk/vpc-types.js +3 -0
- package/dist/cdk/vpc.d.ts +59 -0
- package/dist/cdk/vpc.d.ts.map +1 -0
- package/dist/cdk/vpc.js +298 -0
- package/dist/cdk/vpc.test.d.ts +2 -0
- package/dist/cdk/vpc.test.d.ts.map +1 -0
- package/dist/cdk/vpc.test.js +285 -0
- 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 +21 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +25 -0
- package/dist/hosting.d.ts +9 -0
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +12 -1
- package/dist/hosting.test.js +63 -1
- package/dist/index.cdk.d.ts +3 -2
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lambda-handler.d.ts +17 -0
- package/dist/lambda-handler.d.ts.map +1 -1
- package/dist/lambda-handler.js +61 -5
- package/dist/lambda-handler.test.js +191 -2
- package/dist/raw-route.d.ts +15 -1
- package/dist/raw-route.d.ts.map +1 -1
- package/dist/raw-route.js +96 -12
- package/dist/raw-route.test.js +332 -1
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +11 -0
- package/dist/scripts/extract-ts-types.d.ts.map +1 -1
- package/dist/scripts/extract-ts-types.js +107 -23
- package/dist/scripts/extract-ts-types.test.js +225 -26
- package/dist/scripts/generate-spec.d.ts.map +1 -1
- package/dist/scripts/generate-spec.js +14 -5
- package/dist/scripts/generate-spec.test.js +93 -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 +54 -0
- package/dist/scripts/sandbox.d.ts.map +1 -1
- package/dist/scripts/sandbox.js +163 -25
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -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 +168 -60
- package/src/cdk/blocks-backend.ts +299 -227
- package/src/cdk/blocks-defaults.test.ts +21 -0
- package/src/cdk/blocks-defaults.ts +79 -1
- package/src/cdk/blocks-stack.test.ts +57 -13
- package/src/cdk/compute/compute-registry.ts +45 -0
- package/src/cdk/compute/compute.ts +114 -2
- package/src/cdk/compute/default-compute-factory.ts +1 -0
- package/src/cdk/config-registry.test.ts +149 -0
- package/src/cdk/config-registry.ts +92 -34
- package/src/cdk/dashboard-registry.ts +68 -0
- package/src/cdk/index.ts +427 -256
- package/src/cdk/internal.ts +6 -2
- package/src/cdk/tracer-registry.ts +54 -0
- package/src/cdk/vpc-requirements-registry.ts +63 -0
- package/src/cdk/vpc-types.ts +158 -0
- package/src/cdk/vpc.test.ts +348 -0
- package/src/cdk/vpc.ts +336 -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 +26 -0
- package/src/hosting.test.ts +87 -1
- package/src/hosting.ts +24 -1
- package/src/index.cdk.ts +11 -1
- package/src/index.ts +1 -1
- package/src/lambda-handler.test.ts +220 -2
- package/src/lambda-handler.ts +65 -4
- package/src/raw-route.test.ts +427 -1
- package/src/raw-route.ts +125 -12
- package/src/scripts/dev-server.ts +12 -1
- package/src/scripts/extract-ts-types.test.ts +228 -26
- package/src/scripts/extract-ts-types.ts +104 -20
- package/src/scripts/generate-spec.test.ts +101 -0
- package/src/scripts/generate-spec.ts +15 -5
- package/src/scripts/sandbox-empty-buckets.test.ts +191 -0
- package/src/scripts/sandbox.ts +185 -24
- package/src/version.ts +1 -1
|
@@ -16,6 +16,7 @@ import { join, dirname } from 'path';
|
|
|
16
16
|
import { tmpdir } from 'os';
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
18
18
|
import { generateSpec } from './generate-spec.js';
|
|
19
|
+
import { ApiNamespace } from '../api.js';
|
|
19
20
|
|
|
20
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
// `import.meta.url` lives in dist/scripts/, so `..` is dist/ and the built
|
|
@@ -97,3 +98,103 @@ describe('generateSpec — @blocksSkipCodegen', () => {
|
|
|
97
98
|
}
|
|
98
99
|
});
|
|
99
100
|
});
|
|
101
|
+
|
|
102
|
+
describe('generateSpec — namespaces sharing a method name (regression: #445)', () => {
|
|
103
|
+
it('emits each namespace its OWN schema for a same-named method', async () => {
|
|
104
|
+
const dir = join(tmpdir(), `blocks-spec-test-${Date.now()}-collision`);
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
writeTsconfig(dir);
|
|
107
|
+
|
|
108
|
+
// Typed foundation for the TS type extractor. `create` has DIFFERENT params
|
|
109
|
+
// in each namespace; binding names (widgets/subscriptions) match the runtime
|
|
110
|
+
// module's export names below.
|
|
111
|
+
writeFileSync(join(dir, 'index.ts'), `
|
|
112
|
+
interface ApiNamespaceConstructor { new <T>(scope: any, name: string, handler: (ctx: any) => T): T; }
|
|
113
|
+
const ApiNamespace: ApiNamespaceConstructor = class {} as any;
|
|
114
|
+
|
|
115
|
+
export const widgets = new ApiNamespace(null, 'widgets', () => ({
|
|
116
|
+
async create(label: string): Promise<{ widgetId: string }> { return { widgetId: 'w' }; },
|
|
117
|
+
}));
|
|
118
|
+
export const subscriptions = new ApiNamespace(null, 'subscriptions', () => ({
|
|
119
|
+
async create(topicCount: number): Promise<{ subId: number }> { return { subId: 1 }; },
|
|
120
|
+
}));
|
|
121
|
+
`);
|
|
122
|
+
|
|
123
|
+
// Runtime module for namespace discovery (types come from the .ts above via
|
|
124
|
+
// the extractor). Use the real ApiNamespace so the marker symbol matches.
|
|
125
|
+
const widgets = new ApiNamespace(null as any, 'widgets', () => ({ async create(_label: string) { return { widgetId: 'w' }; } }));
|
|
126
|
+
const subscriptions = new ApiNamespace(null as any, 'subscriptions', () => ({ async create(_topicCount: number) { return { subId: 1 }; } }));
|
|
127
|
+
const loader = async () => ({ widgets, subscriptions }) as Record<string, unknown>;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const doc = await generateSpec(join(dir, 'index.ts'), loader);
|
|
131
|
+
const byName = new Map(doc.methods.map((m) => [m.name, m]));
|
|
132
|
+
|
|
133
|
+
const w = byName.get('widgets.create');
|
|
134
|
+
const s = byName.get('subscriptions.create');
|
|
135
|
+
assert.ok(w && s, 'both namespace-qualified methods should be present');
|
|
136
|
+
|
|
137
|
+
// Each keeps its OWN param — no cross-assignment.
|
|
138
|
+
assert.strictEqual(w!.params[0]?.name, 'label');
|
|
139
|
+
assert.strictEqual((w!.params[0] as any)?.schema?.type, 'string');
|
|
140
|
+
assert.strictEqual(s!.params[0]?.name, 'topicCount');
|
|
141
|
+
assert.strictEqual((s!.params[0] as any)?.schema?.type, 'number');
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(dir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('generateSpec — namespace returned by a factory, then destructured (regression: #444)', () => {
|
|
149
|
+
it('emits real schemas (not unknown) for a factory/destructured namespace', async () => {
|
|
150
|
+
const dir = join(tmpdir(), `blocks-spec-test-${Date.now()}-factory`);
|
|
151
|
+
mkdirSync(dir, { recursive: true });
|
|
152
|
+
writeTsconfig(dir);
|
|
153
|
+
|
|
154
|
+
// Typed foundation: one directly-exported namespace and one built inside a
|
|
155
|
+
// factory, returned as a property, destructured, and re-exported. Both use
|
|
156
|
+
// `create` with DIFFERENT params so a regression would cross-assign or emit
|
|
157
|
+
// unknown.
|
|
158
|
+
writeFileSync(join(dir, 'index.ts'), `
|
|
159
|
+
interface ApiNamespaceConstructor { new <T>(scope: any, name: string, handler: (ctx: any) => T): T; }
|
|
160
|
+
const ApiNamespace: ApiNamespaceConstructor = class {} as any;
|
|
161
|
+
class Scope { constructor(id: string) {} }
|
|
162
|
+
|
|
163
|
+
class Factory {
|
|
164
|
+
constructor(private readonly scope: Scope) {}
|
|
165
|
+
build() {
|
|
166
|
+
return {
|
|
167
|
+
subscriptions: new ApiNamespace(this.scope, 'subscriptions', () => ({
|
|
168
|
+
async create(topicCount: number): Promise<{ subId: number }> { return { subId: 1 }; },
|
|
169
|
+
})),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const scope = new Scope('app');
|
|
175
|
+
export const widgets = new ApiNamespace(scope, 'widgets', () => ({
|
|
176
|
+
async create(label: string): Promise<{ widgetId: string }> { return { widgetId: 'w' }; },
|
|
177
|
+
}));
|
|
178
|
+
const { subscriptions } = new Factory(scope).build();
|
|
179
|
+
export { subscriptions };
|
|
180
|
+
`);
|
|
181
|
+
|
|
182
|
+
const widgets = new ApiNamespace(null as any, 'widgets', () => ({ async create(_label: string) { return { widgetId: 'w' }; } }));
|
|
183
|
+
const subscriptions = new ApiNamespace(null as any, 'subscriptions', () => ({ async create(_topicCount: number) { return { subId: 1 }; } }));
|
|
184
|
+
const loader = async () => ({ widgets, subscriptions }) as Record<string, unknown>;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const doc = await generateSpec(join(dir, 'index.ts'), loader);
|
|
188
|
+
const byName = new Map(doc.methods.map((m) => [m.name, m]));
|
|
189
|
+
const w = byName.get('widgets.create');
|
|
190
|
+
const s = byName.get('subscriptions.create');
|
|
191
|
+
assert.ok(w && s, 'both qualified methods present');
|
|
192
|
+
// The factory-returned namespace keeps its own param, not `unknown` and
|
|
193
|
+
// not the direct namespace's `string`.
|
|
194
|
+
assert.strictEqual((s!.params[0] as any)?.schema?.type, 'number');
|
|
195
|
+
assert.strictEqual((w!.params[0] as any)?.schema?.type, 'string');
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(dir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -324,6 +324,16 @@ export async function generateSpec(
|
|
|
324
324
|
for (const [methodName, methodFn] of Object.entries(methodMap)) {
|
|
325
325
|
if (typeof methodFn !== 'function') continue;
|
|
326
326
|
|
|
327
|
+
const qualifiedName = `${routingName}.${methodName}`;
|
|
328
|
+
// Prefer the namespace-qualified TS types; fall back to the bare method
|
|
329
|
+
// name for entries the extractor couldn't attribute to a namespace.
|
|
330
|
+
// Qualified keys stop two namespaces that share a method name (e.g.
|
|
331
|
+
// widgets.create / subscriptions.create) from cross-assigning schemas.
|
|
332
|
+
// Residual edge: namespaces the extractor can't attribute (default export,
|
|
333
|
+
// factory-wrapped, or an `export { x as y }` rename) still land on bare
|
|
334
|
+
// keys and could overwrite each other here — same class as #445, but rare.
|
|
335
|
+
const tsInfo = tsTypes.get(qualifiedName) ?? tsTypes.get(methodName);
|
|
336
|
+
|
|
327
337
|
// `@blocksSkipCodegen` is a JSDoc tag the customer puts on a method
|
|
328
338
|
// they want callable from the generated TS client (Proxy-based,
|
|
329
339
|
// doesn't enumerate) but absent from the OpenRPC spec — so native
|
|
@@ -334,9 +344,11 @@ export async function generateSpec(
|
|
|
334
344
|
// type-resolution errors and silently empty its Map — we always
|
|
335
345
|
// want the JSDoc tag to be honored, so the AST-only scan acts as
|
|
336
346
|
// a safety net.
|
|
337
|
-
|
|
347
|
+
// The skip set is namespace-qualified too (with a bare fallback), so a
|
|
348
|
+
// `@blocksSkipCodegen` tag on one namespace's method doesn't drop another
|
|
349
|
+
// namespace's same-named method.
|
|
350
|
+
if (tsInfo?.skipCodegen || skipCodegenNames.has(qualifiedName) || skipCodegenNames.has(methodName)) continue;
|
|
338
351
|
|
|
339
|
-
const qualifiedName = `${routingName}.${methodName}`;
|
|
340
352
|
const resultName = capitalize(methodName) + 'Result';
|
|
341
353
|
const paramNames = extractParamNames(methodFn);
|
|
342
354
|
const fnSource = methodFn.toString();
|
|
@@ -380,7 +392,6 @@ export async function generateSpec(
|
|
|
380
392
|
}));
|
|
381
393
|
} else {
|
|
382
394
|
// No Zod schema — fall back to TypeScript AST types
|
|
383
|
-
const tsInfo = tsTypes.get(methodName);
|
|
384
395
|
if (tsInfo) {
|
|
385
396
|
params = tsInfo.params.map((p) => ({
|
|
386
397
|
name: p.name,
|
|
@@ -397,8 +408,7 @@ export async function generateSpec(
|
|
|
397
408
|
}
|
|
398
409
|
}
|
|
399
410
|
|
|
400
|
-
// Return type: Zod doesn't cover this yet, so use TS types
|
|
401
|
-
const tsInfo = tsTypes.get(methodName);
|
|
411
|
+
// Return type: Zod doesn't cover this yet, so use TS types (tsInfo resolved above)
|
|
402
412
|
const rawResultSchema = tsInfo?.returnType && tsInfo.returnType.type !== 'unknown'
|
|
403
413
|
? tsInfo.returnType
|
|
404
414
|
: { type: 'unknown' };
|
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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.
|
|
2
|
+
export const CORE_VERSION = '0.5.0';
|