@celilo/cli 0.15.0 → 0.16.2
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_SUBSYSTEMS.md +1 -0
- package/package.json +3 -3
- package/src/cli/commands/module-config.test.ts +77 -1
- package/src/cli/commands/module-config.ts +45 -3
- package/src/cli/commands/module-journal.test.ts +47 -0
- package/src/cli/commands/module-journal.ts +98 -0
- package/src/cli/completion.ts +2 -0
- package/src/cli/generate-zsh-completion.ts +4 -0
- package/src/cli/index.ts +3 -0
- package/src/module/packaging/release-metadata.test.ts +77 -3
- package/src/module/packaging/release-metadata.ts +11 -1
- package/src/services/alerting/inbound-poller.test.ts +191 -0
- package/src/services/alerting/inbound-poller.ts +31 -7
- package/src/services/alerting/inbound.test.ts +213 -2
- package/src/services/alerting/inbound.ts +161 -32
- package/src/services/alerting/interview-responder.test.ts +0 -32
- package/src/services/alerting/interview-responder.ts +6 -17
- package/src/services/alerting/tokens.ts +39 -1
- package/src/services/module-journal.test.ts +302 -0
- package/src/services/module-journal.ts +160 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the read-only module journal surface
|
|
3
|
+
* (openspec/changes/fix-signal-inbound-delivery, spec transport-diagnostics).
|
|
4
|
+
*
|
|
5
|
+
* The read-only scenarios are the point of this file. They are not assertions
|
|
6
|
+
* of intent — each one runs the operation against a stand-in daemon and proves
|
|
7
|
+
* a property of what actually reached the SSH seam:
|
|
8
|
+
*
|
|
9
|
+
* - "does not steal inbound": a fake daemon holds a queue that only its
|
|
10
|
+
* `receive` verb drains. The diagnostic runs; the queue is then drained by
|
|
11
|
+
* the collection path and the pending reply is still there.
|
|
12
|
+
* - "cannot change the transport": every command string the operation emits
|
|
13
|
+
* is inspected. Anything that is not a `journalctl` read fails the test.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
17
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { type RunResult, type Runner, createMockRunner } from '@celilo/capabilities';
|
|
21
|
+
import type { DbClient } from '../db/client';
|
|
22
|
+
import { moduleSystems, modules } from '../db/schema';
|
|
23
|
+
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
24
|
+
import { planJournalRead, readModuleJournal } from './module-journal';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The command the REMOTE shell actually receives: strip the local `ssh … host`
|
|
28
|
+
* prefix and undo the single-quote escaping remoteExec applied. Assertions run
|
|
29
|
+
* against this rather than the ssh invocation, so they say something about what
|
|
30
|
+
* runs on the host.
|
|
31
|
+
*/
|
|
32
|
+
function remotePayload(sshCmd: string): string {
|
|
33
|
+
const match = sshCmd.match(/ root@[\d.]+ (.*)$/s);
|
|
34
|
+
if (!match) throw new Error(`not an ssh invocation: ${sshCmd}`);
|
|
35
|
+
return match[1].replace(/^'/, '').replace(/'$/, '').split("'\\''").join("'");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The payload with every quoted argument blanked out — what the remote shell
|
|
40
|
+
* reads as SYNTAX rather than data. A `;` surviving this is a real second
|
|
41
|
+
* command; a `;` inside a quoted argument is inert text.
|
|
42
|
+
*/
|
|
43
|
+
function shellSyntax(payload: string): string {
|
|
44
|
+
let out = '';
|
|
45
|
+
let quoted = false;
|
|
46
|
+
for (let i = 0; i < payload.length; i++) {
|
|
47
|
+
const char = payload[i];
|
|
48
|
+
if (!quoted && char === '\\') {
|
|
49
|
+
i++; // the escaped character is data, never syntax
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (char === "'") {
|
|
53
|
+
quoted = !quoted;
|
|
54
|
+
out += "'";
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!quoted) out += char;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let dir: string;
|
|
63
|
+
let db: DbClient;
|
|
64
|
+
|
|
65
|
+
beforeEach(async () => {
|
|
66
|
+
dir = mkdtempSync(join(tmpdir(), 'module-journal-'));
|
|
67
|
+
db = await setupTestDatabase(join(dir, 'celilo.db'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function seedModule(id: string, hosts: Array<{ name: string; ip: string }>): void {
|
|
75
|
+
db.insert(modules)
|
|
76
|
+
.values({
|
|
77
|
+
id,
|
|
78
|
+
name: id,
|
|
79
|
+
version: '1.0.0',
|
|
80
|
+
manifestData: { requires: { system: { zone: 'internal' } } },
|
|
81
|
+
sourcePath: `/tmp/${id}`,
|
|
82
|
+
state: 'VERIFIED',
|
|
83
|
+
})
|
|
84
|
+
.run();
|
|
85
|
+
for (const host of hosts) {
|
|
86
|
+
db.insert(moduleSystems)
|
|
87
|
+
.values({
|
|
88
|
+
moduleId: id,
|
|
89
|
+
name: host.name,
|
|
90
|
+
hostname: host.name,
|
|
91
|
+
ipv4Address: host.ip,
|
|
92
|
+
zone: 'internal',
|
|
93
|
+
infraType: 'container_service',
|
|
94
|
+
})
|
|
95
|
+
.run();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A stand-in for a transport daemon holding one pending inbound reply.
|
|
101
|
+
*
|
|
102
|
+
* The queue drains ONLY on the daemon's own `receive` verb — exactly like the
|
|
103
|
+
* real thing, where reading the journal and consuming the message stream are
|
|
104
|
+
* different operations against different state. Any command that is not a
|
|
105
|
+
* recognised read is recorded as a mutation so a test can fail on it.
|
|
106
|
+
*/
|
|
107
|
+
function fakeDaemonHost() {
|
|
108
|
+
const queue: string[] = ['ack BPRJEH'];
|
|
109
|
+
const commands: string[] = [];
|
|
110
|
+
|
|
111
|
+
const runner: Runner = (cmd): RunResult => {
|
|
112
|
+
commands.push(cmd);
|
|
113
|
+
if (cmd.includes('signal-cli') && cmd.includes('receive')) {
|
|
114
|
+
const drained = queue.splice(0, queue.length);
|
|
115
|
+
return { ok: true, stdout: drained.join('\n'), stderr: '' };
|
|
116
|
+
}
|
|
117
|
+
if (cmd.includes('journalctl')) {
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
stdout: 'Jul 31 17:02:11 signal signal-cli[812]: Received sync sent message',
|
|
121
|
+
stderr: '',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, stdout: '', stderr: '' };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** What the normal collection path would get if it ran now. */
|
|
128
|
+
const collect = () => runner('signal-cli -a +15555550100 receive').stdout;
|
|
129
|
+
|
|
130
|
+
return { runner, commands, collect, pending: () => queue.length };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe('planJournalRead', () => {
|
|
134
|
+
it('defaults the unit to a glob on the module id so signal → signal-cli', () => {
|
|
135
|
+
expect(planJournalRead({ moduleId: 'signal' })).toMatchObject({ unit: 'signal*', lines: 100 });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects a unit carrying shell metacharacters', () => {
|
|
139
|
+
const plan = planJournalRead({ moduleId: 'signal', unit: 'signal-cli; rm -rf /' });
|
|
140
|
+
expect(plan).toHaveProperty('error');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('rejects a --since that is not a time expression', () => {
|
|
144
|
+
const plan = planJournalRead({ moduleId: 'signal', since: '$(id)' });
|
|
145
|
+
expect(plan).toHaveProperty('error');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('rejects a non-positive --lines', () => {
|
|
149
|
+
expect(planJournalRead({ moduleId: 'signal', lines: 0 })).toHaveProperty('error');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('readModuleJournal', () => {
|
|
154
|
+
it('returns a deployed module’s daemon logs without shell access to the host', () => {
|
|
155
|
+
seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]);
|
|
156
|
+
const host = fakeDaemonHost();
|
|
157
|
+
|
|
158
|
+
const report = readModuleJournal({ moduleId: 'signal' }, db, host.runner);
|
|
159
|
+
|
|
160
|
+
expect(report).not.toHaveProperty('error');
|
|
161
|
+
if ('error' in report) throw new Error('unreachable');
|
|
162
|
+
expect(report.systems).toHaveLength(1);
|
|
163
|
+
expect(report.systems[0].ok).toBe(true);
|
|
164
|
+
expect(report.systems[0].lines.join('\n')).toContain('Received sync sent message');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Scenario: "The surface is not transport-specific". A module with no
|
|
168
|
+
// notification capability at all reaches its journal by the same path.
|
|
169
|
+
it('reads the journal of a module that is not a notification transport', () => {
|
|
170
|
+
seedModule('caddy', [{ name: 'caddy', ip: '10.0.10.20' }]);
|
|
171
|
+
const runner = createMockRunner([
|
|
172
|
+
{ match: 'journalctl', result: { ok: true, stdout: 'certificate obtained', stderr: '' } },
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
const report = readModuleJournal({ moduleId: 'caddy' }, db, runner.run);
|
|
176
|
+
|
|
177
|
+
if ('error' in report) throw new Error(report.error);
|
|
178
|
+
expect(report.systems[0].lines).toEqual(['certificate obtained']);
|
|
179
|
+
expect(remotePayload(runner.calls[0].cmd)).toContain("journalctl -u 'caddy*'");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('reads every system serving the module', () => {
|
|
183
|
+
seedModule('forgejo', [
|
|
184
|
+
{ name: 'a', ip: '10.0.20.1' },
|
|
185
|
+
{ name: 'b', ip: '10.0.20.2' },
|
|
186
|
+
]);
|
|
187
|
+
const runner = createMockRunner([
|
|
188
|
+
{ match: 'journalctl', result: { ok: true, stdout: 'line', stderr: '' } },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const report = readModuleJournal({ moduleId: 'forgejo' }, db, runner.run);
|
|
192
|
+
|
|
193
|
+
if ('error' in report) throw new Error(report.error);
|
|
194
|
+
expect(report.systems.map((s) => s.ipv4Address)).toEqual(['10.0.20.1', '10.0.20.2']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Rule 6.2 / the defect class of this whole change: an unreachable host must
|
|
198
|
+
// not present identically to a host whose journal is genuinely empty.
|
|
199
|
+
it('reports an unreachable host instead of returning empty lines', () => {
|
|
200
|
+
seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]);
|
|
201
|
+
const runner = createMockRunner([
|
|
202
|
+
{ match: 'journalctl', result: { ok: false, stdout: '', stderr: 'Connection timed out' } },
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
const report = readModuleJournal({ moduleId: 'signal' }, db, runner.run);
|
|
206
|
+
|
|
207
|
+
if ('error' in report) throw new Error(report.error);
|
|
208
|
+
expect(report.systems[0].ok).toBe(false);
|
|
209
|
+
expect(report.systems[0].error).toBe('Connection timed out');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('an empty journal is reported as empty, not as a failure', () => {
|
|
213
|
+
seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]);
|
|
214
|
+
const runner = createMockRunner([
|
|
215
|
+
{ match: 'journalctl', result: { ok: true, stdout: '', stderr: '' } },
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
const report = readModuleJournal({ moduleId: 'signal' }, db, runner.run);
|
|
219
|
+
|
|
220
|
+
if ('error' in report) throw new Error(report.error);
|
|
221
|
+
expect(report.systems[0]).toMatchObject({ ok: true, lines: [] });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('names the module when it has no deployed systems', () => {
|
|
225
|
+
seedModule('api-only', []);
|
|
226
|
+
const report = readModuleJournal({ moduleId: 'api-only' }, db, () => {
|
|
227
|
+
throw new Error('must not reach a host');
|
|
228
|
+
});
|
|
229
|
+
expect(report).toHaveProperty('error');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('rejects an unknown module before reaching any host', () => {
|
|
233
|
+
const report = readModuleJournal({ moduleId: 'nope' }, db, () => {
|
|
234
|
+
throw new Error('must not reach a host');
|
|
235
|
+
});
|
|
236
|
+
expect(report).toMatchObject({ error: 'Module not found: nope' });
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('read-only (transport-diagnostics acceptance)', () => {
|
|
241
|
+
// Scenario: "Diagnostics do not steal inbound messages".
|
|
242
|
+
it('leaves a pending inbound reply for the normal collection path', () => {
|
|
243
|
+
seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]);
|
|
244
|
+
const host = fakeDaemonHost();
|
|
245
|
+
expect(host.pending()).toBe(1);
|
|
246
|
+
|
|
247
|
+
readModuleJournal({ moduleId: 'signal', grep: 'Received' }, db, host.runner);
|
|
248
|
+
|
|
249
|
+
// The reply is still queued, and the collection path still gets it.
|
|
250
|
+
expect(host.pending()).toBe(1);
|
|
251
|
+
expect(host.collect()).toBe('ack BPRJEH');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Scenario: "Diagnostics cannot change the transport". Proved by inspecting
|
|
255
|
+
// every command that reached the SSH seam, not by asserting the intent.
|
|
256
|
+
it('emits nothing but a journalctl read — no send, no reconfigure', () => {
|
|
257
|
+
seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]);
|
|
258
|
+
const host = fakeDaemonHost();
|
|
259
|
+
|
|
260
|
+
readModuleJournal(
|
|
261
|
+
{ moduleId: 'signal', unit: 'signal-cli', lines: 20, since: '30 min ago', grep: 'sync' },
|
|
262
|
+
db,
|
|
263
|
+
host.runner,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expect(host.commands).toHaveLength(1);
|
|
267
|
+
const remote = remotePayload(host.commands[0]);
|
|
268
|
+
expect(remote).toBe(
|
|
269
|
+
"journalctl -u 'signal-cli' --no-pager -n 20 --since '30 min ago' | grep -F 'sync'",
|
|
270
|
+
);
|
|
271
|
+
for (const forbidden of [
|
|
272
|
+
'systemctl',
|
|
273
|
+
'receive',
|
|
274
|
+
'send',
|
|
275
|
+
'daemon',
|
|
276
|
+
'link',
|
|
277
|
+
'register',
|
|
278
|
+
'rm ',
|
|
279
|
+
'>',
|
|
280
|
+
'tee',
|
|
281
|
+
]) {
|
|
282
|
+
expect(shellSyntax(remote)).not.toContain(forbidden);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('a grep pattern cannot smuggle a second command onto the host', () => {
|
|
287
|
+
seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]);
|
|
288
|
+
const host = fakeDaemonHost();
|
|
289
|
+
|
|
290
|
+
readModuleJournal(
|
|
291
|
+
{ moduleId: 'signal', grep: "'; systemctl stop signal-cli; #" },
|
|
292
|
+
db,
|
|
293
|
+
host.runner,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// The injected text survives as DATA — a quoted argument to grep — and
|
|
297
|
+
// never as syntax the remote shell would execute.
|
|
298
|
+
const syntax = shellSyntax(remotePayload(host.commands[0]));
|
|
299
|
+
expect(syntax).not.toContain(';');
|
|
300
|
+
expect(syntax).not.toContain('systemctl');
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only daemon-journal retrieval for any deployed module (Decision 5 of
|
|
3
|
+
* openspec/changes/fix-signal-inbound-delivery).
|
|
4
|
+
*
|
|
5
|
+
* The gap this closes: a module's daemon knows something celilo cannot report.
|
|
6
|
+
* The decisive evidence in #501 was a `Received sync sent message` line in
|
|
7
|
+
* `journalctl -u signal-cli` — proof the transport HAD a message celilo never
|
|
8
|
+
* saw — and it was reachable only by SSH, so the contradiction could not be
|
|
9
|
+
* established through celilo at all.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately general to every module rather than built for signal: any
|
|
12
|
+
* module can hide the same class of evidence.
|
|
13
|
+
*
|
|
14
|
+
* READ-ONLY is load-bearing, not aspirational. A diagnostic that consumed
|
|
15
|
+
* inbound messages would steal the very replies the collection path needs,
|
|
16
|
+
* turning the debugging tool into a second cause of the bug. The property is
|
|
17
|
+
* structural: `plan()` is pure and emits nothing but a journalctl query, and
|
|
18
|
+
* `readModuleJournal` reaches the host through exactly one primitive —
|
|
19
|
+
* `tailLog`, which reads the journal and touches no daemon state. The tests
|
|
20
|
+
* assert on the command string that reaches the SSH seam, so a future edit
|
|
21
|
+
* that smuggles in a `systemctl`/`send`/`receive` fails the suite.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { type DeployedSystem, type Runner, execRunner, tailLog } from '@celilo/capabilities';
|
|
25
|
+
import { eq } from 'drizzle-orm';
|
|
26
|
+
import type { DbClient } from '../db/client';
|
|
27
|
+
import { modules } from '../db/schema';
|
|
28
|
+
import { getModuleSystems } from './deployed-systems';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* systemd unit names, plus the `*`/`?` globs journalctl's `-u` accepts. The
|
|
32
|
+
* allowlist is the injection guard: the unit lands inside a remote shell
|
|
33
|
+
* command, so nothing outside this set may reach it.
|
|
34
|
+
*/
|
|
35
|
+
const UNIT_PATTERN = /^[A-Za-z0-9@._:\-*?]+$/;
|
|
36
|
+
|
|
37
|
+
/** journalctl `--since` accepts timestamps and relative English ("5 min ago"). */
|
|
38
|
+
const SINCE_PATTERN = /^[A-Za-z0-9 :+\-.]+$/;
|
|
39
|
+
|
|
40
|
+
export interface JournalRequest {
|
|
41
|
+
moduleId: string;
|
|
42
|
+
/** systemd unit or glob. Defaults to `<moduleId>*` — signal → `signal-cli`. */
|
|
43
|
+
unit?: string;
|
|
44
|
+
lines?: number;
|
|
45
|
+
since?: string;
|
|
46
|
+
grep?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One system's journal read. `ok: false` carries why in `error`. */
|
|
50
|
+
export interface SystemJournal {
|
|
51
|
+
system: string;
|
|
52
|
+
hostname: string;
|
|
53
|
+
ipv4Address: string;
|
|
54
|
+
unit: string;
|
|
55
|
+
ok: boolean;
|
|
56
|
+
lines: string[];
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface JournalReport {
|
|
61
|
+
moduleId: string;
|
|
62
|
+
systems: SystemJournal[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The single remote query this operation is allowed to make. */
|
|
66
|
+
export interface JournalPlan {
|
|
67
|
+
unit: string;
|
|
68
|
+
lines: number;
|
|
69
|
+
since?: string;
|
|
70
|
+
grep?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Policy + planning: validate the request and resolve the unit pattern. Pure —
|
|
75
|
+
* no DB, no network — so the read-only property is testable without mocks.
|
|
76
|
+
*
|
|
77
|
+
* Returns the plan, or a message naming what was rejected.
|
|
78
|
+
*/
|
|
79
|
+
export function planJournalRead(req: JournalRequest): JournalPlan | { error: string } {
|
|
80
|
+
// A module id is a systemd-safe kebab-case token by celilo's own naming rule,
|
|
81
|
+
// so `<id>*` is a safe default: it catches `signal-cli` for module `signal`
|
|
82
|
+
// and `caddy` for module `caddy` without the manifest having to declare one.
|
|
83
|
+
const unit = req.unit ?? `${req.moduleId}*`;
|
|
84
|
+
if (!UNIT_PATTERN.test(unit)) {
|
|
85
|
+
return { error: `Invalid unit '${unit}': expected a systemd unit name or glob` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const lines = req.lines ?? 100;
|
|
89
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > 10_000) {
|
|
90
|
+
return { error: '--lines requires an integer between 1 and 10000' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (req.since !== undefined && !SINCE_PATTERN.test(req.since)) {
|
|
94
|
+
return { error: `Invalid --since '${req.since}': expected a timestamp or e.g. '5 min ago'` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { unit, lines, since: req.since, grep: req.grep };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Execution: run the planned journal read against every system serving the
|
|
102
|
+
* module. A host that cannot be reached is reported, never swallowed
|
|
103
|
+
* (Rule 6.2, and Decision 7 — nothing unrecognised disappears quietly).
|
|
104
|
+
*/
|
|
105
|
+
export function readModuleJournal(
|
|
106
|
+
req: JournalRequest,
|
|
107
|
+
db: DbClient,
|
|
108
|
+
runner: Runner = execRunner,
|
|
109
|
+
): JournalReport | { error: string } {
|
|
110
|
+
const module = db.select().from(modules).where(eq(modules.id, req.moduleId)).get();
|
|
111
|
+
if (!module) {
|
|
112
|
+
return { error: `Module not found: ${req.moduleId}` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const plan = planJournalRead(req);
|
|
116
|
+
if ('error' in plan) return plan;
|
|
117
|
+
|
|
118
|
+
const systems = getModuleSystems(req.moduleId, db);
|
|
119
|
+
if (systems.length === 0) {
|
|
120
|
+
return {
|
|
121
|
+
error: `Module '${req.moduleId}' has no deployed systems — nothing to read a journal from.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
moduleId: req.moduleId,
|
|
127
|
+
systems: systems.map((system) => readOne(system, plan, runner)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readOne(system: DeployedSystem, plan: JournalPlan, runner: Runner): SystemJournal {
|
|
132
|
+
const base = {
|
|
133
|
+
system: system.name,
|
|
134
|
+
hostname: system.hostname,
|
|
135
|
+
ipv4Address: system.ipv4_address,
|
|
136
|
+
unit: plan.unit,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const result = tailLog({
|
|
140
|
+
target: system,
|
|
141
|
+
unit: `'${plan.unit}'`, // quoted so the remote shell doesn't glob-expand it
|
|
142
|
+
lines: plan.lines,
|
|
143
|
+
since: plan.since,
|
|
144
|
+
grep: plan.grep,
|
|
145
|
+
runner,
|
|
146
|
+
timeoutMs: 20_000,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
ok: false,
|
|
153
|
+
lines: [],
|
|
154
|
+
error: (result.stderr || result.stdout).trim() || 'journal read failed with no output',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const text = result.stdout.trim();
|
|
159
|
+
return { ...base, ok: true, lines: text ? text.split('\n') : [] };
|
|
160
|
+
}
|