@celilo/cli 4.0.0 → 4.1.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 +1 -0
- package/package.json +5 -4
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +19 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +8 -2
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/services/api-access.test.ts +44 -0
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/celilo-events.test.ts +39 -0
- package/src/services/celilo-events.ts +41 -4
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +81 -2
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/orchestrator.test.ts +164 -0
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/context.ts +14 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { auditServerBunPin, readPinnedBunVersion } from './server-bun-pin';
|
|
6
|
+
|
|
7
|
+
const PIN = '/etc/celilo/mise.pin.toml';
|
|
8
|
+
|
|
9
|
+
describe('server bun pin audit', () => {
|
|
10
|
+
test('silent when the running bun is the pinned bun', () => {
|
|
11
|
+
expect(
|
|
12
|
+
auditServerBunPin({ pinnedVersion: '1.4.2', runningVersion: '1.4.2', pinPath: PIN }),
|
|
13
|
+
).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('reports drift naming both versions — the celilo#1379 shape', () => {
|
|
17
|
+
const [finding] = auditServerBunPin({
|
|
18
|
+
pinnedVersion: '1.4.2',
|
|
19
|
+
runningVersion: '1.3.3',
|
|
20
|
+
pinPath: PIN,
|
|
21
|
+
});
|
|
22
|
+
expect(finding?.code).toBe('server_bun_pin_drift');
|
|
23
|
+
expect(finding?.severity).toBe('drift');
|
|
24
|
+
expect(finding?.message).toContain('1.3.3');
|
|
25
|
+
expect(finding?.message).toContain('1.4.2');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('an unreadable pin is unmeasured, never green', () => {
|
|
29
|
+
const [finding] = auditServerBunPin({
|
|
30
|
+
pinnedVersion: null,
|
|
31
|
+
runningVersion: '1.4.2',
|
|
32
|
+
pinPath: PIN,
|
|
33
|
+
});
|
|
34
|
+
expect(finding?.severity).toBe('unmeasured');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('reads the bun line out of the shipped pin file', () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-pin-'));
|
|
39
|
+
const path = join(dir, 'mise.pin.toml');
|
|
40
|
+
writeFileSync(path, '# comment\n[tools]\nbun = "1.4.2"\nterraform = "1.10.0"\n');
|
|
41
|
+
expect(readPinnedBunVersion(path)).toBe('1.4.2');
|
|
42
|
+
expect(readPinnedBunVersion(join(dir, 'absent.toml'))).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server bun-pin drift (celilo#1379).
|
|
3
|
+
*
|
|
4
|
+
* The deb pins the bun celilo was built and tested against, and the CLI runs
|
|
5
|
+
* on whatever bun mise resolves at /var/celilo. Those can disagree: for three
|
|
6
|
+
* months celilo-mgr ran on bun 1.3.3 while the shipped pin said 1.4.2,
|
|
7
|
+
* because the postinst seeded the pin file once and an upgrade never
|
|
8
|
+
* rewrote it. 1.3.3's `Bun.YAML.parse` reads a bare `on:` key as `true`, so
|
|
9
|
+
* production was running a parser no gate had ever exercised.
|
|
10
|
+
*
|
|
11
|
+
* The postinst now rewrites the pin on every configure, which closes the way
|
|
12
|
+
* that drift arose. This check is what makes the drift VISIBLE if it arises
|
|
13
|
+
* some other way — an operator's `mise use`, a hand-edited config, a
|
|
14
|
+
* dispatcher still holding an old runtime.
|
|
15
|
+
*
|
|
16
|
+
* It compares the pin the deb shipped against the bun THIS process is
|
|
17
|
+
* running on. Not `mise which bun`, which answers what mise would resolve
|
|
18
|
+
* now rather than what is actually executing (measured 2026-09-10: mise
|
|
19
|
+
* named 1.4.2 and ran 1.3.3 on the same box).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import type { DriftFinding } from './types';
|
|
24
|
+
|
|
25
|
+
export interface ServerBunPinAuditDeps {
|
|
26
|
+
/** The bun version the installed deb pins, or `null` when unreadable. */
|
|
27
|
+
pinnedVersion: string | null;
|
|
28
|
+
/** The bun this process is running on, or `null` when not under bun. */
|
|
29
|
+
runningVersion: string | null;
|
|
30
|
+
/** Where `pinnedVersion` was read from — named in the finding. */
|
|
31
|
+
pinPath: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function auditServerBunPin(deps: ServerBunPinAuditDeps): DriftFinding[] {
|
|
35
|
+
const { pinnedVersion, runningVersion, pinPath } = deps;
|
|
36
|
+
|
|
37
|
+
if (pinnedVersion === null || runningVersion === null) {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
category: 'server_bun_pin',
|
|
41
|
+
severity: 'unmeasured',
|
|
42
|
+
code: 'server_bun_pin_unmeasured',
|
|
43
|
+
subject: 'system',
|
|
44
|
+
message:
|
|
45
|
+
pinnedVersion === null
|
|
46
|
+
? `No bun pin could be read from ${pinPath}, so the running bun was compared against nothing.`
|
|
47
|
+
: 'This process is not running under bun, so the running bun version is unknown.',
|
|
48
|
+
remediation:
|
|
49
|
+
pinnedVersion === null
|
|
50
|
+
? `Confirm the celilo deb is installed and ${pinPath} exists (\`sudo apt install --reinstall celilo\`).`
|
|
51
|
+
: 'Run `celilo system audit` from the deb-installed celilo (/usr/local/bin/celilo).',
|
|
52
|
+
actionable: false,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (pinnedVersion === runningVersion) return [];
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
category: 'server_bun_pin',
|
|
62
|
+
severity: 'drift',
|
|
63
|
+
code: 'server_bun_pin_drift',
|
|
64
|
+
subject: 'system',
|
|
65
|
+
message: `celilo is running on bun ${runningVersion}, but the installed deb pins bun ${pinnedVersion} (${pinPath}).`,
|
|
66
|
+
details:
|
|
67
|
+
'celilo is tested against the pinned bun only. A different runtime is not a version-number mismatch — bun 1.3.3 and 1.4.2 parse the same YAML differently, which is how a deploy shipped on an untested parser (celilo#1379).',
|
|
68
|
+
remediation:
|
|
69
|
+
'Reconcile the runtime: `sudo apt install --reinstall celilo` rewrites /var/celilo/mise.local.toml from the deb pin, then `celilo events restart-daemon` puts the dispatcher on it.',
|
|
70
|
+
actionable: false,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The pin the deb ships. Read at audit time — never a copy in celilo's code. */
|
|
76
|
+
export const DEB_PIN_PATH = '/etc/celilo/mise.pin.toml';
|
|
77
|
+
|
|
78
|
+
export function readPinnedBunVersion(path: string = DEB_PIN_PATH): string | null {
|
|
79
|
+
try {
|
|
80
|
+
// One `bun = "x.y.z"` line under [tools]. A TOML parser for one key would
|
|
81
|
+
// be a dependency to read a file this file's own format gate keeps simple.
|
|
82
|
+
const match = readFileSync(path, 'utf-8').match(/^\s*bun\s*=\s*"([^"]+)"/m);
|
|
83
|
+
return match?.[1] ?? null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function collectServerBunPinDeps(pinPath: string = DEB_PIN_PATH): ServerBunPinAuditDeps {
|
|
90
|
+
return {
|
|
91
|
+
pinnedVersion: readPinnedBunVersion(pinPath),
|
|
92
|
+
runningVersion: process.versions.bun ?? null,
|
|
93
|
+
pinPath,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -24,6 +24,28 @@ function provider(
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
describe('unservedCapabilityRequirements', () => {
|
|
27
|
+
/**
|
|
28
|
+
* celilo#1384. Optional in PRESENCE, not in contract: with no provider the
|
|
29
|
+
* module is fine, but an installed provider on another major is called by
|
|
30
|
+
* the module's hooks and fails there — as a warning, silently.
|
|
31
|
+
*/
|
|
32
|
+
test('an optional capability with no provider is not a blocker', () => {
|
|
33
|
+
expect(
|
|
34
|
+
unservedCapabilityRequirements([], [], [{ name: 'external_web', version: '1.0.0' }]),
|
|
35
|
+
).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('an optional capability whose installed provider is another major IS a blocker', () => {
|
|
39
|
+
const result = unservedCapabilityRequirements(
|
|
40
|
+
[],
|
|
41
|
+
[provider('external_web', 'generic-cpanel', '2.0.0')],
|
|
42
|
+
[{ name: 'external_web', version: '1.0.0' }],
|
|
43
|
+
);
|
|
44
|
+
expect(result).toHaveLength(1);
|
|
45
|
+
expect(result[0]?.capability).toBe('external_web');
|
|
46
|
+
expect(result[0]?.providerModuleId).toBe('generic-cpanel');
|
|
47
|
+
});
|
|
48
|
+
|
|
27
49
|
test('a requirement any deployed provider serves is not a blocker', () => {
|
|
28
50
|
const providers = [provider('public_web', 'caddy', '4.0.0')];
|
|
29
51
|
expect(
|
|
@@ -53,15 +53,27 @@ export interface UnservedCapability {
|
|
|
53
53
|
export function unservedCapabilityRequirements(
|
|
54
54
|
requirements: CapabilityRequirement[] | undefined,
|
|
55
55
|
providers: DeployedCapabilityProvider[],
|
|
56
|
+
/**
|
|
57
|
+
* Capabilities the module uses only if present. Their provider may be
|
|
58
|
+
* absent, but an installed provider on another major breaks at runtime
|
|
59
|
+
* exactly as a required one would (celilo#1384), so they take the version
|
|
60
|
+
* check and skip the presence check.
|
|
61
|
+
*/
|
|
62
|
+
optional: CapabilityRequirement[] | undefined = undefined,
|
|
56
63
|
): UnservedCapability[] {
|
|
57
64
|
const unserved: UnservedCapability[] = [];
|
|
58
|
-
|
|
65
|
+
const consumed = [
|
|
66
|
+
...(requirements ?? []).map((cap) => ({ cap, required: true })),
|
|
67
|
+
...(optional ?? []).map((cap) => ({ cap, required: false })),
|
|
68
|
+
];
|
|
69
|
+
for (const { cap, required } of consumed) {
|
|
59
70
|
// Framework-granted privileges (e.g. cross_module_read) are not
|
|
60
71
|
// provider-backed — the same skip `deploy-preflight` makes.
|
|
61
72
|
if (isPrivilegedCapability(cap.name)) continue;
|
|
62
73
|
|
|
63
74
|
const installed = providers.filter((p) => p.capabilityName === cap.name);
|
|
64
75
|
if (installed.length === 0) {
|
|
76
|
+
if (!required) continue;
|
|
65
77
|
unserved.push({
|
|
66
78
|
capability: cap.name,
|
|
67
79
|
required: cap.version,
|
|
@@ -119,10 +131,11 @@ export function readDeployedProviders(db: DbClient): DeployedCapabilityProvider[
|
|
|
119
131
|
*/
|
|
120
132
|
export function capabilityBlockersForManifest(
|
|
121
133
|
db: DbClient,
|
|
122
|
-
manifest: Pick<ModuleManifest, 'requires' | 'id'>,
|
|
134
|
+
manifest: Pick<ModuleManifest, 'requires' | 'optional' | 'id'>,
|
|
123
135
|
): UnservedCapability[] {
|
|
124
136
|
return unservedCapabilityRequirements(
|
|
125
137
|
manifest.requires?.capabilities as CapabilityRequirement[] | undefined,
|
|
126
138
|
readDeployedProviders(db),
|
|
139
|
+
manifest.optional?.capabilities as CapabilityRequirement[] | undefined,
|
|
127
140
|
);
|
|
128
141
|
}
|
|
@@ -208,6 +208,20 @@ describe('celilo lifecycle events', () => {
|
|
|
208
208
|
bus.close();
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/** One failed attempt that will be retried — the delivery stays pending. */
|
|
212
|
+
function recordRetryingFailure(message: string): void {
|
|
213
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
214
|
+
const sub = bus.getSubscriberByName('caddy.reconcile-web-routes');
|
|
215
|
+
const event = bus.recentEvents({ type: 'public_web.routes_changed', limit: 1 })[0];
|
|
216
|
+
if (!sub || !event) {
|
|
217
|
+
throw new Error('expected a subscriber and an emitted event');
|
|
218
|
+
}
|
|
219
|
+
bus.markFailed({ eventId: event.id, subscriberId: sub.id }, message, {
|
|
220
|
+
retryAfter: Date.now() + 60_000,
|
|
221
|
+
});
|
|
222
|
+
bus.close();
|
|
223
|
+
}
|
|
224
|
+
|
|
211
225
|
it('routesChangedHighWater is 0 with no events, then the latest id', () => {
|
|
212
226
|
expect(routesChangedHighWater()).toBe(0);
|
|
213
227
|
emitWebRoutesChanged('celilo-website');
|
|
@@ -222,6 +236,7 @@ describe('celilo lifecycle events', () => {
|
|
|
222
236
|
succeeded: 0,
|
|
223
237
|
failed: 0,
|
|
224
238
|
timedOut: false,
|
|
239
|
+
lastError: null,
|
|
225
240
|
noDispatcher: false,
|
|
226
241
|
});
|
|
227
242
|
});
|
|
@@ -237,6 +252,7 @@ describe('celilo lifecycle events', () => {
|
|
|
237
252
|
succeeded: 0,
|
|
238
253
|
failed: 0,
|
|
239
254
|
timedOut: false,
|
|
255
|
+
lastError: null,
|
|
240
256
|
noDispatcher: true,
|
|
241
257
|
});
|
|
242
258
|
});
|
|
@@ -251,6 +267,7 @@ describe('celilo lifecycle events', () => {
|
|
|
251
267
|
succeeded: 0,
|
|
252
268
|
failed: 0,
|
|
253
269
|
timedOut: false,
|
|
270
|
+
lastError: null,
|
|
254
271
|
noDispatcher: false,
|
|
255
272
|
});
|
|
256
273
|
});
|
|
@@ -265,6 +282,26 @@ describe('celilo lifecycle events', () => {
|
|
|
265
282
|
expect(result.timedOut).toBe(true);
|
|
266
283
|
});
|
|
267
284
|
|
|
285
|
+
/**
|
|
286
|
+
* celilo#1401. A handler that CRASHES is retried, so between attempts its
|
|
287
|
+
* delivery is `pending` with the error already recorded. The deadline hits
|
|
288
|
+
* in that window and the caller used to report a bare timeout — `0 ok, 0
|
|
289
|
+
* failed of 1` — which is what hid a caddy crash for days.
|
|
290
|
+
*/
|
|
291
|
+
it('carries the crashing handler error out of a timeout', async () => {
|
|
292
|
+
markDispatcherAlive();
|
|
293
|
+
subscribeReconciler();
|
|
294
|
+
const since = routesChangedHighWater();
|
|
295
|
+
emitWebRoutesChanged('celilo-website');
|
|
296
|
+
// Failed but NOT abandoned: it goes back to pending for the next attempt.
|
|
297
|
+
recordRetryingFailure('{} is not iterable');
|
|
298
|
+
|
|
299
|
+
const result = await waitForRouteReconcile(since, { timeoutMs: 300, pollMs: 50 });
|
|
300
|
+
expect(result.timedOut).toBe(true);
|
|
301
|
+
expect(result.failed).toBe(0);
|
|
302
|
+
expect(result.lastError).toContain('{} is not iterable');
|
|
303
|
+
});
|
|
304
|
+
|
|
268
305
|
it('completes once the provider delivery succeeds', async () => {
|
|
269
306
|
markDispatcherAlive();
|
|
270
307
|
subscribeReconciler();
|
|
@@ -278,6 +315,7 @@ describe('celilo lifecycle events', () => {
|
|
|
278
315
|
succeeded: 1,
|
|
279
316
|
failed: 0,
|
|
280
317
|
timedOut: false,
|
|
318
|
+
lastError: null,
|
|
281
319
|
noDispatcher: false,
|
|
282
320
|
});
|
|
283
321
|
});
|
|
@@ -313,6 +351,7 @@ describe('celilo lifecycle events', () => {
|
|
|
313
351
|
succeeded: 1,
|
|
314
352
|
failed: 0,
|
|
315
353
|
timedOut: false,
|
|
354
|
+
lastError: null,
|
|
316
355
|
noDispatcher: false,
|
|
317
356
|
});
|
|
318
357
|
});
|
|
@@ -222,6 +222,17 @@ export interface RouteReconcileWaitResult {
|
|
|
222
222
|
succeeded: number;
|
|
223
223
|
failed: number;
|
|
224
224
|
timedOut: boolean;
|
|
225
|
+
/**
|
|
226
|
+
* The handler's own error text from the most recent settled-or-retrying
|
|
227
|
+
* delivery, or null when no delivery has reported one.
|
|
228
|
+
*
|
|
229
|
+
* A handler that CRASHES is retried, so for `max_attempts` retries its
|
|
230
|
+
* delivery is back in `pending` with an error already recorded. The deadline
|
|
231
|
+
* can hit in that window, and the caller then reported a timeout for what was
|
|
232
|
+
* a crash — `0 ok, 0 failed of 1` with the real cause nowhere in sight
|
|
233
|
+
* (celilo#1401, which hid a caddy crash for days behind a deadline message).
|
|
234
|
+
*/
|
|
235
|
+
lastError: string | null;
|
|
225
236
|
/**
|
|
226
237
|
* True when no event dispatcher was running, so the `routes_changed` event
|
|
227
238
|
* was persisted but never DELIVERED to the provider (caddy). The route is
|
|
@@ -290,18 +301,36 @@ export async function waitForRouteReconcile(
|
|
|
290
301
|
.filter((e) => e.id > sinceEventId);
|
|
291
302
|
|
|
292
303
|
if (events.length === 0) {
|
|
293
|
-
return {
|
|
304
|
+
return {
|
|
305
|
+
events: 0,
|
|
306
|
+
succeeded: 0,
|
|
307
|
+
failed: 0,
|
|
308
|
+
timedOut: false,
|
|
309
|
+
lastError: null,
|
|
310
|
+
noDispatcher: false,
|
|
311
|
+
};
|
|
294
312
|
}
|
|
295
313
|
|
|
296
|
-
|
|
314
|
+
// Confirmed, not instant: a dispatcher starved by the deploy's own load looks
|
|
315
|
+
// exactly like a dead one for a single sample, and abandoning the reconcile on
|
|
316
|
+
// that reading is celilo#1399 — it failed consumers' re-registration during the
|
|
317
|
+
// one operation that generates route changes. `lagging` waits like any healthy
|
|
318
|
+
// bus; the deadline below still bounds it.
|
|
319
|
+
const health = await b.healthConfirmed();
|
|
320
|
+
if (health.status === 'no_dispatcher' || health.status === 'wedged') {
|
|
321
|
+
const why =
|
|
322
|
+
health.status === 'wedged'
|
|
323
|
+
? `dispatcher heartbeat stale with nothing running (wedged, ${Math.round((health.lastHeartbeatAgeMs ?? 0) / 1000)}s)`
|
|
324
|
+
: 'no dispatcher running';
|
|
297
325
|
console.warn(
|
|
298
|
-
`[celilo] route-reconcile:
|
|
326
|
+
`[celilo] route-reconcile: ${why} — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
|
|
299
327
|
);
|
|
300
328
|
return {
|
|
301
329
|
events: events.length,
|
|
302
330
|
succeeded: 0,
|
|
303
331
|
failed: 0,
|
|
304
332
|
timedOut: false,
|
|
333
|
+
lastError: null,
|
|
305
334
|
noDispatcher: true,
|
|
306
335
|
};
|
|
307
336
|
}
|
|
@@ -319,6 +348,7 @@ export async function waitForRouteReconcile(
|
|
|
319
348
|
succeeded: settled('succeeded'),
|
|
320
349
|
failed: settled('failed') + settled('abandoned'),
|
|
321
350
|
timedOut: pending > 0,
|
|
351
|
+
lastError: deliveries.find((d) => d.lastError)?.lastError ?? null,
|
|
322
352
|
noDispatcher: false,
|
|
323
353
|
};
|
|
324
354
|
}
|
|
@@ -328,7 +358,14 @@ export async function waitForRouteReconcile(
|
|
|
328
358
|
} catch (err) {
|
|
329
359
|
const msg = err instanceof Error ? err.message : String(err);
|
|
330
360
|
console.warn(`[celilo] route-reconcile wait errored: ${msg}`);
|
|
331
|
-
return {
|
|
361
|
+
return {
|
|
362
|
+
events: 0,
|
|
363
|
+
succeeded: 0,
|
|
364
|
+
failed: 0,
|
|
365
|
+
timedOut: false,
|
|
366
|
+
lastError: null,
|
|
367
|
+
noDispatcher: false,
|
|
368
|
+
};
|
|
332
369
|
} finally {
|
|
333
370
|
bus?.close();
|
|
334
371
|
}
|
|
@@ -112,8 +112,15 @@ export async function runPreflight(
|
|
|
112
112
|
// drift after deploy; this surface refuses up front so the
|
|
113
113
|
// operator gets an actionable error instead of a silent
|
|
114
114
|
// template-generation against an ABI the provider can't fulfil.
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
// An OPTIONAL capability joins the version check but not the presence check:
|
|
116
|
+
// its provider may legitimately be absent, but when one IS installed the
|
|
117
|
+
// module's hooks call it, so another major breaks at runtime (celilo#1384).
|
|
118
|
+
{
|
|
119
|
+
const consumed = [
|
|
120
|
+
...(manifest.requires?.capabilities ?? []).map((cap) => ({ cap, required: true })),
|
|
121
|
+
...(manifest.optional?.capabilities ?? []).map((cap) => ({ cap, required: false })),
|
|
122
|
+
];
|
|
123
|
+
for (const { cap, required } of consumed) {
|
|
117
124
|
// Framework-granted privileges (e.g. cross_module_read) aren't
|
|
118
125
|
// provider-backed — they're gated by the allow-list at import time,
|
|
119
126
|
// not satisfied by deploying another module. Skip them here.
|
|
@@ -126,6 +133,7 @@ export async function runPreflight(
|
|
|
126
133
|
.where(eq(capabilities.capabilityName, cap.name))
|
|
127
134
|
.all();
|
|
128
135
|
if (providers.length === 0) {
|
|
136
|
+
if (!required) continue;
|
|
129
137
|
errors.push({
|
|
130
138
|
category: 'missing-capability',
|
|
131
139
|
message: `Required capability '${cap.name}' has no provider installed`,
|
|
@@ -154,7 +162,8 @@ export async function runPreflight(
|
|
|
154
162
|
errors.push({
|
|
155
163
|
category: 'capability-version-mismatch',
|
|
156
164
|
message:
|
|
157
|
-
`Module '${moduleId}' requires
|
|
165
|
+
`Module '${moduleId}' ${required ? 'requires' : 'optionally uses'} ` +
|
|
166
|
+
`${cap.name}@${cap.version} but ` +
|
|
158
167
|
`provider '${example.moduleId}' offers ${cap.name}@${example.version}`,
|
|
159
168
|
suggestion:
|
|
160
169
|
reason === 'caller_minor_too_old'
|
|
@@ -19,11 +19,15 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { type DbClient, getDb } from '../db/client';
|
|
22
|
-
import { modules, secrets } from '../db/schema';
|
|
22
|
+
import { capabilities, modules, secrets } from '../db/schema';
|
|
23
23
|
import type { ModuleManifest } from '../manifest/schema';
|
|
24
24
|
import { resetTestDbPath } from '../test-utils/db-path';
|
|
25
25
|
import { findMissingSecrets } from './config-interview';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
findMissingBuildArtifacts,
|
|
28
|
+
findMissingRequiredVariables,
|
|
29
|
+
validateAndPrepareDeployment,
|
|
30
|
+
} from './deploy-validation';
|
|
27
31
|
|
|
28
32
|
function manifestWithStringMapSecret(): ModuleManifest {
|
|
29
33
|
return {
|
|
@@ -366,3 +370,78 @@ describe('findMissingBuildArtifacts (deploy build-gate)', () => {
|
|
|
366
370
|
expect(findMissingBuildArtifacts(manifest, dir)).toEqual(['dist/index.html', 'dist/db.sqlite']);
|
|
367
371
|
});
|
|
368
372
|
});
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* celilo#1384. An OPTIONAL capability is optional in its PRESENCE, not in its
|
|
376
|
+
* contract: with a provider installed the module's hooks call it, so a
|
|
377
|
+
* provider on another major fails at runtime — and `on_install` failing is
|
|
378
|
+
* only a warning, so it fails silently. Measured: generic-cpanel 0.4.1+2
|
|
379
|
+
* (external_web 2.0.0) against tango-nexus 0.8.0 (optional external_web
|
|
380
|
+
* 1.0.0); nothing published, the site kept serving, nothing said so.
|
|
381
|
+
*/
|
|
382
|
+
describe('validateAndPrepareDeployment (optional capability version gate)', () => {
|
|
383
|
+
let tempDir: string;
|
|
384
|
+
let db: DbClient;
|
|
385
|
+
|
|
386
|
+
function seed(optionalVersion: string, providerVersion: string | null) {
|
|
387
|
+
db.insert(modules)
|
|
388
|
+
.values({
|
|
389
|
+
id: 'consumer',
|
|
390
|
+
name: 'Consumer',
|
|
391
|
+
sourcePath: tempDir,
|
|
392
|
+
version: '1.0.0',
|
|
393
|
+
manifestData: {
|
|
394
|
+
celilo_contract: '1.0',
|
|
395
|
+
id: 'consumer',
|
|
396
|
+
name: 'Consumer',
|
|
397
|
+
version: '1.0.0',
|
|
398
|
+
description: 'fixture',
|
|
399
|
+
optional: { capabilities: [{ name: 'external_web', version: optionalVersion }] },
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
.run();
|
|
403
|
+
if (providerVersion === null) return;
|
|
404
|
+
db.insert(modules)
|
|
405
|
+
.values({
|
|
406
|
+
id: 'provider',
|
|
407
|
+
name: 'Provider',
|
|
408
|
+
sourcePath: tempDir,
|
|
409
|
+
version: '1.0.0',
|
|
410
|
+
manifestData: {},
|
|
411
|
+
})
|
|
412
|
+
.run();
|
|
413
|
+
db.insert(capabilities)
|
|
414
|
+
.values({
|
|
415
|
+
moduleId: 'provider',
|
|
416
|
+
capabilityName: 'external_web',
|
|
417
|
+
version: providerVersion,
|
|
418
|
+
data: {},
|
|
419
|
+
})
|
|
420
|
+
.run();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
beforeEach(() => {
|
|
424
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-optional-cap-'));
|
|
425
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
426
|
+
db = getDb();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
afterEach(() => {
|
|
430
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
431
|
+
resetTestDbPath();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('refuses the deploy when the installed provider is another major', async () => {
|
|
435
|
+
seed('1.0.0', '2.0.0');
|
|
436
|
+
const result = await validateAndPrepareDeployment('consumer', db);
|
|
437
|
+
expect(result.success).toBe(false);
|
|
438
|
+
expect(result.error).toContain('external_web');
|
|
439
|
+
expect(result.error).toContain('provider');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('does not refuse when nobody provides the optional capability', async () => {
|
|
443
|
+
seed('1.0.0', null);
|
|
444
|
+
const result = await validateAndPrepareDeployment('consumer', db);
|
|
445
|
+
expect(result.error).toBeUndefined();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
@@ -113,6 +113,25 @@ export async function validateAndPrepareDeployment(
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// An OPTIONAL capability is optional in its PRESENCE, not in its contract.
|
|
117
|
+
// When a provider is installed the module's hooks will call it, so a provider
|
|
118
|
+
// on another major breaks at runtime exactly as a required one would — except
|
|
119
|
+
// an `on_install` failure is only a warning, so it breaks silently
|
|
120
|
+
// (celilo#1384). No provider installed is still fine: the version check below
|
|
121
|
+
// skips a capability nobody provides.
|
|
122
|
+
if (manifest.optional?.capabilities) {
|
|
123
|
+
const optionalMismatches = await findCapabilityVersionMismatches(
|
|
124
|
+
manifest.optional.capabilities,
|
|
125
|
+
db,
|
|
126
|
+
);
|
|
127
|
+
if (optionalMismatches.length > 0) {
|
|
128
|
+
return {
|
|
129
|
+
success: false,
|
|
130
|
+
error: formatVersionMismatchError(moduleId, optionalMismatches),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
// Check for missing required variables BEFORE generating — if any are missing
|
|
117
136
|
// we return them for the caller to interview, then the caller re-invokes deploy
|
|
118
137
|
// after the user has answered. This prevents generation from failing mid-way
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { readFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
-
import { type Bus, describeError } from '@celilo/event-bus';
|
|
22
|
+
import { type Bus, type BusHealth, describeError } from '@celilo/event-bus';
|
|
23
23
|
import { desc, eq, inArray } from 'drizzle-orm';
|
|
24
24
|
import { parse as parseYaml } from 'yaml';
|
|
25
25
|
import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
|
|
@@ -203,6 +203,12 @@ export interface DispatcherCheckOptions {
|
|
|
203
203
|
* Injected so the crash-loop aspect is testable without launchd.
|
|
204
204
|
*/
|
|
205
205
|
launchdProbe?: (scope: SupervisorScope) => LaunchdUnitStatus | null;
|
|
206
|
+
/**
|
|
207
|
+
* A verdict already taken, normally `await bus.healthConfirmed()`. Callers
|
|
208
|
+
* that can await pass it so a load-starved dispatcher reads `lagging` rather
|
|
209
|
+
* than the `wedged` a single sample cannot distinguish it from (celilo#1399).
|
|
210
|
+
*/
|
|
211
|
+
health?: BusHealth;
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
/**
|
|
@@ -214,7 +220,7 @@ export interface DispatcherCheckOptions {
|
|
|
214
220
|
*/
|
|
215
221
|
export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): FleetFinding {
|
|
216
222
|
const now = opts.now ?? Date.now();
|
|
217
|
-
const health = bus.health();
|
|
223
|
+
const health = opts.health ?? bus.health();
|
|
218
224
|
const hb = bus.db
|
|
219
225
|
.query<HeartbeatRow, []>(
|
|
220
226
|
'SELECT dispatcher_id, last_heartbeat, started_at, pid, version FROM dispatcher_heartbeat ORDER BY last_heartbeat DESC LIMIT 1',
|
|
@@ -227,14 +233,25 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
227
233
|
|
|
228
234
|
// (1) running — a fresh heartbeat. health() already classifies a
|
|
229
235
|
// stale/absent heartbeat as no_dispatcher.
|
|
230
|
-
if (health.status === 'no_dispatcher' || !hb) {
|
|
236
|
+
if (health.status === 'no_dispatcher' || health.status === 'wedged' || !hb) {
|
|
231
237
|
statuses.push('fail');
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
+
if (health.status === 'wedged') {
|
|
239
|
+
// Registered, process alive, heartbeat stale, nothing running: it is present
|
|
240
|
+
// but dead, so "start the dispatcher" is the wrong instruction (celilo#1398).
|
|
241
|
+
detail.push(
|
|
242
|
+
`dispatcher WEDGED — pid ${hb?.pid ?? '?'} is alive and registered but its heartbeat is ${Math.round((health.lastHeartbeatAgeMs ?? 0) / 1000)}s old with nothing running (events are queueing, not delivered)`,
|
|
243
|
+
);
|
|
244
|
+
remediations.push(
|
|
245
|
+
health.remedy ?? 'restart the dispatcher: `celilo events restart-daemon --system`',
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
detail.push(
|
|
249
|
+
'no live dispatcher — heartbeat absent or stale (events are queueing, not delivered)',
|
|
250
|
+
);
|
|
251
|
+
remediations.push(
|
|
252
|
+
'start the dispatcher: `systemctl --user enable --now celilo-events.service` (or `celilo events install-daemon` then enable it)',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
238
255
|
// A unit can be INSTALLED and still dead: the supervisor respawns a
|
|
239
256
|
// program that cannot start in the unit's environment and it dies again,
|
|
240
257
|
// forever. On macOS the classic cause is a PATH-less
|
|
@@ -274,6 +291,15 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
274
291
|
detail.push(
|
|
275
292
|
`running (pid ${hb.pid}, heartbeat ${Math.round(ageMs / 1000)}s ago, code v${hb.version})`,
|
|
276
293
|
);
|
|
294
|
+
if (health.status === 'lagging') {
|
|
295
|
+
// Confirmed alive by a second beat: slow, not dead. Worth saying — an operator
|
|
296
|
+
// reading a 60s heartbeat wants to know it was checked — but not a failure,
|
|
297
|
+
// and explicitly not a restart (celilo#1399).
|
|
298
|
+
statuses.push('warn');
|
|
299
|
+
detail.push(
|
|
300
|
+
'heartbeat is stale but still advancing — the dispatcher is alive and starved for CPU, not wedged; it recovers when load drops',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
277
303
|
if (health.status === 'stuck') {
|
|
278
304
|
statuses.push('warn');
|
|
279
305
|
detail.push(
|
|
@@ -1395,7 +1421,11 @@ export async function runFleetChecks(
|
|
|
1395
1421
|
const hostLiveness = opts.hostLiveness ?? (() => collectHostLiveness(db));
|
|
1396
1422
|
return [
|
|
1397
1423
|
checkSchemaDrift(db),
|
|
1398
|
-
checkDispatcher(bus, {
|
|
1424
|
+
checkDispatcher(bus, {
|
|
1425
|
+
now: opts.now,
|
|
1426
|
+
installedCodeMtimeMs: opts.installedCodeMtimeMs,
|
|
1427
|
+
health: await bus.healthConfirmed(),
|
|
1428
|
+
}),
|
|
1399
1429
|
checkBuildBusPublishing(bus, db, { now: opts.now }),
|
|
1400
1430
|
checkSubscribers(bus, db),
|
|
1401
1431
|
checkCapabilityProviders(db),
|