@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
@@ -117,7 +117,10 @@ export interface EcsContainerConfig {
117
117
  essential?: boolean;
118
118
  /**
119
119
  * Health check configuration.
120
- * Default: For primary container with port, uses curl health check.
120
+ * Default: none the construct emits no container health check unless one
121
+ * is declared here. Without one (and without an ALB target group), ECS
122
+ * treats the container as healthy from the moment it is RUNNING, so
123
+ * post-start exits are invisible to the deployment circuit breaker.
121
124
  */
122
125
  healthCheck?: {
123
126
  command: string[];
@@ -252,6 +255,35 @@ export type EcsHookMigrationsConfig = EcsLifecycleHookMigrationsConfig | EcsPost
252
255
  * etc. on the narrowed object.
253
256
  */
254
257
  export declare function isHookMigrations(config: EcsMigrationsConfig): config is EcsHookMigrationsConfig;
258
+ /**
259
+ * Object form of the `schemaGate` service knob. See the `schemaGate` JSDoc
260
+ * on `EcsServiceConfig` for the semantics of each field.
261
+ */
262
+ export interface EcsSchemaGateConfig {
263
+ /**
264
+ * Materialise the synthetic `fjall-schema-gate` container. Defaults
265
+ * `true`. `false` keeps env injection but restores the pre-10.x
266
+ * behaviour where the app's own boot gate is the sole enforcement.
267
+ */
268
+ materialise?: boolean;
269
+ /**
270
+ * Override the gate image URI. Defaults to the release-pinned
271
+ * `@fjall/schema-gate` image mirrored into the target account's private
272
+ * ECR by the deploy engine.
273
+ */
274
+ image?: string;
275
+ }
276
+ /**
277
+ * Collapse the `boolean | EcsSchemaGateConfig` knob into its three
278
+ * independent answers. One decode site — every consumer (env resolution,
279
+ * materialisation, validation) reads the same normalised shape rather than
280
+ * re-deriving the union's semantics.
281
+ */
282
+ export declare function normaliseSchemaGate(schemaGate: boolean | EcsSchemaGateConfig | undefined): {
283
+ envInjection: boolean;
284
+ materialise: boolean;
285
+ image: string | undefined;
286
+ };
255
287
  /**
256
288
  * `mode: "init-container"` variant — migration runs as a non-essential init
257
289
  * container inside each replica's task, dependsOn `COMPLETE` before main starts.
@@ -442,6 +474,19 @@ export interface EcsCircuitBreakerConfig {
442
474
  * trips. Defaults to `true`.
443
475
  */
444
476
  rollback?: boolean;
477
+ /**
478
+ * Whether one healthy task resets the breaker's failure counter. Defaults
479
+ * to `false` — deliberately stricter than AWS's `true` default, where a
480
+ * slow crash loop that occasionally boots a task can hold a deployment
481
+ * `IN_PROGRESS` indefinitely.
482
+ */
483
+ resetOnHealthyTask?: boolean;
484
+ /**
485
+ * Absolute failed-task count before the breaker trips (CFN
486
+ * `ThresholdConfiguration { Type: COUNT }`). Omitted → ECS computes its
487
+ * default from the desired count. Must be a positive integer.
488
+ */
489
+ threshold?: number;
445
490
  }
446
491
  /**
447
492
  * ECS scaling configuration.
@@ -497,6 +542,29 @@ export interface EcsScheduledTaskConfig {
497
542
  cpu: number;
498
543
  memoryLimitMiB: number;
499
544
  command?: string[];
545
+ /**
546
+ * Plain (non-secret) env vars for the task's container. Framework-resolved
547
+ * schema-version gate env (from a migrated database in `connections`) merges
548
+ * BENEATH these — author values win, mirroring the service-container
549
+ * semantics in `buildContainerConfigs`.
550
+ */
551
+ environment?: Record<string, string>;
552
+ /**
553
+ * Connections to data resources — the same connector interface services
554
+ * use. Produces IAM grants on the task role, security-group wiring (via the
555
+ * cluster's EC2 capacity security group — scheduled tasks run on the
556
+ * cluster's instances), and schema-version gate env injection when a
557
+ * connected database declares `migrations:`. Scheduled tasks never
558
+ * materialise a `fjall-schema-gate` container; only the env is injected —
559
+ * the task's own entrypoint decides whether to gate on it.
560
+ */
561
+ connections?: ConnectionSpec[];
562
+ /**
563
+ * Set `false` to opt the task out of schema-version gate env injection.
564
+ * Scheduled tasks never materialise a gate container, so unlike the
565
+ * service-level knob only the boolean form applies here.
566
+ */
567
+ schemaGate?: boolean;
500
568
  /** Container secrets (Secrets Manager / SSM) — same shape as ECS `secrets`. */
501
569
  secrets?: Record<string, EcsSecret>;
502
570
  /** Pre-existing CDK log group. Mutually exclusive with `logRetention`. */
@@ -608,6 +676,19 @@ export interface EcsServiceConfig {
608
676
  * For multi-container services, the first container with a port is the primary container.
609
677
  */
610
678
  containers?: EcsContainerConfig[];
679
+ /**
680
+ * Service intent. `"web"` (the default when omitted) may declare container
681
+ * ports and ALB routing. `"worker"` is a background service with no inbound
682
+ * surface — a container `port`/`portMappings` or service `routing` on a
683
+ * worker is rejected at synth, so the declared intent cannot drift from the
684
+ * shape. Declaring it changes nothing else: portless services already skip
685
+ * ALB attachment, and no container health check is ever injected by
686
+ * default (see `EcsContainerConfig.healthCheck`). Without a declared health
687
+ * check the deployment circuit breaker cannot see a worker that starts and
688
+ * then exits or wedges — declare one on workers whose liveness should gate
689
+ * deployments.
690
+ */
691
+ serviceType?: "web" | "worker";
611
692
  /**
612
693
  * Routing rules for this service on the cluster's ALB.
613
694
  * Required when cluster has multiple services with ports.
@@ -692,20 +773,36 @@ export interface EcsServiceConfig {
692
773
  */
693
774
  connections?: ConnectionSpec[];
694
775
  /**
695
- * Schema-version gate opt-out. Defaults `true`.
776
+ * Schema-version gate configuration. Defaults `true` (fully on).
696
777
  *
697
778
  * When any database in `connections` carries a `migrations` block, every
698
779
  * container in this service receives `EXPECTED_SCHEMA_VERSION` resolved
699
- * from that database's migration tool at synth time. Set `false` to skip
700
- * the injectionintended for sidecars that intentionally tolerate
701
- * schema drift or one-shot maintenance tasks.
780
+ * from that database's migration tool at synth time, AND when the
781
+ * service is eligible a synthetic `fjall-schema-gate` container is
782
+ * materialised that verifies the live database BEFORE any app container
783
+ * starts (`dependsOn: SUCCESS`), so a schema refusal stops the task
784
+ * pre-RUNNING and trips the deployment circuit breaker instead of
785
+ * crash-looping invisibly.
786
+ *
787
+ * - `false` — everything off: no env injection, no materialised gate.
788
+ * Intended for sidecars that intentionally tolerate schema drift or
789
+ * one-shot maintenance tasks.
790
+ * - `{ materialise: false }` — env injection only (the pre-10.x
791
+ * behaviour): the app's own boot gate remains the sole enforcement.
792
+ * - `{ image: "…" }` — bring-your-own gate image URI (endpoint-only
793
+ * VPCs that cannot reach the mirrored default, or a custom runner).
794
+ *
795
+ * Materialisation auto-disables (with a synth warning) when the gate
796
+ * cannot verify the target: non-`prisma` migration tools, non-postgres
797
+ * engine families, and `migrations.mode: "post-deploy"` (where the
798
+ * expected version is deliberately ahead of the database at scale-up).
702
799
  *
703
800
  * Named opt-out is auditable: grep-recoverable across the monorepo. Note
704
801
  * the spelling collision with `@fjall/generator`'s codemod pipeline gate
705
802
  * at `validationGate/gates/schema.ts` — different surface, different
706
803
  * concept.
707
804
  */
708
- schemaGate?: boolean;
805
+ schemaGate?: boolean | EcsSchemaGateConfig;
709
806
  /**
710
807
  * Cross-app resources reachable via VPC peering. Each entry resolves the
711
808
  * peered app's exposed resource at synth time (SSM `valueForStringParameter`
@@ -960,6 +1057,16 @@ export interface EcsComputeProps {
960
1057
  * Accepts either an ITopic directly or a topic ARN string (resolved internally).
961
1058
  */
962
1059
  alertsTopic?: ITopic | string;
1060
+ /**
1061
+ * ECS task-stop watchdog (default on): a per-cluster EventBridge rule
1062
+ * captures abnormal task stops into a 30-day forensic log group
1063
+ * `/fjall/<cluster>/task-stops`, derives
1064
+ * `Fjall/ECS StoppedTaskCount{ClusterName,ServiceName}` via a metric
1065
+ * filter, and (when `alertsTopic` is set) raises a notification-only churn
1066
+ * alarm per service. `false` disables the whole watchdog — auditable
1067
+ * opt-out for cost-sensitive clusters.
1068
+ */
1069
+ taskStopWatchdog?: false;
963
1070
  /** Application ID for alarm tagging (used by webhook to map alarms to applications). */
964
1071
  applicationId?: string;
965
1072
  }
@@ -10,3 +10,22 @@ export { ScalingType };
10
10
  export function isHookMigrations(config) {
11
11
  return config.mode === "lifecycle-hook" || config.mode === "post-deploy";
12
12
  }
13
+ /**
14
+ * Collapse the `boolean | EcsSchemaGateConfig` knob into its three
15
+ * independent answers. One decode site — every consumer (env resolution,
16
+ * materialisation, validation) reads the same normalised shape rather than
17
+ * re-deriving the union's semantics.
18
+ */
19
+ export function normaliseSchemaGate(schemaGate) {
20
+ if (schemaGate === false) {
21
+ return { envInjection: false, materialise: false, image: undefined };
22
+ }
23
+ if (schemaGate === true || schemaGate === undefined) {
24
+ return { envInjection: true, materialise: true, image: undefined };
25
+ }
26
+ return {
27
+ envInjection: true,
28
+ materialise: schemaGate.materialise !== false,
29
+ image: schemaGate.image
30
+ };
31
+ }
@@ -16,6 +16,7 @@ const COMPUTE_PROP_APPLICABILITY = {
16
16
  services: ["ecs"],
17
17
  alertsTopic: ["ecs"],
18
18
  applicationId: ["ecs"],
19
+ taskStopWatchdog: ["ecs"],
19
20
  deployment: ["lambda"],
20
21
  functionUrl: ["lambda"],
21
22
  handler: ["lambda"],
@@ -1,4 +1,5 @@
1
1
  import { Construct } from "constructs";
2
+ import { type RemovalPolicy } from "aws-cdk-lib";
2
3
  import type { ITopic } from "aws-cdk-lib/aws-sns";
3
4
  import type { RdsAlarmThresholds } from "../../resources/aws/monitoring/index.js";
4
5
  import type App from "../../app.js";
@@ -6,7 +7,7 @@ import { type DynamoDBKeySchema, type DynamoDBGlobalSecondaryIndex } from "../..
6
7
  import { ClickHouseDatabase, type ClickHouseDatabaseProps } from "./clickhouseDatabase.js";
7
8
  import { type Connections, type IConnectable, type IVpc } from "aws-cdk-lib/aws-ec2";
8
9
  import { type ITable } from "aws-cdk-lib/aws-dynamodb";
9
- import { type Secret } from "../../resources/aws/secrets/index.js";
10
+ import { type Secret, type SecretImport } from "../../resources/aws/secrets/index.js";
10
11
  import { PolicyStatement, type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
11
12
  import { type IRelationalDatabaseBase, type IRelationalInstanceDatabase, type IRelationalClusterDatabase, type IDynamoDBDatabase, type RelationalDatabaseType, type SnapshotTarget, type MigrationsConfig } from "./interfaces/database.js";
12
13
  import { type MigrationContributions } from "./interfaces/migrationContributor.js";
@@ -59,6 +60,12 @@ export interface AuroraDatabaseProps extends BaseDatabaseProps {
59
60
  databaseEngine?: DatabaseEngine;
60
61
  /** Overrides databaseEngine if provided */
61
62
  engine?: IClusterEngine;
63
+ /**
64
+ * Defaults to SNAPSHOT (final snapshot on delete). DESTROY is reserved for
65
+ * disposable-data clusters (dev substrates) — delete takes the data with it,
66
+ * no snapshot, no recovery.
67
+ */
68
+ removalPolicy?: RemovalPolicy;
62
69
  /**
63
70
  * Aurora PostgreSQL version to build the engine at, e.g. `"16.6"`. Overrides
64
71
  * the `databaseEngine` default version; ignored when `engine` is supplied.
@@ -294,6 +301,10 @@ export declare class RelationalDatabase extends Construct implements IRelational
294
301
  getMigrationSnapshotPolicy(): PolicyStatement;
295
302
  getMigrationsConfig(): MigrationsConfig | undefined;
296
303
  getExpectedSchemaVersion(): string | undefined;
304
+ getSchemaGateContribution(): {
305
+ readonly environment: Record<string, string>;
306
+ readonly secretsImport: Record<string, SecretImport>;
307
+ } | undefined;
297
308
  /**
298
309
  * Migration contributions for a task connecting to this database via
299
310
  * `service.connections:`. Returns env (host/port/name + snapshot identifiers
@@ -12,6 +12,7 @@ import { Port } from "aws-cdk-lib/aws-ec2";
12
12
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
13
13
  import { MIGRATION_SNAPSHOT_NAME_PREFIX } from "./interfaces/database.js";
14
14
  import { pickLatestPrismaMigration } from "../../utils/migrationVersionResolvers.js";
15
+ import { SCHEMA_GATE_DB_PASSWORD_ENV, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_DB_USER_ENV } from "@fjall/util/migration";
15
16
  import { DatabaseClusterEngine, DatabaseInstanceEngine, AuroraPostgresEngineVersion, AuroraMysqlEngineVersion, PostgresEngineVersion, MysqlEngineVersion } from "aws-cdk-lib/aws-rds";
16
17
  import { Duration } from "aws-cdk-lib";
17
18
  export { isAwsManagedKey, isCMKRequested, AWS_MANAGED, USE_CMK } from "../../utils/databaseTypes.js";
@@ -389,6 +390,7 @@ export class RelationalDatabase extends Construct {
389
390
  port: props.port,
390
391
  databaseInsights: props.databaseInsights,
391
392
  deletionProtection: props.deletionProtection,
393
+ removalPolicy: props.removalPolicy,
392
394
  snapshotIdentifier: props.snapshotIdentifier,
393
395
  snapshotUsername: props.snapshotUsername,
394
396
  allowVpcAccess: props.allowVpcAccess,
@@ -567,6 +569,20 @@ export class RelationalDatabase extends Construct {
567
569
  }
568
570
  return version;
569
571
  }
572
+ getSchemaGateContribution() {
573
+ if (this.migrationsConfig === undefined)
574
+ return undefined;
575
+ const credentials = this.getCredentials();
576
+ return {
577
+ environment: {
578
+ [SCHEMA_GATE_DB_URL_BASE_ENV]: this.getConnectionString()
579
+ },
580
+ secretsImport: {
581
+ [SCHEMA_GATE_DB_USER_ENV]: credentials.getImport("username"),
582
+ [SCHEMA_GATE_DB_PASSWORD_ENV]: credentials.getImport("password")
583
+ }
584
+ };
585
+ }
570
586
  /**
571
587
  * Migration contributions for a task connecting to this database via
572
588
  * `service.connections:`. Returns env (host/port/name + snapshot identifiers
@@ -1,4 +1,4 @@
1
- import { CfnOutput, Duration, Stack, Tags } from "aws-cdk-lib";
1
+ import { CfnOutput, Duration, RemovalPolicy, Stack, Tags } from "aws-cdk-lib";
2
2
  import { Port } from "aws-cdk-lib/aws-ec2";
3
3
  import { Cluster } from "aws-cdk-lib/aws-ecs";
4
4
  import { Repository, TagMutability, TagStatus } from "aws-cdk-lib/aws-ecr";
@@ -164,7 +164,12 @@ export class DevSubstrate extends Construct {
164
164
  serverlessV2MinCapacity: DEV_AURORA_DEFAULTS.SERVERLESS_V2_MIN_CAPACITY,
165
165
  serverlessV2MaxCapacity: DEV_AURORA_DEFAULTS.SERVERLESS_V2_MAX_CAPACITY,
166
166
  serverlessV2AutoPauseDuration: Duration.seconds(DEV_AURORA_DEFAULTS.AUTO_PAUSE_SECONDS),
167
- enableDataApi: DEV_AURORA_DEFAULTS.ENABLE_DATA_API
167
+ enableDataApi: DEV_AURORA_DEFAULTS.ENABLE_DATA_API,
168
+ // Disposable-data cluster: branch databases are re-seedable from the
169
+ // owning app, so an explicit substrate destroy must not strand a
170
+ // protected cluster or a final snapshot behind it.
171
+ deletionProtection: false,
172
+ removalPolicy: RemovalPolicy.DESTROY
168
173
  }));
169
174
  this.#tagFenced(database);
170
175
  return database;
@@ -177,7 +182,11 @@ export class DevSubstrate extends Construct {
177
182
  const dropBucket = this.app.addStorage(StorageFactory.build(DEV_SUBSTRATE_DROP_ID, {
178
183
  stackPlacement: "compute",
179
184
  versioned: true,
180
- encryption: "AES256"
185
+ encryption: "AES256",
186
+ // Pinned rather than env-resolved: the pre-empty marker tag must be
187
+ // present for deploy-core to empty the drop on explicit destroy, and
188
+ // sync drops are transient by design.
189
+ removalPolicy: "DESTROY"
181
190
  }));
182
191
  this.#tagFenced(dropBucket);
183
192
  const eventQueue = this.app.addMessaging(MessagingFactory.build(DEV_SUBSTRATE_EVENTS_ID, {
@@ -242,14 +251,23 @@ export class DevSubstrate extends Construct {
242
251
  // Adopt-mode is an import only — no CFN resource, so it carries no fence tags
243
252
  // (nor mutability convergence — a pre-BUG-22 adopted repo keeps IMMUTABLE
244
253
  // until flipped out-of-band).
245
- const slotRepository = props.adoptSlotEcr === true
246
- ? Repository.fromRepositoryName(this, slotEcrId, slotEcrRepositoryName)
247
- : new Ecr(this, slotEcrId, {
254
+ let slotRepository;
255
+ if (props.adoptSlotEcr === true) {
256
+ slotRepository = Repository.fromRepositoryName(this, slotEcrId, slotEcrRepositoryName);
257
+ this.#registerAdoptedEcrResidual(slotEcrId, slotEcrRepositoryName);
258
+ }
259
+ else {
260
+ slotRepository = new Ecr(this, slotEcrId, {
248
261
  repositoryName: slotEcrRepositoryName,
249
262
  // Every `fjall dev up` repushes the branch tag; IMMUTABLE breaks the
250
263
  // first same-branch recreate (BUG-22).
251
- tagMutability: TagMutability.MUTABLE
264
+ tagMutability: TagMutability.MUTABLE,
265
+ // Pinned rather than env-resolved: slot images are rebuildable, and
266
+ // an explicit substrate destroy must not strand the repo (BUG-5's
267
+ // orphan-repo wedge on rollback).
268
+ removalPolicy: "DESTROY"
252
269
  });
270
+ }
253
271
  // Buildx registry cache for slot builds, named by the same helper as the app
254
272
  // path's `<repo>-cache` so `fjall dev up` derives this URI from the slot repo's
255
273
  // without a second wire field. Adopt is gated on its OWN `adoptCacheEcr` flag,
@@ -259,12 +277,16 @@ export class DevSubstrate extends Construct {
259
277
  const slotCacheRepositoryName = buildCacheRepositoryName(slotEcrRepositoryName);
260
278
  if (props.adoptCacheEcr === true) {
261
279
  Repository.fromRepositoryName(this, `${slotEcrId}Cache`, slotCacheRepositoryName);
280
+ this.#registerAdoptedEcrResidual(`${slotEcrId}Cache`, slotCacheRepositoryName);
262
281
  }
263
282
  else {
264
283
  const slotCacheRepository = new Ecr(this, `${slotEcrId}Cache`, {
265
284
  repositoryName: slotCacheRepositoryName,
266
285
  // buildx overwrites the per-repo cache manifest tag on every export.
267
- tagMutability: TagMutability.MUTABLE
286
+ tagMutability: TagMutability.MUTABLE,
287
+ // Rebuildable cache blobs; pinned so an explicit destroy reclaims the
288
+ // repo instead of stranding it behind env resolution.
289
+ removalPolicy: "DESTROY"
268
290
  });
269
291
  // Each `mode=max` export orphans the previous manifest's blobs. The
270
292
  // dev-deploy role holds no BatchDeleteImage or PutLifecyclePolicy, so this
@@ -365,6 +387,19 @@ export class DevSubstrate extends Construct {
365
387
  // the global `<zone>-hosted-zone-id` export, colliding with the owning
366
388
  // Domain stack's.
367
389
  childZone = PublicHostedZone.fromHostedZoneAttributes(this, DEV_SUBSTRATE_ZONE_ID, { hostedZoneId, zoneName: childZoneName });
390
+ this.app.getManifestCollector().addResidual({
391
+ constructPath: `${this.node.path}/${DEV_SUBSTRATE_ZONE_ID}`,
392
+ resourceType: "AWS::Route53::HostedZone",
393
+ physicalNameHint: childZoneName,
394
+ policy: "adopted",
395
+ destroyDisposition: "survives",
396
+ reason: "Adopted hosted zone owned by the account Domain deploy — the " +
397
+ "substrate destroy leaves the zone, its parent NS delegation, and " +
398
+ "any ACM validation CNAMEs in place.",
399
+ costClass: "fixed-monthly",
400
+ cleanupHint: "Zone is shared with the owning Domain stack; remove it there if " +
401
+ "the domain itself is being retired."
402
+ });
368
403
  }
369
404
  else {
370
405
  // Create mode: createDelegationRole:false because the PARENT account owns
@@ -478,4 +513,25 @@ export class DevSubstrate extends Construct {
478
513
  Tags.of(scope).add(DEV_TAG_KEY, DEV_TAG_VALUE);
479
514
  Tags.of(scope).add(DEV_APP_TAG_KEY, this.appId);
480
515
  }
516
+ /**
517
+ * Adopted ECR repos are imports — CFN cannot delete what it never created,
518
+ * so an explicit destroy removes them SDK-side (the worker's post-CFN
519
+ * DeleteRepository sweep), hence `deleted-on-explicit-destroy`.
520
+ */
521
+ #registerAdoptedEcrResidual(constructId, repositoryName) {
522
+ this.app.getManifestCollector().addResidual({
523
+ constructPath: `${this.node.path}/${constructId}`,
524
+ resourceType: "AWS::ECR::Repository",
525
+ physicalNameHint: repositoryName,
526
+ policy: "adopted",
527
+ destroyDisposition: "deleted-on-explicit-destroy",
528
+ reason: "Adopted repository predating this substrate deploy — outside the " +
529
+ "stack, so CloudFormation cannot delete it; the destroy worker " +
530
+ "removes it SDK-side.",
531
+ costClass: "storage",
532
+ cleanupHint: `If the destroy report shows this repository was not removed, delete ` +
533
+ `it manually: aws ecr delete-repository --repository-name ` +
534
+ `${repositoryName} --force`
535
+ });
536
+ }
481
537
  }
@@ -147,6 +147,25 @@ export interface IRelationalDatabaseBase extends IDatabase, IConnectable, IMigra
147
147
  * — fail loud, never silent.
148
148
  */
149
149
  getExpectedSchemaVersion(): string | undefined;
150
+ /**
151
+ * Connection env + credential imports for the synthetic
152
+ * `fjall-schema-gate` container (the materialised boot gate). Returns the
153
+ * credential-free connection URL (`SCHEMA_GATE_DB_URL_BASE_ENV`) plus
154
+ * username/password Secrets-Manager imports, or `undefined` when no
155
+ * `migrations:` config is declared — without migrations there is nothing
156
+ * for the gate to verify. Relational mirror of
157
+ * `IClickHouseDatabase.getSchemaGateContribution()`.
158
+ *
159
+ * Distinct from `getMigrationContributions()`: that is the per-migration
160
+ * task contract (env + secrets + IAM + egress for running migrations);
161
+ * this is the narrower contract the gate container needs to READ the
162
+ * migrations metadata table. Injected ONLY into the gate container —
163
+ * app containers keep their own connection wiring.
164
+ */
165
+ getSchemaGateContribution(): {
166
+ readonly environment: Record<string, string>;
167
+ readonly secretsImport: Record<string, SecretImport>;
168
+ } | undefined;
150
169
  /**
151
170
  * Grant connect permissions to a grantee.
152
171
  * This adds the grantee to the database security group.
@@ -319,6 +338,13 @@ export interface IClickHouseDatabase extends IDatabase, IConnectable, IMigration
319
338
  readonly environment: Record<string, string>;
320
339
  readonly secretsImport: Record<string, SecretImport>;
321
340
  } | undefined;
341
+ /**
342
+ * SecretImport for the cluster's CA certificate (raw PEM, no JSON field),
343
+ * or `undefined` when TLS is disabled (`tls: { mode: "none", … }`).
344
+ * Consumers wire it under `CLICKHOUSE_CA_CERT` so
345
+ * `createFjallClickHouseClient()` can verify the chain strictly.
346
+ */
347
+ getTlsCaCertImport(): SecretImport | undefined;
322
348
  /**
323
349
  * Grant connect permissions to a grantee.
324
350
  * Adds the grantee to the database security group on both ports.
@@ -40,6 +40,13 @@ export interface S3Props {
40
40
  readonly deployment?: S3DeploymentConfig;
41
41
  /** When true, sets RemovalPolicy.RETAIN (overriding the env-aware default). Used for imported buckets. */
42
42
  readonly retain?: boolean;
43
+ /**
44
+ * Explicit removal policy, overriding both the env-aware default and the
45
+ * legacy `retain` flag. DESTROY pins the pre-empty marker tag so deploy-core
46
+ * empties the bucket on explicit destroy regardless of environment
47
+ * resolution — required for disposable-data buckets (dev substrates).
48
+ */
49
+ readonly removalPolicy?: "DESTROY" | "RETAIN";
43
50
  /** Declarative bucket-policy statements appended to the bucket's resource policy. */
44
51
  readonly resourcePolicyStatements?: ResourcePolicyStatement[];
45
52
  }
@@ -38,6 +38,18 @@ function toCorsRules(cors) {
38
38
  ...(rule.maxAge !== undefined && { maxAge: rule.maxAge })
39
39
  }));
40
40
  }
41
+ /**
42
+ * Explicit `removalPolicy` wins over the legacy `retain` flag; when neither is
43
+ * set, returns undefined so {@link S3Bucket} applies its env-aware default.
44
+ */
45
+ function resolveStorageRemovalPolicy(props) {
46
+ if (props.removalPolicy !== undefined) {
47
+ return props.removalPolicy === "DESTROY"
48
+ ? RemovalPolicy.DESTROY
49
+ : RemovalPolicy.RETAIN;
50
+ }
51
+ return props.retain ? RemovalPolicy.RETAIN : undefined;
52
+ }
41
53
  export function validateStorageProps(props) {
42
54
  if (props.encryption === "KMS" && !props.kmsKeyArn) {
43
55
  FjallLogger.warn("'encryption' is set to 'KMS' but 'kmsKeyArn' is not provided. " +
@@ -58,6 +70,7 @@ export class Storage extends Construct {
58
70
  const encryptionKey = props.kmsKeyArn
59
71
  ? Key.fromKeyArn(this, `${id}KmsKey`, props.kmsKeyArn)
60
72
  : undefined;
73
+ const removalPolicy = resolveStorageRemovalPolicy(props);
61
74
  this.bucket = new S3Bucket(this, `${id}Bucket`, {
62
75
  bucketName: props.bucketName,
63
76
  // The bucket nests one level below, so its own id is `${id}Bucket` — the
@@ -71,7 +84,7 @@ export class Storage extends Construct {
71
84
  websiteHosting: props.websiteHosting,
72
85
  resourcePolicyStatements: props.resourcePolicyStatements,
73
86
  ...(props.cors && { cors: toCorsRules(props.cors) }),
74
- ...(props.retain && { removalPolicy: RemovalPolicy.RETAIN })
87
+ ...(removalPolicy !== undefined && { removalPolicy })
75
88
  });
76
89
  if (props.deployment) {
77
90
  this.bucketDeployment = this.createDeployment(id, props.deployment);
@@ -1,5 +1,7 @@
1
+ import { Duration } from "aws-cdk-lib";
1
2
  import { ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
3
  import { SubnetType } from "aws-cdk-lib/aws-ec2";
4
+ import { DEFAULT_ALB_IDLE_TIMEOUT_SECONDS } from "@fjall/util/httpKeepAlive";
3
5
  /**
4
6
  * Context-free Application Load Balancer factory shared by the ECS cluster path
5
7
  * and the dev substrate. Subnet placement is derived from `internetFacing`
@@ -10,6 +12,10 @@ export function createApplicationLoadBalancer(scope, id, options) {
10
12
  return new ApplicationLoadBalancer(scope, id, {
11
13
  vpc: options.vpc,
12
14
  internetFacing: options.internetFacing,
15
+ // Pinned (rather than left at the AWS default of the same value) because
16
+ // ALB-fronted containers receive it as FJALL_ALB_IDLE_TIMEOUT_SECONDS
17
+ // (ecsTaskDefinition.ts) for keep-alive tuning — one constant, both sides.
18
+ idleTimeout: Duration.seconds(DEFAULT_ALB_IDLE_TIMEOUT_SECONDS),
13
19
  ...(options.securityGroup && { securityGroup: options.securityGroup }),
14
20
  ...(options.loadBalancerName && {
15
21
  loadBalancerName: options.loadBalancerName
@@ -89,6 +89,18 @@ export default class EcsCluster extends Construct implements IConnectable {
89
89
  registerScheduledTaskDefinition(name: string, taskDefinition: TaskDefinition): void;
90
90
  /** Get the ECS cluster construct. */
91
91
  getCluster(): CdkCluster;
92
+ /**
93
+ * Connectable scoped to the cluster's EC2 capacity security groups alone.
94
+ * Scheduled tasks run on the cluster's instances (bridge/host networking),
95
+ * so their network reachability is the instance security groups' — NOT the
96
+ * per-service groups that `this.connections` aggregates; using the
97
+ * aggregate would grant every service's group ingress on the peer. All
98
+ * capacity slots' groups are included because the EventBridge EcsTask
99
+ * target places tasks unconstrained across capacity providers — a task can
100
+ * land on any slot's instances. Returns `undefined` for clusters with no
101
+ * EC2 capacity.
102
+ */
103
+ getEc2CapacityConnectable(): IConnectable | undefined;
92
104
  /**
93
105
  * Get the EC2 instance role for the cluster's ASG (D10). Returns undefined
94
106
  * for Fargate-only clusters. Pulled from the shared ASG capacity state.
@@ -5,7 +5,7 @@ import { CfnOutput, Aspects } from "aws-cdk-lib";
5
5
  import { processConnections } from "../../../utils/connections.js";
6
6
  import { stackScopedExportName } from "../../../utils/exportNaming.js";
7
7
  import { toPascalCase } from "../../../utils/capitaliseString.js";
8
- import { createEcsServiceAlarms, createLogPatternAlarms } from "../monitoring/index.js";
8
+ import { createEcsServiceAlarms, createEcsTaskStopWatchdog, createLogPatternAlarms } from "../monitoring/index.js";
9
9
  // Extracted modules
10
10
  import { CapacityProviderDependencyAspect } from "./ecsCapacityProviderAspect.js";
11
11
  import { validateEcsClusterProps } from "./ecsValidation.js";
@@ -69,7 +69,8 @@ export default class EcsCluster extends Construct {
69
69
  providers: new Map(),
70
70
  asgOrigins: new Map(),
71
71
  autoScalingGroup: undefined,
72
- asgSecurityGroup: undefined
72
+ asgSecurityGroup: undefined,
73
+ asgSecurityGroups: []
73
74
  };
74
75
  services = new Map();
75
76
  scheduledTaskDefinitions = new Map();
@@ -119,6 +120,18 @@ export default class EcsCluster extends Construct {
119
120
  for (const serviceProps of props.services) {
120
121
  this.addServiceToCluster(serviceProps);
121
122
  }
123
+ if (props.taskStopWatchdog !== false) {
124
+ createEcsTaskStopWatchdog({
125
+ scope: this,
126
+ clusterName: props.clusterName,
127
+ cluster: this.cluster,
128
+ serviceNames: props.services
129
+ .filter((s) => s.alarms !== false)
130
+ .map((s) => s.name),
131
+ alertsTopic: props.alertsTopic,
132
+ applicationId: props.applicationId
133
+ });
134
+ }
122
135
  this.addDeployableServiceOutputs(props);
123
136
  this.connections = this.setupConnections(props);
124
137
  Aspects.of(this).add(new CapacityProviderDependencyAspect(this.cluster));
@@ -182,6 +195,23 @@ export default class EcsCluster extends Construct {
182
195
  getCluster() {
183
196
  return this.cluster;
184
197
  }
198
+ /**
199
+ * Connectable scoped to the cluster's EC2 capacity security groups alone.
200
+ * Scheduled tasks run on the cluster's instances (bridge/host networking),
201
+ * so their network reachability is the instance security groups' — NOT the
202
+ * per-service groups that `this.connections` aggregates; using the
203
+ * aggregate would grant every service's group ingress on the peer. All
204
+ * capacity slots' groups are included because the EventBridge EcsTask
205
+ * target places tasks unconstrained across capacity providers — a task can
206
+ * land on any slot's instances. Returns `undefined` for clusters with no
207
+ * EC2 capacity.
208
+ */
209
+ getEc2CapacityConnectable() {
210
+ const groups = this.asgState.asgSecurityGroups;
211
+ if (groups.length === 0)
212
+ return undefined;
213
+ return { connections: new Connections({ securityGroups: [...groups] }) };
214
+ }
185
215
  /**
186
216
  * Get the EC2 instance role for the cluster's ASG (D10). Returns undefined
187
217
  * for Fargate-only clusters. Pulled from the shared ASG capacity state.
@@ -3,6 +3,10 @@ import { Repository } from "aws-cdk-lib/aws-ecr";
3
3
  import { DEFAULT_ECS_FALLBACK_IMAGE } from "./ecsConstants.js";
4
4
  import { getOrCreateImageTagParameter } from "./imageTagParameter.js";
5
5
  export function getContainerImage(ctx, serviceName, containerConfig, serviceProps) {
6
+ if (containerConfig.verbatimImage === true &&
7
+ typeof containerConfig.image === "string") {
8
+ return ContainerImage.fromRegistry(containerConfig.image);
9
+ }
6
10
  const imageSource = containerConfig.image || serviceProps.image || ctx.props.ecrRepository;
7
11
  if (!imageSource) {
8
12
  return ContainerImage.fromRegistry(DEFAULT_ECS_FALLBACK_IMAGE);