@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.
@@ -1,7 +1,7 @@
1
1
  import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, NetworkMode, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
2
- import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
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.find((c) => c.port !== undefined) ?? containers[0];
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
- const mergedSecrets = { ...contributedSecrets };
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: c.name || `${service.name}Container${index > 0 ? index : ""}`,
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?.find((c) => c.port !== undefined) ??
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}': migrations.separateTaskDef requires a string image URI ` +
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}': migrations.separateTaskDef requires a resolvable image — ` +
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 DependsOn is the
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 to order the ` +
1346
- `rollouts, or set schemaGate: { materialise: false }.`);
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 Lambda + IAM role + log group that backs the deployment
1354
- * lifecycle hook. The init-container path is unaffected — services without
1355
- * `mode` (or with `mode: "init-container"`) still get the synthetic
1356
- * migrate container injected by `expandMigrationsSugar`.
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
- const hasNat = vpcHasNatGateways(cluster.vpc);
1384
- const subnets = cluster.vpc.selectSubnets({
1385
- subnetType: hasNat ? SubnetType.PRIVATE_WITH_EGRESS : SubnetType.PUBLIC
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 migrationTaskDef = effectiveSeparateTaskDef !== undefined
1461
+ const migrationTaskDefinition = effectiveSeparateTaskDef !== undefined
1399
1462
  ? this.synthesiseMigrationTaskDef(svcConfig, effectiveMigrations, effectiveSeparateTaskDef, service, cluster.vpc, schemaVersionEnv)
1400
- : undefined;
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
- taskDefinitionArn: taskDefinition.taskDefinitionArn,
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
- * Wire a native CloudFormation `DependsOn` from each service that declares
1436
- * `awaitMigrationsFrom` to the named migrate-owning service, so CFN does not
1437
- * roll out the dependent service until the target's ECS deployment — including
1438
- * its PRE_SCALE_UP migrate hook — has completed. Prevents the RDS-IAM auth /
1439
- * schema-mismatch race when a DB-connecting worker starts before the app's
1440
- * migrations have applied, and enables a single-pass greenfield deploy.
1441
- * Shape (target exists, declares hook migrations, no self / cycle) is already
1442
- * guaranteed by `validateEcsProps`; the undefined guards are synth invariants.
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
- if (svcConfig.awaitMigrationsFrom === undefined)
1495
+ const ownerName = svcConfig.awaitMigrationsFrom;
1496
+ if (ownerName === undefined)
1447
1497
  continue;
1448
1498
  const dependent = this.ecsCluster.getService(svcConfig.name);
1449
- const dependency = this.ecsCluster.getService(svcConfig.awaitMigrationsFrom);
1450
- if (dependent !== undefined && dependency !== undefined) {
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.defaultContainer?.imageName;
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
- securityGroupIds = [migrationSg.securityGroupId];
1596
- }
1597
- else {
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
- taskDefinitionArn: taskDefinition.taskDefinitionArn,
1603
- family: taskDefinition.family,
1604
- taskRoleArn: taskRole.roleArn,
1605
- executionRoleArn: executionRole.roleArn,
1606
- securityGroupIds
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];