@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
package/src/cli/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
import { handleMachineAdd } from './commands/machine-add';
|
|
66
66
|
import { handleMachineEarmark } from './commands/machine-earmark';
|
|
67
67
|
import { handleMachineList } from './commands/machine-list';
|
|
68
|
+
import { handleMachineReclassify } from './commands/machine-reclassify';
|
|
68
69
|
import { handleMachineRemove } from './commands/machine-remove';
|
|
69
70
|
import { handleMachineStatus } from './commands/machine-status';
|
|
70
71
|
import { moduleAudit } from './commands/module-audit';
|
|
@@ -132,6 +133,7 @@ import { handleSystemDoctor } from './commands/system-doctor';
|
|
|
132
133
|
import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
|
|
133
134
|
import { handleSystemInit } from './commands/system-init';
|
|
134
135
|
import { handleSystemMigrate } from './commands/system-migrate';
|
|
136
|
+
import { handleSystemReboot } from './commands/system-reboot';
|
|
135
137
|
import { handleSystemSecretGet } from './commands/system-secret-get';
|
|
136
138
|
import { handleSystemSecretSet } from './commands/system-secret-set';
|
|
137
139
|
import { handleSystemUpdate } from './commands/system-update';
|
|
@@ -1097,6 +1099,10 @@ Examples:
|
|
|
1097
1099
|
celilo machine earmark 10.0.10.10 caddy
|
|
1098
1100
|
celilo machine earmark pi-01 --clear
|
|
1099
1101
|
|
|
1102
|
+
# Re-classify stored interface zone labels after a zone-model change
|
|
1103
|
+
celilo machine reclassify celilo-mgr
|
|
1104
|
+
celilo machine reclassify celilo-mgr --apply
|
|
1105
|
+
|
|
1100
1106
|
# Remove machine (by hostname or IP)
|
|
1101
1107
|
celilo machine remove pi-01
|
|
1102
1108
|
celilo machine remove 10.0.10.10 --force
|
|
@@ -1214,6 +1220,11 @@ Subcommands:
|
|
|
1214
1220
|
|
|
1215
1221
|
doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
|
|
1216
1222
|
|
|
1223
|
+
reboot [--allow-reboot] [--verify] Reboot the management server. Refuses while a module
|
|
1224
|
+
operation is in flight and names it. The process asking
|
|
1225
|
+
dies in the reboot, so --verify is what confirms the host
|
|
1226
|
+
came back: boot id changed, dispatcher live, CLI answering.
|
|
1227
|
+
|
|
1217
1228
|
Runbook:
|
|
1218
1229
|
https://celilo.computer/docs/system-update
|
|
1219
1230
|
(offline summary in apps/celilo/docs/INDEX.md)
|
|
@@ -2269,6 +2280,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2269
2280
|
return handleMachineEarmark(parsed.args, parsed.flags);
|
|
2270
2281
|
}
|
|
2271
2282
|
|
|
2283
|
+
if (parsed.subcommand === 'reclassify') {
|
|
2284
|
+
return handleMachineReclassify(parsed.args, parsed.flags);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2272
2287
|
return {
|
|
2273
2288
|
success: false,
|
|
2274
2289
|
error: `Unknown machine subcommand: ${parsed.subcommand}\n\nRun "celilo machine --help" for usage`,
|
|
@@ -2369,6 +2384,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2369
2384
|
return handleSystemMigrate(parsed.args, parsed.flags);
|
|
2370
2385
|
}
|
|
2371
2386
|
|
|
2387
|
+
if (parsed.subcommand === 'reboot') {
|
|
2388
|
+
return handleSystemReboot(parsed.args, parsed.flags);
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2372
2391
|
return {
|
|
2373
2392
|
success: false,
|
|
2374
2393
|
error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,
|
|
@@ -73,6 +73,8 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
|
|
|
73
73
|
'schema',
|
|
74
74
|
'capability_abi',
|
|
75
75
|
'browser_pin',
|
|
76
|
+
'server_bun_pin',
|
|
77
|
+
'reboot_pending',
|
|
76
78
|
'terraform_plan',
|
|
77
79
|
'module_versions',
|
|
78
80
|
'module_configs',
|
|
@@ -96,6 +98,8 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
|
|
|
96
98
|
];
|
|
97
99
|
|
|
98
100
|
export const CATEGORY_LABELS: Record<DriftCategory, string> = {
|
|
101
|
+
server_bun_pin: 'Server bun pin',
|
|
102
|
+
reboot_pending: 'Pending reboot',
|
|
99
103
|
module_integrity: 'Module integrity',
|
|
100
104
|
detect_without_converge: 'Drift without converge',
|
|
101
105
|
jail_exemptions: 'Hook jail exemptions',
|
package/src/hooks/broker.ts
CHANGED
|
@@ -105,6 +105,18 @@ export interface Broker {
|
|
|
105
105
|
outcome(): HookOutcome | undefined;
|
|
106
106
|
/** Every protocol-level complaint, for the error message when one matters. */
|
|
107
107
|
faults(): string[];
|
|
108
|
+
/**
|
|
109
|
+
* The capability calls that are still in flight, newest last, each rendered
|
|
110
|
+
* as `provider_module:capability.method (Ns)`.
|
|
111
|
+
*
|
|
112
|
+
* A hook that dies at its total timeout has usually died WAITING on one of
|
|
113
|
+
* these, and the bare "Hook total timeout exceeded" names only the hook —
|
|
114
|
+
* never what it was waiting for. Measured 2026-09-21 (celilo#1403):
|
|
115
|
+
* bna-yard-sale's on_install died at 120s blocked on caddy's 75s route
|
|
116
|
+
* reconcile, and the log's only tell was a `publishStaticSite` push line
|
|
117
|
+
* with no matching completion. The operator had to infer the provider.
|
|
118
|
+
*/
|
|
119
|
+
pendingCalls(): string[];
|
|
108
120
|
/** Refuse further calls. Idempotent. Called on kill and again on cleanup. */
|
|
109
121
|
stop(): void;
|
|
110
122
|
/** Close the socket and remove its directory. */
|
|
@@ -184,6 +196,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
184
196
|
let outcome: HookOutcome | undefined;
|
|
185
197
|
let stopped = false;
|
|
186
198
|
const faults: string[] = [];
|
|
199
|
+
/** In-flight capability calls by frame id, so a timeout can name them. */
|
|
200
|
+
const pending = new Map<string, { capability: string; method: string; startedAt: number }>();
|
|
187
201
|
let connection: Socket | undefined;
|
|
188
202
|
// Already resolved: no connection means nothing left to deliver.
|
|
189
203
|
let connectionClosed: Promise<void> = Promise.resolve();
|
|
@@ -300,6 +314,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
300
314
|
return;
|
|
301
315
|
}
|
|
302
316
|
|
|
317
|
+
pending.set(id, { capability: capabilityName, method, startedAt: Date.now() });
|
|
303
318
|
try {
|
|
304
319
|
const value = await (fn as (...a: unknown[]) => unknown).apply(capability, args);
|
|
305
320
|
options.onActivity();
|
|
@@ -307,6 +322,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
307
322
|
} catch (error) {
|
|
308
323
|
options.onActivity();
|
|
309
324
|
send({ type: 'throw', id, error: serializeError(error) });
|
|
325
|
+
} finally {
|
|
326
|
+
pending.delete(id);
|
|
310
327
|
}
|
|
311
328
|
}
|
|
312
329
|
|
|
@@ -384,6 +401,20 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
384
401
|
]),
|
|
385
402
|
outcome: () => outcome,
|
|
386
403
|
faults: () => [...faults],
|
|
404
|
+
pendingCalls: () => {
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
return [...pending.values()]
|
|
407
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
408
|
+
.map((call) => {
|
|
409
|
+
// `providerModuleId` is stamped onto the capability's data half by
|
|
410
|
+
// the loader, so the message can name the MODULE the hook is waiting
|
|
411
|
+
// on and not only the capability it asked through.
|
|
412
|
+
const view = options.capabilities[call.capability] as Record<string, unknown> | undefined;
|
|
413
|
+
const provider = typeof view?.providerModuleId === 'string' ? view.providerModuleId : '';
|
|
414
|
+
const waited = Math.round((now - call.startedAt) / 1000);
|
|
415
|
+
return `${provider ? `${provider}:` : ''}${call.capability}.${call.method} (${waited}s)`;
|
|
416
|
+
});
|
|
417
|
+
},
|
|
387
418
|
stop: () => {
|
|
388
419
|
stopped = true;
|
|
389
420
|
},
|
|
@@ -80,6 +80,57 @@ import { loadHookConfigMap } from './load-hook-config';
|
|
|
80
80
|
* upstream-chain wiring takes a second factory argument (`upstreamFirewall`)
|
|
81
81
|
* that doesn't fit the `defineCapabilityFunction` shape.
|
|
82
82
|
*/
|
|
83
|
+
/**
|
|
84
|
+
* Make the public_web provider re-render and converge, and insist it happened.
|
|
85
|
+
*
|
|
86
|
+
* Both triggers that change what the provider must serve come through here: a
|
|
87
|
+
* route registered or withdrawn, and a static site published. They used to be
|
|
88
|
+
* two different mechanisms — an event for routes, a direct core-side content
|
|
89
|
+
* converge for publishes — which is how a publish could converge content
|
|
90
|
+
* against a config the provider had not re-rendered. One path now
|
|
91
|
+
* (providers-converge-declared-state, design D6).
|
|
92
|
+
*
|
|
93
|
+
* Authoritative (ISS-0081): a change the provider never reconciled is a
|
|
94
|
+
* FAILURE, not a warning. These used to be logger.warn while the call returned
|
|
95
|
+
* success anyway, so a consumer could report "ready" while caddy never learned
|
|
96
|
+
* the hostname — no site block, no cert, TLS internal_error for clients.
|
|
97
|
+
*/
|
|
98
|
+
async function requireProviderReconcile(consumingModuleId: string, what: string): Promise<void> {
|
|
99
|
+
const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
|
|
100
|
+
|
|
101
|
+
if (reconcile.noDispatcher) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`public_web ${what} for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// The handler's own words, when it left any. A crash is retried, so its
|
|
107
|
+
// delivery sits in `pending` with the error already recorded while the
|
|
108
|
+
// deadline runs out — which is how celilo#1401 reported a TIMEOUT for a
|
|
109
|
+
// caddy crash, for days, with `0 ok, 0 failed of 1` and the cause nowhere.
|
|
110
|
+
const because = reconcile.lastError ? ` The provider reported: ${reconcile.lastError}` : '';
|
|
111
|
+
|
|
112
|
+
if (reconcile.timedOut) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`public_web reconcile for ${consumingModuleId} (${what}) did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the change is live.${because}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (reconcile.failed > 0) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`public_web reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — caddy could not apply the change, so the hostname is not served as declared.${because}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
// ISS-0087: a change WAS persisted and the dispatcher IS alive, yet ZERO
|
|
123
|
+
// providers reconciled it (no subscriber consumed routes_changed). That
|
|
124
|
+
// would otherwise report success for something nobody applied. `events === 0`
|
|
125
|
+
// means nothing changed (benign); `events > 0 && succeeded === 0` means it
|
|
126
|
+
// changed and nobody served it — a failure.
|
|
127
|
+
if (reconcile.events > 0 && reconcile.succeeded === 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`public_web ${what} for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the change is persisted yet never served. Ensure a public_web provider (caddy) is deployed and subscribed.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
83
134
|
export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName: string }> =
|
|
84
135
|
{
|
|
85
136
|
dns_registrar: {
|
|
@@ -646,19 +697,26 @@ export async function loadCapabilityFunctions(
|
|
|
646
697
|
caddyModuleId: provider.moduleId,
|
|
647
698
|
dnsManagedDomains,
|
|
648
699
|
dnsRegistrarModuleId,
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
700
|
+
// The bytes move through the provider's Ansible converge, not a
|
|
701
|
+
// hand-built ssh tar pipe (design D10). What changed in
|
|
702
|
+
// providers-converge-declared-state is WHO decides what to converge:
|
|
703
|
+
// core used to plan the whole fleet's release set here and throw when
|
|
704
|
+
// any one module had no built site (celilo#1383). Now the publish
|
|
705
|
+
// makes the PROVIDER re-render from the declared rows — the same path
|
|
706
|
+
// a route change takes — so content and config converge together and a
|
|
707
|
+
// broken consumer costs only its own site.
|
|
708
|
+
//
|
|
709
|
+
// The field keeps its name: it is passed into `createPublicWeb` inside
|
|
710
|
+
// the CONSUMER's bundled copy of @celilo/capabilities, and a module
|
|
711
|
+
// running an older bundle calls `deps.convergeStaticContent()` by that
|
|
712
|
+
// name. Renaming it here would leave that call undefined on every
|
|
713
|
+
// module that has not reinstalled.
|
|
714
|
+
//
|
|
715
|
+
// Awaited, so a publish returns only once the host matches: a publish
|
|
654
716
|
// that reports ready while the host never received the bytes is the
|
|
655
717
|
// exact "served but silently unreachable" anti-pattern.
|
|
656
718
|
convergeStaticContent: async () => {
|
|
657
|
-
|
|
658
|
-
const result = await convergeStaticContent(db, provider.moduleId, consumingModuleId);
|
|
659
|
-
if (!result.success) {
|
|
660
|
-
throw new Error(result.error ?? 'static-content converge failed');
|
|
661
|
-
}
|
|
719
|
+
await requireProviderReconcile(consumingModuleId, 'static publish');
|
|
662
720
|
},
|
|
663
721
|
// ISS-0035: register_route/unregister_routes emit this coarse signal
|
|
664
722
|
// instead of SSHing caddy; the caddy provider's reconcile_routes
|
|
@@ -667,37 +725,7 @@ export async function loadCapabilityFunctions(
|
|
|
667
725
|
// the consuming module's health_check runs right after and would
|
|
668
726
|
// otherwise race the async reconcile.
|
|
669
727
|
onRoutesChanged: async () => {
|
|
670
|
-
|
|
671
|
-
// Authoritative (ISS-0081): a route the provider (caddy) never
|
|
672
|
-
// reconciled is a deploy FAILURE, not a warning. These used to be
|
|
673
|
-
// logger.warn while register_route returned success anyway, so a
|
|
674
|
-
// consumer could report "ready" while caddy never learned the
|
|
675
|
-
// hostname — no site block, no cert, TLS internal_error for clients.
|
|
676
|
-
if (reconcile.noDispatcher) {
|
|
677
|
-
throw new Error(
|
|
678
|
-
`public_web route for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
if (reconcile.timedOut) {
|
|
682
|
-
throw new Error(
|
|
683
|
-
`public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the route is live.`,
|
|
684
|
-
);
|
|
685
|
-
}
|
|
686
|
-
if (reconcile.failed > 0) {
|
|
687
|
-
throw new Error(
|
|
688
|
-
`public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
// ISS-0087: a route WAS registered and the dispatcher IS alive, yet ZERO
|
|
692
|
-
// providers reconciled it (no subscriber consumed routes_changed). That
|
|
693
|
-
// registers a route nobody applied and would otherwise report success.
|
|
694
|
-
// `events === 0` means no routes changed (benign); `events > 0 &&
|
|
695
|
-
// succeeded === 0` means the route changed but nobody served it — a failure.
|
|
696
|
-
if (reconcile.events > 0 && reconcile.succeeded === 0) {
|
|
697
|
-
throw new Error(
|
|
698
|
-
`public_web route for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the route is persisted yet never served (no site block, no cert). Ensure a public_web provider (caddy) is deployed and subscribed.`,
|
|
699
|
-
);
|
|
700
|
-
}
|
|
728
|
+
await requireProviderReconcile(consumingModuleId, 'route change');
|
|
701
729
|
},
|
|
702
730
|
});
|
|
703
731
|
debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
|
|
@@ -734,6 +762,22 @@ export async function loadCapabilityFunctions(
|
|
|
734
762
|
const { resolveModuleStateWebRoot, resolveModuleWebRoot } = await import(
|
|
735
763
|
'../module/web-root'
|
|
736
764
|
);
|
|
765
|
+
|
|
766
|
+
// Pause preserves state (module-pause spec), so a paused module's
|
|
767
|
+
// site is not re-converged. Leaving it out of the provider's site
|
|
768
|
+
// list is exactly that: the role converges only the slugs it is
|
|
769
|
+
// given and prunes nothing else, so the release already on the box
|
|
770
|
+
// stays, and the module's routes still render into the config.
|
|
771
|
+
//
|
|
772
|
+
// This is where d04a2bb7's paused filter went when core's fleet-wide
|
|
773
|
+
// plan was deleted. It moved rather than vanished, and it moved to
|
|
774
|
+
// the per-module lookup, which is the only place that can answer for
|
|
775
|
+
// one module without walking the rest.
|
|
776
|
+
const { isModulePaused } = await import('../services/module-pause');
|
|
777
|
+
if (isModulePaused(db, moduleId)) {
|
|
778
|
+
return { unavailable: `module '${moduleId}' is paused — its site is left as it is` };
|
|
779
|
+
}
|
|
780
|
+
|
|
737
781
|
const sourceDir = resolveModuleWebRoot(moduleId, db);
|
|
738
782
|
if (!sourceDir) {
|
|
739
783
|
return { unavailable: `module '${moduleId}' is not installed` };
|
|
@@ -749,9 +793,8 @@ export async function loadCapabilityFunctions(
|
|
|
749
793
|
return { sourceDir, ...overlayDir };
|
|
750
794
|
},
|
|
751
795
|
converge: async (artifacts) => {
|
|
752
|
-
const { convergeProviderConfig } = await import(
|
|
753
|
-
|
|
754
|
-
'../services/static-content-converge'
|
|
796
|
+
const { convergeProviderConfig, resolveStaticContentRetention } = await import(
|
|
797
|
+
'../services/provider-converge'
|
|
755
798
|
);
|
|
756
799
|
const result = await convergeProviderConfig(db, providerModuleId, {
|
|
757
800
|
...artifacts,
|
package/src/hooks/executor.ts
CHANGED
|
@@ -511,7 +511,14 @@ export async function executeHookScript(
|
|
|
511
511
|
setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
|
|
512
512
|
};
|
|
513
513
|
|
|
514
|
-
|
|
514
|
+
// Snapshotted inside the timer rather than read at throw time: the child's
|
|
515
|
+
// death rejects its in-flight calls, so by the time we build the message
|
|
516
|
+
// the map that knows the answer has drained itself.
|
|
517
|
+
let pendingCallsAtKill: string[] = [];
|
|
518
|
+
const totalTimer = setTimeout(() => {
|
|
519
|
+
pendingCallsAtKill = broker.pendingCalls();
|
|
520
|
+
kill('total');
|
|
521
|
+
}, timeoutMs);
|
|
515
522
|
// Debug runs get no idle timer, same as before: an operator stepping
|
|
516
523
|
// through a browser hook is silent for minutes on purpose.
|
|
517
524
|
const idleTimer = context.debug
|
|
@@ -536,7 +543,18 @@ export async function executeHookScript(
|
|
|
536
543
|
// nothing keeps running past this line. That is the difference from the
|
|
537
544
|
// `Promise.race` this replaces (celilo#1003).
|
|
538
545
|
if (killedFor === 'total') {
|
|
539
|
-
|
|
546
|
+
// Name what it was WAITING ON, not just that it waited. A hook whose
|
|
547
|
+
// budget is consumed by one slow provider used to die as a bare
|
|
548
|
+
// "Hook total timeout exceeded", so the operator had to infer the
|
|
549
|
+
// provider from a capability push line with no matching completion
|
|
550
|
+
// (celilo#1403: bna-yard-sale's 120s on_install blocked on caddy's 75s
|
|
551
|
+
// route reconcile). Captured before `close()`, while the calls are
|
|
552
|
+
// still in flight — `stop()` refuses new calls but clears nothing.
|
|
553
|
+
const waitingOn = pendingCallsAtKill;
|
|
554
|
+
const waitingSuffix = waitingOn.length > 0 ? `, waiting on ${waitingOn.join(', ')}` : '';
|
|
555
|
+
throw new Error(
|
|
556
|
+
`Hook total timeout exceeded (${Math.round(timeoutMs / 1000)}s)${waitingSuffix}`,
|
|
557
|
+
);
|
|
540
558
|
}
|
|
541
559
|
if (killedFor === 'idle') {
|
|
542
560
|
throw new Error(
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that blocks inside a capability call it never gets an
|
|
3
|
+
* answer to, and is killed at its own total timeout (celilo#1403).
|
|
4
|
+
*
|
|
5
|
+
* The real shape is a consumer's `on_install` calling
|
|
6
|
+
* `public_web.publishStaticSite`, which blocks on the provider's route
|
|
7
|
+
* reconcile. This fixture reproduces the boundary condition — a call in flight
|
|
8
|
+
* when the kill lands — without a provider.
|
|
9
|
+
*
|
|
10
|
+
* It logs BEFORE the call so the run is not mistaken for an idle timeout, then
|
|
11
|
+
* awaits forever.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineHook } from '@celilo/capabilities';
|
|
15
|
+
|
|
16
|
+
interface SlowCapability {
|
|
17
|
+
neverReturns(request: Record<string, unknown>): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default defineHook({
|
|
21
|
+
hook: 'container_created',
|
|
22
|
+
requires: [],
|
|
23
|
+
handler: async (ctx) => {
|
|
24
|
+
const slow = (ctx.capabilities as unknown as Record<string, SlowCapability>).slow_provider;
|
|
25
|
+
ctx.logger.info('calling the provider');
|
|
26
|
+
await slow.neverReturns({});
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hook killed at its TOTAL timeout must name what it was waiting on.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-09-21 (celilo#1403): bna-yard-sale's `on_install` called
|
|
5
|
+
* `public_web.publishStaticSite`, which blocks on caddy's route reconcile —
|
|
6
|
+
* 70-75s across the fleet's sites. The consumer died at its 120s budget with:
|
|
7
|
+
*
|
|
8
|
+
* [bna-yard-sale:on_install] -> public_web.publishStaticSite
|
|
9
|
+
* [bna-yard-sale:on_install] OK dns_registrar.registerHost
|
|
10
|
+
* [bna-yard-sale:on_install] OK dns_internal.registerRecord
|
|
11
|
+
* [bna-yard-sale:on_install] FAIL Hook total timeout exceeded (120s)
|
|
12
|
+
*
|
|
13
|
+
* Both DNS calls completed; the push line had no matching completion, and that
|
|
14
|
+
* ABSENCE was the only evidence of which provider held it. The error named the
|
|
15
|
+
* hook and never the cause, so the operator had to infer it from a gap in the
|
|
16
|
+
* log — the same class of missing signal the greenwave/axon ISP-router hooks
|
|
17
|
+
* already carry a comment about.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, test } from 'bun:test';
|
|
20
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { executeHookScript } from './executor';
|
|
24
|
+
import { createCapturingLogger } from './logger';
|
|
25
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
26
|
+
import type { HookContext } from './types';
|
|
27
|
+
|
|
28
|
+
const FIXTURES = join(__dirname, 'test-fixtures');
|
|
29
|
+
|
|
30
|
+
/** A capability whose one method never settles — the provider that holds the hook. */
|
|
31
|
+
function hangingCapabilities(): Record<string, unknown> {
|
|
32
|
+
return {
|
|
33
|
+
slow_provider: {
|
|
34
|
+
providerModuleId: 'caddy',
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
neverReturns: () => new Promise(() => {}),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function runUntilTotalTimeout(): Promise<Error> {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-timeout-'));
|
|
43
|
+
try {
|
|
44
|
+
const context: HookContext = {
|
|
45
|
+
config: configStore(),
|
|
46
|
+
secrets: secretStore(),
|
|
47
|
+
systems: [],
|
|
48
|
+
logger: createCapturingLogger().logger,
|
|
49
|
+
debug: false,
|
|
50
|
+
screenshotDir: dir,
|
|
51
|
+
stateDir: dir,
|
|
52
|
+
capabilities: hangingCapabilities(),
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
await executeHookScript(join(FIXTURES, 'slow-capability-hook.ts'), context, {
|
|
56
|
+
// Total well under idle, so the kill is unambiguously the TOTAL one:
|
|
57
|
+
// a hook blocked in a capability call emits nothing, so a shorter idle
|
|
58
|
+
// timer would win the race and prove a different thing.
|
|
59
|
+
timeoutMs: 3_000,
|
|
60
|
+
idleTimeoutMs: 60_000,
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return error as Error;
|
|
64
|
+
}
|
|
65
|
+
throw new Error('the hook returned; it was supposed to be killed at its total timeout');
|
|
66
|
+
} finally {
|
|
67
|
+
rmSync(dir, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('a total-timeout error names the provider it was waiting on (celilo#1403)', () => {
|
|
72
|
+
test('the run reached the total timeout, not the idle one', async () => {
|
|
73
|
+
const error = await runUntilTotalTimeout();
|
|
74
|
+
// Anchor the subject: an idle kill would exercise none of this.
|
|
75
|
+
expect(error.message).toContain('Hook total timeout exceeded');
|
|
76
|
+
expect(error.message).not.toContain('idle');
|
|
77
|
+
}, 20_000);
|
|
78
|
+
|
|
79
|
+
test('the message names the provider, the capability and the method', async () => {
|
|
80
|
+
const error = await runUntilTotalTimeout();
|
|
81
|
+
expect(error.message).toContain('waiting on');
|
|
82
|
+
// The provider MODULE is the operationally useful half — it is what the
|
|
83
|
+
// operator goes and looks at. `caddy` comes from `providerModuleId`.
|
|
84
|
+
expect(error.message).toContain('caddy');
|
|
85
|
+
expect(error.message).toContain('slow_provider.neverReturns');
|
|
86
|
+
}, 20_000);
|
|
87
|
+
});
|
|
@@ -514,10 +514,4 @@ export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
|
|
|
514
514
|
count: 3,
|
|
515
515
|
why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout (#940)",
|
|
516
516
|
},
|
|
517
|
-
{
|
|
518
|
-
file: 'apps/celilo/src/services/static-content-converge.ts',
|
|
519
|
-
literal: '/srv/www',
|
|
520
|
-
count: 1,
|
|
521
|
-
why: "D10 (capability-owned-tables stage 4): the converge's slug-collision error names the on-disk release path an operator must fix. The role under modules/caddy owns the real path handling; this is message text, not path logic.",
|
|
522
|
-
},
|
|
523
517
|
];
|
|
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { closeDb } from '@/db/client';
|
|
6
|
+
import { COMMANDS, flattenLeaves, isReadOnlyPath, opOf, readOnlyGrants } from '@celilo/core';
|
|
6
7
|
import {
|
|
7
8
|
getPrincipalByName,
|
|
8
9
|
grantPrincipal,
|
|
@@ -136,3 +137,46 @@ describe('api-access (DB-backed)', () => {
|
|
|
136
137
|
expect(await listPrincipals()).toHaveLength(0);
|
|
137
138
|
});
|
|
138
139
|
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The recurrence gate for celilo#1404: every command classified read-only must
|
|
143
|
+
* actually be reachable by the read-only principal.
|
|
144
|
+
*
|
|
145
|
+
* The check is cross-instrument on purpose: `readOnlyGrants` decides what the RO
|
|
146
|
+
* principal is granted, and `grantsAllow` is the SERVER's own matcher deciding
|
|
147
|
+
* what that grant admits. What it catches is the two disagreeing about the shape
|
|
148
|
+
* of an op — which is how a derived list still leaves a read command denied.
|
|
149
|
+
* Verified by mutation (Rule 7.6): rendering the op as `module.journal` instead of
|
|
150
|
+
* `module:journal` turns this red.
|
|
151
|
+
*
|
|
152
|
+
* What it does NOT catch is the classifier itself calling a read a write — both
|
|
153
|
+
* sides read the same derivation, so they move together. That direction is
|
|
154
|
+
* covered in read-only-classifier.test.ts by a hand-written list of ops that must
|
|
155
|
+
* never be granted.
|
|
156
|
+
*/
|
|
157
|
+
describe('read-only principal reachability (celilo#1404)', () => {
|
|
158
|
+
const grants = readOnlyGrants(COMMANDS);
|
|
159
|
+
const readLeaves = flattenLeaves(COMMANDS).filter((l) => isReadOnlyPath(l.path));
|
|
160
|
+
|
|
161
|
+
test('the registry has read-only leaves at all', () => {
|
|
162
|
+
// Otherwise the sweep below is vacuously green.
|
|
163
|
+
expect(readLeaves.length).toBeGreaterThan(20);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the server admits every read-only command for the RO principal', () => {
|
|
167
|
+
const denied = readLeaves
|
|
168
|
+
.map((l) => ({ path: l.path.join(' '), op: opOf(l.path) }))
|
|
169
|
+
.filter((l) => !grantsAllow(grants, l.op.split(':')[0], l.op.split(':')[1]));
|
|
170
|
+
expect(denied).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('module journal — the op the live server denied with a bare exit 126', () => {
|
|
174
|
+
expect(grantsAllow(grants, 'module', 'journal')).toBe(true);
|
|
175
|
+
expect(grantsAllow(grants, 'module', 'where')).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('and still denies a write op', () => {
|
|
179
|
+
expect(grantsAllow(grants, 'module', 'deploy')).toBe(false);
|
|
180
|
+
expect(grantsAllow(grants, 'api', 'grant')).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -20,6 +20,12 @@ const emptyDeps = {
|
|
|
20
20
|
db: fakeDb,
|
|
21
21
|
},
|
|
22
22
|
browserPin: { consumers: [], provisioned: null },
|
|
23
|
+
serverBunPin: {
|
|
24
|
+
pinnedVersion: '1.4.2',
|
|
25
|
+
runningVersion: '1.4.2',
|
|
26
|
+
pinPath: '/etc/celilo/mise.pin.toml',
|
|
27
|
+
},
|
|
28
|
+
rebootPending: { verdict: { pending: false, unmeasured: false, reasons: [] }, marker: null },
|
|
23
29
|
capabilityAbi: { modules: [] },
|
|
24
30
|
terraformPlan: {
|
|
25
31
|
modules: [],
|
|
@@ -42,6 +48,7 @@ const emptyDeps = {
|
|
|
42
48
|
secretsDecryptable: { results: [] },
|
|
43
49
|
servicesReachable: { results: [] },
|
|
44
50
|
machinesReachable: { results: [] },
|
|
51
|
+
machineInterfaceZones: { machines: [] },
|
|
45
52
|
diskSpace: { results: [] },
|
|
46
53
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
47
54
|
trustedSources: { firewalls: [], unreachableFirewalls: [] },
|
|
@@ -25,13 +25,19 @@ import {
|
|
|
25
25
|
import { type DiskSpaceAuditDeps, auditDiskSpace } from './disk-space';
|
|
26
26
|
import { type HealthAuditDeps, auditHealth } from './health';
|
|
27
27
|
import { type JailExemptionsAuditDeps, auditJailExemptions } from './jail-exemptions';
|
|
28
|
+
import {
|
|
29
|
+
type MachineInterfaceZonesAuditDeps,
|
|
30
|
+
auditMachineInterfaceZones,
|
|
31
|
+
} from './machine-interface-zones';
|
|
28
32
|
import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
|
|
29
33
|
import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
|
|
30
34
|
import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
|
|
31
35
|
import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
|
|
32
36
|
import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
|
|
37
|
+
import { type RebootPendingAuditDeps, auditRebootPending } from './reboot-pending';
|
|
33
38
|
import { type SchemaAuditDeps, auditSchema } from './schema';
|
|
34
39
|
import { type SecretsDecryptableAuditDeps, auditSecretsDecryptable } from './secrets-decryptable';
|
|
40
|
+
import { type ServerBunPinAuditDeps, auditServerBunPin } from './server-bun-pin';
|
|
35
41
|
import {
|
|
36
42
|
type ServicesCredentialsAuditDeps,
|
|
37
43
|
auditServicesCredentials,
|
|
@@ -57,6 +63,8 @@ export interface AuditDeps {
|
|
|
57
63
|
schema: SchemaAuditDeps;
|
|
58
64
|
capabilityAbi: CapabilityAbiAuditDeps;
|
|
59
65
|
browserPin: BrowserPinAuditDeps;
|
|
66
|
+
serverBunPin: ServerBunPinAuditDeps;
|
|
67
|
+
rebootPending: RebootPendingAuditDeps;
|
|
60
68
|
terraformPlan: TerraformPlanAuditDeps;
|
|
61
69
|
moduleVersions: ModuleVersionsAuditDeps;
|
|
62
70
|
moduleConfigs: ModuleConfigsAuditDeps;
|
|
@@ -72,6 +80,7 @@ export interface AuditDeps {
|
|
|
72
80
|
secretsDecryptable: SecretsDecryptableAuditDeps;
|
|
73
81
|
servicesReachable: ServicesReachableAuditDeps;
|
|
74
82
|
machinesReachable: MachinesReachableAuditDeps;
|
|
83
|
+
machineInterfaceZones: MachineInterfaceZonesAuditDeps;
|
|
75
84
|
publicDns: PublicDnsAuditDeps;
|
|
76
85
|
diskSpace: DiskSpaceAuditDeps;
|
|
77
86
|
transportReads: TransportReadsAuditDeps;
|
|
@@ -115,6 +124,8 @@ export async function runAudit(
|
|
|
115
124
|
wrap('schema', auditSchema(deps.schema)),
|
|
116
125
|
wrap('capability_abi', auditCapabilityAbi(deps.capabilityAbi)),
|
|
117
126
|
wrap('browser_pin', auditBrowserPin(deps.browserPin)),
|
|
127
|
+
wrap('server_bun_pin', Promise.resolve(auditServerBunPin(deps.serverBunPin))),
|
|
128
|
+
wrap('reboot_pending', Promise.resolve(auditRebootPending(deps.rebootPending))),
|
|
118
129
|
wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
|
|
119
130
|
wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
|
|
120
131
|
wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
|
|
@@ -136,6 +147,10 @@ export async function runAudit(
|
|
|
136
147
|
wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
|
|
137
148
|
wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
|
|
138
149
|
wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
|
|
150
|
+
wrap(
|
|
151
|
+
'interface_classification',
|
|
152
|
+
Promise.resolve(auditMachineInterfaceZones(deps.machineInterfaceZones)),
|
|
153
|
+
),
|
|
139
154
|
wrap('public_dns', auditPublicDns(deps.publicDns)),
|
|
140
155
|
wrap('disk_space', Promise.resolve(auditDiskSpace(deps.diskSpace))),
|
|
141
156
|
wrap('transport_reads', auditTransportReads(deps.transportReads)),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { auditMachineInterfaceZones } from './machine-interface-zones';
|
|
3
|
+
|
|
4
|
+
describe('auditMachineInterfaceZones', () => {
|
|
5
|
+
test('reports a machine whose stored labels predate the zone model', () => {
|
|
6
|
+
const findings = auditMachineInterfaceZones({
|
|
7
|
+
machines: [
|
|
8
|
+
{
|
|
9
|
+
hostname: 'celilo-mgr',
|
|
10
|
+
changes: [
|
|
11
|
+
{ name: 'eth0', ipAddress: '10.0.120.10', from: 'external', to: 'secure-mgmt' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(findings).toHaveLength(1);
|
|
18
|
+
expect(findings[0].severity).toBe('drift');
|
|
19
|
+
expect(findings[0].code).toBe('stale_interface_zone_labels');
|
|
20
|
+
expect(findings[0].subject).toBe('celilo-mgr');
|
|
21
|
+
expect(findings[0].message).toContain('external → secure-mgmt');
|
|
22
|
+
expect(findings[0].remediation).toContain('celilo machine reclassify celilo-mgr --apply');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('reports nothing for a machine whose labels agree', () => {
|
|
26
|
+
expect(auditMachineInterfaceZones({ machines: [{ hostname: 'pi-01', changes: [] }] })).toEqual(
|
|
27
|
+
[],
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|