@fjall/components-infrastructure 2.31.1 → 2.32.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 (26) hide show
  1. package/dist/lib/app.d.ts +27 -1
  2. package/dist/lib/app.js +35 -4
  3. package/dist/lib/patterns/aws/database.d.ts +19 -1
  4. package/dist/lib/patterns/aws/database.js +22 -3
  5. package/dist/lib/patterns/aws/devSubstrate.d.ts +85 -0
  6. package/dist/lib/patterns/aws/devSubstrate.js +390 -0
  7. package/dist/lib/patterns/aws/devSubstrate.waker.source.cjs +13 -0
  8. package/dist/lib/resources/aws/compute/applicationLoadBalancer.d.ts +17 -0
  9. package/dist/lib/resources/aws/compute/applicationLoadBalancer.js +23 -0
  10. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +4 -10
  11. package/dist/lib/resources/aws/compute/ecsNetworking.js +13 -48
  12. package/dist/lib/resources/aws/compute/ecsRoles.d.ts +25 -0
  13. package/dist/lib/resources/aws/compute/ecsRoles.js +39 -20
  14. package/dist/lib/resources/aws/compute/hostHeaderListenerRule.d.ts +23 -0
  15. package/dist/lib/resources/aws/compute/hostHeaderListenerRule.js +59 -0
  16. package/dist/lib/resources/aws/compute/lambdaAlbTarget.d.ts +17 -0
  17. package/dist/lib/resources/aws/compute/lambdaAlbTarget.js +18 -0
  18. package/dist/lib/resources/aws/compute/listenerRouting.d.ts +21 -0
  19. package/dist/lib/resources/aws/compute/listenerRouting.js +20 -0
  20. package/dist/lib/resources/aws/database/rdsAurora.d.ts +16 -0
  21. package/dist/lib/resources/aws/database/rdsAurora.js +57 -3
  22. package/dist/lib/resources/aws/database/rdsDefaults.d.ts +28 -0
  23. package/dist/lib/resources/aws/database/rdsDefaults.js +28 -0
  24. package/dist/lib/utils/devSubstrateTags.d.ts +22 -0
  25. package/dist/lib/utils/devSubstrateTags.js +22 -0
  26. package/package.json +5 -5
@@ -0,0 +1,390 @@
1
+ import { CfnOutput, Duration, Stack, Tags } from "aws-cdk-lib";
2
+ import { Port } from "aws-cdk-lib/aws-ec2";
3
+ import { Cluster } from "aws-cdk-lib/aws-ecs";
4
+ import { Repository } from "aws-cdk-lib/aws-ecr";
5
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
6
+ import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
7
+ import { SqsDestination } from "aws-cdk-lib/aws-s3-notifications";
8
+ import { Construct } from "constructs";
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { DatabaseFactory } from "./database.js";
13
+ import { StorageFactory } from "./storage.js";
14
+ import { MessagingFactory } from "./messaging.js";
15
+ import { createBaseExecutionRole } from "../../resources/aws/compute/ecsRoles.js";
16
+ import { createApplicationLoadBalancer } from "../../resources/aws/compute/applicationLoadBalancer.js";
17
+ import { addRoutingListener } from "../../resources/aws/compute/listenerRouting.js";
18
+ import { createLambdaTargetGroup } from "../../resources/aws/compute/lambdaAlbTarget.js";
19
+ import { LambdaFunction } from "../../resources/aws/compute/lambda.js";
20
+ import { Ecr } from "../../resources/aws/storage/ecr.js";
21
+ import { SecurityGroup } from "../../resources/aws/networking/securityGroup.js";
22
+ import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
23
+ import { CrossAccountDelegationRecord } from "../../resources/aws/networking/crossAccountDelegationRecord.js";
24
+ import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
25
+ import { Role } from "../../resources/aws/iam/role.js";
26
+ import { toPascalCase } from "../../utils/capitaliseString.js";
27
+ import { DEV_AURORA_DEFAULTS } from "../../resources/aws/database/rdsDefaults.js";
28
+ import { DEV_APP_TAG_KEY, DEV_TAG_KEY, DEV_TAG_VALUE } from "../../utils/devSubstrateTags.js";
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+ /** Construct id + logical database name for the substrate's shared Aurora. */
31
+ const DEV_SUBSTRATE_DB_ID = "DevSubstrateDb";
32
+ const DEV_SUBSTRATE_DATABASE_NAME = "devsubstrate";
33
+ /** Construct ids for the substrate's S3-drop → SQS event channel. */
34
+ const DEV_SUBSTRATE_DROP_ID = "DevSubstrateDrop";
35
+ const DEV_SUBSTRATE_EVENTS_ID = "DevSubstrateEvents";
36
+ /** Construct ids for the substrate's compute/network layer. */
37
+ const DEV_SUBSTRATE_CLUSTER_ID = "DevSubstrateCluster";
38
+ const DEV_SUBSTRATE_SLOT_SG_ID = "DevSubstrateSlotSg";
39
+ const DEV_SUBSTRATE_SLOT_EXEC_ROLE_ID = "DevSubstrateSlotExecutionRole";
40
+ const DEV_SUBSTRATE_SLOT_ECR_ID = "DevSubstrateSlotEcr";
41
+ /** Construct ids for the substrate's ALB layer. */
42
+ const DEV_SUBSTRATE_ALB_SG_ID = "DevSubstrateAlbSg";
43
+ const DEV_SUBSTRATE_ALB_ID = "DevSubstrateAlb";
44
+ const DEV_SUBSTRATE_HTTP_LISTENER_ID = "DevSubstrateHttpListener";
45
+ /** Construct ids + inline source for the substrate's ALB Lambda waker. */
46
+ const DEV_SUBSTRATE_WAKER_ID = "DevSubstrateWaker";
47
+ const DEV_SUBSTRATE_WAKER_TG_ID = "DevSubstrateWakerTargetGroup";
48
+ const DEV_SUBSTRATE_WAKER_SOURCE_FILE = "devSubstrate.waker.source.cjs";
49
+ const DEV_SUBSTRATE_WAKER_TIMEOUT_SECONDS = 30;
50
+ /** Construct ids for the substrate's delegated `dev.<appDomain>` child zone. */
51
+ const DEV_SUBSTRATE_ZONE_ID = "DevSubstrateZone";
52
+ const DEV_SUBSTRATE_DELEGATION_ID = "DevSubstrateDelegation";
53
+ const DEV_SUBSTRATE_PARENT_ROLE_ID = "DevSubstrateParentDelegationRole";
54
+ /** Construct ids for the substrate's wildcard cert + :443 HTTPS listener (A6). */
55
+ const DEV_SUBSTRATE_CERT_ID = "DevSubstrateCertificate";
56
+ const DEV_SUBSTRATE_HTTPS_LISTENER_ID = "DevSubstrateHttpsListener";
57
+ /**
58
+ * Default deploy phase when `phase` is omitted — the cert + :443 listener build.
59
+ * The two-step guard (design R2) sets `"zone"` explicitly for step 1, so the
60
+ * safe default for a single-shot deploy is the complete substrate.
61
+ */
62
+ const DEV_SUBSTRATE_DEFAULT_PHASE = "full";
63
+ /**
64
+ * The substrate's database mode, surfaced to slot-ops as an export so the
65
+ * hand-created G2 slot knows whether to wire Aurora Data-API creds or a
66
+ * point-at-existing `DATABASE_URL`. Only `"aurora"` exists today —
67
+ * `point-at-existing` (GAP-7) is deferred, so this is a fixed value for now.
68
+ */
69
+ const DEV_SUBSTRATE_DATABASE_MODE = "aurora";
70
+ /**
71
+ * The container port slots serve HTTP on. Fjall owns the slot image, so this is a
72
+ * fixed contract: the ALB → slot-SG ingress opens exactly this port and the
73
+ * host-header rules a slot registers (§5.4) forward to it. The public :80 ALB
74
+ * front listener is separate.
75
+ */
76
+ const DEV_SUBSTRATE_SLOT_PORT = 8080;
77
+ /**
78
+ * Physical-name prefix pinning every substrate resource inside the `fjall-dev-*`
79
+ * fence. Load-bearing: the dev-tier IAM grants (`@fjall/generator devRolePolicies`)
80
+ * scope cluster / secret / repo access to this exact prefix, so a drift here
81
+ * AccessDenies slot ops — single-sourced so the cluster identifier, the master
82
+ * secret, the ECS cluster name and the slot ECR repo cannot diverge.
83
+ */
84
+ const devFenceName = (appKebab) => `fjall-dev-${appKebab}`;
85
+ /**
86
+ * Compile-time parity guard (design T1 / C2-4). `IDevSubstrateProps` — the
87
+ * `addDevSubstrate` param — and `DevSubstrateSynthProps` — the versioned params
88
+ * schema the webapp worker (producer) and the Fjall-owned synth entrypoint
89
+ * (consumer) both import from `@fjall/util` — MUST stay structurally identical, so
90
+ * a field added to one but not the other fails HERE as a TS error, not as a silent
91
+ * stale-params synth at deploy.
92
+ *
93
+ * Two guards, because neither alone is sufficient:
94
+ * - The bidirectional `extends` pair catches a REQUIRED field added to either
95
+ * side and any type drift on a shared field (widened/narrowed value type).
96
+ * - The key-set equality catches an OPTIONAL field added to one side only —
97
+ * which `extends` misses (an extra optional key stays mutually assignable),
98
+ * and which is the common case here since almost every prop is optional.
99
+ */
100
+ const _devSubstratePropsParity = [true, true];
101
+ const _devSubstratePropsKeyParity = true;
102
+ void _devSubstratePropsParity;
103
+ void _devSubstratePropsKeyParity;
104
+ /**
105
+ * Shared per-app substrate for ephemeral dev/preview slots (fast dev-envs
106
+ * Phase 3). Stands up the long-lived infrastructure a customer dev team's slots
107
+ * share — Aurora (A1), an S3→SQS drop (A2), an ECS cluster + slot SG (A2.5), an
108
+ * ALB with host-header routing (A3) fronted by a Lambda waker (A4), a delegated
109
+ * child zone (A5) and a wildcard cert (A6) — fenced to the development account.
110
+ *
111
+ * Instantiated ONLY via the internal `App.addDevSubstrate(props)`, which scopes
112
+ * it under the default compute stack so it actually deploys (design VF2). Every
113
+ * resource it creates carries `fjall:dev = "true"` (the IAM fence tag) and
114
+ * `fjall:app = <appId>` (the reaper tag) — including resources created through
115
+ * the app factories in sibling stacks, which are tagged explicitly since the
116
+ * `Tags.of(this)` aspect only reaches this construct's own scope subtree.
117
+ */
118
+ export class DevSubstrate extends Construct {
119
+ app;
120
+ appId;
121
+ constructor(scope, id, app, props) {
122
+ super(scope, id);
123
+ this.app = app;
124
+ this.appId = props.appId;
125
+ this.#tagFenced(this);
126
+ const database = this.#createDatabase(props);
127
+ this.#createEventDrop();
128
+ const { vpc, slotSecurityGroup, cluster, slotExecutionRole, slotRepository } = this.#createComputeNetwork(props, database);
129
+ const { loadBalancer, httpListener } = this.#createLoadBalancer(vpc, slotSecurityGroup);
130
+ const wakerTargetGroup = this.#createWaker(props);
131
+ const { devDomain, httpsListener } = this.#createDomain(props, loadBalancer);
132
+ this.#emitExports(database, {
133
+ vpcId: vpc.vpcId,
134
+ privateSubnetIds: vpc.privateSubnets.map((subnet) => subnet.subnetId),
135
+ slotSecurityGroupId: slotSecurityGroup.securityGroupId,
136
+ clusterName: cluster.clusterName,
137
+ // Slots attach their host-header rule to the frontmost listener — :443 once
138
+ // the cert exists (the G2 path), else the :80 fallback.
139
+ slotListenerArn: (httpsListener ?? httpListener).listenerArn,
140
+ albCloudWatchName: loadBalancer.loadBalancerFullName,
141
+ // getSnapshotTarget().arn is the only accessor the RelationalDatabase wrapper
142
+ // exposes for the cluster ARN; it returns databaseCluster.clusterArn.
143
+ dbClusterArn: database.getSnapshotTarget().arn,
144
+ dbSecretArn: database.getCredentials().secret.secretArn,
145
+ slotExecutionRoleArn: slotExecutionRole.roleArn,
146
+ slotRepositoryUri: slotRepository.repositoryUri,
147
+ wakerTargetGroupArn: wakerTargetGroup.targetGroupArn,
148
+ databaseMode: DEV_SUBSTRATE_DATABASE_MODE,
149
+ devDomain
150
+ });
151
+ }
152
+ #createDatabase(props) {
153
+ const database = this.app.addDatabase(DatabaseFactory.build(DEV_SUBSTRATE_DB_ID, {
154
+ type: "Aurora",
155
+ databaseName: DEV_SUBSTRATE_DATABASE_NAME,
156
+ clusterIdentifier: devFenceName(props.appKebab),
157
+ credentialsSecretName: `${devFenceName(props.appKebab)}-master`,
158
+ engineVersion: props.engineVersion ?? DEV_AURORA_DEFAULTS.ENGINE_VERSION,
159
+ serverlessV2MinCapacity: DEV_AURORA_DEFAULTS.SERVERLESS_V2_MIN_CAPACITY,
160
+ serverlessV2MaxCapacity: DEV_AURORA_DEFAULTS.SERVERLESS_V2_MAX_CAPACITY,
161
+ serverlessV2AutoPauseDuration: Duration.seconds(DEV_AURORA_DEFAULTS.AUTO_PAUSE_SECONDS),
162
+ enableDataApi: DEV_AURORA_DEFAULTS.ENABLE_DATA_API
163
+ }));
164
+ this.#tagFenced(database);
165
+ return database;
166
+ }
167
+ #createEventDrop() {
168
+ // Bucket and queue MUST share one stack. The OBJECT_CREATED notification and
169
+ // its auto-generated SQS queue policy reference each other's ARN, so leaving
170
+ // the queue on the default Messaging stack synthesises a fatal cross-stack
171
+ // cycle — both are pinned to the compute stack via stackPlacement.
172
+ const dropBucket = this.app.addStorage(StorageFactory.build(DEV_SUBSTRATE_DROP_ID, {
173
+ stackPlacement: "compute",
174
+ versioned: true,
175
+ encryption: "AES256"
176
+ }));
177
+ this.#tagFenced(dropBucket);
178
+ const eventQueue = this.app.addMessaging(MessagingFactory.build(DEV_SUBSTRATE_EVENTS_ID, {
179
+ type: "queue"
180
+ }), { stackPlacement: "compute" });
181
+ this.#tagFenced(eventQueue);
182
+ dropBucket.addObjectCreatedNotification(new SqsDestination(eventQueue.getQueue()));
183
+ }
184
+ #createComputeNetwork(props, database) {
185
+ const vpc = this.app.getVpc();
186
+ // Every resource below is scoped under `this`, so the Tags.of(this) fence
187
+ // auto-propagates — unlike the factory-created DB/S3/SQS, which land in sibling
188
+ // stacks and must be tagged explicitly. Names are pinned inside the fjall-dev-*
189
+ // fence so the scoped slot-ops role's IAM grants reach them.
190
+ const cluster = new Cluster(this, DEV_SUBSTRATE_CLUSTER_ID, {
191
+ vpc,
192
+ clusterName: devFenceName(props.appKebab)
193
+ });
194
+ const slotSecurityGroup = new SecurityGroup(this, DEV_SUBSTRATE_SLOT_SG_ID, {
195
+ vpc,
196
+ allowAllOutbound: true,
197
+ description: "Dev substrate slot tasks"
198
+ });
199
+ // createBaseExecutionRole DELIBERATELY omits logs (auto-granted only on the
200
+ // ECS-pattern path via AwsLogDriver.bind()). Slot task-defs reference this
201
+ // shared role by ARN, so that auto-grant never fires — add the log-write grant
202
+ // explicitly.
203
+ const slotExecutionRole = createBaseExecutionRole(this, DEV_SUBSTRATE_SLOT_EXEC_ROLE_ID);
204
+ const { partition, region, account } = Stack.of(this);
205
+ slotExecutionRole.addToPolicy(new PolicyStatement({
206
+ effect: Effect.ALLOW,
207
+ actions: ["logs:CreateLogStream", "logs:PutLogEvents"],
208
+ resources: [
209
+ `arn:${partition}:logs:${region}:${account}:log-group:/fjall/dev/${props.appKebab}/slots/*:*`
210
+ ]
211
+ }));
212
+ // Slot-SG → Aurora ingress. Dormant in Data-API mode (Data API reaches the
213
+ // cluster over HTTPS, not the VPC) — live only in the deferred
214
+ // point-at-existing pooled mode (GAP-7). The rule lands in the Database stack
215
+ // referencing the compute-stack slot SG (Database → Compute, one-directional),
216
+ // so it adds no cross-stack cycle.
217
+ database.connections.allowDefaultPortFrom(slotSecurityGroup, "Dev substrate slot tasks");
218
+ // The Ecr wrapper force-emits a `${id}RepositoryName` CFN export, and export
219
+ // names are account+region-global — so the id MUST be app-scoped or two
220
+ // substrates in one dev account collide at deploy. Matches
221
+ // getDefaultContainerRegistry's `${stackPrefix}Ecr`.
222
+ const slotEcrId = `${toPascalCase(this.app.getName())}${DEV_SUBSTRATE_SLOT_ECR_ID}`;
223
+ const slotEcrRepositoryName = `${devFenceName(props.appKebab)}-slots`;
224
+ // Adopt-mode is an import only — no CFN resource, so it carries no fence tags.
225
+ const slotRepository = props.adoptSlotEcr === true
226
+ ? Repository.fromRepositoryName(this, slotEcrId, slotEcrRepositoryName)
227
+ : new Ecr(this, slotEcrId, { repositoryName: slotEcrRepositoryName });
228
+ return {
229
+ vpc,
230
+ slotSecurityGroup,
231
+ cluster,
232
+ slotExecutionRole,
233
+ slotRepository
234
+ };
235
+ }
236
+ #createLoadBalancer(vpc, slotSecurityGroup) {
237
+ const albSecurityGroup = new SecurityGroup(this, DEV_SUBSTRATE_ALB_SG_ID, {
238
+ vpc,
239
+ allowAllOutbound: true,
240
+ description: "Dev substrate ALB"
241
+ });
242
+ // No loadBalancerName: the dev-tier slot-ops ELB grants are account-wide
243
+ // (`elasticloadbalancing:*:<acct>:*`), not name-prefixed, so the ALB needs no
244
+ // fence-pinned name — the reaper finds it by the subtree fence tags. A pinned
245
+ // name would only reintroduce the 32-char ALB-name truncation dance.
246
+ const loadBalancer = createApplicationLoadBalancer(this, DEV_SUBSTRATE_ALB_ID, { vpc, internetFacing: true, securityGroup: albSecurityGroup });
247
+ slotSecurityGroup.connections.allowFrom(albSecurityGroup, Port.tcp(DEV_SUBSTRATE_SLOT_PORT), "Dev substrate ALB → slot tasks");
248
+ // Unconditional 404 default — the substrate's only routes are per-slot
249
+ // host-header rules, so an unmatched host must 404, never fall through (the ECS
250
+ // path's 404 is conditional on >=2 routes). The :443 listener + wildcard cert
251
+ // is phase "full" (A6); open:true opens the custom ALB SG on :80.
252
+ const httpListener = addRoutingListener(loadBalancer, DEV_SUBSTRATE_HTTP_LISTENER_ID, { port: 80, default404: true });
253
+ return { loadBalancer, httpListener };
254
+ }
255
+ #createWaker(props) {
256
+ const wakerSecretParam = `/fjall/dev/${props.appKebab}/waker/secret`;
257
+ const source = readFileSync(path.resolve(__dirname, DEV_SUBSTRATE_WAKER_SOURCE_FILE), "utf-8");
258
+ const { partition, region, account } = Stack.of(this);
259
+ // NOT VPC-attached (no `vpc` prop): the waker resumes slots via the AWS
260
+ // control plane (ECS / Aurora / SSM), never over the VPC, and the ALB invokes
261
+ // it through the Lambda API — not the network path.
262
+ const waker = new LambdaFunction(this, DEV_SUBSTRATE_WAKER_ID, {
263
+ runtime: Runtime.NODEJS_22_X,
264
+ handler: "index.handler",
265
+ code: Code.fromInline(source),
266
+ lambdaDescription: "Dev substrate slot waker",
267
+ roleDescription: "Execution role for the dev substrate slot waker",
268
+ timeout: DEV_SUBSTRATE_WAKER_TIMEOUT_SECONDS,
269
+ environment: { WAKER_SECRET_PARAM: wakerSecretParam },
270
+ inlinePolicy: [
271
+ new PolicyStatement({
272
+ effect: Effect.ALLOW,
273
+ actions: ["ssm:GetParameter"],
274
+ resources: [
275
+ `arn:${partition}:ssm:${region}:${account}:parameter${wakerSecretParam}`
276
+ ]
277
+ }),
278
+ // Default-key SecureString: scope kms:Decrypt by ViaService, NOT by a CMK
279
+ // alias — the AWS-managed alias/aws/ssm never matches a per-app alias, so
280
+ // an alias-scoped grant would AccessDeny the decrypt (design VF4).
281
+ new PolicyStatement({
282
+ effect: Effect.ALLOW,
283
+ actions: ["kms:Decrypt"],
284
+ resources: ["*"],
285
+ conditions: {
286
+ StringEquals: { "kms:ViaService": `ssm.${region}.amazonaws.com` }
287
+ }
288
+ })
289
+ ]
290
+ });
291
+ // Standalone TG, NOT attached to the :80 listener — slot-ops wire it in per
292
+ // sleeping slot via the exported ARN (A7/§5.4), so the listener keeps its
293
+ // unmatched-host 404. Rationale + the auto ELB-invoke grant: lambdaAlbTarget.ts.
294
+ return createLambdaTargetGroup(this, DEV_SUBSTRATE_WAKER_TG_ID, {
295
+ handler: waker
296
+ });
297
+ }
298
+ #createDomain(props, loadBalancer) {
299
+ if (props.domain === undefined)
300
+ return {};
301
+ const { appDomain, parentDelegationRoleArn } = props.domain;
302
+ const childZoneName = `dev.${appDomain}`;
303
+ // The delegated child zone: createDelegationRole:false because the PARENT
304
+ // account owns the delegation role (this is the child). The HostedZone
305
+ // wrapper's create path auto-emits the `<zone>-nameservers` CFN export the
306
+ // parent consumes to point NS records at us — no manual export needed.
307
+ const childZone = new HostedZone(this, DEV_SUBSTRATE_ZONE_ID, {
308
+ zoneName: childZoneName,
309
+ createDelegationRole: false
310
+ });
311
+ // Case-1 same-org delegation fires only when a parent role ARN is supplied.
312
+ // The in-stack custom resource assumes the parent account's DelegationRole by
313
+ // LITERAL ARN (Role.fromRoleArn) — NOT Fn.importValue, which resolves
314
+ // same-account only and is exactly why the DomainDelegation pattern cannot go
315
+ // cross-account (design VD2-a). The parent DelegationRole trusts the whole
316
+ // org (OrganizationPrincipal), so a sibling dev account can assume it. Absent
317
+ // ARN ⇒ Case 2 (worker UPSERT, B3b) / Case 3 (manual), handled out of band:
318
+ // the zone + NS export above is all the substrate emits.
319
+ if (parentDelegationRoleArn !== undefined) {
320
+ const parentRole = Role.fromRoleArn(this, DEV_SUBSTRATE_PARENT_ROLE_ID, parentDelegationRoleArn);
321
+ new CrossAccountDelegationRecord(this, DEV_SUBSTRATE_DELEGATION_ID, {
322
+ delegationRole: parentRole,
323
+ delegatedZone: childZone.hostedZone,
324
+ delegatedZoneName: childZoneName,
325
+ parentHostedZoneName: appDomain
326
+ });
327
+ }
328
+ // Step 2 of the R2 two-step gate: synthesising the cert before the step-1 NS
329
+ // has propagated hangs ACM DNS-validation for hours, so "zone" stops here.
330
+ if ((props.phase ?? DEV_SUBSTRATE_DEFAULT_PHASE) !== "full")
331
+ return { devDomain: childZoneName };
332
+ // exportCertificateArn:false — consumed in-stack by the :443 listener (slots
333
+ // reference the exported LISTENER arn, not the cert), and the wildcard `*.`
334
+ // would otherwise render an invalid CFN export name.
335
+ const certificate = new DomainCertificate(this, DEV_SUBSTRATE_CERT_ID, {
336
+ domainName: `*.${childZoneName}`,
337
+ hostedZone: childZone.hostedZone,
338
+ exportCertificateArn: false
339
+ });
340
+ // Unconditional 404 as on :80 (per-slot rules attach out of band, §5.4). open
341
+ // (CDK default) adds the :443 ingress to the ALB SG.
342
+ const httpsListener = addRoutingListener(loadBalancer, DEV_SUBSTRATE_HTTPS_LISTENER_ID, { port: 443, certificate: certificate.certificate, default404: true });
343
+ return { devDomain: childZoneName, httpsListener };
344
+ }
345
+ /**
346
+ * Emit the substrate's cross-stack exports under a `FjallDev<App>DevSubstrate*`
347
+ * prefix. The scoped slot-ops role has no `ec2:Describe*` / discovery reach, so
348
+ * the hand-created G2 slot service consumes these to place tasks, register a
349
+ * host-header rule, inject DB creds and point a sleeping slot at the waker.
350
+ *
351
+ * No KMS-alias export: the substrate's Aurora + waker use the AWS-managed default
352
+ * key (design VF4 — the waker's kms:Decrypt is ViaService-scoped precisely
353
+ * because there is no per-app CMK), so there is no alias to export.
354
+ *
355
+ * The Aurora slot-SG ingress (A2.5) makes the Database stack depend on the
356
+ * Compute stack. So the two DB exports are scoped under `databaseScope` (the
357
+ * Database stack) — a Compute-stack output referencing a Database-stack ARN
358
+ * would invert that edge into a synth-fatal cycle. Every other export is a
359
+ * Compute-stack (or Compute→Network) same-direction ref and rides `this`.
360
+ */
361
+ #emitExports(databaseScope, values) {
362
+ const prefix = `${toPascalCase(this.app.getName())}DevSubstrate`;
363
+ const emit = (scope, key, value) => {
364
+ new CfnOutput(scope, `${prefix}${key}`, {
365
+ value,
366
+ exportName: `${prefix}${key}`
367
+ });
368
+ };
369
+ emit(this, "VpcId", values.vpcId);
370
+ // CFN exports are scalar — comma-join the subnet ids; the consumer splits.
371
+ emit(this, "PrivateSubnetIds", values.privateSubnetIds.join(","));
372
+ emit(this, "SlotSecurityGroupId", values.slotSecurityGroupId);
373
+ emit(this, "ClusterName", values.clusterName);
374
+ emit(this, "SlotListenerArn", values.slotListenerArn);
375
+ emit(this, "AlbCloudWatchName", values.albCloudWatchName);
376
+ emit(databaseScope, "DbClusterArn", values.dbClusterArn);
377
+ emit(databaseScope, "DbSecretArn", values.dbSecretArn);
378
+ emit(this, "SlotExecutionRoleArn", values.slotExecutionRoleArn);
379
+ emit(this, "SlotRepositoryUri", values.slotRepositoryUri);
380
+ emit(this, "WakerTargetGroupArn", values.wakerTargetGroupArn);
381
+ emit(this, "DatabaseMode", values.databaseMode);
382
+ if (values.devDomain !== undefined) {
383
+ emit(this, "DevDomain", values.devDomain);
384
+ }
385
+ }
386
+ #tagFenced(scope) {
387
+ Tags.of(scope).add(DEV_TAG_KEY, DEV_TAG_VALUE);
388
+ Tags.of(scope).add(DEV_APP_TAG_KEY, this.appId);
389
+ }
390
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+
3
+ // Dev-substrate slot waker, invoked as an ALB Lambda target. Milestone
4
+ // placeholder — the real wake-token check + ECS/Aurora resume land in §5.4.
5
+ // ALB Lambda-target responses MUST carry all five fields; a missing
6
+ // statusDescription or isBase64Encoded 502s at the ALB.
7
+ exports.handler = async () => ({
8
+ statusCode: 200,
9
+ statusDescription: "200 OK",
10
+ isBase64Encoded: false,
11
+ headers: { "content-type": "text/plain; charset=utf-8" },
12
+ body: "Slot is warming up. Refresh in a moment."
13
+ });
@@ -0,0 +1,17 @@
1
+ import { ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ import { type ISecurityGroup, type IVpc } from "aws-cdk-lib/aws-ec2";
3
+ import type { Construct } from "constructs";
4
+ export interface ApplicationLoadBalancerOptions {
5
+ readonly vpc: IVpc;
6
+ /** Public (internet-facing, PUBLIC subnets) vs internal (PRIVATE_WITH_EGRESS). */
7
+ readonly internetFacing: boolean;
8
+ readonly loadBalancerName?: string;
9
+ readonly securityGroup?: ISecurityGroup;
10
+ }
11
+ /**
12
+ * Context-free Application Load Balancer factory shared by the ECS cluster path
13
+ * and the dev substrate. Subnet placement is derived from `internetFacing`
14
+ * (PUBLIC when internet-facing, PRIVATE_WITH_EGRESS when internal) — matching the
15
+ * ECS path's existing derivation so the extraction is byte-identical.
16
+ */
17
+ export declare function createApplicationLoadBalancer(scope: Construct, id: string, options: ApplicationLoadBalancerOptions): ApplicationLoadBalancer;
@@ -0,0 +1,23 @@
1
+ import { ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ import { SubnetType } from "aws-cdk-lib/aws-ec2";
3
+ /**
4
+ * Context-free Application Load Balancer factory shared by the ECS cluster path
5
+ * and the dev substrate. Subnet placement is derived from `internetFacing`
6
+ * (PUBLIC when internet-facing, PRIVATE_WITH_EGRESS when internal) — matching the
7
+ * ECS path's existing derivation so the extraction is byte-identical.
8
+ */
9
+ export function createApplicationLoadBalancer(scope, id, options) {
10
+ return new ApplicationLoadBalancer(scope, id, {
11
+ vpc: options.vpc,
12
+ internetFacing: options.internetFacing,
13
+ ...(options.securityGroup && { securityGroup: options.securityGroup }),
14
+ ...(options.loadBalancerName && {
15
+ loadBalancerName: options.loadBalancerName
16
+ }),
17
+ vpcSubnets: {
18
+ subnetType: options.internetFacing
19
+ ? SubnetType.PUBLIC
20
+ : SubnetType.PRIVATE_WITH_EGRESS
21
+ }
22
+ });
23
+ }
@@ -1,4 +1,4 @@
1
- import { type ApplicationListener, ApplicationLoadBalancer, type IApplicationTargetGroup, ListenerCondition } from "aws-cdk-lib/aws-elasticloadbalancingv2";
1
+ import { type ApplicationListener, type ApplicationLoadBalancer, type IApplicationTargetGroup } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
2
  import { type ISecurityGroup } from "aws-cdk-lib/aws-ec2";
3
3
  import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
4
4
  import { ARecord, type IHostedZone } from "aws-cdk-lib/aws-route53";
@@ -6,15 +6,9 @@ import type { AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
6
6
  import type { ContainerDefinition, FargateService, Ec2Service } from "aws-cdk-lib/aws-ecs";
7
7
  import { SecurityGroup } from "../networking/securityGroup.js";
8
8
  import type { EcsConstructContext } from "./ecsContext.js";
9
- import type { EcsRoutingConfig, EcsServiceProps } from "./ecsTypes.js";
10
- /** Mutable priority state for ALB rule ordering. */
11
- export interface PriorityState {
12
- nextPriority: number;
13
- usedPriorities: Set<number>;
14
- }
15
- /** Returns the next unused auto-incremented ALB priority, skipping any manually assigned values. */
16
- export declare function getNextPriority(state: PriorityState): number;
17
- export declare function buildRoutingConditions(rule: EcsRoutingConfig | undefined): ListenerCondition[];
9
+ import type { EcsServiceProps } from "./ecsTypes.js";
10
+ import { type PriorityState } from "./hostHeaderListenerRule.js";
11
+ export type { PriorityState } from "./hostHeaderListenerRule.js";
18
12
  export declare function addLoadBalancer(ctx: EcsConstructContext, anyServiceUsesEc2: boolean, asgSecurityGroup?: ISecurityGroup): {
19
13
  loadBalancer: ApplicationLoadBalancer;
20
14
  loadBalancerSecurityGroup?: SecurityGroup;
@@ -1,5 +1,5 @@
1
- import { ApplicationLoadBalancer, ApplicationProtocol, ListenerAction, ListenerCondition } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
- import { Port, SubnetType } from "aws-cdk-lib/aws-ec2";
1
+ import { ApplicationProtocol, ListenerAction } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ import { Port } from "aws-cdk-lib/aws-ec2";
3
3
  import { CfnOutput, Duration, Fn } from "aws-cdk-lib";
4
4
  import { Certificate, CertificateValidation } from "aws-cdk-lib/aws-certificatemanager";
5
5
  import { ARecord, HostedZone as AWSHostedZone, RecordTarget } from "aws-cdk-lib/aws-route53";
@@ -7,25 +7,9 @@ import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
7
7
  import { HostedZone as FjallHostedZone } from "../networking/hostedZone.js";
8
8
  import { SecurityGroup } from "../networking/securityGroup.js";
9
9
  import { isServiceEc2 } from "./ecsTaskDefinition.js";
10
- /** Returns the next unused auto-incremented ALB priority, skipping any manually assigned values. */
11
- export function getNextPriority(state) {
12
- while (state.usedPriorities.has(state.nextPriority)) {
13
- state.nextPriority++;
14
- }
15
- const priority = state.nextPriority++;
16
- state.usedPriorities.add(priority);
17
- return priority;
18
- }
19
- export function buildRoutingConditions(rule) {
20
- const conditions = [];
21
- if (rule?.path) {
22
- conditions.push(ListenerCondition.pathPatterns([rule.path]));
23
- }
24
- if (rule?.host) {
25
- conditions.push(ListenerCondition.hostHeaders([rule.host]));
26
- }
27
- return conditions;
28
- }
10
+ import { createApplicationLoadBalancer } from "./applicationLoadBalancer.js";
11
+ import { addRoutingListener } from "./listenerRouting.js";
12
+ import { buildRoutingConditions, getNextPriority } from "./hostHeaderListenerRule.js";
29
13
  export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
30
14
  const props = ctx.props;
31
15
  const defaultLoadBalancerName = `${props.clusterName}LoadBalancer`;
@@ -48,18 +32,13 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
48
32
  asgSecurityGroup.connections.allowFrom(loadBalancerSecurityGroup, Port.tcpRange(49152, 65535));
49
33
  }
50
34
  }
51
- const loadBalancer = new ApplicationLoadBalancer(ctx.scope, `${props.clusterName}LoadBalancer`, {
35
+ const loadBalancer = createApplicationLoadBalancer(ctx.scope, `${props.clusterName}LoadBalancer`, {
52
36
  vpc: ctx.cluster.vpc,
53
37
  internetFacing: !isInternal,
38
+ loadBalancerName: truncatedLoadBalancerName,
54
39
  ...(loadBalancerSecurityGroup && {
55
40
  securityGroup: loadBalancerSecurityGroup
56
- }),
57
- loadBalancerName: truncatedLoadBalancerName,
58
- vpcSubnets: {
59
- subnetType: isInternal
60
- ? SubnetType.PRIVATE_WITH_EGRESS
61
- : SubnetType.PUBLIC
62
- }
41
+ })
63
42
  });
64
43
  new CfnOutput(ctx.scope, `${ctx.outputName}LoadBalancerDnsName`, {
65
44
  key: `${ctx.outputName}LoadBalancerDnsName`,
@@ -112,25 +91,11 @@ export function addLoadBalancerListener(ctx, loadBalancer, certificate) {
112
91
  });
113
92
  // CDK rejects listeners with neither a default action nor target groups.
114
93
  const noServicePorts = servicesWithPorts.length === 0;
115
- const defaultAction = willHaveMultipleRoutes || noServicePorts
116
- ? ListenerAction.fixedResponse(404, {
117
- contentType: "text/plain",
118
- messageBody: "Not Found"
119
- })
120
- : undefined;
121
- if (certificate) {
122
- return loadBalancer.addListener(`${ctx.props.clusterName}Listener`, {
123
- port,
124
- certificates: [certificate],
125
- ...(defaultAction !== undefined && { defaultAction })
126
- });
127
- }
128
- else {
129
- return loadBalancer.addListener(`${ctx.props.clusterName}Listener`, {
130
- port,
131
- ...(defaultAction !== undefined && { defaultAction })
132
- });
133
- }
94
+ return addRoutingListener(loadBalancer, `${ctx.props.clusterName}Listener`, {
95
+ port,
96
+ ...(certificate && { certificate }),
97
+ default404: willHaveMultipleRoutes || noServicePorts
98
+ });
134
99
  }
135
100
  export function addHostedZone(ctx, loadBalancer) {
136
101
  const props = ctx.props;
@@ -1,10 +1,35 @@
1
1
  import { Role } from "aws-cdk-lib/aws-iam";
2
+ import type { Construct } from "constructs";
2
3
  import type { EcsConstructContext } from "./ecsContext.js";
3
4
  import type { EcsServiceProps } from "./ecsTypes.js";
5
+ /**
6
+ * Builds the base ECS task-execution role shared by every task the platform
7
+ * runs: the ECR-pull quartet (account-level, so `resources: ["*"]`) and a
8
+ * ViaService-conditioned `kms:Decrypt` for SSM SecureString / Secrets-Manager
9
+ * CMK env injection. Pass `ssmSecretsPath` (already derived — no partition/
10
+ * region/account prefix) to add the path-scoped `ssm:GetParameters` grant.
11
+ * Trust principal is `ecs-tasks.amazonaws.com`.
12
+ *
13
+ * Deliberately omits `logs:*` and `secretsmanager:GetSecretValue`: on the
14
+ * ECS-pattern path `AwsLogDriver.bind()` and `addContainer({ secrets })`
15
+ * auto-grant those on the exact LogGroup / secret ARNs. A caller that builds a
16
+ * SHARED or IMPORTED role those auto-grants cannot reach — e.g. the dev
17
+ * substrate's slot exec role, referenced by slot task-defs via ARN — MUST add
18
+ * the explicit `logs` / `secretsmanager` statements itself.
19
+ */
20
+ export declare function createBaseExecutionRole(scope: Construct, id: string, opts?: {
21
+ ssmSecretsPath?: string;
22
+ }): Role;
4
23
  /**
5
24
  * Creates the execution role for ECS infrastructure operations.
6
25
  * Used by the ECS agent to pull images, write logs, and inject secrets.
7
26
  * NOT used by application code — that's the task role.
27
+ *
28
+ * Delegates the ECR/kms/ssm shape to `createBaseExecutionRole`. Logs +
29
+ * secretsmanager are auto-granted downstream on this path (AwsLogDriver.bind()
30
+ * on the real LogGroup, addContainer({ secrets }) on the exact secret ARN), so a
31
+ * hand-rolled `/ecs/{cluster}*` logs or bare `-*` secrets statement here would be
32
+ * dead (the 2026-06-04 outage shape).
8
33
  */
9
34
  export declare function createExecutionRole(ctx: EcsConstructContext, serviceName: string): Role;
10
35
  /**