@celilo/cli 0.26.0 → 0.27.0

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 (41) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/package.json +3 -3
  4. package/src/__integration__/container-services-cli.integration.test.ts +0 -4
  5. package/src/ansible/dependencies.test.ts +233 -289
  6. package/src/ansible/dependencies.ts +151 -83
  7. package/src/cli/commands/alerts-sweep.ts +14 -3
  8. package/src/cli/commands/machine-add.ts +0 -1
  9. package/src/cli/commands/machine-list.ts +10 -4
  10. package/src/cli/commands/machine-remove.ts +13 -7
  11. package/src/cli/commands/machine-status.ts +9 -11
  12. package/src/db/schema.ts +6 -4
  13. package/src/hooks/capability-loader.ts +6 -0
  14. package/src/hooks/define-hook.test.ts +4 -0
  15. package/src/hooks/types.ts +2 -1
  16. package/src/infrastructure/property-extractor.test.ts +0 -2
  17. package/src/manifest/contracts/v1.ts +19 -0
  18. package/src/manifest/schema.ts +1 -0
  19. package/src/services/alerting/inbound.test.ts +66 -0
  20. package/src/services/alerting/inbound.ts +35 -2
  21. package/src/services/alerting/sweep-runner.test.ts +5 -1
  22. package/src/services/alerting/sweep-runner.ts +14 -8
  23. package/src/services/aspect-runner.test.ts +0 -1
  24. package/src/services/audit/machines-reachable.test.ts +67 -8
  25. package/src/services/audit/machines-reachable.ts +18 -4
  26. package/src/services/deployed-systems.ts +31 -0
  27. package/src/services/fleet-checks.test.ts +232 -0
  28. package/src/services/fleet-checks.ts +275 -3
  29. package/src/services/infrastructure-selector.test.ts +0 -7
  30. package/src/services/infrastructure-selector.ts +24 -25
  31. package/src/services/infrastructure-variable-resolver.test.ts +0 -6
  32. package/src/services/infrastructure-variable-resolver.ts +0 -3
  33. package/src/services/machine-pool.test.ts +53 -85
  34. package/src/services/machine-pool.ts +68 -84
  35. package/src/services/machine-probe.test.ts +3 -4
  36. package/src/services/machine-probe.ts +2 -3
  37. package/src/services/module-deploy.ts +17 -39
  38. package/src/services/module-operations.test.ts +72 -1
  39. package/src/services/module-operations.ts +49 -10
  40. package/src/services/ssh-key-manager.test.ts +0 -10
  41. package/src/types/infrastructure.ts +11 -1
@@ -410,3 +410,69 @@ describe('reply verbs', () => {
410
410
  expect(parseInbound('K7QM2X akc')).toEqual({ kind: 'unrecognised' });
411
411
  });
412
412
  });
413
+
414
+ /**
415
+ * celilo#500. The accepted grammar, in one table, because it was previously
416
+ * knowable only by reading the parser.
417
+ *
418
+ * The headline case is `reply K7QM2X`: every page ends with that literal line,
419
+ * and an operator who did exactly what it said was told `unrecognised` while
420
+ * the alert kept firing. Two consecutive real operator replies were lost this
421
+ * way. The instruction the system gives was the one input it refused.
422
+ *
423
+ * The trailing-period case is the same shape of unkindness. iOS inserts a full
424
+ * stop on a double space by default, and a one-word message is precisely where
425
+ * that fires.
426
+ *
427
+ * Being liberal here costs nothing: authentication is the token plus the sender
428
+ * check (see the header of inbound.ts), never punctuation strictness.
429
+ */
430
+ describe('the grammar accepts what a phone actually sends (#500)', () => {
431
+ const ACKNOWLEDGES: Array<[string, string]> = [
432
+ ['K7QM2X', 'the bare token'],
433
+ ['k7qm2x', 'lower case'],
434
+ ['K7-QM2X', 'a separator the operator kept'],
435
+ ['K7QM2X ack', 'a trailing verb'],
436
+ ['ack K7QM2X', 'the natural spoken order'],
437
+ ['reply K7QM2X', 'WHAT THE PAGE ITSELF INSTRUCTS'],
438
+ ['K7QM2X.', 'iOS double-space autocorrect'],
439
+ ['Ack K7QM2X.', 'both at once, capitalised'],
440
+ ['reply k7-qm2x.', 'everything at once'],
441
+ ];
442
+
443
+ test.each(ACKNOWLEDGES)('%p acknowledges (%s)', (body) => {
444
+ expect(parseInbound(body)).toEqual({ kind: 'ack', token: 'K7QM2X' });
445
+ });
446
+
447
+ /**
448
+ * The guard that must survive. `<token> resolve` silently acknowledging an
449
+ * alert the operator meant to escalate is the worst outcome available here,
450
+ * so widening the grammar must not widen THIS.
451
+ */
452
+ const REFUSED: Array<[string, string]> = [
453
+ ['K7QM2X resolve', 'a verb celilo does not implement'],
454
+ ['K7QM2X silence 2h', 'a request with an argument'],
455
+ ['K7QM2X akc', 'a typo, not guessed at'],
456
+ ['reply K7QM2X resolve', 'the new verb does not smuggle a sentence through'],
457
+ ['what is going on', 'no token-shaped word anywhere'],
458
+ ['K7QM2XY', 'too long to be a token'],
459
+ ['reply', 'the verb alone names nothing'],
460
+ ];
461
+
462
+ test.each(REFUSED)('%p is refused (%s)', (body) => {
463
+ expect(parseInbound(body)).toEqual({ kind: 'unrecognised' });
464
+ });
465
+
466
+ /**
467
+ * The instruction and the parser are pinned to the same literal from both
468
+ * sides, so they cannot drift apart again. `composeBody` in
469
+ * `modules/signal/scripts/notification.ts` emits `\n\nreply <token>`, and
470
+ * `signal-rpc.test.ts:273` asserts that exact output. This asserts the parser
471
+ * accepts it. Change the wording and one of the two goes red.
472
+ */
473
+ test('the exact line composeBody emits is accepted', () => {
474
+ const asSent = 'caddy is down\n\nreply K7QM2X'.split('\n\n')[1];
475
+ expect(asSent).toBe('reply K7QM2X');
476
+ expect(parseInbound(asSent)).toEqual({ kind: 'ack', token: 'K7QM2X' });
477
+ });
478
+ });
@@ -65,6 +65,33 @@ export type InboundIntent =
65
65
  | { kind: 'unrecognised' };
66
66
 
67
67
  const ACK_VERB = /^(ack|ok|k|👍)$/iu;
68
+
69
+ /**
70
+ * A verb that may only LEAD, and does not itself acknowledge.
71
+ *
72
+ * Every page ends with the literal line `reply <TOKEN>` (`composeBody` in
73
+ * modules/signal/scripts/notification.ts), and an operator who did exactly that
74
+ * was told `unrecognised` while the alert kept firing — the instruction the
75
+ * system gives was the one input it refused (#500). Two consecutive real
76
+ * operator replies were lost to it.
77
+ *
78
+ * Distinct from `ACK_VERB` on purpose: an ack synonym standing alone IS an
79
+ * acknowledgement (`bare_ack`), whereas `reply` alone is someone echoing the
80
+ * instruction without the token, which names nothing and stays unrecognised.
81
+ */
82
+ const LEADING_VERB = /^reply$/i;
83
+
84
+ /**
85
+ * Punctuation a phone added that the operator did not type.
86
+ *
87
+ * iOS turns a double space into a full stop by default, and a one-word reply is
88
+ * exactly where that fires. Stripping it costs nothing: authentication here is
89
+ * the token plus the sender check (see the header), never punctuation
90
+ * strictness.
91
+ */
92
+ function stripTrailingPunctuation(word: string): string {
93
+ return word.replace(/[.,!?;:]+$/u, '');
94
+ }
68
95
  /**
69
96
  * One to six characters of the token alphabet. Not anchored to the full
70
97
  * length: a prefix is legal input, and whether it identifies something is a
@@ -76,9 +103,15 @@ export function parseInbound(body: string): InboundIntent {
76
103
  const words = body.trim().split(/\s+/).filter(Boolean);
77
104
  if (words.length === 0) return { kind: 'unrecognised' };
78
105
 
106
+ // Drop a leading `reply` — instruction-echo, not content. Removed before
107
+ // anything else so the rest of the grammar is entirely unaffected by whether
108
+ // the operator included it.
109
+ if (LEADING_VERB.test(words[0])) words.shift();
110
+ if (words.length === 0) return { kind: 'unrecognised' };
111
+
79
112
  // Strip ack synonyms wherever they appear. What remains must be the token,
80
113
  // or nothing at all.
81
- const remainder = words.filter((word) => !ACK_VERB.test(word));
114
+ const remainder = words.filter((word) => !ACK_VERB.test(stripTrailingPunctuation(word)));
82
115
  if (remainder.length === 0) return { kind: 'bare_ack' };
83
116
 
84
117
  // More than one non-verb word is not a token with politeness around it, it
@@ -89,7 +122,7 @@ export function parseInbound(body: string): InboundIntent {
89
122
  // so is strictly better than doing the wrong thing quietly.
90
123
  if (remainder.length > 1) return { kind: 'unrecognised' };
91
124
 
92
- const token = normaliseToken(remainder[0]);
125
+ const token = normaliseToken(stripTrailingPunctuation(remainder[0]));
93
126
  if (!TOKEN_SHAPE.test(token)) return { kind: 'unrecognised' };
94
127
 
95
128
  return { kind: 'ack', token };
@@ -329,7 +329,11 @@ describe('runSweep', () => {
329
329
 
330
330
  expect(report.notified).toBe(0);
331
331
  expect(report.noPolicy).toEqual([]);
332
- expect(report.skipped.within_grace).toBe(1);
332
+ // The alert is named, not just counted — an operator asking "why was I
333
+ // not paged" is asking about a specific alert (#450).
334
+ expect(report.skipped).toEqual([
335
+ { alertKey: 'module:homebridge/check:port', reason: 'within_grace' },
336
+ ]);
333
337
  });
334
338
 
335
339
  test('a transport that cannot be loaded records the error, not just a count', async () => {
@@ -78,14 +78,20 @@ export interface SweepReport {
78
78
  */
79
79
  noPolicy: { alertKey: string; monitor: string }[];
80
80
  /**
81
- * Deliveries escalation declined, keyed by its reason (`within_grace`,
82
- * `no_eligible_route`, …).
81
+ * Deliveries escalation declined the reason AND the alert it applies to.
83
82
  *
84
- * `notifyAlert` returns the reason precisely so the caller can record it its
85
- * own contract says a silent skip is indistinguishable from a bug. Dropping it
86
- * here is what made a firing-but-undelivered alert undebuggable (#450).
83
+ * `notifyAlert` returns the reason precisely so the caller can record it: its
84
+ * own contract says a silent skip is indistinguishable from a bug, and
85
+ * dropping it here is what made a firing-but-undelivered alert undebuggable
86
+ * (#450).
87
+ *
88
+ * The alert key is carried too, because a bare `within_grace×2` still does not
89
+ * answer "why was I not paged" for the alert the operator is actually looking
90
+ * at — they cannot tell which of their live alerts each count refers to. Same
91
+ * reasoning `noPolicy` already applies, and the same failure it was fixing.
92
+ * Counts are derived at render time so there is one source for both.
87
93
  */
88
- skipped: Record<string, number>;
94
+ skipped: { alertKey: string; reason: string }[];
89
95
  /**
90
96
  * Why each failed delivery failed, as `<alert key>: <error>`.
91
97
  *
@@ -121,7 +127,7 @@ export async function runSweep(
121
127
  deferredDelivered: 0,
122
128
  failed: 0,
123
129
  noPolicy: [],
124
- skipped: {},
130
+ skipped: [],
125
131
  failures: [],
126
132
  };
127
133
 
@@ -282,7 +288,7 @@ export async function runSweep(
282
288
  report.failed++;
283
289
  report.failures.push(`${alert.key}: ${outcome.error}`);
284
290
  } else if (outcome.result === 'skipped') {
285
- report.skipped[outcome.reason] = (report.skipped[outcome.reason] ?? 0) + 1;
291
+ report.skipped.push({ alertKey: alert.key, reason: outcome.reason });
286
292
  }
287
293
  }
288
294
 
@@ -54,7 +54,6 @@ async function seedMachine(opts: {
54
54
  sshUser: 'root',
55
55
  sshKey: 'ssh-key-placeholder',
56
56
  hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 5, arch: 'amd64' },
57
- assignedModuleIds: [],
58
57
  earmarkedModule: null,
59
58
  });
60
59
  if (opts.apiOnly) {
@@ -1,12 +1,14 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { failingKeysFromFindings } from '../alerting/builtin-monitors';
3
+ import { ancestorKeysFor, machineAlertKey } from '../alerting/suppression';
2
4
  import { auditMachinesReachable } from './machines-reachable';
3
5
 
4
6
  describe('auditMachinesReachable', () => {
5
7
  test('no findings when every machine is reachable', async () => {
6
8
  const result = await auditMachinesReachable({
7
9
  results: [
8
- { id: 'm1', hostname: 'iot', ipAddress: '10.0.0.10', reachable: true },
9
- { id: 'm2', hostname: 'dns-ext', ipAddress: '203.0.113.5', reachable: true },
10
+ { hostname: 'iot', ipAddress: '10.0.0.10', reachable: true },
11
+ { hostname: 'dns-ext', ipAddress: '203.0.113.5', reachable: true },
10
12
  ],
11
13
  });
12
14
  expect(result).toEqual([]);
@@ -15,9 +17,8 @@ describe('auditMachinesReachable', () => {
15
17
  test('per-machine drift finding for one unreachable host', async () => {
16
18
  const result = await auditMachinesReachable({
17
19
  results: [
18
- { id: 'm1', hostname: 'iot', ipAddress: '10.0.0.10', reachable: true },
20
+ { hostname: 'iot', ipAddress: '10.0.0.10', reachable: true },
19
21
  {
20
- id: 'm2',
21
22
  hostname: 'dns-ext',
22
23
  ipAddress: '203.0.113.5',
23
24
  reachable: false,
@@ -30,7 +31,8 @@ describe('auditMachinesReachable', () => {
30
31
  category: 'machines_reachable',
31
32
  severity: 'drift',
32
33
  code: 'machine_unreachable',
33
- subject: 'm2',
34
+ // Hostname, not the DB UUID — this assertion encoded the #596 bug.
35
+ subject: 'dns-ext',
34
36
  actionable: false,
35
37
  });
36
38
  expect(result[0].message).toContain('dns-ext');
@@ -42,14 +44,12 @@ describe('auditMachinesReachable', () => {
42
44
  const result = await auditMachinesReachable({
43
45
  results: [
44
46
  {
45
- id: 'm1',
46
47
  hostname: 'iot',
47
48
  ipAddress: '10.0.0.10',
48
49
  reachable: false,
49
50
  message: 'host down',
50
51
  },
51
52
  {
52
- id: 'm2',
53
53
  hostname: 'dns-ext',
54
54
  ipAddress: '203.0.113.5',
55
55
  reachable: false,
@@ -68,12 +68,71 @@ describe('auditMachinesReachable', () => {
68
68
  expect(result[0].message).toContain('All 2 machines unreachable');
69
69
  });
70
70
 
71
+ /**
72
+ * celilo#596. The finding's subject becomes the alert key, and suppression
73
+ * resolves a machine's ancestor key from its HOSTNAME. Subjecting on the DB
74
+ * UUID produced a key nothing could ever match, so an unreachable machine
75
+ * suppressed nothing and every module on it paged independently — the exact
76
+ * cascade suppression exists to prevent.
77
+ *
78
+ * The prefix-only assertion in `e2e/tests/alert-ack-return-leg.test.ts`
79
+ * (`toContain('builtin:machines_reachable/machine:')`) passes for either
80
+ * value, which is why this survived. These assert the WHOLE key.
81
+ */
82
+ describe('the alert key is one suppression can match (#596)', () => {
83
+ test('producer and consumer derive the same key', async () => {
84
+ const findings = await auditMachinesReachable({
85
+ results: [
86
+ { hostname: 'iot', ipAddress: '10.0.0.10', reachable: true },
87
+ {
88
+ hostname: 'dns-ext',
89
+ ipAddress: '203.0.113.5',
90
+ reachable: false,
91
+ message: 'Connection timed out',
92
+ },
93
+ ],
94
+ });
95
+
96
+ const keys = failingKeysFromFindings('machines_reachable', findings, 'warning').map(
97
+ (k) => k.key,
98
+ );
99
+
100
+ expect(keys).toEqual(['builtin:machines_reachable/machine:dns-ext']);
101
+ // The identity that actually matters: asserted against the consumer's own
102
+ // constructor rather than a second literal, so the two cannot drift apart
103
+ // while both still look right.
104
+ expect(keys[0]).toBe(machineAlertKey('dns-ext'));
105
+ });
106
+
107
+ test('an unreachable machine suppresses a module deployed on it', async () => {
108
+ const findings = await auditMachinesReachable({
109
+ results: [
110
+ { hostname: 'iot', ipAddress: '10.0.0.10', reachable: true },
111
+ { hostname: 'dns-ext', ipAddress: '203.0.113.5', reachable: false, message: 'down' },
112
+ ],
113
+ });
114
+ const firing = failingKeysFromFindings('machines_reachable', findings, 'warning').map(
115
+ (k) => k.key,
116
+ );
117
+
118
+ const ancestors = ancestorKeysFor('module:homebridge/check:service_running', {
119
+ moduleSystems: [
120
+ { moduleId: 'homebridge', hostname: 'dns-ext', zone: 'internal', infraType: 'machine' },
121
+ ],
122
+ zoneProviders: [],
123
+ });
124
+
125
+ // The behaviour suppression.ts documents: the machine's own alert is what
126
+ // explains the module's. Before the fix the intersection was empty.
127
+ expect(ancestors.some((a) => firing.includes(a))).toBe(true);
128
+ });
129
+ });
130
+
71
131
  test('does NOT collapse when only one machine is in the pool', async () => {
72
132
  // A single machine failing is per-machine, not a system-wide signal.
73
133
  const result = await auditMachinesReachable({
74
134
  results: [
75
135
  {
76
- id: 'm1',
77
136
  hostname: 'iot',
78
137
  ipAddress: '10.0.0.10',
79
138
  reachable: false,
@@ -19,9 +19,21 @@
19
19
  import type { DriftFinding } from './types';
20
20
 
21
21
  export interface MachineReachableResult {
22
- /** Stable machine ID (UUID in the DB). */
23
- id: string;
24
- /** Hostname for the finding message. */
22
+ /**
23
+ * User-facing hostname, and the identifier every finding here is keyed by.
24
+ *
25
+ * NOT the machine's UUID. Suppression resolves a machine's ancestor key from
26
+ * its hostname (`machineAlertKey` in alerting/suppression.ts), so a finding
27
+ * subjected on the UUID produces an alert key suppression can never match —
28
+ * an unreachable machine then suppresses nothing and every module on it pages
29
+ * independently, which is the cascade suppression exists to prevent. That was
30
+ * celilo#596, filed against this check and fixed here; `disk-space.ts` cites
31
+ * it as the reason it keys on hostname too.
32
+ *
33
+ * The UUID used to be carried alongside as `id`. It is deleted rather than
34
+ * left unused (Rule 3.9): its only reader was the defect, and a field kept
35
+ * "just in case" is what the next subject line would reach for.
36
+ */
25
37
  hostname: string;
26
38
  ipAddress: string;
27
39
  /** True if SSH probe succeeded. */
@@ -79,7 +91,9 @@ export async function auditMachinesReachable(
79
91
  ].join('\n'),
80
92
  // Multi-step / interactive; not a one-shot.
81
93
  actionable: false,
82
- subject: r.id,
94
+ // Hostname, so `machineAlertKey` can match this — see the note on
95
+ // MachineReachableResult.hostname (celilo#596).
96
+ subject: r.hostname,
83
97
  });
84
98
  }
85
99
 
@@ -99,6 +99,37 @@ export function getProvisionedSystems(db: DbClient): ProvisionedSystem[] {
99
99
  .sort((a, b) => (a.vmid ?? 0) - (b.vmid ?? 0));
100
100
  }
101
101
 
102
+ /** One deployed system, with the module on it — the whole fleet, both infra types. */
103
+ export interface ModulePlacementRow {
104
+ moduleId: string;
105
+ hostname: string;
106
+ infraType: 'machine' | 'container_service';
107
+ vmid: number | null;
108
+ }
109
+
110
+ /**
111
+ * Every module deployment across the fleet, machine-pool and container alike.
112
+ *
113
+ * The complement to `getModuleSystems` (one module) and `getProvisionedSystems`
114
+ * (containers only). Added for the doctor's host-liveness check (celilo#728),
115
+ * which has to ask about EVERY host something is running on — the defect there
116
+ * was precisely that no fleet-wide view of "what runs where" was being
117
+ * consulted.
118
+ */
119
+ export function listAllModuleSystems(db: DbClient): ModulePlacementRow[] {
120
+ return db
121
+ .select()
122
+ .from(moduleSystems)
123
+ .all()
124
+ .map((r) => ({
125
+ moduleId: r.moduleId,
126
+ hostname: r.hostname,
127
+ infraType: r.infraType,
128
+ vmid: r.vmid ?? null,
129
+ }))
130
+ .sort((a, b) => a.moduleId.localeCompare(b.moduleId));
131
+ }
132
+
102
133
  /**
103
134
  * All container_service systems (Proxmox LXCs, droplets, …) whose zone is in
104
135
  * `zones`, across every module — the LXC complement to machine-pool's
@@ -18,14 +18,17 @@ import { ensureInboundSubscriber, ensureSweepSubscriber } from './alerting/monit
18
18
  import { ensureBackupSweepSubscriber } from './backup-sweep';
19
19
  import { getDaemonUnitPath } from './events-daemon';
20
20
  import {
21
+ type HostLivenessInputs,
21
22
  checkCapabilityProviders,
22
23
  checkControlPlaneNetwork,
23
24
  checkDispatcher,
25
+ checkHostLiveness,
24
26
  checkSchemaDrift,
25
27
  checkServiceDns,
26
28
  checkSubscribers,
27
29
  describeCapabilityProblem,
28
30
  findBrokenCapabilityDerivations,
31
+ runFleetChecks,
29
32
  } from './fleet-checks';
30
33
  import { ensureOperationsSweepSubscriber } from './module-operations';
31
34
 
@@ -766,3 +769,232 @@ describe('checkControlPlaneNetwork', () => {
766
769
  expect(finding.summary).toContain('192.168.0.0/24');
767
770
  });
768
771
  });
772
+
773
+ /**
774
+ * celilo#728. `system doctor` reported "OK with warnings" while a Proxmox node
775
+ * was OFFLINE with `celilo-apt-repo` and `lunacycle` deployed on it. Its two
776
+ * warnings were about the dispatcher and a false-positive subscriber drift —
777
+ * neither related. The node's status was already in `proxmox node list`; doctor
778
+ * never consulted it, and the condition surfaced only because a release run got
779
+ * an HTTP 502 from the apt repo that happened to live there.
780
+ */
781
+ describe('checkHostLiveness', () => {
782
+ const inputs = (over: Partial<HostLivenessInputs> = {}): HostLivenessInputs => ({
783
+ placements: [],
784
+ machines: [],
785
+ nodes: [],
786
+ guestNodes: [],
787
+ ...over,
788
+ });
789
+
790
+ it('reproduces #728: an offline node hosting modules is a FAILURE naming both', () => {
791
+ const finding = checkHostLiveness(
792
+ inputs({
793
+ placements: [
794
+ {
795
+ moduleId: 'celilo-apt-repo',
796
+ hostname: 'apt',
797
+ infraType: 'container_service',
798
+ vmid: 205,
799
+ },
800
+ { moduleId: 'lunacycle', hostname: 'luna', infraType: 'container_service', vmid: 202 },
801
+ { moduleId: 'caddy', hostname: 'caddy', infraType: 'container_service', vmid: 301 },
802
+ ],
803
+ nodes: [
804
+ { node: 'node2', online: false },
805
+ { node: 'node3', online: true },
806
+ ],
807
+ guestNodes: [
808
+ { vmid: 205, node: 'node2' },
809
+ { vmid: 202, node: 'node2' },
810
+ { vmid: 301, node: 'node3' },
811
+ ],
812
+ }),
813
+ );
814
+
815
+ expect(finding.status).toBe('fail');
816
+ expect(finding.summary).toContain('node2');
817
+ expect(finding.summary).toContain('2 module(s)');
818
+ // Both the host AND what it takes down with it — the thing doctor could not say.
819
+ expect(finding.detail.join('\n')).toContain('DOWN node2: celilo-apt-repo, lunacycle');
820
+ // The healthy node is not implicated.
821
+ expect(finding.summary).not.toContain('node3');
822
+ expect(finding.remediation).toBeTruthy();
823
+ });
824
+
825
+ it('covers the machine pool, not only container-service nodes', () => {
826
+ const finding = checkHostLiveness(
827
+ inputs({
828
+ placements: [{ moduleId: 'homebridge', hostname: 'iot', infraType: 'machine', vmid: null }],
829
+ machines: [{ hostname: 'iot', reachable: false }],
830
+ }),
831
+ );
832
+ expect(finding.status).toBe('fail');
833
+ expect(finding.detail.join('\n')).toContain('DOWN iot: homebridge');
834
+ });
835
+
836
+ it('stays quiet when every host is up — no new permanent warning', () => {
837
+ const finding = checkHostLiveness(
838
+ inputs({
839
+ placements: [
840
+ { moduleId: 'homebridge', hostname: 'iot', infraType: 'machine', vmid: null },
841
+ { moduleId: 'caddy', hostname: 'caddy', infraType: 'container_service', vmid: 301 },
842
+ ],
843
+ machines: [{ hostname: 'iot', reachable: true }],
844
+ nodes: [{ node: 'node3', online: true }],
845
+ guestNodes: [{ vmid: 301, node: 'node3' }],
846
+ }),
847
+ );
848
+ expect(finding.status).toBe('ok');
849
+ expect(finding.detail).toEqual([]);
850
+ expect(finding.summary).toBe('2 host(s) up');
851
+ });
852
+
853
+ /**
854
+ * The absent-vs-empty rule, one level down: "the cluster did not answer" must
855
+ * never read as "every node is healthy".
856
+ *
857
+ * It WARNS rather than passing quietly. A cluster that will not answer its
858
+ * own API is not evidence of health, and the failure to answer may be the
859
+ * outage this check exists to catch — so reporting it as ok-with-a-note would
860
+ * rebuild #728 one level down. The warning is safe to have precisely because
861
+ * it is not permanent: a machine-only fleet produces no unverified hosts at
862
+ * all, every placement resolving through the probe.
863
+ */
864
+ it('a host celilo tried and failed to verify WARNS, with the reason', () => {
865
+ const finding = checkHostLiveness(
866
+ inputs({
867
+ placements: [
868
+ { moduleId: 'lunacycle', hostname: 'luna', infraType: 'container_service', vmid: 202 },
869
+ ],
870
+ // The cluster was unreachable, so nothing came back about vmid 202.
871
+ }),
872
+ );
873
+ expect(finding.status).toBe('warn');
874
+ expect(finding.detail.join('\n')).toContain('lunacycle');
875
+ expect(finding.detail.join('\n')).toContain("not present in the cluster's resources");
876
+ expect(finding.remediation).toBeTruthy();
877
+ });
878
+
879
+ it('an unprobed machine warns, never assumed reachable', () => {
880
+ const finding = checkHostLiveness(
881
+ inputs({
882
+ placements: [{ moduleId: 'homebridge', hostname: 'iot', infraType: 'machine', vmid: null }],
883
+ machines: [{ hostname: 'somethingelse', reachable: true }],
884
+ }),
885
+ );
886
+ expect(finding.status).toBe('warn');
887
+ expect(finding.detail.join('\n')).toContain('no probe result for this machine');
888
+ });
889
+
890
+ /**
891
+ * The reason is per host, not one blanket sentence: "could not verify" tells
892
+ * an operator nothing about whether to go and look at a cluster, a machine,
893
+ * or a stale row.
894
+ */
895
+ it('names a different reason for each way verification can fail', () => {
896
+ const finding = checkHostLiveness(
897
+ inputs({
898
+ placements: [
899
+ { moduleId: 'homebridge', hostname: 'iot', infraType: 'machine', vmid: null },
900
+ { moduleId: 'droplet-app', hostname: 'vps', infraType: 'container_service', vmid: null },
901
+ ],
902
+ }),
903
+ );
904
+ expect(finding.status).toBe('warn');
905
+ const detail = finding.detail.join('\n');
906
+ expect(detail).toContain('no probe result for this machine');
907
+ // The shape a non-Proxmox provider takes today, until celilo can read it.
908
+ expect(detail).toContain('no liveness source for this provider');
909
+ });
910
+
911
+ it('a down host still fails when a sibling host is unverified', () => {
912
+ const finding = checkHostLiveness(
913
+ inputs({
914
+ placements: [
915
+ { moduleId: 'homebridge', hostname: 'iot', infraType: 'machine', vmid: null },
916
+ { moduleId: 'lunacycle', hostname: 'luna', infraType: 'container_service', vmid: 202 },
917
+ ],
918
+ machines: [{ hostname: 'iot', reachable: false }],
919
+ }),
920
+ );
921
+ expect(finding.status).toBe('fail');
922
+ expect(finding.detail.join('\n')).toContain('DOWN iot: homebridge');
923
+ expect(finding.detail.join('\n')).toContain('unverified');
924
+ });
925
+
926
+ it('is silent on a fleet with nothing deployed', () => {
927
+ const finding = checkHostLiveness(inputs());
928
+ expect(finding.status).toBe('ok');
929
+ expect(finding.detail).toEqual([]);
930
+ });
931
+ });
932
+
933
+ /**
934
+ * The wiring, which is what actually fixes celilo#728. A pure check nothing
935
+ * calls changes nothing: on `main` today `runFleetChecks` returns no
936
+ * host-liveness finding at all, which is precisely why doctor said
937
+ * "OK with warnings" over an offline node.
938
+ */
939
+ describe('runFleetChecks includes host liveness (#728)', () => {
940
+ let dir: string;
941
+ let db: DbClient;
942
+ let bus: Bus;
943
+
944
+ beforeEach(async () => {
945
+ dir = mkdtempSync(join(tmpdir(), 'fleet-liveness-'));
946
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
947
+ process.env.EVENT_BUS_DB = join(dir, 'events.db');
948
+ db = await setupTestDatabase(join(dir, 'celilo.db'));
949
+ bus = openBus({ dbPath: join(dir, 'events.db'), events: defineEvents({}) });
950
+ });
951
+
952
+ afterEach(() => {
953
+ bus.close();
954
+ db.$client.close();
955
+ process.env.CELILO_DB_PATH = undefined;
956
+ process.env.EVENT_BUS_DB = undefined;
957
+ try {
958
+ rmSync(dir, { recursive: true, force: true });
959
+ } catch {
960
+ /* ignore */
961
+ }
962
+ });
963
+
964
+ it('surfaces an offline node through the doctor findings, not just the checker', async () => {
965
+ const findings = await runFleetChecks(bus, db, {
966
+ hostLiveness: async () => ({
967
+ placements: [
968
+ {
969
+ moduleId: 'celilo-apt-repo',
970
+ hostname: 'apt',
971
+ infraType: 'container_service',
972
+ vmid: 205,
973
+ },
974
+ ],
975
+ machines: [],
976
+ nodes: [{ node: 'node2', online: false }],
977
+ guestNodes: [{ vmid: 205, node: 'node2' }],
978
+ }),
979
+ });
980
+
981
+ const liveness = findings.find((f) => f.id === 'host-liveness');
982
+ // Against main this is `undefined` — the check does not exist in the list.
983
+ expect(liveness).toBeDefined();
984
+ expect(liveness?.status).toBe('fail');
985
+ expect(liveness?.summary).toContain('node2');
986
+ });
987
+
988
+ it('does not add a standing warning to a healthy fleet', async () => {
989
+ const findings = await runFleetChecks(bus, db, {
990
+ hostLiveness: async () => ({
991
+ placements: [{ moduleId: 'homebridge', hostname: 'iot', infraType: 'machine', vmid: null }],
992
+ machines: [{ hostname: 'iot', reachable: true }],
993
+ nodes: [],
994
+ guestNodes: [],
995
+ }),
996
+ });
997
+ const liveness = findings.find((f) => f.id === 'host-liveness');
998
+ expect(liveness?.status).toBe('ok');
999
+ });
1000
+ });