@fjall/components-infrastructure 2.30.3 → 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 (118) hide show
  1. package/dist/lib/app.d.ts +27 -1
  2. package/dist/lib/app.js +35 -4
  3. package/dist/lib/config/aws/identityCenterMembership.d.ts +11 -0
  4. package/dist/lib/config/aws/identityCenterMembership.js +61 -0
  5. package/dist/lib/config/aws/oidcConnector.js +11 -3
  6. package/dist/lib/lambda-assets/cert-generator/asset/index.js +12 -3
  7. package/dist/lib/patterns/aws/compute.d.ts +2 -2
  8. package/dist/lib/patterns/aws/compute.js +18 -9
  9. package/dist/lib/patterns/aws/computeLambda.d.ts +15 -1
  10. package/dist/lib/patterns/aws/computeLambda.js +18 -2
  11. package/dist/lib/patterns/aws/computePropApplicability.d.ts +20 -0
  12. package/dist/lib/patterns/aws/computePropApplicability.js +87 -0
  13. package/dist/lib/patterns/aws/database.d.ts +19 -1
  14. package/dist/lib/patterns/aws/database.js +22 -3
  15. package/dist/lib/patterns/aws/devSubstrate.d.ts +85 -0
  16. package/dist/lib/patterns/aws/devSubstrate.js +390 -0
  17. package/dist/lib/patterns/aws/devSubstrate.waker.source.cjs +13 -0
  18. package/dist/lib/resources/aws/compute/applicationLoadBalancer.d.ts +17 -0
  19. package/dist/lib/resources/aws/compute/applicationLoadBalancer.js +23 -0
  20. package/dist/lib/resources/aws/compute/ecsImages.js +7 -2
  21. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +4 -10
  22. package/dist/lib/resources/aws/compute/ecsNetworking.js +13 -48
  23. package/dist/lib/resources/aws/compute/ecsRoles.d.ts +25 -0
  24. package/dist/lib/resources/aws/compute/ecsRoles.js +39 -20
  25. package/dist/lib/resources/aws/compute/hostHeaderListenerRule.d.ts +23 -0
  26. package/dist/lib/resources/aws/compute/hostHeaderListenerRule.js +59 -0
  27. package/dist/lib/resources/aws/compute/imageTagParameter.d.ts +18 -1
  28. package/dist/lib/resources/aws/compute/imageTagParameter.js +32 -2
  29. package/dist/lib/resources/aws/compute/lambda.d.ts +1 -1
  30. package/dist/lib/resources/aws/compute/lambda.js +6 -4
  31. package/dist/lib/resources/aws/compute/lambdaAlbTarget.d.ts +17 -0
  32. package/dist/lib/resources/aws/compute/lambdaAlbTarget.js +18 -0
  33. package/dist/lib/resources/aws/compute/listenerRouting.d.ts +21 -0
  34. package/dist/lib/resources/aws/compute/listenerRouting.js +20 -0
  35. package/dist/lib/resources/aws/database/rdsAurora.d.ts +16 -0
  36. package/dist/lib/resources/aws/database/rdsAurora.js +57 -3
  37. package/dist/lib/resources/aws/database/rdsDefaults.d.ts +28 -0
  38. package/dist/lib/resources/aws/database/rdsDefaults.js +28 -0
  39. package/dist/lib/resources/aws/iam/identityCenter/user.js +5 -1
  40. package/dist/lib/resources/aws/logging/index.d.ts +1 -0
  41. package/dist/lib/resources/aws/logging/index.js +1 -0
  42. package/dist/lib/resources/aws/logging/logGroup.d.ts +7 -0
  43. package/dist/lib/resources/aws/logging/logGroup.js +7 -0
  44. package/dist/lib/resources/aws/logging/singletonLogGroup.d.ts +15 -0
  45. package/dist/lib/resources/aws/logging/singletonLogGroup.js +24 -0
  46. package/dist/lib/resources/aws/networking/vpcPeeringConnection.js +2 -1
  47. package/dist/lib/resources/aws/storage/bucketDeployment.d.ts +5 -6
  48. package/dist/lib/resources/aws/storage/bucketDeployment.js +14 -7
  49. package/dist/lib/resources/aws/utilities/awsCustomResource.js +5 -0
  50. package/dist/lib/resources/aws/utilities/customResource.js +5 -1
  51. package/dist/lib/utils/addSuffixToEmail.js +1 -5
  52. package/dist/lib/utils/devSubstrateTags.d.ts +22 -0
  53. package/dist/lib/utils/devSubstrateTags.js +22 -0
  54. package/dist/lib/utils/validationLogger.d.ts +2 -2
  55. package/dist/lib/utils/validationLogger.js +4 -4
  56. package/package.json +5 -5
  57. package/dist/lib/__tests__/setup.d.ts +0 -48
  58. package/dist/lib/__tests__/setup.js +0 -1
  59. package/dist/lib/config/aws/accountId.d.ts +0 -6
  60. package/dist/lib/config/aws/accountId.js +0 -33
  61. package/dist/lib/config/aws/backupGlobalSettings.d.ts +0 -29
  62. package/dist/lib/config/aws/backupGlobalSettings.js +0 -51
  63. package/dist/lib/config/aws/costAllocationTags.d.ts +0 -12
  64. package/dist/lib/config/aws/costAllocationTags.js +0 -48
  65. package/dist/lib/config/aws/eventBus.d.ts +0 -7
  66. package/dist/lib/config/aws/eventBus.js +0 -24
  67. package/dist/lib/config/aws/identityCenterGroupMembership.d.ts +0 -10
  68. package/dist/lib/config/aws/identityCenterGroupMembership.js +0 -115
  69. package/dist/lib/config/aws/ipamDelegateAdmin.d.ts +0 -8
  70. package/dist/lib/config/aws/ipamDelegateAdmin.js +0 -58
  71. package/dist/lib/config/aws/ipamPoolId.d.ts +0 -10
  72. package/dist/lib/config/aws/ipamPoolId.js +0 -39
  73. package/dist/lib/config/aws/organisation.d.ts +0 -30
  74. package/dist/lib/config/aws/organisation.js +0 -95
  75. package/dist/lib/config/aws/organisationId.d.ts +0 -7
  76. package/dist/lib/config/aws/organisationId.js +0 -46
  77. package/dist/lib/config/aws/organisationsAccess.d.ts +0 -10
  78. package/dist/lib/config/aws/organisationsAccess.js +0 -50
  79. package/dist/lib/config/aws/ramSharing.d.ts +0 -4
  80. package/dist/lib/config/aws/ramSharing.js +0 -35
  81. package/dist/lib/config/monitoring.d.ts +0 -18
  82. package/dist/lib/config/monitoring.js +0 -22
  83. package/dist/lib/patterns/aws/cicdRole.d.ts +0 -67
  84. package/dist/lib/patterns/aws/cicdRole.js +0 -68
  85. package/dist/lib/patterns/aws/hostedZone.d.ts +0 -28
  86. package/dist/lib/patterns/aws/hostedZone.js +0 -150
  87. package/dist/lib/patterns/aws/managedAccount.d.ts +0 -9
  88. package/dist/lib/patterns/aws/managedAccount.js +0 -55
  89. package/dist/lib/patterns/aws/managedIdentityCenter.d.ts +0 -4
  90. package/dist/lib/patterns/aws/managedIdentityCenter.js +0 -24
  91. package/dist/lib/patterns/aws/managedOrganisation.d.ts +0 -36
  92. package/dist/lib/patterns/aws/managedOrganisation.js +0 -97
  93. package/dist/lib/patterns/aws/managedPlatform.d.ts +0 -12
  94. package/dist/lib/patterns/aws/managedPlatform.js +0 -29
  95. package/dist/lib/patterns/aws/subdomainHostedZone.d.ts +0 -9
  96. package/dist/lib/patterns/aws/subdomainHostedZone.js +0 -39
  97. package/dist/lib/resources/aws/cicd/cicdRole.d.ts +0 -65
  98. package/dist/lib/resources/aws/cicd/cicdRole.js +0 -191
  99. package/dist/lib/resources/aws/compute/ecsFreeTier.d.ts +0 -75
  100. package/dist/lib/resources/aws/compute/ecsFreeTier.js +0 -1
  101. package/dist/lib/resources/aws/compute/ecsSpot.d.ts +0 -75
  102. package/dist/lib/resources/aws/compute/ecsSpot.js +0 -1
  103. package/dist/lib/resources/aws/compute/utilities/capacityProviderDrainWaiter.d.ts +0 -20
  104. package/dist/lib/resources/aws/compute/utilities/capacityProviderDrainWaiter.js +0 -1
  105. package/dist/lib/resources/aws/database/database.d.ts +0 -14
  106. package/dist/lib/resources/aws/database/database.js +0 -28
  107. package/dist/lib/resources/aws/database/databaseInstance.d.ts +0 -15
  108. package/dist/lib/resources/aws/database/databaseInstance.js +0 -30
  109. package/dist/lib/resources/aws/iam/identityCenter/attachManagedPolicy.d.ts +0 -13
  110. package/dist/lib/resources/aws/iam/identityCenter/attachManagedPolicy.js +0 -52
  111. package/dist/lib/resources/aws/iam/securityGroup.d.ts +0 -5
  112. package/dist/lib/resources/aws/iam/securityGroup.js +0 -14
  113. package/dist/lib/resources/aws/monitoring/monitoringRole.d.ts +0 -28
  114. package/dist/lib/resources/aws/monitoring/monitoringRole.js +0 -125
  115. package/dist/lib/resources/aws/utilities/cfnOutput.d.ts +0 -5
  116. package/dist/lib/resources/aws/utilities/cfnOutput.js +0 -1
  117. package/dist/lib/utils/capitalizeString.d.ts +0 -1
  118. package/dist/lib/utils/capitalizeString.js +0 -7
@@ -0,0 +1,85 @@
1
+ import { Construct } from "constructs";
2
+ import type App from "../../app.js";
3
+ /**
4
+ * Props for the internal `DevSubstrate` construct.
5
+ *
6
+ * Deliberately standalone — NOT a member of the `IPatternProps` union and NOT
7
+ * registered in `PATTERN_TYPE_VALUES` / the pattern registry / `PatternFactory`.
8
+ * The substrate is Fjall-managed dev infrastructure, never offered on the
9
+ * customer create/scaffold surface (design R5). Increments A2–A7 extend this
10
+ * interface (S3→SQS, compute/network, domain, delegation, `phase`) as each lands.
11
+ */
12
+ export interface IDevSubstrateProps {
13
+ /**
14
+ * Real customer application identifier, surfaced as the `fjall:app` reaper
15
+ * tag. Distinct from the `fjall-dev-<app>` App identity that drives the
16
+ * substrate's stack names.
17
+ */
18
+ readonly appId: string;
19
+ /**
20
+ * Kebab-cased customer app name used to pin the substrate's physical resource
21
+ * names inside the `fjall-dev-*` fence (cluster identifier, master secret) so
22
+ * the dev-tier IAM grants match. Single-sourced with the `fjall-dev-<app>` App
23
+ * identity (design VERDICT-3).
24
+ */
25
+ readonly appKebab: string;
26
+ /**
27
+ * Aurora PostgreSQL engine version override (default `DEV_AURORA_DEFAULTS`).
28
+ * Must be `>= 16.3` — the shared cluster runs `serverlessV2MinCapacity` 0,
29
+ * which needs an auto-pause-capable engine (RdsAurora rejects lower at synth).
30
+ */
31
+ readonly engineVersion?: string;
32
+ /**
33
+ * When `true`, adopt an already-existing slot image ECR repository
34
+ * (`fjall-dev-<appKebab>-slots`) by reference instead of creating one — an
35
+ * import only, emitting no CloudFormation resource (and therefore untaggable).
36
+ * Defaults to `false`: create and fence-tag the repo. Set on a redeploy into an
37
+ * account where the repo was provisioned out-of-band.
38
+ */
39
+ readonly adoptSlotEcr?: boolean;
40
+ /**
41
+ * Deploy phase for the R2 two-step cert-hang guard. `"zone"` synthesises the
42
+ * `dev.<appDomain>` child zone + cross-account NS delegation only; `"full"`
43
+ * additionally builds the wildcard cert + :443 listener (A6) — deploy that
44
+ * only after the delegated NS has propagated, else ACM DNS-validation hangs.
45
+ * Defaults to `"full"`. Inert unless `domain` is set.
46
+ */
47
+ readonly phase?: "zone" | "full";
48
+ /**
49
+ * Public domain the substrate hangs a `dev.<appDomain>` child zone under. When
50
+ * omitted, the substrate stands up no zone/cert (early milestone — G2 reaches
51
+ * slots via `curl --resolve`).
52
+ *
53
+ * `parentDelegationRoleArn`, when present, wires the same-org **Case-1** in-stack
54
+ * NS delegation (design VD2-a): the child stack's `CrossAccountZoneDelegationRecord`
55
+ * assumes the parent account's `DelegationRole` by LITERAL ARN and UPSERTs the
56
+ * child NS into the parent zone. Omit it for a plain externally-owned parent
57
+ * (Case 3 — manual one-off delegation) or a connected different-org parent
58
+ * (Case 2 — worker UPSERT, B3b, deferred): the substrate then emits only the
59
+ * zone + its NS export for out-of-band delegation.
60
+ */
61
+ readonly domain?: {
62
+ readonly appDomain: string;
63
+ readonly parentDelegationRoleArn?: string;
64
+ };
65
+ }
66
+ /**
67
+ * Shared per-app substrate for ephemeral dev/preview slots (fast dev-envs
68
+ * Phase 3). Stands up the long-lived infrastructure a customer dev team's slots
69
+ * share — Aurora (A1), an S3→SQS drop (A2), an ECS cluster + slot SG (A2.5), an
70
+ * ALB with host-header routing (A3) fronted by a Lambda waker (A4), a delegated
71
+ * child zone (A5) and a wildcard cert (A6) — fenced to the development account.
72
+ *
73
+ * Instantiated ONLY via the internal `App.addDevSubstrate(props)`, which scopes
74
+ * it under the default compute stack so it actually deploys (design VF2). Every
75
+ * resource it creates carries `fjall:dev = "true"` (the IAM fence tag) and
76
+ * `fjall:app = <appId>` (the reaper tag) — including resources created through
77
+ * the app factories in sibling stacks, which are tagged explicitly since the
78
+ * `Tags.of(this)` aspect only reaches this construct's own scope subtree.
79
+ */
80
+ export declare class DevSubstrate extends Construct {
81
+ #private;
82
+ private readonly app;
83
+ private readonly appId;
84
+ constructor(scope: Construct, id: string, app: App, props: IDevSubstrateProps);
85
+ }
@@ -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
+ }
@@ -7,6 +7,11 @@ export function getContainerImage(ctx, serviceName, containerConfig, serviceProp
7
7
  if (!imageSource) {
8
8
  return ContainerImage.fromRegistry(DEFAULT_ECS_FALLBACK_IMAGE);
9
9
  }
10
+ const entity = {
11
+ kind: "ecs-service",
12
+ declaredBy: serviceName,
13
+ docker: serviceProps.docker
14
+ };
10
15
  if (typeof imageSource === "string") {
11
16
  const isFullRegistryUrl = /^(docker\.io|registry\.hub\.docker\.com|ghcr\.io)\//i.test(imageSource) ||
12
17
  imageSource.startsWith("public.ecr.aws/") ||
@@ -14,11 +19,11 @@ export function getContainerImage(ctx, serviceName, containerConfig, serviceProp
14
19
  if (isFullRegistryUrl) {
15
20
  return ContainerImage.fromRegistry(imageSource);
16
21
  }
17
- const param = getOrCreateImageTagParameter(ctx.scope, serviceName);
22
+ const param = getOrCreateImageTagParameter(ctx.scope, serviceName, entity);
18
23
  return ContainerImage.fromEcrRepository(Repository.fromRepositoryName(ctx.scope, `${serviceName}${containerConfig.name}EcrRepo`, imageSource), param.valueAsString);
19
24
  }
20
25
  if (imageSource instanceof Repository) {
21
- const param = getOrCreateImageTagParameter(ctx.scope, serviceName);
26
+ const param = getOrCreateImageTagParameter(ctx.scope, serviceName, entity);
22
27
  return ContainerImage.fromEcrRepository(imageSource, param.valueAsString);
23
28
  }
24
29
  return imageSource;
@@ -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;