@fjall/components-infrastructure 9.0.0 → 10.1.2
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/patterns/aws/compute.js +13 -1
- package/dist/lib/patterns/aws/computeEcs.d.ts +80 -1
- package/dist/lib/patterns/aws/computeEcs.js +450 -70
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +113 -6
- package/dist/lib/patterns/aws/computeEcsTypes.js +19 -0
- package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
- package/dist/lib/patterns/aws/database.d.ts +12 -1
- package/dist/lib/patterns/aws/database.js +16 -0
- package/dist/lib/patterns/aws/devSubstrate.js +64 -8
- package/dist/lib/patterns/aws/interfaces/database.d.ts +26 -0
- package/dist/lib/patterns/aws/storage.d.ts +7 -0
- package/dist/lib/patterns/aws/storage.js +14 -1
- package/dist/lib/resources/aws/compute/applicationLoadBalancer.js +6 -0
- package/dist/lib/resources/aws/compute/ecs.d.ts +12 -0
- package/dist/lib/resources/aws/compute/ecs.js +32 -2
- package/dist/lib/resources/aws/compute/ecsImages.js +4 -0
- package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +29 -3
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +32 -0
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +12 -0
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +42 -2
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +6 -0
- package/dist/lib/resources/aws/compute/ecsValidation.js +13 -0
- package/dist/lib/resources/aws/database/rdsAurora.d.ts +7 -1
- package/dist/lib/resources/aws/database/rdsAurora.js +1 -1
- package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -2
- package/dist/lib/resources/aws/messaging/eventTargets.js +55 -2
- package/dist/lib/resources/aws/monitoring/alarmDefaults.d.ts +1 -0
- package/dist/lib/resources/aws/monitoring/alarmDefaults.js +1 -1
- package/dist/lib/resources/aws/monitoring/ecsTaskStopWatchdog.d.ts +59 -0
- package/dist/lib/resources/aws/monitoring/ecsTaskStopWatchdog.js +115 -0
- package/dist/lib/resources/aws/monitoring/index.d.ts +1 -0
- package/dist/lib/resources/aws/monitoring/index.js +1 -0
- package/dist/lib/resources/aws/monitoring/metricNamespaces.d.ts +1 -0
- package/dist/lib/resources/aws/monitoring/metricNamespaces.js +1 -0
- package/dist/lib/utils/manifestWriter.d.ts +16 -2
- package/dist/lib/utils/manifestWriter.js +22 -0
- package/package.json +4 -3
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
|
|
2
2
|
import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
|
|
3
3
|
import { Effect, Grant, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
4
|
-
import { Annotations, Stack, Token } from "aws-cdk-lib";
|
|
4
|
+
import { Annotations, Aws, Stack, Token } from "aws-cdk-lib";
|
|
5
5
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
6
6
|
import { StringParameter } from "aws-cdk-lib/aws-ssm";
|
|
7
7
|
import { Construct } from "constructs";
|
|
8
8
|
import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
|
|
9
9
|
import { EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV, isClickHouseDatabase, isDatabase, isRelationalDatabase } from "./interfaces/database.js";
|
|
10
10
|
import { isConnectionConfig } from "../../utils/connector.js";
|
|
11
|
+
import { processConnections } from "../../utils/connections.js";
|
|
11
12
|
import { isMigrationContributor } from "./interfaces/migrationContributor.js";
|
|
12
13
|
import { resolveImportedSecret } from "../../resources/aws/secrets/index.js";
|
|
13
14
|
import App from "../../app.js";
|
|
@@ -25,10 +26,12 @@ import { FjallLogger } from "../../utils/validationLogger.js";
|
|
|
25
26
|
import { VALIDATION_PATTERNS } from "@fjall/generator";
|
|
26
27
|
import { evaluateBakeGuard } from "@fjall/util/docker";
|
|
27
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";
|
|
30
|
+
import { CONSTRUCTS_VERSION } from "../../utils/engineCompat.js";
|
|
28
31
|
import { validateEc2ServiceSizing, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
|
|
29
32
|
import { validateSharedEc2CapacityConfig } from "../../resources/aws/compute/ecsCapacityConfig.js";
|
|
30
33
|
import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
|
|
31
|
-
import { isHookMigrations } from "./computeEcsTypes.js";
|
|
34
|
+
import { isHookMigrations, normaliseSchemaGate } from "./computeEcsTypes.js";
|
|
32
35
|
export { ScalingType } from "./computeEcsTypes.js";
|
|
33
36
|
import { ScalingType } from "./computeEcsTypes.js";
|
|
34
37
|
export const ECS_CAPACITY_PROVIDER_CONFIG = {
|
|
@@ -142,6 +145,34 @@ export function validateEcsProps(props) {
|
|
|
142
145
|
throw new Error(`Service '${service.name}': Duplicate container names: ` +
|
|
143
146
|
`${[...new Set(duplicateContainerNames)].join(", ")}`);
|
|
144
147
|
}
|
|
148
|
+
// Reserved unconditionally (even under `schemaGate: false`) so the name's
|
|
149
|
+
// meaning never depends on distant config. Patterns layer ONLY — the
|
|
150
|
+
// resources layer must accept the materialised gate container this layer
|
|
151
|
+
// hands it. Contract corpus: `ecs/container-named-fjall-schema-gate`,
|
|
152
|
+
// rejectedSince 10.0.0.
|
|
153
|
+
if (service.containers.some((c) => c.name === SCHEMA_GATE_CONTAINER_NAME)) {
|
|
154
|
+
throw new Error(`Service '${service.name}': container name '${SCHEMA_GATE_CONTAINER_NAME}' ` +
|
|
155
|
+
`is reserved for the synthetic schema-gate container. Rename the container.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (service.migrations?.name === SCHEMA_GATE_CONTAINER_NAME) {
|
|
159
|
+
throw new Error(`Service '${service.name}': migrations.name '${SCHEMA_GATE_CONTAINER_NAME}' ` +
|
|
160
|
+
`is reserved for the synthetic schema-gate container. Choose a different name.`);
|
|
161
|
+
}
|
|
162
|
+
if (service.serviceType === "worker") {
|
|
163
|
+
const inboundContainers = (service.containers ?? []).filter((container) => container.port !== undefined || container.portMappings !== undefined);
|
|
164
|
+
if (inboundContainers.length > 0) {
|
|
165
|
+
const names = inboundContainers
|
|
166
|
+
.map((container) => container.name ?? "<unnamed>")
|
|
167
|
+
.join(", ");
|
|
168
|
+
throw new Error(`Service '${service.name}': serviceType 'worker' declares no inbound surface, ` +
|
|
169
|
+
`but container(s) ${names} declare port/portMappings. ` +
|
|
170
|
+
`Remove the ports or drop serviceType: 'worker'.`);
|
|
171
|
+
}
|
|
172
|
+
if (service.routing !== undefined) {
|
|
173
|
+
throw new Error(`Service '${service.name}': serviceType 'worker' declares no inbound surface, ` +
|
|
174
|
+
`but routing is configured. Remove routing or drop serviceType: 'worker'.`);
|
|
175
|
+
}
|
|
145
176
|
}
|
|
146
177
|
if (service.capacityProvider === "EC2" && !service.ec2Config) {
|
|
147
178
|
throw new Error(`Service '${service.name}' uses EC2 capacity provider but no ec2Config is defined. ` +
|
|
@@ -152,6 +183,13 @@ export function validateEcsProps(props) {
|
|
|
152
183
|
"The ec2Config will be ignored unless capacityProvider is set to 'EC2'.");
|
|
153
184
|
}
|
|
154
185
|
validateEc2ServiceSizing(service);
|
|
186
|
+
if (typeof service.circuitBreaker === "object") {
|
|
187
|
+
const threshold = service.circuitBreaker.threshold;
|
|
188
|
+
if (threshold !== undefined &&
|
|
189
|
+
(!Number.isInteger(threshold) || threshold < 1)) {
|
|
190
|
+
throw new Error(`Service '${service.name}': circuitBreaker.threshold must be a positive integer (got ${threshold}).`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
155
193
|
if (service.deployment !== undefined) {
|
|
156
194
|
const min = service.deployment.minHealthyPercent;
|
|
157
195
|
const max = service.deployment.maxHealthyPercent;
|
|
@@ -227,6 +265,37 @@ export function validateEcsProps(props) {
|
|
|
227
265
|
}
|
|
228
266
|
}
|
|
229
267
|
const DEFAULT_MIGRATE_CONTAINER_NAME = "migrate";
|
|
268
|
+
/**
|
|
269
|
+
* How long dependent containers wait for the gate to reach SUCCESS before
|
|
270
|
+
* giving up (stopping the task pre-RUNNING). ECS declares this on the
|
|
271
|
+
* depended-ON container. 120s is the verified Fargate ceiling
|
|
272
|
+
* (design 2026-08-10 § 4); the runner's worst-case retry budget
|
|
273
|
+
* (3 connects × 10s + 2 sleeps × 5s, PG and CH in parallel) fits inside it.
|
|
274
|
+
*/
|
|
275
|
+
const SCHEMA_GATE_START_TIMEOUT_SECONDS = 120;
|
|
276
|
+
/**
|
|
277
|
+
* Postgres-family scheme test for the gate's v1 eligibility filter. The URL
|
|
278
|
+
* base is `getConnectionString()` output whose host may be an unresolved CDK
|
|
279
|
+
* token, so this checks the literal scheme prefix rather than URL-parsing.
|
|
280
|
+
*/
|
|
281
|
+
function isPostgresUrlBase(urlBase) {
|
|
282
|
+
return (urlBase.startsWith("postgresql://") || urlBase.startsWith("postgres://"));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Default gate-image URI: the target account's private ECR mirror of
|
|
286
|
+
* `@fjall/schema-gate`, tagged with the exact package semver (lockstep with
|
|
287
|
+
* this constructs package). Composed from CDK pseudo-parameters so one synth
|
|
288
|
+
* works in every account/region; deploy-core's `ensureSchemaGateImage`
|
|
289
|
+
* creates/mirrors the repository pre-deploy using the same
|
|
290
|
+
* `SCHEMA_GATE_ECR_REPO_NAME` derivation. `undefined` when the package
|
|
291
|
+
* version is unresolvable (broken layout) — the caller warns and skips
|
|
292
|
+
* materialisation rather than emitting an unpullable URI.
|
|
293
|
+
*/
|
|
294
|
+
function defaultSchemaGateImageUri() {
|
|
295
|
+
if (CONSTRUCTS_VERSION === undefined)
|
|
296
|
+
return undefined;
|
|
297
|
+
return `${Aws.ACCOUNT_ID}.dkr.ecr.${Aws.REGION}.${Aws.URL_SUFFIX}/${SCHEMA_GATE_ECR_REPO_NAME}:${CONSTRUCTS_VERSION}`;
|
|
298
|
+
}
|
|
230
299
|
function lifecycleStageForHookMode(mode) {
|
|
231
300
|
switch (mode) {
|
|
232
301
|
case "lifecycle-hook":
|
|
@@ -298,6 +367,9 @@ export function expandMigrationsSugar(service, userContainers) {
|
|
|
298
367
|
});
|
|
299
368
|
return [migrateContainer, ...wired];
|
|
300
369
|
}
|
|
370
|
+
function schemaGateSubjectLabel(subject) {
|
|
371
|
+
return subject.subjectLabel ?? `Service '${subject.name}'`;
|
|
372
|
+
}
|
|
301
373
|
/**
|
|
302
374
|
* Resolve the relational database carrying `migrations:` from a service's
|
|
303
375
|
* `connections` graph. Returns `undefined` when no connected database declares
|
|
@@ -325,7 +397,7 @@ function resolveMigrationDatabaseForService(service) {
|
|
|
325
397
|
return undefined;
|
|
326
398
|
if (matches.length > 1) {
|
|
327
399
|
const names = matches.map((d) => d.node.id).join(", ");
|
|
328
|
-
throw new Error(
|
|
400
|
+
throw new Error(`${schemaGateSubjectLabel(service)}: connections include two or more relational ` +
|
|
329
401
|
`databases declaring \`migrations:\` (${names}). The schema-version ` +
|
|
330
402
|
`gate cannot inject ${EXPECTED_SCHEMA_VERSION_ENV} unambiguously. ` +
|
|
331
403
|
`Split the service so each consumes a single migrated database, or ` +
|
|
@@ -356,7 +428,7 @@ function resolveClickHouseDatabaseForService(service) {
|
|
|
356
428
|
return undefined;
|
|
357
429
|
if (matches.length > 1) {
|
|
358
430
|
const names = matches.map((d) => d.node.id).join(", ");
|
|
359
|
-
throw new Error(
|
|
431
|
+
throw new Error(`${schemaGateSubjectLabel(service)}: connections include two or more ClickHouse ` +
|
|
360
432
|
`databases declaring \`migrations:\` (${names}). The schema-version ` +
|
|
361
433
|
`gate cannot inject ${EXPECTED_CH_SCHEMA_VERSION_ENV} unambiguously. ` +
|
|
362
434
|
`Split the service so each consumes a single migrated database, or ` +
|
|
@@ -364,6 +436,83 @@ function resolveClickHouseDatabaseForService(service) {
|
|
|
364
436
|
}
|
|
365
437
|
return matches[0];
|
|
366
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Author-wins merge: framework entries land only where the authored map has
|
|
441
|
+
* no value for the key.
|
|
442
|
+
*/
|
|
443
|
+
function mergeAuthorWins(authored, framework) {
|
|
444
|
+
const merged = { ...(authored ?? {}) };
|
|
445
|
+
for (const [k, v] of Object.entries(framework)) {
|
|
446
|
+
if (merged[k] === undefined)
|
|
447
|
+
merged[k] = v;
|
|
448
|
+
}
|
|
449
|
+
return merged;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Author-wins merge of the resolved PG schema-version env into an authored
|
|
453
|
+
* env map. Shared by the service-container path (`buildContainerConfigs`) and
|
|
454
|
+
* the scheduled-task path (`buildScheduledTaskDefinition`) — coupled values:
|
|
455
|
+
* the TOOL sibling must ride with VERSION on every surface, including under
|
|
456
|
+
* an author override, because the runtime gate refuses VERSION-without-TOOL.
|
|
457
|
+
*/
|
|
458
|
+
function mergePgSchemaVersionEnv(params) {
|
|
459
|
+
const { authored, resolved, subject, annotationsScope } = params;
|
|
460
|
+
if (resolved === undefined)
|
|
461
|
+
return authored;
|
|
462
|
+
const authoredValue = authored?.[EXPECTED_SCHEMA_VERSION_ENV];
|
|
463
|
+
if (authoredValue !== undefined) {
|
|
464
|
+
const resolvedValue = resolved[EXPECTED_SCHEMA_VERSION_ENV];
|
|
465
|
+
if (authoredValue !== resolvedValue && annotationsScope !== undefined) {
|
|
466
|
+
const gateNote = params.materialisedGateNote
|
|
467
|
+
? `The author value wins in this container's env (the app's own ` +
|
|
468
|
+
`boot gate reads it), but a materialised schema-gate container ` +
|
|
469
|
+
`still enforces the resolved value — set schemaGate: ` +
|
|
470
|
+
`{ materialise: false } if the author value must be ` +
|
|
471
|
+
`authoritative, or schemaGate: false to silence this warning.`
|
|
472
|
+
: `The author value wins in this container's env.`;
|
|
473
|
+
Annotations.of(annotationsScope).addWarning(`${subject}: author-set ${EXPECTED_SCHEMA_VERSION_ENV}` +
|
|
474
|
+
`='${authoredValue}' overrides the value resolved from the connected ` +
|
|
475
|
+
`database's \`migrations:\` config ('${resolvedValue}'). ${gateNote}` +
|
|
476
|
+
` Note that ` +
|
|
477
|
+
`${EXPECTED_SCHEMA_VERSION_TOOL_ENV} will still be auto-injected ` +
|
|
478
|
+
`to ensure the runtime gate passes — both envs must emit together.`);
|
|
479
|
+
}
|
|
480
|
+
// Runtime gate rejects startup if VERSION is present but TOOL is absent.
|
|
481
|
+
return {
|
|
482
|
+
[EXPECTED_SCHEMA_VERSION_TOOL_ENV]: resolved[EXPECTED_SCHEMA_VERSION_TOOL_ENV],
|
|
483
|
+
...authored
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const merged = { ...(authored ?? {}) };
|
|
487
|
+
if (resolved[EXPECTED_SCHEMA_VERSION_ENV] !== undefined) {
|
|
488
|
+
merged[EXPECTED_SCHEMA_VERSION_ENV] = resolved[EXPECTED_SCHEMA_VERSION_ENV];
|
|
489
|
+
}
|
|
490
|
+
if (resolved[EXPECTED_SCHEMA_VERSION_TOOL_ENV] !== undefined) {
|
|
491
|
+
merged[EXPECTED_SCHEMA_VERSION_TOOL_ENV] =
|
|
492
|
+
resolved[EXPECTED_SCHEMA_VERSION_TOOL_ENV];
|
|
493
|
+
}
|
|
494
|
+
return merged;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* ClickHouse sibling of `mergePgSchemaVersionEnv` — same author-wins
|
|
498
|
+
* semantics, same two consuming paths.
|
|
499
|
+
*/
|
|
500
|
+
function mergeChSchemaVersionEnv(params) {
|
|
501
|
+
const { authored, resolved, subject, annotationsScope } = params;
|
|
502
|
+
if (resolved === undefined)
|
|
503
|
+
return authored;
|
|
504
|
+
const authoredValue = authored?.[EXPECTED_CH_SCHEMA_VERSION_ENV];
|
|
505
|
+
const resolvedValue = resolved[EXPECTED_CH_SCHEMA_VERSION_ENV];
|
|
506
|
+
if (authoredValue !== undefined &&
|
|
507
|
+
authoredValue !== resolvedValue &&
|
|
508
|
+
annotationsScope !== undefined) {
|
|
509
|
+
Annotations.of(annotationsScope).addWarning(`${subject}: author-set ${EXPECTED_CH_SCHEMA_VERSION_ENV}` +
|
|
510
|
+
`='${authoredValue}' overrides the value resolved from the connected ` +
|
|
511
|
+
`ClickHouse database's \`migrations:\` config ('${resolvedValue}'). The ` +
|
|
512
|
+
`author value wins; ${params.overrideRemedy}`);
|
|
513
|
+
}
|
|
514
|
+
return mergeAuthorWins(authored, resolved);
|
|
515
|
+
}
|
|
367
516
|
function getResourceLabel(resource) {
|
|
368
517
|
if (resource !== null &&
|
|
369
518
|
typeof resource === "object" &&
|
|
@@ -570,9 +719,14 @@ function assertMigrationSecretsResolvable(service, effectiveMigrations) {
|
|
|
570
719
|
* @param annotationsScope Construct used as the source for synth-time
|
|
571
720
|
* warnings when the author has set `EXPECTED_SCHEMA_VERSION`
|
|
572
721
|
* themselves and the resolved value differs.
|
|
722
|
+
* @param schemaGate Resolved spec for the synthetic `fjall-schema-gate`
|
|
723
|
+
* container, or `undefined` when materialisation is off
|
|
724
|
+
* (opted out, ineligible, or nothing to gate). When set,
|
|
725
|
+
* the gate container is prepended and every non-migrate
|
|
726
|
+
* container gains a `dependsOn` SUCCESS edge on it.
|
|
573
727
|
* @internal Exported for testing only
|
|
574
728
|
*/
|
|
575
|
-
export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope, chGate) {
|
|
729
|
+
export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope, chGate, schemaGate) {
|
|
576
730
|
const userContainers = service.containers && service.containers.length > 0
|
|
577
731
|
? service.containers
|
|
578
732
|
: undefined;
|
|
@@ -581,61 +735,20 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
581
735
|
: userContainers;
|
|
582
736
|
const chSchemaVersionEnv = chGate?.environment;
|
|
583
737
|
const chGateSecretsImport = chGate?.secretsImport;
|
|
584
|
-
const mergeSchemaEnv = (authored) => {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
// Runtime gate rejects startup if VERSION is present but TOOL is absent.
|
|
599
|
-
return {
|
|
600
|
-
[EXPECTED_SCHEMA_VERSION_TOOL_ENV]: schemaVersionEnv[EXPECTED_SCHEMA_VERSION_TOOL_ENV],
|
|
601
|
-
...authored
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
|
-
const merged = { ...(authored ?? {}) };
|
|
605
|
-
if (schemaVersionEnv[EXPECTED_SCHEMA_VERSION_ENV] !== undefined) {
|
|
606
|
-
merged[EXPECTED_SCHEMA_VERSION_ENV] =
|
|
607
|
-
schemaVersionEnv[EXPECTED_SCHEMA_VERSION_ENV];
|
|
608
|
-
}
|
|
609
|
-
if (schemaVersionEnv[EXPECTED_SCHEMA_VERSION_TOOL_ENV] !== undefined) {
|
|
610
|
-
merged[EXPECTED_SCHEMA_VERSION_TOOL_ENV] =
|
|
611
|
-
schemaVersionEnv[EXPECTED_SCHEMA_VERSION_TOOL_ENV];
|
|
612
|
-
}
|
|
613
|
-
return merged;
|
|
614
|
-
};
|
|
615
|
-
const mergeChSchemaEnv = (authored) => {
|
|
616
|
-
if (chSchemaVersionEnv === undefined)
|
|
617
|
-
return authored;
|
|
618
|
-
const authoredValue = authored?.[EXPECTED_CH_SCHEMA_VERSION_ENV];
|
|
619
|
-
const resolvedValue = chSchemaVersionEnv[EXPECTED_CH_SCHEMA_VERSION_ENV];
|
|
620
|
-
if (authoredValue !== undefined) {
|
|
621
|
-
if (authoredValue !== resolvedValue && annotationsScope !== undefined) {
|
|
622
|
-
Annotations.of(annotationsScope).addWarning(`Service '${service.name}': author-set ${EXPECTED_CH_SCHEMA_VERSION_ENV}` +
|
|
623
|
-
`='${authoredValue}' overrides the value resolved from the connected ` +
|
|
624
|
-
`ClickHouse database's \`migrations:\` config ('${resolvedValue}'). The ` +
|
|
625
|
-
`author value wins; set \`schemaGate: false\` to silence this warning.`);
|
|
626
|
-
}
|
|
627
|
-
return mergeAuthorWins(authored, chSchemaVersionEnv);
|
|
628
|
-
}
|
|
629
|
-
return mergeAuthorWins(authored, chSchemaVersionEnv);
|
|
630
|
-
};
|
|
631
|
-
const mergeAuthorWins = (authored, framework) => {
|
|
632
|
-
const merged = { ...(authored ?? {}) };
|
|
633
|
-
for (const [k, v] of Object.entries(framework)) {
|
|
634
|
-
if (merged[k] === undefined)
|
|
635
|
-
merged[k] = v;
|
|
636
|
-
}
|
|
637
|
-
return merged;
|
|
638
|
-
};
|
|
738
|
+
const mergeSchemaEnv = (authored) => mergePgSchemaVersionEnv({
|
|
739
|
+
authored,
|
|
740
|
+
resolved: schemaVersionEnv,
|
|
741
|
+
subject: `Service '${service.name}'`,
|
|
742
|
+
annotationsScope,
|
|
743
|
+
materialisedGateNote: true
|
|
744
|
+
});
|
|
745
|
+
const mergeChSchemaEnv = (authored) => mergeChSchemaVersionEnv({
|
|
746
|
+
authored,
|
|
747
|
+
resolved: chSchemaVersionEnv,
|
|
748
|
+
subject: `Service '${service.name}'`,
|
|
749
|
+
annotationsScope,
|
|
750
|
+
overrideRemedy: "set `schemaGate: false` to silence this warning."
|
|
751
|
+
});
|
|
639
752
|
const mergeChGateSecretsImport = (authored) => {
|
|
640
753
|
if (chGateSecretsImport === undefined)
|
|
641
754
|
return authored;
|
|
@@ -649,8 +762,49 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
649
762
|
return merged;
|
|
650
763
|
};
|
|
651
764
|
const mergeAllSchemaEnv = (authored) => mergeChSchemaEnv(mergeSchemaEnv(authored));
|
|
765
|
+
// Prepend the synthetic gate container and wire every other container to
|
|
766
|
+
// wait on its SUCCESS. In init-container mode the gate itself waits on the
|
|
767
|
+
// migrate container's SUCCESS (migrate → gate → app); the app containers'
|
|
768
|
+
// existing migrate dependency stays — redundant but harmless. The migrate
|
|
769
|
+
// container must NOT depend on the gate (it runs against the pre-migration
|
|
770
|
+
// schema by definition).
|
|
771
|
+
const wireSchemaGate = (built) => {
|
|
772
|
+
if (schemaGate === undefined || built.length === 0)
|
|
773
|
+
return built;
|
|
774
|
+
const migrateName = service.migrations !== undefined && !isHookMigrations(service.migrations)
|
|
775
|
+
? (service.migrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME)
|
|
776
|
+
: undefined;
|
|
777
|
+
const gateContainer = {
|
|
778
|
+
name: SCHEMA_GATE_CONTAINER_NAME,
|
|
779
|
+
image: schemaGate.image,
|
|
780
|
+
verbatimImage: true,
|
|
781
|
+
// SUCCESS dependencies require a non-essential target
|
|
782
|
+
essential: false,
|
|
783
|
+
environment: schemaGate.environment,
|
|
784
|
+
...(Object.keys(schemaGate.secretsImport).length > 0 && {
|
|
785
|
+
secretsImport: schemaGate.secretsImport
|
|
786
|
+
}),
|
|
787
|
+
startTimeout: SCHEMA_GATE_START_TIMEOUT_SECONDS,
|
|
788
|
+
...(migrateName !== undefined && {
|
|
789
|
+
dependsOn: [{ container: migrateName, condition: "SUCCESS" }]
|
|
790
|
+
})
|
|
791
|
+
};
|
|
792
|
+
const wired = built.map((c) => c.name === migrateName
|
|
793
|
+
? c
|
|
794
|
+
: {
|
|
795
|
+
...c,
|
|
796
|
+
dependsOn: [
|
|
797
|
+
...(c.dependsOn ?? []),
|
|
798
|
+
{
|
|
799
|
+
container: SCHEMA_GATE_CONTAINER_NAME,
|
|
800
|
+
condition: "SUCCESS"
|
|
801
|
+
}
|
|
802
|
+
]
|
|
803
|
+
});
|
|
804
|
+
return [gateContainer, ...wired];
|
|
805
|
+
};
|
|
652
806
|
if (expanded) {
|
|
653
|
-
return expanded.map((c, index) => {
|
|
807
|
+
return wireSchemaGate(expanded.map((c, index) => {
|
|
654
808
|
return {
|
|
655
809
|
name: c.name || `${service.name}Container${index > 0 ? index : ""}`,
|
|
656
810
|
image: c.image,
|
|
@@ -667,11 +821,11 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
667
821
|
volumes: c.volumes,
|
|
668
822
|
stopTimeout: c.stopTimeout
|
|
669
823
|
};
|
|
670
|
-
});
|
|
824
|
+
}));
|
|
671
825
|
}
|
|
672
826
|
const fallbackEnv = mergeAllSchemaEnv(undefined);
|
|
673
827
|
const fallbackSecretsImport = mergeChGateSecretsImport(undefined);
|
|
674
|
-
return [
|
|
828
|
+
return wireSchemaGate([
|
|
675
829
|
{
|
|
676
830
|
name: `${service.name}Container`,
|
|
677
831
|
...(fallbackEnv !== undefined && { environment: fallbackEnv }),
|
|
@@ -679,7 +833,7 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
679
833
|
secretsImport: fallbackSecretsImport
|
|
680
834
|
})
|
|
681
835
|
}
|
|
682
|
-
];
|
|
836
|
+
]);
|
|
683
837
|
}
|
|
684
838
|
function resolveMigrationImage(svcConfig, migrations, inheritedImage) {
|
|
685
839
|
if (migrations.image !== undefined)
|
|
@@ -741,15 +895,30 @@ export class EcsCompute extends Construct {
|
|
|
741
895
|
clusterId;
|
|
742
896
|
appName;
|
|
743
897
|
migrationTaskDefinitions = new Map();
|
|
898
|
+
/**
|
|
899
|
+
* Service name → exact `@fjall/schema-gate` semver, for every service that
|
|
900
|
+
* materialised the gate container with the DEFAULT image this synth.
|
|
901
|
+
* ComputeFactory copies it onto the manifest entry (`schemaGateImageTag`)
|
|
902
|
+
* so deploy-core's `ensureSchemaGateImage` mirrors the tag pre-deploy.
|
|
903
|
+
* BYO-image services (`schemaGate.image`) are deliberately absent — the
|
|
904
|
+
* author owns that image's availability.
|
|
905
|
+
*/
|
|
906
|
+
schemaGateImageTags;
|
|
744
907
|
constructor(scope, id, props) {
|
|
745
908
|
super(scope, id);
|
|
746
909
|
validateEcsProps(props);
|
|
747
910
|
this.clusterId = id;
|
|
748
911
|
this.appName = props.appName;
|
|
912
|
+
const schemaGateImageTags = new Map();
|
|
913
|
+
this.schemaGateImageTags = schemaGateImageTags;
|
|
749
914
|
const services = props.services.map((service) => {
|
|
750
915
|
const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
|
|
751
916
|
const chGate = this.resolveClickHouseSchemaVersionEnv(service);
|
|
752
|
-
const
|
|
917
|
+
const schemaGateSpec = this.resolveSchemaGateMaterialisation(service, schemaVersionEnv, chGate);
|
|
918
|
+
if (schemaGateSpec?.defaultImageTag !== undefined) {
|
|
919
|
+
schemaGateImageTags.set(service.name, schemaGateSpec.defaultImageTag);
|
|
920
|
+
}
|
|
921
|
+
const containers = buildContainerConfigs(service, schemaVersionEnv, this, chGate, schemaGateSpec);
|
|
753
922
|
const { scalingType, minCapacity, maxCapacity, queueScaling } = resolveScalingConfig(service.scaling);
|
|
754
923
|
const cloudMapService = service.serviceDiscovery !== undefined
|
|
755
924
|
? App.getInstance().registerService({
|
|
@@ -818,7 +987,8 @@ export class EcsCompute extends Construct {
|
|
|
818
987
|
cluster,
|
|
819
988
|
services,
|
|
820
989
|
alertsTopic: resolvedAlertsTopic,
|
|
821
|
-
applicationId: props.applicationId
|
|
990
|
+
applicationId: props.applicationId,
|
|
991
|
+
...(props.taskStopWatchdog === false && { taskStopWatchdog: false })
|
|
822
992
|
};
|
|
823
993
|
this.ecsCluster = new EcsCluster(this, `${id}Ecs`, ecsProps);
|
|
824
994
|
this.connections = this.ecsCluster.connections;
|
|
@@ -842,7 +1012,7 @@ export class EcsCompute extends Construct {
|
|
|
842
1012
|
* hardcoding one.
|
|
843
1013
|
*/
|
|
844
1014
|
resolveSchemaVersionEnv(service) {
|
|
845
|
-
if (service.schemaGate
|
|
1015
|
+
if (!normaliseSchemaGate(service.schemaGate).envInjection)
|
|
846
1016
|
return undefined;
|
|
847
1017
|
const db = resolveMigrationDatabaseForService(service);
|
|
848
1018
|
if (db === undefined)
|
|
@@ -852,7 +1022,7 @@ export class EcsCompute extends Construct {
|
|
|
852
1022
|
return undefined;
|
|
853
1023
|
const config = db.getMigrationsConfig();
|
|
854
1024
|
if (config === undefined) {
|
|
855
|
-
throw new Error(
|
|
1025
|
+
throw new Error(`${schemaGateSubjectLabel(service)}: schema-version-gate produced a version ` +
|
|
856
1026
|
`but the connected database returned no migrations config — these ` +
|
|
857
1027
|
`must move together.`);
|
|
858
1028
|
}
|
|
@@ -877,7 +1047,7 @@ export class EcsCompute extends Construct {
|
|
|
877
1047
|
* - Two or more migrated CHs → throws via `resolveClickHouseDatabaseForService`
|
|
878
1048
|
*/
|
|
879
1049
|
resolveClickHouseSchemaVersionEnv(service) {
|
|
880
|
-
if (service.schemaGate
|
|
1050
|
+
if (!normaliseSchemaGate(service.schemaGate).envInjection)
|
|
881
1051
|
return undefined;
|
|
882
1052
|
const db = resolveClickHouseDatabaseForService(service);
|
|
883
1053
|
if (db === undefined)
|
|
@@ -896,6 +1066,120 @@ export class EcsCompute extends Construct {
|
|
|
896
1066
|
}
|
|
897
1067
|
return { environment, secretsImport };
|
|
898
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Decide whether the synthetic `fjall-schema-gate` container materialises
|
|
1071
|
+
* for this service, and compose its env/secrets/image when it does.
|
|
1072
|
+
* Returns `undefined` (env-injection-only, the pre-10.x behaviour) when:
|
|
1073
|
+
*
|
|
1074
|
+
* - `schemaGate: false` or `{ materialise: false }` (author opt-out)
|
|
1075
|
+
* - Neither a PG nor a CH gate resolved for the service
|
|
1076
|
+
* - `migrations.mode: "post-deploy"` — expected is deliberately ahead of
|
|
1077
|
+
* the DB during scale-up, so a materialised gate would refuse every task
|
|
1078
|
+
* (synth warning)
|
|
1079
|
+
* - `migrations.mode: "lifecycle-hook"` WITHOUT `separateTaskDef` — the
|
|
1080
|
+
* hook's migration RunTask reuses the service task definition, so a
|
|
1081
|
+
* materialised gate would refuse the migration task itself while the DB
|
|
1082
|
+
* still holds the pre-migration schema (synth warning; adding
|
|
1083
|
+
* `separateTaskDef` restores full gating)
|
|
1084
|
+
* - No eligible half survives the v1 scope filters: the relational half
|
|
1085
|
+
* requires `tool: "prisma"` and a postgres-family connection scheme
|
|
1086
|
+
* (per-half synth warnings; env injection into app containers stays on)
|
|
1087
|
+
* - The default image URI cannot be derived because the constructs package
|
|
1088
|
+
* version is unresolvable and no `schemaGate.image` override is set
|
|
1089
|
+
*
|
|
1090
|
+
* The relational half re-resolves the migrated DB for its
|
|
1091
|
+
* `getSchemaGateContribution()` (credential-free URL base + user/password
|
|
1092
|
+
* secret imports). The CH half composes `CLICKHOUSE_URL` / database name /
|
|
1093
|
+
* CA-cert import from the resolved CH database — `chGate` alone carries
|
|
1094
|
+
* only the version + schema-admin entries the app containers get.
|
|
1095
|
+
*/
|
|
1096
|
+
resolveSchemaGateMaterialisation(service, schemaVersionEnv, chGate) {
|
|
1097
|
+
const normalised = normaliseSchemaGate(service.schemaGate);
|
|
1098
|
+
if (!normalised.materialise)
|
|
1099
|
+
return undefined;
|
|
1100
|
+
if (schemaVersionEnv === undefined && chGate === undefined) {
|
|
1101
|
+
return undefined;
|
|
1102
|
+
}
|
|
1103
|
+
if (service.migrations?.mode === "post-deploy") {
|
|
1104
|
+
Annotations.of(this).addWarning(`Service '${service.name}': schema-gate materialisation is disabled ` +
|
|
1105
|
+
`because migrations.mode is "post-deploy" — the expected schema ` +
|
|
1106
|
+
`version is deliberately ahead of the database while new tasks ` +
|
|
1107
|
+
`scale up, so a materialised gate would refuse every task. ` +
|
|
1108
|
+
`Env injection stays on. Set schemaGate: false to silence.`);
|
|
1109
|
+
return undefined;
|
|
1110
|
+
}
|
|
1111
|
+
if (service.migrations?.mode === "lifecycle-hook" &&
|
|
1112
|
+
service.migrations.separateTaskDef === undefined) {
|
|
1113
|
+
Annotations.of(this).addWarning(`Service '${service.name}': schema-gate materialisation is disabled ` +
|
|
1114
|
+
`because migrations.mode is "lifecycle-hook" without ` +
|
|
1115
|
+
`separateTaskDef — the migration RunTask reuses the service task ` +
|
|
1116
|
+
`definition, so a materialised gate would refuse the migration ` +
|
|
1117
|
+
`task itself during the pre-migration window. Add ` +
|
|
1118
|
+
`migrations.separateTaskDef to run migrations in their own task ` +
|
|
1119
|
+
`definition and restore full gating. Env injection stays on.`);
|
|
1120
|
+
return undefined;
|
|
1121
|
+
}
|
|
1122
|
+
const environment = {};
|
|
1123
|
+
const secretsImport = {};
|
|
1124
|
+
let eligibleHalves = 0;
|
|
1125
|
+
if (schemaVersionEnv !== undefined) {
|
|
1126
|
+
const tool = schemaVersionEnv[EXPECTED_SCHEMA_VERSION_TOOL_ENV];
|
|
1127
|
+
const db = resolveMigrationDatabaseForService(service);
|
|
1128
|
+
const contribution = db?.getSchemaGateContribution();
|
|
1129
|
+
const urlBase = contribution?.environment[SCHEMA_GATE_DB_URL_BASE_ENV];
|
|
1130
|
+
if (tool !== "prisma") {
|
|
1131
|
+
Annotations.of(this).addWarning(`Service '${service.name}': the materialised schema gate cannot ` +
|
|
1132
|
+
`verify migrations.tool "${tool ?? "<unset>"}" (v1 supports ` +
|
|
1133
|
+
`"prisma" only) — the relational half is skipped. Env injection ` +
|
|
1134
|
+
`into app containers stays on.`);
|
|
1135
|
+
}
|
|
1136
|
+
else if (contribution === undefined ||
|
|
1137
|
+
urlBase === undefined ||
|
|
1138
|
+
!isPostgresUrlBase(urlBase)) {
|
|
1139
|
+
Annotations.of(this).addWarning(`Service '${service.name}': the materialised schema gate supports ` +
|
|
1140
|
+
`postgres-family databases only (v1) — the relational half is ` +
|
|
1141
|
+
`skipped. Env injection into app containers stays on.`);
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
Object.assign(environment, schemaVersionEnv, contribution.environment);
|
|
1145
|
+
Object.assign(secretsImport, contribution.secretsImport);
|
|
1146
|
+
eligibleHalves += 1;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (chGate !== undefined) {
|
|
1150
|
+
const db = resolveClickHouseDatabaseForService(service);
|
|
1151
|
+
if (db !== undefined) {
|
|
1152
|
+
Object.assign(environment, chGate.environment, {
|
|
1153
|
+
[SCHEMA_GATE_CH_URL_ENV]: db.getUrl(),
|
|
1154
|
+
[SCHEMA_GATE_CH_DATABASE_ENV]: db.getDatabaseName()
|
|
1155
|
+
});
|
|
1156
|
+
Object.assign(secretsImport, chGate.secretsImport);
|
|
1157
|
+
const caCert = db.getTlsCaCertImport();
|
|
1158
|
+
if (caCert !== undefined) {
|
|
1159
|
+
secretsImport[SCHEMA_GATE_CH_CA_CERT_ENV] = caCert;
|
|
1160
|
+
}
|
|
1161
|
+
eligibleHalves += 1;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (eligibleHalves === 0)
|
|
1165
|
+
return undefined;
|
|
1166
|
+
if (normalised.image !== undefined) {
|
|
1167
|
+
return { environment, secretsImport, image: normalised.image };
|
|
1168
|
+
}
|
|
1169
|
+
const defaultImage = defaultSchemaGateImageUri();
|
|
1170
|
+
if (defaultImage === undefined || CONSTRUCTS_VERSION === undefined) {
|
|
1171
|
+
Annotations.of(this).addWarning(`Service '${service.name}': schema-gate materialisation is disabled ` +
|
|
1172
|
+
`because the constructs package version could not be resolved for ` +
|
|
1173
|
+
`the default gate image tag. Set schemaGate.image explicitly.`);
|
|
1174
|
+
return undefined;
|
|
1175
|
+
}
|
|
1176
|
+
return {
|
|
1177
|
+
environment,
|
|
1178
|
+
secretsImport,
|
|
1179
|
+
image: defaultImage,
|
|
1180
|
+
defaultImageTag: CONSTRUCTS_VERSION
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
899
1183
|
materialiseScheduledTasks(id, props) {
|
|
900
1184
|
const scheduledTasks = props.cluster?.scheduledTasks;
|
|
901
1185
|
if (!scheduledTasks || scheduledTasks.length === 0)
|
|
@@ -947,14 +1231,24 @@ export class EcsCompute extends Construct {
|
|
|
947
1231
|
streamPrefix: `/ecs-scheduled/${id}/${entry.name}`,
|
|
948
1232
|
logGroup
|
|
949
1233
|
});
|
|
1234
|
+
const { environment, secrets } = this.composeScheduledTaskEnvAndSecrets(id, entry);
|
|
950
1235
|
const container = taskDef.addContainer(`${id}${toPascalCase(entry.name)}Container`, {
|
|
951
1236
|
image: entry.image,
|
|
952
1237
|
cpu: entry.cpu,
|
|
953
1238
|
memoryLimitMiB: entry.memoryLimitMiB,
|
|
954
1239
|
...(entry.command !== undefined && { command: entry.command }),
|
|
955
|
-
...(
|
|
1240
|
+
...(environment !== undefined && { environment }),
|
|
1241
|
+
...(secrets !== undefined && { secrets }),
|
|
956
1242
|
logging
|
|
957
1243
|
});
|
|
1244
|
+
if (entry.connections !== undefined && entry.connections.length > 0) {
|
|
1245
|
+
try {
|
|
1246
|
+
processConnections(entry.connections, taskDef.taskRole, this.ecsCluster.getEc2CapacityConnectable());
|
|
1247
|
+
}
|
|
1248
|
+
catch (error) {
|
|
1249
|
+
throw new Error(`Failed to process connections for scheduled task '${entry.name}': ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
958
1252
|
if (entry.volumes) {
|
|
959
1253
|
for (const volume of entry.volumes) {
|
|
960
1254
|
container.addMountPoints({
|
|
@@ -966,6 +1260,91 @@ export class EcsCompute extends Construct {
|
|
|
966
1260
|
}
|
|
967
1261
|
return taskDef;
|
|
968
1262
|
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Compose a scheduled task's container env and secrets: schema-version gate
|
|
1265
|
+
* env resolved from the entry's `connections` merges beneath the author's
|
|
1266
|
+
* `environment` (author wins — the same semantics as service containers),
|
|
1267
|
+
* and the ClickHouse gate's credential imports merge beneath the author's
|
|
1268
|
+
* `secrets`. Returns `undefined` halves when nothing applies so the
|
|
1269
|
+
* container definition stays byte-identical for unconnected entries.
|
|
1270
|
+
*/
|
|
1271
|
+
composeScheduledTaskEnvAndSecrets(id, entry) {
|
|
1272
|
+
const subject = `Scheduled task '${entry.name}'`;
|
|
1273
|
+
const gateSubject = {
|
|
1274
|
+
name: entry.name,
|
|
1275
|
+
subjectLabel: subject,
|
|
1276
|
+
connections: entry.connections,
|
|
1277
|
+
schemaGate: entry.schemaGate
|
|
1278
|
+
};
|
|
1279
|
+
const schemaVersionEnv = this.resolveSchemaVersionEnv(gateSubject);
|
|
1280
|
+
const chGate = this.resolveClickHouseSchemaVersionEnv(gateSubject);
|
|
1281
|
+
const environment = mergeChSchemaVersionEnv({
|
|
1282
|
+
authored: mergePgSchemaVersionEnv({
|
|
1283
|
+
authored: entry.environment,
|
|
1284
|
+
resolved: schemaVersionEnv,
|
|
1285
|
+
subject,
|
|
1286
|
+
annotationsScope: this,
|
|
1287
|
+
materialisedGateNote: false
|
|
1288
|
+
}),
|
|
1289
|
+
resolved: chGate?.environment,
|
|
1290
|
+
subject,
|
|
1291
|
+
annotationsScope: this,
|
|
1292
|
+
overrideRemedy: "remove the env entry to accept the resolved value."
|
|
1293
|
+
});
|
|
1294
|
+
let secrets = entry.secrets;
|
|
1295
|
+
if (chGate !== undefined && Object.keys(chGate.secretsImport).length > 0) {
|
|
1296
|
+
const merged = {};
|
|
1297
|
+
for (const [key, secretImport] of Object.entries(chGate.secretsImport)) {
|
|
1298
|
+
if (entry.secrets?.[key] !== undefined)
|
|
1299
|
+
continue;
|
|
1300
|
+
const secret = resolveImportedSecret(this, `${id}${toPascalCase(entry.name)}${key}Secret`, secretImport);
|
|
1301
|
+
merged[key] = EcsSecret.fromSecretsManager(secret, secretImport.field);
|
|
1302
|
+
}
|
|
1303
|
+
secrets = { ...merged, ...(entry.secrets ?? {}) };
|
|
1304
|
+
}
|
|
1305
|
+
return { environment, secrets };
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* A sibling service that consumes a hook-migrated database materialises a
|
|
1309
|
+
* schema gate that refuses while the owner's migration is still in flight.
|
|
1310
|
+
* Without `awaitMigrationsFrom` the sibling rolls out in parallel with the
|
|
1311
|
+
* owner, so its gate-refused task churn can trip the deployment circuit
|
|
1312
|
+
* breaker before the migration lands. The gate refusing is by design (new
|
|
1313
|
+
* code must not serve the old schema); the missing DependsOn is the
|
|
1314
|
+
* author-fixable part — warn, don't disable.
|
|
1315
|
+
*/
|
|
1316
|
+
warnUngatedSiblingsOfHookOwners(services) {
|
|
1317
|
+
for (const owner of services) {
|
|
1318
|
+
const ownerMigrations = owner.migrations;
|
|
1319
|
+
if (!ownerMigrations || !isHookMigrations(ownerMigrations))
|
|
1320
|
+
continue;
|
|
1321
|
+
const ownerDb = resolveMigrationDatabaseForService(owner);
|
|
1322
|
+
const ownerCh = resolveClickHouseDatabaseForService(owner);
|
|
1323
|
+
if (ownerDb === undefined && ownerCh === undefined)
|
|
1324
|
+
continue;
|
|
1325
|
+
for (const sibling of services) {
|
|
1326
|
+
if (sibling.name === owner.name)
|
|
1327
|
+
continue;
|
|
1328
|
+
if (sibling.awaitMigrationsFrom === owner.name)
|
|
1329
|
+
continue;
|
|
1330
|
+
if (!normaliseSchemaGate(sibling.schemaGate).materialise)
|
|
1331
|
+
continue;
|
|
1332
|
+
const sharesDb = ownerDb !== undefined &&
|
|
1333
|
+
resolveMigrationDatabaseForService(sibling) === ownerDb;
|
|
1334
|
+
const sharesCh = ownerCh !== undefined &&
|
|
1335
|
+
resolveClickHouseDatabaseForService(sibling) === ownerCh;
|
|
1336
|
+
if (!sharesDb && !sharesCh)
|
|
1337
|
+
continue;
|
|
1338
|
+
Annotations.of(this).addWarning(`Service '${sibling.name}': consumes a database whose migrations ` +
|
|
1339
|
+
`run via service '${owner.name}''s deployment hook, but does ` +
|
|
1340
|
+
`not declare awaitMigrationsFrom: "${owner.name}". Its ` +
|
|
1341
|
+
`materialised schema gate will refuse tasks that scale up ` +
|
|
1342
|
+
`before the migration lands, which can trip the deployment ` +
|
|
1343
|
+
`circuit breaker. Add awaitMigrationsFrom to order the ` +
|
|
1344
|
+
`rollouts, or set schemaGate: { materialise: false }.`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
969
1348
|
/**
|
|
970
1349
|
* For each service whose `migrations.mode` is a lambda-hook variant
|
|
971
1350
|
* (`"lifecycle-hook"` for PRE_SCALE_UP, `"post-deploy"` for POST_SCALE_UP),
|
|
@@ -975,6 +1354,7 @@ export class EcsCompute extends Construct {
|
|
|
975
1354
|
* migrate container injected by `expandMigrationsSugar`.
|
|
976
1355
|
*/
|
|
977
1356
|
wireLifecycleHookMigrations(services) {
|
|
1357
|
+
this.warnUngatedSiblingsOfHookOwners(services);
|
|
978
1358
|
for (const svcConfig of services) {
|
|
979
1359
|
const migrations = svcConfig.migrations;
|
|
980
1360
|
if (!migrations || !isHookMigrations(migrations))
|