@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.
- package/dist/lib/patterns/aws/clickhouseDatabase.js +4 -0
- package/dist/lib/resources/aws/compute/ec2.d.ts +31 -14
- package/dist/lib/resources/aws/compute/ec2.js +34 -10
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +56 -16
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +226 -64
- package/dist/lib/resources/aws/compute/ecsConstants.d.ts +15 -0
- package/dist/lib/resources/aws/compute/ecsConstants.js +15 -0
- package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +16 -6
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +68 -19
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +9 -6
- package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
- package/dist/lib/utils/capacityIdentityContext.js +43 -0
- package/dist/lib/utils/manifestWriter.d.ts +19 -2
- package/dist/lib/utils/manifestWriter.js +35 -0
- package/package.json +4 -5
- package/dist/lib/config/aws/identityCenterMembership.d.ts +0 -11
- package/dist/lib/config/aws/identityCenterMembership.js +0 -61
- package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets +0 -30
- package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets.mjs +0 -212
- package/dist/lib/patterns/aws/buildkite/alarms.d.ts +0 -25
- package/dist/lib/patterns/aws/buildkite/alarms.js +0 -78
- package/dist/lib/patterns/aws/domainDelegation.d.ts +0 -8
- package/dist/lib/patterns/aws/domainDelegation.js +0 -45
- package/dist/lib/patterns/aws/domainFactory.d.ts +0 -23
- package/dist/lib/patterns/aws/domainFactory.js +0 -61
- package/dist/lib/utils/addSuffixToEmail.d.ts +0 -1
- 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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
33
|
-
const
|
|
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
|
-
*
|
|
37
|
-
* ASG construction applies (`getOrCreateAsgCapacityProvider`), imported
|
|
38
|
-
* their `ecsConstants.ts` single source, so an explicit value equal to
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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:
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* as
|
|
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,
|
|
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}':
|
|
87
|
-
`as service '${origin.serviceName}', whose
|
|
88
|
-
`
|
|
89
|
-
`
|
|
90
|
-
`
|
|
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
|
|
97
|
-
`
|
|
98
|
-
`is built from the first service's ec2Config.
|
|
99
|
-
`for ${differing.map((f) => `'${f}'`).join(", ")}, or
|
|
100
|
-
`
|
|
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
|
-
*
|
|
104
|
-
*
|
|
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
|
|
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
|
|
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
|
|
139
|
-
*
|
|
140
|
-
* — config drift the shared ASG would
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* `
|
|
144
|
-
*
|
|
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
|
|
154
|
-
|
|
311
|
+
const slot = resolveCapacitySlot(ec2Config);
|
|
312
|
+
assertValidCapacitySlot(slot);
|
|
313
|
+
const origin = origins.get(slot);
|
|
155
314
|
if (origin === undefined) {
|
|
156
|
-
|
|
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,
|
|
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
|
|
23
|
-
* service
|
|
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
|
|
34
|
+
* Services declaring the same capacity SLOT share the same ASG.
|
|
34
35
|
*
|
|
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.
|
|
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
|
|
34
|
+
* Services declaring the same capacity SLOT share the same ASG.
|
|
33
35
|
*
|
|
34
|
-
*
|
|
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
|
|
39
|
-
|
|
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(
|
|
53
|
+
const origin = state.asgOrigins.get(slot);
|
|
42
54
|
if (origin !== undefined) {
|
|
43
|
-
assertSharedAsgConfigMatches(serviceProps.name, ec2Config,
|
|
55
|
+
assertSharedAsgConfigMatches(serviceProps.name, ec2Config, slot, origin);
|
|
44
56
|
}
|
|
45
57
|
return existing;
|
|
46
58
|
}
|
|
47
|
-
|
|
48
|
-
const
|
|
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, `${
|
|
100
|
+
new SecurityGroup(ctx.scope, `${anchor}AsgSecurityGroup`, {
|
|
59
101
|
vpc: ctx.cluster.vpc,
|
|
60
|
-
description:
|
|
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, `${
|
|
126
|
+
const instanceRole = new Role(ctx.scope, `${anchor}InstanceRole`, {
|
|
84
127
|
assumedBy: new ServicePrincipal("ec2.amazonaws.com"),
|
|
85
|
-
description: `EC2 instance role for ${
|
|
128
|
+
description: `EC2 instance role for the ${slot} ECS capacity slot`
|
|
86
129
|
});
|
|
87
|
-
const ec2Instance = new Ec2Instance(ctx.scope, `${
|
|
88
|
-
serviceName:
|
|
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, `${
|
|
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(
|
|
191
|
+
state.providers.set(slot, provider);
|
|
143
192
|
if (!state.autoScalingGroup) {
|
|
144
193
|
state.autoScalingGroup = asg;
|
|
145
194
|
}
|