@fjall/components-infrastructure 16.0.1 → 18.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 (30) hide show
  1. package/dist/lib/config/aws/scpPreset.js +103 -17
  2. package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +103 -8
  3. package/dist/lib/patterns/aws/clickhouseDatabase.js +51 -12
  4. package/dist/lib/patterns/aws/database.d.ts +1 -1
  5. package/dist/lib/patterns/aws/database.js +1 -1
  6. package/dist/lib/patterns/aws/interfaces/domain.d.ts +1 -0
  7. package/dist/lib/patterns/aws/storage.d.ts +8 -0
  8. package/dist/lib/patterns/aws/storage.js +10 -3
  9. package/dist/lib/patterns/aws/targets/fjallTargets.d.ts +7 -5
  10. package/dist/lib/patterns/aws/targets/fjallTargets.js +9 -6
  11. package/dist/lib/patterns/aws/targets/targetResolution.d.ts +8 -5
  12. package/dist/lib/patterns/aws/targets/targetResolution.js +11 -8
  13. package/dist/lib/resources/aws/database/clickhouseBackupScript.d.ts +103 -0
  14. package/dist/lib/resources/aws/database/clickhouseBackupScript.js +123 -0
  15. package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +132 -20
  16. package/dist/lib/resources/aws/database/clickhouseConstants.js +126 -15
  17. package/dist/lib/resources/aws/database/clickhouseSchemas.d.ts +33 -1
  18. package/dist/lib/resources/aws/database/clickhouseSchemas.js +31 -0
  19. package/dist/lib/resources/aws/database/clickhouseStorage.d.ts +58 -0
  20. package/dist/lib/resources/aws/database/clickhouseStorage.js +87 -0
  21. package/dist/lib/resources/aws/database/clickhouseTuning.d.ts +5 -6
  22. package/dist/lib/resources/aws/database/clickhouseTuning.js +39 -16
  23. package/dist/lib/resources/aws/database/clickhouseUserData.d.ts +21 -1
  24. package/dist/lib/resources/aws/database/clickhouseUserData.js +22 -6
  25. package/dist/lib/resources/aws/database/clickhouseXmlRenderer.js +22 -1
  26. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.d.ts +54 -5
  27. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.js +106 -10
  28. package/dist/lib/resources/aws/storage/s3.d.ts +19 -7
  29. package/dist/lib/resources/aws/storage/s3.js +15 -3
  30. package/package.json +3 -3
@@ -5,7 +5,7 @@ import { Metric } from "aws-cdk-lib/aws-cloudwatch";
5
5
  import { FilterPattern, MetricFilter } from "aws-cdk-lib/aws-logs";
6
6
  import { ALARM_DEFAULTS, registerAlarm, tagAlarmsWithApplicationId, buildAlarmDescription } from "./alarmDefaults.js";
7
7
  import { METRIC_NAMESPACE, stackScopedMetricNamespace } from "./metricNamespaces.js";
8
- import { CLICKHOUSE_HOST_METRICS } from "../database/clickhouseConstants.js";
8
+ import { CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER, CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER, CLICKHOUSE_HOST_METRICS } from "../database/clickhouseConstants.js";
9
9
  export const BACKUP_HEARTBEAT_DEFAULT_WINDOW_HOURS = 26;
10
10
  /**
11
11
  * Ceiling derivation — an ORDERING bound, not CloudWatch expressibility.
@@ -55,7 +55,9 @@ export function validateClickHouseAlarmThresholds(config) {
55
55
  * (never installed on these hosts) but by the lightweight put-metric-data
56
56
  * timer the user-data installs (see `buildClickHouseUserData`), which publishes
57
57
  * `mem_used_percent` / `disk_used_percent` under the same names and
58
- * `AutoScalingGroupName` dimension the agent would use plus the
58
+ * `AutoScalingGroupName` dimension the agent would use, plus `disk_free_gib`,
59
+ * which has no CloudWatch Agent equivalent and exists because the disk
60
+ * question that pages must be answered in bytes rather than percent — plus the
59
61
  * backup-failure log alarm when a backup-task log group is supplied:
60
62
  *
61
63
  * - **Backup failures** — `AccessDenied` or `S3Exception` from the backup
@@ -79,7 +81,7 @@ export function validateClickHouseAlarmThresholds(config) {
79
81
  * see (the EC2 host and the backup task).
80
82
  */
81
83
  export function createClickHouseAlarms(props) {
82
- const { scope, instanceRole, asgName, alarmTopic, backupTaskLogGroup, config = {}, applicationId } = props;
84
+ const { scope, instanceRole, asgName, alarmTopic, backupTaskLogGroup, backupVerifyEnabled = true, config = {}, diskFreeCriticalGib, applicationId } = props;
83
85
  validateClickHouseAlarmThresholds(config);
84
86
  const alarms = [];
85
87
  const snsAction = new SnsAction(alarmTopic);
@@ -116,24 +118,28 @@ export function createClickHouseAlarms(props) {
116
118
  treatMissingData: TreatMissingData.NOT_BREACHING
117
119
  });
118
120
  registerAlarm(diskWarnAlarm, snsAction, alarms);
119
- const diskCriticalThreshold = config.diskCriticalThreshold ?? 85;
120
121
  const diskCriticalAlarm = new Alarm(scope, "ClickHouseDiskCriticalAlarm", {
121
- alarmDescription: buildAlarmDescription(`ClickHouse data volume above ${diskCriticalThreshold}% usedimminent insert failures`, applicationId),
122
+ alarmDescription: buildAlarmDescription(`ClickHouse data volume under ${diskFreeCriticalGib} GiB free merges and inserts are about to fail`, applicationId),
122
123
  metric: new Metric({
123
124
  namespace: CLICKHOUSE_HOST_METRICS.namespace,
124
- metricName: CLICKHOUSE_HOST_METRICS.diskMetric,
125
+ metricName: CLICKHOUSE_HOST_METRICS.diskFreeMetric,
125
126
  dimensionsMap: { [CLICKHOUSE_HOST_METRICS.asgDimension]: asgName },
126
127
  period: Duration.minutes(5),
127
- statistic: "Average"
128
+ // Minimum, not Average: a single sample under the floor is the event,
129
+ // and averaging a dip against the surrounding minute hides exactly the
130
+ // moment a large merge claims the last of the disk.
131
+ statistic: "Minimum"
128
132
  }),
129
- threshold: diskCriticalThreshold,
133
+ threshold: diskFreeCriticalGib,
130
134
  evaluationPeriods: 2,
131
135
  datapointsToAlarm: 2,
132
- comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
136
+ comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
133
137
  // BREACHING, unlike the other host alarms: a dead metrics publisher looks
134
138
  // identical to a full disk (no datapoints either way), and this alarm's
135
139
  // whole job is to fire before inserts start failing. The 2×5min window
136
- // absorbs the boot gap on instance replacement.
140
+ // absorbs the boot gap on instance replacement — and, on the deploy that
141
+ // first introduces the metric, the instance refresh that starts
142
+ // publishing it. Expect one transient ALARM on that deploy.
137
143
  treatMissingData: TreatMissingData.BREACHING
138
144
  });
139
145
  registerAlarm(diskCriticalAlarm, snsAction, alarms);
@@ -163,6 +169,16 @@ export function createClickHouseAlarms(props) {
163
169
  treatMissingData: TreatMissingData.NOT_BREACHING
164
170
  });
165
171
  registerAlarm(backupFailureAlarm, snsAction, alarms);
172
+ if (backupVerifyEnabled) {
173
+ createBackupVerifyAlarms({
174
+ scope,
175
+ backupTaskLogGroup,
176
+ namespace: backupFailureNamespace,
177
+ snsAction,
178
+ applicationId,
179
+ alarms
180
+ });
181
+ }
166
182
  createBackupHeartbeat({
167
183
  scope,
168
184
  backupTaskLogGroup,
@@ -176,6 +192,86 @@ export function createClickHouseAlarms(props) {
176
192
  tagAlarmsWithApplicationId(alarms, applicationId);
177
193
  return alarms;
178
194
  }
195
+ /**
196
+ * Restore-verify alarms — the half of the backup story `BACKUP_CREATED`
197
+ * cannot tell.
198
+ *
199
+ * The success marker the heartbeat reads means the BACKUP statement returned.
200
+ * It says nothing about whether the bytes can be read back, and the cheap
201
+ * check that looks like it would say so does not: a `structure_only` restore
202
+ * of a backup whose entire `data/` tree had been deleted returns `RESTORED`
203
+ * on 26.3.17.56, because it reads `metadata/` and never opens a part. So the
204
+ * backup task restores in full into a scratch database and emits its own
205
+ * verdict, and these two alarms read it.
206
+ *
207
+ * - **Verify failed** (`BACKUP_VERIFY_FAILED`, Sum >= 1 per hour) — the
208
+ * backup that was just written could not be restored, or restored without
209
+ * the tables the live database has, or restored empty. This is the alarm
210
+ * that means the backups are not backups.
211
+ * - **Verify skipped for size** (`BACKUP_VERIFY_SKIPPED_FOR_SIZE`, Sum >= 1
212
+ * per DAY) — the restore would not have left the critical free-space floor
213
+ * clear, so the task declined to run it. Slower than its sibling because
214
+ * it is not an emergency on the day it first fires; it is nonetheless a
215
+ * page, because from that day on nothing is checking that the backups
216
+ * restore, and a degradation nobody is told about is the failure mode this
217
+ * whole mechanism exists to remove. The cure is a larger `storageGb` or
218
+ * verification moved off-box.
219
+ *
220
+ * Both treat missing data as NOT_BREACHING: a run that never happened is the
221
+ * heartbeat's question, not this one's, and double-paging one outage from two
222
+ * alarms buys nothing.
223
+ */
224
+ function createBackupVerifyAlarms(props) {
225
+ const { scope, backupTaskLogGroup, namespace, snsAction, applicationId, alarms } = props;
226
+ const verifyFailedMetricName = "ClickHouseBackupVerifyFailedCount";
227
+ new MetricFilter(scope, "ClickHouseBackupVerifyFailedMetricFilter", {
228
+ logGroup: backupTaskLogGroup,
229
+ metricNamespace: namespace,
230
+ metricName: verifyFailedMetricName,
231
+ filterPattern: FilterPattern.allTerms(CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER),
232
+ metricValue: "1",
233
+ defaultValue: 0
234
+ });
235
+ const verifyFailedAlarm = new Alarm(scope, "ClickHouseBackupVerifyFailedAlarm", {
236
+ alarmDescription: buildAlarmDescription("ClickHouse backup could not be restored — the backup completed but does not read back; treat the retained backups as unproven", applicationId),
237
+ metric: new Metric({
238
+ namespace,
239
+ metricName: verifyFailedMetricName,
240
+ period: Duration.hours(1),
241
+ statistic: "Sum"
242
+ }),
243
+ threshold: 1,
244
+ evaluationPeriods: 1,
245
+ datapointsToAlarm: 1,
246
+ comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
247
+ treatMissingData: TreatMissingData.NOT_BREACHING
248
+ });
249
+ registerAlarm(verifyFailedAlarm, snsAction, alarms);
250
+ const verifySkippedMetricName = "ClickHouseBackupVerifySkippedCount";
251
+ new MetricFilter(scope, "ClickHouseBackupVerifySkippedMetricFilter", {
252
+ logGroup: backupTaskLogGroup,
253
+ metricNamespace: namespace,
254
+ metricName: verifySkippedMetricName,
255
+ filterPattern: FilterPattern.allTerms(CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER),
256
+ metricValue: "1",
257
+ defaultValue: 0
258
+ });
259
+ const verifySkippedAlarm = new Alarm(scope, "ClickHouseBackupVerifySkippedAlarm", {
260
+ alarmDescription: buildAlarmDescription("ClickHouse backup restore-verify skipped — the database no longer fits a verify restore alongside itself; raise storageGb or move verification off-box", applicationId),
261
+ metric: new Metric({
262
+ namespace,
263
+ metricName: verifySkippedMetricName,
264
+ period: Duration.days(1),
265
+ statistic: "Sum"
266
+ }),
267
+ threshold: 1,
268
+ evaluationPeriods: 1,
269
+ datapointsToAlarm: 1,
270
+ comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
271
+ treatMissingData: TreatMissingData.NOT_BREACHING
272
+ });
273
+ registerAlarm(verifySkippedAlarm, snsAction, alarms);
274
+ }
179
275
  /**
180
276
  * Backup success heartbeat — the complete-but-slow half of the backup alarm
181
277
  * pair (the AccessDenied/S3Exception failure alarm above is the fast half).
@@ -37,15 +37,27 @@ export interface S3BucketProps extends BucketProps {
37
37
  publicReadAccess?: boolean;
38
38
  websiteHosting?: WebsiteHostingConfig;
39
39
  /**
40
- * Name a `fjallBucket(<name>)` alias target uses to import this bucket's
41
- * website exports. Defaults to `bucketName ?? id`, which is right for a
42
- * direct `new S3Bucket(scope, "Assets", …)` — `id` is then the author's own
43
- * handle. A wrapper that nests the bucket under a derived id MUST pass this:
44
- * `Storage` builds its bucket at `${id}Bucket`, so without it a
45
- * `fjallBucket("assets")` record would import `AssetsWebsiteEndpoint`
46
- * against an exported `AssetsBucketWebsiteEndpoint`.
40
+ * Name a `fjallBucket(<app>, <name>)` alias target uses to import this
41
+ * bucket's website exports. Defaults to `bucketName ?? id`, which is right
42
+ * for a direct `new S3Bucket(scope, "Assets", …)` — `id` is then the
43
+ * author's own handle. A wrapper that nests the bucket under a derived id
44
+ * MUST pass this: `Storage` builds its bucket at `${id}Bucket`, so without
45
+ * it a `fjallBucket(app, "assets")` record would import
46
+ * `…-Assets-WebsiteEndpoint` against an exported
47
+ * `…-AssetsBucket-WebsiteEndpoint`.
47
48
  */
48
49
  aliasTargetName?: string;
50
+ /**
51
+ * The owning Fjall app's name — the other half of the website-export key
52
+ * (`bucketWebsiteEndpointExportName(appName, bucket)`). REQUIRED whenever
53
+ * `websiteHosting` is set: export names are account+region-global and every
54
+ * scaffolded app names its website bucket identically, so an unqualified
55
+ * export makes two website-bearing apps mutually undeployable. The
56
+ * `StorageFactory` path fills this from the App automatically (as
57
+ * `CdnFactory` does for `Cdn`); only direct `new S3Bucket`/`new Storage`
58
+ * construction needs to pass it.
59
+ */
60
+ appName?: string;
49
61
  /**
50
62
  * Declarative bucket-policy statements, each appended via
51
63
  * `addToResourcePolicy`. The TLS-only `enforceSSL` deny is always applied
@@ -18,7 +18,7 @@ function toResourcePolicyPrincipal(identifier) {
18
18
  export class S3Bucket extends Bucket {
19
19
  backupVaultTier;
20
20
  constructor(scope, id, props = {}) {
21
- const { websiteHosting, backupVaultTier, resourcePolicyStatements, aliasTargetName, ...cdkProps } = props;
21
+ const { websiteHosting, backupVaultTier, resourcePolicyStatements, aliasTargetName, appName, ...cdkProps } = props;
22
22
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
23
23
  const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
24
24
  const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
@@ -74,6 +74,18 @@ export class S3Bucket extends Bucket {
74
74
  Tags.of(this).add(SDK_PRE_EMPTY_TAG_KEY, "true");
75
75
  }
76
76
  if (websiteHosting) {
77
+ if (appName === undefined) {
78
+ // Refusing beats falling back to a bucket-only export name: the
79
+ // consumer (`resolveBucketTarget`) always imports the app-qualified
80
+ // pair, so an unqualified export could never be resolved — it would
81
+ // only surface at CloudFormation execution as "No export named …",
82
+ // or worse, collide with a sibling app's identically-named bucket.
83
+ throw new Error(`S3 bucket '${id}': websiteHosting requires 'appName' — the ` +
84
+ "website exports are keyed on (app, bucket) so two apps' " +
85
+ "identically-named buckets stay deployable side by side. " +
86
+ "App-managed paths (StorageFactory/CdnFactory) fill it " +
87
+ "automatically; pass appName explicitly for direct construction.");
88
+ }
77
89
  const targetBucketName = aliasTargetName ?? props.bucketName ?? id;
78
90
  // Only the export name routes through the contract helper. `safeBucket`
79
91
  // stays local so the CfnOutput logical ids are unchanged — unifying the
@@ -81,7 +93,7 @@ export class S3Bucket extends Bucket {
81
93
  const safeBucket = toPascalCase(targetBucketName.replace(/[^A-Za-z0-9-]/g, ""));
82
94
  new CfnOutput(this, `${safeBucket}WebsiteEndpoint`, {
83
95
  key: `${safeBucket}WebsiteEndpoint`,
84
- exportName: bucketWebsiteEndpointExportName(targetBucketName),
96
+ exportName: bucketWebsiteEndpointExportName(appName, targetBucketName),
85
97
  value: this.bucketWebsiteDomainName,
86
98
  description: `S3 website endpoint for ${id} (consumed by Domain alias targets)`
87
99
  });
@@ -93,7 +105,7 @@ export class S3Bucket extends Bucket {
93
105
  }
94
106
  new CfnOutput(this, `${safeBucket}WebsiteHostedZoneId`, {
95
107
  key: `${safeBucket}WebsiteHostedZoneId`,
96
- exportName: bucketWebsiteHostedZoneIdExportName(targetBucketName),
108
+ exportName: bucketWebsiteHostedZoneIdExportName(appName, targetBucketName),
97
109
  value: websiteHostedZoneId,
98
110
  description: `Route 53 hosted zone id for the S3 website endpoint of ${id}`
99
111
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "16.0.1",
3
+ "version": "18.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^16.0.1",
84
- "@fjall/util": "^16.0.1",
83
+ "@fjall/generator": "^18.0.0",
84
+ "@fjall/util": "^18.0.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {