@celilo/cli 2.1.0 → 2.2.1
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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/schemas/system_config.json +7 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +158 -8
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +22 -2
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +87 -4
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +6 -4
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +160 -0
- package/src/config/paths.test.ts +3 -3
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +62 -135
- package/src/db/migrate.ts +17 -12
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +52 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +106 -19
- package/src/hooks/executor.ts +120 -13
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +168 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +39 -5
- package/src/hooks/jail.test.ts +62 -3
- package/src/hooks/jail.ts +67 -8
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +130 -2
- package/src/module/packaging/extract.ts +1 -5
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +235 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +264 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +47 -3
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-interview.ts +37 -14
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/config-provenance.ts +4 -0
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +123 -2
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +25 -2
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +3 -2
- package/src/services/fleet-key.test.ts +68 -3
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +188 -0
- package/src/services/module-deploy.ts +199 -21
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/restore-from-file.test.ts +23 -2
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- package/src/services/static-content-converge.test.ts +144 -5
- package/src/services/static-content-converge.ts +87 -17
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +33 -2
- package/src/services/trusted-sources.ts +47 -10
- package/src/services/zone-detector.test.ts +2 -1
- package/src/templates/generator.ts +9 -2
- package/src/test-utils/bus-responder.ts +5 -3
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/variables/context.ts +16 -5
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
package/src/hooks/broker.test.ts
CHANGED
|
@@ -18,7 +18,9 @@ import { join } from 'node:path';
|
|
|
18
18
|
import { MissingProviderInputError } from '@celilo/capabilities';
|
|
19
19
|
import { capabilityShape } from './broker';
|
|
20
20
|
import { executeHookScript } from './executor';
|
|
21
|
+
import type { HookStoreBackend, HookStores } from './hook-store';
|
|
21
22
|
import { createCapturingLogger } from './logger';
|
|
23
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
22
24
|
import type { HookContext } from './types';
|
|
23
25
|
|
|
24
26
|
const FIXTURES = join(__dirname, 'test-fixtures');
|
|
@@ -49,8 +51,8 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
|
|
|
49
51
|
const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-'));
|
|
50
52
|
try {
|
|
51
53
|
const context: HookContext = {
|
|
52
|
-
config:
|
|
53
|
-
secrets:
|
|
54
|
+
config: configStore(),
|
|
55
|
+
secrets: secretStore(),
|
|
54
56
|
systems: [],
|
|
55
57
|
logger: createCapturingLogger().logger,
|
|
56
58
|
debug: false,
|
|
@@ -67,6 +69,84 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
|
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* In-memory stand-ins for the broker-side stores. The storage mechanics have
|
|
74
|
+
* their own suite (`hook-store.test.ts`, real DB); what this layer must prove
|
|
75
|
+
* is the ROUND TRIP — a write in the hook's process landing in the broker's
|
|
76
|
+
* store and the read coming back.
|
|
77
|
+
*/
|
|
78
|
+
function memoryStores(): HookStores {
|
|
79
|
+
const makeBackend = (
|
|
80
|
+
kind: 'secret' | 'hook-owned config',
|
|
81
|
+
declared: string[],
|
|
82
|
+
): HookStoreBackend => {
|
|
83
|
+
const rows = new Map<string, string>();
|
|
84
|
+
const assertDeclared = (name: string) => {
|
|
85
|
+
if (!declared.includes(name)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Module 'test-module' has no declared ${kind} '${name}'. Declared ${kind} names: ${declared.join(', ')}.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
get: (name) => {
|
|
93
|
+
assertDeclared(name);
|
|
94
|
+
return Promise.resolve(rows.get(name));
|
|
95
|
+
},
|
|
96
|
+
set: (name, value) => {
|
|
97
|
+
assertDeclared(name);
|
|
98
|
+
rows.set(name, value);
|
|
99
|
+
return Promise.resolve();
|
|
100
|
+
},
|
|
101
|
+
delete: (name) => {
|
|
102
|
+
assertDeclared(name);
|
|
103
|
+
rows.delete(name);
|
|
104
|
+
return Promise.resolve();
|
|
105
|
+
},
|
|
106
|
+
applyTransaction: (ops) => {
|
|
107
|
+
for (const entry of ops) {
|
|
108
|
+
assertDeclared(entry.name);
|
|
109
|
+
if (entry.op === 'set') rows.set(entry.name, entry.value ?? '');
|
|
110
|
+
else rows.delete(entry.name);
|
|
111
|
+
}
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
secrets: makeBackend('secret', ['bot_token', 'api_key']),
|
|
118
|
+
config: makeBackend('hook-owned config', ['public_ip']),
|
|
119
|
+
declaredSecretNames: ['bot_token', 'api_key'],
|
|
120
|
+
declaredConfigNames: ['public_ip'],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function runStoreHook(
|
|
125
|
+
contextData: Record<string, unknown>,
|
|
126
|
+
): Promise<Record<string, unknown>> {
|
|
127
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-store-'));
|
|
128
|
+
try {
|
|
129
|
+
const context = {
|
|
130
|
+
config: { mapOnlyValue: 'from-the-context-frame' },
|
|
131
|
+
secrets: {},
|
|
132
|
+
systems: [],
|
|
133
|
+
logger: createCapturingLogger().logger,
|
|
134
|
+
debug: false,
|
|
135
|
+
screenshotDir: dir,
|
|
136
|
+
stateDir: dir,
|
|
137
|
+
capabilities: {},
|
|
138
|
+
...contextData,
|
|
139
|
+
} as unknown as HookContext;
|
|
140
|
+
return await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
|
|
141
|
+
timeoutMs: 30_000,
|
|
142
|
+
idleTimeoutMs: 30_000,
|
|
143
|
+
hookStores: () => Promise.resolve(memoryStores()),
|
|
144
|
+
});
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
70
150
|
describe('capabilityShape', () => {
|
|
71
151
|
test('splits functions from data', () => {
|
|
72
152
|
const shape = capabilityShape(demoCapabilities());
|
|
@@ -149,3 +229,27 @@ describe('capability calls across the boundary', () => {
|
|
|
149
229
|
expect(orphans).toEqual([]);
|
|
150
230
|
}, 30_000);
|
|
151
231
|
});
|
|
232
|
+
|
|
233
|
+
describe('hook-owned-state store calls across the boundary', () => {
|
|
234
|
+
test('every store operation survives the round trip', async () => {
|
|
235
|
+
const outputs = await runStoreHook({});
|
|
236
|
+
|
|
237
|
+
expect(outputs.secretRoundTrip).toBe('token-value');
|
|
238
|
+
expect(outputs.configRoundTrip).toBe('203.0.113.7');
|
|
239
|
+
// The old map surface still answers beside the new methods.
|
|
240
|
+
expect(outputs.mapRead).toBe('from-the-context-frame');
|
|
241
|
+
expect(outputs.deletedIsGone).toBeUndefined();
|
|
242
|
+
|
|
243
|
+
// D4: plain sets land immediately; a transaction discards on throw.
|
|
244
|
+
expect(outputs.transactionA).toBe('committed-a');
|
|
245
|
+
expect(outputs.transactionB).toBe('committed-b');
|
|
246
|
+
expect(outputs.discarded).toBe('hook failed midway');
|
|
247
|
+
expect(outputs.afterDiscard).toBe('committed-a');
|
|
248
|
+
|
|
249
|
+
// D2: the undeclared name is an ERROR naming the module and the set. The
|
|
250
|
+
// message text itself is pinned by hook-store.test.ts; here it only has
|
|
251
|
+
// to have survived the wire intact.
|
|
252
|
+
expect(outputs.undeclaredSecret).toContain('not_declared');
|
|
253
|
+
expect(outputs.undeclaredSecret).toContain('bot_token');
|
|
254
|
+
}, 30_000);
|
|
255
|
+
});
|
package/src/hooks/broker.ts
CHANGED
|
@@ -23,7 +23,10 @@ import { type Server, type Socket, createServer } from 'node:net';
|
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import {
|
|
26
|
+
type BufferedStoreOp,
|
|
26
27
|
type CapabilityShape,
|
|
28
|
+
type ChildFrame,
|
|
29
|
+
HOOK_PROTOCOL_VERSION,
|
|
27
30
|
type HookError,
|
|
28
31
|
createLineReader,
|
|
29
32
|
encodeFrame,
|
|
@@ -31,8 +34,22 @@ import {
|
|
|
31
34
|
serializeError,
|
|
32
35
|
versionMismatch,
|
|
33
36
|
} from './hook-protocol';
|
|
37
|
+
import type { HookStores } from './hook-store';
|
|
34
38
|
import type { HookLogger } from './types';
|
|
35
39
|
|
|
40
|
+
/** The answering half's view of one store frame. */
|
|
41
|
+
type StoreCallFrame = Extract<ChildFrame, { type: 'store' }>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A lazy provider of the module's two hook state stores.
|
|
45
|
+
*
|
|
46
|
+
* Lazy because building the stores reads the module row and its manifest, and
|
|
47
|
+
* because the secrets store touches the master key only when an operation
|
|
48
|
+
* actually needs it. A hook that never calls `context.secrets` should pay
|
|
49
|
+
* none of that.
|
|
50
|
+
*/
|
|
51
|
+
export type HookStoresProvider = () => Promise<HookStores>;
|
|
52
|
+
|
|
36
53
|
/**
|
|
37
54
|
* How long to wait after the child exits for its last frame to arrive. Short,
|
|
38
55
|
* because by this point the writer is already gone and the bytes are either in
|
|
@@ -56,6 +73,13 @@ export interface BrokerOptions {
|
|
|
56
73
|
logger: HookLogger;
|
|
57
74
|
/** Called on every frame, so the idle timer measures the whole channel. */
|
|
58
75
|
onActivity: () => void;
|
|
76
|
+
/**
|
|
77
|
+
* The module's hook-owned-state stores (hook-owned-state task 3.5). Absent
|
|
78
|
+
* means this run has none wired, and a `store` frame from the hook is
|
|
79
|
+
* REFUSED with an error naming the gap — a write that vanishes silently is
|
|
80
|
+
* the exact defect the accessor exists to remove.
|
|
81
|
+
*/
|
|
82
|
+
stores?: HookStoresProvider;
|
|
59
83
|
}
|
|
60
84
|
|
|
61
85
|
export interface Broker {
|
|
@@ -163,6 +187,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
163
187
|
let connection: Socket | undefined;
|
|
164
188
|
// Already resolved: no connection means nothing left to deliver.
|
|
165
189
|
let connectionClosed: Promise<void> = Promise.resolve();
|
|
190
|
+
// The module's stores, resolved from the provider on first use.
|
|
191
|
+
let storesCache: HookStores | undefined;
|
|
166
192
|
|
|
167
193
|
const server: Server = createServer((socket) => {
|
|
168
194
|
if (connection) {
|
|
@@ -200,7 +226,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
200
226
|
}
|
|
201
227
|
send({
|
|
202
228
|
type: 'context',
|
|
203
|
-
protocolVersion:
|
|
229
|
+
protocolVersion: HOOK_PROTOCOL_VERSION,
|
|
204
230
|
scriptPath: options.scriptPath,
|
|
205
231
|
context: options.context,
|
|
206
232
|
});
|
|
@@ -216,6 +242,10 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
216
242
|
void dispatch(frame.id, frame.capability, frame.method, frame.args, send);
|
|
217
243
|
return;
|
|
218
244
|
|
|
245
|
+
case 'store':
|
|
246
|
+
void dispatchStore(frame, send);
|
|
247
|
+
return;
|
|
248
|
+
|
|
219
249
|
case 'result':
|
|
220
250
|
outcome = { ok: true, outputs: frame.outputs };
|
|
221
251
|
return;
|
|
@@ -285,6 +315,66 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
285
315
|
server.listen(socketPath, resolve);
|
|
286
316
|
});
|
|
287
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Answer one hook-owned-state store call.
|
|
320
|
+
*
|
|
321
|
+
* The stores resolve on first use and stay cached for the run. Everything
|
|
322
|
+
* that can fail — an undeclared name, a missing module row, a store that was
|
|
323
|
+
* never wired — answers as a `throw` frame rather than crashing the broker,
|
|
324
|
+
* so the hook's own `try`/`catch` sees it the way an in-process throw would
|
|
325
|
+
* behave.
|
|
326
|
+
*/
|
|
327
|
+
async function dispatchStore(
|
|
328
|
+
frame: StoreCallFrame,
|
|
329
|
+
send: (frame: Parameters<typeof encodeFrame>[0]) => void,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
if (stopped) {
|
|
332
|
+
send({
|
|
333
|
+
type: 'throw',
|
|
334
|
+
id: frame.id,
|
|
335
|
+
error: {
|
|
336
|
+
name: 'Error',
|
|
337
|
+
message: `Hook run has ended; refusing context.${frame.store}.${frame.method}.`,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
if (!options.stores) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`No hook state stores were wired for this run, so context.${frame.store} is unavailable. This is a celilo bug: every hook invocation must receive both stores (hook-owned-state task 3.5).`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
storesCache ??= await options.stores();
|
|
350
|
+
const backend = storesCache[frame.store];
|
|
351
|
+
|
|
352
|
+
let value: unknown;
|
|
353
|
+
switch (frame.method) {
|
|
354
|
+
case 'get':
|
|
355
|
+
value = await backend.get(frame.args[0] as string);
|
|
356
|
+
break;
|
|
357
|
+
case 'set':
|
|
358
|
+
await backend.set(frame.args[0] as string, frame.args[1] as string);
|
|
359
|
+
value = null;
|
|
360
|
+
break;
|
|
361
|
+
case 'delete':
|
|
362
|
+
await backend.delete(frame.args[0] as string);
|
|
363
|
+
value = null;
|
|
364
|
+
break;
|
|
365
|
+
case 'transaction':
|
|
366
|
+
await backend.applyTransaction(frame.args[0] as BufferedStoreOp[]);
|
|
367
|
+
value = null;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
options.onActivity();
|
|
371
|
+
send({ type: 'return', id: frame.id, value });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
options.onActivity();
|
|
374
|
+
send({ type: 'throw', id: frame.id, error: serializeError(error) });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
288
378
|
return {
|
|
289
379
|
socketPath,
|
|
290
380
|
drained: () =>
|
|
@@ -302,6 +302,39 @@ describe('Firewall Chain Building', () => {
|
|
|
302
302
|
expect(config.isolateTransitNetwork).toBe(true);
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
test('single provider: the recorded interface_baseline is forwarded as a parsed list', async () => {
|
|
306
|
+
// The recorded baseline is what turns an undeclared interface from a
|
|
307
|
+
// refusal into an isolation. The single-provider iptables firewall (the
|
|
308
|
+
// production shape: the ISP router is not a celilo module) is built by
|
|
309
|
+
// buildCapabilityInterface, and THIS is the construction site that
|
|
310
|
+
// dropped the field — the baseline was written to module config and
|
|
311
|
+
// never read back, so the module converged in onboarding mode forever
|
|
312
|
+
// (ce-qzxm).
|
|
313
|
+
installReporter('iptables', '{"has_external":true}', '["dmz","app","secure"]');
|
|
314
|
+
upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
|
|
315
|
+
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
316
|
+
upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0, eth1, eth2, eth3');
|
|
317
|
+
|
|
318
|
+
const config = await loadedConfig();
|
|
319
|
+
// A parsed array of NAMES, as the module's D5 split consumes it — not
|
|
320
|
+
// the raw comma string, and not with the write-side padding intact.
|
|
321
|
+
expect(config.interfaceBaseline).toEqual(['eth0', 'eth1', 'eth2', 'eth3']);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('single provider: an empty interface_baseline arrives undefined, meaning onboarding', async () => {
|
|
325
|
+
// Empty and absent are the same thing, and both mean "never converged
|
|
326
|
+
// cleanly": treating an empty string as a baseline of nothing would make
|
|
327
|
+
// every interface new. The loader normalizes this so the module sees
|
|
328
|
+
// one shape for one meaning.
|
|
329
|
+
installReporter('iptables', '{"has_external":true}', '["dmz"]');
|
|
330
|
+
upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
|
|
331
|
+
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
332
|
+
upsertModuleConfig(db, 'iptables', 'interface_baseline', '');
|
|
333
|
+
|
|
334
|
+
const config = await loadedConfig();
|
|
335
|
+
expect(config.interfaceBaseline).toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
|
|
305
338
|
test('chained provider: the downstream layer gets them too', async () => {
|
|
306
339
|
// greenwave owns the WAN, so iptables is built through buildFirewallChain
|
|
307
340
|
// rather than the single-provider path. That is a SECOND construction
|
|
@@ -324,10 +357,14 @@ describe('Firewall Chain Building', () => {
|
|
|
324
357
|
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
325
358
|
upsertModuleConfig(db, 'iptables', 'default_route_zone', 'internal');
|
|
326
359
|
upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
|
|
360
|
+
upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth1');
|
|
327
361
|
|
|
328
362
|
const config = await loadedConfig();
|
|
329
363
|
expect(config.defaultRouteZone).toBe('internal');
|
|
330
364
|
expect(config.isolateTransitNetwork).toBe(true);
|
|
365
|
+
// The downstream construction site already forwarded this; pinned so the
|
|
366
|
+
// two sites cannot drift apart again.
|
|
367
|
+
expect(config.interfaceBaseline).toEqual(['eth0', 'eth1']);
|
|
331
368
|
});
|
|
332
369
|
|
|
333
370
|
test('unset settings arrive undefined, not as a wrong default', async () => {
|
|
@@ -7,7 +7,9 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import type { HookLogger, RouteReadView } from '@celilo/capabilities';
|
|
10
|
+
import { and, eq } from 'drizzle-orm';
|
|
10
11
|
import type { DbClient } from '../db/client';
|
|
12
|
+
import { moduleConfigs } from '../db/schema';
|
|
11
13
|
import { upsertModuleConfig } from '../services/module-config';
|
|
12
14
|
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
13
15
|
import { loadCapabilityFunctions } from './capability-loader';
|
|
@@ -40,6 +42,36 @@ export default function createDhcpServer(context) {
|
|
|
40
42
|
}
|
|
41
43
|
`;
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A factory that WRITES through the hook-owned-state accessor, so the loader's
|
|
47
|
+
* store view is proven end to end: the method's write lands as a hook-owned
|
|
48
|
+
* row, and an undeclared name throws instead of landing. Branded
|
|
49
|
+
* (defineCapabilityFunction, the Phase 8 pattern) — the branded path is the
|
|
50
|
+
* one the loader hands the store views to. The temp dir the fixture is
|
|
51
|
+
* dropped into has no node_modules link, so the import uses the workspace
|
|
52
|
+
* package by absolute path, the same trick capability-loader-firewall uses.
|
|
53
|
+
*/
|
|
54
|
+
const TEST_STORE_WRITING_DHCP_MODULE = `
|
|
55
|
+
import { defineCapabilityFunction } from '${join(__dirname, '../../../..', 'packages/capabilities/src/index.ts')}';
|
|
56
|
+
|
|
57
|
+
export default defineCapabilityFunction({
|
|
58
|
+
capability: 'dhcp_server',
|
|
59
|
+
handler: (context) => ({
|
|
60
|
+
async setDnsServers() {},
|
|
61
|
+
async getDnsServers() { return [context.config.marker]; },
|
|
62
|
+
async setDomainName() {},
|
|
63
|
+
async getDomainName() { return context.config.marker; },
|
|
64
|
+
async claimIp() {
|
|
65
|
+
await context.config.set('public_ip', '203.0.113.7');
|
|
66
|
+
return 'claimed';
|
|
67
|
+
},
|
|
68
|
+
async claimUndeclared() {
|
|
69
|
+
await context.config.set('no_such_key', 'x');
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
`;
|
|
74
|
+
|
|
43
75
|
describe('Capability Loader', () => {
|
|
44
76
|
let db: DbClient;
|
|
45
77
|
let tempDir: string;
|
|
@@ -134,6 +166,52 @@ describe('Capability Loader', () => {
|
|
|
134
166
|
expect(await dhcp.getDomainName()).toBe('internal-dhcp');
|
|
135
167
|
});
|
|
136
168
|
|
|
169
|
+
test('a provider factory writes hook-owned config through the injected store view', async () => {
|
|
170
|
+
// The store view must do more than carry the plain record a factory reads.
|
|
171
|
+
// A method writing `context.config.set` lands a hook-owned row (validated
|
|
172
|
+
// against the PROVIDER's manifest, source column 'hook'), and an
|
|
173
|
+
// undeclared name throws instead of writing a phantom row.
|
|
174
|
+
const modulePath = join(tempDir, 'storewriting-vpn');
|
|
175
|
+
const scriptsDir = join(modulePath, 'scripts');
|
|
176
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
177
|
+
writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_STORE_WRITING_DHCP_MODULE);
|
|
178
|
+
const manifest = JSON.stringify({
|
|
179
|
+
variables: { owns: [{ name: 'public_ip', type: 'string', source: 'hook' }] },
|
|
180
|
+
});
|
|
181
|
+
db.$client.run(
|
|
182
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('storewriting-vpn', 'StoreWritingVpn', '1.0.0', '${modulePath}', '${manifest.replace(/'/g, "''")}')`,
|
|
183
|
+
);
|
|
184
|
+
db.$client.run(
|
|
185
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('storewriting-vpn', 'dhcp_server', '1.0.0', '{}', unixepoch())`,
|
|
186
|
+
);
|
|
187
|
+
upsertModuleConfig(db, 'storewriting-vpn', 'marker', 'storewriting-vpn');
|
|
188
|
+
|
|
189
|
+
const result = await loadCapabilityFunctions('store-consumer', db, noopLogger);
|
|
190
|
+
const dhcp = result.dhcp_server as {
|
|
191
|
+
claimIp(): Promise<string>;
|
|
192
|
+
claimUndeclared(): Promise<void>;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
expect(await dhcp.claimIp()).toBe('claimed');
|
|
196
|
+
const row = db
|
|
197
|
+
.select()
|
|
198
|
+
.from(moduleConfigs)
|
|
199
|
+
.where(
|
|
200
|
+
and(eq(moduleConfigs.moduleId, 'storewriting-vpn'), eq(moduleConfigs.key, 'public_ip')),
|
|
201
|
+
)
|
|
202
|
+
.get();
|
|
203
|
+
expect(row?.valueJson).toBe('"203.0.113.7"');
|
|
204
|
+
expect(row?.source).toBe('hook');
|
|
205
|
+
|
|
206
|
+
// The store round-trips text, so the map read the NEXT load takes sees the
|
|
207
|
+
// same string the write sent.
|
|
208
|
+
const reloaded = await loadCapabilityFunctions('store-consumer', db, noopLogger);
|
|
209
|
+
const dhcpAgain = reloaded.dhcp_server as { getDnsServers(): Promise<string[]> };
|
|
210
|
+
expect(await dhcpAgain.getDnsServers()).toEqual(['storewriting-vpn']);
|
|
211
|
+
|
|
212
|
+
await expect(dhcp.claimUndeclared()).rejects.toThrow(/no declared hook-owned config/);
|
|
213
|
+
});
|
|
214
|
+
|
|
137
215
|
test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => {
|
|
138
216
|
const modulePath = join(tempDir, 'upstream-dhcp');
|
|
139
217
|
const scriptsDir = join(modulePath, 'scripts');
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
systemConfig,
|
|
40
40
|
webRoutes,
|
|
41
41
|
} from '../db/schema';
|
|
42
|
-
import { resolveModuleWebRoot } from '../module/web-root';
|
|
42
|
+
import { resolveModuleStateWebRoot, resolveModuleWebRoot } from '../module/web-root';
|
|
43
43
|
import { decryptSecret } from '../secrets/encryption';
|
|
44
44
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
45
45
|
import { buildControlPlaneApi } from '../services/api-principal-enrolment';
|
|
@@ -60,6 +60,9 @@ import {
|
|
|
60
60
|
import { resolveComputedFields } from '../variables/computed/evaluate';
|
|
61
61
|
import { containsComputedMarker } from '../variables/computed/marker';
|
|
62
62
|
import { buildProviderLookup } from '../variables/computed/provider-lookup';
|
|
63
|
+
import type { BufferedStoreOp } from './hook-protocol';
|
|
64
|
+
import { type HookStores, createHookStores } from './hook-store';
|
|
65
|
+
import { type StoreTransport, buildStoreView } from './hook-store-proxy';
|
|
63
66
|
import { loadHookConfigMap } from './load-hook-config';
|
|
64
67
|
|
|
65
68
|
/**
|
|
@@ -260,6 +263,13 @@ export async function loadCapabilityFunctions(
|
|
|
260
263
|
// This allows modules to optionally consume capabilities at runtime
|
|
261
264
|
// (e.g., caddy uses dns_registrar if available, falls back if not).
|
|
262
265
|
const verbose = process.env.CELILO_DEBUG === '1';
|
|
266
|
+
|
|
267
|
+
// The broker-side stores, resolved from the PROVIDER on the first store
|
|
268
|
+
// call this consumer load makes, and shared across every capability it
|
|
269
|
+
// builds. Nothing pays for the manifest read until a factory actually
|
|
270
|
+
// writes or reads through the accessor. See the storeTransport construction
|
|
271
|
+
// at the factory-invocation site below.
|
|
272
|
+
let storesPromise: Promise<HookStores> | undefined;
|
|
263
273
|
const debugLog = verbose
|
|
264
274
|
? (msg: string) => process.stderr.write(`[capability-loader] ${msg}\n`)
|
|
265
275
|
: (_msg: string) => {};
|
|
@@ -392,10 +402,35 @@ export async function loadCapabilityFunctions(
|
|
|
392
402
|
// Phase 8 path: branded defineCapabilityFunction factory. Call with
|
|
393
403
|
// the canonical { config, secrets, logger } context. The factory
|
|
394
404
|
// applies wrapWithLogging internally so we don't double-wrap.
|
|
405
|
+
//
|
|
406
|
+
// Both maps arrive as the hook-owned-state view (buildStoreView): the
|
|
407
|
+
// plain record the factory reads, PLUS get/set/delete/transaction that
|
|
408
|
+
// reach the broker-side stores (createHookStores), validated against
|
|
409
|
+
// the PROVIDER module's own manifest. The transport is in-process — a
|
|
410
|
+
// capability factory runs here in celilo's process, not in the jail —
|
|
411
|
+
// but the view is the same object a jailed hook sees, so a provider's
|
|
412
|
+
// self-config write goes through the same validated door regardless of
|
|
413
|
+
// which process it runs in (hook-owned-state tasks 0.7 and 5.5 shape).
|
|
395
414
|
if (isCompiledCapabilityFactory(exported)) {
|
|
415
|
+
const storeTransport: StoreTransport = (store, method, args) => {
|
|
416
|
+
storesPromise ??= createHookStores(db, capability.moduleId);
|
|
417
|
+
return storesPromise.then((backend: HookStores): Promise<unknown> => {
|
|
418
|
+
const one = backend[store];
|
|
419
|
+
switch (method) {
|
|
420
|
+
case 'transaction':
|
|
421
|
+
return one.applyTransaction(args[0] as readonly BufferedStoreOp[]);
|
|
422
|
+
case 'set':
|
|
423
|
+
return one.set(args[0] as string, args[1] as string);
|
|
424
|
+
case 'delete':
|
|
425
|
+
return one.delete(args[0] as string);
|
|
426
|
+
default:
|
|
427
|
+
return one.get(args[0] as string);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
};
|
|
396
431
|
const capabilityInterface = exported({
|
|
397
|
-
config: providerConfig,
|
|
398
|
-
secrets: providerSecrets,
|
|
432
|
+
config: buildStoreView('config', providerConfig, storeTransport),
|
|
433
|
+
secrets: buildStoreView('secrets', providerSecrets, storeTransport),
|
|
399
434
|
systems: getModuleSystems(capability.moduleId, db),
|
|
400
435
|
logger,
|
|
401
436
|
// WHO IS CALLING, so a provider can scope per-consumer state to them.
|
|
@@ -408,6 +443,10 @@ export async function loadCapabilityFunctions(
|
|
|
408
443
|
// without this they could not find the caller's web root now that
|
|
409
444
|
// `sourceDir` has left the request (D10 amendment).
|
|
410
445
|
consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
446
|
+
// WHERE THE CALLER'S GENERATED SITE FILES ARE (celilo#1265), by the
|
|
447
|
+
// same seam. A jailed hook cannot write into its own web root, so a
|
|
448
|
+
// module-provided web provider needs the state overlay to honor it.
|
|
449
|
+
consumerStateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db),
|
|
411
450
|
});
|
|
412
451
|
// Stamp here too, not only on the legacy path: a consumer that cannot
|
|
413
452
|
// get what it needs must be able to name WHICH provider could not give
|
|
@@ -594,6 +633,7 @@ export async function loadCapabilityFunctions(
|
|
|
594
633
|
// Resolved here, not in the capability: on a converge there is no
|
|
595
634
|
// consumer process to ask (D10 amendment).
|
|
596
635
|
webRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
636
|
+
stateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db),
|
|
597
637
|
logger,
|
|
598
638
|
config: providerConfig,
|
|
599
639
|
secrets: providerSecrets,
|
|
@@ -789,6 +829,15 @@ function buildCapabilityInterface(
|
|
|
789
829
|
trustedSubnets: zones?.trustedSubnets ?? [],
|
|
790
830
|
controlPlaneSubnet: zones?.controlPlaneSubnet,
|
|
791
831
|
frontedSubnets: zones?.frontedSubnets ?? [],
|
|
832
|
+
// The recorded baseline (D12), from this firewall's own module config.
|
|
833
|
+
// Absent means the box has never converged cleanly, so an interface
|
|
834
|
+
// celilo cannot attribute refuses rather than being disabled. The
|
|
835
|
+
// downstream-chain site below forwards this too; the single-provider
|
|
836
|
+
// firewall (an iptables box with the WAN held by a non-celilo ISP
|
|
837
|
+
// router) is built HERE, and this is the site that dropped it — the
|
|
838
|
+
// baseline was recorded but never read back, so every converge stayed
|
|
839
|
+
// in onboarding mode and refused instead of isolating (ce-qzxm).
|
|
840
|
+
interfaceBaseline: parseInterfaceBaseline(config.interface_baseline),
|
|
792
841
|
// Operator settings, forwarded verbatim. `parseStoredConfigValue`
|
|
793
842
|
// preserves each manifest-declared type, so the boolean arrives as a
|
|
794
843
|
// boolean and needs no coercion here.
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
isCompiledCapabilityFactory,
|
|
31
31
|
isCompiledHook,
|
|
32
32
|
} from '@celilo/capabilities';
|
|
33
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
33
34
|
|
|
34
35
|
function makeLogger(): HookLogger {
|
|
35
36
|
return {
|
|
@@ -42,8 +43,8 @@ function makeLogger(): HookLogger {
|
|
|
42
43
|
|
|
43
44
|
function makeContext(overrides: Partial<HookContext> = {}): HookContext {
|
|
44
45
|
return {
|
|
45
|
-
config:
|
|
46
|
-
secrets:
|
|
46
|
+
config: configStore(),
|
|
47
|
+
secrets: secretStore(),
|
|
47
48
|
systems: [],
|
|
48
49
|
logger: makeLogger(),
|
|
49
50
|
consumerModuleId: 'test-consumer',
|
|
@@ -152,7 +153,7 @@ describe('defineHook', () => {
|
|
|
152
153
|
},
|
|
153
154
|
});
|
|
154
155
|
|
|
155
|
-
await hook(makeContext({ config: { foo: 'bar' } }));
|
|
156
|
+
await hook(makeContext({ config: configStore({ foo: 'bar' }) }));
|
|
156
157
|
|
|
157
158
|
expect(seenConfig).toEqual({ foo: 'bar' });
|
|
158
159
|
});
|