@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/package.json +4 -2
- package/src/api-clients/proxmox-over-http.test.ts +328 -0
- package/src/hooks/capability-loader.ts +4 -0
- package/src/hooks/types.ts +2 -1
- package/src/ipam/subnet-parser.ts +12 -15
- package/src/manifest/contracts/v1.ts +19 -0
- package/src/manifest/schema.ts +1 -0
- package/src/services/alerting/inbound.test.ts +66 -0
- package/src/services/alerting/inbound.ts +35 -2
- package/src/services/audit/machines-reachable.test.ts +67 -8
- package/src/services/audit/machines-reachable.ts +18 -4
- package/src/services/deployed-systems.ts +31 -0
- package/src/services/fleet-checks.test.ts +232 -0
- package/src/services/fleet-checks.ts +275 -3
- package/src/services/machine-probe.test.ts +3 -4
- package/src/services/machine-probe.ts +2 -3
- package/src/services/module-operations.test.ts +72 -1
- package/src/services/module-operations.ts +49 -10
|
@@ -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
|
-
{
|
|
9
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
+
});
|