@celilo/cli 0.18.0 → 0.19.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_SUBSYSTEMS.md +4 -2
- package/package.json +2 -2
- package/src/api/remote-client.test.ts +62 -0
- package/src/api/serve.ts +14 -6
- package/src/cli/commands/apt-upgrade.test.ts +20 -1
- package/src/cli/commands/apt-upgrade.ts +12 -2
- package/src/cli/commands/events.ts +90 -0
- package/src/cli/commands/module-update.test.ts +72 -1
- package/src/cli/commands/module-update.ts +45 -22
- package/src/cli/commands/system-migrate.test.ts +56 -0
- package/src/cli/commands/system-migrate.ts +52 -4
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +5 -1
- package/src/db/migration-status.test.ts +114 -0
- package/src/db/migration-status.ts +78 -0
- package/src/db/schema-introspection.ts +8 -1
- package/src/services/bus-interview.ts +11 -5
- package/src/services/events-daemon.test.ts +244 -0
- package/src/services/events-daemon.ts +295 -8
- package/src/services/fleet-checks.test.ts +75 -4
- package/src/services/fleet-checks.ts +82 -12
- package/src/services/interview-errors.ts +20 -0
- package/src/services/remote-responder.test.ts +70 -0
- package/src/services/remote-responder.ts +27 -10
- package/src/services/responder-probe.ts +3 -1
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -232,7 +232,9 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
|
|
|
232
232
|
|
|
233
233
|
## Events
|
|
234
234
|
|
|
235
|
-
- **
|
|
235
|
+
- **Migration interrogation** — `apps/celilo/src/db/migration-status.ts` — `getMigrationStatus(sqlite, migrationsFolder)` reports applied count, latest applied migration BY TAG, and pending ones by name (joining `__drizzle_migrations.created_at` to drizzle journal `when`, which is exact). Surface: `celilo system migrate --status`, which opens the DB **read-only** on purpose — `getDb()` auto-migrates on open, so a status routed through it would repair what it claims to report and could never say "pending". Paired with `findSchemaDrift` (`db/schema-introspection.ts`), which is column-aware: a table COUNT cannot distinguish "the column migration applied" from "nothing happened", which is why a rollout asserting `backups.pid` had to reach for `sqlite3` over SSH. `checkSchemaDrift` (`services/fleet-checks.ts`) fails on a missing table, a missing column, OR an unapplied journal migration, and its summary names tables AND columns so the operator can see what was checked.
|
|
236
|
+
- **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`). **Exactly one dispatcher per bus**: `runDispatcher` refuses to start while another dispatcher's process is alive (`assertSoleDispatcher` in `dispatcher.ts`, liveness via `bus.liveDispatchers()` — `kill(pid,0)`, not heartbeat age, since a tick blocks for as long as its slowest handler). The exclusion is here rather than in the systemd unit because a stranded dispatcher can sit outside the unit's cgroup where `KillMode` cannot reach it (#580). `bus.health()` reports `dispatcherCount`/`dispatchers` and a `duplicate_dispatcher` status; `checkDispatcher` (`services/fleet-checks.ts`) fails on more than one. **Supervision** (`services/events-daemon.ts`): the unit is named `celilo-events.service` in BOTH the user and system scope, so the two are indistinguishable in every operator-facing string — `install-daemon` (which defaults to **user** scope) therefore refuses when the other scope's unit exists, and `checkDispatcher` compares the live pid against each installed unit's `MainPID` (`unitMainPid`) rather than merely testing that a unit file exists, since a file nobody is running is not supervision (#610).
|
|
237
|
+
- **Dispatcher supervision (install / restart)** — `apps/celilo/src/services/events-daemon.ts` — `installDaemon`/`planDaemonInstall`/`uninstallDaemon`/`readInstalledUnit` write the systemd unit or launchd plist without touching supervisor state, and `restartDaemon` (+ pure `orphanDispatcherPids`, `resolveRestartScope`, `supervisorCommands`) is the one verb that DOES cycle it. CLI: `celilo events install-daemon|uninstall-daemon|show-daemon|restart-daemon [--system]`. `restart-daemon` exists because the dispatcher runs the code it LOADED: celilo-mgr sat 9 days on event-bus v0.1.8 after apt installed v0.2.0, running the very bug the release fixed (celilo#604). Two things make it non-trivial and both are load-bearing: (1) it stops any live dispatcher the supervisor does not own first — an ORPHAN (PPID 1) is invisible to `systemctl restart`, and `assertSoleDispatcher` then crash-loops the unit while the old code keeps serving; (2) it verifies on the BUS that a NEW pid is live reporting `BUS_VERSION`, never off systemctl's exit code, which returns 0 into exactly that crash loop. System scope shells `sudo systemctl` (the unit is root-owned; celilo runs unprivileged), covered by the scoped `/etc/sudoers.d/celilo-events-restart` conffile that `celilo-bootstrap` ships — a unit test asserts the argv and the grant cannot drift apart.
|
|
236
238
|
|
|
237
239
|
## Remote API (drive the CLI over the wire)
|
|
238
240
|
|
|
@@ -246,7 +248,7 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
246
248
|
- **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
|
|
247
249
|
- **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
|
|
248
250
|
- **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
|
|
249
|
-
- **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100). It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`. **This upgrades celilo ITSELF — not the modules it manages. For those, see Module auto-upgrade below; the two are routinely confused.**
|
|
251
|
+
- **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100), then `celilo events restart-daemon` so the dispatcher actually runs the code just installed — a failure there fails the whole command and names which steps DID complete, because "upgraded" while the dispatcher serves stale code is the silent state celilo#604 documents. It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`. **This upgrades celilo ITSELF — not the modules it manages. For those, see Module auto-upgrade below; the two are routinely confused.**
|
|
250
252
|
- **Module auto-upgrade (registry-poll CD)** — the *pull* half of continuous deployment: celilo-mgr polls the registry and upgrades opted-in modules unattended. Spec: `openspec/specs/module-auto-upgrade/spec.md`. Entry points: `apps/celilo/src/cli/commands/module-upgrade.ts` — `runRegistryPoll` (the `--poll` path), `selectPollTargets` (pure: `autoUpgrade && latest && change ∉ {up-to-date, ahead}`), `upgradeOneModule` (update → backup → deploy → verify), `needsPreUpgradeBackup`, `pickAutoUpgrade`/`pickUpgradePolicy` (both fail closed/safe); `classifyVersionChange` in `module-update.ts` (treats a registry `+N` revision as a patch); `resolveDeployPosture` in `apps/celilo/src/services/deploy-posture.ts`. Trigger: celilo-mgmt's `registry-poll` subscription (`modules/celilo-mgmt/manifest.yml`) on `timer.tick.15m` with handler **`celilo module upgrade --poll`** — the flag is REQUIRED, since the dispatcher appends the event id positionally and a bare handler would consume it as the optional module name (silent: 3108 deliveries, 0 successes). Operator controls are framework config keys settable on ANY module (`FRAMEWORK_CONFIG_KEYS` in `module-config.ts`): `auto_upgrade` (opt-in, default false) and `upgrade_policy` (`by-semver`|`always-safe`|`always-fast`), validated at set time because both readers fail open. ⚠️ `always-safe` guarantees safe *posture*, NOT a backup — `needsPreUpgradeBackup` also requires the TARGET manifest to declare an `on_backup` hook, else it warns and proceeds. Confirm a data-bearing module declares `on_backup` before enabling `auto_upgrade` on it. The *build* half (app CI publishing a `.netapp` on merge) is not yet shipped — `openspec/changes/build-bus-poll-cd`.
|
|
251
253
|
- **MCP service (`@celilo/mcp`)** — `packages/mcp/src/` — an operator-facing stdio MCP server (official `@modelcontextprotocol/sdk`, bin `celilo-mcp`) that drives a remote celilo server over the Remote API for an AI client. Two-item config (`config.ts`: `server` + `defaultUser`, env or `~/.config/celilo-mcp/config.json`). Dual-principal auth (`auth.ts`: `celilo-mcp auth setup` enrolls read-only `celilo-mcp-ro` + full `celilo-mcp-rw` ed25519 keypairs, prints the exact `celilo api grant` lines the operator runs server-side). Transport (`transport.ts`): reuses `@celilo/core` `runRemoteClient`, selecting the principal by `ssh -i <key>` and capturing structured output. Tool surface is generated LIVE from the server's command registry — `registry-fetch.ts` fetches `celilo commands --json` (+ `service list --json` for configured providers) over the RO principal on connect; `tools-from-registry.ts` (pure) projects that into one tool per runnable leaf, grouped by top-level command (`celilo_module_*`, `celilo_proxmox_*`, …), each with a Zod input schema from the leaf's args/flags and a read/write tag → RO/RW routing, plus a generic `celilo_run` escape hatch. Auto-detect hides provider-gated groups (e.g. `celilo_proxmox_*` until a Proxmox service is configured) and re-detects on a timer, emitting `notifications/tools/list_changed` when the surface changes. Coverage gate (`tests/coverage.test.ts`) asserts every registry leaf maps to a tool. Composite RO troubleshooting tools (`troubleshoot.ts` pure correlation + `troubleshoot-tools.ts` thin adapters): `celilo_assess_module <id>` and `celilo_fleet_status` correlate `celilo audit --json` (the drift backbone) with the `module list --json` roster into a per-module / fleet-wide verdict. Design: `openspec/changes/celilo-mcp-service/proposal.md`. (Distinct from the dev/ops `@celilo/mcp-server` below.)
|
|
252
254
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
60
60
|
"@celilo/capabilities": "^0.10.0",
|
|
61
61
|
"@celilo/cli-display": "^0.1.10",
|
|
62
|
-
"@celilo/core": "^0.
|
|
62
|
+
"@celilo/core": "^0.4.0",
|
|
63
63
|
"@celilo/event-bus": "^0.2.0",
|
|
64
64
|
"@clack/prompts": "^1.1.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
@@ -89,3 +89,65 @@ test('renders a forwarded interview and sends the answer back', async () => {
|
|
|
89
89
|
expect(answer).toBeDefined();
|
|
90
90
|
expect(JSON.parse(answer as string)).toEqual({ type: 'answer', id: 'q1', value: 'myhost' });
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Regression for the fabricated "operator declined".
|
|
95
|
+
*
|
|
96
|
+
* The default renderer prompts with clack, which reads keypresses off stdin
|
|
97
|
+
* whether or not stdin is a terminal. Driven over the MCP (stdin = the JSON-RPC
|
|
98
|
+
* stream) the next newline submitted the prompt at its `initialValue` — the
|
|
99
|
+
* question's `defaultValue` — so a breaking update nobody saw came back as a
|
|
100
|
+
* considered "no". With no terminal the client must say it cannot answer.
|
|
101
|
+
*/
|
|
102
|
+
test('no TTY and no renderer → answers with an error, never the default', async () => {
|
|
103
|
+
const writes: string[] = [];
|
|
104
|
+
const encoder = new TextEncoder();
|
|
105
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
106
|
+
const stdout = new ReadableStream<Uint8Array>({
|
|
107
|
+
start(c) {
|
|
108
|
+
controller = c;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const push = (obj: unknown) => controller.enqueue(encoder.encode(`${JSON.stringify(obj)}\n`));
|
|
112
|
+
|
|
113
|
+
const transport: RemoteTransport = {
|
|
114
|
+
stdin: {
|
|
115
|
+
write(chunk: string) {
|
|
116
|
+
writes.push(chunk);
|
|
117
|
+
if (chunk.includes('"answer"')) {
|
|
118
|
+
push({ type: 'result', success: false, exitCode: 1 });
|
|
119
|
+
controller.close();
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
stdout,
|
|
124
|
+
kill() {},
|
|
125
|
+
exited: Promise.resolve(1),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
push({ type: 'ready', protocolVersion: 1 });
|
|
129
|
+
push({
|
|
130
|
+
type: 'interview',
|
|
131
|
+
id: 'q1',
|
|
132
|
+
scope: 'module-upgrade:iptables',
|
|
133
|
+
key: 'apply_breaking',
|
|
134
|
+
kind: 'confirm',
|
|
135
|
+
message: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?',
|
|
136
|
+
defaultValue: 'false',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// No `renderInterview` — exactly what runRemoteCapture used to do. bun test
|
|
140
|
+
// runs with a piped stdin, i.e. the MCP server's situation.
|
|
141
|
+
expect(process.stdin.isTTY).toBeFalsy();
|
|
142
|
+
const code = await runRemoteClient('ignored', ['module', 'update'], {
|
|
143
|
+
openTransport: () => transport,
|
|
144
|
+
out: { write() {} },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(code).toBe(1);
|
|
148
|
+
const answer = writes.find((w) => w.includes('"answer"'));
|
|
149
|
+
expect(answer).toBeDefined();
|
|
150
|
+
const parsed = JSON.parse(answer as string) as { value: unknown; error?: string };
|
|
151
|
+
expect(parsed.value).not.toBe(false);
|
|
152
|
+
expect(parsed.error).toContain('module-upgrade:iptables.apply_breaking');
|
|
153
|
+
});
|
package/src/api/serve.ts
CHANGED
|
@@ -108,14 +108,19 @@ export async function apiServeMode(principal: string): Promise<void> {
|
|
|
108
108
|
send({ type: 'ready', protocolVersion: API_PROTOCOL_VERSION });
|
|
109
109
|
|
|
110
110
|
const busDbPath = getEventBusPath();
|
|
111
|
-
const pendingAnswers = new Map<
|
|
111
|
+
const pendingAnswers = new Map<
|
|
112
|
+
string,
|
|
113
|
+
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
|
|
114
|
+
>();
|
|
112
115
|
|
|
113
116
|
const ask = (interview: WireInterview): Promise<unknown> =>
|
|
114
|
-
new Promise((resolve) => {
|
|
115
|
-
pendingAnswers.set(interview.id, resolve);
|
|
117
|
+
new Promise((resolve, reject) => {
|
|
118
|
+
pendingAnswers.set(interview.id, { resolve, reject });
|
|
116
119
|
send({
|
|
117
120
|
type: 'interview',
|
|
118
121
|
id: interview.id,
|
|
122
|
+
scope: interview.scope,
|
|
123
|
+
key: interview.key,
|
|
119
124
|
kind: interview.kind,
|
|
120
125
|
message: interview.message,
|
|
121
126
|
description: interview.description,
|
|
@@ -140,10 +145,13 @@ export async function apiServeMode(principal: string): Promise<void> {
|
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
if (msg.type === 'answer') {
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
148
|
+
const pending = pendingAnswers.get(msg.id);
|
|
149
|
+
if (pending) {
|
|
145
150
|
pendingAnswers.delete(msg.id);
|
|
146
|
-
|
|
151
|
+
// `error` = the client couldn't reach a decider. Fail the waiting
|
|
152
|
+
// command rather than letting the question fall back to its default.
|
|
153
|
+
if (msg.error) pending.reject(new Error(msg.error));
|
|
154
|
+
else pending.resolve(msg.value);
|
|
147
155
|
}
|
|
148
156
|
continue;
|
|
149
157
|
}
|
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { handleAptUpgrade } from './apt-upgrade';
|
|
3
3
|
|
|
4
4
|
describe('handleAptUpgrade', () => {
|
|
5
|
-
test('runs
|
|
5
|
+
test('runs every step in order when each succeeds, ending with the dispatcher restart', async () => {
|
|
6
6
|
const seen: string[][] = [];
|
|
7
7
|
const result = await handleAptUpgrade([], {}, (argv) => {
|
|
8
8
|
seen.push(argv);
|
|
@@ -14,9 +14,28 @@ describe('handleAptUpgrade', () => {
|
|
|
14
14
|
['sudo', 'apt-get', 'update'],
|
|
15
15
|
['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
|
|
16
16
|
['/usr/local/bin/celilo', 'system', 'migrate'],
|
|
17
|
+
// celilo#604: without this, apt installs new code and the running
|
|
18
|
+
// dispatcher keeps serving the old — celilo-mgr did so for 9 days.
|
|
19
|
+
['/usr/local/bin/celilo', 'events', 'restart-daemon'],
|
|
17
20
|
]);
|
|
18
21
|
});
|
|
19
22
|
|
|
23
|
+
test('fails, and names what DID complete, when the dispatcher restart fails', async () => {
|
|
24
|
+
const result = await handleAptUpgrade([], {}, (argv) => ({
|
|
25
|
+
status: argv.includes('restart-daemon') ? 1 : 0,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// The packages ARE upgraded and the dispatcher is NOT on the new code.
|
|
29
|
+
// Saying so in the failure is the acceptance condition — silence here is
|
|
30
|
+
// what let a stale dispatcher pass for a successful upgrade.
|
|
31
|
+
expect(result.success).toBe(false);
|
|
32
|
+
if (!result.success) {
|
|
33
|
+
expect(result.error).toContain('restart the event dispatcher');
|
|
34
|
+
expect(result.error).toContain('apt-get upgrade');
|
|
35
|
+
expect(result.error).toContain('apply DB migrations');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
20
39
|
test('stops at the first failing step and does not run later ones', async () => {
|
|
21
40
|
const seen: string[][] = [];
|
|
22
41
|
const result = await handleAptUpgrade([], {}, (argv) => {
|
|
@@ -34,6 +34,11 @@ const STEPS: Step[] = [
|
|
|
34
34
|
argv: ['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
|
|
35
35
|
},
|
|
36
36
|
{ label: 'apply DB migrations', argv: [CELILO_BIN, 'system', 'migrate'] },
|
|
37
|
+
// The dispatcher runs the code it LOADED, not the code on disk. Without this
|
|
38
|
+
// step celilo-mgr sat 9 days on event-bus v0.1.8 after apt installed v0.2.0,
|
|
39
|
+
// faithfully running the bug the upgrade shipped to fix (celilo#604). The
|
|
40
|
+
// upgraded binary does the restart so the verification is the new code's.
|
|
41
|
+
{ label: 'restart the event dispatcher', argv: [CELILO_BIN, 'events', 'restart-daemon'] },
|
|
37
42
|
];
|
|
38
43
|
|
|
39
44
|
/** Run one argv, inheriting stdio so its output streams through api-serve. */
|
|
@@ -46,18 +51,23 @@ export async function handleAptUpgrade(
|
|
|
46
51
|
_flags: Record<string, string | boolean>,
|
|
47
52
|
runStep: StepRunner = defaultRunner,
|
|
48
53
|
): Promise<CommandResult> {
|
|
54
|
+
const done: string[] = [];
|
|
49
55
|
for (const step of STEPS) {
|
|
50
56
|
process.stdout.write(`\n▸ ${step.label}\n`);
|
|
51
57
|
const { status } = runStep(step.argv);
|
|
52
58
|
if (status !== 0) {
|
|
59
|
+
// Name what DID happen. A failure on the last step means new code is
|
|
60
|
+
// installed and the dispatcher is still serving the old — the operator
|
|
61
|
+
// has to be told that in the failure itself, not left to infer it.
|
|
53
62
|
return {
|
|
54
63
|
success: false,
|
|
55
|
-
error: `apt-upgrade failed at "${step.label}" (exit ${status ?? 'signal'}). Nothing further was run.`,
|
|
64
|
+
error: `apt-upgrade failed at "${step.label}" (exit ${status ?? 'signal'}). Completed: ${done.length > 0 ? done.join(', ') : 'nothing'}. Nothing further was run.`,
|
|
56
65
|
};
|
|
57
66
|
}
|
|
67
|
+
done.push(step.label);
|
|
58
68
|
}
|
|
59
69
|
return {
|
|
60
70
|
success: true,
|
|
61
|
-
message: 'celilo apt packages upgraded
|
|
71
|
+
message: 'celilo apt packages upgraded, migrations applied, dispatcher restarted on new code.',
|
|
62
72
|
};
|
|
63
73
|
}
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* resume alias for repair (acknowledges halt-on-recovery)
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
23
24
|
import {
|
|
25
|
+
BUS_VERSION,
|
|
24
26
|
defineEvents,
|
|
25
27
|
drainOnce,
|
|
26
28
|
openBus,
|
|
@@ -37,10 +39,15 @@ import type { HookName } from '../../hooks/types';
|
|
|
37
39
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
38
40
|
import type { EnsureRequiredPayload } from '../../services/bus-interview';
|
|
39
41
|
import {
|
|
42
|
+
detectPlatform,
|
|
40
43
|
installDaemon,
|
|
41
44
|
planDaemonInstall,
|
|
42
45
|
readInstalledUnit,
|
|
46
|
+
resolveRestartScope,
|
|
47
|
+
restartDaemon,
|
|
48
|
+
supervisorCommands,
|
|
43
49
|
uninstallDaemon,
|
|
50
|
+
unitInstalledInAnyScope,
|
|
44
51
|
} from '../../services/events-daemon';
|
|
45
52
|
import { getArg, hasFlag } from '../parser';
|
|
46
53
|
import type { CommandResult } from '../types';
|
|
@@ -897,6 +904,89 @@ export async function handleEventsUninstallDaemon(
|
|
|
897
904
|
}
|
|
898
905
|
}
|
|
899
906
|
|
|
907
|
+
/**
|
|
908
|
+
* `celilo events restart-daemon` — cycle the dispatcher through its supervisor
|
|
909
|
+
* and PROVE the new process is live on the installed code (celilo#604).
|
|
910
|
+
*
|
|
911
|
+
* The interesting case is the orphan: a dispatcher the supervisor doesn't own
|
|
912
|
+
* can't be killed by `systemctl restart`, and the one-dispatcher-per-bus guard
|
|
913
|
+
* (#584) then crash-loops the unit while the old code keeps serving. So this
|
|
914
|
+
* stops unmanaged dispatchers first, and verifies on the bus afterwards rather
|
|
915
|
+
* than trusting systemctl's exit code.
|
|
916
|
+
*/
|
|
917
|
+
export async function handleEventsRestartDaemon(
|
|
918
|
+
_args: string[],
|
|
919
|
+
flags: Record<string, string | boolean>,
|
|
920
|
+
): Promise<CommandResult> {
|
|
921
|
+
const bus = openCliBus();
|
|
922
|
+
try {
|
|
923
|
+
const platform = detectPlatform();
|
|
924
|
+
// No unit installed at all. apt-upgrade runs this on every box, including
|
|
925
|
+
// ones that never installed the daemon — failing those would break an
|
|
926
|
+
// upgrade that has nothing stale to fix. Distinguish the two cases:
|
|
927
|
+
// nothing running is a genuine no-op; something running is unsupervised
|
|
928
|
+
// and may be stale, and there is no supervisor to cycle it through.
|
|
929
|
+
if (!flags.system && !unitInstalledInAnyScope(platform)) {
|
|
930
|
+
const live = bus.liveDispatchers();
|
|
931
|
+
if (live.length === 0) {
|
|
932
|
+
return {
|
|
933
|
+
success: true,
|
|
934
|
+
message: 'No supervisor unit installed and no dispatcher running — nothing to restart.',
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
success: false,
|
|
939
|
+
error: `A dispatcher is running unsupervised (pid ${live.map((d) => d.pid).join(', ')}, code v${live[0]?.version ?? '?'}) and no supervisor unit is installed, so it cannot be cycled — it may be serving stale code. Run \`celilo events install-daemon\`, enable the unit, then retry.`,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const scope = resolveRestartScope({ platform, scope: flags.system ? 'system' : undefined });
|
|
943
|
+
const cmds = supervisorCommands(platform, scope);
|
|
944
|
+
const result = await restartDaemon(
|
|
945
|
+
{ platform, scope, expectedVersion: BUS_VERSION },
|
|
946
|
+
{
|
|
947
|
+
liveDispatchers: () =>
|
|
948
|
+
bus.liveDispatchers().map((d) => ({ pid: d.pid, version: d.version })),
|
|
949
|
+
supervisorPid: () => {
|
|
950
|
+
if (!cmds.mainPid) return null;
|
|
951
|
+
const out = spawnSync(cmds.mainPid[0], cmds.mainPid.slice(1), { encoding: 'utf-8' });
|
|
952
|
+
const pid = Number((out.stdout ?? '').trim());
|
|
953
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
954
|
+
},
|
|
955
|
+
kill: (pid, signal) => {
|
|
956
|
+
try {
|
|
957
|
+
process.kill(pid, signal);
|
|
958
|
+
} catch {
|
|
959
|
+
// Already gone, or not ours to signal — the bus poll is the arbiter.
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
restartUnit: () => {
|
|
963
|
+
const out = spawnSync(cmds.restart[0], cmds.restart.slice(1), { encoding: 'utf-8' });
|
|
964
|
+
if (out.status !== 0) {
|
|
965
|
+
throw new Error(
|
|
966
|
+
`${cmds.restart.join(' ')} failed (exit ${out.status ?? 'signal'}): ${(out.stderr ?? '').trim()}`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
971
|
+
},
|
|
972
|
+
);
|
|
973
|
+
const lines = [
|
|
974
|
+
`Dispatcher restarted under the ${result.scope}-scope unit: ${shortenPath(result.unitPath)}`,
|
|
975
|
+
` now running: pid ${result.dispatcher.pid}, code v${result.dispatcher.version}`,
|
|
976
|
+
...(result.orphansKilled.length > 0
|
|
977
|
+
? [
|
|
978
|
+
` stopped ${result.orphansKilled.length} unsupervised dispatcher(s): pid ${result.orphansKilled.join(', ')}`,
|
|
979
|
+
]
|
|
980
|
+
: []),
|
|
981
|
+
];
|
|
982
|
+
return { success: true, message: lines.join('\n'), data: result };
|
|
983
|
+
} catch (err) {
|
|
984
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
985
|
+
} finally {
|
|
986
|
+
bus.close();
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
900
990
|
/**
|
|
901
991
|
* `celilo events show-daemon` — print whatever unit file is currently
|
|
902
992
|
* installed (`--system` for the system-scope unit) so the operator can
|
|
@@ -11,7 +11,7 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { eq } from 'drizzle-orm';
|
|
12
12
|
import { type DbClient, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
14
|
-
import { classifyVersionChange, updateOne } from './module-update';
|
|
14
|
+
import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
|
|
15
15
|
|
|
16
16
|
describe('classifyVersionChange', () => {
|
|
17
17
|
test('identical versions are up-to-date', () => {
|
|
@@ -250,3 +250,74 @@ subscriptions:
|
|
|
250
250
|
}
|
|
251
251
|
});
|
|
252
252
|
});
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Regression for the fabricated decline: driven headlessly with no responder,
|
|
256
|
+
* the registry sweep reported a breaking update as "operator declined" — a
|
|
257
|
+
* decision nobody was asked to make. An unanswerable question is not a "no".
|
|
258
|
+
*/
|
|
259
|
+
describe('registry sweep — an unanswered breaking update is not a decline', () => {
|
|
260
|
+
let tempDir: string;
|
|
261
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
262
|
+
let registryUrl: string;
|
|
263
|
+
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-sweep-'));
|
|
266
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
267
|
+
process.env.CELILO_ORIGINAL_CWD = tempDir;
|
|
268
|
+
// Isolated bus with no responder attached — the headless case.
|
|
269
|
+
process.env.EVENT_BUS_DB = join(tempDir, 'events.db');
|
|
270
|
+
|
|
271
|
+
getDb()
|
|
272
|
+
.insert(modules)
|
|
273
|
+
.values({
|
|
274
|
+
id: 'iptables',
|
|
275
|
+
name: 'iptables',
|
|
276
|
+
sourcePath: join(tempDir, 'installed'),
|
|
277
|
+
version: '1.0.2+9',
|
|
278
|
+
manifestData: {
|
|
279
|
+
celilo_contract: '1.0',
|
|
280
|
+
id: 'iptables',
|
|
281
|
+
name: 'iptables',
|
|
282
|
+
version: '1.0.2',
|
|
283
|
+
},
|
|
284
|
+
})
|
|
285
|
+
.run();
|
|
286
|
+
|
|
287
|
+
// Minimal sparse-index server offering a major bump for `iptables`.
|
|
288
|
+
server = Bun.serve({
|
|
289
|
+
port: 0,
|
|
290
|
+
fetch(req) {
|
|
291
|
+
const path = new URL(req.url).pathname;
|
|
292
|
+
if (path === '/index/ip/ta/iptables') {
|
|
293
|
+
return new Response(
|
|
294
|
+
`${JSON.stringify({ name: 'iptables', vers: '2.0.0+1', deps: [], cksum: 'x' })}\n`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return new Response('not found', { status: 404 });
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
registryUrl = `http://localhost:${server.port}`;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
afterEach(() => {
|
|
304
|
+
server.stop(true);
|
|
305
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
306
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
307
|
+
process.env.CELILO_ORIGINAL_CWD = undefined;
|
|
308
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('reports it as unanswered, never as declined, and fails the sweep', async () => {
|
|
312
|
+
const result = await handleModuleUpdate([], { registry: registryUrl });
|
|
313
|
+
|
|
314
|
+
const report = result.success ? (result.message ?? '') : (result.error ?? '');
|
|
315
|
+
expect(report).not.toContain('operator declined');
|
|
316
|
+
expect(report).toContain('NOT declined');
|
|
317
|
+
expect(report).toContain('iptables');
|
|
318
|
+
// A breaking update that silently didn't land must not read as success.
|
|
319
|
+
expect(result.success).toBe(false);
|
|
320
|
+
// And the module is still on the old version — no accidental upgrade.
|
|
321
|
+
expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -23,6 +23,7 @@ import type { ModuleManifest } from '../../manifest/schema';
|
|
|
23
23
|
import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
|
|
24
24
|
import { RegistryClient } from '../../registry/client';
|
|
25
25
|
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
26
|
+
import { InterviewUnansweredError } from '../../services/interview-errors';
|
|
26
27
|
import { getFlag } from '../parser';
|
|
27
28
|
import { log } from '../prompts';
|
|
28
29
|
import type { CommandResult } from '../types';
|
|
@@ -496,7 +497,8 @@ async function runRegistrySweep(
|
|
|
496
497
|
}
|
|
497
498
|
|
|
498
499
|
let appliedBreaking = 0;
|
|
499
|
-
let
|
|
500
|
+
let declinedBreaking = 0;
|
|
501
|
+
const unanswered: Array<{ moduleId: string; error: string }> = [];
|
|
500
502
|
|
|
501
503
|
if (breaking.length > 0) {
|
|
502
504
|
log.info('\nBreaking updates available — review required (semver-major bump):');
|
|
@@ -508,16 +510,29 @@ async function runRegistrySweep(
|
|
|
508
510
|
log.message('Each breaking update will be applied only on explicit confirmation.\n');
|
|
509
511
|
|
|
510
512
|
for (const plan of breaking) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
513
|
+
let proceed: boolean;
|
|
514
|
+
try {
|
|
515
|
+
proceed = await withInterviewSession(() =>
|
|
516
|
+
askConfirm({
|
|
517
|
+
scope: `module-upgrade:${plan.moduleId}`,
|
|
518
|
+
key: 'apply_breaking',
|
|
519
|
+
message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
|
|
520
|
+
defaultValue: false,
|
|
521
|
+
}),
|
|
522
|
+
);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
// Nobody could answer. That is NOT a decline — record it as unanswered
|
|
525
|
+
// so the summary says so and the sweep exits non-zero, and keep going
|
|
526
|
+
// so the updates already applied aren't thrown away with a hard abort.
|
|
527
|
+
if (!(err instanceof InterviewUnansweredError)) throw err;
|
|
528
|
+
unanswered.push({ moduleId: plan.moduleId, error: err.message });
|
|
529
|
+
console.log(
|
|
530
|
+
` ? ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, UNANSWERED)`,
|
|
531
|
+
);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
519
534
|
if (!proceed) {
|
|
520
|
-
|
|
535
|
+
declinedBreaking++;
|
|
521
536
|
continue;
|
|
522
537
|
}
|
|
523
538
|
const result = await fetchAndUpdate(client, plan.moduleId, plan.targetVersion, db, flags);
|
|
@@ -545,8 +560,17 @@ async function runRegistrySweep(
|
|
|
545
560
|
} else {
|
|
546
561
|
summary.push('No updates applied.');
|
|
547
562
|
}
|
|
548
|
-
if (
|
|
549
|
-
summary.push(`Skipped ${
|
|
563
|
+
if (declinedBreaking > 0) {
|
|
564
|
+
summary.push(`Skipped ${declinedBreaking} breaking update(s) (operator declined).`);
|
|
565
|
+
}
|
|
566
|
+
if (unanswered.length > 0) {
|
|
567
|
+
summary.push(
|
|
568
|
+
`Skipped ${unanswered.length} breaking update(s) — NOT declined: the confirmation could not be answered (${unanswered
|
|
569
|
+
.map((u) => u.moduleId)
|
|
570
|
+
.join(
|
|
571
|
+
', ',
|
|
572
|
+
)}). Re-run with a responder attached, or pre-stage the answer under "module-upgrade:<module>.apply_breaking".`,
|
|
573
|
+
);
|
|
550
574
|
}
|
|
551
575
|
if (notInRegistry.length > 0) {
|
|
552
576
|
summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
|
|
@@ -556,16 +580,15 @@ async function runRegistrySweep(
|
|
|
556
580
|
`Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
|
|
557
581
|
);
|
|
558
582
|
}
|
|
559
|
-
if (failed.length > 0) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
};
|
|
583
|
+
if (failed.length > 0 || unanswered.length > 0) {
|
|
584
|
+
const detail: string[] = [];
|
|
585
|
+
if (failed.length > 0) {
|
|
586
|
+
detail.push('', 'Failures:', ...failed.map((f) => ` ${f.moduleId}: ${f.error}`));
|
|
587
|
+
}
|
|
588
|
+
if (unanswered.length > 0) {
|
|
589
|
+
detail.push('', 'Unanswered:', ...unanswered.map((u) => ` ${u.moduleId}: ${u.error}`));
|
|
590
|
+
}
|
|
591
|
+
return { success: false, error: [...summary, ...detail].join('\n') };
|
|
569
592
|
}
|
|
570
593
|
return { success: true, message: summary.join('\n') };
|
|
571
594
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
3
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
@@ -37,4 +38,59 @@ describe('handleSystemMigrate', () => {
|
|
|
37
38
|
const second = await handleSystemMigrate();
|
|
38
39
|
expect(second.success).toBe(true);
|
|
39
40
|
});
|
|
41
|
+
|
|
42
|
+
// celilo#604: the runbook asserts "applied 19 -> 20, backups.pid present".
|
|
43
|
+
// Before this, the only answer was a table COUNT, which cannot see a column.
|
|
44
|
+
describe('--status', () => {
|
|
45
|
+
it('names the applied count and the latest applied migration', async () => {
|
|
46
|
+
await handleSystemMigrate();
|
|
47
|
+
closeDb();
|
|
48
|
+
|
|
49
|
+
const result = await handleSystemMigrate([], { status: true });
|
|
50
|
+
|
|
51
|
+
expect(result.success).toBe(true);
|
|
52
|
+
if (result.success) {
|
|
53
|
+
expect(result.message).toMatch(/Applied migrations: \d+/);
|
|
54
|
+
expect(result.message).toContain('0019_backup_pid');
|
|
55
|
+
expect(result.message).toContain('Pending: none');
|
|
56
|
+
expect(result.message).toContain('columns');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('reports a pending migration WITHOUT applying it', async () => {
|
|
61
|
+
await handleSystemMigrate();
|
|
62
|
+
closeDb();
|
|
63
|
+
// Rewind one migration, the way an upgrade that never ran would look.
|
|
64
|
+
const raw = new Database(process.env.CELILO_DB_PATH as string);
|
|
65
|
+
raw.run(
|
|
66
|
+
'DELETE FROM `__drizzle_migrations` WHERE created_at = (SELECT MAX(created_at) FROM `__drizzle_migrations`)',
|
|
67
|
+
);
|
|
68
|
+
raw.run('ALTER TABLE backups DROP COLUMN pid');
|
|
69
|
+
const countBefore = raw
|
|
70
|
+
.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
|
|
71
|
+
.get()?.c;
|
|
72
|
+
raw.close();
|
|
73
|
+
|
|
74
|
+
const result = await handleSystemMigrate([], { status: true });
|
|
75
|
+
|
|
76
|
+
expect(result.success).toBe(false);
|
|
77
|
+
if (!result.success) {
|
|
78
|
+
expect(result.error).toContain('0019_backup_pid');
|
|
79
|
+
expect(result.error).toContain('backups.pid');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A status that repaired what it reports would always read clean — the
|
|
83
|
+
// exact placebo this command exists to replace.
|
|
84
|
+
const after = new Database(process.env.CELILO_DB_PATH as string);
|
|
85
|
+
const countAfter = after
|
|
86
|
+
.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
|
|
87
|
+
.get()?.c;
|
|
88
|
+
const cols = after
|
|
89
|
+
.query<{ name: string }, []>("SELECT name FROM pragma_table_info('backups')")
|
|
90
|
+
.all();
|
|
91
|
+
after.close();
|
|
92
|
+
expect(countAfter).toBe(countBefore as number);
|
|
93
|
+
expect(cols.map((c) => c.name)).not.toContain('pid');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
40
96
|
});
|