@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.
- package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +20 -8
- package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.d.ts +11 -2
- package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.js +12 -2
- package/dist/lib/resources/aws/compute/ec2.d.ts +10 -3
- package/dist/lib/resources/aws/compute/ec2.js +8 -4
- package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.d.ts +17 -5
- package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +55 -46
- package/dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs +422 -147
- package/dist/lib/resources/aws/compute/ecsCapacityProviderGate.d.ts +41 -0
- package/dist/lib/resources/aws/compute/ecsCapacityProviderGate.js +116 -0
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +5 -0
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +6 -5
- package/dist/lib/resources/aws/compute/lambda.d.ts +3 -0
- package/dist/lib/resources/aws/compute/lambda.js +10 -6
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +17 -15
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +27 -21
- package/dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs +235 -104
- package/dist/lib/resources/aws/database/clickhouseUserData.js +49 -6
- package/dist/lib/resources/aws/monitoring/metricNamespaces.d.ts +4 -1
- package/dist/lib/resources/aws/monitoring/metricNamespaces.js +4 -1
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +18 -10
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +37 -33
- 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 →
|
|
18
|
-
* instance
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
275
|
+
budgetMs,
|
|
276
|
+
onUnstopped
|
|
131
277
|
) {
|
|
132
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
717
|
+
isNoActiveLifecycleAction,
|
|
449
718
|
completeLifecycle,
|
|
450
719
|
readEnv,
|
|
451
720
|
processRecord,
|
|
452
721
|
DRAIN_POLL_INTERVAL_MS,
|
|
453
722
|
DRAIN_DEADLINE_MS,
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
};
|