@fjall/components-infrastructure 5.0.0 → 7.0.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 (52) hide show
  1. package/dist/lib/config/aws/ebsDefaultEncryption.js +1 -1
  2. package/dist/lib/config/aws/inspectorEnablement.js +1 -1
  3. package/dist/lib/config/aws/oidcConnector.js +1 -1
  4. package/dist/lib/config/aws/s3BlockPublicAccess.js +1 -1
  5. package/dist/lib/config/aws/securityServicesAdmin.js +2 -2
  6. package/dist/lib/lambda-assets/cert-generator/asset/index.js +31 -31
  7. package/dist/lib/patterns/aws/clickhouseDatabase.js +4 -0
  8. package/dist/lib/patterns/aws/compute.js +1 -1
  9. package/dist/lib/patterns/aws/computeEcs.js +5 -0
  10. package/dist/lib/patterns/aws/computeLambda.d.ts +1 -1
  11. package/dist/lib/patterns/aws/devSubstrate.js +1 -1
  12. package/dist/lib/patterns/aws/payload.js +3 -3
  13. package/dist/lib/patterns/aws/staticSite.js +1 -1
  14. package/dist/lib/resources/aws/compute/ec2.d.ts +31 -14
  15. package/dist/lib/resources/aws/compute/ec2.js +34 -10
  16. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +1 -1
  17. package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +83 -0
  18. package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +323 -0
  19. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +19 -0
  20. package/dist/lib/resources/aws/compute/ecsConstants.js +24 -0
  21. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +1 -1
  22. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +20 -16
  23. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +73 -118
  24. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
  25. package/dist/lib/resources/aws/compute/ecsValidation.js +6 -0
  26. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
  27. package/dist/lib/resources/aws/compute/persistentDataVolume.js +10 -7
  28. package/dist/lib/resources/aws/database/rdsInstance.js +1 -1
  29. package/dist/lib/resources/aws/iam/identityCenter/user.js +1 -1
  30. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +1 -1
  31. package/dist/lib/resources/aws/networking/ipamPool.js +1 -1
  32. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +1 -1
  33. package/dist/lib/resources/aws/utilities/tlsCertGenerator.js +1 -1
  34. package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
  35. package/dist/lib/utils/capacityIdentityContext.js +43 -0
  36. package/dist/lib/utils/engineCompat.d.ts +12 -5
  37. package/dist/lib/utils/engineCompat.js +41 -22
  38. package/dist/lib/utils/manifestWriter.d.ts +19 -2
  39. package/dist/lib/utils/manifestWriter.js +35 -0
  40. package/package.json +10 -6
  41. package/dist/lib/config/aws/identityCenterMembership.d.ts +0 -11
  42. package/dist/lib/config/aws/identityCenterMembership.js +0 -61
  43. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets +0 -30
  44. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets.mjs +0 -212
  45. package/dist/lib/patterns/aws/buildkite/alarms.d.ts +0 -25
  46. package/dist/lib/patterns/aws/buildkite/alarms.js +0 -78
  47. package/dist/lib/patterns/aws/domainDelegation.d.ts +0 -8
  48. package/dist/lib/patterns/aws/domainDelegation.js +0 -45
  49. package/dist/lib/patterns/aws/domainFactory.d.ts +0 -23
  50. package/dist/lib/patterns/aws/domainFactory.js +0 -61
  51. package/dist/lib/utils/addSuffixToEmail.d.ts +0 -1
  52. package/dist/lib/utils/addSuffixToEmail.js +0 -3
@@ -2,16 +2,18 @@ import { FargateService, Ec2Service, PropagatedTagSource, PlacementStrategy, Asg
2
2
  import { Peer, Port, SubnetType, UserData } from "aws-cdk-lib/aws-ec2";
3
3
  import { ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
4
  import { Role } from "../iam/role.js";
5
- import { CfnOutput, Duration, Token } from "aws-cdk-lib";
5
+ import { Aws, CfnOutput, Duration } from "aws-cdk-lib";
6
6
  import { AdjustmentType, PredefinedMetric, ScalableTarget, ServiceNamespace, StepScalingAction, TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
7
7
  import { Alarm, ComparisonOperator, MathExpression } from "aws-cdk-lib/aws-cloudwatch";
8
8
  import { ApplicationScalingAction } from "aws-cdk-lib/aws-cloudwatch-actions";
9
- import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
10
9
  import { SecurityGroup } from "../networking/securityGroup.js";
11
10
  import { Ec2Instance } from "./ec2.js";
12
11
  import { vpcHasNatGateways } from "../../../utils/vpcUtils.js";
13
12
  import { toPascalCase } from "../../../utils/capitaliseString.js";
14
- import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_WARM_POOL_MIN_SIZE, DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN, DEFAULT_HEALTH_CHECK_GRACE_SECONDS, DEFAULT_MIN_HEALTHY_PERCENT, DEFAULT_MAX_HEALTHY_PERCENT, DEFAULT_DESIRED_COUNT, inferAmiHardwareType } from "./ecsConstants.js";
13
+ import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_EC2_MIN_CAPACITY, DEFAULT_EC2_MAX_CAPACITY, DEFAULT_EC2_INSTANCE_MONITORING, DEFAULT_WARM_POOL_MIN_SIZE, DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN, DEFAULT_HEALTH_CHECK_GRACE_SECONDS, DEFAULT_MIN_HEALTHY_PERCENT, DEFAULT_MAX_HEALTHY_PERCENT, DEFAULT_DESIRED_COUNT, inferAmiHardwareType } from "./ecsConstants.js";
14
+ import { assertAnchorUnique, assertSharedAsgConfigMatches, assertValidCapacitySlot, getEc2ConfigKey, legacyAnchorFromKey, LEGACY_KEY_FIELDS, resolveCapacityAnchor, resolveCapacitySlot } from "./ecsCapacityConfig.js";
15
+ import { lookupCapacityIdentityPin } from "../../../utils/capacityIdentityContext.js";
16
+ import { getCurrentCollector } from "../../../utils/manifestWriter.js";
15
17
  import { ScalingType } from "./ecsTypes.js";
16
18
  import { isServiceFargate, isServiceEc2 } from "./ecsTaskDefinition.js";
17
19
  /**
@@ -26,132 +28,79 @@ export function resolveCircuitBreaker(config) {
26
28
  const rollback = config?.rollback ?? true;
27
29
  return { enable: true, rollback };
28
30
  }
29
- /**
30
- * `ec2Config` fields that shape the ASG but are NOT part of
31
- * {@link getEc2ConfigKey}, split by how a difference can be detected without
32
- * false positives. A second service whose key matches reuses the first
33
- * service's ASG, so any of these it sets is silently discarded.
34
- *
35
- * Scalars compare by value. `tags` is plain data, so it compares deeply.
36
- * `machineImage` / `userData` / `blockDevices` are CDK objects with no stable
37
- * structural identity — two equivalent literals are different references, so
38
- * only a defined-vs-undefined asymmetry is safe to report. They deliberately
39
- * under-report: over-reporting is a hard synth failure on config that
40
- * previously worked, which is the more damaging direction.
41
- */
42
- const SCALAR_ASG_FIELDS = [
43
- "minCapacity",
44
- "maxCapacity",
45
- "desiredCapacity",
46
- "instanceMonitoring",
47
- "associatePublicIpAddress"
48
- ];
49
- const OPAQUE_ASG_FIELDS = [
50
- "machineImage",
51
- "userData",
52
- "blockDevices"
53
- ];
54
- function tagRecordsEqual(a, b) {
55
- if (a === undefined || b === undefined)
56
- return a === b;
57
- const aKeys = Object.keys(a);
58
- if (aKeys.length !== Object.keys(b).length)
59
- return false;
60
- return aKeys.every((key) => Object.is(a[key], b[key]));
61
- }
62
- function findDifferingAsgFields(ec2Config, origin) {
63
- const differing = SCALAR_ASG_FIELDS.filter((field) => !Object.is(ec2Config[field], origin[field]));
64
- if (!tagRecordsEqual(ec2Config.tags, origin.tags))
65
- differing.push("tags");
66
- for (const field of OPAQUE_ASG_FIELDS) {
67
- if ((ec2Config[field] === undefined) !== (origin[field] === undefined)) {
68
- differing.push(field);
69
- }
70
- }
71
- return differing;
72
- }
73
- /**
74
- * Rejects at synth when a service reusing a shared ASG asks for ASG settings
75
- * that differ from the ones the ASG was built with — those settings would be
76
- * silently ignored (same silent-ignore class as `validateEc2ServiceSizing`).
77
- */
78
- function assertSharedAsgConfigMatches(serviceName, ec2Config, key, origin) {
79
- const differing = findDifferingAsgFields(ec2Config, origin.ec2Config);
80
- if (differing.length === 0)
81
- return;
82
- throw new Error(`Service '${serviceName}': ec2Config ${differing.map((f) => `'${f}'`).join(", ")} ` +
83
- `${differing.length === 1 ? "is" : "are"} ignored — it shares an auto scaling group with ` +
84
- `service '${origin.serviceName}' (both resolve to EC2 capacity key '${key}'), and that ASG ` +
85
- `is built from the first service's ec2Config. Either give the two services the same value ` +
86
- `for ${differing.map((f) => `'${f}'`).join(", ")}, or give this service its own ASG by ` +
87
- `changing a keyed field ('instanceType', 'amiHardwareType', 'warmPool', ` +
88
- `'persistentDataVolume', 'availabilityZones').`);
89
- }
90
- /**
91
- * Generates a unique key for EC2 config so services with matching
92
- * configurations share an ASG.
93
- */
94
- export function getEc2ConfigKey(ec2Config) {
95
- const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
96
- const amiHardwareType = ec2Config.amiHardwareType ??
97
- (inferAmiHardwareType(instanceType) === AmiHardwareType.ARM
98
- ? "ARM"
99
- : "STANDARD");
100
- const warmPoolKey = ec2Config.warmPool
101
- ? `wp${ec2Config.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE}-${ec2Config.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN}`
102
- : "nowp";
103
- const baseKey = `${instanceType}-${amiHardwareType}-${warmPoolKey}`;
104
- // PDV services are implicit singletons — sharing an ASG would let two tasks race for the same EBS volume.
105
- const pdv = ec2Config.persistentDataVolume;
106
- const extras = [];
107
- if (pdv !== undefined) {
108
- extras.push(`pdv-${pdv.deviceName}-${pdv.sizeGb}-${stableAzSegment(pdv.availabilityZone)}`);
109
- }
110
- // Serialise actual AZ names — `az${length}` would let services pinned to different AZs collide on `az1`.
111
- // CDK Tokens (env-agnostic stacks) substitute a stable sentinel to keep logical IDs deterministic.
112
- if (ec2Config.availabilityZones !== undefined) {
113
- const azKey = [...ec2Config.availabilityZones]
114
- .map(stableAzSegment)
115
- .sort()
116
- .join(",");
117
- extras.push(`az-${azKey}`);
118
- }
119
- return extras.length === 0 ? baseKey : `${baseKey}-${extras.join("-")}`;
120
- }
121
- function stableAzSegment(az) {
122
- return Token.isUnresolved(az) ? "synthAz" : az;
123
- }
31
+ export { getEc2ConfigKey, validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
124
32
  /**
125
33
  * Gets or creates an ASG capacity provider for an EC2-backed service.
126
- * Services with matching EC2 configs share the same ASG.
34
+ * Services declaring the same capacity SLOT share the same ASG.
35
+ *
36
+ * Every CloudFormation identity surface (the four construct-ID prefixes,
37
+ * the physical service name and everything `Ec2Instance` derives from it)
38
+ * comes from the slot's identity ANCHOR — `identityAnchor` pin, else the
39
+ * deploy-injected `fjall:capacityIdentity` context pin, else
40
+ * `toPascalCase(slot)` — never from the hardware config, so a config change
41
+ * updates the deployed ASG in place instead of renaming (replacing) it.
127
42
  *
128
- * Mutates `state` to track the provider and first ASG/security group.
43
+ * Mutates `state` to track the provider and first ASG/security group, and
44
+ * records the slot's identity alias on the manifest collector (rename
45
+ * detection and the pin writer consume it).
129
46
  */
130
47
  export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
131
48
  const ec2Config = serviceProps.ec2Config ?? {};
132
- const key = getEc2ConfigKey(ec2Config);
133
- const existing = state.providers.get(key);
49
+ const slot = resolveCapacitySlot(ec2Config);
50
+ assertValidCapacitySlot(slot);
51
+ const existing = state.providers.get(slot);
134
52
  if (existing) {
135
- const origin = state.asgOrigins.get(key);
53
+ const origin = state.asgOrigins.get(slot);
136
54
  if (origin !== undefined) {
137
- assertSharedAsgConfigMatches(serviceProps.name, ec2Config, key, origin);
55
+ assertSharedAsgConfigMatches(serviceProps.name, ec2Config, slot, origin);
138
56
  }
139
57
  return existing;
140
58
  }
141
- state.asgOrigins.set(key, { serviceName: serviceProps.name, ec2Config });
142
- const safeKey = key.replace(/[^a-zA-Z0-9]/g, "");
59
+ const collector = getCurrentCollector();
60
+ const appName = collector?.getAppName();
61
+ const pin = appName !== undefined
62
+ ? lookupCapacityIdentityPin(ctx.scope, appName, slot)
63
+ : undefined;
64
+ const anchor = resolveCapacityAnchor(ec2Config, pin);
65
+ assertAnchorUnique(slot, anchor, new Map([...state.asgOrigins].map(([s, o]) => [s, o.anchor])));
66
+ state.asgOrigins.set(slot, {
67
+ serviceName: serviceProps.name,
68
+ ec2Config,
69
+ anchor
70
+ });
71
+ const physicalServiceName = `${ctx.props.clusterName}${anchor}`;
72
+ if (collector !== null) {
73
+ const legacyKey = getEc2ConfigKey(ec2Config);
74
+ const legacyAnchor = legacyAnchorFromKey(legacyKey);
75
+ collector.addCapacityIdentityAlias({
76
+ appName: collector.getAppName(),
77
+ slot,
78
+ clusterName: ctx.props.clusterName,
79
+ anchor,
80
+ pinned: pin !== undefined || ec2Config.identityAnchor !== undefined,
81
+ currentServiceName: physicalServiceName,
82
+ legacyKey,
83
+ legacyAnchor,
84
+ legacyServiceName: `${ctx.props.clusterName}${legacyAnchor}`,
85
+ legacyKeyFields: [...LEGACY_KEY_FIELDS]
86
+ });
87
+ }
143
88
  const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
144
89
  const amiHardwareType = ec2Config.amiHardwareType
145
90
  ? ec2Config.amiHardwareType === "STANDARD"
146
91
  ? AmiHardwareType.STANDARD
147
92
  : AmiHardwareType.ARM
148
93
  : inferAmiHardwareType(instanceType);
149
- const minCapacity = ec2Config.minCapacity ?? 2;
150
- const maxCapacity = ec2Config.maxCapacity ?? 3;
94
+ const minCapacity = ec2Config.minCapacity ?? DEFAULT_EC2_MIN_CAPACITY;
95
+ const maxCapacity = ec2Config.maxCapacity ?? DEFAULT_EC2_MAX_CAPACITY;
96
+ // GroupDescription is CFN create-only: a pinned slot must reproduce the
97
+ // deployed string verbatim or the SG is replaced. New stacks derive it
98
+ // from the anchor-based physical service name, stable by construction.
151
99
  const asgSecurityGroup = ctx.props.cluster?.securityGroup ??
152
- new SecurityGroup(ctx.scope, `${safeKey}AsgSecurityGroup`, {
100
+ new SecurityGroup(ctx.scope, `${anchor}AsgSecurityGroup`, {
153
101
  vpc: ctx.cluster.vpc,
154
- description: `Security group for ${key} auto scaling group`
102
+ description: pin?.legacySgDescription ??
103
+ `Security group for the ${physicalServiceName} auto scaling group`
155
104
  });
156
105
  if (ctx.directAccessEnabled) {
157
106
  for (const service of ctx.props.services) {
@@ -174,12 +123,12 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
174
123
  : undefined;
175
124
  // userData + role must be set on the LaunchTemplate or AsgCapacityProvider
176
125
  // throws at synth (ProvidedLaunchTemplateExposeUser/ExposeDefine).
177
- const instanceRole = new Role(ctx.scope, `${safeKey}InstanceRole`, {
126
+ const instanceRole = new Role(ctx.scope, `${anchor}InstanceRole`, {
178
127
  assumedBy: new ServicePrincipal("ec2.amazonaws.com"),
179
- description: `EC2 instance role for ${key} ECS capacity`
128
+ description: `EC2 instance role for the ${slot} ECS capacity slot`
180
129
  });
181
- const ec2Instance = new Ec2Instance(ctx.scope, `${safeKey}Ec2Instance`, {
182
- serviceName: `${ctx.props.clusterName}${safeKey}`,
130
+ const ec2Instance = new Ec2Instance(ctx.scope, `${anchor}Ec2Instance`, {
131
+ serviceName: physicalServiceName,
183
132
  vpc: ctx.cluster.vpc,
184
133
  vpcSubnets: {
185
134
  subnetType: hasNat ? SubnetType.PRIVATE_WITH_EGRESS : SubnetType.PUBLIC,
@@ -195,7 +144,7 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
195
144
  EcsOptimizedImage.amazonLinux2023(amiHardwareType),
196
145
  userData: ec2Config.userData ?? UserData.forLinux(),
197
146
  role: instanceRole,
198
- instanceMonitoring: ec2Config.instanceMonitoring ?? Monitoring.BASIC,
147
+ instanceMonitoring: ec2Config.instanceMonitoring ?? DEFAULT_EC2_INSTANCE_MONITORING,
199
148
  capacityRebalance: true,
200
149
  ecsClusterArn: ctx.cluster.clusterArn,
201
150
  ...(ec2Config.desiredCapacity !== undefined && {
@@ -208,6 +157,9 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
208
157
  associatePublicIpAddress: ec2Config.associatePublicIpAddress
209
158
  }),
210
159
  ...(resolvedWarmPool !== undefined && { warmPool: resolvedWarmPool }),
160
+ ...(ec2Config.updatePolicy !== undefined && {
161
+ updatePolicy: ec2Config.updatePolicy
162
+ }),
211
163
  ...(ec2Config.persistentDataVolume !== undefined && {
212
164
  persistentDataVolume: {
213
165
  ...ec2Config.persistentDataVolume,
@@ -215,12 +167,15 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
215
167
  ctx.props.alertsTopic !== undefined && {
216
168
  alarmTopic: ctx.props.alertsTopic
217
169
  })
218
- }
170
+ },
171
+ // Pinned slots reproduce the deployed fjall:OwnerLogicalId verbatim —
172
+ // the re-attach Lambdas filter on it, so a drift strands the volume.
173
+ dataVolumeStableOwnerId: pin?.legacyOwnerId ?? `${Aws.STACK_NAME}/${anchor}/PersistentDataVolume`
219
174
  }),
220
175
  ...(ec2Config.tags !== undefined && { tags: ec2Config.tags })
221
176
  });
222
177
  const asg = ec2Instance.getAutoScalingGroup();
223
- const provider = new AsgCapacityProvider(ctx.scope, `${safeKey}AsgCapacityProvider`, {
178
+ const provider = new AsgCapacityProvider(ctx.scope, `${anchor}AsgCapacityProvider`, {
224
179
  autoScalingGroup: asg,
225
180
  enableManagedDraining: true,
226
181
  // MTP's ProtectedFromScaleIn flag does NOT clear on CP deletion, so
@@ -233,7 +188,7 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
233
188
  enableManagedScaling: minCapacity !== maxCapacity
234
189
  });
235
190
  ctx.cluster.addAsgCapacityProvider(provider);
236
- state.providers.set(key, provider);
191
+ state.providers.set(slot, provider);
237
192
  if (!state.autoScalingGroup) {
238
193
  state.autoScalingGroup = asg;
239
194
  }
@@ -22,7 +22,7 @@ import type { ManagedDomainBinding, ManagedDomainExports } from "../../../utils/
22
22
  import type { ITopic } from "aws-cdk-lib/aws-sns";
23
23
  import type { ILogGroup } from "aws-cdk-lib/aws-logs";
24
24
  import type { EcsServiceAlarmThresholds, LogPatternAlarmSpec } from "../monitoring/index.js";
25
- import { type Ec2InstancePersistentDataVolumeConfig } from "./ec2.js";
25
+ import { type Ec2InstancePersistentDataVolumeConfig, type Ec2InstanceUpdatePolicyConfig } from "./ec2.js";
26
26
  export declare enum Protocol {
27
27
  HTTP = 0,
28
28
  HTTPS = 1
@@ -94,6 +94,26 @@ export type { EcsCapacityProvider };
94
94
  * Only used when capacityProvider is "EC2".
95
95
  */
96
96
  export interface Ec2CapacityConfig {
97
+ /**
98
+ * Capacity SLOT — the stable, operator-chosen identity for this ASG within
99
+ * its cluster. Lowercase kebab (`CAPACITY_SLOT_PATTERN` from `@fjall/util`),
100
+ * max 64 chars. Default: `"primary"`. Every CloudFormation identity surface
101
+ * (logical-ID prefixes, physical service name, launch-template name)
102
+ * derives from the slot's ANCHOR (`toPascalCase(slot)`), never from the
103
+ * hardware config — changing `instanceType` or any other property updates
104
+ * the ASG in place instead of renaming (replacing) it. Services declaring
105
+ * the same slot share its ASG and must agree on every property field
106
+ * (`assertSharedAsgConfigMatches`).
107
+ */
108
+ slot?: string;
109
+ /**
110
+ * Explicit identity-anchor pin. When present it wins over both the
111
+ * slot-derived anchor AND any `fjall:capacityIdentity` context pin. Must
112
+ * match `CAPACITY_ANCHOR_PATTERN` (alphanumeric, letter first). Primarily
113
+ * for freezing a deployed pre-6.0 config-derived identity; new stacks
114
+ * should rely on the slot derivation.
115
+ */
116
+ identityAnchor?: string;
97
117
  /** EC2 instance type. Default: "t4g.micro" */
98
118
  instanceType?: string;
99
119
  /** AMI hardware type. Default: "ARM" (Graviton - better cost/performance) */
@@ -151,9 +171,9 @@ export interface Ec2CapacityConfig {
151
171
  * Pairs the EC2 capacity ASG with a standalone EBS data volume that
152
172
  * re-attaches across instance refreshes. Forwarded to `Ec2Instance` which
153
173
  * locates and detaches the volume via TERMINATING/LAUNCHING lifecycle
154
- * hooks. Implies a singleton service — do not share an ASG across
155
- * services when this is set (`getEc2ConfigKey` adds a discriminator to
156
- * keep them apart).
174
+ * hooks. Implies a singleton service — a second service declaring the
175
+ * same slot is rejected at synth (`assertSharedAsgConfigMatches`); give
176
+ * it its own slot instead.
157
177
  */
158
178
  persistentDataVolume?: Ec2InstancePersistentDataVolumeConfig;
159
179
  /**
@@ -163,6 +183,15 @@ export interface Ec2CapacityConfig {
163
183
  * Empty-string keys or values are rejected at the resources layer.
164
184
  */
165
185
  tags?: Record<string, string>;
186
+ /**
187
+ * ASG `UpdatePolicy` passthrough, forwarded to `Ec2Instance` verbatim.
188
+ * Absent → `instanceRefresh` with `minHealthyPercentage 0 /
189
+ * maxHealthyPercentage 100` when the slot owns a `persistentDataVolume`
190
+ * (single-attach EBS forbids surge), `100 / 200` otherwise (surge-then
191
+ * -shrink, no capacity dip). A property field, not identity — changing it
192
+ * never renames the ASG.
193
+ */
194
+ updatePolicy?: Ec2InstanceUpdatePolicyConfig;
166
195
  }
167
196
  /**
168
197
  * Explicit Route53 latency routing policy for the cluster's alias records
@@ -3,6 +3,7 @@ import { evaluateBakeGuard } from "@fjall/util/docker";
3
3
  import { toKebab, SSM_COMPONENT_PATTERN, SSM_COMPONENT_ERROR, SECRET_NAME_PATTERN, SECRET_NAME_ERROR } from "@fjall/util";
4
4
  import { ScalingType } from "./ecsTypes.js";
5
5
  import { DEFAULT_EC2_CONTAINER_MEMORY_MIB } from "./ecsConstants.js";
6
+ import { validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
6
7
  /**
7
8
  * Validates ECS cluster props before construction.
8
9
  * Pure function — does not depend on class state.
@@ -196,6 +197,11 @@ export function validateEcsClusterProps(props) {
196
197
  }
197
198
  }
198
199
  }
200
+ // Cross-service, so it runs after the per-service loop: EC2 services whose
201
+ // ec2Config resolves to the same capacity key share one ASG — reject config
202
+ // drift the shared ASG would silently discard, and any sharing of a
203
+ // persistentDataVolume (singleton) ASG.
204
+ validateSharedEc2CapacityConfig(props.services);
199
205
  }
200
206
  /**
201
207
  * Rejects service-level task sizing that is silently ignored for EC2 capacity.
@@ -19,6 +19,16 @@ export declare const PERSISTENT_DATA_VOLUME_TAG_STACK_ID = "fjall:StackId";
19
19
  export interface PersistentDataVolumeProps {
20
20
  /** ASG whose EC2_INSTANCE_LAUNCHING transitions trigger the re-attach. */
21
21
  autoScalingGroup: AutoScalingGroup;
22
+ /**
23
+ * Stable owner identifier — becomes the volume's `fjall:OwnerLogicalId`
24
+ * tag and the Lambdas' `OWNER_LOGICAL_ID` env. An explicit prop (not
25
+ * `this.node.path`) so the re-attach identity survives construct-tree
26
+ * refactors: renaming or reparenting an ancestor must never strand the
27
+ * deployed volume. Must remain byte-stable for the stack's whole life;
28
+ * for a stack deployed before the identity-anchor era, pass the deployed
29
+ * tag value verbatim (the pin's `legacyOwnerId`).
30
+ */
31
+ stableOwnerId: string;
22
32
  /** Size in GiB. */
23
33
  sizeGb: number;
24
34
  /** Device path the bootstrap script expects (e.g. /dev/xvdf). */
@@ -56,7 +66,7 @@ export interface PersistentDataVolumeProps {
56
66
  *
57
67
  * Volume tagging: three tags carry the re-attach identity —
58
68
  * `fjall:Lifecycle = data-volume` (constant; gates IAM scoping)
59
- * `fjall:OwnerLogicalId = <this.node.path>` (per-construct, stable across
69
+ * `fjall:OwnerLogicalId = <props.stableOwnerId>` (per-slot, stable across
60
70
  * instance refreshes in one stack so the Lambda re-finds the same volume)
61
71
  * `fjall:StackId = <Aws.STACK_ID>` (per-stack-creation discriminator;
62
72
  * CloudFormation issues a fresh StackId for every CREATE, so orphans
@@ -89,10 +99,10 @@ export declare class PersistentDataVolume extends Construct {
89
99
  * Identifier used as the `fjall:OwnerLogicalId` tag on the volume and the
90
100
  * `OWNER_LOGICAL_ID` env on the Lambda. Consumers MUST pass this exact
91
101
  * value to the TERMINATING handler so it locates the same volume for
92
- * detach. Derived from `this.node.path`; renaming or reparenting any
93
- * ancestor changes the value, which breaks the re-attach chain (Lambda
94
- * filter no longer matches, new instance fails its launch hook). Rename
95
- * across an existing deployment is a manual snapshot-restore step.
102
+ * detach. Carries `props.stableOwnerId` verbatim; changing it across an
103
+ * existing deployment breaks the re-attach chain (Lambda filter no longer
104
+ * matches, new instance fails its launch hook) — recovery is a manual
105
+ * snapshot-restore step.
96
106
  */
97
107
  readonly ownerLogicalId: string;
98
108
  readonly attachFailureAlarm: Alarm;
@@ -46,7 +46,7 @@ const ALARM_THRESHOLD = 1;
46
46
  *
47
47
  * Volume tagging: three tags carry the re-attach identity —
48
48
  * `fjall:Lifecycle = data-volume` (constant; gates IAM scoping)
49
- * `fjall:OwnerLogicalId = <this.node.path>` (per-construct, stable across
49
+ * `fjall:OwnerLogicalId = <props.stableOwnerId>` (per-slot, stable across
50
50
  * instance refreshes in one stack so the Lambda re-finds the same volume)
51
51
  * `fjall:StackId = <Aws.STACK_ID>` (per-stack-creation discriminator;
52
52
  * CloudFormation issues a fresh StackId for every CREATE, so orphans
@@ -79,17 +79,17 @@ export class PersistentDataVolume extends Construct {
79
79
  * Identifier used as the `fjall:OwnerLogicalId` tag on the volume and the
80
80
  * `OWNER_LOGICAL_ID` env on the Lambda. Consumers MUST pass this exact
81
81
  * value to the TERMINATING handler so it locates the same volume for
82
- * detach. Derived from `this.node.path`; renaming or reparenting any
83
- * ancestor changes the value, which breaks the re-attach chain (Lambda
84
- * filter no longer matches, new instance fails its launch hook). Rename
85
- * across an existing deployment is a manual snapshot-restore step.
82
+ * detach. Carries `props.stableOwnerId` verbatim; changing it across an
83
+ * existing deployment breaks the re-attach chain (Lambda filter no longer
84
+ * matches, new instance fails its launch hook) — recovery is a manual
85
+ * snapshot-restore step.
86
86
  */
87
87
  ownerLogicalId;
88
88
  attachFailureAlarm;
89
89
  constructor(scope, id, props) {
90
90
  super(scope, id);
91
91
  validatePersistentDataVolumeProps(props);
92
- this.ownerLogicalId = this.node.path;
92
+ this.ownerLogicalId = props.stableOwnerId;
93
93
  this.volume = new Volume(this, "Volume", {
94
94
  availabilityZone: props.availabilityZone,
95
95
  size: Size.gibibytes(props.sizeGb),
@@ -157,7 +157,7 @@ export class PersistentDataVolume extends Construct {
157
157
  resources: [asgArnWildcard]
158
158
  });
159
159
  this.lambda = new LambdaFunction(this, `${id}Fn`, {
160
- runtime: Runtime.NODEJS_22_X,
160
+ runtime: Runtime.NODEJS_24_X,
161
161
  handler: "index.handler",
162
162
  code: Code.fromInline(source),
163
163
  lambdaDescription: `${id} ${PERSISTENT_DATA_VOLUME_LAUNCHING_DESCRIPTION}`,
@@ -236,6 +236,9 @@ function validatePersistentDataVolumeProps(props) {
236
236
  if (props.deviceName === "") {
237
237
  throw new Error("PersistentDataVolume.deviceName must be non-empty");
238
238
  }
239
+ if (props.stableOwnerId === "") {
240
+ throw new Error("PersistentDataVolume.stableOwnerId must be non-empty");
241
+ }
239
242
  if (props.availabilityZone === "") {
240
243
  throw new Error("PersistentDataVolume.availabilityZone must be non-empty");
241
244
  }
@@ -252,7 +252,7 @@ export class RdsInstance extends Construct {
252
252
  const instanceId = ResourceNaming.dbInstanceId(this.constructId);
253
253
  const waiterTimeout = Duration.minutes(10);
254
254
  const deletionWaiter = new CustomResource(this, `${this.databaseNameValue}DeletionWaiter`, {
255
- runtime: Runtime.NODEJS_22_X,
255
+ runtime: Runtime.NODEJS_24_X,
256
256
  timeout: waiterTimeout.plus(Duration.seconds(30)),
257
257
  lambdaDescription: `${this.databaseNameValue} deletion waiter`,
258
258
  inlineCode: `
@@ -33,7 +33,7 @@ export class IdentityStoreUserProvider extends Construct {
33
33
  const identityStoreArn = `arn:${stack.partition}:identitystore::${stack.account}:identitystore/${props.identityStoreId}`;
34
34
  const userArnPattern = identityStoreUserArnPattern(stack.partition);
35
35
  const handler = new SingletonFunction(this, "Handler", {
36
- runtime: Runtime.NODEJS_22_X,
36
+ runtime: Runtime.NODEJS_24_X,
37
37
  code: Code.fromAsset(LAMBDA_ASSET_DIR),
38
38
  handler: "index.handler",
39
39
  timeout: HANDLER_TIMEOUT_SECONDS,
@@ -21,7 +21,7 @@ export class CrossAccountReturnRoutes extends Construct {
21
21
  constructor(scope, id, props) {
22
22
  super(scope, id);
23
23
  this.customResource = new CustomResource(this, "Handler", {
24
- runtime: Runtime.NODEJS_22_X,
24
+ runtime: Runtime.NODEJS_24_X,
25
25
  timeout: Duration.minutes(5),
26
26
  lambdaDescription: `${id} cross-account return-route manager`,
27
27
  roleDescription: `${id} return-route Lambda execution role`,
@@ -166,7 +166,7 @@ export class IpamPool extends Construct {
166
166
  // On stack deletion, VPC allocations (not managed by CloudFormation)
167
167
  // block CIDR deprovisioning. This custom resource releases them first.
168
168
  const allocationCleanup = new CustomResource(this, `${constructKey}IpamCleanup${regionSuffix}`, {
169
- runtime: Runtime.NODEJS_22_X,
169
+ runtime: Runtime.NODEJS_24_X,
170
170
  timeout: Duration.minutes(5),
171
171
  lambdaDescription: `${account} IPAM pool allocation cleanup - ${region}`,
172
172
  properties: {
@@ -36,7 +36,7 @@ export class CostAllocationTagActivator extends Construct {
36
36
  this.lambda = new LambdaFunction(this, "TagActivatorFunction", {
37
37
  // Referenced cross-environment by the App messaging stack's schedule rule.
38
38
  functionName: PhysicalName.GENERATE_IF_NEEDED,
39
- runtime: Runtime.NODEJS_22_X,
39
+ runtime: Runtime.NODEJS_24_X,
40
40
  handler: "index.handler",
41
41
  code: Code.fromInline(HANDLER_CODE),
42
42
  lambdaDescription: "Auto-discovers and activates cost allocation tags in Cost Explorer",
@@ -66,7 +66,7 @@ export class TlsCertGenerator extends Construct {
66
66
  resources: [caCmk.key.keyArn, serverCmk.key.keyArn]
67
67
  });
68
68
  const cr = new CustomResource(this, "Generator", {
69
- runtime: Runtime.NODEJS_22_X,
69
+ runtime: Runtime.NODEJS_24_X,
70
70
  timeout: LAMBDA_TIMEOUT,
71
71
  codePath: LAMBDA_ASSET_DIR,
72
72
  handler: "index.handler",
@@ -0,0 +1,20 @@
1
+ import type { Construct } from "constructs";
2
+ import { type CapacityIdentityPin, type CapacityIdentityWire } from "@fjall/util";
3
+ /**
4
+ * Reader for the `fjall:capacityIdentity` CDK context the deploy engine
5
+ * injects (already projected to the current deploy target — see
6
+ * `@fjall/util § capacityIdentity` for the wire contract).
7
+ *
8
+ * Malformed context is a LOUD synth error, never a silent skip: a pin that
9
+ * fails to apply would synth the slot unpinned, and the resulting template
10
+ * renames (replaces) every stateful resource in the slot. The deploy-time
11
+ * rename gate would catch that downstream, but the failure must name the
12
+ * real cause — the malformed pin — not surface as a mystery rename.
13
+ */
14
+ export declare function readCapacityIdentityPins(scope: Construct): CapacityIdentityWire | undefined;
15
+ /**
16
+ * The pin for one `{ appName, slot }`, or undefined when the app/slot is
17
+ * unpinned. `appName` must be the manifest appName — the same value the pin
18
+ * writer records.
19
+ */
20
+ export declare function lookupCapacityIdentityPin(scope: Construct, appName: string, slot: string): CapacityIdentityPin | undefined;
@@ -0,0 +1,43 @@
1
+ import { CAPACITY_IDENTITY_CONTEXT_KEY, CapacityIdentityWireSchema } from "@fjall/util";
2
+ /**
3
+ * Reader for the `fjall:capacityIdentity` CDK context the deploy engine
4
+ * injects (already projected to the current deploy target — see
5
+ * `@fjall/util § capacityIdentity` for the wire contract).
6
+ *
7
+ * Malformed context is a LOUD synth error, never a silent skip: a pin that
8
+ * fails to apply would synth the slot unpinned, and the resulting template
9
+ * renames (replaces) every stateful resource in the slot. The deploy-time
10
+ * rename gate would catch that downstream, but the failure must name the
11
+ * real cause — the malformed pin — not surface as a mystery rename.
12
+ */
13
+ export function readCapacityIdentityPins(scope) {
14
+ const raw = scope.node.tryGetContext(CAPACITY_IDENTITY_CONTEXT_KEY);
15
+ if (raw === undefined)
16
+ return undefined;
17
+ let value = raw;
18
+ if (typeof raw === "string") {
19
+ // `-c key=value` always arrives as a string; tests may set the object form.
20
+ try {
21
+ value = JSON.parse(raw);
22
+ }
23
+ catch (error) {
24
+ throw new Error(`${CAPACITY_IDENTITY_CONTEXT_KEY} context is not valid JSON: ` +
25
+ `${error instanceof Error ? error.message : String(error)}`, { cause: error });
26
+ }
27
+ }
28
+ const parsed = CapacityIdentityWireSchema.safeParse(value);
29
+ if (!parsed.success) {
30
+ throw new Error(`${CAPACITY_IDENTITY_CONTEXT_KEY} context is malformed — refusing to ` +
31
+ `synth with a pin that cannot apply (an unpinned synth would rename ` +
32
+ `every stateful resource in the slot): ${parsed.error.message}`);
33
+ }
34
+ return parsed.data;
35
+ }
36
+ /**
37
+ * The pin for one `{ appName, slot }`, or undefined when the app/slot is
38
+ * unpinned. `appName` must be the manifest appName — the same value the pin
39
+ * writer records.
40
+ */
41
+ export function lookupCapacityIdentityPin(scope, appName, slot) {
42
+ return readCapacityIdentityPins(scope)?.[appName]?.[slot];
43
+ }
@@ -1,11 +1,18 @@
1
1
  import { type EngineCompat } from "@fjall/util/manifest/schemas";
2
2
  /**
3
3
  * Normalise an npm dependency RANGE to its minimum X.Y.Z floor: strip a leading
4
- * range operator (`^`, `~`, `>=`, `>`, `=`, `v`) and any prerelease/build
5
- * metadata, then reconstruct `${major}.${minor}.${patch}` (missing minor/patch
6
- * default to 0). Returns undefined for anything without a valid integer major —
7
- * so a non-semver range (a git URL, `*`) yields no floor and the caller omits
8
- * the field rather than emitting garbage.
4
+ * inclusive range operator (`^`, `~`, `>=`, `=`, `v`), then reconstruct
5
+ * `${major}.${minor}.${patch}` (missing minor/patch default to 0).
6
+ *
7
+ * Deliberately conservative returns undefined ( the caller omits the field,
8
+ * FM1 degrades to no-op) for every shape whose true floor the first-token strip
9
+ * would MIS-state rather than merely fail to state:
10
+ * - unions (`^3 || ^2` floors at 2.0.0, not the first branch's 3.0.0)
11
+ * - strict lower bounds (`>2.1134.0` excludes its own literal)
12
+ * - prerelease bounds (`^2.0.0-rc.1` floors below the stripped 2.0.0)
13
+ * - non-decimal segments (`1e2`, hex, `2.x`, `*`, git URLs, 4+ segments)
14
+ * A wrong floor is worse than no floor: over-strict refuses a valid engine,
15
+ * under-strict admits an incompatible one — both silently.
9
16
  */
10
17
  export declare function rangeFloor(range: string | undefined): string | undefined;
11
18
  /**