@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.
Files changed (37) hide show
  1. package/dist/lib/patterns/aws/compute.js +13 -1
  2. package/dist/lib/patterns/aws/computeEcs.d.ts +80 -1
  3. package/dist/lib/patterns/aws/computeEcs.js +450 -70
  4. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +113 -6
  5. package/dist/lib/patterns/aws/computeEcsTypes.js +19 -0
  6. package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
  7. package/dist/lib/patterns/aws/database.d.ts +12 -1
  8. package/dist/lib/patterns/aws/database.js +16 -0
  9. package/dist/lib/patterns/aws/devSubstrate.js +64 -8
  10. package/dist/lib/patterns/aws/interfaces/database.d.ts +26 -0
  11. package/dist/lib/patterns/aws/storage.d.ts +7 -0
  12. package/dist/lib/patterns/aws/storage.js +14 -1
  13. package/dist/lib/resources/aws/compute/applicationLoadBalancer.js +6 -0
  14. package/dist/lib/resources/aws/compute/ecs.d.ts +12 -0
  15. package/dist/lib/resources/aws/compute/ecs.js +32 -2
  16. package/dist/lib/resources/aws/compute/ecsImages.js +4 -0
  17. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +29 -3
  18. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +32 -0
  19. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +12 -0
  20. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +42 -2
  21. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +6 -0
  22. package/dist/lib/resources/aws/compute/ecsValidation.js +13 -0
  23. package/dist/lib/resources/aws/database/rdsAurora.d.ts +7 -1
  24. package/dist/lib/resources/aws/database/rdsAurora.js +1 -1
  25. package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -2
  26. package/dist/lib/resources/aws/messaging/eventTargets.js +55 -2
  27. package/dist/lib/resources/aws/monitoring/alarmDefaults.d.ts +1 -0
  28. package/dist/lib/resources/aws/monitoring/alarmDefaults.js +1 -1
  29. package/dist/lib/resources/aws/monitoring/ecsTaskStopWatchdog.d.ts +59 -0
  30. package/dist/lib/resources/aws/monitoring/ecsTaskStopWatchdog.js +115 -0
  31. package/dist/lib/resources/aws/monitoring/index.d.ts +1 -0
  32. package/dist/lib/resources/aws/monitoring/index.js +1 -0
  33. package/dist/lib/resources/aws/monitoring/metricNamespaces.d.ts +1 -0
  34. package/dist/lib/resources/aws/monitoring/metricNamespaces.js +1 -0
  35. package/dist/lib/utils/manifestWriter.d.ts +16 -2
  36. package/dist/lib/utils/manifestWriter.js +22 -0
  37. 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(`Service '${service.name}': connections include two or more relational ` +
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(`Service '${service.name}': connections include two or more ClickHouse ` +
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
- if (schemaVersionEnv === undefined)
586
- return authored;
587
- const authoredValue = authored?.[EXPECTED_SCHEMA_VERSION_ENV];
588
- if (authoredValue !== undefined) {
589
- const resolvedValue = schemaVersionEnv[EXPECTED_SCHEMA_VERSION_ENV];
590
- if (authoredValue !== resolvedValue && annotationsScope !== undefined) {
591
- Annotations.of(annotationsScope).addWarning(`Service '${service.name}': author-set ${EXPECTED_SCHEMA_VERSION_ENV}` +
592
- `='${authoredValue}' overrides the value resolved from the connected ` +
593
- `database's \`migrations:\` config ('${resolvedValue}'). The author ` +
594
- `value wins; set \`schemaGate: false\` to silence this warning. Note ` +
595
- `that ${EXPECTED_SCHEMA_VERSION_TOOL_ENV} will still be auto-injected ` +
596
- `to ensure the runtime gate passes both envs must emit together.`);
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 containers = buildContainerConfigs(service, schemaVersionEnv, this, chGate);
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 === false)
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(`Service '${service.name}': schema-version-gate produced a version ` +
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 === false)
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
- ...(entry.secrets !== undefined && { secrets: entry.secrets }),
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))