@celilo/cli 0.25.1 → 0.26.1

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.
@@ -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
+ });