@fjall/components-infrastructure 4.4.0 → 5.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.
@@ -24,12 +24,12 @@ export interface OidcConnectorProps {
24
24
  * Account TIER of the connected account (organisation | platform | account) —
25
25
  * the structural TIER axis, from the account stack's polymorphic
26
26
  * `deployRoleAccountTier()`; NEVER the wire `environment` / stage axis (that is
27
- * `accountStage`). Drives the §4.4a deploy-trust governance expand: a
28
- * governance tier (organisation/platform) plus the synth account id widens the
29
- * `FjallDeploy` machine subject to ALSO accept the account-scoped literal
30
- * `org:{orgId}:acct:{awsAccountId}` (StringEquals array = OR of exact
31
- * literals), so per-account deploy issuance can later flip. App-tier
32
- * ("account") or absent generic-only scalar (unchanged, fail-safe).
27
+ * `accountStage`). Since the §4.4b Step-1 expand, trust WIDTH no longer
28
+ * keys on this tier: every synth passes the account id, so the `FjallDeploy`
29
+ * machine subject accepts `[generic, acct]` (StringEquals array = OR of
30
+ * exact literals) for EVERY tier, letting per-account deploy issuance flip.
31
+ * The tier now gates only the `machineTrustMode: "account-scoped"` contract
32
+ * (governance-only until §4.4b's probe gate) and the boundary preset.
33
33
  */
34
34
  accountTier?: AccountTier;
35
35
  /**
@@ -61,16 +61,17 @@ export declare class Account extends Stack {
61
61
  */
62
62
  protected receivesDeployRole(): boolean;
63
63
  /**
64
- * Account TIER passed to this account's `OidcConnector` for the §4.4a
65
- * deploy-trust governance expand. A polymorphic METHOD, not a read of the
66
- * `organisationType` field: the connector is instantiated in THIS base
67
- * constructor, and subclass field initialisers run AFTER `super()`, so
68
- * `this.organisationType` would read "account" for every subclass here (the
69
- * same construction-order reason `receivesDeployRole()` is a method). Base ⇒
70
- * "account" (app-tier, generic-only trust); `Platform` "platform" and
71
- * `Organisation` "organisation" override. Governance tiers widen the deploy
72
- * machine subject; kept in lockstep with `organisationType` by the
73
- * Platform-synth deploy-trust parity test.
64
+ * Account TIER passed to this account's `OidcConnector`. A polymorphic
65
+ * METHOD, not a read of the `organisationType` field: the connector is
66
+ * instantiated in THIS base constructor, and subclass field initialisers run
67
+ * AFTER `super()`, so `this.organisationType` would read "account" for every
68
+ * subclass here (the same construction-order reason `receivesDeployRole()`
69
+ * is a method). Base ⇒ "account"; `Platform` ⇒ "platform" and `Organisation`
70
+ * "organisation" override. Since the §4.4b Step-1 expand every tier
71
+ * renders the widened `[generic, acct]` machine subject the tier now gates
72
+ * only the governance-only "account-scoped" contract mode (the builder
73
+ * throws for tier "account"); kept in lockstep with `organisationType` by
74
+ * the Platform-synth deploy-trust parity test.
74
75
  */
75
76
  protected deployRoleAccountTier(): AccountTier;
76
77
  /**
@@ -151,16 +151,17 @@ export class Account extends Stack {
151
151
  return this.constructor === Account;
152
152
  }
153
153
  /**
154
- * Account TIER passed to this account's `OidcConnector` for the §4.4a
155
- * deploy-trust governance expand. A polymorphic METHOD, not a read of the
156
- * `organisationType` field: the connector is instantiated in THIS base
157
- * constructor, and subclass field initialisers run AFTER `super()`, so
158
- * `this.organisationType` would read "account" for every subclass here (the
159
- * same construction-order reason `receivesDeployRole()` is a method). Base ⇒
160
- * "account" (app-tier, generic-only trust); `Platform` "platform" and
161
- * `Organisation` "organisation" override. Governance tiers widen the deploy
162
- * machine subject; kept in lockstep with `organisationType` by the
163
- * Platform-synth deploy-trust parity test.
154
+ * Account TIER passed to this account's `OidcConnector`. A polymorphic
155
+ * METHOD, not a read of the `organisationType` field: the connector is
156
+ * instantiated in THIS base constructor, and subclass field initialisers run
157
+ * AFTER `super()`, so `this.organisationType` would read "account" for every
158
+ * subclass here (the same construction-order reason `receivesDeployRole()`
159
+ * is a method). Base ⇒ "account"; `Platform` ⇒ "platform" and `Organisation`
160
+ * "organisation" override. Since the §4.4b Step-1 expand every tier
161
+ * renders the widened `[generic, acct]` machine subject the tier now gates
162
+ * only the governance-only "account-scoped" contract mode (the builder
163
+ * throws for tier "account"); kept in lockstep with `organisationType` by
164
+ * the Platform-synth deploy-trust parity test.
164
165
  */
165
166
  deployRoleAccountTier() {
166
167
  return "account";
@@ -17,9 +17,22 @@ export interface ClickHouseDatabaseProps {
17
17
  type: "ClickHouse";
18
18
  /** VPC to deploy into. Falls back to `app.getVpc()` via the factory. */
19
19
  vpc?: IVpc;
20
- /** EC2 instance type. Default: `m7g.medium` (1 vCPU sustained, 4 GiB).
21
- * CH user-data is tuned for 1 vCPU; raise per-user `max_threads` and
22
- * `max_concurrent_queries` if bumping to a >=2 vCPU host. */
20
+ /** EC2 instance type. Default: `m7g.medium` (1 vCPU sustained, 4 GiB);
21
+ * recommended step-up `r8g.medium` (8 GiB see
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.
27
+ *
28
+ * WARNING — changing this on a DEPLOYED stack REPLACES the persistent
29
+ * data volume, not just the instance. The instance type is embedded in
30
+ * the compute construct's logical-ID key (`getEc2ConfigKey`), so the
31
+ * `AWS::EC2::Volume` beneath it gets a new logical ID: CloudFormation
32
+ * snapshots-then-deletes the old volume (`RemovalPolicy.SNAPSHOT`) and
33
+ * creates an EMPTY one, and the volume's `ownerLogicalId` re-attach tag
34
+ * chain resets. Plan a snapshot-restore migration for live data — this
35
+ * is NOT a config-only change on a stack with data. */
23
36
  instanceType?: string;
24
37
  /**
25
38
  * ECS service desired task count. Defaults to 1 (single-node, single-task
@@ -22,7 +22,7 @@ import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
22
22
  import { createClickHouseAlarms } from "../../resources/aws/monitoring/index.js";
23
23
  import { ClickHouseSchemaAdminSchema, ManagedPasswordNameSchema, ProfileSpecSchema, ClickHouseDefaultProfiles, PROFILE_NAME_PATTERN } from "../../resources/aws/database/clickhouseSchemas.js";
24
24
  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, CLICKHOUSE_TASK_MEMORY_MIB, 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, 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";
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
26
  import { TlsCertGenerator } from "../../resources/aws/utilities/tlsCertGenerator.js";
27
27
  import { EcsCompute } from "./computeEcs.js";
28
28
  /**
@@ -375,6 +375,10 @@ export class ClickHouseDatabase extends Construct {
375
375
  name: "ClickHouseService",
376
376
  capacityProvider: "EC2",
377
377
  desiredCount,
378
+ // Omitting `scaling` attaches default CPU target tracking. A scaled-out
379
+ // second task is an EMPTY second database behind the same Cloud Map name
380
+ // (plain MergeTree does not replicate) — desired-count scaling stays off.
381
+ scaling: false,
378
382
  serviceDiscovery: {
379
383
  name: CLICKHOUSE_CLOUDMAP_SERVICE_NAME,
380
384
  // getaddrinfo clients require A; AWS rejects A under HOST/BRIDGE → AWS_VPC.
@@ -403,7 +407,7 @@ export class ClickHouseDatabase extends Construct {
403
407
  desiredCapacity: 1,
404
408
  minCapacity: 1,
405
409
  maxCapacity: 1,
406
- memoryLimitMiB: CLICKHOUSE_TASK_MEMORY_MIB,
410
+ memoryLimitMiB: clickHouseTaskMemoryMiB(instanceType),
407
411
  tags: {
408
412
  [CLICKHOUSE_SERVER_ROLE_TAG.key]: CLICKHOUSE_SERVER_ROLE_TAG.value
409
413
  }
@@ -493,6 +497,23 @@ export class ClickHouseDatabase extends Construct {
493
497
  tlsCaSecret.grantRead(instanceRole);
494
498
  if (tlsServerSecret !== undefined)
495
499
  tlsServerSecret.grantRead(instanceRole);
500
+ // For the user-data host-metrics timer (see buildClickHouseUserData).
501
+ // PutMetricData is not resource-scopable; the namespace condition bounds
502
+ // the grant to the CWAgent metrics the host alarms consume. The describe
503
+ // grant backs the IMDS-tag fallback path for ASG-name resolution.
504
+ instanceRole.addToPrincipalPolicy(new PolicyStatement({
505
+ actions: ["cloudwatch:PutMetricData"],
506
+ resources: ["*"],
507
+ conditions: {
508
+ StringEquals: {
509
+ "cloudwatch:namespace": CLICKHOUSE_HOST_METRICS.namespace
510
+ }
511
+ }
512
+ }));
513
+ instanceRole.addToPrincipalPolicy(new PolicyStatement({
514
+ actions: ["autoscaling:DescribeAutoScalingInstances"],
515
+ resources: ["*"]
516
+ }));
496
517
  // asgName is the CloudWatch dimension the host metrics key off, so without
497
518
  // it the alarms cannot be built; no topic is the default, so skip silently
498
519
  // rather than throw.
@@ -622,9 +622,19 @@ export interface EcsServiceConfig {
622
622
  * ]
623
623
  */
624
624
  routing?: EcsRoutingConfig | EcsRoutingConfig[];
625
- /** CPU units for this service's tasks (256-4096) */
625
+ /**
626
+ * CPU units for this service's tasks (256-4096). Fargate only — EC2 task
627
+ * definitions carry no task-level CPU reservation (CPU is bounded by the
628
+ * instance type), so a value here is rejected at synth when `capacityProvider`
629
+ * is 'EC2' (see `validateEc2ServiceSizing`).
630
+ */
626
631
  cpu?: number;
627
- /** Memory in MiB for this service's tasks (512-30720) */
632
+ /**
633
+ * Memory in MiB for this service's tasks (512-30720). Fargate only — for EC2
634
+ * capacity the container memory comes from `ec2Config.memoryLimitMiB`; a
635
+ * service-level value here is rejected at synth unless it exactly mirrors
636
+ * that field (see `validateEc2ServiceSizing`).
637
+ */
628
638
  memoryLimitMiB?: number;
629
639
  /** Desired number of tasks. Default: 2 */
630
640
  desiredCount?: number;
@@ -16,15 +16,15 @@ import { Duration } from "aws-cdk-lib";
16
16
  export { isAwsManagedKey, isCMKRequested, AWS_MANAGED, USE_CMK } from "../../utils/databaseTypes.js";
17
17
  export const DATABASE_ENGINE_CONFIG = Object.freeze({
18
18
  postgresql: {
19
- aurora: { version: "16.10", majorVersion: "16" },
20
- instance: { version: "17.5", majorVersion: "17" },
19
+ aurora: { version: "18.4", majorVersion: "18" },
20
+ instance: { version: "17.9", majorVersion: "17" },
21
21
  defaultUsername: "postgres",
22
22
  sslParameters: { "rds.force_ssl": "1" }
23
23
  },
24
24
  mysql: {
25
- // majorVersion must be Aurora version (3.08.0), NOT MySQL version (8.0)
26
- aurora: { version: "8.0.mysql_aurora.3.08.0", majorVersion: "3.08.0" },
27
- instance: { version: "8.4.4", majorVersion: "8.4" },
25
+ // majorVersion must be Aurora version (3.12.0), NOT MySQL version (8.0)
26
+ aurora: { version: "8.0.mysql_aurora.3.12.0", majorVersion: "3.12.0" },
27
+ instance: { version: "8.4.9", majorVersion: "8.4" },
28
28
  defaultUsername: "admin",
29
29
  sslParameters: { require_secure_transport: "ON" }
30
30
  }
@@ -66,6 +66,7 @@ export default class EcsCluster extends Construct {
66
66
  // EC2-specific (mutable state shared with ecsServiceFactory)
67
67
  asgState = {
68
68
  providers: new Map(),
69
+ asgOrigins: new Map(),
69
70
  autoScalingGroup: undefined,
70
71
  asgSecurityGroup: undefined
71
72
  };
@@ -8,6 +8,20 @@ export declare const DEFAULT_LOG_RETENTION = RetentionDays.TWO_WEEKS;
8
8
  export declare const DEFAULT_FARGATE_CPU = 256;
9
9
  export declare const DEFAULT_FARGATE_MEMORY_MIB = 512;
10
10
  export declare const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
11
+ /**
12
+ * The container hard memory limit for an EC2-capacity ECS service.
13
+ *
14
+ * Takes `ec2Config` and nothing else — deliberately. The service-level
15
+ * `memoryLimitMiB` is Fargate-only, and a `?? serviceProps.memoryLimitMiB`
16
+ * fallback here is the silent-ignore footgun `validateEc2ServiceSizing`
17
+ * exists to reject. Narrowing the parameter to the one field that may feed
18
+ * the derivation makes that fallback unrepresentable rather than merely
19
+ * discouraged: re-adding it requires widening this signature, which is a
20
+ * visible act in review. Do not widen it.
21
+ */
22
+ export declare function resolveEc2ContainerMemoryMiB(ec2Config: {
23
+ readonly memoryLimitMiB?: number;
24
+ } | undefined): number;
11
25
  export declare const DEFAULT_ECS_FALLBACK_IMAGE = "amazon/amazon-ecs-sample";
12
26
  export declare const DEFAULT_CUSTOM_RESOURCE_TIMEOUT_SECONDS = 300;
13
27
  export declare const DEFAULT_HEALTH_CHECK_GRACE_SECONDS = 120;
@@ -16,6 +16,20 @@ export const DEFAULT_LOG_RETENTION = RetentionDays.TWO_WEEKS;
16
16
  export const DEFAULT_FARGATE_CPU = 256;
17
17
  export const DEFAULT_FARGATE_MEMORY_MIB = 512;
18
18
  export const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
19
+ /**
20
+ * The container hard memory limit for an EC2-capacity ECS service.
21
+ *
22
+ * Takes `ec2Config` and nothing else — deliberately. The service-level
23
+ * `memoryLimitMiB` is Fargate-only, and a `?? serviceProps.memoryLimitMiB`
24
+ * fallback here is the silent-ignore footgun `validateEc2ServiceSizing`
25
+ * exists to reject. Narrowing the parameter to the one field that may feed
26
+ * the derivation makes that fallback unrepresentable rather than merely
27
+ * discouraged: re-adding it requires widening this signature, which is a
28
+ * visible act in review. Do not widen it.
29
+ */
30
+ export function resolveEc2ContainerMemoryMiB(ec2Config) {
31
+ return ec2Config?.memoryLimitMiB ?? DEFAULT_EC2_CONTAINER_MEMORY_MIB;
32
+ }
19
33
  // AWS sample image used when no ECR repository is provided. Consumed by both
20
34
  // the resources/ image resolver and the patterns/ defaults block — keep them
21
35
  // in lockstep via this single export.
@@ -46,10 +60,19 @@ export const ARM_INSTANCE_PREFIXES = [
46
60
  "r6gd",
47
61
  "r7g",
48
62
  "r7gd",
63
+ "r8g",
64
+ "r8gd",
65
+ "c8g",
66
+ "c8gd",
67
+ "c8gn",
49
68
  "m6g",
50
69
  "m6gd",
51
70
  "m7g",
52
71
  "m7gd",
72
+ "m8g",
73
+ "m8gd",
74
+ "x8g",
75
+ "i8g",
53
76
  "a1",
54
77
  "x2gd",
55
78
  "im4gn",
@@ -17,6 +17,15 @@ export declare function resolveCircuitBreaker(config: false | {
17
17
  /** Mutable state for ASG capacity provider deduplication. */
18
18
  export interface AsgCapacityState {
19
19
  providers: Map<string, AsgCapacityProvider>;
20
+ /**
21
+ * The service + ec2Config that first created each keyed ASG, so a later
22
+ * service sharing the key can be checked against it
23
+ * ({@link assertSharedAsgConfigMatches}).
24
+ */
25
+ asgOrigins: Map<string, {
26
+ serviceName: string;
27
+ ec2Config: Ec2CapacityConfig;
28
+ }>;
20
29
  autoScalingGroup?: AutoScalingGroup;
21
30
  asgSecurityGroup?: ISecurityGroup;
22
31
  }
@@ -26,6 +26,67 @@ export function resolveCircuitBreaker(config) {
26
26
  const rollback = config?.rollback ?? true;
27
27
  return { enable: true, rollback };
28
28
  }
29
+ /**
30
+ * `ec2Config` fields that shape the ASG but are NOT part of
31
+ * {@link getEc2ConfigKey}, split by how a difference can be detected without
32
+ * false positives. A second service whose key matches reuses the first
33
+ * service's ASG, so any of these it sets is silently discarded.
34
+ *
35
+ * Scalars compare by value. `tags` is plain data, so it compares deeply.
36
+ * `machineImage` / `userData` / `blockDevices` are CDK objects with no stable
37
+ * structural identity — two equivalent literals are different references, so
38
+ * only a defined-vs-undefined asymmetry is safe to report. They deliberately
39
+ * under-report: over-reporting is a hard synth failure on config that
40
+ * previously worked, which is the more damaging direction.
41
+ */
42
+ const SCALAR_ASG_FIELDS = [
43
+ "minCapacity",
44
+ "maxCapacity",
45
+ "desiredCapacity",
46
+ "instanceMonitoring",
47
+ "associatePublicIpAddress"
48
+ ];
49
+ const OPAQUE_ASG_FIELDS = [
50
+ "machineImage",
51
+ "userData",
52
+ "blockDevices"
53
+ ];
54
+ function tagRecordsEqual(a, b) {
55
+ if (a === undefined || b === undefined)
56
+ return a === b;
57
+ const aKeys = Object.keys(a);
58
+ if (aKeys.length !== Object.keys(b).length)
59
+ return false;
60
+ return aKeys.every((key) => Object.is(a[key], b[key]));
61
+ }
62
+ function findDifferingAsgFields(ec2Config, origin) {
63
+ const differing = SCALAR_ASG_FIELDS.filter((field) => !Object.is(ec2Config[field], origin[field]));
64
+ if (!tagRecordsEqual(ec2Config.tags, origin.tags))
65
+ differing.push("tags");
66
+ for (const field of OPAQUE_ASG_FIELDS) {
67
+ if ((ec2Config[field] === undefined) !== (origin[field] === undefined)) {
68
+ differing.push(field);
69
+ }
70
+ }
71
+ return differing;
72
+ }
73
+ /**
74
+ * Rejects at synth when a service reusing a shared ASG asks for ASG settings
75
+ * that differ from the ones the ASG was built with — those settings would be
76
+ * silently ignored (same silent-ignore class as `validateEc2ServiceSizing`).
77
+ */
78
+ function assertSharedAsgConfigMatches(serviceName, ec2Config, key, origin) {
79
+ const differing = findDifferingAsgFields(ec2Config, origin.ec2Config);
80
+ if (differing.length === 0)
81
+ return;
82
+ throw new Error(`Service '${serviceName}': ec2Config ${differing.map((f) => `'${f}'`).join(", ")} ` +
83
+ `${differing.length === 1 ? "is" : "are"} ignored — it shares an auto scaling group with ` +
84
+ `service '${origin.serviceName}' (both resolve to EC2 capacity key '${key}'), and that ASG ` +
85
+ `is built from the first service's ec2Config. Either give the two services the same value ` +
86
+ `for ${differing.map((f) => `'${f}'`).join(", ")}, or give this service its own ASG by ` +
87
+ `changing a keyed field ('instanceType', 'amiHardwareType', 'warmPool', ` +
88
+ `'persistentDataVolume', 'availabilityZones').`);
89
+ }
29
90
  /**
30
91
  * Generates a unique key for EC2 config so services with matching
31
92
  * configurations share an ASG.
@@ -71,8 +132,13 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
71
132
  const key = getEc2ConfigKey(ec2Config);
72
133
  const existing = state.providers.get(key);
73
134
  if (existing) {
135
+ const origin = state.asgOrigins.get(key);
136
+ if (origin !== undefined) {
137
+ assertSharedAsgConfigMatches(serviceProps.name, ec2Config, key, origin);
138
+ }
74
139
  return existing;
75
140
  }
141
+ state.asgOrigins.set(key, { serviceName: serviceProps.name, ec2Config });
76
142
  const safeKey = key.replace(/[^a-zA-Z0-9]/g, "");
77
143
  const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
78
144
  const amiHardwareType = ec2Config.amiHardwareType
@@ -160,7 +226,11 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
160
226
  // MTP's ProtectedFromScaleIn flag does NOT clear on CP deletion, so
161
227
  // CFN rollback wedges on a stranded protected instance. The drain
162
228
  // path is Ec2GracefulTerminationHandler instead.
163
- enableManagedTerminationProtection: false
229
+ enableManagedTerminationProtection: false,
230
+ // A min=max pinned ASG cannot scale; managed scaling would still mint a
231
+ // CapacityProviderReservation target-tracking pair that wedges in ALARM
232
+ // whenever a task cannot place (2026-07-24 ClickHouse phantom task).
233
+ enableManagedScaling: minCapacity !== maxCapacity
164
234
  });
165
235
  ctx.cluster.addAsgCapacityProvider(provider);
166
236
  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 &&
@@ -156,12 +156,19 @@ export function validateEcsClusterProps(props) {
156
156
  // backlog scaling — CPU/MEMORY target-tracking produces no datapoint at
157
157
  // zero running tasks, so DesiredCount never rises (the 2026-06-04
158
158
  // asset-discovery outage). Reject the un-wakeable shape at synth.
159
+ // scalingType undefined means scaling was EXPLICITLY disabled
160
+ // (resolveScalingConfig defaults undefined input to CPU): no policy exists
161
+ // to wake the service, so desiredCount 0 is a deliberately parked service
162
+ // (the ClickHouse bring-up escape hatch), not the trap.
159
163
  if (service.desiredCount === 0 &&
164
+ service.scalingType !== undefined &&
160
165
  service.scalingType !== ScalingType.QUEUE) {
161
166
  throw new Error(`Service '${service.name}': desiredCount is 0 but scalingType is ` +
162
- `${service.scalingType ?? "unset"} — a service that starts at zero can ` +
167
+ `${service.scalingType} — a service that starts at zero can ` +
163
168
  "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. " +
169
+ "so it scales up from 0, raise desiredCount to keep at least one task running, " +
170
+ "or disable scaling to park the service deliberately (patterns layer: " +
171
+ "scaling: false; resources layer: omit scalingType). " +
165
172
  "CPU/MEMORY target-tracking produces no datapoint at zero tasks and never scales up.");
166
173
  }
167
174
  if (service.scalingType === ScalingType.QUEUE) {
@@ -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;