@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.
- package/dist/lib/patterns/aws/compute.d.ts +2 -2
- package/dist/lib/patterns/aws/computeEcs.d.ts +1 -1
- package/dist/lib/patterns/aws/computeEcs.js +9 -1
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +37 -2
- package/dist/lib/resources/aws/compute/ecs.js +2 -3
- package/dist/lib/resources/aws/compute/ecsNetworking.js +6 -18
- package/dist/lib/resources/aws/compute/ecsTargetGroup.d.ts +55 -0
- package/dist/lib/resources/aws/compute/ecsTargetGroup.js +161 -0
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +39 -1
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +7 -1
- package/dist/lib/resources/aws/compute/ecsValidation.js +15 -5
- package/dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs +84 -11
- package/dist/lib/resources/aws/database/rdsDefaults.d.ts +2 -2
- package/dist/lib/resources/aws/database/rdsDefaults.js +2 -2
- package/dist/lib/utils/getConfig.d.ts +5 -0
- package/dist/lib/utils/getConfig.js +21 -2
- package/dist/lib/utils/validationLogger.js +3 -3
- package/package.json +3 -3
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
|
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 >
|
|
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
|
|
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:
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
285
|
-
* task
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
85
|
-
"@fjall/util": "^39.0.
|
|
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
|
},
|