@fjall/components-infrastructure 0.96.0 → 0.99.1

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.
Files changed (209) hide show
  1. package/dist/lib/app.d.ts +68 -1
  2. package/dist/lib/app.js +113 -4
  3. package/dist/lib/config/aws/__t17fixture.d.ts +1 -0
  4. package/dist/lib/config/aws/__t17fixture.js +3 -0
  5. package/dist/lib/config/aws/__t17fixtureType.d.ts +2 -0
  6. package/dist/lib/config/aws/__t17fixtureType.js +1 -0
  7. package/dist/lib/config/aws/alarmTopic.js +8 -4
  8. package/dist/lib/config/aws/cloudTrail.js +1 -1
  9. package/dist/lib/config/aws/disasterRecovery.js +11 -16
  10. package/dist/lib/config/aws/ecrDefaultImage.d.ts +0 -1
  11. package/dist/lib/config/aws/ecrDefaultImage.js +13 -23
  12. package/dist/lib/config/aws/identityCenter.d.ts +10 -3
  13. package/dist/lib/config/aws/identityCenter.js +101 -37
  14. package/dist/lib/config/aws/identityCenterGroupMembership.js +8 -2
  15. package/dist/lib/config/aws/identityCenterMembership.d.ts +11 -0
  16. package/dist/lib/config/aws/identityCenterMembership.js +61 -0
  17. package/dist/lib/config/aws/index.d.ts +1 -1
  18. package/dist/lib/config/aws/index.js +1 -1
  19. package/dist/lib/config/aws/ipam.js +6 -11
  20. package/dist/lib/config/aws/oidcConnector.js +5 -1
  21. package/dist/lib/config/aws/scpPreset.js +4 -1
  22. package/dist/lib/patterns/aws/_eslint_test_tmp/leak.d.ts +1 -0
  23. package/dist/lib/patterns/aws/_eslint_test_tmp/leak.js +4 -0
  24. package/dist/lib/patterns/aws/account.js +2 -4
  25. package/dist/lib/patterns/aws/apexDomainPattern.js +10 -10
  26. package/dist/lib/patterns/aws/bastionFactory.d.ts +10 -0
  27. package/dist/lib/patterns/aws/bastionFactory.js +29 -0
  28. package/dist/lib/patterns/aws/buildkite.d.ts +2 -2
  29. package/dist/lib/patterns/aws/buildkite.js +51 -97
  30. package/dist/lib/patterns/aws/cdn.js +1 -1
  31. package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +172 -0
  32. package/dist/lib/patterns/aws/clickhouseDatabase.js +600 -0
  33. package/dist/lib/patterns/aws/compute.d.ts +4 -6
  34. package/dist/lib/patterns/aws/compute.js +7 -13
  35. package/dist/lib/patterns/aws/computeEcs.d.ts +93 -5
  36. package/dist/lib/patterns/aws/computeEcs.js +867 -37
  37. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +528 -25
  38. package/dist/lib/patterns/aws/computeEcsTypes.js +10 -0
  39. package/dist/lib/patterns/aws/computeLambda.d.ts +0 -5
  40. package/dist/lib/patterns/aws/computeLambda.js +1 -2
  41. package/dist/lib/patterns/aws/database.d.ts +50 -8
  42. package/dist/lib/patterns/aws/database.js +183 -27
  43. package/dist/lib/patterns/aws/domain.js +6 -4
  44. package/dist/lib/patterns/aws/index.d.ts +1 -0
  45. package/dist/lib/patterns/aws/index.js +1 -0
  46. package/dist/lib/patterns/aws/interfaces/compute.d.ts +7 -1
  47. package/dist/lib/patterns/aws/interfaces/database.d.ts +187 -8
  48. package/dist/lib/patterns/aws/interfaces/database.js +17 -3
  49. package/dist/lib/patterns/aws/interfaces/index.d.ts +2 -1
  50. package/dist/lib/patterns/aws/interfaces/index.js +3 -1
  51. package/dist/lib/patterns/aws/interfaces/messaging.d.ts +7 -0
  52. package/dist/lib/patterns/aws/interfaces/migrationContributor.d.ts +47 -0
  53. package/dist/lib/patterns/aws/interfaces/migrationContributor.js +9 -0
  54. package/dist/lib/patterns/aws/messaging.d.ts +66 -10
  55. package/dist/lib/patterns/aws/messaging.js +115 -20
  56. package/dist/lib/patterns/aws/network.js +16 -7
  57. package/dist/lib/patterns/aws/organisation.d.ts +4 -0
  58. package/dist/lib/patterns/aws/organisation.js +22 -4
  59. package/dist/lib/patterns/aws/storage.d.ts +1 -2
  60. package/dist/lib/patterns/aws/storage.js +3 -2
  61. package/dist/lib/patterns/aws/vpcPeer.js +3 -1
  62. package/dist/lib/resources/aws/analytics/clickhouse.js +18 -9
  63. package/dist/lib/resources/aws/analytics/clickhouseAlarms.d.ts +24 -9
  64. package/dist/lib/resources/aws/analytics/clickhouseAlarms.js +61 -10
  65. package/dist/lib/resources/aws/analytics/clickhouseConstants.d.ts +3 -3
  66. package/dist/lib/resources/aws/analytics/clickhouseConstants.js +3 -3
  67. package/dist/lib/resources/aws/analytics/clickhouseTypes.d.ts +7 -1
  68. package/dist/lib/resources/aws/analytics/clickhouseUserData.d.ts +1 -1
  69. package/dist/lib/resources/aws/analytics/clickhouseUserData.js +53 -3
  70. package/dist/lib/resources/aws/base/awsStack.js +4 -2
  71. package/dist/lib/resources/aws/compute/__tmp__/regression-shape.d.ts +2 -0
  72. package/dist/lib/resources/aws/compute/__tmp__/regression-shape.js +11 -0
  73. package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.d.ts +52 -0
  74. package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.js +60 -0
  75. package/dist/lib/resources/aws/compute/blockDeviceVolume.d.ts +8 -0
  76. package/dist/lib/resources/aws/compute/blockDeviceVolume.js +10 -0
  77. package/dist/lib/resources/aws/compute/ec2.d.ts +132 -12
  78. package/dist/lib/resources/aws/compute/ec2.js +163 -23
  79. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.d.ts +41 -0
  80. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +194 -0
  81. package/dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs +458 -0
  82. package/dist/lib/resources/aws/compute/ecs.d.ts +27 -1
  83. package/dist/lib/resources/aws/compute/ecs.js +42 -2
  84. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +9 -0
  85. package/dist/lib/resources/aws/compute/ecsConstants.js +16 -0
  86. package/dist/lib/resources/aws/compute/ecsImages.js +32 -20
  87. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.d.ts +96 -0
  88. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +113 -0
  89. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +2 -1
  90. package/dist/lib/resources/aws/compute/ecsNetworking.js +18 -6
  91. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +13 -4
  92. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +155 -33
  93. package/dist/lib/resources/aws/compute/ecsTaskDefinition.d.ts +31 -1
  94. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +102 -6
  95. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +173 -13
  96. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +9 -0
  97. package/dist/lib/resources/aws/compute/ecsValidation.js +63 -0
  98. package/dist/lib/resources/aws/compute/index.d.ts +2 -0
  99. package/dist/lib/resources/aws/compute/index.js +2 -0
  100. package/dist/lib/resources/aws/compute/lambda.d.ts +7 -13
  101. package/dist/lib/resources/aws/compute/lambda.js +30 -38
  102. package/dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs +192 -0
  103. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +104 -0
  104. package/dist/lib/resources/aws/compute/persistentDataVolume.js +245 -0
  105. package/dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs +398 -0
  106. package/dist/lib/resources/aws/compute/samApplication.d.ts +15 -0
  107. package/dist/lib/resources/aws/compute/samApplication.js +27 -0
  108. package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +159 -0
  109. package/dist/lib/resources/aws/database/clickhouseConstants.js +181 -0
  110. package/dist/lib/resources/aws/database/clickhouseSchemas.d.ts +71 -0
  111. package/dist/lib/resources/aws/database/clickhouseSchemas.js +157 -0
  112. package/dist/lib/resources/aws/database/clickhouseSecurityGroup.d.ts +14 -0
  113. package/dist/lib/resources/aws/database/clickhouseSecurityGroup.js +23 -0
  114. package/dist/lib/resources/aws/database/clickhouseUserData.d.ts +69 -0
  115. package/dist/lib/resources/aws/database/clickhouseUserData.js +371 -0
  116. package/dist/lib/resources/aws/database/clickhouseXmlRenderer.d.ts +56 -0
  117. package/dist/lib/resources/aws/database/clickhouseXmlRenderer.js +112 -0
  118. package/dist/lib/resources/aws/database/rdsAurora.d.ts +8 -1
  119. package/dist/lib/resources/aws/database/rdsAurora.js +42 -32
  120. package/dist/lib/resources/aws/database/rdsAuroraGlobal.d.ts +15 -2
  121. package/dist/lib/resources/aws/database/rdsAuroraGlobal.js +39 -43
  122. package/dist/lib/resources/aws/database/rdsDefaults.d.ts +6 -0
  123. package/dist/lib/resources/aws/database/rdsDefaults.js +7 -1
  124. package/dist/lib/resources/aws/database/rdsHelpers.d.ts +3 -3
  125. package/dist/lib/resources/aws/database/rdsHelpers.js +1 -0
  126. package/dist/lib/resources/aws/database/rdsInstance.d.ts +8 -1
  127. package/dist/lib/resources/aws/database/rdsInstance.js +51 -34
  128. package/dist/lib/resources/aws/database/rdsProxyOutput.d.ts +1 -1
  129. package/dist/lib/resources/aws/database/rdsProxyOutput.js +1 -1
  130. package/dist/lib/resources/aws/iam/delegationRole.js +1 -1
  131. package/dist/lib/resources/aws/iam/identityCenter/groupMembership.d.ts +9 -0
  132. package/dist/lib/resources/aws/iam/identityCenter/groupMembership.js +12 -0
  133. package/dist/lib/resources/aws/iam/identityCenter/index.d.ts +1 -0
  134. package/dist/lib/resources/aws/iam/identityCenter/index.js +1 -0
  135. package/dist/lib/resources/aws/iam/identityCenter/permissionSet.d.ts +1 -0
  136. package/dist/lib/resources/aws/iam/identityCenter/permissionSet.js +1 -0
  137. package/dist/lib/resources/aws/logging/logGroup.d.ts +0 -8
  138. package/dist/lib/resources/aws/logging/logGroup.js +0 -11
  139. package/dist/lib/resources/aws/messaging/defaultEventBus.d.ts +7 -0
  140. package/dist/lib/resources/aws/messaging/defaultEventBus.js +21 -0
  141. package/dist/lib/resources/aws/messaging/eventBridgeRule.d.ts +96 -0
  142. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +110 -0
  143. package/dist/lib/resources/aws/messaging/eventTargets.d.ts +84 -0
  144. package/dist/lib/resources/aws/messaging/eventTargets.js +152 -0
  145. package/dist/lib/resources/aws/messaging/eventbridge.d.ts +25 -2
  146. package/dist/lib/resources/aws/messaging/eventbridge.js +22 -10
  147. package/dist/lib/resources/aws/messaging/index.d.ts +5 -0
  148. package/dist/lib/resources/aws/messaging/index.js +2 -0
  149. package/dist/lib/resources/aws/messaging/schedule.d.ts +118 -0
  150. package/dist/lib/resources/aws/messaging/schedule.js +64 -0
  151. package/dist/lib/resources/aws/messaging/sns.d.ts +2 -1
  152. package/dist/lib/resources/aws/messaging/sqs.d.ts +2 -1
  153. package/dist/lib/resources/aws/messaging/subscription.d.ts +112 -0
  154. package/dist/lib/resources/aws/messaging/subscription.js +67 -0
  155. package/dist/lib/resources/aws/messaging/utils.d.ts +6 -0
  156. package/dist/lib/resources/aws/messaging/utils.js +10 -0
  157. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.d.ts +60 -0
  158. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.js +139 -0
  159. package/dist/lib/resources/aws/monitoring/index.d.ts +2 -0
  160. package/dist/lib/resources/aws/monitoring/index.js +2 -0
  161. package/dist/lib/resources/aws/monitoring/scheduleAlarms.d.ts +47 -0
  162. package/dist/lib/resources/aws/monitoring/scheduleAlarms.js +106 -0
  163. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +6 -4
  164. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +17 -13
  165. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +7 -5
  166. package/dist/lib/resources/aws/networking/domainCertificate.d.ts +2 -2
  167. package/dist/lib/resources/aws/networking/domainCertificate.js +6 -4
  168. package/dist/lib/resources/aws/networking/hostedZone.js +6 -5
  169. package/dist/lib/resources/aws/networking/serviceDiscovery.d.ts +96 -0
  170. package/dist/lib/resources/aws/networking/serviceDiscovery.js +96 -0
  171. package/dist/lib/resources/aws/networking/vpc.d.ts +4 -1
  172. package/dist/lib/resources/aws/networking/vpc.js +4 -1
  173. package/dist/lib/resources/aws/networking/vpcPeeringConnection.js +21 -3
  174. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.d.ts +16 -5
  175. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +17 -3
  176. package/dist/lib/resources/aws/organisation/index.d.ts +1 -1
  177. package/dist/lib/resources/aws/organisation/organisationPolicy.d.ts +2 -0
  178. package/dist/lib/resources/aws/organisation/organisationPolicy.js +3 -2
  179. package/dist/lib/resources/aws/secrets/secret.d.ts +7 -0
  180. package/dist/lib/resources/aws/secrets/secret.js +4 -3
  181. package/dist/lib/resources/aws/storage/bucketDeployment.d.ts +16 -0
  182. package/dist/lib/resources/aws/storage/bucketDeployment.js +17 -0
  183. package/dist/lib/resources/aws/storage/ecr.js +5 -5
  184. package/dist/lib/resources/aws/storage/index.d.ts +1 -0
  185. package/dist/lib/resources/aws/storage/index.js +1 -0
  186. package/dist/lib/resources/aws/storage/s3.js +10 -3
  187. package/dist/lib/resources/aws/utilities/customResource.js +18 -9
  188. package/dist/lib/synth_dump.d.ts +1 -0
  189. package/dist/lib/synth_dump.js +42 -0
  190. package/dist/lib/utils/cdkContext.d.ts +2 -0
  191. package/dist/lib/utils/cdkContext.js +4 -2
  192. package/dist/lib/utils/connections.js +6 -0
  193. package/dist/lib/utils/connector.d.ts +12 -0
  194. package/dist/lib/utils/costAllocationTags.d.ts +9 -0
  195. package/dist/lib/utils/costAllocationTags.js +11 -1
  196. package/dist/lib/utils/databaseTypes.d.ts +14 -0
  197. package/dist/lib/utils/getConfig.d.ts +2 -0
  198. package/dist/lib/utils/getConfig.js +2 -0
  199. package/dist/lib/utils/index.d.ts +1 -0
  200. package/dist/lib/utils/index.js +1 -0
  201. package/dist/lib/utils/manifestWriter.d.ts +6 -89
  202. package/dist/lib/utils/manifestWriter.js +36 -23
  203. package/dist/lib/utils/migrationVersionResolvers.d.ts +2 -0
  204. package/dist/lib/utils/migrationVersionResolvers.js +2 -0
  205. package/dist/lib/utils/orgConfigParser.js +2 -1
  206. package/dist/lib/utils/resolveAlertsTopic.d.ts +14 -0
  207. package/dist/lib/utils/resolveAlertsTopic.js +30 -0
  208. package/dist/lib/utils/validationLogger.js +6 -3
  209. package/package.json +22 -19
@@ -1,11 +1,28 @@
1
- import { Grant } from "aws-cdk-lib/aws-iam";
2
- import { Fn } from "aws-cdk-lib";
1
+ import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
2
+ import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
3
+ import { Effect, Grant, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
+ import { Annotations, Stack } from "aws-cdk-lib";
5
+ import { RetentionDays } from "aws-cdk-lib/aws-logs";
6
+ import { Secret } from "aws-cdk-lib/aws-secretsmanager";
7
+ import { StringParameter } from "aws-cdk-lib/aws-ssm";
3
8
  import { Construct } from "constructs";
4
- import { Topic } from "aws-cdk-lib/aws-sns";
9
+ import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
10
+ import { EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, isDatabase, isRelationalDatabase } from "./interfaces/database.js";
11
+ import { isConnectionConfig } from "../../utils/connector.js";
12
+ import { isMigrationContributor } from "./interfaces/migrationContributor.js";
13
+ import App from "../../app.js";
5
14
  import EcsCluster from "../../resources/aws/compute/ecs.js";
15
+ import { createScheduledTaskDefinition, createMigrationTaskDefinition } from "../../resources/aws/compute/ecsTaskDefinition.js";
16
+ import { EcsLifecycleHookMigration } from "../../resources/aws/compute/ecsLifecycleHookMigration.js";
17
+ import { Role } from "../../resources/aws/iam/role.js";
18
+ import { LogGroup } from "../../resources/aws/logging/logGroup.js";
19
+ import { SecurityGroup } from "../../resources/aws/networking/securityGroup.js";
20
+ import { vpcHasNatGateways } from "../../utils/vpcUtils.js";
21
+ import { toPascalCase } from "../../utils/capitaliseString.js";
6
22
  import { FjallLogger } from "../../utils/validationLogger.js";
7
23
  import { VALIDATION_PATTERNS } from "@fjall/generator";
8
24
  import { COMPUTE_DEFAULTS } from "./compute.js";
25
+ import { isHookMigrations } from "./computeEcsTypes.js";
9
26
  export { ScalingType } from "./computeEcsTypes.js";
10
27
  import { ScalingType } from "./computeEcsTypes.js";
11
28
  export const ECS_CAPACITY_PROVIDER_CONFIG = {
@@ -25,6 +42,35 @@ export const ECS_CAPACITY_PROVIDER_CONFIG = {
25
42
  export function getEcsCapacityProviderConfig(provider) {
26
43
  return ECS_CAPACITY_PROVIDER_CONFIG[provider];
27
44
  }
45
+ /**
46
+ * Fargate task-size matrix (CPU → valid memory range in MiB).
47
+ *
48
+ * @see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-tasks-services.html#fargate-tasks-size
49
+ */
50
+ const FARGATE_CPU_MEMORY_MATRIX = {
51
+ 256: { min: 512, max: 2048 },
52
+ 512: { min: 1024, max: 4096 },
53
+ 1024: { min: 2048, max: 8192 },
54
+ 2048: { min: 4096, max: 16384 },
55
+ 4096: { min: 8192, max: 30720 },
56
+ 8192: { min: 16384, max: 61440 },
57
+ 16384: { min: 32768, max: 122880 }
58
+ };
59
+ const FARGATE_VALID_CPUS = Object.keys(FARGATE_CPU_MEMORY_MATRIX)
60
+ .map((k) => parseInt(k, 10))
61
+ .sort((a, b) => a - b);
62
+ function validateSeparateTaskDef(serviceName, separateTaskDef) {
63
+ const matrix = FARGATE_CPU_MEMORY_MATRIX[separateTaskDef.cpu];
64
+ if (!matrix) {
65
+ throw new Error(`Service '${serviceName}': migrations.separateTaskDef.cpu must be one of ${FARGATE_VALID_CPUS.join(", ")} ` +
66
+ `(got ${separateTaskDef.cpu}). See AWS Fargate task-size matrix.`);
67
+ }
68
+ if (separateTaskDef.memoryLimitMiB < matrix.min ||
69
+ separateTaskDef.memoryLimitMiB > matrix.max) {
70
+ throw new Error(`Service '${serviceName}': migrations.separateTaskDef.memoryLimitMiB (${separateTaskDef.memoryLimitMiB}) ` +
71
+ `is incompatible with cpu=${separateTaskDef.cpu} (allowed range: ${matrix.min}-${matrix.max} MiB).`);
72
+ }
73
+ }
28
74
  /**
29
75
  * Validates ECS-specific props.
30
76
  * Extracted for clarity and detail parity with database/network patterns.
@@ -80,35 +126,397 @@ export function validateEcsProps(props) {
80
126
  throw new Error(`Service '${service.name}' uses EC2 capacity provider but no ec2Config is defined. ` +
81
127
  "Provide ec2Config on the service.");
82
128
  }
83
- // Warn if service ec2Config is defined but capacityProvider is not EC2
84
129
  if (service.ec2Config && service.capacityProvider !== "EC2") {
85
130
  FjallLogger.warn(`Service '${service.name}' has ec2Config but capacityProvider is not 'EC2'. ` +
86
131
  "The ec2Config will be ignored unless capacityProvider is set to 'EC2'.");
87
132
  }
133
+ if (service.deployment !== undefined) {
134
+ const min = service.deployment.minHealthyPercent;
135
+ const max = service.deployment.maxHealthyPercent;
136
+ if (min !== undefined && (min < 0 || min > 100)) {
137
+ throw new Error(`Service '${service.name}': deployment.minHealthyPercent must be between 0 and 100 (got ${min}).`);
138
+ }
139
+ if (max !== undefined && (max < 100 || max > 200)) {
140
+ throw new Error(`Service '${service.name}': deployment.maxHealthyPercent must be between 100 and 200 (got ${max}).`);
141
+ }
142
+ if (min !== undefined && max !== undefined && min > max) {
143
+ throw new Error(`Service '${service.name}': deployment.minHealthyPercent (${min}) must be <= maxHealthyPercent (${max}).`);
144
+ }
145
+ if (min === 100 && max === 100) {
146
+ throw new Error(`Service '${service.name}': deployment.minHealthyPercent and maxHealthyPercent cannot both be 100 ` +
147
+ "(no capacity to drain or expand — deploys would never roll forward).");
148
+ }
149
+ }
150
+ if (service.migrations !== undefined &&
151
+ isHookMigrations(service.migrations) &&
152
+ service.migrations.separateTaskDef !== undefined) {
153
+ validateSeparateTaskDef(service.name, service.migrations.separateTaskDef);
154
+ }
155
+ }
156
+ if (props.cluster?.directAccess === true) {
157
+ const hasEc2Service = props.services.some((s) => s.capacityProvider === "EC2");
158
+ if (!hasEc2Service) {
159
+ throw new Error("cluster.directAccess: true requires at least one service with capacityProvider: 'EC2'. " +
160
+ "directAccess opens host-network ports on EC2 instances and has no effect on Fargate services.");
161
+ }
162
+ if (props.cluster.securityGroup !== undefined) {
163
+ throw new Error("cluster.directAccess: true cannot be combined with a caller-supplied cluster.securityGroup. " +
164
+ "directAccess adds 0.0.0.0/0 ingress rules to the ASG security group; routing those rules onto an " +
165
+ "externally-owned security group couples this construct's permissions onto a SG whose lifecycle it " +
166
+ "does not own. Either omit cluster.securityGroup (a Fjall-owned SG will be created) or set directAccess: false.");
167
+ }
88
168
  }
89
169
  }
170
+ const DEFAULT_MIGRATE_CONTAINER_NAME = "migrate";
171
+ function lifecycleStageForHookMode(mode) {
172
+ switch (mode) {
173
+ case "lifecycle-hook":
174
+ return DeploymentLifecycleStage.PRE_SCALE_UP;
175
+ case "post-deploy":
176
+ return DeploymentLifecycleStage.POST_SCALE_UP;
177
+ default: {
178
+ const _exhaustive = mode;
179
+ throw new Error(`Unsupported hook migration mode: ${String(_exhaustive)}`);
180
+ }
181
+ }
182
+ }
183
+ /**
184
+ * Expand a service's `migrations` sugar into a synthetic init container plus
185
+ * auto-injected `dependsOn` entries on every other container.
186
+ *
187
+ * - Synthesises a non-essential container that runs the migration command and exits.
188
+ * - Inherits image / environment / secrets / secretsImport from the primary container
189
+ * (first container with a port, or first container if none have a port).
190
+ * - Auto-wires every other container to wait on the migrate container's `SUCCESS`,
191
+ * skipping containers that already declare the dependency to keep user overrides intact.
192
+ * - Throws on name collision when a user-defined container shares the migrate name.
193
+ *
194
+ * When `migrations.mode` is a lambda-hook variant (`"lifecycle-hook"` /
195
+ * `"post-deploy"`), this helper is a no-op: the task
196
+ * definition stays unmodified and the migration is run by a deployment lifecycle
197
+ * hook synthesised separately at the pattern layer.
198
+ *
199
+ * @internal Exported for testing only
200
+ */
201
+ export function expandMigrationsSugar(service, userContainers) {
202
+ const migrations = service.migrations;
203
+ if (!migrations) {
204
+ return userContainers ?? [];
205
+ }
206
+ if (isHookMigrations(migrations)) {
207
+ return userContainers ?? [];
208
+ }
209
+ const migrateName = migrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME;
210
+ const containers = userContainers ?? [];
211
+ for (const c of containers) {
212
+ if (c.name === migrateName) {
213
+ throw new Error(`Service '${service.name}': container '${migrateName}' collides with the synthetic ` +
214
+ `migrate container created by 'migrations'. Set 'migrations.name' to a different value.`);
215
+ }
216
+ }
217
+ const primary = containers.find((c) => c.port !== undefined) ?? containers[0];
218
+ const migrateContainer = {
219
+ name: migrateName,
220
+ essential: false,
221
+ command: migrations.command,
222
+ image: migrations.image ?? primary?.image,
223
+ environment: migrations.environment ?? primary?.environment,
224
+ secrets: migrations.secrets ?? primary?.secrets,
225
+ secretsImport: migrations.secretsImport ?? primary?.secretsImport
226
+ };
227
+ const wired = containers.map((c) => {
228
+ const existing = c.dependsOn ?? [];
229
+ const alreadyDepends = existing.some((d) => d.container === migrateName);
230
+ if (alreadyDepends)
231
+ return c;
232
+ return {
233
+ ...c,
234
+ dependsOn: [
235
+ ...existing,
236
+ { container: migrateName, condition: "SUCCESS" }
237
+ ]
238
+ };
239
+ });
240
+ return [migrateContainer, ...wired];
241
+ }
242
+ /**
243
+ * Resolve the relational database carrying `migrations:` from a service's
244
+ * `connections` graph. Returns `undefined` when no connected database declares
245
+ * migrations. Throws when two or more do — the conflict names both databases
246
+ * so the author can pick a winner via service split or `schemaGate: false`.
247
+ *
248
+ * The author-explicit-wins arbitration for the env value itself lives in
249
+ * `buildContainerConfigs` — this helper only chooses the database.
250
+ */
251
+ function resolveMigrationDatabaseForService(service) {
252
+ const connections = service.connections;
253
+ if (connections === undefined || connections.length === 0)
254
+ return undefined;
255
+ const matches = [];
256
+ for (const spec of connections) {
257
+ const resource = isConnectionConfig(spec) ? spec.resource : spec;
258
+ if (!isDatabase(resource))
259
+ continue;
260
+ if (!isRelationalDatabase(resource))
261
+ continue;
262
+ if (resource.getMigrationsConfig() !== undefined)
263
+ matches.push(resource);
264
+ }
265
+ if (matches.length === 0)
266
+ return undefined;
267
+ if (matches.length > 1) {
268
+ const names = matches.map((d) => d.node.id).join(", ");
269
+ throw new Error(`Service '${service.name}': connections include two or more relational ` +
270
+ `databases declaring \`migrations:\` (${names}). The schema-version ` +
271
+ `gate cannot inject ${EXPECTED_SCHEMA_VERSION_ENV} unambiguously. ` +
272
+ `Split the service so each consumes a single migrated database, or ` +
273
+ `set \`schemaGate: false\` and inject the env manually.`);
274
+ }
275
+ return matches[0];
276
+ }
277
+ function getResourceLabel(resource) {
278
+ if (resource !== null &&
279
+ typeof resource === "object" &&
280
+ "node" in resource &&
281
+ resource.node !== null &&
282
+ typeof resource.node === "object" &&
283
+ "id" in resource.node &&
284
+ typeof resource.node.id === "string") {
285
+ return resource.node.id;
286
+ }
287
+ return resource?.constructor?.name ?? "<unknown>";
288
+ }
289
+ function isSecurityGroupPeer(peer) {
290
+ return typeof peer.addIngressRule === "function";
291
+ }
292
+ /**
293
+ * Walks a service's `connections:` array, dispatches through
294
+ * `isMigrationContributor`, and accumulates a single merged
295
+ * `MigrationContributions` describing what the migration task should inherit.
296
+ *
297
+ * Author-explicit-wins is enforced separately by the consumer
298
+ * (`mergeContributionsIntoMigrations` / `mergeContributionsIntoSeparateTaskDef`
299
+ * below). Here we ONLY arbitrate between contributors themselves: if two
300
+ * contributors disagree on an env key or `secretsImport` entry (two databases
301
+ * each contributing `DATABASE_HOST` with different values, or two ClickHouse
302
+ * instances each contributing `SCHEMA_ADMIN_PASSWORD` from different secrets),
303
+ * synth throws naming both resources so the author resolves via
304
+ * `migrations.environment.<key>` / `migrations.secretsImport.<key>` or by
305
+ * splitting the service. `taskRolePolicies` and `egress` are additive — no
306
+ * conflict resolution needed. Two-DB conflicts on the schema-version gate
307
+ * already throw upstream in `resolveMigrationDatabaseForService`.
308
+ */
309
+ function collectMigrationContributions(service) {
310
+ const merged = {
311
+ environment: {},
312
+ secretsImport: {},
313
+ taskRolePolicies: [],
314
+ egress: []
315
+ };
316
+ const envOrigin = new Map();
317
+ const secretOrigin = new Map();
318
+ const connections = service.connections;
319
+ if (connections === undefined || connections.length === 0)
320
+ return merged;
321
+ for (const spec of connections) {
322
+ const resource = isConnectionConfig(spec) ? spec.resource : spec;
323
+ if (!isMigrationContributor(resource))
324
+ continue;
325
+ const contribution = resource.getMigrationContributions();
326
+ if (contribution === undefined)
327
+ continue;
328
+ const label = getResourceLabel(resource);
329
+ if (contribution.environment !== undefined) {
330
+ for (const [key, value] of Object.entries(contribution.environment)) {
331
+ const prior = envOrigin.get(key);
332
+ if (prior !== undefined && merged.environment[key] !== value) {
333
+ throw new Error(`Service '${service.name}': contributors '${prior}' and '${label}' ` +
334
+ `both contribute migration env '${key}' with conflicting values ` +
335
+ `('${merged.environment[key]}' vs '${value}'). Resolve by setting ` +
336
+ `migrations.environment.${key} explicitly on the service.`);
337
+ }
338
+ merged.environment[key] = value;
339
+ envOrigin.set(key, prior ?? label);
340
+ }
341
+ }
342
+ if (contribution.secretsImport !== undefined) {
343
+ for (const [key, value] of Object.entries(contribution.secretsImport)) {
344
+ const prior = secretOrigin.get(key);
345
+ if (prior !== undefined &&
346
+ merged.secretsImport[key]?.name !== value.name) {
347
+ throw new Error(`Service '${service.name}': contributors '${prior}' and '${label}' ` +
348
+ `both contribute migration secretsImport '${key}' with conflicting ` +
349
+ `values (secret '${merged.secretsImport[key]?.name ?? ""}' vs ` +
350
+ `'${value.name}'). Resolve by setting migrations.secretsImport.${key} ` +
351
+ `explicitly on the service.`);
352
+ }
353
+ merged.secretsImport[key] = value;
354
+ secretOrigin.set(key, prior ?? label);
355
+ }
356
+ }
357
+ if (contribution.taskRolePolicies !== undefined) {
358
+ merged.taskRolePolicies.push(...contribution.taskRolePolicies);
359
+ }
360
+ if (contribution.egress !== undefined) {
361
+ merged.egress.push(...contribution.egress);
362
+ }
363
+ }
364
+ return merged;
365
+ }
366
+ /**
367
+ * Merge contributor-supplied env/secrets into the author's explicit
368
+ * `migrations:` block. Author-explicit wins on key collisions with a synth
369
+ * warning so the author can audit what was overridden.
370
+ */
371
+ function mergeContributionsIntoMigrations(migrations, contributions, scope, serviceName) {
372
+ const contributedEnv = contributions.environment ?? {};
373
+ const contributedSecrets = contributions.secretsImport ?? {};
374
+ const authoredEnv = migrations.environment ?? {};
375
+ const authoredSecrets = migrations.secretsImport ?? {};
376
+ const mergedEnv = { ...contributedEnv };
377
+ for (const [key, value] of Object.entries(authoredEnv)) {
378
+ if (contributedEnv[key] !== undefined && contributedEnv[key] !== value) {
379
+ Annotations.of(scope).addWarning(`Service '${serviceName}': author-set migrations.environment.${key}` +
380
+ `='${value}' overrides contributor value ` +
381
+ `('${contributedEnv[key]}').`);
382
+ }
383
+ mergedEnv[key] = value;
384
+ }
385
+ const mergedSecrets = { ...contributedSecrets };
386
+ for (const [key, value] of Object.entries(authoredSecrets)) {
387
+ if (contributedSecrets[key] !== undefined &&
388
+ contributedSecrets[key]?.name !== value.name) {
389
+ Annotations.of(scope).addWarning(`Service '${serviceName}': author-set migrations.secretsImport.${key} ` +
390
+ `(secret '${value.name}') overrides contributor secret ` +
391
+ `('${contributedSecrets[key]?.name ?? ""}').`);
392
+ }
393
+ mergedSecrets[key] = value;
394
+ }
395
+ return {
396
+ ...migrations,
397
+ ...(Object.keys(mergedEnv).length > 0 && { environment: mergedEnv }),
398
+ ...(Object.keys(mergedSecrets).length > 0 && {
399
+ secretsImport: mergedSecrets
400
+ })
401
+ };
402
+ }
403
+ /**
404
+ * Merge contributor-supplied IAM policies and egress entries into the author's
405
+ * `separateTaskDef` block. Both fields are additive — contributor entries are
406
+ * appended to the author's list.
407
+ */
408
+ function mergeContributionsIntoSeparateTaskDef(separateTaskDef, contributions) {
409
+ const contributedPolicies = contributions.taskRolePolicies ?? [];
410
+ const contributedEgress = contributions.egress ?? [];
411
+ const mergedPolicies = [
412
+ ...contributedPolicies,
413
+ ...(separateTaskDef.taskRolePolicies ?? [])
414
+ ];
415
+ const mergedEgress = [
416
+ ...contributedEgress,
417
+ ...(separateTaskDef.egressTo ?? [])
418
+ ];
419
+ return {
420
+ ...separateTaskDef,
421
+ ...(mergedPolicies.length > 0 && { taskRolePolicies: mergedPolicies }),
422
+ ...(mergedEgress.length > 0 && { egressTo: mergedEgress })
423
+ };
424
+ }
90
425
  /**
91
426
  * Build container configurations for an ECS service.
92
427
  * Converts user-facing EcsContainerConfig to internal EcsClusterProps format.
428
+ *
429
+ * @param service Service config from EcsComputeProps.
430
+ * @param schemaVersionEnv Pre-resolved `{ EXPECTED_SCHEMA_VERSION: <ver> }`
431
+ * from a connected database's `migrations:` config, or
432
+ * `undefined` when the service has no migrated DB or
433
+ * opted out via `schemaGate: false`.
434
+ * @param annotationsScope Construct used as the source for synth-time
435
+ * warnings when the author has set `EXPECTED_SCHEMA_VERSION`
436
+ * themselves and the resolved value differs.
93
437
  * @internal Exported for testing only
94
438
  */
95
- export function buildContainerConfigs(service) {
96
- if (service.containers && service.containers.length > 0) {
97
- return service.containers.map((c, index) => ({
98
- name: c.name || `${service.name}Container${index > 0 ? index : ""}`,
99
- image: c.image,
100
- port: c.port,
101
- environment: c.environment,
102
- secrets: c.secrets,
103
- secretsImport: c.secretsImport,
104
- command: c.command,
105
- entryPoint: c.entryPoint,
106
- essential: c.essential,
107
- healthCheck: c.healthCheck
108
- }));
439
+ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope) {
440
+ const userContainers = service.containers && service.containers.length > 0
441
+ ? service.containers
442
+ : undefined;
443
+ const expanded = service.migrations
444
+ ? expandMigrationsSugar(service, userContainers)
445
+ : userContainers;
446
+ const mergeSchemaEnv = (authored) => {
447
+ if (schemaVersionEnv === undefined)
448
+ return authored;
449
+ const authoredValue = authored?.[EXPECTED_SCHEMA_VERSION_ENV];
450
+ if (authoredValue !== undefined) {
451
+ const resolvedValue = schemaVersionEnv[EXPECTED_SCHEMA_VERSION_ENV];
452
+ if (authoredValue !== resolvedValue && annotationsScope !== undefined) {
453
+ Annotations.of(annotationsScope).addWarning(`Service '${service.name}': author-set ${EXPECTED_SCHEMA_VERSION_ENV}` +
454
+ `='${authoredValue}' overrides the value resolved from the connected ` +
455
+ `database's \`migrations:\` config ('${resolvedValue}'). The author ` +
456
+ `value wins; set \`schemaGate: false\` to silence this warning. Note ` +
457
+ `that ${EXPECTED_SCHEMA_VERSION_TOOL_ENV} will still be auto-injected ` +
458
+ `to ensure the runtime gate passes — both envs must emit together.`);
459
+ }
460
+ // Runtime gate rejects startup if VERSION is present but TOOL is absent.
461
+ return {
462
+ [EXPECTED_SCHEMA_VERSION_TOOL_ENV]: schemaVersionEnv[EXPECTED_SCHEMA_VERSION_TOOL_ENV],
463
+ ...authored
464
+ };
465
+ }
466
+ const merged = { ...(authored ?? {}) };
467
+ for (const [key, value] of Object.entries(schemaVersionEnv)) {
468
+ if (merged[key] === undefined)
469
+ merged[key] = value;
470
+ }
471
+ return merged;
472
+ };
473
+ if (expanded) {
474
+ return expanded.map((c, index) => {
475
+ return {
476
+ name: c.name || `${service.name}Container${index > 0 ? index : ""}`,
477
+ image: c.image,
478
+ port: c.port,
479
+ environment: mergeSchemaEnv(c.environment),
480
+ secrets: c.secrets,
481
+ secretsImport: c.secretsImport,
482
+ command: c.command,
483
+ entryPoint: c.entryPoint,
484
+ essential: c.essential,
485
+ healthCheck: c.healthCheck,
486
+ dependsOn: c.dependsOn,
487
+ portMappings: c.portMappings,
488
+ volumes: c.volumes,
489
+ stopTimeout: c.stopTimeout
490
+ };
491
+ });
492
+ }
493
+ const fallbackEnv = mergeSchemaEnv(undefined);
494
+ return [
495
+ {
496
+ name: `${service.name}Container`,
497
+ ...(fallbackEnv !== undefined && { environment: fallbackEnv })
498
+ }
499
+ ];
500
+ }
501
+ function resolveMigrationImage(svcConfig, migrations, inheritedImage) {
502
+ if (migrations.image !== undefined)
503
+ return migrations.image;
504
+ // Order dependency: inheritedImage carries the <ServiceName>ImageTag
505
+ // CfnParameter token; checked before raw svcConfig.image strings.
506
+ if (inheritedImage !== undefined)
507
+ return inheritedImage;
508
+ const primary = svcConfig.containers?.find((c) => c.port !== undefined) ??
509
+ svcConfig.containers?.[0];
510
+ const containerImage = primary?.image ?? svcConfig.image;
511
+ if (containerImage !== undefined && typeof containerImage !== "string") {
512
+ throw new Error(`Service '${svcConfig.name}': migrations.separateTaskDef requires a string image URI ` +
513
+ `(got CDK Repository). Pass migrations.image as a string.`);
514
+ }
515
+ if (containerImage === undefined) {
516
+ throw new Error(`Service '${svcConfig.name}': migrations.separateTaskDef requires a resolvable image — ` +
517
+ `set migrations.image, service.image, or a container.image (string URI).`);
109
518
  }
110
- // Default container (no port = worker)
111
- return [{ name: `${service.name}Container` }];
519
+ return containerImage;
112
520
  }
113
521
  /**
114
522
  * Resolve scaling configuration from service props.
@@ -144,12 +552,26 @@ export class EcsCompute extends Construct {
144
552
  computeType = "ecs";
145
553
  connections;
146
554
  ecsCluster;
555
+ clusterId;
556
+ appName;
557
+ migrationTaskDefinitions = new Map();
147
558
  constructor(scope, id, props) {
148
559
  super(scope, id);
149
- // Transform EcsServiceConfig[] to EcsServiceProps[] for EcsCluster
560
+ validateEcsProps(props);
561
+ this.clusterId = id;
562
+ this.appName = props.appName;
150
563
  const services = props.services.map((service) => {
151
- const containers = buildContainerConfigs(service);
564
+ const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
565
+ const containers = buildContainerConfigs(service, schemaVersionEnv, this);
152
566
  const { scalingType, minCapacity, maxCapacity } = resolveScalingConfig(service.scaling);
567
+ const cloudMapService = service.serviceDiscovery !== undefined
568
+ ? App.getInstance().registerService({
569
+ name: service.serviceDiscovery.name,
570
+ ...(service.serviceDiscovery.dnsRecordType !== undefined && {
571
+ dnsRecordType: service.serviceDiscovery.dnsRecordType
572
+ })
573
+ })
574
+ : undefined;
153
575
  return {
154
576
  name: service.name,
155
577
  image: service.image,
@@ -168,30 +590,36 @@ export class EcsCompute extends Construct {
168
590
  capacityProvider: service.capacityProvider,
169
591
  ec2Config: service.ec2Config,
170
592
  ssmSecretsPath: service.ssmSecretsPath,
171
- dockerTarget: service.dockerTarget,
172
- alarms: service.alarms
593
+ docker: service.docker,
594
+ alarms: service.alarms,
595
+ circuitBreaker: service.circuitBreaker,
596
+ ...(service.deployment !== undefined && {
597
+ deployment: service.deployment
598
+ }),
599
+ ...(cloudMapService !== undefined && { cloudMapService }),
600
+ ...(service.serviceDiscovery?.dnsRecordType !== undefined && {
601
+ cloudMapDnsRecordType: service.serviceDiscovery.dnsRecordType
602
+ }),
603
+ ...(service.networkMode !== undefined && {
604
+ networkMode: service.networkMode
605
+ }),
606
+ ...(service.securityGroups !== undefined && {
607
+ securityGroups: service.securityGroups
608
+ })
173
609
  };
174
610
  });
175
- // Build cluster config
176
611
  const cluster = props.cluster
177
612
  ? {
178
613
  domain: props.cluster.domain,
179
614
  loadBalancer: props.cluster.loadBalancer,
180
615
  directAccess: props.cluster.directAccess,
181
- domainConfig: props.cluster.domainConfig
616
+ domainConfig: props.cluster.domainConfig,
617
+ ...(props.cluster.securityGroup !== undefined && {
618
+ securityGroup: props.cluster.securityGroup
619
+ })
182
620
  }
183
621
  : undefined;
184
- // Resolve alertsTopic: accept ITopic directly or string (ARN or "import:ExportName")
185
- let resolvedAlertsTopic;
186
- if (typeof props.alertsTopic === "string") {
187
- const arn = props.alertsTopic.startsWith("import:")
188
- ? Fn.importValue(props.alertsTopic.slice("import:".length))
189
- : props.alertsTopic;
190
- resolvedAlertsTopic = Topic.fromTopicArn(this, "AlertsTopic", arn);
191
- }
192
- else {
193
- resolvedAlertsTopic = props.alertsTopic;
194
- }
622
+ const resolvedAlertsTopic = resolveAlertsTopic(this, `${id}AlertsTopic`, props.alertsTopic);
195
623
  const ecsProps = {
196
624
  clusterName: id,
197
625
  appName: props.appName,
@@ -204,6 +632,379 @@ export class EcsCompute extends Construct {
204
632
  };
205
633
  this.ecsCluster = new EcsCluster(this, `${id}Ecs`, ecsProps);
206
634
  this.connections = this.ecsCluster.connections;
635
+ this.wireLifecycleHookMigrations(props.services);
636
+ this.materialiseScheduledTasks(id, props);
637
+ }
638
+ /**
639
+ * Walks a service's `connections:` for a relational database carrying a
640
+ * `migrations:` config. Returns the schema-version gate env entries to
641
+ * thread into the service's containers, or `undefined` when the service is
642
+ * not gated.
643
+ *
644
+ * - `schemaGate: false` → returns `undefined` (auditable opt-out)
645
+ * - No migrated DB in connections → returns `undefined`
646
+ * - Exactly one migrated DB → returns `{ EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_VERSION_TOOL }`
647
+ * - Two or more migrated DBs → throws via `resolveMigrationDatabaseForService`
648
+ *
649
+ * Both envs always emit together. The tool sibling lets the runtime gate
650
+ * dispatch to the matching resolver (or refuse on unknown tool) instead of
651
+ * hardcoding one.
652
+ */
653
+ resolveSchemaVersionEnv(service) {
654
+ if (service.schemaGate === false)
655
+ return undefined;
656
+ const db = resolveMigrationDatabaseForService(service);
657
+ if (db === undefined)
658
+ return undefined;
659
+ const version = db.getExpectedSchemaVersion();
660
+ if (version === undefined)
661
+ return undefined;
662
+ const config = db.getMigrationsConfig();
663
+ if (config === undefined) {
664
+ throw new Error(`Service '${service.name}': schema-version-gate produced a version ` +
665
+ `but the connected database returned no migrations config — these ` +
666
+ `must move together.`);
667
+ }
668
+ return {
669
+ [EXPECTED_SCHEMA_VERSION_ENV]: version,
670
+ [EXPECTED_SCHEMA_VERSION_TOOL_ENV]: config.tool
671
+ };
672
+ }
673
+ materialiseScheduledTasks(id, props) {
674
+ const scheduledTasks = props.cluster?.scheduledTasks;
675
+ if (!scheduledTasks || scheduledTasks.length === 0)
676
+ return;
677
+ const app = App.getInstance();
678
+ for (const entry of scheduledTasks) {
679
+ const taskDef = this.buildScheduledTaskDefinition(id, entry);
680
+ this.ecsCluster.registerScheduledTaskDefinition(entry.name, taskDef);
681
+ app.addSchedule(`${id}${toPascalCase(entry.name)}Schedule`, {
682
+ schedule: entry.schedule,
683
+ target: {
684
+ ecs: this,
685
+ serviceName: entry.name,
686
+ taskCount: 1
687
+ },
688
+ stackPlacement: "compute"
689
+ });
690
+ }
691
+ }
692
+ buildScheduledTaskDefinition(id, entry) {
693
+ const taskDef = createScheduledTaskDefinition(this, `${id}${toPascalCase(entry.name)}TaskDefinition`, {
694
+ family: `${id}-${entry.name}`,
695
+ ...(entry.networkMode !== undefined && {
696
+ networkMode: entry.networkMode
697
+ })
698
+ });
699
+ if (entry.volumes) {
700
+ for (const volume of entry.volumes) {
701
+ taskDef.addVolume({
702
+ name: volume.name,
703
+ ...(volume.hostSourcePath !== undefined && {
704
+ host: { sourcePath: volume.hostSourcePath }
705
+ })
706
+ });
707
+ }
708
+ }
709
+ const logging = new AwsLogDriver({
710
+ streamPrefix: `/ecs-scheduled/${id}/${entry.name}`,
711
+ ...(entry.logGroup !== undefined && { logGroup: entry.logGroup }),
712
+ ...(entry.logGroup === undefined &&
713
+ entry.logRetention !== undefined && {
714
+ logRetention: entry.logRetention
715
+ })
716
+ });
717
+ const container = taskDef.addContainer(`${id}${toPascalCase(entry.name)}Container`, {
718
+ image: entry.image,
719
+ cpu: entry.cpu,
720
+ memoryLimitMiB: entry.memoryLimitMiB,
721
+ ...(entry.command !== undefined && { command: entry.command }),
722
+ ...(entry.secrets !== undefined && { secrets: entry.secrets }),
723
+ logging
724
+ });
725
+ if (entry.volumes) {
726
+ for (const volume of entry.volumes) {
727
+ container.addMountPoints({
728
+ sourceVolume: volume.name,
729
+ containerPath: volume.mountPath,
730
+ readOnly: volume.readOnly ?? false
731
+ });
732
+ }
733
+ }
734
+ return taskDef;
735
+ }
736
+ /**
737
+ * For each service whose `migrations.mode` is a lambda-hook variant
738
+ * (`"lifecycle-hook"` for PRE_SCALE_UP, `"post-deploy"` for POST_SCALE_UP),
739
+ * synthesise the Lambda + IAM role + log group that backs the deployment
740
+ * lifecycle hook. The init-container path is unaffected — services without
741
+ * `mode` (or with `mode: "init-container"`) still get the synthetic
742
+ * migrate container injected by `expandMigrationsSugar`.
743
+ */
744
+ wireLifecycleHookMigrations(services) {
745
+ for (const svcConfig of services) {
746
+ const migrations = svcConfig.migrations;
747
+ if (!migrations || !isHookMigrations(migrations))
748
+ continue;
749
+ const modeLabel = migrations.mode;
750
+ if (svcConfig.capacityProvider === "EC2") {
751
+ throw new Error(`Service '${svcConfig.name}': migrations.mode === "${modeLabel}" requires FARGATE or FARGATE_SPOT capacity (got "EC2"). ` +
752
+ `Use migrations.mode === "init-container" (the default) for EC2 services.`);
753
+ }
754
+ if (migrations.image !== undefined &&
755
+ typeof migrations.image !== "string") {
756
+ throw new Error(`Service '${svcConfig.name}': migrations.image must be a string in ${modeLabel} mode. ` +
757
+ `CDK Repository constructs are not supported here — pass the image URI as a string.`);
758
+ }
759
+ const service = this.ecsCluster.getService(svcConfig.name);
760
+ if (!service) {
761
+ throw new Error(`Service '${svcConfig.name}' not found in cluster after synthesis — cannot attach lifecycle hook.`);
762
+ }
763
+ const taskDefinition = service.taskDefinition;
764
+ if (!taskDefinition.executionRole) {
765
+ throw new Error(`Service '${svcConfig.name}': task definition has no execution role — cannot grant iam:PassRole for the migrate Lambda.`);
766
+ }
767
+ const cluster = this.ecsCluster.getCluster();
768
+ const hasNat = vpcHasNatGateways(cluster.vpc);
769
+ const subnets = cluster.vpc.selectSubnets({
770
+ subnetType: hasNat ? SubnetType.PRIVATE_WITH_EGRESS : SubnetType.PUBLIC
771
+ });
772
+ const securityGroupIds = service.connections.securityGroups.map((sg) => sg.securityGroupId);
773
+ if (securityGroupIds.length === 0) {
774
+ throw new Error(`EcsCompute: service "${svcConfig.name}" has no security groups; lifecycle-hook migration RunTask requires at least one.`);
775
+ }
776
+ const contributions = collectMigrationContributions(svcConfig);
777
+ const effectiveMigrations = mergeContributionsIntoMigrations(migrations, contributions, this, svcConfig.name);
778
+ const effectiveSeparateTaskDef = migrations.separateTaskDef !== undefined
779
+ ? mergeContributionsIntoSeparateTaskDef(migrations.separateTaskDef, contributions)
780
+ : undefined;
781
+ const schemaVersionEnv = this.resolveSchemaVersionEnv(svcConfig);
782
+ const migrationTaskDef = effectiveSeparateTaskDef !== undefined
783
+ ? this.synthesiseMigrationTaskDef(svcConfig, effectiveMigrations, effectiveSeparateTaskDef, service, cluster.vpc, schemaVersionEnv)
784
+ : undefined;
785
+ const effectiveSecurityGroupIds = migrationTaskDef?.securityGroupIds ?? securityGroupIds;
786
+ new EcsLifecycleHookMigration(this, `${toPascalCase(svcConfig.name)}Migrate`, {
787
+ service,
788
+ clusterArn: cluster.clusterArn,
789
+ taskDefinitionArn: taskDefinition.taskDefinitionArn,
790
+ taskDefinitionFamily: taskDefinition.family,
791
+ taskExecutionRoleArn: taskDefinition.executionRole.roleArn,
792
+ taskRoleArn: taskDefinition.taskRole.roleArn,
793
+ command: effectiveMigrations.command,
794
+ containerName: effectiveMigrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME,
795
+ image: effectiveMigrations.image,
796
+ ...(effectiveMigrations.entryPoint !== undefined && {
797
+ entryPoint: effectiveMigrations.entryPoint
798
+ }),
799
+ environment: effectiveMigrations.environment,
800
+ timeoutSeconds: effectiveMigrations.timeoutSeconds,
801
+ networkConfiguration: {
802
+ subnetIds: subnets.subnetIds,
803
+ securityGroupIds: effectiveSecurityGroupIds,
804
+ assignPublicIp: !hasNat
805
+ },
806
+ ...(migrationTaskDef !== undefined && {
807
+ migrationTaskDef: {
808
+ definitionArn: migrationTaskDef.taskDefinitionArn,
809
+ family: migrationTaskDef.family,
810
+ taskRoleArn: migrationTaskDef.taskRoleArn,
811
+ executionRoleArn: migrationTaskDef.executionRoleArn
812
+ }
813
+ }),
814
+ lifecycleStage: lifecycleStageForHookMode(effectiveMigrations.mode)
815
+ });
816
+ }
817
+ }
818
+ /**
819
+ * Synthesise a dedicated migration task definition for a lifecycle-hook
820
+ * migration when `separateTaskDef` is set. Creates the migration's own
821
+ * execution + task roles, log group, security group (when `egressTo` is
822
+ * present, else reuses the service's SGs), and the Fargate task definition
823
+ * with the migration container baked in.
824
+ */
825
+ synthesiseMigrationTaskDef(svcConfig, migrations, separateTaskDef, service, vpc, schemaVersionEnv) {
826
+ const stack = Stack.of(this);
827
+ const idPrefix = `${toPascalCase(svcConfig.name)}Migrate`;
828
+ const containerName = migrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME;
829
+ const family = `${this.clusterId}-${svcConfig.name}-migrate`;
830
+ const inheritedImage = service.taskDefinition.defaultContainer?.imageName;
831
+ const image = resolveMigrationImage(svcConfig, migrations, inheritedImage);
832
+ const logGroup = new LogGroup(this, `${idPrefix}LogGroup`, {
833
+ retention: RetentionDays.ONE_MONTH
834
+ });
835
+ const executionRole = new Role(this, `${idPrefix}ExecutionRole`, {
836
+ assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com")
837
+ });
838
+ executionRole.addToPolicy(new PolicyStatement({
839
+ effect: Effect.ALLOW,
840
+ actions: [
841
+ "ecr:GetAuthorizationToken",
842
+ "ecr:BatchCheckLayerAvailability",
843
+ "ecr:GetDownloadUrlForLayer",
844
+ "ecr:BatchGetImage"
845
+ ],
846
+ resources: ["*"]
847
+ }));
848
+ logGroup.grantWrite(executionRole);
849
+ const taskRole = new Role(this, `${idPrefix}TaskRole`, {
850
+ assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com")
851
+ });
852
+ taskRole.addToPolicy(new PolicyStatement({
853
+ effect: Effect.ALLOW,
854
+ actions: [
855
+ "ssmmessages:CreateControlChannel",
856
+ "ssmmessages:CreateDataChannel",
857
+ "ssmmessages:OpenControlChannel",
858
+ "ssmmessages:OpenDataChannel"
859
+ ],
860
+ resources: ["*"]
861
+ }));
862
+ if (separateTaskDef.taskRolePolicies) {
863
+ for (const statement of separateTaskDef.taskRolePolicies) {
864
+ taskRole.addToPolicy(statement);
865
+ }
866
+ }
867
+ if (separateTaskDef.extraSecretReads) {
868
+ const stackHere = Stack.of(this);
869
+ const secretArns = separateTaskDef.extraSecretReads.map((entry) => entry.secretName.startsWith("arn:")
870
+ ? entry.secretName
871
+ : `arn:${stackHere.partition}:secretsmanager:${stackHere.region}:${stackHere.account}:secret:${entry.secretName}-*`);
872
+ taskRole.addToPolicy(new PolicyStatement({
873
+ effect: Effect.ALLOW,
874
+ actions: ["secretsmanager:GetSecretValue"],
875
+ resources: secretArns
876
+ }));
877
+ }
878
+ const taskDefinition = createMigrationTaskDefinition(this, `${idPrefix}TaskDefinition`, {
879
+ family,
880
+ cpu: separateTaskDef.cpu,
881
+ memoryLimitMiB: separateTaskDef.memoryLimitMiB,
882
+ executionRole,
883
+ taskRole
884
+ });
885
+ const { secretsResolved, secretArns, ssmPath } = this.resolveMigrationSecrets(svcConfig, migrations, idPrefix);
886
+ const authoredEnvironment = migrations.environment ?? {};
887
+ const containerEnvironment = {
888
+ ...authoredEnvironment
889
+ };
890
+ if (schemaVersionEnv !== undefined) {
891
+ for (const [key, value] of Object.entries(schemaVersionEnv)) {
892
+ if (containerEnvironment[key] === undefined) {
893
+ containerEnvironment[key] = value;
894
+ }
895
+ }
896
+ }
897
+ taskDefinition.addContainer(`${idPrefix}Container`, {
898
+ containerName,
899
+ image: ContainerImage.fromRegistry(image),
900
+ command: migrations.command,
901
+ ...(migrations.entryPoint !== undefined && {
902
+ entryPoint: migrations.entryPoint
903
+ }),
904
+ environment: containerEnvironment,
905
+ secrets: secretsResolved,
906
+ logging: new AwsLogDriver({
907
+ streamPrefix: `/ecs-migrate/${svcConfig.name}`,
908
+ logGroup
909
+ })
910
+ });
911
+ if (secretArns.length > 0) {
912
+ executionRole.addToPolicy(new PolicyStatement({
913
+ effect: Effect.ALLOW,
914
+ actions: [
915
+ "secretsmanager:GetSecretValue",
916
+ "secretsmanager:DescribeSecret"
917
+ ],
918
+ resources: secretArns
919
+ }));
920
+ }
921
+ if (ssmPath !== undefined) {
922
+ executionRole.addToPolicy(new PolicyStatement({
923
+ effect: Effect.ALLOW,
924
+ actions: ["ssm:GetParameters", "ssm:GetParameter"],
925
+ resources: [
926
+ `arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${ssmPath}/*`
927
+ ]
928
+ }));
929
+ }
930
+ if (secretArns.length > 0 || ssmPath !== undefined) {
931
+ executionRole.addToPolicy(new PolicyStatement({
932
+ effect: Effect.ALLOW,
933
+ actions: ["kms:Decrypt"],
934
+ resources: ["*"],
935
+ conditions: {
936
+ StringEquals: {
937
+ "kms:ViaService": [
938
+ `ssm.${stack.region}.amazonaws.com`,
939
+ `secretsmanager.${stack.region}.amazonaws.com`
940
+ ]
941
+ }
942
+ }
943
+ }));
944
+ }
945
+ let securityGroupIds;
946
+ if (separateTaskDef.egressTo !== undefined &&
947
+ separateTaskDef.egressTo.length > 0) {
948
+ const migrationSg = new SecurityGroup(this, `${idPrefix}SecurityGroup`, {
949
+ vpc,
950
+ description: `Migration SG for ${svcConfig.name}`,
951
+ allowAllOutbound: false
952
+ });
953
+ for (const [i, egress] of separateTaskDef.egressTo.entries()) {
954
+ migrationSg.addEgressRule(egress.peer, egress.port, egress.description);
955
+ if (isSecurityGroupPeer(egress.peer)) {
956
+ // Cross-stack: addIngressRule on the peer directly cycles
957
+ // (peer stack would back-ref migrationSg). Import locally.
958
+ const localPeer = SecurityGroup.fromSecurityGroupId(this, `${idPrefix}MigratePeer${i}`, egress.peer.securityGroupId, { mutable: true });
959
+ localPeer.addIngressRule(migrationSg, egress.port, egress.description);
960
+ }
961
+ }
962
+ // Fargate agent pre-container 443 fetches; ECR API needs NAT egress.
963
+ migrationSg.addEgressRule(Peer.anyIpv4(), Port.tcp(443), "Fargate agent HTTPS for ECR / secrets / logs");
964
+ securityGroupIds = [migrationSg.securityGroupId];
965
+ }
966
+ else {
967
+ securityGroupIds = service.connections.securityGroups.map((sg) => sg.securityGroupId);
968
+ }
969
+ this.migrationTaskDefinitions.set(svcConfig.name, taskDefinition);
970
+ return {
971
+ taskDefinitionArn: taskDefinition.taskDefinitionArn,
972
+ family: taskDefinition.family,
973
+ taskRoleArn: taskRole.roleArn,
974
+ executionRoleArn: executionRole.roleArn,
975
+ securityGroupIds
976
+ };
977
+ }
978
+ resolveMigrationSecrets(svcConfig, migrations, idPrefix) {
979
+ const stack = Stack.of(this);
980
+ const secretsResolved = {};
981
+ const secretArns = [];
982
+ if (migrations.secretsImport) {
983
+ for (const [key, secretImport] of Object.entries(migrations.secretsImport)) {
984
+ const secret = Secret.fromSecretNameV2(this, `${idPrefix}${key}Secret`, secretImport.name);
985
+ secretsResolved[key] = EcsSecret.fromSecretsManager(secret, secretImport.field);
986
+ secretArns.push(`arn:${stack.partition}:secretsmanager:${stack.region}:${stack.account}:secret:${secretImport.name}-*`);
987
+ }
988
+ }
989
+ let ssmPath;
990
+ if (migrations.secrets && migrations.secrets.length > 0) {
991
+ if (svcConfig.ssmSecretsPath !== undefined) {
992
+ ssmPath = svcConfig.ssmSecretsPath;
993
+ }
994
+ else {
995
+ if (this.appName === undefined || this.appName === "") {
996
+ throw new Error(`Service '${svcConfig.name}' declares migration secrets but no ssmSecretsPath ` +
997
+ `is set and EcsComputeProps.appName is missing — set one to enable ` +
998
+ `SSM path derivation (/<appName>/<clusterName>/<serviceName>).`);
999
+ }
1000
+ ssmPath = `/${this.appName}/${this.clusterId}/${svcConfig.name}`;
1001
+ }
1002
+ for (const secretName of migrations.secrets) {
1003
+ const param = StringParameter.fromSecureStringParameterAttributes(this, `${idPrefix}${secretName}SsmParam`, { parameterName: `${ssmPath}/${secretName}` });
1004
+ secretsResolved[secretName] = EcsSecret.fromSsmParameter(param);
1005
+ }
1006
+ }
1007
+ return { secretsResolved, secretArns, ssmPath };
207
1008
  }
208
1009
  /** Get the ECS cluster. */
209
1010
  getCluster() {
@@ -222,6 +1023,18 @@ export class EcsCompute extends Construct {
222
1023
  const servicesMap = this.ecsCluster.getServices();
223
1024
  return Array.from(servicesMap.values());
224
1025
  }
1026
+ getTaskDefinition(serviceName) {
1027
+ return this.ecsCluster.getTaskDefinition(serviceName);
1028
+ }
1029
+ /**
1030
+ * Get the migration task definition for a service. Returns `undefined` when
1031
+ * the service has no `migrations: { mode: "lifecycle-hook", separateTaskDef }`
1032
+ * configured — escape hatch for callers that need to attach grants (e.g.
1033
+ * `bucket.grantReadWrite(td.taskRole)`) to the migration task role.
1034
+ */
1035
+ getMigrationTaskDefinition(serviceName) {
1036
+ return this.migrationTaskDefinitions.get(serviceName);
1037
+ }
225
1038
  /** Get the security group for the cluster. */
226
1039
  getSecurityGroup() {
227
1040
  const sg = this.connections.securityGroups[0];
@@ -257,4 +1070,21 @@ export class EcsCompute extends Construct {
257
1070
  resourceArns: ["*"]
258
1071
  });
259
1072
  }
1073
+ /**
1074
+ * Get the EC2 instance role for the cluster's underlying ASG. EC2-mode
1075
+ * clusters return the ASG instance role; Fargate-only clusters return
1076
+ * `undefined`. Use to attach S3 grants etc. that must reach the host
1077
+ * process (D10 — single accessor; no separate `getAutoScalingGroup()`).
1078
+ */
1079
+ getInstanceRole() {
1080
+ return this.ecsCluster.getInstanceRole();
1081
+ }
1082
+ /**
1083
+ * Get the underlying ASG's `autoScalingGroupName` token. String-only —
1084
+ * D10 forbids exposing the ASG construct itself. Used by alarm helpers
1085
+ * that need a CloudWatch dimension value.
1086
+ */
1087
+ getAutoScalingGroupName() {
1088
+ return this.ecsCluster.getAutoScalingGroupName();
1089
+ }
260
1090
  }