@fjall/components-infrastructure 39.0.5 → 39.0.6
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/computeEcs.d.ts +65 -21
- package/dist/lib/patterns/aws/computeEcs.js +243 -95
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +49 -25
- package/dist/lib/resources/aws/compute/ecs.d.ts +3 -0
- package/dist/lib/resources/aws/compute/ecs.js +4 -0
- package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.d.ts +32 -62
- package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +96 -50
- package/dist/lib/resources/aws/compute/ecsRolloutBarrier.d.ts +61 -0
- package/dist/lib/resources/aws/compute/ecsRolloutBarrier.js +185 -0
- package/dist/lib/resources/aws/compute/ecsRolloutBarrierLambda.source.cjs +475 -0
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.d.ts +19 -8
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +16 -7
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +3 -3
- package/dist/lib/resources/aws/compute/ecsValidation.js +3 -3
- package/dist/lib/resources/aws/compute/lambda.d.ts +6 -0
- package/dist/lib/resources/aws/compute/lambda.js +18 -12
- package/dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs +329 -82
- package/package.json +5 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, NetworkMode, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
|
|
2
|
-
import { Peer, Port
|
|
2
|
+
import { Peer, Port } from "aws-cdk-lib/aws-ec2";
|
|
3
3
|
import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
4
|
-
import { Annotations, Aws, Stack, Token } from "aws-cdk-lib";
|
|
4
|
+
import { Annotations, Aws, Stack, Tags, Token } from "aws-cdk-lib";
|
|
5
5
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
6
6
|
import { StringParameter } from "aws-cdk-lib/aws-ssm";
|
|
7
7
|
import { Construct } from "constructs";
|
|
@@ -13,20 +13,20 @@ import { isMigrationContributor } from "./interfaces/migrationContributor.js";
|
|
|
13
13
|
import { resolveImportedSecret } from "../../resources/aws/secrets/index.js";
|
|
14
14
|
import App from "../../app.js";
|
|
15
15
|
import EcsCluster from "../../resources/aws/compute/ecs.js";
|
|
16
|
-
import { createScheduledTaskDefinition, createMigrationTaskDefinition } from "../../resources/aws/compute/ecsTaskDefinition.js";
|
|
16
|
+
import { createScheduledTaskDefinition, createMigrationTaskDefinition, taskSize } from "../../resources/aws/compute/ecsTaskDefinition.js";
|
|
17
17
|
import { DEFAULT_LOG_RETENTION } from "../../resources/aws/compute/ecsConstants.js";
|
|
18
18
|
import { EcsLifecycleHookMigration } from "../../resources/aws/compute/ecsLifecycleHookMigration.js";
|
|
19
|
+
import { EcsRolloutBarrier } from "../../resources/aws/compute/ecsRolloutBarrier.js";
|
|
19
20
|
import { Role } from "../../resources/aws/iam/role.js";
|
|
20
21
|
import { LogGroup } from "../../resources/aws/logging/logGroup.js";
|
|
21
22
|
import { Schedule } from "../../resources/aws/messaging/schedule.js";
|
|
22
23
|
import { SecurityGroup } from "../../resources/aws/networking/securityGroup.js";
|
|
23
|
-
import { vpcHasNatGateways } from "../../utils/vpcUtils.js";
|
|
24
24
|
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
25
25
|
import { FjallLogger } from "../../utils/validationLogger.js";
|
|
26
26
|
import { VALIDATION_PATTERNS } from "@fjall/generator";
|
|
27
27
|
import { evaluateBakeGuard } from "@fjall/util/docker";
|
|
28
28
|
import { toKebab, buildParameterPath } from "@fjall/util";
|
|
29
|
-
import { SCHEMA_GATE_CH_CA_CERT_ENV, SCHEMA_GATE_CH_DATABASE_ENV, SCHEMA_GATE_CH_URL_ENV, SCHEMA_GATE_CONTAINER_NAME, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_ECR_REPO_NAME, SCHEMA_GATE_START_TIMEOUT_SECONDS } from "@fjall/util/migration";
|
|
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
31
|
import { validateEc2ServiceSizing, validateEc2TaskMemoryFit, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
|
|
32
32
|
import { validateSharedEc2CapacityConfig } from "../../resources/aws/compute/ecsCapacityConfig.js";
|
|
@@ -355,7 +355,7 @@ export function expandMigrationsSugar(service, userContainers) {
|
|
|
355
355
|
`migrate container created by 'migrations'. Set 'migrations.name' to a different value.`);
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
|
-
const primary = containers
|
|
358
|
+
const primary = primaryContainerConfig(containers);
|
|
359
359
|
const migrateContainer = {
|
|
360
360
|
name: migrateName,
|
|
361
361
|
essential: false,
|
|
@@ -641,7 +641,18 @@ function mergeContributionsIntoMigrations(migrations, contributions, scope, serv
|
|
|
641
641
|
}
|
|
642
642
|
mergedEnv[key] = value;
|
|
643
643
|
}
|
|
644
|
-
|
|
644
|
+
// An authored SSM secret is author-explicit too: the contributor's import
|
|
645
|
+
// of that name is dropped, so no variable resolves from both kinds.
|
|
646
|
+
const authoredSsm = new Set(migrations.secrets ?? []);
|
|
647
|
+
const mergedSecrets = {};
|
|
648
|
+
for (const [key, value] of Object.entries(contributedSecrets)) {
|
|
649
|
+
if (authoredSsm.has(key)) {
|
|
650
|
+
Annotations.of(scope).addWarning(`Service '${serviceName}': author-set migrations.secrets entry ${key} ` +
|
|
651
|
+
`overrides contributor secret ('${value.name ?? ""}').`);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
mergedSecrets[key] = value;
|
|
655
|
+
}
|
|
645
656
|
for (const [key, value] of Object.entries(authoredSecrets)) {
|
|
646
657
|
if (contributedSecrets[key] !== undefined &&
|
|
647
658
|
contributedSecrets[key]?.name !== value.name) {
|
|
@@ -805,7 +816,7 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
805
816
|
if (expanded) {
|
|
806
817
|
return wireSchemaGate(expanded.map((c, index) => {
|
|
807
818
|
return {
|
|
808
|
-
name:
|
|
819
|
+
name: builtContainerName(service.name, c, index),
|
|
809
820
|
image: c.image,
|
|
810
821
|
port: c.port,
|
|
811
822
|
environment: mergeAllSchemaEnv(c.environment),
|
|
@@ -830,6 +841,64 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
|
|
|
830
841
|
}
|
|
831
842
|
]);
|
|
832
843
|
}
|
|
844
|
+
/**
|
|
845
|
+
* The service's primary container: the first with a port, else the first.
|
|
846
|
+
* Its migration inherits from it, and its definition carries the migration's
|
|
847
|
+
* label.
|
|
848
|
+
*/
|
|
849
|
+
function primaryContainerConfig(containers) {
|
|
850
|
+
return containers?.find((c) => c.port !== undefined) ?? containers?.[0];
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The name a service's container is built under: its own, or, when it has
|
|
854
|
+
* none (an empty name included), one derived from its place in the list.
|
|
855
|
+
*/
|
|
856
|
+
function builtContainerName(serviceName, container, index) {
|
|
857
|
+
return container.name || `${serviceName}Container${index > 0 ? index : ""}`;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The task definition's container for the service's primary container, or
|
|
861
|
+
* its default container when the service authors none. Hook migrations only:
|
|
862
|
+
* their containers are built in the order they are authored, so the
|
|
863
|
+
* primary's place in `containers` is its place when built.
|
|
864
|
+
*/
|
|
865
|
+
function primaryContainerDefinition(svcConfig, taskDefinition) {
|
|
866
|
+
const containers = svcConfig.containers ?? [];
|
|
867
|
+
const primary = primaryContainerConfig(containers);
|
|
868
|
+
if (primary === undefined)
|
|
869
|
+
return taskDefinition.defaultContainer;
|
|
870
|
+
const name = builtContainerName(svcConfig.name, primary, containers.indexOf(primary));
|
|
871
|
+
const definition = taskDefinition.findContainer(name);
|
|
872
|
+
if (definition === undefined) {
|
|
873
|
+
throw new Error(`Service '${svcConfig.name}': its task definition has no container '${name}' for its primary container`);
|
|
874
|
+
}
|
|
875
|
+
return definition;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* The migration's secrets with its primary container's beneath them, by
|
|
879
|
+
* variable name: a name the migration gives a secret of either kind is its
|
|
880
|
+
* own, and the primary's secret of that name, of either kind, is dropped.
|
|
881
|
+
*/
|
|
882
|
+
function secretsBeneathMigration(authored, primary) {
|
|
883
|
+
const own = new Set([
|
|
884
|
+
...(authored.secrets ?? []),
|
|
885
|
+
...Object.keys(authored.secretsImport ?? {})
|
|
886
|
+
]);
|
|
887
|
+
const secrets = [
|
|
888
|
+
...new Set([
|
|
889
|
+
...(authored.secrets ?? []),
|
|
890
|
+
...(primary?.secrets ?? []).filter((name) => !own.has(name))
|
|
891
|
+
])
|
|
892
|
+
];
|
|
893
|
+
const secretsImport = {
|
|
894
|
+
...Object.fromEntries(Object.entries(primary?.secretsImport ?? {}).filter(([name]) => !own.has(name))),
|
|
895
|
+
...(authored.secretsImport ?? {})
|
|
896
|
+
};
|
|
897
|
+
return {
|
|
898
|
+
...(secrets.length > 0 && { secrets }),
|
|
899
|
+
...(Object.keys(secretsImport).length > 0 && { secretsImport })
|
|
900
|
+
};
|
|
901
|
+
}
|
|
833
902
|
function resolveMigrationImage(svcConfig, migrations, inheritedImage) {
|
|
834
903
|
if (migrations.image !== undefined)
|
|
835
904
|
return migrations.image;
|
|
@@ -837,15 +906,14 @@ function resolveMigrationImage(svcConfig, migrations, inheritedImage) {
|
|
|
837
906
|
// CfnParameter token; checked before raw svcConfig.image strings.
|
|
838
907
|
if (inheritedImage !== undefined)
|
|
839
908
|
return inheritedImage;
|
|
840
|
-
const primary = svcConfig.containers
|
|
841
|
-
svcConfig.containers?.[0];
|
|
909
|
+
const primary = primaryContainerConfig(svcConfig.containers);
|
|
842
910
|
const containerImage = primary?.image ?? svcConfig.image;
|
|
843
911
|
if (containerImage !== undefined && typeof containerImage !== "string") {
|
|
844
|
-
throw new Error(`Service '${svcConfig.name}':
|
|
912
|
+
throw new Error(`Service '${svcConfig.name}': a lifecycle-hook migration needs a string image URI ` +
|
|
845
913
|
`(got CDK Repository). Pass migrations.image as a string.`);
|
|
846
914
|
}
|
|
847
915
|
if (containerImage === undefined) {
|
|
848
|
-
throw new Error(`Service '${svcConfig.name}':
|
|
916
|
+
throw new Error(`Service '${svcConfig.name}': a lifecycle-hook migration needs a resolvable image — ` +
|
|
849
917
|
`set migrations.image, service.image, or a container.image (string URI).`);
|
|
850
918
|
}
|
|
851
919
|
return containerImage;
|
|
@@ -890,6 +958,10 @@ export class EcsCompute extends Construct {
|
|
|
890
958
|
clusterId;
|
|
891
959
|
appName;
|
|
892
960
|
migrationTaskDefinitions = new Map();
|
|
961
|
+
/** The cluster's physical name, as a literal. */
|
|
962
|
+
clusterName;
|
|
963
|
+
/** Service name → its rollout barrier, made on first use. */
|
|
964
|
+
rolloutBarriers = new Map();
|
|
893
965
|
/**
|
|
894
966
|
* Service name → exact `@fjall/schema-gate` semver, for every service that
|
|
895
967
|
* materialised the gate container with the DEFAULT image this synth.
|
|
@@ -999,6 +1071,7 @@ export class EcsCompute extends Construct {
|
|
|
999
1071
|
appBackupTier: props.appBackupTier
|
|
1000
1072
|
})
|
|
1001
1073
|
};
|
|
1074
|
+
this.clusterName = ecsProps.clusterName;
|
|
1002
1075
|
this.ecsCluster = new EcsCluster(this, `${id}Ecs`, ecsProps);
|
|
1003
1076
|
this.connections = this.ecsCluster.connections;
|
|
1004
1077
|
this.wireLifecycleHookMigrations(props.services);
|
|
@@ -1075,11 +1148,6 @@ export class EcsCompute extends Construct {
|
|
|
1075
1148
|
* - `migrations.mode: "post-deploy"` — expected is deliberately ahead of
|
|
1076
1149
|
* the DB during scale-up, so a materialised gate would refuse every task
|
|
1077
1150
|
* (synth warning)
|
|
1078
|
-
* - `migrations.mode: "lifecycle-hook"` WITHOUT `separateTaskDef` — the
|
|
1079
|
-
* hook's migration RunTask reuses the service task definition, so a
|
|
1080
|
-
* materialised gate would refuse the migration task itself while the DB
|
|
1081
|
-
* still holds the pre-migration schema (synth warning; adding
|
|
1082
|
-
* `separateTaskDef` restores full gating)
|
|
1083
1151
|
* - No eligible half survives the v1 scope filters: the relational half
|
|
1084
1152
|
* requires `tool: "prisma"` and a postgres-family connection scheme
|
|
1085
1153
|
* (per-half synth warnings; env injection into app containers stays on)
|
|
@@ -1109,17 +1177,6 @@ export class EcsCompute extends Construct {
|
|
|
1109
1177
|
`Env injection stays on. Set schemaGate: false to silence.`);
|
|
1110
1178
|
return undefined;
|
|
1111
1179
|
}
|
|
1112
|
-
if (service.migrations?.mode === "lifecycle-hook" &&
|
|
1113
|
-
service.migrations.separateTaskDef === undefined) {
|
|
1114
|
-
Annotations.of(this).addWarning(`Service '${service.name}': schema-gate materialisation is disabled ` +
|
|
1115
|
-
`because migrations.mode is "lifecycle-hook" without ` +
|
|
1116
|
-
`separateTaskDef — the migration RunTask reuses the service task ` +
|
|
1117
|
-
`definition, so a materialised gate would refuse the migration ` +
|
|
1118
|
-
`task itself during the pre-migration window. Add ` +
|
|
1119
|
-
`migrations.separateTaskDef to run migrations in their own task ` +
|
|
1120
|
-
`definition and restore full gating. Env injection stays on.`);
|
|
1121
|
-
return undefined;
|
|
1122
|
-
}
|
|
1123
1180
|
const environment = {};
|
|
1124
1181
|
const secretsImport = {};
|
|
1125
1182
|
let eligibleHalves = 0;
|
|
@@ -1312,8 +1369,8 @@ export class EcsCompute extends Construct {
|
|
|
1312
1369
|
* Without `awaitMigrationsFrom` the sibling rolls out in parallel with the
|
|
1313
1370
|
* owner, so its gate-refused task churn can trip the deployment circuit
|
|
1314
1371
|
* breaker before the migration lands. The gate refusing is by design (new
|
|
1315
|
-
* code must not serve the old schema); the missing
|
|
1316
|
-
* author-fixable part — warn, don't disable.
|
|
1372
|
+
* code must not serve the old schema); the missing ordering on the owner's
|
|
1373
|
+
* rollout barrier is the author-fixable part — warn, don't disable.
|
|
1317
1374
|
*/
|
|
1318
1375
|
warnUngatedSiblingsOfHookOwners(services) {
|
|
1319
1376
|
for (const owner of services) {
|
|
@@ -1342,18 +1399,27 @@ export class EcsCompute extends Construct {
|
|
|
1342
1399
|
`not declare awaitMigrationsFrom: "${owner.name}". Its ` +
|
|
1343
1400
|
`materialised schema gate will refuse tasks that scale up ` +
|
|
1344
1401
|
`before the migration lands, which can trip the deployment ` +
|
|
1345
|
-
`circuit breaker. Add awaitMigrationsFrom
|
|
1346
|
-
`
|
|
1402
|
+
`circuit breaker. Add awaitMigrationsFrom: "${owner.name}" so ` +
|
|
1403
|
+
`it rolls out only once '${owner.name}''s deployment has ` +
|
|
1404
|
+
`finished; if it already awaits another service, depend on ` +
|
|
1405
|
+
`getRolloutBarrier("${owner.name}") instead. Or set ` +
|
|
1406
|
+
`schemaGate: { materialise: false }.`);
|
|
1347
1407
|
}
|
|
1348
1408
|
}
|
|
1349
1409
|
}
|
|
1350
1410
|
/**
|
|
1351
1411
|
* For each service whose `migrations.mode` is a lambda-hook variant
|
|
1352
1412
|
* (`"lifecycle-hook"` for PRE_SCALE_UP, `"post-deploy"` for POST_SCALE_UP),
|
|
1353
|
-
* synthesise the
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1413
|
+
* synthesise the migration's own task definition and the Lambda + IAM role
|
|
1414
|
+
* + log group that backs the deployment lifecycle hook. Every hook
|
|
1415
|
+
* migration runs from a task definition of its own, which the service's
|
|
1416
|
+
* task definition names in its `fjall:migration-task-definition` tag and
|
|
1417
|
+
* its primary container's docker label: the hook reads the tag from the
|
|
1418
|
+
* revision each deployment targets, and the label where a copy carries no
|
|
1419
|
+
* tag. The
|
|
1420
|
+
* init-container path is unaffected — services without `mode` (or with
|
|
1421
|
+
* `mode: "init-container"`) still get the synthetic migrate container
|
|
1422
|
+
* injected by `expandMigrationsSugar`.
|
|
1357
1423
|
*/
|
|
1358
1424
|
wireLifecycleHookMigrations(services) {
|
|
1359
1425
|
this.warnUngatedSiblingsOfHookOwners(services);
|
|
@@ -1380,12 +1446,9 @@ export class EcsCompute extends Construct {
|
|
|
1380
1446
|
throw new Error(`Service '${svcConfig.name}': task definition has no execution role — cannot grant iam:PassRole for the migrate Lambda.`);
|
|
1381
1447
|
}
|
|
1382
1448
|
const cluster = this.ecsCluster.getCluster();
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
});
|
|
1387
|
-
const securityGroupIds = service.connections.securityGroups.map((sg) => sg.securityGroupId);
|
|
1388
|
-
if (securityGroupIds.length === 0) {
|
|
1449
|
+
// The hook runs the migration in the network of the revision it serves,
|
|
1450
|
+
// the service's own, unless the migration has security groups of its own.
|
|
1451
|
+
if (service.connections.securityGroups.length === 0) {
|
|
1389
1452
|
throw new Error(`EcsCompute: service "${svcConfig.name}" has no security groups; lifecycle-hook migration RunTask requires at least one.`);
|
|
1390
1453
|
}
|
|
1391
1454
|
const contributions = collectMigrationContributions(svcConfig);
|
|
@@ -1395,63 +1458,68 @@ export class EcsCompute extends Construct {
|
|
|
1395
1458
|
? mergeContributionsIntoSeparateTaskDef(migrations.separateTaskDef, contributions)
|
|
1396
1459
|
: undefined;
|
|
1397
1460
|
const schemaVersionEnv = this.resolveSchemaVersionEnv(svcConfig);
|
|
1398
|
-
const
|
|
1461
|
+
const migrationTaskDefinition = effectiveSeparateTaskDef !== undefined
|
|
1399
1462
|
? this.synthesiseMigrationTaskDef(svcConfig, effectiveMigrations, effectiveSeparateTaskDef, service, cluster.vpc, schemaVersionEnv)
|
|
1400
|
-
:
|
|
1401
|
-
const effectiveSecurityGroupIds = migrationTaskDef?.securityGroupIds ?? securityGroupIds;
|
|
1463
|
+
: this.synthesiseInheritedMigrationTaskDef(svcConfig, migrations, effectiveMigrations, service, schemaVersionEnv);
|
|
1402
1464
|
new EcsLifecycleHookMigration(this, `${toPascalCase(svcConfig.name)}Migrate`, {
|
|
1403
1465
|
service,
|
|
1466
|
+
primaryContainer: primaryContainerDefinition(svcConfig, taskDefinition),
|
|
1467
|
+
serviceName: svcConfig.name,
|
|
1468
|
+
clusterName: this.clusterName,
|
|
1404
1469
|
clusterArn: cluster.clusterArn,
|
|
1405
|
-
|
|
1406
|
-
taskDefinitionFamily: taskDefinition.family,
|
|
1407
|
-
taskExecutionRoleArn: taskDefinition.executionRole.roleArn,
|
|
1408
|
-
taskRoleArn: taskDefinition.taskRole.roleArn,
|
|
1409
|
-
command: effectiveMigrations.command,
|
|
1410
|
-
containerName: effectiveMigrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME,
|
|
1411
|
-
image: effectiveMigrations.image,
|
|
1412
|
-
...(effectiveMigrations.entryPoint !== undefined && {
|
|
1413
|
-
entryPoint: effectiveMigrations.entryPoint
|
|
1414
|
-
}),
|
|
1415
|
-
environment: effectiveMigrations.environment,
|
|
1470
|
+
migrationTaskDefinition,
|
|
1416
1471
|
timeoutSeconds: effectiveMigrations.timeoutSeconds,
|
|
1417
|
-
networkConfiguration: {
|
|
1418
|
-
subnetIds: subnets.subnetIds,
|
|
1419
|
-
securityGroupIds: effectiveSecurityGroupIds,
|
|
1420
|
-
assignPublicIp: !hasNat
|
|
1421
|
-
},
|
|
1422
|
-
...(migrationTaskDef !== undefined && {
|
|
1423
|
-
migrationTaskDef: {
|
|
1424
|
-
definitionArn: migrationTaskDef.taskDefinitionArn,
|
|
1425
|
-
family: migrationTaskDef.family,
|
|
1426
|
-
taskRoleArn: migrationTaskDef.taskRoleArn,
|
|
1427
|
-
executionRoleArn: migrationTaskDef.executionRoleArn
|
|
1428
|
-
}
|
|
1429
|
-
}),
|
|
1430
1472
|
lifecycleStage: lifecycleStageForHookMode(effectiveMigrations.mode)
|
|
1431
1473
|
});
|
|
1432
1474
|
}
|
|
1433
1475
|
}
|
|
1434
1476
|
/**
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1477
|
+
* Orders each service that declares `awaitMigrationsFrom` after the named
|
|
1478
|
+
* migrate-owning service's rollout, so a DB-connecting worker never starts
|
|
1479
|
+
* before the owner's migrations have applied. Each owner gets one rollout
|
|
1480
|
+
* barrier (`EcsRolloutBarrier`): a custom resource that completes only once
|
|
1481
|
+
* ECS reports the owner's deployment of its task definition finished, its
|
|
1482
|
+
* lifecycle-hook migration included, and fails once ECS rolled that
|
|
1483
|
+
* deployment back. Every dependant's `DependsOn` names the barrier.
|
|
1484
|
+
*
|
|
1485
|
+
* A `DependsOn` on the owner service itself no longer orders this: since
|
|
1486
|
+
* 2026-10-01 CloudFormation starts the dependants of an updating ECS
|
|
1487
|
+
* service at its eventual-consistency check, 20-31 s into the update, while
|
|
1488
|
+
* a custom resource's dependants wait for its response (FJL-901).
|
|
1489
|
+
*
|
|
1490
|
+
* Shape (target exists, declares hook migrations, no self / cycle) is
|
|
1491
|
+
* already guaranteed by `validateEcsProps`.
|
|
1443
1492
|
*/
|
|
1444
1493
|
wireServiceMigrationDependencies(services) {
|
|
1445
1494
|
for (const svcConfig of services) {
|
|
1446
|
-
|
|
1495
|
+
const ownerName = svcConfig.awaitMigrationsFrom;
|
|
1496
|
+
if (ownerName === undefined)
|
|
1447
1497
|
continue;
|
|
1448
1498
|
const dependent = this.ecsCluster.getService(svcConfig.name);
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
dependent.node.addDependency(dependency);
|
|
1499
|
+
if (dependent === undefined) {
|
|
1500
|
+
throw new Error(`Service '${svcConfig.name}' not found in cluster after synthesis — cannot order its rollout.`);
|
|
1452
1501
|
}
|
|
1502
|
+
dependent.node.addDependency(this.rolloutBarrierOf(ownerName));
|
|
1453
1503
|
}
|
|
1454
1504
|
}
|
|
1505
|
+
/** The service's one rollout barrier, made on first use. */
|
|
1506
|
+
rolloutBarrierOf(serviceName) {
|
|
1507
|
+
const existing = this.rolloutBarriers.get(serviceName);
|
|
1508
|
+
if (existing !== undefined)
|
|
1509
|
+
return existing;
|
|
1510
|
+
const service = this.ecsCluster.getService(serviceName);
|
|
1511
|
+
if (service === undefined) {
|
|
1512
|
+
throw new Error(`Service '${serviceName}' is not a service of this cluster, so it has no rollout barrier.`);
|
|
1513
|
+
}
|
|
1514
|
+
const barrier = new EcsRolloutBarrier(this, `${toPascalCase(serviceName)}RolloutBarrier`, {
|
|
1515
|
+
service,
|
|
1516
|
+
serviceName,
|
|
1517
|
+
clusterName: this.clusterName,
|
|
1518
|
+
taskDefinitionArn: service.taskDefinition.taskDefinitionArn
|
|
1519
|
+
});
|
|
1520
|
+
this.rolloutBarriers.set(serviceName, barrier);
|
|
1521
|
+
return barrier;
|
|
1522
|
+
}
|
|
1455
1523
|
/**
|
|
1456
1524
|
* Synthesise a dedicated migration task definition for a lifecycle-hook
|
|
1457
1525
|
* migration when `separateTaskDef` is set. Creates the migration's own
|
|
@@ -1464,7 +1532,7 @@ export class EcsCompute extends Construct {
|
|
|
1464
1532
|
const idPrefix = `${toPascalCase(svcConfig.name)}Migrate`;
|
|
1465
1533
|
const containerName = migrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME;
|
|
1466
1534
|
const family = `${this.clusterId}-${svcConfig.name}-migrate`;
|
|
1467
|
-
const inheritedImage = service.taskDefinition
|
|
1535
|
+
const inheritedImage = primaryContainerDefinition(svcConfig, service.taskDefinition)?.imageName;
|
|
1468
1536
|
const image = resolveMigrationImage(svcConfig, migrations, inheritedImage);
|
|
1469
1537
|
const logGroup = new LogGroup(this, `${idPrefix}LogGroup`, {
|
|
1470
1538
|
retention: RetentionDays.ONE_MONTH,
|
|
@@ -1573,7 +1641,6 @@ export class EcsCompute extends Construct {
|
|
|
1573
1641
|
}
|
|
1574
1642
|
}));
|
|
1575
1643
|
}
|
|
1576
|
-
let securityGroupIds;
|
|
1577
1644
|
if (separateTaskDef.egressTo !== undefined &&
|
|
1578
1645
|
separateTaskDef.egressTo.length > 0) {
|
|
1579
1646
|
const migrationSg = new SecurityGroup(this, `${idPrefix}SecurityGroup`, {
|
|
@@ -1592,24 +1659,86 @@ export class EcsCompute extends Construct {
|
|
|
1592
1659
|
}
|
|
1593
1660
|
// Fargate agent pre-container 443 fetches; ECR API needs NAT egress.
|
|
1594
1661
|
migrationSg.addEgressRule(Peer.anyIpv4(), Port.tcp(443), "Fargate agent HTTPS for ECR / secrets / logs");
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
securityGroupIds = service.connections.securityGroups.map((sg) => sg.securityGroupId);
|
|
1662
|
+
// The hook reads the migration's own group here; without one, the
|
|
1663
|
+
// migration runs in the service's groups, from the revision it serves.
|
|
1664
|
+
Tags.of(taskDefinition).add(MIGRATION_SECURITY_GROUPS_TAG_KEY, migrationSg.securityGroupId, { includeResourceTypes: ["AWS::ECS::TaskDefinition"] });
|
|
1599
1665
|
}
|
|
1600
1666
|
this.migrationTaskDefinitions.set(svcConfig.name, taskDefinition);
|
|
1601
|
-
return
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1667
|
+
return taskDefinition;
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* The migration task definition of a lifecycle-hook migration without
|
|
1671
|
+
* `separateTaskDef`. It runs what the hook's `RunTask` of the service's
|
|
1672
|
+
* own task definition, its command and environment overridden, ran until
|
|
1673
|
+
* FJL-901: as the service's own roles, at the service's size, in the
|
|
1674
|
+
* service's log group, from the service's image, with the primary
|
|
1675
|
+
* container's environment, entry point and secrets beneath the
|
|
1676
|
+
* migration's own. A connected database's contributed secrets and role
|
|
1677
|
+
* policies, such as its schema administrator's, reach only a
|
|
1678
|
+
* `separateTaskDef` migration, whose roles are its own, so they never
|
|
1679
|
+
* widen the service's.
|
|
1680
|
+
*/
|
|
1681
|
+
synthesiseInheritedMigrationTaskDef(svcConfig, authored, migrations, service, schemaVersionEnv) {
|
|
1682
|
+
const idPrefix = `${toPascalCase(svcConfig.name)}Migrate`;
|
|
1683
|
+
const serviceTaskDefinition = service.taskDefinition;
|
|
1684
|
+
const executionRole = serviceTaskDefinition.executionRole;
|
|
1685
|
+
if (executionRole === undefined) {
|
|
1686
|
+
throw new Error(`Service '${svcConfig.name}': task definition has no execution role — its migration runs as the service's roles.`);
|
|
1687
|
+
}
|
|
1688
|
+
const logGroup = this.ecsCluster.getServiceLogGroup(svcConfig.name);
|
|
1689
|
+
if (logGroup === undefined) {
|
|
1690
|
+
throw new Error(`Service '${svcConfig.name}' has no log group after synthesis — cannot route its migration's logs.`);
|
|
1691
|
+
}
|
|
1692
|
+
const primary = primaryContainerConfig(svcConfig.containers);
|
|
1693
|
+
const taskDefinition = createMigrationTaskDefinition(this, `${idPrefix}TaskDefinition`, {
|
|
1694
|
+
family: `${this.clusterId}-${svcConfig.name}-migrate`,
|
|
1695
|
+
...taskSize(svcConfig),
|
|
1696
|
+
executionRole,
|
|
1697
|
+
taskRole: serviceTaskDefinition.taskRole
|
|
1698
|
+
});
|
|
1699
|
+
const image = resolveMigrationImage(svcConfig, migrations, primaryContainerDefinition(svcConfig, serviceTaskDefinition)?.imageName);
|
|
1700
|
+
const { secretsResolved } = this.resolveMigrationSecrets(svcConfig, { ...authored, ...secretsBeneathMigration(authored, primary) }, idPrefix);
|
|
1701
|
+
const containerEnvironment = {
|
|
1702
|
+
...(primary?.environment ?? {}),
|
|
1703
|
+
...(migrations.environment ?? {})
|
|
1607
1704
|
};
|
|
1705
|
+
if (schemaVersionEnv !== undefined) {
|
|
1706
|
+
for (const [key, value] of Object.entries(schemaVersionEnv)) {
|
|
1707
|
+
if (migrations.environment?.[key] === undefined) {
|
|
1708
|
+
containerEnvironment[key] = value;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
const entryPoint = migrations.entryPoint ?? primary?.entryPoint;
|
|
1713
|
+
taskDefinition.addContainer(`${idPrefix}Container`, {
|
|
1714
|
+
containerName: migrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME,
|
|
1715
|
+
image: ContainerImage.fromRegistry(image),
|
|
1716
|
+
command: migrations.command,
|
|
1717
|
+
...(entryPoint !== undefined && { entryPoint }),
|
|
1718
|
+
environment: containerEnvironment,
|
|
1719
|
+
secrets: secretsResolved,
|
|
1720
|
+
logging: new AwsLogDriver({
|
|
1721
|
+
streamPrefix: `/ecs-migrate/${svcConfig.name}`,
|
|
1722
|
+
logGroup
|
|
1723
|
+
})
|
|
1724
|
+
});
|
|
1725
|
+
return taskDefinition;
|
|
1608
1726
|
}
|
|
1609
1727
|
resolveMigrationSecrets(svcConfig, migrations, idPrefix) {
|
|
1610
1728
|
const secretsResolved = {};
|
|
1611
1729
|
const hasSecretsManagerSecrets = migrations.secretsImport !== undefined &&
|
|
1612
1730
|
Object.keys(migrations.secretsImport).length > 0;
|
|
1731
|
+
const secretsImport = migrations.secretsImport;
|
|
1732
|
+
const namedTwice = secretsImport === undefined
|
|
1733
|
+
? []
|
|
1734
|
+
: [...new Set(migrations.secrets ?? [])].filter((key) => Object.hasOwn(secretsImport, key));
|
|
1735
|
+
if (namedTwice.length > 0) {
|
|
1736
|
+
// Both kinds resolve into one record keyed by variable name, and the SSM
|
|
1737
|
+
// secret, resolved second, replaces the import, as before 39.0.6.
|
|
1738
|
+
Annotations.of(this).addWarning(`The migration of service '${svcConfig.name}' names ${namedTwice.join(", ")} ` +
|
|
1739
|
+
`in both secrets and secretsImport. The SSM secret is used and the ` +
|
|
1740
|
+
`secretsImport entry is not; name each variable once. Fjall 40 refuses this at synth.`);
|
|
1741
|
+
}
|
|
1613
1742
|
if (migrations.secretsImport) {
|
|
1614
1743
|
for (const [key, secretImport] of Object.entries(migrations.secretsImport)) {
|
|
1615
1744
|
const secret = resolveImportedSecret(this, `${idPrefix}${key}Secret`, secretImport);
|
|
@@ -1633,7 +1762,7 @@ export class EcsCompute extends Construct {
|
|
|
1633
1762
|
service: svcConfig.name
|
|
1634
1763
|
});
|
|
1635
1764
|
}
|
|
1636
|
-
for (const secretName of migrations.secrets) {
|
|
1765
|
+
for (const secretName of new Set(migrations.secrets)) {
|
|
1637
1766
|
validateSecretName(secretName, `migrations of service '${svcConfig.name}'`);
|
|
1638
1767
|
const param = StringParameter.fromSecureStringParameterAttributes(this, `${idPrefix}${secretName}SsmParam`, { parameterName: `${ssmPath}/${secretName}` });
|
|
1639
1768
|
secretsResolved[secretName] = EcsSecret.fromSsmParameter(param);
|
|
@@ -1675,11 +1804,30 @@ export class EcsCompute extends Construct {
|
|
|
1675
1804
|
* Get the migration task definition for a service. Returns `undefined` when
|
|
1676
1805
|
* the service has no `migrations: { mode: "lifecycle-hook", separateTaskDef }`
|
|
1677
1806
|
* configured — escape hatch for callers that need to attach grants (e.g.
|
|
1678
|
-
* `bucket.grantReadWrite(td.taskRole)`) to the migration task role.
|
|
1807
|
+
* `bucket.grantReadWrite(td.taskRole)`) to the migration task role. Without
|
|
1808
|
+
* `separateTaskDef` the migration runs in a task definition of its own that
|
|
1809
|
+
* this does not return: its task role is the service's, so a grant to it
|
|
1810
|
+
* would widen the service.
|
|
1679
1811
|
*/
|
|
1680
1812
|
getMigrationTaskDefinition(serviceName) {
|
|
1681
1813
|
return this.migrationTaskDefinitions.get(serviceName);
|
|
1682
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* The rollout barrier of a service: a custom resource that completes only
|
|
1817
|
+
* once ECS reports the service's deployment of its task definition
|
|
1818
|
+
* finished, and fails once ECS rolled that deployment back. A construct
|
|
1819
|
+
* that depends on it is created or updated only after the service has
|
|
1820
|
+
* rolled out, for orderings `awaitMigrationsFrom` cannot express:
|
|
1821
|
+
* `compute.getService("worker")?.node.addDependency(compute.getRolloutBarrier("app"))`.
|
|
1822
|
+
* A `DependsOn` on the service itself does not order this: CloudFormation
|
|
1823
|
+
* may start a service's dependants at its eventual-consistency check, long
|
|
1824
|
+
* before its deployment finishes (FJL-901). A service has one barrier,
|
|
1825
|
+
* shared with `awaitMigrationsFrom`; one the cluster does not run is
|
|
1826
|
+
* refused.
|
|
1827
|
+
*/
|
|
1828
|
+
getRolloutBarrier(serviceName) {
|
|
1829
|
+
return this.rolloutBarrierOf(serviceName);
|
|
1830
|
+
}
|
|
1683
1831
|
/** Get the security group for the cluster. */
|
|
1684
1832
|
getSecurityGroup() {
|
|
1685
1833
|
const sg = this.connections.securityGroups[0];
|