@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
@@ -30,14 +30,19 @@ class StubLambdaCompute extends Compute {
30
30
  readonly fn: lambda.NodejsFunction;
31
31
  readonly apiGateway: apigateway.RestApi;
32
32
  readonly apiUrl: string;
33
+ readonly logGroup: cdk.aws_logs.LogGroup;
33
34
 
34
35
  constructor(scope: ScopeParent, id: string) {
35
36
  super(id, { parent: scope });
37
+ this.logGroup = new cdk.aws_logs.LogGroup(this, 'HandlerLogGroup', {
38
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
39
+ });
36
40
  this.fn = new lambda.NodejsFunction(this, 'Handler', {
37
41
  entry: this.backendHandlerPath,
38
42
  runtime: cdk.aws_lambda.Runtime.NODEJS_22_X,
39
43
  handler: 'handler',
40
44
  role: this.executionRole,
45
+ logGroup: this.logGroup,
41
46
  environment: { BLOCKS_STACK_NAME: this.backendStackName },
42
47
  bundling: { minify: true, esbuildArgs: { '--conditions': 'aws-runtime' } },
43
48
  });
@@ -165,12 +170,31 @@ describe('shared execution role', () => {
165
170
  'BlocksRole should attach AWSLambdaBasicExecutionRole',
166
171
  );
167
172
 
173
+ // Core is BB-agnostic: with no Building Block that runs AS the shared role, the trust policy
174
+ // must NOT allow any compute principal beyond Lambda (e.g. bedrock-agentcore is added by the
175
+ // Agent BB's own construct, not here — see packages/bb-agent).
176
+ assert.ok(
177
+ !JSON.stringify(blocksRole.Properties.AssumeRolePolicyDocument).includes('bedrock-agentcore'),
178
+ 'core must not trust bedrock-agentcore by default (no agent → no AgentCore trust)',
179
+ );
180
+
168
181
  // The Blocks handler references the shared role, not an auto-generated one.
169
182
  template.hasResourceProperties('AWS::Lambda::Function', {
170
183
  Role: { 'Fn::GetAtt': [blocksRoleId, 'Arn'] },
171
184
  });
172
185
  });
173
186
 
187
+ test('exposes backendModulePath (props.backendCDKPath) for co-bundling BBs', async () => {
188
+ const app = new cdk.App();
189
+ const parent = new cdk.Stack(app, 'BackendPathStack');
190
+
191
+ const backend = await makeBackend(parent, 'Blocks', sideEffectBackendPath);
192
+
193
+ // Building Blocks that co-bundle the app backend at synth time (e.g. the Agent BB's AgentCore
194
+ // Runtime) discover it via globalThis.CURRENT_BLOCKS_STACK.backendModulePath.
195
+ assert.strictEqual(backend.backendModulePath, sideEffectBackendPath);
196
+ });
197
+
174
198
  test('a nested block resolves executionRole via the construct-tree walk', async () => {
175
199
  const app = new cdk.App();
176
200
  const parent = new cdk.Stack(app, 'RoleResolveStack');
@@ -12,6 +12,7 @@ import { finalizeConfigRegistry, registerConfig } from './config-registry.js';
12
12
  import type { BlocksDefaults } from './blocks-defaults.js';
13
13
  import { registerBuiltinRoutes } from '../builtin-routes.js';
14
14
  import type { Compute } from './compute/compute.js';
15
+ import { getComputes } from './compute/compute-registry.js';
15
16
  import type { DefaultComputeFactory, LambdaShapedCompute } from './compute/default-compute-factory.js';
16
17
 
17
18
  /**
@@ -105,9 +106,10 @@ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id
105
106
  // so permissions would quietly vanish. If a bring-your-own-role option is ever
106
107
  // added, it must resolve to a mutable role (`{ mutable: true }`).
107
108
  const executionRole = new iam.Role(scope, 'BlocksRole', {
108
- // CompositePrincipal (rather than a bare ServicePrincipal) so additional
109
- // compute types can assume this same shared role as they are introduced
110
- // (e.g. ECS tasks via ecs-tasks.amazonaws.com), by adding principals here.
109
+ // CompositePrincipal (rather than a bare ServicePrincipal) so a Building Block
110
+ // whose compute runs AS this shared role can add its own trust principal here
111
+ // (e.g. the Agent BB adds bedrock-agentcore in its CDK construct) core stays
112
+ // agnostic and only Lambda is trusted by default.
111
113
  assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')),
112
114
  managedPolicies: [
113
115
  iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
@@ -188,6 +190,12 @@ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id
188
190
  */
189
191
  export class BlocksBackend extends Construct {
190
192
  public readonly backendHandlerPath: string;
193
+ /**
194
+ * Path to the app's backend module (`props.backendCDKPath`). Exposed so Building Blocks that
195
+ * co-bundle the backend at synth (e.g. the Agent BB's AgentCore Runtime) can discover it via
196
+ * `globalThis.CURRENT_BLOCKS_STACK.backendModulePath`.
197
+ */
198
+ public readonly backendModulePath: string;
191
199
  /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */
192
200
  public readonly executionRole: iam.IRole;
193
201
  /** Infrastructure defaults for Building Blocks created under this backend. */
@@ -207,6 +215,10 @@ export class BlocksBackend extends Construct {
207
215
  get apiUrl(): string {
208
216
  return this.requireDefaultCompute().apiUrl;
209
217
  }
218
+ /** The default compute's handler CloudWatch log group. `bb-logger` reconfigures its retention. */
219
+ get handlerLogGroup(): cdk.aws_logs.ILogGroup {
220
+ return this.requireDefaultCompute().logGroup;
221
+ }
210
222
 
211
223
  private requireDefaultCompute(): LambdaShapedCompute {
212
224
  if (!this._defaultCompute) {
@@ -250,6 +262,7 @@ export class BlocksBackend extends Construct {
250
262
  super(scope, id);
251
263
 
252
264
  this.backendHandlerPath = props.backendHandlerPath;
265
+ this.backendModulePath = props.backendCDKPath;
253
266
 
254
267
  // Expose self to Building Blocks at CDK time
255
268
  (globalThis as any).CURRENT_BLOCKS_STACK = this;
@@ -290,7 +303,7 @@ export class BlocksBackend extends Construct {
290
303
  addBlocksStackMetadata(cdk.Stack.of(backend));
291
304
 
292
305
  // Finalize BB config → S3 (after all BBs have registered their config)
293
- finalizeConfigRegistry(backend, backend.handler);
306
+ finalizeConfigRegistry(backend, backend.executionRole, getComputes(backend));
294
307
 
295
308
  return backend;
296
309
  }
@@ -10,6 +10,7 @@
10
10
  import { test, describe } from 'node:test';
11
11
  import assert from 'node:assert';
12
12
  import { RemovalPolicy } from 'aws-cdk-lib';
13
+ import { RetentionDays } from 'aws-cdk-lib/aws-logs';
13
14
  import { BlocksPresets } from './blocks-defaults.js';
14
15
 
15
16
  describe('BlocksPresets', () => {
@@ -24,4 +25,24 @@ describe('BlocksPresets', () => {
24
25
  assert.strictEqual(BlocksPresets.production.deletionProtection, true);
25
26
  assert.strictEqual(BlocksPresets.production.pointInTimeRecovery, true);
26
27
  });
28
+
29
+ test('sandbox keeps logs briefly', () => {
30
+ assert.strictEqual(BlocksPresets.sandbox.logRetention, RetentionDays.ONE_WEEK);
31
+ });
32
+
33
+ test('production keeps logs a year', () => {
34
+ assert.strictEqual(BlocksPresets.production.logRetention, RetentionDays.ONE_YEAR);
35
+ });
36
+
37
+ test('access logging is off by default in BOTH presets (opt-in; mutates an account singleton)', () => {
38
+ assert.strictEqual(BlocksPresets.sandbox.accessLogging, false);
39
+ assert.strictEqual(BlocksPresets.production.accessLogging, false);
40
+ });
41
+
42
+ test('sandbox caps tighter (200/400) than production (1000/2000)', () => {
43
+ assert.strictEqual(BlocksPresets.sandbox.throttling.rateLimit, 200);
44
+ assert.strictEqual(BlocksPresets.sandbox.throttling.burstLimit, 400);
45
+ assert.strictEqual(BlocksPresets.production.throttling.rateLimit, 1000);
46
+ assert.strictEqual(BlocksPresets.production.throttling.burstLimit, 2000);
47
+ });
27
48
  });
@@ -2,6 +2,20 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { RemovalPolicy } from 'aws-cdk-lib';
5
+ import { RetentionDays } from 'aws-cdk-lib/aws-logs';
6
+
7
+ /**
8
+ * Request-rate limits applied to an API Gateway stage. On a REST API these are
9
+ * requests/second; on a WebSocket API the unit is messages/second across the
10
+ * connection. `rateLimit` is the steady-state ceiling and `burstLimit` the
11
+ * token-bucket size for short spikes.
12
+ */
13
+ export interface BlocksThrottling {
14
+ /** Steady-state request (or WebSocket message) rate ceiling, per second. */
15
+ rateLimit: number;
16
+ /** Token-bucket burst size for short spikes above `rateLimit`. */
17
+ burstLimit: number;
18
+ }
5
19
 
6
20
  /**
7
21
  * Default values for every Amazon-authored Building Block created within a
@@ -47,6 +61,43 @@ export interface BlocksDefaults {
47
61
  * 1–35 days). Backups only have a window when on, so the two are one field.
48
62
  */
49
63
  pointInTimeRecovery: boolean | { retentionDays: number };
64
+
65
+ /**
66
+ * How long CloudWatch Logs keeps log events written by Blocks-managed log
67
+ * groups (the core handler Lambda, migration/GSI Lambdas, hosting compute,
68
+ * `bb-logger`, and API Gateway access logs) before expiring them. Without a
69
+ * retention set, AWS keeps log events forever, which grows cost unbounded —
70
+ * every Blocks-managed log group reads this default so retention is applied
71
+ * consistently.
72
+ */
73
+ logRetention: RetentionDays;
74
+
75
+ /**
76
+ * Request-rate limits applied to every Blocks-managed API Gateway stage: the
77
+ * core REST API, the SSR/hosting REST API, and the `bb-realtime` WebSocket
78
+ * stage. Protects the backend from runaway clients and caps blast radius.
79
+ * The sandbox preset caps tighter (200/400) than production (1000/2000) so a
80
+ * disposable stack is well-protected without throttling real production
81
+ * traffic. See {@link BlocksThrottling}.
82
+ */
83
+ throttling: BlocksThrottling;
84
+
85
+ /**
86
+ * Whether to emit structured JSON access logs from every Blocks-managed API
87
+ * Gateway stage to a dedicated CloudWatch log group (retention follows
88
+ * {@link logRetention}).
89
+ *
90
+ * **Off by default in both presets** — opt in with a per-stack override
91
+ * (`defaults: { ...BlocksPresets.production, accessLogging: true }`). It is
92
+ * off by default (rather than on for production) because enabling it
93
+ * provisions the account-level API Gateway CloudWatch Logs role, which is an
94
+ * account/region-level singleton: a second Blocks stack in the same
95
+ * account+region that also enables access logging can repoint that role on
96
+ * deploy or, on teardown, leave the survivor's access logging broken.
97
+ * Enabling it is therefore safe for **one Blocks stack per region** — see
98
+ * `ensureApiGatewayAccount` for the full multi-stack teardown caveat.
99
+ */
100
+ accessLogging: boolean;
50
101
  }
51
102
 
52
103
  /**
@@ -64,12 +115,27 @@ export const BlocksPresets = {
64
115
  deletionProtection: false,
65
116
  allowedOrigins: ['^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$'],
66
117
  pointInTimeRecovery: false,
118
+ logRetention: RetentionDays.ONE_WEEK,
119
+ throttling: { rateLimit: 200, burstLimit: 400 },
120
+ accessLogging: false,
67
121
  },
68
- /** Durable, protected posture for permanent deployments. */
122
+ /**
123
+ * Durable, protected posture for permanent deployments. A higher throttle
124
+ * ceiling (1000/2000) than sandbox so the default doesn't 429 real
125
+ * production traffic — raise it via a per-stack `throttling` override for
126
+ * higher-volume APIs.
127
+ */
69
128
  production: {
70
129
  removalPolicy: RemovalPolicy.RETAIN,
71
130
  deletionProtection: true,
72
131
  allowedOrigins: [],
73
132
  pointInTimeRecovery: true,
133
+ logRetention: RetentionDays.ONE_YEAR,
134
+ throttling: { rateLimit: 1000, burstLimit: 2000 },
135
+ // Off by default even in production: enabling access logging mutates the
136
+ // account/region-level API Gateway CloudWatch role (a singleton). Opt in
137
+ // per stack once you've confirmed a single Blocks stack owns it in the
138
+ // region — see the `accessLogging` field doc.
139
+ accessLogging: false,
74
140
  },
75
141
  } satisfies Record<string, BlocksDefaults>;
@@ -13,6 +13,7 @@ import type { ScopeParent } from '../common/index.js';
13
13
  import { BLOCKS_RPC_PREFIX } from '../constants.js';
14
14
  import { BlocksBackend } from './blocks-backend.js';
15
15
  import { Compute } from './compute/compute.js';
16
+ import { getComputes } from './compute/compute-registry.js';
16
17
  import type { DefaultComputeFactory } from './compute/default-compute-factory.js';
17
18
  import { BlocksStack, BlocksPresets, Scope } from './index.js';
18
19
 
@@ -27,14 +28,19 @@ class StubLambdaCompute extends Compute {
27
28
  readonly fn: lambda.NodejsFunction;
28
29
  readonly apiGateway: apigateway.RestApi;
29
30
  readonly apiUrl: string;
31
+ readonly logGroup: cdk.aws_logs.LogGroup;
30
32
 
31
33
  constructor(scope: ScopeParent, id: string) {
32
34
  super(id, { parent: scope });
35
+ this.logGroup = new cdk.aws_logs.LogGroup(this, 'HandlerLogGroup', {
36
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
37
+ });
33
38
  this.fn = new lambda.NodejsFunction(this, 'Handler', {
34
39
  entry: this.backendHandlerPath,
35
40
  runtime: cdk.aws_lambda.Runtime.NODEJS_22_X,
36
41
  handler: 'handler',
37
42
  role: this.executionRole,
43
+ logGroup: this.logGroup,
38
44
  environment: { BLOCKS_STACK_NAME: this.backendStackName },
39
45
  bundling: { minify: true, esbuildArgs: { '--conditions': 'aws-runtime' } },
40
46
  });
@@ -166,6 +172,25 @@ describe('root is bound to the owning stack (multi-stack synth)', () => {
166
172
  assert.strictEqual(blockA.backendStackName, 'RootBindingA', 'blockA derives its own stack name');
167
173
  assert.strictEqual(blockB.backendStackName, 'RootBindingB', 'blockB derives its own stack name');
168
174
  });
175
+
176
+ test('each stack owns an isolated compute registry (no cross-stack bleed)', async () => {
177
+ // Computes self-register on their owning stack (keyed per stack, not a
178
+ // process-global list), so a multi-stack synth keeps each stack's computes
179
+ // separate — finalize steps for one stack never see another's compute.
180
+ const app = new cdk.App();
181
+
182
+ const stackA = await makeStack(app, 'ComputeRegistryA', sideEffectBackendPath);
183
+ const stackB = await makeStack(app, 'ComputeRegistryB', sideEffectBackendPath);
184
+
185
+ const computesA = getComputes(stackA);
186
+ const computesB = getComputes(stackB);
187
+
188
+ assert.strictEqual(computesA.length, 1, 'stackA registered exactly its default compute');
189
+ assert.strictEqual(computesB.length, 1, 'stackB registered exactly its default compute');
190
+ assert.strictEqual(computesA[0], stackA._defaultCompute, 'stackA lists its own default');
191
+ assert.strictEqual(computesB[0], stackB._defaultCompute, 'stackB lists its own default');
192
+ assert.notStrictEqual(computesA[0], computesB[0], 'the two stacks hold distinct computes');
193
+ });
169
194
  });
170
195
 
171
196
  describe('assertCdkConditionActive', () => {
@@ -0,0 +1,45 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import * as cdk from 'aws-cdk-lib';
5
+ import type { Construct } from 'constructs';
6
+ import type { Compute } from './compute.js';
7
+
8
+ const REGISTRY_KEY = Symbol.for('BLOCKS_COMPUTE_REGISTRY');
9
+
10
+ /**
11
+ * Get or create the compute list for a given stack. The list is stored on the
12
+ * stack object (keyed by a Symbol), so each stack in a multi-stack synth gets
13
+ * its own — a compute never leaks into another stack's list. Mirrors the config
14
+ * registry (`config-registry.ts`), which scopes its state the same way.
15
+ */
16
+ function getRegistry(stack: cdk.Stack): Compute[] {
17
+ let list = (stack as any)[REGISTRY_KEY] as Compute[] | undefined;
18
+ if (!list) {
19
+ list = [];
20
+ (stack as any)[REGISTRY_KEY] = list;
21
+ }
22
+ return list;
23
+ }
24
+
25
+ /**
26
+ * Register a compute on its owning stack. Called from the {@link Compute} base
27
+ * constructor, so every compute self-registers the moment it is constructed —
28
+ * the finalize steps then enumerate them without a separate discovery pass
29
+ * (mirrors how `registerConfig` accumulates config during the backend import).
30
+ *
31
+ * @param compute - The compute to register (used to locate its stack).
32
+ */
33
+ export function registerCompute(compute: Compute): void {
34
+ getRegistry(cdk.Stack.of(compute)).push(compute);
35
+ }
36
+
37
+ /**
38
+ * The computes registered on the stack that owns `scope`, in construction
39
+ * order. Returns an empty array before any compute is constructed.
40
+ *
41
+ * @param scope - Any construct in the stack (used to locate the stack).
42
+ */
43
+ export function getComputes(scope: Construct): readonly Compute[] {
44
+ return getRegistry(cdk.Stack.of(scope));
45
+ }
@@ -1,7 +1,9 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ import type { ScopeOptions } from '../../common/index.js';
4
5
  import { Scope } from '../index.js';
6
+ import { registerCompute } from './compute-registry.js';
5
7
 
6
8
  /**
7
9
  * Base class for a Blocks *compute* — a runtime that executes handler code
@@ -28,6 +30,14 @@ export abstract class Compute extends Scope {
28
30
  */
29
31
  readonly namespaces: string[] = [];
30
32
 
33
+ constructor(id: string, options?: ScopeOptions) {
34
+ super(id, options);
35
+ // Self-register on the owning stack so finalize steps (config, routing,
36
+ // dashboards) can enumerate every compute without a separate discovery
37
+ // pass. Scoped per stack, so a multi-stack synth keeps lists isolated.
38
+ registerCompute(this);
39
+ }
40
+
31
41
  /**
32
42
  * Inject a runtime configuration value (an environment variable) into this
33
43
  * compute. The framework calls this instead of `handler.addEnvironment()`
@@ -20,6 +20,7 @@ export interface LambdaShapedCompute extends Compute {
20
20
  readonly fn: cdk.aws_lambda_nodejs.NodejsFunction;
21
21
  readonly apiGateway: cdk.aws_apigateway.RestApi;
22
22
  readonly apiUrl: string;
23
+ readonly logGroup: cdk.aws_logs.ILogGroup;
23
24
  }
24
25
 
25
26
  /**
@@ -0,0 +1,135 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * CDK tests for the config registry — specifically the getConfigLocation ↔ finalizeConfigRegistry
6
+ * interaction. `getConfigLocation()` creates the config bucket eagerly (so co-located compute can
7
+ * inject BLOCKS_CONFIG_BUCKET/KEY at construction); finalize must therefore still upload the config
8
+ * object + wire the computes whenever a bucket exists, even if zero entries were registered —
9
+ * otherwise that compute's loadConfigToProcessEnv() would 404 forever against a created-but-empty
10
+ * bucket.
11
+ */
12
+ import assert from 'node:assert';
13
+ import { afterEach, test } from 'node:test';
14
+ import * as cdk from 'aws-cdk-lib';
15
+ import { Match, Template } from 'aws-cdk-lib/assertions';
16
+ import { Construct } from 'constructs';
17
+ import { Compute } from './compute/compute.js';
18
+ import { finalizeConfigRegistry, getConfigLocation, registerConfig } from './config-registry.js';
19
+ import { DEFAULT_NODE_RUNTIME } from './node-version.js';
20
+
21
+ // getConfigLocation reads globalThis.CURRENT_BLOCKS_STACK to place the bucket under the owning
22
+ // stack/backend; clear it between tests so one test's owner never leaks into another.
23
+ afterEach(() => {
24
+ delete (globalThis as any).CURRENT_BLOCKS_STACK;
25
+ });
26
+
27
+ // A real app's compute comes from @aws-blocks/bb-lambda-compute, which core's own tests can't
28
+ // depend on. This is the same shape: a Compute that owns a real Lambda function and injects config
29
+ // via addEnvironment — enough for finalizeConfigRegistry to stamp BLOCKS_CONFIG_BUCKET/KEY on it.
30
+ class TestCompute extends Compute {
31
+ readonly fn: cdk.aws_lambda.Function;
32
+
33
+ constructor(scope: Construct, id: string) {
34
+ super(id, { parent: scope as never });
35
+ this.fn = new cdk.aws_lambda.Function(this, 'Handler', {
36
+ runtime: DEFAULT_NODE_RUNTIME,
37
+ handler: 'index.handler',
38
+ code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
39
+ });
40
+ }
41
+
42
+ setEnv(key: string, value: string): void {
43
+ this.fn.addEnvironment(key, value);
44
+ }
45
+ }
46
+
47
+ function stackWithCompute(id: string): {
48
+ stack: cdk.Stack;
49
+ role: cdk.aws_iam.Role;
50
+ computes: readonly Compute[];
51
+ } {
52
+ const app = new cdk.App();
53
+ const stack = new cdk.Stack(app, id);
54
+ const role = new cdk.aws_iam.Role(stack, 'BlocksRole', {
55
+ assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
56
+ });
57
+ const compute = new TestCompute(stack, 'Compute');
58
+ return { stack, role, computes: [compute] };
59
+ }
60
+
61
+ test('finalize uploads + wires the computes even with zero entries when a bucket was created', () => {
62
+ const { stack, role, computes } = stackWithCompute('EmptyWithBucket');
63
+ // Simulate a co-located BB that creates the bucket but registers no config of its own.
64
+ getConfigLocation(stack);
65
+ finalizeConfigRegistry(stack, role, computes);
66
+
67
+ const t = Template.fromStack(stack);
68
+ assert.strictEqual(Object.keys(t.findResources('AWS::S3::Bucket')).length, 1, 'one config bucket');
69
+ t.resourceCountIs('Custom::CDKBucketDeployment', 1); // the (empty) blocks-config.json is uploaded
70
+ t.hasResourceProperties('AWS::Lambda::Function', {
71
+ Environment: { Variables: Match.objectLike({ BLOCKS_CONFIG_KEY: 'blocks-config.json' }) },
72
+ });
73
+ // Read is granted once to the shared role (not per-function), so every compute that assumes it
74
+ // — including co-located compute that never went through finalize — can read the object.
75
+ t.hasResourceProperties('AWS::IAM::Policy', {
76
+ PolicyDocument: {
77
+ Statement: Match.arrayWith([
78
+ Match.objectLike({ Action: Match.arrayWith([Match.stringLikeRegexp('^s3:GetObject')]) }),
79
+ ]),
80
+ },
81
+ Roles: Match.arrayWith([Match.objectLike({ Ref: Match.stringLikeRegexp('BlocksRole') })]),
82
+ });
83
+ });
84
+
85
+ test('finalize is a no-op with zero entries and no bucket', () => {
86
+ const { stack, role, computes } = stackWithCompute('EmptyNoBucket');
87
+ finalizeConfigRegistry(stack, role, computes);
88
+
89
+ const t = Template.fromStack(stack);
90
+ assert.strictEqual(Object.keys(t.findResources('AWS::S3::Bucket')).length, 0, 'no config bucket created');
91
+ t.resourceCountIs('Custom::CDKBucketDeployment', 0);
92
+ });
93
+
94
+ test('finalize uploads + wires the computes when config was registered (bucket auto-created)', () => {
95
+ const { stack, role, computes } = stackWithCompute('WithEntries');
96
+ registerConfig(stack, 'BLOCKS_SOMETHING', 'value');
97
+ finalizeConfigRegistry(stack, role, computes);
98
+
99
+ const t = Template.fromStack(stack);
100
+ assert.strictEqual(Object.keys(t.findResources('AWS::S3::Bucket')).length, 1, 'one config bucket');
101
+ t.resourceCountIs('Custom::CDKBucketDeployment', 1);
102
+ t.hasResourceProperties('AWS::Lambda::Function', {
103
+ Environment: { Variables: Match.objectLike({ BLOCKS_CONFIG_KEY: 'blocks-config.json' }) },
104
+ });
105
+ });
106
+
107
+ test('the config bucket is created under the owning stack/backend, not the (deep) caller scope', () => {
108
+ // Mimic a BlocksBackend embedded in a customer stack: the owner is a nested construct, and the
109
+ // first caller of getConfigLocation is a *deep* construct (like the AgentCore Runtime).
110
+ const app = new cdk.App();
111
+ const stack = new cdk.Stack(app, 'CustomerStack');
112
+ const owner = new Construct(stack, 'Embedded'); // stands in for the BlocksBackend construct
113
+ (globalThis as any).CURRENT_BLOCKS_STACK = owner;
114
+ const deepScope = new Construct(new Construct(owner, 'agent'), 'runtime');
115
+
116
+ getConfigLocation(deepScope);
117
+
118
+ const t = Template.fromStack(stack);
119
+ const bucketIds = Object.keys(t.findResources('AWS::S3::Bucket'));
120
+ assert.strictEqual(bucketIds.length, 1, 'exactly one config bucket');
121
+ // Logical IDs encode the construct path — under the owner it's `EmbeddedBlocksConfigBucket…`,
122
+ // at the stack root it would be `BlocksConfigBucket…`. Pin that it follows the owner.
123
+ assert.ok(bucketIds[0].startsWith('Embedded'), `bucket should be nested under the owner, got ${bucketIds[0]}`);
124
+ });
125
+
126
+ test('getConfigLocation creates exactly one bucket across repeated calls (idempotent)', () => {
127
+ const app = new cdk.App();
128
+ const stack = new cdk.Stack(app, 'Idempotent');
129
+ const a = getConfigLocation(stack);
130
+ const b = getConfigLocation(stack);
131
+ assert.strictEqual(a.key, b.key, 'same config key');
132
+ assert.strictEqual(a.bucketName, b.bucketName, 'same bucket');
133
+ const t = Template.fromStack(stack);
134
+ assert.strictEqual(Object.keys(t.findResources('AWS::S3::Bucket')).length, 1, 'exactly one bucket');
135
+ });