@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +0 -4
- package/src/ansible/dependencies.test.ts +233 -289
- package/src/ansible/dependencies.ts +151 -83
- package/src/cli/commands/alerts-sweep.ts +14 -3
- package/src/cli/commands/machine-add.ts +0 -1
- package/src/cli/commands/machine-list.ts +10 -4
- package/src/cli/commands/machine-remove.ts +13 -7
- package/src/cli/commands/machine-status.ts +9 -11
- package/src/db/schema.ts +6 -4
- package/src/hooks/capability-loader.ts +6 -0
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +0 -2
- 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/alerting/sweep-runner.test.ts +5 -1
- package/src/services/alerting/sweep-runner.ts +14 -8
- package/src/services/aspect-runner.test.ts +0 -1
- 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/infrastructure-selector.test.ts +0 -7
- package/src/services/infrastructure-selector.ts +24 -25
- package/src/services/infrastructure-variable-resolver.test.ts +0 -6
- package/src/services/infrastructure-variable-resolver.ts +0 -3
- package/src/services/machine-pool.test.ts +53 -85
- package/src/services/machine-pool.ts +68 -84
- package/src/services/machine-probe.test.ts +3 -4
- package/src/services/machine-probe.ts +2 -3
- package/src/services/module-deploy.ts +17 -39
- package/src/services/module-operations.test.ts +72 -1
- package/src/services/module-operations.ts +49 -10
- package/src/services/ssh-key-manager.test.ts +0 -10
- 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
|
-
|
|
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
|
|
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
|
|
85
|
-
* own contract says a silent skip is indistinguishable from a bug
|
|
86
|
-
* here is what made a firing-but-undelivered alert undebuggable
|
|
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:
|
|
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
|
|
291
|
+
report.skipped.push({ alertKey: alert.key, reason: outcome.reason });
|
|
286
292
|
}
|
|
287
293
|
}
|
|
288
294
|
|
|
@@ -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
|
+
});
|