@celilo/cli 0.26.0 → 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 +2 -2
- package/src/hooks/types.ts +2 -1
- 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
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -27,7 +27,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
27
27
|
- **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
|
|
28
28
|
- **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
|
|
29
29
|
- **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
|
|
30
|
-
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
|
|
30
|
+
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **PROVIDES `control_plane_vpn`** so another module can enrol clients without an operator editing YAML: `registerClient` / `revokeClient` write to `registered_peers` — the same key the adoption claim uses — and `getEndpoint` reads the server's public key LIVE off the host rather than storing a copy that could be republished after the key changed. `client_pool` is the range a consumer may allocate from, a strict subset of the client subnet, and `validate_config` REFUSES any operator-declared peer inside it: the two allocators cannot see each other, so the range is divided rather than negotiated, and that check is the only place a bad division is caught. ⚠️ Granting this capability grants fleet-wide reach — the tunnel's client subnet is a registered trusted source, so every client enrolled through it reaches every managed zone. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
|
|
31
31
|
|
|
32
32
|
## Public edge (ingress / identity)
|
|
33
33
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -60,6 +60,8 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
|
|
|
60
60
|
| `registry_publish` | `packages/capabilities/src/registry-publish.ts` | celilo-registry |
|
|
61
61
|
| `notification` | `packages/capabilities/src/notification.ts` (`send`, optional `receive`) | signal (planned) — transports are ordinary modules, both self-hosted and credential-only |
|
|
62
62
|
| `cross_module_read` | `packages/capabilities/src/cross-module-read.ts` | framework (read other modules' capability data) |
|
|
63
|
+
| `control_plane_vpn` | `packages/capabilities/src/control-plane-vpn.ts` (`registerClient`, `revokeClient`, `listClients`, `getEndpoint`). **Its state is NOT in a celilo table** — registrations live in the PROVIDER's own module config under `registered_peers`, kept separate from the operator's `peers` so a machine can never rewrite what an operator typed, and so `revokeClient` structurally cannot reach a declared peer. Do not go looking for a `vpn_clients` table; there isn't one and that is deliberate. Addresses are allocated by the CONSUMER from `client_pool` — celilo's IPAM deliberately does not cover VPN clients. ⚠️ Every client registered here reaches every managed zone: the tunnel's client subnet is a registered trusted source. | wireguard |
|
|
64
|
+
| `private_web` | `packages/capabilities/src/private-web.ts` (`publishStaticSite`, `registerReverseProxy`, `unregisterRoutes`, `getCaCertificate`). **Fleet-only HTTP ingress** — internal DNS, an internally-issued certificate, and no public exposure. A SIBLING of `public_web` rather than a flag on it, because `public_web` treats an unreachable route as a deploy failure and publishes a public record to prevent one. Reuses `public_web`'s request types so a consumer can write `private_web ?? public_web` and call through the union without branching. | caddy-internal (planned) |
|
|
63
65
|
|
|
64
66
|
### In-fleet (`public_web`) vs off-fleet (`external_web`)
|
|
65
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^1.
|
|
61
|
+
"@celilo/capabilities": "^1.4.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
63
|
"@celilo/core": "^0.8.0",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
package/src/hooks/types.ts
CHANGED
|
@@ -210,6 +210,25 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
210
210
|
inputs: {},
|
|
211
211
|
outputs: {},
|
|
212
212
|
},
|
|
213
|
+
/**
|
|
214
|
+
* Reconcile the clients a self-service app has enrolled against what the
|
|
215
|
+
* VPN provider actually carries (openspec/changes/wireguard-manager, D1).
|
|
216
|
+
*
|
|
217
|
+
* The hook, rather than a bus `handler` subscription, because it needs
|
|
218
|
+
* CAPABILITY INJECTION: it calls `control_plane_vpn.registerClient` and
|
|
219
|
+
* `revokeClient`, and a handler gets no capabilities. No framework inputs —
|
|
220
|
+
* it reads the app's address from its own `systems` and holds a token it
|
|
221
|
+
* minted for itself at install.
|
|
222
|
+
*
|
|
223
|
+
* Typically driven by a `timer.tick.*` subscription, and the tick interval IS
|
|
224
|
+
* the window in which a revoked device still has reach — which is why the
|
|
225
|
+
* design requires the UI to show the pending state rather than imply
|
|
226
|
+
* revocation is instant (D12).
|
|
227
|
+
*/
|
|
228
|
+
reconcile_clients: {
|
|
229
|
+
inputs: {},
|
|
230
|
+
outputs: {},
|
|
231
|
+
},
|
|
213
232
|
/**
|
|
214
233
|
* Build-bus upstream publish hook. The executor passes the
|
|
215
234
|
* PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
|
package/src/manifest/schema.ts
CHANGED
|
@@ -674,6 +674,7 @@ export const ModuleManifestSchema = z
|
|
|
674
674
|
* client can be handed the wrong resolver. See celilo#739.
|
|
675
675
|
*/
|
|
676
676
|
reassert_dhcp_dns: LifecycleHookSchema.optional(),
|
|
677
|
+
reconcile_clients: LifecycleHookSchema.optional(),
|
|
677
678
|
/**
|
|
678
679
|
* Build-bus upstream publish hooks. Array (a module can react
|
|
679
680
|
* to multiple upstream packages with different actions). See
|
|
@@ -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 };
|
|
@@ -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
|
+
});
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { type Bus, describeError } from '@celilo/event-bus';
|
|
21
21
|
import { inArray } from 'drizzle-orm';
|
|
22
|
+
import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
|
|
22
23
|
import { getModuleStoragePath } from '../config/paths';
|
|
23
24
|
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
24
25
|
import { getMigrationStatus } from '../db/migration-status';
|
|
@@ -27,9 +28,8 @@ import { findSchemaDrift } from '../db/schema-introspection';
|
|
|
27
28
|
import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capability-loader';
|
|
28
29
|
import type { ModuleManifest } from '../manifest/schema';
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import { getModuleSystems } from './deployed-systems';
|
|
31
|
+
import { getServiceCredentials, listContainerServices } from './container-service';
|
|
32
|
+
import { getModuleSystems, listAllModuleSystems } from './deployed-systems';
|
|
33
33
|
import { listDnsInternalRecords } from './dns-internal-records';
|
|
34
34
|
import {
|
|
35
35
|
SUPERVISOR_SCOPES,
|
|
@@ -38,9 +38,13 @@ import {
|
|
|
38
38
|
readInstalledUnit,
|
|
39
39
|
unitMainPid,
|
|
40
40
|
} from './events-daemon';
|
|
41
|
+
import { probeMachines } from './machine-probe';
|
|
41
42
|
import { describePausedModule, listPausedModules } from './module-pause';
|
|
42
43
|
import { resolveSubscription } from './module-subscriptions';
|
|
43
44
|
|
|
45
|
+
/** The module that IS celilo's control plane. */
|
|
46
|
+
const CONTROL_PLANE_MODULE = 'celilo-mgmt';
|
|
47
|
+
|
|
44
48
|
/**
|
|
45
49
|
* Zones reachable from the operator's LAN. A celilo placement zone other
|
|
46
50
|
* than `internal` is firewall-segmented — an unmanaged LAN device has no
|
|
@@ -786,6 +790,12 @@ export async function checkServiceDns(db: DbClient): Promise<FleetFinding> {
|
|
|
786
790
|
export interface RunFleetChecksOptions {
|
|
787
791
|
now?: number;
|
|
788
792
|
installedCodeMtimeMs?: number | null;
|
|
793
|
+
/**
|
|
794
|
+
* Where the host-liveness verdict gets its facts. Injected so the check can
|
|
795
|
+
* be exercised without SSH or a Proxmox credential; defaults to the live
|
|
796
|
+
* fleet (`collectHostLiveness`).
|
|
797
|
+
*/
|
|
798
|
+
hostLiveness?: () => Promise<HostLivenessInputs>;
|
|
789
799
|
}
|
|
790
800
|
|
|
791
801
|
/**
|
|
@@ -901,11 +911,272 @@ export function checkPausedModules(db: DbClient): FleetFinding {
|
|
|
901
911
|
};
|
|
902
912
|
}
|
|
903
913
|
|
|
914
|
+
/** One module deployment, and the host it landed on. */
|
|
915
|
+
export interface HostPlacement {
|
|
916
|
+
moduleId: string;
|
|
917
|
+
/** The host's user-facing name — a pool hostname, or the container's. */
|
|
918
|
+
hostname: string;
|
|
919
|
+
infraType: 'machine' | 'container_service';
|
|
920
|
+
/** Proxmox VMID for a celilo-provisioned container; null for a pool machine. */
|
|
921
|
+
vmid: number | null;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Everything the liveness verdict is computed from, injected so the check is a
|
|
926
|
+
* pure function over data and needs neither SSH nor a Proxmox credential to
|
|
927
|
+
* test.
|
|
928
|
+
*
|
|
929
|
+
* ⚠️ Every source here is ALLOWED TO BE ABSENT, and absent is not "fine".
|
|
930
|
+
* A machine missing from `machines` was not probed; a node missing from `nodes`
|
|
931
|
+
* was not reported. Neither means the host is up, and neither means it is down.
|
|
932
|
+
* Conflating "I could not look" with "I looked and it was healthy" is the
|
|
933
|
+
* failure this whole check exists to end — doctor said OK-with-warnings while a
|
|
934
|
+
* node hosting two modules was offline.
|
|
935
|
+
*/
|
|
936
|
+
export interface HostLivenessInputs {
|
|
937
|
+
placements: HostPlacement[];
|
|
938
|
+
/** Machine-pool SSH probe results. A hostname absent here was NOT probed. */
|
|
939
|
+
machines: Array<{ hostname: string; reachable: boolean }>;
|
|
940
|
+
/** Proxmox node status. Empty when no container service is configured. */
|
|
941
|
+
nodes: Array<{ node: string; online: boolean }>;
|
|
942
|
+
/** VMID → node name, from `/cluster/resources`. Empty when unqueried. */
|
|
943
|
+
guestNodes: Array<{ vmid: number; node: string }>;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
type HostState = 'up' | 'down' | 'unknown';
|
|
947
|
+
|
|
948
|
+
interface HostVerdict {
|
|
949
|
+
host: string;
|
|
950
|
+
state: HostState;
|
|
951
|
+
/** Why the state could not be determined. Set only when state is 'unknown'. */
|
|
952
|
+
reason?: string;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function resolveHostState(placement: HostPlacement, inputs: HostLivenessInputs): HostVerdict {
|
|
956
|
+
if (placement.infraType === 'machine') {
|
|
957
|
+
const probe = inputs.machines.find((m) => m.hostname === placement.hostname);
|
|
958
|
+
if (!probe) {
|
|
959
|
+
return {
|
|
960
|
+
host: placement.hostname,
|
|
961
|
+
state: 'unknown',
|
|
962
|
+
// Either the SSH probe did not run at all, or this hostname is no
|
|
963
|
+
// longer in the machine pool — a stale `module_systems` row, which is
|
|
964
|
+
// its own defect and worth surfacing rather than rounding off.
|
|
965
|
+
reason: 'no probe result for this machine',
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
return { host: placement.hostname, state: probe.reachable ? 'up' : 'down' };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// A container's liveness is its NODE's liveness. The guest being stopped is a
|
|
972
|
+
// different condition with a different owner (`module pause --stop-infra`
|
|
973
|
+
// stops guests deliberately), so this deliberately reads the node only.
|
|
974
|
+
if (placement.vmid === null) {
|
|
975
|
+
return {
|
|
976
|
+
host: placement.hostname,
|
|
977
|
+
state: 'unknown',
|
|
978
|
+
reason: 'no VMID recorded — celilo has no liveness source for this provider',
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const guest = inputs.guestNodes.find((g) => g.vmid === placement.vmid);
|
|
982
|
+
if (!guest) {
|
|
983
|
+
return {
|
|
984
|
+
host: placement.hostname,
|
|
985
|
+
state: 'unknown',
|
|
986
|
+
reason: `VMID ${placement.vmid} not present in the cluster's resources`,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
const node = inputs.nodes.find((n) => n.node === guest.node);
|
|
990
|
+
if (!node) {
|
|
991
|
+
return { host: guest.node, state: 'unknown', reason: 'the cluster reported no such node' };
|
|
992
|
+
}
|
|
993
|
+
return { host: guest.node, state: node.online ? 'up' : 'down' };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Are the hosts this fleet's modules actually run on alive? (celilo#728)
|
|
998
|
+
*
|
|
999
|
+
* Every other `Fleet runtime` check is a control-plane concern — the
|
|
1000
|
+
* dispatcher, bus subscribers, capability-derived config, internal DNS. None of
|
|
1001
|
+
* them asked the most basic data-plane question, so `system doctor` reported
|
|
1002
|
+
* "OK with warnings" while a Proxmox node was OFFLINE with `celilo-apt-repo`
|
|
1003
|
+
* and `lunacycle` on it. The information was already in `proxmox node list`;
|
|
1004
|
+
* doctor simply never consulted it. It surfaced only because a release run got
|
|
1005
|
+
* an HTTP 502 from the apt repo that happened to live there — had nothing tried
|
|
1006
|
+
* to publish, the node could have stayed down indefinitely.
|
|
1007
|
+
*
|
|
1008
|
+
* A down host is a FAILURE, not a warning: it is strictly worse than the
|
|
1009
|
+
* conditions already reported as failures here, and every module on it is down
|
|
1010
|
+
* with it.
|
|
1011
|
+
*
|
|
1012
|
+
* A host celilo TRIED to verify and could not is a WARNING, and the reason is
|
|
1013
|
+
* named per host. This is not the same as "quiet because it might be fine": a
|
|
1014
|
+
* cluster that will not answer its own API is not obviously healthier than one
|
|
1015
|
+
* reporting a node offline, and the failure to answer may BE the outage this
|
|
1016
|
+
* check exists to catch. Reporting it as ok-with-a-note would rebuild the
|
|
1017
|
+
* defect one level down — a report reading healthy over something unmeasured.
|
|
1018
|
+
*
|
|
1019
|
+
* ⚠️ The thing that makes the warning safe to have is that it is not
|
|
1020
|
+
* permanent. A machine-only fleet produces NO unverified hosts at all: every
|
|
1021
|
+
* placement takes the probe path and resolves. The one standing source would be
|
|
1022
|
+
* a provider celilo cannot interrogate — today a DigitalOcean droplet, whose
|
|
1023
|
+
* client can verify the token but never reads droplet status. That is a gap to
|
|
1024
|
+
* close (its own change), not a reason to soften the signal here. A warning
|
|
1025
|
+
* that fires forever is what trains an operator to skim the whole report
|
|
1026
|
+
* (celilo#723, whose false positive was competing for attention in the very
|
|
1027
|
+
* output that missed the offline node) — so if this one ever becomes standing,
|
|
1028
|
+
* the fix is to teach celilo the missing provider, not to quieten it.
|
|
1029
|
+
*/
|
|
1030
|
+
export function checkHostLiveness(inputs: HostLivenessInputs): FleetFinding {
|
|
1031
|
+
const base = {
|
|
1032
|
+
id: 'host-liveness',
|
|
1033
|
+
title: 'The hosts running deployed modules are alive',
|
|
1034
|
+
autoFixable: false,
|
|
1035
|
+
} as const;
|
|
1036
|
+
|
|
1037
|
+
if (inputs.placements.length === 0) {
|
|
1038
|
+
return {
|
|
1039
|
+
...base,
|
|
1040
|
+
status: 'ok',
|
|
1041
|
+
summary: 'no modules are deployed to a host yet',
|
|
1042
|
+
detail: [],
|
|
1043
|
+
remediation: null,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
interface HostEntry {
|
|
1048
|
+
state: HostState;
|
|
1049
|
+
modules: Set<string>;
|
|
1050
|
+
reason?: string;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const modulesByHost = new Map<string, HostEntry>();
|
|
1054
|
+
for (const placement of inputs.placements) {
|
|
1055
|
+
const { host, state, reason } = resolveHostState(placement, inputs);
|
|
1056
|
+
const entry = modulesByHost.get(host) ?? { state, modules: new Set<string>(), reason };
|
|
1057
|
+
// A host resolved 'down' by any placement stays down — one authoritative
|
|
1058
|
+
// negative outranks an unknown from a sibling placement.
|
|
1059
|
+
if (state === 'down' || entry.state === 'unknown') {
|
|
1060
|
+
entry.state = state;
|
|
1061
|
+
entry.reason = reason;
|
|
1062
|
+
}
|
|
1063
|
+
entry.modules.add(placement.moduleId);
|
|
1064
|
+
modulesByHost.set(host, entry);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const describe = (host: string, e: HostEntry) => `${host}: ${[...e.modules].sort().join(', ')}`;
|
|
1068
|
+
const describeUnverified = (host: string, e: HostEntry) =>
|
|
1069
|
+
`unverified — ${describe(host, e)}${e.reason ? ` (${e.reason})` : ''}`;
|
|
1070
|
+
|
|
1071
|
+
const down = [...modulesByHost].filter(([, e]) => e.state === 'down');
|
|
1072
|
+
const unknown = [...modulesByHost].filter(([, e]) => e.state === 'unknown');
|
|
1073
|
+
const up = [...modulesByHost].filter(([, e]) => e.state === 'up');
|
|
1074
|
+
|
|
1075
|
+
if (down.length > 0) {
|
|
1076
|
+
const affected = down.reduce((n, [, e]) => n + e.modules.size, 0);
|
|
1077
|
+
return {
|
|
1078
|
+
...base,
|
|
1079
|
+
status: 'fail',
|
|
1080
|
+
summary: `${down.length} host(s) down, ${affected} module(s) unreachable: ${down
|
|
1081
|
+
.map(([host]) => host)
|
|
1082
|
+
.join(', ')}`,
|
|
1083
|
+
detail: [
|
|
1084
|
+
...down.map(([host, e]) => `DOWN ${describe(host, e)}`),
|
|
1085
|
+
...unknown.map(([host, e]) => describeUnverified(host, e)),
|
|
1086
|
+
'every module listed against a down host is down with it, whatever its own status says',
|
|
1087
|
+
],
|
|
1088
|
+
remediation:
|
|
1089
|
+
'bring the host back, then confirm with "celilo proxmox node list" (container services) or "celilo machine status <hostname>" (pool machines)',
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (unknown.length > 0) {
|
|
1094
|
+
// WARN, not ok-with-a-note. celilo tried and could not find out, and the
|
|
1095
|
+
// reason it could not may be the outage itself — a cluster that will not
|
|
1096
|
+
// answer its own API is not evidence of health. Reporting this quietly
|
|
1097
|
+
// would rebuild #728 one level down.
|
|
1098
|
+
const affected = unknown.reduce((n, [, e]) => n + e.modules.size, 0);
|
|
1099
|
+
return {
|
|
1100
|
+
...base,
|
|
1101
|
+
status: 'warn',
|
|
1102
|
+
summary: `${up.length} host(s) up, ${unknown.length} could not be verified (${affected} module(s))`,
|
|
1103
|
+
// Named and reasoned, never counted: "1 not verified" tells an operator
|
|
1104
|
+
// neither which host nor what to do about it.
|
|
1105
|
+
detail: unknown.map(([host, e]) => describeUnverified(host, e)),
|
|
1106
|
+
remediation:
|
|
1107
|
+
'check the host directly — "celilo proxmox node list" for a container service, "celilo machine status <hostname>" for a pool machine; a host celilo cannot reach is not a host known to be healthy',
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return {
|
|
1112
|
+
...base,
|
|
1113
|
+
status: 'ok',
|
|
1114
|
+
summary: `${up.length} host(s) up`,
|
|
1115
|
+
detail: [],
|
|
1116
|
+
remediation: null,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Read the liveness facts off the live fleet.
|
|
1122
|
+
*
|
|
1123
|
+
* Every source degrades to ABSENT rather than to a cheerful default. A Proxmox
|
|
1124
|
+
* cluster that cannot be reached, or a fleet with no container service at all,
|
|
1125
|
+
* contributes no node rows — and `checkHostLiveness` reads that as unverified,
|
|
1126
|
+
* never as healthy. That distinction is the whole point of the check.
|
|
1127
|
+
*/
|
|
1128
|
+
export async function collectHostLiveness(db: DbClient): Promise<HostLivenessInputs> {
|
|
1129
|
+
const placements: HostPlacement[] = listAllModuleSystems(db).map((s) => ({
|
|
1130
|
+
moduleId: s.moduleId,
|
|
1131
|
+
hostname: s.hostname,
|
|
1132
|
+
infraType: s.infraType,
|
|
1133
|
+
vmid: s.vmid ?? null,
|
|
1134
|
+
}));
|
|
1135
|
+
|
|
1136
|
+
// Nothing deployed — skip the probes entirely rather than SSH a fleet of none.
|
|
1137
|
+
if (placements.length === 0) {
|
|
1138
|
+
return { placements, machines: [], nodes: [], guestNodes: [] };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
let machines: HostLivenessInputs['machines'] = [];
|
|
1142
|
+
try {
|
|
1143
|
+
machines = (await probeMachines()).map((m) => ({
|
|
1144
|
+
hostname: m.hostname,
|
|
1145
|
+
reachable: m.reachable,
|
|
1146
|
+
}));
|
|
1147
|
+
} catch {
|
|
1148
|
+
// Leave it empty: unprobed, which reports as unverified rather than up.
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const nodes: HostLivenessInputs['nodes'] = [];
|
|
1152
|
+
const guestNodes: HostLivenessInputs['guestNodes'] = [];
|
|
1153
|
+
try {
|
|
1154
|
+
for (const service of await listContainerServices()) {
|
|
1155
|
+
if (service.providerName !== 'proxmox') continue;
|
|
1156
|
+
const creds = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
|
|
1157
|
+
const result = await new ProxmoxClient(creds).clusterResources();
|
|
1158
|
+
if (!result.success) continue;
|
|
1159
|
+
for (const row of result.data) {
|
|
1160
|
+
if (row.type === 'node' && row.node) {
|
|
1161
|
+
nodes.push({ node: row.node, online: row.status === 'online' });
|
|
1162
|
+
} else if (typeof row.vmid === 'number' && row.node) {
|
|
1163
|
+
guestNodes.push({ vmid: row.vmid, node: row.node });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
} catch {
|
|
1168
|
+
// Same rule: unreachable is unverified, not healthy.
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return { placements, machines, nodes, guestNodes };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
904
1174
|
export async function runFleetChecks(
|
|
905
1175
|
bus: Bus,
|
|
906
1176
|
db: DbClient,
|
|
907
1177
|
opts: RunFleetChecksOptions = {},
|
|
908
1178
|
): Promise<FleetFinding[]> {
|
|
1179
|
+
const hostLiveness = opts.hostLiveness ?? (() => collectHostLiveness(db));
|
|
909
1180
|
return [
|
|
910
1181
|
checkSchemaDrift(db),
|
|
911
1182
|
checkDispatcher(bus, { now: opts.now, installedCodeMtimeMs: opts.installedCodeMtimeMs }),
|
|
@@ -913,6 +1184,7 @@ export async function runFleetChecks(
|
|
|
913
1184
|
checkCapabilityProviders(db),
|
|
914
1185
|
checkControlPlaneNetwork(db),
|
|
915
1186
|
checkPausedModules(db),
|
|
1187
|
+
checkHostLiveness(await hostLiveness()),
|
|
916
1188
|
await checkServiceDns(db),
|
|
917
1189
|
];
|
|
918
1190
|
}
|
|
@@ -21,8 +21,8 @@ describe('local machine reachability', () => {
|
|
|
21
21
|
test('a reachable local box produces no finding', async () => {
|
|
22
22
|
const findings = await auditMachinesReachable({
|
|
23
23
|
results: [
|
|
24
|
-
{
|
|
25
|
-
{
|
|
24
|
+
{ hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
|
|
25
|
+
{ hostname: 'briq', ipAddress: '192.168.0.254', reachable: true },
|
|
26
26
|
],
|
|
27
27
|
});
|
|
28
28
|
|
|
@@ -33,9 +33,8 @@ describe('local machine reachability', () => {
|
|
|
33
33
|
// The skip must not blunt the check for the machines it exists to watch.
|
|
34
34
|
const findings = await auditMachinesReachable({
|
|
35
35
|
results: [
|
|
36
|
-
{
|
|
36
|
+
{ hostname: 'celilo-mgr', ipAddress: LOCAL_MACHINE_IP, reachable: true },
|
|
37
37
|
{
|
|
38
|
-
id: 'briq',
|
|
39
38
|
hostname: 'briq',
|
|
40
39
|
ipAddress: '192.168.0.254',
|
|
41
40
|
reachable: false,
|
|
@@ -38,7 +38,7 @@ export async function probeMachines(): Promise<MachineReachableResult[]> {
|
|
|
38
38
|
return Promise.all(
|
|
39
39
|
machines.map(async (m): Promise<MachineReachableResult> => {
|
|
40
40
|
if (m.ipAddress === LOCAL_MACHINE_IP) {
|
|
41
|
-
return {
|
|
41
|
+
return { hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
|
|
42
42
|
}
|
|
43
43
|
try {
|
|
44
44
|
await execFileAsync(
|
|
@@ -57,11 +57,10 @@ export async function probeMachines(): Promise<MachineReachableResult[]> {
|
|
|
57
57
|
],
|
|
58
58
|
{ timeout: 8000 },
|
|
59
59
|
);
|
|
60
|
-
return {
|
|
60
|
+
return { hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
|
|
61
61
|
} catch (err) {
|
|
62
62
|
const e = err as { stderr?: string; message?: string };
|
|
63
63
|
return {
|
|
64
|
-
id: m.id,
|
|
65
64
|
hostname: m.hostname,
|
|
66
65
|
ipAddress: m.ipAddress,
|
|
67
66
|
reachable: false,
|
|
@@ -71,6 +71,70 @@ describe('module-operations', () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* celilo#737. Recording an outcome is BOOKKEEPING; the caller's error is the
|
|
76
|
+
* information. On celilo-mgr a `SQLITE_BUSY` inside `failOperation` propagated
|
|
77
|
+
* out of the catch block that called it and REPLACED the deploy's own error,
|
|
78
|
+
* so the operator was shown a database-locking problem and never learned what
|
|
79
|
+
* the deploy did wrong — the original was destroyed and is unrecoverable.
|
|
80
|
+
*
|
|
81
|
+
* `breakOperationsTable` stands in for any write failure. The mechanism does
|
|
82
|
+
* not matter; what matters is that no failure of the write can reach the
|
|
83
|
+
* caller.
|
|
84
|
+
*/
|
|
85
|
+
describe('recording an outcome cannot destroy what it records (#737)', () => {
|
|
86
|
+
function breakOperationsTable(): void {
|
|
87
|
+
const { getDb } = require('../db/client');
|
|
88
|
+
getDb().$client.run('DROP TABLE module_operations');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
it('failOperation does not replace the error it was called to record', () => {
|
|
92
|
+
const id = startOperation('caddy', 'deploy');
|
|
93
|
+
breakOperationsTable();
|
|
94
|
+
|
|
95
|
+
const original = new Error('ansible task failed on step 7');
|
|
96
|
+
let surfaced: unknown;
|
|
97
|
+
|
|
98
|
+
// Exactly the shape every call site uses (module-deploy.ts,
|
|
99
|
+
// module-remove.ts): record the failure, then rethrow the original.
|
|
100
|
+
try {
|
|
101
|
+
try {
|
|
102
|
+
throw original;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
failOperation(id, err);
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
surfaced = err;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
expect(surfaced).toBe(original);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('failOperation does not throw when the write fails', () => {
|
|
115
|
+
const id = startOperation('caddy', 'deploy');
|
|
116
|
+
breakOperationsTable();
|
|
117
|
+
expect(() => failOperation(id, new Error('original'))).not.toThrow();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('completeOperation does not throw when the write fails', () => {
|
|
121
|
+
// The mirror bug: a deploy that SUCCEEDED reporting a database error, and
|
|
122
|
+
// skipping the `emitDeployCompleted` that follows the call.
|
|
123
|
+
const id = startOperation('caddy', 'deploy');
|
|
124
|
+
breakOperationsTable();
|
|
125
|
+
expect(() => completeOperation(id)).not.toThrow();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('startOperation still throws — its row IS the in-flight lock', () => {
|
|
129
|
+
// Deliberately NOT swallowed. `checkInFlight` reads this row to refuse a
|
|
130
|
+
// backup during a deploy, so a silently-missing row would let the two run
|
|
131
|
+
// together. Failing before any work happens is honest; failing after it
|
|
132
|
+
// is what #737 is about.
|
|
133
|
+
breakOperationsTable();
|
|
134
|
+
expect(() => startOperation('caddy', 'deploy')).toThrow();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
74
138
|
describe('checkInFlight', () => {
|
|
75
139
|
it('returns empty when no operations are in flight', () => {
|
|
76
140
|
expect(checkInFlight()).toHaveLength(0);
|
|
@@ -96,7 +160,14 @@ describe('module-operations', () => {
|
|
|
96
160
|
|
|
97
161
|
it('ignores rows whose pid is no longer alive', () => {
|
|
98
162
|
// Spawn a short-lived process, capture its pid, wait for it to exit.
|
|
99
|
-
|
|
163
|
+
//
|
|
164
|
+
// `process.execPath` (the bun binary running this suite), NOT a bare
|
|
165
|
+
// `node`: this repo is bun-based and nothing guarantees a node on PATH.
|
|
166
|
+
// Where there was none, spawnSync returned `pid: undefined` and the
|
|
167
|
+
// assertion below failed with "Expected and actual values must be numbers
|
|
168
|
+
// or bigints" — which reads as a broken pid check rather than a missing
|
|
169
|
+
// interpreter.
|
|
170
|
+
const child = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
|
|
100
171
|
const deadPid = child.pid;
|
|
101
172
|
expect(deadPid).toBeGreaterThan(0);
|
|
102
173
|
expect(isPidRunnable(deadPid)).toBe(false);
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { spawnSync } from 'node:child_process';
|
|
34
34
|
import { randomUUID } from 'node:crypto';
|
|
35
35
|
import { eq } from 'drizzle-orm';
|
|
36
|
+
import { log } from '../cli/prompts';
|
|
36
37
|
import { getDb } from '../db/client';
|
|
37
38
|
import { type ModuleOperation, type ModuleOperationKind, moduleOperations } from '../db/schema';
|
|
38
39
|
|
|
@@ -55,21 +56,59 @@ export function startOperation(moduleId: string, operation: ModuleOperationKind)
|
|
|
55
56
|
return id;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Write an operation's outcome without ever being able to break the flow that
|
|
61
|
+
* is reporting it (celilo#737).
|
|
62
|
+
*
|
|
63
|
+
* Recording an outcome is BOOKKEEPING; the caller's error is the information.
|
|
64
|
+
* On celilo-mgr a `SQLITE_BUSY` inside `failOperation` propagated out of the
|
|
65
|
+
* catch block that called it and REPLACED the deploy's own error, so the
|
|
66
|
+
* operator was shown a database-locking problem and never learned what the
|
|
67
|
+
* deploy actually did wrong. That error was destroyed and is unrecoverable —
|
|
68
|
+
* the whole cost of the bug. It also skipped the `emitDeployFailed` that
|
|
69
|
+
* follows the call, so the event bus never learned the deploy had failed at
|
|
70
|
+
* all, and the module was left `INSTALLED` while in fact verified.
|
|
71
|
+
*
|
|
72
|
+
* `getDb()` is inside the try on purpose: opening the database is one of the
|
|
73
|
+
* things that can throw here.
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ `startOperation` deliberately does NOT get this treatment. Its row IS the
|
|
76
|
+
* in-flight lock `checkInFlight` reads to refuse a backup during a deploy, so a
|
|
77
|
+
* silently-missing row would let the two run together against the same module.
|
|
78
|
+
* Failing before any work happens is honest; failing after it is what #737 is
|
|
79
|
+
* about.
|
|
80
|
+
*/
|
|
81
|
+
function recordOutcome(outcome: 'completed' | 'failed', operationId: string, write: () => void) {
|
|
82
|
+
try {
|
|
83
|
+
write();
|
|
84
|
+
} catch (persistError) {
|
|
85
|
+
// Rule 6.2: never a bare catch. Secondary to whatever the caller is already
|
|
86
|
+
// reporting, so it is a warning rather than the headline — the caller's own
|
|
87
|
+
// error is what the operator needs to read.
|
|
88
|
+
const reason = persistError instanceof Error ? persistError.message : String(persistError);
|
|
89
|
+
log.warn(`Could not record operation ${operationId} as ${outcome}: ${reason}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
58
93
|
export function completeOperation(operationId: string): void {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
recordOutcome('completed', operationId, () => {
|
|
95
|
+
getDb()
|
|
96
|
+
.update(moduleOperations)
|
|
97
|
+
.set({ status: 'completed', completedAt: new Date() })
|
|
98
|
+
.where(eq(moduleOperations.id, operationId))
|
|
99
|
+
.run();
|
|
100
|
+
});
|
|
64
101
|
}
|
|
65
102
|
|
|
66
103
|
export function failOperation(operationId: string, error: unknown): void {
|
|
67
|
-
const db = getDb();
|
|
68
104
|
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
105
|
+
recordOutcome('failed', operationId, () => {
|
|
106
|
+
getDb()
|
|
107
|
+
.update(moduleOperations)
|
|
108
|
+
.set({ status: 'failed', completedAt: new Date(), errorMessage: message })
|
|
109
|
+
.where(eq(moduleOperations.id, operationId))
|
|
110
|
+
.run();
|
|
111
|
+
});
|
|
73
112
|
}
|
|
74
113
|
|
|
75
114
|
/**
|