@fjall/components-infrastructure 4.4.0 → 6.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.
Files changed (46) hide show
  1. package/dist/lib/config/aws/ebsDefaultEncryption.js +1 -1
  2. package/dist/lib/config/aws/inspectorEnablement.js +1 -1
  3. package/dist/lib/config/aws/oidcConnector.d.ts +6 -6
  4. package/dist/lib/config/aws/oidcConnector.js +1 -1
  5. package/dist/lib/config/aws/s3BlockPublicAccess.js +1 -1
  6. package/dist/lib/config/aws/securityServicesAdmin.js +2 -2
  7. package/dist/lib/lambda-assets/cert-generator/asset/index.js +31 -31
  8. package/dist/lib/patterns/aws/account.d.ts +11 -10
  9. package/dist/lib/patterns/aws/account.js +11 -10
  10. package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +16 -3
  11. package/dist/lib/patterns/aws/clickhouseDatabase.js +23 -2
  12. package/dist/lib/patterns/aws/compute.js +1 -1
  13. package/dist/lib/patterns/aws/computeEcs.js +5 -0
  14. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +12 -2
  15. package/dist/lib/patterns/aws/computeLambda.d.ts +1 -1
  16. package/dist/lib/patterns/aws/database.js +5 -5
  17. package/dist/lib/patterns/aws/devSubstrate.js +1 -1
  18. package/dist/lib/patterns/aws/payload.js +3 -3
  19. package/dist/lib/patterns/aws/staticSite.js +1 -1
  20. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +1 -1
  21. package/dist/lib/resources/aws/compute/ecs.js +1 -0
  22. package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +43 -0
  23. package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +161 -0
  24. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +18 -0
  25. package/dist/lib/resources/aws/compute/ecsConstants.js +32 -0
  26. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +1 -1
  27. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +9 -6
  28. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +17 -41
  29. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +2 -6
  30. package/dist/lib/resources/aws/compute/ecsValidation.js +15 -2
  31. package/dist/lib/resources/aws/compute/persistentDataVolume.js +1 -1
  32. package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +58 -25
  33. package/dist/lib/resources/aws/database/clickhouseConstants.js +89 -25
  34. package/dist/lib/resources/aws/database/clickhouseUserData.js +77 -4
  35. package/dist/lib/resources/aws/database/rdsAurora.js +1 -1
  36. package/dist/lib/resources/aws/database/rdsInstance.js +2 -2
  37. package/dist/lib/resources/aws/iam/identityCenter/user.js +1 -1
  38. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.d.ts +9 -5
  39. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.js +21 -12
  40. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +1 -1
  41. package/dist/lib/resources/aws/networking/ipamPool.js +1 -1
  42. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +1 -1
  43. package/dist/lib/resources/aws/utilities/tlsCertGenerator.js +1 -1
  44. package/dist/lib/utils/engineCompat.d.ts +42 -13
  45. package/dist/lib/utils/engineCompat.js +111 -25
  46. package/package.json +30 -23
@@ -0,0 +1,161 @@
1
+ import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
2
+ import { Token } from "aws-cdk-lib";
3
+ 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, inferAmiHardwareType } from "./ecsConstants.js";
4
+ /**
5
+ * Every `Ec2CapacityConfig` field, classified. The `Record<keyof …, …>`
6
+ * satisfies-shape is a compile-time exhaustiveness guard in compiled `src/`
7
+ * (typescript-standards § "Compile-time guards must live in compiled src/"):
8
+ * adding a field to `Ec2CapacityConfig` without classifying it here fails
9
+ * `tsc`, so a new ASG-shaping field cannot silently join the "not compared,
10
+ * silently discarded on a shared ASG" bucket.
11
+ */
12
+ const EC2_CONFIG_FIELD_ROLES = {
13
+ instanceType: "keyed",
14
+ amiHardwareType: "keyed",
15
+ warmPool: "keyed",
16
+ persistentDataVolume: "keyed",
17
+ availabilityZones: "keyed",
18
+ minCapacity: "scalar",
19
+ maxCapacity: "scalar",
20
+ desiredCapacity: "scalar",
21
+ instanceMonitoring: "scalar",
22
+ associatePublicIpAddress: "scalar",
23
+ machineImage: "opaque",
24
+ userData: "opaque",
25
+ blockDevices: "opaque",
26
+ tags: "tags",
27
+ memoryLimitMiB: "taskDefinition"
28
+ };
29
+ function fieldsWithRole(role) {
30
+ return Object.keys(EC2_CONFIG_FIELD_ROLES).filter((field) => EC2_CONFIG_FIELD_ROLES[field] === role);
31
+ }
32
+ const KEYED_ASG_FIELDS = fieldsWithRole("keyed");
33
+ const SCALAR_ASG_FIELDS = fieldsWithRole("scalar");
34
+ const OPAQUE_ASG_FIELDS = fieldsWithRole("opaque");
35
+ /**
36
+ * Defaults resolved before a scalar field is compared — the SAME values the
37
+ * ASG construction applies (`getOrCreateAsgCapacityProvider`), imported from
38
+ * their `ecsConstants.ts` single source, so an explicit value equal to the
39
+ * default compares equal to an omitted one instead of tripping the drift
40
+ * throw on a pair that synthesises identically. Fields with no construction
41
+ * default (`desiredCapacity`, `associatePublicIpAddress`) compare raw.
42
+ */
43
+ const SCALAR_ASG_FIELD_DEFAULTS = {
44
+ minCapacity: DEFAULT_EC2_MIN_CAPACITY,
45
+ maxCapacity: DEFAULT_EC2_MAX_CAPACITY,
46
+ instanceMonitoring: DEFAULT_EC2_INSTANCE_MONITORING
47
+ };
48
+ function resolvedScalar(config, field) {
49
+ return config[field] ?? SCALAR_ASG_FIELD_DEFAULTS[field];
50
+ }
51
+ /** Deep tag-record equality, treating `{}` and absent as the same ASG shape. */
52
+ function tagRecordsEqual(a, b) {
53
+ const aKeys = a === undefined ? [] : Object.keys(a);
54
+ const bKeys = b === undefined ? [] : Object.keys(b);
55
+ if (aKeys.length !== bKeys.length)
56
+ return false;
57
+ if (a === undefined || b === undefined)
58
+ return true;
59
+ return aKeys.every((key) => Object.is(a[key], b[key]));
60
+ }
61
+ function findDifferingAsgFields(ec2Config, origin) {
62
+ const differing = SCALAR_ASG_FIELDS.filter((field) => !Object.is(resolvedScalar(ec2Config, field), resolvedScalar(origin, field)));
63
+ if (!tagRecordsEqual(ec2Config.tags, origin.tags))
64
+ differing.push("tags");
65
+ for (const field of OPAQUE_ASG_FIELDS) {
66
+ if ((ec2Config[field] === undefined) !== (origin[field] === undefined)) {
67
+ differing.push(field);
68
+ }
69
+ }
70
+ return differing;
71
+ }
72
+ /**
73
+ * Rejects at synth when a service reusing a shared ASG is incompatible with
74
+ * it: either the origin's `persistentDataVolume` makes the ASG a singleton
75
+ * (two tasks would race to attach the same EBS volume), or the reusing
76
+ * service asks for ASG settings that differ from the ones the ASG was built
77
+ * with — those settings would be silently ignored (same silent-ignore class
78
+ * as `validateEc2ServiceSizing`).
79
+ */
80
+ export function assertSharedAsgConfigMatches(serviceName, ec2Config, key, origin) {
81
+ // Cross-service only: the construction path legitimately re-resolves the
82
+ // SAME service's provider (e.g. a second task definition riding its ASG),
83
+ // and that re-entry must not trip the singleton throw.
84
+ if (origin.ec2Config.persistentDataVolume !== undefined &&
85
+ serviceName !== origin.serviceName) {
86
+ throw new Error(`Service '${serviceName}': its ec2Config resolves to the same EC2 capacity key ('${key}') ` +
87
+ `as service '${origin.serviceName}', whose persistentDataVolume pairs that auto scaling ` +
88
+ `group with a single EBS data volume. A persistent-data-volume service is a singleton — ` +
89
+ `two services sharing its ASG would race to attach the same volume. Change a keyed field ` +
90
+ `(${KEYED_ASG_FIELDS.map((f) => `'${f}'`).join(", ")}) so this service gets its own ASG.`);
91
+ }
92
+ const differing = findDifferingAsgFields(ec2Config, origin.ec2Config);
93
+ if (differing.length === 0)
94
+ return;
95
+ throw new Error(`Service '${serviceName}': ec2Config ${differing.map((f) => `'${f}'`).join(", ")} ` +
96
+ `${differing.length === 1 ? "is" : "are"} ignored — it shares an auto scaling group with ` +
97
+ `service '${origin.serviceName}' (both resolve to EC2 capacity key '${key}'), and that ASG ` +
98
+ `is built from the first service's ec2Config. Either give the two services the same value ` +
99
+ `for ${differing.map((f) => `'${f}'`).join(", ")}, or give this service its own ASG by ` +
100
+ `changing a keyed field (${KEYED_ASG_FIELDS.map((f) => `'${f}'`).join(", ")}).`);
101
+ }
102
+ /**
103
+ * Generates a unique key for EC2 config so services with matching
104
+ * configurations share an ASG.
105
+ */
106
+ export function getEc2ConfigKey(ec2Config) {
107
+ const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
108
+ const amiHardwareType = ec2Config.amiHardwareType ??
109
+ (inferAmiHardwareType(instanceType) === AmiHardwareType.ARM
110
+ ? "ARM"
111
+ : "STANDARD");
112
+ const warmPoolKey = ec2Config.warmPool
113
+ ? `wp${ec2Config.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE}-${ec2Config.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN}`
114
+ : "nowp";
115
+ const baseKey = `${instanceType}-${amiHardwareType}-${warmPoolKey}`;
116
+ // PDV services are implicit singletons — two identical PDV configs still
117
+ // collide on this key, so sharing is rejected in assertSharedAsgConfigMatches.
118
+ const pdv = ec2Config.persistentDataVolume;
119
+ const extras = [];
120
+ if (pdv !== undefined) {
121
+ extras.push(`pdv-${pdv.deviceName}-${pdv.sizeGb}-${stableAzSegment(pdv.availabilityZone)}`);
122
+ }
123
+ // Serialise actual AZ names — `az${length}` would let services pinned to different AZs collide on `az1`.
124
+ // CDK Tokens (env-agnostic stacks) substitute a stable sentinel to keep logical IDs deterministic.
125
+ if (ec2Config.availabilityZones !== undefined) {
126
+ const azKey = [...ec2Config.availabilityZones]
127
+ .map(stableAzSegment)
128
+ .sort()
129
+ .join(",");
130
+ extras.push(`az-${azKey}`);
131
+ }
132
+ return extras.length === 0 ? baseKey : `${baseKey}-${extras.join("-")}`;
133
+ }
134
+ function stableAzSegment(az) {
135
+ return Token.isUnresolved(az) ? "synthAz" : az;
136
+ }
137
+ /**
138
+ * Cross-service shared-ASG compatibility: groups EC2 services by their
139
+ * capacity key and rejects a reuse that is incompatible with the ASG's origin
140
+ * — config drift the shared ASG would silently discard, or a
141
+ * `persistentDataVolume` singleton being shared. Pure; called from BOTH
142
+ * validation layers — resources `validateEcsClusterProps` and patterns
143
+ * `validateEcsProps` (generator-standards § "Validate at the Lowest Layer the
144
+ * Field Belongs To") — with the same check at the construction site
145
+ * (`getOrCreateAsgCapacityProvider`) as defence-in-depth.
146
+ */
147
+ export function validateSharedEc2CapacityConfig(services) {
148
+ const origins = new Map();
149
+ for (const service of services) {
150
+ if (service.capacityProvider !== "EC2")
151
+ continue;
152
+ const ec2Config = service.ec2Config ?? {};
153
+ const key = getEc2ConfigKey(ec2Config);
154
+ const origin = origins.get(key);
155
+ if (origin === undefined) {
156
+ origins.set(key, { serviceName: service.name, ec2Config });
157
+ continue;
158
+ }
159
+ assertSharedAsgConfigMatches(service.name, ec2Config, key, origin);
160
+ }
161
+ }
@@ -1,3 +1,4 @@
1
+ import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
1
2
  import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
2
3
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
3
4
  export declare const DEFAULT_EC2_INSTANCE_TYPE = "t4g.micro";
@@ -8,6 +9,23 @@ export declare const DEFAULT_LOG_RETENTION = RetentionDays.TWO_WEEKS;
8
9
  export declare const DEFAULT_FARGATE_CPU = 256;
9
10
  export declare const DEFAULT_FARGATE_MEMORY_MIB = 512;
10
11
  export declare const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
12
+ export declare const DEFAULT_EC2_MIN_CAPACITY = 2;
13
+ export declare const DEFAULT_EC2_MAX_CAPACITY = 3;
14
+ export declare const DEFAULT_EC2_INSTANCE_MONITORING = Monitoring.BASIC;
15
+ /**
16
+ * The container hard memory limit for an EC2-capacity ECS service.
17
+ *
18
+ * Takes `ec2Config` and nothing else — deliberately. The service-level
19
+ * `memoryLimitMiB` is Fargate-only, and a `?? serviceProps.memoryLimitMiB`
20
+ * fallback here is the silent-ignore footgun `validateEc2ServiceSizing`
21
+ * exists to reject. Narrowing the parameter to the one field that may feed
22
+ * the derivation makes that fallback unrepresentable rather than merely
23
+ * discouraged: re-adding it requires widening this signature, which is a
24
+ * visible act in review. Do not widen it.
25
+ */
26
+ export declare function resolveEc2ContainerMemoryMiB(ec2Config: {
27
+ readonly memoryLimitMiB?: number;
28
+ } | undefined): number;
11
29
  export declare const DEFAULT_ECS_FALLBACK_IMAGE = "amazon/amazon-ecs-sample";
12
30
  export declare const DEFAULT_CUSTOM_RESOURCE_TIMEOUT_SECONDS = 300;
13
31
  export declare const DEFAULT_HEALTH_CHECK_GRACE_SECONDS = 120;
@@ -1,3 +1,4 @@
1
+ import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
1
2
  import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
2
3
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
3
4
  // Canonical source: @fjall/generator schemas/constants.ts — keep in sync
@@ -16,6 +17,28 @@ export const DEFAULT_LOG_RETENTION = RetentionDays.TWO_WEEKS;
16
17
  export const DEFAULT_FARGATE_CPU = 256;
17
18
  export const DEFAULT_FARGATE_MEMORY_MIB = 512;
18
19
  export const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
20
+ // Read at two sites that must agree (code-quality § coupled values): the ASG
21
+ // construction (`getOrCreateAsgCapacityProvider`) and the shared-ASG drift
22
+ // compare (`ecsCapacityConfig.ts` SCALAR_ASG_FIELD_DEFAULTS) — the compare
23
+ // resolves the same defaults the construction applies, or an explicit value
24
+ // equal to the default would read as drift.
25
+ export const DEFAULT_EC2_MIN_CAPACITY = 2;
26
+ export const DEFAULT_EC2_MAX_CAPACITY = 3;
27
+ export const DEFAULT_EC2_INSTANCE_MONITORING = Monitoring.BASIC;
28
+ /**
29
+ * The container hard memory limit for an EC2-capacity ECS service.
30
+ *
31
+ * Takes `ec2Config` and nothing else — deliberately. The service-level
32
+ * `memoryLimitMiB` is Fargate-only, and a `?? serviceProps.memoryLimitMiB`
33
+ * fallback here is the silent-ignore footgun `validateEc2ServiceSizing`
34
+ * exists to reject. Narrowing the parameter to the one field that may feed
35
+ * the derivation makes that fallback unrepresentable rather than merely
36
+ * discouraged: re-adding it requires widening this signature, which is a
37
+ * visible act in review. Do not widen it.
38
+ */
39
+ export function resolveEc2ContainerMemoryMiB(ec2Config) {
40
+ return ec2Config?.memoryLimitMiB ?? DEFAULT_EC2_CONTAINER_MEMORY_MIB;
41
+ }
19
42
  // AWS sample image used when no ECR repository is provided. Consumed by both
20
43
  // the resources/ image resolver and the patterns/ defaults block — keep them
21
44
  // in lockstep via this single export.
@@ -46,10 +69,19 @@ export const ARM_INSTANCE_PREFIXES = [
46
69
  "r6gd",
47
70
  "r7g",
48
71
  "r7gd",
72
+ "r8g",
73
+ "r8gd",
74
+ "c8g",
75
+ "c8gd",
76
+ "c8gn",
49
77
  "m6g",
50
78
  "m6gd",
51
79
  "m7g",
52
80
  "m7gd",
81
+ "m8g",
82
+ "m8gd",
83
+ "x8g",
84
+ "i8g",
53
85
  "a1",
54
86
  "x2gd",
55
87
  "im4gn",
@@ -49,7 +49,7 @@ export class EcsLifecycleHookMigration extends Construct {
49
49
  : "DISABLED"
50
50
  };
51
51
  this.lambda = new LambdaFunction(this, `${id}Fn`, {
52
- runtime: Runtime.NODEJS_22_X,
52
+ runtime: Runtime.NODEJS_24_X,
53
53
  handler: "index.handler",
54
54
  code: Code.fromInline(source),
55
55
  lambdaDescription: `${id} ECS deployment lifecycle hook for migration`,
@@ -3,7 +3,8 @@ import type { FargateTaskDefinition, Ec2TaskDefinition } from "aws-cdk-lib/aws-e
3
3
  import { type ISecurityGroup } from "aws-cdk-lib/aws-ec2";
4
4
  import { TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
5
5
  import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
6
- import { type EcsServiceProps, type Ec2CapacityConfig, type QueueScalingResources } from "./ecsTypes.js";
6
+ import { type Ec2AsgOrigin } from "./ecsCapacityConfig.js";
7
+ import { type EcsServiceProps, type QueueScalingResources } from "./ecsTypes.js";
7
8
  import type { EcsConstructContext } from "./ecsContext.js";
8
9
  /**
9
10
  * Resolves the user's `circuitBreaker` config to the CDK
@@ -17,14 +18,16 @@ export declare function resolveCircuitBreaker(config: false | {
17
18
  /** Mutable state for ASG capacity provider deduplication. */
18
19
  export interface AsgCapacityState {
19
20
  providers: Map<string, AsgCapacityProvider>;
21
+ /**
22
+ * The service + ec2Config that first created each keyed ASG, so a later
23
+ * service sharing the key can be checked against it
24
+ * ({@link assertSharedAsgConfigMatches}).
25
+ */
26
+ asgOrigins: Map<string, Ec2AsgOrigin>;
20
27
  autoScalingGroup?: AutoScalingGroup;
21
28
  asgSecurityGroup?: ISecurityGroup;
22
29
  }
23
- /**
24
- * Generates a unique key for EC2 config so services with matching
25
- * configurations share an ASG.
26
- */
27
- export declare function getEc2ConfigKey(ec2Config: Ec2CapacityConfig): string;
30
+ export { getEc2ConfigKey, validateSharedEc2CapacityConfig, type Ec2AsgOrigin, type SharedEc2CapacityServiceInput } from "./ecsCapacityConfig.js";
28
31
  /**
29
32
  * Gets or creates an ASG capacity provider for an EC2-backed service.
30
33
  * Services with matching EC2 configs share the same ASG.
@@ -2,16 +2,16 @@ 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, Token } from "aws-cdk-lib";
5
+ import { 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 { assertSharedAsgConfigMatches, getEc2ConfigKey } from "./ecsCapacityConfig.js";
15
15
  import { ScalingType } from "./ecsTypes.js";
16
16
  import { isServiceFargate, isServiceEc2 } from "./ecsTaskDefinition.js";
17
17
  /**
@@ -26,40 +26,7 @@ export function resolveCircuitBreaker(config) {
26
26
  const rollback = config?.rollback ?? true;
27
27
  return { enable: true, rollback };
28
28
  }
29
- /**
30
- * Generates a unique key for EC2 config so services with matching
31
- * configurations share an ASG.
32
- */
33
- export function getEc2ConfigKey(ec2Config) {
34
- const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
35
- const amiHardwareType = ec2Config.amiHardwareType ??
36
- (inferAmiHardwareType(instanceType) === AmiHardwareType.ARM
37
- ? "ARM"
38
- : "STANDARD");
39
- const warmPoolKey = ec2Config.warmPool
40
- ? `wp${ec2Config.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE}-${ec2Config.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN}`
41
- : "nowp";
42
- const baseKey = `${instanceType}-${amiHardwareType}-${warmPoolKey}`;
43
- // PDV services are implicit singletons — sharing an ASG would let two tasks race for the same EBS volume.
44
- const pdv = ec2Config.persistentDataVolume;
45
- const extras = [];
46
- if (pdv !== undefined) {
47
- extras.push(`pdv-${pdv.deviceName}-${pdv.sizeGb}-${stableAzSegment(pdv.availabilityZone)}`);
48
- }
49
- // Serialise actual AZ names — `az${length}` would let services pinned to different AZs collide on `az1`.
50
- // CDK Tokens (env-agnostic stacks) substitute a stable sentinel to keep logical IDs deterministic.
51
- if (ec2Config.availabilityZones !== undefined) {
52
- const azKey = [...ec2Config.availabilityZones]
53
- .map(stableAzSegment)
54
- .sort()
55
- .join(",");
56
- extras.push(`az-${azKey}`);
57
- }
58
- return extras.length === 0 ? baseKey : `${baseKey}-${extras.join("-")}`;
59
- }
60
- function stableAzSegment(az) {
61
- return Token.isUnresolved(az) ? "synthAz" : az;
62
- }
29
+ export { getEc2ConfigKey, validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
63
30
  /**
64
31
  * Gets or creates an ASG capacity provider for an EC2-backed service.
65
32
  * Services with matching EC2 configs share the same ASG.
@@ -71,8 +38,13 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
71
38
  const key = getEc2ConfigKey(ec2Config);
72
39
  const existing = state.providers.get(key);
73
40
  if (existing) {
41
+ const origin = state.asgOrigins.get(key);
42
+ if (origin !== undefined) {
43
+ assertSharedAsgConfigMatches(serviceProps.name, ec2Config, key, origin);
44
+ }
74
45
  return existing;
75
46
  }
47
+ state.asgOrigins.set(key, { serviceName: serviceProps.name, ec2Config });
76
48
  const safeKey = key.replace(/[^a-zA-Z0-9]/g, "");
77
49
  const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
78
50
  const amiHardwareType = ec2Config.amiHardwareType
@@ -80,8 +52,8 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
80
52
  ? AmiHardwareType.STANDARD
81
53
  : AmiHardwareType.ARM
82
54
  : inferAmiHardwareType(instanceType);
83
- const minCapacity = ec2Config.minCapacity ?? 2;
84
- const maxCapacity = ec2Config.maxCapacity ?? 3;
55
+ const minCapacity = ec2Config.minCapacity ?? DEFAULT_EC2_MIN_CAPACITY;
56
+ const maxCapacity = ec2Config.maxCapacity ?? DEFAULT_EC2_MAX_CAPACITY;
85
57
  const asgSecurityGroup = ctx.props.cluster?.securityGroup ??
86
58
  new SecurityGroup(ctx.scope, `${safeKey}AsgSecurityGroup`, {
87
59
  vpc: ctx.cluster.vpc,
@@ -129,7 +101,7 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
129
101
  EcsOptimizedImage.amazonLinux2023(amiHardwareType),
130
102
  userData: ec2Config.userData ?? UserData.forLinux(),
131
103
  role: instanceRole,
132
- instanceMonitoring: ec2Config.instanceMonitoring ?? Monitoring.BASIC,
104
+ instanceMonitoring: ec2Config.instanceMonitoring ?? DEFAULT_EC2_INSTANCE_MONITORING,
133
105
  capacityRebalance: true,
134
106
  ecsClusterArn: ctx.cluster.clusterArn,
135
107
  ...(ec2Config.desiredCapacity !== undefined && {
@@ -160,7 +132,11 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
160
132
  // MTP's ProtectedFromScaleIn flag does NOT clear on CP deletion, so
161
133
  // CFN rollback wedges on a stranded protected instance. The drain
162
134
  // path is Ec2GracefulTerminationHandler instead.
163
- enableManagedTerminationProtection: false
135
+ enableManagedTerminationProtection: false,
136
+ // A min=max pinned ASG cannot scale; managed scaling would still mint a
137
+ // CapacityProviderReservation target-tracking pair that wedges in ALARM
138
+ // whenever a task cannot place (2026-07-24 ClickHouse phantom task).
139
+ enableManagedScaling: minCapacity !== maxCapacity
164
140
  });
165
141
  ctx.cluster.addAsgCapacityProvider(provider);
166
142
  state.providers.set(key, provider);
@@ -5,7 +5,7 @@ import { StringParameter } from "aws-cdk-lib/aws-ssm";
5
5
  import { buildParameterPath } from "@fjall/util";
6
6
  import { resolveOrgId } from "../../../utils/cdkContext.js";
7
7
  import { validateSsmPathComponent, validateSecretName } from "./ecsValidation.js";
8
- import { DEFAULT_LOG_RETENTION, DEFAULT_FARGATE_CPU, DEFAULT_FARGATE_MEMORY_MIB, DEFAULT_EC2_CONTAINER_MEMORY_MIB } from "./ecsConstants.js";
8
+ import { DEFAULT_LOG_RETENTION, DEFAULT_FARGATE_CPU, DEFAULT_FARGATE_MEMORY_MIB, resolveEc2ContainerMemoryMiB } from "./ecsConstants.js";
9
9
  import { LogGroup } from "../logging/logGroup.js";
10
10
  import { getContainerImage } from "./ecsImages.js";
11
11
  import { resolveRemoteConnections } from "./ecsRemoteConnections.js";
@@ -214,12 +214,8 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
214
214
  stopTimeout: containerConfig.stopTimeout !== undefined
215
215
  ? Duration.seconds(containerConfig.stopTimeout)
216
216
  : undefined,
217
- // EC2 container memory is ec2Config-only by design — do NOT add a
218
- // `?? serviceProps.memoryLimitMiB` fallback (that is the silent-ignore
219
- // footgun `validateEc2ServiceSizing` exists to reject).
220
217
  ...(isServiceEc2(serviceProps) && {
221
- memoryLimitMiB: serviceProps.ec2Config?.memoryLimitMiB ??
222
- DEFAULT_EC2_CONTAINER_MEMORY_MIB
218
+ memoryLimitMiB: resolveEc2ContainerMemoryMiB(serviceProps.ec2Config)
223
219
  })
224
220
  });
225
221
  if (containerConfig.port !== undefined &&
@@ -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.
@@ -156,12 +157,19 @@ export function validateEcsClusterProps(props) {
156
157
  // backlog scaling — CPU/MEMORY target-tracking produces no datapoint at
157
158
  // zero running tasks, so DesiredCount never rises (the 2026-06-04
158
159
  // asset-discovery outage). Reject the un-wakeable shape at synth.
160
+ // scalingType undefined means scaling was EXPLICITLY disabled
161
+ // (resolveScalingConfig defaults undefined input to CPU): no policy exists
162
+ // to wake the service, so desiredCount 0 is a deliberately parked service
163
+ // (the ClickHouse bring-up escape hatch), not the trap.
159
164
  if (service.desiredCount === 0 &&
165
+ service.scalingType !== undefined &&
160
166
  service.scalingType !== ScalingType.QUEUE) {
161
167
  throw new Error(`Service '${service.name}': desiredCount is 0 but scalingType is ` +
162
- `${service.scalingType ?? "unset"} — a service that starts at zero can ` +
168
+ `${service.scalingType} — a service that starts at zero can ` +
163
169
  "only wake on queue backlog. Use ScalingType.QUEUE with queueScaling.queues " +
164
- "so it scales up from 0, or raise desiredCount to keep at least one task running. " +
170
+ "so it scales up from 0, raise desiredCount to keep at least one task running, " +
171
+ "or disable scaling to park the service deliberately (patterns layer: " +
172
+ "scaling: false; resources layer: omit scalingType). " +
165
173
  "CPU/MEMORY target-tracking produces no datapoint at zero tasks and never scales up.");
166
174
  }
167
175
  if (service.scalingType === ScalingType.QUEUE) {
@@ -189,6 +197,11 @@ export function validateEcsClusterProps(props) {
189
197
  }
190
198
  }
191
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);
192
205
  }
193
206
  /**
194
207
  * Rejects service-level task sizing that is silently ignored for EC2 capacity.
@@ -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.NODEJS_22_X,
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}`,
@@ -14,10 +14,15 @@ export declare const CLICKHOUSE_DATABASE_NAME = "analytics";
14
14
  * max_concurrent_queries=8) — see clickhouseUserData.ts. Bumping to a larger
15
15
  * instance MUST be paired with raising those thread/concurrency caps.
16
16
  *
17
- * Next size up: `m7g.large` (2 vCPU, 8 GiB) for sustained workloads, or
18
- * `r7g.medium` (1 vCPU, 8 GiB) when memory-bound. Set via
19
- * `clickhouseInstanceType` CDK context or the `instanceType` prop, no code
20
- * change needed (see clickhouseDatabase.ts:162). */
17
+ * Next size up (2026-07-31 right-sizing, designs/2026-07-31-clickhouse-
18
+ * factory-fit-for-purpose.md): `r8g.medium` (1 vCPU Graviton4, 8 GiB,
19
+ * ~+30%/core over Graviton3) CH's own doctrine floors production at
20
+ * 8 GiB, and the memory-bound single-tenant profile wants RAM before
21
+ * cores. `m7g.large` (2 vCPU, 8 GiB) only when CPU-bound. Set via
22
+ * `clickhouseInstanceType` CDK context or the `instanceType` prop —
23
+ * the container memory limit derives automatically via
24
+ * `clickHouseTaskMemoryMiB()`, but the thread/concurrency caps in
25
+ * clickhouseUserData.ts still need a lockstep re-tune. */
21
26
  export declare const DEFAULT_CLICKHOUSE_INSTANCE_TYPE = "m7g.medium";
22
27
  /** ClickHouse container image. Explicit `docker.io/` prefix is required so
23
28
  * string-form consumers in `ecsImages.ts#getContainerImage()` route through
@@ -38,17 +43,35 @@ export declare const DEFAULT_CLICKHOUSE_INSTANCE_TYPE = "m7g.medium";
38
43
  * Image size is ~250 MB larger but irrelevant on a single-instance EC2 ASG
39
44
  * that pulls once per launch. Upstream CH CI runs its full perf + stress
40
45
  * matrix on the Ubuntu build; Alpine is community-tier coverage. */
41
- export declare const CLICKHOUSE_IMAGE = "docker.io/clickhouse/clickhouse-server:26.3.10.60";
46
+ export declare const CLICKHOUSE_IMAGE = "docker.io/clickhouse/clickhouse-server:26.3.17.56";
42
47
  /** EBS volume configuration. */
43
48
  export declare const CLICKHOUSE_EBS_VOLUME_SIZE_GB = 80;
44
49
  export declare const CLICKHOUSE_EBS_IOPS = 3000;
45
50
  export declare const CLICKHOUSE_EBS_THROUGHPUT_MBPS = 125;
46
- /** ECS task resource allocation. m7g.medium ships with 4 GB; reserve 1 GB
47
- * for the host (kernel + ECS agent + cloudwatch agent + IMDS) and give
48
- * ClickHouse the remaining 3 GB. Stays right when bumping to m7g.large
49
- * (8 GB) raise this value in lockstep, or fall back to deriving from
50
- * the instance type if we ever support multiple sizes. */
51
- export declare const CLICKHOUSE_TASK_MEMORY_MIB = 3072;
51
+ /** Host memory reserved from the ClickHouse container: kernel + ECS agent +
52
+ * the host-metrics timer. The ECS agent advertises MemTotal minus
53
+ * CLICKHOUSE_ECS_RESERVED_MEMORY_MIB, and MemTotal itself runs below the
54
+ * AWS nominal figure (firmware carve-outs plus page-struct overhead that
55
+ * grows with RAM, ~205 MiB observed on the 4 GiB m7g.medium), so this
56
+ * reserve must absorb both or the task never places. 1 GiB is proven at
57
+ * 4–16 GiB; the ≥32 GiB rows double it (see clickHouseTaskMemoryMiB). */
58
+ export declare const CLICKHOUSE_HOST_RESERVE_MIB = 1024;
59
+ /** MiB the ECS agent withholds from advertised capacity — written to
60
+ * /etc/ecs/ecs.config as `ECS_RESERVED_MEMORY` by buildClickHouseUserData.
61
+ * Must stay well inside CLICKHOUSE_HOST_RESERVE_MIB: the container limit is
62
+ * derived from nominal memory, so the host reserve is what absorbs this
63
+ * withholding at task placement. */
64
+ export declare const CLICKHOUSE_ECS_RESERVED_MEMORY_MIB = 256;
65
+ /** Total memory (GiB) of the instance types the ClickHouse construct knows
66
+ * how to size a container for. Values are the AWS nominal figures. */
67
+ export declare const CLICKHOUSE_INSTANCE_MEMORY_GIB: Record<string, number>;
68
+ /** ECS container memory for the ClickHouse server task, derived from the
69
+ * instance type so a size change cannot leave the hand-set container cap
70
+ * behind (the pre-2026-07 shape: a hardcoded 3072 that silently starved —
71
+ * or over-committed — any non-4-GiB host). Unknown types throw at synth:
72
+ * a memoryLimitMiB above what the host advertises strands the task in
73
+ * PROVISIONING with no CloudFormation error. */
74
+ export declare function clickHouseTaskMemoryMiB(instanceType: string): number;
52
75
  /** ClickHouse ports. */
53
76
  export declare const CLICKHOUSE_HTTP_PORT = 8123;
54
77
  export declare const CLICKHOUSE_NATIVE_PORT = 9000;
@@ -58,22 +81,16 @@ export declare const CLICKHOUSE_PROMETHEUS_PORT = 9363;
58
81
  * See aiDocs/patterns/clickhouse-tls-pattern.md § "The contract". */
59
82
  export declare const CLICKHOUSE_HTTPS_PORT = 8443;
60
83
  export declare const CLICKHOUSE_TCP_SECURE_PORT = 9440;
61
- /** Mount path inside the main ClickHouse container for the materialised
62
- * TLS cert + key. The init container writes to a shared task-scoped volume
63
- * mounted here read-only on the main container. */
84
+ /** Mount path inside the ClickHouse container for the materialised TLS cert
85
+ * + key. The EC2 user-data bootstrap (`buildClickHouseUserData` tlsBootstrap)
86
+ * fetches the PEMs from Secrets Manager onto the EBS data volume at
87
+ * `<mount>/server-certs`, which is bind-mounted read-only into the container
88
+ * at this path. */
64
89
  export declare const CLICKHOUSE_TLS_CERT_MOUNT_PATH = "/etc/clickhouse-server/certs";
65
- /** Task-scoped Docker volume name shared between the TLS init container
66
- * (writer) and the main ClickHouse container (reader). */
67
- export declare const CLICKHOUSE_TLS_CERT_VOLUME_NAME = "tls-certs";
68
- /** UID:GID the official ClickHouse image runs as. Init container `chown`s
69
- * the materialised cert files to this UID so the server can read them. */
90
+ /** UID:GID the official ClickHouse image runs as. The user-data bootstrap
91
+ * `chown`s the materialised cert files to this UID so the server can read
92
+ * them. */
70
93
  export declare const CLICKHOUSE_UID = 101;
71
- /** Digest-pinned alpine image used by the TLS init container. The init
72
- * container needs `jq` (extracts `cert`+`key` from the server-cert JSON
73
- * secret); alpine ships it via `apk add` — but pinning the digest avoids
74
- * fetching `:latest` on every deploy. Bump in lockstep with renovate
75
- * alerts; CI verifies the digest resolves. */
76
- export declare const ALPINE_INIT_CONTAINER_IMAGE = "public.ecr.aws/docker/library/alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d";
77
94
  /** EBS device name for the data volume (must match user data script). */
78
95
  export declare const CLICKHOUSE_EBS_DEVICE_NAME = "/dev/xvdf";
79
96
  /** EBS mount path on the EC2 host. */
@@ -123,6 +140,22 @@ export declare const CLICKHOUSE_SERVER_ROLE_TAG: {
123
140
  readonly key: "ClickHouseRole";
124
141
  readonly value: "server";
125
142
  };
143
+ /** CloudWatch identity of the host metrics: namespace, metric names, and the
144
+ * dimension key. Three sites consume this and they MUST match: (a) the
145
+ * user-data put-metric-data timer (`buildClickHouseUserData`) that publishes
146
+ * the datapoints, (b) the host alarms (`createClickHouseAlarms`) that
147
+ * consume them, and (c) the instance-role PutMetricData namespace condition
148
+ * (`clickhouseDatabase.ts`). One-character drift re-blinds the alarms
149
+ * silently — and with disk-critical on TreatMissingData.BREACHING, a
150
+ * namespace typo pages as a phantom full disk. "CWAgent" matches what the
151
+ * CloudWatch Agent would publish, keeping dashboards portable if the timer
152
+ * is ever replaced by the real agent. */
153
+ export declare const CLICKHOUSE_HOST_METRICS: {
154
+ readonly namespace: "CWAgent";
155
+ readonly memoryMetric: "mem_used_percent";
156
+ readonly diskMetric: "disk_used_percent";
157
+ readonly asgDimension: "AutoScalingGroupName";
158
+ };
126
159
  /** Shared secret generation options (all ClickHouse users share the same policy). */
127
160
  export declare const CLICKHOUSE_SECRET_OPTIONS: {
128
161
  readonly excludePunctuation: true;