@fjall/components-infrastructure 0.94.1 → 0.96.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 (67) hide show
  1. package/dist/lib/app.d.ts +25 -109
  2. package/dist/lib/app.js +37 -136
  3. package/dist/lib/patterns/aws/account.js +5 -4
  4. package/dist/lib/patterns/aws/computeEcs.d.ts +8 -397
  5. package/dist/lib/patterns/aws/computeEcs.js +13 -9
  6. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +386 -0
  7. package/dist/lib/patterns/aws/computeEcsTypes.js +2 -0
  8. package/dist/lib/patterns/aws/domain.js +4 -5
  9. package/dist/lib/patterns/aws/index.d.ts +2 -0
  10. package/dist/lib/patterns/aws/index.js +2 -0
  11. package/dist/lib/patterns/aws/interfaces/compute.d.ts +6 -0
  12. package/dist/lib/patterns/aws/interfaces/connector.d.ts +1 -1
  13. package/dist/lib/patterns/aws/interfaces/connector.js +1 -1
  14. package/dist/lib/patterns/aws/interfaces/index.d.ts +2 -1
  15. package/dist/lib/patterns/aws/interfaces/index.js +1 -1
  16. package/dist/lib/patterns/aws/interfaces/vpcPeer.d.ts +7 -0
  17. package/dist/lib/patterns/aws/interfaces/vpcPeer.js +1 -0
  18. package/dist/lib/patterns/aws/organisation.js +2 -1
  19. package/dist/lib/patterns/aws/vpcPeer.d.ts +34 -0
  20. package/dist/lib/patterns/aws/vpcPeer.js +36 -0
  21. package/dist/lib/patterns/aws/vpcPeerAccepter.d.ts +29 -0
  22. package/dist/lib/patterns/aws/vpcPeerAccepter.js +196 -0
  23. package/dist/lib/resources/aws/analytics/clickhouse.js +10 -1
  24. package/dist/lib/resources/aws/analytics/clickhouseAlarms.d.ts +34 -0
  25. package/dist/lib/resources/aws/analytics/clickhouseAlarms.js +89 -0
  26. package/dist/lib/resources/aws/analytics/clickhouseConstants.d.ts +1 -1
  27. package/dist/lib/resources/aws/analytics/clickhouseConstants.js +3 -1
  28. package/dist/lib/resources/aws/analytics/clickhouseTypes.d.ts +6 -0
  29. package/dist/lib/resources/aws/analytics/clickhouseUserData.d.ts +1 -0
  30. package/dist/lib/resources/aws/analytics/clickhouseUserData.js +3 -2
  31. package/dist/lib/resources/aws/analytics/index.d.ts +2 -0
  32. package/dist/lib/resources/aws/analytics/index.js +1 -0
  33. package/dist/lib/resources/aws/compute/ecsRemoteConnections.d.ts +38 -0
  34. package/dist/lib/resources/aws/compute/ecsRemoteConnections.js +80 -0
  35. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +8 -0
  36. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +7 -0
  37. package/dist/lib/resources/aws/iam/delegationRole.js +11 -4
  38. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +2 -1
  39. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.d.ts +40 -0
  40. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +154 -0
  41. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +2 -1
  42. package/dist/lib/resources/aws/networking/domainCertificate.js +2 -1
  43. package/dist/lib/resources/aws/networking/hostedZone.js +2 -1
  44. package/dist/lib/resources/aws/networking/index.d.ts +3 -0
  45. package/dist/lib/resources/aws/networking/index.js +3 -0
  46. package/dist/lib/resources/aws/networking/vpc.js +6 -2
  47. package/dist/lib/resources/aws/networking/vpcPeeringAccepterRole.d.ts +18 -0
  48. package/dist/lib/resources/aws/networking/vpcPeeringAccepterRole.js +61 -0
  49. package/dist/lib/resources/aws/networking/vpcPeeringConnection.d.ts +49 -0
  50. package/dist/lib/resources/aws/networking/vpcPeeringConnection.js +88 -0
  51. package/dist/lib/utils/bastionFactory.d.ts +10 -0
  52. package/dist/lib/utils/bastionFactory.js +29 -0
  53. package/dist/lib/utils/capitaliseString.d.ts +1 -1
  54. package/dist/lib/utils/capitaliseString.js +1 -1
  55. package/dist/lib/utils/cdkContext.d.ts +8 -0
  56. package/dist/lib/utils/cdkContext.js +11 -0
  57. package/dist/lib/utils/connections.d.ts +7 -1
  58. package/dist/lib/utils/connections.js +15 -0
  59. package/dist/lib/utils/connector.d.ts +18 -2
  60. package/dist/lib/utils/connector.js +6 -1
  61. package/dist/lib/utils/costAllocationTags.d.ts +6 -0
  62. package/dist/lib/utils/costAllocationTags.js +6 -0
  63. package/dist/lib/utils/index.d.ts +3 -0
  64. package/dist/lib/utils/index.js +3 -0
  65. package/dist/lib/utils/vpcPeerInterface.d.ts +22 -0
  66. package/dist/lib/utils/vpcPeerInterface.js +1 -0
  67. package/package.json +4 -3
@@ -0,0 +1,196 @@
1
+ import { Annotations, Stack } from "aws-cdk-lib";
2
+ import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
3
+ import { CfnResourcePolicy, StringListParameter, StringParameter } from "aws-cdk-lib/aws-ssm";
4
+ import { CIDR_REGEX, VALIDATION_PATTERNS } from "@fjall/generator";
5
+ import { buildSsmPrefix, DEFAULT_ORG_ID, resolveOrgId } from "../../utils/cdkContext.js";
6
+ import { VpcPeeringAccepterRole } from "../../resources/aws/networking/vpcPeeringAccepterRole.js";
7
+ import { isDatabase, isRelationalDatabase } from "./interfaces/database.js";
8
+ import { isCompute, isEcsCompute } from "./interfaces/compute.js";
9
+ export class VpcPeerAccepterFactory {
10
+ static build(id, props) {
11
+ return (app, scope) => {
12
+ if (props.requesterAccountIds.length === 0) {
13
+ throw new Error("VpcPeerAccepterFactory requires at least one requester account ID.");
14
+ }
15
+ for (const accountId of props.requesterAccountIds) {
16
+ if (!VALIDATION_PATTERNS.AWS_ACCOUNT_ID.test(accountId)) {
17
+ throw new Error(`Invalid requester account ID "${accountId}". Must be a 12-digit AWS account ID.`);
18
+ }
19
+ }
20
+ const localVpc = app.getVpc(props.localVpcName);
21
+ const accepterRole = new VpcPeeringAccepterRole(scope, id, {
22
+ requesterAccountIds: props.requesterAccountIds,
23
+ localVpc,
24
+ costAllocationEnvironment: props.costAllocationEnvironment,
25
+ costAllocationDomain: props.costAllocationDomain
26
+ });
27
+ const orgId = resolveOrgId(app.node, DEFAULT_ORG_ID);
28
+ const ssmPrefix = buildSsmPrefix(orgId, app.getName());
29
+ const publishVpcMetadata = props.publishToSsm ?? true;
30
+ const vpcMetadataParams = [];
31
+ if (publishVpcMetadata) {
32
+ const vpcIdParam = new StringParameter(scope, `${id}VpcIdParam`, {
33
+ parameterName: `${ssmPrefix}/vpc-id`,
34
+ stringValue: localVpc.vpcId
35
+ });
36
+ const vpcCidrParam = new StringParameter(scope, `${id}VpcCidrParam`, {
37
+ parameterName: `${ssmPrefix}/vpc-cidr`,
38
+ stringValue: localVpc.vpcCidrBlock
39
+ });
40
+ const roleArnParam = new StringParameter(scope, `${id}PeeringRoleArnParam`, {
41
+ parameterName: `${ssmPrefix}/peering-role-arn`,
42
+ stringValue: accepterRole.roleArn
43
+ });
44
+ const privateSubnets = localVpc.selectSubnets({
45
+ subnetType: SubnetType.PRIVATE_WITH_EGRESS
46
+ });
47
+ const routeTableIds = privateSubnets.subnets.map((s) => s.routeTable.routeTableId);
48
+ const routeTableIdsParam = new StringListParameter(scope, `${id}RouteTableIdsParam`, {
49
+ parameterName: `${ssmPrefix}/route-table-ids`,
50
+ stringListValue: routeTableIds
51
+ });
52
+ vpcMetadataParams.push(vpcIdParam, vpcCidrParam, roleArnParam, routeTableIdsParam);
53
+ }
54
+ if (vpcMetadataParams.length > 0) {
55
+ attachCrossAccountReadPolicy(scope, id, props.requesterAccountIds, vpcMetadataParams);
56
+ }
57
+ applyExposedResources(scope, id, ssmPrefix, props.requesterAccountIds, props.exposedResources ?? []);
58
+ return accepterRole;
59
+ };
60
+ }
61
+ }
62
+ function applyExposedResources(accepterScope, id, ssmPrefix, requesterAccountIds, entries) {
63
+ const seenNames = new Set();
64
+ for (const entry of entries) {
65
+ if (seenNames.has(entry.name)) {
66
+ throw new Error(`Duplicate exposedResource name '${entry.name}' — each exposed resource must have a unique name.`);
67
+ }
68
+ seenNames.add(entry.name);
69
+ for (const cidr of entry.allowedFromCidrs) {
70
+ if (!CIDR_REGEX.test(cidr)) {
71
+ throw new Error(`exposedResource '${entry.name}' has malformed CIDR '${cidr}' — expected dotted-quad/prefix-length form (e.g. 10.0.0.0/16).`);
72
+ }
73
+ }
74
+ const resourcePrefix = `${ssmPrefix}/resources/${entry.name}`;
75
+ const constructPrefix = `${id}Exposed${entry.name}`;
76
+ const { params, scope: resourceScope } = resolveExposedResource(accepterScope, constructPrefix, resourcePrefix, entry);
77
+ if (params.length > 0) {
78
+ attachCrossAccountReadPolicy(resourceScope, constructPrefix, requesterAccountIds, params);
79
+ }
80
+ }
81
+ }
82
+ function resolveExposedResource(accepterScope, constructPrefix, resourcePrefix, entry) {
83
+ if ("serviceName" in entry) {
84
+ const resource = entry.resource;
85
+ if (!isCompute(resource) || !isEcsCompute(resource)) {
86
+ throw new Error(`exposedResource '${entry.name}' carries 'serviceName' (ECS variant) but its resource is not IEcsCompute.`);
87
+ }
88
+ const scope = stackOfNode(resource.node);
89
+ const params = applyEcsServiceExposure(scope, constructPrefix, resourcePrefix, entry);
90
+ return { params, scope };
91
+ }
92
+ const resource = entry.resource;
93
+ if (!isDatabase(resource) || !isRelationalDatabase(resource)) {
94
+ throw new Error(`exposedResource '${entry.name}' has no 'serviceName' (database variant) but its resource is not IRelationalDatabase.`);
95
+ }
96
+ const scope = stackOfNode(resource.node);
97
+ const params = applyDatabaseExposure(scope, constructPrefix, resourcePrefix, {
98
+ ...entry,
99
+ resource
100
+ });
101
+ return { params, scope };
102
+ }
103
+ function applyDatabaseExposure(scope, constructPrefix, resourcePrefix, entry) {
104
+ const port = parseInt(entry.resource.getHostPort(), 10);
105
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
106
+ throw new Error(`exposedResource '${entry.name}' resolved an out-of-range port from getHostPort() (must be 1-65535); cannot open ingress.`);
107
+ }
108
+ if (entry.allowedFromCidrs.length === 0) {
109
+ Annotations.of(scope).addWarning(`exposedResource '${entry.name}' has no allowed CIDRs; consumers will not be able to reach it.`);
110
+ return [];
111
+ }
112
+ for (const cidr of entry.allowedFromCidrs) {
113
+ entry.resource.connections.allowFrom(Peer.ipv4(cidr), Port.tcp(port), `fjall:peer:${cidr}`);
114
+ }
115
+ return publishExposedResourceParams(scope, constructPrefix, resourcePrefix, {
116
+ kind: "relational-db",
117
+ endpoint: entry.resource.getHostEndpoint(),
118
+ port: String(port),
119
+ access: entry.access
120
+ });
121
+ }
122
+ function applyEcsServiceExposure(scope, constructPrefix, resourcePrefix, entry) {
123
+ const loadBalancer = entry.resource.getLoadBalancer();
124
+ if (!loadBalancer) {
125
+ throw new Error(`exposedResource '${entry.name}' targets ECS service '${entry.serviceName}' but the cluster has no load balancer (cluster.loadBalancer: false). Enable an internal ALB to expose this service.`);
126
+ }
127
+ const resolvedListenerPort = entry.port === undefined
128
+ ? entry.resource.getPrimaryListenerPort()
129
+ : undefined;
130
+ const port = entry.port ?? resolvedListenerPort ?? 443;
131
+ if (entry.port === undefined && resolvedListenerPort !== undefined) {
132
+ Annotations.of(scope).addInfo(`exposedResource '${entry.name}' defaulted to listener port ${resolvedListenerPort}`);
133
+ }
134
+ if (entry.allowedFromCidrs.length === 0) {
135
+ Annotations.of(scope).addWarning(`exposedResource '${entry.name}' has no allowed CIDRs; consumers will not be able to reach it.`);
136
+ return [];
137
+ }
138
+ for (const cidr of entry.allowedFromCidrs) {
139
+ loadBalancer.connections.allowFrom(Peer.ipv4(cidr), Port.tcp(port), `fjall:peer:${cidr}`);
140
+ }
141
+ return publishExposedResourceParams(scope, constructPrefix, resourcePrefix, {
142
+ kind: "ecs-service",
143
+ endpoint: loadBalancer.loadBalancerDnsName,
144
+ port: String(port)
145
+ });
146
+ }
147
+ function publishExposedResourceParams(scope, constructPrefix, resourcePrefix, values) {
148
+ const kindParam = new StringParameter(scope, `${constructPrefix}KindParam`, {
149
+ parameterName: `${resourcePrefix}/kind`,
150
+ stringValue: values.kind
151
+ });
152
+ const endpointParam = new StringParameter(scope, `${constructPrefix}EndpointParam`, {
153
+ parameterName: `${resourcePrefix}/endpoint`,
154
+ stringValue: values.endpoint
155
+ });
156
+ const portParam = new StringParameter(scope, `${constructPrefix}PortParam`, {
157
+ parameterName: `${resourcePrefix}/port`,
158
+ stringValue: values.port
159
+ });
160
+ const params = [kindParam, endpointParam, portParam];
161
+ if (values.access !== undefined) {
162
+ const accessParam = new StringParameter(scope, `${constructPrefix}AccessParam`, {
163
+ parameterName: `${resourcePrefix}/access`,
164
+ stringValue: values.access
165
+ });
166
+ params.push(accessParam);
167
+ }
168
+ return params;
169
+ }
170
+ function attachCrossAccountReadPolicy(scope, id, requesterAccountIds, parameters) {
171
+ const partition = Stack.of(scope).partition;
172
+ const principals = requesterAccountIds.map((accountId) => `arn:${partition}:iam::${accountId}:root`);
173
+ for (const [index, parameter] of parameters.entries()) {
174
+ new CfnResourcePolicy(scope, `${id}ReadPolicy${index}`, {
175
+ resourceArn: parameter.parameterArn,
176
+ policy: {
177
+ Version: "2012-10-17",
178
+ Statement: [
179
+ {
180
+ Effect: "Allow",
181
+ Principal: { AWS: principals },
182
+ Action: ["ssm:GetParameter"],
183
+ Resource: parameter.parameterArn
184
+ }
185
+ ]
186
+ }
187
+ });
188
+ }
189
+ }
190
+ function stackOfNode(node) {
191
+ const stack = node.scopes.find((a) => Stack.isStack(a));
192
+ if (!stack) {
193
+ throw new Error("exposedResource resource is not bound to a CDK Stack — cannot derive scope for SSM publishing.");
194
+ }
195
+ return stack;
196
+ }
@@ -12,6 +12,7 @@ import { vpcHasNatGateways } from "../../../utils/vpcUtils.js";
12
12
  import { inferAmiHardwareType } from "../compute/ecsConstants.js";
13
13
  import { createClickHouseSecurityGroup } from "./clickhouseSecurityGroup.js";
14
14
  import { generateClickHouseUserData } from "./clickhouseUserData.js";
15
+ import { createClickHouseAlarms } from "./clickhouseAlarms.js";
15
16
  import { CLICKHOUSE_CLUSTER_NAME, DEFAULT_CLICKHOUSE_INSTANCE_TYPE, CLICKHOUSE_IMAGE, CLICKHOUSE_EBS_VOLUME_SIZE_GB, CLICKHOUSE_EBS_IOPS, CLICKHOUSE_EBS_THROUGHPUT_MBPS, CLICKHOUSE_TASK_MEMORY_MIB, CLICKHOUSE_TASK_CPU_UNITS, CLICKHOUSE_HTTP_PORT, CLICKHOUSE_NATIVE_PORT, CLICKHOUSE_PROMETHEUS_PORT, CLICKHOUSE_DATA_MOUNT_PATH, CLICKHOUSE_SECRETS_PREFIX, CLICKHOUSE_SECRET_NAMES, CLICKHOUSE_SECRET_OPTIONS, CLICKHOUSE_HEALTH_CHECK, CLICKHOUSE_EBS_DEVICE_NAME, CLICKHOUSE_CONFIG_SUBDIR, CLICKHOUSE_USERS_SUBDIR, OPTIMISE_FINAL_SCHEDULE, REPLACING_MERGE_TREE_TABLES, OPTIMISE_MV_TABLES, CLICKHOUSE_CLOUDMAP_NAMESPACE, CLICKHOUSE_CLOUDMAP_SERVICE_NAME, OPTIMISE_TASK_MEMORY_MIB, OPTIMISE_TASK_CPU_UNITS, BACKUP_SCHEDULE, BACKUP_TASK_MEMORY_MIB, BACKUP_TASK_CPU_UNITS, BACKUP_RETENTION_DAYS } from "./clickhouseConstants.js";
16
17
  function createClickHouseSecret(scope, id, secretKey, description) {
17
18
  return new Secret(scope, id, {
@@ -273,7 +274,15 @@ export default class ClickHouse extends Construct {
273
274
  auditPasswordSecret.secret.grantRead(executionRole);
274
275
  backupPasswordSecret.secret.grantRead(executionRole);
275
276
  schemaPasswordSecret.secret.grantRead(executionRole);
276
- // 14. Connections and outputs
277
+ // 14. CloudWatch alarms (CPU, memory, disk) — wired only when an SNS topic is supplied
278
+ if (props.alarmTopic) {
279
+ createClickHouseAlarms({
280
+ scope: this,
281
+ asg,
282
+ alarmTopic: props.alarmTopic
283
+ });
284
+ }
285
+ // 15. Connections and outputs
277
286
  this.connections = new Connections({
278
287
  securityGroups: [securityGroup],
279
288
  defaultPort: Port.tcp(CLICKHOUSE_HTTP_PORT)
@@ -0,0 +1,34 @@
1
+ import { Alarm } from "aws-cdk-lib/aws-cloudwatch";
2
+ import type { AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
3
+ import type { ITopic } from "aws-cdk-lib/aws-sns";
4
+ import type { Construct } from "constructs";
5
+ export interface ClickHouseAlarmThresholds {
6
+ /** EC2 host CPU % over 5 min. Default 90. */
7
+ cpuThreshold?: number;
8
+ /** EC2 host memory % over 5 min (requires CWAgent). Default 80. */
9
+ memoryThreshold?: number;
10
+ /** EBS root-volume disk % used. Default 70 (warn) — paired with critical at 85. */
11
+ diskWarnThreshold?: number;
12
+ /** EBS root-volume disk % used. Default 85. */
13
+ diskCriticalThreshold?: number;
14
+ }
15
+ export interface ClickHouseAlarmsProps {
16
+ scope: Construct;
17
+ asg: AutoScalingGroup;
18
+ alarmTopic: ITopic;
19
+ config?: ClickHouseAlarmThresholds;
20
+ }
21
+ /**
22
+ * Single-node ClickHouse posture alarms. Covers host-level CPU + (optional)
23
+ * memory and disk via the CloudWatch Agent metric namespace `CWAgent`.
24
+ *
25
+ * Engine-level alarms (parts count, failed inserts) require the Prometheus
26
+ * exporter to be scraped into CW custom metrics. The Prometheus endpoint is
27
+ * enabled in the user-data XML (port 9363); the scraper is a follow-up.
28
+ *
29
+ * Stuck merges are detected app-side: `client.ts` queries `system.merges`
30
+ * every 5 min and logs `serverLogger.warn("ClickHouse", "Stuck merge detected")`
31
+ * when elapsed > 30 min. A CW Logs metric filter on that pattern + alarm
32
+ * completes the circuit once the webapp log group ARN is available here.
33
+ */
34
+ export declare function createClickHouseAlarms(props: ClickHouseAlarmsProps): Alarm[];
@@ -0,0 +1,89 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import { Alarm, ComparisonOperator, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
3
+ import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
4
+ import { Metric } from "aws-cdk-lib/aws-cloudwatch";
5
+ import { ALARM_DEFAULTS, registerAlarm, buildAlarmDescription } from "../monitoring/alarmDefaults.js";
6
+ /**
7
+ * Single-node ClickHouse posture alarms. Covers host-level CPU + (optional)
8
+ * memory and disk via the CloudWatch Agent metric namespace `CWAgent`.
9
+ *
10
+ * Engine-level alarms (parts count, failed inserts) require the Prometheus
11
+ * exporter to be scraped into CW custom metrics. The Prometheus endpoint is
12
+ * enabled in the user-data XML (port 9363); the scraper is a follow-up.
13
+ *
14
+ * Stuck merges are detected app-side: `client.ts` queries `system.merges`
15
+ * every 5 min and logs `serverLogger.warn("ClickHouse", "Stuck merge detected")`
16
+ * when elapsed > 30 min. A CW Logs metric filter on that pattern + alarm
17
+ * completes the circuit once the webapp log group ARN is available here.
18
+ */
19
+ export function createClickHouseAlarms(props) {
20
+ const { scope, asg, alarmTopic, config = {} } = props;
21
+ const alarms = [];
22
+ const snsAction = new SnsAction(alarmTopic);
23
+ const asgName = asg.autoScalingGroupName;
24
+ const cpuAlarm = new Alarm(scope, "ClickHouseCpuAlarm", {
25
+ alarmDescription: buildAlarmDescription("ClickHouse host CPU utilisation exceeds threshold", undefined),
26
+ metric: new Metric({
27
+ namespace: "AWS/EC2",
28
+ metricName: "CPUUtilization",
29
+ dimensionsMap: { AutoScalingGroupName: asgName },
30
+ period: ALARM_DEFAULTS.EVALUATION_PERIOD,
31
+ statistic: "Average"
32
+ }),
33
+ threshold: config.cpuThreshold ?? 90,
34
+ evaluationPeriods: 3,
35
+ datapointsToAlarm: 2,
36
+ comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
37
+ treatMissingData: TreatMissingData.NOT_BREACHING
38
+ });
39
+ registerAlarm(cpuAlarm, snsAction, alarms);
40
+ const memoryAlarm = new Alarm(scope, "ClickHouseMemoryAlarm", {
41
+ alarmDescription: buildAlarmDescription("ClickHouse host memory utilisation exceeds threshold (CWAgent)", undefined),
42
+ metric: new Metric({
43
+ namespace: "CWAgent",
44
+ metricName: "mem_used_percent",
45
+ dimensionsMap: { AutoScalingGroupName: asgName },
46
+ period: ALARM_DEFAULTS.EVALUATION_PERIOD,
47
+ statistic: "Average"
48
+ }),
49
+ threshold: config.memoryThreshold ?? 80,
50
+ evaluationPeriods: 3,
51
+ datapointsToAlarm: 2,
52
+ comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
53
+ treatMissingData: TreatMissingData.NOT_BREACHING
54
+ });
55
+ registerAlarm(memoryAlarm, snsAction, alarms);
56
+ const diskWarnAlarm = new Alarm(scope, "ClickHouseDiskWarnAlarm", {
57
+ alarmDescription: buildAlarmDescription("ClickHouse data volume above 70% used — plan growth response", undefined),
58
+ metric: new Metric({
59
+ namespace: "CWAgent",
60
+ metricName: "disk_used_percent",
61
+ dimensionsMap: { AutoScalingGroupName: asgName },
62
+ period: Duration.minutes(15),
63
+ statistic: "Average"
64
+ }),
65
+ threshold: config.diskWarnThreshold ?? 70,
66
+ evaluationPeriods: 2,
67
+ datapointsToAlarm: 2,
68
+ comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
69
+ treatMissingData: TreatMissingData.NOT_BREACHING
70
+ });
71
+ registerAlarm(diskWarnAlarm, snsAction, alarms);
72
+ const diskCriticalAlarm = new Alarm(scope, "ClickHouseDiskCriticalAlarm", {
73
+ alarmDescription: buildAlarmDescription("ClickHouse data volume above 85% used — imminent insert failures", undefined),
74
+ metric: new Metric({
75
+ namespace: "CWAgent",
76
+ metricName: "disk_used_percent",
77
+ dimensionsMap: { AutoScalingGroupName: asgName },
78
+ period: Duration.minutes(5),
79
+ statistic: "Average"
80
+ }),
81
+ threshold: config.diskCriticalThreshold ?? 85,
82
+ evaluationPeriods: 2,
83
+ datapointsToAlarm: 2,
84
+ comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
85
+ treatMissingData: TreatMissingData.NOT_BREACHING
86
+ });
87
+ registerAlarm(diskCriticalAlarm, snsAction, alarms);
88
+ return alarms;
89
+ }
@@ -60,7 +60,7 @@ export declare const CLICKHOUSE_CLOUDMAP_SERVICE_NAME = "clickhouse";
60
60
  /** Materialised views that benefit from periodic OPTIMIZE to reduce part count at read time.
61
61
  * These are not ReplacingMergeTree (no dedup needed) but un-merged parts force
62
62
  * read-time aggregation which degrades query performance. */
63
- export declare const OPTIMISE_MV_TABLES: readonly ["metrics_hourly_mv", "metrics_daily_mv", "response_time_quantiles_hourly_mv", "deployment_duration_quantiles_daily_mv", "log_severity_hourly_mv", "compliance_score_daily_mv", "ai_usage_daily_mv"];
63
+ export declare const OPTIMISE_MV_TABLES: readonly ["metrics_hourly_mv", "metrics_daily_mv", "response_time_quantiles_hourly_mv", "deployment_duration_quantiles_daily_mv", "log_severity_hourly_mv", "compliance_score_daily_mv", "ai_usage_daily_mv", "finding_daily_aggregate", "insight_pattern_dismissals"];
64
64
  /** Resource allocation for the lightweight optimise task. */
65
65
  export declare const OPTIMISE_TASK_MEMORY_MIB = 256;
66
66
  export declare const OPTIMISE_TASK_CPU_UNITS = 256;
@@ -73,7 +73,9 @@ export const OPTIMISE_MV_TABLES = [
73
73
  "deployment_duration_quantiles_daily_mv",
74
74
  "log_severity_hourly_mv",
75
75
  "compliance_score_daily_mv",
76
- "ai_usage_daily_mv"
76
+ "ai_usage_daily_mv",
77
+ "finding_daily_aggregate",
78
+ "insight_pattern_dismissals"
77
79
  ];
78
80
  /** Resource allocation for the lightweight optimise task. */
79
81
  export const OPTIMISE_TASK_MEMORY_MIB = 256;
@@ -1,6 +1,7 @@
1
1
  import type { IVpc, ISecurityGroup } from "aws-cdk-lib/aws-ec2";
2
2
  import type { IBucket } from "aws-cdk-lib/aws-s3";
3
3
  import type { ISecret } from "aws-cdk-lib/aws-secretsmanager";
4
+ import type { ITopic } from "aws-cdk-lib/aws-sns";
4
5
  /** Props for the ClickHouse CDK construct. */
5
6
  export interface ClickHouseProps {
6
7
  /** VPC to deploy into. */
@@ -21,6 +22,11 @@ export interface ClickHouseProps {
21
22
  * If omitted, tiered storage is disabled (local-only).
22
23
  */
23
24
  r2Config?: ClickHouseR2Config;
25
+ /**
26
+ * SNS topic for CloudWatch alarms (CPU, memory, disk).
27
+ * If omitted, posture alarms are not created.
28
+ */
29
+ alarmTopic?: ITopic;
24
30
  }
25
31
  /** Cloudflare R2 configuration for tiered storage and backups. */
26
32
  export interface ClickHouseR2Config {
@@ -2,4 +2,5 @@ export interface ClickHouseUserDataOptions {
2
2
  /** Cloudflare account ID for R2 cold storage. If omitted, local-only storage is used. */
3
3
  cfAccountId?: string;
4
4
  }
5
+ export declare const USERS_CONFIG_XML = "<clickhouse>\n <users>\n <default>\n <networks>\n <ip>127.0.0.1</ip>\n <ip>::1</ip>\n </networks>\n </default>\n </users>\n <profiles>\n <default>\n <optimize_move_to_prewhere>1</optimize_move_to_prewhere>\n </default>\n <app_writer>\n <max_threads>2</max_threads>\n <max_insert_threads>1</max_insert_threads>\n <max_concurrent_queries_for_user>4</max_concurrent_queries_for_user>\n <log_queries_min_query_duration_ms>100</log_queries_min_query_duration_ms>\n <optimize_move_to_prewhere>1</optimize_move_to_prewhere>\n <use_query_condition_cache>1</use_query_condition_cache>\n <!-- Re-enable skip indexes under FINAL (tenantQuery auto-FINALs RMT tables;\n default disables idx_aws_account, idx_application, idx_dedup, idx_fingerprint). -->\n <use_skip_indexes_if_final>1</use_skip_indexes_if_final>\n <async_insert>1</async_insert>\n <wait_for_async_insert>1</wait_for_async_insert>\n <async_insert_max_data_size>10000000</async_insert_max_data_size>\n <!-- Adaptive batching: tune flush window between 50 ms (low-latency rare inserts)\n and 2 s (absorbs bursts). A single fixed value is silently overridden by the\n adaptive algorithm. -->\n <async_insert_busy_timeout_min_ms>50</async_insert_busy_timeout_min_ms>\n <async_insert_busy_timeout_max_ms>2000</async_insert_busy_timeout_max_ms>\n <async_insert_use_adaptive_busy_timeout>1</async_insert_use_adaptive_busy_timeout>\n <input_format_parallel_parsing>0</input_format_parallel_parsing>\n <output_format_parallel_formatting>0</output_format_parallel_formatting>\n <max_memory_usage_for_user>2684354560</max_memory_usage_for_user>\n <max_bytes_before_external_sort>536870912</max_bytes_before_external_sort>\n <max_bytes_before_external_group_by>536870912</max_bytes_before_external_group_by>\n </app_writer>\n <readonly>\n <readonly>1</readonly>\n </readonly>\n </profiles>\n <quotas>\n <tenant_default>\n <interval>\n <duration>3600</duration>\n <queries>1000</queries>\n <result_rows>10000000</result_rows>\n </interval>\n </tenant_default>\n </quotas>\n</clickhouse>";
5
6
  export declare function generateClickHouseUserData(options?: ClickHouseUserDataOptions): string;
@@ -96,7 +96,6 @@ function generateServerConfigXml(cfAccountId) {
96
96
  </merge_tree>
97
97
  <http_port>${CLICKHOUSE_HTTP_PORT}</http_port>
98
98
  <custom_settings_prefixes>current_</custom_settings_prefixes>
99
- <allow_experimental_full_text_index>1</allow_experimental_full_text_index>
100
99
  <!-- HTTP keep-alive window. Must exceed @clickhouse/client idle_socket_ttl (15 s)
101
100
  so the client always closes the socket first. Prevents ECONNRESET on reuse. -->
102
101
  <keep_alive_timeout>30</keep_alive_timeout>
@@ -145,7 +144,7 @@ ${storageBlock}
145
144
  <processors_profile_log remove="1"/>
146
145
  </clickhouse>`;
147
146
  }
148
- const USERS_CONFIG_XML = `<clickhouse>
147
+ export const USERS_CONFIG_XML = `<clickhouse>
149
148
  <users>
150
149
  <default>
151
150
  <networks>
@@ -161,6 +160,8 @@ const USERS_CONFIG_XML = `<clickhouse>
161
160
  <app_writer>
162
161
  <max_threads>2</max_threads>
163
162
  <max_insert_threads>1</max_insert_threads>
163
+ <max_concurrent_queries_for_user>4</max_concurrent_queries_for_user>
164
+ <log_queries_min_query_duration_ms>100</log_queries_min_query_duration_ms>
164
165
  <optimize_move_to_prewhere>1</optimize_move_to_prewhere>
165
166
  <use_query_condition_cache>1</use_query_condition_cache>
166
167
  <!-- Re-enable skip indexes under FINAL (tenantQuery auto-FINALs RMT tables;
@@ -1,2 +1,4 @@
1
1
  export { default as ClickHouse } from "./clickhouse.js";
2
2
  export type { ClickHouseProps, ClickHouseR2Config, ClickHouseOutputs } from "./clickhouseTypes.js";
3
+ export { createClickHouseAlarms } from "./clickhouseAlarms.js";
4
+ export type { ClickHouseAlarmThresholds, ClickHouseAlarmsProps } from "./clickhouseAlarms.js";
@@ -1 +1,2 @@
1
1
  export { default as ClickHouse } from "./clickhouse.js";
2
+ export { createClickHouseAlarms } from "./clickhouseAlarms.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Synth-time resolver for ECS `remoteConnections` — turns each declared
3
+ * cross-app resource into a pair of `${PREFIX}_HOST` / `${PREFIX}_PORT` env
4
+ * vars resolved from SSM (`StringParameter.valueForStringParameter`) and
5
+ * indexed by service name for downstream merge into container environments.
6
+ *
7
+ * SSM path layout (matches the accepter side's publishing layout in
8
+ * `vpcPeerAccepter.ts`):
9
+ * /fjall/{orgId ?? "default"}/{peer.peerAppName}/resources/{resource}/endpoint
10
+ * /fjall/{orgId ?? "default"}/{peer.peerAppName}/resources/{resource}/port
11
+ *
12
+ * Default env-var prefix: `${toScreamingSnake(peer.peerAppName)}_${toScreamingSnake(resource)}`.
13
+ * Override via `RemoteConnectionSpec.envPrefix`.
14
+ */
15
+ import { type Construct } from "constructs";
16
+ import { type IVpcPeer } from "../../../utils/vpcPeerInterface.js";
17
+ export interface RemoteConnectionSpec {
18
+ peer: IVpcPeer;
19
+ resource: string;
20
+ envPrefix?: string;
21
+ }
22
+ interface ServiceWithRemoteConnections {
23
+ name: string;
24
+ remoteConnections?: RemoteConnectionSpec[];
25
+ }
26
+ /**
27
+ * Resolve `remoteConnections` for every service into a per-service env-var
28
+ * bag. Returns `Record<serviceName, Record<envVarName, value>>`.
29
+ *
30
+ * Validates each `RemoteConnectionSpec` at synth time:
31
+ * - `resource` matches `RESOURCE_NAME_PATTERN` (PascalCase).
32
+ * - `envPrefix` (when supplied) matches `ENV_PREFIX_PATTERN`.
33
+ * - `peer.peerAppName` is defined and is not a CFN token — token-derived app
34
+ * names produce `${Token[…]}` literals in env-var names, breaking ECS task
35
+ * definitions silently.
36
+ */
37
+ export declare function resolveRemoteConnections(services: ServiceWithRemoteConnections[], scope: Construct, orgId: string | undefined): Record<string, Record<string, string>>;
38
+ export {};
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Synth-time resolver for ECS `remoteConnections` — turns each declared
3
+ * cross-app resource into a pair of `${PREFIX}_HOST` / `${PREFIX}_PORT` env
4
+ * vars resolved from SSM (`StringParameter.valueForStringParameter`) and
5
+ * indexed by service name for downstream merge into container environments.
6
+ *
7
+ * SSM path layout (matches the accepter side's publishing layout in
8
+ * `vpcPeerAccepter.ts`):
9
+ * /fjall/{orgId ?? "default"}/{peer.peerAppName}/resources/{resource}/endpoint
10
+ * /fjall/{orgId ?? "default"}/{peer.peerAppName}/resources/{resource}/port
11
+ *
12
+ * Default env-var prefix: `${toScreamingSnake(peer.peerAppName)}_${toScreamingSnake(resource)}`.
13
+ * Override via `RemoteConnectionSpec.envPrefix`.
14
+ */
15
+ import { Token } from "aws-cdk-lib";
16
+ import { StringParameter } from "aws-cdk-lib/aws-ssm";
17
+ import { VALIDATION_PATTERNS } from "@fjall/generator";
18
+ import { buildSsmPrefix, DEFAULT_ORG_ID } from "../../../utils/cdkContext.js";
19
+ import { toScreamingSnake } from "../../../utils/capitaliseString.js";
20
+ const RESOURCE_NAME_PATTERN = VALIDATION_PATTERNS.RESOURCE_NAME;
21
+ /** SCREAMING_SNAKE_CASE env-var prefix (digits allowed, must start with letter). */
22
+ const ENV_PREFIX_PATTERN = /^[A-Z][A-Z0-9_]*$/;
23
+ /**
24
+ * Resolve `remoteConnections` for every service into a per-service env-var
25
+ * bag. Returns `Record<serviceName, Record<envVarName, value>>`.
26
+ *
27
+ * Validates each `RemoteConnectionSpec` at synth time:
28
+ * - `resource` matches `RESOURCE_NAME_PATTERN` (PascalCase).
29
+ * - `envPrefix` (when supplied) matches `ENV_PREFIX_PATTERN`.
30
+ * - `peer.peerAppName` is defined and is not a CFN token — token-derived app
31
+ * names produce `${Token[…]}` literals in env-var names, breaking ECS task
32
+ * definitions silently.
33
+ */
34
+ export function resolveRemoteConnections(services, scope, orgId) {
35
+ const byService = {};
36
+ for (const service of services) {
37
+ const specs = service.remoteConnections ?? [];
38
+ if (specs.length === 0)
39
+ continue;
40
+ const envVars = {};
41
+ const seenPrefixes = new Set();
42
+ for (const spec of specs) {
43
+ const peerAppName = validateSpec(service.name, spec);
44
+ const peerOrgId = spec.peer.peerOrgId ?? orgId ?? DEFAULT_ORG_ID;
45
+ const ssmPrefix = `${buildSsmPrefix(peerOrgId, peerAppName)}/resources/${spec.resource}`;
46
+ const endpoint = StringParameter.valueForStringParameter(scope, `${ssmPrefix}/endpoint`);
47
+ const port = StringParameter.valueForStringParameter(scope, `${ssmPrefix}/port`);
48
+ const prefix = spec.envPrefix ??
49
+ `${toScreamingSnake(peerAppName)}_${toScreamingSnake(spec.resource)}`;
50
+ if (seenPrefixes.has(prefix)) {
51
+ throw new Error(`remoteConnections on service '${service.name}': duplicate env-var prefix '${prefix}' — set distinct envPrefix on each spec to avoid silently clobbering ${prefix}_HOST and ${prefix}_PORT.`);
52
+ }
53
+ seenPrefixes.add(prefix);
54
+ envVars[`${prefix}_HOST`] = endpoint;
55
+ envVars[`${prefix}_PORT`] = port;
56
+ }
57
+ byService[service.name] = envVars;
58
+ }
59
+ return byService;
60
+ }
61
+ function validateSpec(serviceName, spec) {
62
+ if (!RESOURCE_NAME_PATTERN.test(spec.resource)) {
63
+ throw new Error(`remoteConnections on service '${serviceName}': resource '${spec.resource}' is not a valid PascalCase resource name (must match ${RESOURCE_NAME_PATTERN.source}).`);
64
+ }
65
+ if (spec.envPrefix !== undefined &&
66
+ !ENV_PREFIX_PATTERN.test(spec.envPrefix)) {
67
+ throw new Error(`remoteConnections on service '${serviceName}': envPrefix '${spec.envPrefix}' is not SCREAMING_SNAKE_CASE (must match ${ENV_PREFIX_PATTERN.source}).`);
68
+ }
69
+ const peerAppName = spec.peer.peerAppName;
70
+ if (peerAppName === undefined) {
71
+ throw new Error(`remoteConnections on service '${serviceName}': peer.peerAppName is undefined — pass a peer built via VpcPeerFactory.build() so the SSM path can be resolved at synth.`);
72
+ }
73
+ if (typeof peerAppName === "string" && peerAppName.length === 0) {
74
+ throw new Error(`remoteConnections on service '${serviceName}': peer.peerAppName is an empty string, which would produce malformed env-var names and an invalid SSM path. Pass a literal app name (typically the Fjall app's name string).`);
75
+ }
76
+ if (Token.isUnresolved(peerAppName)) {
77
+ throw new Error(`remoteConnections on service '${serviceName}': peer.peerAppName is a CFN token, which would corrupt env-var names. Pass a literal app name (typically the Fjall app's name string).`);
78
+ }
79
+ return peerAppName;
80
+ }
@@ -3,9 +3,11 @@ import { Duration } from "aws-cdk-lib";
3
3
  import { Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
4
4
  import { Secret } from "aws-cdk-lib/aws-secretsmanager";
5
5
  import { StringParameter } from "aws-cdk-lib/aws-ssm";
6
+ import { resolveOrgId } from "../../../utils/cdkContext.js";
6
7
  import { validateSsmPathComponent } from "./ecsValidation.js";
7
8
  import { DEFAULT_LOG_RETENTION_DAYS } from "./ecsConstants.js";
8
9
  import { getContainerImage } from "./ecsImages.js";
10
+ import { resolveRemoteConnections } from "./ecsRemoteConnections.js";
9
11
  // Re-export extracted functions so existing consumers are not broken
10
12
  export { createExecutionRole, createTaskRole } from "./ecsRoles.js";
11
13
  export { getContainerImage } from "./ecsImages.js";
@@ -91,6 +93,9 @@ export function createTaskDefinition(ctx, serviceName, serviceProps, executionRo
91
93
  export function addContainersToTask(ctx, serviceName, serviceProps, taskDefinition) {
92
94
  const containers = [];
93
95
  let primaryContainer;
96
+ const orgId = resolveOrgId(ctx.scope.node);
97
+ const remoteEnvByService = resolveRemoteConnections([serviceProps], ctx.scope, orgId);
98
+ const remoteEnv = remoteEnvByService[serviceName] ?? {};
94
99
  for (const containerConfig of serviceProps.containers) {
95
100
  const image = getContainerImage(ctx, serviceName, containerConfig, serviceProps);
96
101
  const isFirstWithPort = !primaryContainer && containerConfig.port !== undefined;
@@ -125,8 +130,11 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
125
130
  streamPrefix: `/ecs/${ctx.props.clusterName}/${serviceName}`,
126
131
  logRetention: DEFAULT_LOG_RETENTION_DAYS
127
132
  }),
133
+ // remoteEnv (cross-app `${PREFIX}_HOST/_PORT`) intentionally overrides user
134
+ // values — a stale manual setting must not mask the resolved peer.
128
135
  environment: {
129
136
  ...containerConfig.environment,
137
+ ...remoteEnv,
130
138
  ...(containerConfig.port
131
139
  ? { PORT: String(containerConfig.port) }
132
140
  : {})
@@ -10,6 +10,7 @@ import { type Role } from "aws-cdk-lib/aws-iam";
10
10
  import { type HostedZone as FjallHostedZone } from "../networking/hostedZone.js";
11
11
  import { type Certificate } from "aws-cdk-lib/aws-certificatemanager";
12
12
  import { type ConnectionSpec } from "../../../utils/connector.js";
13
+ import { type RemoteConnectionSpec } from "./ecsRemoteConnections.js";
13
14
  import { type SecretImport } from "../secrets/index.js";
14
15
  import type { ManagedDomainExports } from "../../../utils/domainTypes.js";
15
16
  import type { ITopic } from "aws-cdk-lib/aws-sns";
@@ -249,6 +250,12 @@ export interface EcsServiceProps {
249
250
  * ]
250
251
  */
251
252
  connections?: ConnectionSpec[];
253
+ /**
254
+ * Cross-app resources reachable via VPC peering. Resolved at synth time
255
+ * into `${PREFIX}_HOST` / `${PREFIX}_PORT` env vars merged into every
256
+ * container in this service's task definition.
257
+ */
258
+ remoteConnections?: RemoteConnectionSpec[];
252
259
  /**
253
260
  * Capacity provider for this service. REQUIRED.
254
261
  * Each service specifies its own capacity provider.