@fjall/components-infrastructure 39.0.8 → 39.0.10

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.
@@ -3,10 +3,10 @@ import { type Construct } from "constructs";
3
3
  import { type SecretImport } from "../../resources/aws/secrets/index.js";
4
4
  import { type ComputeType, type IEcsCompute, type ILambdaCompute, type IEc2Compute, type AnyCompute, isCompute, isEcsCompute, isLambdaCompute, isEc2Compute } from "./interfaces/compute.js";
5
5
  import type App from "../../app.js";
6
- import { EcsCompute, type EcsComputeProps, type EcsServiceConfig, type ServiceLogAlarm, type EcsContainerConfig, type EcsScalingConfig, type EcsClusterConfig, type EcsRoutingConfig, type EcsCapacityProviderConfig, type ContainerDependency, type EcsMigrationsConfig, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, type EcsCapacityProvider, type Ec2CapacityConfig, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, type ResolvedScalingConfig, resolveScalingConfig } from "./computeEcs.js";
6
+ import { EcsCompute, type EcsComputeProps, type EcsServiceConfig, type ServiceLogAlarm, type EcsContainerConfig, type EcsScalingConfig, type EcsClusterConfig, type EcsRoutingConfig, type EcsTargetGroupHealthCheck, type EcsCapacityProviderConfig, type ContainerDependency, type EcsMigrationsConfig, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, type EcsCapacityProvider, type Ec2CapacityConfig, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, type ResolvedScalingConfig, resolveScalingConfig } from "./computeEcs.js";
7
7
  import { LambdaCompute, type LambdaComputeProps, type ContainerLambdaProps, type CodeLambdaProps, type FunctionUrlConfig, type ResolvedLambdaDeployment, resolveLambdaDeployment, lambdaImageKey, Architecture, HttpMethod, InvokeMode, type FunctionUrlCorsOptions } from "./computeLambda.js";
8
8
  import { Ec2Compute, type Ec2ComputeProps } from "./computeEc2.js";
9
- export { EcsCompute, type EcsComputeProps, type EcsServiceConfig, type ServiceLogAlarm, type EcsContainerConfig, type EcsScalingConfig, type EcsClusterConfig, type EcsRoutingConfig, type EcsCapacityProviderConfig, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, type EcsCapacityProvider, type Ec2CapacityConfig, type ContainerDependency, type EcsMigrationsConfig, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, type ResolvedScalingConfig, resolveScalingConfig, LambdaCompute, type LambdaComputeProps, type ContainerLambdaProps, type CodeLambdaProps, type FunctionUrlConfig, type ResolvedLambdaDeployment, resolveLambdaDeployment, lambdaImageKey, Architecture, HttpMethod, InvokeMode, type FunctionUrlCorsOptions, Ec2Compute, type Ec2ComputeProps };
9
+ export { EcsCompute, type EcsComputeProps, type EcsServiceConfig, type ServiceLogAlarm, type EcsContainerConfig, type EcsScalingConfig, type EcsClusterConfig, type EcsRoutingConfig, type EcsTargetGroupHealthCheck, type EcsCapacityProviderConfig, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, type EcsCapacityProvider, type Ec2CapacityConfig, type ContainerDependency, type EcsMigrationsConfig, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, type ResolvedScalingConfig, resolveScalingConfig, LambdaCompute, type LambdaComputeProps, type ContainerLambdaProps, type CodeLambdaProps, type FunctionUrlConfig, type ResolvedLambdaDeployment, resolveLambdaDeployment, lambdaImageKey, Architecture, HttpMethod, InvokeMode, type FunctionUrlCorsOptions, Ec2Compute, type Ec2ComputeProps };
10
10
  export type { ComputeType } from "./interfaces/compute.js";
11
11
  /**
12
12
  * Configuration defaults for each compute type.
@@ -7,7 +7,7 @@ import { type IEcsCompute } from "./interfaces/compute.js";
7
7
  import { type SecretImport } from "../../resources/aws/secrets/index.js";
8
8
  import EcsCluster, { type EcsClusterProps, type EcsIngressProfile } from "../../resources/aws/compute/ecs.js";
9
9
  export { ScalingType } from "./computeEcsTypes.js";
10
- export type { EcsCapacityProvider, Ec2CapacityConfig, RemoteConnectionSpec, EcsCapacityProviderConfig, EcsContainerConfig, ContainerDependency, ContainerVolume, EcsScheduledTaskConfig, EcsLifecycleHookMigrationsConfig, EcsPostDeployMigrationsConfig, EcsHookMigrationsConfig, EcsMigrationsConfig, EcsMigrationsMode, EcsCircuitBreakerConfig, EcsScalingConfig, EcsClusterConfig, EcsRoutingConfig, EcsServiceConfig, ServiceLogAlarm, EcsComputeProps } from "./computeEcsTypes.js";
10
+ export type { EcsCapacityProvider, Ec2CapacityConfig, RemoteConnectionSpec, EcsCapacityProviderConfig, EcsContainerConfig, ContainerDependency, ContainerVolume, EcsScheduledTaskConfig, EcsLifecycleHookMigrationsConfig, EcsPostDeployMigrationsConfig, EcsHookMigrationsConfig, EcsMigrationsConfig, EcsMigrationsMode, EcsCircuitBreakerConfig, EcsScalingConfig, EcsClusterConfig, EcsRoutingConfig, EcsTargetGroupHealthCheck, EcsServiceConfig, ServiceLogAlarm, EcsComputeProps } from "./computeEcsTypes.js";
11
11
  import { ScalingType, type EcsCapacityProviderConfig, type EcsCapacityProvider, type EcsContainerConfig, type EcsScalingConfig, type EcsServiceConfig, type EcsComputeProps, type QueueScalingConfig } from "./computeEcsTypes.js";
12
12
  export declare const ECS_CAPACITY_PROVIDER_CONFIG: Record<EcsCapacityProvider, EcsCapacityProviderConfig>;
13
13
  export declare function getEcsCapacityProviderConfig(provider: EcsCapacityProvider): EcsCapacityProviderConfig;
@@ -28,8 +28,9 @@ import { evaluateBakeGuard } from "@fjall/util/docker";
28
28
  import { toKebab, buildParameterPath } from "@fjall/util";
29
29
  import { MIGRATION_SECURITY_GROUPS_TAG_KEY, SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_ECR_REPO_NAME, SCHEMA_GATE_START_TIMEOUT_SECONDS } from "@fjall/util/migration";
30
30
  import { CONSTRUCTS_VERSION } from "../../utils/engineCompat.js";
31
- import { validateEc2ServiceSizing, validateEc2TaskMemoryFit, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
31
+ import { clusterHasNoLoadBalancer, validateEc2ServiceSizing, validateEc2TaskMemoryFit, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
32
32
  import { validateSharedEc2CapacityConfig } from "../../resources/aws/compute/ecsCapacityConfig.js";
33
+ import { validateTargetGroupSettings } from "../../resources/aws/compute/ecsTargetGroup.js";
33
34
  import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
34
35
  import { isHookMigrations, normaliseSchemaGate } from "./computeEcsTypes.js";
35
36
  export { ScalingType } from "./computeEcsTypes.js";
@@ -196,6 +197,7 @@ export function validateEcsProps(props) {
196
197
  throw new Error(`Service '${service.name}': circuitBreaker.threshold must be a positive integer (got ${threshold}).`);
197
198
  }
198
199
  }
200
+ validateTargetGroupSettings(service, clusterHasNoLoadBalancer(props.cluster));
199
201
  if (service.deployment !== undefined) {
200
202
  const min = service.deployment.minHealthyPercent;
201
203
  const max = service.deployment.maxHealthyPercent;
@@ -1007,6 +1009,12 @@ export class EcsCompute extends Construct {
1007
1009
  maxCapacity,
1008
1010
  ...(queueScaling !== undefined && { queueScaling }),
1009
1011
  routing: service.routing,
1012
+ ...(service.healthCheck !== undefined && {
1013
+ healthCheck: service.healthCheck
1014
+ }),
1015
+ ...(service.deregistrationDelay !== undefined && {
1016
+ deregistrationDelay: service.deregistrationDelay
1017
+ }),
1010
1018
  taskRoleInlinePolicies: service.taskRoleInlinePolicies,
1011
1019
  taskRoleManagedPolicies: service.taskRoleManagedPolicies,
1012
1020
  connections: service.connections,
@@ -7,7 +7,8 @@ import type { ITopic } from "aws-cdk-lib/aws-sns";
7
7
  import { type ConnectionSpec } from "./interfaces/connector.js";
8
8
  import type { BackupTier } from "../../utils/backupTierMapping.js";
9
9
  import { type RemoteConnectionSpec } from "../../resources/aws/compute/ecsRemoteConnections.js";
10
- import { type EcsRoutingConfig, type EcsContainerDependency } from "../../resources/aws/compute/ecsTypes.js";
10
+ import type { Duration } from "aws-cdk-lib";
11
+ import { type EcsRoutingConfig, type EcsContainerDependency, type EcsTargetGroupHealthCheck } from "../../resources/aws/compute/ecsTypes.js";
11
12
  import { ScalingType, type DomainConfig, type EcsCapacityProvider, type Ec2CapacityConfig, type QueueScalingConfig } from "../../resources/aws/compute/ecs.js";
12
13
  import type { EcsServiceAlarmThresholds, LogPatternAlarmSpec } from "../../resources/aws/monitoring/index.js";
13
14
  import { type SecretImport } from "../../resources/aws/secrets/index.js";
@@ -666,7 +667,7 @@ export interface EcsClusterConfig {
666
667
  */
667
668
  scheduledTasks?: EcsScheduledTaskConfig[];
668
669
  }
669
- export type { EcsRoutingConfig };
670
+ export type { EcsRoutingConfig, EcsTargetGroupHealthCheck };
670
671
  /**
671
672
  * Configuration for a service in an ECS cluster.
672
673
  * Each service gets its own task definition, scaling config, and target group.
@@ -739,6 +740,40 @@ export interface EcsServiceConfig {
739
740
  * ]
740
741
  */
741
742
  routing?: EcsRoutingConfig | EcsRoutingConfig[];
743
+ /**
744
+ * Health check of this service's target group on the cluster's load
745
+ * balancer, in CDK's `HealthCheck` names. Each key left out takes its launch
746
+ * type's default: on EC2, `interval` 30 s, `timeout` 15 s,
747
+ * `healthyThresholdCount` 3 and `unhealthyThresholdCount` 3; on Fargate,
748
+ * `interval` 120 s and `timeout` 10 s, with both thresholds left to Elastic
749
+ * Load Balancing (5 and 2). `path` defaults to the routing rule's
750
+ * `healthCheckPath` or `/`. Refused at synth on a service with no target
751
+ * group (no container port, or a cluster without a load balancer), outside
752
+ * Elastic Load Balancing's ranges, with a `timeout` no shorter than the
753
+ * `interval`, and with an `interval` above 120 seconds: a rollout waits up
754
+ * to one interval for each new task's first check, and
755
+ * `fjall deploy --deploy-only`, `fjall rollback` and `fjall rollout` wait a
756
+ * fixed time sized for no more. Of its keys only `path` is refused beside a
757
+ * routing `healthCheckPath`, which sets the same value; the others may be
758
+ * set beside one.
759
+ *
760
+ * @example
761
+ * healthCheck: { path: "/healthz", interval: Duration.seconds(10), timeout: Duration.seconds(5) }
762
+ */
763
+ healthCheck?: EcsTargetGroupHealthCheck;
764
+ /**
765
+ * How long the target group drains a task it deregisters before ECS stops
766
+ * it. Default: none set, so Elastic Load Balancing's 300 seconds applies.
767
+ * ECS sends the stop signal only after the drain (verified 2026-10-15
768
+ * against the ECS developer guide), so a container's `stopTimeout` starts
769
+ * once the drain has ended, and the drain should cover the longest request
770
+ * the service serves. Lower it for a service whose requests are short: a
771
+ * rollout waits out each old task's drain, and `fjall deploy --deploy-only`,
772
+ * `fjall rollback` and `fjall rollout` wait a fixed time sized for no more
773
+ * than 300 seconds. Refused at synth above 300 seconds, on a service with no
774
+ * target group, and when it is not a whole number of seconds.
775
+ */
776
+ deregistrationDelay?: Duration;
742
777
  /**
743
778
  * CPU units for this service's tasks (256-4096). Fargate only — EC2 task
744
779
  * definitions carry no task-level CPU reservation (CPU is bounded by the
@@ -13,7 +13,7 @@ import { toPascalCase } from "../../../utils/capitaliseString.js";
13
13
  import { createEcsServiceAlarms, createEcsTaskStopWatchdog, createLogPatternAlarms } from "../monitoring/index.js";
14
14
  // Extracted modules
15
15
  import { CapacityProviderDependencyAspect } from "./ecsCapacityProviderAspect.js";
16
- import { validateEcsClusterProps } from "./ecsValidation.js";
16
+ import { clusterHasNoLoadBalancer, validateEcsClusterProps } from "./ecsValidation.js";
17
17
  import { createExecutionRole, createTaskRole, createTaskDefinition, addContainersToTask, isServiceFargate, isServiceEc2 } from "./ecsTaskDefinition.js";
18
18
  import { addLoadBalancer, addLoadBalancerListener, addHostedZone, addDirectAccessOutputs, addRedirectHostRules, registerServiceWithALB } from "./ecsNetworking.js";
19
19
  import { createService, addServiceScaling, getOrCreateAsgCapacityProvider } from "./ecsServiceFactory.js";
@@ -110,8 +110,7 @@ export default class EcsCluster extends Construct {
110
110
  // Sanitise cluster name for CloudFormation output keys (must be alphanumeric)
111
111
  this.outputName = toPascalCase(props.clusterName);
112
112
  this.directAccessEnabled = props.cluster?.directAccess === true;
113
- this.loadBalancerDisabled =
114
- props.cluster?.loadBalancer === false || this.directAccessEnabled;
113
+ this.loadBalancerDisabled = clusterHasNoLoadBalancer(props.cluster);
115
114
  // Tier-owned defaults resolve exactly once, here; every helper reads the
116
115
  // resolved value from ctx. An explicit prop is the override.
117
116
  const tier = props.appBackupTier;
@@ -1,11 +1,12 @@
1
1
  import { ApplicationProtocol, ListenerAction, ListenerCertificate, ListenerCondition } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
2
  import { Port } from "aws-cdk-lib/aws-ec2";
3
- import { CfnOutput, Duration, Fn, Stack, Token } from "aws-cdk-lib";
3
+ import { CfnOutput, Fn, Stack, Token } from "aws-cdk-lib";
4
4
  import { createHash } from "node:crypto";
5
5
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
6
6
  import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
7
7
  import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
8
8
  import { DomainCertificate } from "../networking/domainCertificate.js";
9
+ import { resolveTargetGroupSettings } from "./ecsTargetGroup.js";
9
10
  import { AliasRecord } from "../networking/dnsRecord/aliasRecord.js";
10
11
  import { SecurityGroup } from "../networking/securityGroup.js";
11
12
  import { isManagedDomainBinding, isWithinZone, recordLabelWithin } from "../../../utils/domainTypes.js";
@@ -570,22 +571,7 @@ export function registerServiceWithALB(ctx, listener, serviceName, serviceProps,
570
571
  "ports, which can never receive listener traffic.");
571
572
  }
572
573
  const { routingRules } = servicePlan;
573
- const healthCheckPath = routingRules.find((r) => r.healthCheckPath)?.healthCheckPath ?? "/";
574
- const healthCheckConfig = isServiceEc2(serviceProps)
575
- ? {
576
- interval: Duration.seconds(30),
577
- healthyThresholdCount: 3,
578
- unhealthyThresholdCount: 3,
579
- path: healthCheckPath,
580
- port: "traffic-port",
581
- timeout: Duration.seconds(15)
582
- }
583
- : {
584
- interval: Duration.seconds(120),
585
- path: healthCheckPath,
586
- port: `${containerPort}`,
587
- timeout: Duration.seconds(10)
588
- };
574
+ const { healthCheck: healthCheckConfig, deregistrationDelay } = resolveTargetGroupSettings(serviceProps, isServiceEc2(serviceProps) ? "traffic-port" : `${containerPort}`);
589
575
  if (servicePlan.mode === "default") {
590
576
  return listener.addTargets(`${serviceName}TargetGroup`, {
591
577
  targets: [
@@ -596,7 +582,8 @@ export function registerServiceWithALB(ctx, listener, serviceName, serviceProps,
596
582
  ],
597
583
  port: containerPort,
598
584
  protocol: ApplicationProtocol.HTTP,
599
- healthCheck: healthCheckConfig
585
+ healthCheck: healthCheckConfig,
586
+ ...(deregistrationDelay !== undefined && { deregistrationDelay })
600
587
  });
601
588
  }
602
589
  else {
@@ -614,6 +601,7 @@ export function registerServiceWithALB(ctx, listener, serviceName, serviceProps,
614
601
  port: containerPort,
615
602
  protocol: ApplicationProtocol.HTTP,
616
603
  healthCheck: healthCheckConfig,
604
+ ...(deregistrationDelay !== undefined && { deregistrationDelay }),
617
605
  conditions: buildRoutingConditions(firstRule),
618
606
  priority: firstPriority
619
607
  });
@@ -0,0 +1,55 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import type { HealthCheck } from "aws-cdk-lib/aws-elasticloadbalancingv2";
3
+ import type { EcsCapacityProvider, EcsRoutingConfig, EcsTargetGroupHealthCheck } from "./ecsTypes.js";
4
+ /**
5
+ * The fields of a service the target-group settings are judged and resolved
6
+ * from. Both layers' service shapes satisfy it, so the patterns layer's
7
+ * `EcsServiceConfig` and the resources layer's `EcsServiceProps` go through
8
+ * the same check.
9
+ */
10
+ interface TargetGroupSettingsSource {
11
+ readonly name: string;
12
+ readonly capacityProvider: EcsCapacityProvider;
13
+ readonly containers?: ReadonlyArray<{
14
+ readonly port?: number;
15
+ }>;
16
+ readonly routing?: EcsRoutingConfig | EcsRoutingConfig[];
17
+ readonly healthCheck?: EcsTargetGroupHealthCheck;
18
+ readonly deregistrationDelay?: Duration;
19
+ }
20
+ /**
21
+ * Refuses a service's target-group settings that would be silently ignored
22
+ * or that Elastic Load Balancing would refuse mid-deploy. Pure: the
23
+ * resources layer's `validateEcsClusterProps` calls it for every service, and
24
+ * the patterns layer's `validateEcsProps` calls it again.
25
+ *
26
+ * - A service with no container port, or on a cluster with no load balancer,
27
+ * has no target group, so either setting is refused.
28
+ * - `healthCheck.path` beside a routing rule's `healthCheckPath` is two
29
+ * sources for one value, so the pair is refused.
30
+ * - A timeout no shorter than the interval is refused, each key the service
31
+ * leaves out taking its launch type's default.
32
+ * - An `interval` or a drain longer than `ECS_TASK_REPLACEMENT_LIMITS` allows
33
+ * is refused, though Elastic Load Balancing would take it: a rollout waits
34
+ * for each new task's first check and each old task's drain, and the
35
+ * engine's own rollout waits are sized for those limits.
36
+ *
37
+ * No refusal judges the drain against a container's `stopTimeout`. ECS
38
+ * deregisters a task and waits out the drain before it sends the stop
39
+ * signal, and only then starts the `stopTimeout` wait for SIGKILL (verified
40
+ * 2026-10-15 against the ECS developer guide), so neither phase has to cover
41
+ * the other, and a drain shorter than `stopTimeout` is valid.
42
+ */
43
+ export declare function validateTargetGroupSettings(source: TargetGroupSettingsSource, loadBalancerDisabled: boolean): void;
44
+ /**
45
+ * The health check and drain a service's target group is built with: each
46
+ * key the service sets, and its launch type's default for every other. A
47
+ * threshold with no default is left out, as is the drain when the service
48
+ * sets none, so Elastic Load Balancing's own value applies. `port` is the
49
+ * launch type's (the container port on Fargate, `traffic-port` on EC2).
50
+ */
51
+ export declare function resolveTargetGroupSettings(source: TargetGroupSettingsSource, port: string): {
52
+ healthCheck: HealthCheck;
53
+ deregistrationDelay: Duration | undefined;
54
+ };
55
+ export {};
@@ -0,0 +1,161 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import { ECS_TASK_REPLACEMENT_LIMITS } from "@fjall/util";
3
+ const EC2_HEALTH_CHECK_DEFAULTS = {
4
+ launchType: "EC2",
5
+ intervalSeconds: 30,
6
+ timeoutSeconds: 15,
7
+ healthyThresholdCount: 3,
8
+ unhealthyThresholdCount: 3
9
+ };
10
+ const FARGATE_HEALTH_CHECK_DEFAULTS = {
11
+ launchType: "Fargate",
12
+ intervalSeconds: 120,
13
+ timeoutSeconds: 10
14
+ };
15
+ const HEALTH_CHECK_DEFAULTS = {
16
+ EC2: EC2_HEALTH_CHECK_DEFAULTS,
17
+ FARGATE: FARGATE_HEALTH_CHECK_DEFAULTS,
18
+ FARGATE_SPOT: FARGATE_HEALTH_CHECK_DEFAULTS
19
+ };
20
+ // Elastic Load Balancing's bounds for an application target group
21
+ // (CreateTargetGroup / ModifyTargetGroupAttributes), except the interval's
22
+ // and the drain's maxima: Elastic Load Balancing allows 300 s and 3600 s, and
23
+ // these stop at what Fjall's rollout waits are sized for.
24
+ const HEALTH_CHECK_INTERVAL_RANGE = {
25
+ min: 5,
26
+ max: ECS_TASK_REPLACEMENT_LIMITS.healthCheckIntervalSeconds
27
+ };
28
+ const HEALTH_CHECK_TIMEOUT_RANGE = { min: 2, max: 120 };
29
+ const THRESHOLD_COUNT_RANGE = { min: 2, max: 10 };
30
+ const DEREGISTRATION_DELAY_RANGE = {
31
+ min: 0,
32
+ max: ECS_TASK_REPLACEMENT_LIMITS.deregistrationDelaySeconds
33
+ };
34
+ const HEALTH_CHECK_PATH_MAX_LENGTH = 1024;
35
+ const ROLLOUT_WAIT_REASON = {
36
+ interval: " A rollout waits up to one interval for each new task's first check, and fjall deploy --deploy-only, " +
37
+ "fjall rollback and fjall rollout wait a fixed time sized for no more.",
38
+ deregistrationDelay: " A rollout waits out each old task's drain, and fjall deploy --deploy-only, fjall rollback and " +
39
+ "fjall rollout wait a fixed time sized for no more."
40
+ };
41
+ function routingHealthCheckPath(routing) {
42
+ const rules = Array.isArray(routing) ? routing : routing ? [routing] : [];
43
+ return rules.find((r) => r.healthCheckPath)?.healthCheckPath;
44
+ }
45
+ function wholeSeconds(serviceName, key, value) {
46
+ const seconds = value.toSeconds({ integral: false });
47
+ if (!Number.isInteger(seconds)) {
48
+ throw new Error(`Service '${serviceName}': ${key} must be a whole number of seconds.`);
49
+ }
50
+ return seconds;
51
+ }
52
+ function requireWithin(serviceName, key, value, range, unit, reason = "") {
53
+ if (value < range.min || value > range.max) {
54
+ throw new Error(`Service '${serviceName}': ${key} must be between ${range.min} and ${range.max}${unit} (got ${value}).${reason}`);
55
+ }
56
+ }
57
+ /**
58
+ * Refuses a service's target-group settings that would be silently ignored
59
+ * or that Elastic Load Balancing would refuse mid-deploy. Pure: the
60
+ * resources layer's `validateEcsClusterProps` calls it for every service, and
61
+ * the patterns layer's `validateEcsProps` calls it again.
62
+ *
63
+ * - A service with no container port, or on a cluster with no load balancer,
64
+ * has no target group, so either setting is refused.
65
+ * - `healthCheck.path` beside a routing rule's `healthCheckPath` is two
66
+ * sources for one value, so the pair is refused.
67
+ * - A timeout no shorter than the interval is refused, each key the service
68
+ * leaves out taking its launch type's default.
69
+ * - An `interval` or a drain longer than `ECS_TASK_REPLACEMENT_LIMITS` allows
70
+ * is refused, though Elastic Load Balancing would take it: a rollout waits
71
+ * for each new task's first check and each old task's drain, and the
72
+ * engine's own rollout waits are sized for those limits.
73
+ *
74
+ * No refusal judges the drain against a container's `stopTimeout`. ECS
75
+ * deregisters a task and waits out the drain before it sends the stop
76
+ * signal, and only then starts the `stopTimeout` wait for SIGKILL (verified
77
+ * 2026-10-15 against the ECS developer guide), so neither phase has to cover
78
+ * the other, and a drain shorter than `stopTimeout` is valid.
79
+ */
80
+ export function validateTargetGroupSettings(source, loadBalancerDisabled) {
81
+ const { name, healthCheck, deregistrationDelay } = source;
82
+ if (healthCheck === undefined && deregistrationDelay === undefined)
83
+ return;
84
+ const setting = healthCheck !== undefined ? "healthCheck" : "deregistrationDelay";
85
+ if (loadBalancerDisabled) {
86
+ throw new Error(`Service '${name}': ${setting} configures the service's target group on the cluster's load balancer, ` +
87
+ "which loadBalancer: false and directAccess: true both leave out. Drop it, or give the cluster a load balancer.");
88
+ }
89
+ if (!source.containers?.some((c) => c.port !== undefined)) {
90
+ throw new Error(`Service '${name}': ${setting} configures the service's target group, but no container declares a port, ` +
91
+ "so the service is never registered with the load balancer. Drop it, or give the primary container a port.");
92
+ }
93
+ if (healthCheck !== undefined) {
94
+ const defaults = HEALTH_CHECK_DEFAULTS[source.capacityProvider];
95
+ const interval = healthCheck.interval !== undefined
96
+ ? wholeSeconds(name, "healthCheck.interval", healthCheck.interval)
97
+ : defaults.intervalSeconds;
98
+ const timeout = healthCheck.timeout !== undefined
99
+ ? wholeSeconds(name, "healthCheck.timeout", healthCheck.timeout)
100
+ : defaults.timeoutSeconds;
101
+ requireWithin(name, "healthCheck.interval", interval, HEALTH_CHECK_INTERVAL_RANGE, " seconds", ROLLOUT_WAIT_REASON.interval);
102
+ requireWithin(name, "healthCheck.timeout", timeout, HEALTH_CHECK_TIMEOUT_RANGE, " seconds");
103
+ if (timeout >= interval) {
104
+ throw new Error(`Service '${name}': healthCheck.timeout (${timeout} seconds) must be shorter than healthCheck.interval ` +
105
+ `(${interval} seconds); a key left out takes the ${defaults.launchType} default (interval ` +
106
+ `${defaults.intervalSeconds}, timeout ${defaults.timeoutSeconds}).`);
107
+ }
108
+ for (const key of [
109
+ "healthyThresholdCount",
110
+ "unhealthyThresholdCount"
111
+ ]) {
112
+ const count = healthCheck[key];
113
+ if (count === undefined)
114
+ continue;
115
+ if (!Number.isInteger(count)) {
116
+ throw new Error(`Service '${name}': healthCheck.${key} must be a whole number (got ${count}).`);
117
+ }
118
+ requireWithin(name, `healthCheck.${key}`, count, THRESHOLD_COUNT_RANGE, "");
119
+ }
120
+ if (healthCheck.path !== undefined) {
121
+ if (!healthCheck.path.startsWith("/") ||
122
+ healthCheck.path.length > HEALTH_CHECK_PATH_MAX_LENGTH) {
123
+ throw new Error(`Service '${name}': healthCheck.path must start with '/' and be at most ${HEALTH_CHECK_PATH_MAX_LENGTH} characters.`);
124
+ }
125
+ if (routingHealthCheckPath(source.routing) !== undefined) {
126
+ throw new Error(`Service '${name}': healthCheck.path and routing healthCheckPath both set the target group's health-check path. ` +
127
+ "Keep one.");
128
+ }
129
+ }
130
+ }
131
+ if (deregistrationDelay !== undefined) {
132
+ const delay = wholeSeconds(name, "deregistrationDelay", deregistrationDelay);
133
+ requireWithin(name, "deregistrationDelay", delay, DEREGISTRATION_DELAY_RANGE, " seconds", ROLLOUT_WAIT_REASON.deregistrationDelay);
134
+ }
135
+ }
136
+ /**
137
+ * The health check and drain a service's target group is built with: each
138
+ * key the service sets, and its launch type's default for every other. A
139
+ * threshold with no default is left out, as is the drain when the service
140
+ * sets none, so Elastic Load Balancing's own value applies. `port` is the
141
+ * launch type's (the container port on Fargate, `traffic-port` on EC2).
142
+ */
143
+ export function resolveTargetGroupSettings(source, port) {
144
+ const configured = source.healthCheck;
145
+ const defaults = HEALTH_CHECK_DEFAULTS[source.capacityProvider];
146
+ const healthyThresholdCount = configured?.healthyThresholdCount ?? defaults.healthyThresholdCount;
147
+ const unhealthyThresholdCount = configured?.unhealthyThresholdCount ?? defaults.unhealthyThresholdCount;
148
+ return {
149
+ healthCheck: {
150
+ interval: configured?.interval ?? Duration.seconds(defaults.intervalSeconds),
151
+ ...(healthyThresholdCount !== undefined && { healthyThresholdCount }),
152
+ ...(unhealthyThresholdCount !== undefined && {
153
+ unhealthyThresholdCount
154
+ }),
155
+ path: configured?.path ?? routingHealthCheckPath(source.routing) ?? "/",
156
+ port,
157
+ timeout: configured?.timeout ?? Duration.seconds(defaults.timeoutSeconds)
158
+ },
159
+ deregistrationDelay: source.deregistrationDelay
160
+ };
161
+ }
@@ -11,7 +11,7 @@ import { type Duration } from "aws-cdk-lib";
11
11
  import { type GeoLocation } from "aws-cdk-lib/aws-route53";
12
12
  import { type Repository } from "aws-cdk-lib/aws-ecr";
13
13
  import { type FargateService, type Ec2Service, type FargateTaskDefinition, type Ec2TaskDefinition } from "aws-cdk-lib/aws-ecs";
14
- import { type IApplicationTargetGroup } from "aws-cdk-lib/aws-elasticloadbalancingv2";
14
+ import { type HealthCheck, type IApplicationTargetGroup } from "aws-cdk-lib/aws-elasticloadbalancingv2";
15
15
  import { type Role } from "aws-cdk-lib/aws-iam";
16
16
  import { type HostedZone as FjallHostedZone } from "../networking/hostedZone.js";
17
17
  import { type Certificate } from "aws-cdk-lib/aws-certificatemanager";
@@ -502,6 +502,13 @@ export interface EcsRoutingConfig {
502
502
  /** Health check path for this service's target group. Default: "/" */
503
503
  healthCheckPath?: string;
504
504
  }
505
+ /**
506
+ * The settings a service may tune on its target group's health check: CDK's
507
+ * `HealthCheck`, narrowed to the five keys Fjall exposes. The port and
508
+ * protocol stay Fjall's (the container port on Fargate, the traffic port on
509
+ * EC2; HTTP), so they are not offered.
510
+ */
511
+ export type EcsTargetGroupHealthCheck = Pick<HealthCheck, "interval" | "timeout" | "healthyThresholdCount" | "unhealthyThresholdCount" | "path">;
505
512
  /**
506
513
  * Configuration for a service in an ECS cluster.
507
514
  * Each service gets its own task definition, scaling, and target group.
@@ -568,6 +575,37 @@ export interface EcsServiceProps {
568
575
  * Can be a single rule or an array of rules pointing to the same target group.
569
576
  */
570
577
  routing?: EcsRoutingConfig | EcsRoutingConfig[];
578
+ /**
579
+ * Health check of this service's target group on the cluster's load
580
+ * balancer, in CDK's `HealthCheck` names. Each key left out takes its launch
581
+ * type's default: on EC2, `interval` 30 s, `timeout` 15 s,
582
+ * `healthyThresholdCount` 3 and `unhealthyThresholdCount` 3; on Fargate,
583
+ * `interval` 120 s and `timeout` 10 s, with both thresholds left to Elastic
584
+ * Load Balancing (5 and 2). `path` defaults to the routing rule's
585
+ * `healthCheckPath` or `/`. Refused at synth on a service with no target
586
+ * group (no container port, or a cluster without a load balancer), outside
587
+ * Elastic Load Balancing's ranges, with a `timeout` no shorter than the
588
+ * `interval`, and with an `interval` above 120 seconds: a rollout waits up
589
+ * to one interval for each new task's first check, and
590
+ * `fjall deploy --deploy-only`, `fjall rollback` and `fjall rollout` wait a
591
+ * fixed time sized for no more. Of its keys only `path` is refused beside a
592
+ * routing `healthCheckPath`, which sets the same value; the others may be
593
+ * set beside one.
594
+ */
595
+ healthCheck?: EcsTargetGroupHealthCheck;
596
+ /**
597
+ * How long the target group drains a task it deregisters before ECS stops
598
+ * it. Default: none set, so Elastic Load Balancing's 300 seconds applies.
599
+ * ECS sends the stop signal only after the drain (verified 2026-10-15
600
+ * against the ECS developer guide), so a container's `stopTimeout` starts
601
+ * once the drain has ended, and the drain should cover the longest request
602
+ * the service serves. Lower it for a service whose requests are short: a
603
+ * rollout waits out each old task's drain, and `fjall deploy --deploy-only`,
604
+ * `fjall rollback` and `fjall rollout` wait a fixed time sized for no more
605
+ * than 300 seconds. Refused at synth above 300 seconds, on a service with no
606
+ * target group, and when it is not a whole number of seconds.
607
+ */
608
+ deregistrationDelay?: Duration;
571
609
  /**
572
610
  * Additional inline policies for this service's task role.
573
611
  * Added on top of the default ECS Exec permissions.
@@ -1,5 +1,11 @@
1
1
  import type { IConstruct } from "constructs";
2
- import type { DomainConfig, EcsCapacityProvider, EcsClusterProps } from "./ecsTypes.js";
2
+ import type { DomainConfig, EcsCapacityProvider, EcsClusterClusterConfig, EcsClusterProps } from "./ecsTypes.js";
3
+ /**
4
+ * Whether the cluster leaves its shared load balancer out. `EcsCluster`
5
+ * builds none on this condition, and both validation layers read it, so the
6
+ * construct and its validation cannot answer it differently.
7
+ */
8
+ export declare function clusterHasNoLoadBalancer(cluster: Pick<EcsClusterClusterConfig, "loadBalancer" | "directAccess"> | undefined): boolean;
3
9
  /**
4
10
  * Validates ECS cluster props before construction.
5
11
  * Pure function — does not depend on class state.
@@ -2,10 +2,19 @@ import { Annotations } from "aws-cdk-lib";
2
2
  import { NetworkMode } from "aws-cdk-lib/aws-ecs";
3
3
  import { evaluateBakeGuard } from "@fjall/util/docker";
4
4
  import { SCHEMA_GATE_CONTAINER_NAME } from "@fjall/util/migration";
5
- import { toKebab, SSM_COMPONENT_PATTERN, SSM_COMPONENT_ERROR, SECRET_NAME_PATTERN, SECRET_NAME_ERROR } from "@fjall/util";
5
+ import { toKebab, ECS_TASK_REPLACEMENT_LIMITS, SSM_COMPONENT_PATTERN, SSM_COMPONENT_ERROR, SECRET_NAME_PATTERN, SECRET_NAME_ERROR } from "@fjall/util";
6
6
  import { ScalingType } from "./ecsTypes.js";
7
7
  import { DEFAULT_EC2_CONTAINER_MEMORY_MIB, DEFAULT_ECS_EC2_INSTANCE_TYPE, SCHEMA_GATE_CONTAINER_MEMORY_MIB, estimatedEc2MemoryReserveMiB, nominalEc2InstanceMemoryMiB, resolveEc2ContainerMemoryMiB } from "./ecsConstants.js";
8
8
  import { validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
9
+ import { validateTargetGroupSettings } from "./ecsTargetGroup.js";
10
+ /**
11
+ * Whether the cluster leaves its shared load balancer out. `EcsCluster`
12
+ * builds none on this condition, and both validation layers read it, so the
13
+ * construct and its validation cannot answer it differently.
14
+ */
15
+ export function clusterHasNoLoadBalancer(cluster) {
16
+ return cluster?.loadBalancer === false || cluster?.directAccess === true;
17
+ }
9
18
  /**
10
19
  * Validates ECS cluster props before construction.
11
20
  * Pure function — does not depend on class state.
@@ -50,8 +59,7 @@ import { validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
50
59
  */
51
60
  export function validateEcsClusterProps(props, annotationsScope) {
52
61
  validateEcsDomainConfig(props.cluster?.domainConfig, props.clusterName);
53
- const loadBalancerDisabled = props.cluster?.loadBalancer === false ||
54
- props.cluster?.directAccess === true;
62
+ const loadBalancerDisabled = clusterHasNoLoadBalancer(props.cluster);
55
63
  if (loadBalancerDisabled) {
56
64
  const domainConfig = props.cluster?.domainConfig;
57
65
  const listenerFeature = (domainConfig?.redirectHosts?.length ?? 0) > 0
@@ -119,11 +127,12 @@ export function validateEcsClusterProps(props, annotationsScope) {
119
127
  }
120
128
  for (const container of service.containers) {
121
129
  if (container.stopTimeout !== undefined) {
130
+ const { stopTimeoutSeconds } = ECS_TASK_REPLACEMENT_LIMITS;
122
131
  if (!Number.isInteger(container.stopTimeout) ||
123
132
  container.stopTimeout < 1 ||
124
- container.stopTimeout > 120) {
133
+ container.stopTimeout > stopTimeoutSeconds) {
125
134
  throw new Error(`Service '${service.name}', container '${container.name ?? "(default)"}': ` +
126
- `stopTimeout must be an integer between 1 and 120 seconds (got ${container.stopTimeout}).`);
135
+ `stopTimeout must be an integer between 1 and ${stopTimeoutSeconds} seconds (got ${container.stopTimeout}).`);
127
136
  }
128
137
  }
129
138
  // A host-bind volume silently no-ops on Fargate (no host filesystem),
@@ -175,6 +184,7 @@ export function validateEcsClusterProps(props, annotationsScope) {
175
184
  "(no capacity to drain or expand — deploys would never roll forward).");
176
185
  }
177
186
  }
187
+ validateTargetGroupSettings(service, loadBalancerDisabled);
178
188
  if (service.cloudMapDnsRecordType !== undefined &&
179
189
  service.cloudMapService === undefined) {
180
190
  throw new Error(`Service '${service.name}': cloudMapDnsRecordType is set but cloudMapService is not. ` +
@@ -16,10 +16,15 @@
16
16
  * own update and the service's are ordered. The Lambda changes only when its
17
17
  * code does.
18
18
  *
19
- * Re-invocation safety: ECS hook responses do NOT carry state between
20
- * IN_PROGRESS invocations. On every invocation we reconstruct the
21
- * deterministic startedBy tag from the event's
22
- * executionDetails.targetServiceRevisionArn and use ListTasks to find the
19
+ * Re-invocation safety: every IN_PROGRESS carries the migration task's ARN in
20
+ * hookDetails, which ECS passes back on the hook's next invocation within the
21
+ * same deployment (AWS "Lambda hooks for Amazon ECS service deployments",
22
+ * developerguide/lambda-lifecycle-hooks.html, read 1 November 2026). An
23
+ * invocation that carries it polls that task, adopting it only when it was
24
+ * started under this revision's startedBy tag from this revision's migration
25
+ * task definition, and never lists or runs one. An invocation that carries
26
+ * none reconstructs the deterministic startedBy tag from the event's
27
+ * executionDetails.targetServiceRevisionArn and uses ListTasks to find the
23
28
  * migration task already started for it, running or stopped. RunTask fires
24
29
  * only when there is none.
25
30
  *
@@ -83,6 +88,10 @@ const MIGRATION_SECURITY_GROUPS_TAG_KEY = "fjall:migration-security-groups";
83
88
  const TASK_DEFINITION_ARN =
84
89
  /^arn:[^:]+:ecs:[^:]+:\d{12}:task-definition\/[^:]+:\d+$/;
85
90
  const SECURITY_GROUP_ID = /^sg-[0-9a-f]+$/;
91
+ const TASK_ARN = /^arn:[^:]+:ecs:[^:]+:\d{12}:task\/[^/\s]+(\/[^/\s]+)?$/;
92
+
93
+ /** The hookDetails key that carries the migration task between invocations. */
94
+ const HOOK_DETAILS_TASK_KEY = "fjallMigrationTaskArn";
86
95
 
87
96
  let _defaultClient;
88
97
  function getDefaultClient() {
@@ -280,9 +289,11 @@ async function tasksStartedBy(client, clusterArn, startedBy, desiredStatus) {
280
289
 
281
290
  /**
282
291
  * The migration task already started for this revision: a running one, or
283
- * else the newest stopped one. ListTasks answers RUNNING tasks unless asked,
284
- * and a re-invocation can land after the task stopped; ECS keeps a stopped
285
- * task listable for about an hour, longer than any one deployment's hook.
292
+ * else the newest stopped one. ListTasks answers RUNNING tasks unless asked.
293
+ * Its reference says startedBy must be the only filter and that a recently
294
+ * stopped task only might appear (@aws-sdk/client-ecs, read 1 November 2026),
295
+ * so a miss here can run a stopped migration again. A reinvocation avoids this
296
+ * lookup by carrying its task in hookDetails.
286
297
  */
287
298
  async function findExistingTaskArn(client, clusterArn, startedBy) {
288
299
  const running = await tasksStartedBy(
@@ -324,10 +335,18 @@ function describeFailureReason(desc) {
324
335
  // "not visible yet" and the poll continues; once it has been seen, a miss is
325
336
  // the task vanishing, which is loud. A task never seen inside one
326
337
  // invocation's poll budget is loud too - that is a hundred seconds, not
327
- // consistency lag.
328
- async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
338
+ // consistency lag. A task carried in hookDetails was seen by an earlier
339
+ // invocation, and `adopt` refuses one that is not this revision's migration.
340
+ async function pollTaskUntilStopped(
341
+ client,
342
+ clusterArn,
343
+ taskArn,
344
+ sleep,
345
+ options
346
+ ) {
329
347
  const sleepFn = sleep || defaultSleep;
330
- let seen = false;
348
+ const adopt = options && options.adopt;
349
+ let seen = Boolean(options && options.seen);
331
350
  let lastMissReason = "no failure reported";
332
351
  for (let i = 0; i < MAX_POLLS_PER_INVOCATION; i++) {
333
352
  const desc = await client.send(
@@ -336,6 +355,8 @@ async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
336
355
  const task = desc.tasks && desc.tasks[0];
337
356
  if (task) {
338
357
  seen = true;
358
+ const refusal = adopt === undefined ? undefined : adopt(task);
359
+ if (refusal !== undefined) return refusal;
339
360
  if (task.lastStatus === "STOPPED") {
340
361
  const exitCode =
341
362
  task.containers && task.containers[0]
@@ -365,10 +386,37 @@ async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
365
386
  }
366
387
  return {
367
388
  hookStatus: "IN_PROGRESS",
368
- callBackDelay: HOOK_CALLBACK_DELAY_SECONDS
389
+ callBackDelay: HOOK_CALLBACK_DELAY_SECONDS,
390
+ hookDetails: { [HOOK_DETAILS_TASK_KEY]: taskArn }
369
391
  };
370
392
  }
371
393
 
394
+ /**
395
+ * The migration task an earlier invocation carried in the event's
396
+ * hookDetails: `{ taskArn }`, `{}` when it carried none, or a FAILED result
397
+ * that names the key and never the value, which the service's own hook
398
+ * configuration could have set.
399
+ */
400
+ function carriedTask(event) {
401
+ const details = event && event.hookDetails;
402
+ if (
403
+ details === null ||
404
+ typeof details !== "object" ||
405
+ !Object.prototype.hasOwnProperty.call(details, HOOK_DETAILS_TASK_KEY)
406
+ ) {
407
+ return {};
408
+ }
409
+ const taskArn = details[HOOK_DETAILS_TASK_KEY];
410
+ if (typeof taskArn !== "string" || !TASK_ARN.test(taskArn)) {
411
+ return {
412
+ failure: failed(
413
+ `The ${HOOK_DETAILS_TASK_KEY} in the event's hookDetails is not a task ARN`
414
+ )
415
+ };
416
+ }
417
+ return { taskArn };
418
+ }
419
+
372
420
  /**
373
421
  * The hook's verdict, and the cluster and migration it ran, for its log. The
374
422
  * log says whether the tag or the label named the migration: a revision
@@ -401,6 +449,30 @@ async function runHandler(event, deps) {
401
449
  };
402
450
  const startedBy = startedByTag(targetServiceRevisionArn);
403
451
 
452
+ const carried = carriedTask(event);
453
+ if (carried.failure) return { ...ran, result: carried.failure };
454
+ if (carried.taskArn !== undefined) {
455
+ return {
456
+ ...ran,
457
+ result: await pollTaskUntilStopped(
458
+ client,
459
+ revision.clusterArn,
460
+ carried.taskArn,
461
+ sleep,
462
+ {
463
+ seen: true,
464
+ adopt: (task) =>
465
+ task.startedBy === startedBy &&
466
+ task.taskDefinitionArn === plan.migrationTaskDefinition
467
+ ? undefined
468
+ : failed(
469
+ `The task the event's hookDetails names in ${HOOK_DETAILS_TASK_KEY} was not started for this revision's migration`
470
+ )
471
+ }
472
+ )
473
+ };
474
+ }
475
+
404
476
  const existingTaskArn = await findExistingTaskArn(
405
477
  client,
406
478
  revision.clusterArn,
@@ -494,6 +566,7 @@ exports._internals = {
494
566
  MIGRATION_TASK_DEFINITION_TAG_KEY,
495
567
  MIGRATION_TASK_DEFINITION_LABEL_KEY,
496
568
  MIGRATION_SECURITY_GROUPS_TAG_KEY,
569
+ HOOK_DETAILS_TASK_KEY,
497
570
  POLL_INTERVAL_MS,
498
571
  MAX_POLLS_PER_INVOCATION,
499
572
  MAX_REVISION_READS,
@@ -14,7 +14,7 @@ export declare const RDS_DEFAULTS: Readonly<{
14
14
  * (fast dev-envs Phase 3). `min 0` ACU scales the shared cluster to zero when
15
15
  * idle; `enableDataApi` lets slot DDL run over HTTPS with no VPC connection, and
16
16
  * is the only mode under which min-0 actually saves (a live pooled connection
17
- * pins the cluster above 0 ACU). Engine 16.10 satisfies the auto-pause-capable
17
+ * pins the cluster above 0 ACU). Engine 16.15 satisfies the auto-pause-capable
18
18
  * floor (see `MIN_AUTO_PAUSE_POSTGRES_VERSION`). AWS removes old minors from
19
19
  * the creatable list over time (16.6 rejected with "Cannot find version" by
20
20
  * 2026-07) — pin a mid-window minor, not the oldest available, and expect this
@@ -25,7 +25,7 @@ export declare const DEV_AURORA_DEFAULTS: Readonly<{
25
25
  readonly SERVERLESS_V2_MAX_CAPACITY: 4;
26
26
  readonly AUTO_PAUSE_SECONDS: 1200;
27
27
  readonly ENABLE_DATA_API: true;
28
- readonly ENGINE_VERSION: "16.10";
28
+ readonly ENGINE_VERSION: "16.15";
29
29
  }>;
30
30
  /**
31
31
  * Bounds AWS enforces on `ServerlessV2ScalingConfiguration.SecondsUntilAutoPause`
@@ -14,7 +14,7 @@ export const RDS_DEFAULTS = Object.freeze({
14
14
  * (fast dev-envs Phase 3). `min 0` ACU scales the shared cluster to zero when
15
15
  * idle; `enableDataApi` lets slot DDL run over HTTPS with no VPC connection, and
16
16
  * is the only mode under which min-0 actually saves (a live pooled connection
17
- * pins the cluster above 0 ACU). Engine 16.10 satisfies the auto-pause-capable
17
+ * pins the cluster above 0 ACU). Engine 16.15 satisfies the auto-pause-capable
18
18
  * floor (see `MIN_AUTO_PAUSE_POSTGRES_VERSION`). AWS removes old minors from
19
19
  * the creatable list over time (16.6 rejected with "Cannot find version" by
20
20
  * 2026-07) — pin a mid-window minor, not the oldest available, and expect this
@@ -25,7 +25,7 @@ export const DEV_AURORA_DEFAULTS = Object.freeze({
25
25
  SERVERLESS_V2_MAX_CAPACITY: 4,
26
26
  AUTO_PAUSE_SECONDS: 1200,
27
27
  ENABLE_DATA_API: true,
28
- ENGINE_VERSION: "16.10"
28
+ ENGINE_VERSION: "16.15"
29
29
  });
30
30
  /**
31
31
  * Bounds AWS enforces on `ServerlessV2ScalingConfiguration.SecondsUntilAutoPause`
@@ -11,5 +11,10 @@ export interface Config {
11
11
  allRegions: string[];
12
12
  disasterRecoveryRegion?: string;
13
13
  }
14
+ /**
15
+ * Forget which warnings getConfig has printed, so the next call prints them
16
+ * again. For testing only.
17
+ */
18
+ export declare function _resetConfigWarnings(): void;
14
19
  export declare function getConfig(accountName?: string): Config;
15
20
  export default getConfig;
@@ -3,6 +3,25 @@ import { CDK_CONTEXT_KEYS } from "./cdkContext.js";
3
3
  import { parseOrgConfig, resolveSynthEnvironment } from "./orgConfigParser.js";
4
4
  import { findAccountNameCollision } from "./capitaliseString.js";
5
5
  import { FjallLogger } from "./validationLogger.js";
6
+ /**
7
+ * The warnings getConfig has printed in this process. Each describes the
8
+ * process, not the call, and a synth calls getConfig several times, so each
9
+ * prints once, as `getEnvironment` warns once through its cache.
10
+ */
11
+ const printedWarnings = new Set();
12
+ function warnOncePerProcess(message) {
13
+ if (printedWarnings.has(message))
14
+ return;
15
+ printedWarnings.add(message);
16
+ FjallLogger.warn(message);
17
+ }
18
+ /**
19
+ * Forget which warnings getConfig has printed, so the next call prints them
20
+ * again. For testing only.
21
+ */
22
+ export function _resetConfigWarnings() {
23
+ printedWarnings.clear();
24
+ }
6
25
  export function getConfig(accountName) {
7
26
  const app = App.getInstance();
8
27
  const config = {
@@ -13,11 +32,11 @@ export function getConfig(accountName) {
13
32
  allRegions: []
14
33
  };
15
34
  if (!process.env.AWS_REGION)
16
- FjallLogger.warn("AWS_REGION is not set, defaulting to us-east-1");
35
+ warnOncePerProcess("AWS_REGION is not set, defaulting to us-east-1");
17
36
  // Primary: read org config from CDK context (injected by CLI/worker)
18
37
  const orgConfigRaw = app.node.tryGetContext(CDK_CONTEXT_KEYS.ORG_CONFIG);
19
38
  if (typeof orgConfigRaw !== "string") {
20
- FjallLogger.warn("No orgConfig context provided — org-level config (accounts, regions) will be empty");
39
+ warnOncePerProcess("No orgConfig context provided — org-level config (accounts, regions) will be empty");
21
40
  }
22
41
  const orgConfig = parseOrgConfig(typeof orgConfigRaw === "string" ? orgConfigRaw : undefined);
23
42
  const providerAccounts = orgConfig.providerAccounts;
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { appendFileSync, existsSync, mkdirSync } from "fs";
7
7
  import { join } from "path";
8
- import { fjallLogDir } from "@fjall/util";
8
+ import { FJALL_NOTE_PREFIX, FJALL_WARNING_PREFIX, fjallLogDir } from "@fjall/util";
9
9
  import { FileRotator, resolveLogRotationBudget } from "@fjall/util/logRotation";
10
10
  const LOG_FILENAME = "infrastructure.jsonl";
11
11
  /**
@@ -46,14 +46,14 @@ export const FjallLogger = {
46
46
  * Log a warning message with Fjall prefix.
47
47
  */
48
48
  warn: (message) => {
49
- console.warn(`[Fjall] Warning: ${message}`);
49
+ console.warn(`${FJALL_WARNING_PREFIX}${message}`);
50
50
  writeToLog("warn", message);
51
51
  },
52
52
  /**
53
53
  * Log an info message with Fjall prefix.
54
54
  */
55
55
  info: (message) => {
56
- console.info(`[Fjall] Note: ${message}`);
56
+ console.info(`${FJALL_NOTE_PREFIX}${message}`);
57
57
  writeToLog("info", message);
58
58
  }
59
59
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "39.0.8",
3
+ "version": "39.0.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -81,8 +81,8 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@aws-sdk/client-organizations": "^3.1098.0",
84
- "@fjall/generator": "^39.0.8",
85
- "@fjall/util": "^39.0.8",
84
+ "@fjall/generator": "^39.0.10",
85
+ "@fjall/util": "^39.0.10",
86
86
  "constructs": "^10.7.2",
87
87
  "zod": "^4.4.3"
88
88
  },