@fjall/components-infrastructure 10.1.3 → 12.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.d.ts +13 -7
- package/dist/lib/patterns/aws/clickhouseDatabase.js +21 -16
- package/dist/lib/patterns/aws/computeEcs.js +7 -1
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +1 -1
- package/dist/lib/resources/aws/compute/ecs.js +1 -1
- package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +9 -5
- package/dist/lib/resources/aws/compute/ecsConstants.d.ts +24 -0
- package/dist/lib/resources/aws/compute/ecsConstants.js +109 -1
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +2 -2
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +1 -1
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +68 -1
- package/dist/lib/resources/aws/compute/ecsValidation.js +105 -2
- package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +24 -9
- package/dist/lib/resources/aws/database/clickhouseConstants.js +32 -23
- package/dist/lib/resources/aws/database/clickhouseSchemas.d.ts +20 -0
- package/dist/lib/resources/aws/database/clickhouseSchemas.js +20 -77
- package/dist/lib/resources/aws/database/clickhouseTuning.d.ts +64 -0
- package/dist/lib/resources/aws/database/clickhouseTuning.js +182 -0
- package/dist/lib/resources/aws/database/clickhouseUserData.d.ts +7 -0
- package/dist/lib/resources/aws/database/clickhouseUserData.js +87 -52
- package/dist/lib/resources/aws/database/clickhouseXmlRenderer.js +10 -0
- package/package.json +3 -3
|
@@ -20,10 +20,13 @@ export interface ClickHouseDatabaseProps {
|
|
|
20
20
|
/** EC2 instance type. Default: `m7g.medium` (1 vCPU sustained, 4 GiB);
|
|
21
21
|
* recommended step-up `r8g.medium` (8 GiB — see
|
|
22
22
|
* `DEFAULT_CLICKHOUSE_INSTANCE_TYPE`). Must appear in
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* `CLICKHOUSE_INSTANCE_SPECS` — the container memory limit
|
|
24
|
+
* (`clickHouseTaskMemoryMiB()`), the server pools/caches
|
|
25
|
+
* (`deriveClickHouseServerTuning()`) and the default profiles
|
|
26
|
+
* (`deriveClickHouseDefaultProfiles()`) ALL derive from it, and unknown
|
|
27
|
+
* types throw at synth. Server-side values are baked into EC2 user-data:
|
|
28
|
+
* taking a new type live needs a launch-template refresh + instance
|
|
29
|
+
* refresh + ClickHouse restart, not a config reload.
|
|
27
30
|
*
|
|
28
31
|
* WARNING — changing this on a DEPLOYED stack REPLACES the persistent
|
|
29
32
|
* data volume, not just the instance. The instance type is embedded in
|
|
@@ -103,9 +106,12 @@ export interface ClickHouseDatabaseProps {
|
|
|
103
106
|
managedPasswords?: string[];
|
|
104
107
|
/**
|
|
105
108
|
* Per-profile resource caps. Profile keys MUST match lowercase snake_case
|
|
106
|
-
* (`^[a-z][a-z0-9_]*$`). Defaults to
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
+
* (`^[a-z][a-z0-9_]*$`). Defaults to the four workload-class profiles
|
|
110
|
+
* (`high_throughput_ingest`, `audit_append`, `read_only`, `ddl_admin`)
|
|
111
|
+
* derived from the RESOLVED instance type via
|
|
112
|
+
* `deriveClickHouseDefaultProfiles()` — memory caps and thread counts
|
|
113
|
+
* track the host. Supplying this prop replaces the derived defaults
|
|
114
|
+
* wholesale (no merge), so an explicit map opts out of instance scaling.
|
|
109
115
|
*
|
|
110
116
|
* Throws `Error` on validation failure — unknown profile fields,
|
|
111
117
|
* non-snake_case names.
|
|
@@ -20,9 +20,10 @@ import { buildClickHouseEntrypointWrapper, buildClickHouseUserData, generateUser
|
|
|
20
20
|
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
21
21
|
import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
|
|
22
22
|
import { createClickHouseAlarms } from "../../resources/aws/monitoring/index.js";
|
|
23
|
-
import { ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema,
|
|
23
|
+
import { ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, PROFILE_NAME_PATTERN } from "../../resources/aws/database/clickhouseSchemas.js";
|
|
24
|
+
import { deriveClickHouseDefaultProfiles } from "../../resources/aws/database/clickhouseTuning.js";
|
|
24
25
|
import { inferAmiHardwareType } from "../../resources/aws/compute/ecsConstants.js";
|
|
25
|
-
import { CLICKHOUSE_DATABASE_NAME, DEFAULT_CLICKHOUSE_INSTANCE_TYPE, CLICKHOUSE_IMAGE, CLICKHOUSE_EBS_VOLUME_SIZE_GB, CLICKHOUSE_EBS_IOPS, CLICKHOUSE_EBS_THROUGHPUT_MBPS, clickHouseTaskMemoryMiB, CLICKHOUSE_HTTP_PORT, CLICKHOUSE_HTTPS_PORT, CLICKHOUSE_NATIVE_PORT, CLICKHOUSE_TCP_SECURE_PORT, CLICKHOUSE_TLS_CERT_MOUNT_PATH, CLICKHOUSE_PROMETHEUS_PORT, CLICKHOUSE_DATA_MOUNT_PATH, CLICKHOUSE_SECRET_OPTIONS, CLICKHOUSE_SERVER_ROLE_TAG, CLICKHOUSE_HOST_METRICS, clickHouseUserSecretName, CLICKHOUSE_HEALTH_CHECK, CLICKHOUSE_STOP_TIMEOUT_SECONDS, CLICKHOUSE_EBS_DEVICE_NAME, CLICKHOUSE_CONFIG_SUBDIR, CLICKHOUSE_USERS_SUBDIR, userPasswordEnvName, OPTIMISE_FINAL_SCHEDULE, REPLACING_MERGE_TREE_TABLES, OPTIMISE_MV_TABLES, CLICKHOUSE_CLOUDMAP_SERVICE_NAME, CLICKHOUSE_SERVER_CONTAINER_NAME, OPTIMISE_TASK_MEMORY_MIB, OPTIMISE_TASK_CPU_UNITS, BACKUP_SCHEDULE, BACKUP_TASK_MEMORY_MIB, BACKUP_TASK_CPU_UNITS, BACKUP_RETENTION_DAYS } from "../../resources/aws/database/clickhouseConstants.js";
|
|
26
|
+
import { CLICKHOUSE_DATABASE_NAME, DEFAULT_CLICKHOUSE_INSTANCE_TYPE, CLICKHOUSE_IMAGE, CLICKHOUSE_EBS_VOLUME_SIZE_GB, CLICKHOUSE_EBS_IOPS, CLICKHOUSE_EBS_THROUGHPUT_MBPS, clickHouseTaskMemoryMiB, CLICKHOUSE_HTTP_PORT, CLICKHOUSE_HTTPS_PORT, CLICKHOUSE_NATIVE_PORT, CLICKHOUSE_TCP_SECURE_PORT, CLICKHOUSE_TLS_CERT_MOUNT_PATH, CLICKHOUSE_PROMETHEUS_PORT, CLICKHOUSE_DATA_MOUNT_PATH, CLICKHOUSE_SECRET_OPTIONS, CLICKHOUSE_SERVER_ROLE_TAG, CLICKHOUSE_HOST_METRICS, clickHouseUserSecretName, CLICKHOUSE_HEALTH_CHECK, CLICKHOUSE_STOP_TIMEOUT_SECONDS, CLICKHOUSE_EBS_DEVICE_NAME, CLICKHOUSE_CONFIG_SUBDIR, CLICKHOUSE_USERS_SUBDIR, userPasswordEnvName, OPTIMISE_FINAL_SCHEDULE, REPLACING_MERGE_TREE_TABLES, OPTIMISE_MV_TABLES, CLICKHOUSE_CLOUDMAP_SERVICE_NAME, CLICKHOUSE_SERVER_CONTAINER_NAME, CLICKHOUSE_SERVICE_NAME, OPTIMISE_TASK_MEMORY_MIB, OPTIMISE_TASK_CPU_UNITS, BACKUP_SCHEDULE, BACKUP_TASK_MEMORY_MIB, BACKUP_TASK_CPU_UNITS, BACKUP_RETENTION_DAYS } from "../../resources/aws/database/clickhouseConstants.js";
|
|
26
27
|
import { TlsCertGenerator } from "../../resources/aws/utilities/tlsCertGenerator.js";
|
|
27
28
|
import { EcsCompute } from "./computeEcs.js";
|
|
28
29
|
/**
|
|
@@ -134,7 +135,20 @@ export class ClickHouseDatabase extends Construct {
|
|
|
134
135
|
throw new Error(`ClickHouseDatabase: invalid managedPasswords: prop — ${managedPasswordsParse.error.message}`);
|
|
135
136
|
}
|
|
136
137
|
const managedPasswords = managedPasswordsParse.data;
|
|
137
|
-
const
|
|
138
|
+
const contextValue = this.node.tryGetContext("clickhouseInstanceType");
|
|
139
|
+
const contextInstanceType = typeof contextValue === "string" && contextValue !== ""
|
|
140
|
+
? contextValue
|
|
141
|
+
: undefined;
|
|
142
|
+
const propInstanceType = props.instanceType !== undefined && props.instanceType !== ""
|
|
143
|
+
? props.instanceType
|
|
144
|
+
: undefined;
|
|
145
|
+
const instanceType = contextInstanceType ??
|
|
146
|
+
propInstanceType ??
|
|
147
|
+
DEFAULT_CLICKHOUSE_INSTANCE_TYPE;
|
|
148
|
+
// Default profiles derive from the RESOLVED instance type so memory caps
|
|
149
|
+
// and thread counts track the actual host — a caller-supplied `profiles:`
|
|
150
|
+
// map opts out of scaling entirely (their values, their responsibility).
|
|
151
|
+
const profiles = props.profiles ?? deriveClickHouseDefaultProfiles(instanceType);
|
|
138
152
|
const ProfilesRecordSchema = z.record(z
|
|
139
153
|
.string()
|
|
140
154
|
.regex(PROFILE_NAME_PATTERN, "Profile name must be lowercase snake_case"), ProfileSpecSchema);
|
|
@@ -157,16 +171,6 @@ export class ClickHouseDatabase extends Construct {
|
|
|
157
171
|
schemaAdmin.name,
|
|
158
172
|
...managedPasswords
|
|
159
173
|
];
|
|
160
|
-
const contextValue = this.node.tryGetContext("clickhouseInstanceType");
|
|
161
|
-
const contextInstanceType = typeof contextValue === "string" && contextValue !== ""
|
|
162
|
-
? contextValue
|
|
163
|
-
: undefined;
|
|
164
|
-
const propInstanceType = props.instanceType !== undefined && props.instanceType !== ""
|
|
165
|
-
? props.instanceType
|
|
166
|
-
: undefined;
|
|
167
|
-
const instanceType = contextInstanceType ??
|
|
168
|
-
propInstanceType ??
|
|
169
|
-
DEFAULT_CLICKHOUSE_INSTANCE_TYPE;
|
|
170
174
|
const desiredCount = resolveClickHouseDesiredCount(this.node.tryGetContext("clickhouseDesiredCount"), props.desiredCount);
|
|
171
175
|
const optimiseEnabled = props.optimiseSchedule !== false;
|
|
172
176
|
const backupEnabled = props.backupSchedule !== false;
|
|
@@ -258,6 +262,7 @@ export class ClickHouseDatabase extends Construct {
|
|
|
258
262
|
const userData = UserData.custom(buildClickHouseUserData({
|
|
259
263
|
backupBucketName: backupBucket.bucketName,
|
|
260
264
|
backupBucketRegion: Stack.of(this).region,
|
|
265
|
+
instanceType,
|
|
261
266
|
...(coldTierBucket !== undefined && {
|
|
262
267
|
coldTier: {
|
|
263
268
|
bucketName: coldTierBucket.bucketName,
|
|
@@ -372,7 +377,7 @@ export class ClickHouseDatabase extends Construct {
|
|
|
372
377
|
},
|
|
373
378
|
services: [
|
|
374
379
|
{
|
|
375
|
-
name:
|
|
380
|
+
name: CLICKHOUSE_SERVICE_NAME,
|
|
376
381
|
capacityProvider: "EC2",
|
|
377
382
|
desiredCount,
|
|
378
383
|
// Omitting `scaling` attaches default CPU target tracking. A scaled-out
|
|
@@ -484,9 +489,9 @@ export class ClickHouseDatabase extends Construct {
|
|
|
484
489
|
}
|
|
485
490
|
]
|
|
486
491
|
});
|
|
487
|
-
const clickHouseTaskDef = ecsCompute.getTaskDefinition(
|
|
492
|
+
const clickHouseTaskDef = ecsCompute.getTaskDefinition(CLICKHOUSE_SERVICE_NAME);
|
|
488
493
|
if (!clickHouseTaskDef) {
|
|
489
|
-
throw new Error(
|
|
494
|
+
throw new Error(`ClickHouseDatabase: EcsCompute did not expose a ${CLICKHOUSE_SERVICE_NAME} task definition — expected the service to be registered.`);
|
|
490
495
|
}
|
|
491
496
|
backupBucket.grantReadWrite(clickHouseTaskDef.taskRole);
|
|
492
497
|
coldTierBucket?.grantReadWrite(clickHouseTaskDef.taskRole);
|
|
@@ -28,7 +28,7 @@ import { evaluateBakeGuard } from "@fjall/util/docker";
|
|
|
28
28
|
import { toKebab, buildParameterPath } from "@fjall/util";
|
|
29
29
|
import { SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_ECR_REPO_NAME } from "@fjall/util/migration";
|
|
30
30
|
import { CONSTRUCTS_VERSION } from "../../utils/engineCompat.js";
|
|
31
|
-
import { validateEc2ServiceSizing, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
|
|
31
|
+
import { validateEc2ServiceSizing, validateEc2TaskMemoryFit, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
|
|
32
32
|
import { validateSharedEc2CapacityConfig } from "../../resources/aws/compute/ecsCapacityConfig.js";
|
|
33
33
|
import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
|
|
34
34
|
import { isHookMigrations, normaliseSchemaGate } from "./computeEcsTypes.js";
|
|
@@ -183,6 +183,12 @@ export function validateEcsProps(props) {
|
|
|
183
183
|
"The ec2Config will be ignored unless capacityProvider is set to 'EC2'.");
|
|
184
184
|
}
|
|
185
185
|
validateEc2ServiceSizing(service);
|
|
186
|
+
// Throw-only at this layer: the gate container is not yet injected here
|
|
187
|
+
// (wireSchemaGate prepends it later), so the demand under-counts by the
|
|
188
|
+
// gate's 512 MiB — a conservative early throw. The resources-layer call
|
|
189
|
+
// in validateEcsClusterProps sees the final wired containers and also
|
|
190
|
+
// emits the non-fatal warnings.
|
|
191
|
+
validateEc2TaskMemoryFit(service);
|
|
186
192
|
if (typeof service.circuitBreaker === "object") {
|
|
187
193
|
const threshold = service.circuitBreaker.threshold;
|
|
188
194
|
if (threshold !== undefined &&
|
|
@@ -825,7 +825,7 @@ export interface EcsServiceConfig {
|
|
|
825
825
|
* {
|
|
826
826
|
* services: [
|
|
827
827
|
* { name: "api", capacityProvider: "FARGATE" },
|
|
828
|
-
* { name: "worker", capacityProvider: "EC2", ec2Config: { instanceType: "t4g.
|
|
828
|
+
* { name: "worker", capacityProvider: "EC2", ec2Config: { instanceType: "t4g.small" } }
|
|
829
829
|
* ]
|
|
830
830
|
* }
|
|
831
831
|
*/
|
|
@@ -94,7 +94,7 @@ export default class EcsCluster extends Construct {
|
|
|
94
94
|
this.directAccessEnabled = props.cluster?.directAccess === true;
|
|
95
95
|
this.loadBalancerDisabled =
|
|
96
96
|
props.cluster?.loadBalancer === false || this.directAccessEnabled;
|
|
97
|
-
validateEcsClusterProps(props);
|
|
97
|
+
validateEcsClusterProps(props, this);
|
|
98
98
|
this.cluster = this.addCluster(props);
|
|
99
99
|
for (const serviceProps of props.services) {
|
|
100
100
|
if (serviceProps.capacityProvider === "EC2") {
|
|
@@ -2,7 +2,7 @@ import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
|
|
|
2
2
|
import { Token } from "aws-cdk-lib";
|
|
3
3
|
import { CAPACITY_ANCHOR_MAX_LENGTH, CAPACITY_ANCHOR_PATTERN, CAPACITY_SLOT_MAX_LENGTH, CAPACITY_SLOT_PATTERN, DEFAULT_CAPACITY_SLOT } from "@fjall/util";
|
|
4
4
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
5
|
-
import {
|
|
5
|
+
import { DEFAULT_ECS_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
6
|
/**
|
|
7
7
|
* Every `Ec2CapacityConfig` field, classified. The `Record<keyof …, …>`
|
|
8
8
|
* satisfies-shape is a compile-time exhaustiveness guard in compiled `src/`
|
|
@@ -40,8 +40,7 @@ const OPAQUE_ASG_FIELDS = fieldsWithRole("property:opaque");
|
|
|
40
40
|
/** The `"ARM" | "STANDARD"` label the config key and drift compare share. */
|
|
41
41
|
function resolvedAmiHardwareLabel(config) {
|
|
42
42
|
return (config.amiHardwareType ??
|
|
43
|
-
(inferAmiHardwareType(config.instanceType ??
|
|
44
|
-
AmiHardwareType.ARM
|
|
43
|
+
(inferAmiHardwareType(config.instanceType ?? DEFAULT_ECS_EC2_INSTANCE_TYPE) === AmiHardwareType.ARM
|
|
45
44
|
? "ARM"
|
|
46
45
|
: "STANDARD"));
|
|
47
46
|
}
|
|
@@ -54,7 +53,7 @@ function resolvedAmiHardwareLabel(config) {
|
|
|
54
53
|
* default (`desiredCapacity`, `associatePublicIpAddress`) compare raw.
|
|
55
54
|
*/
|
|
56
55
|
const SCALAR_ASG_FIELD_RESOLVERS = {
|
|
57
|
-
instanceType: (c) => c.instanceType ??
|
|
56
|
+
instanceType: (c) => c.instanceType ?? DEFAULT_ECS_EC2_INSTANCE_TYPE,
|
|
58
57
|
amiHardwareType: resolvedAmiHardwareLabel,
|
|
59
58
|
minCapacity: (c) => c.minCapacity ?? DEFAULT_EC2_MIN_CAPACITY,
|
|
60
59
|
maxCapacity: (c) => c.maxCapacity ?? DEFAULT_EC2_MAX_CAPACITY,
|
|
@@ -246,6 +245,11 @@ export const LEGACY_KEY_FIELDS = [
|
|
|
246
245
|
"persistentDataVolume",
|
|
247
246
|
"availabilityZones"
|
|
248
247
|
];
|
|
248
|
+
// The ECS EC2 instance-type default as it stood when the pre-6.0 legacy key
|
|
249
|
+
// format was live. FROZEN: the legacy checksum must keep matching what
|
|
250
|
+
// deployed pre-6.0 stacks derived, so it must not follow
|
|
251
|
+
// DEFAULT_ECS_EC2_INSTANCE_TYPE.
|
|
252
|
+
const LEGACY_KEY_DEFAULT_INSTANCE_TYPE = "t4g.micro";
|
|
249
253
|
/**
|
|
250
254
|
* The pre-6.0 config-derived identity key. SURVIVES ONLY as the legacy
|
|
251
255
|
* checksum behind manifest identity-alias hints (`legacyKey`/`legacyAnchor`)
|
|
@@ -253,7 +257,7 @@ export const LEGACY_KEY_FIELDS = [
|
|
|
253
257
|
* ASG sharing keys on the capacity SLOT.
|
|
254
258
|
*/
|
|
255
259
|
export function getEc2ConfigKey(ec2Config) {
|
|
256
|
-
const instanceType = ec2Config.instanceType ??
|
|
260
|
+
const instanceType = ec2Config.instanceType ?? LEGACY_KEY_DEFAULT_INSTANCE_TYPE;
|
|
257
261
|
const amiHardwareType = resolvedAmiHardwareLabel(ec2Config);
|
|
258
262
|
const warmPoolKey = ec2Config.warmPool
|
|
259
263
|
? `wp${ec2Config.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE}-${ec2Config.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN}`
|
|
@@ -2,6 +2,7 @@ import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
|
|
|
2
2
|
import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
|
|
3
3
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
4
4
|
export declare const DEFAULT_EC2_INSTANCE_TYPE = "t4g.micro";
|
|
5
|
+
export declare const DEFAULT_ECS_EC2_INSTANCE_TYPE = "t4g.small";
|
|
5
6
|
export declare const DEFAULT_WARM_POOL_MIN_SIZE = 1;
|
|
6
7
|
export declare const DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN = false;
|
|
7
8
|
export declare const DEFAULT_LOG_RETENTION_DAYS = 14;
|
|
@@ -75,3 +76,26 @@ export declare const ARM_INSTANCE_PREFIXES: string[];
|
|
|
75
76
|
* @returns AmiHardwareType.ARM for Graviton instances, AmiHardwareType.STANDARD for Intel/AMD
|
|
76
77
|
*/
|
|
77
78
|
export declare function inferAmiHardwareType(instanceType: string): AmiHardwareType;
|
|
79
|
+
/**
|
|
80
|
+
* The nominal (advertised) memory of an EC2 instance type, or `undefined` when
|
|
81
|
+
* the family or size is outside the allow-list (metal sizes, network variants,
|
|
82
|
+
* free-form strings) — callers MUST treat `undefined` as "cannot judge" and
|
|
83
|
+
* skip, never as zero.
|
|
84
|
+
*
|
|
85
|
+
* Nominal is an upper bound on what ECS can place against: the agent, kernel
|
|
86
|
+
* and system reserve consume a slice before the instance registers with the
|
|
87
|
+
* cluster (a t4g.medium registers 3835 of its nominal 4096 — the measured
|
|
88
|
+
* ground truth from the 2026-08-12 deploy-worker wedge). So a task demand
|
|
89
|
+
* `>= nominal` can NEVER be placed, and {@link estimatedEc2MemoryReserveMiB}
|
|
90
|
+
* approximates the slice for the near-miss warning band.
|
|
91
|
+
*/
|
|
92
|
+
export declare function nominalEc2InstanceMemoryMiB(instanceType: string): number | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Estimated MiB the ECS agent + OS reserve consume before an instance
|
|
95
|
+
* registers its memory with the cluster. 6.5% is calibrated on the one
|
|
96
|
+
* measured point we own (t4g.medium: 4096 nominal − 3835 registered = 261,
|
|
97
|
+
* 6.4%), rounded up because under-estimating the reserve turns the warning
|
|
98
|
+
* band into a false pass; the 128 floor covers the smallest sizes where a
|
|
99
|
+
* percentage underestimates the fixed kernel cost.
|
|
100
|
+
*/
|
|
101
|
+
export declare function estimatedEc2MemoryReserveMiB(nominalMiB: number): number;
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
|
|
2
2
|
import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
|
|
3
3
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
4
|
-
// Canonical source: @fjall/generator schemas/constants.ts — keep in sync
|
|
4
|
+
// Canonical source: @fjall/generator schemas/constants.ts — keep in sync.
|
|
5
|
+
// Standalone EC2 instances (the compute:ec2 pattern) only; ECS EC2 capacity
|
|
6
|
+
// defaults to DEFAULT_ECS_EC2_INSTANCE_TYPE below.
|
|
5
7
|
export const DEFAULT_EC2_INSTANCE_TYPE = "t4g.micro";
|
|
8
|
+
// Smallest type whose usable memory (nominal 2048 minus the agent/OS reserve)
|
|
9
|
+
// fits the default container hard limit (1024) plus the schema-gate sidecar
|
|
10
|
+
// (512). A t4g.micro registers under 1024 with ECS, so a micro default made
|
|
11
|
+
// every defaults-only EC2 service structurally unplaceable — the same class
|
|
12
|
+
// as the 2026-08-12 deploy-worker wedge, present from first deploy.
|
|
13
|
+
export const DEFAULT_ECS_EC2_INSTANCE_TYPE = "t4g.small";
|
|
6
14
|
export const DEFAULT_WARM_POOL_MIN_SIZE = 1;
|
|
7
15
|
// reuseOnScaleIn: true returns scaled-in instances to the warm pool Stopped,
|
|
8
16
|
// which can strand ECS tasks DRAINING indefinitely (the 2026-06-04 outage).
|
|
@@ -130,3 +138,103 @@ export function inferAmiHardwareType(instanceType) {
|
|
|
130
138
|
? AmiHardwareType.ARM
|
|
131
139
|
: AmiHardwareType.STANDARD;
|
|
132
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Nominal (advertised) memory at the `.medium` size for the EC2 families the
|
|
143
|
+
* memory-fit guard recognises. Sizes scale linearly from this base (nano ×1/8,
|
|
144
|
+
* micro ×1/4, small ×1/2, medium ×1, large ×2, xlarge ×4, Nxlarge ×4N), so one
|
|
145
|
+
* number per family covers the whole size ladder.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately an allow-list, not a parser: network/local-NVMe variants
|
|
148
|
+
* (c5n, c6gn, m5zn, …) and exotic families carry different memory-per-size
|
|
149
|
+
* ratios, and a wrong nominal would turn the fit guard's verdict into a false
|
|
150
|
+
* rejection or a false pass. Unknown families make the guard skip instead —
|
|
151
|
+
* add a family here only with its ratio verified against the EC2 sizing table.
|
|
152
|
+
*/
|
|
153
|
+
const EC2_FAMILY_MEDIUM_MEMORY_MIB = {
|
|
154
|
+
// 4 GiB at .medium — burstable and general-purpose
|
|
155
|
+
t2: 4096,
|
|
156
|
+
t3: 4096,
|
|
157
|
+
t3a: 4096,
|
|
158
|
+
t4g: 4096,
|
|
159
|
+
m5: 4096,
|
|
160
|
+
m5a: 4096,
|
|
161
|
+
m6g: 4096,
|
|
162
|
+
m6gd: 4096,
|
|
163
|
+
m6i: 4096,
|
|
164
|
+
m7g: 4096,
|
|
165
|
+
m7gd: 4096,
|
|
166
|
+
m7i: 4096,
|
|
167
|
+
m8g: 4096,
|
|
168
|
+
m8gd: 4096,
|
|
169
|
+
// 2 GiB at .medium — compute-optimised
|
|
170
|
+
c5: 2048,
|
|
171
|
+
c6g: 2048,
|
|
172
|
+
c6gd: 2048,
|
|
173
|
+
c6i: 2048,
|
|
174
|
+
c7g: 2048,
|
|
175
|
+
c7gd: 2048,
|
|
176
|
+
c7i: 2048,
|
|
177
|
+
c8g: 2048,
|
|
178
|
+
c8gd: 2048,
|
|
179
|
+
// 8 GiB at .medium — memory-optimised
|
|
180
|
+
r5: 8192,
|
|
181
|
+
r6g: 8192,
|
|
182
|
+
r6gd: 8192,
|
|
183
|
+
r6i: 8192,
|
|
184
|
+
r7g: 8192,
|
|
185
|
+
r7gd: 8192,
|
|
186
|
+
r8g: 8192,
|
|
187
|
+
r8gd: 8192
|
|
188
|
+
};
|
|
189
|
+
const EC2_SIZE_MEMORY_MULTIPLIER = {
|
|
190
|
+
nano: 0.125,
|
|
191
|
+
micro: 0.25,
|
|
192
|
+
small: 0.5,
|
|
193
|
+
medium: 1,
|
|
194
|
+
large: 2,
|
|
195
|
+
xlarge: 4
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* The nominal (advertised) memory of an EC2 instance type, or `undefined` when
|
|
199
|
+
* the family or size is outside the allow-list (metal sizes, network variants,
|
|
200
|
+
* free-form strings) — callers MUST treat `undefined` as "cannot judge" and
|
|
201
|
+
* skip, never as zero.
|
|
202
|
+
*
|
|
203
|
+
* Nominal is an upper bound on what ECS can place against: the agent, kernel
|
|
204
|
+
* and system reserve consume a slice before the instance registers with the
|
|
205
|
+
* cluster (a t4g.medium registers 3835 of its nominal 4096 — the measured
|
|
206
|
+
* ground truth from the 2026-08-12 deploy-worker wedge). So a task demand
|
|
207
|
+
* `>= nominal` can NEVER be placed, and {@link estimatedEc2MemoryReserveMiB}
|
|
208
|
+
* approximates the slice for the near-miss warning band.
|
|
209
|
+
*/
|
|
210
|
+
export function nominalEc2InstanceMemoryMiB(instanceType) {
|
|
211
|
+
const parts = instanceType.split(".");
|
|
212
|
+
if (parts.length !== 2)
|
|
213
|
+
return undefined;
|
|
214
|
+
const family = parts[0];
|
|
215
|
+
const size = parts[1];
|
|
216
|
+
if (family === undefined || size === undefined)
|
|
217
|
+
return undefined;
|
|
218
|
+
const mediumMemoryMiB = EC2_FAMILY_MEDIUM_MEMORY_MIB[family];
|
|
219
|
+
if (mediumMemoryMiB === undefined)
|
|
220
|
+
return undefined;
|
|
221
|
+
const fixedMultiplier = EC2_SIZE_MEMORY_MULTIPLIER[size];
|
|
222
|
+
if (fixedMultiplier !== undefined)
|
|
223
|
+
return mediumMemoryMiB * fixedMultiplier;
|
|
224
|
+
const scaledSize = /^(\d+)xlarge$/.exec(size);
|
|
225
|
+
if (scaledSize?.[1] !== undefined) {
|
|
226
|
+
return mediumMemoryMiB * 4 * parseInt(scaledSize[1], 10);
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Estimated MiB the ECS agent + OS reserve consume before an instance
|
|
232
|
+
* registers its memory with the cluster. 6.5% is calibrated on the one
|
|
233
|
+
* measured point we own (t4g.medium: 4096 nominal − 3835 registered = 261,
|
|
234
|
+
* 6.4%), rounded up because under-estimating the reserve turns the warning
|
|
235
|
+
* band into a false pass; the 128 floor covers the smallest sizes where a
|
|
236
|
+
* percentage underestimates the fixed kernel cost.
|
|
237
|
+
*/
|
|
238
|
+
export function estimatedEc2MemoryReserveMiB(nominalMiB) {
|
|
239
|
+
return Math.max(128, Math.ceil(nominalMiB * 0.065));
|
|
240
|
+
}
|
|
@@ -11,7 +11,7 @@ import { Ec2Instance } from "./ec2.js";
|
|
|
11
11
|
import { vpcHasNatGateways } from "../../../utils/vpcUtils.js";
|
|
12
12
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
13
13
|
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
14
|
-
import {
|
|
14
|
+
import { DEFAULT_ECS_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";
|
|
15
15
|
import { assertAnchorUnique, assertSharedAsgConfigMatches, assertValidCapacitySlot, getEc2ConfigKey, legacyAnchorFromKey, LEGACY_KEY_FIELDS, resolveCapacityAnchor, resolveCapacitySlot } from "./ecsCapacityConfig.js";
|
|
16
16
|
import { lookupCapacityIdentityPin } from "../../../utils/capacityIdentityContext.js";
|
|
17
17
|
import { getCurrentCollector } from "../../../utils/manifestWriter.js";
|
|
@@ -114,7 +114,7 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
|
|
|
114
114
|
legacyKeyFields: [...LEGACY_KEY_FIELDS]
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
-
const instanceType = ec2Config.instanceType ??
|
|
117
|
+
const instanceType = ec2Config.instanceType ?? DEFAULT_ECS_EC2_INSTANCE_TYPE;
|
|
118
118
|
const amiHardwareType = ec2Config.amiHardwareType
|
|
119
119
|
? ec2Config.amiHardwareType === "STANDARD"
|
|
120
120
|
? AmiHardwareType.STANDARD
|
|
@@ -114,7 +114,7 @@ export interface Ec2CapacityConfig {
|
|
|
114
114
|
* should rely on the slot derivation.
|
|
115
115
|
*/
|
|
116
116
|
identityAnchor?: string;
|
|
117
|
-
/** EC2 instance type. Default: "t4g.
|
|
117
|
+
/** EC2 instance type. Default: "t4g.small" */
|
|
118
118
|
instanceType?: string;
|
|
119
119
|
/** AMI hardware type. Default: "ARM" (Graviton - better cost/performance) */
|
|
120
120
|
amiHardwareType?: "ARM" | "STANDARD";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IConstruct } from "constructs";
|
|
1
2
|
import type { DomainConfig, EcsCapacityProvider, EcsClusterProps } from "./ecsTypes.js";
|
|
2
3
|
/**
|
|
3
4
|
* Validates ECS cluster props before construction.
|
|
@@ -26,9 +27,14 @@ import type { DomainConfig, EcsCapacityProvider, EcsClusterProps } from "./ecsTy
|
|
|
26
27
|
* cannot pass it and there is no resources-layer code path to validate.
|
|
27
28
|
*
|
|
28
29
|
* @param props - The cluster props to validate
|
|
30
|
+
* @param annotationsScope - When provided (the `EcsCluster` constructor passes
|
|
31
|
+
* `this`), non-fatal findings from {@link validateEc2TaskMemoryFit} are
|
|
32
|
+
* emitted as CDK synth warnings; without a scope (pure validation contexts:
|
|
33
|
+
* the contract corpus, unit tests) warnings are dropped and only throws
|
|
34
|
+
* surface
|
|
29
35
|
* @throws Error if validation fails
|
|
30
36
|
*/
|
|
31
|
-
export declare function validateEcsClusterProps(props: EcsClusterProps): void;
|
|
37
|
+
export declare function validateEcsClusterProps(props: EcsClusterProps, annotationsScope?: IConstruct): void;
|
|
32
38
|
/**
|
|
33
39
|
* Minimal structural input for {@link validateEc2ServiceSizing} — exactly the
|
|
34
40
|
* fields the guard reads. Both the resources-layer `EcsServiceProps` and the
|
|
@@ -83,6 +89,67 @@ interface Ec2ServiceSizingInput {
|
|
|
83
89
|
* `ec2Config.memoryLimitMiB`
|
|
84
90
|
*/
|
|
85
91
|
export declare function validateEc2ServiceSizing(service: Ec2ServiceSizingInput): void;
|
|
92
|
+
/**
|
|
93
|
+
* Minimal structural input for {@link validateEc2TaskMemoryFit} — the fields
|
|
94
|
+
* the guard reads and nothing wider, so the resources-layer `EcsServiceProps`
|
|
95
|
+
* and the patterns-layer `EcsServiceConfig` both satisfy it (same seam
|
|
96
|
+
* discipline as {@link validateEc2ServiceSizing} above).
|
|
97
|
+
*/
|
|
98
|
+
interface Ec2MemoryFitInput {
|
|
99
|
+
name: string;
|
|
100
|
+
capacityProvider?: EcsCapacityProvider;
|
|
101
|
+
containers?: ReadonlyArray<{
|
|
102
|
+
name?: string;
|
|
103
|
+
}>;
|
|
104
|
+
ec2Config?: {
|
|
105
|
+
instanceType?: string;
|
|
106
|
+
memoryLimitMiB?: number;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Rejects an EC2 service whose task can never be PLACED: on the EC2 launch
|
|
111
|
+
* type every container carries a hard memory limit (the gate container gets
|
|
112
|
+
* {@link SCHEMA_GATE_CONTAINER_MEMORY_MIB}, every other container gets
|
|
113
|
+
* `ec2Config.memoryLimitMiB ?? DEFAULT_EC2_CONTAINER_MEMORY_MIB` — see
|
|
114
|
+
* `addContainersToTask` in `ecsTaskDefinition.ts`, whose derivation this guard
|
|
115
|
+
* mirrors), and ECS places a task only if the SUM of those limits fits the
|
|
116
|
+
* instance's registered memory. Registered memory is always strictly below the
|
|
117
|
+
* type's nominal memory (agent + OS reserve), so a demand `>= nominal` is
|
|
118
|
+
* structurally unplaceable on ANY instance count — the stack deploys cleanly
|
|
119
|
+
* while desiredCount is 0, then wedges at the first scale-up. That is the
|
|
120
|
+
* 2026-08-12 production wedge (deploy-worker 2048 + gate 2048 = 4096 demanded
|
|
121
|
+
* of a t4g.medium's 3835 registered), which this guard converts into a synth
|
|
122
|
+
* error.
|
|
123
|
+
*
|
|
124
|
+
* Two verdicts:
|
|
125
|
+
*
|
|
126
|
+
* - demand >= nominal → THROW. Zero false positives by construction:
|
|
127
|
+
* registered < nominal always, so nothing that deploys today can trip it.
|
|
128
|
+
* Applies equally to the defaulted instance type
|
|
129
|
+
* ({@link DEFAULT_ECS_EC2_INSTANCE_TYPE}) — the defaults combo (t4g.small +
|
|
130
|
+
* 1024, or + the gate's 512) fits with headroom, so an oversubscribed bare
|
|
131
|
+
* `ec2Config: {}` means the caller added containers without sizing and the
|
|
132
|
+
* task is just as unplaceable as an explicitly-sized one.
|
|
133
|
+
* - demand > nominal − {@link estimatedEc2MemoryReserveMiB} → WARNING. The
|
|
134
|
+
* demand fits nominal but sits inside the estimated agent/OS reserve band,
|
|
135
|
+
* where placement depends on the AMI's exact registered memory.
|
|
136
|
+
*
|
|
137
|
+
* Skips (returns no findings) for Fargate services and for instance types
|
|
138
|
+
* outside the `nominalEc2InstanceMemoryMiB` allow-list.
|
|
139
|
+
*
|
|
140
|
+
* Called from BOTH validation layers, like {@link validateEc2ServiceSizing}:
|
|
141
|
+
* patterns `validateEcsProps` (throw-only — at that layer the gate container
|
|
142
|
+
* is not yet injected, so its demand under-counts by the gate's 512 MiB and
|
|
143
|
+
* warnings would be premature) and resources `validateEcsClusterProps` (the
|
|
144
|
+
* containers list is final there — gate and migration containers included —
|
|
145
|
+
* so both throws and warnings are exact).
|
|
146
|
+
*
|
|
147
|
+
* @param service - The service props to validate (no-op unless EC2 capacity)
|
|
148
|
+
* @returns Warning messages for the non-fatal verdicts (caller decides the
|
|
149
|
+
* sink — `validateEcsClusterProps` emits them as CDK synth warnings)
|
|
150
|
+
* @throws Error when the task demand cannot fit the explicitly chosen sizing
|
|
151
|
+
*/
|
|
152
|
+
export declare function validateEc2TaskMemoryFit(service: Ec2MemoryFitInput): string[];
|
|
86
153
|
/**
|
|
87
154
|
* Domain-config constraints shared by the resources-layer hook
|
|
88
155
|
* (`validateEcsClusterProps`) and the patterns-layer mirror
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { Annotations } from "aws-cdk-lib";
|
|
1
2
|
import { NetworkMode } from "aws-cdk-lib/aws-ecs";
|
|
2
3
|
import { evaluateBakeGuard } from "@fjall/util/docker";
|
|
4
|
+
import { SCHEMA_GATE_CONTAINER_NAME } from "@fjall/util/migration";
|
|
3
5
|
import { toKebab, SSM_COMPONENT_PATTERN, SSM_COMPONENT_ERROR, SECRET_NAME_PATTERN, SECRET_NAME_ERROR } from "@fjall/util";
|
|
4
6
|
import { ScalingType } from "./ecsTypes.js";
|
|
5
|
-
import { DEFAULT_EC2_CONTAINER_MEMORY_MIB } from "./ecsConstants.js";
|
|
7
|
+
import { DEFAULT_EC2_CONTAINER_MEMORY_MIB, DEFAULT_ECS_EC2_INSTANCE_TYPE, SCHEMA_GATE_CONTAINER_MEMORY_MIB, estimatedEc2MemoryReserveMiB, nominalEc2InstanceMemoryMiB, resolveEc2ContainerMemoryMiB } from "./ecsConstants.js";
|
|
6
8
|
import { validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
|
|
7
9
|
/**
|
|
8
10
|
* Validates ECS cluster props before construction.
|
|
@@ -31,9 +33,14 @@ import { validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
|
|
|
31
33
|
* cannot pass it and there is no resources-layer code path to validate.
|
|
32
34
|
*
|
|
33
35
|
* @param props - The cluster props to validate
|
|
36
|
+
* @param annotationsScope - When provided (the `EcsCluster` constructor passes
|
|
37
|
+
* `this`), non-fatal findings from {@link validateEc2TaskMemoryFit} are
|
|
38
|
+
* emitted as CDK synth warnings; without a scope (pure validation contexts:
|
|
39
|
+
* the contract corpus, unit tests) warnings are dropped and only throws
|
|
40
|
+
* surface
|
|
34
41
|
* @throws Error if validation fails
|
|
35
42
|
*/
|
|
36
|
-
export function validateEcsClusterProps(props) {
|
|
43
|
+
export function validateEcsClusterProps(props, annotationsScope) {
|
|
37
44
|
validateEcsDomainConfig(props.cluster?.domainConfig, props.clusterName);
|
|
38
45
|
const loadBalancerDisabled = props.cluster?.loadBalancer === false ||
|
|
39
46
|
props.cluster?.directAccess === true;
|
|
@@ -130,6 +137,12 @@ export function validateEcsClusterProps(props) {
|
|
|
130
137
|
"Provide ec2Config on the service.");
|
|
131
138
|
}
|
|
132
139
|
validateEc2ServiceSizing(service);
|
|
140
|
+
const memoryFitWarnings = validateEc2TaskMemoryFit(service);
|
|
141
|
+
if (annotationsScope !== undefined) {
|
|
142
|
+
for (const warning of memoryFitWarnings) {
|
|
143
|
+
Annotations.of(annotationsScope).addWarningV2("@fjall/components-infrastructure:ec2TaskMemoryFit", warning);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
133
146
|
if (typeof service.circuitBreaker === "object") {
|
|
134
147
|
const threshold = service.circuitBreaker.threshold;
|
|
135
148
|
if (threshold !== undefined &&
|
|
@@ -280,6 +293,96 @@ export function validateEc2ServiceSizing(service) {
|
|
|
280
293
|
"(Service-level cpu only applies to Fargate services.)");
|
|
281
294
|
}
|
|
282
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Rejects an EC2 service whose task can never be PLACED: on the EC2 launch
|
|
298
|
+
* type every container carries a hard memory limit (the gate container gets
|
|
299
|
+
* {@link SCHEMA_GATE_CONTAINER_MEMORY_MIB}, every other container gets
|
|
300
|
+
* `ec2Config.memoryLimitMiB ?? DEFAULT_EC2_CONTAINER_MEMORY_MIB` — see
|
|
301
|
+
* `addContainersToTask` in `ecsTaskDefinition.ts`, whose derivation this guard
|
|
302
|
+
* mirrors), and ECS places a task only if the SUM of those limits fits the
|
|
303
|
+
* instance's registered memory. Registered memory is always strictly below the
|
|
304
|
+
* type's nominal memory (agent + OS reserve), so a demand `>= nominal` is
|
|
305
|
+
* structurally unplaceable on ANY instance count — the stack deploys cleanly
|
|
306
|
+
* while desiredCount is 0, then wedges at the first scale-up. That is the
|
|
307
|
+
* 2026-08-12 production wedge (deploy-worker 2048 + gate 2048 = 4096 demanded
|
|
308
|
+
* of a t4g.medium's 3835 registered), which this guard converts into a synth
|
|
309
|
+
* error.
|
|
310
|
+
*
|
|
311
|
+
* Two verdicts:
|
|
312
|
+
*
|
|
313
|
+
* - demand >= nominal → THROW. Zero false positives by construction:
|
|
314
|
+
* registered < nominal always, so nothing that deploys today can trip it.
|
|
315
|
+
* Applies equally to the defaulted instance type
|
|
316
|
+
* ({@link DEFAULT_ECS_EC2_INSTANCE_TYPE}) — the defaults combo (t4g.small +
|
|
317
|
+
* 1024, or + the gate's 512) fits with headroom, so an oversubscribed bare
|
|
318
|
+
* `ec2Config: {}` means the caller added containers without sizing and the
|
|
319
|
+
* task is just as unplaceable as an explicitly-sized one.
|
|
320
|
+
* - demand > nominal − {@link estimatedEc2MemoryReserveMiB} → WARNING. The
|
|
321
|
+
* demand fits nominal but sits inside the estimated agent/OS reserve band,
|
|
322
|
+
* where placement depends on the AMI's exact registered memory.
|
|
323
|
+
*
|
|
324
|
+
* Skips (returns no findings) for Fargate services and for instance types
|
|
325
|
+
* outside the `nominalEc2InstanceMemoryMiB` allow-list.
|
|
326
|
+
*
|
|
327
|
+
* Called from BOTH validation layers, like {@link validateEc2ServiceSizing}:
|
|
328
|
+
* patterns `validateEcsProps` (throw-only — at that layer the gate container
|
|
329
|
+
* is not yet injected, so its demand under-counts by the gate's 512 MiB and
|
|
330
|
+
* warnings would be premature) and resources `validateEcsClusterProps` (the
|
|
331
|
+
* containers list is final there — gate and migration containers included —
|
|
332
|
+
* so both throws and warnings are exact).
|
|
333
|
+
*
|
|
334
|
+
* @param service - The service props to validate (no-op unless EC2 capacity)
|
|
335
|
+
* @returns Warning messages for the non-fatal verdicts (caller decides the
|
|
336
|
+
* sink — `validateEcsClusterProps` emits them as CDK synth warnings)
|
|
337
|
+
* @throws Error when the task demand cannot fit the explicitly chosen sizing
|
|
338
|
+
*/
|
|
339
|
+
export function validateEc2TaskMemoryFit(service) {
|
|
340
|
+
if (service.capacityProvider !== "EC2")
|
|
341
|
+
return [];
|
|
342
|
+
const instanceTypeIsDefaulted = service.ec2Config?.instanceType === undefined;
|
|
343
|
+
const instanceType = service.ec2Config?.instanceType ?? DEFAULT_ECS_EC2_INSTANCE_TYPE;
|
|
344
|
+
const nominal = nominalEc2InstanceMemoryMiB(instanceType);
|
|
345
|
+
if (nominal === undefined)
|
|
346
|
+
return [];
|
|
347
|
+
const perContainerMemory = resolveEc2ContainerMemoryMiB(service.ec2Config);
|
|
348
|
+
const containers = service.containers !== undefined && service.containers.length > 0
|
|
349
|
+
? service.containers
|
|
350
|
+
: [{ name: service.name }];
|
|
351
|
+
const accounting = [];
|
|
352
|
+
let demand = 0;
|
|
353
|
+
for (const container of containers) {
|
|
354
|
+
const memory = container.name === SCHEMA_GATE_CONTAINER_NAME
|
|
355
|
+
? SCHEMA_GATE_CONTAINER_MEMORY_MIB
|
|
356
|
+
: perContainerMemory;
|
|
357
|
+
demand += memory;
|
|
358
|
+
accounting.push(`${container.name ?? "(default)"}=${memory}`);
|
|
359
|
+
}
|
|
360
|
+
const reserve = estimatedEc2MemoryReserveMiB(nominal);
|
|
361
|
+
const instanceTypeLabel = instanceTypeIsDefaulted
|
|
362
|
+
? `${instanceType} (the ECS EC2 default)`
|
|
363
|
+
: instanceType;
|
|
364
|
+
if (demand >= nominal) {
|
|
365
|
+
throw new Error(`Service '${service.name}': container hard memory limits total ${demand} MiB ` +
|
|
366
|
+
`(${accounting.join(" + ")}), which cannot fit a ${instanceTypeLabel} — ECS ` +
|
|
367
|
+
`registers less than the nominal ${nominal} MiB per instance (the ECS agent ` +
|
|
368
|
+
`and OS reserve roughly ${reserve} MiB), so no task of this shape can ever ` +
|
|
369
|
+
`be placed, on any instance count. The stack deploys cleanly while ` +
|
|
370
|
+
`desiredCount is 0, then wedges at the first scale-up. Lower ` +
|
|
371
|
+
`ec2Config.memoryLimitMiB (every app container takes that hard limit), ` +
|
|
372
|
+
`choose a larger ec2Config.instanceType, or run fewer containers in the task.`);
|
|
373
|
+
}
|
|
374
|
+
if (demand > nominal - reserve) {
|
|
375
|
+
return [
|
|
376
|
+
`Service '${service.name}': container hard memory limits total ${demand} MiB ` +
|
|
377
|
+
`of a ${instanceTypeLabel}'s nominal ${nominal} MiB — inside the ECS agent/OS ` +
|
|
378
|
+
`reserve band (estimated usable ~${nominal - reserve} MiB). Placement can ` +
|
|
379
|
+
`fail depending on the AMI's exact registered memory. Leave ~${reserve} MiB ` +
|
|
380
|
+
`headroom: lower ec2Config.memoryLimitMiB or choose a larger ` +
|
|
381
|
+
`ec2Config.instanceType.`
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
283
386
|
/**
|
|
284
387
|
* Domain-config constraints shared by the resources-layer hook
|
|
285
388
|
* (`validateEcsClusterProps`) and the patterns-layer mirror
|