@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.
@@ -320,10 +320,13 @@ export interface EcsInitContainerMigrationsConfig {
320
320
  * deployment lifecycle hook (PRE_SCALE_UP). The service rollout BLOCKS until
321
321
  * the migration succeeds; one task per deploy.
322
322
  *
323
- * When `separateTaskDef` is present, `wireLifecycleHookMigrations` synthesises
324
- * a SEPARATE migration task definition (its own IAM role, log group, runtime
325
- * platform) instead of using `ContainerOverrides` on the service task def.
326
- * Required when migrations need permissions the service doesn't.
323
+ * The migration runs from a task definition of its own, which the service's
324
+ * task definition names in a tag. The hook reads that tag from the revision
325
+ * each deployment targets, so it always runs the migration that deployment's
326
+ * code was built against. With `separateTaskDef` that task definition has
327
+ * its own IAM roles, log group and size; without it, it runs as the
328
+ * service's. Use `separateTaskDef` when migrations need permissions or
329
+ * credentials the service doesn't.
327
330
  */
328
331
  export interface EcsLifecycleHookMigrationsConfig {
329
332
  /** Required for the lifecycle-hook variant. */
@@ -333,8 +336,7 @@ export interface EcsLifecycleHookMigrationsConfig {
333
336
  /** Override timeout in seconds. Defaults to 300 (5 min). Capped at 900s per Lambda invocation; longer migrations use the IN_PROGRESS callback chain. */
334
337
  timeoutSeconds?: number;
335
338
  /**
336
- * Override image URI (string only). CDK `Repository` constructs cannot be
337
- * JSON-serialised into the runner Lambda's `MIGRATE_CONFIG` env var; synth
339
+ * Override image URI (string only): the migration container's image. Synth
338
340
  * throws if a `Repository` is passed in lifecycle-hook mode.
339
341
  */
340
342
  image?: string;
@@ -344,7 +346,7 @@ export interface EcsLifecycleHookMigrationsConfig {
344
346
  secretsImport?: Record<string, SecretImport>;
345
347
  /** SSM Parameter Store secret keys. */
346
348
  secrets?: string[];
347
- /** Synthetic container name. Defaults to `"migrate"`. */
349
+ /** The migration container's name. Defaults to `"migrate"`. */
348
350
  name?: string;
349
351
  /**
350
352
  * Container entrypoint. Defaults to inheriting from the image. Set to
@@ -353,14 +355,19 @@ export interface EcsLifecycleHookMigrationsConfig {
353
355
  */
354
356
  entryPoint?: string[];
355
357
  /**
356
- * When present, synthesise a SEPARATE migration task definition (its own
357
- * IAM role, log group, runtime platform) instead of using `ContainerOverrides`
358
- * on the service task def. Required when migrations need permissions the
359
- * service doesn't (e.g. `rds:CreateDBSnapshot`, cross-service Secrets Manager
360
- * reads) — preserves least-privilege on the service task role.
358
+ * When present, the migration's task definition has its own IAM roles,
359
+ * log group and size, and with `egressTo` its own security group. Required
360
+ * when migrations need permissions the service doesn't (e.g.
361
+ * `rds:CreateDBSnapshot`, cross-service Secrets Manager reads), or a
362
+ * connected database's contributed credentials and policies, such as its
363
+ * schema administrator's — preserves least-privilege on the service task
364
+ * role.
361
365
  *
362
- * When absent, the lifecycle-hook runner uses the service's task def via
363
- * ContainerOverrides — existing behaviour, unchanged.
366
+ * When absent, the migration's task definition runs as the service's own
367
+ * roles, at its size, in its log group, from its image, with its primary
368
+ * container's environment, entry point and secrets beneath the
369
+ * migration's own. A connected database's contributed secrets and
370
+ * policies are not added: they would widen the service's roles.
364
371
  */
365
372
  separateTaskDef?: EcsLifecycleHookMigrationsSeparateTaskDef;
366
373
  }
@@ -394,7 +401,7 @@ export interface EcsPostDeployMigrationsConfig {
394
401
  secretsImport?: Record<string, SecretImport>;
395
402
  /** SSM Parameter Store secret keys. */
396
403
  secrets?: string[];
397
- /** Synthetic container name. Defaults to `"migrate"`. */
404
+ /** The migration container's name. Defaults to `"migrate"`. */
398
405
  name?: string;
399
406
  /** Container entrypoint. Defaults to inheriting from the image. */
400
407
  entryPoint?: string[];
@@ -910,14 +917,28 @@ export interface EcsServiceConfig {
910
917
  * Names another service in the same cluster whose **lifecycle-hook** (or
911
918
  * post-deploy) migrations must finish before this service rolls out.
912
919
  *
913
- * Implemented as a native CloudFormation `DependsOn`: CFN will not begin
914
- * creating or updating this service until the named service's ECS deployment
915
- * — including its PRE_SCALE_UP migrate hook — has completed. Use it for
916
- * DB-connecting workers that must not start until the migrate-owning service
917
- * has applied role / grant / schema migrations; without it, a worker can
918
- * attempt RDS-IAM auth before the migrate task has run `GRANT rds_iam` (the
919
- * race that aborts a migration-bearing deploy). Also enables a single-pass
920
- * greenfield deploy (app migrates, then workers come up, in one `cdk deploy`).
920
+ * Implemented as the named service's rollout barrier: a custom resource
921
+ * that completes only once ECS reports the named service's deployment of
922
+ * its task definition finished, its migrate hook included, and fails once
923
+ * ECS rolled that deployment back. This service's CloudFormation
924
+ * `DependsOn` names the barrier, so CloudFormation does not begin creating
925
+ * or updating this service until the named service has rolled out. Use it
926
+ * for DB-connecting workers that must not start until the migrate-owning
927
+ * service has applied role / grant / schema migrations; without it, a
928
+ * worker can attempt RDS-IAM auth before the migrate task has run
929
+ * `GRANT rds_iam` (the race that aborts a migration-bearing deploy).
930
+ *
931
+ * A `DependsOn` on the named service itself would not order this:
932
+ * CloudFormation may start a service's dependants at its
933
+ * eventual-consistency check, long before its ECS deployment has finished
934
+ * (measured in us-east-1 from 2026-10-01).
935
+ *
936
+ * The barrier waits only when the named service's task definition changes,
937
+ * which a new image or schema version does, so a deploy that changes
938
+ * neither orders nothing. It holds only for deploys CloudFormation runs:
939
+ * Fjall's code-only deploy and its rollback register a task definition and
940
+ * update the service directly. It fails a rollout that takes longer than
941
+ * an hour, CloudFormation's limit for a custom resource.
921
942
  *
922
943
  * Works for EC2-capacity services — it is a resource-ordering edge, not a
923
944
  * worker-side lifecycle hook, so the FARGATE-only restriction on hook
@@ -928,8 +949,11 @@ export interface EcsServiceConfig {
928
949
  * single completion point to await. Validated at synth (target exists,
929
950
  * declares hook migrations, no self-reference, no cycle).
930
951
  *
931
- * Escape hatch for orderings this field can't express: drop to the L2 API,
932
- * `cluster.getService("x")?.node.addDependency(cluster.getService("y")!)`.
952
+ * Escape hatch for orderings this field can't express: depend on the
953
+ * service's rollout barrier,
954
+ * `compute.getService("x")?.node.addDependency(compute.getRolloutBarrier("y"))`.
955
+ * A `DependsOn` between the services themselves does not order their
956
+ * rollouts.
933
957
  *
934
958
  * @example
935
959
  * awaitMigrationsFrom: "app"
@@ -4,6 +4,7 @@ import { Construct } from "constructs";
4
4
  import type { StackBuilder } from "../base/awsStack.js";
5
5
  import { Grant, type IGrantable } from "aws-cdk-lib/aws-iam";
6
6
  import type { ApplicationListener, ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
7
+ import type { ILogGroup } from "aws-cdk-lib/aws-logs";
7
8
  import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
8
9
  import { type EcsClusterProps } from "./ecsTypes.js";
9
10
  import { type EcsIngressProfile } from "./ingressProfile.js";
@@ -96,6 +97,8 @@ export default class EcsCluster extends Construct implements IConnectable {
96
97
  getService(name: string): FargateService | Ec2Service | undefined;
97
98
  /** Get all services in this cluster. */
98
99
  getServices(): Map<string, FargateService | Ec2Service>;
100
+ /** The log group every container of the named service writes to. */
101
+ getServiceLogGroup(serviceName: string): ILogGroup | undefined;
99
102
  /**
100
103
  * Get the task definition for a service or a registered scheduled task.
101
104
  * Used by Schedule / Subscription targets to construct the EcsTask adapter
@@ -226,6 +226,10 @@ export default class EcsCluster extends Construct {
226
226
  }
227
227
  return result;
228
228
  }
229
+ /** The log group every container of the named service writes to. */
230
+ getServiceLogGroup(serviceName) {
231
+ return this.services.get(serviceName)?.logGroup;
232
+ }
229
233
  /**
230
234
  * Get the task definition for a service or a registered scheduled task.
231
235
  * Used by Schedule / Subscription targets to construct the EcsTask adapter
@@ -1,77 +1,38 @@
1
- import { type BaseService, DeploymentLifecycleStage } from "aws-cdk-lib/aws-ecs";
1
+ import { type BaseService, type ContainerDefinition, DeploymentLifecycleStage, type TaskDefinition } from "aws-cdk-lib/aws-ecs";
2
2
  import { Construct } from "constructs";
3
3
  import { LambdaFunction } from "./lambda.js";
4
- /**
5
- * Awsvpc network configuration consumed by the runner's `RunTask` call.
6
- */
7
- export interface LifecycleHookNetworkConfiguration {
8
- subnetIds: string[];
9
- securityGroupIds: string[];
10
- assignPublicIp: boolean;
11
- }
12
- /**
13
- * Optional nested block describing a separate migration task definition.
14
- *
15
- * Three lockstep fields enforced by the type system — present-as-a-group or
16
- * absent-as-a-group. This shape is the resources-layer mirror of
17
- * `EcsLifecycleHookMigrationsSeparateTaskDef` at the patterns layer.
18
- */
19
- export interface MigrationTaskDef {
20
- /** ARN of the migration task definition (different from the service task def). */
21
- definitionArn: string;
22
- /** Family of the migration task definition — used to scope `ecs:RunTask`. */
23
- family: string;
24
- /** Task role ARN for the migration task. */
25
- taskRoleArn: string;
26
- /** Execution role ARN for the migration task. */
27
- executionRoleArn: string;
28
- }
29
4
  export interface EcsLifecycleHookMigrationProps {
30
5
  /**
31
6
  * The ECS service this hook fires for.
32
7
  */
33
8
  service: BaseService;
9
+ /**
10
+ * The container whose definition carries the migration's label: the
11
+ * service's primary container. Defaults to the task definition's default
12
+ * container, its first essential one.
13
+ */
14
+ primaryContainer?: ContainerDefinition;
15
+ /** The service's physical name, as a literal. */
16
+ serviceName: string;
17
+ /** The physical name of the service's cluster, as a literal. */
18
+ clusterName: string;
34
19
  /** Cluster ARN — used for both `ecs:RunTask`'s cluster condition and ListTasks scoping. */
35
20
  clusterArn: string;
36
- /** ARN of the task definition the migrate task launches from. */
37
- taskDefinitionArn: string;
38
- /** Family of the task definition — used in the `ecs:RunTask` resource ARN. */
39
- taskDefinitionFamily: string;
40
- /** Task execution role ARN (the role pulling the image, writing logs). */
41
- taskExecutionRoleArn: string;
42
- /** Task role ARN (the role the running container assumes). */
43
- taskRoleArn: string;
44
- /** Migration command, passed through to the runner. */
45
- command: string[];
46
- /** Synthetic container name used in `RunTask` overrides. */
47
- containerName: string;
48
- /** Optional image override (else the task definition's image is used). */
49
- image?: string;
50
21
  /**
51
- * Optional container entrypoint override (else inherited from the image).
52
- * Lets the patterns layer carry `["/usr/bin/tini", "--"]` shapes through to
53
- * the runner's `ContainerOverrides` block.
22
+ * The task definition the migration runs: its container carries the
23
+ * command, entry point and environment. The service's task definition
24
+ * names it in its `fjall:migration-task-definition` tag, which the hook
25
+ * reads from the revision each deployment targets, and in its primary
26
+ * container's docker label, which the hook reads when that revision
27
+ * carries no tag.
54
28
  */
55
- entryPoint?: string[];
56
- /** Optional environment override. */
57
- environment?: Record<string, string>;
29
+ migrationTaskDefinition: TaskDefinition;
58
30
  /**
59
31
  * Lambda timeout in seconds. Defaults to 300, capped at 900 (Lambda max).
60
32
  * Should comfortably exceed the migration command's expected runtime
61
33
  * including a few re-invocation cycles.
62
34
  */
63
35
  timeoutSeconds?: number;
64
- /** Awsvpc network configuration for the migrate task. */
65
- networkConfiguration: LifecycleHookNetworkConfiguration;
66
- /**
67
- * Optional separate migration task definition. When present, `RunTask`
68
- * targets THIS task def instead of the service's, and the Lambda's IAM
69
- * policy expands to cover both task-def families + both role pairs.
70
- *
71
- * When absent, behaviour is unchanged — the migration runs in the service
72
- * task def via `ContainerOverrides`.
73
- */
74
- migrationTaskDef?: MigrationTaskDef;
75
36
  /**
76
37
  * Which deployment lifecycle stage to fire at. Defaults to
77
38
  * `PRE_SCALE_UP` (migration runs BEFORE the new revision scales up;
@@ -84,11 +45,20 @@ export interface EcsLifecycleHookMigrationProps {
84
45
  lifecycleStage?: DeploymentLifecycleStage;
85
46
  }
86
47
  /**
87
- * ECS deployment lifecycle hook (PRE_SCALE_UP) that runs migrations as a
88
- * one-off `RunTask` before the new revision scales up. Self-wires:
89
- * synthesises the Lambda + IAM + LogGroup, then attaches the hook to the
90
- * service via the typed CDK API. CDK auto-generates the hook-invocation
91
- * role.
48
+ * ECS deployment lifecycle hook that runs a service's migration as a one-off
49
+ * `RunTask` of its migration task definition, before the new revision scales
50
+ * up (or after, for post-deploy work). Self-wires: synthesises the Lambda +
51
+ * IAM + LogGroup and the role ECS assumes to invoke the hook, stamps the
52
+ * service's task definition with the migration task definition it runs, and
53
+ * attaches the hook to the service via the typed CDK API.
54
+ *
55
+ * The hook reads everything it runs from the deployment it serves (FJL-901):
56
+ * the target revision's cluster, task definition and network, and the
57
+ * migration the task definition's tag names, or its primary container's
58
+ * docker label where a copy carries no tag. Its function has no
59
+ * environment, so it changes only when its code does, never on an image
60
+ * deploy, and its own update can never pair one release's migration with
61
+ * another's code.
92
62
  */
93
63
  export declare class EcsLifecycleHookMigration extends Construct {
94
64
  readonly lambda: LambdaFunction;
@@ -1,10 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { Stack } from "aws-cdk-lib";
4
+ import { MIGRATION_TASK_DEFINITION_LABEL_KEY, MIGRATION_TASK_DEFINITION_TAG_KEY } from "@fjall/util/migration";
5
+ import { ArnFormat, Stack, Tags } from "aws-cdk-lib";
5
6
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
6
- import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
7
7
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
8
+ import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
8
9
  import { DeploymentLifecycleLambdaTarget, DeploymentLifecycleStage } from "aws-cdk-lib/aws-ecs";
9
10
  import { Construct } from "constructs";
10
11
  import { LambdaFunction } from "./lambda.js";
@@ -13,11 +14,20 @@ const LAMBDA_SOURCE_FILE = "lifecycleHookLambda.source.cjs";
13
14
  const LAMBDA_TIMEOUT_DEFAULT_SECONDS = 300;
14
15
  const LAMBDA_TIMEOUT_MAX_SECONDS = 900;
15
16
  /**
16
- * ECS deployment lifecycle hook (PRE_SCALE_UP) that runs migrations as a
17
- * one-off `RunTask` before the new revision scales up. Self-wires:
18
- * synthesises the Lambda + IAM + LogGroup, then attaches the hook to the
19
- * service via the typed CDK API. CDK auto-generates the hook-invocation
20
- * role.
17
+ * ECS deployment lifecycle hook that runs a service's migration as a one-off
18
+ * `RunTask` of its migration task definition, before the new revision scales
19
+ * up (or after, for post-deploy work). Self-wires: synthesises the Lambda +
20
+ * IAM + LogGroup and the role ECS assumes to invoke the hook, stamps the
21
+ * service's task definition with the migration task definition it runs, and
22
+ * attaches the hook to the service via the typed CDK API.
23
+ *
24
+ * The hook reads everything it runs from the deployment it serves (FJL-901):
25
+ * the target revision's cluster, task definition and network, and the
26
+ * migration the task definition's tag names, or its primary container's
27
+ * docker label where a copy carries no tag. Its function has no
28
+ * environment, so it changes only when its code does, never on an image
29
+ * deploy, and its own update can never pair one release's migration with
30
+ * another's code.
21
31
  */
22
32
  export class EcsLifecycleHookMigration extends Construct {
23
33
  lambda;
@@ -25,62 +35,81 @@ export class EcsLifecycleHookMigration extends Construct {
25
35
  super(scope, id);
26
36
  const stack = Stack.of(scope);
27
37
  const timeoutSeconds = Math.min(props.timeoutSeconds ?? LAMBDA_TIMEOUT_DEFAULT_SECONDS, LAMBDA_TIMEOUT_MAX_SECONDS);
28
- const sourcePath = path.resolve(__dirname, LAMBDA_SOURCE_FILE);
29
- const source = readFileSync(sourcePath, "utf-8");
30
- const migrationTaskDef = props.migrationTaskDef;
31
- const targetTaskDefinitionArn = migrationTaskDef?.definitionArn ?? props.taskDefinitionArn;
32
- const runTaskResources = [
33
- `arn:${stack.partition}:ecs:${stack.region}:${stack.account}:task-definition/${props.taskDefinitionFamily}:*`
34
- ];
35
- if (migrationTaskDef !== undefined) {
36
- runTaskResources.push(`arn:${stack.partition}:ecs:${stack.region}:${stack.account}:task-definition/${migrationTaskDef.family}:*`);
38
+ const migration = props.migrationTaskDefinition;
39
+ const ecsArn = (resource, resourceName) => stack.formatArn({
40
+ service: "ecs",
41
+ resource,
42
+ resourceName,
43
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME
44
+ });
45
+ // Literal names, never the service's attributes: the service names this
46
+ // function as its hook target, so a policy that referenced the service
47
+ // would close a cycle.
48
+ const serviceKey = `${props.clusterName}/${props.serviceName}`;
49
+ const familyRevisions = (family) => `arn:${stack.partition}:ecs:${stack.region}:${stack.account}:task-definition/${family}:*`;
50
+ // The tag is a reference, so the service's task definition registers
51
+ // after the migration's. The hook reads the tag, and the label on a
52
+ // revision that carries no tag. A tag alone updates in place, on
53
+ // CloudFormation's own revision only, so a revision a restart or a
54
+ // code-only deploy copied from it would go on naming a migration task
55
+ // definition CloudFormation then deregisters. The label carries the same
56
+ // reference in the container definitions, which no revision can change:
57
+ // a change to the migration alone registers a new revision of the
58
+ // service's task definition, the deploy rolls the service onto it, and
59
+ // every lane that copies a revision copies the live one's label and tag.
60
+ Tags.of(props.service.taskDefinition).add(MIGRATION_TASK_DEFINITION_TAG_KEY, migration.taskDefinitionArn, { includeResourceTypes: ["AWS::ECS::TaskDefinition"] });
61
+ const primary = props.primaryContainer ?? props.service.taskDefinition.defaultContainer;
62
+ if (primary === undefined) {
63
+ throw new Error(`EcsLifecycleHookMigration: the task definition of service "${props.serviceName}" has no essential container to carry its migration's label`);
37
64
  }
38
- const passRoleResources = [props.taskExecutionRoleArn, props.taskRoleArn];
39
- if (migrationTaskDef !== undefined) {
40
- passRoleResources.push(migrationTaskDef.taskRoleArn, migrationTaskDef.executionRoleArn);
65
+ primary.addDockerLabel(MIGRATION_TASK_DEFINITION_LABEL_KEY, migration.taskDefinitionArn);
66
+ const passRoleResources = [migration.taskRole.roleArn];
67
+ if (migration.executionRole !== undefined) {
68
+ passRoleResources.push(migration.executionRole.roleArn);
41
69
  }
42
- // ECS RunTask awsvpcConfiguration uses subnets/securityGroups and
43
- // assignPublicIp as "ENABLED"/"DISABLED" string, not boolean.
44
- const awsvpcConfiguration = {
45
- subnets: props.networkConfiguration.subnetIds,
46
- securityGroups: props.networkConfiguration.securityGroupIds,
47
- assignPublicIp: props.networkConfiguration.assignPublicIp
48
- ? "ENABLED"
49
- : "DISABLED"
50
- };
51
70
  this.lambda = new LambdaFunction(this, `${id}Fn`, {
52
71
  runtime: Runtime.NODEJS_24_X,
53
72
  handler: "index.handler",
54
- code: Code.fromInline(source),
73
+ code: Code.fromInline(readFileSync(path.resolve(__dirname, LAMBDA_SOURCE_FILE), "utf-8")),
55
74
  lambdaDescription: `${id} ECS deployment lifecycle hook for migration`,
56
75
  roleDescription: `Execution role for ${id} migration lifecycle-hook runner`,
57
76
  timeout: timeoutSeconds,
58
77
  memorySize: 256,
59
78
  logGroupRetention: RetentionDays.ONE_MONTH,
60
- environment: {
61
- MIGRATE_CONFIG: JSON.stringify({
62
- clusterArn: props.clusterArn,
63
- taskDefinitionArn: targetTaskDefinitionArn,
64
- name: props.containerName,
65
- command: props.command,
66
- ...(props.image !== undefined ? { image: props.image } : {}),
67
- ...(props.entryPoint !== undefined
68
- ? { entryPoint: props.entryPoint }
69
- : {}),
70
- ...(props.environment !== undefined
71
- ? { environment: props.environment }
72
- : {}),
73
- networkConfiguration: awsvpcConfiguration,
74
- ...(migrationTaskDef !== undefined
75
- ? { hasSeparateMigrationTaskDef: true }
76
- : {})
77
- })
78
- },
79
79
  inlinePolicy: [
80
+ new PolicyStatement({
81
+ effect: Effect.ALLOW,
82
+ actions: ["ecs:DescribeServiceRevisions"],
83
+ resources: [
84
+ ecsArn("service", serviceKey),
85
+ ecsArn("service-revision", `${serviceKey}/*`)
86
+ ]
87
+ }),
88
+ new PolicyStatement({
89
+ effect: Effect.ALLOW,
90
+ actions: ["ecs:ListTagsForResource"],
91
+ resources: [
92
+ familyRevisions(props.service.taskDefinition.family),
93
+ familyRevisions(migration.family)
94
+ ]
95
+ }),
96
+ // The label fallback reads a copied revision's container definitions.
97
+ // DescribeTaskDefinition takes no resource type (AWS service reference,
98
+ // ecs.json, read 2026-10-03), so it cannot be scoped to the family.
99
+ // The revision ECS hands the hook is in the stack's own Region, so the
100
+ // read is held to it.
101
+ new PolicyStatement({
102
+ effect: Effect.ALLOW,
103
+ actions: ["ecs:DescribeTaskDefinition"],
104
+ resources: ["*"],
105
+ conditions: {
106
+ StringEquals: { "aws:RequestedRegion": stack.region }
107
+ }
108
+ }),
80
109
  new PolicyStatement({
81
110
  effect: Effect.ALLOW,
82
111
  actions: ["ecs:RunTask"],
83
- resources: runTaskResources,
112
+ resources: [familyRevisions(migration.family)],
84
113
  conditions: {
85
114
  ArnEquals: { "ecs:cluster": props.clusterArn }
86
115
  }
@@ -105,9 +134,26 @@ export class EcsLifecycleHookMigration extends Construct {
105
134
  })
106
135
  ]
107
136
  });
137
+ // Without a role the target creates one while the service renders, after
138
+ // every aspect has run, so no tag and no removal marker ever reach it. The
139
+ // scope and id are the target's own, so the logical id does not move.
140
+ const hookRole = new Role(props.service, `${id}Role`, {
141
+ assumedBy: new ServicePrincipal("ecs.amazonaws.com")
142
+ });
143
+ this.lambda.grantInvoke(hookRole);
144
+ // The hook names the role, which orders the service after the role but
145
+ // not after the grant in the role's own policy, so a create could start
146
+ // the service's first deployment, and ECS invoke the hook, before the
147
+ // grant exists (FJL-1029).
148
+ const serviceResource = props.service.node.defaultChild;
149
+ if (serviceResource === undefined) {
150
+ throw new Error(`EcsLifecycleHookMigration: service "${props.serviceName}" has no CloudFormation resource to order after its hook role's grant`);
151
+ }
152
+ serviceResource.node.addDependency(hookRole);
108
153
  const lifecycleStage = props.lifecycleStage ?? DeploymentLifecycleStage.PRE_SCALE_UP;
109
154
  props.service.addLifecycleHook(new DeploymentLifecycleLambdaTarget(this.lambda, id, {
110
- lifecycleStages: [lifecycleStage]
155
+ lifecycleStages: [lifecycleStage],
156
+ role: hookRole
111
157
  }));
112
158
  }
113
159
  }
@@ -0,0 +1,61 @@
1
+ import { CustomResource } from "aws-cdk-lib";
2
+ import type { BaseService } from "aws-cdk-lib/aws-ecs";
3
+ import { Construct, type IDependable } from "constructs";
4
+ /**
5
+ * The stack's one rollout-barrier provider: the `onEvent` and `isComplete`
6
+ * handlers, from one inline source, and the provider framework that asks
7
+ * `isComplete` every 15 s for at most an hour. Each barrier grants the
8
+ * `isComplete` role the reads of its own service only; the role also reads
9
+ * its own stack's status, to complete at once on a rollback.
10
+ *
11
+ * The framework's waiter state machine logs at FATAL, without execution
12
+ * data, to a group named under `/aws/vendedlogs/states/`
13
+ * (`waiterLogGroupName`). Being named, the group is destroyed with its stack
14
+ * whatever the stage: a group a rollback retained would collide with the one
15
+ * the next upgrade creates under the same name.
16
+ */
17
+ export declare class EcsRolloutBarrierProvider extends Construct {
18
+ /** The stack's provider, made on first use. */
19
+ static of(scope: Construct): EcsRolloutBarrierProvider;
20
+ readonly serviceToken: string;
21
+ private readonly isCompleteHandler;
22
+ private readonly granted;
23
+ private constructor();
24
+ /**
25
+ * The reads `isComplete` makes of one service, scoped to it: its
26
+ * description, its deployments and their revisions (AWS service reference,
27
+ * ecs.json, 2026-10-01). Granted once a service.
28
+ *
29
+ * The ARNs are built from the literal cluster and service names, never from
30
+ * the service's attributes: the grant lands on the one role every barrier in
31
+ * the stack shares, so a reference to a service would make each barrier
32
+ * wait for every owner, and an owner that itself awaits another owner would
33
+ * close a cycle.
34
+ */
35
+ grantRolloutReads(clusterName: string, serviceName: string): void;
36
+ }
37
+ export interface EcsRolloutBarrierProps {
38
+ /** The service whose rollout the barrier waits for. */
39
+ readonly service: BaseService;
40
+ /** The service's physical name, as a literal. */
41
+ readonly serviceName: string;
42
+ /** The physical name of the service's cluster, as a literal. */
43
+ readonly clusterName: string;
44
+ /**
45
+ * The task definition the service deploys. It changes whenever the image
46
+ * or the expected schema version does, which is exactly when CloudFormation
47
+ * invokes the barrier.
48
+ */
49
+ readonly taskDefinitionArn: string;
50
+ }
51
+ /**
52
+ * A custom resource that completes only once ECS reports the service's
53
+ * deployment of its task definition finished, and fails once ECS rolled it
54
+ * back (FJL-901). Its dependants therefore wait for the rollout, PRE_SCALE_UP
55
+ * migrate hook included, whatever CloudFormation's own stabilisation of the
56
+ * service reports. Delete only acknowledges.
57
+ */
58
+ export declare class EcsRolloutBarrier extends Construct implements IDependable {
59
+ readonly resource: CustomResource;
60
+ constructor(scope: Construct, id: string, props: EcsRolloutBarrierProps);
61
+ }