@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
@@ -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
  /**
@@ -2,12 +2,22 @@ import { Stack } from "aws-cdk-lib";
2
2
  import { Effect, Policy, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
3
3
  import { deriveSsmSecretsPath } from "./ecsTaskDefinition.js";
4
4
  /**
5
- * Creates the execution role for ECS infrastructure operations.
6
- * Used by the ECS agent to pull images, write logs, and inject secrets.
7
- * NOT used by application code that's the task role.
5
+ * Builds the base ECS task-execution role shared by every task the platform
6
+ * runs: the ECR-pull quartet (account-level, so `resources: ["*"]`) and a
7
+ * ViaService-conditioned `kms:Decrypt` for SSM SecureString / Secrets-Manager
8
+ * CMK env injection. Pass `ssmSecretsPath` (already derived — no partition/
9
+ * region/account prefix) to add the path-scoped `ssm:GetParameters` grant.
10
+ * Trust principal is `ecs-tasks.amazonaws.com`.
11
+ *
12
+ * Deliberately omits `logs:*` and `secretsmanager:GetSecretValue`: on the
13
+ * ECS-pattern path `AwsLogDriver.bind()` and `addContainer({ secrets })`
14
+ * auto-grant those on the exact LogGroup / secret ARNs. A caller that builds a
15
+ * SHARED or IMPORTED role those auto-grants cannot reach — e.g. the dev
16
+ * substrate's slot exec role, referenced by slot task-defs via ARN — MUST add
17
+ * the explicit `logs` / `secretsmanager` statements itself.
8
18
  */
9
- export function createExecutionRole(ctx, serviceName) {
10
- const executionRole = new Role(ctx.scope, `${serviceName}ExecutionRole`, {
19
+ export function createBaseExecutionRole(scope, id, opts = {}) {
20
+ const executionRole = new Role(scope, id, {
11
21
  assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com")
12
22
  });
13
23
  // GetAuthorizationToken is an account-level API that requires resources: ["*"].
@@ -23,25 +33,13 @@ export function createExecutionRole(ctx, serviceName) {
23
33
  ],
24
34
  resources: ["*"]
25
35
  }));
26
- const { partition, region, account } = Stack.of(ctx.scope);
27
- // Gotcha: no manual logs grant here. The task definition's AwsLogDriver
28
- // binds an explicit LogGroup construct (CFN auto-named — there is NO
29
- // `/ecs/{cluster}/...` group; that shape is only the stream prefix), and
30
- // `AwsLogDriver.bind()` auto-grants CreateLogStream/PutLogEvents on the
31
- // real group's ARN. A hand-rolled `/ecs/{cluster}*` statement grants
32
- // access to log groups that do not exist.
33
- // Gotcha: do NOT add a manual `secretsImport` grant here. `addContainer({ secrets })`
34
- // auto-grants `secret.grantRead(executionRole)` on the resolved complete ARN (exact, no
35
- // wildcard). A manual bare/`-*` statement is the 2026-06-04 outage shape and is dead.
36
- const serviceProps = ctx.props.services.find((s) => s.name === serviceName);
37
- const hasSsmSecrets = serviceProps?.containers.some((container) => container.secrets && container.secrets.length > 0) ?? false;
38
- if (hasSsmSecrets && serviceProps) {
39
- const ssmPath = deriveSsmSecretsPath(ctx.props, serviceName, serviceProps.ssmSecretsPath);
36
+ const { partition, region, account } = Stack.of(scope);
37
+ if (opts.ssmSecretsPath !== undefined) {
40
38
  executionRole.addToPolicy(new PolicyStatement({
41
39
  effect: Effect.ALLOW,
42
40
  actions: ["ssm:GetParameters", "ssm:GetParameter"],
43
41
  resources: [
44
- `arn:${partition}:ssm:${region}:${account}:parameter${ssmPath}/*`
42
+ `arn:${partition}:ssm:${region}:${account}:parameter${opts.ssmSecretsPath}/*`
45
43
  ]
46
44
  }));
47
45
  }
@@ -61,6 +59,27 @@ export function createExecutionRole(ctx, serviceName) {
61
59
  }));
62
60
  return executionRole;
63
61
  }
62
+ /**
63
+ * Creates the execution role for ECS infrastructure operations.
64
+ * Used by the ECS agent to pull images, write logs, and inject secrets.
65
+ * NOT used by application code — that's the task role.
66
+ *
67
+ * Delegates the ECR/kms/ssm shape to `createBaseExecutionRole`. Logs +
68
+ * secretsmanager are auto-granted downstream on this path (AwsLogDriver.bind()
69
+ * on the real LogGroup, addContainer({ secrets }) on the exact secret ARN), so a
70
+ * hand-rolled `/ecs/{cluster}*` logs or bare `-*` secrets statement here would be
71
+ * dead (the 2026-06-04 outage shape).
72
+ */
73
+ export function createExecutionRole(ctx, serviceName) {
74
+ const serviceProps = ctx.props.services.find((s) => s.name === serviceName);
75
+ const hasSsmSecrets = serviceProps?.containers.some((container) => container.secrets && container.secrets.length > 0) ?? false;
76
+ const ssmSecretsPath = hasSsmSecrets && serviceProps
77
+ ? deriveSsmSecretsPath(ctx.props, serviceName, serviceProps.ssmSecretsPath)
78
+ : undefined;
79
+ return createBaseExecutionRole(ctx.scope, `${serviceName}ExecutionRole`, {
80
+ ssmSecretsPath
81
+ });
82
+ }
64
83
  /**
65
84
  * Creates the task role for application code running in the container.
66
85
  * This role is assumed by the application, not the ECS agent.
@@ -0,0 +1,23 @@
1
+ import { ListenerCondition } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ import type { EcsRoutingConfig } from "./ecsTypes.js";
3
+ /**
4
+ * Mutable ALB rule-priority state shared across every rule on one listener. The
5
+ * ECS cluster path and the dev substrate both thread a single instance so
6
+ * auto-incremented and host-hashed priorities never collide on the same listener.
7
+ */
8
+ export interface PriorityState {
9
+ nextPriority: number;
10
+ usedPriorities: Set<number>;
11
+ }
12
+ /** Returns the next unused auto-incremented ALB priority, skipping any manually assigned values. */
13
+ export declare function getNextPriority(state: PriorityState): number;
14
+ export declare function buildRoutingConditions(rule: EcsRoutingConfig | undefined): ListenerCondition[];
15
+ /**
16
+ * A deterministic, in-band ALB rule priority for a host-routed slot rule. Same
17
+ * host → same priority across synths. Collisions (two hosts hashing equal, a
18
+ * host re-registering, or a manually-claimed priority) are resolved by
19
+ * open-addressing linear probe with wraparound within the band, claimed against
20
+ * the shared `usedPriorities`. Throws only if the (40 000-slot) band is exhausted
21
+ * — unreachable in practice given AWS's per-listener rule ceiling.
22
+ */
23
+ export declare function deterministicHostPriority(host: string, state: PriorityState): number;
@@ -0,0 +1,59 @@
1
+ import { ListenerCondition } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ /** Returns the next unused auto-incremented ALB priority, skipping any manually assigned values. */
3
+ export function getNextPriority(state) {
4
+ while (state.usedPriorities.has(state.nextPriority)) {
5
+ state.nextPriority++;
6
+ }
7
+ const priority = state.nextPriority++;
8
+ state.usedPriorities.add(priority);
9
+ return priority;
10
+ }
11
+ export function buildRoutingConditions(rule) {
12
+ const conditions = [];
13
+ if (rule?.path) {
14
+ conditions.push(ListenerCondition.pathPatterns([rule.path]));
15
+ }
16
+ if (rule?.host) {
17
+ conditions.push(ListenerCondition.hostHeaders([rule.host]));
18
+ }
19
+ return conditions;
20
+ }
21
+ // ALB rule priorities are 1..50000 and unique per listener. Host-hashed slot
22
+ // rules occupy a band DISJOINT from the auto-increment counter (which starts at
23
+ // 100 and climbs — realistically a few hundred at most), so a deterministic slot
24
+ // priority can never collide with an ECS-service rule that shares the same
25
+ // PriorityState. Band [10000, 49999] sits above the counter and below the 50000 max.
26
+ const HOST_PRIORITY_BAND_START = 10_000;
27
+ const HOST_PRIORITY_BAND_SIZE = 40_000;
28
+ /**
29
+ * FNV-1a 32-bit string hash. Deterministic across processes and machines (no
30
+ * Math.random / Date), so the same host maps to the same base priority on every
31
+ * synth — stable rule ordering, no spurious CloudFormation diffs.
32
+ */
33
+ function fnv1a32(input) {
34
+ let hash = 0x811c9dc5;
35
+ for (let i = 0; i < input.length; i++) {
36
+ hash ^= input.charCodeAt(i);
37
+ hash = Math.imul(hash, 0x0100_0193);
38
+ }
39
+ return hash >>> 0;
40
+ }
41
+ /**
42
+ * A deterministic, in-band ALB rule priority for a host-routed slot rule. Same
43
+ * host → same priority across synths. Collisions (two hosts hashing equal, a
44
+ * host re-registering, or a manually-claimed priority) are resolved by
45
+ * open-addressing linear probe with wraparound within the band, claimed against
46
+ * the shared `usedPriorities`. Throws only if the (40 000-slot) band is exhausted
47
+ * — unreachable in practice given AWS's per-listener rule ceiling.
48
+ */
49
+ export function deterministicHostPriority(host, state) {
50
+ const base = fnv1a32(host) % HOST_PRIORITY_BAND_SIZE;
51
+ for (let probe = 0; probe < HOST_PRIORITY_BAND_SIZE; probe++) {
52
+ const priority = HOST_PRIORITY_BAND_START + ((base + probe) % HOST_PRIORITY_BAND_SIZE);
53
+ if (!state.usedPriorities.has(priority)) {
54
+ state.usedPriorities.add(priority);
55
+ return priority;
56
+ }
57
+ }
58
+ throw new Error(`Exhausted the host-priority band resolving a priority for "${host}"`);
59
+ }
@@ -1,5 +1,15 @@
1
1
  import { CfnParameter } from "aws-cdk-lib";
2
2
  import { type Construct } from "constructs";
3
+ import { type DockerBuild } from "@fjall/util/manifest/schemas";
4
+ /** Which key namespace is registering the parameter. */
5
+ export type ImageTagEntityKind = "ecs-service" | "lambda-image";
6
+ /** Identity of the entity requesting the parameter, for collision reporting. */
7
+ export interface ImageTagEntity {
8
+ readonly kind: ImageTagEntityKind;
9
+ /** ECS: the service name. Lambda: the function's construct id. */
10
+ readonly declaredBy: string;
11
+ readonly docker: DockerBuild | undefined;
12
+ }
3
13
  /**
4
14
  * Get or create the per-key image-tag CfnParameter (logical id from
5
15
  * `imageTagParameterName`). Idempotent by logical id: a second call with the
@@ -8,5 +18,12 @@ import { type Construct } from "constructs";
8
18
  * than once within a stack). Distinct keys that happen to collide on the same
9
19
  * PascalCase logical id fail closed rather than silently aliasing two
10
20
  * different things onto one parameter.
21
+ *
22
+ * Cross-entity reuse (an ECS service name and a Lambda `image` key sharing
23
+ * one build key) is allowed only when both declare fingerprint-identical
24
+ * docker configs — the deliberate shared-build case. Any divergence
25
+ * (including docker-vs-no-docker) throws at synth, mirroring deploy-core's
26
+ * `assertNoCrossEntityBuildKeyDivergence`: a shared parameter with divergent
27
+ * builds means a deploy of either entity silently rolls both onto one image.
11
28
  */
12
- export declare function getOrCreateImageTagParameter(scope: Construct, key: string): CfnParameter;
29
+ export declare function getOrCreateImageTagParameter(scope: Construct, key: string, entity: ImageTagEntity): CfnParameter;
@@ -1,8 +1,16 @@
1
1
  import { CfnParameter, Stack } from "aws-cdk-lib";
2
2
  import { imageTagParameterName } from "@fjall/util";
3
+ import { dockerBuildFingerprint } from "@fjall/util/manifest/schemas";
4
+ /** Per-parameter entity metadata; WeakMap keeps the registry per-stack and test-safe. */
5
+ const registrations = new WeakMap();
3
6
  function buildImageTagDescription(key) {
4
7
  return `Image tag for ${key}. Set by fjall deploy to the content-hash tag.`;
5
8
  }
9
+ function describeEntity(kind, declaredBy) {
10
+ return kind === "ecs-service"
11
+ ? `ECS service "${declaredBy}"`
12
+ : `Lambda function "${declaredBy}"`;
13
+ }
6
14
  /**
7
15
  * Get or create the per-key image-tag CfnParameter (logical id from
8
16
  * `imageTagParameterName`). Idempotent by logical id: a second call with the
@@ -11,8 +19,15 @@ function buildImageTagDescription(key) {
11
19
  * than once within a stack). Distinct keys that happen to collide on the same
12
20
  * PascalCase logical id fail closed rather than silently aliasing two
13
21
  * different things onto one parameter.
22
+ *
23
+ * Cross-entity reuse (an ECS service name and a Lambda `image` key sharing
24
+ * one build key) is allowed only when both declare fingerprint-identical
25
+ * docker configs — the deliberate shared-build case. Any divergence
26
+ * (including docker-vs-no-docker) throws at synth, mirroring deploy-core's
27
+ * `assertNoCrossEntityBuildKeyDivergence`: a shared parameter with divergent
28
+ * builds means a deploy of either entity silently rolls both onto one image.
14
29
  */
15
- export function getOrCreateImageTagParameter(scope, key) {
30
+ export function getOrCreateImageTagParameter(scope, key, entity) {
16
31
  const stack = Stack.of(scope);
17
32
  const paramLogicalId = imageTagParameterName(key);
18
33
  const description = buildImageTagDescription(key);
@@ -22,11 +37,26 @@ export function getOrCreateImageTagParameter(scope, key) {
22
37
  throw new Error(`CfnParameter logical-ID collision: "${paramLogicalId}" is already registered for a different service. ` +
23
38
  `Distinct service/lambda names cannot share a PascalCase identifier — rename one of the conflicting entries.`);
24
39
  }
40
+ const prior = registrations.get(existing);
41
+ const fingerprint = dockerBuildFingerprint(entity.docker);
42
+ if (prior !== undefined && prior.fingerprint !== fingerprint) {
43
+ throw new Error(`Image-tag parameter collision: ${describeEntity(prior.kind, prior.declaredBy)} and ` +
44
+ `${describeEntity(entity.kind, entity.declaredBy)} share the build key "${key}" ` +
45
+ `(CfnParameter "${paramLogicalId}") but declare different docker configs — ` +
46
+ `a deploy of either would silently roll both onto one image. Rename one of them, ` +
47
+ `or make the docker configs identical to deliberately share the build.`);
48
+ }
25
49
  return existing;
26
50
  }
27
- return new CfnParameter(stack, paramLogicalId, {
51
+ const param = new CfnParameter(stack, paramLogicalId, {
28
52
  type: "String",
29
53
  default: "latest",
30
54
  description
31
55
  });
56
+ registrations.set(param, {
57
+ kind: entity.kind,
58
+ declaredBy: entity.declaredBy,
59
+ fingerprint: dockerBuildFingerprint(entity.docker)
60
+ });
61
+ return param;
32
62
  }
@@ -2,7 +2,7 @@ import { SingletonFunction as singletonFunction, Function, Code, type Runtime, A
2
2
  import { type Bucket } from "aws-cdk-lib/aws-s3";
3
3
  import { PolicyStatement, type IRole } from "aws-cdk-lib/aws-iam";
4
4
  import { type IVpc } from "aws-cdk-lib/aws-ec2";
5
- import { RetentionDays } from "aws-cdk-lib/aws-logs";
5
+ import type { RetentionDays } from "aws-cdk-lib/aws-logs";
6
6
  import { type IQueue } from "aws-cdk-lib/aws-sqs";
7
7
  import { type ITable } from "aws-cdk-lib/aws-dynamodb";
8
8
  import { type Construct } from "constructs";
@@ -7,8 +7,7 @@ import { createHash } from "node:crypto";
7
7
  import { SqsEventSource, DynamoEventSource, S3EventSource } from "aws-cdk-lib/aws-lambda-event-sources";
8
8
  import { EventType } from "aws-cdk-lib/aws-s3";
9
9
  import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
10
- import { RetentionDays } from "aws-cdk-lib/aws-logs";
11
- import { LogGroup } from "../logging/logGroup.js";
10
+ import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
12
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
12
  import { buildParameterPath } from "@fjall/util";
14
13
  import { resolveImportedSecret } from "../secrets/index.js";
@@ -95,7 +94,10 @@ export class SingletonFunction extends singletonFunction {
95
94
  runtime: props.runtime,
96
95
  ephemeralStorageSize: props.ephemeralStorageSize
97
96
  ? Size.mebibytes(props.ephemeralStorageSize)
98
- : undefined
97
+ : undefined,
98
+ logGroup: new LogGroup(scope, `${id}LogGroup`, {
99
+ retention: props.logGroupRetention ?? DEFAULT_FRAMEWORK_LOG_RETENTION
100
+ })
99
101
  });
100
102
  addPoliciesToRole(this, props.inlinePolicy);
101
103
  applyRoleDescription(this, props.roleDescription);
@@ -125,7 +127,7 @@ export class LambdaFunction extends Function {
125
127
  description: props.lambdaDescription ?? `${id} Lambda`,
126
128
  environment: props.environment,
127
129
  logGroup: new LogGroup(scope, `${id}LogGroup`, {
128
- retention: props.logGroupRetention ?? RetentionDays.ONE_WEEK
130
+ retention: props.logGroupRetention ?? DEFAULT_FRAMEWORK_LOG_RETENTION
129
131
  })
130
132
  });
131
133
  addPoliciesToRole(this, props.inlinePolicy);
@@ -0,0 +1,17 @@
1
+ import { type IApplicationTargetGroup } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ import type { IFunction } from "aws-cdk-lib/aws-lambda";
3
+ import type { Construct } from "constructs";
4
+ export interface LambdaAlbTargetOptions {
5
+ readonly handler: IFunction;
6
+ }
7
+ /**
8
+ * Context-free Lambda-behind-ALB target group. Registering the handler as a
9
+ * `LambdaTarget` auto-adds the `elasticloadbalancing.amazonaws.com` invoke
10
+ * permission (CDK's `LambdaTarget.attachToApplicationTargetGroup` → `grantInvoke`),
11
+ * so no explicit `AWS::Lambda::Permission` is needed.
12
+ *
13
+ * Intentionally NOT attached to a listener here — the substrate exports the
14
+ * target-group ARN (A7) so slot-ops point a sleeping slot's host-header rule at
15
+ * it (§5.4); an unmatched host still falls through to the listener's 404.
16
+ */
17
+ export declare function createLambdaTargetGroup(scope: Construct, id: string, options: LambdaAlbTargetOptions): IApplicationTargetGroup;
@@ -0,0 +1,18 @@
1
+ import { ApplicationTargetGroup, TargetType } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ import { LambdaTarget } from "aws-cdk-lib/aws-elasticloadbalancingv2-targets";
3
+ /**
4
+ * Context-free Lambda-behind-ALB target group. Registering the handler as a
5
+ * `LambdaTarget` auto-adds the `elasticloadbalancing.amazonaws.com` invoke
6
+ * permission (CDK's `LambdaTarget.attachToApplicationTargetGroup` → `grantInvoke`),
7
+ * so no explicit `AWS::Lambda::Permission` is needed.
8
+ *
9
+ * Intentionally NOT attached to a listener here — the substrate exports the
10
+ * target-group ARN (A7) so slot-ops point a sleeping slot's host-header rule at
11
+ * it (§5.4); an unmatched host still falls through to the listener's 404.
12
+ */
13
+ export function createLambdaTargetGroup(scope, id, options) {
14
+ return new ApplicationTargetGroup(scope, id, {
15
+ targetType: TargetType.LAMBDA,
16
+ targets: [new LambdaTarget(options.handler)]
17
+ });
18
+ }
@@ -0,0 +1,21 @@
1
+ import { type ApplicationListener, type ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ import type { ICertificate } from "aws-cdk-lib/aws-certificatemanager";
3
+ export interface RoutingListenerOptions {
4
+ readonly port: number;
5
+ readonly certificate?: ICertificate;
6
+ /**
7
+ * Attach a fixed 404 default action. The ECS path passes this conditionally
8
+ * (only when ≥2 routes exist or no service has a port — CDK rejects a listener
9
+ * with neither a default action nor target groups); the dev substrate passes
10
+ * `true` unconditionally, since its host-header rules are the only routes and
11
+ * an unmatched host must return 404, not fall through.
12
+ */
13
+ readonly default404: boolean;
14
+ }
15
+ /**
16
+ * Context-free ALB listener factory shared by the ECS cluster path and the dev
17
+ * substrate. When `default404` is set, the listener's default action is a plain
18
+ * `404 Not Found` fixed response; otherwise no default action is attached (the
19
+ * caller's target groups become the listener's default).
20
+ */
21
+ export declare function addRoutingListener(loadBalancer: ApplicationLoadBalancer, id: string, options: RoutingListenerOptions): ApplicationListener;
@@ -0,0 +1,20 @@
1
+ import { ListenerAction } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
+ /**
3
+ * Context-free ALB listener factory shared by the ECS cluster path and the dev
4
+ * substrate. When `default404` is set, the listener's default action is a plain
5
+ * `404 Not Found` fixed response; otherwise no default action is attached (the
6
+ * caller's target groups become the listener's default).
7
+ */
8
+ export function addRoutingListener(loadBalancer, id, options) {
9
+ const defaultAction = options.default404
10
+ ? ListenerAction.fixedResponse(404, {
11
+ contentType: "text/plain",
12
+ messageBody: "Not Found"
13
+ })
14
+ : undefined;
15
+ return loadBalancer.addListener(id, {
16
+ port: options.port,
17
+ ...(options.certificate && { certificates: [options.certificate] }),
18
+ ...(defaultAction !== undefined && { defaultAction })
19
+ });
20
+ }
@@ -10,8 +10,24 @@ interface RdsProps {
10
10
  vpc: IVpc;
11
11
  databaseName?: string;
12
12
  engine?: IClusterEngine;
13
+ /**
14
+ * Aurora PostgreSQL version string the `engine` was built at, carried purely
15
+ * so the auto-pause floor can be validated here (the layer that owns the
16
+ * min-capacity knob). `undefined` means the engine default (>= 16.3) is in use.
17
+ */
18
+ engineVersion?: string;
13
19
  engineConfig?: EngineConfig;
14
20
  clusterIdentifier?: string;
21
+ /** Physical name of the generated master-credentials secret. */
22
+ credentialsSecretName?: string;
23
+ /** Serverless-v2 floor in ACU. 0 enables scale-to-zero (auto-pause). */
24
+ serverlessV2MinCapacity?: number;
25
+ /** Serverless-v2 ceiling in ACU. */
26
+ serverlessV2MaxCapacity?: number;
27
+ /** Idle duration before an idle serverless-v2 cluster auto-pauses to 0 ACU. */
28
+ serverlessV2AutoPauseDuration?: Duration;
29
+ /** Enables the RDS Data API (HTTP endpoint) on the cluster. */
30
+ enableDataApi?: boolean;
15
31
  writer?: AuroraWriterConfig;
16
32
  readers?: AuroraReadersConfig | false;
17
33
  proxy?: ProxyConfig | false;
@@ -8,10 +8,49 @@ import { Secret } from "../secrets/index.js";
8
8
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
9
9
  import { addProxyCfnOutput } from "./rdsProxyOutput.js";
10
10
  import { getDatabaseInsightsRetention } from "./index.js";
11
- import { RDS_DEFAULTS } from "./rdsDefaults.js";
11
+ import { RDS_DEFAULTS, SERVERLESS_V2_AUTO_PAUSE_MIN_SECONDS, SERVERLESS_V2_AUTO_PAUSE_MAX_SECONDS, MIN_AUTO_PAUSE_POSTGRES_VERSION } from "./rdsDefaults.js";
12
12
  import { createRdsAlarms } from "../monitoring/index.js";
13
13
  import { DEFAULT_POSTGRES_ENGINE_CONFIG, resolveDatabaseInsights, resolveStorageEncryptionKey, resolvePerformanceInsightsKey, addMultiUserSecretRotation, warnIfSnapshotUsernameAssumed } from "./rdsHelpers.js";
14
14
  import { resolveSecretRotation } from "../../../utils/databaseTypes.js";
15
+ function parsePostgresVersion(version) {
16
+ const parts = version.split(".");
17
+ return {
18
+ major: parseInt(parts[0] ?? "", 10),
19
+ minor: parseInt(parts[1] ?? "0", 10)
20
+ };
21
+ }
22
+ function assertAutoPauseCapablePostgresVersion(engineVersion) {
23
+ const { major, minor } = parsePostgresVersion(engineVersion);
24
+ if (Number.isNaN(major) || Number.isNaN(minor)) {
25
+ throw new Error(`Invalid Aurora engineVersion "${engineVersion}" (expected "<major>.<minor>")`);
26
+ }
27
+ const floor = parsePostgresVersion(MIN_AUTO_PAUSE_POSTGRES_VERSION);
28
+ const meetsFloor = major > floor.major || (major === floor.major && minor >= floor.minor);
29
+ if (!meetsFloor) {
30
+ throw new Error(`serverlessV2MinCapacity 0 requires Aurora PostgreSQL >= ${MIN_AUTO_PAUSE_POSTGRES_VERSION} for auto-pause; received engineVersion "${engineVersion}"`);
31
+ }
32
+ }
33
+ function validateServerlessV2DevKnobs(props) {
34
+ const min = props.serverlessV2MinCapacity;
35
+ const max = props.serverlessV2MaxCapacity;
36
+ if (min !== undefined && max !== undefined && min > max) {
37
+ throw new Error(`serverlessV2MinCapacity (${min}) must be <= serverlessV2MaxCapacity (${max})`);
38
+ }
39
+ if (props.serverlessV2AutoPauseDuration !== undefined) {
40
+ const seconds = props.serverlessV2AutoPauseDuration.toSeconds();
41
+ if (seconds < SERVERLESS_V2_AUTO_PAUSE_MIN_SECONDS ||
42
+ seconds > SERVERLESS_V2_AUTO_PAUSE_MAX_SECONDS) {
43
+ throw new Error(`serverlessV2AutoPauseDuration must be between ${SERVERLESS_V2_AUTO_PAUSE_MIN_SECONDS}s and ${SERVERLESS_V2_AUTO_PAUSE_MAX_SECONDS}s; received ${seconds}s`);
44
+ }
45
+ }
46
+ // Floor enforced only via engineVersion (opaque `IClusterEngine` can't be
47
+ // introspected): a raw `engine` below the floor with min 0 and no version is
48
+ // the caller's own risk. No such caller exists — DevSubstrate always passes
49
+ // engineVersion.
50
+ if (min === 0 && props.engineVersion !== undefined) {
51
+ assertAutoPauseCapablePostgresVersion(props.engineVersion);
52
+ }
53
+ }
15
54
  export class RdsAurora extends Construct {
16
55
  connections;
17
56
  constructId;
@@ -25,6 +64,7 @@ export class RdsAurora extends Construct {
25
64
  databaseNameValue;
26
65
  constructor(scope, id, props) {
27
66
  super(scope, id);
67
+ validateServerlessV2DevKnobs(props);
28
68
  this.constructId = id;
29
69
  this.databaseNameValue = props.databaseName ?? id;
30
70
  // PostgreSQL fallback for direct usage - ensure engine and engineConfig match
@@ -44,7 +84,8 @@ export class RdsAurora extends Construct {
44
84
  // Global secondary clusters import replicated secret instead of creating new
45
85
  if (props.isGlobalSecondary) {
46
86
  this.databaseCredentials = new Secret(this, `${this.databaseNameValue}Credentials`, {
47
- secretName: ResourceNaming.credentialsSecretName(id),
87
+ secretName: props.credentialsSecretName ??
88
+ ResourceNaming.credentialsSecretName(id),
48
89
  importExisting: true
49
90
  });
50
91
  }
@@ -57,7 +98,8 @@ export class RdsAurora extends Construct {
57
98
  assumedUsername: username
58
99
  });
59
100
  this.databaseCredentials = new Secret(this, `${this.databaseNameValue}Credentials`, {
60
- secretName: ResourceNaming.credentialsSecretName(id),
101
+ secretName: props.credentialsSecretName ??
102
+ ResourceNaming.credentialsSecretName(id),
61
103
  generateSecretString: {
62
104
  secretStringTemplate: JSON.stringify({ username }),
63
105
  excludePunctuation: true,
@@ -140,6 +182,18 @@ export class RdsAurora extends Construct {
140
182
  removalPolicy: RemovalPolicy.SNAPSHOT,
141
183
  deletionProtection: props.deletionProtection ?? true,
142
184
  iamAuthentication: true,
185
+ ...(props.serverlessV2MinCapacity !== undefined && {
186
+ serverlessV2MinCapacity: props.serverlessV2MinCapacity
187
+ }),
188
+ ...(props.serverlessV2MaxCapacity !== undefined && {
189
+ serverlessV2MaxCapacity: props.serverlessV2MaxCapacity
190
+ }),
191
+ ...(props.serverlessV2AutoPauseDuration !== undefined && {
192
+ serverlessV2AutoPauseDuration: props.serverlessV2AutoPauseDuration
193
+ }),
194
+ ...(props.enableDataApi !== undefined && {
195
+ enableDataApi: props.enableDataApi
196
+ }),
143
197
  writer,
144
198
  readers
145
199
  };