@celilo/cli 0.14.1 → 0.14.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.14.1",
3
+ "version": "0.14.4",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
- "@celilo/capabilities": "^0.9.0",
60
+ "@celilo/capabilities": "^0.9.1",
61
61
  "@celilo/cli-display": "^0.1.9",
62
62
  "@celilo/core": "^0.1.0",
63
63
  "@celilo/event-bus": "^0.1.8",
@@ -516,7 +516,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
516
516
  };
517
517
  }
518
518
 
519
- test('registers the public A record at externalIp for a NEW hostname', async () => {
519
+ test('registers the public A record for a NEW hostname without supplying an IP', async () => {
520
520
  const { ops } = makeRouteOps();
521
521
  const { registrar, calls } = makeRegistrar();
522
522
  const cap = createPublicWeb({
@@ -530,7 +530,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
530
530
  dnsManagedDomains: ['example.com'],
531
531
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
532
532
  dnsRegistrar: registrar as any,
533
- externalIp: '100.100.0.100',
534
533
  });
535
534
 
536
535
  await cap.register_route({
@@ -539,7 +538,9 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
539
538
  hostname: 'nexus.example.com',
540
539
  });
541
540
 
542
- expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: '100.100.0.100' }]);
541
+ // No `ip` the registrar publishes the source address of the update
542
+ // request, which is by construction the address the internet must dial.
543
+ expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: undefined }]);
543
544
  });
544
545
 
545
546
  test('skips the default hostname (caddy already registered it at install)', async () => {
@@ -556,7 +557,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
556
557
  dnsManagedDomains: ['example.com'],
557
558
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
558
559
  dnsRegistrar: registrar as any,
559
- externalIp: '100.100.0.100',
560
560
  });
561
561
 
562
562
  await cap.register_route({ type: 'static', path: '/', hostname: 'www.example.com' });
@@ -568,7 +568,13 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
568
568
  // hostname that can't be wired FAILS the deploy instead of silently
569
569
  // reporting success (the "served but unreachable" anti-pattern).
570
570
 
571
- test('fails loudly when externalIp is unknown for a NEW hostname', async () => {
571
+ // The regression guard for #464. This case used to THROW "caddy has no known
572
+ // external IP" — and it threw on every real fleet, because the stored copy it
573
+ // demanded was never populated: caddy produced a `public_ip` hook output that
574
+ // its manifest never declared, so the framework discarded it. A correct fleet
575
+ // with correct public DNS could not register a new hostname. Registration must
576
+ // not depend on knowing the external IP at all.
577
+ test('registers a NEW hostname even though no external IP is known anywhere', async () => {
572
578
  const { ops } = makeRouteOps();
573
579
  const { registrar, calls } = makeRegistrar();
574
580
  const cap = createPublicWeb({
@@ -582,13 +588,11 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
582
588
  dnsManagedDomains: ['example.com'],
583
589
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
584
590
  dnsRegistrar: registrar as any,
585
- // externalIp omitted
586
591
  });
587
592
 
588
- await expect(
589
- cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
590
- ).rejects.toThrow('no known external IP');
591
- expect(calls).toHaveLength(0);
593
+ await cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' });
594
+
595
+ expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: undefined }]);
592
596
  });
593
597
 
594
598
  test('fails loudly when no dns_registrar is available for a NEW hostname', async () => {
@@ -602,8 +606,7 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
602
606
  hostnames: ['www.example.com'],
603
607
  caddyModuleId: 'caddy',
604
608
  dnsManagedDomains: ['example.com'],
605
- // dnsRegistrar + externalIp both omitted
606
- externalIp: '100.100.0.100',
609
+ // dnsRegistrar omitted
607
610
  });
608
611
 
609
612
  await expect(
@@ -629,7 +632,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
629
632
  dnsManagedDomains: ['example.com'],
630
633
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
631
634
  dnsRegistrar: registrar as any,
632
- externalIp: '100.100.0.100',
633
635
  });
634
636
 
635
637
  await expect(
@@ -656,7 +658,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
656
658
  dnsManagedDomains: ['example.com'],
657
659
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
658
660
  dnsRegistrar: registrar as any,
659
- externalIp: '100.100.0.100',
660
661
  // biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
661
662
  dnsInternal: dnsInternal as any,
662
663
  });
@@ -687,7 +688,6 @@ describe('register_route — public DNS wiring (D1/M1 #328)', () => {
687
688
  dnsManagedDomains: ['example.com'],
688
689
  // biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
689
690
  dnsRegistrar: registrar as any,
690
- externalIp: '100.100.0.100',
691
691
  firewallNatIp: '192.168.0.253',
692
692
  // biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
693
693
  dnsInternal: dnsInternal as any,
@@ -152,5 +152,35 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
152
152
  `${report.unsuppressed} unsuppressed`,
153
153
  `${report.notified} notified`,
154
154
  ];
155
- return { success: true, message: `alert sweep: ${parts.join(', ')}` };
155
+ // Only shown when non-zero: a quiet sweep should stay quiet. But a delivery
156
+ // that failed, was deferred, or was declined must never render as `0 notified`
157
+ // and nothing else — that is indistinguishable from "nothing needed sending",
158
+ // which is exactly how a transport that has stopped paging looks like a quiet
159
+ // night (#450).
160
+ if (report.deferred > 0) parts.push(`${report.deferred} deferred`);
161
+ if (report.deferredDelivered > 0) parts.push(`${report.deferredDelivered} deferred-delivered`);
162
+ if (report.failed > 0) parts.push(`${report.failed} FAILED`);
163
+ if (report.noPolicy > 0) parts.push(`${report.noPolicy} no-policy`);
164
+
165
+ const lines = [`alert sweep: ${parts.join(', ')}`];
166
+
167
+ // The reason escalation declined is the single most useful fact when someone
168
+ // asks "why was I not paged", so name it rather than aggregating it away.
169
+ const skipped = Object.entries(report.skipped).sort(([, a], [, b]) => b - a);
170
+ if (skipped.length > 0) {
171
+ lines.push(` not delivered: ${skipped.map(([r, n]) => `${r}×${n}`).join(', ')}`);
172
+ }
173
+ // The error itself, not just a count: the transport is loaded lazily inside
174
+ // the send, so a capability that will not load produces no other record
175
+ // anywhere — nothing ever reaches the transport's own logs.
176
+ for (const failure of report.failures) {
177
+ lines.push(` FAILED ${failure}`);
178
+ }
179
+ if (report.noPolicy > 0) {
180
+ lines.push(
181
+ ` ${report.noPolicy} live alert(s) have no escalation policy — assign one with:\n celilo escalation-policy assign <policy> <monitor>`,
182
+ );
183
+ }
184
+
185
+ return { success: true, message: lines.join('\n') };
156
186
  }
@@ -52,7 +52,7 @@ import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
52
52
  import { getServiceCredentials, listContainerServices } from '../../services/container-service';
53
53
  import { collectFirewallReach } from '../../services/firewall-reach';
54
54
  import { runAllHealthChecks } from '../../services/health-runner';
55
- import { listMachines } from '../../services/machine-pool';
55
+ import { probeMachines } from '../../services/machine-probe';
56
56
  import { parseStoredConfigValue } from '../../services/module-config';
57
57
  import { buildTerraformEnvForModule } from '../../services/terraform-env';
58
58
  import { hasFlag } from '../parser';
@@ -353,43 +353,11 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
353
353
  }),
354
354
  );
355
355
 
356
- // Machines-reachable: SSH probe each pool machine in parallel.
357
- // BatchMode=yes prevents password prompt hangs; ConnectTimeout=5
358
- // bounds wait on unresponsive hosts. The audit's collapse logic
359
- // turns "all unreachable" into a single host/network finding.
360
- const allMachines = await listMachines();
361
- const machineReachableResults: MachineReachableResult[] = await Promise.all(
362
- allMachines.map(async (m): Promise<MachineReachableResult> => {
363
- try {
364
- await execFileAsync(
365
- 'ssh',
366
- [
367
- '-o',
368
- 'BatchMode=yes',
369
- '-o',
370
- 'ConnectTimeout=5',
371
- '-o',
372
- 'StrictHostKeyChecking=no',
373
- '-o',
374
- 'UserKnownHostsFile=/dev/null',
375
- `${m.sshUser}@${m.ipAddress}`,
376
- 'true',
377
- ],
378
- { timeout: 8000 },
379
- );
380
- return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
381
- } catch (err) {
382
- const e = err as { stderr?: string; message?: string };
383
- return {
384
- id: m.id,
385
- hostname: m.hostname,
386
- ipAddress: m.ipAddress,
387
- reachable: false,
388
- message: (e.stderr || e.message || 'unknown error').slice(0, 200),
389
- };
390
- }
391
- }),
392
- );
356
+ // Machines-reachable: probe each pool machine. Shared with the monitor sweep
357
+ // (services/machine-probe.ts) this block used to be a second copy of the
358
+ // same SSH logic, and both copies reported the local management box as
359
+ // unreachable because celilo does not hold an SSH key for itself.
360
+ const machineReachableResults: MachineReachableResult[] = await probeMachines();
393
361
 
394
362
  const migrationsFolder = findMigrationsFolderSafe();
395
363
 
@@ -361,17 +361,6 @@ export async function loadCapabilityFunctions(
361
361
  debugLog(`public_web: using firewall natIp ${firewallNatIp} for internal DNS`);
362
362
  }
363
363
 
364
- // Caddy's external (WAN) IP for the D1 public A record (M1 / #328). Caddy
365
- // learns it from its own firewall.exposeService at install and persists it
366
- // as the `public_ip` hook output (stored as a caddy secret). register_route
367
- // points a new hostname's PUBLIC record here, so it's reachable from one
368
- // deploy — no per-hostname DNAT (shared :443 ingress).
369
- const caddyExternalIp =
370
- typeof providerSecrets.public_ip === 'string' ? providerSecrets.public_ip : undefined;
371
- if (caddyExternalIp) {
372
- debugLog(`public_web: using caddy externalIp ${caddyExternalIp} for public DNS`);
373
- }
374
-
375
364
  // Caddy's configured hostnames — public_web rejects routes for any
376
365
  // hostname not in this list, throwing a structured error that runs
377
366
  // caddy's `managed_hostname` ensure interview. Empty list means
@@ -484,7 +473,6 @@ export async function loadCapabilityFunctions(
484
473
  dnsInternal: result.dns_internal as DnsInternalCapability | undefined,
485
474
  firewallNatIp,
486
475
  dnsRegistrar: result.dns_registrar as DnsRegistrarCapability | undefined,
487
- externalIp: caddyExternalIp,
488
476
  hostnames: caddyHostnames,
489
477
  caddyModuleId: provider.moduleId,
490
478
  dnsManagedDomains,
@@ -12,14 +12,9 @@
12
12
  * as "nothing is wrong".
13
13
  */
14
14
 
15
- import { execFile } from 'node:child_process';
16
- import { promisify } from 'node:util';
17
15
  import { auditMachinesReachable } from '../audit/machines-reachable';
18
- import type { MachineReachableResult } from '../audit/machines-reachable';
19
16
  import type { DriftCategory, DriftFinding } from '../audit/types';
20
- import { listMachines } from '../machine-pool';
21
-
22
- const execFileAsync = promisify(execFile);
17
+ import { probeMachines } from '../machine-probe';
23
18
 
24
19
  /** Categories a monitor can currently schedule. */
25
20
  export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = ['machines_reachable'];
@@ -28,49 +23,6 @@ export function isSchedulableBuiltin(category: string): category is DriftCategor
28
23
  return (SCHEDULABLE_BUILTIN_CHECKS as readonly string[]).includes(category);
29
24
  }
30
25
 
31
- /**
32
- * SSH-probe every pool machine.
33
- *
34
- * `BatchMode=yes` prevents a password prompt from hanging the probe forever,
35
- * and `ConnectTimeout` bounds the wait on an unresponsive host — the exact
36
- * condition this check exists to detect must not be the one that wedges it.
37
- */
38
- async function probeMachines(): Promise<MachineReachableResult[]> {
39
- const machines = await listMachines();
40
- return Promise.all(
41
- machines.map(async (m): Promise<MachineReachableResult> => {
42
- try {
43
- await execFileAsync(
44
- 'ssh',
45
- [
46
- '-o',
47
- 'BatchMode=yes',
48
- '-o',
49
- 'ConnectTimeout=5',
50
- '-o',
51
- 'StrictHostKeyChecking=no',
52
- '-o',
53
- 'UserKnownHostsFile=/dev/null',
54
- `${m.sshUser}@${m.ipAddress}`,
55
- 'true',
56
- ],
57
- { timeout: 8000 },
58
- );
59
- return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
60
- } catch (err) {
61
- const e = err as { stderr?: string; message?: string };
62
- return {
63
- id: m.id,
64
- hostname: m.hostname,
65
- ipAddress: m.ipAddress,
66
- reachable: false,
67
- message: e.stderr?.trim() || e.message || 'SSH probe failed',
68
- };
69
- }
70
- }),
71
- );
72
- }
73
-
74
26
  export async function runBuiltinCheckForMonitor(category: DriftCategory): Promise<DriftFinding[]> {
75
27
  if (category === 'machines_reachable') {
76
28
  return auditMachinesReachable({ results: await probeMachines() });
@@ -226,4 +226,94 @@ describe('runSweep', () => {
226
226
  expect(db.select().from(monitors).get()?.lastRunAt).toEqual(NOW);
227
227
  expect(monitor.lastRunAt).toBeNull();
228
228
  });
229
+
230
+ // A delivery that never happened must be distinguishable from one that was
231
+ // never needed. Both used to render as `notified: 0` and nothing else, which
232
+ // is how a firing-but-undelivered alert became undebuggable (#450).
233
+ describe('undelivered alerts are accounted for, not silently dropped', () => {
234
+ test('an alert with no escalation policy is counted, not skipped in silence', async () => {
235
+ // `notifyDepsFor` returning null IS "nobody is configured to be told" —
236
+ // the default in every other test here, which is why this went unnoticed.
237
+ const report = await runSweep(db, currentMonitors(), deps());
238
+
239
+ expect(liveAlerts()).toHaveLength(1);
240
+ expect(report.notified).toBe(0);
241
+ expect(report.noPolicy).toBe(1);
242
+ });
243
+
244
+ test('escalation declining to notify records WHICH reason', async () => {
245
+ // A fresh alert is inside its grace window, so escalation declines with
246
+ // `within_grace` — a real skip reason reached through the real code path
247
+ // rather than a stubbed outcome.
248
+ const notifyDeps = {
249
+ steps: [{ stepIndex: 0, routeId: 'route-1', delayMinutes: 0 }],
250
+ routes: new Map([['route-1', { id: 'route-1', severityFloor: 'warning', enabled: true }]]),
251
+ routeDetails: new Map([
252
+ [
253
+ 'route-1',
254
+ { id: 'route-1', personId: 'p1', address: '+15550000000', canAck: false } as never,
255
+ ],
256
+ ]),
257
+ quietHoursByPerson: new Map(),
258
+ bypassQuietHours: false,
259
+ transportFor: () => {
260
+ throw new Error('transport must not be reached for a skipped delivery');
261
+ },
262
+ mintToken: () => 'tok',
263
+ now: NOW,
264
+ } as never;
265
+
266
+ const report = await runSweep(
267
+ db,
268
+ currentMonitors(),
269
+ deps({ notifyDepsFor: () => notifyDeps }),
270
+ );
271
+
272
+ expect(report.notified).toBe(0);
273
+ expect(report.noPolicy).toBe(0);
274
+ expect(report.skipped.within_grace).toBe(1);
275
+ });
276
+
277
+ test('a transport that cannot be loaded records the error, not just a count', async () => {
278
+ // The transport is resolved lazily INSIDE the send, so a capability that
279
+ // will not load never reaches the transport's own logs. If the sweep does
280
+ // not carry the message, it exists nowhere.
281
+ const notifyDeps = (now: Date) =>
282
+ ({
283
+ steps: [{ stepIndex: 0, routeId: 'route-1', delayMinutes: 0 }],
284
+ routes: new Map([
285
+ ['route-1', { id: 'route-1', severityFloor: 'warning', enabled: true }],
286
+ ]),
287
+ routeDetails: new Map([
288
+ [
289
+ 'route-1',
290
+ { id: 'route-1', personId: 'p1', address: '+15550000000', canAck: false } as never,
291
+ ],
292
+ ]),
293
+ quietHoursByPerson: new Map(),
294
+ bypassQuietHours: false,
295
+ transportFor: () => {
296
+ throw new Error('does not provide the notification capability');
297
+ },
298
+ mintToken: () => 'tok',
299
+ now,
300
+ }) as never;
301
+
302
+ // First sweep creates the alert; the second is past the grace window, so
303
+ // escalation actually reaches the transport.
304
+ await runSweep(db, currentMonitors(), deps());
305
+ const at = later(20);
306
+ const report = await runSweep(
307
+ db,
308
+ currentMonitors(),
309
+ deps({ notifyDepsFor: () => notifyDeps(at) }, failing, at),
310
+ );
311
+
312
+ expect(report.notified).toBe(0);
313
+ expect(report.failed).toBe(1);
314
+ expect(report.failures).toHaveLength(1);
315
+ expect(report.failures[0]).toContain(PORT_CHECK);
316
+ expect(report.failures[0]).toContain('does not provide the notification capability');
317
+ });
318
+ });
229
319
  });
@@ -61,6 +61,34 @@ export interface SweepReport {
61
61
  /** Messages held over quiet hours and delivered now that the window ended. */
62
62
  deferredDelivered: number;
63
63
  failed: number;
64
+ /**
65
+ * Live alerts nobody is configured to be told about — no escalation policy on
66
+ * the monitor, so `notifyDepsFor` returns null.
67
+ *
68
+ * Counted rather than skipped in silence: "nothing needed sending" and "an
69
+ * alert is firing and no policy points at anyone" are opposite situations that
70
+ * previously rendered identically as `0 notified`.
71
+ */
72
+ noPolicy: number;
73
+ /**
74
+ * Deliveries escalation declined, keyed by its reason (`within_grace`,
75
+ * `no_eligible_route`, …).
76
+ *
77
+ * `notifyAlert` returns the reason precisely so the caller can record it — its
78
+ * own contract says a silent skip is indistinguishable from a bug. Dropping it
79
+ * here is what made a firing-but-undelivered alert undebuggable (#450).
80
+ */
81
+ skipped: Record<string, number>;
82
+ /**
83
+ * Why each failed delivery failed, as `<alert key>: <error>`.
84
+ *
85
+ * A count alone does not answer the only question that matters after a page
86
+ * did not arrive. The transport is loaded lazily *inside* `notifyAlert`'s try
87
+ * block, so "the capability would not load" and "Signal rejected the message"
88
+ * both surface here and nowhere else — there is no daemon-side log for the
89
+ * former, because nothing ever reached the daemon.
90
+ */
91
+ failures: string[];
64
92
  }
65
93
 
66
94
  /**
@@ -85,6 +113,9 @@ export async function runSweep(
85
113
  deferred: 0,
86
114
  deferredDelivered: 0,
87
115
  failed: 0,
116
+ noPolicy: 0,
117
+ skipped: {},
118
+ failures: [],
88
119
  };
89
120
 
90
121
  // 1. Run due monitors.
@@ -162,24 +193,36 @@ export async function runSweep(
162
193
  try {
163
194
  const outcome = await deliverDeferred(alert, route, notifyDeps);
164
195
  if (outcome.result === 'sent') report.deferredDelivered++;
165
- else if (outcome.result === 'failed') report.failed++;
166
- } catch {
196
+ else if (outcome.result === 'failed') {
197
+ report.failed++;
198
+ report.failures.push(`${alert.key} (deferred): ${outcome.error}`);
199
+ }
200
+ } catch (error) {
167
201
  report.failed++;
202
+ report.failures.push(
203
+ `${alert.key} (deferred): ${error instanceof Error ? error.message : String(error)}`,
204
+ );
168
205
  }
169
206
  }
170
207
 
171
208
  // 5. Notify. Re-read: the steps above changed state under us.
172
209
  for (const alert of loadAllLiveAlerts(db)) {
173
210
  const notifyDeps = deps.notifyDepsFor(alert);
174
- if (!notifyDeps) continue;
211
+ if (!notifyDeps) {
212
+ report.noPolicy++;
213
+ continue;
214
+ }
175
215
 
176
216
  let outcome: NotifyOutcome;
177
217
  try {
178
218
  outcome = await notifyAlert(alert, notifyDeps);
179
- } catch {
219
+ } catch (error) {
180
220
  // notifyAlert already converts transport errors into a `failed` outcome;
181
221
  // reaching here means something above the transport broke.
182
222
  report.failed++;
223
+ report.failures.push(
224
+ `${alert.key}: ${error instanceof Error ? error.message : String(error)}`,
225
+ );
183
226
  continue;
184
227
  }
185
228
 
@@ -197,6 +240,9 @@ export async function runSweep(
197
240
  report.deferred++;
198
241
  } else if (outcome.result === 'failed') {
199
242
  report.failed++;
243
+ report.failures.push(`${alert.key}: ${outcome.error}`);
244
+ } else if (outcome.result === 'skipped') {
245
+ report.skipped[outcome.reason] = (report.skipped[outcome.reason] ?? 0) + 1;
200
246
  }
201
247
  }
202
248
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The local management box must never be reported unreachable.
3
+ *
4
+ * celilo runs there as the `celilo` user and deliberately holds no SSH key for
5
+ * itself, so probing `root@127.0.0.1` fails with `Permission denied (publickey)`
6
+ * on a completely healthy host. That produced a permanently firing
7
+ * `machines_reachable` alert against celilo-mgr — and that monitor is
8
+ * unsuppressible by design, so nothing could explain it away.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { auditMachinesReachable } from './audit/machines-reachable';
13
+ import { LOCAL_MACHINE_IP } from './ssh-key-manager';
14
+
15
+ describe('local machine reachability', () => {
16
+ test('LOCAL_MACHINE_IP is the loopback address the machine pool records', () => {
17
+ // The probe's skip is keyed on this exact value, so pin it.
18
+ expect(LOCAL_MACHINE_IP).toBe('127.0.0.1');
19
+ });
20
+
21
+ test('a reachable local box produces no finding', async () => {
22
+ const findings = await auditMachinesReachable({
23
+ results: [
24
+ { id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
25
+ { id: 'briq', hostname: 'briq', ipAddress: '192.168.0.254', reachable: true },
26
+ ],
27
+ });
28
+
29
+ expect(findings).toEqual([]);
30
+ });
31
+
32
+ test('an unreachable remote machine still produces a finding', async () => {
33
+ // The skip must not blunt the check for the machines it exists to watch.
34
+ const findings = await auditMachinesReachable({
35
+ results: [
36
+ { id: 'mgr', hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
37
+ {
38
+ id: 'briq',
39
+ hostname: 'briq',
40
+ ipAddress: '192.168.0.254',
41
+ reachable: false,
42
+ message: 'connection refused',
43
+ },
44
+ ],
45
+ });
46
+
47
+ expect(findings).toHaveLength(1);
48
+ expect(findings[0].message).toContain('briq');
49
+ });
50
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Reachability probe for the machine pool.
3
+ *
4
+ * One implementation, deliberately. This SSH probe previously existed twice —
5
+ * once in `alerting/builtin-source.ts` for the monitor sweep and once in
6
+ * `cli/commands/system-audit.ts` for `celilo system audit` — as identical
7
+ * copy-pasted blocks. Both carried the same bug, and fixing one would have left
8
+ * the other reporting the management server as unreachable forever.
9
+ */
10
+
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
13
+ import type { MachineReachableResult } from './audit/machines-reachable';
14
+ import { listMachines } from './machine-pool';
15
+ import { LOCAL_MACHINE_IP } from './ssh-key-manager';
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /**
20
+ * Probe every machine in the pool.
21
+ *
22
+ * `BatchMode=yes` prevents a password prompt from hanging the probe forever,
23
+ * and `ConnectTimeout` bounds the wait on an unresponsive host — the exact
24
+ * condition this check exists to detect must not be the one that wedges it.
25
+ *
26
+ * The local management box is reported reachable WITHOUT probing it. celilo
27
+ * runs there as the `celilo` user and deliberately does not materialize an SSH
28
+ * key for itself, so `ssh root@127.0.0.1` fails with `Permission denied
29
+ * (publickey)` on a perfectly healthy host. Left unhandled that produced a
30
+ * permanently firing `machines_reachable` alert against celilo-mgr — and since
31
+ * that monitor is unsuppressible by design, nothing could explain it away.
32
+ *
33
+ * The check is also meaningless there: if the box running this code were
34
+ * unreachable, this code would not be running.
35
+ */
36
+ export async function probeMachines(): Promise<MachineReachableResult[]> {
37
+ const machines = await listMachines();
38
+ return Promise.all(
39
+ machines.map(async (m): Promise<MachineReachableResult> => {
40
+ if (m.ipAddress === LOCAL_MACHINE_IP) {
41
+ return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
42
+ }
43
+ try {
44
+ await execFileAsync(
45
+ 'ssh',
46
+ [
47
+ '-o',
48
+ 'BatchMode=yes',
49
+ '-o',
50
+ 'ConnectTimeout=5',
51
+ '-o',
52
+ 'StrictHostKeyChecking=no',
53
+ '-o',
54
+ 'UserKnownHostsFile=/dev/null',
55
+ `${m.sshUser}@${m.ipAddress}`,
56
+ 'true',
57
+ ],
58
+ { timeout: 8000 },
59
+ );
60
+ return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
61
+ } catch (err) {
62
+ const e = err as { stderr?: string; message?: string };
63
+ return {
64
+ id: m.id,
65
+ hostname: m.hostname,
66
+ ipAddress: m.ipAddress,
67
+ reachable: false,
68
+ message: e.stderr?.trim() || e.message || 'SSH probe failed',
69
+ };
70
+ }
71
+ }),
72
+ );
73
+ }