@fjall/components-infrastructure 39.0.1 → 39.0.2

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.
@@ -41,10 +41,10 @@ export interface ClickHouseDatabaseProps {
41
41
  * It is still not free: the type lives in the launch template and in
42
42
  * user-data-baked server tuning, so it takes an instance refresh, and on
43
43
  * this single-node cluster that refresh is a restart. The ASG terminates
44
- * the instance (TERMINATING hook detaches the volume), launches a
45
- * replacement, and the LAUNCHING hook re-attaches — ClickHouse is
46
- * unavailable across that window. Size the change into a quiet period;
47
- * do not plan a snapshot-restore migration for it. */
44
+ * the instance and launches a replacement, and the data volume moves
45
+ * across by the path `storageGb` describes — ClickHouse is unavailable
46
+ * across that window. Size the change into a quiet period; do not plan a
47
+ * snapshot-restore migration for it. */
48
48
  instanceType?: string;
49
49
  /**
50
50
  * EBS data-volume size in GiB. Default `CLICKHOUSE_EBS_VOLUME_SIZE_GB`
@@ -64,10 +64,22 @@ export interface ClickHouseDatabaseProps {
64
64
  * is embedded in that user data, so changing this prop versions the launch
65
65
  * template and triggers the ASG instance refresh whose boot grows the
66
66
  * filesystem — the increase applies in the same deploy, by the same
67
- * mechanism an `instanceType` change rides. On this single-node cluster
68
- * that relaunch is a restart (the TERMINATING hook detaches the volume,
69
- * LAUNCHING re-attaches it): the data survives; the cluster is unavailable
70
- * across the window. Size increases into a quiet period, exactly as with
67
+ * mechanism an `instanceType` change rides.
68
+ *
69
+ * On this single-node cluster that relaunch is a restart, and the data
70
+ * survives it because nothing detaches the volume. The TERMINATING hook
71
+ * drains the old instance and waits until every task on it is proven
72
+ * STOPPED, stopping any that outlast the drain and alarming when it cannot
73
+ * prove it; the instance's own shutdown then unmounts the filesystem and
74
+ * releases the volume. The LAUNCHING hook waits for that release, attaches
75
+ * the volume to the replacement, and ABANDONs the replacement if the
76
+ * volume is not released or attached in time, so the ASG launches another
77
+ * and the wait starts over. At boot the replacement formats the device
78
+ * only when it carries no filesystem signature, reads back, and holds the
79
+ * ext4 magic in neither its primary superblock nor its first backup; an
80
+ * ext4 filesystem is mounted as found, and anything else is refused rather
81
+ * than overwritten. The cluster is unavailable across the
82
+ * window, so size increases into a quiet period, exactly as with
71
83
  * `instanceType`.
72
84
  */
73
85
  storageGb?: number;
@@ -40,13 +40,22 @@ export interface InlineAsgLifecycleHookProps {
40
40
  * events on the account+region default bus for every lifecycle hook regardless
41
41
  * of whether a notification target is configured. Each consumer attaches a
42
42
  * `Subscription` (from `lib/resources/aws/messaging/subscription.ts`) whose
43
- * event pattern discriminates by `AutoScalingGroupName` + `LifecycleHookName`,
43
+ * event pattern discriminates by `LifecycleHookName` alone — the name embeds
44
+ * the stack name, and matching on `AutoScalingGroupName` would Ref the ASG —
44
45
  * targeting the consumer's own SQS queue. The Lambda sees the EventBridge event
45
46
  * envelope and reads `detail.LifecycleActionToken` etc. from the unwrapped
46
47
  * detail.
47
48
  *
49
+ * The ASG depends on `owner`, the construct that consumes the hook's events.
50
+ * A hook whose rule, queue or Lambda does not exist yet drops its event and
51
+ * runs out its heartbeat, so the consumer must be created before the ASG
52
+ * launches, updated before an instance refresh replaces an instance, and
53
+ * deleted only after the ASG has terminated its instances. The consumer
54
+ * therefore must never Ref the ASG; the hook name and a wildcard ASG ARN are
55
+ * how the consumers avoid one.
56
+ *
48
57
  * Multiple consumers may call this helper against the same ASG — the existing
49
58
  * spec list is read, the new entry appended, and the merged array assigned
50
59
  * back. Synth is sequential per scope, so the merge is race-free.
51
60
  */
52
- export declare function attachInlineAsgLifecycleHook(_scope: Construct, _id: string, props: InlineAsgLifecycleHookProps): void;
61
+ export declare function attachInlineAsgLifecycleHook(owner: Construct, _id: string, props: InlineAsgLifecycleHookProps): void;
@@ -26,16 +26,26 @@
26
26
  * events on the account+region default bus for every lifecycle hook regardless
27
27
  * of whether a notification target is configured. Each consumer attaches a
28
28
  * `Subscription` (from `lib/resources/aws/messaging/subscription.ts`) whose
29
- * event pattern discriminates by `AutoScalingGroupName` + `LifecycleHookName`,
29
+ * event pattern discriminates by `LifecycleHookName` alone — the name embeds
30
+ * the stack name, and matching on `AutoScalingGroupName` would Ref the ASG —
30
31
  * targeting the consumer's own SQS queue. The Lambda sees the EventBridge event
31
32
  * envelope and reads `detail.LifecycleActionToken` etc. from the unwrapped
32
33
  * detail.
33
34
  *
35
+ * The ASG depends on `owner`, the construct that consumes the hook's events.
36
+ * A hook whose rule, queue or Lambda does not exist yet drops its event and
37
+ * runs out its heartbeat, so the consumer must be created before the ASG
38
+ * launches, updated before an instance refresh replaces an instance, and
39
+ * deleted only after the ASG has terminated its instances. The consumer
40
+ * therefore must never Ref the ASG; the hook name and a wildcard ASG ARN are
41
+ * how the consumers avoid one.
42
+ *
34
43
  * Multiple consumers may call this helper against the same ASG — the existing
35
44
  * spec list is read, the new entry appended, and the merged array assigned
36
45
  * back. Synth is sequential per scope, so the merge is race-free.
37
46
  */
38
- export function attachInlineAsgLifecycleHook(_scope, _id, props) {
47
+ export function attachInlineAsgLifecycleHook(owner, _id, props) {
48
+ props.autoScalingGroup.node.addDependency(owner);
39
49
  const cfnAsg = props.autoScalingGroup.node
40
50
  .defaultChild;
41
51
  const existing = readLifecycleHookSpecList(cfnAsg);
@@ -3,6 +3,7 @@ import { Construct } from "constructs";
3
3
  import { type Ec2SshAccessProps } from "./ec2SshAccess.js";
4
4
  import { Duration, Stack, type StackProps } from "aws-cdk-lib";
5
5
  import { type Role } from "aws-cdk-lib/aws-iam";
6
+ import type { ITopic } from "aws-cdk-lib/aws-sns";
6
7
  import { AutoScalingGroup, Monitoring, type WarmPoolOptions } from "aws-cdk-lib/aws-autoscaling";
7
8
  import { type PersistentDataVolumeProps } from "./persistentDataVolume.js";
8
9
  export type Ec2InstancePersistentDataVolumeConfig = Omit<PersistentDataVolumeProps, "autoScalingGroup" | "stableOwnerId">;
@@ -114,13 +115,19 @@ export interface Ec2InstanceProps extends StackProps {
114
115
  * Bare-EC2 consumers (bastion, Fivetran) leave this unset.
115
116
  */
116
117
  ecsClusterArn?: string;
118
+ /**
119
+ * SNS topic for the graceful-termination handler's alarms: its queue's
120
+ * dead letters and, with `ecsClusterArn`, a terminating instance whose
121
+ * tasks were not proven STOPPED.
122
+ */
123
+ alertsTopic?: ITopic;
117
124
  /**
118
125
  * Pairs the ASG with a standalone EBS data volume that re-attaches across
119
126
  * instance refreshes. When set, requires `vpcSubnets.availabilityZones` to
120
127
  * be exactly one entry (matching `persistentDataVolume.availabilityZone`);
121
- * the wrapper's volume is AZ-local and cannot follow a multi-AZ ASG.
122
- * Forwards the wrapper's `ownerLogicalId` into the graceful-termination
123
- * Lambda so the TERMINATING and LAUNCHING handlers locate the same volume.
128
+ * the wrapper's volume is AZ-local and cannot follow a multi-AZ ASG. It
129
+ * cannot be combined with `warmPool`: a single-attach volume cannot follow
130
+ * a pre-warmed instance.
124
131
  */
125
132
  persistentDataVolume?: Ec2InstancePersistentDataVolumeConfig;
126
133
  /**
@@ -222,7 +222,7 @@ export class Ec2Instance extends Construct {
222
222
  updatePolicy: resolvedUpdatePolicy
223
223
  }),
224
224
  // ECS task drain on terminate is handled by the EC2_INSTANCE_TERMINATING
225
- // lifecycle hook + Ec2GracefulTerminationHandler (300s heartbeat), not by
225
+ // lifecycle hook + Ec2GracefulTerminationHandler, not by
226
226
  // managed termination protection. Leaving instances scale-in-protected
227
227
  // wedges CFN rollback because ASG cannot terminate the instance.
228
228
  newInstancesProtectedFromScaleIn: false,
@@ -289,15 +289,14 @@ export class Ec2Instance extends Construct {
289
289
  * ASGs (ClickHouse) without duplicate plumbing in the ECS layer.
290
290
  */
291
291
  addGracefulTerminationHandler(props) {
292
- const dataVolumeOwnerLogicalId = this.persistentDataVolume?.ownerLogicalId;
293
292
  new Ec2GracefulTerminationHandler(this, `${props.serviceName}GracefulTermination`, {
294
293
  autoScalingGroup: this.autoScalingGroup,
295
294
  ...(props.ecsClusterArn !== undefined &&
296
295
  props.ecsClusterArn !== "" && {
297
296
  ecsClusterArn: props.ecsClusterArn
298
297
  }),
299
- ...(dataVolumeOwnerLogicalId !== undefined && {
300
- dataVolumeOwnerLogicalId
298
+ ...(props.alertsTopic !== undefined && {
299
+ alertsTopic: props.alertsTopic
301
300
  })
302
301
  });
303
302
  }
@@ -362,6 +361,11 @@ function validateEc2InstanceProps(props) {
362
361
  if (azs[0] !== props.persistentDataVolume.availabilityZone) {
363
362
  throw new Error(`Ec2Instance.persistentDataVolume.availabilityZone must match vpcSubnets.availabilityZones[0]; got '${props.persistentDataVolume.availabilityZone}' vs '${azs[0]}'`);
364
363
  }
364
+ if (props.warmPool !== undefined) {
365
+ throw new Error("Ec2Instance.persistentDataVolume cannot be combined with warmPool — " +
366
+ "the volume attaches to one instance at a time, so a pre-warmed " +
367
+ "instance can never receive it and its launch is abandoned on every attempt");
368
+ }
365
369
  }
366
370
  if (props.tags !== undefined) {
367
371
  for (const [key, value] of Object.entries(props.tags)) {
@@ -1,3 +1,4 @@
1
+ import type { ITopic } from "aws-cdk-lib/aws-sns";
1
2
  import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
2
3
  import { Construct } from "constructs";
3
4
  import { LambdaFunction } from "./lambda.js";
@@ -9,6 +10,12 @@ import { SQSQueue } from "../messaging/sqs.js";
9
10
  * source of truth so a rename does not silently break the test query.
10
11
  */
11
12
  export declare const EC2_GRACEFUL_TERMINATION_HANDLER_DESCRIPTION = "EC2 graceful termination handler";
13
+ /**
14
+ * The Lambda logs this term when it cannot prove every task on a terminating
15
+ * instance STOPPED (`DRAIN_EVENT.UNPROVEN` in the Lambda source; a test pins
16
+ * the two together).
17
+ */
18
+ export declare const DRAIN_UNPROVEN_EVENT = "drain_unproven";
12
19
  export interface Ec2GracefulTerminationHandlerProps {
13
20
  /** ASG to attach the EC2_INSTANCE_TERMINATING hook to. */
14
21
  autoScalingGroup: AutoScalingGroup;
@@ -20,11 +27,16 @@ export interface Ec2GracefulTerminationHandlerProps {
20
27
  */
21
28
  ecsClusterArn?: string;
22
29
  /**
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.
30
+ * SNS topic for the queue's dead-letter alarm and, when `ecsClusterArn` is
31
+ * set, the alarm that fires when a terminating instance's tasks are not
32
+ * proven STOPPED.
33
+ */
34
+ alertsTopic?: ITopic;
35
+ /**
36
+ * Ignored. The handler no longer detaches a paired `PersistentDataVolume`:
37
+ * the terminating instance's shutdown releases it.
38
+ *
39
+ * @deprecated Removed in 40.0.0; stop passing it.
28
40
  */
29
41
  dataVolumeOwnerLogicalId?: string;
30
42
  }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { Aws, Stack } from "aws-cdk-lib";
4
+ import { Aws, Duration, Stack } from "aws-cdk-lib";
5
5
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
6
6
  import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
7
7
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
@@ -9,9 +9,10 @@ import { LifecycleTransition, DefaultResult } from "aws-cdk-lib/aws-autoscaling"
9
9
  import { Construct } from "constructs";
10
10
  import { attachInlineAsgLifecycleHook } from "./asgInlineLifecycleHook.js";
11
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
12
  import { SQSQueue } from "../messaging/sqs.js";
14
13
  import { Subscription } from "../messaging/subscription.js";
14
+ import { createLogPatternAlarms } from "../monitoring/logPatternAlarms.js";
15
+ import { METRIC_NAMESPACE, stackScopedMetricNamespace } from "../monitoring/metricNamespaces.js";
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
17
  const LAMBDA_SOURCE_FILE = "ec2GracefulTerminationLambda.source.cjs";
17
18
  /**
@@ -21,11 +22,21 @@ const LAMBDA_SOURCE_FILE = "ec2GracefulTerminationLambda.source.cjs";
21
22
  * source of truth so a rename does not silently break the test query.
22
23
  */
23
24
  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;
25
+ /**
26
+ * The Lambda logs this term when it cannot prove every task on a terminating
27
+ * instance STOPPED (`DRAIN_EVENT.UNPROVEN` in the Lambda source; a test pins
28
+ * the two together).
29
+ */
30
+ export const DRAIN_UNPROVEN_EVENT = "drain_unproven";
31
+ // Covers the Lambda's drain (240 s) and StopTask (150 s) budgets plus the
32
+ // 90 s it keeps back for deregistration, cleanup and completion.
33
+ const LAMBDA_TIMEOUT_SECONDS = 540;
34
+ // SQS requires a visibility timeout of at least the function timeout.
35
+ const QUEUE_VISIBILITY_TIMEOUT_SECONDS = 600;
36
+ // Outlasts one full attempt and the redelivery of a timed-out one. The Lambda
37
+ // reads it too: a proof read after the heartbeat could have released the
38
+ // instance does not count.
39
+ const HOOK_HEARTBEAT_SECONDS = 900;
29
40
  /**
30
41
  * ASG `EC2_INSTANCE_TERMINATING` hook + Lambda that drains the instance
31
42
  * cleanly. Drain ownership lives at the EC2 layer; ECS-specific drain plugs
@@ -40,29 +51,36 @@ export class Ec2GracefulTerminationHandler extends Construct {
40
51
  const sourcePath = path.resolve(__dirname, LAMBDA_SOURCE_FILE);
41
52
  const source = readFileSync(sourcePath, "utf-8");
42
53
  const ecsClusterArn = resolveOptionalString(props.ecsClusterArn);
43
- const dataVolumeOwnerLogicalId = resolveOptionalString(props.dataVolumeOwnerLogicalId);
44
54
  this.queue = new SQSQueue(this, `${id}Queue`, {
45
55
  visibilityTimeout: QUEUE_VISIBILITY_TIMEOUT_SECONDS,
46
56
  deadLetterQueue: { enabled: true, maxReceiveCount: 5 },
47
57
  // Transient instance-drain signals — no durable state. Pinned DESTROY
48
58
  // (now also the SQSQueue wrapper default) so a replacing deploy of the
49
59
  // parent Ec2Instance reclaims this queue + DLQ instead of orphaning them.
50
- removalPolicy: "DESTROY"
60
+ removalPolicy: "DESTROY",
61
+ ...(props.alertsTopic !== undefined && { alertsTopic: props.alertsTopic })
51
62
  });
52
63
  const stack = Stack.of(this);
53
64
  const ecsPolicies = ecsClusterArn !== undefined
54
65
  ? [
66
+ // Both act on the cluster resource and carry no `ecs:cluster`
67
+ // condition key (AWS service authorization reference), so a
68
+ // condition on that key would deny them.
55
69
  new PolicyStatement({
56
70
  effect: Effect.ALLOW,
57
- actions: ["ecs:ListContainerInstances"],
71
+ actions: [
72
+ "ecs:ListContainerInstances",
73
+ "ecs:DeregisterContainerInstance"
74
+ ],
58
75
  resources: [ecsClusterArn]
59
76
  }),
60
77
  new PolicyStatement({
61
78
  effect: Effect.ALLOW,
62
79
  actions: [
63
- "ecs:DescribeContainerInstances",
64
80
  "ecs:UpdateContainerInstancesState",
65
- "ecs:DeregisterContainerInstance"
81
+ "ecs:ListTasks",
82
+ "ecs:DescribeTasks",
83
+ "ecs:StopTask"
66
84
  ],
67
85
  resources: ["*"],
68
86
  conditions: {
@@ -116,33 +134,6 @@ export class Ec2GracefulTerminationHandler extends Construct {
116
134
  actions: ["autoscaling:CompleteLifecycleAction"],
117
135
  resources: [asgArnWildcard]
118
136
  });
119
- const volumeArnPattern = `arn:${stack.partition}:ec2:${stack.region}:${stack.account}:volume/*`;
120
- const instanceArnPattern = `arn:${stack.partition}:ec2:${stack.region}:${stack.account}:instance/*`;
121
- const dataVolumePolicies = dataVolumeOwnerLogicalId !== undefined
122
- ? [
123
- new PolicyStatement({
124
- effect: Effect.ALLOW,
125
- actions: ["ec2:DescribeVolumes"],
126
- resources: ["*"]
127
- }),
128
- new PolicyStatement({
129
- effect: Effect.ALLOW,
130
- actions: ["ec2:DetachVolume"],
131
- resources: [volumeArnPattern],
132
- conditions: {
133
- StringEquals: {
134
- [`aws:ResourceTag/${PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE}`]: PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE,
135
- [`aws:ResourceTag/${PERSISTENT_DATA_VOLUME_TAG_STACK_ID}`]: Aws.STACK_ID
136
- }
137
- }
138
- }),
139
- new PolicyStatement({
140
- effect: Effect.ALLOW,
141
- actions: ["ec2:DetachVolume"],
142
- resources: [instanceArnPattern]
143
- })
144
- ]
145
- : [];
146
137
  this.lambda = new LambdaFunction(this, `${id}Fn`, {
147
138
  runtime: Runtime.NODEJS_24_X,
148
139
  handler: "index.handler",
@@ -153,10 +144,9 @@ export class Ec2GracefulTerminationHandler extends Construct {
153
144
  memorySize: 256,
154
145
  logGroupRetention: RetentionDays.ONE_MONTH,
155
146
  environment: {
156
- ...(ecsClusterArn !== undefined && { ECS_CLUSTER_ARN: ecsClusterArn }),
157
- ...(dataVolumeOwnerLogicalId !== undefined && {
158
- DATA_VOLUME_OWNER_LOGICAL_ID: dataVolumeOwnerLogicalId,
159
- DATA_VOLUME_STACK_ID: Aws.STACK_ID
147
+ ...(ecsClusterArn !== undefined && {
148
+ ECS_CLUSTER_ARN: ecsClusterArn,
149
+ LIFECYCLE_HEARTBEAT_SECONDS: String(HOOK_HEARTBEAT_SECONDS)
160
150
  })
161
151
  },
162
152
  inlinePolicy: [
@@ -164,11 +154,30 @@ export class Ec2GracefulTerminationHandler extends Construct {
164
154
  ec2Policies,
165
155
  elbReadPolicy,
166
156
  elbDeregisterPolicy,
167
- asgPolicy,
168
- ...dataVolumePolicies
157
+ asgPolicy
169
158
  ]
170
159
  });
171
- this.lambda.addSqsEventSource(this.queue.getQueue());
160
+ if (ecsClusterArn !== undefined && props.alertsTopic !== undefined) {
161
+ createLogPatternAlarms({
162
+ scope: this,
163
+ logGroup: this.lambda.functionLogGroup,
164
+ alarmTopic: props.alertsTopic,
165
+ metricNamespace: stackScopedMetricNamespace(METRIC_NAMESPACE.EC2),
166
+ specs: [
167
+ {
168
+ idStem: `${id}DrainUnproven`,
169
+ metricName: `${id}DrainUnprovenCount`,
170
+ description: `${id}: a terminating instance's ECS tasks were not proven STOPPED ` +
171
+ "before it was released — check the handler's log for the unstopped task ARNs",
172
+ anyTerms: [DRAIN_UNPROVEN_EVENT],
173
+ period: Duration.minutes(5)
174
+ }
175
+ ]
176
+ });
177
+ }
178
+ // One lifecycle action per invocation: a batch is processed in turn, so
179
+ // several instances terminating together would share one timeout.
180
+ this.lambda.addSqsEventSource(this.queue.getQueue(), { batchSize: 1 });
172
181
  // Hook name embeds Aws.STACK_NAME (pseudo-parameter Ref, NOT a resource
173
182
  // Ref — no CFN dep) so LifecycleHookName alone discriminates across
174
183
  // stacks in the EB pattern. Sibling LAUNCHING hook uses "-launching".