@celilo/cli 0.19.0 → 0.21.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 +2 -2
- package/CELILO_SUBSYSTEMS.md +4 -2
- package/drizzle/0020_dns_registrations_drop_ip.sql +25 -0
- package/drizzle/0021_dns_registration_consumers.sql +63 -0
- package/drizzle/0022_dns_registrations_companion.sql +15 -0
- package/drizzle/0023_public_dns_evidence.sql +19 -0
- package/drizzle/meta/_journal.json +29 -1
- package/package.json +4 -4
- package/schemas/system_config.json +22 -11
- package/src/api/remote-client.test.ts +34 -12
- package/src/api/serve.ts +234 -38
- package/src/api/sessions.test.ts +196 -0
- package/src/api/sessions.ts +278 -0
- package/src/cli/commands/backup-sweep.ts +25 -9
- package/src/cli/commands/dns.ts +8 -4
- package/src/cli/commands/events.ts +64 -5
- package/src/cli/commands/module-update.ts +34 -11
- package/src/cli/commands/system-audit.ts +15 -0
- package/src/cli/commands/system-migrate.test.ts +25 -4
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +22 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/dns-registrations-migration.test.ts +205 -0
- package/src/db/schema.ts +77 -8
- package/src/hooks/define-hook.test.ts +3 -3
- package/src/hooks/executor.test.ts +58 -0
- package/src/hooks/executor.ts +67 -7
- package/src/hooks/run-named-hook.ts +7 -1
- package/src/hooks/test-fixtures/silent-hook.ts +20 -0
- package/src/module/packaging/build.ts +14 -0
- package/src/services/alerting/builtin-monitors.ts +3 -0
- package/src/services/alerting/builtin-source.ts +23 -0
- package/src/services/audit/index.test.ts +2 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/public-dns-source.ts +55 -0
- package/src/services/audit/public-dns.test.ts +209 -0
- package/src/services/audit/public-dns.ts +286 -0
- package/src/services/audit/types.ts +1 -0
- package/src/services/backup-metadata.ts +17 -0
- package/src/services/backup-staging.test.ts +98 -0
- package/src/services/backup-staging.ts +73 -1
- package/src/services/backup-sweep.test.ts +15 -0
- package/src/services/backup-sweep.ts +17 -1
- package/src/services/bus-interview-park.test.ts +179 -0
- package/src/services/bus-interview.ts +13 -8
- package/src/services/dns-registrations.test.ts +78 -16
- package/src/services/dns-registrations.ts +107 -19
- package/src/services/fleet-checks.test.ts +47 -1
- package/src/services/fleet-checks.ts +36 -4
- package/src/services/interview-errors.ts +24 -7
- package/src/services/module-subscriptions.test.ts +9 -0
- package/src/services/public-dns-probe.test.ts +81 -0
- package/src/services/public-dns-probe.ts +156 -0
- package/src/services/remote-responder.test.ts +33 -20
- package/src/services/remote-responder.ts +10 -6
- package/src/services/update/orchestrator.test.ts +2 -0
package/src/api/serve.ts
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
* Remote API server — `celilo api-serve --principal=<id>` (Slices 1–3).
|
|
3
3
|
*
|
|
4
4
|
* Invoked as the sshd forced command for an enrolled key, so it starts already
|
|
5
|
-
* authenticated as `principal`. Reads `command`/`answer
|
|
6
|
-
* stdin/stdout, authorizes each command
|
|
7
|
-
* (deny-by-default), and — if allowed — runs it
|
|
8
|
-
* translating the child's protocol-mode output into
|
|
9
|
-
* terminal `result`. While a command runs, a remote
|
|
10
|
-
* bus to the wire so mid-run `interview`s are
|
|
11
|
-
* attempt is audited to stderr.
|
|
5
|
+
* authenticated as `principal`. Reads `command`/`answer`/`unanswerable`/
|
|
6
|
+
* `attach`/`cancel` messages as NDJSON on stdin/stdout, authorizes each command
|
|
7
|
+
* against the principal's grants (deny-by-default), and — if allowed — runs it
|
|
8
|
+
* as a child `celilo` process, translating the child's protocol-mode output into
|
|
9
|
+
* `progress`/`log` and a terminal `result`. While a command runs, a remote
|
|
10
|
+
* responder bridges the event bus to the wire so mid-run `interview`s are
|
|
11
|
+
* answered by the client. Every attempt is audited to stderr.
|
|
12
|
+
*
|
|
13
|
+
* When the client cannot decide a question, the command **parks**: the query is
|
|
14
|
+
* left unanswered on the bus, the child stays alive past the end of the ssh
|
|
15
|
+
* session, its output is buffered in the session registry, and the client is
|
|
16
|
+
* told `blocked` with the session and query ids (celilo#609). Another responder
|
|
17
|
+
* answers by event id; a later `attach` collects the outcome.
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
20
|
import { createInterface } from 'node:readline';
|
|
@@ -16,16 +22,30 @@ import {
|
|
|
16
22
|
API_PROTOCOL_VERSION,
|
|
17
23
|
ClientMessageSchema,
|
|
18
24
|
type ServerMessage,
|
|
25
|
+
ServerMessageSchema,
|
|
19
26
|
translateOutputLine,
|
|
20
27
|
} from '@celilo/core';
|
|
21
28
|
import { parseArguments } from '../cli/parser';
|
|
22
29
|
import { getEventBusPath } from '../config/paths';
|
|
23
30
|
import { isAuthorized } from '../services/api-access';
|
|
31
|
+
import { EVENT_TYPES } from '../services/bus-interview';
|
|
24
32
|
import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
|
|
33
|
+
import {
|
|
34
|
+
SessionWriter,
|
|
35
|
+
abandonSession,
|
|
36
|
+
expiryReason,
|
|
37
|
+
readSession,
|
|
38
|
+
reapExpiredSessions,
|
|
39
|
+
replayOutput,
|
|
40
|
+
stillParked,
|
|
41
|
+
} from './sessions';
|
|
25
42
|
|
|
26
43
|
/** Exit code returned to the client when authz denies a command. */
|
|
27
44
|
const EXIT_PERMISSION_DENIED = 126;
|
|
28
45
|
|
|
46
|
+
/** How often an attached client polls a session's buffer for new output. */
|
|
47
|
+
const ATTACH_POLL_MS = 250;
|
|
48
|
+
|
|
29
49
|
function send(msg: ServerMessage): void {
|
|
30
50
|
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
31
51
|
}
|
|
@@ -44,8 +64,20 @@ function opOf(argv: string[]): { command: string; subcommand?: string; label: st
|
|
|
44
64
|
return { command: parsed.command, subcommand: parsed.subcommand, label };
|
|
45
65
|
}
|
|
46
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Emit a message to the attached client (if any) AND to the session buffer, so
|
|
69
|
+
* output produced while nobody is attached is not lost.
|
|
70
|
+
*/
|
|
71
|
+
function emit(session: SessionWriter, msg: ServerMessage, attached: () => boolean): void {
|
|
72
|
+
session.append(msg);
|
|
73
|
+
if (attached()) send(msg);
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
/** Drain a child stream line-by-line, forwarding each line as a message. */
|
|
48
|
-
async function pumpLines(
|
|
77
|
+
async function pumpLines(
|
|
78
|
+
stream: ReadableStream<Uint8Array>,
|
|
79
|
+
forward: (msg: ServerMessage) => void,
|
|
80
|
+
): Promise<void> {
|
|
49
81
|
const decoder = new TextDecoder();
|
|
50
82
|
let buffer = '';
|
|
51
83
|
for await (const chunk of stream) {
|
|
@@ -54,17 +86,17 @@ async function pumpLines(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
|
54
86
|
while (nl >= 0) {
|
|
55
87
|
const line = buffer.slice(0, nl);
|
|
56
88
|
buffer = buffer.slice(nl + 1);
|
|
57
|
-
|
|
89
|
+
forward(translateOutputLine(line));
|
|
58
90
|
nl = buffer.indexOf('\n');
|
|
59
91
|
}
|
|
60
92
|
}
|
|
61
93
|
if (buffer.length > 0) {
|
|
62
|
-
|
|
94
|
+
forward(translateOutputLine(buffer));
|
|
63
95
|
}
|
|
64
96
|
}
|
|
65
97
|
|
|
66
98
|
/** Run an authorized command as a child; returns its exit code. */
|
|
67
|
-
async function runCommand(argv: string[]): Promise<number> {
|
|
99
|
+
async function runCommand(argv: string[], forward: (msg: ServerMessage) => void): Promise<number> {
|
|
68
100
|
// Re-invoke this same CLI as a child. The child is non-TTY (piped stdout) so
|
|
69
101
|
// ProgressDisplay resolves to protocol mode and emits `[progress:*]` markers.
|
|
70
102
|
const child = Bun.spawn([process.execPath, Bun.main, ...argv], {
|
|
@@ -73,49 +105,132 @@ async function runCommand(argv: string[]): Promise<number> {
|
|
|
73
105
|
stderr: 'pipe',
|
|
74
106
|
});
|
|
75
107
|
|
|
76
|
-
await Promise.all([pumpLines(child.stdout), pumpLines(child.stderr)]);
|
|
108
|
+
await Promise.all([pumpLines(child.stdout, forward), pumpLines(child.stderr, forward)]);
|
|
77
109
|
const exitCode = await child.exited;
|
|
78
|
-
|
|
110
|
+
forward({ type: 'result', success: exitCode === 0, exitCode });
|
|
79
111
|
return exitCode;
|
|
80
112
|
}
|
|
81
113
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Replay a parked session's buffered output to the attaching client, then tail
|
|
116
|
+
* it until the session goes terminal. Deny-by-default on the owning principal.
|
|
117
|
+
*/
|
|
118
|
+
async function handleAttach(principal: string, sessionId: string): Promise<void> {
|
|
119
|
+
const record = readSession(sessionId);
|
|
120
|
+
if (!record) {
|
|
121
|
+
send({ type: 'error', error: `no such session: ${sessionId}` });
|
|
122
|
+
send({ type: 'result', success: false, exitCode: 1 });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (record.principal !== principal) {
|
|
126
|
+
send({
|
|
127
|
+
type: 'error',
|
|
128
|
+
error: `permission denied: session ${sessionId} belongs to another principal`,
|
|
129
|
+
});
|
|
92
130
|
send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
|
|
93
|
-
audit(principal,
|
|
131
|
+
audit(principal, `attach:${sessionId}`, 'deny');
|
|
94
132
|
return;
|
|
95
133
|
}
|
|
134
|
+
audit(principal, `attach:${sessionId}`, 'allow');
|
|
135
|
+
|
|
136
|
+
let cursor = 0;
|
|
137
|
+
for (;;) {
|
|
138
|
+
const { messages, next } = replayOutput(sessionId, cursor);
|
|
139
|
+
cursor = next;
|
|
140
|
+
for (const line of messages) {
|
|
141
|
+
const parsed = ServerMessageSchema.safeParse(JSON.parse(line));
|
|
142
|
+
if (!parsed.success) continue;
|
|
143
|
+
send(parsed.data);
|
|
144
|
+
if (parsed.data.type === 'result') return;
|
|
145
|
+
}
|
|
146
|
+
const current = readSession(sessionId);
|
|
147
|
+
if (!current) return;
|
|
148
|
+
// Still waiting on a decision → say so rather than tailing forever. Asked of
|
|
149
|
+
// the bus, not of the record: the answer arrives from a third party the
|
|
150
|
+
// owning process never hears from, so its `state` lags.
|
|
151
|
+
if (current.parkedEventId && stillParked(current, getEventBusPath())) {
|
|
152
|
+
send({
|
|
153
|
+
type: 'blocked',
|
|
154
|
+
sessionId,
|
|
155
|
+
eventId: current.parkedEventId,
|
|
156
|
+
question: current.question ?? '',
|
|
157
|
+
key: current.questionKey ?? undefined,
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await new Promise((r) => setTimeout(r, ATTACH_POLL_MS));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
96
164
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
165
|
+
/** Abandon a parked session on the operator's say-so, before its TTL. */
|
|
166
|
+
function handleCancel(principal: string, sessionId: string): void {
|
|
167
|
+
const record = readSession(sessionId);
|
|
168
|
+
if (!record) {
|
|
169
|
+
send({ type: 'error', error: `no such session: ${sessionId}` });
|
|
170
|
+
send({ type: 'result', success: false, exitCode: 1 });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (record.principal !== principal) {
|
|
174
|
+
send({
|
|
175
|
+
type: 'error',
|
|
176
|
+
error: `permission denied: session ${sessionId} belongs to another principal`,
|
|
177
|
+
});
|
|
178
|
+
send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
|
|
179
|
+
audit(principal, `cancel:${sessionId}`, 'deny');
|
|
180
|
+
return;
|
|
104
181
|
}
|
|
182
|
+
abandonSession(record, {
|
|
183
|
+
busDbPath: getEventBusPath(),
|
|
184
|
+
reason: `Session ${sessionId} was cancelled by ${principal} with "${record.question ?? 'the question'}" still unanswered.`,
|
|
185
|
+
emittedBy: `api:${principal}`,
|
|
186
|
+
});
|
|
187
|
+
audit(principal, `cancel:${sessionId}`, 'allow');
|
|
188
|
+
send({ type: 'result', success: true, exitCode: 0 });
|
|
105
189
|
}
|
|
106
190
|
|
|
107
191
|
export async function apiServeMode(principal: string): Promise<void> {
|
|
108
192
|
send({ type: 'ready', protocolVersion: API_PROTOCOL_VERSION });
|
|
109
193
|
|
|
110
194
|
const busDbPath = getEventBusPath();
|
|
195
|
+
// A session whose owning process died (reboot, OOM) would otherwise stay
|
|
196
|
+
// parked forever, holding whatever its command holds.
|
|
197
|
+
reapExpiredSessions({ busDbPath });
|
|
198
|
+
|
|
199
|
+
// When the ssh client goes away, sshd hangs up its forced command. Ignoring
|
|
200
|
+
// SIGHUP is what lets a parked command outlive the session that started it —
|
|
201
|
+
// the TTL reaper, not the transport, is what bounds it now.
|
|
202
|
+
process.on('SIGHUP', () => {});
|
|
203
|
+
|
|
111
204
|
const pendingAnswers = new Map<
|
|
112
205
|
string,
|
|
113
|
-
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
|
|
206
|
+
{ resolve: (value: unknown) => void; reject: (error: Error) => void; interview: WireInterview }
|
|
114
207
|
>();
|
|
115
208
|
|
|
209
|
+
/** Set while a command is running; the parked child outlives the transport. */
|
|
210
|
+
const live: { session: SessionWriter | null; clientAttached: boolean } = {
|
|
211
|
+
session: null,
|
|
212
|
+
clientAttached: true,
|
|
213
|
+
};
|
|
214
|
+
let commandRunning: Promise<void> | null = null;
|
|
215
|
+
|
|
116
216
|
const ask = (interview: WireInterview): Promise<unknown> =>
|
|
117
217
|
new Promise((resolve, reject) => {
|
|
118
|
-
pendingAnswers.set(interview.id, { resolve, reject });
|
|
218
|
+
pendingAnswers.set(interview.id, { resolve, reject, interview });
|
|
219
|
+
if (!live.clientAttached) {
|
|
220
|
+
// Nobody to ask. Re-park on THIS question: a detached command that was
|
|
221
|
+
// answered and moved on is now waiting on a *different* event, and a
|
|
222
|
+
// record still naming the previous one is actively wrong — it hides the
|
|
223
|
+
// live question from `events list-unanswered`, and it aims the TTL
|
|
224
|
+
// reaper at a question that was already decided, so the reaper retires
|
|
225
|
+
// the session while the child is still alive and blocked.
|
|
226
|
+
live.session?.park({
|
|
227
|
+
eventId: interview.id,
|
|
228
|
+
eventType: EVENT_TYPES.interviewRequired(interview.scope, interview.key),
|
|
229
|
+
question: interview.message,
|
|
230
|
+
questionKey: `${interview.scope}.${interview.key}`,
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
119
234
|
send({
|
|
120
235
|
type: 'interview',
|
|
121
236
|
id: interview.id,
|
|
@@ -131,6 +246,46 @@ export async function apiServeMode(principal: string): Promise<void> {
|
|
|
131
246
|
});
|
|
132
247
|
});
|
|
133
248
|
|
|
249
|
+
const handleCommand = async (argv: string[]): Promise<void> => {
|
|
250
|
+
const { command, subcommand, label } = opOf(argv);
|
|
251
|
+
|
|
252
|
+
if (!(await isAuthorized(principal, command, subcommand))) {
|
|
253
|
+
send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
|
|
254
|
+
send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
|
|
255
|
+
audit(principal, label, 'deny');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const writer = SessionWriter.create({ principal, argv });
|
|
260
|
+
live.session = writer;
|
|
261
|
+
const reaper = setTimeout(
|
|
262
|
+
() => {
|
|
263
|
+
if (writer.current.state !== 'parked') return;
|
|
264
|
+
abandonSession(writer.current, {
|
|
265
|
+
busDbPath,
|
|
266
|
+
reason: expiryReason(writer.current),
|
|
267
|
+
emittedBy: `api:${principal}`,
|
|
268
|
+
});
|
|
269
|
+
writer.finish('abandoned');
|
|
270
|
+
},
|
|
271
|
+
Math.max(0, writer.current.expiresAt - Date.now()),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Bridge the bus to the wire so mid-run interviews reach the client.
|
|
275
|
+
const responder = startRemoteResponder({ busDbPath, ask, emittedBy: `api:${principal}` });
|
|
276
|
+
try {
|
|
277
|
+
const exitCode = await runCommand(argv, (msg) =>
|
|
278
|
+
emit(writer, msg, () => live.clientAttached),
|
|
279
|
+
);
|
|
280
|
+
writer.finish('finished');
|
|
281
|
+
audit(principal, label, 'allow', exitCode);
|
|
282
|
+
} finally {
|
|
283
|
+
clearTimeout(reaper);
|
|
284
|
+
responder.close();
|
|
285
|
+
live.session = null;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
134
289
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
135
290
|
|
|
136
291
|
for await (const line of rl) {
|
|
@@ -148,20 +303,61 @@ export async function apiServeMode(principal: string): Promise<void> {
|
|
|
148
303
|
const pending = pendingAnswers.get(msg.id);
|
|
149
304
|
if (pending) {
|
|
150
305
|
pendingAnswers.delete(msg.id);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
306
|
+
live.session?.unpark();
|
|
307
|
+
pending.resolve(msg.value);
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (msg.type === 'unanswerable') {
|
|
313
|
+
// The client cannot decide. Do NOT resolve the question — park on it and
|
|
314
|
+
// tell the client where it stands. The responder, seeing `ask` reject,
|
|
315
|
+
// emits nothing on the bus, so the query is still there to be answered.
|
|
316
|
+
const pending = pendingAnswers.get(msg.id);
|
|
317
|
+
if (!pending) continue;
|
|
318
|
+
pendingAnswers.delete(msg.id);
|
|
319
|
+
const { interview } = pending;
|
|
320
|
+
pending.reject(new Error(msg.reason));
|
|
321
|
+
if (live.session) {
|
|
322
|
+
live.session.park({
|
|
323
|
+
eventId: msg.id,
|
|
324
|
+
eventType: EVENT_TYPES.interviewRequired(interview.scope, interview.key),
|
|
325
|
+
question: interview.message,
|
|
326
|
+
questionKey: `${interview.scope}.${interview.key}`,
|
|
327
|
+
});
|
|
328
|
+
send({
|
|
329
|
+
type: 'blocked',
|
|
330
|
+
sessionId: live.session.id,
|
|
331
|
+
eventId: msg.id,
|
|
332
|
+
question: interview.message,
|
|
333
|
+
key: `${interview.scope}.${interview.key}`,
|
|
334
|
+
});
|
|
155
335
|
}
|
|
156
336
|
continue;
|
|
157
337
|
}
|
|
158
338
|
|
|
339
|
+
if (msg.type === 'attach') {
|
|
340
|
+
await handleAttach(principal, msg.sessionId);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (msg.type === 'cancel') {
|
|
345
|
+
handleCancel(principal, msg.sessionId);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
159
349
|
if (msg.type === 'command') {
|
|
160
350
|
// Fire-and-forget so the read loop keeps consuming `answer` messages while
|
|
161
351
|
// the command runs — mid-run interviews are answered in-flight.
|
|
162
|
-
|
|
352
|
+
commandRunning = handleCommand(msg.argv);
|
|
353
|
+
void commandRunning;
|
|
163
354
|
}
|
|
164
355
|
}
|
|
165
356
|
|
|
357
|
+
// stdin closed: the client is gone. A parked command must NOT die with it —
|
|
358
|
+
// that is the bug. Stay alive (output goes to the session buffer) until the
|
|
359
|
+
// command finishes or the reaper abandons it.
|
|
360
|
+
live.clientAttached = false;
|
|
361
|
+
if (commandRunning) await commandRunning;
|
|
166
362
|
process.exit(0);
|
|
167
363
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does the reaper actually fire, and does the command say the right thing?
|
|
3
|
+
*
|
|
4
|
+
* A parked session holds a live child process and possibly a module-operation
|
|
5
|
+
* lock. The TTL reaper is the only thing standing between that and the 20-day
|
|
6
|
+
* lock outage `module operations` was built for — so a reaper nobody has watched
|
|
7
|
+
* fire is decorative. This drives a real `module update` sweep into a park,
|
|
8
|
+
* expires its session deliberately, and asserts on what comes back.
|
|
9
|
+
*
|
|
10
|
+
* What it asserts is the *value*, not liveness. Three outcomes are confusable
|
|
11
|
+
* here and only one is correct:
|
|
12
|
+
*
|
|
13
|
+
* declined — someone said no. (`{value: false}`)
|
|
14
|
+
* unanswered — nobody was listening at all. (responder-probe throw)
|
|
15
|
+
* abandoned — the question stood; the deadline passed; nobody decided.
|
|
16
|
+
*
|
|
17
|
+
* Reporting an abandonment as "operator declined" is the original celilo#609
|
|
18
|
+
* bug wearing a different hat, so that string is asserted absent.
|
|
19
|
+
*
|
|
20
|
+
* WATCHED RED: with `abandonSession` emitting `{value: false}` instead of
|
|
21
|
+
* `{abandoned}` — i.e. a reaper that resolves the question the old way — this
|
|
22
|
+
* file fails on `expect(report).not.toContain('operator declined')`. With the
|
|
23
|
+
* reaper removed entirely it fails by timeout, the sweep never resuming.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
27
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { type Bus, type BusEvent, defineEvents, openBus } from '@celilo/event-bus';
|
|
31
|
+
import { handleModuleUpdate } from '../cli/commands/module-update';
|
|
32
|
+
import { getDb } from '../db/client';
|
|
33
|
+
import { modules } from '../db/schema';
|
|
34
|
+
import { RESPONDER_PROBE_EVENT } from '../services/responder-probe';
|
|
35
|
+
import { SessionWriter, listSessions, reapExpiredSessions } from './sessions';
|
|
36
|
+
|
|
37
|
+
const NO_SCHEMAS = defineEvents({});
|
|
38
|
+
const QUERY_TYPE = 'interview.required.module-upgrade:iptables.apply_breaking';
|
|
39
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
40
|
+
|
|
41
|
+
let dir: string;
|
|
42
|
+
let busDbPath: string;
|
|
43
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
44
|
+
let registryUrl: string;
|
|
45
|
+
|
|
46
|
+
/** A responder that proves liveness but never answers the question. */
|
|
47
|
+
function probeOnlyResponder(dbPath: string): { bus: Bus; close: () => void } {
|
|
48
|
+
const bus = openBus({ dbPath, events: NO_SCHEMAS });
|
|
49
|
+
const watch = bus.watch(RESPONDER_PROBE_EVENT, async (event) => {
|
|
50
|
+
if (event.replyFor !== null) return;
|
|
51
|
+
bus.emitRaw(
|
|
52
|
+
`${event.type}.reply`,
|
|
53
|
+
{ kind: 'daemon', emittedBy: 'reaper-test' },
|
|
54
|
+
{ replyFor: event.id, emittedBy: 'reaper-test' },
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
bus,
|
|
59
|
+
close: () => {
|
|
60
|
+
watch.close();
|
|
61
|
+
bus.close();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-reap-'));
|
|
68
|
+
busDbPath = join(dir, 'events.db');
|
|
69
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
70
|
+
process.env.CELILO_DATA_DIR = dir;
|
|
71
|
+
process.env.CELILO_ORIGINAL_CWD = dir;
|
|
72
|
+
process.env.EVENT_BUS_DB = busDbPath;
|
|
73
|
+
|
|
74
|
+
getDb()
|
|
75
|
+
.insert(modules)
|
|
76
|
+
.values({
|
|
77
|
+
id: 'iptables',
|
|
78
|
+
name: 'iptables',
|
|
79
|
+
sourcePath: join(dir, 'installed'),
|
|
80
|
+
version: '1.0.2+9',
|
|
81
|
+
manifestData: { celilo_contract: '1.0', id: 'iptables', name: 'iptables', version: '1.0.2' },
|
|
82
|
+
})
|
|
83
|
+
.run();
|
|
84
|
+
|
|
85
|
+
server = Bun.serve({
|
|
86
|
+
port: 0,
|
|
87
|
+
fetch(req) {
|
|
88
|
+
if (new URL(req.url).pathname === '/index/ip/ta/iptables') {
|
|
89
|
+
return new Response(
|
|
90
|
+
`${JSON.stringify({ name: 'iptables', vers: '2.0.0+1', deps: [], cksum: 'x' })}\n`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return new Response('not found', { status: 404 });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
registryUrl = `http://localhost:${server.port}`;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
server.stop(true);
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
103
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
104
|
+
process.env.CELILO_ORIGINAL_CWD = undefined;
|
|
105
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an expired session is reaped and the command reports abandoned, not declined', async () => {
|
|
109
|
+
const responder = probeOnlyResponder(busDbPath);
|
|
110
|
+
const observer = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
let settled = false;
|
|
114
|
+
const sweep = handleModuleUpdate([], { registry: registryUrl }).then((r) => {
|
|
115
|
+
settled = true;
|
|
116
|
+
return r;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await sleep(600);
|
|
120
|
+
expect(settled).toBe(false);
|
|
121
|
+
|
|
122
|
+
const query = observer.recentEvents({ type: QUERY_TYPE })[0] as BusEvent;
|
|
123
|
+
expect(query).toBeDefined();
|
|
124
|
+
|
|
125
|
+
// A session parked on that question, already past its deadline.
|
|
126
|
+
const session = SessionWriter.create({
|
|
127
|
+
principal: 'tester',
|
|
128
|
+
argv: ['module', 'update'],
|
|
129
|
+
ttlMs: -1,
|
|
130
|
+
});
|
|
131
|
+
session.park({
|
|
132
|
+
eventId: String(query.id),
|
|
133
|
+
eventType: QUERY_TYPE,
|
|
134
|
+
question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?',
|
|
135
|
+
questionKey: 'module-upgrade:iptables.apply_breaking',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const reaped = reapExpiredSessions({ busDbPath });
|
|
139
|
+
expect(reaped.map((r) => r.sessionId)).toEqual([session.id]);
|
|
140
|
+
|
|
141
|
+
// The command resumes — and says nobody decided, not that anyone declined.
|
|
142
|
+
const result = await sweep;
|
|
143
|
+
const report = result.success ? (result.message ?? '') : (result.error ?? '');
|
|
144
|
+
expect(report).toContain('Abandoned:');
|
|
145
|
+
expect(report).toContain('never decided');
|
|
146
|
+
expect(report).not.toContain('operator declined');
|
|
147
|
+
// ...and not the *unanswered* wording either: a responder was listening.
|
|
148
|
+
expect(report).not.toContain('could not be answered');
|
|
149
|
+
expect(report).toContain('iptables');
|
|
150
|
+
// A breaking update that silently didn't land must not read as success.
|
|
151
|
+
expect(result.success).toBe(false);
|
|
152
|
+
// The module is untouched — nothing was applied on nobody's authority.
|
|
153
|
+
expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
|
|
154
|
+
|
|
155
|
+
// The record is retained, so a repeatedly-parking command is detectable.
|
|
156
|
+
const retained = listSessions();
|
|
157
|
+
expect(retained).toHaveLength(1);
|
|
158
|
+
expect(retained[0].state).toBe('abandoned');
|
|
159
|
+
expect(retained[0].question).toContain('iptables');
|
|
160
|
+
} finally {
|
|
161
|
+
observer.close();
|
|
162
|
+
responder.close();
|
|
163
|
+
}
|
|
164
|
+
}, 30_000);
|
|
165
|
+
|
|
166
|
+
test('a question answered before the deadline is never overwritten by the reaper', async () => {
|
|
167
|
+
const observer = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
168
|
+
try {
|
|
169
|
+
const query = observer.emitRaw(QUERY_TYPE, { scope: 'x', key: 'y' });
|
|
170
|
+
observer.emitRaw(
|
|
171
|
+
`${QUERY_TYPE}.reply`,
|
|
172
|
+
{ value: true },
|
|
173
|
+
{ replyFor: query.id, emittedBy: 'a-real-decider' },
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const session = SessionWriter.create({
|
|
177
|
+
principal: 'tester',
|
|
178
|
+
argv: ['module', 'update'],
|
|
179
|
+
ttlMs: -1,
|
|
180
|
+
});
|
|
181
|
+
session.park({
|
|
182
|
+
eventId: String(query.id),
|
|
183
|
+
eventType: QUERY_TYPE,
|
|
184
|
+
question: 'Apply breaking update for iptables?',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
reapExpiredSessions({ busDbPath });
|
|
188
|
+
|
|
189
|
+
// Exactly one reply, and it is the decision that was actually made.
|
|
190
|
+
const replies = observer.repliesFor(query.id);
|
|
191
|
+
expect(replies).toHaveLength(1);
|
|
192
|
+
expect((replies[0].payload as { value: unknown }).value).toBe(true);
|
|
193
|
+
} finally {
|
|
194
|
+
observer.close();
|
|
195
|
+
}
|
|
196
|
+
});
|