@fjall/components-infrastructure 39.0.0 → 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.
Files changed (23) hide show
  1. package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +20 -8
  2. package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.d.ts +11 -2
  3. package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.js +12 -2
  4. package/dist/lib/resources/aws/compute/ec2.d.ts +10 -3
  5. package/dist/lib/resources/aws/compute/ec2.js +8 -4
  6. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.d.ts +17 -5
  7. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +55 -46
  8. package/dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs +422 -147
  9. package/dist/lib/resources/aws/compute/ecsCapacityProviderGate.d.ts +41 -0
  10. package/dist/lib/resources/aws/compute/ecsCapacityProviderGate.js +116 -0
  11. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +5 -0
  12. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +6 -5
  13. package/dist/lib/resources/aws/compute/lambda.d.ts +3 -0
  14. package/dist/lib/resources/aws/compute/lambda.js +10 -6
  15. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +17 -15
  16. package/dist/lib/resources/aws/compute/persistentDataVolume.js +27 -21
  17. package/dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs +235 -104
  18. package/dist/lib/resources/aws/database/clickhouseUserData.js +49 -6
  19. package/dist/lib/resources/aws/monitoring/metricNamespaces.d.ts +4 -1
  20. package/dist/lib/resources/aws/monitoring/metricNamespaces.js +4 -1
  21. package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +18 -10
  22. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +37 -33
  23. package/package.json +3 -3
@@ -14,13 +14,35 @@
14
14
  * are logged and skipped.
15
15
  *
16
16
  * Behaviour, in order:
17
- * 1. (Conditional) ECS_CLUSTER_ARN set + non-empty → drain container
18
- * instance, wait for runningTasksCount=0, deregister.
17
+ * 1. (Conditional) ECS_CLUSTER_ARN set + non-empty → set the container
18
+ * instance DRAINING and wait until every task on it is proven STOPPED;
19
+ * a task still short of STOPPED at the drain deadline is sent StopTask
20
+ * and waited on again. Then deregister the container instance. The
21
+ * outcome is logged as `event: "drain_proven"` or
22
+ * `event: "drain_unproven"`; the construct alarms on the second.
19
23
  * 2. (Always) Dissociate EIPs, deregister from any target groups,
20
24
  * detach non-primary ENIs.
21
25
  * 3. CompleteLifecycleAction CONTINUE — even on partial failure, so the
22
26
  * ASG never stalls indefinitely. Better to terminate dirty than hang
23
- * the whole stack delete.
27
+ * the whole stack delete. A token whose action has already ended is
28
+ * logged as `event: "lifecycle_action_already_ended"`, not retried.
29
+ *
30
+ * STOPPED (or DELETED, which ECS reports after it) is the only proof. A
31
+ * container instance's `runningTasksCount` drops a task the moment it leaves
32
+ * RUNNING, while the task is still STOPPING and its container is still
33
+ * writing; reading that count as "the instance is idle" is how a data volume
34
+ * was once detached from under a running ClickHouse.
35
+ *
36
+ * A proof counts only if it was read before the lifecycle hook's heartbeat
37
+ * (LIFECYCLE_HEARTBEAT_SECONDS, dated from the event's time) could have let
38
+ * the Auto Scaling group terminate the instance on its own. A later proof is
39
+ * logged as unproven with `provenAfterHeartbeat: true`.
40
+ *
41
+ * This handler never detaches an EBS volume. EC2 requires a volume to be
42
+ * unmounted before it is detached, and nothing here can unmount it: the
43
+ * instance's own shutdown unmounts every filesystem, and termination then
44
+ * releases the volume for the PersistentDataVolume LAUNCHING handler, which
45
+ * waits for that release before it attaches.
24
46
  *
25
47
  * CommonJS rather than ESM because Code.fromInline lands the source as
26
48
  * `index.js`, which Lambda treats as CommonJS by default.
@@ -28,18 +50,18 @@
28
50
  const {
29
51
  ECSClient,
30
52
  ListContainerInstancesCommand,
31
- DescribeContainerInstancesCommand,
32
53
  UpdateContainerInstancesStateCommand,
33
- DeregisterContainerInstanceCommand
54
+ DeregisterContainerInstanceCommand,
55
+ ListTasksCommand,
56
+ DescribeTasksCommand,
57
+ StopTaskCommand
34
58
  } = require("@aws-sdk/client-ecs");
35
59
  const {
36
60
  EC2Client,
37
61
  DescribeAddressesCommand,
38
62
  DisassociateAddressCommand,
39
63
  DescribeNetworkInterfacesCommand,
40
- DetachNetworkInterfaceCommand,
41
- DescribeVolumesCommand,
42
- DetachVolumeCommand
64
+ DetachNetworkInterfaceCommand
43
65
  } = require("@aws-sdk/client-ec2");
44
66
  const {
45
67
  ElasticLoadBalancingV2Client,
@@ -53,16 +75,54 @@ const {
53
75
  } = require("@aws-sdk/client-auto-scaling");
54
76
 
55
77
  const DRAIN_POLL_INTERVAL_MS = 10_000;
78
+ // How long DRAINING is given to stop the instance's tasks on its own. The
79
+ // service scheduler stops service tasks; standalone tasks run to completion.
56
80
  const DRAIN_DEADLINE_MS = 240_000;
57
- const DETACH_POLL_INTERVAL_MS = 5_000;
58
- const DETACH_DEADLINE_MS = 60_000;
59
- const TAG_OWNER_LOGICAL_ID = "fjall:OwnerLogicalId";
60
- const TAG_STACK_ID = "fjall:StackId";
81
+ // How long a task sent StopTask is given to reach STOPPED. ECS caps a
82
+ // container's stopTimeout at 120 s before it sends SIGKILL.
83
+ const STOP_DEADLINE_MS = 150_000;
84
+ // DescribeTasks accepts at most 100 task ARNs per call.
85
+ const DESCRIBE_TASKS_BATCH_SIZE = 100;
86
+ // ListTasks never reports a desired status of PENDING, so these two cover
87
+ // every task ECS still tracks on the instance.
88
+ const TASK_DESIRED_STATUSES = ["RUNNING", "STOPPED"];
89
+ // ECS reports DELETED after STOPPED ("a transition state when a task stops",
90
+ // shown only by DescribeTasks). Either means the task's containers have
91
+ // exited. Any other status, including one ECS adds later, is not proof.
92
+ const STOPPED_TASK_STATUSES = ["STOPPED", "DELETED"];
93
+ // Bounds every AWS call, SDK retries included, so a hung request cannot carry
94
+ // an attempt past its deadlines.
95
+ const SDK_CALL_TIMEOUT_MS = 20_000;
96
+ // Kept back from the end of the invocation for deregistration, the EIP,
97
+ // target-group and ENI cleanup, and CompleteLifecycleAction.
98
+ const COMPLETION_RESERVE_MS = 90_000;
99
+ // Kept back from the end of the lifecycle hook's heartbeat. The event's time
100
+ // is when the action began, so a proof read after this point may postdate
101
+ // the Auto Scaling group releasing the instance on its own.
102
+ const HEARTBEAT_RESERVE_MS = 30_000;
103
+ const DRAIN_EVENT = {
104
+ PROVEN: "drain_proven",
105
+ UNPROVEN: "drain_unproven",
106
+ NOT_REGISTERED: "drain_not_registered"
107
+ };
108
+ const LIFECYCLE_EVENT = {
109
+ ALREADY_ENDED: "lifecycle_action_already_ended"
110
+ };
111
+ const LIFECYCLE_OUTCOME = {
112
+ COMPLETED: "completed",
113
+ ALREADY_ENDED: "already_ended"
114
+ };
61
115
 
62
116
  function errMessage(err) {
63
117
  return err && err.message ? err.message : String(err);
64
118
  }
65
119
 
120
+ function send(client, command) {
121
+ return client.send(command, {
122
+ abortSignal: AbortSignal.timeout(SDK_CALL_TIMEOUT_MS)
123
+ });
124
+ }
125
+
66
126
  let _ecs;
67
127
  let _ec2;
68
128
  let _elbv2;
@@ -105,12 +165,48 @@ function parseLifecycleMessage(record) {
105
165
  asgName: detail.AutoScalingGroupName,
106
166
  actionToken: detail.LifecycleActionToken,
107
167
  hookName: detail.LifecycleHookName,
168
+ startedAtMs: lifecycleActionStartedAtMs(record, envelope),
108
169
  isTest: false
109
170
  };
110
171
  }
111
172
 
173
+ // When the lifecycle action began, as near as the message can say: the
174
+ // event's own time, else the moment EventBridge sent it to the queue.
175
+ function lifecycleActionStartedAtMs(record, envelope) {
176
+ const eventTime =
177
+ typeof envelope.time === "string" ? Date.parse(envelope.time) : NaN;
178
+ if (Number.isFinite(eventTime)) return eventTime;
179
+ const sentAt = Number(record.attributes && record.attributes.SentTimestamp);
180
+ return Number.isFinite(sentAt) && sentAt > 0 ? sentAt : undefined;
181
+ }
182
+
183
+ // The attempt's two deadlines, on the Lambda's clock. A proof read after
184
+ // `proofDeadlineMs` is not trusted; no drain phase runs past
185
+ // `workDeadlineMs`, which also leaves the invocation time to complete.
186
+ function attemptDeadlines(startedAtMs, heartbeatSeconds, invocationEndMs) {
187
+ const proofDeadlineMs =
188
+ startedAtMs !== undefined && heartbeatSeconds !== undefined
189
+ ? startedAtMs + heartbeatSeconds * 1000 - HEARTBEAT_RESERVE_MS
190
+ : Infinity;
191
+ const invocationWorkEndMs =
192
+ invocationEndMs !== undefined
193
+ ? invocationEndMs - COMPLETION_RESERVE_MS
194
+ : Infinity;
195
+ return {
196
+ proofDeadlineMs,
197
+ workDeadlineMs: Math.min(proofDeadlineMs, invocationWorkEndMs)
198
+ };
199
+ }
200
+
201
+ function readPositiveInteger(env, name) {
202
+ const value = readEnv(env, name);
203
+ if (value === undefined || !/^[1-9][0-9]*$/.test(value)) return undefined;
204
+ return Number.parseInt(value, 10);
205
+ }
206
+
112
207
  async function findContainerInstanceArn(ecsClient, clusterArn, instanceId) {
113
- const list = await ecsClient.send(
208
+ const list = await send(
209
+ ecsClient,
114
210
  new ListContainerInstancesCommand({
115
211
  cluster: clusterArn,
116
212
  filter: `ec2InstanceId == ${instanceId}`
@@ -122,41 +218,224 @@ async function findContainerInstanceArn(ecsClient, clusterArn, instanceId) {
122
218
  return list.containerInstanceArns[0];
123
219
  }
124
220
 
125
- async function drainContainerInstance(
126
- ecsClient,
127
- clusterArn,
221
+ async function listInstanceTaskArns(ctx, containerInstanceArn) {
222
+ const taskArns = new Set();
223
+ for (const desiredStatus of TASK_DESIRED_STATUSES) {
224
+ let nextToken;
225
+ do {
226
+ const page = await send(
227
+ ctx.ecsClient,
228
+ new ListTasksCommand({
229
+ cluster: ctx.clusterArn,
230
+ containerInstance: containerInstanceArn,
231
+ desiredStatus,
232
+ ...(nextToken !== undefined && { nextToken })
233
+ })
234
+ );
235
+ for (const taskArn of page.taskArns || []) taskArns.add(taskArn);
236
+ nextToken = page.nextToken;
237
+ } while (nextToken);
238
+ }
239
+ return [...taskArns];
240
+ }
241
+
242
+ // The ARNs of the instance's tasks not proven STOPPED. A task DescribeTasks
243
+ // cannot describe (a `failures` entry) is not proven either; once ECS has
244
+ // forgotten it, the next ListTasks no longer names it.
245
+ async function unstoppedTaskArns(ctx, containerInstanceArn) {
246
+ const taskArns = await listInstanceTaskArns(ctx, containerInstanceArn);
247
+ const unstopped = [];
248
+ for (let i = 0; i < taskArns.length; i += DESCRIBE_TASKS_BATCH_SIZE) {
249
+ const batch = taskArns.slice(i, i + DESCRIBE_TASKS_BATCH_SIZE);
250
+ const described = await send(
251
+ ctx.ecsClient,
252
+ new DescribeTasksCommand({ cluster: ctx.clusterArn, tasks: batch })
253
+ );
254
+ const stopped = new Set(
255
+ (described.tasks || [])
256
+ .filter((task) => STOPPED_TASK_STATUSES.includes(task.lastStatus))
257
+ .map((task) => task.taskArn)
258
+ );
259
+ for (const taskArn of batch) {
260
+ if (!stopped.has(taskArn)) unstopped.push(taskArn);
261
+ }
262
+ }
263
+ return unstopped;
264
+ }
265
+
266
+ // Polls until every task is STOPPED or the budget is spent, reading once
267
+ // more at the deadline, so the last read starts no later than it. The budget never runs past the attempt's own
268
+ // deadline. A failed read is retried, and a verdict whose last read failed
269
+ // is unproven. A proof read after the proof deadline is unproven too, since
270
+ // the instance may already have been released. `onUnstopped` sees each
271
+ // successful read's list.
272
+ async function awaitTasksStopped(
273
+ ctx,
128
274
  containerInstanceArn,
129
- sleepFn,
130
- nowFn
275
+ budgetMs,
276
+ onUnstopped
131
277
  ) {
132
- await ecsClient.send(
278
+ const deadline = Math.min(ctx.now() + budgetMs, ctx.workDeadlineMs);
279
+ for (;;) {
280
+ let verdict;
281
+ try {
282
+ const unstopped = await unstoppedTaskArns(ctx, containerInstanceArn);
283
+ verdict = { proven: unstopped.length === 0, unstopped };
284
+ } catch (err) {
285
+ ctx.log("Task status read failed; retrying until the deadline", {
286
+ containerInstanceArn,
287
+ error: errMessage(err)
288
+ });
289
+ verdict = { proven: false, unstopped: undefined, error: errMessage(err) };
290
+ }
291
+ if (verdict.proven && ctx.now() > ctx.proofDeadlineMs) {
292
+ return { proven: false, unstopped: [], provenAfterHeartbeat: true };
293
+ }
294
+ if (verdict.proven || ctx.now() >= deadline) return verdict;
295
+ if (verdict.unstopped !== undefined) await onUnstopped(verdict.unstopped);
296
+ await ctx.sleep(
297
+ Math.max(0, Math.min(DRAIN_POLL_INTERVAL_MS, deadline - ctx.now()))
298
+ );
299
+ }
300
+ }
301
+
302
+ async function drainContainerInstance(ctx, instanceId, containerInstanceArn) {
303
+ await send(
304
+ ctx.ecsClient,
133
305
  new UpdateContainerInstancesStateCommand({
134
- cluster: clusterArn,
306
+ cluster: ctx.clusterArn,
135
307
  containerInstances: [containerInstanceArn],
136
308
  status: "DRAINING"
137
309
  })
138
310
  );
139
311
 
140
- const deadline = nowFn() + DRAIN_DEADLINE_MS;
141
- while (nowFn() < deadline) {
142
- const desc = await ecsClient.send(
143
- new DescribeContainerInstancesCommand({
144
- cluster: clusterArn,
145
- containerInstances: [containerInstanceArn]
312
+ const drained = await awaitTasksStopped(
313
+ ctx,
314
+ containerInstanceArn,
315
+ DRAIN_DEADLINE_MS,
316
+ async () => {}
317
+ );
318
+ if (
319
+ drained.proven ||
320
+ drained.provenAfterHeartbeat ||
321
+ ctx.now() >= ctx.workDeadlineMs
322
+ ) {
323
+ return { ...drained, stopRequested: [] };
324
+ }
325
+
326
+ const stopRequested = new Set();
327
+ const stopped = await awaitTasksStopped(
328
+ ctx,
329
+ containerInstanceArn,
330
+ STOP_DEADLINE_MS,
331
+ async (unstopped) => {
332
+ for (const taskArn of unstopped) {
333
+ if (stopRequested.has(taskArn)) continue;
334
+ try {
335
+ await send(
336
+ ctx.ecsClient,
337
+ new StopTaskCommand({
338
+ cluster: ctx.clusterArn,
339
+ task: taskArn,
340
+ reason: `Instance ${instanceId} is terminating and the task did not stop within the drain deadline`
341
+ })
342
+ );
343
+ stopRequested.add(taskArn);
344
+ } catch (err) {
345
+ ctx.log("StopTask failed; retrying on the next poll", {
346
+ taskArn,
347
+ error: errMessage(err)
348
+ });
349
+ }
350
+ }
351
+ }
352
+ );
353
+ return { ...stopped, stopRequested: [...stopRequested] };
354
+ }
355
+
356
+ function drainVerdictMessage(verdict) {
357
+ if (verdict.proven) return "Every task on the instance is STOPPED";
358
+ if (verdict.provenAfterHeartbeat) {
359
+ return "Every task on the instance is STOPPED, but only after the lifecycle hook's heartbeat could have released the instance";
360
+ }
361
+ return "Tasks on the instance are not proven STOPPED; terminating anyway";
362
+ }
363
+
364
+ // Drains, proves and deregisters the instance's container instance. Never
365
+ // throws: every failure is logged as an unproven drain, because the
366
+ // lifecycle action must still complete.
367
+ async function drainEcsTasks(ctx, instanceId) {
368
+ let containerInstanceArn;
369
+ try {
370
+ containerInstanceArn = await findContainerInstanceArn(
371
+ ctx.ecsClient,
372
+ ctx.clusterArn,
373
+ instanceId
374
+ );
375
+ } catch (err) {
376
+ ctx.log("Container instance lookup failed; the drain is unproven", {
377
+ event: DRAIN_EVENT.UNPROVEN,
378
+ instanceId,
379
+ error: errMessage(err)
380
+ });
381
+ return;
382
+ }
383
+ if (containerInstanceArn === undefined) {
384
+ ctx.log("Instance is not registered in the cluster; nothing to drain", {
385
+ event: DRAIN_EVENT.NOT_REGISTERED,
386
+ instanceId
387
+ });
388
+ return;
389
+ }
390
+
391
+ try {
392
+ const verdict = await drainContainerInstance(
393
+ ctx,
394
+ instanceId,
395
+ containerInstanceArn
396
+ );
397
+ ctx.log(drainVerdictMessage(verdict), {
398
+ event: verdict.proven ? DRAIN_EVENT.PROVEN : DRAIN_EVENT.UNPROVEN,
399
+ instanceId,
400
+ containerInstanceArn,
401
+ stopRequestedTaskArns: verdict.stopRequested,
402
+ ...(verdict.provenAfterHeartbeat && { provenAfterHeartbeat: true }),
403
+ ...(!verdict.proven &&
404
+ !verdict.provenAfterHeartbeat && {
405
+ unstoppedTaskArns: verdict.unstopped,
406
+ ...(verdict.error !== undefined && { error: verdict.error })
407
+ })
408
+ });
409
+ } catch (err) {
410
+ ctx.log("ECS drain failed; the drain is unproven", {
411
+ event: DRAIN_EVENT.UNPROVEN,
412
+ instanceId,
413
+ containerInstanceArn,
414
+ error: errMessage(err)
415
+ });
416
+ }
417
+
418
+ try {
419
+ await send(
420
+ ctx.ecsClient,
421
+ new DeregisterContainerInstanceCommand({
422
+ cluster: ctx.clusterArn,
423
+ containerInstance: containerInstanceArn,
424
+ force: true
146
425
  })
147
426
  );
148
- const instance =
149
- desc.containerInstances && desc.containerInstances[0]
150
- ? desc.containerInstances[0]
151
- : undefined;
152
- if (!instance) return;
153
- if ((instance.runningTasksCount || 0) === 0) return;
154
- await sleepFn(DRAIN_POLL_INTERVAL_MS);
427
+ } catch (err) {
428
+ ctx.log("Container instance deregistration failed", {
429
+ instanceId,
430
+ containerInstanceArn,
431
+ error: errMessage(err)
432
+ });
155
433
  }
156
434
  }
157
435
 
158
436
  async function dissociateInstanceEips(ec2Client, instanceId) {
159
- const result = await ec2Client.send(
437
+ const result = await send(
438
+ ec2Client,
160
439
  new DescribeAddressesCommand({
161
440
  Filters: [{ Name: "instance-id", Values: [instanceId] }]
162
441
  })
@@ -164,20 +443,22 @@ async function dissociateInstanceEips(ec2Client, instanceId) {
164
443
  const addresses = result.Addresses || [];
165
444
  for (const addr of addresses) {
166
445
  if (!addr.AssociationId) continue;
167
- await ec2Client.send(
446
+ await send(
447
+ ec2Client,
168
448
  new DisassociateAddressCommand({ AssociationId: addr.AssociationId })
169
449
  );
170
450
  }
171
451
  }
172
452
 
173
453
  async function deregisterFromTargetGroups(elbv2Client, instanceId) {
174
- const groups = await elbv2Client.send(new DescribeTargetGroupsCommand({}));
454
+ const groups = await send(elbv2Client, new DescribeTargetGroupsCommand({}));
175
455
  const targetGroups = groups.TargetGroups || [];
176
456
  for (const tg of targetGroups) {
177
457
  if (!tg.TargetGroupArn) continue;
178
458
  let health;
179
459
  try {
180
- health = await elbv2Client.send(
460
+ health = await send(
461
+ elbv2Client,
181
462
  new DescribeTargetHealthCommand({ TargetGroupArn: tg.TargetGroupArn })
182
463
  );
183
464
  } catch (err) {
@@ -194,7 +475,8 @@ async function deregisterFromTargetGroups(elbv2Client, instanceId) {
194
475
  (d) => d.Target && d.Target.Id === instanceId
195
476
  );
196
477
  if (matches.length === 0) continue;
197
- await elbv2Client.send(
478
+ await send(
479
+ elbv2Client,
198
480
  new DeregisterTargetsCommand({
199
481
  TargetGroupArn: tg.TargetGroupArn,
200
482
  Targets: matches.map((m) => ({
@@ -207,7 +489,8 @@ async function deregisterFromTargetGroups(elbv2Client, instanceId) {
207
489
  }
208
490
 
209
491
  async function detachSecondaryEnis(ec2Client, instanceId) {
210
- const result = await ec2Client.send(
492
+ const result = await send(
493
+ ec2Client,
211
494
  new DescribeNetworkInterfacesCommand({
212
495
  Filters: [{ Name: "attachment.instance-id", Values: [instanceId] }]
213
496
  })
@@ -220,7 +503,8 @@ async function detachSecondaryEnis(ec2Client, instanceId) {
220
503
  // instance and cannot be detached independently.
221
504
  if (attachment.DeviceIndex === 0) continue;
222
505
  try {
223
- await ec2Client.send(
506
+ await send(
507
+ ec2Client,
224
508
  new DetachNetworkInterfaceCommand({
225
509
  AttachmentId: attachment.AttachmentId,
226
510
  Force: true
@@ -238,50 +522,40 @@ async function detachSecondaryEnis(ec2Client, instanceId) {
238
522
  }
239
523
  }
240
524
 
241
- async function detachDataVolume(
242
- ec2Client,
243
- instanceId,
244
- ownerLogicalId,
245
- stackId,
246
- sleepFn,
247
- nowFn
248
- ) {
249
- const described = await ec2Client.send(
250
- new DescribeVolumesCommand({
251
- Filters: [
252
- { Name: `tag:${TAG_OWNER_LOGICAL_ID}`, Values: [ownerLogicalId] },
253
- { Name: `tag:${TAG_STACK_ID}`, Values: [stackId] },
254
- { Name: "attachment.instance-id", Values: [instanceId] }
255
- ]
256
- })
525
+ // Auto Scaling refuses a token whose action has already ended, because
526
+ // another delivery of the event completed it or the heartbeat expired, with
527
+ // a ValidationError naming no active lifecycle action.
528
+ function isNoActiveLifecycleAction(err) {
529
+ return (
530
+ Boolean(err) &&
531
+ err.name === "ValidationError" &&
532
+ /No active Lifecycle Action/i.test(errMessage(err))
257
533
  );
258
- const volumes = described.Volumes || [];
259
- if (volumes.length === 0) return;
260
- const volumeId = volumes[0].VolumeId;
261
- if (!volumeId) return;
262
- await ec2Client.send(
263
- new DetachVolumeCommand({ VolumeId: volumeId, InstanceId: instanceId })
264
- );
265
- const deadline = nowFn() + DETACH_DEADLINE_MS;
266
- while (nowFn() < deadline) {
267
- const desc = await ec2Client.send(
268
- new DescribeVolumesCommand({ VolumeIds: [volumeId] })
534
+ }
535
+
536
+ async function completeLifecycle(asgClient, asgName, hookName, actionToken) {
537
+ try {
538
+ await send(
539
+ asgClient,
540
+ new CompleteLifecycleActionCommand({
541
+ AutoScalingGroupName: asgName,
542
+ LifecycleHookName: hookName,
543
+ LifecycleActionToken: actionToken,
544
+ LifecycleActionResult: "CONTINUE"
545
+ })
269
546
  );
270
- const volume =
271
- desc.Volumes && desc.Volumes[0] ? desc.Volumes[0] : undefined;
272
- if (volume && volume.State === "available") return;
273
- await sleepFn(DETACH_POLL_INTERVAL_MS);
547
+ return LIFECYCLE_OUTCOME.COMPLETED;
548
+ } catch (err) {
549
+ if (isNoActiveLifecycleAction(err)) return LIFECYCLE_OUTCOME.ALREADY_ENDED;
550
+ throw err;
274
551
  }
275
552
  }
276
553
 
277
- async function completeLifecycle(asgClient, asgName, hookName, actionToken) {
278
- await asgClient.send(
279
- new CompleteLifecycleActionCommand({
280
- AutoScalingGroupName: asgName,
281
- LifecycleHookName: hookName,
282
- LifecycleActionToken: actionToken,
283
- LifecycleActionResult: "CONTINUE"
284
- })
554
+ function logLifecycleOutcome(log, outcome, instanceId) {
555
+ if (outcome !== LIFECYCLE_OUTCOME.ALREADY_ENDED) return;
556
+ log(
557
+ "The lifecycle action had already ended: another delivery completed it, or its heartbeat expired",
558
+ { event: LIFECYCLE_EVENT.ALREADY_ENDED, instanceId }
285
559
  );
286
560
  }
287
561
 
@@ -291,6 +565,10 @@ function readEnv(env, name) {
291
565
  return value;
292
566
  }
293
567
 
568
+ function consoleLog(msg, fields) {
569
+ console.log(JSON.stringify({ message: msg, ...(fields || {}) }));
570
+ }
571
+
294
572
  async function processRecord(record, deps) {
295
573
  const ecsClient = (deps && deps.ecsClient) || getEcs();
296
574
  const ec2Client = (deps && deps.ec2Client) || getEc2();
@@ -301,11 +579,7 @@ async function processRecord(record, deps) {
301
579
  ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
302
580
  const nowFn = (deps && deps.now) || (() => Date.now());
303
581
  const env = (deps && deps.env) || process.env;
304
- const log =
305
- (deps && deps.log) ||
306
- ((msg, fields) => {
307
- console.log(JSON.stringify({ message: msg, ...(fields || {}) }));
308
- });
582
+ const log = (deps && deps.log) || consoleLog;
309
583
 
310
584
  const parsed = parseLifecycleMessage(record);
311
585
  if (!parsed.valid) {
@@ -318,34 +592,36 @@ async function processRecord(record, deps) {
318
592
 
319
593
  const clusterArn = readEnv(env, "ECS_CLUSTER_ARN");
320
594
  if (clusterArn !== undefined) {
321
- try {
322
- const containerInstanceArn = await findContainerInstanceArn(
323
- ecsClient,
324
- clusterArn,
325
- instanceId
595
+ const heartbeatSeconds = readPositiveInteger(
596
+ env,
597
+ "LIFECYCLE_HEARTBEAT_SECONDS"
598
+ );
599
+ const deadlines = attemptDeadlines(
600
+ parsed.startedAtMs,
601
+ heartbeatSeconds,
602
+ deps && deps.invocationEndMs
603
+ );
604
+ if (deadlines.proofDeadlineMs === Infinity) {
605
+ log(
606
+ "The drain's proof cannot be dated against the lifecycle hook's heartbeat",
607
+ {
608
+ instanceId,
609
+ heartbeatSecondsKnown: heartbeatSeconds !== undefined,
610
+ startedAtKnown: parsed.startedAtMs !== undefined
611
+ }
326
612
  );
327
- if (containerInstanceArn !== undefined) {
328
- await drainContainerInstance(
329
- ecsClient,
330
- clusterArn,
331
- containerInstanceArn,
332
- sleepFn,
333
- nowFn
334
- );
335
- await ecsClient.send(
336
- new DeregisterContainerInstanceCommand({
337
- cluster: clusterArn,
338
- containerInstance: containerInstanceArn,
339
- force: true
340
- })
341
- );
342
- }
343
- } catch (err) {
344
- log("ECS drain step failed; continuing to generic cleanup", {
345
- instanceId,
346
- error: errMessage(err)
347
- });
348
613
  }
614
+ await drainEcsTasks(
615
+ {
616
+ ecsClient,
617
+ clusterArn,
618
+ sleep: sleepFn,
619
+ now: nowFn,
620
+ log,
621
+ ...deadlines
622
+ },
623
+ instanceId
624
+ );
349
625
  }
350
626
 
351
627
  try {
@@ -373,38 +649,22 @@ async function processRecord(record, deps) {
373
649
  });
374
650
  }
375
651
 
376
- const dataVolumeOwnerLogicalId = readEnv(env, "DATA_VOLUME_OWNER_LOGICAL_ID");
377
- const dataVolumeStackId = readEnv(env, "DATA_VOLUME_STACK_ID");
378
- if (
379
- dataVolumeOwnerLogicalId !== undefined &&
380
- dataVolumeStackId !== undefined
381
- ) {
382
- try {
383
- await detachDataVolume(
384
- ec2Client,
385
- instanceId,
386
- dataVolumeOwnerLogicalId,
387
- dataVolumeStackId,
388
- sleepFn,
389
- nowFn
390
- );
391
- } catch (err) {
392
- log("Data volume detach failed; AWS will force-detach after heartbeat", {
393
- instanceId,
394
- ownerLogicalId: dataVolumeOwnerLogicalId,
395
- error: errMessage(err)
396
- });
397
- }
398
- }
399
-
400
- await completeLifecycle(asgClient, asgName, hookName, actionToken);
652
+ logLifecycleOutcome(
653
+ log,
654
+ await completeLifecycle(asgClient, asgName, hookName, actionToken),
655
+ instanceId
656
+ );
401
657
  }
402
658
 
403
- exports.handler = async (event) => {
659
+ exports.handler = async (event, context) => {
660
+ const invocationEndMs =
661
+ context && typeof context.getRemainingTimeInMillis === "function"
662
+ ? Date.now() + context.getRemainingTimeInMillis()
663
+ : undefined;
404
664
  const records = (event && event.Records) || [];
405
665
  for (const record of records) {
406
666
  try {
407
- await processRecord(record);
667
+ await processRecord(record, { invocationEndMs });
408
668
  } catch (err) {
409
669
  // Last-resort: try to release the lifecycle action so the stack does
410
670
  // not hang. Failure here is logged and the message will retry via SQS.
@@ -419,11 +679,15 @@ exports.handler = async (event) => {
419
679
  if (!parsed.valid) {
420
680
  continue;
421
681
  }
422
- await completeLifecycle(
423
- getAsg(),
424
- parsed.asgName,
425
- parsed.hookName,
426
- parsed.actionToken
682
+ logLifecycleOutcome(
683
+ consoleLog,
684
+ await completeLifecycle(
685
+ getAsg(),
686
+ parsed.asgName,
687
+ parsed.hookName,
688
+ parsed.actionToken
689
+ ),
690
+ parsed.instanceId
427
691
  );
428
692
  } catch (innerErr) {
429
693
  console.error(
@@ -440,19 +704,30 @@ exports.handler = async (event) => {
440
704
 
441
705
  exports._internals = {
442
706
  parseLifecycleMessage,
707
+ attemptDeadlines,
443
708
  findContainerInstanceArn,
709
+ listInstanceTaskArns,
710
+ unstoppedTaskArns,
711
+ awaitTasksStopped,
444
712
  drainContainerInstance,
713
+ drainEcsTasks,
445
714
  dissociateInstanceEips,
446
715
  deregisterFromTargetGroups,
447
716
  detachSecondaryEnis,
448
- detachDataVolume,
717
+ isNoActiveLifecycleAction,
449
718
  completeLifecycle,
450
719
  readEnv,
451
720
  processRecord,
452
721
  DRAIN_POLL_INTERVAL_MS,
453
722
  DRAIN_DEADLINE_MS,
454
- DETACH_POLL_INTERVAL_MS,
455
- DETACH_DEADLINE_MS,
456
- TAG_OWNER_LOGICAL_ID,
457
- TAG_STACK_ID
723
+ STOP_DEADLINE_MS,
724
+ DESCRIBE_TASKS_BATCH_SIZE,
725
+ TASK_DESIRED_STATUSES,
726
+ STOPPED_TASK_STATUSES,
727
+ SDK_CALL_TIMEOUT_MS,
728
+ COMPLETION_RESERVE_MS,
729
+ HEARTBEAT_RESERVE_MS,
730
+ DRAIN_EVENT,
731
+ LIFECYCLE_EVENT,
732
+ LIFECYCLE_OUTCOME
458
733
  };