@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.
- package/dist/lib/config/aws/ebsDefaultEncryption.js +1 -1
- package/dist/lib/config/aws/inspectorEnablement.js +1 -1
- package/dist/lib/config/aws/oidcConnector.js +1 -1
- package/dist/lib/config/aws/s3BlockPublicAccess.js +1 -1
- package/dist/lib/config/aws/securityServicesAdmin.js +2 -2
- package/dist/lib/lambda-assets/cert-generator/asset/index.js +31 -31
- package/dist/lib/patterns/aws/clickhouseDatabase.js +4 -0
- package/dist/lib/patterns/aws/compute.js +1 -1
- package/dist/lib/patterns/aws/computeEcs.js +5 -0
- package/dist/lib/patterns/aws/computeLambda.d.ts +1 -1
- package/dist/lib/patterns/aws/devSubstrate.js +1 -1
- package/dist/lib/patterns/aws/payload.js +3 -3
- package/dist/lib/patterns/aws/staticSite.js +1 -1
- 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/ec2GracefulTerminationHandler.js +1 -1
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +83 -0
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +323 -0
- package/dist/lib/resources/aws/compute/ecsConstants.d.ts +19 -0
- package/dist/lib/resources/aws/compute/ecsConstants.js +24 -0
- package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +1 -1
- package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +20 -16
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +73 -118
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
- package/dist/lib/resources/aws/compute/ecsValidation.js +6 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +10 -7
- package/dist/lib/resources/aws/database/rdsInstance.js +1 -1
- package/dist/lib/resources/aws/iam/identityCenter/user.js +1 -1
- package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +1 -1
- package/dist/lib/resources/aws/networking/ipamPool.js +1 -1
- package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +1 -1
- package/dist/lib/resources/aws/utilities/tlsCertGenerator.js +1 -1
- package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
- package/dist/lib/utils/capacityIdentityContext.js +43 -0
- package/dist/lib/utils/engineCompat.d.ts +12 -5
- package/dist/lib/utils/engineCompat.js +41 -22
- package/dist/lib/utils/manifestWriter.d.ts +19 -2
- package/dist/lib/utils/manifestWriter.js +35 -0
- package/package.json +10 -6
- 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
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
|
|
2
|
+
import { Token } from "aws-cdk-lib";
|
|
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";
|
|
6
|
+
/**
|
|
7
|
+
* Every `Ec2CapacityConfig` field, classified. The `Record<keyof …, …>`
|
|
8
|
+
* satisfies-shape is a compile-time exhaustiveness guard in compiled `src/`
|
|
9
|
+
* (typescript-standards § "Compile-time guards must live in compiled src/"):
|
|
10
|
+
* adding a field to `Ec2CapacityConfig` without classifying it here fails
|
|
11
|
+
* `tsc`, so a new ASG-shaping field cannot silently join the "not compared,
|
|
12
|
+
* silently discarded on a shared ASG" bucket.
|
|
13
|
+
*/
|
|
14
|
+
const EC2_CONFIG_FIELD_ROLES = {
|
|
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"
|
|
33
|
+
};
|
|
34
|
+
function fieldsWithRole(role) {
|
|
35
|
+
return Object.keys(EC2_CONFIG_FIELD_ROLES).filter((field) => EC2_CONFIG_FIELD_ROLES[field] === role);
|
|
36
|
+
}
|
|
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
|
+
}
|
|
48
|
+
/**
|
|
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
|
|
53
|
+
* throw on a pair that synthesises identically. Fields with no construction
|
|
54
|
+
* default (`desiredCapacity`, `associatePublicIpAddress`) compare raw.
|
|
55
|
+
*/
|
|
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
|
|
62
|
+
};
|
|
63
|
+
function resolvedScalar(config, 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]);
|
|
125
|
+
}
|
|
126
|
+
/** Deep tag-record equality, treating `{}` and absent as the same ASG shape. */
|
|
127
|
+
function tagRecordsEqual(a, b) {
|
|
128
|
+
const aKeys = a === undefined ? [] : Object.keys(a);
|
|
129
|
+
const bKeys = b === undefined ? [] : Object.keys(b);
|
|
130
|
+
if (aKeys.length !== bKeys.length)
|
|
131
|
+
return false;
|
|
132
|
+
if (a === undefined || b === undefined)
|
|
133
|
+
return true;
|
|
134
|
+
return aKeys.every((key) => Object.is(a[key], b[key]));
|
|
135
|
+
}
|
|
136
|
+
function findDifferingAsgFields(ec2Config, origin) {
|
|
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
|
+
}
|
|
143
|
+
if (!tagRecordsEqual(ec2Config.tags, origin.tags))
|
|
144
|
+
differing.push("tags");
|
|
145
|
+
for (const field of OPAQUE_ASG_FIELDS) {
|
|
146
|
+
if ((ec2Config[field] === undefined) !== (origin[field] === undefined)) {
|
|
147
|
+
differing.push(field);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return differing;
|
|
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
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Rejects at synth when a service reusing a shared ASG is incompatible with
|
|
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`).
|
|
206
|
+
*/
|
|
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
|
+
}
|
|
216
|
+
// Cross-service only: the construction path legitimately re-resolves the
|
|
217
|
+
// SAME service's provider (e.g. a second task definition riding its ASG),
|
|
218
|
+
// and that re-entry must not trip the singleton throw.
|
|
219
|
+
if (origin.ec2Config.persistentDataVolume !== undefined &&
|
|
220
|
+
serviceName !== origin.serviceName) {
|
|
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.`);
|
|
227
|
+
}
|
|
228
|
+
const differing = findDifferingAsgFields(ec2Config, origin.ec2Config);
|
|
229
|
+
if (differing.length === 0)
|
|
230
|
+
return;
|
|
231
|
+
throw new Error(`Service '${serviceName}': ec2Config ${differing.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.`);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
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.
|
|
254
|
+
*/
|
|
255
|
+
export function getEc2ConfigKey(ec2Config) {
|
|
256
|
+
const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
|
|
257
|
+
const amiHardwareType = resolvedAmiHardwareLabel(ec2Config);
|
|
258
|
+
const warmPoolKey = ec2Config.warmPool
|
|
259
|
+
? `wp${ec2Config.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE}-${ec2Config.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN}`
|
|
260
|
+
: "nowp";
|
|
261
|
+
const baseKey = `${instanceType}-${amiHardwareType}-${warmPoolKey}`;
|
|
262
|
+
const pdv = ec2Config.persistentDataVolume;
|
|
263
|
+
const extras = [];
|
|
264
|
+
if (pdv !== undefined) {
|
|
265
|
+
extras.push(`pdv-${pdv.deviceName}-${pdv.sizeGb}-${stableAzSegment(pdv.availabilityZone)}`);
|
|
266
|
+
}
|
|
267
|
+
// Serialise actual AZ names — `az${length}` would let services pinned to different AZs collide on `az1`.
|
|
268
|
+
// CDK Tokens (env-agnostic stacks) substitute a stable sentinel to keep the checksum deterministic.
|
|
269
|
+
if (ec2Config.availabilityZones !== undefined) {
|
|
270
|
+
const azKey = [...ec2Config.availabilityZones]
|
|
271
|
+
.map(stableAzSegment)
|
|
272
|
+
.sort()
|
|
273
|
+
.join(",");
|
|
274
|
+
extras.push(`az-${azKey}`);
|
|
275
|
+
}
|
|
276
|
+
return extras.length === 0 ? baseKey : `${baseKey}-${extras.join("-")}`;
|
|
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
|
+
}
|
|
286
|
+
function stableAzSegment(az) {
|
|
287
|
+
return Token.isUnresolved(az) ? "synthAz" : az;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
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
|
|
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.
|
|
303
|
+
*/
|
|
304
|
+
export function validateSharedEc2CapacityConfig(services) {
|
|
305
|
+
const origins = new Map();
|
|
306
|
+
const anchorsBySlot = new Map();
|
|
307
|
+
for (const service of services) {
|
|
308
|
+
if (service.capacityProvider !== "EC2")
|
|
309
|
+
continue;
|
|
310
|
+
const ec2Config = service.ec2Config ?? {};
|
|
311
|
+
const slot = resolveCapacitySlot(ec2Config);
|
|
312
|
+
assertValidCapacitySlot(slot);
|
|
313
|
+
const origin = origins.get(slot);
|
|
314
|
+
if (origin === undefined) {
|
|
315
|
+
const anchor = resolveCapacityAnchor(ec2Config);
|
|
316
|
+
assertAnchorUnique(slot, anchor, anchorsBySlot);
|
|
317
|
+
anchorsBySlot.set(slot, anchor);
|
|
318
|
+
origins.set(slot, { serviceName: service.name, ec2Config, anchor });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
assertSharedAsgConfigMatches(service.name, ec2Config, slot, origin);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
|
|
1
2
|
import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
|
|
2
3
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
3
4
|
export declare const DEFAULT_EC2_INSTANCE_TYPE = "t4g.micro";
|
|
@@ -8,6 +9,24 @@ export declare const DEFAULT_LOG_RETENTION = RetentionDays.TWO_WEEKS;
|
|
|
8
9
|
export declare const DEFAULT_FARGATE_CPU = 256;
|
|
9
10
|
export declare const DEFAULT_FARGATE_MEMORY_MIB = 512;
|
|
10
11
|
export declare const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
|
|
12
|
+
export declare const DEFAULT_EC2_MIN_CAPACITY = 2;
|
|
13
|
+
export declare const DEFAULT_EC2_MAX_CAPACITY = 3;
|
|
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;
|
|
11
30
|
/**
|
|
12
31
|
* The container hard memory limit for an EC2-capacity ECS service.
|
|
13
32
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
|
|
1
2
|
import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
|
|
2
3
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
3
4
|
// Canonical source: @fjall/generator schemas/constants.ts — keep in sync
|
|
@@ -16,6 +17,29 @@ export const DEFAULT_LOG_RETENTION = RetentionDays.TWO_WEEKS;
|
|
|
16
17
|
export const DEFAULT_FARGATE_CPU = 256;
|
|
17
18
|
export const DEFAULT_FARGATE_MEMORY_MIB = 512;
|
|
18
19
|
export const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
|
|
20
|
+
// Read at two sites that must agree (code-quality § coupled values): the ASG
|
|
21
|
+
// construction (`getOrCreateAsgCapacityProvider`) and the shared-ASG drift
|
|
22
|
+
// compare (`ecsCapacityConfig.ts` SCALAR_ASG_FIELD_DEFAULTS) — the compare
|
|
23
|
+
// resolves the same defaults the construction applies, or an explicit value
|
|
24
|
+
// equal to the default would read as drift.
|
|
25
|
+
export const DEFAULT_EC2_MIN_CAPACITY = 2;
|
|
26
|
+
export const DEFAULT_EC2_MAX_CAPACITY = 3;
|
|
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;
|
|
19
43
|
/**
|
|
20
44
|
* The container hard memory limit for an EC2-capacity ECS service.
|
|
21
45
|
*
|
|
@@ -49,7 +49,7 @@ export class EcsLifecycleHookMigration extends Construct {
|
|
|
49
49
|
: "DISABLED"
|
|
50
50
|
};
|
|
51
51
|
this.lambda = new LambdaFunction(this, `${id}Fn`, {
|
|
52
|
-
runtime: Runtime.
|
|
52
|
+
runtime: Runtime.NODEJS_24_X,
|
|
53
53
|
handler: "index.handler",
|
|
54
54
|
code: Code.fromInline(source),
|
|
55
55
|
lambdaDescription: `${id} ECS deployment lifecycle hook for migration`,
|
|
@@ -3,7 +3,8 @@ import type { FargateTaskDefinition, Ec2TaskDefinition } from "aws-cdk-lib/aws-e
|
|
|
3
3
|
import { type ISecurityGroup } from "aws-cdk-lib/aws-ec2";
|
|
4
4
|
import { TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
|
|
5
5
|
import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
|
|
6
|
-
import { type
|
|
6
|
+
import { type Ec2AsgOrigin } from "./ecsCapacityConfig.js";
|
|
7
|
+
import { type EcsServiceProps, type QueueScalingResources } from "./ecsTypes.js";
|
|
7
8
|
import type { EcsConstructContext } from "./ecsContext.js";
|
|
8
9
|
/**
|
|
9
10
|
* Resolves the user's `circuitBreaker` config to the CDK
|
|
@@ -14,31 +15,34 @@ import type { EcsConstructContext } from "./ecsContext.js";
|
|
|
14
15
|
export declare function resolveCircuitBreaker(config: false | {
|
|
15
16
|
rollback?: boolean;
|
|
16
17
|
} | undefined): DeploymentCircuitBreaker | undefined;
|
|
17
|
-
/** Mutable state for ASG capacity provider deduplication. */
|
|
18
|
+
/** Mutable state for ASG capacity provider deduplication, keyed by SLOT. */
|
|
18
19
|
export interface AsgCapacityState {
|
|
19
20
|
providers: Map<string, AsgCapacityProvider>;
|
|
20
21
|
/**
|
|
21
|
-
* The service + ec2Config that first
|
|
22
|
-
* service
|
|
23
|
-
* ({@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}).
|
|
24
26
|
*/
|
|
25
|
-
asgOrigins: Map<string,
|
|
26
|
-
serviceName: string;
|
|
27
|
-
ec2Config: Ec2CapacityConfig;
|
|
28
|
-
}>;
|
|
27
|
+
asgOrigins: Map<string, Ec2AsgOrigin>;
|
|
29
28
|
autoScalingGroup?: AutoScalingGroup;
|
|
30
29
|
asgSecurityGroup?: ISecurityGroup;
|
|
31
30
|
}
|
|
32
|
-
|
|
33
|
-
* Generates a unique key for EC2 config so services with matching
|
|
34
|
-
* configurations share an ASG.
|
|
35
|
-
*/
|
|
36
|
-
export declare function getEc2ConfigKey(ec2Config: Ec2CapacityConfig): string;
|
|
31
|
+
export { getEc2ConfigKey, validateSharedEc2CapacityConfig, type Ec2AsgOrigin, type SharedEc2CapacityServiceInput } from "./ecsCapacityConfig.js";
|
|
37
32
|
/**
|
|
38
33
|
* Gets or creates an ASG capacity provider for an EC2-backed service.
|
|
39
|
-
* Services
|
|
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.
|
|
40
42
|
*
|
|
41
|
-
* 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).
|
|
42
46
|
*/
|
|
43
47
|
export declare function getOrCreateAsgCapacityProvider(ctx: EcsConstructContext, serviceProps: EcsServiceProps, state: AsgCapacityState): AsgCapacityProvider;
|
|
44
48
|
/**
|