@fjall/components-infrastructure 0.95.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 (234) hide show
  1. package/dist/lib/app.d.ts +90 -107
  2. package/dist/lib/app.js +149 -139
  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 +7 -8
  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 +95 -396
  36. package/dist/lib/patterns/aws/computeEcs.js +880 -46
  37. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +889 -0
  38. package/dist/lib/patterns/aws/computeEcsTypes.js +12 -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 +8 -7
  44. package/dist/lib/patterns/aws/index.d.ts +3 -0
  45. package/dist/lib/patterns/aws/index.js +3 -0
  46. package/dist/lib/patterns/aws/interfaces/compute.d.ts +13 -1
  47. package/dist/lib/patterns/aws/interfaces/connector.d.ts +1 -1
  48. package/dist/lib/patterns/aws/interfaces/connector.js +1 -1
  49. package/dist/lib/patterns/aws/interfaces/database.d.ts +187 -8
  50. package/dist/lib/patterns/aws/interfaces/database.js +17 -3
  51. package/dist/lib/patterns/aws/interfaces/index.d.ts +4 -2
  52. package/dist/lib/patterns/aws/interfaces/index.js +4 -2
  53. package/dist/lib/patterns/aws/interfaces/messaging.d.ts +7 -0
  54. package/dist/lib/patterns/aws/interfaces/migrationContributor.d.ts +47 -0
  55. package/dist/lib/patterns/aws/interfaces/migrationContributor.js +9 -0
  56. package/dist/lib/patterns/aws/interfaces/vpcPeer.d.ts +7 -0
  57. package/dist/lib/patterns/aws/interfaces/vpcPeer.js +1 -0
  58. package/dist/lib/patterns/aws/messaging.d.ts +66 -10
  59. package/dist/lib/patterns/aws/messaging.js +115 -20
  60. package/dist/lib/patterns/aws/network.js +16 -7
  61. package/dist/lib/patterns/aws/organisation.d.ts +4 -0
  62. package/dist/lib/patterns/aws/organisation.js +24 -5
  63. package/dist/lib/patterns/aws/storage.d.ts +1 -2
  64. package/dist/lib/patterns/aws/storage.js +3 -2
  65. package/dist/lib/patterns/aws/vpcPeer.d.ts +34 -0
  66. package/dist/lib/patterns/aws/vpcPeer.js +38 -0
  67. package/dist/lib/patterns/aws/vpcPeerAccepter.d.ts +29 -0
  68. package/dist/lib/patterns/aws/vpcPeerAccepter.js +196 -0
  69. package/dist/lib/resources/aws/analytics/clickhouse.js +25 -7
  70. package/dist/lib/resources/aws/analytics/clickhouseAlarms.d.ts +49 -0
  71. package/dist/lib/resources/aws/analytics/clickhouseAlarms.js +140 -0
  72. package/dist/lib/resources/aws/analytics/clickhouseConstants.d.ts +4 -4
  73. package/dist/lib/resources/aws/analytics/clickhouseConstants.js +6 -4
  74. package/dist/lib/resources/aws/analytics/clickhouseTypes.d.ts +12 -0
  75. package/dist/lib/resources/aws/analytics/clickhouseUserData.d.ts +1 -0
  76. package/dist/lib/resources/aws/analytics/clickhouseUserData.js +56 -5
  77. package/dist/lib/resources/aws/analytics/index.d.ts +2 -0
  78. package/dist/lib/resources/aws/analytics/index.js +1 -0
  79. package/dist/lib/resources/aws/base/awsStack.js +4 -2
  80. package/dist/lib/resources/aws/compute/__tmp__/regression-shape.d.ts +2 -0
  81. package/dist/lib/resources/aws/compute/__tmp__/regression-shape.js +11 -0
  82. package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.d.ts +52 -0
  83. package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.js +60 -0
  84. package/dist/lib/resources/aws/compute/blockDeviceVolume.d.ts +8 -0
  85. package/dist/lib/resources/aws/compute/blockDeviceVolume.js +10 -0
  86. package/dist/lib/resources/aws/compute/ec2.d.ts +132 -12
  87. package/dist/lib/resources/aws/compute/ec2.js +163 -23
  88. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.d.ts +41 -0
  89. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +194 -0
  90. package/dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs +458 -0
  91. package/dist/lib/resources/aws/compute/ecs.d.ts +27 -1
  92. package/dist/lib/resources/aws/compute/ecs.js +42 -2
  93. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +9 -0
  94. package/dist/lib/resources/aws/compute/ecsConstants.js +16 -0
  95. package/dist/lib/resources/aws/compute/ecsImages.js +32 -20
  96. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.d.ts +96 -0
  97. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +113 -0
  98. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +2 -1
  99. package/dist/lib/resources/aws/compute/ecsNetworking.js +18 -6
  100. package/dist/lib/resources/aws/compute/ecsRemoteConnections.d.ts +38 -0
  101. package/dist/lib/resources/aws/compute/ecsRemoteConnections.js +80 -0
  102. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +13 -4
  103. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +155 -33
  104. package/dist/lib/resources/aws/compute/ecsTaskDefinition.d.ts +31 -1
  105. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +110 -6
  106. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +180 -13
  107. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +9 -0
  108. package/dist/lib/resources/aws/compute/ecsValidation.js +63 -0
  109. package/dist/lib/resources/aws/compute/index.d.ts +2 -0
  110. package/dist/lib/resources/aws/compute/index.js +2 -0
  111. package/dist/lib/resources/aws/compute/lambda.d.ts +7 -13
  112. package/dist/lib/resources/aws/compute/lambda.js +30 -38
  113. package/dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs +192 -0
  114. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +104 -0
  115. package/dist/lib/resources/aws/compute/persistentDataVolume.js +245 -0
  116. package/dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs +398 -0
  117. package/dist/lib/resources/aws/compute/samApplication.d.ts +15 -0
  118. package/dist/lib/resources/aws/compute/samApplication.js +27 -0
  119. package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +159 -0
  120. package/dist/lib/resources/aws/database/clickhouseConstants.js +181 -0
  121. package/dist/lib/resources/aws/database/clickhouseSchemas.d.ts +71 -0
  122. package/dist/lib/resources/aws/database/clickhouseSchemas.js +157 -0
  123. package/dist/lib/resources/aws/database/clickhouseSecurityGroup.d.ts +14 -0
  124. package/dist/lib/resources/aws/database/clickhouseSecurityGroup.js +23 -0
  125. package/dist/lib/resources/aws/database/clickhouseUserData.d.ts +69 -0
  126. package/dist/lib/resources/aws/database/clickhouseUserData.js +371 -0
  127. package/dist/lib/resources/aws/database/clickhouseXmlRenderer.d.ts +56 -0
  128. package/dist/lib/resources/aws/database/clickhouseXmlRenderer.js +112 -0
  129. package/dist/lib/resources/aws/database/rdsAurora.d.ts +8 -1
  130. package/dist/lib/resources/aws/database/rdsAurora.js +42 -32
  131. package/dist/lib/resources/aws/database/rdsAuroraGlobal.d.ts +15 -2
  132. package/dist/lib/resources/aws/database/rdsAuroraGlobal.js +39 -43
  133. package/dist/lib/resources/aws/database/rdsDefaults.d.ts +6 -0
  134. package/dist/lib/resources/aws/database/rdsDefaults.js +7 -1
  135. package/dist/lib/resources/aws/database/rdsHelpers.d.ts +3 -3
  136. package/dist/lib/resources/aws/database/rdsHelpers.js +1 -0
  137. package/dist/lib/resources/aws/database/rdsInstance.d.ts +8 -1
  138. package/dist/lib/resources/aws/database/rdsInstance.js +51 -34
  139. package/dist/lib/resources/aws/database/rdsProxyOutput.d.ts +1 -1
  140. package/dist/lib/resources/aws/database/rdsProxyOutput.js +1 -1
  141. package/dist/lib/resources/aws/iam/delegationRole.js +12 -5
  142. package/dist/lib/resources/aws/iam/identityCenter/groupMembership.d.ts +9 -0
  143. package/dist/lib/resources/aws/iam/identityCenter/groupMembership.js +12 -0
  144. package/dist/lib/resources/aws/iam/identityCenter/index.d.ts +1 -0
  145. package/dist/lib/resources/aws/iam/identityCenter/index.js +1 -0
  146. package/dist/lib/resources/aws/iam/identityCenter/permissionSet.d.ts +1 -0
  147. package/dist/lib/resources/aws/iam/identityCenter/permissionSet.js +1 -0
  148. package/dist/lib/resources/aws/logging/logGroup.d.ts +0 -8
  149. package/dist/lib/resources/aws/logging/logGroup.js +0 -11
  150. package/dist/lib/resources/aws/messaging/defaultEventBus.d.ts +7 -0
  151. package/dist/lib/resources/aws/messaging/defaultEventBus.js +21 -0
  152. package/dist/lib/resources/aws/messaging/eventBridgeRule.d.ts +96 -0
  153. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +110 -0
  154. package/dist/lib/resources/aws/messaging/eventTargets.d.ts +84 -0
  155. package/dist/lib/resources/aws/messaging/eventTargets.js +152 -0
  156. package/dist/lib/resources/aws/messaging/eventbridge.d.ts +25 -2
  157. package/dist/lib/resources/aws/messaging/eventbridge.js +22 -10
  158. package/dist/lib/resources/aws/messaging/index.d.ts +5 -0
  159. package/dist/lib/resources/aws/messaging/index.js +2 -0
  160. package/dist/lib/resources/aws/messaging/schedule.d.ts +118 -0
  161. package/dist/lib/resources/aws/messaging/schedule.js +64 -0
  162. package/dist/lib/resources/aws/messaging/sns.d.ts +2 -1
  163. package/dist/lib/resources/aws/messaging/sqs.d.ts +2 -1
  164. package/dist/lib/resources/aws/messaging/subscription.d.ts +112 -0
  165. package/dist/lib/resources/aws/messaging/subscription.js +67 -0
  166. package/dist/lib/resources/aws/messaging/utils.d.ts +6 -0
  167. package/dist/lib/resources/aws/messaging/utils.js +10 -0
  168. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.d.ts +60 -0
  169. package/dist/lib/resources/aws/monitoring/clickhouseAlarms.js +139 -0
  170. package/dist/lib/resources/aws/monitoring/index.d.ts +2 -0
  171. package/dist/lib/resources/aws/monitoring/index.js +2 -0
  172. package/dist/lib/resources/aws/monitoring/scheduleAlarms.d.ts +47 -0
  173. package/dist/lib/resources/aws/monitoring/scheduleAlarms.js +106 -0
  174. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +6 -3
  175. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.d.ts +40 -0
  176. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +158 -0
  177. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +7 -4
  178. package/dist/lib/resources/aws/networking/domainCertificate.d.ts +2 -2
  179. package/dist/lib/resources/aws/networking/domainCertificate.js +6 -3
  180. package/dist/lib/resources/aws/networking/hostedZone.js +6 -4
  181. package/dist/lib/resources/aws/networking/index.d.ts +3 -0
  182. package/dist/lib/resources/aws/networking/index.js +3 -0
  183. package/dist/lib/resources/aws/networking/serviceDiscovery.d.ts +96 -0
  184. package/dist/lib/resources/aws/networking/serviceDiscovery.js +96 -0
  185. package/dist/lib/resources/aws/networking/vpc.d.ts +4 -1
  186. package/dist/lib/resources/aws/networking/vpc.js +10 -3
  187. package/dist/lib/resources/aws/networking/vpcPeeringAccepterRole.d.ts +18 -0
  188. package/dist/lib/resources/aws/networking/vpcPeeringAccepterRole.js +61 -0
  189. package/dist/lib/resources/aws/networking/vpcPeeringConnection.d.ts +49 -0
  190. package/dist/lib/resources/aws/networking/vpcPeeringConnection.js +106 -0
  191. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.d.ts +16 -5
  192. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +17 -3
  193. package/dist/lib/resources/aws/organisation/index.d.ts +1 -1
  194. package/dist/lib/resources/aws/organisation/organisationPolicy.d.ts +2 -0
  195. package/dist/lib/resources/aws/organisation/organisationPolicy.js +3 -2
  196. package/dist/lib/resources/aws/secrets/secret.d.ts +7 -0
  197. package/dist/lib/resources/aws/secrets/secret.js +4 -3
  198. package/dist/lib/resources/aws/storage/bucketDeployment.d.ts +16 -0
  199. package/dist/lib/resources/aws/storage/bucketDeployment.js +17 -0
  200. package/dist/lib/resources/aws/storage/ecr.js +5 -5
  201. package/dist/lib/resources/aws/storage/index.d.ts +1 -0
  202. package/dist/lib/resources/aws/storage/index.js +1 -0
  203. package/dist/lib/resources/aws/storage/s3.js +10 -3
  204. package/dist/lib/resources/aws/utilities/customResource.js +18 -9
  205. package/dist/lib/synth_dump.d.ts +1 -0
  206. package/dist/lib/synth_dump.js +42 -0
  207. package/dist/lib/utils/bastionFactory.d.ts +10 -0
  208. package/dist/lib/utils/bastionFactory.js +29 -0
  209. package/dist/lib/utils/capitaliseString.d.ts +1 -1
  210. package/dist/lib/utils/capitaliseString.js +1 -1
  211. package/dist/lib/utils/cdkContext.d.ts +10 -0
  212. package/dist/lib/utils/cdkContext.js +13 -0
  213. package/dist/lib/utils/connections.d.ts +7 -1
  214. package/dist/lib/utils/connections.js +21 -0
  215. package/dist/lib/utils/connector.d.ts +30 -2
  216. package/dist/lib/utils/connector.js +6 -1
  217. package/dist/lib/utils/costAllocationTags.d.ts +15 -0
  218. package/dist/lib/utils/costAllocationTags.js +16 -0
  219. package/dist/lib/utils/databaseTypes.d.ts +14 -0
  220. package/dist/lib/utils/getConfig.d.ts +2 -0
  221. package/dist/lib/utils/getConfig.js +2 -0
  222. package/dist/lib/utils/index.d.ts +4 -0
  223. package/dist/lib/utils/index.js +4 -0
  224. package/dist/lib/utils/manifestWriter.d.ts +6 -89
  225. package/dist/lib/utils/manifestWriter.js +36 -23
  226. package/dist/lib/utils/migrationVersionResolvers.d.ts +2 -0
  227. package/dist/lib/utils/migrationVersionResolvers.js +2 -0
  228. package/dist/lib/utils/orgConfigParser.js +2 -1
  229. package/dist/lib/utils/resolveAlertsTopic.d.ts +14 -0
  230. package/dist/lib/utils/resolveAlertsTopic.js +30 -0
  231. package/dist/lib/utils/validationLogger.js +6 -3
  232. package/dist/lib/utils/vpcPeerInterface.d.ts +22 -0
  233. package/dist/lib/utils/vpcPeerInterface.js +1 -0
  234. package/package.json +22 -18
@@ -1,12 +1,30 @@
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";
5
- import EcsCluster, { ScalingType } from "../../resources/aws/compute/ecs.js";
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";
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";
23
+ import { VALIDATION_PATTERNS } from "@fjall/generator";
7
24
  import { COMPUTE_DEFAULTS } from "./compute.js";
8
- // Re-export from ecs.ts to maintain single source of truth
9
- export { ScalingType };
25
+ import { isHookMigrations } from "./computeEcsTypes.js";
26
+ export { ScalingType } from "./computeEcsTypes.js";
27
+ import { ScalingType } from "./computeEcsTypes.js";
10
28
  export const ECS_CAPACITY_PROVIDER_CONFIG = {
11
29
  FARGATE: {
12
30
  usesSpot: false,
@@ -24,6 +42,35 @@ export const ECS_CAPACITY_PROVIDER_CONFIG = {
24
42
  export function getEcsCapacityProviderConfig(provider) {
25
43
  return ECS_CAPACITY_PROVIDER_CONFIG[provider];
26
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
+ }
27
74
  /**
28
75
  * Validates ECS-specific props.
29
76
  * Extracted for clarity and detail parity with database/network patterns.
@@ -40,7 +87,7 @@ export function validateEcsProps(props) {
40
87
  throw new Error(`Duplicate service names: ${[...new Set(duplicateNames)].join(", ")}`);
41
88
  }
42
89
  // Validate service name format
43
- const invalidNames = props.services.filter((s) => !/^[a-zA-Z][a-zA-Z0-9-]*$/.test(s.name));
90
+ const invalidNames = props.services.filter((s) => !VALIDATION_PATTERNS.ECS_SERVICE_NAME.test(s.name));
44
91
  if (invalidNames.length > 0) {
45
92
  throw new Error(`Invalid service names: ${invalidNames.map((s) => s.name).join(", ")}. ` +
46
93
  "Service names must start with a letter and contain only letters, numbers, and hyphens.");
@@ -79,35 +126,397 @@ export function validateEcsProps(props) {
79
126
  throw new Error(`Service '${service.name}' uses EC2 capacity provider but no ec2Config is defined. ` +
80
127
  "Provide ec2Config on the service.");
81
128
  }
82
- // Warn if service ec2Config is defined but capacityProvider is not EC2
83
129
  if (service.ec2Config && service.capacityProvider !== "EC2") {
84
130
  FjallLogger.warn(`Service '${service.name}' has ec2Config but capacityProvider is not 'EC2'. ` +
85
131
  "The ec2Config will be ignored unless capacityProvider is set to 'EC2'.");
86
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
+ }
168
+ }
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
+ }
87
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
+ };
88
424
  }
89
425
  /**
90
426
  * Build container configurations for an ECS service.
91
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.
92
437
  * @internal Exported for testing only
93
438
  */
94
- export function buildContainerConfigs(service) {
95
- if (service.containers && service.containers.length > 0) {
96
- return service.containers.map((c, index) => ({
97
- name: c.name || `${service.name}Container${index > 0 ? index : ""}`,
98
- image: c.image,
99
- port: c.port,
100
- environment: c.environment,
101
- secrets: c.secrets,
102
- secretsImport: c.secretsImport,
103
- command: c.command,
104
- entryPoint: c.entryPoint,
105
- essential: c.essential,
106
- healthCheck: c.healthCheck
107
- }));
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
+ });
108
492
  }
109
- // Default container (no port = worker)
110
- return [{ name: `${service.name}Container` }];
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).`);
518
+ }
519
+ return containerImage;
111
520
  }
112
521
  /**
113
522
  * Resolve scaling configuration from service props.
@@ -143,12 +552,26 @@ export class EcsCompute extends Construct {
143
552
  computeType = "ecs";
144
553
  connections;
145
554
  ecsCluster;
555
+ clusterId;
556
+ appName;
557
+ migrationTaskDefinitions = new Map();
146
558
  constructor(scope, id, props) {
147
559
  super(scope, id);
148
- // Transform EcsServiceConfig[] to EcsServiceProps[] for EcsCluster
560
+ validateEcsProps(props);
561
+ this.clusterId = id;
562
+ this.appName = props.appName;
149
563
  const services = props.services.map((service) => {
150
- const containers = buildContainerConfigs(service);
564
+ const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
565
+ const containers = buildContainerConfigs(service, schemaVersionEnv, this);
151
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;
152
575
  return {
153
576
  name: service.name,
154
577
  image: service.image,
@@ -163,33 +586,40 @@ export class EcsCompute extends Construct {
163
586
  taskRoleInlinePolicies: service.taskRoleInlinePolicies,
164
587
  taskRoleManagedPolicies: service.taskRoleManagedPolicies,
165
588
  connections: service.connections,
589
+ remoteConnections: service.remoteConnections,
166
590
  capacityProvider: service.capacityProvider,
167
591
  ec2Config: service.ec2Config,
168
592
  ssmSecretsPath: service.ssmSecretsPath,
169
- dockerTarget: service.dockerTarget,
170
- 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
+ })
171
609
  };
172
610
  });
173
- // Build cluster config
174
611
  const cluster = props.cluster
175
612
  ? {
176
613
  domain: props.cluster.domain,
177
614
  loadBalancer: props.cluster.loadBalancer,
178
615
  directAccess: props.cluster.directAccess,
179
- domainConfig: props.cluster.domainConfig
616
+ domainConfig: props.cluster.domainConfig,
617
+ ...(props.cluster.securityGroup !== undefined && {
618
+ securityGroup: props.cluster.securityGroup
619
+ })
180
620
  }
181
621
  : undefined;
182
- // Resolve alertsTopic: accept ITopic directly or string (ARN or "import:ExportName")
183
- let resolvedAlertsTopic;
184
- if (typeof props.alertsTopic === "string") {
185
- const arn = props.alertsTopic.startsWith("import:")
186
- ? Fn.importValue(props.alertsTopic.slice("import:".length))
187
- : props.alertsTopic;
188
- resolvedAlertsTopic = Topic.fromTopicArn(this, "AlertsTopic", arn);
189
- }
190
- else {
191
- resolvedAlertsTopic = props.alertsTopic;
192
- }
622
+ const resolvedAlertsTopic = resolveAlertsTopic(this, `${id}AlertsTopic`, props.alertsTopic);
193
623
  const ecsProps = {
194
624
  clusterName: id,
195
625
  appName: props.appName,
@@ -200,8 +630,381 @@ export class EcsCompute extends Construct {
200
630
  alertsTopic: resolvedAlertsTopic,
201
631
  applicationId: props.applicationId
202
632
  };
203
- this.ecsCluster = new EcsCluster(scope, `${id}Ecs`, ecsProps);
633
+ this.ecsCluster = new EcsCluster(this, `${id}Ecs`, ecsProps);
204
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 };
205
1008
  }
206
1009
  /** Get the ECS cluster. */
207
1010
  getCluster() {
@@ -220,14 +1023,25 @@ export class EcsCompute extends Construct {
220
1023
  const servicesMap = this.ecsCluster.getServices();
221
1024
  return Array.from(servicesMap.values());
222
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
+ }
223
1038
  /** Get the security group for the cluster. */
224
1039
  getSecurityGroup() {
225
- // Return the first security group from connections
226
- const securityGroups = this.connections.securityGroups;
227
- if (securityGroups.length === 0) {
1040
+ const sg = this.connections.securityGroups[0];
1041
+ if (!sg) {
228
1042
  throw new Error("No security groups found for ECS cluster");
229
1043
  }
230
- return securityGroups[0];
1044
+ return sg;
231
1045
  }
232
1046
  /**
233
1047
  * Get the ALB listener if this is an ECS compute with ALB.
@@ -235,6 +1049,9 @@ export class EcsCompute extends Construct {
235
1049
  getListener() {
236
1050
  return this.ecsCluster.getListener();
237
1051
  }
1052
+ getPrimaryListenerPort() {
1053
+ return this.getListener()?.port;
1054
+ }
238
1055
  /**
239
1056
  * Get the underlying ECS cluster construct.
240
1057
  */
@@ -253,4 +1070,21 @@ export class EcsCompute extends Construct {
253
1070
  resourceArns: ["*"]
254
1071
  });
255
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
+ }
256
1090
  }