@fjall/components-infrastructure 6.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 (29) hide show
  1. package/dist/lib/patterns/aws/clickhouseDatabase.js +4 -0
  2. package/dist/lib/resources/aws/compute/ec2.d.ts +31 -14
  3. package/dist/lib/resources/aws/compute/ec2.js +34 -10
  4. package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +56 -16
  5. package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +226 -64
  6. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +15 -0
  7. package/dist/lib/resources/aws/compute/ecsConstants.js +15 -0
  8. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +16 -6
  9. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +68 -19
  10. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
  11. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
  12. package/dist/lib/resources/aws/compute/persistentDataVolume.js +9 -6
  13. package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
  14. package/dist/lib/utils/capacityIdentityContext.js +43 -0
  15. package/dist/lib/utils/manifestWriter.d.ts +19 -2
  16. package/dist/lib/utils/manifestWriter.js +35 -0
  17. package/package.json +4 -5
  18. package/dist/lib/config/aws/identityCenterMembership.d.ts +0 -11
  19. package/dist/lib/config/aws/identityCenterMembership.js +0 -61
  20. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets +0 -30
  21. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets.mjs +0 -212
  22. package/dist/lib/patterns/aws/buildkite/alarms.d.ts +0 -25
  23. package/dist/lib/patterns/aws/buildkite/alarms.js +0 -78
  24. package/dist/lib/patterns/aws/domainDelegation.d.ts +0 -8
  25. package/dist/lib/patterns/aws/domainDelegation.js +0 -45
  26. package/dist/lib/patterns/aws/domainFactory.d.ts +0 -23
  27. package/dist/lib/patterns/aws/domainFactory.js +0 -61
  28. package/dist/lib/utils/addSuffixToEmail.d.ts +0 -1
  29. package/dist/lib/utils/addSuffixToEmail.js +0 -3
@@ -1,6 +1,8 @@
1
1
  import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
2
2
  import { Token } from "aws-cdk-lib";
3
- 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, inferAmiHardwareType } from "./ecsConstants.js";
3
+ import { CAPACITY_ANCHOR_MAX_LENGTH, CAPACITY_ANCHOR_PATTERN, CAPACITY_SLOT_MAX_LENGTH, CAPACITY_SLOT_PATTERN, DEFAULT_CAPACITY_SLOT } from "@fjall/util";
4
+ import { toPascalCase } from "../../../utils/capitaliseString.js";
5
+ import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_EC2_MIN_CAPACITY, DEFAULT_EC2_MAX_CAPACITY, DEFAULT_EC2_INSTANCE_MONITORING, DEFAULT_ROLLING_UPDATE_PAUSE_SECONDS, DEFAULT_WARM_POOL_MIN_SIZE, DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN, INSTANCE_REFRESH_DEFAULT_MAX_HEALTHY, INSTANCE_REFRESH_DEFAULT_MIN_HEALTHY, INSTANCE_REFRESH_PDV_MAX_HEALTHY, INSTANCE_REFRESH_PDV_MIN_HEALTHY, inferAmiHardwareType } from "./ecsConstants.js";
4
6
  /**
5
7
  * Every `Ec2CapacityConfig` field, classified. The `Record<keyof …, …>`
6
8
  * satisfies-shape is a compile-time exhaustiveness guard in compiled `src/`
@@ -10,43 +12,116 @@ import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_EC2_MIN_CAPACITY, DEFAULT_EC2_MAX_CA
10
12
  * silently discarded on a shared ASG" bucket.
11
13
  */
12
14
  const EC2_CONFIG_FIELD_ROLES = {
13
- instanceType: "keyed",
14
- amiHardwareType: "keyed",
15
- warmPool: "keyed",
16
- persistentDataVolume: "keyed",
17
- availabilityZones: "keyed",
18
- minCapacity: "scalar",
19
- maxCapacity: "scalar",
20
- desiredCapacity: "scalar",
21
- instanceMonitoring: "scalar",
22
- associatePublicIpAddress: "scalar",
23
- machineImage: "opaque",
24
- userData: "opaque",
25
- blockDevices: "opaque",
26
- tags: "tags",
27
- memoryLimitMiB: "taskDefinition"
15
+ slot: "identity",
16
+ identityAnchor: "identity",
17
+ instanceType: "property:scalar",
18
+ amiHardwareType: "property:scalar",
19
+ minCapacity: "property:scalar",
20
+ maxCapacity: "property:scalar",
21
+ desiredCapacity: "property:scalar",
22
+ instanceMonitoring: "property:scalar",
23
+ associatePublicIpAddress: "property:scalar",
24
+ warmPool: "property:deep",
25
+ persistentDataVolume: "property:deep",
26
+ availabilityZones: "property:deep",
27
+ updatePolicy: "property:deep",
28
+ machineImage: "property:opaque",
29
+ userData: "property:opaque",
30
+ blockDevices: "property:opaque",
31
+ tags: "property:tags",
32
+ memoryLimitMiB: "validation"
28
33
  };
29
34
  function fieldsWithRole(role) {
30
35
  return Object.keys(EC2_CONFIG_FIELD_ROLES).filter((field) => EC2_CONFIG_FIELD_ROLES[field] === role);
31
36
  }
32
- const KEYED_ASG_FIELDS = fieldsWithRole("keyed");
33
- const SCALAR_ASG_FIELDS = fieldsWithRole("scalar");
34
- const OPAQUE_ASG_FIELDS = fieldsWithRole("opaque");
37
+ const SCALAR_ASG_FIELDS = fieldsWithRole("property:scalar");
38
+ const DEEP_ASG_FIELDS = fieldsWithRole("property:deep");
39
+ const OPAQUE_ASG_FIELDS = fieldsWithRole("property:opaque");
40
+ /** The `"ARM" | "STANDARD"` label the config key and drift compare share. */
41
+ function resolvedAmiHardwareLabel(config) {
42
+ return (config.amiHardwareType ??
43
+ (inferAmiHardwareType(config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE) ===
44
+ AmiHardwareType.ARM
45
+ ? "ARM"
46
+ : "STANDARD"));
47
+ }
35
48
  /**
36
- * Defaults resolved before a scalar field is compared — the SAME values the
37
- * ASG construction applies (`getOrCreateAsgCapacityProvider`), imported from
38
- * their `ecsConstants.ts` single source, so an explicit value equal to the
39
- * default compares equal to an omitted one instead of tripping the drift
49
+ * Per-field resolution before a scalar field is compared — the SAME values
50
+ * the ASG construction applies (`getOrCreateAsgCapacityProvider`), imported
51
+ * from their `ecsConstants.ts` single source, so an explicit value equal to
52
+ * the default compares equal to an omitted one instead of tripping the drift
40
53
  * throw on a pair that synthesises identically. Fields with no construction
41
54
  * default (`desiredCapacity`, `associatePublicIpAddress`) compare raw.
42
55
  */
43
- const SCALAR_ASG_FIELD_DEFAULTS = {
44
- minCapacity: DEFAULT_EC2_MIN_CAPACITY,
45
- maxCapacity: DEFAULT_EC2_MAX_CAPACITY,
46
- instanceMonitoring: DEFAULT_EC2_INSTANCE_MONITORING
56
+ const SCALAR_ASG_FIELD_RESOLVERS = {
57
+ instanceType: (c) => c.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE,
58
+ amiHardwareType: resolvedAmiHardwareLabel,
59
+ minCapacity: (c) => c.minCapacity ?? DEFAULT_EC2_MIN_CAPACITY,
60
+ maxCapacity: (c) => c.maxCapacity ?? DEFAULT_EC2_MAX_CAPACITY,
61
+ instanceMonitoring: (c) => c.instanceMonitoring ?? DEFAULT_EC2_INSTANCE_MONITORING
47
62
  };
48
63
  function resolvedScalar(config, field) {
49
- return config[field] ?? SCALAR_ASG_FIELD_DEFAULTS[field];
64
+ const resolver = SCALAR_ASG_FIELD_RESOLVERS[field];
65
+ return resolver !== undefined ? resolver(config) : config[field];
66
+ }
67
+ /**
68
+ * JSON-stable projections for the plain-data config objects, defaults
69
+ * resolved. `persistentDataVolume.alarmTopic` is a CDK `ITopic` — projected
70
+ * to defined-vs-undefined like the opaque fields. `updatePolicy.pauseTime`
71
+ * is a literal `Duration` — projected to seconds.
72
+ */
73
+ const DEEP_ASG_FIELD_PROJECTIONS = {
74
+ warmPool: (c) => c.warmPool === undefined
75
+ ? undefined
76
+ : {
77
+ minSize: c.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE,
78
+ reuseOnScaleIn: c.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN
79
+ },
80
+ persistentDataVolume: (c) => c.persistentDataVolume === undefined
81
+ ? undefined
82
+ : {
83
+ sizeGb: c.persistentDataVolume.sizeGb,
84
+ deviceName: c.persistentDataVolume.deviceName,
85
+ availabilityZone: stableAzSegment(c.persistentDataVolume.availabilityZone),
86
+ volumeType: c.persistentDataVolume.volumeType,
87
+ iops: c.persistentDataVolume.iops,
88
+ throughputMbps: c.persistentDataVolume.throughputMbps,
89
+ alarmTopicDefined: c.persistentDataVolume.alarmTopic !== undefined
90
+ },
91
+ availabilityZones: (c) => c.availabilityZones === undefined
92
+ ? undefined
93
+ : [...c.availabilityZones].map(stableAzSegment).sort(),
94
+ updatePolicy: (c) => {
95
+ // Mirrors resolveUpdatePolicy (ec2.ts): explicit-equal-to-default ≠ drift.
96
+ const policy = c.updatePolicy ?? { type: "instanceRefresh" };
97
+ const hasPdv = c.persistentDataVolume !== undefined;
98
+ switch (policy.type) {
99
+ case "rollingUpdate":
100
+ return {
101
+ type: policy.type,
102
+ pauseTimeSeconds: policy.pauseTime?.toSeconds() ??
103
+ DEFAULT_ROLLING_UPDATE_PAUSE_SECONDS
104
+ };
105
+ case "instanceRefresh":
106
+ return {
107
+ type: policy.type,
108
+ minHealthyPercentage: policy.minHealthyPercentage ??
109
+ (hasPdv
110
+ ? INSTANCE_REFRESH_PDV_MIN_HEALTHY
111
+ : INSTANCE_REFRESH_DEFAULT_MIN_HEALTHY),
112
+ maxHealthyPercentage: policy.maxHealthyPercentage ??
113
+ (hasPdv
114
+ ? INSTANCE_REFRESH_PDV_MAX_HEALTHY
115
+ : INSTANCE_REFRESH_DEFAULT_MAX_HEALTHY)
116
+ };
117
+ default:
118
+ return { type: policy.type };
119
+ }
120
+ }
121
+ };
122
+ function projectedDeep(config, field) {
123
+ const projection = DEEP_ASG_FIELD_PROJECTIONS[field];
124
+ return JSON.stringify(projection !== undefined ? projection(config) : config[field]);
50
125
  }
51
126
  /** Deep tag-record equality, treating `{}` and absent as the same ASG shape. */
52
127
  function tagRecordsEqual(a, b) {
@@ -60,6 +135,11 @@ function tagRecordsEqual(a, b) {
60
135
  }
61
136
  function findDifferingAsgFields(ec2Config, origin) {
62
137
  const differing = SCALAR_ASG_FIELDS.filter((field) => !Object.is(resolvedScalar(ec2Config, field), resolvedScalar(origin, field)));
138
+ for (const field of DEEP_ASG_FIELDS) {
139
+ if (projectedDeep(ec2Config, field) !== projectedDeep(origin, field)) {
140
+ differing.push(field);
141
+ }
142
+ }
63
143
  if (!tagRecordsEqual(ec2Config.tags, origin.tags))
64
144
  differing.push("tags");
65
145
  for (const field of OPAQUE_ASG_FIELDS) {
@@ -69,59 +149,123 @@ function findDifferingAsgFields(ec2Config, origin) {
69
149
  }
70
150
  return differing;
71
151
  }
152
+ /** The slot this ec2Config binds to (`"primary"` when undeclared). */
153
+ export function resolveCapacitySlot(ec2Config) {
154
+ return ec2Config.slot ?? DEFAULT_CAPACITY_SLOT;
155
+ }
156
+ /** Rejects at synth a slot name outside the kebab grammar. */
157
+ export function assertValidCapacitySlot(slot) {
158
+ if (slot.length > CAPACITY_SLOT_MAX_LENGTH ||
159
+ !CAPACITY_SLOT_PATTERN.test(slot)) {
160
+ throw new Error(`EC2 capacity slot '${slot}' is invalid — slot names are lowercase kebab ` +
161
+ `(letter first, e.g. 'primary', 'clickhouse-data'), max ` +
162
+ `${CAPACITY_SLOT_MAX_LENGTH} characters.`);
163
+ }
164
+ }
165
+ /**
166
+ * The identity anchor every CloudFormation identity surface derives from.
167
+ * Precedence: explicit `identityAnchor` config pin, then the deploy-injected
168
+ * context pin's `legacyAnchor`, then `toPascalCase(slot)`. The anchor grammar
169
+ * is validated whichever source wins — a bad anchor corrupts every logical
170
+ * ID and physical name downstream.
171
+ */
172
+ export function resolveCapacityAnchor(ec2Config, pin) {
173
+ const anchor = ec2Config.identityAnchor ??
174
+ pin?.legacyAnchor ??
175
+ toPascalCase(resolveCapacitySlot(ec2Config));
176
+ if (anchor.length > CAPACITY_ANCHOR_MAX_LENGTH ||
177
+ !CAPACITY_ANCHOR_PATTERN.test(anchor)) {
178
+ throw new Error(`EC2 capacity identity anchor '${anchor}' is invalid — anchors are ` +
179
+ `alphanumeric, start with a letter, max ${CAPACITY_ANCHOR_MAX_LENGTH} ` +
180
+ `characters.`);
181
+ }
182
+ return anchor;
183
+ }
184
+ /**
185
+ * Rejects at synth two slots whose anchors collide (`web-1` and `web1` both
186
+ * normalise to `Web1`) — colliding anchors mean colliding logical IDs.
187
+ * `anchorsBySlot` carries every anchor already resolved for the cluster.
188
+ */
189
+ export function assertAnchorUnique(slot, anchor, anchorsBySlot) {
190
+ for (const [otherSlot, otherAnchor] of anchorsBySlot) {
191
+ if (otherSlot !== slot && otherAnchor === anchor) {
192
+ throw new Error(`EC2 capacity slots '${otherSlot}' and '${slot}' both resolve to ` +
193
+ `identity anchor '${anchor}' — their CloudFormation logical IDs ` +
194
+ `would collide. Rename one slot (or adjust its identityAnchor).`);
195
+ }
196
+ }
197
+ }
72
198
  /**
73
199
  * Rejects at synth when a service reusing a shared ASG is incompatible with
74
- * it: either the origin's `persistentDataVolume` makes the ASG a singleton
75
- * (two tasks would race to attach the same EBS volume), or the reusing
76
- * service asks for ASG settings that differ from the ones the ASG was built
77
- * with those settings would be silently ignored (same silent-ignore class
78
- * as `validateEc2ServiceSizing`).
200
+ * it: the slot's identity is contested (`identityAnchor` disagreement), the
201
+ * origin's `persistentDataVolume` makes the slot a singleton (two tasks
202
+ * would race to attach the same EBS volume), or the reusing service asks for
203
+ * ASG settings that differ from the ones the ASG was built with — those
204
+ * settings would be silently ignored (same silent-ignore class as
205
+ * `validateEc2ServiceSizing`).
79
206
  */
80
- export function assertSharedAsgConfigMatches(serviceName, ec2Config, key, origin) {
207
+ export function assertSharedAsgConfigMatches(serviceName, ec2Config, slot, origin) {
208
+ if (ec2Config.identityAnchor !== origin.ec2Config.identityAnchor) {
209
+ const describe = (anchor) => anchor === undefined ? "unset" : `'${anchor}'`;
210
+ throw new Error(`Service '${serviceName}': ec2Config.identityAnchor ` +
211
+ `(${describe(ec2Config.identityAnchor)}) disagrees with service ` +
212
+ `'${origin.serviceName}' (${describe(origin.ec2Config.identityAnchor)}) ` +
213
+ `for capacity slot '${slot}' — a slot has exactly one identity. ` +
214
+ `Align the pin, or declare a second slot.`);
215
+ }
81
216
  // Cross-service only: the construction path legitimately re-resolves the
82
217
  // SAME service's provider (e.g. a second task definition riding its ASG),
83
218
  // and that re-entry must not trip the singleton throw.
84
219
  if (origin.ec2Config.persistentDataVolume !== undefined &&
85
220
  serviceName !== origin.serviceName) {
86
- throw new Error(`Service '${serviceName}': its ec2Config resolves to the same EC2 capacity key ('${key}') ` +
87
- `as service '${origin.serviceName}', whose persistentDataVolume pairs that auto scaling ` +
88
- `group with a single EBS data volume. A persistent-data-volume service is a singleton ` +
89
- `two services sharing its ASG would race to attach the same volume. Change a keyed field ` +
90
- `(${KEYED_ASG_FIELDS.map((f) => `'${f}'`).join(", ")}) so this service gets its own ASG.`);
221
+ throw new Error(`Service '${serviceName}': it declares the same capacity slot ` +
222
+ `('${slot}') as service '${origin.serviceName}', whose ` +
223
+ `persistentDataVolume pairs that auto scaling group with a single ` +
224
+ `EBS data volume. A persistent-data-volume slot is a singleton ` +
225
+ `two services sharing its ASG would race to attach the same volume. ` +
226
+ `Declare a separate slot for this service.`);
91
227
  }
92
228
  const differing = findDifferingAsgFields(ec2Config, origin.ec2Config);
93
229
  if (differing.length === 0)
94
230
  return;
95
231
  throw new Error(`Service '${serviceName}': ec2Config ${differing.map((f) => `'${f}'`).join(", ")} ` +
96
- `${differing.length === 1 ? "is" : "are"} ignored — it shares an auto scaling group with ` +
97
- `service '${origin.serviceName}' (both resolve to EC2 capacity key '${key}'), and that ASG ` +
98
- `is built from the first service's ec2Config. Either give the two services the same value ` +
99
- `for ${differing.map((f) => `'${f}'`).join(", ")}, or give this service its own ASG by ` +
100
- `changing a keyed field (${KEYED_ASG_FIELDS.map((f) => `'${f}'`).join(", ")}).`);
232
+ `${differing.length === 1 ? "is" : "are"} ignored — it shares capacity ` +
233
+ `slot '${slot}' with service '${origin.serviceName}', and that slot's ` +
234
+ `ASG is built from the first service's ec2Config. Align the two ` +
235
+ `services' values for ${differing.map((f) => `'${f}'`).join(", ")}, or ` +
236
+ `declare a second slot to give this service its own ASG.`);
101
237
  }
102
238
  /**
103
- * Generates a unique key for EC2 config so services with matching
104
- * configurations share an ASG.
239
+ * Config fields that fed the pre-6.0 (config-keyed) identity derivation
240
+ * the "identity derives from …" causes named in manifest identity aliases.
241
+ */
242
+ export const LEGACY_KEY_FIELDS = [
243
+ "instanceType",
244
+ "amiHardwareType",
245
+ "warmPool",
246
+ "persistentDataVolume",
247
+ "availabilityZones"
248
+ ];
249
+ /**
250
+ * The pre-6.0 config-derived identity key. SURVIVES ONLY as the legacy
251
+ * checksum behind manifest identity-alias hints (`legacyKey`/`legacyAnchor`)
252
+ * — it feeds no construct IDs, no physical names and no template properties.
253
+ * ASG sharing keys on the capacity SLOT.
105
254
  */
106
255
  export function getEc2ConfigKey(ec2Config) {
107
256
  const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
108
- const amiHardwareType = ec2Config.amiHardwareType ??
109
- (inferAmiHardwareType(instanceType) === AmiHardwareType.ARM
110
- ? "ARM"
111
- : "STANDARD");
257
+ const amiHardwareType = resolvedAmiHardwareLabel(ec2Config);
112
258
  const warmPoolKey = ec2Config.warmPool
113
259
  ? `wp${ec2Config.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE}-${ec2Config.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN}`
114
260
  : "nowp";
115
261
  const baseKey = `${instanceType}-${amiHardwareType}-${warmPoolKey}`;
116
- // PDV services are implicit singletons — two identical PDV configs still
117
- // collide on this key, so sharing is rejected in assertSharedAsgConfigMatches.
118
262
  const pdv = ec2Config.persistentDataVolume;
119
263
  const extras = [];
120
264
  if (pdv !== undefined) {
121
265
  extras.push(`pdv-${pdv.deviceName}-${pdv.sizeGb}-${stableAzSegment(pdv.availabilityZone)}`);
122
266
  }
123
267
  // Serialise actual AZ names — `az${length}` would let services pinned to different AZs collide on `az1`.
124
- // CDK Tokens (env-agnostic stacks) substitute a stable sentinel to keep logical IDs deterministic.
268
+ // CDK Tokens (env-agnostic stacks) substitute a stable sentinel to keep the checksum deterministic.
125
269
  if (ec2Config.availabilityZones !== undefined) {
126
270
  const azKey = [...ec2Config.availabilityZones]
127
271
  .map(stableAzSegment)
@@ -131,31 +275,49 @@ export function getEc2ConfigKey(ec2Config) {
131
275
  }
132
276
  return extras.length === 0 ? baseKey : `${baseKey}-${extras.join("-")}`;
133
277
  }
278
+ /**
279
+ * The anchor a pre-6.0 synth derived from a config key — the exact `safeKey`
280
+ * strip the factory used (`key.replace(/[^a-zA-Z0-9]/g, "")`). Feeds the
281
+ * manifest alias hints and pin capture; never a live identity surface.
282
+ */
283
+ export function legacyAnchorFromKey(key) {
284
+ return key.replace(/[^a-zA-Z0-9]/g, "");
285
+ }
134
286
  function stableAzSegment(az) {
135
287
  return Token.isUnresolved(az) ? "synthAz" : az;
136
288
  }
137
289
  /**
138
- * Cross-service shared-ASG compatibility: groups EC2 services by their
139
- * capacity key and rejects a reuse that is incompatible with the ASG's origin
140
- * — config drift the shared ASG would silently discard, or a
141
- * `persistentDataVolume` singleton being shared. Pure; called from BOTH
142
- * validation layers resources `validateEcsClusterProps` and patterns
143
- * `validateEcsProps` (generator-standards § "Validate at the Lowest Layer the
144
- * Field Belongs To") with the same check at the construction site
290
+ * Cross-service capacity-slot compatibility: validates slot grammar, rejects
291
+ * anchor collisions across slots, and rejects a slot reuse that is
292
+ * incompatible with the slot's origin — config drift the shared ASG would
293
+ * silently discard, a contested identity, or a `persistentDataVolume`
294
+ * singleton being shared. Pure; called from BOTH validation layers —
295
+ * resources `validateEcsClusterProps` and patterns `validateEcsProps`
296
+ * (generator-standards § "Validate at the Lowest Layer the Field Belongs To")
297
+ * — with the same checks at the construction site
145
298
  * (`getOrCreateAsgCapacityProvider`) as defence-in-depth.
299
+ *
300
+ * Anchor collisions are checked on the PIN-BLIND anchor (`identityAnchor` or
301
+ * slot derivation) — this layer is pure and cannot read the deploy-injected
302
+ * context pins. The factory re-checks with pins applied.
146
303
  */
147
304
  export function validateSharedEc2CapacityConfig(services) {
148
305
  const origins = new Map();
306
+ const anchorsBySlot = new Map();
149
307
  for (const service of services) {
150
308
  if (service.capacityProvider !== "EC2")
151
309
  continue;
152
310
  const ec2Config = service.ec2Config ?? {};
153
- const key = getEc2ConfigKey(ec2Config);
154
- const origin = origins.get(key);
311
+ const slot = resolveCapacitySlot(ec2Config);
312
+ assertValidCapacitySlot(slot);
313
+ const origin = origins.get(slot);
155
314
  if (origin === undefined) {
156
- origins.set(key, { serviceName: service.name, ec2Config });
315
+ const anchor = resolveCapacityAnchor(ec2Config);
316
+ assertAnchorUnique(slot, anchor, anchorsBySlot);
317
+ anchorsBySlot.set(slot, anchor);
318
+ origins.set(slot, { serviceName: service.name, ec2Config, anchor });
157
319
  continue;
158
320
  }
159
- assertSharedAsgConfigMatches(service.name, ec2Config, key, origin);
321
+ assertSharedAsgConfigMatches(service.name, ec2Config, slot, origin);
160
322
  }
161
323
  }
@@ -12,6 +12,21 @@ export declare const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
12
12
  export declare const DEFAULT_EC2_MIN_CAPACITY = 2;
13
13
  export declare const DEFAULT_EC2_MAX_CAPACITY = 3;
14
14
  export declare const DEFAULT_EC2_INSTANCE_MONITORING = Monitoring.BASIC;
15
+ /**
16
+ * `updatePolicy` construction defaults, read at the same two must-agree sites
17
+ * as the capacity defaults above: `resolveUpdatePolicy` (ec2.ts) applies them
18
+ * at construction; the shared-ASG drift compare (`ecsCapacityConfig.ts`
19
+ * DEEP_ASG_FIELD_PROJECTIONS) resolves them so an explicit value equal to the
20
+ * default never reads as drift. A `persistentDataVolume` ASG cannot surge
21
+ * (its single-attach EBS volume can only follow one instance), so it
22
+ * terminates-then-launches (0/100); every other ASG surges-then-shrinks
23
+ * (100/200) so capacity never dips.
24
+ */
25
+ export declare const INSTANCE_REFRESH_PDV_MIN_HEALTHY = 0;
26
+ export declare const INSTANCE_REFRESH_PDV_MAX_HEALTHY = 100;
27
+ export declare const INSTANCE_REFRESH_DEFAULT_MIN_HEALTHY = 100;
28
+ export declare const INSTANCE_REFRESH_DEFAULT_MAX_HEALTHY = 200;
29
+ export declare const DEFAULT_ROLLING_UPDATE_PAUSE_SECONDS = 300;
15
30
  /**
16
31
  * The container hard memory limit for an EC2-capacity ECS service.
17
32
  *
@@ -25,6 +25,21 @@ export const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
25
25
  export const DEFAULT_EC2_MIN_CAPACITY = 2;
26
26
  export const DEFAULT_EC2_MAX_CAPACITY = 3;
27
27
  export const DEFAULT_EC2_INSTANCE_MONITORING = Monitoring.BASIC;
28
+ /**
29
+ * `updatePolicy` construction defaults, read at the same two must-agree sites
30
+ * as the capacity defaults above: `resolveUpdatePolicy` (ec2.ts) applies them
31
+ * at construction; the shared-ASG drift compare (`ecsCapacityConfig.ts`
32
+ * DEEP_ASG_FIELD_PROJECTIONS) resolves them so an explicit value equal to the
33
+ * default never reads as drift. A `persistentDataVolume` ASG cannot surge
34
+ * (its single-attach EBS volume can only follow one instance), so it
35
+ * terminates-then-launches (0/100); every other ASG surges-then-shrinks
36
+ * (100/200) so capacity never dips.
37
+ */
38
+ export const INSTANCE_REFRESH_PDV_MIN_HEALTHY = 0;
39
+ export const INSTANCE_REFRESH_PDV_MAX_HEALTHY = 100;
40
+ export const INSTANCE_REFRESH_DEFAULT_MIN_HEALTHY = 100;
41
+ export const INSTANCE_REFRESH_DEFAULT_MAX_HEALTHY = 200;
42
+ export const DEFAULT_ROLLING_UPDATE_PAUSE_SECONDS = 300;
28
43
  /**
29
44
  * The container hard memory limit for an EC2-capacity ECS service.
30
45
  *
@@ -15,13 +15,14 @@ import type { EcsConstructContext } from "./ecsContext.js";
15
15
  export declare function resolveCircuitBreaker(config: false | {
16
16
  rollback?: boolean;
17
17
  } | undefined): DeploymentCircuitBreaker | undefined;
18
- /** Mutable state for ASG capacity provider deduplication. */
18
+ /** Mutable state for ASG capacity provider deduplication, keyed by SLOT. */
19
19
  export interface AsgCapacityState {
20
20
  providers: Map<string, AsgCapacityProvider>;
21
21
  /**
22
- * The service + ec2Config that first created each keyed ASG, so a later
23
- * service sharing the key can be checked against it
24
- * ({@link assertSharedAsgConfigMatches}).
22
+ * The service + ec2Config + anchor that first claimed each capacity slot,
23
+ * so a later service declaring the slot can be checked against it
24
+ * ({@link assertSharedAsgConfigMatches}) and anchor collisions across
25
+ * slots rejected ({@link assertAnchorUnique}).
25
26
  */
26
27
  asgOrigins: Map<string, Ec2AsgOrigin>;
27
28
  autoScalingGroup?: AutoScalingGroup;
@@ -30,9 +31,18 @@ export interface AsgCapacityState {
30
31
  export { getEc2ConfigKey, validateSharedEc2CapacityConfig, type Ec2AsgOrigin, type SharedEc2CapacityServiceInput } from "./ecsCapacityConfig.js";
31
32
  /**
32
33
  * Gets or creates an ASG capacity provider for an EC2-backed service.
33
- * Services with matching EC2 configs share the same ASG.
34
+ * Services declaring the same capacity SLOT share the same ASG.
34
35
  *
35
- * Mutates `state` to track the provider and first ASG/security group.
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.
42
+ *
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).
36
46
  */
37
47
  export declare function getOrCreateAsgCapacityProvider(ctx: EcsConstructContext, serviceProps: EcsServiceProps, state: AsgCapacityState): AsgCapacityProvider;
38
48
  /**
@@ -2,7 +2,7 @@ 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 } 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";
@@ -11,7 +11,9 @@ import { Ec2Instance } from "./ec2.js";
11
11
  import { vpcHasNatGateways } from "../../../utils/vpcUtils.js";
12
12
  import { toPascalCase } from "../../../utils/capitaliseString.js";
13
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 { assertSharedAsgConfigMatches, getEc2ConfigKey } from "./ecsCapacityConfig.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
  /**
@@ -29,23 +31,60 @@ export function resolveCircuitBreaker(config) {
29
31
  export { getEc2ConfigKey, validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
30
32
  /**
31
33
  * Gets or creates an ASG capacity provider for an EC2-backed service.
32
- * Services with matching EC2 configs share the same ASG.
34
+ * Services declaring the same capacity SLOT share the same ASG.
33
35
  *
34
- * Mutates `state` to track the provider and first ASG/security group.
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.
42
+ *
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).
35
46
  */
36
47
  export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
37
48
  const ec2Config = serviceProps.ec2Config ?? {};
38
- const key = getEc2ConfigKey(ec2Config);
39
- const existing = state.providers.get(key);
49
+ const slot = resolveCapacitySlot(ec2Config);
50
+ assertValidCapacitySlot(slot);
51
+ const existing = state.providers.get(slot);
40
52
  if (existing) {
41
- const origin = state.asgOrigins.get(key);
53
+ const origin = state.asgOrigins.get(slot);
42
54
  if (origin !== undefined) {
43
- assertSharedAsgConfigMatches(serviceProps.name, ec2Config, key, origin);
55
+ assertSharedAsgConfigMatches(serviceProps.name, ec2Config, slot, origin);
44
56
  }
45
57
  return existing;
46
58
  }
47
- state.asgOrigins.set(key, { serviceName: serviceProps.name, ec2Config });
48
- 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
+ }
49
88
  const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
50
89
  const amiHardwareType = ec2Config.amiHardwareType
51
90
  ? ec2Config.amiHardwareType === "STANDARD"
@@ -54,10 +93,14 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
54
93
  : inferAmiHardwareType(instanceType);
55
94
  const minCapacity = ec2Config.minCapacity ?? DEFAULT_EC2_MIN_CAPACITY;
56
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.
57
99
  const asgSecurityGroup = ctx.props.cluster?.securityGroup ??
58
- new SecurityGroup(ctx.scope, `${safeKey}AsgSecurityGroup`, {
100
+ new SecurityGroup(ctx.scope, `${anchor}AsgSecurityGroup`, {
59
101
  vpc: ctx.cluster.vpc,
60
- description: `Security group for ${key} auto scaling group`
102
+ description: pin?.legacySgDescription ??
103
+ `Security group for the ${physicalServiceName} auto scaling group`
61
104
  });
62
105
  if (ctx.directAccessEnabled) {
63
106
  for (const service of ctx.props.services) {
@@ -80,12 +123,12 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
80
123
  : undefined;
81
124
  // userData + role must be set on the LaunchTemplate or AsgCapacityProvider
82
125
  // throws at synth (ProvidedLaunchTemplateExposeUser/ExposeDefine).
83
- const instanceRole = new Role(ctx.scope, `${safeKey}InstanceRole`, {
126
+ const instanceRole = new Role(ctx.scope, `${anchor}InstanceRole`, {
84
127
  assumedBy: new ServicePrincipal("ec2.amazonaws.com"),
85
- description: `EC2 instance role for ${key} ECS capacity`
128
+ description: `EC2 instance role for the ${slot} ECS capacity slot`
86
129
  });
87
- const ec2Instance = new Ec2Instance(ctx.scope, `${safeKey}Ec2Instance`, {
88
- serviceName: `${ctx.props.clusterName}${safeKey}`,
130
+ const ec2Instance = new Ec2Instance(ctx.scope, `${anchor}Ec2Instance`, {
131
+ serviceName: physicalServiceName,
89
132
  vpc: ctx.cluster.vpc,
90
133
  vpcSubnets: {
91
134
  subnetType: hasNat ? SubnetType.PRIVATE_WITH_EGRESS : SubnetType.PUBLIC,
@@ -114,6 +157,9 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
114
157
  associatePublicIpAddress: ec2Config.associatePublicIpAddress
115
158
  }),
116
159
  ...(resolvedWarmPool !== undefined && { warmPool: resolvedWarmPool }),
160
+ ...(ec2Config.updatePolicy !== undefined && {
161
+ updatePolicy: ec2Config.updatePolicy
162
+ }),
117
163
  ...(ec2Config.persistentDataVolume !== undefined && {
118
164
  persistentDataVolume: {
119
165
  ...ec2Config.persistentDataVolume,
@@ -121,12 +167,15 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
121
167
  ctx.props.alertsTopic !== undefined && {
122
168
  alarmTopic: ctx.props.alertsTopic
123
169
  })
124
- }
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`
125
174
  }),
126
175
  ...(ec2Config.tags !== undefined && { tags: ec2Config.tags })
127
176
  });
128
177
  const asg = ec2Instance.getAutoScalingGroup();
129
- const provider = new AsgCapacityProvider(ctx.scope, `${safeKey}AsgCapacityProvider`, {
178
+ const provider = new AsgCapacityProvider(ctx.scope, `${anchor}AsgCapacityProvider`, {
130
179
  autoScalingGroup: asg,
131
180
  enableManagedDraining: true,
132
181
  // MTP's ProtectedFromScaleIn flag does NOT clear on CP deletion, so
@@ -139,7 +188,7 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
139
188
  enableManagedScaling: minCapacity !== maxCapacity
140
189
  });
141
190
  ctx.cluster.addAsgCapacityProvider(provider);
142
- state.providers.set(key, provider);
191
+ state.providers.set(slot, provider);
143
192
  if (!state.autoScalingGroup) {
144
193
  state.autoScalingGroup = asg;
145
194
  }