@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.
@@ -26,16 +26,25 @@
26
26
  * Lambda-Errors alarm (a tag-uniqueness invariant violation; silently
27
27
  * picking one risks attaching the wrong data and requires operator
28
28
  * investigation).
29
- * 3. AttachVolume(Device: DEVICE_NAME, InstanceId, VolumeId).
30
- * 4. Poll DescribeVolumes until State === "in-use" AND the matching
29
+ * 3. Wait for the volume to be released: poll DescribeVolumes until its
30
+ * State is "available", deadline RELEASE_DEADLINE_MS. Timeout →
31
+ * ABANDON; the ASG launches another instance and the wait starts over.
32
+ * 4. AttachVolume(Device: DEVICE_NAME, InstanceId, VolumeId). If EC2
33
+ * refuses because a concurrent delivery of the same event is already
34
+ * attaching the volume here, carry on to the attach wait.
35
+ * 5. Poll DescribeVolumes until State === "in-use" AND the matching
31
36
  * Attachments[].State === "attached", deadline 90s. Timeout → ABANDON.
32
- * 5. CompleteLifecycleAction(CONTINUE) on success.
37
+ * 6. CompleteLifecycleAction(CONTINUE) on success. A token whose action
38
+ * has already ended is logged as
39
+ * `event: "lifecycle_action_already_ended"`, not retried.
33
40
  *
34
- * The Lambda is paired with the TERMINATING-side DetachVolume branch in
35
- * ec2GracefulTerminationLambda.source.cjs. The two Lambdas form a closed
36
- * re-attach contract: TERMINATING detaches the tagged volume from the
37
- * outgoing instance, LAUNCHING (this file) re-attaches it to the incoming
38
- * instance.
41
+ * Nothing detaches the volume. The instance that holds it releases it when
42
+ * it terminates, after the TERMINATING handler
43
+ * (ec2GracefulTerminationLambda.source.cjs) has drained its tasks, so a
44
+ * detach can never pull the filesystem out from under a running database.
45
+ * This handler therefore waits for the release instead of forcing it.
46
+ * RELEASE_DEADLINE_MS covers the TERMINATING handler's drain and StopTask
47
+ * budgets plus the instance's shutdown; a test pins it to them.
39
48
  *
40
49
  * CommonJS rather than ESM because Code.fromInline lands the source as
41
50
  * `index.js`, which Lambda treats as CommonJS by default.
@@ -51,18 +60,41 @@ const {
51
60
  } = require("@aws-sdk/client-auto-scaling");
52
61
 
53
62
  const ATTACH_POLL_INTERVAL_MS = 5_000;
54
- // 90s attach deadline. AWS volume attach is usually < 30s; the bootstrap
55
- // script waits 60s for /dev/xvdf so the hook MUST complete inside that.
63
+ // AWS volume attach is usually < 30s.
56
64
  const ATTACH_DEADLINE_MS = 90_000;
65
+ const RELEASE_POLL_INTERVAL_MS = 10_000;
66
+ // The TERMINATING handler's drain (240 s) and StopTask (150 s) budgets, plus
67
+ // its network cleanup and the instance's OS shutdown.
68
+ const RELEASE_DEADLINE_MS = 480_000;
57
69
  const TAG_OWNER_LOGICAL_ID = "fjall:OwnerLogicalId";
58
70
  const TAG_LIFECYCLE = "fjall:Lifecycle";
59
71
  const TAG_LIFECYCLE_VALUE = "data-volume";
60
72
  const TAG_STACK_ID = "fjall:StackId";
73
+ // Bounds every AWS call, SDK retries included, so a hung request cannot carry
74
+ // an attempt past the Lambda's timeout.
75
+ const SDK_CALL_TIMEOUT_MS = 20_000;
76
+ const LIFECYCLE_EVENT = {
77
+ ALREADY_ENDED: "lifecycle_action_already_ended"
78
+ };
79
+ const LIFECYCLE_OUTCOME = {
80
+ COMPLETED: "completed",
81
+ ALREADY_ENDED: "already_ended"
82
+ };
61
83
 
62
84
  function errMessage(err) {
63
85
  return err && err.message ? err.message : String(err);
64
86
  }
65
87
 
88
+ function send(client, command) {
89
+ return client.send(command, {
90
+ abortSignal: AbortSignal.timeout(SDK_CALL_TIMEOUT_MS)
91
+ });
92
+ }
93
+
94
+ function consoleLog(msg, fields) {
95
+ console.log(JSON.stringify({ message: msg, ...(fields || {}) }));
96
+ }
97
+
66
98
  let _ec2;
67
99
  let _asg;
68
100
  function getEc2() {
@@ -106,7 +138,8 @@ function readEnv(env, name) {
106
138
  }
107
139
 
108
140
  async function findOwnedVolume(ec2Client, ownerLogicalId, stackId) {
109
- const result = await ec2Client.send(
141
+ const result = await send(
142
+ ec2Client,
110
143
  new DescribeVolumesCommand({
111
144
  Filters: [
112
145
  { Name: `tag:${TAG_OWNER_LOGICAL_ID}`, Values: [ownerLogicalId] },
@@ -119,6 +152,57 @@ async function findOwnedVolume(ec2Client, ownerLogicalId, stackId) {
119
152
  return volumes;
120
153
  }
121
154
 
155
+ /**
156
+ * Poll until the volume can be attached to `instanceId`. Resolves
157
+ * `{ released: true, attachedHere: false }` once it is "available",
158
+ * `{ released: true, attachedHere: true }` if an earlier delivery of this
159
+ * lifecycle action already attached it here, and `{ released: false, ... }`
160
+ * at the deadline. A failed read is retried until the deadline and named in
161
+ * the result, so a timeout caused by an unreadable volume says so.
162
+ */
163
+ async function pollUntilReleased(
164
+ ec2Client,
165
+ volumeId,
166
+ instanceId,
167
+ sleepFn,
168
+ nowFn
169
+ ) {
170
+ const deadline = nowFn() + RELEASE_DEADLINE_MS;
171
+ let lastState;
172
+ let lastError;
173
+ for (;;) {
174
+ try {
175
+ const desc = await send(
176
+ ec2Client,
177
+ new DescribeVolumesCommand({ VolumeIds: [volumeId] })
178
+ );
179
+ const volume =
180
+ desc.Volumes && desc.Volumes[0] ? desc.Volumes[0] : undefined;
181
+ lastError = undefined;
182
+ lastState = volume ? volume.State : undefined;
183
+ if (volume && volume.State === "available") {
184
+ return { released: true, attachedHere: false };
185
+ }
186
+ const attachedHere = (volume?.Attachments || []).some(
187
+ (a) => a.InstanceId === instanceId && a.State === "attached"
188
+ );
189
+ if (attachedHere) {
190
+ return { released: true, attachedHere: true };
191
+ }
192
+ } catch (err) {
193
+ lastError = errMessage(err);
194
+ }
195
+ if (nowFn() >= deadline) {
196
+ return {
197
+ released: false,
198
+ lastState,
199
+ ...(lastError !== undefined && { error: lastError })
200
+ };
201
+ }
202
+ await sleepFn(Math.min(RELEASE_POLL_INTERVAL_MS, deadline - nowFn()));
203
+ }
204
+ }
205
+
122
206
  async function pollUntilAttached(
123
207
  ec2Client,
124
208
  volumeId,
@@ -127,8 +211,9 @@ async function pollUntilAttached(
127
211
  nowFn
128
212
  ) {
129
213
  const deadline = nowFn() + ATTACH_DEADLINE_MS;
130
- while (nowFn() < deadline) {
131
- const desc = await ec2Client.send(
214
+ for (;;) {
215
+ const desc = await send(
216
+ ec2Client,
132
217
  new DescribeVolumesCommand({ VolumeIds: [volumeId] })
133
218
  );
134
219
  const volume =
@@ -141,9 +226,45 @@ async function pollUntilAttached(
141
226
  return true;
142
227
  }
143
228
  }
144
- await sleepFn(ATTACH_POLL_INTERVAL_MS);
229
+ if (nowFn() >= deadline) return false;
230
+ await sleepFn(Math.min(ATTACH_POLL_INTERVAL_MS, deadline - nowFn()));
231
+ }
232
+ }
233
+
234
+ // Whether a refused AttachVolume lost a race with a concurrent delivery of
235
+ // the same event, which is attaching the volume to this instance.
236
+ async function attachingHere(ec2Client, volumeId, instanceId, log) {
237
+ try {
238
+ const desc = await send(
239
+ ec2Client,
240
+ new DescribeVolumesCommand({ VolumeIds: [volumeId] })
241
+ );
242
+ const volume =
243
+ desc.Volumes && desc.Volumes[0] ? desc.Volumes[0] : undefined;
244
+ return (volume?.Attachments || []).some(
245
+ (a) =>
246
+ a.InstanceId === instanceId &&
247
+ (a.State === "attaching" || a.State === "attached")
248
+ );
249
+ } catch (err) {
250
+ log("Volume read after a refused attach failed", {
251
+ volumeId,
252
+ instanceId,
253
+ error: errMessage(err)
254
+ });
255
+ return false;
145
256
  }
146
- return false;
257
+ }
258
+
259
+ // Auto Scaling refuses a token whose action has already ended, because
260
+ // another delivery of the event completed it or the heartbeat expired, with
261
+ // a ValidationError naming no active lifecycle action.
262
+ function isNoActiveLifecycleAction(err) {
263
+ return (
264
+ Boolean(err) &&
265
+ err.name === "ValidationError" &&
266
+ /No active Lifecycle Action/i.test(errMessage(err))
267
+ );
147
268
  }
148
269
 
149
270
  async function completeLifecycle(
@@ -153,13 +274,28 @@ async function completeLifecycle(
153
274
  actionToken,
154
275
  result
155
276
  ) {
156
- await asgClient.send(
157
- new CompleteLifecycleActionCommand({
158
- AutoScalingGroupName: asgName,
159
- LifecycleHookName: hookName,
160
- LifecycleActionToken: actionToken,
161
- LifecycleActionResult: result
162
- })
277
+ try {
278
+ await send(
279
+ asgClient,
280
+ new CompleteLifecycleActionCommand({
281
+ AutoScalingGroupName: asgName,
282
+ LifecycleHookName: hookName,
283
+ LifecycleActionToken: actionToken,
284
+ LifecycleActionResult: result
285
+ })
286
+ );
287
+ return LIFECYCLE_OUTCOME.COMPLETED;
288
+ } catch (err) {
289
+ if (isNoActiveLifecycleAction(err)) return LIFECYCLE_OUTCOME.ALREADY_ENDED;
290
+ throw err;
291
+ }
292
+ }
293
+
294
+ function logLifecycleOutcome(log, outcome, instanceId) {
295
+ if (outcome !== LIFECYCLE_OUTCOME.ALREADY_ENDED) return;
296
+ log(
297
+ "The lifecycle action had already ended: another delivery completed it, or its heartbeat expired",
298
+ { event: LIFECYCLE_EVENT.ALREADY_ENDED, instanceId }
163
299
  );
164
300
  }
165
301
 
@@ -171,11 +307,7 @@ async function processRecord(record, deps) {
171
307
  ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
172
308
  const nowFn = (deps && deps.now) || (() => Date.now());
173
309
  const env = (deps && deps.env) || process.env;
174
- const log =
175
- (deps && deps.log) ||
176
- ((msg, fields) => {
177
- console.log(JSON.stringify({ message: msg, ...(fields || {}) }));
178
- });
310
+ const log = (deps && deps.log) || consoleLog;
179
311
 
180
312
  const parsed = parseLifecycleMessage(record);
181
313
  if (!parsed.valid) {
@@ -185,6 +317,18 @@ async function processRecord(record, deps) {
185
317
  return;
186
318
  }
187
319
  const { instanceId, asgName, actionToken, hookName } = parsed;
320
+ const finish = async (result) =>
321
+ logLifecycleOutcome(
322
+ log,
323
+ await completeLifecycle(
324
+ asgClient,
325
+ asgName,
326
+ hookName,
327
+ actionToken,
328
+ result
329
+ ),
330
+ instanceId
331
+ );
188
332
 
189
333
  const ownerLogicalId = readEnv(env, "OWNER_LOGICAL_ID");
190
334
  const stackId = readEnv(env, "STACK_ID");
@@ -198,13 +342,7 @@ async function processRecord(record, deps) {
198
342
  asgName,
199
343
  instanceId
200
344
  });
201
- await completeLifecycle(
202
- asgClient,
203
- asgName,
204
- hookName,
205
- actionToken,
206
- "ABANDON"
207
- );
345
+ await finish("ABANDON");
208
346
  return;
209
347
  }
210
348
 
@@ -217,13 +355,7 @@ async function processRecord(record, deps) {
217
355
  instanceId,
218
356
  error: errMessage(err)
219
357
  });
220
- await completeLifecycle(
221
- asgClient,
222
- asgName,
223
- hookName,
224
- actionToken,
225
- "ABANDON"
226
- );
358
+ await finish("ABANDON");
227
359
  return;
228
360
  }
229
361
 
@@ -232,13 +364,7 @@ async function processRecord(record, deps) {
232
364
  ownerLogicalId,
233
365
  instanceId
234
366
  });
235
- await completeLifecycle(
236
- asgClient,
237
- asgName,
238
- hookName,
239
- actionToken,
240
- "ABANDON"
241
- );
367
+ await finish("ABANDON");
242
368
  return;
243
369
  }
244
370
 
@@ -250,41 +376,44 @@ async function processRecord(record, deps) {
250
376
  volumeIds: volumes.map((v) => v.VolumeId)
251
377
  }
252
378
  );
253
- await completeLifecycle(
254
- asgClient,
255
- asgName,
256
- hookName,
257
- actionToken,
258
- "ABANDON"
259
- );
379
+ await finish("ABANDON");
260
380
  return;
261
381
  }
262
382
 
263
- const volume = volumes[0];
264
- const volumeId = volume.VolumeId;
383
+ const volumeId = volumes[0].VolumeId;
265
384
 
266
- // Already attached to the launching instance (e.g. previous Lambda invocation
267
- // succeeded before the hook timeout fired and ASG re-fired the message).
268
- const existingAttachment = (volume.Attachments || []).find(
269
- (a) => a.InstanceId === instanceId && a.State === "attached"
385
+ // An earlier delivery of this lifecycle action may already have attached
386
+ // the volume here; the release wait reports that as `attachedHere`.
387
+ const release = await pollUntilReleased(
388
+ ec2Client,
389
+ volumeId,
390
+ instanceId,
391
+ sleepFn,
392
+ nowFn
270
393
  );
271
- if (existingAttachment !== undefined) {
394
+ if (!release.released) {
395
+ log("Volume was not released within the deadline; abandoning launch", {
396
+ volumeId,
397
+ instanceId,
398
+ deadlineMs: RELEASE_DEADLINE_MS,
399
+ lastState: release.lastState,
400
+ ...(release.error !== undefined && { error: release.error })
401
+ });
402
+ await finish("ABANDON");
403
+ return;
404
+ }
405
+ if (release.attachedHere) {
272
406
  log("Volume already attached to launching instance; completing CONTINUE", {
273
407
  volumeId,
274
408
  instanceId
275
409
  });
276
- await completeLifecycle(
277
- asgClient,
278
- asgName,
279
- hookName,
280
- actionToken,
281
- "CONTINUE"
282
- );
410
+ await finish("CONTINUE");
283
411
  return;
284
412
  }
285
413
 
286
414
  try {
287
- await ec2Client.send(
415
+ await send(
416
+ ec2Client,
288
417
  new AttachVolumeCommand({
289
418
  VolumeId: volumeId,
290
419
  InstanceId: instanceId,
@@ -292,19 +421,21 @@ async function processRecord(record, deps) {
292
421
  })
293
422
  );
294
423
  } catch (err) {
295
- log("AttachVolume failed; abandoning", {
296
- volumeId,
297
- instanceId,
298
- error: errMessage(err)
299
- });
300
- await completeLifecycle(
301
- asgClient,
302
- asgName,
303
- hookName,
304
- actionToken,
305
- "ABANDON"
306
- );
307
- return;
424
+ if (await attachingHere(ec2Client, volumeId, instanceId, log)) {
425
+ log("AttachVolume was refused, but the volume is attaching here", {
426
+ volumeId,
427
+ instanceId,
428
+ error: errMessage(err)
429
+ });
430
+ } else {
431
+ log("AttachVolume failed; abandoning", {
432
+ volumeId,
433
+ instanceId,
434
+ error: errMessage(err)
435
+ });
436
+ await finish("ABANDON");
437
+ return;
438
+ }
308
439
  }
309
440
 
310
441
  const attached = await pollUntilAttached(
@@ -323,13 +454,7 @@ async function processRecord(record, deps) {
323
454
  deadlineMs: ATTACH_DEADLINE_MS
324
455
  }
325
456
  );
326
- await completeLifecycle(
327
- asgClient,
328
- asgName,
329
- hookName,
330
- actionToken,
331
- "ABANDON"
332
- );
457
+ await finish("ABANDON");
333
458
  return;
334
459
  }
335
460
 
@@ -337,13 +462,7 @@ async function processRecord(record, deps) {
337
462
  volumeId,
338
463
  instanceId
339
464
  });
340
- await completeLifecycle(
341
- asgClient,
342
- asgName,
343
- hookName,
344
- actionToken,
345
- "CONTINUE"
346
- );
465
+ await finish("CONTINUE");
347
466
  }
348
467
 
349
468
  exports.handler = async (event) => {
@@ -362,12 +481,16 @@ exports.handler = async (event) => {
362
481
  try {
363
482
  const parsed = parseLifecycleMessage(record);
364
483
  if (!parsed.valid) continue;
365
- await completeLifecycle(
366
- getAsg(),
367
- parsed.asgName,
368
- parsed.hookName,
369
- parsed.actionToken,
370
- "ABANDON"
484
+ logLifecycleOutcome(
485
+ consoleLog,
486
+ await completeLifecycle(
487
+ getAsg(),
488
+ parsed.asgName,
489
+ parsed.hookName,
490
+ parsed.actionToken,
491
+ "ABANDON"
492
+ ),
493
+ parsed.instanceId
371
494
  );
372
495
  } catch (innerErr) {
373
496
  console.error(
@@ -386,13 +509,21 @@ exports._internals = {
386
509
  parseLifecycleMessage,
387
510
  readEnv,
388
511
  findOwnedVolume,
512
+ pollUntilReleased,
389
513
  pollUntilAttached,
514
+ attachingHere,
515
+ isNoActiveLifecycleAction,
390
516
  completeLifecycle,
391
517
  processRecord,
392
518
  ATTACH_POLL_INTERVAL_MS,
393
519
  ATTACH_DEADLINE_MS,
520
+ RELEASE_POLL_INTERVAL_MS,
521
+ RELEASE_DEADLINE_MS,
394
522
  TAG_OWNER_LOGICAL_ID,
395
523
  TAG_LIFECYCLE,
396
524
  TAG_LIFECYCLE_VALUE,
397
- TAG_STACK_ID
525
+ TAG_STACK_ID,
526
+ SDK_CALL_TIMEOUT_MS,
527
+ LIFECYCLE_EVENT,
528
+ LIFECYCLE_OUTCOME
398
529
  };
@@ -1,3 +1,4 @@
1
+ import { PERSISTENT_DATA_VOLUME_LAUNCHING_HEARTBEAT_SECONDS } from "../compute/persistentDataVolume.js";
1
2
  import { CLICKHOUSE_DATA_MOUNT_PATH, CLICKHOUSE_EBS_DEVICE_NAME, CLICKHOUSE_CONFIG_SUBDIR, CLICKHOUSE_USERS_SUBDIR, CLICKHOUSE_ECS_RESERVED_MEMORY_MIB, CLICKHOUSE_HOST_METRICS, CLICKHOUSE_HTTP_PORT, CLICKHOUSE_HTTPS_PORT, CLICKHOUSE_TCP_SECURE_PORT, CLICKHOUSE_TLS_CERT_MOUNT_PATH, CLICKHOUSE_PROMETHEUS_PORT, CLICKHOUSE_UID, clickHousePasswordSha256Snippet } from "./clickhouseConstants.js";
2
3
  import { deriveClickHouseServerTuning, MAX_SERVER_MEMORY_RAM_RATIO, SYSTEM_LOG_TTL } from "./clickhouseTuning.js";
3
4
  import { renderUsersXml } from "./clickhouseXmlRenderer.js";
@@ -462,7 +463,7 @@ mkdir -p /etc/systemd/system/docker.service.d
462
463
  cat > /etc/systemd/system/docker.service.d/override.conf << 'DOCKEROVERRIDE'
463
464
  [Service]
464
465
  LimitNOFILE=1048576
465
- LimitNPROC=unlimited
466
+ LimitNPROC=infinity
466
467
  DOCKEROVERRIDE
467
468
  systemctl daemon-reload
468
469
  systemctl restart docker || true
@@ -475,8 +476,10 @@ systemctl restart docker || true
475
476
  mkdir -p /etc/ecs
476
477
  echo "ECS_RESERVED_MEMORY=${CLICKHOUSE_ECS_RESERVED_MEMORY_MIB}" >> /etc/ecs/ecs.config
477
478
 
478
- # Wait for the EBS volume to be attached (up to 60 seconds)
479
- for i in $(seq 1 60); do
479
+ # Wait for the EBS volume. The launching lifecycle hook attaches it only once
480
+ # the previous instance has released it, and ABANDONs this instance when its
481
+ # heartbeat expires, so the wait matches the heartbeat.
482
+ for _ in $(seq 1 ${PERSISTENT_DATA_VOLUME_LAUNCHING_HEARTBEAT_SECONDS}); do
480
483
  if [ -b "$DEVICE" ]; then
481
484
  break
482
485
  fi
@@ -484,13 +487,53 @@ for i in $(seq 1 60); do
484
487
  done
485
488
 
486
489
  if [ ! -b "$DEVICE" ]; then
487
- echo "ERROR: EBS volume $DEVICE not found after 60 seconds" >&2
490
+ echo "ERROR: EBS volume $DEVICE not found after ${PERSISTENT_DATA_VOLUME_LAUNCHING_HEARTBEAT_SECONDS} seconds" >&2
488
491
  exit 1
489
492
  fi
490
493
 
491
- # Format only if not already formatted (idempotent)
492
- if ! blkid "$DEVICE" | grep -q ext4; then
494
+ # Format only a device that holds no filesystem this construct could have
495
+ # made. blkid -p exits 2 both for a blank device and for one it could not
496
+ # read, so the device must also read back, and carry the ext4 magic neither
497
+ # in the primary superblock nor in the first backup (group 1 of a 4 KiB-block
498
+ # filesystem, the one e2fsck -b 32768 reads). Content is not a test: a new EBS
499
+ # volume need not read back as zeros. Random bytes carry the magic at a given
500
+ # offset once in 65,536 reads, and such a new volume is refused too, which is
501
+ # the side to err on. Anything else is refused: formatting is the one step
502
+ # here that can destroy the database.
503
+ # Prints, as hex, the two bytes at offset $2 of the 4 KiB block $1; fails
504
+ # unless the whole block was read and parsed.
505
+ read_magic() {
506
+ local block
507
+ block=$(mktemp) || return 1
508
+ if ! dd if="$DEVICE" of="$block" bs=4096 skip="$1" count=1 iflag=direct status=none || [ "$(stat -c %s "$block")" != 4096 ]; then
509
+ rm -f "$block"
510
+ return 1
511
+ fi
512
+ od -An -tx1 -j "$2" -N 2 "$block" | tr -d ' \\n'
513
+ local rc=$?
514
+ rm -f "$block"
515
+ return "$rc"
516
+ }
517
+ BLKID_RC=0
518
+ DATA_VOLUME_FS_TYPE=$(blkid -p -s TYPE -o value "$DEVICE") || BLKID_RC=$?
519
+ if [ "$BLKID_RC" -eq 0 ]; then
520
+ if [ "$DATA_VOLUME_FS_TYPE" != "ext4" ]; then
521
+ echo "ERROR: $DEVICE holds '$DATA_VOLUME_FS_TYPE', not an ext4 filesystem; refusing to mount or format it" >&2
522
+ exit 1
523
+ fi
524
+ elif [ "$BLKID_RC" -eq 2 ]; then
525
+ if ! PRIMARY_MAGIC=$(read_magic 0 1080) || ! BACKUP_MAGIC=$(read_magic 32768 56); then
526
+ echo "ERROR: blkid found no filesystem on $DEVICE, but its superblocks could not be read; refusing to format it" >&2
527
+ exit 1
528
+ fi
529
+ if [ "$PRIMARY_MAGIC" = 53ef ] || [ "$BACKUP_MAGIC" = 53ef ]; then
530
+ echo "ERROR: blkid found no filesystem on $DEVICE, but it carries an ext4 superblock (primary '$PRIMARY_MAGIC', backup '$BACKUP_MAGIC'); refusing to format it (e2fsck -b 32768 $DEVICE repairs one whose primary superblock is damaged)" >&2
531
+ exit 1
532
+ fi
493
533
  mkfs.ext4 -m 0 "$DEVICE"
534
+ else
535
+ echo "ERROR: blkid could not probe $DEVICE (exit $BLKID_RC); refusing to mount or format it" >&2
536
+ exit 1
494
537
  fi
495
538
 
496
539
  # Mount
@@ -4,10 +4,13 @@
4
4
  * Log-pattern metric filters and their alarms read from these namespaces. The
5
5
  * webapp app service declares RLS alarms under `WEBAPP` and relocates the
6
6
  * ClickHouse stuck-merge alarm under `CLICKHOUSE` (the log line is app-emitted,
7
- * so it is an app-log alarm carrying a database-domain namespace).
7
+ * so it is an app-log alarm carrying a database-domain namespace). The EC2
8
+ * graceful-termination handler alarms under `EC2` when it cannot prove a
9
+ * terminating instance's ECS tasks stopped.
8
10
  */
9
11
  export declare const METRIC_NAMESPACE: {
10
12
  readonly CLICKHOUSE: "Fjall/ClickHouse";
13
+ readonly EC2: "Fjall/EC2";
11
14
  readonly ECS: "Fjall/ECS";
12
15
  readonly WEBAPP: "Fjall/WebApp";
13
16
  };
@@ -5,10 +5,13 @@ import { Aws } from "aws-cdk-lib";
5
5
  * Log-pattern metric filters and their alarms read from these namespaces. The
6
6
  * webapp app service declares RLS alarms under `WEBAPP` and relocates the
7
7
  * ClickHouse stuck-merge alarm under `CLICKHOUSE` (the log line is app-emitted,
8
- * so it is an app-log alarm carrying a database-domain namespace).
8
+ * so it is an app-log alarm carrying a database-domain namespace). The EC2
9
+ * graceful-termination handler alarms under `EC2` when it cannot prove a
10
+ * terminating instance's ECS tasks stopped.
9
11
  */
10
12
  export const METRIC_NAMESPACE = {
11
13
  CLICKHOUSE: "Fjall/ClickHouse",
14
+ EC2: "Fjall/EC2",
12
15
  ECS: "Fjall/ECS",
13
16
  WEBAPP: "Fjall/WebApp"
14
17
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "39.0.1",
3
+ "version": "39.0.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^39.0.1",
84
- "@fjall/util": "^39.0.1",
83
+ "@fjall/generator": "^39.0.2",
84
+ "@fjall/util": "^39.0.2",
85
85
  "constructs": "^10.7.2",
86
86
  "zod": "^4.4.3"
87
87
  },