@celilo/cli 4.0.0 → 5.0.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/AGENTS.md +2 -2
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0034_private_routes.sql +41 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/api/sessions.test.ts +1 -1
- package/src/capabilities/registration.test.ts +11 -11
- package/src/capabilities/secrets.test.ts +18 -18
- package/src/capabilities/validation.test.ts +24 -24
- package/src/cli/cli.test.ts +3 -3
- 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/module-changeset.test.ts +1 -1
- package/src/cli/commands/module-generate.test.ts +2 -2
- package/src/cli/commands/module-import-aspect.test.ts +2 -2
- package/src/cli/commands/module-update.test.ts +14 -14
- package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
- package/src/cli/commands/module-upgrade.test.ts +3 -3
- package/src/cli/commands/module-upgrade.ts +12 -0
- package/src/cli/commands/module-version.test.ts +1 -1
- 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 +41 -2
- package/src/cli/restore-migration-failure.test.ts +1 -1
- package/src/cli/stdout-pipe-flush.test.ts +35 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/db/private-routes.test.ts +159 -0
- package/src/db/schema.ts +49 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +185 -12
- package/src/hooks/executor.test.ts +13 -13
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/hook-state-dir.test.ts +2 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/test-fixtures/store-backed.ts +5 -3
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/manifest/contracts/index.ts +28 -7
- package/src/manifest/contracts/v2.ts +32 -0
- package/src/manifest/icon-schema.test.ts +1 -1
- package/src/manifest/schema.ts +13 -2
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/validate-privileged.test.ts +1 -1
- package/src/manifest/validate.test.ts +68 -29
- package/src/manifest/validate.ts +14 -1
- package/src/module/import.test.ts +7 -7
- package/src/module/packaging/build.test.ts +1 -1
- package/src/packaging/nfpm-depends.test.ts +42 -0
- package/src/policy/capability-shape-baseline.ts +7 -1
- package/src/policy/fixture-capability-coverage.test.ts +5 -5
- package/src/policy/module-business-baseline.ts +33 -4
- package/src/services/api-access.test.ts +44 -0
- package/src/services/aspect-approvals.test.ts +2 -2
- package/src/services/aspect-reconcile.test.ts +2 -2
- package/src/services/aspect-runner.test.ts +4 -4
- package/src/services/audit/backups.test.ts +1 -1
- package/src/services/audit/capability-abi.test.ts +1 -1
- 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/module-configs.test.ts +1 -1
- 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/build-bus/hook-dispatch-executor.test.ts +3 -3
- package/src/services/bus-interview-park.test.ts +1 -1
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/capability-table-rows.test.ts +20 -2
- package/src/services/celilo-events.test.ts +119 -6
- package/src/services/celilo-events.ts +156 -16
- package/src/services/celilo-mgmt-hooks.test.ts +6 -3
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +86 -7
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +1 -1
- package/src/services/fleet-checks.test.ts +2 -2
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/health-runner.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +11 -11
- package/src/services/instance-ops.test.ts +2 -2
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.dns-repoint.test.ts +1 -1
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/module-pause-quiescence.test.ts +1 -1
- package/src/services/module-pause.test.ts +1 -1
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-generator.test.ts +1 -1
- package/src/services/module-validator/contract-version.test.ts +3 -3
- package/src/services/network-ensure.test.ts +1 -1
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/proxmox-reconcile.test.ts +2 -2
- package/src/services/remove-guard.test.ts +1 -1
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/dep-graph.test.ts +1 -1
- package/src/services/update/orchestrator.test.ts +165 -1
- 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/capability-self-ref.test.ts +2 -2
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/context.ts +14 -1
- package/src/variables/declarative-derivation.test.ts +19 -19
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo system reboot` — reboot the management server (celilo#1378).
|
|
3
|
+
*
|
|
4
|
+
* Thin adapter (Rule 10.5): parse flags, obtain consent, compose the real
|
|
5
|
+
* host's dependencies, call the service, format the result. Every judgement —
|
|
6
|
+
* the in-flight refusal, the boot-id bookkeeping, what counts as "came back" —
|
|
7
|
+
* lives in `services/system-reboot.ts` and is tested against a fake host.
|
|
8
|
+
*
|
|
9
|
+
* Consent follows the repo's flag-or-interview rule: `--allow-reboot` (or
|
|
10
|
+
* `--yes`) wins, otherwise the question goes on the event bus so a headless
|
|
11
|
+
* responder can answer it. No stdin prompt.
|
|
12
|
+
*
|
|
13
|
+
* `--verify` is the other half, and it exists because the process asking for a
|
|
14
|
+
* reboot dies in it: no single call can both reboot this host and report that
|
|
15
|
+
* it came back. See the service's header.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
21
|
+
import { getEventBusPath } from '../../config/paths';
|
|
22
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
23
|
+
import {
|
|
24
|
+
BOOT_ID_PATH,
|
|
25
|
+
collectRebootPendingDeps,
|
|
26
|
+
requestReboot,
|
|
27
|
+
verifyReturn,
|
|
28
|
+
} from '../../services/system-reboot';
|
|
29
|
+
import { hasFlag } from '../parser';
|
|
30
|
+
import type { CommandResult } from '../types';
|
|
31
|
+
|
|
32
|
+
/** The deb's wrapper — the same binary `apt-upgrade` re-execs for migrations. */
|
|
33
|
+
const CELILO_BIN = '/usr/local/bin/celilo';
|
|
34
|
+
|
|
35
|
+
/** This command publishes nothing; it only reads the bus's health. */
|
|
36
|
+
const NO_SCHEMAS = defineEvents({});
|
|
37
|
+
|
|
38
|
+
function readBootId(): string | null {
|
|
39
|
+
try {
|
|
40
|
+
const id = readFileSync(BOOT_ID_PATH, 'utf-8').trim();
|
|
41
|
+
return id.length > 0 ? id : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Does the installed CLI answer? Its version, or null. */
|
|
48
|
+
function installedCliVersion(): string | null {
|
|
49
|
+
try {
|
|
50
|
+
const { status, stdout } = spawnSync(CELILO_BIN, ['--version'], { encoding: 'utf-8' });
|
|
51
|
+
if (status !== 0) return null;
|
|
52
|
+
const version = (stdout ?? '').trim();
|
|
53
|
+
return version.length > 0 ? version : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is the dispatcher alive? Confirmed rather than sampled — a dispatcher
|
|
61
|
+
* starved on a box that just booted looks exactly like a dead one for one
|
|
62
|
+
* sample (celilo#1399).
|
|
63
|
+
*/
|
|
64
|
+
async function dispatcherHeartbeatLive(): Promise<boolean> {
|
|
65
|
+
let bus: ReturnType<typeof openBus> | undefined;
|
|
66
|
+
try {
|
|
67
|
+
bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
68
|
+
const health = await bus.healthConfirmed();
|
|
69
|
+
return health.status !== 'no_dispatcher' && health.status !== 'wedged';
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
} finally {
|
|
73
|
+
bus?.close?.();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function handleSystemReboot(
|
|
78
|
+
_args: string[],
|
|
79
|
+
flags: Record<string, string | boolean>,
|
|
80
|
+
): Promise<CommandResult> {
|
|
81
|
+
if (hasFlag(flags, 'verify')) {
|
|
82
|
+
const result = await verifyReturn({
|
|
83
|
+
bootId: readBootId,
|
|
84
|
+
heartbeatLive: dispatcherHeartbeatLive,
|
|
85
|
+
cliVersion: installedCliVersion,
|
|
86
|
+
});
|
|
87
|
+
return result.success
|
|
88
|
+
? { success: true, message: result.message ?? 'Reboot verified.' }
|
|
89
|
+
: { success: false, error: result.error ?? 'Reboot could not be verified.' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Report the condition before asking, so the operator is answering about
|
|
93
|
+
// the host in front of them rather than about the word "reboot".
|
|
94
|
+
const { verdict } = collectRebootPendingDeps();
|
|
95
|
+
const why = verdict.pending
|
|
96
|
+
? verdict.reasons.join('; ')
|
|
97
|
+
: 'no pending kernel or deferred restart was detected';
|
|
98
|
+
|
|
99
|
+
const consented =
|
|
100
|
+
flags['allow-reboot'] === true ||
|
|
101
|
+
hasFlag(flags, 'yes') ||
|
|
102
|
+
(await withInterviewSession(() =>
|
|
103
|
+
askConfirm({
|
|
104
|
+
scope: 'system-reboot',
|
|
105
|
+
key: 'reboot',
|
|
106
|
+
message: `Reboot the management server? This takes down the dispatcher, the MCP server and the remote API. Condition: ${why}.`,
|
|
107
|
+
defaultValue: false,
|
|
108
|
+
}),
|
|
109
|
+
));
|
|
110
|
+
|
|
111
|
+
if (!consented) {
|
|
112
|
+
return { success: false, error: 'Reboot not approved — re-run with --allow-reboot.' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = requestReboot(
|
|
116
|
+
{
|
|
117
|
+
bootId: readBootId,
|
|
118
|
+
issueReboot: () => spawnSync('sudo', ['systemctl', 'reboot'], { stdio: 'inherit' }),
|
|
119
|
+
},
|
|
120
|
+
why,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return result.success
|
|
124
|
+
? { success: true, message: result.message ?? 'Reboot issued.' }
|
|
125
|
+
: { success: false, error: result.error ?? 'Reboot refused.' };
|
|
126
|
+
}
|
|
@@ -31,12 +31,14 @@ import { collectBrowserPinDeps } from '../../services/audit/browser-pin';
|
|
|
31
31
|
import { fetchLatestCliVersion } from '../../services/audit/cli-version';
|
|
32
32
|
import { unusedPublicDnsProbe } from '../../services/audit/public-dns';
|
|
33
33
|
import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
|
|
34
|
+
import { collectServerBunPinDeps } from '../../services/audit/server-bun-pin';
|
|
34
35
|
import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
|
|
35
36
|
import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
|
|
36
37
|
import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
|
|
37
38
|
import { collectJailExemptions } from '../../services/jail-exemptions';
|
|
38
39
|
import { configOverride, parseStoredConfigValue } from '../../services/module-config';
|
|
39
40
|
import { deployModule } from '../../services/module-deploy';
|
|
41
|
+
import { collectRebootPendingDeps } from '../../services/system-reboot';
|
|
40
42
|
import { buildModuleGraph } from '../../services/update/dep-graph';
|
|
41
43
|
import {
|
|
42
44
|
type ModuleSnapshot,
|
|
@@ -549,6 +551,8 @@ export async function handleSystemUpdate(
|
|
|
549
551
|
})),
|
|
550
552
|
},
|
|
551
553
|
browserPin: collectBrowserPinDeps(upgradableModules),
|
|
554
|
+
serverBunPin: collectServerBunPinDeps(),
|
|
555
|
+
rebootPending: collectRebootPendingDeps(),
|
|
552
556
|
terraformPlan: {
|
|
553
557
|
modules: upgradableModules.map((m) => ({
|
|
554
558
|
id: m.id,
|
|
@@ -616,6 +620,7 @@ export async function handleSystemUpdate(
|
|
|
616
620
|
secretsDecryptable: { results: [] },
|
|
617
621
|
servicesReachable: { results: [] },
|
|
618
622
|
machinesReachable: { results: [] },
|
|
623
|
+
machineInterfaceZones: { machines: [] },
|
|
619
624
|
// Public reachability needs a network round trip per name; the update
|
|
620
625
|
// flow's partial audit does no probing. `system audit` and the scheduled
|
|
621
626
|
// monitor own this check.
|
package/src/cli/completion.ts
CHANGED
|
@@ -439,14 +439,14 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
if (command === 'machine' && currentIndex === 1) {
|
|
442
|
-
const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
|
|
442
|
+
const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'reclassify', 'detect'];
|
|
443
443
|
return filterSuggestions(subcommands, args[1] || '');
|
|
444
444
|
}
|
|
445
445
|
|
|
446
446
|
// Machine remove/earmark - complete with machine hostnames and IPs
|
|
447
447
|
if (
|
|
448
448
|
command === 'machine' &&
|
|
449
|
-
(args[1] === 'remove' || args[1] === 'earmark') &&
|
|
449
|
+
(args[1] === 'remove' || args[1] === 'earmark' || args[1] === 'reclassify') &&
|
|
450
450
|
currentIndex === 2
|
|
451
451
|
) {
|
|
452
452
|
const machineList = await listMachines();
|
|
@@ -704,6 +704,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
704
704
|
'update',
|
|
705
705
|
'doctor',
|
|
706
706
|
'migrate',
|
|
707
|
+
'reboot',
|
|
707
708
|
];
|
|
708
709
|
return filterSuggestions(subcommands, args[1] || '');
|
|
709
710
|
}
|
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`,
|
|
@@ -2559,6 +2578,26 @@ function parseCommand(command: string): string[] {
|
|
|
2559
2578
|
return args;
|
|
2560
2579
|
}
|
|
2561
2580
|
|
|
2581
|
+
/**
|
|
2582
|
+
* Write to stdout and WAIT for it to reach the far end before returning.
|
|
2583
|
+
*
|
|
2584
|
+
* `process.stdout.write()` followed by `process.exit()` silently drops
|
|
2585
|
+
* everything past the pipe buffer (64 KiB on macOS) whenever stdout is a pipe
|
|
2586
|
+
* rather than a TTY — so `celilo completion zsh > file` was whole while
|
|
2587
|
+
* `GENERATED=$(celilo completion zsh)` lost its tail mid-line, with exit 0 and
|
|
2588
|
+
* no diagnostic anywhere. install-zsh-completion.sh captured exactly that way
|
|
2589
|
+
* and installed a truncated, unparseable `_celilo`.
|
|
2590
|
+
*
|
|
2591
|
+
* Awaiting the stream's own write callback is the whole fix: it fires once the
|
|
2592
|
+
* bytes have been accepted downstream, so the following `process.exit()` can no
|
|
2593
|
+
* longer race the flush.
|
|
2594
|
+
*/
|
|
2595
|
+
async function writeStdout(text: string): Promise<void> {
|
|
2596
|
+
await new Promise<void>((resolve, reject) => {
|
|
2597
|
+
process.stdout.write(text, (err) => (err ? reject(err) : resolve()));
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2562
2601
|
async function serverMode(): Promise<void> {
|
|
2563
2602
|
const readline = await import('node:readline');
|
|
2564
2603
|
|
|
@@ -2680,7 +2719,7 @@ export async function main(): Promise<void> {
|
|
|
2680
2719
|
if (result.success) {
|
|
2681
2720
|
// For completion mode, output raw text without formatting
|
|
2682
2721
|
if (process.argv.includes('--get-completions') || process.argv.includes('completion')) {
|
|
2683
|
-
|
|
2722
|
+
await writeStdout(`${result.message}\n`);
|
|
2684
2723
|
process.exit(0);
|
|
2685
2724
|
}
|
|
2686
2725
|
|
|
@@ -2703,7 +2742,7 @@ export async function main(): Promise<void> {
|
|
|
2703
2742
|
// `line.startsWith('<module-id> ')` over `celilo module list` match
|
|
2704
2743
|
// nothing — read in celilo#695 as a missing module rather than as a
|
|
2705
2744
|
// parse failure, at the cost of a full e2e run.
|
|
2706
|
-
|
|
2745
|
+
await writeStdout(`${result.message}\n`);
|
|
2707
2746
|
process.exit(0);
|
|
2708
2747
|
}
|
|
2709
2748
|
|
|
@@ -37,7 +37,7 @@ const MODULE_ID = 'restore-migration-test';
|
|
|
37
37
|
|
|
38
38
|
/** The manifest the module row carries: one on_restore hook, the fixture. */
|
|
39
39
|
const MODULE_MANIFEST = {
|
|
40
|
-
celilo_contract: '
|
|
40
|
+
celilo_contract: '2.0',
|
|
41
41
|
id: MODULE_ID,
|
|
42
42
|
name: 'Restore Migration Test',
|
|
43
43
|
version: '0.0.1',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// `process.stdout.write()` followed by `process.exit()` drops everything past
|
|
4
|
+
// the 64 KiB OS pipe buffer when stdout is a pipe. The zsh completion script is
|
|
5
|
+
// the CLI's largest stdout payload (~70 KB), so it is the cheapest witness.
|
|
6
|
+
//
|
|
7
|
+
// The capture has to go through a real shell: a Bun.spawn pipe is not an OS
|
|
8
|
+
// pipe and never truncates, so a test written that way passes with the defect
|
|
9
|
+
// present. `$(...)` is how install-zsh-completion.sh reads the generator, and
|
|
10
|
+
// it read 65536 bytes of a 70092-byte script — exit 0, no diagnostic, and a
|
|
11
|
+
// `_celilo` cut off mid-line installed into the operator's fpath.
|
|
12
|
+
//
|
|
13
|
+
// The invariant is that a capture and a redirect of the same command agree to
|
|
14
|
+
// within the trailing newlines `$(...)` strips. Both are measured in the same
|
|
15
|
+
// shell, so nothing here depends on the generator's exact size.
|
|
16
|
+
describe('CLI stdout survives an OS pipe', () => {
|
|
17
|
+
test('completion zsh is captured whole, not truncated at 64 KiB', async () => {
|
|
18
|
+
const cli = `bun run ${import.meta.dir}/index.ts completion zsh`;
|
|
19
|
+
const proc = Bun.spawn(
|
|
20
|
+
[
|
|
21
|
+
'bash',
|
|
22
|
+
'-c',
|
|
23
|
+
`f=$(mktemp); ${cli} > "$f"; out=$(${cli}); printf %s "$out" | wc -c; wc -c < "$f"; rm -f "$f"`,
|
|
24
|
+
],
|
|
25
|
+
{ stdout: 'pipe', stderr: 'ignore' },
|
|
26
|
+
);
|
|
27
|
+
const [captured, redirected] = (await new Response(proc.stdout).text())
|
|
28
|
+
.trim()
|
|
29
|
+
.split('\n')
|
|
30
|
+
.map((n) => Number(n.trim()));
|
|
31
|
+
|
|
32
|
+
expect(redirected).toBeGreaterThan(65536); // below the buffer this proves nothing
|
|
33
|
+
expect(redirected - captured).toBeLessThanOrEqual(2); // only stripped newlines
|
|
34
|
+
}, 60_000);
|
|
35
|
+
});
|
|
@@ -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',
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `private_routes` — the internal ingress's own declared rows (slice 2 of
|
|
3
|
+
* providers-converge-declared-state, task 2.1).
|
|
4
|
+
*
|
|
5
|
+
* Same shape as `web_routes`, separate table on purpose. caddy derives the
|
|
6
|
+
* hostnames it SERVES from every row of `web_routes`, so a private route stored
|
|
7
|
+
* there would be picked up and served by the PUBLIC ingress — the exact leak
|
|
8
|
+
* caddy-internal exists to prevent (celilo#846). The isolation is therefore a
|
|
9
|
+
* property worth asserting rather than assuming: a table boundary is only as
|
|
10
|
+
* good as the queries that respect it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { eq } from 'drizzle-orm';
|
|
18
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
19
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
20
|
+
import type { DbClient } from './client';
|
|
21
|
+
import { modules, privateRoutes, webRoutes } from './schema';
|
|
22
|
+
|
|
23
|
+
describe('private_routes', () => {
|
|
24
|
+
let dir: string;
|
|
25
|
+
let db: DbClient;
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
dir = mkdtempSync(join(tmpdir(), 'private-routes-'));
|
|
29
|
+
const dbPath = join(dir, 'celilo.db');
|
|
30
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
31
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
32
|
+
|
|
33
|
+
for (const id of ['wireguard-manager', 'hello-foo']) {
|
|
34
|
+
db.insert(modules)
|
|
35
|
+
.values({
|
|
36
|
+
id,
|
|
37
|
+
name: id,
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
manifestData: {},
|
|
40
|
+
sourcePath: join(dir, 'modules', id),
|
|
41
|
+
state: 'VERIFIED',
|
|
42
|
+
})
|
|
43
|
+
.onConflictDoNothing()
|
|
44
|
+
.run();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
db.$client.close();
|
|
50
|
+
resetTestDbPath();
|
|
51
|
+
try {
|
|
52
|
+
rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function insertPrivate(overrides: Record<string, unknown> = {}) {
|
|
59
|
+
db.insert(privateRoutes)
|
|
60
|
+
.values({
|
|
61
|
+
slug: 'vpn-manager',
|
|
62
|
+
moduleId: 'wireguard-manager',
|
|
63
|
+
type: 'reverse_proxy',
|
|
64
|
+
path: '/',
|
|
65
|
+
hostname: 'vpn-manager.celilo.computer',
|
|
66
|
+
targetHost: '10.0.20.15',
|
|
67
|
+
targetPort: 3000,
|
|
68
|
+
websocket: false,
|
|
69
|
+
...overrides,
|
|
70
|
+
})
|
|
71
|
+
.run();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test('carries web_routes columns, so one renderer shape serves both', () => {
|
|
75
|
+
insertPrivate();
|
|
76
|
+
const row = db.select().from(privateRoutes).get();
|
|
77
|
+
|
|
78
|
+
// Named individually rather than compared as a set: the point is that a
|
|
79
|
+
// renderer written against WebRoute reads this row unchanged.
|
|
80
|
+
expect(row?.slug).toBe('vpn-manager');
|
|
81
|
+
expect(row?.moduleId).toBe('wireguard-manager');
|
|
82
|
+
expect(row?.type).toBe('reverse_proxy');
|
|
83
|
+
expect(row?.path).toBe('/');
|
|
84
|
+
expect(row?.hostname).toBe('vpn-manager.celilo.computer');
|
|
85
|
+
expect(row?.targetHost).toBe('10.0.20.15');
|
|
86
|
+
expect(row?.targetPort).toBe(3000);
|
|
87
|
+
expect(row?.websocket).toBe(false);
|
|
88
|
+
expect(row?.contentHash ?? null).toBeNull();
|
|
89
|
+
expect(row?.createdAt).toBeInstanceOf(Date);
|
|
90
|
+
expect(row?.updatedAt).toBeInstanceOf(Date);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a private route is unreachable from the public provider query (celilo#846)', () => {
|
|
94
|
+
insertPrivate();
|
|
95
|
+
|
|
96
|
+
// This is the query the public provider runs — buildRouteOps.getAllRoutes,
|
|
97
|
+
// which caddy's renderer turns into served hostnames. It must not see a
|
|
98
|
+
// private row, or the private hostname gets a public site block and a
|
|
99
|
+
// public certificate.
|
|
100
|
+
const publicRows = db.select().from(webRoutes).all();
|
|
101
|
+
expect(publicRows).toEqual([]);
|
|
102
|
+
|
|
103
|
+
// And the converse, so the assertion cannot pass merely because nothing
|
|
104
|
+
// was written anywhere.
|
|
105
|
+
expect(db.select().from(privateRoutes).all()).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a public and a private route may share a hostname and path without colliding', () => {
|
|
109
|
+
// Separate tables means separate unique indexes. Same name served by both
|
|
110
|
+
// ingresses is a legitimate split-horizon arrangement, and a shared table
|
|
111
|
+
// would have rejected the second row.
|
|
112
|
+
insertPrivate({ hostname: 'shared.celilo.computer' });
|
|
113
|
+
db.insert(webRoutes)
|
|
114
|
+
.values({
|
|
115
|
+
slug: 'shared',
|
|
116
|
+
moduleId: 'hello-foo',
|
|
117
|
+
type: 'reverse_proxy',
|
|
118
|
+
path: '/',
|
|
119
|
+
hostname: 'shared.celilo.computer',
|
|
120
|
+
targetHost: '10.0.10.9',
|
|
121
|
+
targetPort: 8080,
|
|
122
|
+
websocket: false,
|
|
123
|
+
})
|
|
124
|
+
.run();
|
|
125
|
+
|
|
126
|
+
expect(db.select().from(privateRoutes).all()).toHaveLength(1);
|
|
127
|
+
expect(db.select().from(webRoutes).all()).toHaveLength(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('uniqueness is scoped to (hostname, path), as web_routes is', () => {
|
|
131
|
+
insertPrivate();
|
|
132
|
+
expect(() => insertPrivate({ moduleId: 'hello-foo', slug: 'other' })).toThrow();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('removing the consumer removes its routes (consumer cascade)', () => {
|
|
136
|
+
insertPrivate();
|
|
137
|
+
db.insert(privateRoutes)
|
|
138
|
+
.values({
|
|
139
|
+
slug: 'other',
|
|
140
|
+
moduleId: 'hello-foo',
|
|
141
|
+
type: 'reverse_proxy',
|
|
142
|
+
path: '/other',
|
|
143
|
+
hostname: 'vpn-manager.celilo.computer',
|
|
144
|
+
targetHost: '10.0.20.16',
|
|
145
|
+
targetPort: 3000,
|
|
146
|
+
websocket: false,
|
|
147
|
+
})
|
|
148
|
+
.run();
|
|
149
|
+
|
|
150
|
+
db.delete(modules).where(eq(modules.id, 'wireguard-manager')).run();
|
|
151
|
+
|
|
152
|
+
// The departing consumer's row goes; the other consumer's stays. A private
|
|
153
|
+
// route outliving its module would render a site block pointing at a
|
|
154
|
+
// backend nothing owns.
|
|
155
|
+
const left = db.select().from(privateRoutes).all();
|
|
156
|
+
expect(left).toHaveLength(1);
|
|
157
|
+
expect(left[0]?.moduleId).toBe('hello-foo');
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/db/schema.ts
CHANGED
|
@@ -816,6 +816,55 @@ export const webRoutes = sqliteTable(
|
|
|
816
816
|
}),
|
|
817
817
|
);
|
|
818
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Private-route registry — the desired-state store for the `private_web`
|
|
821
|
+
* capability (openspec/changes/providers-converge-declared-state, slice 2).
|
|
822
|
+
*
|
|
823
|
+
* `web_routes`' columns, its own table. The shape is shared so ONE renderer
|
|
824
|
+
* shape serves both ingresses and the plumbing around them is the same
|
|
825
|
+
* (design D1); the table is separate because the PUBLIC provider derives the
|
|
826
|
+
* hostnames it serves from every row of `web_routes`, so a private route
|
|
827
|
+
* stored there would be picked up and served publicly — the exact leak
|
|
828
|
+
* caddy-internal exists to prevent (celilo#846). Two tables is what makes that
|
|
829
|
+
* impossible rather than merely intended.
|
|
830
|
+
*
|
|
831
|
+
* This replaces caddy-internal's `routes` module-config key, which held the
|
|
832
|
+
* same rows as module business inside one module's config. No data migration
|
|
833
|
+
* (design D9): the table starts empty, and when caddy-internal deploys,
|
|
834
|
+
* provider arrival re-registers its consumers, whose hooks write their routes
|
|
835
|
+
* into it inside that same deploy.
|
|
836
|
+
* @owner capability:private_web — one row is one route on the internal ingress
|
|
837
|
+
*/
|
|
838
|
+
export const privateRoutes = sqliteTable(
|
|
839
|
+
'private_routes',
|
|
840
|
+
{
|
|
841
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
842
|
+
slug: text('slug').notNull(),
|
|
843
|
+
moduleId: text('module_id')
|
|
844
|
+
.notNull()
|
|
845
|
+
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
846
|
+
type: text('type').$type<'static' | 'reverse_proxy'>().notNull(),
|
|
847
|
+
path: text('path').notNull(),
|
|
848
|
+
/** FQDN this route serves under, in the INTERNAL DNS view only. */
|
|
849
|
+
hostname: text('hostname').notNull(),
|
|
850
|
+
targetHost: text('target_host'),
|
|
851
|
+
targetPort: integer('target_port'),
|
|
852
|
+
websocket: integer('websocket', { mode: 'boolean' }).notNull().default(false),
|
|
853
|
+
contentHash: text('content_hash'),
|
|
854
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
855
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
856
|
+
},
|
|
857
|
+
(table) => ({
|
|
858
|
+
// Scoped to hostname exactly as web_routes is, and scoped to THIS table:
|
|
859
|
+
// the same hostname and path may exist on both ingresses, which is a
|
|
860
|
+
// legitimate split-horizon arrangement.
|
|
861
|
+
hostnamePathUnique: uniqueIndex('private_routes_hostname_path_idx').on(
|
|
862
|
+
table.hostname,
|
|
863
|
+
table.path,
|
|
864
|
+
),
|
|
865
|
+
}),
|
|
866
|
+
);
|
|
867
|
+
|
|
819
868
|
/**
|
|
820
869
|
* Port-forward registry — the desired-state store for the `firewall` capability
|
|
821
870
|
* (openspec/changes/unified-management-no-ssh/proposal.md). One row per exposed forward. `exposeService`
|
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
|
},
|