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