@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
|
@@ -117,7 +117,10 @@ export interface EcsContainerConfig {
|
|
|
117
117
|
essential?: boolean;
|
|
118
118
|
/**
|
|
119
119
|
* Health check configuration.
|
|
120
|
-
* Default:
|
|
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
|
|
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
|
|
700
|
-
*
|
|
701
|
-
*
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
...(
|
|
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);
|