@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.
@@ -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
- * `CLICKHOUSE_INSTANCE_MEMORY_GIB` — the container memory limit derives
24
- * from it via `clickHouseTaskMemoryMiB()` and unknown types throw at
25
- * synth. CH user-data is tuned for 1 vCPU; raise per-user `max_threads`
26
- * and `max_concurrent_queries` if bumping to a >=2 vCPU host.
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 `ClickHouseDefaultProfiles` when
107
- * omitted four workload-class profiles
108
- * (`high_throughput_ingest`, `audit_append`, `read_only`, `ddl_admin`).
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, ClickHouseDefaultProfiles, PROFILE_NAME_PATTERN } from "../../resources/aws/database/clickhouseSchemas.js";
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 profiles = props.profiles ?? ClickHouseDefaultProfiles;
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: "ClickHouseService",
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("ClickHouseService");
492
+ const clickHouseTaskDef = ecsCompute.getTaskDefinition(CLICKHOUSE_SERVICE_NAME);
488
493
  if (!clickHouseTaskDef) {
489
- throw new Error("ClickHouseDatabase: EcsCompute did not expose a ClickHouseService task definition — expected the service to be registered.");
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.micro" } }
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 { 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";
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 ?? DEFAULT_EC2_INSTANCE_TYPE) ===
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 ?? DEFAULT_EC2_INSTANCE_TYPE,
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 ?? DEFAULT_EC2_INSTANCE_TYPE;
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 { 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 { 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 ?? DEFAULT_EC2_INSTANCE_TYPE;
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.micro" */
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