@celilo/cli 3.1.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/publish/workspace.ts +9 -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 +87 -44
- 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/policy/module-business-baseline.ts +0 -6
- 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 +48 -13
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/provider-converge.test.ts +16 -0
- package/src/services/provider-converge.ts +106 -4
- 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
- package/src/services/static-content-converge.test.ts +0 -516
- package/src/services/static-content-converge.ts +0 -390
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored machine interface zone labels that no longer match the zone model.
|
|
3
|
+
*
|
|
4
|
+
* A label written at `machine add` is never revisited, so a zone-model change
|
|
5
|
+
* leaves every earlier machine describing a topology that no longer exists.
|
|
6
|
+
* Nothing said so until this check: celilo#1382 surfaced only as an upgrade
|
|
7
|
+
* failing with "no non-loopback interface in zone 'secure-mgmt'", on a machine
|
|
8
|
+
* that had one.
|
|
9
|
+
*
|
|
10
|
+
* `drift`, not `blocked`: a stale label breaks a deploy only when something
|
|
11
|
+
* resolves by zone, and the remediation is one command.
|
|
12
|
+
*
|
|
13
|
+
* Pure over a pre-computed diff — see services/machine-interface-zones.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ZoneReclassification } from '../machine-interface-zones';
|
|
17
|
+
import type { DriftFinding } from './types';
|
|
18
|
+
|
|
19
|
+
export interface MachineInterfaceZonesAuditDeps {
|
|
20
|
+
/** One entry per machine, carrying only the labels that disagree. */
|
|
21
|
+
machines: Array<{ hostname: string; changes: ZoneReclassification[] }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function auditMachineInterfaceZones(deps: MachineInterfaceZonesAuditDeps): DriftFinding[] {
|
|
25
|
+
return deps.machines
|
|
26
|
+
.filter((m) => m.changes.length > 0)
|
|
27
|
+
.map(({ hostname, changes }) => {
|
|
28
|
+
const named = changes
|
|
29
|
+
.map((c) => `${c.name} (${c.ipAddress}): ${c.from} → ${c.to}`)
|
|
30
|
+
.join(', ');
|
|
31
|
+
return {
|
|
32
|
+
category: 'interface_classification',
|
|
33
|
+
severity: 'drift',
|
|
34
|
+
code: 'stale_interface_zone_labels',
|
|
35
|
+
subject: hostname,
|
|
36
|
+
message: `${hostname}: ${changes.length} stored interface zone label(s) predate the current zone model — ${named}`,
|
|
37
|
+
details:
|
|
38
|
+
'Interface zones are decided once, at `machine add`, and never re-checked. When a zone is added or a subnet is declared for one that had none, every machine added earlier keeps the old label. Anything resolving a machine by zone then reports the interface as absent rather than as mislabelled.',
|
|
39
|
+
remediation: `celilo machine reclassify ${hostname} --apply`,
|
|
40
|
+
actionable: true,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { auditRebootPending } from './reboot-pending';
|
|
3
|
+
|
|
4
|
+
const CLEAN = { pending: false, unmeasured: false, reasons: [] };
|
|
5
|
+
const NOW = 1_700_000_000_000;
|
|
6
|
+
|
|
7
|
+
describe('auditRebootPending', () => {
|
|
8
|
+
it('reports a pending reboot as drift, carrying the reason', () => {
|
|
9
|
+
const [finding] = auditRebootPending({
|
|
10
|
+
verdict: {
|
|
11
|
+
pending: true,
|
|
12
|
+
unmeasured: false,
|
|
13
|
+
reasons: ['the running kernel is 6.8.0-1063-raspi'],
|
|
14
|
+
},
|
|
15
|
+
marker: null,
|
|
16
|
+
now: () => NOW,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(finding.severity).toBe('drift');
|
|
20
|
+
expect(finding.code).toBe('reboot_pending');
|
|
21
|
+
expect(finding.message).toContain('6.8.0-1063-raspi');
|
|
22
|
+
expect(finding.remediation).toContain('celilo system reboot');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reports an unverified reboot as BLOCKED — a control plane may not have come back', () => {
|
|
26
|
+
const [finding] = auditRebootPending({
|
|
27
|
+
verdict: CLEAN,
|
|
28
|
+
marker: { bootIdBefore: 'a', requestedAt: NOW - 3 * 60 * 60 * 1000, reason: 'kernel' },
|
|
29
|
+
now: () => NOW,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(finding.severity).toBe('blocked');
|
|
33
|
+
expect(finding.code).toBe('reboot_unverified');
|
|
34
|
+
expect(finding.message).toContain('3h ago');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is silent on a host that is current and has no reboot outstanding', () => {
|
|
38
|
+
expect(auditRebootPending({ verdict: CLEAN, marker: null, now: () => NOW })).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports UNMEASURED rather than clean when the host could not be asked', () => {
|
|
42
|
+
const [finding] = auditRebootPending({
|
|
43
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
44
|
+
marker: null,
|
|
45
|
+
now: () => NOW,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(finding.severity).toBe('unmeasured');
|
|
49
|
+
expect(finding.actionable).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('still reports the unverified reboot when the host itself is unmeasurable', () => {
|
|
53
|
+
// The two facts are independent: not being able to read the kernel does
|
|
54
|
+
// not make an outstanding reboot request go away.
|
|
55
|
+
const findings = auditRebootPending({
|
|
56
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
57
|
+
marker: { bootIdBefore: 'a', requestedAt: NOW, reason: '' },
|
|
58
|
+
now: () => NOW,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(findings.map((f) => f.code)).toEqual(['reboot_unverified', 'reboot_pending_unmeasured']);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending reboot on the management server (celilo#1378).
|
|
3
|
+
*
|
|
4
|
+
* `apt-upgrade` printed needrestart's pending-kernel block and that was the
|
|
5
|
+
* only place the condition was ever visible — so it was visible exactly when
|
|
6
|
+
* an operator happened to run an upgrade, and invisible the rest of the time.
|
|
7
|
+
* celilo-mgr sat on a kernel it was not running for days that way.
|
|
8
|
+
*
|
|
9
|
+
* Two findings, and they are different facts:
|
|
10
|
+
*
|
|
11
|
+
* reboot_pending — the host needs a reboot and has not had one.
|
|
12
|
+
* reboot_unverified — celilo ASKED for a reboot and nothing has confirmed
|
|
13
|
+
* the host came back. That is the more serious of the
|
|
14
|
+
* two: it is the shape of a control plane that went
|
|
15
|
+
* down and did not return, or returned without its
|
|
16
|
+
* dispatcher.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RebootMarker, RebootPendingVerdict } from '../system-reboot';
|
|
20
|
+
import type { DriftFinding } from './types';
|
|
21
|
+
|
|
22
|
+
export interface RebootPendingAuditDeps {
|
|
23
|
+
verdict: RebootPendingVerdict;
|
|
24
|
+
/** A reboot celilo requested that `verifyReturn` has not yet cleared. */
|
|
25
|
+
marker: RebootMarker | null;
|
|
26
|
+
/** Defaults to `Date.now()`. */
|
|
27
|
+
now?: () => number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HOUR = 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
export function auditRebootPending(deps: RebootPendingAuditDeps): DriftFinding[] {
|
|
33
|
+
const findings: DriftFinding[] = [];
|
|
34
|
+
const now = (deps.now ?? Date.now)();
|
|
35
|
+
|
|
36
|
+
if (deps.marker !== null) {
|
|
37
|
+
const ageHours = Math.round((now - deps.marker.requestedAt) / HOUR);
|
|
38
|
+
findings.push({
|
|
39
|
+
category: 'reboot_pending',
|
|
40
|
+
severity: 'blocked',
|
|
41
|
+
code: 'reboot_unverified',
|
|
42
|
+
subject: 'system',
|
|
43
|
+
message: `celilo requested a reboot ${ageHours}h ago and nothing has confirmed the host came back.`,
|
|
44
|
+
details:
|
|
45
|
+
'The process that asks for a reboot goes down with the host, so the return is verified by a second call. An uncleared marker means that call never succeeded — either the host did not reboot, or it came back without its dispatcher.',
|
|
46
|
+
remediation:
|
|
47
|
+
'Run `celilo system reboot --verify`. It passes only once the boot id has changed and the dispatcher and CLI answer, and it names which of those is missing.',
|
|
48
|
+
actionable: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (deps.verdict.unmeasured) {
|
|
53
|
+
findings.push({
|
|
54
|
+
category: 'reboot_pending',
|
|
55
|
+
severity: 'unmeasured',
|
|
56
|
+
code: 'reboot_pending_unmeasured',
|
|
57
|
+
subject: 'system',
|
|
58
|
+
message:
|
|
59
|
+
'Neither the reboot-required marker nor a kernel pair could be read, so nothing was compared.',
|
|
60
|
+
details:
|
|
61
|
+
'An absent /var/run/reboot-required is not evidence of a current kernel — a host without that path simply never writes one.',
|
|
62
|
+
remediation:
|
|
63
|
+
'Run `celilo system audit` on the management server itself (Debian/Ubuntu), where /var/run/reboot-required and `uname -r` are readable.',
|
|
64
|
+
actionable: false,
|
|
65
|
+
});
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (deps.verdict.pending) {
|
|
70
|
+
findings.push({
|
|
71
|
+
category: 'reboot_pending',
|
|
72
|
+
severity: 'drift',
|
|
73
|
+
code: 'reboot_pending',
|
|
74
|
+
subject: 'system',
|
|
75
|
+
message: `The management server needs a reboot: ${deps.verdict.reasons.join('; ')}.`,
|
|
76
|
+
details:
|
|
77
|
+
'A kernel or service the host has installed is not the one it is running. Until it reboots, an upgrade that has been applied on disk has not taken effect.',
|
|
78
|
+
remediation:
|
|
79
|
+
'Reboot through celilo: `celilo system reboot --allow-reboot`. It refuses while a module operation is in flight, and `--verify` confirms the host came back.',
|
|
80
|
+
actionable: true,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return findings;
|
|
85
|
+
}
|
|
@@ -38,7 +38,9 @@ import { auditInterfaceClassification } from './interface-classification';
|
|
|
38
38
|
import { auditModuleIntegrity } from './module-integrity';
|
|
39
39
|
import { auditModuleVersions } from './module-versions';
|
|
40
40
|
import { auditPublicDns } from './public-dns';
|
|
41
|
+
import { auditRebootPending } from './reboot-pending';
|
|
41
42
|
import { auditSchema } from './schema';
|
|
43
|
+
import { auditServerBunPin } from './server-bun-pin';
|
|
42
44
|
import { auditTerraformPlan } from './terraform-plan';
|
|
43
45
|
import { auditTrustedSources } from './trusted-sources';
|
|
44
46
|
import type { DriftCategory, DriftFinding, DriftSeverity } from './types';
|
|
@@ -68,6 +70,25 @@ const GATE: Record<DriftCategory, GateEntry> = {
|
|
|
68
70
|
because:
|
|
69
71
|
'compares capability versions claimed in installed module manifests against the framework registry and deployed providers, all read from celilo own DB and local manifests — the subject is celilo itself and is always reachable',
|
|
70
72
|
},
|
|
73
|
+
reboot_pending: {
|
|
74
|
+
probe: () =>
|
|
75
|
+
auditRebootPending({
|
|
76
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
77
|
+
marker: null,
|
|
78
|
+
}),
|
|
79
|
+
because:
|
|
80
|
+
'probed: a host with no /var/run/reboot-required and no readable kernel pair was not measured, and an absent marker is not a current kernel',
|
|
81
|
+
},
|
|
82
|
+
server_bun_pin: {
|
|
83
|
+
probe: () =>
|
|
84
|
+
auditServerBunPin({
|
|
85
|
+
pinnedVersion: null,
|
|
86
|
+
runningVersion: '1.4.2',
|
|
87
|
+
pinPath: '/etc/celilo/mise.pin.toml',
|
|
88
|
+
}),
|
|
89
|
+
because:
|
|
90
|
+
'probed: the deb pin file can be absent (a non-deb install), and then nothing was compared',
|
|
91
|
+
},
|
|
71
92
|
browser_pin: {
|
|
72
93
|
because:
|
|
73
94
|
'compares a bundled client playwright revision against the locally provisioned browser binary, both on this machine — always reachable',
|
|
@@ -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'
|