@fjall/components-infrastructure 0.96.0 → 0.99.3

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 +173 -0
  32. package/dist/lib/patterns/aws/clickhouseDatabase.js +601 -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 +160 -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,27 +1,75 @@
1
1
  import { InstanceType, LaunchTemplate, SubnetType, MachineImage, LaunchTemplateHttpTokens, KeyPair, SecurityGroup, Peer, Port, Connections } from "aws-cdk-lib/aws-ec2";
2
2
  import { Construct } from "constructs";
3
3
  import { resolvePrivateSubnetType } from "../../../utils/vpcUtils.js";
4
- import { Duration, Stack } from "aws-cdk-lib";
5
- import { AutoScalingGroup, GroupMetrics, TerminationPolicy, UpdatePolicy, OnDemandAllocationStrategy, SpotAllocationStrategy } from "aws-cdk-lib/aws-autoscaling";
4
+ import { safeEbs } from "./blockDeviceVolume.js";
5
+ import { Duration, Stack, Tags } from "aws-cdk-lib";
6
+ import { AutoScalingGroup, GroupMetrics, Monitoring, TerminationPolicy, UpdatePolicy, OnDemandAllocationStrategy, SpotAllocationStrategy } from "aws-cdk-lib/aws-autoscaling";
6
7
  import { AwsCustomResource } from "../utilities/awsCustomResource.js";
7
8
  import { PhysicalResourceId } from "aws-cdk-lib/custom-resources";
8
9
  import { Vpc } from "../networking/vpc.js";
10
+ import { Ec2GracefulTerminationHandler } from "./ec2GracefulTerminationHandler.js";
11
+ import { PersistentDataVolume } from "./persistentDataVolume.js";
12
+ /**
13
+ * SOC2-uniform default root device when callers omit `blockDevices`.
14
+ * `/dev/xvda` is the AL2023 / EcsOptimizedImage root device name and matches
15
+ * every Linux AMI consumed by `Ec2Instance` today (ECS, Buildkite, ClickHouse).
16
+ * `encrypted: true` enforces EBS encryption at the wrapper layer rather than
17
+ * relying on the AWS account-level "encryption by default" setting — the
18
+ * propagation contract documented in
19
+ * `.claude/rules/generator-standards.md § "Wrapper Routing Discipline"`.
20
+ */
21
+ const DEFAULT_ROOT_DEVICE_NAME = "/dev/xvda";
22
+ const DEFAULT_ROOT_VOLUME_SIZE_GIB = 30;
23
+ /**
24
+ * Resolve the caller's `updatePolicy` prop to a CDK `UpdatePolicy`. Default
25
+ * (`config` absent) → single-instance rolling roll on every
26
+ * `LaunchTemplateVersion` change. The `none` branch returns `undefined` so
27
+ * `addAutoScalingGroup` can conditionally spread the field — passing
28
+ * `updatePolicy: undefined` directly emits an empty `UpdatePolicy: {}` block
29
+ * on some CDK versions.
30
+ *
31
+ * Free function rather than a private method: `this` state is not read, and
32
+ * a free function is easier to reach from focused tests.
33
+ */
34
+ function resolveUpdatePolicy(config) {
35
+ const resolved = config ?? { type: "rollingUpdate" };
36
+ switch (resolved.type) {
37
+ case "none":
38
+ return undefined;
39
+ case "replacingUpdate":
40
+ return UpdatePolicy.replacingUpdate();
41
+ case "rollingUpdate":
42
+ return UpdatePolicy.rollingUpdate({
43
+ minInstancesInService: 0,
44
+ maxBatchSize: 1,
45
+ pauseTime: resolved.pauseTime ?? Duration.minutes(5)
46
+ });
47
+ default:
48
+ return assertNever(resolved);
49
+ }
50
+ }
51
+ function assertNever(value) {
52
+ throw new Error(`Ec2Instance.updatePolicy: unrecognised config ${JSON.stringify(value)}`);
53
+ }
9
54
  export class Ec2Instance extends Construct {
10
55
  launchTemplate;
11
56
  vpc;
12
57
  asgSecurityGroup;
13
58
  autoScalingGroup;
14
59
  keyPair;
60
+ persistentDataVolume;
15
61
  connections;
16
62
  constructor(scope, id, props) {
17
63
  super(scope, id);
64
+ validateEc2InstanceProps(props);
18
65
  this.addVpc(props);
19
66
  this.addKeyPair(props);
20
67
  this.addLaunchTemplate(props);
21
68
  this.addAutoScalingGroup(props);
69
+ this.applyInstanceTags(props);
22
70
  this.suspendAutoScaling(props);
23
- // Initialise connections with the security group and optional defaultPort
24
- // When defaultPort is set, other resources can use allowToDefaultPort(ec2Instance)
71
+ this.addPersistentDataVolume(props);
72
+ this.addGracefulTerminationHandler(props);
25
73
  this.connections = new Connections({
26
74
  securityGroups: [this.asgSecurityGroup],
27
75
  defaultPort: props.defaultPort ? Port.tcp(props.defaultPort) : undefined
@@ -43,52 +91,76 @@ export class Ec2Instance extends Construct {
43
91
  this.keyPair = undefined;
44
92
  }
45
93
  else {
46
- // TODO: Break out into a separate construct for use with better prop handling
47
94
  this.keyPair = new KeyPair(this, "KeyPair", {
48
95
  keyPairName: `${props.serviceName}KeyPair`
49
96
  });
50
97
  }
51
98
  }
52
99
  addLaunchTemplate(props) {
53
- this.asgSecurityGroup = new SecurityGroup(this, `AsgSecurityGroup`, {
54
- vpc: this.vpc,
55
- description: `Security group for the ${props.serviceName} auto scaling group`
56
- });
100
+ if (props.securityGroup !== undefined) {
101
+ this.asgSecurityGroup = props.securityGroup;
102
+ }
103
+ else {
104
+ this.asgSecurityGroup = new SecurityGroup(this, `AsgSecurityGroup`, {
105
+ vpc: this.vpc,
106
+ description: `Security group for the ${props.serviceName} auto scaling group`
107
+ });
108
+ }
57
109
  if (props.enableSSH) {
58
110
  this.asgSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(22), "Allow SSH");
59
111
  }
60
112
  this.launchTemplate = new LaunchTemplate(this, "LaunchTemplate", {
61
113
  launchTemplateName: `${props.serviceName}LaunchTemplate`,
62
- instanceType: new InstanceType(`${props.instanceType}`),
63
- machineImage: props.machineImage || MachineImage.latestAmazonLinux2023(),
114
+ instanceType: new InstanceType(props.instanceType),
115
+ machineImage: props.machineImage ?? MachineImage.latestAmazonLinux2023(),
64
116
  userData: props.userData,
65
117
  role: props.role,
66
- blockDevices: props?.blockDevices,
118
+ blockDevices: props.blockDevices ?? [
119
+ {
120
+ deviceName: DEFAULT_ROOT_DEVICE_NAME,
121
+ volume: safeEbs(DEFAULT_ROOT_VOLUME_SIZE_GIB)
122
+ }
123
+ ],
67
124
  securityGroup: this.asgSecurityGroup,
68
- detailedMonitoring: true,
125
+ detailedMonitoring: props.instanceMonitoring === undefined
126
+ ? true
127
+ : props.instanceMonitoring === Monitoring.DETAILED,
69
128
  requireImdsv2: true,
70
129
  httpPutResponseHopLimit: 2,
71
130
  httpTokens: LaunchTemplateHttpTokens.REQUIRED,
72
131
  instanceMetadataTags: true,
73
132
  keyPair: this.keyPair,
74
- associatePublicIpAddress: !!this.keyPair
133
+ associatePublicIpAddress: props.associatePublicIpAddress ?? !!this.keyPair
75
134
  });
76
135
  }
77
136
  addAutoScalingGroup(props) {
78
- // TODO: Handle terminating EC2 instances when updating, currently hangs.
137
+ const vpcSubnets = props.vpcSubnets ?? {
138
+ subnetType: props.enableSSH
139
+ ? SubnetType.PUBLIC
140
+ : resolvePrivateSubnetType(this.vpc)
141
+ };
142
+ const resolvedUpdatePolicy = resolveUpdatePolicy(props.updatePolicy);
79
143
  const baseAsgProps = {
80
144
  vpc: this.vpc,
81
- vpcSubnets: {
82
- subnetType: props.enableSSH
83
- ? SubnetType.PUBLIC
84
- : resolvePrivateSubnetType(this.vpc)
85
- },
145
+ vpcSubnets,
86
146
  minCapacity: props.minCapacity,
87
147
  maxCapacity: props.maxCapacity,
148
+ ...(props.desiredCapacity !== undefined && {
149
+ desiredCapacity: props.desiredCapacity
150
+ }),
151
+ ...(props.capacityRebalance !== undefined && {
152
+ capacityRebalance: props.capacityRebalance
153
+ }),
88
154
  cooldown: Duration.seconds(60),
89
155
  groupMetrics: [GroupMetrics.all()],
90
- updatePolicy: UpdatePolicy.replacingUpdate(),
91
- newInstancesProtectedFromScaleIn: true,
156
+ ...(resolvedUpdatePolicy !== undefined && {
157
+ updatePolicy: resolvedUpdatePolicy
158
+ }),
159
+ // ECS task drain on terminate is handled by the EC2_INSTANCE_TERMINATING
160
+ // lifecycle hook + Ec2GracefulTerminationHandler (300s heartbeat), not by
161
+ // managed termination protection. Leaving instances scale-in-protected
162
+ // wedges CFN rollback because ASG cannot terminate the instance.
163
+ newInstancesProtectedFromScaleIn: false,
92
164
  terminationPolicies: [
93
165
  TerminationPolicy.OLDEST_LAUNCH_CONFIGURATION,
94
166
  TerminationPolicy.CLOSEST_TO_NEXT_INSTANCE_HOUR
@@ -98,7 +170,7 @@ export class Ec2Instance extends Construct {
98
170
  // Support spot instances via mixed instances policy
99
171
  if (props.spotCapacityPercentage !== undefined &&
100
172
  props.spotCapacityPercentage > 0) {
101
- const spotPercentage = Math.min(100, Math.max(0, props.spotCapacityPercentage));
173
+ const spotPercentage = Math.min(100, props.spotCapacityPercentage);
102
174
  const onDemandPercentage = 100 - spotPercentage;
103
175
  this.autoScalingGroup = new AutoScalingGroup(this, "AutoScalingGroup", {
104
176
  ...baseAsgProps,
@@ -119,6 +191,24 @@ export class Ec2Instance extends Construct {
119
191
  launchTemplate: this.launchTemplate
120
192
  });
121
193
  }
194
+ if (props.warmPool !== undefined) {
195
+ this.autoScalingGroup.addWarmPool(props.warmPool);
196
+ }
197
+ }
198
+ /**
199
+ * Apply `props.tags` to the underlying ASG with
200
+ * `applyToLaunchedInstances: true` so the CFN ASG `Tags` array carries
201
+ * `{ Key, Value, PropagateAtLaunch: true }` for each entry. Enables
202
+ * tag-based SSM `SendCommand` targeting on the launched EC2 instances.
203
+ */
204
+ applyInstanceTags(props) {
205
+ if (props.tags === undefined)
206
+ return;
207
+ for (const [key, value] of Object.entries(props.tags)) {
208
+ Tags.of(this.autoScalingGroup).add(key, value, {
209
+ applyToLaunchedInstances: true
210
+ });
211
+ }
122
212
  }
123
213
  /**
124
214
  * Get the Auto Scaling Group.
@@ -126,6 +216,34 @@ export class Ec2Instance extends Construct {
126
216
  getAutoScalingGroup() {
127
217
  return this.autoScalingGroup;
128
218
  }
219
+ /**
220
+ * Wire an ASG `EC2_INSTANCE_TERMINATING` hook + Lambda so instances
221
+ * terminate cleanly during stack updates and stack deletes. Drain ownership
222
+ * lives at the EC2 layer; ECS-specific drain plugs in via `ecsClusterArn`
223
+ * so a single hook handles bare-EC2 (bastion, Fivetran) and ECS-wired
224
+ * ASGs (ClickHouse) without duplicate plumbing in the ECS layer.
225
+ */
226
+ addGracefulTerminationHandler(props) {
227
+ const dataVolumeOwnerLogicalId = this.persistentDataVolume?.ownerLogicalId;
228
+ new Ec2GracefulTerminationHandler(this, `${props.serviceName}GracefulTermination`, {
229
+ autoScalingGroup: this.autoScalingGroup,
230
+ ...(props.ecsClusterArn !== undefined &&
231
+ props.ecsClusterArn !== "" && {
232
+ ecsClusterArn: props.ecsClusterArn
233
+ }),
234
+ ...(dataVolumeOwnerLogicalId !== undefined && {
235
+ dataVolumeOwnerLogicalId
236
+ })
237
+ });
238
+ }
239
+ addPersistentDataVolume(props) {
240
+ if (props.persistentDataVolume === undefined)
241
+ return;
242
+ this.persistentDataVolume = new PersistentDataVolume(this, `${props.serviceName}PersistentDataVolume`, {
243
+ autoScalingGroup: this.autoScalingGroup,
244
+ ...props.persistentDataVolume
245
+ });
246
+ }
129
247
  suspendAutoScaling(_props) {
130
248
  const suspendCall = {
131
249
  service: "AutoScaling",
@@ -143,9 +261,31 @@ export class Ec2Instance extends Construct {
143
261
  });
144
262
  }
145
263
  }
264
+ function validateEc2InstanceProps(props) {
265
+ if (props.persistentDataVolume !== undefined) {
266
+ const azs = props.vpcSubnets?.availabilityZones;
267
+ if (azs === undefined || azs.length !== 1) {
268
+ throw new Error(`Ec2Instance.persistentDataVolume requires vpcSubnets.availabilityZones to be exactly 1 entry; got ${azs?.length ?? 0}`);
269
+ }
270
+ if (azs[0] !== props.persistentDataVolume.availabilityZone) {
271
+ throw new Error(`Ec2Instance.persistentDataVolume.availabilityZone must match vpcSubnets.availabilityZones[0]; got '${props.persistentDataVolume.availabilityZone}' vs '${azs[0]}'`);
272
+ }
273
+ }
274
+ if (props.tags !== undefined) {
275
+ for (const [key, value] of Object.entries(props.tags)) {
276
+ if (key === "") {
277
+ throw new Error("Ec2Instance.tags: empty-string keys are not permitted");
278
+ }
279
+ if (value === "") {
280
+ throw new Error(`Ec2Instance.tags['${key}']: empty-string values are not permitted`);
281
+ }
282
+ }
283
+ }
284
+ }
146
285
  export class Ec2InstanceStack extends Stack {
147
286
  constructor(scope, id, props) {
148
287
  super(scope, id, props);
288
+ validateEc2InstanceProps(props);
149
289
  new Ec2Instance(this, id, {
150
290
  ...props
151
291
  });
@@ -0,0 +1,41 @@
1
+ import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
2
+ import { Construct } from "constructs";
3
+ import { LambdaFunction } from "./lambda.js";
4
+ import { SQSQueue } from "../messaging/sqs.js";
5
+ /**
6
+ * Description prefix baked into the Lambda + execution-role descriptions.
7
+ * Tests grep CFN output for this needle to locate the function across
8
+ * stacks that may contain unrelated Lambdas — keep it here as the single
9
+ * source of truth so a rename does not silently break the test query.
10
+ */
11
+ export declare const EC2_GRACEFUL_TERMINATION_HANDLER_DESCRIPTION = "EC2 graceful termination handler";
12
+ export interface Ec2GracefulTerminationHandlerProps {
13
+ /** ASG to attach the EC2_INSTANCE_TERMINATING hook to. */
14
+ autoScalingGroup: AutoScalingGroup;
15
+ /**
16
+ * ECS cluster ARN — when set, the Lambda drains and deregisters the
17
+ * container instance before generic cleanup. Empty string is normalised
18
+ * to `undefined`; consumers should pass `undefined` for bare-EC2
19
+ * deployments (bastion, Fivetran).
20
+ */
21
+ ecsClusterArn?: string;
22
+ /**
23
+ * `fjall:OwnerLogicalId` of a paired `PersistentDataVolume`. When set, the
24
+ * Lambda detaches the tagged volume from the terminating instance before
25
+ * `CompleteLifecycleAction`. Empty string is normalised to `undefined`.
26
+ * Pass the `PersistentDataVolume.ownerLogicalId` so the TERMINATING and
27
+ * LAUNCHING handlers locate the same volume.
28
+ */
29
+ dataVolumeOwnerLogicalId?: string;
30
+ }
31
+ /**
32
+ * ASG `EC2_INSTANCE_TERMINATING` hook + Lambda that drains the instance
33
+ * cleanly. Drain ownership lives at the EC2 layer; ECS-specific drain plugs
34
+ * in via `ecsClusterArn` so a single hook handles both bare-EC2 (bastion,
35
+ * Fivetran) and ECS-wired ASGs (ClickHouse).
36
+ */
37
+ export declare class Ec2GracefulTerminationHandler extends Construct {
38
+ readonly lambda: LambdaFunction;
39
+ readonly queue: SQSQueue;
40
+ constructor(scope: Construct, id: string, props: Ec2GracefulTerminationHandlerProps);
41
+ }
@@ -0,0 +1,194 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Aws, Stack } from "aws-cdk-lib";
5
+ import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
7
+ import { RetentionDays } from "aws-cdk-lib/aws-logs";
8
+ import { LifecycleTransition, DefaultResult } from "aws-cdk-lib/aws-autoscaling";
9
+ import { Construct } from "constructs";
10
+ import { attachInlineAsgLifecycleHook } from "./asgInlineLifecycleHook.js";
11
+ import { LambdaFunction } from "./lambda.js";
12
+ import { PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE, PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE, PERSISTENT_DATA_VOLUME_TAG_STACK_ID } from "./persistentDataVolume.js";
13
+ import { SQSQueue } from "../messaging/sqs.js";
14
+ import { Subscription } from "../messaging/subscription.js";
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const LAMBDA_SOURCE_FILE = "ec2GracefulTerminationLambda.source.cjs";
17
+ /**
18
+ * Description prefix baked into the Lambda + execution-role descriptions.
19
+ * Tests grep CFN output for this needle to locate the function across
20
+ * stacks that may contain unrelated Lambdas — keep it here as the single
21
+ * source of truth so a rename does not silently break the test query.
22
+ */
23
+ export const EC2_GRACEFUL_TERMINATION_HANDLER_DESCRIPTION = "EC2 graceful termination handler";
24
+ const LAMBDA_TIMEOUT_SECONDS = 300;
25
+ // Heartbeat must comfortably exceed the Lambda's drain (240s) + detach (60s)
26
+ // budget so the hook does not time out mid-cleanup when PDV is wired.
27
+ const HOOK_HEARTBEAT_SECONDS = 600;
28
+ const QUEUE_VISIBILITY_TIMEOUT_SECONDS = 360;
29
+ /**
30
+ * ASG `EC2_INSTANCE_TERMINATING` hook + Lambda that drains the instance
31
+ * cleanly. Drain ownership lives at the EC2 layer; ECS-specific drain plugs
32
+ * in via `ecsClusterArn` so a single hook handles both bare-EC2 (bastion,
33
+ * Fivetran) and ECS-wired ASGs (ClickHouse).
34
+ */
35
+ export class Ec2GracefulTerminationHandler extends Construct {
36
+ lambda;
37
+ queue;
38
+ constructor(scope, id, props) {
39
+ super(scope, id);
40
+ const sourcePath = path.resolve(__dirname, LAMBDA_SOURCE_FILE);
41
+ const source = readFileSync(sourcePath, "utf-8");
42
+ const ecsClusterArn = resolveOptionalString(props.ecsClusterArn);
43
+ const dataVolumeOwnerLogicalId = resolveOptionalString(props.dataVolumeOwnerLogicalId);
44
+ this.queue = new SQSQueue(this, `${id}Queue`, {
45
+ visibilityTimeout: QUEUE_VISIBILITY_TIMEOUT_SECONDS,
46
+ deadLetterQueue: { enabled: true, maxReceiveCount: 5 }
47
+ });
48
+ const ecsPolicies = ecsClusterArn !== undefined
49
+ ? [
50
+ new PolicyStatement({
51
+ effect: Effect.ALLOW,
52
+ actions: ["ecs:ListContainerInstances"],
53
+ resources: [ecsClusterArn]
54
+ }),
55
+ new PolicyStatement({
56
+ effect: Effect.ALLOW,
57
+ actions: [
58
+ "ecs:DescribeContainerInstances",
59
+ "ecs:UpdateContainerInstancesState",
60
+ "ecs:DeregisterContainerInstance"
61
+ ],
62
+ resources: ["*"],
63
+ conditions: {
64
+ ArnEquals: { "ecs:cluster": ecsClusterArn }
65
+ }
66
+ })
67
+ ]
68
+ : [];
69
+ const ec2Policies = new PolicyStatement({
70
+ effect: Effect.ALLOW,
71
+ actions: [
72
+ "ec2:DescribeAddresses",
73
+ "ec2:DisassociateAddress",
74
+ "ec2:DescribeNetworkInterfaces",
75
+ "ec2:DetachNetworkInterface"
76
+ ],
77
+ resources: ["*"]
78
+ });
79
+ const elbReadPolicy = new PolicyStatement({
80
+ effect: Effect.ALLOW,
81
+ actions: [
82
+ "elasticloadbalancing:DescribeTargetGroups",
83
+ "elasticloadbalancing:DescribeTargetHealth"
84
+ ],
85
+ resources: ["*"]
86
+ });
87
+ const elbDeregisterPolicy = new PolicyStatement({
88
+ effect: Effect.ALLOW,
89
+ actions: ["elasticloadbalancing:DeregisterTargets"],
90
+ resources: ["*"],
91
+ conditions: {
92
+ StringEquals: {
93
+ "aws:ResourceTag/aws:cloudformation:stack-id": Aws.STACK_ID
94
+ }
95
+ }
96
+ });
97
+ const stack = Stack.of(this);
98
+ // Account/region-scoped wildcard rather than the specific ASG ARN —
99
+ // see PersistentDataVolume for the full deadlock writeup. Same gotcha:
100
+ // a specific ARN creates a CFN Ref to the ASG, the Lambda's IAM Policy
101
+ // waits for ASG CREATE_COMPLETE, the ASG can't complete without this
102
+ // Lambda's CompleteLifecycleAction call on its first terminating instance.
103
+ const asgArnWildcard = `arn:${stack.partition}:autoscaling:${stack.region}:${stack.account}:autoScalingGroup:*:autoScalingGroupName/*`;
104
+ const asgPolicy = new PolicyStatement({
105
+ effect: Effect.ALLOW,
106
+ actions: ["autoscaling:CompleteLifecycleAction"],
107
+ resources: [asgArnWildcard]
108
+ });
109
+ const volumeArnPattern = `arn:${stack.partition}:ec2:${stack.region}:${stack.account}:volume/*`;
110
+ const instanceArnPattern = `arn:${stack.partition}:ec2:${stack.region}:${stack.account}:instance/*`;
111
+ const dataVolumePolicies = dataVolumeOwnerLogicalId !== undefined
112
+ ? [
113
+ new PolicyStatement({
114
+ effect: Effect.ALLOW,
115
+ actions: ["ec2:DescribeVolumes"],
116
+ resources: ["*"]
117
+ }),
118
+ new PolicyStatement({
119
+ effect: Effect.ALLOW,
120
+ actions: ["ec2:DetachVolume"],
121
+ resources: [volumeArnPattern],
122
+ conditions: {
123
+ StringEquals: {
124
+ [`aws:ResourceTag/${PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE}`]: PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE,
125
+ [`aws:ResourceTag/${PERSISTENT_DATA_VOLUME_TAG_STACK_ID}`]: Aws.STACK_ID
126
+ }
127
+ }
128
+ }),
129
+ new PolicyStatement({
130
+ effect: Effect.ALLOW,
131
+ actions: ["ec2:DetachVolume"],
132
+ resources: [instanceArnPattern]
133
+ })
134
+ ]
135
+ : [];
136
+ this.lambda = new LambdaFunction(this, `${id}Fn`, {
137
+ runtime: Runtime.NODEJS_22_X,
138
+ handler: "index.handler",
139
+ code: Code.fromInline(source),
140
+ lambdaDescription: `${id} ${EC2_GRACEFUL_TERMINATION_HANDLER_DESCRIPTION}`,
141
+ roleDescription: `Execution role for ${id} ${EC2_GRACEFUL_TERMINATION_HANDLER_DESCRIPTION}`,
142
+ timeout: LAMBDA_TIMEOUT_SECONDS,
143
+ memorySize: 256,
144
+ logGroupRetention: RetentionDays.ONE_MONTH,
145
+ environment: {
146
+ ...(ecsClusterArn !== undefined && { ECS_CLUSTER_ARN: ecsClusterArn }),
147
+ ...(dataVolumeOwnerLogicalId !== undefined && {
148
+ DATA_VOLUME_OWNER_LOGICAL_ID: dataVolumeOwnerLogicalId,
149
+ DATA_VOLUME_STACK_ID: Aws.STACK_ID
150
+ })
151
+ },
152
+ inlinePolicy: [
153
+ ...ecsPolicies,
154
+ ec2Policies,
155
+ elbReadPolicy,
156
+ elbDeregisterPolicy,
157
+ asgPolicy,
158
+ ...dataVolumePolicies
159
+ ]
160
+ });
161
+ this.lambda.addSqsEventSource(this.queue.getQueue());
162
+ // Hook name embeds Aws.STACK_NAME (pseudo-parameter Ref, NOT a resource
163
+ // Ref — no CFN dep) so LifecycleHookName alone discriminates across
164
+ // stacks in the EB pattern. Sibling LAUNCHING hook uses "-launching".
165
+ const terminatingHookName = `${Aws.STACK_NAME}-${id}-terminating`;
166
+ attachInlineAsgLifecycleHook(this, `${id}TerminatingHook`, {
167
+ autoScalingGroup: props.autoScalingGroup,
168
+ hookName: terminatingHookName,
169
+ lifecycleTransition: LifecycleTransition.INSTANCE_TERMINATING,
170
+ defaultResult: DefaultResult.CONTINUE,
171
+ heartbeatTimeoutSeconds: HOOK_HEARTBEAT_SECONDS
172
+ });
173
+ // No AutoScalingGroupName in the pattern — that would create a CFN Ref to
174
+ // the ASG and block the EB rule on ASG CREATE_COMPLETE (deadlock — see
175
+ // wildcard ARN comment above and the PersistentDataVolume sibling).
176
+ new Subscription(this, `${id}TerminatingSub`, {
177
+ pattern: {
178
+ source: ["aws.autoscaling"],
179
+ detailType: ["EC2 Instance-terminate Lifecycle Action"],
180
+ detail: {
181
+ LifecycleHookName: [terminatingHookName]
182
+ }
183
+ },
184
+ target: this.queue
185
+ });
186
+ }
187
+ }
188
+ function resolveOptionalString(value) {
189
+ if (value === undefined)
190
+ return undefined;
191
+ if (value === "")
192
+ return undefined;
193
+ return value;
194
+ }