@fjall/components-infrastructure 19.0.0 → 21.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/README.md +50 -0
- package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +23 -3
- package/dist/lib/patterns/aws/clickhouseDatabase.js +116 -26
- package/dist/lib/patterns/aws/computeEcs.d.ts +14 -16
- package/dist/lib/patterns/aws/computeEcs.js +30 -74
- package/dist/lib/patterns/aws/interfaces/database.d.ts +14 -13
- package/dist/lib/resources/aws/compute/ecs.js +2 -1
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +5 -3
- package/dist/lib/resources/aws/database/clickhouseBackupScript.d.ts +12 -0
- package/dist/lib/resources/aws/database/clickhouseBackupScript.js +34 -14
- package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +30 -2
- package/dist/lib/resources/aws/database/clickhouseConstants.js +30 -2
- package/dist/lib/resources/aws/database/clickhouseMaintenanceScript.d.ts +69 -0
- package/dist/lib/resources/aws/database/clickhouseMaintenanceScript.js +131 -0
- package/dist/lib/resources/aws/database/rdsAurora.js +3 -0
- package/dist/lib/resources/aws/database/rdsInstance.js +4 -0
- package/dist/lib/resources/aws/monitoring/clickhouseAlarms.d.ts +12 -0
- package/dist/lib/resources/aws/monitoring/clickhouseAlarms.js +79 -3
- package/dist/lib/resources/aws/monitoring/ecsAlarms.d.ts +10 -1
- package/dist/lib/resources/aws/monitoring/ecsAlarms.js +14 -4
- package/dist/lib/resources/aws/networking/vpc.js +6 -2
- package/dist/lib/resources/aws/storage/s3.js +13 -3
- package/package.json +3 -3
|
@@ -26,7 +26,7 @@ import { FjallLogger } from "../../utils/validationLogger.js";
|
|
|
26
26
|
import { VALIDATION_PATTERNS } from "@fjall/generator";
|
|
27
27
|
import { evaluateBakeGuard } from "@fjall/util/docker";
|
|
28
28
|
import { toKebab, buildParameterPath } from "@fjall/util";
|
|
29
|
-
import { SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_ECR_REPO_NAME } from "@fjall/util/migration";
|
|
29
|
+
import { SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_ECR_REPO_NAME, SCHEMA_GATE_START_TIMEOUT_SECONDS } from "@fjall/util/migration";
|
|
30
30
|
import { CONSTRUCTS_VERSION } from "../../utils/engineCompat.js";
|
|
31
31
|
import { validateEc2ServiceSizing, validateEc2TaskMemoryFit, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
|
|
32
32
|
import { validateSharedEc2CapacityConfig } from "../../resources/aws/compute/ecsCapacityConfig.js";
|
|
@@ -271,14 +271,6 @@ export function validateEcsProps(props) {
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
const DEFAULT_MIGRATE_CONTAINER_NAME = "migrate";
|
|
274
|
-
/**
|
|
275
|
-
* How long dependent containers wait for the gate to reach SUCCESS before
|
|
276
|
-
* giving up (stopping the task pre-RUNNING). ECS declares this on the
|
|
277
|
-
* depended-ON container. 120s is the verified Fargate ceiling
|
|
278
|
-
* (design 2026-08-10 § 4); the runner's worst-case retry budget
|
|
279
|
-
* (3 connects × 10s + 2 sleeps × 5s, PG and CH in parallel) fits inside it.
|
|
280
|
-
*/
|
|
281
|
-
const SCHEMA_GATE_START_TIMEOUT_SECONDS = 120;
|
|
282
274
|
/**
|
|
283
275
|
* Postgres-family scheme test for the gate's v1 eligibility filter. The URL
|
|
284
276
|
* base is `getConnectionString()` output whose host may be an unresolved CDK
|
|
@@ -732,15 +724,13 @@ function assertMigrationSecretsResolvable(service, effectiveMigrations) {
|
|
|
732
724
|
* container gains a `dependsOn` SUCCESS edge on it.
|
|
733
725
|
* @internal Exported for testing only
|
|
734
726
|
*/
|
|
735
|
-
export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope,
|
|
727
|
+
export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope, chSchemaVersionEnv, schemaGate) {
|
|
736
728
|
const userContainers = service.containers && service.containers.length > 0
|
|
737
729
|
? service.containers
|
|
738
730
|
: undefined;
|
|
739
731
|
const expanded = service.migrations
|
|
740
732
|
? expandMigrationsSugar(service, userContainers)
|
|
741
733
|
: userContainers;
|
|
742
|
-
const chSchemaVersionEnv = chGate?.environment;
|
|
743
|
-
const chGateSecretsImport = chGate?.secretsImport;
|
|
744
734
|
const mergeSchemaEnv = (authored) => mergePgSchemaVersionEnv({
|
|
745
735
|
authored,
|
|
746
736
|
resolved: schemaVersionEnv,
|
|
@@ -755,18 +745,6 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
755
745
|
annotationsScope,
|
|
756
746
|
overrideRemedy: "set `schemaGate: false` to silence this warning."
|
|
757
747
|
});
|
|
758
|
-
const mergeChGateSecretsImport = (authored) => {
|
|
759
|
-
if (chGateSecretsImport === undefined)
|
|
760
|
-
return authored;
|
|
761
|
-
if (Object.keys(chGateSecretsImport).length === 0)
|
|
762
|
-
return authored;
|
|
763
|
-
const merged = { ...(authored ?? {}) };
|
|
764
|
-
for (const [k, v] of Object.entries(chGateSecretsImport)) {
|
|
765
|
-
if (merged[k] === undefined)
|
|
766
|
-
merged[k] = v;
|
|
767
|
-
}
|
|
768
|
-
return merged;
|
|
769
|
-
};
|
|
770
748
|
const mergeAllSchemaEnv = (authored) => mergeChSchemaEnv(mergeSchemaEnv(authored));
|
|
771
749
|
// Prepend the synthetic gate container and wire every other container to
|
|
772
750
|
// wait on its SUCCESS. In init-container mode the gate itself waits on the
|
|
@@ -817,7 +795,7 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
817
795
|
port: c.port,
|
|
818
796
|
environment: mergeAllSchemaEnv(c.environment),
|
|
819
797
|
secrets: c.secrets,
|
|
820
|
-
secretsImport:
|
|
798
|
+
secretsImport: c.secretsImport,
|
|
821
799
|
command: c.command,
|
|
822
800
|
entryPoint: c.entryPoint,
|
|
823
801
|
essential: c.essential,
|
|
@@ -830,14 +808,10 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
830
808
|
}));
|
|
831
809
|
}
|
|
832
810
|
const fallbackEnv = mergeAllSchemaEnv(undefined);
|
|
833
|
-
const fallbackSecretsImport = mergeChGateSecretsImport(undefined);
|
|
834
811
|
return wireSchemaGate([
|
|
835
812
|
{
|
|
836
813
|
name: `${service.name}Container`,
|
|
837
|
-
...(fallbackEnv !== undefined && { environment: fallbackEnv })
|
|
838
|
-
...(fallbackSecretsImport !== undefined && {
|
|
839
|
-
secretsImport: fallbackSecretsImport
|
|
840
|
-
})
|
|
814
|
+
...(fallbackEnv !== undefined && { environment: fallbackEnv })
|
|
841
815
|
}
|
|
842
816
|
]);
|
|
843
817
|
}
|
|
@@ -919,12 +893,12 @@ export class EcsCompute extends Construct {
|
|
|
919
893
|
this.schemaGateImageTags = schemaGateImageTags;
|
|
920
894
|
const services = props.services.map((service) => {
|
|
921
895
|
const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
|
|
922
|
-
const
|
|
923
|
-
const schemaGateSpec = this.resolveSchemaGateMaterialisation(service, schemaVersionEnv,
|
|
896
|
+
const chSchemaVersionEnv = this.resolveClickHouseSchemaVersionEnv(service);
|
|
897
|
+
const schemaGateSpec = this.resolveSchemaGateMaterialisation(service, schemaVersionEnv, chSchemaVersionEnv);
|
|
924
898
|
if (schemaGateSpec?.defaultImageTag !== undefined) {
|
|
925
899
|
schemaGateImageTags.set(service.name, schemaGateSpec.defaultImageTag);
|
|
926
900
|
}
|
|
927
|
-
const containers = buildContainerConfigs(service, schemaVersionEnv, this,
|
|
901
|
+
const containers = buildContainerConfigs(service, schemaVersionEnv, this, chSchemaVersionEnv, schemaGateSpec);
|
|
928
902
|
const { scalingType, minCapacity, maxCapacity, queueScaling } = resolveScalingConfig(service.scaling);
|
|
929
903
|
const cloudMapService = service.serviceDiscovery !== undefined
|
|
930
904
|
? App.getInstance().registerService({
|
|
@@ -1041,17 +1015,16 @@ export class EcsCompute extends Construct {
|
|
|
1041
1015
|
}
|
|
1042
1016
|
/**
|
|
1043
1017
|
* ClickHouse mirror of `resolveSchemaVersionEnv`. Returns the
|
|
1044
|
-
* `EXPECTED_CH_SCHEMA_VERSION` env entry
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1018
|
+
* `EXPECTED_CH_SCHEMA_VERSION` env entry, or `undefined` when the service
|
|
1019
|
+
* is not gated. Env only: app containers receive no ClickHouse credentials
|
|
1020
|
+
* from the gate — the materialised gate container authenticates as the
|
|
1021
|
+
* read-only gate identity via `getSchemaGateContribution()`, composed in
|
|
1022
|
+
* `resolveSchemaGateMaterialisation`.
|
|
1047
1023
|
*
|
|
1048
1024
|
* - `schemaGate: false` → returns `undefined` (auditable opt-out — same flag
|
|
1049
1025
|
* covers BOTH PG and CH gates)
|
|
1050
1026
|
* - No migrated CH in connections → returns `undefined`
|
|
1051
|
-
* - Exactly one migrated CH → returns
|
|
1052
|
-
* `SCHEMA_ADMIN_USER`, plus `secretsImport.SCHEMA_ADMIN_PASSWORD`. The
|
|
1053
|
-
* creds piggyback on the same gate resolution so adding the gate to a
|
|
1054
|
-
* service can't ship without the creds it needs to use it.
|
|
1027
|
+
* - Exactly one migrated CH → returns `{ EXPECTED_CH_SCHEMA_VERSION }`
|
|
1055
1028
|
* - Two or more migrated CHs → throws via `resolveClickHouseDatabaseForService`
|
|
1056
1029
|
*/
|
|
1057
1030
|
resolveClickHouseSchemaVersionEnv(service) {
|
|
@@ -1063,16 +1036,7 @@ export class EcsCompute extends Construct {
|
|
|
1063
1036
|
const version = db.getExpectedSchemaVersion();
|
|
1064
1037
|
if (version === undefined)
|
|
1065
1038
|
return undefined;
|
|
1066
|
-
|
|
1067
|
-
[EXPECTED_CH_SCHEMA_VERSION_ENV]: version
|
|
1068
|
-
};
|
|
1069
|
-
const secretsImport = {};
|
|
1070
|
-
const gateContribution = db.getSchemaGateContribution();
|
|
1071
|
-
if (gateContribution !== undefined) {
|
|
1072
|
-
Object.assign(environment, gateContribution.environment);
|
|
1073
|
-
Object.assign(secretsImport, gateContribution.secretsImport);
|
|
1074
|
-
}
|
|
1075
|
-
return { environment, secretsImport };
|
|
1039
|
+
return { [EXPECTED_CH_SCHEMA_VERSION_ENV]: version };
|
|
1076
1040
|
}
|
|
1077
1041
|
/**
|
|
1078
1042
|
* Decide whether the synthetic `fjall-schema-gate` container materialises
|
|
@@ -1095,17 +1059,19 @@ export class EcsCompute extends Construct {
|
|
|
1095
1059
|
* - The default image URI cannot be derived because the constructs package
|
|
1096
1060
|
* version is unresolvable and no `schemaGate.image` override is set
|
|
1097
1061
|
*
|
|
1098
|
-
*
|
|
1099
|
-
* `getSchemaGateContribution()
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1062
|
+
* Both halves re-resolve the migrated DB for its
|
|
1063
|
+
* `getSchemaGateContribution()`: the relational half for the
|
|
1064
|
+
* credential-free URL base + user/password secret imports, the CH half for
|
|
1065
|
+
* the read-only gate identity (`FJALL_SCHEMA_GATE_CH_USER` +
|
|
1066
|
+
* `FJALL_SCHEMA_GATE_CH_PASSWORD`) composed with `CLICKHOUSE_URL` /
|
|
1067
|
+
* database name / CA-cert import — `chSchemaVersionEnv` alone carries only
|
|
1068
|
+
* the version entry the app containers get.
|
|
1103
1069
|
*/
|
|
1104
|
-
resolveSchemaGateMaterialisation(service, schemaVersionEnv,
|
|
1070
|
+
resolveSchemaGateMaterialisation(service, schemaVersionEnv, chSchemaVersionEnv) {
|
|
1105
1071
|
const normalised = normaliseSchemaGate(service.schemaGate);
|
|
1106
1072
|
if (!normalised.materialise)
|
|
1107
1073
|
return undefined;
|
|
1108
|
-
if (schemaVersionEnv === undefined &&
|
|
1074
|
+
if (schemaVersionEnv === undefined && chSchemaVersionEnv === undefined) {
|
|
1109
1075
|
return undefined;
|
|
1110
1076
|
}
|
|
1111
1077
|
if (service.migrations?.mode === "post-deploy") {
|
|
@@ -1154,14 +1120,15 @@ export class EcsCompute extends Construct {
|
|
|
1154
1120
|
eligibleHalves += 1;
|
|
1155
1121
|
}
|
|
1156
1122
|
}
|
|
1157
|
-
if (
|
|
1123
|
+
if (chSchemaVersionEnv !== undefined) {
|
|
1158
1124
|
const db = resolveClickHouseDatabaseForService(service);
|
|
1159
1125
|
if (db !== undefined) {
|
|
1160
|
-
|
|
1126
|
+
const contribution = db.getSchemaGateContribution();
|
|
1127
|
+
Object.assign(environment, chSchemaVersionEnv, contribution?.environment, {
|
|
1161
1128
|
[SCHEMA_GATE_CH_URL_ENV]: db.getUrl(),
|
|
1162
1129
|
[SCHEMA_GATE_CH_DATABASE_ENV]: db.getDatabaseName()
|
|
1163
1130
|
});
|
|
1164
|
-
Object.assign(secretsImport,
|
|
1131
|
+
Object.assign(secretsImport, contribution?.secretsImport);
|
|
1165
1132
|
const caCert = db.getTlsCaCertImport();
|
|
1166
1133
|
if (caCert !== undefined) {
|
|
1167
1134
|
secretsImport[SCHEMA_GATE_CH_CA_CERT_ENV] = caCert;
|
|
@@ -1285,7 +1252,7 @@ export class EcsCompute extends Construct {
|
|
|
1285
1252
|
schemaGate: entry.schemaGate
|
|
1286
1253
|
};
|
|
1287
1254
|
const schemaVersionEnv = this.resolveSchemaVersionEnv(gateSubject);
|
|
1288
|
-
const
|
|
1255
|
+
const chSchemaVersionEnv = this.resolveClickHouseSchemaVersionEnv(gateSubject);
|
|
1289
1256
|
const environment = mergeChSchemaVersionEnv({
|
|
1290
1257
|
authored: mergePgSchemaVersionEnv({
|
|
1291
1258
|
authored: entry.environment,
|
|
@@ -1294,23 +1261,12 @@ export class EcsCompute extends Construct {
|
|
|
1294
1261
|
annotationsScope: this,
|
|
1295
1262
|
materialisedGateNote: false
|
|
1296
1263
|
}),
|
|
1297
|
-
resolved:
|
|
1264
|
+
resolved: chSchemaVersionEnv,
|
|
1298
1265
|
subject,
|
|
1299
1266
|
annotationsScope: this,
|
|
1300
1267
|
overrideRemedy: "remove the env entry to accept the resolved value."
|
|
1301
1268
|
});
|
|
1302
|
-
|
|
1303
|
-
if (chGate !== undefined && Object.keys(chGate.secretsImport).length > 0) {
|
|
1304
|
-
const merged = {};
|
|
1305
|
-
for (const [key, secretImport] of Object.entries(chGate.secretsImport)) {
|
|
1306
|
-
if (entry.secrets?.[key] !== undefined)
|
|
1307
|
-
continue;
|
|
1308
|
-
const secret = resolveImportedSecret(this, `${id}${toPascalCase(entry.name)}${key}Secret`, secretImport);
|
|
1309
|
-
merged[key] = EcsSecret.fromSecretsManager(secret, secretImport.field);
|
|
1310
|
-
}
|
|
1311
|
-
secrets = { ...merged, ...(entry.secrets ?? {}) };
|
|
1312
|
-
}
|
|
1313
|
-
return { environment, secrets };
|
|
1269
|
+
return { environment, secrets: entry.secrets };
|
|
1314
1270
|
}
|
|
1315
1271
|
/**
|
|
1316
1272
|
* A sibling service that consumes a hook-migrated database materialises a
|
|
@@ -262,8 +262,9 @@ export interface IClickHouseDatabase extends IDatabase, IConnectable, IMigration
|
|
|
262
262
|
getUser(name: string): Secret;
|
|
263
263
|
/**
|
|
264
264
|
* Returns the Fjall `Secret` wrapper for the named user, or `undefined`
|
|
265
|
-
* when the user is not declared
|
|
266
|
-
* (
|
|
265
|
+
* when the user is not declared in `schemaAdmin:` / `managedPasswords:`.
|
|
266
|
+
* Framework identities (`fjall_schema_gate`, `fjall_maintenance`) are not
|
|
267
|
+
* reachable here — their secrets are wired by the construct itself.
|
|
267
268
|
*/
|
|
268
269
|
tryGetUser(name: string): Secret | undefined;
|
|
269
270
|
/**
|
|
@@ -320,19 +321,19 @@ export interface IClickHouseDatabase extends IDatabase, IConnectable, IMigration
|
|
|
320
321
|
*/
|
|
321
322
|
getExpectedSchemaVersion(): string | undefined;
|
|
322
323
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* (
|
|
326
|
-
*
|
|
327
|
-
* `
|
|
328
|
-
*
|
|
329
|
-
* for it to verify.
|
|
324
|
+
* ClickHouse credentials for the materialised boot-time schema gate
|
|
325
|
+
* (`fjall-schema-gate` container). The gate authenticates as the read-only
|
|
326
|
+
* gate identity (`fjall_schema_gate`), never as the schema admin: env
|
|
327
|
+
* `FJALL_SCHEMA_GATE_CH_USER` carries the fixed name and secret import
|
|
328
|
+
* `FJALL_SCHEMA_GATE_CH_PASSWORD` its password. Returns `undefined` when
|
|
329
|
+
* no `migrations:` config is declared on this database — the gate is the
|
|
330
|
+
* only consumer, so without migrations there's nothing for it to verify.
|
|
330
331
|
*
|
|
331
332
|
* Distinct from `getMigrationContributions()`: that's the per-migration
|
|
332
|
-
* task contract (env + secrets + IAM + egress for running migrations
|
|
333
|
-
* this is the narrower
|
|
334
|
-
*
|
|
335
|
-
*
|
|
333
|
+
* task contract (env + secrets + IAM + egress for running migrations as
|
|
334
|
+
* the schema admin); this is the narrower gate-container contract (env +
|
|
335
|
+
* secret only). App containers receive neither — they get the
|
|
336
|
+
* `EXPECTED_CH_SCHEMA_VERSION` env entry alone.
|
|
336
337
|
*/
|
|
337
338
|
getSchemaGateContribution(): {
|
|
338
339
|
readonly environment: Record<string, string>;
|
|
@@ -315,7 +315,8 @@ export default class EcsCluster extends Construct {
|
|
|
315
315
|
targetGroup,
|
|
316
316
|
config: typeof serviceProps.alarms === "object" ? serviceProps.alarms : {},
|
|
317
317
|
alarmTopic: this.props.alertsTopic,
|
|
318
|
-
applicationId: this.props.applicationId
|
|
318
|
+
applicationId: this.props.applicationId,
|
|
319
|
+
containerInsightsEnabled: this.props.containerInsights === true
|
|
319
320
|
});
|
|
320
321
|
if (serviceProps.logAlarms && serviceProps.logAlarms.length > 0) {
|
|
321
322
|
createLogPatternAlarms({
|
|
@@ -751,9 +751,11 @@ export interface EcsClusterProps {
|
|
|
751
751
|
/**
|
|
752
752
|
* CloudWatch Container Insights (default off). Enabling it bills
|
|
753
753
|
* ~$0.30/metric-month of custom `ECS/ContainerInsights` metrics per cluster
|
|
754
|
-
* plus performance-log ingestion
|
|
755
|
-
*
|
|
756
|
-
*
|
|
754
|
+
* plus performance-log ingestion. The per-service running-tasks alarm is
|
|
755
|
+
* the one construct consumer of that namespace (`RunningTaskCount` exists
|
|
756
|
+
* nowhere else) and is skipped when insights are off; every other alarm
|
|
757
|
+
* sits on `AWS/ECS` and `Fjall/ECS`. Explicit opt-in for clusters whose
|
|
758
|
+
* operators want the running-tasks alarm or the per-task console breakdown.
|
|
757
759
|
*/
|
|
758
760
|
containerInsights?: true;
|
|
759
761
|
}
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
export interface ClickHouseBackupScriptParams {
|
|
21
21
|
/** Fully-formed `clickhouse-client …` invocation, minus `--query`. */
|
|
22
22
|
readonly client: string;
|
|
23
|
+
/** The migration principal the activity pre-flight defers to. */
|
|
24
|
+
readonly schemaAdminName: string;
|
|
23
25
|
/** Database the backup covers, and the restore compares against. */
|
|
24
26
|
readonly databaseName: string;
|
|
25
27
|
/** `https://<bucket>.s3.<region>.amazonaws.com/backup/` — trailing slash. */
|
|
@@ -99,5 +101,15 @@ export interface ClickHouseBackupScriptParams {
|
|
|
99
101
|
* sets `set -eu`, but ONLY when TLS is active — so a script depending on
|
|
100
102
|
* `set -e` would abort on the first failure with TLS on and blunder past it
|
|
101
103
|
* with TLS off. Explicit branches behave identically either way.
|
|
104
|
+
*
|
|
105
|
+
* Statements go through the maintenance runners (`clickhouseMaintenanceScript`)
|
|
106
|
+
* rather than the bare client, so a `Code: 202` from the maintenance
|
|
107
|
+
* identity's concurrency cap is waited out instead of failing the run.
|
|
108
|
+
* Which runner is deliberate per statement: `fjall_ch_run` for the scratch
|
|
109
|
+
* drop and the BACKUP itself, whose failures have no marker of their own;
|
|
110
|
+
* `fjall_ch_try` for every statement whose failure the script already
|
|
111
|
+
* reports (the restore's FAILED branch, the SKIPPED probe branch) or
|
|
112
|
+
* tolerates (the row/table reads, the trailing drop) — a second failed-status
|
|
113
|
+
* marker there would page the same event twice.
|
|
102
114
|
*/
|
|
103
115
|
export declare function buildClickHouseBackupScript(params: ClickHouseBackupScriptParams): string;
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
* behaviour the alarm is built on.
|
|
19
19
|
*/
|
|
20
20
|
import { CLICKHOUSE_BACKUP_SCRATCH_DATABASE, CLICKHOUSE_BACKUP_VERIFY_FREE_SPACE_MARGIN, CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER, CLICKHOUSE_BACKUP_VERIFY_OK_MARKER, CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER, clickHouseBackupVerifyTimeoutSeconds } from "./clickhouseConstants.js";
|
|
21
|
+
import { buildMaintenancePreamble, maintenanceOkMarker, maintenanceRun, maintenanceTry } from "./clickhouseMaintenanceScript.js";
|
|
21
22
|
const BYTES_PER_GIB = 1024 ** 3;
|
|
23
|
+
const JOB = "backup";
|
|
22
24
|
/**
|
|
23
25
|
* Builds the backup + restore-verify script.
|
|
24
26
|
*
|
|
@@ -83,23 +85,39 @@ const BYTES_PER_GIB = 1024 ** 3;
|
|
|
83
85
|
* sets `set -eu`, but ONLY when TLS is active — so a script depending on
|
|
84
86
|
* `set -e` would abort on the first failure with TLS on and blunder past it
|
|
85
87
|
* with TLS off. Explicit branches behave identically either way.
|
|
88
|
+
*
|
|
89
|
+
* Statements go through the maintenance runners (`clickhouseMaintenanceScript`)
|
|
90
|
+
* rather than the bare client, so a `Code: 202` from the maintenance
|
|
91
|
+
* identity's concurrency cap is waited out instead of failing the run.
|
|
92
|
+
* Which runner is deliberate per statement: `fjall_ch_run` for the scratch
|
|
93
|
+
* drop and the BACKUP itself, whose failures have no marker of their own;
|
|
94
|
+
* `fjall_ch_try` for every statement whose failure the script already
|
|
95
|
+
* reports (the restore's FAILED branch, the SKIPPED probe branch) or
|
|
96
|
+
* tolerates (the row/table reads, the trailing drop) — a second failed-status
|
|
97
|
+
* marker there would page the same event twice.
|
|
86
98
|
*/
|
|
87
99
|
export function buildClickHouseBackupScript(params) {
|
|
88
|
-
const { client, databaseName, backupDestUrl, diskFreeCriticalGib, storageGb, verify } = params;
|
|
100
|
+
const { client, schemaAdminName, databaseName, backupDestUrl, diskFreeCriticalGib, storageGb, verify } = params;
|
|
101
|
+
const preamble = buildMaintenancePreamble({
|
|
102
|
+
client,
|
|
103
|
+
job: JOB,
|
|
104
|
+
schemaAdminName
|
|
105
|
+
});
|
|
89
106
|
const backupStatements = [
|
|
90
107
|
"STAMP=$(date +%Y%m%d-%H%M%S)",
|
|
91
108
|
`DEST="${backupDestUrl}backup-$STAMP/"`,
|
|
92
|
-
`${
|
|
109
|
+
`${maintenanceRun("backup", `BACKUP DATABASE ${databaseName} TO S3('$DEST')`)} || exit 1`
|
|
93
110
|
];
|
|
94
111
|
if (!verify) {
|
|
95
|
-
return backupStatements.join("; ")
|
|
112
|
+
return `${preamble}\n${[...backupStatements, maintenanceOkMarker(JOB)].join("; ")}`;
|
|
96
113
|
}
|
|
97
114
|
const scratch = CLICKHOUSE_BACKUP_SCRATCH_DATABASE;
|
|
98
115
|
const requiredFreeBytes = Math.round(diskFreeCriticalGib *
|
|
99
116
|
BYTES_PER_GIB *
|
|
100
117
|
CLICKHOUSE_BACKUP_VERIFY_FREE_SPACE_MARGIN);
|
|
101
118
|
const verifyTimeoutSeconds = clickHouseBackupVerifyTimeoutSeconds(storageGb);
|
|
102
|
-
const
|
|
119
|
+
const dropScratchSql = `DROP DATABASE IF EXISTS ${scratch} SYNC`;
|
|
120
|
+
const dropScratchTolerated = maintenanceTry("drop_scratch", dropScratchSql);
|
|
103
121
|
const sourceRowsQuery = `SELECT sum(rows) FROM system.parts WHERE database = '${databaseName}' AND active`;
|
|
104
122
|
const fitVerdictQuery = `SELECT if((SELECT free_space FROM system.disks WHERE name = 'default')` +
|
|
105
123
|
` - (SELECT sum(bytes_on_disk) FROM system.parts WHERE database = '${databaseName}' AND active)` +
|
|
@@ -107,17 +125,19 @@ export function buildClickHouseBackupScript(params) {
|
|
|
107
125
|
const missingTablesQuery = `SELECT count() FROM (SELECT name FROM system.tables WHERE database = '${databaseName}' AND name NOT LIKE '.inner%'` +
|
|
108
126
|
` EXCEPT SELECT name FROM system.tables WHERE database = '${scratch}' AND name NOT LIKE '.inner%')`;
|
|
109
127
|
const restoredRowsQuery = `SELECT sum(rows) FROM system.parts WHERE database = '${scratch}' AND active`;
|
|
110
|
-
|
|
111
|
-
`${
|
|
112
|
-
`SRCROWS=$(${
|
|
128
|
+
const body = [
|
|
129
|
+
`${maintenanceRun("drop_scratch", dropScratchSql)} || exit 1`,
|
|
130
|
+
`SRCROWS=$(${maintenanceTry("source_rows", sourceRowsQuery)}) || SRCROWS=unknown`,
|
|
113
131
|
...backupStatements,
|
|
114
|
-
`FIT=$(${
|
|
115
|
-
`if [ "$FIT" != "FITS" ]; then echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST restore would not leave ${requiredFreeBytes} bytes free"; exit 0; fi`,
|
|
116
|
-
|
|
117
|
-
`MISSING=$(${
|
|
118
|
-
`ROWS=$(${
|
|
119
|
-
`${
|
|
132
|
+
`FIT=$(${maintenanceTry("free_space_probe", fitVerdictQuery)}) || { echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST free-space probe failed — verify did not run"; exit 1; }`,
|
|
133
|
+
`if [ "$FIT" != "FITS" ]; then echo "${CLICKHOUSE_BACKUP_VERIFY_SKIPPED_MARKER} $DEST restore would not leave ${requiredFreeBytes} bytes free"; ${maintenanceOkMarker(JOB)}; exit 0; fi`,
|
|
134
|
+
`${maintenanceTry("restore", `RESTORE DATABASE ${databaseName} AS ${scratch} FROM S3('$DEST')`, verifyTimeoutSeconds)} || { echo "${CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER} $DEST restore raised or exceeded ${verifyTimeoutSeconds}s"; ${dropScratchTolerated} || true; exit 1; }`,
|
|
135
|
+
`MISSING=$(${maintenanceTry("missing_tables", missingTablesQuery)}) || MISSING=unknown`,
|
|
136
|
+
`ROWS=$(${maintenanceTry("restored_rows", restoredRowsQuery)}) || ROWS=0`,
|
|
137
|
+
`${dropScratchTolerated} || true`,
|
|
120
138
|
`if [ "$MISSING" != "0" ] || { [ "$ROWS" = "0" ] && [ "$SRCROWS" != "0" ]; }; then echo "${CLICKHOUSE_BACKUP_VERIFY_FAILED_MARKER} $DEST missing_tables=$MISSING restored_rows=$ROWS source_rows=$SRCROWS"; exit 1; fi`,
|
|
121
|
-
`echo "${CLICKHOUSE_BACKUP_VERIFY_OK_MARKER} $DEST restored_rows=$ROWS source_rows=$SRCROWS"
|
|
139
|
+
`echo "${CLICKHOUSE_BACKUP_VERIFY_OK_MARKER} $DEST restored_rows=$ROWS source_rows=$SRCROWS"`,
|
|
140
|
+
maintenanceOkMarker(JOB)
|
|
122
141
|
].join("; ");
|
|
142
|
+
return `${preamble}\n${body}`;
|
|
123
143
|
}
|
|
@@ -303,8 +303,36 @@ export declare const BACKUP_TASK_CPU_UNITS = 256;
|
|
|
303
303
|
export declare const BACKUP_RETENTION_DAYS = 14;
|
|
304
304
|
/** Scratch database the backup task restores into to prove the backup it just
|
|
305
305
|
* wrote can be read back. Dropped on the way in and on the way out; never
|
|
306
|
-
* queried by the application.
|
|
307
|
-
|
|
306
|
+
* queried by the application. Canonical source is `@fjall/util/migration`
|
|
307
|
+
* because `@fjall/clickhouse` grants `fjall_maintenance` DROP on exactly
|
|
308
|
+
* this database — coupled values per `.claude/rules/code-quality.md §
|
|
309
|
+
* "Coupled values: shared source at 2 occurrences"`. */
|
|
310
|
+
export { CLICKHOUSE_BACKUP_SCRATCH_DATABASE } from "@fjall/util/migration";
|
|
311
|
+
/** The two scheduled maintenance jobs that authenticate as
|
|
312
|
+
* `fjall_maintenance`. Drives the per-job log-group → metric-filter wiring
|
|
313
|
+
* in `createClickHouseAlarms` and the `job=` field of the status markers. */
|
|
314
|
+
export declare const CLICKHOUSE_MAINTENANCE_JOBS: readonly ["optimise", "backup"];
|
|
315
|
+
export type ClickHouseMaintenanceJob = (typeof CLICKHOUSE_MAINTENANCE_JOBS)[number];
|
|
316
|
+
/** Total wall-clock budget a maintenance statement may spend waiting out
|
|
317
|
+
* `Code: 202` (TOO_MANY_SIMULTANEOUS_QUERIES) from the maintenance
|
|
318
|
+
* identity's `max_concurrent_queries_for_user = 1` cap before the job
|
|
319
|
+
* reports `status=failed reason=busy`. Ten minutes: long enough to outlast
|
|
320
|
+
* the sibling sidecar's per-statement gaps, short enough that a wedged
|
|
321
|
+
* holder pages inside the hour the failed-status alarm samples. */
|
|
322
|
+
export declare const MAINTENANCE_BUSY_BUDGET_SECONDS = 600;
|
|
323
|
+
/** Prefix of the maintenance sidecars' status log markers. The CloudWatch
|
|
324
|
+
* metric filters match `${prefix}${status}` as a whole token, so the
|
|
325
|
+
* three statuses must remain distinct full words after the `=`. */
|
|
326
|
+
export declare const MAINTENANCE_STATUS_MARKER_PREFIX = "fjall:maintenance:status=";
|
|
327
|
+
export declare const MAINTENANCE_STATUS: {
|
|
328
|
+
readonly ok: "ok";
|
|
329
|
+
readonly deferred: "deferred";
|
|
330
|
+
readonly failed: "failed";
|
|
331
|
+
};
|
|
332
|
+
export type MaintenanceStatus = (typeof MAINTENANCE_STATUS)[keyof typeof MAINTENANCE_STATUS];
|
|
333
|
+
/** The log token a maintenance status marker line starts with — shared by
|
|
334
|
+
* the script builders (emit) and the alarm factory (match). */
|
|
335
|
+
export declare function maintenanceStatusMarker(status: MaintenanceStatus): string;
|
|
308
336
|
/** Multiple of the critical free-space floor the verify restore must leave
|
|
309
337
|
* clear to proceed.
|
|
310
338
|
*
|
|
@@ -345,8 +345,36 @@ export const BACKUP_TASK_CPU_UNITS = 256;
|
|
|
345
345
|
export const BACKUP_RETENTION_DAYS = 14;
|
|
346
346
|
/** Scratch database the backup task restores into to prove the backup it just
|
|
347
347
|
* wrote can be read back. Dropped on the way in and on the way out; never
|
|
348
|
-
* queried by the application.
|
|
349
|
-
|
|
348
|
+
* queried by the application. Canonical source is `@fjall/util/migration`
|
|
349
|
+
* because `@fjall/clickhouse` grants `fjall_maintenance` DROP on exactly
|
|
350
|
+
* this database — coupled values per `.claude/rules/code-quality.md §
|
|
351
|
+
* "Coupled values: shared source at 2 occurrences"`. */
|
|
352
|
+
export { CLICKHOUSE_BACKUP_SCRATCH_DATABASE } from "@fjall/util/migration";
|
|
353
|
+
/** The two scheduled maintenance jobs that authenticate as
|
|
354
|
+
* `fjall_maintenance`. Drives the per-job log-group → metric-filter wiring
|
|
355
|
+
* in `createClickHouseAlarms` and the `job=` field of the status markers. */
|
|
356
|
+
export const CLICKHOUSE_MAINTENANCE_JOBS = ["optimise", "backup"];
|
|
357
|
+
/** Total wall-clock budget a maintenance statement may spend waiting out
|
|
358
|
+
* `Code: 202` (TOO_MANY_SIMULTANEOUS_QUERIES) from the maintenance
|
|
359
|
+
* identity's `max_concurrent_queries_for_user = 1` cap before the job
|
|
360
|
+
* reports `status=failed reason=busy`. Ten minutes: long enough to outlast
|
|
361
|
+
* the sibling sidecar's per-statement gaps, short enough that a wedged
|
|
362
|
+
* holder pages inside the hour the failed-status alarm samples. */
|
|
363
|
+
export const MAINTENANCE_BUSY_BUDGET_SECONDS = 600;
|
|
364
|
+
/** Prefix of the maintenance sidecars' status log markers. The CloudWatch
|
|
365
|
+
* metric filters match `${prefix}${status}` as a whole token, so the
|
|
366
|
+
* three statuses must remain distinct full words after the `=`. */
|
|
367
|
+
export const MAINTENANCE_STATUS_MARKER_PREFIX = "fjall:maintenance:status=";
|
|
368
|
+
export const MAINTENANCE_STATUS = {
|
|
369
|
+
ok: "ok",
|
|
370
|
+
deferred: "deferred",
|
|
371
|
+
failed: "failed"
|
|
372
|
+
};
|
|
373
|
+
/** The log token a maintenance status marker line starts with — shared by
|
|
374
|
+
* the script builders (emit) and the alarm factory (match). */
|
|
375
|
+
export function maintenanceStatusMarker(status) {
|
|
376
|
+
return `${MAINTENANCE_STATUS_MARKER_PREFIX}${status}`;
|
|
377
|
+
}
|
|
350
378
|
/** Multiple of the critical free-space floor the verify restore must leave
|
|
351
379
|
* clear to proceed.
|
|
352
380
|
*
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell fragments shared by the two maintenance sidecars (OPTIMIZE FINAL and
|
|
3
|
+
* BACKUP), which both authenticate as `fjall_maintenance` under a
|
|
4
|
+
* `max_concurrent_queries_for_user = 1` cap.
|
|
5
|
+
*
|
|
6
|
+
* Two things the cap forces on every statement the sidecars issue:
|
|
7
|
+
*
|
|
8
|
+
* 1. **The migrate ↔ maintenance mutex.** A migration holds the DDL slot for
|
|
9
|
+
* minutes; a maintenance job that started underneath it would either
|
|
10
|
+
* contend for parts the migration is rewriting or — worse — make the
|
|
11
|
+
* migration's own probes lose the slot. So each job opens with an activity
|
|
12
|
+
* pre-flight (`buildControlPlaneActivityQuery` against the schema admin)
|
|
13
|
+
* and exits `deferred` (0, a metric but no page) when a migration is under
|
|
14
|
+
* way. The runner asks the mirror question about `fjall_maintenance`.
|
|
15
|
+
*
|
|
16
|
+
* 2. **`Code: 202` is a wait, not a failure.** With a cap of one, the second
|
|
17
|
+
* query from the same identity is refused with TOO_MANY_SIMULTANEOUS_
|
|
18
|
+
* QUERIES. That is the sibling sidecar (or this job's own previous
|
|
19
|
+
* statement still winding down), so the statement runner sleeps with
|
|
20
|
+
* bounded backoff and retries until `MAINTENANCE_BUSY_BUDGET_SECONDS` is
|
|
21
|
+
* spent — only then is it a failure, and it says `reason=busy` so the
|
|
22
|
+
* responder knows to look for the holder rather than the statement.
|
|
23
|
+
*
|
|
24
|
+
* Every marker goes to stderr. The backup script captures several statement
|
|
25
|
+
* results with `$(…)`, and a marker on stdout would land in the captured
|
|
26
|
+
* value; ECS awslogs carries both streams to the same log group, which is
|
|
27
|
+
* where the metric filters read them.
|
|
28
|
+
*
|
|
29
|
+
* `sh` only (the ClickHouse image's `/bin/sh` is dash): no `$RANDOM`, no
|
|
30
|
+
* arrays, no `pipefail`. Jitter comes from the nanosecond clock instead —
|
|
31
|
+
* `%s%N` rather than `%N`, because a bare `%N` is zero-padded and a leading
|
|
32
|
+
* zero makes dash read the number as octal.
|
|
33
|
+
*
|
|
34
|
+
* The TLS preamble these fragments follow sets `set -eu` ONLY when TLS is
|
|
35
|
+
* active, so nothing here relies on `-e`: every failure path is an explicit
|
|
36
|
+
* `||` / `if` branch and the function bodies use `… && return 0` shapes that
|
|
37
|
+
* behave identically with or without it.
|
|
38
|
+
*/
|
|
39
|
+
import { type ClickHouseMaintenanceJob } from "./clickhouseConstants.js";
|
|
40
|
+
/** Runs a statement; on failure emits `status=failed reason=<label>` (or
|
|
41
|
+
* `reason=busy label=<label>` when the busy budget ran out) and returns 1.
|
|
42
|
+
* For statements whose failure the job treats as fatal. */
|
|
43
|
+
export declare const MAINTENANCE_RUN_FN = "fjall_ch_run";
|
|
44
|
+
/** Runs a statement with the same `Code: 202` retry loop but emits NO
|
|
45
|
+
* failed-status marker on a non-busy failure — the caller owns that branch
|
|
46
|
+
* (a tolerated read, or a statement with its own marker such as the backup
|
|
47
|
+
* restore). The busy-exhausted marker is still emitted: a statement that
|
|
48
|
+
* never got a slot is always a job failure. */
|
|
49
|
+
export declare const MAINTENANCE_TRY_FN = "fjall_ch_try";
|
|
50
|
+
export interface MaintenancePreambleParams {
|
|
51
|
+
/** Fully-formed `clickhouse-client …` invocation, minus `--query`. */
|
|
52
|
+
readonly client: string;
|
|
53
|
+
readonly job: ClickHouseMaintenanceJob;
|
|
54
|
+
/** The migration principal — the other side of the mutex. */
|
|
55
|
+
readonly schemaAdminName: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Newline-joined preamble: sets `FJALL_JOB`, defines the two statement
|
|
59
|
+
* runners, then runs the activity pre-flight. Exits the script itself on
|
|
60
|
+
* `deferred` (0) or a probe failure (1), so the job body that follows only
|
|
61
|
+
* ever runs when no migration is active.
|
|
62
|
+
*/
|
|
63
|
+
export declare function buildMaintenancePreamble(params: MaintenancePreambleParams): string;
|
|
64
|
+
/** `fjall_ch_run <label> "<sql>" [timeoutSeconds]` — see `MAINTENANCE_RUN_FN`. */
|
|
65
|
+
export declare function maintenanceRun(label: string, sql: string, timeoutSeconds?: number): string;
|
|
66
|
+
/** `fjall_ch_try <label> "<sql>" [timeoutSeconds]` — see `MAINTENANCE_TRY_FN`. */
|
|
67
|
+
export declare function maintenanceTry(label: string, sql: string, timeoutSeconds?: number): string;
|
|
68
|
+
/** The job's terminal success line. */
|
|
69
|
+
export declare function maintenanceOkMarker(job: ClickHouseMaintenanceJob): string;
|