@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.
- package/dist/lib/config/aws/scpPreset.js +103 -17
- package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +103 -8
- package/dist/lib/patterns/aws/clickhouseDatabase.js +51 -12
- package/dist/lib/patterns/aws/database.d.ts +1 -1
- package/dist/lib/patterns/aws/database.js +1 -1
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +1 -0
- package/dist/lib/patterns/aws/storage.d.ts +8 -0
- package/dist/lib/patterns/aws/storage.js +10 -3
- package/dist/lib/patterns/aws/targets/fjallTargets.d.ts +7 -5
- package/dist/lib/patterns/aws/targets/fjallTargets.js +9 -6
- package/dist/lib/patterns/aws/targets/targetResolution.d.ts +8 -5
- package/dist/lib/patterns/aws/targets/targetResolution.js +11 -8
- package/dist/lib/resources/aws/database/clickhouseBackupScript.d.ts +103 -0
- package/dist/lib/resources/aws/database/clickhouseBackupScript.js +123 -0
- package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +132 -20
- package/dist/lib/resources/aws/database/clickhouseConstants.js +126 -15
- package/dist/lib/resources/aws/database/clickhouseSchemas.d.ts +33 -1
- package/dist/lib/resources/aws/database/clickhouseSchemas.js +31 -0
- package/dist/lib/resources/aws/database/clickhouseStorage.d.ts +58 -0
- package/dist/lib/resources/aws/database/clickhouseStorage.js +87 -0
- package/dist/lib/resources/aws/database/clickhouseTuning.d.ts +5 -6
- package/dist/lib/resources/aws/database/clickhouseTuning.js +39 -16
- package/dist/lib/resources/aws/database/clickhouseUserData.d.ts +21 -1
- package/dist/lib/resources/aws/database/clickhouseUserData.js +22 -6
- package/dist/lib/resources/aws/database/clickhouseXmlRenderer.js +22 -1
- package/dist/lib/resources/aws/monitoring/clickhouseAlarms.d.ts +54 -5
- package/dist/lib/resources/aws/monitoring/clickhouseAlarms.js +106 -10
- package/dist/lib/resources/aws/storage/s3.d.ts +19 -7
- package/dist/lib/resources/aws/storage/s3.js +15 -3
- 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
|
|
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
|
|
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.
|
|
125
|
+
metricName: CLICKHOUSE_HOST_METRICS.diskFreeMetric,
|
|
125
126
|
dimensionsMap: { [CLICKHOUSE_HOST_METRICS.asgDimension]: asgName },
|
|
126
127
|
period: Duration.minutes(5),
|
|
127
|
-
|
|
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:
|
|
133
|
+
threshold: diskFreeCriticalGib,
|
|
130
134
|
evaluationPeriods: 2,
|
|
131
135
|
datapointsToAlarm: 2,
|
|
132
|
-
comparisonOperator: ComparisonOperator.
|
|
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
|
|
41
|
-
* website exports. Defaults to `bucketName ?? id`, which is right
|
|
42
|
-
* direct `new S3Bucket(scope, "Assets", …)` — `id` is then the
|
|
43
|
-
* handle. A wrapper that nests the bucket under a derived id
|
|
44
|
-
* `Storage` builds its bucket at `${id}Bucket`, so without
|
|
45
|
-
* `fjallBucket("assets")` record would import
|
|
46
|
-
* against an exported
|
|
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": "
|
|
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": "^
|
|
84
|
-
"@fjall/util": "^
|
|
83
|
+
"@fjall/generator": "^18.0.0",
|
|
84
|
+
"@fjall/util": "^18.0.0",
|
|
85
85
|
"constructs": "^10.7.2"
|
|
86
86
|
},
|
|
87
87
|
"overrides": {
|